Merge pull request 'Render chat deliverable cards and pin live artifact projection' (#25) from feature/deliverable-docx-r1 into main
This commit was merged in pull request #25.
This commit is contained in:
@@ -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 (
|
||||
<span className="artifact-chip" data-artifact-chip={a?.artifactId}>
|
||||
<button className="ac-main" title={`预览 ${name}(右栏,边聊边看)`} onClick={() => { if (art) store.requestPreview(art) }}>
|
||||
<Icon name={icon} size={14} /> {name}
|
||||
</button>
|
||||
<button className="ac-act" title={`全屏预览 ${name}`}
|
||||
onClick={() => { if (art) store.requestPreview(art, 'full') }}><Icon name="eye" size={13} /></button>
|
||||
<a className="ac-act" title={`下载 ${name}`} href={api.artifactDownloadUrl(a?.artifactId ?? '')}
|
||||
download={name} onClick={e => e.stopPropagation()}><Icon name="download" size={13} /></a>
|
||||
</span>
|
||||
<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 '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<string, ActivityCard> = {}
|
||||
function findCard(id: string) {
|
||||
|
||||
+7
-2
@@ -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: '' }
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
+4
-3
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,20 +9,31 @@ function p(partial: Partial<Part> & { 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', () => {
|
||||
|
||||
Reference in New Issue
Block a user