97 lines
4.5 KiB
TypeScript
97 lines
4.5 KiB
TypeScript
import type { ChatEvent, ChatMessage, MessagePart, ToolCallRecord } from './types'
|
|
|
|
/** 老会话只有合并正文与工具数组;不臆造其已经丢失的发生顺序。 */
|
|
export function getMessageParts(message: ChatMessage): MessagePart[] {
|
|
if (message.parts?.length) return message.parts
|
|
return [
|
|
...(message.content ? [{ type: 'text' as const, text: message.content }] : []),
|
|
...(message.tools ?? []).map((tool) => ({ type: 'tool' as const, toolId: tool.id }))
|
|
]
|
|
}
|
|
|
|
/** 主进程持久化和渲染进程使用同一事件归约,避免重载前后内容顺序不同。 */
|
|
export function applyAssistantEvent(message: ChatMessage, event: ChatEvent): ChatMessage {
|
|
switch (event.type) {
|
|
case 'part': {
|
|
const parts = [...getMessageParts(message)]
|
|
const identity = (part: MessagePart): string | undefined => {
|
|
if (part.type === 'reasoning' || part.type === 'image') return `${part.type}:${part.id}`
|
|
if (part.type === 'plan') return `plan:${part.plan.id}`
|
|
if (part.type === 'task') return `task:${part.task.id}`
|
|
if (part.type === 'artifact') return `artifact:${part.artifact.id}`
|
|
return undefined
|
|
}
|
|
const key = identity(event.part)
|
|
const index = parts.findIndex((part) => identity(part) === key)
|
|
if (index < 0) parts.push(event.part)
|
|
else parts[index] = event.part
|
|
return { ...message, parts }
|
|
}
|
|
case 'interaction': {
|
|
const interactions = [...(message.interactions ?? [])]
|
|
const index = interactions.findIndex((item) => item.id === event.interaction.id)
|
|
if (index < 0) interactions.push(event.interaction)
|
|
else interactions[index] = event.interaction
|
|
return { ...message, interactions }
|
|
}
|
|
case 'text': {
|
|
if (!event.delta) return message
|
|
const parts = getMessageParts(message).map((part) => ({ ...part }))
|
|
const last = parts.at(-1)
|
|
if (last?.type === 'text') last.text += event.delta
|
|
else parts.push({ type: 'text', text: event.delta })
|
|
return { ...message, content: message.content + event.delta, parts }
|
|
}
|
|
case 'tool_call':
|
|
case 'tool_result': {
|
|
const tools = [...(message.tools ?? [])]
|
|
const index = tools.findIndex((tool) => tool.id === event.call.id)
|
|
if (index < 0) tools.push({ ...event.call })
|
|
else tools[index] = { ...tools[index], ...event.call }
|
|
const parts = [...getMessageParts(message)]
|
|
if (!parts.some((part) => part.type === 'tool' && part.toolId === event.call.id)) {
|
|
parts.push({ type: 'tool', toolId: event.call.id })
|
|
}
|
|
return { ...message, tools, parts }
|
|
}
|
|
case 'done': {
|
|
let parts = getMessageParts(message)
|
|
const text = parts.filter((part) => part.type === 'text').map((part) => part.text).join('')
|
|
// 没有增量的回合仍能消费完整终态;正常流式回合保留已知顺序。
|
|
if (text !== event.content) {
|
|
// Correct the plain text while retaining independently ordered rich content.
|
|
const firstTextIndex = parts.findIndex((part) => part.type === 'text')
|
|
parts = parts.filter((part) => part.type !== 'text')
|
|
if (event.content) parts.splice(firstTextIndex < 0 ? 0 : Math.min(firstTextIndex, parts.length), 0, { type: 'text', text: event.content })
|
|
} else {
|
|
parts = [...parts]
|
|
}
|
|
for (const tool of event.tools) {
|
|
if (!parts.some((part) => part.type === 'tool' && part.toolId === tool.id)) {
|
|
parts.push({ type: 'tool', toolId: tool.id })
|
|
}
|
|
}
|
|
return { ...message, content: event.content, tools: event.tools, parts, status: 'done', ...terminalDuration(event) }
|
|
}
|
|
case 'cancelled':
|
|
case 'error': {
|
|
const status = event.type === 'cancelled' ? 'cancelled' : 'error'
|
|
const tools = message.tools?.map((tool): ToolCallRecord =>
|
|
tool.status === 'running' ? { ...tool, status } : tool
|
|
)
|
|
const interactions = message.interactions?.map((item) => item.status === 'pending' || item.status === 'responding' ? { ...item, status: 'cancelled' as const } : item)
|
|
return { ...message, tools, interactions, status, error: event.type === 'error' ? event.message : message.error, ...terminalDuration(event) }
|
|
}
|
|
default:
|
|
return message
|
|
}
|
|
}
|
|
|
|
function terminalDuration(event: ChatEvent): Pick<ChatMessage, 'turnDurationMs' | 'turnStartedAt'> {
|
|
return {
|
|
...(typeof event.turnDurationMs === 'number' && Number.isFinite(event.turnDurationMs) && event.turnDurationMs >= 0
|
|
? { turnDurationMs: event.turnDurationMs } : {}),
|
|
turnStartedAt: undefined
|
|
}
|
|
}
|