fix(frontend): strip and parse the <current_uploads> upload context tag (#4402)

* fix(frontend): strip and parse the <current_uploads> upload context tag

#4174 renamed the upload context block that UploadsMiddleware prepends to
the user message from <uploaded_files> to <current_uploads>, 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.
<uploaded_files> 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 "<n> KB" / "<n> MB" form back to bytes so the chip
re-renders at the original magnitude; update the parse tests to assert
byte values.
This commit is contained in:
Aari 2026-07-23 22:17:40 +08:00 committed by GitHub
parent 90f3a622e9
commit 6aad680596
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 82 additions and 8 deletions

View File

@ -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("<uploaded_files>")) {
// If the content contains the <uploaded_files> tag, we return the parsed files from the content for backward compatibility.
if (
rawContent.includes("<current_uploads>") ||
rawContent.includes("<uploaded_files>")
) {
// If the content contains an upload context tag, we return the parsed files from the content for backward compatibility.
return parseUploadedFiles(rawContent);
}
return null;

View File

@ -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`` ``<uploaded_files>``
* - ``UploadsMiddleware`` ``<current_uploads>`` (``<uploaded_files>``
* before #4174; still emitted by IM channels and present in history)
* - ``SkillActivationMiddleware`` ``<slash_skill_activation>``
* - ``DynamicContextMiddleware`` ``<system-reminder>`` (carrying
* ``<memory>`` / ``<current_date>`` 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 "<n> KB" / "<n> 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<string, number> = {
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 <uploaded_files>...</uploaded_files> tag
const uploadedFilesRegex = /<uploaded_files>([\s\S]*?)<\/uploaded_files>/;
// Match the upload context block; the tag name depends on backend version
// (<current_uploads> since #4174, <uploaded_files> 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(),
});
}

View File

@ -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:
"<current_uploads>\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</current_uploads>\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 =
"<current_uploads>\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</current_uploads>\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 =
"<current_uploads>\n- paper.docx (177.6 KB)\n Path: /mnt/user-data/uploads/paper.docx\n</current_uploads>\n\nExport me";
expect(stripInternalMarkers(content)).toBe("Export me");
});
test("strips slash skill activation context from display content", () => {
const content =
"<slash_skill_activation>\n<skill_content># Secret SKILL.md</skill_content>\n</slash_skill_activation>\nreal user task";