Show work as a timeline and preview files in tabs #30

Merged
weichen merged 1 commits from feature/chat-timeline-preview into main 2026-10-01 07:40:53 +00:00
21 changed files with 688 additions and 237 deletions
+10
View File
@@ -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",
+1
View File
@@ -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",
+24 -4
View File
@@ -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() {
<Conversation />
</div>
{previewOpen && (
<>
<div className="divider" onMouseDown={onDividerDown('preview')} title="拖动调整预览区宽度" />
<div className="preview-slot" style={{ width: ui.previewWidth }}>
<PreviewPane />
</div>
</>
)}
{!ui.rightCollapsed ? (
<>
<div className="divider" onMouseDown={onDividerDown('right')} onDoubleClick={store.toggleRight} title="拖动调整宽度;双击收起" />
+3 -3
View File
@@ -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
? <div className="empty-note">没有匹配「{q}」的文件</div>
: visible.map(a => (
// 预览落位在右栏——弹层必须先收起,否则预览换到被盖住的右栏。
// 预览落在对话右侧预览区。弹层盖住主区,先收起再打开。
<div key={a.id} className="artifact-row" data-artifact-row={a.id}
onClick={() => { store.closeWorkspace(); store.requestPreview(a) }} title={`预览 ${a.name}(右栏,边聊边看)`}>
onClick={() => { store.closeWorkspace(); store.requestPreview(a) }} title={`预览 ${a.name}`}>
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
<span className="ar-main">
<span className="name">{a.name}</span>
+74 -85
View File
@@ -1,16 +1,17 @@
// 中栏:消息流(Part 渲染器注册表)+ 流式渲染 + 过程折叠 + HITL 卡片 + 输入区(4.4)。
// 重设计方案 阶段④:折叠区全部迁 @ui Collapsible(<details> 已禁);工具调用块走
// 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 <div className={live ? 'part-text cursor-blink' : 'part-text'}
dangerouslySetInnerHTML={{ __html: marked.parse(part.text ?? '') }} />
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} <span className={`status-${st}`}>{st === 'ok' ? '✓' : '✗'}</span>
</span>
}
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 (
<div className="deliverable-card mk-rise-in" data-artifact-chip={a?.artifactId}
title={`预览 ${name}(右栏,边聊边看)`}
onClick={() => { if (art) store.requestPreview(art) }}>
<span className="dc-icon"><Icon name={icon} size={18} /></span>
<span className="dc-main">
<span className="dc-name">{name}</span>
<span className="dc-meta">{kindLabelOf(a?.kind ?? art?.kind)}{size ? ` · ${size}` : ''}{art ? '' : ' · 生成中'}</span>
</span>
<span className="dc-act">预览</span>
<a className="dc-dl" title={`下载 ${name}`} href={api.artifactDownloadUrl(a?.artifactId ?? '')}
download={name} onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
</div>
)
}
case 'artifact_ref':
return part.artifact ? <DeliverableCard artifact={part.artifact} /> : null
case 'hitl':
return <div className="tool-chip"><Icon name="message" size={12} /> {part.hitl?.question} → {part.hitl?.answer || '…'}</div>
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(
<ToolBlock key={i} toolId={`${idKey}:${i}`} name={`🛠 ${p.toolCall?.name ?? '工具'}`}
running={live && !res} status={st === 'ok' ? 'ok' : st ? 'error' : undefined}
statusDetail={res?.toolResult?.summary}
autoCollapseOnComplete>
{res && <div className="part-text">{res.toolResult?.summary}</div>}
</ToolBlock>)
continue
}
out.push(<div className="part" key={i}><PartView part={p} live={live && p.type === 'text'} /></div>)
}
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 (
<div className="process-collapse">
<Collapsible id={`process:${idKey}`} triggerClassName="process-collapse-summary" unmountDelay={300}
trigger={<>
<span className="process-collapse-chev">▸</span>
<span>过程输出</span>
<span className="process-collapse-meta">
{hasThinking ? '含思考' : ''}{toolCount > 0 ? ` · ${toolCount} 次工具调用` : ''}
</span>
</>}>
<div className="body">
<ProcessParts parts={parts} live={false} idKey={idKey} />
</div>
</Collapsible>
<div className="deliverable-card mk-rise-in" data-artifact-chip={artifact.artifactId}>
<button type="button" className="dc-open" title={`预览 ${name}`} onClick={open}>
<span className="dc-icon"><Icon name={icon} size={18} /></span>
<span className="dc-main">
<span className="dc-name">{name}</span>
<span className="dc-meta">{kindLabelOf(artifact.kind ?? art?.kind)}{size ? ` · ${size}` : ''}{art ? '' : ' · 生成中'}</span>
</span>
</button>
<button type="button" className="dc-act" onClick={open} disabled={!art}>预览</button>
<button type="button" className="dc-more" aria-label={`更多 ${name}`} aria-expanded={menu}
onClick={() => setMenu(v => !v)}><Icon name="more" size={14} /></button>
{menu && (
<a className="dc-menu" href={api.artifactDownloadUrl(artifact.artifactId)} download={name}
onClick={() => setMenu(false)}>下载</a>
)}
</div>
)
}
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 = (
<ProcessTimeline parts={split.timeline} live={!!live} idKey={msg.id}
renderPart={(part, liveText) => <PartView part={part} live={liveText} />} />
)
return (
<div className="msg assistant" data-msg-id={msg.id}>
<div className="msg-role">
@@ -212,17 +191,27 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
<span>助手</span>
{live && <LiveBar live={live} />}
</div>
{blocks.map((b, i) => b.kind === 'process'
? (showProcess
? <div className="part-group" key={i}><ProcessParts parts={b.parts} live={!!live} idKey={`${msg.id}:${i}`} /></div>
: <ProcessCollapse key={i} parts={b.parts} idKey={`${msg.id}:${i}`} />)
{split.timeline.length > 0 && (showProcess
? <div className="part-group">{timeline}</div>
: (
// 最终输出契约:终轮正文是对用户的回复,一律完整可见(CF0 长回复折叠
// 已废除——过程进折叠区,回复不再折叠);交付卡片钉在正文之后。
<div className="part" key={i}>
<PartView part={b.part} live={!!(live && i === lastContentIdx && b.part.type === 'text')} />
<div className="process-collapse">
<Collapsible id={`process:${msg.id}`} triggerClassName="process-collapse-summary" unmountDelay={300}
trigger={<>
<span className="process-collapse-chev">▸</span>
<span>{workedLabel(run?.createdAt, run?.endedAt)}</span>
</>}>
<div className="body">{timeline}</div>
</Collapsible>
</div>
))}
{split.final.map((part, i) => (
<div className="part" key={i}>
<PartView part={part} live={!!(live && i === lastText && part.type === 'text')} />
</div>
))}
{extras.map(art => (
<DeliverableCard key={art.id} artifact={{ artifactId: art.id, name: art.name, kind: art.kind }} />
))}
{live && <LiveAgentNotes live={live} />}
{!live && ctxObs && <ContextObsView obs={ctxObs} />}
</div>
+50
View File
@@ -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 (
<div className="preview-pane">
<TabStrip
tabs={tabs.map(t => ({ id: t.id, label: t.name }))}
activeId={active.id}
onActivate={id => store.activatePreview(id)}
onClose={id => store.closePreview(id)}
/>
<div className="preview-pane-bar">
<span className="preview-pane-name" title={active.name}>{active.name}</span>
<span className="preview-pane-spacer" />
{art && (
<>
<button type="button" className="icon-btn" title="全屏预览" onClick={() => store.requestPreview(art, 'full')}>
<Icon name="eye" size={14} />
</button>
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(art.id)} download={art.name}>
<Icon name="download" size={14} />
</a>
</>
)}
</div>
<div className="preview-pane-body">
{art ? (
<FilePreview source={{
kind: 'artifact',
id: art.id,
name: art.name,
mime: art.mime,
contentUrl: api.artifactContentUrl(art.id),
downloadUrl: api.artifactDownloadUrl(art.id),
}} />
) : <div className="empty-note" style={{ padding: 14 }}>这个文件已不在当前项目里</div>}
</div>
</div>
)
}
+57
View File
@@ -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 (
<div className="tl">
{rows.map((row, i) => {
if (row.kind === 'thinking') {
const dur = formatDuration(row.durationMs)
const label = row.active ? '思考中' : (dur ? `思考 · 持续了 ${dur}` : '思考')
return (
<div className="part-thinking" key={`${idKey}:th:${i}`}>
<Collapsible id={`think:${idKey}:${i}:${row.active ? 'live' : 'done'}`} defaultOpen={row.active} key={row.active ? 'open' : 'shut'}
triggerClassName="part-thinking-summary"
trigger={<><span className="part-thinking-chev">▸</span>
<span className={row.active ? 'mk-sweep' : ''}>{label}</span></>}>
<div className="part-thinking-body">{row.text}</div>
</Collapsible>
</div>
)
}
if (row.kind === 'text') {
return <div className="part" key={`${idKey}:tx:${i}`}>{renderPart({ type: 'text', text: row.text }, false)}</div>
}
if (row.kind === 'tools') {
const running = row.items.some(it => it.running)
return (
<div className="tl-tools" key={`${idKey}:tl:${i}`}>
<div className={`tl-tools-head${running ? ' mk-sweep' : ''}`}>{toolGroupLabel(row.toolKind, row.items.length, running)}</div>
<ul className="tl-tools-list">
{row.items.map((it, j) => (
<li key={j}>
<span className="tl-tools-name">{it.headline}</span>
{it.status === 'error' && <span className="status-error">失败</span>}
{it.summary && <span className="tl-tools-sum">{it.summary}</span>}
</li>
))}
</ul>
</div>
)
}
return <div className="part" key={`${idKey}:ot:${i}`}>{renderPart(row.part, false)}</div>
})}
</div>
)
}
+5 -34
View File
@@ -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 (
<>
<div className="tabs">
<button className={`tab ${!showPreview && !showFiles ? 'active' : ''}`} onClick={() => store.setUI({ rightTab: 'inbox' })}
<button className={`tab ${!showFiles ? 'active' : ''}`} onClick={() => store.setUI({ rightTab: 'inbox' })}
title="收件箱">收件箱{pending > 0 ? ` ${pending}` : ''}</button>
<button className={`tab ${showFiles ? 'active' : ''}`} onClick={() => store.setUI({ rightTab: 'files', rightCollapsed: false })}
title="文件(与 ⌘1 工件面板同一份列表)">文件</button>
<button className={`tab ${showPreview ? 'active' : ''}`} onClick={() => store.railPreview(railArtifact ?? null)}
title="预览(右栏轻预览)"><Icon name="eye" size={13} /> 预览</button>
<span className="spacer" style={{ flex: 1 }} />
<button className="tab tab-action" title="收纳已完成卡(done→关闭;已确认卡留痕不动)" onClick={() => store.tidyCards()}><Icon name="check" size={13} /> 收纳</button>
<button className="tab" title="收起收件箱" onClick={store.toggleRight}><Icon name="chevron-right" size={14} /></button>
</div>
{showPreview ? (
<div className="tab-body" data-rail-preview="">
{railArtifact ? (
<>
<div className="rail-preview-head">
<span className="rp-name" title={railArtifact.name}>{railArtifact.name}</span>
<span className="spacer" style={{ flex: 1 }} />
<button className="icon-btn" title="全屏预览(唯一大窗落位)" onClick={() => store.requestPreview(railArtifact, 'full')}><Icon name="eye" size={14} /></button>
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(railArtifact.id)} download={railArtifact.name}><Icon name="download" size={14} /></a>
<button className="icon-btn" title="关闭预览" onClick={() => store.railPreview(null)}><Icon name="x" size={14} /></button>
</div>
<FilePreview compact source={{
kind: 'artifact',
id: railArtifact.id,
name: railArtifact.name,
mime: railArtifact.mime,
contentUrl: api.artifactContentUrl(railArtifact.id),
downloadUrl: api.artifactDownloadUrl(railArtifact.id),
}} />
</>
) : <div className="empty-note" style={{ padding: 14 }}>点消息里的工件卡片或右侧工件面板——文件会在这里边聊边看(md/图片/pdf/Office/代码均可)</div>}
</div>
) : showFiles ? (
{showFiles ? (
<div className="tab-body">
<ArtifactsPanel />
</div>
+1
View File
@@ -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 {
+13 -9
View File
@@ -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
}
+43 -22
View File
@@ -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 : '<span class="missing-art">未找到该文件</span>'
))
}
/** 毫秒转「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 共用)。 */
+9 -1
View File
@@ -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 = `<div class="doc-viewer">${value}</div>`
}
const xlsxLoader = async (el: HTMLElement, source: { contentUrl?: string; name?: string }) => {
+27
View File
@@ -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 }
}
+45 -12
View File
@@ -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<string, PreviewTab[]>
previewActive: Record<string, string | null>
}
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<AppState>) {
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<UIState>) {
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 }) },
+35 -16
View File
@@ -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,阶段④合入主线时归一) */
+146
View File
@@ -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<string, unknown>
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
}
+19 -13
View File
@@ -14,12 +14,12 @@ function live(over: Partial<LiveState> = {}): 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)
+39 -37
View File
@@ -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<Part> & { 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('已工作')
})
})
+24
View File
@@ -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 })
})
})
+62
View File
@@ -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<Part> & { 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 })
})
})
+1 -1
View File
@@ -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"}
{"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"}