Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -196,7 +197,7 @@ export function ChannelTimeline({
{item.message.role !== "user" ? <span className="personal-message-avatar"><Bot size={17} /></span> : null}
<div>
<header><strong>{item.message.role === "user" ? item.message.agentLabel ?? t("common.you") : item.message.agentLabel ?? t("header.manager")}</strong>{item.message.time ? <time>{item.message.time}</time> : null}</header>
{item.message.attachments?.length ? <div className="personal-message-images">{item.message.attachments.map((attachment) => <img alt={attachment.name} key={attachment.id} src={attachment.dataUrl} />)}</div> : null}
{item.message.attachments?.length ? <div className="personal-message-images">{item.message.attachments.map((attachment) => isTextDocument(attachment.mimeType) ? <DocumentAttachment key={attachment.id} {...attachment} zh={locale === "zh-CN"}/> : <img alt={attachment.name} key={attachment.id} src={attachment.dataUrl} />)}</div> : null}
{item.message.role === "user" ? <p>{item.message.text}</p> : item.message.text ? <HistoricalMessageText text={item.message.text}
initiallyCollapsed={Boolean(historical?.has(messageKey(item.message)))}
focused={Boolean(messageFocus && messageKey(item.message) === focusKey)} zh={locale === "zh-CN"} onRead={onReadMessage}/> : null}
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 <DocumentAttachment dataUrl={attachment.data_url} name={attachment.name} size={attachment.size} zh={zh}/>;
return failed ? <p role="status">{zh ? "图片无法显示:" : "Image unavailable: "}{attachment.name}</p>
: <img src={attachment.data_url} alt={attachment.name} onError={() => setFailed(true)}/>;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <p role="alert">{zh ? "文件无法读取:" : "Document unavailable: "}{name}</p>;
}
return <div className="personal-document-attachment">
<button type="button" aria-expanded={open} onClick={() => setOpen(value => !value)}>{name} · {size.toLocaleString()} B</button>
<a href={dataUrl} download={name}>{zh ? "下载原文" : "Download source"}</a>
{open ? <pre tabIndex={0} aria-label={name}>{text}</pre> : null}
</div>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -1858,11 +1858,11 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"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 配置,检查后再创建",
Expand All @@ -1875,15 +1875,15 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"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": "配置定时检查",
Expand All @@ -1903,7 +1903,7 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"composer.sendMessage": "向 LoopX 发送消息",
"composer.sendMessageHint": "发送消息",
"composer.turnRunning": "本轮回答进行中。可在回答里调整或中断本轮,结束后再发送。",
"composer.sentImageAlt": "移除图片 {name}",
"composer.sentImageAlt": "移除附件 {name}",
"conversation.agentPending": "正在整理…",
"conversation.close": "关闭对话回执",
"conversation.convertHint": "将 Agent 最新回复转为 Task 草稿",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<WorkspaceImageAttachment> {
async function readImageAttachment(file: File, t: WorkspaceTranslate): Promise<WorkspaceImageAttachment> {
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 })));
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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() };
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down Expand Up @@ -3144,7 +3165,7 @@ export function PersonalWorkspacePage({
}} /> : null}
{imageAttachments.length ? <div className="personal-composer-images" aria-label={t("composer.imagesPending")}>{imageAttachments.map((attachment) => (
<figure key={attachment.id}>
<img alt={attachment.name} src={attachment.dataUrl} />
<>{isTextDocument(attachment.mimeType) ? <DocumentAttachment {...attachment} zh={locale === "zh-CN"}/> : <img alt={attachment.name} src={attachment.dataUrl} />}</>
<button aria-label={t("composer.sentImageAlt", { name: attachment.name })} onClick={() => setImageAttachments((current) => current.filter((item) => item.id !== attachment.id))} type="button"><X size={13} /></button>
</figure>
))}</div> : null}
Expand Down Expand Up @@ -3185,18 +3206,18 @@ export function PersonalWorkspacePage({
<div
className="personal-channel-composer"
onDragOver={(event) => {
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);
}}
>
<input accept="image/png,image/jpeg,image/webp,image/gif" aria-label={t("composer.imagePicker")} className="personal-composer-file-input" disabled={Boolean(workspaceConversations?.entryGate) || steering || (sending && !queueTurnId) || imageAttachments.length >= maxImageAttachmentCount} multiple onChange={(event) => void selectImages(event.target.files)} ref={imageInputRef} type="file" />
<input accept="image/png,image/jpeg,image/webp,image/gif,.md,.txt" aria-label={t("composer.imagePicker")} className="personal-composer-file-input" disabled={Boolean(workspaceConversations?.entryGate) || steering || (sending && !queueTurnId) || imageAttachments.length >= maxImageAttachmentCount} multiple onChange={(event) => void selectImages(event.target.files)} ref={imageInputRef} type="file" />
<textarea
disabled={Boolean(workspaceConversations?.entryGate)}
id="personal-conversation-composer"
Expand Down
Loading
Loading