From 6aad680596b86f67ca2f0858517c9d76f10f2de9 Mon Sep 17 00:00:00 2001 From: Aari Date: Thu, 23 Jul 2026 22:17:40 +0800 Subject: [PATCH] fix(frontend): strip and parse the upload context tag (#4402) * fix(frontend): strip and parse the upload context tag #4174 renamed the upload context block that UploadsMiddleware prepends to the user message from to , but the frontend tag vocabulary was not updated, so the raw block (file list plus usage guidance) rendered inside the user's chat bubble and the file-chip content fallback stopped matching. Add the new tag to stripUploadedFilesTag, INTERNAL_MARKER_TAGS (covers export and streamdown preprocessing), parseUploadedFiles, and the chip fallback detection. stays supported for history and IM-channel messages. * fix(frontend): parse upload-context sizes to bytes for the file chip parseUploadedFiles stored the raw leading number of the human-readable size (e.g. parseInt("177.6 KB") -> 177) into FileInMessage.size, which is documented as bytes. On the content-fallback chip path (history/IM messages without additional_kwargs.files) formatBytes then rendered a 177.6 KB file as "0.2 KB". Convert the backend's " KB" / " MB" form back to bytes so the chip re-renders at the original magnitude; update the parse tests to assert byte values. --- .../workspace/messages/message-list-item.tsx | 7 ++- frontend/src/core/messages/utils.ts | 40 ++++++++++++++--- .../tests/unit/core/messages/utils.test.ts | 43 +++++++++++++++++++ 3 files changed, 82 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index 0141fe111..2f4d98124 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -319,8 +319,11 @@ function MessageContent_({ const files = useMemo(() => { const files = message.additional_kwargs?.files; if (!Array.isArray(files) || files.length === 0) { - if (rawContent.includes("")) { - // If the content contains the tag, we return the parsed files from the content for backward compatibility. + if ( + rawContent.includes("") || + rawContent.includes("") + ) { + // If the content contains an upload context tag, we return the parsed files from the content for backward compatibility. return parseUploadedFiles(rawContent); } return null; diff --git a/frontend/src/core/messages/utils.ts b/frontend/src/core/messages/utils.ts index 6c72d5478..c4a6d71f3 100644 --- a/frontend/src/core/messages/utils.ts +++ b/frontend/src/core/messages/utils.ts @@ -596,7 +596,10 @@ export interface FileInMessage { */ export function stripUploadedFilesTag(content: string): string { return content - .replace(/<(uploaded_files|slash_skill_activation)>[\s\S]*?<\/\1>/g, "") + .replace( + /<(current_uploads|uploaded_files|slash_skill_activation)>[\s\S]*?<\/\1>/g, + "", + ) .trim(); } @@ -606,7 +609,8 @@ export function stripUploadedFilesTag(content: string): string { * * These markers are *not* user copy — they come from: * - * - ``UploadsMiddleware`` → ```` + * - ``UploadsMiddleware`` → ```` (```` + * before #4174; still emitted by IM channels and present in history) * - ``SkillActivationMiddleware`` → ```` * - ``DynamicContextMiddleware`` → ```` (carrying * ```` / ```` inside) @@ -620,6 +624,7 @@ export function stripUploadedFilesTag(content: string): string { * its ``hide_from_ui`` flag set. */ export const INTERNAL_MARKER_TAGS = [ + "current_uploads", "uploaded_files", "slash_skill_activation", "system-reminder", @@ -646,9 +651,32 @@ export function stripInternalMarkers(content: string): string { return content.replace(INTERNAL_MARKER_RE, "").trim(); } +// The upload context block renders sizes as human-readable strings +// (uploads_middleware.py::_format_file_entry emits " KB" / " MB", +// mirroring formatBytes). Convert them back to bytes so the parsed +// FileInMessage.size honours its bytes contract and chips re-render at the +// original magnitude instead of e.g. treating "177.6 KB" as 177 bytes. +function parseHumanReadableSize(raw: string): number { + const match = /([\d.]+)\s*(B|KB|MB|GB|TB)?/i.exec(raw.trim()); + if (!match) return 0; + const value = parseFloat(match[1] ?? ""); + if (!Number.isFinite(value)) return 0; + const multipliers: Record = { + B: 1, + KB: 1024, + MB: 1024 ** 2, + GB: 1024 ** 3, + TB: 1024 ** 4, + }; + const unit = (match[2] ?? "B").toUpperCase(); + return Math.round(value * (multipliers[unit] ?? 1)); +} + export function parseUploadedFiles(content: string): FileInMessage[] { - // Match ... tag - const uploadedFilesRegex = /([\s\S]*?)<\/uploaded_files>/; + // Match the upload context block; the tag name depends on backend version + // ( since #4174, before / on IM paths). + const uploadedFilesRegex = + /<(current_uploads|uploaded_files)>([\s\S]*?)<\/\1>/; // eslint-disable-next-line @typescript-eslint/prefer-regexp-exec const match = content.match(uploadedFilesRegex); @@ -656,7 +684,7 @@ export function parseUploadedFiles(content: string): FileInMessage[] { return []; } - const uploadedFilesContent = match[1]; + const uploadedFilesContent = match[2]; // Check if it's "No files have been uploaded yet." if (uploadedFilesContent?.includes("No files have been uploaded yet.")) { @@ -677,7 +705,7 @@ export function parseUploadedFiles(content: string): FileInMessage[] { while ((fileMatch = fileRegex.exec(uploadedFilesContent ?? "")) !== null) { files.push({ filename: fileMatch[1].trim(), - size: parseInt(fileMatch[2].trim(), 10) ?? 0, + size: parseHumanReadableSize(fileMatch[2]), path: fileMatch[3].trim(), }); } diff --git a/frontend/tests/unit/core/messages/utils.test.ts b/frontend/tests/unit/core/messages/utils.test.ts index 69a718cd0..106ca524e 100644 --- a/frontend/tests/unit/core/messages/utils.test.ts +++ b/frontend/tests/unit/core/messages/utils.test.ts @@ -14,6 +14,8 @@ import { hasContent, hasReasoning, isAssistantMessageGroupStreaming, + parseUploadedFiles, + stripInternalMarkers, stripUploadedFilesTag, } from "@/core/messages/utils"; @@ -307,6 +309,47 @@ describe("human message internal context stripping", () => { expect(getMessageCopyData(message)).toBe("Summarize this paper"); }); + test("strips current_uploads context from copy data", () => { + // Mirrors the block UploadsMiddleware emits since #4174, including the + // trailing usage-guidance lines. + const message = { + id: "human-with-current-uploads", + type: "human", + content: + "\nThe following files were uploaded in this message:\n\n- paper.docx (177.6 KB)\n Path: /mnt/user-data/uploads/paper.docx\n\nTo work with these files:\n- Use `grep` to search for keywords\n (e.g. `grep(pattern='revenue', path='/mnt/user-data/uploads/')`).\n\n\nMake a slide deck from this", + } as Message; + + expect(getMessageCopyData(message)).toBe("Make a slide deck from this"); + }); + + test("parses uploaded files from a current_uploads block", () => { + const content = + "\nThe following files were uploaded in this message:\n\n- paper.docx (177.6 KB)\n Path: /mnt/user-data/uploads/paper.docx\n Document outline (use `read_file` with line ranges to read sections):\n L1: Introduction\n- data.xlsx (12.0 KB)\n Path: /mnt/user-data/uploads/data.xlsx\n\n\nSummarize"; + + // size is bytes (FileInMessage contract): the block's "177.6 KB" / + // "12.0 KB" are converted back from the human-readable form the backend + // emits, so formatBytes re-renders them at the original magnitude. + expect(parseUploadedFiles(content)).toEqual([ + { + filename: "paper.docx", + size: Math.round(177.6 * 1024), // 181862 + path: "/mnt/user-data/uploads/paper.docx", + }, + { + filename: "data.xlsx", + size: 12 * 1024, // 12288 + path: "/mnt/user-data/uploads/data.xlsx", + }, + ]); + }); + + test("stripInternalMarkers removes current_uploads blocks on export", () => { + const content = + "\n- paper.docx (177.6 KB)\n Path: /mnt/user-data/uploads/paper.docx\n\n\nExport me"; + + expect(stripInternalMarkers(content)).toBe("Export me"); + }); + test("strips slash skill activation context from display content", () => { const content = "\n# Secret SKILL.md\n\nreal user task";