From 0ed8ad32f1614e4425c17616573ede582794a426 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 1 Oct 2026 06:15:44 +0000 Subject: [PATCH] Show assistant work as a timeline and preview files in tabs Why: Process output folded on every text delta, successful tool results disappeared after refresh, and generated files stayed plain text instead of a card that opens a closable preview. Evidence: npx tsc -b --noEmit (clean); npx vitest run (51 passed). Impact: Live work stays expanded until the run ends, then folds to one worked-time line. File cards open a per-conversation preview pane. docx renders page by page, with mammoth if that fails. Refs: feature/chat-timeline-preview Co-authored-by: Cursor --- package-lock.json | 10 ++ package.json | 1 + src/App.tsx | 28 ++++- src/components/ArtifactsPanel.tsx | 6 +- src/components/Conversation.tsx | 159 ++++++++++++++--------------- src/components/PreviewPane.tsx | 50 +++++++++ src/components/ProcessTimeline.tsx | 57 +++++++++++ src/components/Workbench.tsx | 39 +------ src/contracts.ts | 1 + src/live.ts | 22 ++-- src/messageparts.ts | 65 ++++++++---- src/previewsetup.ts | 10 +- src/previewtabs.ts | 27 +++++ src/store.ts | 57 ++++++++--- src/styles.css | 51 ++++++--- src/toolkind.ts | 146 ++++++++++++++++++++++++++ test/live.test.ts | 32 +++--- test/messageparts.test.ts | 76 +++++++------- test/previewtabs.test.ts | 24 +++++ test/toolkind.test.ts | 62 +++++++++++ tsconfig.tsbuildinfo | 2 +- 21 files changed, 688 insertions(+), 237 deletions(-) create mode 100644 src/components/PreviewPane.tsx create mode 100644 src/components/ProcessTimeline.tsx create mode 100644 src/previewtabs.ts create mode 100644 src/toolkind.ts create mode 100644 test/previewtabs.test.ts create mode 100644 test/toolkind.test.ts diff --git a/package-lock.json b/package-lock.json index 369f2e4..f4a0d99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "agent-system-frontend", "version": "0.1.0", "dependencies": { + "docx-preview": "^0.4.1", "jszip": "^3.10.2", "mammoth": "^1.13.0", "marked": "^15.0.7", @@ -1670,6 +1671,15 @@ "integrity": "sha512-R0bRerYzy/EZP6QzD3Hkl6JjUDA1Mnn+pd928w0nWjPzihnbAR6dcjGJGiRyznj5E4S1duEKzWrTqw02DPcdzg==", "license": "BSD-2-Clause" }, + "node_modules/docx-preview": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.4.1.tgz", + "integrity": "sha512-Dv4g+LeE0swHpPvNC0lAT0YsR5Zq5pAxw7d3vHQ8EChBLgIrqqe9VPL5nVt2HBJap8bEZbXUI0TJob7kBeNU9w==", + "license": "Apache-2.0", + "dependencies": { + "jszip": ">=3.0.0" + } + }, "node_modules/duck": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", diff --git a/package.json b/package.json index 9cdf907..8da2a9f 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "docx-preview": "^0.4.1", "jszip": "^3.10.2", "mammoth": "^1.13.0", "marked": "^15.0.7", diff --git a/src/App.tsx b/src/App.tsx index bea6c91..37f1b3b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { Icon } from '@ui' import { store, useStore } from './store' import { TaskSidebar } from './components/TaskSidebar' import { Conversation } from './components/Conversation' +import { PreviewPane } from './components/PreviewPane' import { Workbench } from './components/Workbench' import { Workspaces, workspaceShortcuts } from './components/Workspaces' import { AgentTracker } from './components/AgentTracker' @@ -15,9 +16,10 @@ import { SettingsModal } from './components/SettingsModal' import { ProjectSettingsModal } from './components/ProjectSettingsModal' import { DocViewer } from './components/DocViewer' -// 三栏布局:左(项目会话)/ 中(对话)/ 右(工作台);分隔条拖拽与超调收起(4.4)。 +// 布局:左(项目会话)/ 中(对话)/ 预览区(有标签时)/ 右(工作台)。 const LEFT_MIN = 220, LEFT_MAX = 420, LEFT_OVERSHOOT = 40 const RIGHT_MIN = 280, RIGHT_MAX = 560 +const PREVIEW_MIN = 420, PREVIEW_MAX = 1100 export function App() { useEffect(() => { @@ -27,6 +29,10 @@ export function App() { }, []) const ui = useStore(s => s.ui) + const previewOpen = useStore(s => { + const id = s.currentConvId + return id ? (s.previewTabs[id]?.length ?? 0) > 0 : false + }) const toast = useStore(s => s.toast) const trackerRunId = useStore(s => s.ui.trackerRunId) const viewingArtifact = useStore(s => s.ui.viewingArtifact) @@ -57,11 +63,12 @@ export function App() { store.refreshPlugins() }, [authState]) - const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean } | null>(null) + const drag = useRef<{ side: 'left' | 'right' | 'preview'; startX: number; startW: number; overshoot: boolean } | null>(null) - const onDividerDown = (side: 'left' | 'right') => (e: React.MouseEvent) => { + const onDividerDown = (side: 'left' | 'right' | 'preview') => (e: React.MouseEvent) => { e.preventDefault() - drag.current = { side, startX: e.clientX, startW: side === 'left' ? ui.leftWidth : ui.rightWidth, overshoot: false } + const startW = side === 'left' ? ui.leftWidth : side === 'preview' ? ui.previewWidth : ui.rightWidth + drag.current = { side, startX: e.clientX, startW, overshoot: false } const move = (ev: MouseEvent) => { const d = drag.current if (!d) return @@ -70,6 +77,10 @@ export function App() { if (w < LEFT_MIN - LEFT_OVERSHOOT) { d.overshoot = true; return } d.overshoot = false store.setUI({ leftCollapsed: false, leftWidth: Math.min(LEFT_MAX, Math.max(LEFT_MIN, w)) }) + } else if (d.side === 'preview') { + const w = d.startW - (ev.clientX - d.startX) + if (w < PREVIEW_MIN) return + store.setUI({ previewWidth: Math.min(PREVIEW_MAX, w) }) } else { const w = d.startW - (ev.clientX - d.startX) if (w < RIGHT_MIN) return @@ -128,6 +139,15 @@ export function App() { + {previewOpen && ( + <> +
+
+ +
+ + )} + {!ui.rightCollapsed ? ( <>
diff --git a/src/components/ArtifactsPanel.tsx b/src/components/ArtifactsPanel.tsx index c830b17..2f0fd7c 100644 --- a/src/components/ArtifactsPanel.tsx +++ b/src/components/ArtifactsPanel.tsx @@ -1,4 +1,4 @@ -// 工件面板(工作区文件管理):搜索 + kind 过滤 + 上传;行内深预览/右栏预览/下载。 +// 工件面板(工作区文件管理):搜索 + kind 过滤 + 上传;行内打开预览区或下载。 // ⌘1 弹层与右栏「文件」页签共用这一份。删除仍是保留能力,系统默认不提供入口。 import { useState } from 'react' import { store, useStore } from '../store' @@ -37,9 +37,9 @@ export function ArtifactsPanel() { : visible.length === 0 ?
没有匹配「{q}」的文件
: visible.map(a => ( - // 预览落位在右栏——弹层必须先收起,否则预览换到被盖住的右栏。 + // 预览落在对话右侧预览区。弹层盖住主区,先收起再打开。
{ store.closeWorkspace(); store.requestPreview(a) }} title={`预览 ${a.name}(右栏,边聊边看)`}> + onClick={() => { store.closeWorkspace(); store.requestPreview(a) }} title={`预览 ${a.name}`}> {a.name} diff --git a/src/components/Conversation.tsx b/src/components/Conversation.tsx index 85659f6..18cc89b 100644 --- a/src/components/Conversation.tsx +++ b/src/components/Conversation.tsx @@ -1,16 +1,17 @@ // 中栏:消息流(Part 渲染器注册表)+ 流式渲染 + 过程折叠 + HITL 卡片 + 输入区(4.4)。 // 重设计方案 阶段④:折叠区全部迁 @ui Collapsible(
已禁);工具调用块走 // ToolBlock 契约(运行态扫光、完成边沿收起、展开态记忆);live 行扫光 + 已工作计时。 -import { Icon, Avatar, BrandMark, Collapsible, ToolBlock } from '@ui' +import { Icon, Avatar, BrandMark, Collapsible } from '@ui' import { useEffect, useMemo, useRef, useState } from 'react' import { marked } from 'marked' import { api } from '../api' import { store, useStore, activeRun, type LiveState, type ContextObs } from '../store' import { liveToMessage, showProcessInline } from '../live' -import type { Message, Part, HITLCard, ActivityCard } from '../contracts' +import type { Message, Part, HITLCard, ActivityCard, Artifact } from '../contracts' import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui' import { docBridge } from './Workbench' -import { groupParts, artifactIcon, humanSize } from '../messageparts' +import { splitTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, workedLabel } from '../messageparts' +import { ProcessTimeline } from './ProcessTimeline' // marked 走标准 Markdown 语义(不开 breaks):模型输出按块级排版渲染, // 行内换行交给段落折叠,避免与 CSS 块间距叠加出松散排版。 @@ -20,7 +21,7 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) { switch (part.type) { case 'text': return
+ dangerouslySetInnerHTML={{ __html: marked.parse(markMissingArtifacts(part.text ?? '', id => !!findArtifact(id))) as string }} /> case 'thinking': // 流式期间展开可实时看到思考;run 终态后收进过程折叠区,默认收起。 //(此单件形态用于过程组之外散落的思考;组内思考由 ProcessParts 渲染。) @@ -33,29 +34,8 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) { {part.toolCall?.name ?? part.toolResult?.callId} {st === 'ok' ? '✓' : '✗'} } - case 'artifact_ref': { - const a = part.artifact - const art = a ? findArtifact(a.artifactId) : undefined - const name = a?.name ?? '工件' - const icon = artifactIcon(name, a?.kind ?? art?.kind) - const size = humanSize(art?.size) - // 交付物卡片(对话内直接给出):整卡点击 = 右栏预览(唯一落位,全屏从 - // 右栏放大);动作只保留 下载 一个——与工件面板/预览器同一动作集。 - return ( -
{ if (art) store.requestPreview(art) }}> - - - {name} - {kindLabelOf(a?.kind ?? art?.kind)}{size ? ` · ${size}` : ''}{art ? '' : ' · 生成中'} - - 预览 - e.stopPropagation()}> -
- ) - } + case 'artifact_ref': + return part.artifact ? : null case 'hitl': return
{part.hitl?.question} → {part.hitl?.answer || '…'}
case 'plan': @@ -119,52 +99,50 @@ function ThinkingBlock({ part, idKey, defaultOpen }: { part: Part; idKey: string ) } -// 过程段渲染(配对增强):tool_call 吸收紧随其后的同名 tool_result 组成 ToolBlock -//(运行态扫光、完成边沿收起、展开看结果摘要);其余部件原位 PartView。 -function ProcessParts({ parts, live, idKey }: { parts: Part[]; live: boolean; idKey: string }) { - const out: React.ReactNode[] = [] - for (let i = 0; i < parts.length; i++) { - const p = parts[i] - if (p.type === 'tool_call') { - const res = parts[i + 1]?.type === 'tool_result' ? parts[i + 1] : null - if (res) i++ - const st = res?.toolResult?.status - out.push( - - {res &&
{res.toolResult?.summary}
} -
) - continue - } - out.push(
) - } - return <>{out} -} - -// 过程折叠:run 终态后把连续 process 部件折叠为摘要行(可展开回看;Collapsible 契约)。 -function ProcessCollapse({ parts, idKey }: { parts: Part[]; idKey: string }) { - const toolCount = parts.filter(p => p.type === 'tool_call').length - const hasThinking = parts.some(p => p.type === 'thinking' && p.text) +// 交付物卡片:主区域和「预览」打开预览区;下载收进菜单,避免和预览抢同一个按钮。 +function DeliverableCard({ artifact }: { artifact: { artifactId: string; name: string; kind?: string } }) { + const [menu, setMenu] = useState(false) + const art = findArtifact(artifact.artifactId) + const name = artifact.name || art?.name || '文件' + const icon = artifactIcon(name, artifact.kind ?? art?.kind) + const size = humanSize(art?.size) + const open = () => { if (art) store.requestPreview(art) } return ( -
- - ▸ - 过程输出 - - {hasThinking ? '含思考' : ''}{toolCount > 0 ? ` · ${toolCount} 次工具调用` : ''} - - }> -
- -
-
+
+ + + + {menu && ( + setMenu(false)}>下载 + )}
) } +function citedArtifactCards(parts: Part[]): Artifact[] { + const seen = new Set(parts.filter(p => p.type === 'artifact_ref').map(p => p.artifact?.artifactId).filter((id): id is string => !!id)) + const out: Artifact[] = [] + for (const p of parts) { + if (p.type !== 'text' || !p.text) continue + for (const id of artifactIdsIn(p.text)) { + if (seen.has(id)) continue + const art = findArtifact(id) + if (!art) continue + seen.add(id) + out.push(art) + } + } + return out +} + // 已工作计时(ZCode「已工作 X 分 X 秒」摘要行;1s 步进,仅 live 期间挂载)。 function useElapsed(startedAt: number): string { const [, tick] = useState(0) @@ -192,19 +170,20 @@ function LiveBar({ live }: { live: LiveState }) { function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null }) { const autoCollapse = useStore(s => s.ui.autoCollapseProcess) const ctxObs = useStore(s => (msg.runId ? s.contextObs[msg.runId] : undefined)) + const run = useStore(s => (msg.runId ? s.runs.find(r => r.id === msg.runId) : undefined)) const parts = msg.parts ?? [] - // 顺序保真(渲染顺序 = 落库顺序):连续过程部件就地聚块,内容部件原位保留—— - // 工具调用显示在它真正发生的位置,不被抽到消息头部重排。 - const blocks = useMemo(() => groupParts(parts), [parts]) - - // 流式期间:工具/思考阶段过程实时展开;一旦进入 responding(最终回复开始流式) - // 立即折叠过程,只显式显示回复(Cursor 式);工具恢复(acting)时重新展开。 - // run 终态后:按偏好折叠为摘要行(可回看)。 + const split = useMemo(() => splitTimeline(parts), [parts]) + // 运行中时间线一直展开。终轮确认没有后续工具调用之后,才按偏好收成「已工作 X」。 const showProcess = showProcessInline(live ?? null, autoCollapse) - const lastContentIdx = (() => { - for (let i = blocks.length - 1; i >= 0; i--) if (blocks[i].kind === 'content') return i + const extras = citedArtifactCards(split.final) + const lastText = (() => { + for (let i = split.final.length - 1; i >= 0; i--) if (split.final[i].type === 'text') return i return -1 })() + const timeline = ( + } /> + ) return (
@@ -212,17 +191,27 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null 助手 {live && }
- {blocks.map((b, i) => b.kind === 'process' - ? (showProcess - ?
- : ) + {split.timeline.length > 0 && (showProcess + ?
{timeline}
: ( - // 最终输出契约:终轮正文是对用户的回复,一律完整可见(CF0 长回复折叠 - // 已废除——过程进折叠区,回复不再折叠);交付卡片钉在正文之后。 -
- +
+ + ▸ + {workedLabel(run?.createdAt, run?.endedAt)} + }> +
{timeline}
+
))} + {split.final.map((part, i) => ( +
+ +
+ ))} + {extras.map(art => ( + + ))} {live && } {!live && ctxObs && }
diff --git a/src/components/PreviewPane.tsx b/src/components/PreviewPane.tsx new file mode 100644 index 0000000..f185bc7 --- /dev/null +++ b/src/components/PreviewPane.tsx @@ -0,0 +1,50 @@ +// 对话右侧预览区:多标签、可关、全屏与下载。宽度由 App 的分隔条控制。 +import { FilePreview, TabStrip, Icon } from '@ui' +import { api } from '../api' +import { store, useStore } from '../store' + +export function PreviewPane() { + const convId = useStore(s => s.currentConvId) + const tabs = useStore(s => (convId ? s.previewTabs[convId] : undefined) ?? []) + const activeId = useStore(s => (convId ? s.previewActive[convId] : null) ?? null) + const artifacts = useStore(s => s.artifacts) + const active = tabs.find(t => t.id === activeId) ?? tabs[0] + if (!active) return null + const art = artifacts.find(a => a.id === active.id) + return ( +
+ ({ id: t.id, label: t.name }))} + activeId={active.id} + onActivate={id => store.activatePreview(id)} + onClose={id => store.closePreview(id)} + /> +
+ {active.name} + + {art && ( + <> + + + + + + )} +
+
+ {art ? ( + + ) :
这个文件已不在当前项目里
} +
+
+ ) +} diff --git a/src/components/ProcessTimeline.tsx b/src/components/ProcessTimeline.tsx new file mode 100644 index 0000000..7f1c31a --- /dev/null +++ b/src/components/ProcessTimeline.tsx @@ -0,0 +1,57 @@ +// 工作时间线:思考、对用户的解说、同类工具合并成一行。运行中展开; +// 终轮结束后由调用方套进「已工作 X」折叠。 +import type { ReactNode } from 'react' +import { Collapsible } from '@ui' +import type { Part } from '../contracts' +import { formatDuration } from '../messageparts' +import { timelineEntries, toolGroupLabel } from '../toolkind' + +export function ProcessTimeline({ parts, live, idKey, renderPart }: { + parts: Part[] + live: boolean + idKey: string + renderPart: (part: Part, liveText: boolean) => ReactNode +}) { + const rows = timelineEntries(parts, live) + return ( +
+ {rows.map((row, i) => { + if (row.kind === 'thinking') { + const dur = formatDuration(row.durationMs) + const label = row.active ? '思考中' : (dur ? `思考 · 持续了 ${dur}` : '思考') + return ( +
+ ▸ + {label}}> +
{row.text}
+
+
+ ) + } + if (row.kind === 'text') { + return
{renderPart({ type: 'text', text: row.text }, false)}
+ } + if (row.kind === 'tools') { + const running = row.items.some(it => it.running) + return ( +
+
{toolGroupLabel(row.toolKind, row.items.length, running)}
+
    + {row.items.map((it, j) => ( +
  • + {it.headline} + {it.status === 'error' && 失败} + {it.summary && {it.summary}} +
  • + ))} +
+
+ ) + } + return
{renderPart(row.part, false)}
+ })} +
+ ) +} diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx index e9172c4..9d8549d 100644 --- a/src/components/Workbench.tsx +++ b/src/components/Workbench.tsx @@ -1,14 +1,12 @@ -// 右栏终态(提案 T4/D25 → 预览双落位):收件箱/预览两页签 + 「当前任务」常驻卡。 -// 预览页签(裁定 D4)承载右栏轻预览:FilePreview compact 模式,边聊边看。 +// 右栏:收件箱与文件。文件预览在对话右侧的预览区,不再占右栏页签。 import { store, useStore } from '../store' import type { Run } from '../contracts' -import { InboxList, pendingCount, FilePreview, type ActivityCard, type DocBridge } from '@ui' +import { InboxList, pendingCount, type ActivityCard, type DocBridge } from '@ui' import { Icon } from '@ui' -import { api } from '../api' import { ArtifactsPanel } from './ArtifactsPanel' // 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。 -// 打开语义走 requestPreview 唯一入口(阶段③ 收敛:默认右栏轻预览)。 +// 打开语义走 requestPreview 唯一入口(预览区)。 export const docBridge: DocBridge = { resolveDoc: id => store.getState().artifacts.find(a => a.id === id), onOpenDoc: id => { @@ -56,47 +54,20 @@ export function Workbench() { const projectCards = useStore(s => s.projectCards) const pending = pendingCount(cards) + pendingCount(projectCards) const rightTab = useStore(s => s.ui.rightTab) - const railId = useStore(s => s.ui.railPreviewId) - const railArtifact = useStore(s => (railId ? s.artifacts.find(a => a.id === railId) : undefined)) - const showPreview = rightTab === 'preview' const showFiles = rightTab === 'files' return ( <>
- -
- {showPreview ? ( -
- {railArtifact ? ( - <> -
- {railArtifact.name} - - - - -
- - - ) :
点消息里的工件卡片或右侧工件面板——文件会在这里边聊边看(md/图片/pdf/Office/代码均可)
} -
- ) : showFiles ? ( + {showFiles ? (
diff --git a/src/contracts.ts b/src/contracts.ts index 294e977..5ec5349 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -36,6 +36,7 @@ export interface Part { card?: { kind: string; data: UINode[]; cardId?: ID }; // cardId 非空 = 收件箱卡片引用部件(submit_card) error?: { code: string; message: string }; process?: boolean; // run 终态后折叠为摘要行 + durationMs?: number; // thinking 部件的思考时长(毫秒);工具耗时在 toolCall.durationMs } export interface Message { diff --git a/src/live.ts b/src/live.ts index 14a2ceb..2b59be1 100644 --- a/src/live.ts +++ b/src/live.ts @@ -11,20 +11,21 @@ import type { LiveState } from './store' // 到达序部件流的元素。 export type LiveItem = - | { kind: 'thinking'; text: string } + | { kind: 'thinking'; text: string; startedAt: number; endedAt: number } | { kind: 'text'; index: number; text: string } | { kind: 'tool'; callId?: string; name: string; status?: string; summary?: string } | { kind: 'artifact'; artifact: { artifactId: string; name: string; kind?: string } } // applyThinkingDelta 并入思考增量:末段是思考则追加,否则开启新思考段(新轮开始)。 -export function applyThinkingDelta(items: LiveItem[], delta: string): LiveItem[] { +// now 供测试固定首尾时间;缺省用墙上时钟,durationMs = endedAt - startedAt。 +export function applyThinkingDelta(items: LiveItem[], delta: string, now = Date.now()): LiveItem[] { const last = items[items.length - 1] if (last && last.kind === 'thinking') { const out = items.slice() - out[out.length - 1] = { kind: 'thinking', text: last.text + delta } + out[out.length - 1] = { kind: 'thinking', text: last.text + delta, startedAt: last.startedAt, endedAt: now } return out } - return [...items, { kind: 'thinking', text: delta }] + return [...items, { kind: 'thinking', text: delta, startedAt: now, endedAt: now }] } // applyTextDelta 并入正文增量:末段是同 index 的文本段则追加,否则开启新文本段。 @@ -78,7 +79,11 @@ export function liveToMessage(live: LiveState): Message { for (let i = 0; i < live.items.length; i++) { const it = live.items[i] if (it.kind === 'thinking') { - if (it.text) parts.push({ type: 'thinking', text: it.text, process: true }) + const durationMs = it.endedAt - it.startedAt + if (it.text) parts.push({ + type: 'thinking', text: it.text, process: true, + ...(durationMs > 0 ? { durationMs } : {}), + }) } else if (it.kind === 'tool') { parts.push({ type: 'tool_call', process: true, toolCall: { id: it.callId ?? '', name: it.name } }) if (it.status !== undefined) { @@ -97,9 +102,8 @@ export function liveToMessage(live: LiveState): Message { return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' } } -// showProcessInline:流式期间过程区是否展开——thinking/acting 实时展开;一旦进入 -// responding(最终回复开始流式)立即折叠,只显示回复(Cursor 式),工具恢复再展开。 -// 非 live 消息随「自动折叠过程」偏好。 +// showProcessInline:运行中时间线始终展开(不能靠 responding 提前折,那一轮文字 +// 后面还可能再调用工具)。run 结束后随「自动折叠过程」偏好收成一行。 export function showProcessInline(live: LiveState | null, autoCollapse: boolean): boolean { - return live ? live.phase !== 'responding' : !autoCollapse + return live != null || !autoCollapse } diff --git a/src/messageparts.ts b/src/messageparts.ts index 9275a30..f20b0a8 100644 --- a/src/messageparts.ts +++ b/src/messageparts.ts @@ -1,30 +1,51 @@ -// 消息部件顺序保真(渲染顺序 = 落库顺序):后端 llm.go 按时间交错落库 parts -// (text → tool_call → tool_result → artifact_ref → 下一轮 text…),渲染端不得 -// 按 process 标志二分重排——只允许把"连续的"过程部件就地聚成一块折叠。 +// 消息切分:过程时间线 = 从开头到最后一个 process 部件(中间夹着的内容部件留在时间线里); +// 最终段 = 它之后的正文与交付卡片。渲染顺序仍是落库顺序,只在这一刀上折叠。 import type { Part } from './contracts' -export type PartBlock = - | { kind: 'process'; parts: Part[] } // 连续过程段(thinking/tool_call/tool_result…) - | { kind: 'content'; part: Part } // 原位内容部件(text/artifact_ref/card/hitl…) +export interface TimelineSplit { + timeline: Part[] + final: Part[] +} + +/** 最后一个 process 部件之前(含自身)是工作时间线,其余是最终回复。 */ +export function splitTimeline(parts: Part[]): TimelineSplit { + let last = -1 + for (let i = 0; i < parts.length; i++) if (parts[i].process) last = i + if (last < 0) return { timeline: [], final: parts } + return { timeline: parts.slice(0, last + 1), final: parts.slice(last + 1) } +} + +const ART_ID = /art_[0-9a-f]{12}/g + +/** 正文里出现的工件 ID,去重且保持出现顺序。 */ +export function artifactIdsIn(text: string): string[] { + return [...new Set(text.match(ART_ID) ?? [])] +} /** - * 按原始顺序把 parts 切成「过程段 / 内容」交替的块序列。 - * 例:[text, tool_call, tool_result, text, artifact_ref] - * → [content(text), process(tool_call,tool_result), content(text), content(artifact_ref)] - * 连续的过程部件合并为一个折叠单元,不改变相对顺序。 + * 把正文里项目中不存在的工件 ID 换成弱化标记。存在的 ID 原样保留,由调用方补卡片。 + * ID 只有十六进制,替换片段不含用户可控的 HTML。 */ -export function groupParts(parts: Part[]): PartBlock[] { - const out: PartBlock[] = [] - for (const p of parts) { - if (p.process) { - const last = out[out.length - 1] - if (last && last.kind === 'process') last.parts.push(p) - else out.push({ kind: 'process', parts: [p] }) - } else { - out.push({ kind: 'content', part: p }) - } - } - return out +export function markMissingArtifacts(text: string, exists: (id: string) => boolean): string { + return text.replace(/`?(art_[0-9a-f]{12})`?/g, (raw, id: string) => ( + exists(id) ? raw : '未找到该文件' + )) +} + +/** 毫秒转「X 分 Y 秒」;不足 1 秒按 1 秒,无效值返回空串。 */ +export function formatDuration(ms: number | undefined): string { + if (ms == null || !Number.isFinite(ms) || ms <= 0) return '' + const s = Math.max(1, Math.round(ms / 1000)) + const m = Math.floor(s / 60) + return m > 0 ? `${m} 分 ${s % 60} 秒` : `${s} 秒` +} + +/** run 起止时间 → 「已工作 X」。缺时间时只给「已工作」。 */ +export function workedLabel(createdAt?: string, endedAt?: string): string { + if (!createdAt || !endedAt) return '已工作' + const ms = new Date(endedAt).getTime() - new Date(createdAt).getTime() + const text = formatDuration(ms) + return text ? `已工作 ${text}` : '已工作' } /** 文件大小人类可读(工件面板/DocViewer 共用)。 */ diff --git a/src/previewsetup.ts b/src/previewsetup.ts index 2ee393b..4c94b17 100644 --- a/src/previewsetup.ts +++ b/src/previewsetup.ts @@ -39,9 +39,17 @@ const docxLoader = async (el: HTMLElement, source: { contentUrl?: string; name?: renderTextFallback(el, buf, source.name, 'DOCX') return } + try { + const { renderAsync } = await import('docx-preview') + el.innerHTML = '' + await renderAsync(buf, el, el, { className: 'docx', inWrapper: true, breakPages: true }) + return + } catch (err) { + console.error('[preview:docx] page render failed, falling back', source.name, err) + } const mammoth = await import('mammoth') const { value } = await mammoth.convertToHtml({ arrayBuffer: buf }) - el.innerHTML = value + el.innerHTML = `
${value}
` } const xlsxLoader = async (el: HTMLElement, source: { contentUrl?: string; name?: string }) => { diff --git a/src/previewtabs.ts b/src/previewtabs.ts new file mode 100644 index 0000000..79ed08d --- /dev/null +++ b/src/previewtabs.ts @@ -0,0 +1,27 @@ +// 预览区标签的纯操作:打开已有标签只激活,关闭激活标签时选中相邻的一个。 + +export interface PreviewTab { + id: string + name: string +} + +export function openPreviewTab(tabs: PreviewTab[], tab: PreviewTab): { tabs: PreviewTab[]; activeId: string } { + const i = tabs.findIndex(t => t.id === tab.id) + if (i >= 0) { + const next = tabs.slice() + next[i] = { ...next[i], name: tab.name || next[i].name } + return { tabs: next, activeId: tab.id } + } + return { tabs: [...tabs, tab], activeId: tab.id } +} + +export function closePreviewTab( + tabs: PreviewTab[], activeId: string | null, id: string, +): { tabs: PreviewTab[]; activeId: string | null } { + const i = tabs.findIndex(t => t.id === id) + if (i < 0) return { tabs, activeId } + const next = tabs.filter(t => t.id !== id) + if (activeId !== id) return { tabs: next, activeId } + const neighbor = next[Math.min(i, next.length - 1)] ?? null + return { tabs: next, activeId: neighbor?.id ?? null } +} diff --git a/src/store.ts b/src/store.ts index d42feee..9ef07e8 100644 --- a/src/store.ts +++ b/src/store.ts @@ -3,6 +3,7 @@ import { useSyncExternalStore } from 'react' import { api, connectEvents } from './api' import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolResult, applyArtifactCreated, type LiveItem } from './live' +import { closePreviewTab, openPreviewTab, type PreviewTab } from './previewtabs' import { enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox' import type { Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList, @@ -36,8 +37,8 @@ export interface UIState { settingsOpen: boolean trackerRunId: string | null viewingArtifact: Artifact | null - /** 右栏轻预览的工件(rail 预览页签内容;与弹窗深预览 viewingArtifact 并存)。 */ - railPreviewId: string | null + /** 预览区宽度。标签列表按会话放在 AppState,不进本地偏好。 */ + previewWidth: number activeWorkspace: string | null taskOrg: 'project' | 'timeline' showArchived: boolean @@ -77,6 +78,8 @@ interface AppState { toast: string | null outbox: QueuedMessage[] outboxDraining: boolean + previewTabs: Record + previewActive: Record } const DEFAULT_UI: UIState = { @@ -91,7 +94,7 @@ const DEFAULT_UI: UIState = { settingsOpen: false, trackerRunId: null, viewingArtifact: null, - railPreviewId: null, + previewWidth: 640, taskOrg: 'project', showArchived: false, lastProjectId: null, @@ -116,6 +119,7 @@ let state: AppState = { serverWorkspaces: [], ui: loadUI(), connected: false, error: null, toast: null, outbox: [], outboxDraining: false, + previewTabs: {}, previewActive: {}, } const listeners = new Set<() => void>() @@ -125,6 +129,21 @@ function set(patch: Partial) { listeners.forEach(l => l()) } +// 预览区打开前右栏是否收起。null = 当前没有因为预览而改过右栏。 +let previewRightWasCollapsed: boolean | null = null + +function syncPreviewRail(hasTabs: boolean) { + if (hasTabs) { + if (previewRightWasCollapsed === null) previewRightWasCollapsed = state.ui.rightCollapsed + if (!state.ui.rightCollapsed) setUI({ rightCollapsed: true }) + return + } + if (previewRightWasCollapsed === null) return + const restore = previewRightWasCollapsed + previewRightWasCollapsed = null + setUI({ rightCollapsed: restore }) +} + function setUI(patch: Partial) { const ui = { ...state.ui, ...patch } state = { ...state, ui } @@ -244,6 +263,7 @@ export const store = { async openConversation(convId: string) { if (sse) { sse.close(); sse = null } set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], inboxScope: 'conv', outbox: [], outboxDraining: false }) + syncPreviewRail((state.previewTabs[convId] ?? []).length > 0) await store.reconcile() await store.connectLive() }, @@ -650,19 +670,32 @@ export const store = { }, openTracker(runId: string) { setUI({ trackerRunId: runId }) }, viewArtifact(a: Artifact | null) { setUI({ viewingArtifact: a }) }, - /** 右栏轻预览:打开指定工件并切到 rail 预览页签(双落位中的"轻"侧)。 */ - railPreview(a: Artifact | null) { - if (!a) { setUI({ railPreviewId: null, rightTab: 'inbox' }); return } - setUI({ railPreviewId: a.id, rightTab: 'preview', rightCollapsed: false }) - }, /** - * 工件预览唯一入口(重设计方案 阶段③ 收敛,Command 语义):一切预览请求只经这里, - * 落位由本方法派生——默认右栏轻预览(不打断对话),hint 'full' 落位全屏深读 - * (DocViewer,全项目唯一"大窗"形态)。下载语义同理由各落位只保留一个按钮。 + * 工件预览唯一入口。默认在对话右侧预览区打开或激活标签;hint 'full' 落位全屏 + * DocViewer。打开第一张标签时收起右栏,关掉最后一张时恢复。 */ requestPreview(a: Artifact, hint?: 'full') { if (hint === 'full') { setUI({ viewingArtifact: a }); return } - setUI({ railPreviewId: a.id, rightTab: 'preview', rightCollapsed: false }) + const conv = state.currentConvId ?? '_' + const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: a.id, name: a.name }) + set({ + previewTabs: { ...state.previewTabs, [conv]: opened.tabs }, + previewActive: { ...state.previewActive, [conv]: opened.activeId }, + }) + syncPreviewRail(true) + }, + activatePreview(id: string) { + const conv = state.currentConvId ?? '_' + set({ previewActive: { ...state.previewActive, [conv]: id } }) + }, + closePreview(id: string) { + const conv = state.currentConvId ?? '_' + const next = closePreviewTab(state.previewTabs[conv] ?? [], state.previewActive[conv] ?? null, id) + set({ + previewTabs: { ...state.previewTabs, [conv]: next.tabs }, + previewActive: { ...state.previewActive, [conv]: next.activeId }, + }) + syncPreviewRail(next.tabs.length > 0) }, openSettings(open: boolean) { setUI({ settingsOpen: open }) }, openProjectSettings(id: string | null) { setUI({ projectSettingsId: id }) }, diff --git a/src/styles.css b/src/styles.css index bd5adba..2e0b1fa 100644 --- a/src/styles.css +++ b/src/styles.css @@ -112,22 +112,46 @@ button { font-family: inherit; font-size: inherit; color: inherit; } .artifact-chip .ac-act { display: inline-flex; align-items: center; padding: 5px 8px; color: var(--text-dim); border-left: 1px solid var(--border-soft); background: none; cursor: pointer; } .artifact-chip .ac-act:hover { color: var(--accent); background: var(--bg-hover); } -/* 交付物卡片(对话内直接给出;整卡点击 = 右栏预览,动作只剩下载) */ -.deliverable-card { display: flex; align-items: center; gap: 12px; width: fit-content; max-width: 100%; padding: 10px 12px 10px 10px; margin: 6px 0 2px; - background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-lg); cursor: pointer; user-select: none; - transition: border-color .12s, background .12s; } -.deliverable-card:hover { border-color: var(--accent); background: var(--bg-hover); } +/* 交付物卡片:主区域与「预览」打开预览区,下载收在菜单里。 */ +.deliverable-card { position: relative; display: flex; align-items: center; gap: 8px; width: fit-content; max-width: 100%; padding: 8px 8px 8px 8px; margin: 6px 0 2px; + background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-lg); } +.deliverable-card:hover { border-color: var(--accent); } +.deliverable-card .dc-open { display: flex; align-items: center; gap: 12px; min-width: 0; padding: 0; border: none; background: none; color: inherit; font: inherit; cursor: pointer; text-align: left; } .deliverable-card .dc-icon { display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; border-radius: var(--radius); flex: none; background: var(--bg-inset); color: var(--accent); border: 1px solid var(--border-soft); } .deliverable-card .dc-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .deliverable-card .dc-name { color: var(--text); font-weight: 600; font-size: var(--fs-ui-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 380px; } .deliverable-card .dc-meta { color: var(--text-faint); font-size: var(--fs-ui-sm); white-space: nowrap; } -.deliverable-card .dc-act { flex: none; margin-left: auto; padding: 5px 12px; border-radius: var(--radius-sm); color: var(--accent); - font-size: var(--fs-ui-caption); border: 1px solid var(--border); background: var(--bg-panel); } -.deliverable-card:hover .dc-act { border-color: var(--accent); } -.deliverable-card .dc-dl { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-sm); - color: var(--text-dim); border: 1px solid var(--border-soft); } -.deliverable-card .dc-dl:hover { color: var(--accent); border-color: var(--accent); background: var(--bg-hover); } +.deliverable-card .dc-act { flex: none; margin-left: 8px; padding: 5px 12px; border-radius: var(--radius-sm); color: var(--accent); + font-size: var(--fs-ui-caption); border: 1px solid var(--border); background: var(--bg-panel); cursor: pointer; } +.deliverable-card .dc-act:hover:not(:disabled) { border-color: var(--accent); } +.deliverable-card .dc-act:disabled { opacity: .45; cursor: default; } +.deliverable-card .dc-more { flex: none; width: 28px; height: 28px; border: none; border-radius: var(--radius-sm); background: none; color: var(--text-dim); cursor: pointer; } +.deliverable-card .dc-more:hover { background: var(--bg-hover); color: var(--text); } +.deliverable-card .dc-menu { position: absolute; right: 8px; top: calc(100% - 4px); z-index: 2; padding: 6px 12px; border-radius: var(--radius-sm); + background: var(--bg-panel); border: 1px solid var(--border); box-shadow: var(--shadow-md); color: var(--text); font-size: var(--fs-ui-sm); text-decoration: none; } +.missing-art { color: var(--text-faint); border-bottom: 1px dashed var(--border-strong); } + +/* 工作时间线 */ +.tl-tools { margin: 4px 0; } +.tl-tools-head { font-size: var(--fs-ui-sm); color: var(--text-dim); } +.tl-tools-list { margin: 2px 0 0; padding: 0 0 0 16px; color: var(--text-faint); font-size: var(--fs-ui-sm); } +.tl-tools-list li { margin: 1px 0; } +.tl-tools-name { color: var(--text-dim); } +.tl-tools-sum { margin-left: 8px; color: var(--text-faint); } +.tl-tools .status-error { margin-left: 8px; color: var(--err); } + +/* 对话右侧预览区 */ +.preview-slot { flex-shrink: 0; min-width: 0; display: flex; } +.preview-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); overflow: hidden; } +.preview-pane-bar { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border-soft); } +.preview-pane-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-ui-sm); color: var(--text-dim); } +.preview-pane-spacer { flex: 1; } +.preview-pane-body { flex: 1; min-height: 0; overflow: auto; } +.preview-pane-body .ui-fp { height: 100%; } +.preview-pane-body .ui-fp-pdf { height: 100%; min-height: 480px; border: none; } +.docx-wrapper { background: var(--bg-inset) !important; padding: 12px; } +.docx-wrapper > section.docx { background: var(--bg) !important; color: var(--text); box-shadow: var(--shadow-md); } /* 过程段内联组(流式/关闭自动折叠时,按原位渲染的过程容器) */ .part-group { margin: 2px 0; } @@ -438,11 +462,6 @@ button { font-family: inherit; font-size: inherit; color: inherit; } .msg.user .msg-role { align-items: center; } .ad-user-cell { display: flex; align-items: center; gap: 8px; } -/* ---- 右栏轻预览(裁定 D4:右栏 + 弹窗双落位)---- */ -.rail-preview-head { display: flex; align-items: center; gap: 6px; padding: 0 2px 8px; border-bottom: 1px solid var(--border-soft); margin-bottom: 8px; } -.rail-preview-head .rp-name { font-size: var(--fs-ui-caption); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 55%; } -.tab-body[data-rail-preview] { display: flex; flex-direction: column; } -.tab-body[data-rail-preview] .ui-fp { flex: 1; } .artifact-chip .ac-act { border: none; background: none; } /* 项目收件箱(CF3/D51):会话/项目范围分段控件(令牌对齐 DESIGN.md,阶段④合入主线时归一) */ diff --git a/src/toolkind.ts b/src/toolkind.ts new file mode 100644 index 0000000..d16ef5e --- /dev/null +++ b/src/toolkind.ts @@ -0,0 +1,146 @@ +// 工具调用按类别合并成时间线的一行。新增类别必须补进 ToolKind 与两处 switch, +// 否则 never 检查会在编译期失败。 +import type { Part } from './contracts' + +export type ToolKind = 'read' | 'search' | 'exec' | 'write' | 'card' | 'delegate' | 'plan' | 'other' + +const READ = new Set(['fs_read_file', 'fs_list_dir', 'read_conversation', 'list_conversations']) +const SEARCH = new Set(['knowledge_search', 'tavily_search', 'web_search', 'history_search']) +const EXEC = new Set(['run_shell', 'run_python']) +const WRITE = new Set(['write_artifact', 'register_artifact', 'fs_write_file']) +const CARD = new Set(['submit_card', 'card_begin', 'card_append', 'card_commit']) +const DELEGATE = new Set(['delegate_task', 'squad_orchestrate']) +const PLAN = new Set(['update_plan', 'update_todos']) + +export function toolKind(name: string): ToolKind { + if (READ.has(name)) return 'read' + if (SEARCH.has(name)) return 'search' + if (EXEC.has(name)) return 'exec' + if (WRITE.has(name)) return 'write' + if (CARD.has(name)) return 'card' + if (DELEGATE.has(name)) return 'delegate' + if (PLAN.has(name)) return 'plan' + return 'other' +} + +function argOf(raw: string | undefined, keys: string[]): string { + if (!raw) return '' + try { + const v = JSON.parse(raw) as Record + for (const k of keys) { + const x = v[k] + if (typeof x === 'string' && x.trim()) return x.trim() + } + } catch { + return '' + } + return '' +} + +function firstLine(s: string): string { + const line = s.split('\n').map(x => x.trim()).find(Boolean) ?? '' + return line.length > 80 ? `${line.slice(0, 80)}…` : line +} + +/** 从工具参数里取出给用户看的一行:路径、查询词或脚本首行。 */ +export function toolHeadline(name: string, args?: string): string { + const kind = toolKind(name) + switch (kind) { + case 'read': return argOf(args, ['path', 'file', 'conversationId']) || name + case 'search': return argOf(args, ['query', 'q']) || name + case 'exec': return firstLine(argOf(args, ['script', 'command', 'cmd'])) || name + case 'write': return argOf(args, ['path', 'name', 'filename']) || name + case 'card': return argOf(args, ['title']) || name + case 'delegate': return argOf(args, ['task', 'goal', 'name']) || name + case 'plan': return argOf(args, ['title']) || name + case 'other': return name + default: { + const exhaustive: never = kind + return exhaustive + } + } +} + +export function toolGroupLabel(kind: ToolKind, count: number, running: boolean): string { + switch (kind) { + case 'read': return `查阅 · ${count} 个文件` + case 'search': return `搜索 · ${count} 次` + case 'exec': return running ? `运行命令 · ${count} 条` : `已运行命令 · ${count} 条` + case 'write': return `写入文件 · ${count} 个` + case 'card': return `卡片 · ${count} 张` + case 'delegate': return `委派 · ${count} 个任务` + case 'plan': return `更新计划 · ${count} 次` + case 'other': return `工具 · ${count} 次` + default: { + const exhaustive: never = kind + return exhaustive + } + } +} + +export interface ToolGroupItem { + name: string + headline: string + status?: 'ok' | 'error' + summary?: string + running: boolean +} + +export type TimelineEntry = + | { kind: 'thinking'; text: string; durationMs?: number; active: boolean } + | { kind: 'text'; text: string } + | { kind: 'tools'; toolKind: ToolKind; items: ToolGroupItem[] } + | { kind: 'other'; part: Part } + +/** 把时间线部件收成行:相邻同类工具合并,思考是否仍在进行由 live 与后续部件决定。 */ +export function timelineEntries(parts: Part[], live: boolean): TimelineEntry[] { + const out: TimelineEntry[] = [] + let i = 0 + while (i < parts.length) { + const p = parts[i] + if (p.type === 'tool_call') { + const kind = toolKind(p.toolCall?.name ?? '') + const items: ToolGroupItem[] = [] + while (i < parts.length && parts[i].type === 'tool_call' && toolKind(parts[i].toolCall?.name ?? '') === kind) { + const call = parts[i] + i++ + let res: Part | undefined + if (parts[i]?.type === 'tool_result') { + res = parts[i] + i++ + } + const status = res?.toolResult?.status + items.push({ + name: call.toolCall?.name ?? '工具', + headline: toolHeadline(call.toolCall?.name ?? '', call.toolCall?.arguments), + status: status === 'ok' || status === 'error' ? status : undefined, + summary: res?.toolResult?.summary, + running: live && !res, + }) + } + out.push({ kind: 'tools', toolKind: kind, items }) + continue + } + if (p.type === 'tool_result') { i++; continue } + if (p.type === 'thinking') { + out.push({ kind: 'thinking', text: p.text ?? '', durationMs: p.durationMs, active: false }) + i++ + continue + } + if (p.type === 'text') { + out.push({ kind: 'text', text: p.text ?? '' }) + i++ + continue + } + out.push({ kind: 'other', part: p }) + i++ + } + if (live) { + const idx = out.reduce((acc, e, n) => (e.kind === 'thinking' ? n : acc), -1) + const entry = idx >= 0 ? out[idx] : undefined + if (entry && entry.kind === 'thinking' && out.slice(idx + 1).every(e => e.kind === 'thinking')) { + entry.active = true + } + } + return out +} diff --git a/test/live.test.ts b/test/live.test.ts index 50332ec..283b805 100644 --- a/test/live.test.ts +++ b/test/live.test.ts @@ -14,12 +14,12 @@ function live(over: Partial = {}): LiveState { // 两轮流式事件回放:思考→解说文本→工具→思考→最终回复(与后端事件序一致)。 function twoTurnItems(): LiveItem[] { let items: LiveItem[] = [] - items = applyThinkingDelta(items, '先想想') - items = applyThinkingDelta(items, '一下') + items = applyThinkingDelta(items, '先想想', 0) + items = applyThinkingDelta(items, '一下', 0) items = applyTextDelta(items, 0, '我先更新待办。') items = applyToolCall(items, 'update_todos', 'c1') items = applyToolResult(items, 'c1', 'ok', '已更新') - items = applyThinkingDelta(items, '再想想') + items = applyThinkingDelta(items, '再想想', 0) items = applyTextDelta(items, 1, '最终') items = applyTextDelta(items, 1, '回复') return items @@ -28,18 +28,18 @@ function twoTurnItems(): LiveItem[] { describe('applyThinkingDelta / applyTextDelta', () => { it('merges thinking deltas into the last thinking segment', () => { let items: LiveItem[] = [] - items = applyThinkingDelta(items, '先') - items = applyThinkingDelta(items, '想想') - expect(items).toEqual([{ kind: 'thinking', text: '先想想' }]) + items = applyThinkingDelta(items, '先', 0) + items = applyThinkingDelta(items, '想想', 0) + expect(items).toEqual([{ kind: 'thinking', text: '先想想', startedAt: 0, endedAt: 0 }]) }) it('opens a new thinking segment after text/tool (turn boundary)', () => { - let items: LiveItem[] = applyThinkingDelta([], '第一轮思考') + let items: LiveItem[] = applyThinkingDelta([], '第一轮思考', 0) items = applyTextDelta(items, 0, '解说') - items = applyThinkingDelta(items, '第二轮思考') + items = applyThinkingDelta(items, '第二轮思考', 0) expect(items).toEqual([ - { kind: 'thinking', text: '第一轮思考' }, + { kind: 'thinking', text: '第一轮思考', startedAt: 0, endedAt: 0 }, { kind: 'text', index: 0, text: '解说' }, - { kind: 'thinking', text: '第二轮思考' }, + { kind: 'thinking', text: '第二轮思考', startedAt: 0, endedAt: 0 }, ]) }) it('appends text to the last segment when index matches', () => { @@ -115,7 +115,7 @@ describe('liveToMessage', () => { }) it('thinking and tool parts are always process', () => { let items: LiveItem[] = [] - items = applyThinkingDelta(items, '想') + items = applyThinkingDelta(items, '想', 0) items = applyTextDelta(items, 0, '答') items = applyToolCall(items, 'web_search') items = applyToolResult(items, undefined, 'ok', '命中') @@ -138,10 +138,16 @@ describe('liveToMessage', () => { }) describe('showProcessInline', () => { - it('expands during thinking/acting and collapses the moment responding starts', () => { + it('stays expanded for the whole live run, including the responding phase', () => { expect(showProcessInline(live({ phase: 'thinking' }), true)).toBe(true) expect(showProcessInline(live({ phase: 'acting' }), true)).toBe(true) - expect(showProcessInline(live({ phase: 'responding' }), true)).toBe(false) + expect(showProcessInline(live({ phase: 'responding' }), true)).toBe(true) + }) + it('records thinking duration from the first delta to the last', () => { + let items: LiveItem[] = applyThinkingDelta([], '先', 1000) + items = applyThinkingDelta(items, '想想', 2500) + const msg = liveToMessage(live({ items })) + expect(msg.parts[0]).toMatchObject({ type: 'thinking', text: '先想想', durationMs: 1500 }) }) it('falls back to the auto-collapse preference when no live run', () => { expect(showProcessInline(null, true)).toBe(false) diff --git a/test/messageparts.test.ts b/test/messageparts.test.ts index 26a41bb..3098d0f 100644 --- a/test/messageparts.test.ts +++ b/test/messageparts.test.ts @@ -1,15 +1,13 @@ -// 消息部件顺序保真纯函数测试(渲染顺序 = 落库顺序)。 import { describe, it, expect } from 'vitest' -import { groupParts, humanSize, artifactIcon } from '../src/messageparts' +import { splitTimeline, humanSize, artifactIcon, artifactIdsIn, markMissingArtifacts, workedLabel } from '../src/messageparts' import type { Part } from '../src/contracts' function p(partial: Partial & { type: Part['type'] }): Part { return partial as Part } -describe('groupParts(顺序保真)', () => { - it('interleaves tool blocks at their original positions', () => { - // 后端 llm.go 的真实落库序(交付物钉尾):text(过程解说) → tool → tool_result → text(终轮回复) → artifact_ref +describe('splitTimeline', () => { + it('keeps everything up to the last process part in the timeline', () => { const parts: Part[] = [ p({ type: 'text', text: '先找资料', process: true }), p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }), @@ -17,45 +15,49 @@ describe('groupParts(顺序保真)', () => { p({ type: 'text', text: '文档已生成,请查收' }), p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '会议纪要.docx' } }), ] - const blocks = groupParts(parts) - expect(blocks.map(b => b.kind)).toEqual(['process', 'content', 'content']) - expect((blocks[0] as { parts: Part[] }).parts.map(x => x.type)).toEqual(['text', 'tool_call', 'tool_result']) - expect((blocks[1] as { part: Part }).part.text).toBe('文档已生成,请查收') - expect((blocks[2] as { part: Part }).part.type).toBe('artifact_ref') + const split = splitTimeline(parts) + expect(split.timeline.map(x => x.type)).toEqual(['text', 'tool_call', 'tool_result']) + expect(split.final.map(x => x.type)).toEqual(['text', 'artifact_ref']) }) - it('renders deliverable cards as content in legacy inline order too', () => { - // 旧落库序(artifact_ref 夹在过程与正文之间):仍是内容部件,原位渲染不折叠。 + it('keeps a card sandwiched between process parts inside the timeline', () => { const parts: Part[] = [ - p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }), - p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '会议纪要.docx' } }), - p({ type: 'text', text: '文档已生成' }), - ] - const blocks = groupParts(parts) - expect(blocks.map(b => b.kind)).toEqual(['process', 'content', 'content']) - expect((blocks[1] as { part: Part }).part.type).toBe('artifact_ref') - }) - - it('merges only consecutive process parts into one block', () => { - const parts: Part[] = [ - p({ type: 'thinking', process: true, text: '思考' }), - p({ type: 'text', text: '第一段' }), + p({ type: 'thinking', process: true, text: '想' }), + p({ type: 'card', card: { kind: 'result', data: [] } }), p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'a' } }), - p({ type: 'tool_result', process: true, toolResult: { callId: 'c1', status: 'ok' } }), - p({ type: 'text', text: '第二段' }), - p({ type: 'tool_call', process: true, toolCall: { id: 'c2', name: 'b' } }), + p({ type: 'text', text: '最终' }), ] - const blocks = groupParts(parts) - expect(blocks.map(b => b.kind)).toEqual(['process', 'content', 'process', 'content', 'process']) - expect((blocks[2] as { parts: Part[] }).parts).toHaveLength(2) - expect((blocks[4] as { parts: Part[] }).parts).toHaveLength(1) + const split = splitTimeline(parts) + expect(split.timeline.map(x => x.type)).toEqual(['thinking', 'card', 'tool_call']) + expect(split.final.map(x => x.text)).toEqual(['最终']) }) - it('handles all-process and all-content messages', () => { - expect(groupParts([p({ type: 'thinking', process: true, text: 'x' }), p({ type: 'tool_call', process: true, toolCall: { id: '', name: '' } })])) - .toEqual([{ kind: 'process', parts: [expect.anything(), expect.anything()] }]) - expect(groupParts([p({ type: 'text', text: 'hi' })]).map(b => b.kind)).toEqual(['content']) - expect(groupParts([])).toEqual([]) + it('puts a message with no process parts entirely in the final section', () => { + const parts = [p({ type: 'text', text: 'hi' })] + expect(splitTimeline(parts).timeline).toEqual([]) + expect(splitTimeline(parts).final).toEqual(parts) + expect(splitTimeline([])).toEqual({ timeline: [], final: [] }) + }) +}) + +describe('artifact ids in reply text', () => { + it('extracts unique ids in order', () => { + expect(artifactIdsIn('见 art_0123456789ab 和 `art_abcdefabcdef`,再提 art_0123456789ab')) + .toEqual(['art_0123456789ab', 'art_abcdefabcdef']) + }) + it('marks only ids the project does not have', () => { + const text = '文件 art_0123456789ab 与 art_abcdefabcdef' + const out = markMissingArtifacts(text, id => id === 'art_0123456789ab') + expect(out).toContain('art_0123456789ab') + expect(out).not.toContain('art_abcdefabcdef') + expect(out).toContain('未找到该文件') + }) +}) + +describe('workedLabel', () => { + it('formats the run span and degrades without timestamps', () => { + expect(workedLabel('2026-10-01T00:00:00.000Z', '2026-10-01T00:01:05.000Z')).toBe('已工作 1 分 5 秒') + expect(workedLabel(undefined, undefined)).toBe('已工作') }) }) diff --git a/test/previewtabs.test.ts b/test/previewtabs.test.ts new file mode 100644 index 0000000..6331c87 --- /dev/null +++ b/test/previewtabs.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest' +import { openPreviewTab, closePreviewTab } from '../src/previewtabs' + +describe('preview tabs', () => { + it('opens a new tab and re-activates an existing one without duplicating it', () => { + const first = openPreviewTab([], { id: 'a', name: '甲.docx' }) + const second = openPreviewTab(first.tabs, { id: 'b', name: '乙.pptx' }) + expect(second.tabs.map(t => t.id)).toEqual(['a', 'b']) + expect(second.activeId).toBe('b') + const again = openPreviewTab(second.tabs, { id: 'a', name: '甲.docx' }) + expect(again.tabs).toHaveLength(2) + expect(again.activeId).toBe('a') + }) + + it('closing the active tab selects the neighbor, and closing another keeps the active tab', () => { + const tabs = [{ id: 'a', name: '甲' }, { id: 'b', name: '乙' }, { id: 'c', name: '丙' }] + const closedB = closePreviewTab(tabs, 'b', 'b') + expect(closedB.tabs.map(t => t.id)).toEqual(['a', 'c']) + expect(closedB.activeId).toBe('c') + const closedA = closePreviewTab(closedB.tabs, 'c', 'a') + expect(closedA.activeId).toBe('c') + expect(closePreviewTab([{ id: 'a', name: '甲' }], 'a', 'a')).toEqual({ tabs: [], activeId: null }) + }) +}) diff --git a/test/toolkind.test.ts b/test/toolkind.test.ts new file mode 100644 index 0000000..c0d5911 --- /dev/null +++ b/test/toolkind.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest' +import { toolKind, toolHeadline, toolGroupLabel, timelineEntries } from '../src/toolkind' +import type { Part } from '../src/contracts' + +function p(partial: Partial & { type: Part['type'] }): Part { + return partial as Part +} + +describe('toolKind', () => { + it('classifies known tools and falls back to other', () => { + expect(toolKind('fs_read_file')).toBe('read') + expect(toolKind('web_search')).toBe('search') + expect(toolKind('run_python')).toBe('exec') + expect(toolKind('register_artifact')).toBe('write') + expect(toolKind('submit_card')).toBe('card') + expect(toolKind('delegate_task')).toBe('delegate') + expect(toolKind('update_todos')).toBe('plan') + expect(toolKind('time_now')).toBe('other') + }) + + it('pulls a path, a query, or the first script line out of arguments', () => { + expect(toolHeadline('fs_read_file', '{"path":"notes/a.md"}')).toBe('notes/a.md') + expect(toolHeadline('web_search', '{"query":"会议冲突"}')).toBe('会议冲突') + expect(toolHeadline('run_python', '{"script":"print(1)\\nprint(2)"}')).toBe('print(1)') + expect(toolGroupLabel('read', 2, false)).toBe('查阅 · 2 个文件') + expect(toolGroupLabel('exec', 1, true)).toBe('运行命令 · 1 条') + }) +}) + +describe('timelineEntries', () => { + it('merges consecutive tools of the same kind and leaves narration in place', () => { + const parts: Part[] = [ + p({ type: 'thinking', process: true, text: '先看文件', durationMs: 1500 }), + p({ type: 'tool_call', process: true, toolCall: { id: 'a', name: 'fs_read_file', arguments: '{"path":"a.md"}' } }), + p({ type: 'tool_result', process: true, toolResult: { callId: 'a', status: 'ok', summary: '12 行' } }), + p({ type: 'tool_call', process: true, toolCall: { id: 'b', name: 'fs_read_file', arguments: '{"path":"b.md"}' } }), + p({ type: 'tool_result', process: true, toolResult: { callId: 'b', status: 'ok' } }), + p({ type: 'text', process: true, text: '两份都读完了' }), + p({ type: 'tool_call', process: true, toolCall: { id: 'c', name: 'run_python', arguments: '{"script":"print(1)"}' } }), + ] + const rows = timelineEntries(parts, true) + expect(rows.map(r => r.kind)).toEqual(['thinking', 'tools', 'text', 'tools']) + const reads = rows[1] + if (reads.kind !== 'tools') throw new Error('expected tool group') + expect(reads.toolKind).toBe('read') + expect(reads.items.map(it => it.headline)).toEqual(['a.md', 'b.md']) + expect(reads.items[0].summary).toBe('12 行') + const thinking = rows[0] + if (thinking.kind !== 'thinking') throw new Error('expected thinking') + expect(thinking.active).toBe(false) + const exec = rows[3] + if (exec.kind !== 'tools') throw new Error('expected exec group') + expect(exec.items[0].running).toBe(true) + }) + + it('marks the trailing thinking row active while the run is live', () => { + const rows = timelineEntries([p({ type: 'thinking', process: true, text: '还在想' })], true) + expect(rows[0]).toMatchObject({ kind: 'thinking', active: true }) + expect(timelineEntries([p({ type: 'thinking', process: true, text: '想完了' })], false)[0]) + .toMatchObject({ kind: 'thinking', active: false }) + }) +}) diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index 425165d..ac11937 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/live.ts","./src/main.tsx","./src/messageparts.ts","./src/passwords.ts","./src/previewsetup.ts","./src/store.ts","./src/tasklist.ts","./src/components/AdminUsersModal.tsx","./src/components/AdminZone.tsx","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/ProjectSettingsModal.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./src/components/adminSections.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/design-guard.test.ts","./test/live.test.ts","./test/messageparts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/live.ts","./src/main.tsx","./src/messageparts.ts","./src/outbox.ts","./src/passwords.ts","./src/previewsetup.ts","./src/previewtabs.ts","./src/store.ts","./src/tasklist.ts","./src/toolkind.ts","./src/components/AdminUsersModal.tsx","./src/components/AdminZone.tsx","./src/components/AgentTracker.tsx","./src/components/ArtifactsPanel.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/PreviewPane.tsx","./src/components/ProcessTimeline.tsx","./src/components/ProjectSettingsModal.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./src/components/adminSections.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/design-guard.test.ts","./test/live.test.ts","./test/messageparts.test.ts","./test/outbox.test.ts","./test/previewtabs.test.ts","./test/tasklist.test.ts","./test/toolkind.test.ts"],"version":"5.9.3"} \ No newline at end of file -- 2.54.0