From 41ac87e3229198ef886201819fca4d0cc4576a95 Mon Sep 17 00:00:00 2001 From: steven_li Date: Tue, 26 May 2026 13:48:18 +0800 Subject: [PATCH] feat: make task detail timeline first --- .../app/(app)/tasks/[taskId]/page.tsx | 885 ++++-------------- 1 file changed, 176 insertions(+), 709 deletions(-) diff --git a/app-instance/frontend/app/(app)/tasks/[taskId]/page.tsx b/app-instance/frontend/app/(app)/tasks/[taskId]/page.tsx index f9b365f..ad36a2f 100644 --- a/app-instance/frontend/app/(app)/tasks/[taskId]/page.tsx +++ b/app-instance/frontend/app/(app)/tasks/[taskId]/page.tsx @@ -3,127 +3,134 @@ import Link from 'next/link'; import { useParams, useRouter } from 'next/navigation'; import React, { useMemo, useState } from 'react'; -import { AlertCircle, ArrowLeft, Bot, CheckCircle2, Download, FileText, Loader2, MessageSquare, RefreshCw, ThumbsUp, Trash2, User, XCircle } from 'lucide-react'; +import { AlertCircle, ArrowLeft, Loader2, Trash2 } from 'lucide-react'; -import { TaskRuntimeStatusBadge, formatTaskRuntimeDuration, formatTaskRuntimeTime, progressPercent } from '@/components/task-runtime/TaskRuntimeShared'; -import { Badge } from '@/components/ui/badge'; +import { + TaskAcceptanceCard, + TaskLiveHeader, + TaskSideRail, + TaskTimeline, + type TaskFeedbackItem, + type TaskFeedbackType, +} from '@/components/task-detail'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Textarea } from '@/components/ui/textarea'; -import { deleteBackendTask, getBackendTask, getFileUrl, submitChatFeedback } from '@/lib/api'; +import { Card, CardContent } from '@/components/ui/card'; +import { deleteBackendTask, getBackendTask, submitChatFeedback } from '@/lib/api'; import { pickAppText } from '@/lib/i18n/core'; import { useAppI18n } from '@/lib/i18n/provider'; -import { buildTaskRuntimeView, type TaskRuntimeNodeView } from '@/lib/task-runtime'; import { useChatStore } from '@/lib/store'; -import type { BackendTask, BackendTaskRun, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types'; +import { buildTaskTimelineCards } from '@/lib/task-timeline'; +import type { BackendTask } from '@/types'; -type TaskFeedbackType = 'accept' | 'revise' | 'abandon'; -type TaskFeedbackItem = { - acceptance_type?: unknown; - feedback_type?: unknown; - comment?: unknown; - created_at?: unknown; - run_id?: unknown; -}; - -function taskVisibleStatus(task: TaskRuntimeNodeView, locale: 'zh-CN' | 'en-US') { - if (task.status === 'error') return pickAppText(locale, '任务失败', 'Task failed'); - if (task.status === 'cancelled') return pickAppText(locale, '已取消', 'Cancelled'); - return task.stageLabel || task.status; -} - -function downloadText(filename: string, content: string) { - const url = URL.createObjectURL(new Blob([content], { type: 'text/plain;charset=utf-8' })); - const anchor = document.createElement('a'); - anchor.href = url; - anchor.download = filename; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - URL.revokeObjectURL(url); -} +const TERMINAL_TASK_STATUSES = new Set(['closed', 'abandoned', 'cancelled', 'error']); export default function TaskDetailPage() { const { locale } = useAppI18n(); const router = useRouter(); const params = useParams<{ taskId: string }>(); const taskId = decodeURIComponent(Array.isArray(params?.taskId) ? params.taskId[0] : params?.taskId ?? ''); - const sessions = useChatStore((state) => state.sessions); const processRuns = useChatStore((state) => state.processRuns); const processEvents = useChatStore((state) => state.processEvents); const processArtifacts = useChatStore((state) => state.processArtifacts); + const setSessionProcess = useChatStore((state) => state.setSessionProcess); const updateMessageFeedback = useChatStore((state) => state.updateMessageFeedback); + const wsStatus = useChatStore((state) => state.wsStatus); - const task = useMemo( - () => buildTaskRuntimeView(taskId, { sessions, processRuns, processEvents, processArtifacts }, locale), - [locale, processArtifacts, processEvents, processRuns, sessions, taskId] - ); const [backendTask, setBackendTask] = useState(null); - const [backendTaskLoading, setBackendTaskLoading] = useState(false); - const [selectedRunId, setSelectedRunId] = useState(task?.rootRunId ?? null); + const [backendTaskLoading, setBackendTaskLoading] = useState(true); const [revision, setRevision] = useState(''); - const [runtimeFeedback, setRuntimeFeedback] = useState(null); const [actionError, setActionError] = useState(null); const [actionBusy, setActionBusy] = useState(null); + const mountedRef = React.useRef(true); React.useEffect(() => { - setSelectedRunId(task?.rootRunId ?? null); - setRuntimeFeedback(null); - }, [task?.rootRunId]); - - React.useEffect(() => { - let cancelled = false; - if (task || !taskId) { - setBackendTask(null); - return () => { - cancelled = true; - }; - } - setBackendTaskLoading(true); - getBackendTask(taskId) - .then((item) => { - if (!cancelled) setBackendTask(item); - }) - .catch(() => { - if (!cancelled) setBackendTask(null); - }) - .finally(() => { - if (!cancelled) setBackendTaskLoading(false); - }); return () => { - cancelled = true; + mountedRef.current = false; }; - }, [task, taskId]); + }, []); - const runIds = useMemo(() => new Set(task?.tasks.map((item) => item.runId) ?? []), [task?.tasks]); - const artifacts = useMemo( - () => processArtifacts.filter((artifact) => runIds.has(artifact.run_id)), - [processArtifacts, runIds] + const loadBackendTask = React.useCallback(async () => { + if (!taskId) return null; + setBackendTaskLoading(true); + try { + const item = await getBackendTask(taskId); + if (!mountedRef.current) return item; + setBackendTask(item); + setSessionProcess(item.session_id, { + runs: item.process_runs ?? [], + events: item.process_events ?? [], + artifacts: item.process_artifacts ?? [], + }); + return item; + } catch { + if (mountedRef.current) { + setBackendTask(null); + } + return null; + } finally { + if (mountedRef.current) { + setBackendTaskLoading(false); + } + } + }, [setSessionProcess, taskId]); + + React.useEffect(() => { + void loadBackendTask(); + }, [loadBackendTask]); + + const isTaskLive = backendTask ? !TERMINAL_TASK_STATUSES.has(backendTask.status) : false; + + React.useEffect(() => { + if (!isTaskLive || wsStatus === 'connected') return; + const id = window.setInterval(() => { + void loadBackendTask(); + }, 4000); + return () => window.clearInterval(id); + }, [isTaskLive, loadBackendTask, wsStatus]); + + const taskRunIds = useMemo(() => { + const ids = new Set(); + for (const run of backendTask?.process_runs ?? []) ids.add(run.run_id); + for (const runId of backendTask?.run_ids ?? []) ids.add(runId); + return ids; + }, [backendTask]); + + const liveRuns = useMemo( + () => processRuns.filter((run) => taskRunIds.has(run.run_id) || run.metadata?.task_id === taskId), + [processRuns, taskId, taskRunIds] ); - const eventsByRun = useMemo(() => { - const map = new Map(); - for (const event of processEvents) { - if (!runIds.has(event.run_id)) continue; - map.set(event.run_id, [...(map.get(event.run_id) ?? []), event]); - } - return map; - }, [processEvents, runIds]); - const artifactsByRun = useMemo(() => { - const map = new Map(); - for (const artifact of artifacts) { - map.set(artifact.run_id, [...(map.get(artifact.run_id) ?? []), artifact]); - } - return map; - }, [artifacts]); - const phaseGroups = useMemo(() => { - const groups = new Map(); - for (const item of task?.tasks ?? []) { - const label = item.stageLabel || taskVisibleStatus(item, locale); - groups.set(label, [...(groups.get(label) ?? []), item]); - } - return Array.from(groups.entries()).map(([label, nodes]) => ({ label, nodes })); - }, [locale, task?.tasks]); - const selectedNode = task?.tasks.find((item) => item.runId === selectedRunId) ?? task?.tasks[0] ?? null; + + const liveEvents = useMemo( + () => processEvents.filter((event) => taskRunIds.has(event.run_id) || event.metadata?.task_id === taskId), + [processEvents, taskId, taskRunIds] + ); + + const liveArtifacts = useMemo( + () => processArtifacts.filter((artifact) => taskRunIds.has(artifact.run_id) || artifact.metadata?.task_id === taskId), + [processArtifacts, taskId, taskRunIds] + ); + + const renderedRuns = liveRuns.length > 0 ? liveRuns : backendTask?.process_runs ?? []; + const renderedEvents = liveEvents.length > 0 ? liveEvents : backendTask?.process_events ?? []; + const renderedArtifacts = liveArtifacts.length > 0 ? liveArtifacts : backendTask?.process_artifacts ?? []; + + const timelineCards = useMemo( + () => + backendTask + ? buildTaskTimelineCards({ + task: backendTask, + processRuns: renderedRuns, + processEvents: renderedEvents, + processArtifacts: renderedArtifacts, + }) + : [], + [backendTask, renderedArtifacts, renderedEvents, renderedRuns] + ); + + const activeLabel = + [...timelineCards].reverse().find((card) => !['acceptance', 'task_created'].includes(card.type))?.title ?? '-'; + const durationMs = backendTask ? taskDurationMs(backendTask) : null; + const feedbackRunId = backendTask ? pickFeedbackRunId(backendTask) : null; const runAction = async (key: string, action: () => Promise) => { setActionBusy(key); @@ -149,632 +156,95 @@ export default function TaskDetailPage() { }); }; - const backendFeedbackRunId = backendTask ? pickFeedbackRunId(backendTask) : null; - - if (!task && backendTask) { + if (backendTask) { const feedbackItems = backendTask.feedback || []; - return ( -
-
- -
- {backendTask.is_open ? {pickAppText(locale, '进行中', 'Active')} : null} - {humanTaskStatus(backendTask.status, locale)} - -
-
- - -

{backendTask.short_title || String(backendTask.metadata?.short_title || '') || backendTask.description || backendTask.goal || backendTask.task_id}

- {backendTask.description ? ( -

{backendTask.description}

- ) : null} -
- {pickAppText(locale, '来源会话', 'Session')}: {backendTask.session_id} - {pickAppText(locale, '创建者', 'Creator')}: {backendTask.creator} - {pickAppText(locale, '更新', 'Updated')}: {formatTaskRuntimeTime(backendTask.updated_at, locale)} + return ( +
+ + +
+
+
+
- - - - runAction(`backend-feedback-${feedbackType}`, async () => { - await submitChatFeedback({ - sessionId: backendTask.session_id, - runId: backendFeedbackRunId!, - feedbackType, - comment, - }); - const refreshed = await getBackendTask(backendTask.task_id); - setBackendTask(refreshed); - }) - } - /> + {actionError ? ( + + + + {actionError} + + + ) : null} - + - - - {pickAppText(locale, 'Agent 执行过程', 'Agent conversation process')} - - - {(backendTask.runs ?? []).length === 0 ? ( -
{pickAppText(locale, '暂无可展示的问答过程', 'No readable conversation process yet')}
- ) : ( - (backendTask.runs ?? []).map((run, index) => ) - )} -
-
+ + runAction(`backend-feedback-${feedbackType}`, async () => { + if (!feedbackRunId) throw new Error(pickAppText(locale, '暂无可验收的运行记录。', 'No run is available for acceptance yet.')); + await submitChatFeedback({ + sessionId: backendTask.session_id, + runId: feedbackRunId, + feedbackType, + comment, + }); + updateMessageFeedback(feedbackRunId, feedbackType); + setRevision(''); + await loadBackendTask(); + }) + } + /> +
+ +
); } - if (!task) { - return ( -
- - - -

{pickAppText(locale, '任务不存在', 'Task not found')}

-

- {backendTaskLoading - ? pickAppText(locale, '正在从后端任务库加载任务。', 'Loading the task from the backend task store.') - : pickAppText(locale, '当前前端状态和后端任务库里都没有这个任务。', 'Neither frontend state nor backend task store contains this task.')} -

-
-
-
- ); - } - - const progressValue = progressPercent(task.progress.value, task.progress.max); - return ( -
-
-
- - -
-
- - - -
-
-
-

{task.title}

- -
-
- {pickAppText(locale, '来源会话', 'Session')}: {task.sourceSessionLabel} - {pickAppText(locale, '主 Agent', 'Lead agent')}: {task.rootActorName} - {pickAppText(locale, '开始', 'Started')}: {formatTaskRuntimeTime(task.createdAt, locale)} - {pickAppText(locale, '耗时', 'Duration')}: {formatTaskRuntimeDuration(task.durationMs, locale)} -
-
-
- - - - -
-
-
-
- {task.progress.label} - {progressValue}% -
-
-
-
+
+ + + +
+ {backendTaskLoading ? : null}
+

{pickAppText(locale, '任务不存在', 'Task not found')}

+

+ {backendTaskLoading + ? pickAppText(locale, '正在从后端任务库加载任务。', 'Loading the task from the backend task store.') + : pickAppText(locale, '后端任务库里没有这个任务。', 'The backend task store does not contain this task.')} +

- - {actionError && ( - - - - {actionError} - - - )} - -
-
- - - {pickAppText(locale, '阶段链', 'Phase chain')} - - -
- {phaseGroups.map((phase, index) => ( -
-
-
{phase.label}
-
{phase.nodes.length} nodes
-
- {index < phaseGroups.length - 1 ? / : null} -
- ))} -
-
-
- - {phaseGroups.map((phase) => ( - - - {phase.label} - - - {phase.nodes.map((node) => ( - - ))} - - - ))} -
- -
- - - {pickAppText(locale, '节点详情', 'Node detail')} - - - {selectedNode ? ( -
-
-
{selectedNode.title}
-
{selectedNode.runId}
-
- -

{selectedNode.summary || '-'}

-
- {(eventsByRun.get(selectedNode.runId) ?? []).slice(-5).map((event) => ( -
-
{event.kind}
-
{event.text || formatTaskRuntimeTime(event.created_at, locale)}
-
- ))} -
-
- ) : ( -

-

- )} -
-
- - - runAction(`runtime-feedback-${feedbackType}`, async () => { - updateMessageFeedback(task.rootRunId, feedbackType); - await submitChatFeedback({ - sessionId: task.sessionId || 'web:default', - runId: task.rootRunId, - feedbackType, - comment, - }); - setRuntimeFeedback({ - acceptance_type: feedbackType, - feedback_type: feedbackType, - comment: comment || '', - created_at: new Date().toISOString(), - run_id: task.rootRunId, - }); - setRevision(''); - }) - } - /> - - - -
- {pickAppText(locale, '产物', 'Artifacts')} - -
-
- - {artifacts.length === 0 ? ( -

{pickAppText(locale, '暂无产物', 'No artifacts yet')}

- ) : ( - artifacts.map((artifact) => ( -
-
-
- - {artifact.title} -
-
{artifact.actor_name || artifact.actor_id}
-
- {artifact.url || artifact.file_id ? ( - - ) : ( - - )} -
- )) - )} -
-
-
-
); } -function Metric({ label, value }: { label: string; value: string }) { - return ( -
-
{label}
-
{value}
-
- ); -} - -function BackendExecutionStages({ task }: { task: BackendTask }) { - const { locale } = useAppI18n(); - const runs = task.process_runs ?? []; - const events = task.process_events ?? []; - const eventsByRun = React.useMemo(() => { - const map = new Map(); - for (const event of events) { - map.set(event.run_id, [...(map.get(event.run_id) ?? []), event]); - } - return map; - }, [events]); - - return ( - - - {pickAppText(locale, '执行阶段', 'Execution stages')} - - - {runs.length === 0 ? ( -
{pickAppText(locale, '暂无执行阶段记录', 'No execution stage records yet')}
- ) : ( - runs.map((run) => ( - - )) - )} -
-
- ); -} - -function BackendProcessRun({ run, events }: { run: ProcessRun; events: ProcessEvent[] }) { - const { locale } = useAppI18n(); - const metadata = run.metadata ?? {}; - const details = [ - metadata.attempt_index ? `${pickAppText(locale, '尝试', 'Attempt')} ${String(metadata.attempt_index)}` : null, - metadata.plan_mode ? `${pickAppText(locale, '模式', 'Mode')}: ${String(metadata.plan_mode)}` : null, - metadata.strategy ? `${pickAppText(locale, '策略', 'Strategy')}: ${String(metadata.strategy)}` : null, - metadata.node_id ? `${pickAppText(locale, '节点', 'Node')}: ${String(metadata.node_id)}` : null, - metadata.finish_reason ? `${pickAppText(locale, '结束原因', 'Finish')}: ${String(metadata.finish_reason)}` : null, - ].filter(Boolean); - const error = typeof metadata.error === 'string' && metadata.error ? metadata.error : null; - - return ( -
-
-
-
{run.title || run.actor_name}
-
- {run.actor_name} - {run.started_at ? ` · ${formatTaskRuntimeTime(run.started_at, locale)}` : ''} -
-
- -
- {details.length > 0 ?
{details.join(' · ')}
: null} - {run.summary ?

{run.summary}

: null} - {error ?

{error}

: null} - {events.length > 0 ? ( -
- {events.map((event) => ( -
-
- {event.actor_name} - {formatTaskRuntimeTime(event.created_at, locale)} -
-
{event.text || event.kind}
-
- ))} -
- ) : null} -
- ); -} - -function TaskFeedbackPanel({ - sessionId, - runId, - taskStatus, - feedbackItems, - actionBusy, - revision, - onRevisionChange, - onSubmit, -}: { - sessionId: string; - runId: string | null; - taskStatus: string; - feedbackItems: TaskFeedbackItem[]; - actionBusy: string | null; - revision?: string; - onRevisionChange?: (value: string) => void; - onSubmit: (feedbackType: TaskFeedbackType, comment?: string) => Promise; -}) { - const { locale } = useAppI18n(); - const [localComment, setLocalComment] = React.useState(''); - const comment = revision ?? localComment; - const setComment = onRevisionChange ?? setLocalComment; - const isFinalized = taskStatus === 'closed' || taskStatus === 'abandoned'; - const recordedFeedback = feedbackForRun(feedbackItems, runId) ?? (isFinalized ? latestFeedback(feedbackItems) : null); - const canSubmit = Boolean(runId) && !recordedFeedback && !isFinalized && !actionBusy; - - const submit = (feedbackType: TaskFeedbackType, nextComment?: string) => { - if (!runId || !canSubmit) return; - void onSubmit(feedbackType, nextComment); - }; - - return ( - - - {pickAppText(locale, '任务验收', 'Task acceptance')} - - - {recordedFeedback ? ( -
-
- - {pickAppText(locale, '已提交验收', 'Acceptance submitted')}: {humanFeedback(String(recordedFeedback.acceptance_type || recordedFeedback.feedback_type || ''), locale)} -
- {recordedFeedback.comment ? ( -

{String(recordedFeedback.comment)}

- ) : null} - {recordedFeedback.created_at ? ( -

{formatTaskRuntimeTime(String(recordedFeedback.created_at), locale)}

- ) : null} -
- ) : isFinalized ? ( -
- {pickAppText(locale, '任务已结束,不能再提交新的验收。', 'This task is finalized and cannot accept new acceptance.')} -
- ) : !runId ? ( -
- {pickAppText(locale, '暂无可验收的运行记录。', 'No run is available for acceptance yet.')} -
- ) : null} - -
- } - label={pickAppText(locale, '接受', 'Accept')} - actionBusy={actionBusy} - disabled={!canSubmit} - onClick={() => submit('accept', comment.trim() || undefined)} - /> - } - label={pickAppText(locale, '需要修改', 'Needs revision')} - actionBusy={actionBusy} - disabled={!canSubmit || !comment.trim()} - onClick={() => submit('revise', comment.trim())} - /> - } - label={pickAppText(locale, '放弃', 'Abandon')} - actionBusy={actionBusy} - disabled={!canSubmit} - onClick={() => submit('abandon', comment.trim() || undefined)} - /> -
- -