Browse Source
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
6 changed files with 261 additions and 3 deletions
@ -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", |
|||
}); |
|||
}); |
|||
}); |
|||
@ -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…
Reference in new issue