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', () => {