import type { ChatTimelineItem, ChatTimelineKnowledgeHit, ChatTimelineMessageItem, ChatTimelineMessagePart, ChatTimelineStatusItem, ChatTimelineStatusStatus, ChatTimelineStatusTone, ChatTimelineThinkingStatus, ChatTimelineToolApprovalPayload, ChatTimelineToolItem, ChatTimelineToolMode, ChatTimelineToolStatus, } from './types'; 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 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 finishLastAssistantMessage(items: ChatTimelineItem[]) { finishAssistantMessage(items, true); } function finishAssistantMessage( items: ChatTimelineItem[], roundCompleted: boolean, ) { const lastMessage = [...items] .reverse() .find( (item): item is ChatTimelineMessageItem => item.type === 'message' && item.role === 'assistant', ); 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, ) { const identities = new Set( [toolCallId, sourceToolCallId] .map((value) => normalizeText(value).trim()) .filter(Boolean), ); const normalizedTaskId = normalizeText(taskId).trim(); if (identities.size === 0 && !normalizedTaskId) { return undefined; } return items.find( (item): item is ChatTimelineToolItem => item.type === 'tool' && ((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 removeStatusItem(items: ChatTimelineItem[], statusKey: string) { const index = items.findIndex( (item) => item.type === 'status' && item.statusKey === statusKey, ); if (index >= 0) { 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 finishRunningStatusItems(items: ChatTimelineItem[]) { items.forEach((item) => { if (item.type !== 'status' || item.status !== 'running') { return; } item.status = 'done'; item.label = doneStatusLabel(item); }); } function upsertStatus( items: ChatTimelineItem[], payload: { 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; 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', }; items.push(item); return item; } function upsertTool( items: ChatTimelineItem[], payload: { approval?: ChatTimelineToolApprovalPayload; input?: unknown; mode?: ChatTimelineToolMode; output?: unknown; rejectReason?: string; requestId?: string; resumeToken?: 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, ); 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; 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', }; items.push(toolItem); return toolItem; } export const ChatTimelineBuilder = { appendUserMessage( items: ChatTimelineItem[], content?: unknown, metadata?: Partial, ) { const text = normalizeText(content); if (!text && !metadata?.images?.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) { const text = normalizeText(content); if (!text) { return; } const message = ensureMessageTail(items, 'assistant', 'done'); updateThinkingStatus(message, 'end'); replaceTextPart(message, text); }, appendToolApproval( items: ChatTimelineItem[], payload: ChatTimelineToolApprovalPayload, ) { upsertTool(items, { approval: payload, input: payload.input, mode: 'approval', status: 'pending_approval', toolCallId: payload.toolCallId, toolName: payload.toolDisplayName || payload.toolName, }); }, upsertToolCall( items: ChatTimelineItem[], payload: { 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, ); return; } upsertTool(items, { ...payload, mode: 'auto', status: payload.status || 'running', }); }, upsertKnowledgeRetrievalStatus( items: ChatTimelineItem[], status: ChatTimelineStatusStatus, statusKey?: string, ) { finishAssistantMessage(items, false); upsertStatus(items, { label: status === 'running' ? '正在检索知识库' : '已检索知识库', status, statusKey: knowledgeRetrievalStatusKey(statusKey), tone: 'muted', }); }, upsertMemoryCompressionStatus( items: ChatTimelineItem[], payload?: { compressed?: boolean; label?: string; phase?: string; status?: string; statusKey?: string; }, ) { const status = payload?.status === 'done' || payload?.phase === 'completed' ? 'done' : 'running'; const statusKey = payload?.statusKey || 'memory-compression'; finishAssistantMessage(items, false); if (status === 'done' && payload?.compressed === false) { removeStatusItem(items, statusKey); return; } const label = status === 'running' ? payload?.label || '正在整理上下文' : payload?.label || '已整理上下文'; upsertStatus(items, { label, status, statusKey, presentation: 'separator', tone: 'muted', }); }, markToolApproving( items: ChatTimelineItem[], payload: { requestId?: string; resumeToken?: string; toolCallId?: string; }, ) { upsertTool(items, { ...payload, mode: 'approval', status: 'approving', }); }, markToolRejected( items: ChatTimelineItem[], payload: { reason?: string; requestId?: string; resumeToken?: string; toolCallId?: string; }, ) { upsertTool(items, { ...payload, mode: 'approval', rejectReason: payload.reason, status: 'rejected', }); }, appendKnowledge( items: ChatTimelineItem[], knowledgeItems: ChatTimelineKnowledgeHit[], ) { if (knowledgeItems.length === 0) { return; } const lastAssistantMessage = [...items] .reverse() .find( (item): item is ChatTimelineMessageItem => item.type === 'message' && item.role === 'assistant', ); if (lastAssistantMessage) { lastAssistantMessage.knowledgeItems = [ ...(lastAssistantMessage.knowledgeItems || []), ...knowledgeItems, ]; return; } const last = items[items.length - 1]; if (last?.type === 'knowledge') { last.items.push(...knowledgeItems); return; } items.push({ id: createId('knowledge'), createdAt: Date.now(), items: knowledgeItems, type: 'knowledge', }); }, appendError(items: ChatTimelineItem[], message?: unknown) { const text = normalizeText(message) || '请求失败'; const last = items[items.length - 1]; if (last?.type === 'message' && last.role === 'assistant') { updateThinkingStatus(last, 'error'); last.status = 'error'; } items.push({ id: createId('error'), createdAt: Date.now(), message: text, type: 'error', }); }, finalize(items: ChatTimelineItem[]) { finishRunningStatusItems(items); finishLastAssistantMessage(items); }, 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 >= 0) { items.splice(targetIndex, 1, message); } }, };