From 22314ff9b046bfe3e1493e4ef830954e0e58cbaf Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 21 Sep 2026 08:04:27 +0000 Subject: [PATCH] feat(web): add TaskRunCard vertical timeline for task runs Co-authored-by: Cursor --- web/src/components/chat/TaskRunCard.test.ts | 93 +++++++ web/src/components/chat/TaskRunCard.tsx | 280 ++++++++++++++++++++ web/src/lib/taskRunCard.test.ts | 102 +++++++ web/src/lib/taskRunCard.ts | 93 +++++++ web/src/types/workbench.ts | 15 ++ 5 files changed, 583 insertions(+) create mode 100644 web/src/components/chat/TaskRunCard.test.ts create mode 100644 web/src/components/chat/TaskRunCard.tsx create mode 100644 web/src/lib/taskRunCard.test.ts create mode 100644 web/src/lib/taskRunCard.ts diff --git a/web/src/components/chat/TaskRunCard.test.ts b/web/src/components/chat/TaskRunCard.test.ts new file mode 100644 index 00000000..a203cd44 --- /dev/null +++ b/web/src/components/chat/TaskRunCard.test.ts @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import type { ChatMessage } from '../../types/workbench'; +import { TaskRunCard } from './TaskRunCard'; + +function baseMessage(overrides: Partial = {}): ChatMessage { + return { + id: 'asst-1', + sessionId: 'sess-1', + role: 'assistant', + content: '任务执行中…', + timestamp: '12:00', + status: 'streaming_status', + taskRunId: 'run-1', + livePhaseLabel: '法规专员正在整理素材…', + phaseEvents: [ + { step: 'squad_assembly', icon: '👥', label: '组建小队', status: 'completed' }, + { + step: 'step_executing#1', + icon: '⚙️', + label: '专员执行(第 1/3 步)', + status: 'running', + detail: '法规专员正在整理素材…', + }, + ], + taskRequest: { + taskKey: 'intake', + projectName: 'P', + customerName: 'C', + deviceType: 'D', + sessionId: 'sess-1', + userId: 1, + projectId: 1, + clientTurnId: 't1', + userMessage: '编写用户初始需求收集表', + }, + ...overrides, + }; +} + +test('TaskRunCard renders timeline with title badge and current step', () => { + const html = renderToStaticMarkup(React.createElement(TaskRunCard, { message: baseMessage() })); + assert.match(html, /data-testid="task-run-card"/); + assert.match(html, /编写用户初始需求收集表/); + assert.match(html, /执行中/); + assert.match(html, /法规专员正在整理素材…/); + assert.match(html, /data-testid="task-run-card-timeline"/); + assert.match(html, /组建小队/); + assert.match(html, /专员执行(第 1\/3 步)/); +}); + +test('TaskRunCard completed state keeps card with duration and artifact', () => { + const html = renderToStaticMarkup( + React.createElement(TaskRunCard, { + message: baseMessage({ + status: 'completed', + content: '已完成用户初始需求收集表', + livePhaseLabel: undefined, + taskDurationMs: 95_000, + taskArtifact: { title: '用户初始需求收集表', quality_score: 92 }, + phaseEvents: [ + { step: 'squad_assembly', icon: '👥', label: '组建小队', status: 'completed' }, + { step: 'quality_gate', icon: '🛡️', label: '质量门禁', status: 'completed' }, + ], + }), + }), + ); + assert.match(html, /data-testid="task-run-card"/); + assert.match(html, /已完成/); + assert.match(html, /耗时 1m 35s/); + assert.match(html, /用户初始需求收集表/); + assert.match(html, /质量 92/); + assert.doesNotMatch(html, />执行中 { + const html = renderToStaticMarkup( + React.createElement(TaskRunCard, { + message: baseMessage({ + status: 'completed', + phaseEvents: undefined, + livePhaseLabel: undefined, + taskRequest: undefined, + taskArtifact: { title: '调研报告' }, + }), + }), + ); + assert.match(html, /data-testid="task-run-card"/); + assert.match(html, /调研报告/); + assert.match(html, /暂无步骤明细/); +}); diff --git a/web/src/components/chat/TaskRunCard.tsx b/web/src/components/chat/TaskRunCard.tsx new file mode 100644 index 00000000..4fd5d662 --- /dev/null +++ b/web/src/components/chat/TaskRunCard.tsx @@ -0,0 +1,280 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { CheckCircle2, Loader2, Circle, AlertTriangle, Clock3, FileText } from 'lucide-react'; +import type { ChatMessage, PhaseEvent } from '../../types/workbench'; +import { + formatTaskDuration, + resolveTaskRunCardBadge, + resolveTaskRunCardTitle, + runningPhaseDetail, + type TaskRunCardBadgeTone, +} from '../../lib/taskRunCard'; + +export interface TaskRunCardProps { + message: ChatMessage; +} + +const TONE_STYLE: Record< + TaskRunCardBadgeTone, + { color: string; background: string; border: string } +> = { + running: { + color: 'var(--brand-500)', + background: 'rgba(37, 99, 235, 0.10)', + border: '1px solid rgba(37, 99, 235, 0.28)', + }, + success: { + color: 'var(--success)', + background: 'rgba(22, 163, 74, 0.10)', + border: '1px solid rgba(22, 163, 74, 0.28)', + }, + warning: { + color: '#b45309', + background: 'rgba(245, 158, 11, 0.16)', + border: '1px solid rgba(245, 158, 11, 0.35)', + }, + danger: { + color: 'var(--danger)', + background: 'rgba(220, 38, 38, 0.10)', + border: '1px solid rgba(220, 38, 38, 0.28)', + }, + muted: { + color: 'var(--text-muted)', + background: 'var(--bg-subtle)', + border: '1px solid var(--border)', + }, +}; + +function StepStatusIcon({ status }: { status: PhaseEvent['status'] }) { + if (status === 'completed') { + return ; + } + if (status === 'running') { + return ; + } + if (status === 'failed') { + return ; + } + return ; +} + +export const TaskRunCard: React.FC = ({ message }) => { + const title = resolveTaskRunCardTitle(message); + const events = message.phaseEvents || message.phaseSnapshot?.steps || []; + const hasRunningStep = events.some((e) => e.status === 'running'); + const badge = resolveTaskRunCardBadge(message.status, hasRunningStep); + const tone = TONE_STYLE[badge.tone]; + const currentDetail = runningPhaseDetail(events, message.livePhaseLabel); + const isLive = + message.status === 'streaming_status' || + message.status === 'pending' || + message.status === 'auto_retrying' || + hasRunningStep; + + const [tickMs, setTickMs] = useState(message.taskDurationMs); + useEffect(() => { + if (!isLive) { + setTickMs(message.taskDurationMs); + return; + } + const startedAt = message.createdAt ? Date.parse(message.createdAt) : NaN; + if (!Number.isFinite(startedAt)) { + setTickMs(message.taskDurationMs); + return; + } + const update = () => setTickMs(Math.max(0, Date.now() - startedAt)); + update(); + const id = window.setInterval(update, 1000); + return () => window.clearInterval(id); + }, [isLive, message.createdAt, message.taskDurationMs]); + + const durationLabel = useMemo(() => formatTaskDuration(tickMs ?? message.taskDurationMs), [ + tickMs, + message.taskDurationMs, + ]); + + const artifact = message.taskArtifact; + const artifactBits = [ + artifact?.title, + artifact?.quality_score != null ? `质量 ${artifact.quality_score}` : null, + ].filter(Boolean) as string[]; + + return ( +
+
+
+
+ {title} +
+ {durationLabel && ( +
+ + {isLive ? `已用 ${durationLabel}` : `耗时 ${durationLabel}`} +
+ )} +
+ + {badge.tone === 'running' && } + {badge.label} + +
+ + {isLive && currentDetail && ( +
+ + {currentDetail} +
+ )} + + {events.length > 0 && ( +
    + {events.map((evt, idx) => { + const isRunning = evt.status === 'running'; + const isFailed = evt.status === 'failed'; + const isDone = evt.status === 'completed'; + return ( +
  1. + + {evt.icon} + + {evt.label} + +
  2. + ); + })} +
+ )} + + {events.length === 0 && !isLive && ( +
+ 暂无步骤明细 +
+ )} + + {artifactBits.length > 0 && ( +
+ + {artifactBits.join(' · ')} +
+ )} +
+ ); +}; + +export default TaskRunCard; diff --git a/web/src/lib/taskRunCard.test.ts b/web/src/lib/taskRunCard.test.ts new file mode 100644 index 00000000..6a10f6bf --- /dev/null +++ b/web/src/lib/taskRunCard.test.ts @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + formatTaskDuration, + resolveTaskRunCardBadge, + resolveTaskRunCardTitle, + runningPhaseDetail, + shouldRenderTaskRunCard, + truncateTitle, +} from './taskRunCard'; + +test('resolveTaskRunCardTitle prefers artifact title', () => { + assert.equal( + resolveTaskRunCardTitle({ + taskArtifact: { title: '用户初始需求收集表' }, + taskRequest: { taskKey: 'intake', userMessage: '写需求表', projectName: '', customerName: '', deviceType: '', sessionId: '', userId: 0, projectId: 0, clientTurnId: '' }, + }), + '用户初始需求收集表', + ); +}); + +test('resolveTaskRunCardTitle falls back to user message then taskKey', () => { + assert.equal( + resolveTaskRunCardTitle({ + taskRequest: { + taskKey: 'intake', + userMessage: '请编写用户初始需求收集表并补充用途', + projectName: '', + customerName: '', + deviceType: '', + sessionId: '', + userId: 0, + projectId: 0, + clientTurnId: '', + }, + }), + '请编写用户初始需求收集表并补充用途', + ); + assert.equal( + resolveTaskRunCardTitle({ + taskRequest: { + taskKey: 'bd_intake', + projectName: '', + customerName: '', + deviceType: '', + sessionId: '', + userId: 0, + projectId: 0, + clientTurnId: '', + }, + }), + 'bd_intake', + ); +}); + +test('truncateTitle ellipsizes long text', () => { + const long = '请编写一份完整的用户初始需求收集表并补充预期用途与目标市场信息以便后续法规对齐'; + const out = truncateTitle(long, 20); + assert.equal(out.length, 20); + assert.ok(out.endsWith('…')); +}); + +test('formatTaskDuration covers seconds minutes hours', () => { + assert.equal(formatTaskDuration(undefined), null); + assert.equal(formatTaskDuration(4500), '4s'); + assert.equal(formatTaskDuration(65_000), '1m 5s'); + assert.equal(formatTaskDuration(3_600_000), '1h'); + assert.equal(formatTaskDuration(3_900_000), '1h 5m'); +}); + +test('resolveTaskRunCardBadge maps terminal and live states', () => { + assert.deepEqual(resolveTaskRunCardBadge('completed', false), { label: '已完成', tone: 'success' }); + assert.deepEqual(resolveTaskRunCardBadge('streaming_status', true), { label: '执行中', tone: 'running' }); + assert.equal(resolveTaskRunCardBadge('failed', false).tone, 'danger'); + assert.equal(resolveTaskRunCardBadge('pending_author_confirmation', false).tone, 'warning'); +}); + +test('shouldRenderTaskRunCard keeps card shape when run id exists', () => { + assert.equal(shouldRenderTaskRunCard({ role: 'assistant', taskRunId: 'run-1' }), true); + assert.equal(shouldRenderTaskRunCard({ role: 'user', taskRunId: 'run-1' }), false); + assert.equal( + shouldRenderTaskRunCard({ + role: 'assistant', + phaseEvents: [{ step: 'a', icon: 'x', label: 'y', status: 'completed' }], + }), + true, + ); +}); + +test('runningPhaseDetail prefers live label when it differs from step', () => { + assert.equal( + runningPhaseDetail( + [{ step: 'step_executing#1', icon: '⚙️', label: '专员执行', status: 'running', detail: '撰写中' }], + '法规专员正在整理素材…', + ), + '法规专员正在整理素材…', + ); + assert.equal( + runningPhaseDetail([{ step: 'a', icon: 'x', label: '组建小队', status: 'running', detail: '组建小队' }], '组建小队'), + '组建小队', + ); +}); diff --git a/web/src/lib/taskRunCard.ts b/web/src/lib/taskRunCard.ts new file mode 100644 index 00000000..6fb6af2f --- /dev/null +++ b/web/src/lib/taskRunCard.ts @@ -0,0 +1,93 @@ +import type { AssistantMessageStatus, ChatMessage, PhaseEvent } from '../types/workbench'; +import { assistantTaskStatusLabel } from '../api/chatWindow'; + +const TITLE_MAX = 48; + +/** 卡片标题:产物名 > 用户原话截断 > taskKey > 默认 */ +export function resolveTaskRunCardTitle(message: Pick< + ChatMessage, + 'taskArtifact' | 'taskRequest' | 'phaseSnapshot' | 'content' +>): string { + const fromSnapshot = message.phaseSnapshot?.title?.trim(); + if (fromSnapshot) return truncateTitle(fromSnapshot); + + const fromArtifact = message.taskArtifact?.title?.trim(); + if (fromArtifact) return truncateTitle(fromArtifact); + + const userMsg = message.taskRequest?.userMessage?.trim(); + if (userMsg) return truncateTitle(userMsg); + + const taskKey = message.taskRequest?.taskKey?.trim() || message.taskArtifact?.task_key?.trim(); + if (taskKey) return truncateTitle(taskKey); + + const firstLine = (message.content || '') + .split('\n') + .map((line) => line.replace(/^#+\s*/, '').trim()) + .find((line) => line.length > 0 && line !== '任务执行中…'); + if (firstLine) return truncateTitle(firstLine); + + return '任务执行'; +} + +export function truncateTitle(text: string, max = TITLE_MAX): string { + const trimmed = text.trim(); + if (trimmed.length <= max) return trimmed; + return `${trimmed.slice(0, max - 1)}…`; +} + +/** 毫秒 → 可读耗时(中文短格式) */ +export function formatTaskDuration(ms: number | undefined | null): string | null { + if (ms == null || !Number.isFinite(ms) || ms < 0) return null; + const totalSec = Math.floor(ms / 1000); + if (totalSec < 60) return `${totalSec}s`; + const minutes = Math.floor(totalSec / 60); + const seconds = totalSec % 60; + if (minutes < 60) { + return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; + } + const hours = Math.floor(minutes / 60); + const remMin = minutes % 60; + return remMin > 0 ? `${hours}h ${remMin}m` : `${hours}h`; +} + +export type TaskRunCardBadgeTone = 'running' | 'success' | 'warning' | 'danger' | 'muted'; + +export function resolveTaskRunCardBadge( + status: AssistantMessageStatus | undefined, + hasRunningStep: boolean, +): { label: string; tone: TaskRunCardBadgeTone } { + const chip = assistantTaskStatusLabel(status); + if (chip) { + if (status === 'pending_author_confirmation') return { label: chip, tone: 'warning' }; + if (status === 'quality_failed' || status === 'failed') return { label: chip, tone: 'danger' }; + if (status === 'cancelled' || status === 'blocked_pending_governance') { + return { label: chip, tone: 'muted' }; + } + return { label: chip, tone: 'muted' }; + } + if (status === 'completed') return { label: '已完成', tone: 'success' }; + if (status === 'streaming_status' || status === 'pending' || status === 'auto_retrying' || hasRunningStep) { + return { label: '执行中', tone: 'running' }; + } + return { label: '任务', tone: 'muted' }; +} + +/** 是否应渲染任务执行卡片(有 run / 进度 / 任务请求即保留卡片形态) */ +export function shouldRenderTaskRunCard( + message: Pick, +): boolean { + if (message.role !== 'assistant') return false; + if (message.taskRunId) return true; + if (message.phaseSnapshot?.steps?.length) return true; + if (message.phaseEvents && message.phaseEvents.length > 0) return true; + if (message.taskRequest?.taskKey) return true; + return false; +} + +export function runningPhaseDetail(events: PhaseEvent[] | undefined, liveLabel?: string): string | undefined { + const running = events?.find((e) => e.status === 'running'); + const fromStep = running?.detail?.trim() || running?.label?.trim(); + const live = liveLabel?.trim(); + if (live && live !== fromStep) return live; + return fromStep || live || undefined; +} diff --git a/web/src/types/workbench.ts b/web/src/types/workbench.ts index 998e77a9..905527a3 100644 --- a/web/src/types/workbench.ts +++ b/web/src/types/workbench.ts @@ -55,6 +55,17 @@ export interface PhaseEvent { icon: string; label: string; status: 'pending' | 'running' | 'completed' | 'failed'; + /** 后端 phase.message 原文(刷新快照 / 时间线当前行展开用) */ + detail?: string; +} + +/** 服务端写入 chat_messages.metadata.task_phase_snapshot 的折叠进度快照 */ +export interface TaskPhaseSnapshot { + steps: PhaseEvent[]; + label?: string; + duration_ms?: number; + status?: string; + title?: string; } export interface RecommendedCase { @@ -186,6 +197,10 @@ export interface ChatMessage { branchTag?: string; timestamp: string; phaseEvents?: PhaseEvent[]; + /** 任务总耗时(毫秒);done 事件或 snapshot.duration_ms */ + taskDurationMs?: number; + /** 历史消息带来的 phase 快照标记(有则刷新不必再 replay) */ + phaseSnapshot?: TaskPhaseSnapshot; recommendedCases?: RecommendedCase[]; interactiveCard?: InteractiveCardData; status?: AssistantMessageStatus;