393 lines
12 KiB
TypeScript
393 lines
12 KiB
TypeScript
import type { ProcessArtifact, ProcessEvent, ProcessRun, ProcessRunStatus } from '@/types';
|
|
import { getCurrentAppLocale, pickAppText, type AppLocale } from '@/lib/i18n/core';
|
|
|
|
const TERMINAL_STATUSES = new Set<ProcessRunStatus>(['done', 'error', 'cancelled']);
|
|
const ACTIVE_STATUSES = new Set<ProcessRunStatus>(['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 | undefined>): 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<string, unknown> | 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<string, ProcessRun[]> {
|
|
const map = new Map<string, ProcessRun[]>();
|
|
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<string, ProcessRun[]>): ProcessRun[] {
|
|
const collected: ProcessRun[] = [];
|
|
const stack = [rootRun];
|
|
const seen = new Set<string>();
|
|
|
|
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<T extends { run_id: string }>(items: T[]): Map<string, T[]> {
|
|
const map = new Map<string, T[]>();
|
|
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<string, ProcessEvent[]>,
|
|
artifactsByRun: Map<string, ProcessArtifact[]>,
|
|
): 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<string, ProcessEvent[]>,
|
|
artifactsByRun: Map<string, ProcessArtifact[]>,
|
|
): 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<ProcessArtifact['artifact_type'], number>();
|
|
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<string, ProcessEvent[]>,
|
|
artifactsByRun: Map<string, ProcessArtifact[]>,
|
|
): 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<string, ProcessRun[]>();
|
|
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),
|
|
};
|
|
}
|