import { pickAppText, type AppLocale } from '@/lib/i18n/core'; import type { BackendTask, ProcessArtifact, ProcessRun, SessionProcessProjection, TaskTimelineCard } from '@/types'; export type TaskUiStatus = 'done' | 'running' | 'waiting' | 'error' | 'cancelled'; export type TaskUiStep = { id: string; title: string; summary: string; status: TaskUiStatus; createdAt: string; kind: 'task' | 'skill' | 'tool' | 'agent' | 'artifact' | 'result'; }; export type TaskUiSkill = { id: string; name: string; summary: string; status: TaskUiStatus; createdAt: string; }; export type TaskUiToolCall = { id: string; runId?: string | null; toolCallId?: string | null; toolName: string; actorName: string; summary: string; status: TaskUiStatus; createdAt: string; finishedAt?: string; durationMs?: number | null; query?: string; url?: string; quality?: string; resultCount?: number; }; export type TaskUiAttemptRun = { runId: string; title: string; actorName: string; source?: string | null; status: TaskUiStatus; startedAt: string; finishedAt?: string | null; }; export type TaskUiAttempt = { id: string; index: number; title: string; status: TaskUiStatus; startedAt: string; finishedAt?: string | null; runs: TaskUiAttemptRun[]; tools: TaskUiToolCall[]; result?: { title: string; summary: string; status: TaskUiStatus; createdAt: string; }; }; export type TaskUiAgentNode = { runId: string; parentRunId: string | null; name: string; title: string; summary: string; status: TaskUiStatus; progress: number; children: TaskUiAgentNode[]; }; export type TaskUiArtifact = { id: string; runId: string; actorName?: string; title: string; type: ProcessArtifact['artifact_type']; summary: string; createdAt: string; fileId?: string; url?: string; status: TaskUiStatus; sizeLabel?: string; }; export type TaskUiModel = { executionMode: 'single' | 'team'; team: { hasTeam: boolean; status: TaskUiStatus; outcome: string; nodeIds: string[]; incompleteNodeIds: string[]; summary: string; }; summary: TaskUiStep; skills: TaskUiSkill[]; tools: TaskUiToolCall[]; attempts: TaskUiAttempt[]; agentTree: TaskUiAgentNode[]; artifacts: TaskUiArtifact[]; steps: TaskUiStep[]; result: { status: TaskUiStatus; title: string; summary: string; bullets: string[]; }; }; const WAITING_TASK_STATUSES = new Set(['open', 'queued', 'awaiting_acceptance', 'needs_revision']); const RUNNING_TASK_STATUSES = new Set(['running']); const DONE_TASK_STATUSES = new Set(['closed', 'done', 'completed', 'satisfied']); const ERROR_TASK_STATUSES = new Set(['error', 'failed']); const CANCELLED_TASK_STATUSES = new Set(['cancelled', 'abandoned']); function normalizeStatus(status?: string | null): TaskUiStatus { const value = String(status || '').toLowerCase(); if (DONE_TASK_STATUSES.has(value) || value === 'done') return 'done'; if (RUNNING_TASK_STATUSES.has(value)) return 'running'; if (ERROR_TASK_STATUSES.has(value)) return 'error'; if (CANCELLED_TASK_STATUSES.has(value)) return 'cancelled'; if (WAITING_TASK_STATUSES.has(value) || value === 'waiting' || value === 'queued' || !value) return 'waiting'; return 'running'; } export function taskUiStatusLabel(status: TaskUiStatus, locale: AppLocale | string): string { const labels: Record = { done: ['已完成', 'Done'], running: ['进行中', 'Running'], waiting: ['等待中', 'Waiting'], error: ['失败', 'Failed'], cancelled: ['已取消', 'Cancelled'], }; const label = labels[status]; return pickAppText(locale, label[0], label[1]); } export function taskUiStatusClass(status: TaskUiStatus): string { if (status === 'done') return 'border-[#D6E2D5] bg-[#F4F8F3] text-[#557052]'; if (status === 'running') return 'border-[#E8D7B2] bg-[#FFF8EA] text-[#9B6B12]'; if (status === 'error') return 'border-[#E8C8C2] bg-[#FFF4F2] text-[#9D3D2F]'; if (status === 'cancelled') return 'border-[#DED9D5] bg-[#F4F2F0] text-[#756A64]'; return 'border-[#DDD8D4] bg-[#F8F6F4] text-[#746C67]'; } function titleForTask(task: BackendTask): string { return task.short_title || String(task.metadata?.short_title || '') || task.description || task.goal || task.task_id; } function summarizeTask(task: BackendTask): string { return task.description || task.goal || String(task.metadata?.summary || '') || task.task_id; } function statusRank(status: TaskUiStatus): number { if (status === 'error') return 5; if (status === 'running') return 4; if (status === 'waiting') return 3; if (status === 'cancelled') return 2; return 1; } function mergeStatus(current: TaskUiStatus, next: TaskUiStatus): TaskUiStatus { return statusRank(next) > statusRank(current) ? next : current; } function mergeToolStatus(current: TaskUiStatus, next: TaskUiStatus): TaskUiStatus { if (next === 'error' || current === 'error') return 'error'; if (next === 'done' || current === 'done') return 'done'; if (next === 'running' || current === 'running') return 'running'; if (next === 'cancelled' || current === 'cancelled') return 'cancelled'; return 'waiting'; } function firstString(...values: unknown[]): string { for (const value of values) { if (typeof value === 'string' && value.trim()) { return value.trim(); } } return ''; } function stringList(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.map((item) => String(item || '').trim()).filter(Boolean); } function parseJsonObject(value: unknown): Record | null { if (!value || typeof value !== 'string') return null; try { const parsed = JSON.parse(value); return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : null; } catch { return null; } } function countArray(value: unknown): number | undefined { return Array.isArray(value) ? value.length : undefined; } function summarizeToolPayload(toolName: string, card: TaskTimelineCard): { summary: string; query?: string; url?: string; quality?: string; resultCount?: number; } { const details = card.details ?? {}; const args = parseJsonObject(firstString(details.arguments)); const result = parseJsonObject(firstString(details.result_summary, card.summary)); const source = result ?? args ?? {}; const query = firstString(source.query, args?.query); const url = firstString(source.url, args?.url); const quality = firstString(source.quality); const resultCount = countArray(source.results) ?? countArray(source.links); if (toolName === 'web_search') { return { query, quality, resultCount, summary: [ query ? `Query: ${query}` : 'Search query', typeof resultCount === 'number' ? `${resultCount} result(s)` : '', ].filter(Boolean).join(' · '), }; } if (toolName === 'web_fetch') { return { url, resultCount, summary: [ firstString(source.title) || (url ? `Fetch ${url}` : 'Fetch web page'), firstString(source.status_code) ? `HTTP ${String(source.status_code)}` : '', typeof resultCount === 'number' ? `${resultCount} link(s)` : '', ].filter(Boolean).join(' · '), }; } return { query, url, quality, resultCount, summary: firstString(card.summary, details.result_summary, card.title) || toolName, }; } function toolNameFromCard(card: TaskTimelineCard): string { return firstString( card.details?.tool_name, card.details?.tool, card.details?.name, card.actorName, card.title ); } function buildSkills(task: BackendTask, cards: TaskTimelineCard[], locale: AppLocale | string): TaskUiSkill[] { const createdAt = task.created_at || task.updated_at; const names = new Set(task.skill_names.filter(Boolean)); for (const card of cards) { if (card.type !== 'skill') continue; const detailNames = card.details?.skill_names; if (Array.isArray(detailNames)) { detailNames.forEach((item) => { if (typeof item === 'string' && item.trim()) names.add(item.trim()); }); } } return Array.from(names).map((name, index) => { const card = cards.find((item) => item.type === 'skill' && (item.title === name || item.summary?.includes(name))); return { id: `${name}:${index}`, name, summary: card?.summary || '', status: normalizeStatus(card?.status || (task.status === 'running' || task.status === 'open' ? 'running' : 'done')), createdAt: card?.createdAt || createdAt, }; }); } function toolAggregateStatus(toolCards: TaskTimelineCard[]): TaskUiStatus { const calls = new Map(); for (const card of toolCards) { const toolName = toolNameFromCard(card) || card.title; const toolCallId = firstString(card.details?.tool_call_id); const key = toolCallId ? `${card.runId || '-'}:${toolCallId}` : `${card.runId || '-'}:${toolName}:${card.id}`; const item = calls.get(key) ?? { started: false, finished: false, error: false }; if (card.type === 'tool_call') item.started = true; if (card.type === 'tool_result') item.finished = true; if (normalizeStatus(card.status) === 'error') item.error = true; calls.set(key, item); } const values = Array.from(calls.values()); if (values.some((item) => item.error)) return 'error'; if (values.length > 0 && values.every((item) => item.finished)) return 'done'; if (values.some((item) => item.started)) return 'running'; return 'waiting'; } function toolActorNameForRun(run: ProcessRun | undefined): string { if (!run) return 'Agent'; if (run.source === 'task_team' || run.metadata?.node_id) { return run.actor_name || run.title || String(run.metadata?.node_id || 'Agent'); } return 'Agent'; } function buildTools(cards: TaskTimelineCard[], runs: ProcessRun[]): TaskUiToolCall[] { const map = new Map(); const runsById = new Map(runs.map((run) => [run.run_id, run])); for (const card of cards) { if (card.type !== 'tool_call' && card.type !== 'tool_result') continue; const toolName = toolNameFromCard(card) || card.title; const toolCallId = firstString(card.details?.tool_call_id); const key = toolCallId ? `${card.runId || '-'}:${toolCallId}` : card.id; const status = normalizeStatus(card.status || (card.type === 'tool_result' ? 'done' : 'running')); const summary = summarizeToolPayload(toolName, card); const existing = map.get(key); if (existing) { const finishedAt = card.type === 'tool_result' ? card.createdAt : existing.finishedAt; map.set(key, { ...existing, summary: summary.summary || existing.summary, status: mergeToolStatus(existing.status, status), finishedAt, durationMs: calculateDurationMs(existing.createdAt, finishedAt), query: summary.query || existing.query, url: summary.url || existing.url, quality: summary.quality || existing.quality, resultCount: summary.resultCount ?? existing.resultCount, }); continue; } map.set(key, { id: key, runId: card.runId, toolCallId, toolName, actorName: toolActorNameForRun(card.runId ? runsById.get(card.runId) : undefined), summary: summary.summary || card.title, status, createdAt: card.createdAt, finishedAt: card.type === 'tool_result' ? card.createdAt : undefined, durationMs: calculateDurationMs(card.createdAt, card.type === 'tool_result' ? card.createdAt : undefined), query: summary.query, url: summary.url, quality: summary.quality, resultCount: summary.resultCount, }); } return Array.from(map.values()).sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); } function calculateDurationMs(start?: string | null, end?: string | null): number | null { if (!start || !end) return null; const startMs = new Date(start).getTime(); const endMs = new Date(end).getTime(); if (Number.isNaN(startMs) || Number.isNaN(endMs) || endMs < startMs) return null; return endMs - startMs; } function attemptIndexForRun(run: ProcessRun, fallback: number): number { const metadataIndex = run.metadata?.attempt_index; if (typeof metadataIndex === 'number' && Number.isFinite(metadataIndex)) return metadataIndex; if (typeof metadataIndex === 'string' && /^\d+$/.test(metadataIndex)) return Number(metadataIndex); const match = run.run_id.match(/:attempt:(\d+)/); return match ? Number(match[1]) : fallback; } function runStartedAt(run: ProcessRun): string { return run.started_at || run.finished_at || ''; } function runFinishedAt(run: ProcessRun): string | null | undefined { return run.finished_at; } function maxTime(values: Array): string | null { let selected: string | null = null; let selectedMs = -Infinity; for (const value of values) { if (!value) continue; const time = new Date(value).getTime(); if (!Number.isNaN(time) && time > selectedMs) { selected = value; selectedMs = time; } } return selected; } function attemptStatus(runs: ProcessRun[], planner?: ProcessRun): TaskUiStatus { const statuses = [...runs.map((run) => normalizeStatus(run.status)), normalizeStatus(planner?.status)]; if (statuses.some((status) => status === 'running')) return 'running'; if (statuses.some((status) => status === 'error')) return 'error'; if (statuses.some((status) => status === 'cancelled')) return 'cancelled'; if (statuses.some((status) => status === 'done')) return 'done'; return 'waiting'; } function collectChildRunIds(rootRunId: string, runs: ProcessRun[]): Set { const ids = new Set([rootRunId]); let changed = true; while (changed) { changed = false; for (const run of runs) { if (run.parent_run_id && ids.has(run.parent_run_id) && !ids.has(run.run_id)) { ids.add(run.run_id); changed = true; } } } return ids; } function buildAttempts( process: SessionProcessProjection, cards: TaskTimelineCard[], tools: TaskUiToolCall[], locale: AppLocale | string, ): TaskUiAttempt[] { const runs = process.runs ?? []; const plannerRuns = runs .filter((run) => run.source === 'task_mode' || /:attempt:\d+/.test(run.run_id)) .sort((a, b) => new Date(runStartedAt(a)).getTime() - new Date(runStartedAt(b)).getTime()); const attemptRuns = plannerRuns.length > 0 ? plannerRuns : []; const attempts = attemptRuns.map((planner, index) => { const runIds = collectChildRunIds(planner.run_id, runs); const groupedRuns = runs .filter((run) => runIds.has(run.run_id)) .sort((a, b) => new Date(runStartedAt(a)).getTime() - new Date(runStartedAt(b)).getTime()); const groupedTools = tools.filter((tool) => tool.runId && runIds.has(tool.runId)); const resultCard = [...cards].reverse().find((card) => card.type === 'result' && card.runId && runIds.has(card.runId)); const attemptIndex = attemptIndexForRun(planner, index + 1); return { id: planner.run_id, index: attemptIndex, title: pickAppText(locale, `第 ${attemptIndex} 次执行`, `Attempt ${attemptIndex}`), status: attemptStatus(groupedRuns, planner), startedAt: runStartedAt(planner), finishedAt: maxTime(groupedRuns.map(runFinishedAt)) ?? planner.finished_at, runs: groupedRuns.map((run) => ({ runId: run.run_id, title: run.title || run.actor_name || run.actor_id, actorName: run.actor_name || run.actor_id, source: run.source, status: normalizeStatus(run.status), startedAt: runStartedAt(run), finishedAt: run.finished_at, })), tools: groupedTools, result: resultCard ? { title: resultCard.title, summary: resultCard.summary || '', status: normalizeStatus(resultCard.status), createdAt: resultCard.createdAt, } : undefined, }; }); if (attempts.length > 0) return attempts; return [ { id: 'single-run', index: 1, title: pickAppText(locale, '本次执行', 'Current run'), status: tools.some((tool) => tool.status === 'running') ? 'running' : tools.length > 0 ? 'done' : 'waiting', startedAt: cards[0]?.createdAt || '', runs: runs.map((run) => ({ runId: run.run_id, title: run.title || run.actor_name || run.actor_id, actorName: run.actor_name || run.actor_id, source: run.source, status: normalizeStatus(run.status), startedAt: runStartedAt(run), finishedAt: run.finished_at, })), tools, result: undefined, }, ]; } function progressForStatus(status: TaskUiStatus): number { if (status === 'done') return 100; if (status === 'running') return 58; if (status === 'error' || status === 'cancelled') return 100; return 12; } function buildAgentTree(runs: ProcessRun[]): TaskUiAgentNode[] { const nodes = new Map(); const teamRuns = runs.filter((run) => run.source === 'task_team' || Boolean(run.metadata?.node_id)); for (const run of teamRuns) { const status = normalizeStatus(run.status); nodes.set(run.run_id, { runId: run.run_id, parentRunId: run.parent_run_id ?? null, name: run.actor_name || run.actor_id || run.title, title: run.title || run.actor_name || run.actor_id, summary: run.summary || String(run.metadata?.summary || ''), status, progress: progressForStatus(status), children: [], }); } const roots: TaskUiAgentNode[] = []; for (const node of Array.from(nodes.values())) { if (node.parentRunId && nodes.has(node.parentRunId)) { nodes.get(node.parentRunId)!.children.push(node); } else { roots.push(node); } } return roots; } function buildTeam(cards: TaskTimelineCard[]): TaskUiModel['team'] { const teamCard = [...cards].reverse().find((card) => card.type === 'agent_team'); if (!teamCard) { return { hasTeam: false, status: 'waiting', outcome: 'single', nodeIds: [], incompleteNodeIds: [], summary: '', }; } const nodeIds = stringList(teamCard.details?.node_ids); const incompleteNodeIds = stringList(teamCard.details?.incomplete_node_ids); const outcome = firstString(teamCard.details?.task_outcome) || (teamCard.status === 'error' ? 'incomplete' : 'complete'); return { hasTeam: true, status: normalizeStatus(teamCard.status), outcome, nodeIds, incompleteNodeIds, summary: teamCard.summary || (outcome === 'complete' ? 'Agent Team completed' : 'Team 执行未完成 / 子节点失败'), }; } function buildArtifacts(process: SessionProcessProjection): TaskUiArtifact[] { return process.artifacts.map((artifact) => ({ id: artifact.artifact_id, runId: artifact.run_id, actorName: artifact.actor_name, title: artifact.title || artifact.artifact_id, type: artifact.artifact_type, summary: firstString(artifact.metadata?.summary, artifact.content, artifact.url) || artifact.artifact_type, createdAt: artifact.created_at, fileId: artifact.file_id, url: artifact.url, status: normalizeStatus(firstString(artifact.metadata?.status, artifact.metadata?.state) || 'done'), sizeLabel: firstString(artifact.metadata?.size_label, artifact.metadata?.size, artifact.metadata?.file_size), })); } function stepKind(card: TaskTimelineCard): TaskUiStep['kind'] { if (card.type === 'skill') return 'skill'; if (card.type === 'tool_call' || card.type === 'tool_result') return 'tool'; if (card.type === 'agent_team' || card.type === 'agent_progress' || card.type === 'agent_handoff') return 'agent'; if (card.type === 'artifact') return 'artifact'; if (card.type === 'result' || card.type === 'acceptance') return 'result'; return 'task'; } function buildSteps(task: BackendTask, cards: TaskTimelineCard[], locale: AppLocale | string): TaskUiStep[] { const taskStep: TaskUiStep = { id: `summary:${task.task_id}`, title: titleForTask(task), summary: summarizeTask(task), status: normalizeStatus(task.status), createdAt: task.created_at, kind: 'task', }; const skillCard = cards.find((card) => card.type === 'skill'); const teamCard = cards.find((card) => card.type === 'agent_team'); const resultCard = [...cards].reverse().find((card) => card.type === 'result'); const toolCards = cards.filter((card) => card.type === 'tool_call' || card.type === 'tool_result'); const toolNames = new Set(toolCards.map(toolNameFromCard).filter(Boolean)); const cardSteps: Array = [ skillCard ? { id: `${skillCard.id}:step`, title: pickAppText(locale, '选择 Skill', 'Skill selected'), summary: skillCard.summary || '', status: normalizeStatus(skillCard.status), createdAt: skillCard.createdAt, kind: 'skill' as const, } : null, toolCards.length ? { id: `tools:${task.task_id}`, title: pickAppText(locale, '调用工具', 'Tool calls'), summary: `${toolCards.filter((card) => card.type === 'tool_result').length || toolCards.length} calls · ${Array.from(toolNames).slice(0, 3).join(', ')}`, status: toolAggregateStatus(toolCards), createdAt: toolCards[0].createdAt, kind: 'tool' as const, } : null, teamCard ? { id: `${teamCard.id}:step`, title: pickAppText(locale, 'Agent Team 执行', 'Agent Team execution'), summary: teamCard.summary || '', status: normalizeStatus(teamCard.status), createdAt: teamCard.createdAt, kind: 'agent' as const, } : null, resultCard ? { id: `${resultCard.id}:step`, title: pickAppText(locale, '生成结果', 'Result ready'), summary: resultCard.summary || '', status: normalizeStatus(resultCard.status), createdAt: resultCard.createdAt, kind: 'result' as const, } : null, ]; return [taskStep, ...cardSteps.filter((step): step is TaskUiStep => step !== null)]; } function buildResult(task: BackendTask, cards: TaskTimelineCard[], locale: AppLocale | string): TaskUiModel['result'] { const resultCard = [...cards].reverse().find((card) => card.type === 'result'); const summary = resultCard?.summary || firstString(task.metadata?.result_summary, task.close_reason); const bullets = summary .replace(/[。.!?]\s+/g, '\n') .split(/\n+/) .map((item) => item.trim()) .filter(Boolean) .slice(0, 3); return { status: normalizeStatus(resultCard?.status || task.status), title: resultCard?.title || pickAppText(locale, '本轮结果', 'Current result'), summary: summary || '', bullets, }; } export function buildTaskUiModel({ task, process, cards, locale, }: { task: BackendTask; process: SessionProcessProjection; cards: TaskTimelineCard[]; locale: AppLocale | string; }): TaskUiModel { const steps = buildSteps(task, cards, locale); const team = buildTeam(cards); const tools = buildTools(cards, process.runs); return { executionMode: team.hasTeam ? 'team' : 'single', team, summary: steps[0], skills: buildSkills(task, cards, locale), tools, attempts: buildAttempts(process, cards, tools, locale), agentTree: buildAgentTree(process.runs), artifacts: buildArtifacts(process), steps, result: buildResult(task, cards, locale), }; }