Why: tool artifacts were only reachable via the collapsed process section or the artifacts workspace; users had to read model text to learn a file existed. Deliverables should appear directly in the conversation as an openable card (ZCode/Kimi pattern) that opens the right-rail preview. Evidence: - Conversation.tsx artifact_ref renderer upgraded from a small chip to a deliverable card (icon + name + kind/size meta + preview affordance + single download action); whole card click = rail preview (the one preview placement, fullscreen still reachable from the rail header) - live.ts projects artifact items as content parts pinned after the reply text, isomorphic with the new llm.go persisted order; card is visible while streaming - styles: deliverable-card uses tokens only (radius-lg card, bg-raised, hairline border, mk-rise-in entrance); design-guard suite green - vitest 40/40 (live.test pinned-tail case updated, messageparts.test covers new persisted order plus legacy inline order); tsc clean Impact: every generated artifact (docx/pdf/image/…) now surfaces as an in-chat card immediately, click-to-preview in the right rail with real formatting for styled docx; workspace panel (⌘1) unchanged as the file-system fallback. Refs: task feature/deliverable-docx-r1 (backend: write_docx + pinned artifact_ref)
106 lines
5.6 KiB
TypeScript
106 lines
5.6 KiB
TypeScript
// live 流式投影纯逻辑(中栏过程/回复分离):事件按到达序并入 items——thinking/text
|
||
// 增量各并入其末段,工具按当前位置落位。SSE 有序且后端每轮按 思考→正文→工具 追加落库
|
||
// 部件,事件到达序即落库序,故合成消息与落库部件同构,终态对账不重排。
|
||
// 文本段是否为过程(process)由「本段之后、下一个思考/文本段之前是否出现工具」判定,
|
||
// 对齐落库的 Process: len(calls) > 0——工具轮解说的 tool.call 一到即并入过程区,
|
||
// 无需等下一轮文本段开启。
|
||
// 残余边界:某轮若既无正文也无思考、仅静默发起工具,其调用会被并入上一文本段(需协议
|
||
// 携带轮次归属才能消除;现行事件流无法区分)。
|
||
import type { Message, Part } from './contracts'
|
||
import type { LiveState } from './store'
|
||
|
||
// 到达序部件流的元素。
|
||
export type LiveItem =
|
||
| { kind: 'thinking'; text: string }
|
||
| { 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[] {
|
||
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 }
|
||
return out
|
||
}
|
||
return [...items, { kind: 'thinking', text: delta }]
|
||
}
|
||
|
||
// applyTextDelta 并入正文增量:末段是同 index 的文本段则追加,否则开启新文本段。
|
||
export function applyTextDelta(items: LiveItem[], index: number, delta: string): LiveItem[] {
|
||
const last = items[items.length - 1]
|
||
if (last && last.kind === 'text' && last.index === index) {
|
||
const out = items.slice()
|
||
out[out.length - 1] = { kind: 'text', index, text: last.text + delta }
|
||
return out
|
||
}
|
||
return [...items, { kind: 'text', index, text: delta }]
|
||
}
|
||
|
||
export function applyToolCall(items: LiveItem[], name: string, callId?: string): LiveItem[] {
|
||
return [...items, { kind: 'tool', callId, name }]
|
||
}
|
||
|
||
// applyArtifactCreated 追加工件引用(artifact.created 事件)。投影时作为
|
||
// 交付物钉在消息尾部(内容部件,不进过程折叠)——与落库序同构。
|
||
export function applyArtifactCreated(items: LiveItem[], artifact: { artifactId: string; name: string; kind?: string }): LiveItem[] {
|
||
return [...items, { kind: 'artifact', artifact }]
|
||
}
|
||
|
||
// applyToolResult 回填对应工具(优先按 callId 配对,回落到最近一个未决工具——
|
||
// 与落库的尾部配对语义一致)。
|
||
export function applyToolResult(items: LiveItem[], callId: string | undefined, status: string, summary?: string): LiveItem[] {
|
||
for (let i = items.length - 1; i >= 0; i--) {
|
||
const it = items[i]
|
||
if (it.kind !== 'tool' || it.status !== undefined) continue
|
||
if (callId && it.callId && it.callId !== callId) continue
|
||
const out = items.slice()
|
||
out[i] = { kind: 'tool', callId: it.callId, name: it.name, status, summary }
|
||
return out
|
||
}
|
||
return items
|
||
}
|
||
|
||
// liveToMessage:run 进行中没有已落库的助手消息,把 LiveState 合成同构临时消息,
|
||
// 复用 AssistantMessage 渲染(思考/工具/文本实时上屏);终态对账后由真消息替代。
|
||
export function liveToMessage(live: LiveState): Message {
|
||
// 文本段是否为过程:本段之后、下一个思考/文本段之前出现工具(= 该轮带工具调用)。
|
||
const textProcess = new Map<number, boolean>()
|
||
for (let i = 0; i < live.items.length; i++) {
|
||
if (live.items[i].kind !== 'text') continue
|
||
let hasCalls = false
|
||
for (let j = i + 1; j < live.items.length && live.items[j].kind === 'tool'; j++) hasCalls = true
|
||
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') {
|
||
if (it.text) parts.push({ type: 'thinking', text: it.text, process: true })
|
||
} else if (it.kind === 'tool') {
|
||
parts.push({ type: 'tool_call', process: true, toolCall: { id: it.callId ?? '', name: it.name } })
|
||
if (it.status !== undefined) {
|
||
parts.push({ type: 'tool_result', process: true, toolCall: { id: it.callId ?? '', name: it.name },
|
||
toolResult: { callId: it.callId ?? '', status: it.status === 'error' ? 'error' : 'ok', summary: it.summary } })
|
||
}
|
||
} else if (it.kind === '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: '' }
|
||
}
|
||
|
||
// showProcessInline:流式期间过程区是否展开——thinking/acting 实时展开;一旦进入
|
||
// responding(最终回复开始流式)立即折叠,只显示回复(Cursor 式),工具恢复再展开。
|
||
// 非 live 消息随「自动折叠过程」偏好。
|
||
export function showProcessInline(live: LiveState | null, autoCollapse: boolean): boolean {
|
||
return live ? live.phase !== 'responding' : !autoCollapse
|
||
}
|