Files
Agent-System-Frontend/src/live.ts
T
weichenandCursor e4ca2acfa6 Show a run as active as soon as the send returns
Why: Stamping the user message with the run id made the live bubble look settled, and the composer stayed on 发送 until the next reconcile.
Evidence: npx tsc -b passed. vitest 75, including hasSettledReply and mergeStartedRun. On a mock server the button became 停止/排队 within 250ms and the reply grew while the run was open.
Impact: Composer, live bubble, and agent tracker only. A stored user message no longer hides the stream. Reopening replays from resumeSeq when the server sends it.
Refs: Dev fix/live-run-visibility
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 15:21:28 +00:00

259 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// live 流式投影纯逻辑(中栏过程/回复分离):事件按到达序并入 items——thinking/text
// 增量各并入其末段,工具按当前位置落位。SSE 有序且后端每轮按 思考→正文→工具 追加落库
// 部件,事件到达序即落库序,故合成消息与落库部件同构,终态对账不重排。
// 文本段是否为过程(process)由「本段之后、下一个思考/文本段之前是否出现工具」判定。
// 参数增量(编写中)一到即算工具,解说不用等工具真正开始执行才离开回复区。
import type { Message, Part } from './contracts'
import type { LiveState } from './store'
export type ToolLiveState = 'composing' | 'running' | 'done'
// 到达序部件流的元素。
export type LiveItem =
| { kind: 'thinking'; text: string; startedAt: number; endedAt: number }
| { kind: 'text'; index: number; text: string }
| {
kind: 'tool'
callId?: string
index?: number
name: string
args: string
state: ToolLiveState
startedAt: number
endedAt: number
output: string
status?: string
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' }>
const OUTPUT_TAIL = 4096
function isTool(it: LiveItem): it is LiveTool {
return it.kind === 'tool'
}
// applyThinkingDelta 并入思考增量:末段是思考则追加,否则开启新思考段(新轮开始)。
// now 供测试固定首尾时间;缺省用墙上时钟,durationMs = endedAt - startedAt。
export function applyThinkingDelta(items: LiveItem[], delta: string, now = Date.now()): 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, startedAt: last.startedAt, endedAt: now }
return out
}
return [...items, { kind: 'thinking', text: delta, startedAt: now, endedAt: now }]
}
// 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 }]
}
// applyToolCallDelta 并入正在编写的参数。同 index(或同 callId)的编写中条目追加,否则新开一条。
export function applyToolCallDelta(items: LiveItem[], index: number, delta: string, callId?: string, name?: string, now = Date.now()): LiveItem[] {
for (let i = items.length - 1; i >= 0; i--) {
const it = items[i]
if (!isTool(it) || it.state !== 'composing') continue
if (it.index !== index && !(callId && it.callId === callId)) continue
const out = items.slice()
out[i] = {
...it,
args: it.args + delta,
callId: callId || it.callId,
name: name || it.name,
endedAt: now,
}
return out
}
return [...items, {
kind: 'tool', index, callId, name: name || '工具', args: delta,
state: 'composing', startedAt: now, endedAt: now, output: '',
}]
}
// applyToolCall 把一条调用标成运行中。优先按 callId 接上编写中的条目,否则接第一条编写中的条目。
export function applyToolCall(items: LiveItem[], name: string, callId?: string, args?: string, now = 0): LiveItem[] {
let idx = -1
if (callId) {
for (let i = 0; i < items.length; i++) {
const it = items[i]
if (isTool(it) && it.state === 'composing' && it.callId === callId) { idx = i; break }
}
}
if (idx < 0) {
for (let i = 0; i < items.length; i++) {
const it = items[i]
if (isTool(it) && it.state === 'composing') { idx = i; break }
}
}
if (idx >= 0) {
const it = items[idx] as LiveTool
const out = items.slice()
out[idx] = {
...it,
name: name || it.name,
callId: callId || it.callId,
args: args || it.args,
state: 'running',
startedAt: now || it.startedAt,
endedAt: now,
}
return out
}
return [...items, {
kind: 'tool', callId, name, args: args ?? '', state: 'running',
startedAt: now, endedAt: now, output: '',
}]
}
// applyToolOutput 把执行中的输出追加到对应工具,只保留尾部,避免长日志撑住内存。
export function applyToolOutput(items: LiveItem[], callId: string | undefined, text: string): LiveItem[] {
for (let i = items.length - 1; i >= 0; i--) {
const it = items[i]
if (!isTool(it) || it.state === 'done') continue
if (callId && it.callId && it.callId !== callId) continue
let output = it.output + text
if (output.length > OUTPUT_TAIL) output = output.slice(output.length - OUTPUT_TAIL)
const out = items.slice()
out[i] = { ...it, output }
return out
}
return items
}
// applyToolResult 回填对应工具(优先按 callId 配对,回落到最近一个未决工具)。
export function applyToolResult(items: LiveItem[], callId: string | undefined, status: string, summary?: string, now = 0): LiveItem[] {
for (let i = items.length - 1; i >= 0; i--) {
const it = items[i]
if (!isTool(it) || it.state === 'done') continue
if (callId && it.callId && it.callId !== callId) continue
const out = items.slice()
out[i] = { ...it, callId: it.callId, name: it.name, status, summary, state: 'done', endedAt: now || it.endedAt }
return out
}
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 }]
}
export function applyArtifactUpdated(items: LiveItem[], artifact: { artifactId: string; name: string; kind?: string }): LiveItem[] {
const out = items.slice()
for (let i = out.length - 1; i >= 0; i--) {
const it = out[i]
if (it.kind === 'artifact' && it.artifact.artifactId === artifact.artifactId) {
out[i] = { kind: 'artifact', artifact: { ...it.artifact, ...artifact } }
return out
}
}
return [...out, { kind: 'artifact', artifact }]
}
// 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[] = []
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({
type: 'thinking', text: it.text, process: true,
...(durationMs > 0 ? { durationMs } : {}),
})
} else if (it.kind === 'tool') {
const durationMs = it.state === 'done' ? it.endedAt - it.startedAt : 0
const call = {
id: it.callId ?? '',
name: it.name,
...(it.args ? { arguments: it.args } : {}),
...(durationMs > 0 ? { durationMs } : {}),
}
parts.push({
type: 'tool_call', process: true, toolCall: call,
toolPhase: it.state, ...(it.output ? { toolOutput: it.output } : {}),
} as Part)
if (it.state === 'done') {
parts.push({
type: 'tool_result', process: true, toolCall: { id: call.id, name: call.name },
toolResult: { callId: call.id, status: it.status === 'error' ? 'error' : 'ok', summary: it.summary },
})
}
} else {
parts.push({ type: 'text', text: it.text, process: textProcess.get(i) || undefined })
}
}
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--) {
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') {
if (last.state === 'composing') return `正在编写 ${last.name}`
if (last.state === 'running') {
const s = Math.max(0, Math.floor((now - last.startedAt) / 1000))
const m = Math.floor(s / 60)
const clock = m > 0 ? `${m} 分 ${s % 60} 秒` : `${s} 秒`
return `正在运行 ${last.name} · ${clock}`
}
return '回复中'
}
return '回复中'
}
// showProcessInline:运行中时间线始终展开。run 结束后随「自动折叠过程」偏好收成一行。
export function showProcessInline(live: LiveState | null, autoCollapse: boolean): boolean {
return live != null || !autoCollapse
}
// hasSettledReply:助手回复落库后,这个 run 的实时气泡才该收起。
// 用户消息也会带同一个 runId,不能拿它判断回复已经落库。
export function hasSettledReply(messages: { role: string; runId?: string }[], runId: string): boolean {
return messages.some(m => m.role === 'assistant' && m.runId === runId)
}