From 7b638b083adcaf32c9252b7c234aba3e6c7eb9e4 Mon Sep 17 00:00:00 2001 From: steven_li Date: Tue, 26 May 2026 11:28:57 +0800 Subject: [PATCH] feat: add task timeline model --- .../frontend/lib/task-timeline.test.ts | 172 ++++++++++ app-instance/frontend/lib/task-timeline.ts | 297 ++++++++++++++++++ app-instance/frontend/types/index.ts | 30 ++ 3 files changed, 499 insertions(+) create mode 100644 app-instance/frontend/lib/task-timeline.test.ts create mode 100644 app-instance/frontend/lib/task-timeline.ts diff --git a/app-instance/frontend/lib/task-timeline.test.ts b/app-instance/frontend/lib/task-timeline.test.ts new file mode 100644 index 0000000..c95670e --- /dev/null +++ b/app-instance/frontend/lib/task-timeline.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest'; + +import { buildTaskTimelineCards } from '@/lib/task-timeline'; +import type { BackendTask, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types'; + +function makeTask(overrides: Partial = {}): BackendTask { + return { + task_id: 'task-1', + session_id: 'web:default', + description: 'Research the market', + short_title: 'Market research', + is_open: true, + goal: 'Summarize the market', + constraints: [], + priority: 1, + status: 'running', + creator: 'user', + created_at: '2026-05-26T10:00:00.000Z', + updated_at: '2026-05-26T10:00:00.000Z', + run_ids: ['run-main'], + skill_names: [], + feedback: [], + metadata: {}, + ...overrides, + }; +} + +function eventKind(kind: string): ProcessEvent['kind'] { + return kind as ProcessEvent['kind']; +} + +describe('buildTaskTimelineCards', () => { + it('builds ordered timeline cards from task process data', () => { + const task = makeTask(); + const processRuns: ProcessRun[] = [ + { + run_id: 'run-main', + parent_run_id: null, + session_id: 'web:default', + actor_type: 'agent', + actor_id: 'main-agent', + actor_name: 'Main Agent', + title: 'Plan and coordinate', + status: 'running', + started_at: '2026-05-26T10:00:30.000Z', + }, + { + run_id: 'run-research', + parent_run_id: 'run-main', + session_id: 'web:default', + actor_type: 'agent', + actor_id: 'research-agent', + actor_name: 'Research Agent', + title: 'Read source documents', + status: 'done', + started_at: '2026-05-26T10:05:00.000Z', + finished_at: '2026-05-26T10:05:30.000Z', + summary: 'Finished reading source documents.', + }, + ]; + const processEvents: ProcessEvent[] = [ + { + event_id: 'evt-plan', + run_id: 'run-main', + parent_run_id: null, + kind: eventKind('task_planned'), + actor_type: 'agent', + actor_id: 'main-agent', + actor_name: 'Main Agent', + text: 'Plan created.', + created_at: '2026-05-26T10:01:00.000Z', + }, + { + event_id: 'evt-skill', + run_id: 'run-main', + parent_run_id: null, + kind: eventKind('skill_selected'), + actor_type: 'system', + actor_id: 'skill-router', + actor_name: 'Skill Router', + text: 'Research skill selected.', + created_at: '2026-05-26T10:02:00.000Z', + metadata: { + selected_skill_names: ['research'], + reason: 'Need source review.', + }, + }, + { + event_id: 'evt-tool-start', + run_id: 'run-research', + parent_run_id: 'run-main', + kind: eventKind('tool_call_started'), + actor_type: 'mcp', + actor_id: 'document-reader', + actor_name: 'Document Reader', + text: 'Reading source documents.', + created_at: '2026-05-26T10:03:00.000Z', + }, + { + event_id: 'evt-tool-finish', + run_id: 'run-research', + parent_run_id: 'run-main', + kind: eventKind('tool_call_finished'), + actor_type: 'mcp', + actor_id: 'document-reader', + actor_name: 'Document Reader', + text: 'Documents read.', + created_at: '2026-05-26T10:04:00.000Z', + metadata: { + result_summary: '2 documents read successfully.', + }, + }, + ]; + const processArtifacts: ProcessArtifact[] = [ + { + artifact_id: 'artifact-summary', + run_id: 'run-research', + actor_type: 'agent', + actor_id: 'research-agent', + actor_name: 'Research Agent', + title: 'Research summary', + artifact_type: 'markdown', + content: '# Summary', + created_at: '2026-05-26T10:06:00.000Z', + }, + ]; + + const cards = buildTaskTimelineCards({ + task, + processRuns, + processEvents, + processArtifacts, + }); + + expect(cards.map((card) => card.type)).toEqual([ + 'task_created', + 'plan', + 'skill', + 'tool_call', + 'tool_result', + 'agent_progress', + 'artifact', + ]); + expect(cards[1].title).toBe('执行计划'); + expect(cards[2].title).toBe('选择 Skill'); + expect(cards[4].summary).toBe('2 documents read successfully.'); + expect(cards[6].relatedArtifactIds).toEqual(['artifact-summary']); + }); + + it('appends result and acceptance cards for closed tasks with feedback', () => { + const task = makeTask({ + is_open: false, + status: 'closed', + updated_at: '2026-05-26T10:04:00.000Z', + closed_at: '2026-05-26T10:04:00.000Z', + feedback: [ + { + acceptance_type: 'accept', + comment: '可以', + created_at: '2026-05-26T10:05:00.000Z', + run_id: 'run-main', + }, + ], + }); + + const cards = buildTaskTimelineCards({ task }); + + expect(cards.at(-2)?.type).toBe('result'); + expect(cards.at(-1)?.type).toBe('acceptance'); + expect(cards.at(-1)?.summary).toContain('可以'); + }); +}); diff --git a/app-instance/frontend/lib/task-timeline.ts b/app-instance/frontend/lib/task-timeline.ts new file mode 100644 index 0000000..5d90b1a --- /dev/null +++ b/app-instance/frontend/lib/task-timeline.ts @@ -0,0 +1,297 @@ +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([ + '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 { + const parsed = new Date(value).getTime(); + return Number.isFinite(parsed) ? parsed : 0; +} + +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 { + if (isTimelineCardType(event.metadata?.timeline_type)) { + return event.metadata.timeline_type; + } + + 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 | 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 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 { + const map = new Map(); + for (const run of processRuns) { + map.set(run.run_id, run); + } + return map; +} + +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 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; + + 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; + + 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)) { + cards.push({ + id: `${task.task_id}:result`, + taskId: task.task_id, + runId: task.run_ids.at(-1) ?? null, + 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 (const [index, feedback] of task.feedback.entries()) { + cards.push({ + id: `${task.task_id}:acceptance:${index}`, + taskId: task.task_id, + runId: firstString(feedback.run_id) ?? null, + type: 'acceptance', + title: titleForCard('acceptance'), + summary: feedbackSummary(feedback), + status: firstString(feedback.acceptance_type), + createdAt: feedbackCreatedAt(feedback, task), + details: feedback, + }); + } + + return cards + .map((card, index) => ({ card, index })) + .sort((a, b) => toTime(a.card.createdAt) - toTime(b.card.createdAt) || a.index - b.index) + .map(({ card }) => card); +} diff --git a/app-instance/frontend/types/index.ts b/app-instance/frontend/types/index.ts index bd7b2da..33ef53b 100644 --- a/app-instance/frontend/types/index.ts +++ b/app-instance/frontend/types/index.ts @@ -771,6 +771,36 @@ export interface ProcessArtifact { created_at: string; } +export type TaskTimelineCardType = + | 'task_created' + | 'plan' + | 'skill' + | 'tool_call' + | 'tool_result' + | 'next_step' + | 'agent_team' + | 'agent_progress' + | 'agent_handoff' + | 'artifact' + | 'error' + | 'result' + | 'acceptance'; + +export interface TaskTimelineCard { + id: string; + taskId: string; + runId?: string | null; + parentRunId?: string | null; + type: TaskTimelineCardType; + title: string; + summary?: string; + actorName?: string; + status?: string; + createdAt: string; + relatedArtifactIds?: string[]; + details?: Record; +} + export interface SessionProcessProjection { runs: ProcessRun[]; events: ProcessEvent[];