Merge pull request 'Show the final reply apart from the work timeline' (#36) from feature/final-reply-compose into main
This commit was merged in pull request #36.
This commit is contained in:
@@ -216,8 +216,8 @@ function AssistantMessage({ msg, live }: { msg: Message; live?: LiveState | null
|
||||
useEffect(() => {
|
||||
if (live && msg.runId && !touchedProcess.has(msg.runId)) setOpen(true)
|
||||
}, [live, msg.runId])
|
||||
const extras = citedArtifactCards(split.final)
|
||||
const cards = dedupeDeliverables(split.final)
|
||||
const extras = live ? [] : citedArtifactCards(split.final)
|
||||
const cards = live ? [] : dedupeDeliverables(split.final)
|
||||
const lastText = (() => {
|
||||
for (let i = split.final.length - 1; i >= 0; i--) if (split.final[i].type === 'text') return i
|
||||
return -1
|
||||
|
||||
@@ -33,6 +33,10 @@ export function ProcessTimeline({ parts, live, idKey, renderPart }: {
|
||||
if (row.kind === 'text') {
|
||||
return <div className="part" key={`${idKey}:tx:${i}`}>{renderPart({ type: 'text', text: row.text }, false)}</div>
|
||||
}
|
||||
if (row.kind === 'check') {
|
||||
const text = row.passed ? '终轮检查通过' : `终轮检查未通过:${row.reason}`
|
||||
return <div className="tl-check" key={`${idKey}:ck:${i}`}>{text}</div>
|
||||
}
|
||||
if (row.kind === 'toolblock') {
|
||||
return <ToolCallBlock key={`${idKey}:tb:${i}`} item={row.item} />
|
||||
}
|
||||
|
||||
+5
-3
@@ -23,7 +23,7 @@ export type Role = 'user' | 'assistant' | 'system';
|
||||
|
||||
export type PartType =
|
||||
| 'text' | 'thinking' | 'tool_call' | 'tool_result' | 'artifact_ref'
|
||||
| 'hitl' | 'plan' | 'error' | 'card';
|
||||
| 'hitl' | 'plan' | 'error' | 'card' | 'check';
|
||||
|
||||
export interface Part {
|
||||
type: PartType;
|
||||
@@ -35,6 +35,7 @@ export interface Part {
|
||||
plan?: PlanSnapshot;
|
||||
card?: { kind: string; data: UINode[]; cardId?: ID }; // cardId 非空 = 收件箱卡片引用部件(submit_card)
|
||||
error?: { code: string; message: string };
|
||||
check?: { passed: boolean; reason?: string }; // 终轮自动检查,过程区一行
|
||||
process?: boolean; // run 终态后折叠为摘要行
|
||||
durationMs?: number; // thinking 部件的思考时长(毫秒);工具耗时在 toolCall.durationMs
|
||||
}
|
||||
@@ -48,7 +49,7 @@ export type RunKind = 'conversation' | 'subagent' | 'tool';
|
||||
export type RunStatus =
|
||||
| 'pending' | 'running' | 'waiting_hitl' | 'completed'
|
||||
| 'failed' | 'cancelled' | 'interrupted';
|
||||
export type RunPhase = 'thinking' | 'acting' | 'responding' | 'done';
|
||||
export type RunPhase = 'thinking' | 'acting' | 'responding' | 'finalizing' | 'done';
|
||||
|
||||
export interface Usage {
|
||||
promptTokens: number; completionTokens: number; totalTokens: number;
|
||||
@@ -82,7 +83,7 @@ export interface TodoList {
|
||||
export type StreamEventType =
|
||||
| 'message.delta' | 'thinking.delta'
|
||||
| 'tool.call.delta' | 'tool.call' | 'tool.output' | 'tool.result'
|
||||
| 'run.phase' | 'hitl.request' | 'hitl.resolved' | 'plan.update' | 'todo.update'
|
||||
| 'run.phase' | 'run.check' | 'hitl.request' | 'hitl.resolved' | 'plan.update' | 'todo.update'
|
||||
| 'agent.spawn' | 'agent.progress' | 'agent.result'
|
||||
| 'artifact.created' | 'artifact.updated'
|
||||
| 'run.completed' | 'run.failed' | 'error'
|
||||
@@ -112,6 +113,7 @@ export interface ContextObservation {
|
||||
truncations?: string[]
|
||||
estChars: number
|
||||
}
|
||||
export interface RunCheckPayload { passed: boolean; reason?: string }
|
||||
export interface RunFailedPayload { code: string; message: string }
|
||||
|
||||
// ---- 收件箱卡片 ----
|
||||
|
||||
+25
-5
@@ -26,6 +26,8 @@ export type LiveItem =
|
||||
summary?: string
|
||||
}
|
||||
| { kind: 'artifact'; artifact: { artifactId: string; name: string; kind?: string } }
|
||||
| { kind: 'check'; passed: boolean; reason: string }
|
||||
| { kind: 'final' }
|
||||
|
||||
export type LiveTool = Extract<LiveItem, { kind: 'tool' }>
|
||||
|
||||
@@ -143,6 +145,16 @@ export function applyToolResult(items: LiveItem[], callId: string | undefined, s
|
||||
return items
|
||||
}
|
||||
|
||||
export function applyCheck(items: LiveItem[], passed: boolean, reason: string): LiveItem[] {
|
||||
return [...items, { kind: 'check', passed, reason }]
|
||||
}
|
||||
|
||||
// applyFinalizing 标记收尾开始:此前的正文都是过程,此后的正文才是给用户的回复。
|
||||
export function applyFinalizing(items: LiveItem[]): LiveItem[] {
|
||||
if (items.some(it => it.kind === 'final')) return items
|
||||
return [...items, { kind: 'final' }]
|
||||
}
|
||||
|
||||
export function applyArtifactCreated(items: LiveItem[], artifact: { artifactId: string; name: string; kind?: string }): LiveItem[] {
|
||||
return [...items, { kind: 'artifact', artifact }]
|
||||
}
|
||||
@@ -161,17 +173,26 @@ export function applyArtifactUpdated(items: LiveItem[], artifact: { artifactId:
|
||||
|
||||
// liveToMessage:run 进行中没有已落库的助手消息,把 LiveState 合成同构临时消息。
|
||||
export function liveToMessage(live: LiveState): Message {
|
||||
const finalAt = live.items.findIndex(it => it.kind === 'final')
|
||||
const textProcess = new Map<number, boolean>()
|
||||
for (let i = 0; i < live.items.length; i++) {
|
||||
if (live.items[i].kind !== 'text') continue
|
||||
if (finalAt >= 0) {
|
||||
textProcess.set(i, i < finalAt)
|
||||
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 === 'final' || it.kind === 'artifact') continue
|
||||
if (it.kind === 'check') {
|
||||
parts.push({ type: 'check', process: true, check: { passed: it.passed, reason: it.reason || undefined } })
|
||||
continue
|
||||
}
|
||||
if (it.kind === 'thinking') {
|
||||
const durationMs = it.endedAt - it.startedAt
|
||||
if (it.text) parts.push({
|
||||
@@ -196,21 +217,20 @@ export function liveToMessage(live: LiveState): Message {
|
||||
toolResult: { callId: call.id, status: it.status === 'error' ? 'error' : 'ok', summary: it.summary },
|
||||
})
|
||||
}
|
||||
} else if (it.kind === 'artifact') {
|
||||
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: '' }
|
||||
}
|
||||
|
||||
// livePhaseLabel 用最后一个实时条目给出中文阶段,而不是后端的英文 phase。
|
||||
export function livePhaseLabel(live: LiveState, now = Date.now()): string {
|
||||
if (live.phase === 'finalizing') return '整理回复中'
|
||||
let last: LiveItem | undefined
|
||||
for (let i = live.items.length - 1; i >= 0; i--) {
|
||||
if (live.items[i].kind !== 'artifact') { last = live.items[i]; break }
|
||||
const kind = live.items[i].kind
|
||||
if (kind !== 'artifact' && kind !== 'check' && kind !== 'final') { last = live.items[i]; break }
|
||||
}
|
||||
if (!last || last.kind === 'thinking') return '思考中'
|
||||
if (last.kind === 'tool') {
|
||||
|
||||
+10
-2
@@ -2,7 +2,7 @@
|
||||
// useSyncExternalStore 驱动;live 流式部件为会话内临时态,run 终态后由消息树对账收敛。
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { api, connectEvents } from './api'
|
||||
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, type LiveItem } from './live'
|
||||
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
|
||||
import { closePreviewTab, openPreviewTab, type PreviewTab } from './previewtabs'
|
||||
import { enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox'
|
||||
import { flashElement } from './scrollsync'
|
||||
@@ -376,7 +376,15 @@ export const store = {
|
||||
}
|
||||
case 'run.phase': {
|
||||
const p = e.payload as { phase: string }
|
||||
set({ live: touchLive(e.runId ?? '', l => { l.phase = p.phase }) })
|
||||
set({ live: touchLive(e.runId ?? '', l => {
|
||||
l.phase = p.phase
|
||||
if (p.phase === 'finalizing') l.items = applyFinalizing(l.items)
|
||||
}) })
|
||||
break
|
||||
}
|
||||
case 'run.check': {
|
||||
const p = e.payload as { passed?: boolean; reason?: string }
|
||||
set({ live: touchLive(e.runId ?? '', l => { l.items = applyCheck(l.items, !!p.passed, p.reason ?? '') }) })
|
||||
break
|
||||
}
|
||||
case 'hitl.request': {
|
||||
|
||||
@@ -191,6 +191,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.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-check { color: var(--text-faint); font-size: var(--fs-ui-sm); margin: 4px 0; }
|
||||
.tl-tools .status-error { margin-left: 8px; color: var(--err); }
|
||||
|
||||
/* 对话右侧预览区 */
|
||||
|
||||
@@ -104,6 +104,7 @@ export type TimelineEntry =
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'tools'; toolKind: ToolKind; items: ToolGroupItem[] }
|
||||
| { kind: 'toolblock'; toolKind: ToolKind; item: ToolGroupItem }
|
||||
| { kind: 'check'; passed: boolean; reason: string }
|
||||
| { kind: 'other'; part: Part }
|
||||
|
||||
const SOLO: ReadonlySet<ToolKind> = new Set(['exec', 'write', 'delegate'])
|
||||
@@ -169,6 +170,11 @@ export function timelineEntries(parts: Part[], live: boolean): TimelineEntry[] {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (p.type === 'check') {
|
||||
out.push({ kind: 'check', passed: !!p.check?.passed, reason: p.check?.reason ?? '' })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
out.push({ kind: 'other', part: p })
|
||||
i++
|
||||
}
|
||||
|
||||
+14
-4
@@ -3,6 +3,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated,
|
||||
applyCheck, applyFinalizing,
|
||||
liveToMessage, livePhaseLabel, showProcessInline, type LiveItem,
|
||||
} from '../src/live'
|
||||
import type { LiveState } from '../src/store'
|
||||
@@ -135,8 +136,7 @@ describe('liveToMessage', () => {
|
||||
const msg = liveToMessage(live({ items }))
|
||||
for (const p of msg.parts.filter(p => p.type !== 'text')) expect(p.process).toBe(true)
|
||||
})
|
||||
it('deliverable cards are content parts pinned after the reply text', () => {
|
||||
// 与落库序同构(llm.go 交付物钉尾):artifact_ref 在终轮正文之后、不折叠。
|
||||
it('does not project deliverable cards while the run is live', () => {
|
||||
let items: LiveItem[] = []
|
||||
items = applyTextDelta(items, 0, '生成报表。')
|
||||
items = applyToolCall(items, 'make_report', 'c1')
|
||||
@@ -144,10 +144,19 @@ 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', 'text', 'artifact_ref'])
|
||||
expect(msg.parts.find(p => p.type === 'artifact_ref')?.process).toBeFalsy() // 内容部件,不折叠
|
||||
expect(msg.parts.some(p => p.type === 'artifact_ref')).toBe(false)
|
||||
expect(msg.parts.filter(p => p.type === 'text').map(p => p.process)).toEqual([true, undefined])
|
||||
})
|
||||
it('moves earlier text into the process area once finalizing starts', () => {
|
||||
let items: LiveItem[] = applyTextDelta([], 0, '草稿回复')
|
||||
items = applyCheck(items, false, '纸张不是 A4')
|
||||
items = applyFinalizing(items)
|
||||
items = applyTextDelta(items, 1, '纪要已写好。')
|
||||
const msg = liveToMessage(live({ phase: 'finalizing', items }))
|
||||
expect(msg.parts.map(p => p.type)).toEqual(['text', 'check', 'text'])
|
||||
expect(msg.parts.filter(p => p.type === 'text').map(p => p.process)).toEqual([true, undefined])
|
||||
expect(msg.parts[1]).toMatchObject({ type: 'check', process: true, check: { passed: false, reason: '纸张不是 A4' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('livePhaseLabel', () => {
|
||||
@@ -161,6 +170,7 @@ describe('livePhaseLabel', () => {
|
||||
items = applyToolResult(items, 'c', 'ok', 'done', 4000)
|
||||
items = applyTextDelta(items, 1, '好了')
|
||||
expect(livePhaseLabel(live({ items }), 4000)).toBe('回复中')
|
||||
expect(livePhaseLabel(live({ items, phase: 'finalizing' }), 4000)).toBe('整理回复中')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -54,6 +54,17 @@ describe('timelineEntries', () => {
|
||||
expect(exec.item.headline).toBe('print(1)')
|
||||
})
|
||||
|
||||
it('renders a check row instead of narration', () => {
|
||||
const rows = timelineEntries([
|
||||
p({ type: 'check', process: true, check: { passed: false, reason: '纸张不是 A4' } }),
|
||||
p({ type: 'check', process: true, check: { passed: true } }),
|
||||
], false)
|
||||
expect(rows).toEqual([
|
||||
{ kind: 'check', passed: false, reason: '纸张不是 A4' },
|
||||
{ kind: 'check', passed: true, reason: '' },
|
||||
])
|
||||
})
|
||||
|
||||
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 })
|
||||
|
||||
Reference in New Issue
Block a user