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