import type { ProcessArtifact, ProcessEvent, ProcessRun, ProcessRunStatus } from '@/types'; import { getCurrentAppLocale, pickAppText, type AppLocale } from '@/lib/i18n/core'; const TERMINAL_STATUSES = new Set(['done', 'error', 'cancelled']); const ACTIVE_STATUSES = new Set(['queued', 'running', 'waiting']); const ARTIFACT_TYPE_ORDER: ProcessArtifact['artifact_type'][] = [ 'text', 'json', 'file', 'image', 'link', 'markdown', ]; export interface SessionProgressValueView { label: string; value: number | null; max: number | null; percent: number | null; } export interface SessionProgressStepView { runId: string; title: string; actorName: string; status: ProcessRunStatus; description: string | null; startedAt: string; updatedAt: string; finishedAt: string | null; artifactCount: number; isRoot: boolean; isCurrent: boolean; } export interface SessionProgressArtifactView { artifactId: string; runId: string; title: string; type: ProcessArtifact['artifact_type']; typeLabel: string; actorName: string; preview: string; createdAt: string; url?: string; } export interface SessionProgressArtifactTypeSummary { type: ProcessArtifact['artifact_type']; count: number; label: string; } export interface SessionProgressView { rootRunId: string; title: string; status: ProcessRunStatus; summary: string | null; updatedAt: string; progress: SessionProgressValueView; steps: SessionProgressStepView[]; artifacts: SessionProgressArtifactView[]; artifactTypeSummaries: SessionProgressArtifactTypeSummary[]; } export type BuildSessionProgressInput = { sessionId: string; processRuns: ProcessRun[]; processEvents: ProcessEvent[]; processArtifacts: ProcessArtifact[]; locale?: AppLocale; }; function toTime(value?: string | null): number | null { if (!value) return null; const parsed = new Date(value).getTime(); return Number.isFinite(parsed) ? parsed : null; } function latestTimestamp(values: Array): string | null { let selected: string | null = null; let selectedTime = -1; for (const value of values) { const time = toTime(value); if (time === null || time <= selectedTime) continue; selected = value ?? null; selectedTime = time; } return selected; } function compareIsoDesc(a?: string | null, b?: string | null): number { return (toTime(b) ?? 0) - (toTime(a) ?? 0); } function firstNumber(metadata: Record | undefined, keys: string[]): number | null { for (const key of keys) { const value = metadata?.[key]; if (typeof value === 'number' && Number.isFinite(value)) return value; } return null; } function buildChildrenMap(processRuns: ProcessRun[]): Map { const map = new Map(); for (const run of processRuns) { if (!run.parent_run_id) continue; const children = map.get(run.parent_run_id); if (children) { children.push(run); } else { map.set(run.parent_run_id, [run]); } } return map; } function collectRunTree(rootRun: ProcessRun, childrenMap: Map): ProcessRun[] { const collected: ProcessRun[] = []; const stack = [rootRun]; const seen = new Set(); while (stack.length > 0) { const current = stack.pop(); if (!current || seen.has(current.run_id)) continue; seen.add(current.run_id); collected.push(current); const children = childrenMap.get(current.run_id) ?? []; for (let index = children.length - 1; index >= 0; index -= 1) { stack.push(children[index]); } } return collected; } function groupByRunId(items: T[]): Map { const map = new Map(); for (const item of items) { const existing = map.get(item.run_id); if (existing) { existing.push(item); } else { map.set(item.run_id, [item]); } } return map; } function getRunUpdatedAt( run: ProcessRun, eventsByRun: Map, artifactsByRun: Map, ): string { return ( latestTimestamp([ run.finished_at, run.started_at, ...(eventsByRun.get(run.run_id) ?? []).map((event) => event.created_at), ...(artifactsByRun.get(run.run_id) ?? []).map((artifact) => artifact.created_at), ]) ?? run.started_at ); } function getTreeUpdatedAt( runs: ProcessRun[], eventsByRun: Map, artifactsByRun: Map, ): string { return latestTimestamp(runs.map((run) => getRunUpdatedAt(run, eventsByRun, artifactsByRun))) ?? runs[0]?.started_at ?? ''; } function latestEventText(events: ProcessEvent[]): string | null { const event = [...events] .filter((item) => item.text?.trim()) .sort((a, b) => compareIsoDesc(a.created_at, b.created_at))[0]; return event?.text?.trim() || null; } function percent(value: number, max: number): number { return Math.max(0, Math.min(100, Math.round((value / max) * 100))); } function explicitProgress( rootRun: ProcessRun, treeEvents: ProcessEvent[], locale: AppLocale, ): SessionProgressValueView | null { const metadataSources = [ rootRun.metadata, ...[...treeEvents] .sort((a, b) => compareIsoDesc(a.created_at, b.created_at)) .map((event) => event.metadata), ]; for (const metadata of metadataSources) { const stepValue = firstNumber(metadata, ['step_index']); const stepMax = firstNumber(metadata, ['step_total']); if (stepValue !== null && stepMax !== null && stepMax > 0) { const safeValue = Math.min(stepValue, stepMax); return { label: pickAppText(locale, `运行中:${safeValue} / ${stepMax} 步`, `Running: ${safeValue} / ${stepMax} steps`), value: safeValue, max: stepMax, percent: percent(safeValue, stepMax), }; } const stageValue = firstNumber(metadata, ['stage_index', 'phase_index']); const stageMax = firstNumber(metadata, ['stage_total', 'phase_total']); if (stageValue !== null && stageMax !== null && stageMax > 0) { const safeValue = Math.min(stageValue, stageMax); return { label: pickAppText(locale, `运行中:${safeValue} / ${stageMax} 阶段`, `Running: ${safeValue} / ${stageMax} stages`), value: safeValue, max: stageMax, percent: percent(safeValue, stageMax), }; } } return null; } function fallbackProgress(taskRuns: ProcessRun[], locale: AppLocale): SessionProgressValueView { const childRuns = taskRuns.filter((run) => run.parent_run_id); const runsForProgress = childRuns.length > 0 ? childRuns : taskRuns; const doneRuns = runsForProgress.filter((run) => run.status === 'done').length; const totalRuns = runsForProgress.length; if (totalRuns > 0) { return { label: pickAppText(locale, `已完成 ${doneRuns} / ${totalRuns} 步`, `Completed ${doneRuns} / ${totalRuns} steps`), value: doneRuns, max: totalRuns, percent: percent(doneRuns, totalRuns), }; } return { label: pickAppText(locale, '等待任务数据', 'Waiting for task data'), value: null, max: null, percent: null, }; } function artifactTypeLabel(type: ProcessArtifact['artifact_type'], locale: AppLocale): string { if (type === 'text') return pickAppText(locale, '文本', 'Text'); if (type === 'json') return 'JSON'; if (type === 'file') return pickAppText(locale, '文件', 'File'); if (type === 'image') return pickAppText(locale, '图片', 'Image'); if (type === 'link') return pickAppText(locale, '链接', 'Link'); return 'Markdown'; } function artifactPreview(artifact: ProcessArtifact, locale: AppLocale): string { if (artifact.content?.trim()) { return artifact.content.trim().replace(/\s+/g, ' ').slice(0, 120); } if (artifact.url?.trim()) return artifact.url.trim(); if (artifact.data !== undefined) { return JSON.stringify(artifact.data).slice(0, 120); } return pickAppText(locale, '暂无预览', 'No preview'); } function buildArtifactSummaries( artifacts: ProcessArtifact[], locale: AppLocale, ): SessionProgressArtifactTypeSummary[] { const counts = new Map(); for (const artifact of artifacts) { counts.set(artifact.artifact_type, (counts.get(artifact.artifact_type) ?? 0) + 1); } return ARTIFACT_TYPE_ORDER .filter((type) => counts.has(type)) .map((type) => ({ type, count: counts.get(type) ?? 0, label: artifactTypeLabel(type, locale), })); } function buildArtifactViews( artifacts: ProcessArtifact[], locale: AppLocale, ): SessionProgressArtifactView[] { return [...artifacts] .sort((a, b) => compareIsoDesc(a.created_at, b.created_at)) .map((artifact) => ({ artifactId: artifact.artifact_id, runId: artifact.run_id, title: artifact.title, type: artifact.artifact_type, typeLabel: artifactTypeLabel(artifact.artifact_type, locale), actorName: artifact.actor_name || artifact.actor_id, preview: artifactPreview(artifact, locale), createdAt: artifact.created_at, url: artifact.url, })); } function buildSteps( rootRun: ProcessRun, taskRuns: ProcessRun[], eventsByRun: Map, artifactsByRun: Map, ): SessionProgressStepView[] { return [...taskRuns] .sort((a, b) => { if (a.run_id === rootRun.run_id) return 1; if (b.run_id === rootRun.run_id) return -1; return (toTime(a.started_at) ?? 0) - (toTime(b.started_at) ?? 0); }) .map((run) => { const runEvents = eventsByRun.get(run.run_id) ?? []; const runArtifacts = artifactsByRun.get(run.run_id) ?? []; return { runId: run.run_id, title: run.title, actorName: run.actor_name, status: run.status, description: latestEventText(runEvents) || run.summary?.trim() || null, startedAt: run.started_at, updatedAt: getRunUpdatedAt(run, eventsByRun, artifactsByRun), finishedAt: run.finished_at ?? null, artifactCount: runArtifacts.length, isRoot: run.run_id === rootRun.run_id, isCurrent: !TERMINAL_STATUSES.has(run.status), }; }); } export function buildSessionProgressView({ sessionId, processRuns, processEvents, processArtifacts, locale = getCurrentAppLocale(), }: BuildSessionProgressInput): SessionProgressView | null { const sessionRuns = processRuns.filter((run) => run.session_id === sessionId); const rootRuns = sessionRuns.filter((run) => !run.parent_run_id); if (rootRuns.length === 0) return null; const allChildrenMap = buildChildrenMap(processRuns); const runTreeCache = new Map(); const treeForRoot = (root: ProcessRun) => { const cached = runTreeCache.get(root.run_id); if (cached) return cached; const tree = collectRunTree(root, allChildrenMap).filter( (run) => run.session_id === sessionId || run.run_id === root.run_id ); runTreeCache.set(root.run_id, tree); return tree; }; const allEventsByRun = groupByRunId(processEvents); const allArtifactsByRun = groupByRunId(processArtifacts); const selectedRoot = [...rootRuns].sort((a, b) => { const aActive = ACTIVE_STATUSES.has(a.status); const bActive = ACTIVE_STATUSES.has(b.status); if (aActive !== bActive) return aActive ? -1 : 1; return compareIsoDesc( getTreeUpdatedAt(treeForRoot(a), allEventsByRun, allArtifactsByRun), getTreeUpdatedAt(treeForRoot(b), allEventsByRun, allArtifactsByRun) ); })[0]; if (!selectedRoot) return null; const taskRuns = treeForRoot(selectedRoot); const taskRunIds = new Set(taskRuns.map((run) => run.run_id)); const taskEvents = processEvents.filter((event) => taskRunIds.has(event.run_id)); const taskArtifacts = processArtifacts.filter((artifact) => taskRunIds.has(artifact.run_id)); const eventsByRun = groupByRunId(taskEvents); const artifactsByRun = groupByRunId(taskArtifacts); const updatedAt = getTreeUpdatedAt(taskRuns, eventsByRun, artifactsByRun); const progress = explicitProgress(selectedRoot, taskEvents, locale) ?? fallbackProgress(taskRuns, locale); return { rootRunId: selectedRoot.run_id, title: selectedRoot.title, status: selectedRoot.status, summary: selectedRoot.summary?.trim() || latestEventText(eventsByRun.get(selectedRoot.run_id) ?? []) || null, updatedAt, progress, steps: buildSteps(selectedRoot, taskRuns, eventsByRun, artifactsByRun), artifacts: buildArtifactViews(taskArtifacts, locale), artifactTypeSummaries: buildArtifactSummaries(taskArtifacts, locale), }; }