Browse Source

Let text nodes carry imported writing briefs

Prompt nodes now accept TXT, Markdown, and DOCX uploads while preserving the node contract as plain prompt text. The canvas preview shows imported Markdown as a lightweight document card, and DOCX extraction stays local to body text so generation inputs remain deterministic.

Constraint: Text-class nodes must still output plain text into existing workflow edges.

Rejected: Add a full document editor/parser layer | too broad for the requested node-level upload and preview behavior.

Confidence: high

Scope-risk: moderate

Directive: Keep uploaded document data normalized to prompt text unless downstream nodes explicitly gain rich document inputs.

Tested: npm run test:run -- src/components/__tests__/PromptNode.test.tsx src/components/modals/__tests__/MarkdownEditorModal.test.tsx src/utils/__tests__/textDocumentImport.test.ts --reporter=dot

Tested: npm run build

Tested: git diff --check

Not-tested: npm run lint still fails because Next 16 treats the existing next lint script as a /lint project path.
TEST-s
jiajia 2 months ago
parent
commit
7281d4f593
  1. 35
      src/components/__tests__/PromptNode.test.tsx
  2. 90
      src/components/nodes/PromptNode.tsx
  3. 12
      src/i18n/index.tsx
  4. 4
      src/types/nodes.ts
  5. 39
      src/utils/__tests__/textDocumentImport.test.ts
  6. 84
      src/utils/textDocumentImport.ts

35
src/components/__tests__/PromptNode.test.tsx

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react"; import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { PromptNode } from "@/components/nodes/PromptNode"; import { PromptNode } from "@/components/nodes/PromptNode";
import { ReactFlowProvider } from "@xyflow/react"; import { ReactFlowProvider } from "@xyflow/react";
@ -108,6 +108,19 @@ describe("PromptNode", () => {
expect(text).toHaveClass("break-words"); expect(text).toHaveClass("break-words");
}); });
it("should keep the selected preview surface draggable while not editing", () => {
render(
<TestWrapper>
<PromptNode {...defaultProps} selected />
</TestWrapper>
);
const preview = screen.getByRole("button", { name: "Double-click to edit" });
expect(preview).toHaveClass("cursor-move");
expect(preview).not.toHaveClass("nodrag");
expect(preview).not.toHaveClass("nopan");
});
it("should call updateNodeData when typing in textarea and blurring", () => { it("should call updateNodeData when typing in textarea and blurring", () => {
render( render(
<TestWrapper> <TestWrapper>
@ -177,6 +190,26 @@ describe("PromptNode", () => {
}); });
}); });
it("should import a markdown file into the prompt node", async () => {
render(
<TestWrapper>
<PromptNode {...defaultProps} selected />
</TestWrapper>
);
const input = screen.getByLabelText("Upload text document") as HTMLInputElement;
const file = new File(["# Uploaded\n\n- item"], "brief.md", { type: "text/markdown" });
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => {
expect(mockUpdateNodeData).toHaveBeenCalledWith("test-prompt-1", {
prompt: "# Uploaded\n\n- item",
documentName: "brief.md",
documentFormat: "markdown",
});
});
});
it("should hide unavailable rich text actions while editing", () => { it("should hide unavailable rich text actions while editing", () => {
render( render(
<TestWrapper> <TestWrapper>

90
src/components/nodes/PromptNode.tsx

@ -8,10 +8,12 @@ import { useWorkflowStore } from "@/store/workflowStore";
import { PromptNodeData } from "@/types"; import { PromptNodeData } from "@/types";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { MarkdownEditorModal } from "@/components/modals/MarkdownEditorModal"; import { MarkdownEditorModal } from "@/components/modals/MarkdownEditorModal";
import { readTextDocumentFile } from "@/utils/textDocumentImport";
type PromptNodeType = Node<PromptNodeData, "prompt">; type PromptNodeType = Node<PromptNodeData, "prompt">;
function getPromptNodeTitle(id: string, data: PromptNodeData, textLabel: string) { function getPromptNodeTitle(id: string, data: PromptNodeData, textLabel: string) {
if (data.documentName) return data.documentName;
if (data.customTitle) return data.customTitle; if (data.customTitle) return data.customTitle;
if (data.label) return data.label; if (data.label) return data.label;
const match = id.match(/(\d+)$/); const match = id.match(/(\d+)$/);
@ -54,6 +56,41 @@ function ExpandIcon() {
); );
} }
function UploadIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 16V4m0 0 4 4m-4-4-4 4M5 16v3h14v-3" />
</svg>
);
}
function renderDocumentPreview(text: string) {
return text.split("\n").map((line, index) => {
const trimmed = line.trim();
if (!trimmed) return <div key={index} className="h-2" aria-hidden="true" />;
const heading = trimmed.match(/^(#{1,3})\s+(.+)$/);
if (heading) {
const className = heading[1].length === 1
? "text-base font-bold leading-tight"
: "text-sm font-bold leading-snug";
return <div key={index} className={className}>{heading[2]}</div>;
}
const bullet = trimmed.match(/^[-*]\s+(.+)$/);
if (bullet) {
return (
<div key={index} className="flex gap-2 text-xs leading-snug">
<span aria-hidden="true"></span>
<span>{bullet[1]}</span>
</div>
);
}
return <div key={index} className="whitespace-pre-wrap text-xs leading-snug">{line}</div>;
});
}
export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) { export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) {
const { t } = useI18n(); const { t } = useI18n();
const nodeData = data; const nodeData = data;
@ -64,6 +101,7 @@ export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) {
const getConnectedInputs = useWorkflowStore((state) => state.getConnectedInputs); const getConnectedInputs = useWorkflowStore((state) => state.getConnectedInputs);
const edges = useWorkflowStore((state) => state.edges); const edges = useWorkflowStore((state) => state.edges);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Local state for prompt to prevent cursor jumping during typing // Local state for prompt to prevent cursor jumping during typing
const [localPrompt, setLocalPrompt] = useState(prompt); const [localPrompt, setLocalPrompt] = useState(prompt);
@ -159,6 +197,30 @@ export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) {
updateNodeData(id, { prompt: nextPrompt }); updateNodeData(id, { prompt: nextPrompt });
}, [id, updateNodeData]); }, [id, updateNodeData]);
const handleUploadClick = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
fileInputRef.current?.click();
}, []);
const handleDocumentUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
try {
const imported = await readTextDocumentFile(file);
setLocalPrompt(imported.text);
updateNodeData(id, {
prompt: imported.text,
documentName: imported.filename,
documentFormat: imported.format,
});
} catch {
alert(t("prompt.unsupportedDocument"));
}
}, [id, t, updateNodeData]);
const handleSaveVariableName = useCallback(() => { const handleSaveVariableName = useCallback(() => {
updateNodeData(id, { variableName: varNameInput || undefined }); updateNodeData(id, { variableName: varNameInput || undefined });
setShowVarDialog(false); setShowVarDialog(false);
@ -192,6 +254,15 @@ export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) {
<span className="text-lg font-medium text-neutral-100 drop-shadow-sm">{title}</span> <span className="text-lg font-medium text-neutral-100 drop-shadow-sm">{title}</span>
</div> </div>
<input
ref={fileInputRef}
type="file"
accept=".txt,.md,.markdown,.docx,text/plain,text/markdown,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
aria-label={t("prompt.uploadDocument")}
className="hidden"
onChange={handleDocumentUpload}
/>
{selected && !isEditing && ( {selected && !isEditing && (
<div className="nodrag nopan absolute left-1/2 -top-[116px] z-[10002] flex -translate-x-1/2 items-center gap-3"> <div className="nodrag nopan absolute left-1/2 -top-[116px] z-[10002] flex -translate-x-1/2 items-center gap-3">
<button <button
@ -203,6 +274,15 @@ export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) {
<ExpandIcon /> <ExpandIcon />
<span className="whitespace-nowrap">{t("prompt.expandEditor")}</span> <span className="whitespace-nowrap">{t("prompt.expandEditor")}</span>
</button> </button>
<button
type="button"
onClick={handleUploadClick}
aria-label={t("prompt.replaceDocument")}
className="flex h-[72px] min-w-[160px] items-center justify-center gap-5 rounded-2xl border border-neutral-600/70 bg-neutral-800 px-8 text-xl text-neutral-300 shadow-2xl shadow-black/40 transition-colors hover:border-neutral-500 hover:bg-neutral-700 hover:text-neutral-100"
>
<UploadIcon />
<span className="whitespace-nowrap">{t("imageInput.replace")}</span>
</button>
<button <button
type="button" type="button"
onClick={handleDelete} onClick={handleDelete}
@ -267,7 +347,7 @@ export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) {
setIsEditing(true); setIsEditing(true);
} }
}} }}
className={`nodrag nopan nowheel flex h-full w-full cursor-text rounded-lg bg-neutral-800 p-8 outline-none ${ className={`nowheel flex h-full w-full rounded-lg bg-neutral-800 p-8 outline-none ${selected ? "cursor-move" : "cursor-text"} ${
promptText promptText
? "items-start justify-start overflow-hidden text-left select-text" ? "items-start justify-start overflow-hidden text-left select-text"
: "items-center justify-center text-center" : "items-center justify-center text-center"
@ -275,7 +355,13 @@ export function PromptNode({ id, data, selected }: NodeProps<PromptNodeType>) {
> >
{promptText ? ( {promptText ? (
<div className="nowheel max-h-full w-full overflow-y-auto whitespace-pre-wrap break-words pr-1 text-lg leading-relaxed text-neutral-100 select-text"> <div className="nowheel max-h-full w-full overflow-y-auto whitespace-pre-wrap break-words pr-1 text-lg leading-relaxed text-neutral-100 select-text">
{localPrompt} {nodeData.documentName || /^#{1,3}\s+|^[-*]\s+/m.test(promptText) ? (
<div className="mx-auto min-h-full w-full rounded-sm bg-neutral-100 p-5 text-neutral-950 shadow-sm">
<div className="space-y-1.5">
{renderDocumentPreview(localPrompt)}
</div>
</div>
) : localPrompt}
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center gap-5 text-neutral-500"> <div className="flex flex-col items-center gap-5 text-neutral-500">

12
src/i18n/index.tsx

@ -521,6 +521,9 @@ const en = {
"prompt.link": "Link", "prompt.link": "Link",
"prompt.table": "Table", "prompt.table": "Table",
"prompt.expandEditor": "Expand editor", "prompt.expandEditor": "Expand editor",
"prompt.uploadDocument": "Upload text document",
"prompt.replaceDocument": "Replace text document",
"prompt.unsupportedDocument": "Unsupported text document. Use TXT, MD, or DOCX.",
"prompt.addVariable": "Add variable", "prompt.addVariable": "Add variable",
"prompt.setVariableName": "Set Variable Name", "prompt.setVariableName": "Set Variable Name",
"prompt.variableHelp": "Use this prompt as a variable in PromptConstructor nodes", "prompt.variableHelp": "Use this prompt as a variable in PromptConstructor nodes",
@ -1340,6 +1343,9 @@ const zhCN: Dictionary = {
"prompt.link": "链接", "prompt.link": "链接",
"prompt.table": "表格", "prompt.table": "表格",
"prompt.expandEditor": "展开编辑器", "prompt.expandEditor": "展开编辑器",
"prompt.uploadDocument": "上传文本文档",
"prompt.replaceDocument": "替换文本文档",
"prompt.unsupportedDocument": "格式不支持。请使用 TXT、MD 或 DOCX。",
"prompt.addVariable": "添加变量", "prompt.addVariable": "添加变量",
"prompt.setVariableName": "设置变量名", "prompt.setVariableName": "设置变量名",
"prompt.variableHelp": "将此提示词作为 PromptConstructor 节点中的变量使用", "prompt.variableHelp": "将此提示词作为 PromptConstructor 节点中的变量使用",
@ -2009,6 +2015,9 @@ const zhTW: Dictionary = {
"prompt.link": "連結", "prompt.link": "連結",
"prompt.table": "表格", "prompt.table": "表格",
"prompt.expandEditor": "展開編輯器", "prompt.expandEditor": "展開編輯器",
"prompt.uploadDocument": "上傳文字文件",
"prompt.replaceDocument": "替換文字文件",
"prompt.unsupportedDocument": "格式不支援。請使用 TXT、MD 或 DOCX。",
"prompt.addVariable": "新增變數", "prompt.addVariable": "新增變數",
"prompt.setVariableName": "設定變數名稱", "prompt.setVariableName": "設定變數名稱",
"prompt.variableHelp": "將此提示詞作為 PromptConstructor 節點中的變數使用", "prompt.variableHelp": "將此提示詞作為 PromptConstructor 節點中的變數使用",
@ -2581,6 +2590,9 @@ const ja: Dictionary = {
"prompt.link": "リンク", "prompt.link": "リンク",
"prompt.table": "表", "prompt.table": "表",
"prompt.expandEditor": "エディタを展開", "prompt.expandEditor": "エディタを展開",
"prompt.uploadDocument": "テキスト文書をアップロード",
"prompt.replaceDocument": "テキスト文書を置換",
"prompt.unsupportedDocument": "未対応の文書形式です。TXT、MD、DOCX を使用してください。",
"prompt.addVariable": "変数を追加", "prompt.addVariable": "変数を追加",
"prompt.setVariableName": "変数名を設定", "prompt.setVariableName": "変数名を設定",
"prompt.variableHelp": "このプロンプトを PromptConstructor ノードの変数として使用します", "prompt.variableHelp": "このプロンプトを PromptConstructor ノードの変数として使用します",

4
src/types/nodes.ts

@ -94,8 +94,12 @@ export interface VideoInputNodeData extends BaseNodeData {
/** /**
* Prompt node - text input for AI generation * Prompt node - text input for AI generation
*/ */
export type PromptDocumentFormat = "text" | "markdown" | "docx";
export interface PromptNodeData extends BaseNodeData { export interface PromptNodeData extends BaseNodeData {
prompt: string; prompt: string;
documentName?: string;
documentFormat?: PromptDocumentFormat;
variableName?: string; // Optional variable name for use in PromptConstructor templates variableName?: string; // Optional variable name for use in PromptConstructor templates
isOptional?: boolean; isOptional?: boolean;
} }

39
src/utils/__tests__/textDocumentImport.test.ts

@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import JSZip from "jszip";
import { readTextDocumentFile } from "../textDocumentImport";
describe("readTextDocumentFile", () => {
it("reads markdown files as text documents", async () => {
const file = new File(["# Title\n\nBody"], "brief.md", { type: "text/markdown" });
await expect(readTextDocumentFile(file)).resolves.toEqual({
text: "# Title\n\nBody",
filename: "brief.md",
format: "markdown",
});
});
it("extracts plain text from docx document.xml", async () => {
const zip = new JSZip();
zip.file(
"word/document.xml",
`<?xml version="1.0" encoding="UTF-8"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>
<w:p><w:r><w:t>Hello</w:t></w:r></w:p>
<w:p><w:r><w:t>World</w:t></w:r></w:p>
</w:body>
</w:document>`
);
const buffer = await zip.generateAsync({ type: "arraybuffer" });
const file = new File([buffer], "brief.docx", {
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
});
await expect(readTextDocumentFile(file)).resolves.toEqual({
text: "Hello\n\nWorld",
filename: "brief.docx",
format: "docx",
});
});
});

84
src/utils/textDocumentImport.ts

@ -0,0 +1,84 @@
import JSZip from "jszip";
import type { PromptDocumentFormat } from "@/types";
export interface ImportedTextDocument {
text: string;
filename: string;
format: PromptDocumentFormat;
}
function getFormat(filename: string, type: string): PromptDocumentFormat | null {
const lower = filename.toLowerCase();
if (lower.endsWith(".md") || lower.endsWith(".markdown") || type === "text/markdown") return "markdown";
if (lower.endsWith(".txt") || type === "text/plain") return "text";
if (
lower.endsWith(".docx") ||
type === "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
) {
return "docx";
}
return null;
}
async function readBlobText(file: File): Promise<string> {
if (typeof file.text === "function") return file.text();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("Failed to read text file"));
reader.readAsText(file);
});
}
async function readBlobArrayBuffer(file: File): Promise<ArrayBuffer> {
if (typeof file.arrayBuffer === "function") return file.arrayBuffer();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as ArrayBuffer);
reader.onerror = () => reject(reader.error || new Error("Failed to read binary file"));
reader.readAsArrayBuffer(file);
});
}
function textContentByTag(element: Element, tagName: string): string {
return Array.from(element.getElementsByTagName(tagName))
.map((node) => node.textContent || "")
.join("");
}
async function readDocxText(file: File): Promise<string> {
const zip = await JSZip.loadAsync(await readBlobArrayBuffer(file));
const documentXml = await zip.file("word/document.xml")?.async("text");
if (!documentXml) throw new Error("DOCX document body not found");
const xml = new DOMParser().parseFromString(documentXml, "application/xml");
const paragraphs = [
...Array.from(xml.getElementsByTagName("w:p")),
...Array.from(xml.getElementsByTagName("p")),
];
return paragraphs
.map((paragraph) => {
const text = textContentByTag(paragraph, "w:t") || textContentByTag(paragraph, "t");
return text.trim();
})
.filter(Boolean)
.join("\n\n");
}
export async function readTextDocumentFile(file: File): Promise<ImportedTextDocument> {
const format = getFormat(file.name, file.type);
if (!format) throw new Error("Unsupported text document. Use TXT, MD, or DOCX.");
const text = format === "docx"
? await readDocxText(file)
: await readBlobText(file);
return {
text,
filename: file.name,
format,
};
}
Loading…
Cancel
Save