From cd77340d92bb6f89c5ed74e598129b683c0abff8 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 30 Sep 2026 17:51:14 +0000 Subject: [PATCH 1/3] Sniff docx ZIP magic and fall back to text rendering Why: agent-generated ".docx" artifacts were historically plain text bytes written via string-only write tools; the docx preview loader fed them straight to mammoth/JSZip, which threw "Can't find end of central directory" on the meeting-notes docx from the review session. Evidence: docxLoader now checks the PK\x03\x04 (or empty-archive PK\x05\x06) magic before mammoth; non-ZIP bytes render as markdown with a readable note instead of an error. vitest 33/33 pass, tsc --noEmit clean. Impact: stock fake-docx artifacts become readable immediately; real docx previews unchanged. Rendering only, no API/contract change. Refs: .task/feature-preview-sniff-fallback.md --- src/previewsetup.ts | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/previewsetup.ts b/src/previewsetup.ts index b236bc3..294bb05 100644 --- a/src/previewsetup.ts +++ b/src/previewsetup.ts @@ -2,6 +2,7 @@ // 全部 dynamic import——对应格式首次预览时才下载 chunk,主 bundle 不含解析器。 // 链接预览 fetcher 走服务端 /api/url-preview(SSRF 防护在服务端执行端)。 import { configureFilePreviewLoaders, configureLinkPreviewFetcher } from '@ui' +import { marked } from 'marked' import { auth } from './auth' import type { LinkPreviewData } from '@ui' @@ -11,9 +12,35 @@ const fetchBuf = async (url: string | undefined): Promise => { return r.arrayBuffer() } -const docxLoader = async (el: HTMLElement, source: { contentUrl?: string }) => { +// docx/OOXML 是 ZIP 容器,真文件必以 PK 魔数开头(PK\x03\x04 常规,PK\x05\x06 空包)。 +// 智能体生成侧可能写出"内容是文本的伪 docx",先嗅探再喂 mammoth: +// 非 ZIP 直接按文本/markdown 回退渲染,不再抛 JSZip 底层错误。 +const isZipBytes = (buf: ArrayBuffer): boolean => { + if (buf.byteLength < 4) return false + const h = new Uint8Array(buf, 0, 4) + return h[0] === 0x50 && h[1] === 0x4b && + ((h[2] === 0x03 && h[3] === 0x04) || (h[2] === 0x05 && h[3] === 0x06)) +} + +const renderTextFallback = (el: HTMLElement, buf: ArrayBuffer, name?: string) => { + const note = document.createElement('div') + note.className = 'ui-fp-note' + note.textContent = `「${name ?? '该文件'}」不是有效的 docx 文件(缺少 ZIP 结构),已按文本内容展示;需要正式 Word 格式请让智能体以 docx 格式重新生成。` + const body = document.createElement('div') + body.className = 'doc-viewer' + body.innerHTML = marked.parse(new TextDecoder().decode(buf)) as string + el.innerHTML = '' + el.append(note, body) +} + +const docxLoader = async (el: HTMLElement, source: { contentUrl?: string; name?: string }) => { + const buf = await fetchBuf(source.contentUrl) + if (!isZipBytes(buf)) { + renderTextFallback(el, buf, source.name) + return + } const mammoth = await import('mammoth') - const { value } = await mammoth.convertToHtml({ arrayBuffer: await fetchBuf(source.contentUrl) }) + const { value } = await mammoth.convertToHtml({ arrayBuffer: buf }) el.innerHTML = value } From bfa634d2a991523376302eb4f9479b0fa1df1c1c Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 1 Oct 2026 04:07:05 +0000 Subject: [PATCH 2/3] Fall back to text when xlsx and pptx are not ZIP files Why: docx preview already sniffs ZIP magic so a text file renamed to .docx stays readable. xlsx and pptx loaders still fed those bytes to the parser and surfaced a raw library error. Evidence: npx tsc --noEmit and npx vitest run (40 tests) on this branch after merging origin/main. Legacy .xls/.ppt skip the sniff because they are not OOXML. Impact: pseudo xlsx/pptx render as text with a short note. Real ZIP workbooks and decks still use the existing parsers. Refs: Dev PR #96, task feature/preview-sniff-fallback Co-authored-by: Cursor --- src/previewsetup.ts | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/previewsetup.ts b/src/previewsetup.ts index 294bb05..2ee393b 100644 --- a/src/previewsetup.ts +++ b/src/previewsetup.ts @@ -22,10 +22,10 @@ const isZipBytes = (buf: ArrayBuffer): boolean => { ((h[2] === 0x03 && h[3] === 0x04) || (h[2] === 0x05 && h[3] === 0x06)) } -const renderTextFallback = (el: HTMLElement, buf: ArrayBuffer, name?: string) => { +const renderTextFallback = (el: HTMLElement, buf: ArrayBuffer, name: string | undefined, kindLabel: string) => { const note = document.createElement('div') note.className = 'ui-fp-note' - note.textContent = `「${name ?? '该文件'}」不是有效的 docx 文件(缺少 ZIP 结构),已按文本内容展示;需要正式 Word 格式请让智能体以 docx 格式重新生成。` + note.textContent = `「${name ?? '该文件'}」不是有效的 ${kindLabel} 文件(缺少 ZIP 结构),已按文本内容展示;需要正式格式请让智能体重新生成。` const body = document.createElement('div') body.className = 'doc-viewer' body.innerHTML = marked.parse(new TextDecoder().decode(buf)) as string @@ -36,7 +36,7 @@ const renderTextFallback = (el: HTMLElement, buf: ArrayBuffer, name?: string) => const docxLoader = async (el: HTMLElement, source: { contentUrl?: string; name?: string }) => { const buf = await fetchBuf(source.contentUrl) if (!isZipBytes(buf)) { - renderTextFallback(el, buf, source.name) + renderTextFallback(el, buf, source.name, 'DOCX') return } const mammoth = await import('mammoth') @@ -44,9 +44,15 @@ const docxLoader = async (el: HTMLElement, source: { contentUrl?: string; name?: el.innerHTML = value } -const xlsxLoader = async (el: HTMLElement, source: { contentUrl?: string }) => { +const xlsxLoader = async (el: HTMLElement, source: { contentUrl?: string; name?: string }) => { + const buf = await fetchBuf(source.contentUrl) + // .xls 是 OLE 复合文档,不是 ZIP;只对 OOXML(xlsx/xlsm,或未给文件名)做魔数嗅探。 + if (!/\.xls$/i.test(source.name ?? '') && !isZipBytes(buf)) { + renderTextFallback(el, buf, source.name, 'XLSX') + return + } const XLSX = await import('xlsx') - const wb = XLSX.read(await fetchBuf(source.contentUrl), { type: 'array' }) + const wb = XLSX.read(buf, { type: 'array' }) el.innerHTML = '' const tabs = document.createElement('div') tabs.className = 'ui-fp-sheet-tabs' @@ -90,7 +96,13 @@ function renderSheet(wrap: HTMLElement, rows: unknown[][]) { wrap.append(table) } -const pptxLoader = async (el: HTMLElement, source: { contentUrl?: string }) => { +const pptxLoader = async (el: HTMLElement, source: { contentUrl?: string; name?: string }) => { + const buf = await fetchBuf(source.contentUrl) + // .ppt 是老版二进制;OOXML(pptx/ppsx)才要求 ZIP 魔数。 + if (!/\.ppt$/i.test(source.name ?? '') && !isZipBytes(buf)) { + renderTextFallback(el, buf, source.name, 'PPTX') + return + } // pptx2html@0.3.4 的 module 入口引用了大小写错配的 ./txml(Linux 打包必炸), // 按 D2 备选条款改用 pptx-preview(init(dom).preview(arrayBuffer) 全保真渲染)。 const { init } = await import('pptx-preview') @@ -99,7 +111,7 @@ const pptxLoader = async (el: HTMLElement, source: { contentUrl?: string }) => { stage.className = 'ui-fp-slide-stage' el.append(stage) const previewer = init(stage, { width: stage.clientWidth || 900 }) - await previewer.preview(await fetchBuf(source.contentUrl)) + await previewer.preview(buf) } export function wirePreviewCapabilities() { From 3c815250a0b17c9f5c20bc1c6c41e222f628bf76 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 1 Oct 2026 04:31:50 +0000 Subject: [PATCH 3/3] Queue follow-up messages and pin files in the right rail Why: a busy conversation rejected further input, and the file list lived only behind the workspace overlay. Evidence: npx tsc --noEmit and vitest 42 tests, including outbox append/edit/promote. The queue uses Collapsible and design tokens. The files tab reuses ArtifactsPanel. Impact: while a run is active, Enter queues the draft (edit, delete, or send-now). Finished and failed runs drain the queue. HTTP 409 also enqueues instead of only toasting. Backend serialization is unchanged. Refs: task feature/input-queue Co-authored-by: Cursor --- src/components/ArtifactsPanel.tsx | 67 ++++++++++++++++++++++++++++ src/components/Conversation.tsx | 50 ++++++++++++++++++--- src/components/Workbench.tsx | 10 ++++- src/components/Workspaces.tsx | 70 +----------------------------- src/outbox.ts | 38 ++++++++++++++++ src/store.ts | 72 ++++++++++++++++++++++++++++--- src/styles.css | 5 +++ test/outbox.test.ts | 28 ++++++++++++ 8 files changed, 259 insertions(+), 81 deletions(-) create mode 100644 src/components/ArtifactsPanel.tsx create mode 100644 src/outbox.ts create mode 100644 test/outbox.test.ts diff --git a/src/components/ArtifactsPanel.tsx b/src/components/ArtifactsPanel.tsx new file mode 100644 index 0000000..c830b17 --- /dev/null +++ b/src/components/ArtifactsPanel.tsx @@ -0,0 +1,67 @@ +// 工件面板(工作区文件管理):搜索 + kind 过滤 + 上传;行内深预览/右栏预览/下载。 +// ⌘1 弹层与右栏「文件」页签共用这一份。删除仍是保留能力,系统默认不提供入口。 +import { useState } from 'react' +import { store, useStore } from '../store' +import { api } from '../api' +import { Icon } from '@ui' +import { artifactIcon, humanSize } from '../messageparts' + +export function ArtifactsPanel() { + const artifacts = useStore(s => s.artifacts) + const [q, setQ] = useState('') + const [kind, setKind] = useState<'all' | 'document' | 'data' | 'image'>('all') + + const kinds = ['all', 'document', 'data', 'image'] as const + const visible = artifacts.filter(a => + (kind === 'all' || a.kind === kind) && a.name.toLowerCase().includes(q.toLowerCase())) + + return ( +
+
+ setQ(e.target.value)} /> + {kinds.map(k => ( + + ))} + +
+ {artifacts.length === 0 + ?
暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载
+ : visible.length === 0 + ?
没有匹配「{q}」的文件
+ : visible.map(a => ( + // 预览落位在右栏——弹层必须先收起,否则预览换到被盖住的右栏。 +
{ store.closeWorkspace(); store.requestPreview(a) }} title={`预览 ${a.name}(右栏,边聊边看)`}> + + + {a.name} + {a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)} + + + + e.stopPropagation()}> + +
+ ))} +
+ ) +} + +function shortTime(iso: string): string { + try { + const d = new Date(iso) + const now = new Date() + return d.toDateString() === now.toDateString() + ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : d.toLocaleDateString([], { month: '2-digit', day: '2-digit' }) + } catch { return '' } +} diff --git a/src/components/Conversation.tsx b/src/components/Conversation.tsx index 77e5075..85659f6 100644 --- a/src/components/Conversation.tsx +++ b/src/components/Conversation.tsx @@ -318,6 +318,40 @@ function CommandMenu({ text, onPick }: { text: string; onPick: (cmd: string) => ) } +function OutboxPanel() { + const outbox = useStore(s => s.outbox) + const [editing, setEditing] = useState(null) + const [draft, setDraft] = useState('') + if (outbox.length === 0) return null + return ( + 排队 {outbox.length}} + triggerClassName="outbox-trigger"> +
    + {outbox.map(item => ( +
  • + {editing === item.id ? ( + setDraft(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { store.updateOutbox(item.id, draft); setEditing(null) } + }} /> + ) : ( + {item.text.trim() || '(仅附件)'}{item.attachmentNames.length > 0 ? ` · ${item.attachmentNames.join('、')}` : ''} + )} + {editing === item.id ? ( + + ) : ( + + )} + + +
  • + ))} +
+
+ ) +} + export function Conversation() { const currentConvId = useStore(s => s.currentConvId) const conversation = useStore(s => s.tasks.find(c => c.id === s.currentConvId)) @@ -349,8 +383,14 @@ export function Conversation() { const send = async () => { if (!text.trim() && attachmentIds.length === 0) return const t = text + const ids = attachmentIds + const names = attachmentNames setText(''); setAttachmentIds([]); setAttachmentNames([]) - await store.sendMessage(t, attachmentIds) + if (active) { + store.enqueueOutbox(t, ids, names) + return + } + await store.sendMessage(t, ids) } if (!currentConvId) { @@ -431,6 +471,7 @@ export function Conversation() {
+ {attachmentNames.length > 0 && (
{attachmentNames.map((n, i) => {n} @@ -450,15 +491,14 @@ export function Conversation() { }} />