Why: make material, background work, confirmations and files accessible to nontechnical users across desktop and mobile. Evidence: Dev SELF receipts for Frontend tsc, 190 tests, knip baseline, build, nine fixture browser cases and two real HTTP/browser smoke cases using MockRunner. Impact: existing API and permissions remain authoritative; stale events/queries and duplicate effects are guarded, while one file preview and reading state are preserved. Refs: work-companion-ui design r0.2 and plan r1 U2-U5; UI01-UI13.
259 lines
10 KiB
TypeScript
259 lines
10 KiB
TypeScript
// 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' }
|
||
|
||
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 '回复中'
|
||
}
|
||
|
||
// 默认阅读层只显示阶段;运行中也把技术记录留给用户主动展开。
|
||
export function showProcessInline(_live: LiveState | null, autoCollapse: boolean): boolean {
|
||
return !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)
|
||
}
|