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..7cbc2dd122 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -197,11 +197,11 @@ const en = { "common.task": "Task", "common.you": "You", "common.waiting": "Waiting", - "composer.addImage": "Add image", + "composer.addImage": "Add attachment", "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", @@ -1858,11 +1858,11 @@ const zhCN: Record = { "common.task": "Task", "common.you": "你", "common.waiting": "等待中", - "composer.addImage": "添加图片", + "composer.addImage": "添加附件", "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..f126bf065b 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, @@ -2483,7 +2493,7 @@ export function PersonalWorkspacePage({ } if ((retry || (intent === "steer" && steeringTurnId)) && conversationSessionId && callbacks.onSteerConversationTurn) { if (pendingImages.length) { - setImageAttachmentError(locale === "zh-CN" ? "本轮追加指令暂不支持图片,图片和草稿已保留。" : "This turn accepts text instructions only. Images and draft retained."); + setImageAttachmentError(locale === "zh-CN" ? "本轮追加指令暂不支持附件,附件和草稿已保留。" : "This turn accepts text instructions only. Attachments and draft retained."); return; } const request = retry ?? { sessionId: conversationSessionId, turnId: steeringTurnId!, text: message, id: crypto.randomUUID() }; @@ -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" />