From 53a70057e447382359aca11032dc462312e9d775 Mon Sep 17 00:00:00 2001 From: Yuchao Wang Date: Fri, 30 Oct 2026 09:35:32 +0000 Subject: [PATCH 1/2] feat(chat): accept bounded UTF-8 document attachments Signed-off-by: Yuchao Wang --- .github/workflows/python-tests.yml | 9 ++ apps/presentation/dashboard/src/data/chat.ts | 1 + .../personal-workspace/channel-timeline.tsx | 3 +- .../conversation-requests.tsx | 2 + .../document-attachment.tsx | 20 ++++ .../src/features/personal-workspace/i18n.tsx | 28 +++--- .../personal-workspace-contract.test.mjs | 2 +- .../personal-workspace-model.ts | 1 + .../personal-workspace-page.tsx | 35 +++++-- .../personal-workspace/personal-workspace.css | 9 +- .../dashboard/src/views/dashboard-page.tsx | 2 +- .../app-conversation-and-async-inbox-v0.md | 15 +++ ...p-conversation-and-async-inbox-v0.zh-CN.md | 11 +++ examples/personal-workspace-browser-smoke.mjs | 2 + .../conversation-document-request.mjs | 67 +++++++++++++ .../conversation-image-request.mjs | 2 +- .../capabilities/native_chat/codex_adapter.py | 10 ++ .../native_chat/queued_admission.py | 2 + loopx/chat_agent.py | 6 +- loopx/chat_attachments.py | 95 ++++++++++++++++++- loopx/chat_ingress.py | 4 +- loopx/chat_runtime.py | 8 +- loopx/chat_server.py | 4 +- tests/test_chat_image_attachments.py | 2 +- tests/test_chat_image_http.py | 89 +++++++++++++++++ tests/test_chat_queue_preparation.py | 12 ++- 26 files changed, 398 insertions(+), 43 deletions(-) create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/document-attachment.tsx create mode 100644 examples/personal-workspace-browser/conversation-document-request.mjs diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index f95fe72224..a550ecc6d1 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -484,6 +484,15 @@ jobs: if-no-files-found: error retention-days: 3 + - name: Upload text document visual evidence + if: always() && hashFiles('output/playwright/personal-workspace/document-*.png') != '' + uses: actions/upload-artifact@v7 + with: + name: text-document-evidence-${{ matrix.shard }} + path: output/playwright/personal-workspace/document-*.png + if-no-files-found: error + retention-days: 14 + dashboard-acceptance: needs: [changes, dashboard-browser] if: needs.changes.outputs.core_tests == 'true' && !cancelled() && needs.dashboard-browser.result == 'success' diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index df720acba4..d89c9285e7 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -931,6 +931,7 @@ export type ChatReplyContext = { }; }; +// Existing attachment wire shape; MIME distinguishes images and UTF-8 documents. export type ChatImageAttachment = { data_url: string; id: string; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx b/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx index 8225682463..1c35855822 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-timeline.tsx @@ -1,3 +1,4 @@ +import {DocumentAttachment, isTextDocument} from "./document-attachment"; import { MessageActivity } from "./message-activity"; import { GoalDraftCard } from "./goal-draft-card"; import type { GoalDraft } from "../../../../../../loopx/control_plane/collaboration/goal_draft.js"; @@ -196,7 +197,7 @@ export function ChannelTimeline({ {item.message.role !== "user" ? : null}
{item.message.role === "user" ? item.message.agentLabel ?? t("common.you") : item.message.agentLabel ?? t("header.manager")}{item.message.time ? : null}
- {item.message.attachments?.length ?
{item.message.attachments.map((attachment) => {attachment.name})}
: null} + {item.message.attachments?.length ?
{item.message.attachments.map((attachment) => isTextDocument(attachment.mimeType) ? : {attachment.name})}
: null} {item.message.role === "user" ?

{item.message.text}

: item.message.text ? : null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/conversation-requests.tsx b/apps/presentation/dashboard/src/features/personal-workspace/conversation-requests.tsx index c11a76bc9c..dd0cbadd91 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/conversation-requests.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/conversation-requests.tsx @@ -1,3 +1,4 @@ +import {DocumentAttachment, isTextDocument} from "./document-attachment"; import {useEffect, useRef, useState} from "react"; import {ChevronRight, CircleAlert, ListChecks, RefreshCw, X} from "lucide-react"; import {fetchChatSession, fetchChatSessions, requestJson, type ChatImageAttachment, type ChatVisibleMessage, type ChatReplyContext} from "../../data/chat"; @@ -132,6 +133,7 @@ function requestStage(row: ConversationRequest, zh: boolean) { function OriginalImage({attachment, zh}: {attachment: ChatImageAttachment; zh: boolean}) { const [failed, setFailed] = useState(false); + if (isTextDocument(attachment.mime_type)) return ; return failed ?

{zh ? "图片无法显示:" : "Image unavailable: "}{attachment.name}

: {attachment.name} setFailed(true)}/>; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/document-attachment.tsx b/apps/presentation/dashboard/src/features/personal-workspace/document-attachment.tsx new file mode 100644 index 0000000000..aa114a4ea2 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/document-attachment.tsx @@ -0,0 +1,20 @@ +import {useState} from "react"; + +export const isTextDocument = (mime: string) => mime === "text/plain" || mime === "text/markdown"; + +/** Render source as inert text; downloading preserves the original UTF-8 bytes. */ +export function DocumentAttachment({dataUrl, name, size, zh}: {dataUrl: string; name: string; size: number; zh: boolean}) { + const [open, setOpen] = useState(false); + let text: string; + try { + if (!/^data:text\/(plain|markdown);base64,[A-Za-z0-9+/=]*$/.test(dataUrl)) throw new Error("Invalid source"); + text = new TextDecoder("utf-8", {fatal: true, ignoreBOM: true}).decode(Uint8Array.from(atob(dataUrl.split(",")[1]), c => c.charCodeAt(0))); + } catch { + return

{zh ? "文件无法读取:" : "Document unavailable: "}{name}

; + } + return
+ + {zh ? "下载原文" : "Download source"} + {open ?
{text}
: null} +
; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 89ef9869e3..dff5dbec18 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -201,7 +201,7 @@ const en = { "composer.agentProgress": "Ask Agent for a progress report", "composer.blockers": "Ask what blocks", "composer.agentProgressPrompt": "Give me a progress report for this Goal: completed, running, blocked, and next steps.", - "composer.attachImageHint": "Choose, paste, or drag an image", + "composer.attachImageHint": "Choose images or UTF-8 .md / .txt (local managed Codex; 64KB message limit)", "composer.createGoal": "Create Goal", "composer.createGoalHint": "Configure and review a new Goal", "composer.evidence": "Check what is proven", @@ -214,15 +214,15 @@ const en = { "masthead.completeTitle": "Read the complete Goal", "composer.goalPrompt": "Ask about progress, or suggest a change…", "composer.goalPlaceholder": "Ask or guide {goal}…", - "composer.imageAnalysisPrompt": "Analyze these images in the context of the current Goal and tell me the next step.", - "composer.imageCountError": "You can add up to {count} images.", - "composer.imagePicker": "Image file picker", + "composer.imageAnalysisPrompt": "Read these attachments in the context of the current Goal and tell me the next step.", + "composer.imageCountError": "You can add up to {count} attachments.", + "composer.imagePicker": "Attachment file picker", "composer.imageReadError": "Could not read image {name}.", "composer.imageReadGenericError": "Could not read the image.", "composer.imageSizeError": "Each image must be {size} MB or smaller.", "composer.imageTotalSizeError": "Images must total {size} MB or less.", - "composer.imageTypeError": "PNG, JPEG, WebP, and GIF images are supported.", - "composer.imagesPending": "Images to send", + "composer.imageTypeError": "Choose PNG, JPEG, WebP, GIF, or UTF-8 .md / .txt documents.", + "composer.imagesPending": "Attachments to send", "composer.managerMessageHint": "Your message goes to the LoopX Manager across Goals for global questions or Goal creation.", "composer.managerPlaceholder": "Ask the LoopX Manager, or describe a new Goal…", "composer.monitor": "Configure scheduled check", @@ -242,7 +242,7 @@ const en = { "composer.sendMessage": "Send a message to LoopX", "composer.sendMessageHint": "Send message", "composer.turnRunning": "A turn is in progress. Adjust or interrupt it from the reply, then send.", - "composer.sentImageAlt": "Remove image {name}", + "composer.sentImageAlt": "Remove attachment {name}", "conversation.agentPending": "Working…", "conversation.close": "Close conversation receipt", "conversation.convertHint": "Turn the Agent’s latest reply into a Task draft", @@ -1862,7 +1862,7 @@ const zhCN: Record = { "composer.agentProgress": "向 Agent 获取进度报告", "composer.agentProgressPrompt": "请给我一份当前 Goal 的进度报告:已完成、执行中、阻塞和下一步。", "composer.blockers": "看阻塞", - "composer.attachImageHint": "选择、粘贴或拖入图片", + "composer.attachImageHint": "选择图片或 UTF-8 .md / .txt(文本限本地托管 Codex,消息共用 64KB)", "composer.evidence": "查证据", "composer.createGoal": "创建新 Goal", "composer.createGoalHint": "填写 Goal 配置,检查后再创建", @@ -1875,15 +1875,15 @@ const zhCN: Record = { "masthead.completeTitle": "查看完整 Goal", "composer.goalPrompt": "询问进展,或提出调整…", "composer.goalPlaceholder": "询问或纠偏 {goal}…", - "composer.imageAnalysisPrompt": "请结合这些图片分析当前 Goal,并告诉我下一步。", - "composer.imageCountError": "最多添加 {count} 张图片。", - "composer.imagePicker": "图片文件选择器", + "composer.imageAnalysisPrompt": "请结合这些附件分析当前 Goal,并告诉我下一步。", + "composer.imageCountError": "最多添加 {count} 个附件。", + "composer.imagePicker": "附件文件选择器", "composer.imageReadError": "无法读取图片 {name}", "composer.imageReadGenericError": "图片读取失败。", "composer.imageSizeError": "单张图片不能超过 {size}MB。", "composer.imageTotalSizeError": "图片总大小不能超过 {size}MB。", - "composer.imageTypeError": "支持 PNG、JPEG、WebP 和 GIF 图片。", - "composer.imagesPending": "待发送图片", + "composer.imageTypeError": "支持 PNG、JPEG、WebP、GIF 图片和 UTF-8 .md / .txt 文本文件。", + "composer.imagesPending": "待发送附件", "composer.managerMessageHint": "你的消息由 LoopX 管家跨 Goal 接收,支持全局询问与创建 Goal", "composer.managerPlaceholder": "问问 LoopX 管家,或描述一个新 Goal…", "composer.monitor": "配置定时检查", @@ -1903,7 +1903,7 @@ const zhCN: Record = { "composer.sendMessage": "向 LoopX 发送消息", "composer.sendMessageHint": "发送消息", "composer.turnRunning": "本轮回答进行中。可在回答里调整或中断本轮,结束后再发送。", - "composer.sentImageAlt": "移除图片 {name}", + "composer.sentImageAlt": "移除附件 {name}", "conversation.agentPending": "正在整理…", "conversation.close": "关闭对话回执", "conversation.convertHint": "将 Agent 最新回复转为 Task 草稿", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs index 465add2bd6..1ff069e483 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-contract.test.mjs @@ -177,7 +177,7 @@ assert.match(timeline, /t\("timeline\.waitingConfirmation"\)/, "Historical gated assert.match(timeline, /gatedItems\.length/, "The compact Gate summary exposes the pending count"); assert.match(timeline, /Boolean\(item\.run\.sessionId\)/, "Running count requires a discovered execution Session"); assert.match(timeline, /Boolean\(item\.run\.canInterrupt\)/, "Running count requires an active interruptible turn"); -assert.match(page, /accept="image\/png,image\/jpeg,image\/webp,image\/gif"/, "Composer accepts bounded image types"); +assert.match(page, /accept="image\/png,image\/jpeg,image\/webp,image\/gif,\.md,\.txt"/, "Composer accepts bounded image types"); assert.match(page, /imageInputRef\.current\?\.click\(\)/, "Attachment button explicitly opens the native file chooser"); assert.match(page, /onDrop=\{\(event\)/, "Composer accepts dragged image files"); assert.match(page, /onPaste=\{[^}]*handleComposerPaste/s, "Composer accepts pasted clipboard images"); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index 666d8837b3..4907155da6 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts @@ -424,6 +424,7 @@ export type WorkspaceReturnDelivery = { request_revision?: number | null; }; +// Existing attachment wire shape; MIME distinguishes images and UTF-8 documents. export type WorkspaceImageAttachment = { dataUrl: string; id: string; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index 3de9f747c6..dc16cb1373 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -1,3 +1,4 @@ +import {DocumentAttachment, isTextDocument} from "./document-attachment"; import {AlertCircle, Bot, CalendarClock, CircleHelp, FileText, ListPlus, MessageCircleQuestion, Paperclip, Plus, RefreshCw, Send, X} from "lucide-react"; import {ContextHelp} from "./context-help"; import {readSettingsRoute, writeSettingsRoute} from "./workspace-settings-route"; @@ -903,13 +904,22 @@ function workspaceProposal(proposal: TypedActionProposal, t: WorkspaceTranslate) } const acceptedImageTypes = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]); +const documentMime = (file: File) => /\.md$/i.test(file.name) ? "text/markdown" : /\.txt$/i.test(file.name) ? "text/plain" : null; const maxImageAttachmentBytes = 5 * 1024 * 1024; const maxImageAttachmentCount = 4; // Project refs are alphanumeric, so this value can never name a workspace. const stewardScopeValue = "@steward"; const maxImageAttachmentTotalBytes = 12 * 1024 * 1024; -function readImageAttachment(file: File, t: WorkspaceTranslate): Promise { +async function readImageAttachment(file: File, t: WorkspaceTranslate): Promise { + const mime = documentMime(file); + if (mime) { + const bytes = new Uint8Array(await file.arrayBuffer()); + const text = new TextDecoder("utf-8", {fatal: true, ignoreBOM: true}).decode(bytes); + if (text.includes("\0")) throw new Error("UTF-8 text required; binary files are not supported."); + const dataUrl = `data:${mime};base64,${btoa(Array.from(bytes, byte => String.fromCharCode(byte)).join(""))}`; + return {dataUrl, id: crypto.randomUUID(), mimeType: mime, name: file.name, size: bytes.length}; + } return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = () => reject(new Error(t("composer.imageReadError", { name: file.name }))); @@ -2455,7 +2465,7 @@ export function PersonalWorkspacePage({ const queue = retry?.delivery === "queue" || (!retry && intent === "send" && Boolean(queueTurnId)); if (queue && conversationSessionId && callbacks.onQueueConversationTurn) { if (retry && JSON.stringify(pendingImages) !== JSON.stringify(retry.attachments ?? [])) { - setImageAttachmentError(locale === "zh-CN" ? "待确认消息的图片不能替换;原请求与当前图片已保留。" : "Unconfirmed images cannot be replaced. Original request and current images retained."); + setImageAttachmentError(locale === "zh-CN" ? "待确认消息的附件不能替换;原请求与当前附件已保留。" : "Unconfirmed attachments cannot be replaced. Original request and current attachments retained."); return; } const request = retry ?? { sessionId: conversationSessionId, turnId: queueTurnId!, text: message, @@ -2567,8 +2577,12 @@ export function PersonalWorkspacePage({ if (!files?.length) return; const available = maxImageAttachmentCount - imageAttachments.length; const selected = Array.from(files).slice(0, Math.max(0, available)); - const invalid = selected.find((file) => !acceptedImageTypes.has(file.type)); + const invalid = selected.find((file) => !acceptedImageTypes.has(file.type) && !documentMime(file)); const oversized = selected.find((file) => file.size > maxImageAttachmentBytes); + if (selected.some(file => documentMime(file) && file.size > 48_000)) { + setImageAttachmentError(locale === "zh-CN" ? "文本文件和消息共用 64KB 限额,请缩小文件。" : "Documents and message share a 64KB limit. Choose a smaller file."); + return; + } if (available <= 0) { setImageAttachmentError(t("composer.imageCountError", { count: maxImageAttachmentCount })); return; @@ -2587,6 +2601,13 @@ export function PersonalWorkspacePage({ } try { const loaded = await Promise.all(selected.map((file) => readImageAttachment(file, t))); + const envelope = {message: composer, attachments: [...imageAttachments, ...loaded].map(file => ({ + name: file.name, size: file.size, id: file.id, mime_type: file.mimeType, + ...(isTextDocument(file.mimeType) ? {data_url: file.dataUrl} : {}), + }))}; + if (new TextEncoder().encode(JSON.stringify(envelope)).length > 63_000) { + throw new Error(locale === "zh-CN" ? "文本文件和消息共用 64KB 限额,请缩小文件。" : "Documents and message share a 64KB limit. Choose a smaller file."); + } setImageAttachments((current) => [...current, ...loaded].slice(0, maxImageAttachmentCount)); setImageAttachmentError(files.length > selected.length ? t("composer.imageCountError", { count: maxImageAttachmentCount }) : null); } catch (error) { @@ -3144,7 +3165,7 @@ export function PersonalWorkspacePage({ }} /> : null} {imageAttachments.length ?
{imageAttachments.map((attachment) => (
- {attachment.name} + <>{isTextDocument(attachment.mimeType) ? : {attachment.name}}
))}
: null} @@ -3185,18 +3206,18 @@ export function PersonalWorkspacePage({
{ - if ([...event.dataTransfer.items].some((item) => item.kind === "file" && item.type.startsWith("image/"))) { + if ([...event.dataTransfer.items].some((item) => item.kind === "file")) { event.preventDefault(); } }} onDrop={(event) => { - const images = [...event.dataTransfer.files].filter((file) => file.type.startsWith("image/")); + const images = [...event.dataTransfer.files]; if (!images.length) return; event.preventDefault(); void selectImages(images); }} > - = maxImageAttachmentCount} multiple onChange={(event) => void selectImages(event.target.files)} ref={imageInputRef} type="file" /> + = maxImageAttachmentCount} multiple onChange={(event) => void selectImages(event.target.files)} ref={imageInputRef} type="file" />