Files
ycode/src/shared/message-parts.ts
T

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
}
}