import type { BackendTask, ProcessArtifact, ProcessEvent, ProcessRun, TaskTimelineCard, TaskTimelineCardType, } from '@/types'; import { getCurrentAppLocale, pickAppText, type AppLocale } from '@/lib/i18n/core'; export type BuildTaskTimelineCardsInput = { task: BackendTask; processRuns?: ProcessRun[]; processEvents?: ProcessEvent[]; processArtifacts?: ProcessArtifact[]; locale?: AppLocale | string; }; const TIMELINE_CARD_TYPES = new Set([ 'task_created', 'plan', 'skill', 'tool_call', 'tool_result', 'next_step', 'agent_team', 'agent_progress', 'agent_handoff', 'artifact', 'error', 'result', 'result_history', 'acceptance', ]); const RESULT_STATUSES = new Set(['awaiting_acceptance', 'closed', 'abandoned', 'cancelled', 'error']); function isTimelineCardType(value: unknown): value is TaskTimelineCardType { return typeof value === 'string' && TIMELINE_CARD_TYPES.has(value as TaskTimelineCardType); } function toTime(value: string): number | null { const parsed = new Date(value).getTime(); return Number.isFinite(parsed) ? parsed : null; } function firstString(...values: unknown[]): string | undefined { for (const value of values) { if (typeof value !== 'string') continue; const trimmed = value.trim(); if (trimmed) return trimmed; } return undefined; } function stringList(value: unknown): string[] { if (Array.isArray(value)) { return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0); } if (typeof value === 'string' && value.trim()) { return [value.trim()]; } return []; } function normalizeSkillNames(metadata: Record | undefined): string[] | undefined { if (!metadata || (!('skill_names' in metadata) && !('selected_skill_names' in metadata))) { return undefined; } const names = [ ...stringList(metadata.skill_names), ...stringList(metadata.selected_skill_names), ]; return Array.from(new Set(names)); } function cardTypeForEvent(event: ProcessEvent): TaskTimelineCardType | null { const timelineType = event.metadata?.timeline_type; if (isTimelineCardType(timelineType)) { return timelineType; } switch (String(event.kind)) { case 'task_planned': case 'run_started': return 'plan'; case 'skill_selected': return 'skill'; case 'tool_call_started': return 'tool_call'; case 'tool_call_finished': return 'tool_result'; case 'agent_team_created': return 'agent_team'; case 'agent_handoff': return 'agent_handoff'; case 'agent_finished': case 'run_progress': case 'run_finished': return 'agent_progress'; case 'task_result_ready': return 'result'; case 'task_acceptance_recorded': return 'acceptance'; case 'task_error': return 'error'; default: if (event.status === 'error') { return 'error'; } return null; } } function titleForCard(type: TaskTimelineCardType, actorName?: string, locale: AppLocale | string = getCurrentAppLocale()): string { switch (type) { case 'task_created': return pickAppText(locale, '任务已创建', 'Task created'); case 'plan': return pickAppText(locale, '执行计划', 'Execution plan'); case 'skill': return pickAppText(locale, '选择 Skill', 'Skill selected'); case 'tool_call': return actorName ? pickAppText(locale, `调用工具:${actorName}`, `Calling tool: ${actorName}`) : pickAppText(locale, '调用工具', 'Tool call'); case 'tool_result': return actorName ? pickAppText(locale, `工具结果:${actorName}`, `Tool result: ${actorName}`) : pickAppText(locale, '工具结果', 'Tool result'); case 'next_step': return pickAppText(locale, '下一步', 'Next step'); case 'agent_team': return pickAppText(locale, '启动 Agent Team', 'Agent team started'); case 'agent_progress': return actorName || pickAppText(locale, 'Agent 进展', 'Agent progress'); case 'agent_handoff': return pickAppText(locale, 'Agent 交接', 'Agent handoff'); case 'artifact': return pickAppText(locale, '生成产物', 'Artifact generated'); case 'error': return pickAppText(locale, '执行遇到问题', 'Execution issue'); case 'result': return pickAppText(locale, '本轮结果', 'Run result'); case 'result_history': return pickAppText(locale, '历史结果版本', 'Previous result versions'); case 'acceptance': return pickAppText(locale, '任务验收', 'Task acceptance'); } } function summaryForEvent(event: ProcessEvent): string | undefined { return firstString( event.metadata?.result_summary, event.metadata?.reason, event.metadata?.action_summary, event.text, ); } function detailsForEvent(event: ProcessEvent): Record | undefined { const skillNames = normalizeSkillNames(event.metadata); if (!event.metadata && !skillNames) { return undefined; } return { ...(event.metadata ?? {}), ...(skillNames ? { skill_names: skillNames } : {}), }; } function feedbackCreatedAt(feedback: Record, task: BackendTask): string { return firstString(feedback.created_at, task.updated_at, task.created_at) ?? task.created_at; } function feedbackSummary(feedback: Record): string | undefined { return firstString(feedback.comment, feedback.summary, feedback.acceptance_type); } function acceptanceTypeFromRecord(record: Record | undefined): string | null { return firstString(record?.acceptance_type, record?.feedback_type)?.toLowerCase() ?? null; } function resultSummary(task: BackendTask): string | undefined { return firstString( task.metadata?.result_summary, task.metadata?.summary, task.close_reason, task.validation_result?.summary, ); } function assistantResultForRun(task: BackendTask, runId: string | null | undefined): string | undefined { if (!runId) return undefined; const run = (task.runs ?? []).find((item) => item.run_id === runId); if (!run) return undefined; const assistantMessages = run.messages.filter((message) => message.role === 'assistant' && message.content.trim()); return lastItem(assistantMessages)?.content.trim(); } function resultSummaryForEvent(task: BackendTask, event: ProcessEvent): string | undefined { return firstString(assistantResultForRun(task, event.run_id), summaryForEvent(event)); } function fallbackResultSummary(task: BackendTask): string | undefined { return firstString(assistantResultForRun(task, lastItem(task.run_ids)), resultSummary(task)); } function buildRunMap(processRuns: ProcessRun[]): Map { const map = new Map(); for (const run of processRuns) { map.set(run.run_id, run); } return map; } function lastItem(items: T[]): T | null { return items.length > 0 ? items[items.length - 1] : null; } function compareCardsByCreatedAt( a: { card: TaskTimelineCard; index: number }, b: { card: TaskTimelineCard; index: number }, ): number { const aTime = toTime(a.card.createdAt); const bTime = toTime(b.card.createdAt); if (aTime === null && bTime === null) { return a.index - b.index; } if (aTime === null) { return 1; } if (bTime === null) { return -1; } return aTime - bTime || a.index - b.index; } type AcceptanceEventIdentity = { runId: string | null; acceptanceType: string | null; }; function isCoveredByAcceptanceEvent( feedback: Record, acceptanceEvents: AcceptanceEventIdentity[], ): boolean { const feedbackType = acceptanceTypeFromRecord(feedback); if (!feedbackType) return false; const feedbackRunId = firstString(feedback.run_id) ?? null; const matchingTypeEvents = acceptanceEvents.filter((event) => event.acceptanceType === feedbackType); if (feedbackRunId) { return ( matchingTypeEvents.some((event) => event.runId === feedbackRunId) || (matchingTypeEvents.length === 1 && !matchingTypeEvents[0].runId) ); } return matchingTypeEvents.length === 1; } function cardTime(card: TaskTimelineCard): number { return toTime(card.createdAt) ?? Number.MAX_SAFE_INTEGER; } function cardComment(card: TaskTimelineCard): string | undefined { return firstString(card.details?.comment, card.summary); } function toolCallKeyFromEvent(event: ProcessEvent): string | null { const toolCallId = firstString(event.metadata?.tool_call_id); if (toolCallId) return `${event.run_id}:${toolCallId}`; const toolName = firstString(event.metadata?.tool_name, event.actor_name, event.actor_id); if (toolName) return `${event.run_id}:${toolName}`; return null; } function buildToolResultStatusByCall(processEvents: ProcessEvent[]): Map { const statuses = new Map(); for (const event of processEvents) { if (cardTypeForEvent(event) !== 'tool_result') continue; const key = toolCallKeyFromEvent(event); if (!key) continue; statuses.set(key, event.status || 'done'); } return statuses; } function buildResultHistoryCard( task: BackendTask, resultCards: TaskTimelineCard[], acceptanceCards: TaskTimelineCard[], locale: AppLocale | string, ): TaskTimelineCard { const versions = resultCards.map((resultCard) => { const acceptanceCard = acceptanceCards .filter((card) => card.runId === resultCard.runId) .sort((a, b) => cardTime(a) - cardTime(b)) .at(-1); return { runId: resultCard.runId ?? null, result: resultCard.summary ?? '', createdAt: resultCard.createdAt, status: acceptanceCard?.status ?? resultCard.status ?? null, acceptanceType: acceptanceCard?.status ?? null, comment: acceptanceCard ? cardComment(acceptanceCard) ?? '' : '', acceptedAt: acceptanceCard?.createdAt ?? null, }; }); return { id: `${task.task_id}:result-history`, taskId: task.task_id, type: 'result_history', title: titleForCard('result_history', undefined, locale), summary: pickAppText( locale, `${resultCards.length} 历史结果版本`, `${resultCards.length} previous result ${resultCards.length === 1 ? 'version' : 'versions'}`, ), createdAt: resultCards[0]?.createdAt ?? task.created_at, details: { versions }, }; } function collapseHistoricalResults(task: BackendTask, cards: TaskTimelineCard[], locale: AppLocale | string): TaskTimelineCard[] { const resultCards = cards.filter((card) => card.type === 'result'); if (resultCards.length <= 1) return cards; const finalAcceptedRunId = firstString(task.metadata?.final_accepted_run_id); const latestResult = (finalAcceptedRunId ? resultCards.find((card) => card.runId === finalAcceptedRunId) : undefined) ?? [...resultCards].sort((a, b) => cardTime(a) - cardTime(b)).at(-1); if (!latestResult) return cards; const oldResults = resultCards .filter((card) => card.id !== latestResult.id) .sort((a, b) => cardTime(a) - cardTime(b)); if (oldResults.length === 0) return cards; const oldRunIds = new Set(oldResults.map((card) => card.runId).filter(Boolean)); const oldAcceptances = cards .filter((card) => card.type === 'acceptance' && oldRunIds.has(card.runId)) .sort((a, b) => cardTime(a) - cardTime(b)); const foldedIds = new Set([...oldResults, ...oldAcceptances].map((card) => card.id)); const historyCard = buildResultHistoryCard(task, oldResults, oldAcceptances, locale); const firstOldResultIndex = cards.findIndex((card) => card.id === oldResults[0].id); const output: TaskTimelineCard[] = []; for (let index = 0; index < cards.length; index += 1) { if (index === firstOldResultIndex) { output.push(historyCard); } if (!foldedIds.has(cards[index].id)) { output.push(cards[index]); } } return output; } export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): TaskTimelineCard[] { const { task } = input; const locale = input.locale ?? getCurrentAppLocale(); const processRuns = input.processRuns ?? task.process_runs ?? []; const processEvents = input.processEvents ?? task.process_events ?? []; const processArtifacts = input.processArtifacts ?? task.process_artifacts ?? []; const runsById = buildRunMap(processRuns); const toolResultStatusByCall = buildToolResultStatusByCall(processEvents); const runsWithProgressEvents = new Set(); const acceptanceEvents: AcceptanceEventIdentity[] = []; let hasResultEventCard = false; const cards: TaskTimelineCard[] = [ { id: `${task.task_id}:created`, taskId: task.task_id, type: 'task_created', title: titleForCard('task_created', undefined, locale), summary: firstString(task.short_title, task.description, task.goal), actorName: task.creator, status: task.status, createdAt: task.created_at, details: task.metadata, }, ]; for (const event of processEvents) { const type = cardTypeForEvent(event); if (!type) continue; if (type === 'agent_progress') { runsWithProgressEvents.add(event.run_id); } if (type === 'result') { hasResultEventCard = true; } if (type === 'acceptance') { acceptanceEvents.push({ runId: firstString(event.run_id) ?? null, acceptanceType: acceptanceTypeFromRecord(event.metadata), }); } cards.push({ id: event.event_id, taskId: task.task_id, runId: event.run_id, parentRunId: event.parent_run_id, type, title: titleForCard(type, event.actor_name, locale), summary: type === 'result' ? resultSummaryForEvent(task, event) : summaryForEvent(event), actorName: event.actor_name, status: type === 'tool_call' ? toolResultStatusByCall.get(toolCallKeyFromEvent(event) ?? '') ?? event.status : event.status, createdAt: event.created_at, details: detailsForEvent(event), }); } for (const run of processRuns) { if (!run.parent_run_id) continue; if (runsWithProgressEvents.has(run.run_id)) continue; cards.push({ id: `${run.run_id}:fallback-progress`, taskId: task.task_id, runId: run.run_id, parentRunId: run.parent_run_id, type: 'agent_progress', title: titleForCard('agent_progress', run.actor_name, locale), summary: firstString(run.summary, run.title), actorName: run.actor_name, status: run.status, createdAt: run.started_at, details: run.metadata, }); } for (const artifact of processArtifacts) { const run = runsById.get(artifact.run_id); cards.push({ id: artifact.artifact_id, taskId: task.task_id, runId: artifact.run_id, parentRunId: run?.parent_run_id, type: 'artifact', title: titleForCard('artifact', undefined, locale), summary: firstString(artifact.title), actorName: artifact.actor_name, createdAt: artifact.created_at, relatedArtifactIds: [artifact.artifact_id], details: { ...(artifact.metadata ?? {}), artifact_type: artifact.artifact_type, title: artifact.title, }, }); } if (RESULT_STATUSES.has(task.status) && !hasResultEventCard) { cards.push({ id: `${task.task_id}:result`, taskId: task.task_id, runId: lastItem(task.run_ids), type: 'result', title: titleForCard('result', undefined, locale), summary: fallbackResultSummary(task), status: task.status, createdAt: task.closed_at ?? task.updated_at ?? task.created_at, details: task.validation_result ?? undefined, }); } for (let index = 0; index < task.feedback.length; index += 1) { const feedback = task.feedback[index]; const runId = firstString(feedback.run_id) ?? null; const createdAt = feedbackCreatedAt(feedback, task); if (isCoveredByAcceptanceEvent(feedback, acceptanceEvents)) continue; cards.push({ id: `${task.task_id}:acceptance:${index}`, taskId: task.task_id, runId, type: 'acceptance', title: titleForCard('acceptance', undefined, locale), summary: feedbackSummary(feedback), status: firstString(feedback.acceptance_type), createdAt, details: feedback, }); } const sortedCards = cards .map((card, index) => ({ card, index })) .sort(compareCardsByCreatedAt) .map(({ card }) => card); return collapseHistoricalResults(task, sortedCards, locale); }