Files
beaver_project/app-instance/frontend/lib/task-timeline.ts

345 lines
9.5 KiB
TypeScript

import type {
BackendTask,
ProcessArtifact,
ProcessEvent,
ProcessRun,
TaskTimelineCard,
TaskTimelineCardType,
} from '@/types';
export type BuildTaskTimelineCardsInput = {
task: BackendTask;
processRuns?: ProcessRun[];
processEvents?: ProcessEvent[];
processArtifacts?: ProcessArtifact[];
};
const TIMELINE_CARD_TYPES = new Set<TaskTimelineCardType>([
'task_created',
'plan',
'skill',
'tool_call',
'tool_result',
'next_step',
'agent_team',
'agent_progress',
'agent_handoff',
'artifact',
'error',
'result',
'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<string, unknown> | 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;
}
if (event.status === 'error') {
return 'error';
}
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 '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:
return null;
}
}
function titleForCard(type: TaskTimelineCardType, actorName?: string): string {
switch (type) {
case 'task_created':
return '任务已创建';
case 'plan':
return '执行计划';
case 'skill':
return '选择 Skill';
case 'tool_call':
return actorName ? `调用工具:${actorName}` : '调用工具';
case 'tool_result':
return actorName ? `工具结果:${actorName}` : '工具结果';
case 'next_step':
return '下一步';
case 'agent_team':
return '启动 Agent Team';
case 'agent_progress':
return actorName || 'Agent 进展';
case 'agent_handoff':
return 'Agent 交接';
case 'artifact':
return '生成产物';
case 'error':
return '执行遇到问题';
case 'result':
return '本轮结果';
case 'acceptance':
return '任务验收';
}
}
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<string, unknown> | undefined {
const skillNames = normalizeSkillNames(event.metadata);
if (!event.metadata && !skillNames) {
return undefined;
}
return {
...(event.metadata ?? {}),
...(skillNames ? { skill_names: skillNames } : {}),
};
}
function feedbackCreatedAt(feedback: Record<string, unknown>, task: BackendTask): string {
return firstString(feedback.created_at, task.updated_at, task.created_at) ?? task.created_at;
}
function feedbackSummary(feedback: Record<string, unknown>): string | undefined {
return firstString(feedback.comment, feedback.summary, feedback.acceptance_type);
}
function resultSummary(task: BackendTask): string | undefined {
return firstString(
task.metadata?.result_summary,
task.metadata?.summary,
task.close_reason,
task.validation_result?.summary,
);
}
function buildRunMap(processRuns: ProcessRun[]): Map<string, ProcessRun> {
const map = new Map<string, ProcessRun>();
for (const run of processRuns) {
map.set(run.run_id, run);
}
return map;
}
function lastItem<T>(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;
}
function acceptanceKey(runId: string | null | undefined, createdAt: string): string {
return `${runId ?? ''}:${createdAt}`;
}
export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): TaskTimelineCard[] {
const { task } = input;
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 runsWithProgressEvents = new Set<string>();
const acceptanceEventKeys = new Set<string>();
let hasResultEventCard = false;
const cards: TaskTimelineCard[] = [
{
id: `${task.task_id}:created`,
taskId: task.task_id,
type: 'task_created',
title: titleForCard('task_created'),
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') {
acceptanceEventKeys.add(acceptanceKey(event.run_id, event.created_at));
}
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),
summary: summaryForEvent(event),
actorName: event.actor_name,
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),
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'),
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'),
summary: resultSummary(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 (acceptanceEventKeys.has(acceptanceKey(runId, createdAt))) continue;
cards.push({
id: `${task.task_id}:acceptance:${index}`,
taskId: task.task_id,
runId,
type: 'acceptance',
title: titleForCard('acceptance'),
summary: feedbackSummary(feedback),
status: firstString(feedback.acceptance_type),
createdAt,
details: feedback,
});
}
return cards
.map((card, index) => ({ card, index }))
.sort(compareCardsByCreatedAt)
.map(({ card }) => card);
}