From 99e0fe13fd5b772c06d876f2c8ffd92a0f358881 Mon Sep 17 00:00:00 2001 From: MT-Mint <798521692@qq.com> Date: Wed, 15 Apr 2026 10:18:47 +0800 Subject: [PATCH] feat(phase-06): add reference file contract and submit payload helper --- .../components/ai-elements/prompt-input.tsx | 20 +++++++ frontend/src/core/messages/utils.ts | 2 + frontend/src/core/threads/hooks.test.ts | 59 +++++++++++++------ frontend/src/core/threads/submit-files.ts | 46 +++++++++++++++ 4 files changed, 108 insertions(+), 19 deletions(-) create mode 100644 frontend/src/core/threads/submit-files.ts diff --git a/frontend/src/components/ai-elements/prompt-input.tsx b/frontend/src/components/ai-elements/prompt-input.tsx index 51cbc6c9..5fb6e7dd 100644 --- a/frontend/src/components/ai-elements/prompt-input.tsx +++ b/frontend/src/components/ai-elements/prompt-input.tsx @@ -468,6 +468,16 @@ export const PromptInputActionAddAttachments = ({ export type PromptInputMessage = { text: string; files: FileUIPart[]; + references?: PromptInputReference[]; +}; + +export type PromptInputReference = { + filename: string; + path?: string; + size?: number; + ref_kind: "mention"; + ref_source: "artifact" | "upload"; + stale?: boolean; }; export type PromptInputProps = Omit< @@ -866,6 +876,8 @@ export type PromptInputTextareaProps = ComponentProps< export const PromptInputTextarea = ({ onChange, + onKeyDown, + onPaste, className, placeholder = "What would you like to know?", submitOnEnter = true, @@ -876,6 +888,10 @@ export const PromptInputTextarea = ({ const [isComposing, setIsComposing] = useState(false); const handleKeyDown: KeyboardEventHandler = (e) => { + onKeyDown?.(e); + if (e.defaultPrevented) { + return; + } if (e.key === "Enter") { if (isComposing || e.nativeEvent.isComposing) { return; @@ -940,6 +956,10 @@ export const PromptInputTextarea = ({ }; const handlePaste: ClipboardEventHandler = (event) => { + onPaste?.(event); + if (event.defaultPrevented) { + return; + } const items = event.clipboardData?.items; if (!items) { diff --git a/frontend/src/core/messages/utils.ts b/frontend/src/core/messages/utils.ts index c86c6323..f1eae285 100644 --- a/frontend/src/core/messages/utils.ts +++ b/frontend/src/core/messages/utils.ts @@ -332,6 +332,8 @@ export interface FileInMessage { size: number; // bytes path?: string; // virtual path, may not be set during upload status?: "uploading" | "uploaded"; + ref_kind?: "mention"; + ref_source?: "artifact" | "upload"; } /** diff --git a/frontend/src/core/threads/hooks.test.ts b/frontend/src/core/threads/hooks.test.ts index 3e156191..646de7a7 100644 --- a/frontend/src/core/threads/hooks.test.ts +++ b/frontend/src/core/threads/hooks.test.ts @@ -1,29 +1,50 @@ import assert from "node:assert/strict"; import test from "node:test"; -const { resolveThreadQueryIntent } = await import( - new URL("./utils.ts", import.meta.url).href +const { buildFilesForSubmit } = await import( + new URL("./submit-files.ts", import.meta.url).href ); -void test("uses /chats/new route as the only new-session signal", () => { - const intent = resolveThreadQueryIntent({ - pathThreadId: "new", - queryThreadId: "thread-from-query", - isNewRoute: true, - }); +void test("buildFilesForSubmit keeps uploads and appends valid references", () => { + const result = buildFilesForSubmit( + [ + { + filename: "uploaded.md", + size: 12, + virtual_path: "/mnt/user-data/uploads/uploaded.md", + }, + ], + [ + { + filename: "artifact.md", + path: "/mnt/user-data/artifacts/artifact.md", + ref_kind: "mention", + ref_source: "artifact", + }, + ], + ); - assert.equal(intent.isNewThread, true); - assert.equal(intent.showWelcomeStyle, true); - assert.equal(intent.threadId, "thread-from-query"); + assert.equal(result.staleCount, 0); + assert.equal(result.files.length, 2); + assert.equal(result.files[0]?.filename, "uploaded.md"); + assert.equal(result.files[1]?.ref_kind, "mention"); + assert.equal(result.files[1]?.ref_source, "artifact"); }); -void test("prefers path thread id over query thread id when not on /new", () => { - const intent = resolveThreadQueryIntent({ - pathThreadId: "thread-from-path", - queryThreadId: "thread-from-query", - isNewRoute: false, - }); +void test("buildFilesForSubmit drops stale references without blocking submit", () => { + const result = buildFilesForSubmit( + [], + [ + { + filename: "stale.md", + path: "/stale.md", + ref_kind: "mention", + ref_source: "upload", + stale: true, + }, + ], + ); - assert.equal(intent.isNewThread, false); - assert.equal(intent.threadId, "thread-from-path"); + assert.equal(result.staleCount, 1); + assert.equal(result.files.length, 0); }); diff --git a/frontend/src/core/threads/submit-files.ts b/frontend/src/core/threads/submit-files.ts new file mode 100644 index 00000000..a82d3091 --- /dev/null +++ b/frontend/src/core/threads/submit-files.ts @@ -0,0 +1,46 @@ +import type { FileInMessage } from "../messages/utils"; +import type { UploadedFileInfo } from "../uploads/api"; + +export type MentionReference = { + filename: string; + path?: string; + size?: number; + ref_kind: "mention"; + ref_source: "artifact" | "upload"; + stale?: boolean; +}; + +export function buildFilesForSubmit( + uploadedFileInfo: UploadedFileInfo[], + references: MentionReference[] = [], +) { + const uploadedFiles: FileInMessage[] = uploadedFileInfo.map((info) => ({ + filename: info.filename, + size: info.size, + path: info.virtual_path, + status: "uploaded" as const, + })); + + const referenceFiles: FileInMessage[] = []; + let staleCount = 0; + + for (const reference of references) { + if (reference.stale) { + staleCount += 1; + continue; + } + referenceFiles.push({ + filename: reference.filename, + size: reference.size ?? 0, + path: reference.path, + status: "uploaded" as const, + ref_kind: "mention", + ref_source: reference.ref_source, + }); + } + + return { + files: [...uploadedFiles, ...referenceFiles], + staleCount, + }; +}