import type { ChatArtifactAttachment, ChatTimelineArtifactItem, ChatTimelineItem, ChatTimelineItemBase, ChatTimelineKnowledgeHit, ChatTimelineMessageItem, ChatTimelineMessagePart, ChatTimelineSkillInvocationStatus, ChatTimelineStatusItem, ChatTimelineStatusStatus, ChatTimelineStatusTone, ChatTimelineThinkingStatus, ChatTimelineToolApprovalPayload, ChatTimelineToolItem, ChatTimelineToolMode, ChatTimelineToolStatus, } from './types'; type ChatTimelineTurnMetadata = Partial< Pick< ChatTimelineItemBase, | 'roundCompleted' | 'roundId' | 'turnFinishedAt' | 'turnStartedAt' | 'turnSucceeded' > >; function createId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; } function normalizeText(value: unknown) { return value === null || value === undefined ? '' : String(value); } function normalizePayloadValue(value: unknown) { if (value === null || value === undefined || value === '') { return undefined; } if (typeof value === 'string') { return value; } return value; } function normalizeToolName(value?: string) { return normalizeText(value).trim().toLowerCase(); } function isHiddenToolName(toolName?: string) { const normalizedName = normalizeToolName(toolName); return ( normalizedName === 'retrieve_knowledge' || normalizedName === 'context_reload' || normalizedName === '__fragment__' ); } function isKnowledgeRetrievalToolName(toolName?: string) { return normalizeToolName(toolName) === 'retrieve_knowledge'; } function isBlankToolName(toolName?: string) { return !normalizeToolName(toolName); } function knowledgeRetrievalStatusKey(statusKey?: string) { return normalizeText(statusKey).trim() || 'knowledge-retrieval'; } function ensureMessageTail( items: ChatTimelineItem[], role: ChatTimelineMessageItem['role'], status: ChatTimelineMessageItem['status'] = 'streaming', metadata?: Partial, ) { const last = items[items.length - 1]; if ( last?.type === 'message' && last.role === role && last.status !== 'done' && (!metadata?.roundId || last.roundId === metadata.roundId) ) { last.status = status; Object.assign(last, metadata); return last; } const placeholder = role === 'assistant' && metadata?.roundId ? [...items] .reverse() .find( (item): item is ChatTimelineMessageItem => item.type === 'message' && item.role === 'assistant' && item.roundId === metadata.roundId && item.status !== 'done' && item.parts.length === 0, ) : undefined; if (placeholder) { placeholder.status = status; Object.assign(placeholder, metadata); return placeholder; } const item: ChatTimelineMessageItem = { id: createId(role), role, status, createdAt: Date.now(), parts: [], type: 'message', ...metadata, }; items.push(item); return item; } function appendMessagePart( message: ChatTimelineMessageItem, part: ChatTimelineMessagePart, ) { const tail = message.parts[message.parts.length - 1]; if (tail?.type === part.type) { tail.content += part.content; if (tail.type === 'thinking' && part.type === 'thinking') { tail.status = part.status; } return; } message.parts.push(part); } function appendThinkingPart( message: ChatTimelineMessageItem, part: Extract, ) { appendMessagePart(message, part); } function appendTextPart(message: ChatTimelineMessageItem, content: string) { appendMessagePart(message, { id: createId('text'), content, type: 'text', }); } function replaceTextPart(message: ChatTimelineMessageItem, content: string) { message.parts = [ ...message.parts.filter((part) => part.type !== 'text'), { id: createId('text'), content, type: 'text' as const, }, ]; } function updateThinkingStatus( message: ChatTimelineMessageItem, status: ChatTimelineThinkingStatus, ) { message.parts = message.parts.map((part) => part.type === 'thinking' && part.status === 'thinking' ? { ...part, expanded: status === 'thinking', status } : part, ); } function finishAssistantMessage( items: ChatTimelineItem[], roundCompleted: boolean, roundId?: string, ) { const lastMessage = [...items] .reverse() .find( (item): item is ChatTimelineMessageItem => item.type === 'message' && item.role === 'assistant' && (!roundId || item.roundId === roundId), ); if (!lastMessage) { return; } updateThinkingStatus(lastMessage, 'end'); lastMessage.status = lastMessage.status === 'error' ? 'error' : 'done'; if (lastMessage.status === 'done' && roundCompleted) { lastMessage.roundCompleted = true; } } function findToolItem( items: ChatTimelineItem[], toolCallId?: string, taskId?: string, sourceToolCallId?: string, approvalId?: string, ) { const identities = new Set( [toolCallId, sourceToolCallId] .map((value) => normalizeText(value).trim()) .filter(Boolean), ); const normalizedTaskId = normalizeText(taskId).trim(); const normalizedApprovalId = normalizeText(approvalId).trim(); if (identities.size === 0 && !normalizedTaskId && !normalizedApprovalId) { return undefined; } return items.find( (item): item is ChatTimelineToolItem => item.type === 'tool' && ((Boolean(normalizedApprovalId) && item.approval?.approvalId === normalizedApprovalId) || (Boolean(normalizedTaskId) && item.taskId === normalizedTaskId) || (item.toolCallId ? identities.has(item.toolCallId) : false)), ); } function findStatusItem(items: ChatTimelineItem[], statusKey: string) { return items.find( (item): item is ChatTimelineStatusItem => item.type === 'status' && item.statusKey === statusKey, ); } function resolveStatusOccurrenceKey( items: ChatTimelineItem[], statusKey: string, roundId?: string, ) { // 同一 Turn 可多次触发整理;只复用仍在运行的实例,避免改写已完成的历史状态。 const occurrencePrefix = `${statusKey}::occurrence:`; const occurrences = items.filter( (item): item is ChatTimelineStatusItem => item.type === 'status' && (item.statusKey === statusKey || item.statusKey.startsWith(occurrencePrefix)) && (!roundId || item.roundId === roundId), ); const running = [...occurrences] .reverse() .find((item) => item.status === 'running'); if (running) { return running.statusKey; } return occurrences.length === 0 ? statusKey : `${occurrencePrefix}${occurrences.length + 1}`; } function removeStatusItem(items: ChatTimelineItem[], statusKey: string) { const index = items.findIndex( (item) => item.type === 'status' && item.statusKey === statusKey, ); if (index !== -1) { items.splice(index, 1); } } function doneStatusLabel(item: ChatTimelineStatusItem) { if (item.statusKey === 'knowledge-retrieval') { return '已检索知识库'; } if (item.statusKey === 'memory-compression') { return '已整理上下文'; } return item.label.replace(/^正在/, '已'); } function skillTerminalLabel( label: string, status: Extract, ) { const name = label.replace(/^正在调用\s*/, '').trim() || '技能'; return status === 'cancelled' ? `已停止调用 ${name}` : `调用 ${name} 未完成`; } function finishRunningStatusItems( items: ChatTimelineItem[], roundId?: string, skillTerminalStatus: Extract< ChatTimelineStatusStatus, 'cancelled' | 'incomplete' > = 'incomplete', ) { items.forEach((item) => { if ( item.type !== 'status' || item.status !== 'running' || (roundId && item.roundId !== roundId) ) { return; } if (item.icon === 'skill') { item.status = skillTerminalStatus; item.label = skillTerminalLabel(item.label, skillTerminalStatus); return; } item.status = 'done'; item.label = doneStatusLabel(item); }); } function skillStatusPresentation(status: ChatTimelineSkillInvocationStatus) { switch (status) { case 'CANCELLED': { return { prefix: '已停止调用', status: 'cancelled' as const, }; } case 'FAILED': { return { prefix: '调用', status: 'error' as const, suffix: '失败' }; } case 'INCOMPLETE': { return { prefix: '调用', status: 'incomplete' as const, suffix: '未完成', }; } case 'RUNNING': { return { prefix: '正在调用', status: 'running' as const }; } case 'SUCCESS': { return { prefix: '已调用', status: 'done' as const }; } } } function upsertStatus( items: ChatTimelineItem[], payload: ChatTimelineTurnMetadata & { label: string; presentation?: ChatTimelineStatusItem['presentation']; status: ChatTimelineStatusStatus; statusKey: string; tone?: ChatTimelineStatusTone; }, ) { const found = findStatusItem(items, payload.statusKey); if (found) { found.label = payload.label; found.presentation = payload.presentation ?? found.presentation; found.status = payload.status; found.tone = payload.tone ?? found.tone; applyTurnMetadata(found, payload); return found; } const item: ChatTimelineStatusItem = { id: payload.statusKey, createdAt: Date.now(), label: payload.label, presentation: payload.presentation, status: payload.status, statusKey: payload.statusKey, tone: payload.tone ?? 'muted', type: 'status', }; applyTurnMetadata(item, payload); items.push(item); return item; } function upsertTool( items: ChatTimelineItem[], payload: ChatTimelineTurnMetadata & { approval?: ChatTimelineToolApprovalPayload; approvalId?: string; input?: unknown; mode?: ChatTimelineToolMode; output?: unknown; rejectReason?: string; sourceToolCallId?: string; status?: ChatTimelineToolStatus; taskId?: string; toolCallId?: string; toolName?: string; }, ) { const toolCallId = normalizeText( payload.toolCallId ?? payload.approval?.toolCallId, ).trim(); const taskId = normalizeText(payload.taskId).trim(); const found = findToolItem( items, toolCallId, taskId, payload.sourceToolCallId, payload.approvalId ?? payload.approval?.approvalId, ); const approval = payload.approval ?? found?.approval; const mode = payload.mode === 'approval' ? 'approval' : (found?.mode ?? payload.mode ?? (approval ? 'approval' : 'auto')); const toolName = payload.toolName || approval?.toolDisplayName || approval?.toolName || found?.toolName; if (isHiddenToolName(toolName)) { return found; } if (!found && isBlankToolName(toolName)) { return undefined; } if (found) { found.approval = approval; found.input = normalizePayloadValue(payload.input) ?? normalizePayloadValue(approval?.input) ?? found.input; found.mode = mode; found.output = normalizePayloadValue(payload.output) ?? found.output; found.rejectReason = payload.rejectReason ?? found.rejectReason; found.status = payload.status || found.status; found.taskId = taskId || found.taskId; found.toolCallId = toolCallId || found.toolCallId; found.toolName = toolName || found.toolName; applyTurnMetadata(found, payload); return found; } const toolItem: ChatTimelineToolItem = { id: toolCallId || createId('tool'), approval, createdAt: Date.now(), input: normalizePayloadValue(payload.input) ?? normalizePayloadValue(approval?.input), mode, output: normalizePayloadValue(payload.output), rejectReason: payload.rejectReason, status: payload.status || (mode === 'approval' ? 'pending_approval' : 'running'), taskId: taskId || undefined, toolCallId, toolName: toolName || '工具调用', type: 'tool', }; applyTurnMetadata(toolItem, payload); items.push(toolItem); return toolItem; } function applyTurnMetadata( item: ChatTimelineItemBase, metadata?: ChatTimelineTurnMetadata, ) { if (!metadata) { return; } if (metadata.roundId) { item.roundId = metadata.roundId; } if (metadata.roundCompleted !== undefined) { item.roundCompleted = metadata.roundCompleted; } if (metadata.turnSucceeded !== undefined) { item.turnSucceeded = metadata.turnSucceeded; } if (metadata.turnStartedAt !== undefined) { item.turnStartedAt = Math.min( item.turnStartedAt ?? metadata.turnStartedAt, metadata.turnStartedAt, ); } if (metadata.turnFinishedAt !== undefined) { item.turnFinishedAt = Math.max( item.turnFinishedAt ?? metadata.turnFinishedAt, metadata.turnFinishedAt, ); } } export const ChatTimelineBuilder = { ensureAssistantTurn( items: ChatTimelineItem[], metadata?: Partial, ) { ensureMessageTail(items, 'assistant', 'streaming', metadata); }, appendUserMessage( items: ChatTimelineItem[], content?: unknown, metadata?: Partial, ) { const text = normalizeText(content); if (!text && !metadata?.images?.length && !metadata?.documents?.length) { return; } const item: ChatTimelineMessageItem = { id: createId('user'), role: 'user', status: 'done', createdAt: Date.now(), parts: text ? [ { id: createId('text'), content: text, type: 'text' as const, }, ] : [], type: 'message', ...metadata, }; items.push(item); }, appendThinkingDelta( items: ChatTimelineItem[], delta?: unknown, metadata?: Partial, ) { const text = normalizeText(delta); if (!text) { return; } const message = ensureMessageTail( items, 'assistant', 'streaming', metadata, ); if (message.parts.some((part) => part.type === 'text')) { return; } appendThinkingPart(message, { id: createId('thinking'), content: text, expanded: true, status: 'thinking', type: 'thinking', }); }, appendMessageDelta( items: ChatTimelineItem[], delta?: unknown, metadata?: Partial, ) { const text = normalizeText(delta); if (!text) { return; } const message = ensureMessageTail( items, 'assistant', 'streaming', metadata, ); updateThinkingStatus(message, 'end'); appendTextPart(message, text); }, replaceMessageContent( items: ChatTimelineItem[], content?: unknown, metadata?: Partial, ) { const text = normalizeText(content); const message = (metadata?.id ? items.find( (item): item is ChatTimelineMessageItem => item.type === 'message' && item.id === metadata.id, ) : undefined) || [...items] .reverse() .find( (item): item is ChatTimelineMessageItem => item.type === 'message' && item.role === 'assistant' && (!metadata?.roundId || item.roundId === metadata.roundId), ) || ensureMessageTail(items, 'assistant', 'done', metadata); for (let index = items.length - 1; index >= 0; index--) { const item = items[index]; if ( item === message || item?.type !== 'message' || item.role !== 'assistant' || (metadata?.roundId && item.roundId !== metadata.roundId) ) { continue; } item.parts = item.parts.filter((part) => part.type !== 'text'); if (item.parts.length === 0) { items.splice(index, 1); } } Object.assign(message, metadata); message.status = 'done'; updateThinkingStatus(message, 'end'); replaceTextPart(message, text); }, appendToolApproval( items: ChatTimelineItem[], payload: ChatTimelineToolApprovalPayload, metadata?: ChatTimelineTurnMetadata, ) { upsertTool(items, { ...metadata, approval: payload, input: payload.input, mode: 'approval', status: 'pending_approval', toolCallId: payload.toolCallId, toolName: payload.toolDisplayName || payload.toolName, }); }, upsertToolCall( items: ChatTimelineItem[], payload: ChatTimelineTurnMetadata & { approvalId?: string; input?: unknown; output?: unknown; sourceToolCallId?: string; status?: ChatTimelineToolStatus; statusKey?: string; taskId?: string; toolCallId?: string; toolName?: string; }, ) { if (isKnowledgeRetrievalToolName(payload.toolName)) { ChatTimelineBuilder.upsertKnowledgeRetrievalStatus( items, payload.status === 'success' ? 'done' : 'running', payload.statusKey, payload, ); return; } upsertTool(items, { ...payload, mode: 'auto', status: payload.status || 'running', }); }, upsertKnowledgeRetrievalStatus( items: ChatTimelineItem[], status: ChatTimelineStatusStatus, statusKey?: string, metadata?: ChatTimelineTurnMetadata, ) { finishAssistantMessage(items, false, metadata?.roundId); upsertStatus(items, { ...metadata, label: status === 'running' ? '正在检索知识库' : '已检索知识库', status, statusKey: knowledgeRetrievalStatusKey(statusKey), tone: 'muted', }); }, upsertSkillInvocationStatus( items: ChatTimelineItem[], payload: ChatTimelineTurnMetadata & { displayName?: string; status: ChatTimelineSkillInvocationStatus; statusKey: string; }, ) { const displayName = normalizeText(payload.displayName).trim() || '技能'; const presentation = skillStatusPresentation(payload.status); const label = [presentation.prefix, displayName, presentation.suffix] .filter(Boolean) .join(' '); finishAssistantMessage(items, false, payload.roundId); upsertStatus(items, { ...payload, label, status: presentation.status, statusKey: payload.statusKey, tone: payload.status === 'FAILED' ? 'danger' : 'muted', }).icon = 'skill'; }, upsertMemoryCompressionStatus( items: ChatTimelineItem[], payload?: ChatTimelineTurnMetadata & { compressed?: boolean; label?: string; phase?: string; status?: string; statusKey?: string; }, ) { const status = payload?.status === 'done' || payload?.phase === 'completed' ? 'done' : 'running'; const statusKey = resolveStatusOccurrenceKey( items, payload?.statusKey || 'memory-compression', payload?.roundId, ); finishAssistantMessage(items, false, payload?.roundId); if (status === 'done' && payload?.compressed === false) { removeStatusItem(items, statusKey); return; } const label = status === 'running' ? payload?.label || '正在整理上下文' : payload?.label || '已整理上下文'; upsertStatus(items, { ...payload, label, status, statusKey, presentation: 'separator', tone: 'muted', }); }, markToolApproving( items: ChatTimelineItem[], payload: ChatTimelineTurnMetadata & { approvalId?: string; toolCallId?: string; }, ) { upsertTool(items, { ...payload, mode: 'approval', status: 'approving', }); }, markToolRejected( items: ChatTimelineItem[], payload: ChatTimelineTurnMetadata & { approvalId?: string; reason?: string; toolCallId?: string; }, ) { upsertTool(items, { ...payload, mode: 'approval', rejectReason: payload.reason, status: 'rejected', }); }, appendKnowledge( items: ChatTimelineItem[], knowledgeItems: ChatTimelineKnowledgeHit[], metadata?: ChatTimelineTurnMetadata, ) { if (knowledgeItems.length === 0) { return; } const lastAssistantMessage = [...items] .reverse() .find( (item): item is ChatTimelineMessageItem => item.type === 'message' && item.role === 'assistant' && (!metadata?.roundId || item.roundId === metadata.roundId), ); if (lastAssistantMessage) { applyTurnMetadata(lastAssistantMessage, metadata); lastAssistantMessage.knowledgeItems = [ ...(lastAssistantMessage.knowledgeItems || []), ...knowledgeItems, ]; return; } const last = items[items.length - 1]; if ( last?.type === 'knowledge' && (!metadata?.roundId || last.roundId === metadata.roundId) ) { applyTurnMetadata(last, metadata); last.items.push(...knowledgeItems); return; } const item = { id: createId('knowledge'), createdAt: Date.now(), items: knowledgeItems, type: 'knowledge' as const, }; applyTurnMetadata(item, metadata); items.push(item); }, upsertArtifact( items: ChatTimelineItem[], artifact: ChatArtifactAttachment, metadata?: ChatTimelineTurnMetadata, ) { const artifactId = normalizeText(artifact.artifactId).trim(); const fileName = normalizeText(artifact.fileName).trim(); if (!artifactId || !fileName) { return; } const existing = items.find( (item): item is ChatTimelineArtifactItem => item.type === 'artifact' && item.artifactId === artifactId, ); if (existing) { existing.downloadUrl = artifact.downloadUrl; existing.fileName = fileName; existing.mimeType = artifact.mimeType; existing.sha256 = artifact.sha256; existing.size = artifact.size; existing.status = artifact.status; applyTurnMetadata(existing, metadata); return; } const item = { artifactId, createdAt: Date.now(), downloadUrl: artifact.downloadUrl, fileName, id: `artifact:${artifactId}`, mimeType: artifact.mimeType, sha256: artifact.sha256, size: artifact.size, status: artifact.status, type: 'artifact' as const, }; applyTurnMetadata(item, metadata); items.push(item); }, appendError( items: ChatTimelineItem[], message?: unknown, metadata?: ChatTimelineTurnMetadata, ) { const text = normalizeText(message) || '请求失败'; const last = [...items] .reverse() .find( (item): item is ChatTimelineMessageItem => item.type === 'message' && item.role === 'assistant' && (!metadata?.roundId || item.roundId === metadata.roundId), ); if (last) { updateThinkingStatus(last, 'error'); last.status = 'error'; } const item = { id: createId('error'), createdAt: Date.now(), message: text, type: 'error' as const, }; applyTurnMetadata(item, metadata); items.push(item); }, finalize( items: ChatTimelineItem[], metadata?: ChatTimelineTurnMetadata, options?: { runningSkillStatus?: Extract< ChatTimelineStatusStatus, 'cancelled' | 'incomplete' >; }, ) { finishRunningStatusItems( items, metadata?.roundId, options?.runningSkillStatus, ); finishAssistantMessage( items, metadata?.turnSucceeded ?? true, metadata?.roundId, ); if (metadata?.roundId) { for (const item of items) { if (item.roundId === metadata.roundId) { applyTurnMetadata(item, metadata); } } } }, replaceRoundAssistant( items: ChatTimelineItem[], roundId: string, message: ChatTimelineMessageItem, ) { const targetIndex = items.findIndex( (item): item is ChatTimelineMessageItem => item.type === 'message' && item.role === 'assistant' && item.roundId === roundId, ); if (targetIndex !== -1) { items.splice(targetIndex, 1, message); } }, };