diff --git a/src/components/Conversation.tsx b/src/components/Conversation.tsx index 9429a7c..de132d7 100644 --- a/src/components/Conversation.tsx +++ b/src/components/Conversation.tsx @@ -10,7 +10,7 @@ import { liveToMessage, showProcessInline } from '../live' import type { Message, Part, HITLCard, ActivityCard } from '../contracts' import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui' import { docBridge } from './Workbench' -import { groupParts, artifactIcon } from '../messageparts' +import { groupParts, artifactIcon, humanSize } from '../messageparts' // marked 走标准 Markdown 语义(不开 breaks):模型输出按块级排版渲染, // 行内换行交给段落折叠,避免与 CSS 块间距叠加出松散排版。 @@ -38,17 +38,22 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) { 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 ( - - - - e.stopPropagation()}> - +
{ if (art) store.requestPreview(art) }}> + + + {name} + {kindLabelOf(a?.kind ?? art?.kind)}{size ? ` · ${size}` : ''}{art ? '' : ' · 生成中'} + + 预览 + e.stopPropagation()}> +
) } case 'hitl': @@ -86,6 +91,16 @@ function findArtifact(id: string) { return artifactCache[id] } +/** 交付物卡片的类型文案(与工件面板 kind 过滤同一词汇)。 */ +function kindLabelOf(kind: string | undefined): string { + switch (kind) { + case 'document': return '文档' + case 'data': return '数据' + case 'image': return '图片' + default: return '文件' + } +} + // 收件箱卡片查询缓存(store.cards 的派生索引;卡流锚点取卡数据用)。 let cardCache: Record = {} function findCard(id: string) { diff --git a/src/live.ts b/src/live.ts index b2f008d..14a2ceb 100644 --- a/src/live.ts +++ b/src/live.ts @@ -42,7 +42,8 @@ export function applyToolCall(items: LiveItem[], name: string, callId?: string): return [...items, { kind: 'tool', callId, name }] } -// applyArtifactCreated 追加工件引用(artifact.created 事件;与落库一致标记为过程)。 +// applyArtifactCreated 追加工件引用(artifact.created 事件)。投影时作为 +// 交付物钉在消息尾部(内容部件,不进过程折叠)——与落库序同构。 export function applyArtifactCreated(items: LiveItem[], artifact: { artifactId: string; name: string; kind?: string }): LiveItem[] { return [...items, { kind: 'artifact', artifact }] } @@ -73,6 +74,7 @@ export function liveToMessage(live: LiveState): Message { textProcess.set(i, hasCalls) } const parts: Part[] = [] + const deliverables: Part[] = [] for (let i = 0; i < live.items.length; i++) { const it = live.items[i] if (it.kind === 'thinking') { @@ -84,11 +86,14 @@ export function liveToMessage(live: LiveState): Message { toolResult: { callId: it.callId ?? '', status: it.status === 'error' ? 'error' : 'ok', summary: it.summary } }) } } else if (it.kind === 'artifact') { - parts.push({ type: 'artifact_ref', process: true, artifact: it.artifact }) + // 交付物卡片:内容部件,投影钉尾(落库侧 llm.go 同序——最终正文之后), + // 流式期间即可在消息底部看到"文件已生成"卡片。 + deliverables.push({ type: 'artifact_ref', artifact: it.artifact }) } else { parts.push({ type: 'text', text: it.text, process: textProcess.get(i) || undefined }) } } + parts.push(...deliverables) return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' } } diff --git a/src/styles.css b/src/styles.css index c57aee9..3582e20 100644 --- a/src/styles.css +++ b/src/styles.css @@ -111,6 +111,23 @@ button { font-family: inherit; font-size: inherit; color: inherit; } .artifact-chip:hover { border-color: var(--accent); } .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 .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); } /* 过程段内联组(流式/关闭自动折叠时,按原位渲染的过程容器) */ .part-group { margin: 2px 0; } diff --git a/test/live.test.ts b/test/live.test.ts index c2549d6..50332ec 100644 --- a/test/live.test.ts +++ b/test/live.test.ts @@ -122,7 +122,8 @@ describe('liveToMessage', () => { const msg = liveToMessage(live({ items })) for (const p of msg.parts.filter(p => p.type !== 'text')) expect(p.process).toBe(true) }) - it('artifact refs land after their tool call and are process', () => { + it('deliverable cards are content parts pinned after the reply text', () => { + // 与落库序同构(llm.go 交付物钉尾):artifact_ref 在终轮正文之后、不折叠。 let items: LiveItem[] = [] items = applyTextDelta(items, 0, '生成报表。') items = applyToolCall(items, 'make_report', 'c1') @@ -130,8 +131,8 @@ describe('liveToMessage', () => { items = applyArtifactCreated(items, { artifactId: 'a1', name: 'report.csv', kind: 'csv' }) items = applyTextDelta(items, 1, '完成。') const msg = liveToMessage(live({ items })) - expect(msg.parts.map(p => p.type)).toEqual(['text', 'tool_call', 'tool_result', 'artifact_ref', 'text']) - expect(msg.parts.find(p => p.type === 'artifact_ref')).toMatchObject({ process: true }) + expect(msg.parts.map(p => p.type)).toEqual(['text', 'tool_call', 'tool_result', 'text', 'artifact_ref']) + expect(msg.parts.find(p => p.type === 'artifact_ref')?.process).toBeFalsy() // 内容部件,不折叠 expect(msg.parts.filter(p => p.type === 'text').map(p => p.process)).toEqual([true, undefined]) }) }) diff --git a/test/messageparts.test.ts b/test/messageparts.test.ts index 28d69ec..26a41bb 100644 --- a/test/messageparts.test.ts +++ b/test/messageparts.test.ts @@ -9,20 +9,31 @@ function p(partial: Partial & { type: Part['type'] }): Part { describe('groupParts(顺序保真)', () => { it('interleaves tool blocks at their original positions', () => { - // 后端 llm.go 的真实交错序:text → tool → tool_result → artifact_ref → text + // 后端 llm.go 的真实落库序(交付物钉尾):text(过程解说) → tool → tool_result → text(终轮回复) → artifact_ref const parts: Part[] = [ - p({ type: 'text', text: '先找资料' }), + p({ type: 'text', text: '先找资料', process: true }), p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }), p({ type: 'tool_result', process: true, toolResult: { callId: 'c1', status: 'ok' } }), - p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '会议纪要.docx' } }), p({ type: 'text', text: '文档已生成,请查收' }), + p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '会议纪要.docx' } }), ] const blocks = groupParts(parts) - expect(blocks.map(b => b.kind)).toEqual(['content', 'process', 'content', 'content']) - expect((blocks[0] as { part: Part }).part.text).toBe('先找资料') - expect((blocks[1] as { parts: Part[] }).parts.map(x => x.type)).toEqual(['tool_call', 'tool_result']) + 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') - expect((blocks[3] as { part: Part }).part.text).toBe('文档已生成,请查收') + }) + + it('renders deliverable cards as content in legacy inline order too', () => { + // 旧落库序(artifact_ref 夹在过程与正文之间):仍是内容部件,原位渲染不折叠。 + 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', () => {