feat: make task detail timeline first
This commit is contained in:
@ -3,127 +3,134 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import React, { useMemo, useState } from 'react';
|
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 {
|
||||||
import { Badge } from '@/components/ui/badge';
|
TaskAcceptanceCard,
|
||||||
|
TaskLiveHeader,
|
||||||
|
TaskSideRail,
|
||||||
|
TaskTimeline,
|
||||||
|
type TaskFeedbackItem,
|
||||||
|
type TaskFeedbackType,
|
||||||
|
} from '@/components/task-detail';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { deleteBackendTask, getBackendTask, submitChatFeedback } from '@/lib/api';
|
||||||
import { deleteBackendTask, getBackendTask, getFileUrl, submitChatFeedback } from '@/lib/api';
|
|
||||||
import { pickAppText } from '@/lib/i18n/core';
|
import { pickAppText } from '@/lib/i18n/core';
|
||||||
import { useAppI18n } from '@/lib/i18n/provider';
|
import { useAppI18n } from '@/lib/i18n/provider';
|
||||||
import { buildTaskRuntimeView, type TaskRuntimeNodeView } from '@/lib/task-runtime';
|
|
||||||
import { useChatStore } from '@/lib/store';
|
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';
|
const TERMINAL_TASK_STATUSES = new Set(['closed', 'abandoned', 'cancelled', 'error']);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TaskDetailPage() {
|
export default function TaskDetailPage() {
|
||||||
const { locale } = useAppI18n();
|
const { locale } = useAppI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams<{ taskId: string }>();
|
const params = useParams<{ taskId: string }>();
|
||||||
const taskId = decodeURIComponent(Array.isArray(params?.taskId) ? params.taskId[0] : params?.taskId ?? '');
|
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 processRuns = useChatStore((state) => state.processRuns);
|
||||||
const processEvents = useChatStore((state) => state.processEvents);
|
const processEvents = useChatStore((state) => state.processEvents);
|
||||||
const processArtifacts = useChatStore((state) => state.processArtifacts);
|
const processArtifacts = useChatStore((state) => state.processArtifacts);
|
||||||
|
const setSessionProcess = useChatStore((state) => state.setSessionProcess);
|
||||||
const updateMessageFeedback = useChatStore((state) => state.updateMessageFeedback);
|
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<BackendTask | null>(null);
|
const [backendTask, setBackendTask] = useState<BackendTask | null>(null);
|
||||||
const [backendTaskLoading, setBackendTaskLoading] = useState(false);
|
const [backendTaskLoading, setBackendTaskLoading] = useState(true);
|
||||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(task?.rootRunId ?? null);
|
|
||||||
const [revision, setRevision] = useState('');
|
const [revision, setRevision] = useState('');
|
||||||
const [runtimeFeedback, setRuntimeFeedback] = useState<TaskFeedbackItem | null>(null);
|
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
||||||
|
const mountedRef = React.useRef(true);
|
||||||
|
|
||||||
React.useEffect(() => {
|
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 () => {
|
return () => {
|
||||||
cancelled = true;
|
mountedRef.current = false;
|
||||||
};
|
};
|
||||||
}, [task, taskId]);
|
}, []);
|
||||||
|
|
||||||
const runIds = useMemo(() => new Set(task?.tasks.map((item) => item.runId) ?? []), [task?.tasks]);
|
const loadBackendTask = React.useCallback(async () => {
|
||||||
const artifacts = useMemo(
|
if (!taskId) return null;
|
||||||
() => processArtifacts.filter((artifact) => runIds.has(artifact.run_id)),
|
setBackendTaskLoading(true);
|
||||||
[processArtifacts, runIds]
|
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<string>();
|
||||||
|
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<string, ProcessEvent[]>();
|
const liveEvents = useMemo(
|
||||||
for (const event of processEvents) {
|
() => processEvents.filter((event) => taskRunIds.has(event.run_id) || event.metadata?.task_id === taskId),
|
||||||
if (!runIds.has(event.run_id)) continue;
|
[processEvents, taskId, taskRunIds]
|
||||||
map.set(event.run_id, [...(map.get(event.run_id) ?? []), event]);
|
);
|
||||||
}
|
|
||||||
return map;
|
const liveArtifacts = useMemo(
|
||||||
}, [processEvents, runIds]);
|
() => processArtifacts.filter((artifact) => taskRunIds.has(artifact.run_id) || artifact.metadata?.task_id === taskId),
|
||||||
const artifactsByRun = useMemo(() => {
|
[processArtifacts, taskId, taskRunIds]
|
||||||
const map = new Map<string, ProcessArtifact[]>();
|
);
|
||||||
for (const artifact of artifacts) {
|
|
||||||
map.set(artifact.run_id, [...(map.get(artifact.run_id) ?? []), artifact]);
|
const renderedRuns = liveRuns.length > 0 ? liveRuns : backendTask?.process_runs ?? [];
|
||||||
}
|
const renderedEvents = liveEvents.length > 0 ? liveEvents : backendTask?.process_events ?? [];
|
||||||
return map;
|
const renderedArtifacts = liveArtifacts.length > 0 ? liveArtifacts : backendTask?.process_artifacts ?? [];
|
||||||
}, [artifacts]);
|
|
||||||
const phaseGroups = useMemo(() => {
|
const timelineCards = useMemo(
|
||||||
const groups = new Map<string, TaskRuntimeNodeView[]>();
|
() =>
|
||||||
for (const item of task?.tasks ?? []) {
|
backendTask
|
||||||
const label = item.stageLabel || taskVisibleStatus(item, locale);
|
? buildTaskTimelineCards({
|
||||||
groups.set(label, [...(groups.get(label) ?? []), item]);
|
task: backendTask,
|
||||||
}
|
processRuns: renderedRuns,
|
||||||
return Array.from(groups.entries()).map(([label, nodes]) => ({ label, nodes }));
|
processEvents: renderedEvents,
|
||||||
}, [locale, task?.tasks]);
|
processArtifacts: renderedArtifacts,
|
||||||
const selectedNode = task?.tasks.find((item) => item.runId === selectedRunId) ?? task?.tasks[0] ?? null;
|
})
|
||||||
|
: [],
|
||||||
|
[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<unknown>) => {
|
const runAction = async (key: string, action: () => Promise<unknown>) => {
|
||||||
setActionBusy(key);
|
setActionBusy(key);
|
||||||
@ -149,632 +156,95 @@ export default function TaskDetailPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const backendFeedbackRunId = backendTask ? pickFeedbackRunId(backendTask) : null;
|
if (backendTask) {
|
||||||
|
|
||||||
if (!task && backendTask) {
|
|
||||||
const feedbackItems = backendTask.feedback || [];
|
const feedbackItems = backendTask.feedback || [];
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-5xl space-y-6 p-6">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<Button asChild variant="outline" className="w-fit">
|
|
||||||
<Link href="/tasks">
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
{pickAppText(locale, '返回任务列表', 'Back to tasks')}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{backendTask.is_open ? <Badge variant="secondary">{pickAppText(locale, '进行中', 'Active')}</Badge> : null}
|
|
||||||
<Badge>{humanTaskStatus(backendTask.status, locale)}</Badge>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-destructive hover:text-destructive"
|
|
||||||
disabled={Boolean(actionBusy)}
|
|
||||||
onClick={() => void deleteCurrentBackendTask()}
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
{pickAppText(locale, '删除任务', 'Delete task')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
return (
|
||||||
<CardContent className="p-5">
|
<div className="min-h-screen bg-background">
|
||||||
<h1 className="text-2xl font-semibold">{backendTask.short_title || String(backendTask.metadata?.short_title || '') || backendTask.description || backendTask.goal || backendTask.task_id}</h1>
|
<TaskLiveHeader task={backendTask} activeLabel={activeLabel} durationMs={durationMs} />
|
||||||
{backendTask.description ? (
|
|
||||||
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">{backendTask.description}</p>
|
<main className="mx-auto grid max-w-7xl gap-6 p-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||||
) : null}
|
<div className="space-y-4">
|
||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
<div className="flex justify-end">
|
||||||
<span>{pickAppText(locale, '来源会话', 'Session')}: {backendTask.session_id}</span>
|
<Button
|
||||||
<span>{pickAppText(locale, '创建者', 'Creator')}: {backendTask.creator}</span>
|
variant="ghost"
|
||||||
<span>{pickAppText(locale, '更新', 'Updated')}: {formatTaskRuntimeTime(backendTask.updated_at, locale)}</span>
|
size="sm"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
disabled={Boolean(actionBusy)}
|
||||||
|
onClick={() => void deleteCurrentBackendTask()}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
{pickAppText(locale, '删除任务', 'Delete task')}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<TaskFeedbackPanel
|
{actionError ? (
|
||||||
sessionId={backendTask.session_id}
|
<Card className="border-destructive">
|
||||||
runId={backendFeedbackRunId}
|
<CardContent className="flex items-center gap-2 p-4 text-sm text-destructive">
|
||||||
taskStatus={backendTask.status}
|
<AlertCircle className="h-4 w-4" />
|
||||||
feedbackItems={feedbackItems}
|
{actionError}
|
||||||
actionBusy={actionBusy}
|
</CardContent>
|
||||||
onSubmit={(feedbackType, comment) =>
|
</Card>
|
||||||
runAction(`backend-feedback-${feedbackType}`, async () => {
|
) : null}
|
||||||
await submitChatFeedback({
|
|
||||||
sessionId: backendTask.session_id,
|
|
||||||
runId: backendFeedbackRunId!,
|
|
||||||
feedbackType,
|
|
||||||
comment,
|
|
||||||
});
|
|
||||||
const refreshed = await getBackendTask(backendTask.task_id);
|
|
||||||
setBackendTask(refreshed);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BackendExecutionStages task={backendTask} />
|
<TaskTimeline cards={timelineCards} isLive={isTaskLive && wsStatus === 'connected'} />
|
||||||
|
|
||||||
<Card>
|
<TaskAcceptanceCard
|
||||||
<CardHeader>
|
sessionId={backendTask.session_id}
|
||||||
<CardTitle className="text-base">{pickAppText(locale, 'Agent 执行过程', 'Agent conversation process')}</CardTitle>
|
runId={feedbackRunId}
|
||||||
</CardHeader>
|
taskStatus={backendTask.status}
|
||||||
<CardContent className="space-y-5">
|
feedbackItems={feedbackItems as TaskFeedbackItem[]}
|
||||||
{(backendTask.runs ?? []).length === 0 ? (
|
actionBusy={actionBusy}
|
||||||
<div className="text-sm text-muted-foreground">{pickAppText(locale, '暂无可展示的问答过程', 'No readable conversation process yet')}</div>
|
revision={revision}
|
||||||
) : (
|
onRevisionChange={setRevision}
|
||||||
(backendTask.runs ?? []).map((run, index) => <BackendRunConversation key={run.run_id} run={run} index={index} />)
|
onSubmit={(feedbackType: TaskFeedbackType, comment?: string) =>
|
||||||
)}
|
runAction(`backend-feedback-${feedbackType}`, async () => {
|
||||||
</CardContent>
|
if (!feedbackRunId) throw new Error(pickAppText(locale, '暂无可验收的运行记录。', 'No run is available for acceptance yet.'));
|
||||||
</Card>
|
await submitChatFeedback({
|
||||||
|
sessionId: backendTask.session_id,
|
||||||
|
runId: feedbackRunId,
|
||||||
|
feedbackType,
|
||||||
|
comment,
|
||||||
|
});
|
||||||
|
updateMessageFeedback(feedbackRunId, feedbackType);
|
||||||
|
setRevision('');
|
||||||
|
await loadBackendTask();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TaskSideRail task={backendTask} runs={renderedRuns} artifacts={renderedArtifacts} cards={timelineCards} />
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!task) {
|
|
||||||
return (
|
|
||||||
<div className="mx-auto flex max-w-4xl flex-col gap-4 p-6">
|
|
||||||
<Button asChild variant="outline" className="w-fit">
|
|
||||||
<Link href="/tasks">
|
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
|
||||||
{pickAppText(locale, '返回任务列表', 'Back to tasks')}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<Card className="border-dashed">
|
|
||||||
<CardContent className="py-16 text-center">
|
|
||||||
<h1 className="text-2xl font-semibold">{pickAppText(locale, '任务不存在', 'Task not found')}</h1>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
{backendTaskLoading
|
|
||||||
? pickAppText(locale, '正在从后端任务库加载任务。', 'Loading the task from the backend task store.')
|
|
||||||
: pickAppText(locale, '当前前端状态和后端任务库里都没有这个任务。', 'Neither frontend state nor backend task store contains this task.')}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const progressValue = progressPercent(task.progress.value, task.progress.max);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-7xl space-y-6 p-6">
|
<div className="mx-auto flex max-w-4xl flex-col gap-4 p-6">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<Button asChild variant="outline" className="w-fit">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<Link href="/tasks">
|
||||||
<Button asChild variant="outline" size="sm">
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
<Link href="/tasks">
|
{pickAppText(locale, '返回任务列表', 'Back to tasks')}
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
</Link>
|
||||||
{pickAppText(locale, '返回任务', 'Back to tasks')}
|
</Button>
|
||||||
</Link>
|
<Card className="border-dashed">
|
||||||
</Button>
|
<CardContent className="py-16 text-center">
|
||||||
<Button asChild variant="ghost" size="sm">
|
<div className="flex justify-center">
|
||||||
<Link href="/">
|
{backendTaskLoading ? <Loader2 className="mb-4 h-5 w-5 animate-spin text-muted-foreground" /> : null}
|
||||||
<MessageSquare className="mr-2 h-4 w-4" />
|
|
||||||
{pickAppText(locale, '对话', 'Chat')}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-5">
|
|
||||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<h1 className="truncate text-2xl font-semibold">{task.title}</h1>
|
|
||||||
<TaskRuntimeStatusBadge status={task.status} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
|
||||||
<span>{pickAppText(locale, '来源会话', 'Session')}: {task.sourceSessionLabel}</span>
|
|
||||||
<span>{pickAppText(locale, '主 Agent', 'Lead agent')}: {task.rootActorName}</span>
|
|
||||||
<span>{pickAppText(locale, '开始', 'Started')}: {formatTaskRuntimeTime(task.createdAt, locale)}</span>
|
|
||||||
<span>{pickAppText(locale, '耗时', 'Duration')}: {formatTaskRuntimeDuration(task.durationMs, locale)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid w-full gap-3 sm:grid-cols-4 lg:w-[520px]">
|
|
||||||
<Metric label={pickAppText(locale, '节点', 'Nodes')} value={String(task.stats.totalRuns)} />
|
|
||||||
<Metric label={pickAppText(locale, '活跃', 'Active')} value={String(task.stats.activeRuns)} />
|
|
||||||
<Metric label={pickAppText(locale, '产物', 'Artifacts')} value={String(task.stats.artifactCount)} />
|
|
||||||
<Metric label={pickAppText(locale, '异常', 'Alerts')} value={String(task.stats.alertCount)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-5 space-y-2">
|
|
||||||
<div className="flex justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">{task.progress.label}</span>
|
|
||||||
<span className="font-medium">{progressValue}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 overflow-hidden rounded-full bg-secondary">
|
|
||||||
<div className="h-full bg-primary" style={{ width: `${progressValue}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h1 className="text-2xl font-semibold">{pickAppText(locale, '任务不存在', 'Task not found')}</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{backendTaskLoading
|
||||||
|
? pickAppText(locale, '正在从后端任务库加载任务。', 'Loading the task from the backend task store.')
|
||||||
|
: pickAppText(locale, '后端任务库里没有这个任务。', 'The backend task store does not contain this task.')}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{actionError && (
|
|
||||||
<Card className="border-destructive">
|
|
||||||
<CardContent className="flex items-center gap-2 pt-6 text-sm text-destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
{actionError}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">{pickAppText(locale, '阶段链', 'Phase chain')}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{phaseGroups.map((phase, index) => (
|
|
||||||
<div key={`${phase.label}:${index}`} className="flex items-center gap-2">
|
|
||||||
<div className="rounded-md border border-border bg-muted/35 px-3 py-2 text-sm">
|
|
||||||
<div className="font-medium">{phase.label}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">{phase.nodes.length} nodes</div>
|
|
||||||
</div>
|
|
||||||
{index < phaseGroups.length - 1 ? <span className="text-muted-foreground">/</span> : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{phaseGroups.map((phase) => (
|
|
||||||
<Card key={phase.label}>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">{phase.label}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="grid gap-3 md:grid-cols-2">
|
|
||||||
{phase.nodes.map((node) => (
|
|
||||||
<button
|
|
||||||
key={node.runId}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedRunId(node.runId)}
|
|
||||||
className={`rounded-md border p-4 text-left transition-colors ${selectedRunId === node.runId ? 'border-primary bg-accent/45' : 'border-border bg-card hover:bg-muted/40'}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate font-medium">{node.title}</div>
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">{node.actorName}</div>
|
|
||||||
</div>
|
|
||||||
<TaskRuntimeStatusBadge status={node.status} />
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 text-sm text-muted-foreground">
|
|
||||||
{node.summary || taskVisibleStatus(node, locale)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex gap-3 text-xs text-muted-foreground">
|
|
||||||
<span>{pickAppText(locale, '子节点', 'Children')}: {node.childTaskIds.length}</span>
|
|
||||||
<span>{pickAppText(locale, '节点结果', 'Node results')}: {(artifactsByRun.get(node.runId) ?? []).length}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">{pickAppText(locale, '节点详情', 'Node detail')}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{selectedNode ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{selectedNode.title}</div>
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">{selectedNode.runId}</div>
|
|
||||||
</div>
|
|
||||||
<TaskRuntimeStatusBadge status={selectedNode.status} />
|
|
||||||
<p className="text-sm text-muted-foreground">{selectedNode.summary || '-'}</p>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{(eventsByRun.get(selectedNode.runId) ?? []).slice(-5).map((event) => (
|
|
||||||
<div key={event.event_id} className="rounded-md border border-border bg-muted/30 p-2 text-xs">
|
|
||||||
<div className="font-medium">{event.kind}</div>
|
|
||||||
<div className="mt-1 text-muted-foreground">{event.text || formatTaskRuntimeTime(event.created_at, locale)}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">-</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<TaskFeedbackPanel
|
|
||||||
sessionId={task.sessionId || 'web:default'}
|
|
||||||
runId={task.rootRunId}
|
|
||||||
taskStatus={task.status}
|
|
||||||
feedbackItems={runtimeFeedback ? [runtimeFeedback] : []}
|
|
||||||
actionBusy={actionBusy}
|
|
||||||
revision={revision}
|
|
||||||
onRevisionChange={setRevision}
|
|
||||||
onSubmit={(feedbackType, comment) =>
|
|
||||||
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('');
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<CardTitle className="text-base">{pickAppText(locale, '产物', 'Artifacts')}</CardTitle>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
disabled={artifacts.length === 0}
|
|
||||||
onClick={() => downloadText(`${task.taskId}-artifacts.json`, JSON.stringify(artifacts, null, 2))}
|
|
||||||
>
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
{pickAppText(locale, '全部下载', 'Download all')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2">
|
|
||||||
{artifacts.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">{pickAppText(locale, '暂无产物', 'No artifacts yet')}</p>
|
|
||||||
) : (
|
|
||||||
artifacts.map((artifact) => (
|
|
||||||
<div key={artifact.artifact_id} className="flex items-center justify-between gap-3 rounded-md border border-border p-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex items-center gap-2 text-sm font-medium">
|
|
||||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<span className="truncate">{artifact.title}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">{artifact.actor_name || artifact.actor_id}</div>
|
|
||||||
</div>
|
|
||||||
{artifact.url || artifact.file_id ? (
|
|
||||||
<Button asChild size="sm" variant="outline">
|
|
||||||
<a href={artifact.url || getFileUrl(artifact.file_id!)} target="_blank" rel="noopener noreferrer">
|
|
||||||
<Download className="mr-2 h-3.5 w-3.5" />
|
|
||||||
{pickAppText(locale, '下载', 'Download')}
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => downloadText(`${artifact.title || artifact.artifact_id}.txt`, artifact.content || JSON.stringify(artifact.data ?? {}, null, 2))}
|
|
||||||
>
|
|
||||||
<Download className="mr-2 h-3.5 w-3.5" />
|
|
||||||
{pickAppText(locale, '下载', 'Download')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Metric({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-3">
|
|
||||||
<div className="text-xs text-muted-foreground">{label}</div>
|
|
||||||
<div className="mt-1 text-lg font-semibold">{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string, ProcessEvent[]>();
|
|
||||||
for (const event of events) {
|
|
||||||
map.set(event.run_id, [...(map.get(event.run_id) ?? []), event]);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [events]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">{pickAppText(locale, '执行阶段', 'Execution stages')}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{runs.length === 0 ? (
|
|
||||||
<div className="text-sm text-muted-foreground">{pickAppText(locale, '暂无执行阶段记录', 'No execution stage records yet')}</div>
|
|
||||||
) : (
|
|
||||||
runs.map((run) => (
|
|
||||||
<BackendProcessRun key={run.run_id} run={run} events={eventsByRun.get(run.run_id) ?? []} />
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="rounded-md border border-border bg-background p-3">
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="font-medium">{run.title || run.actor_name}</div>
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{run.actor_name}
|
|
||||||
{run.started_at ? ` · ${formatTaskRuntimeTime(run.started_at, locale)}` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<TaskRuntimeStatusBadge status={run.status} />
|
|
||||||
</div>
|
|
||||||
{details.length > 0 ? <div className="mt-2 text-xs text-muted-foreground">{details.join(' · ')}</div> : null}
|
|
||||||
{run.summary ? <p className="mt-2 whitespace-pre-wrap text-sm text-muted-foreground">{run.summary}</p> : null}
|
|
||||||
{error ? <p className="mt-2 text-sm text-destructive">{error}</p> : null}
|
|
||||||
{events.length > 0 ? (
|
|
||||||
<div className="mt-3 space-y-2">
|
|
||||||
{events.map((event) => (
|
|
||||||
<div key={event.event_id} className="rounded-md bg-muted/30 px-3 py-2 text-xs">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<span className="font-medium">{event.actor_name}</span>
|
|
||||||
<span className="text-muted-foreground">{formatTaskRuntimeTime(event.created_at, locale)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-muted-foreground">{event.text || event.kind}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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<unknown>;
|
|
||||||
}) {
|
|
||||||
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 (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">{pickAppText(locale, '任务验收', 'Task acceptance')}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{recordedFeedback ? (
|
|
||||||
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm">
|
|
||||||
<div className="flex items-center gap-2 font-medium">
|
|
||||||
<CheckCircle2 className="h-4 w-4 text-[#657162]" />
|
|
||||||
{pickAppText(locale, '已提交验收', 'Acceptance submitted')}: {humanFeedback(String(recordedFeedback.acceptance_type || recordedFeedback.feedback_type || ''), locale)}
|
|
||||||
</div>
|
|
||||||
{recordedFeedback.comment ? (
|
|
||||||
<p className="mt-2 text-muted-foreground">{String(recordedFeedback.comment)}</p>
|
|
||||||
) : null}
|
|
||||||
{recordedFeedback.created_at ? (
|
|
||||||
<p className="mt-2 text-xs text-muted-foreground">{formatTaskRuntimeTime(String(recordedFeedback.created_at), locale)}</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : isFinalized ? (
|
|
||||||
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm text-muted-foreground">
|
|
||||||
{pickAppText(locale, '任务已结束,不能再提交新的验收。', 'This task is finalized and cannot accept new acceptance.')}
|
|
||||||
</div>
|
|
||||||
) : !runId ? (
|
|
||||||
<div className="rounded-md border border-border bg-muted/25 p-3 text-sm text-muted-foreground">
|
|
||||||
{pickAppText(locale, '暂无可验收的运行记录。', 'No run is available for acceptance yet.')}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="grid gap-2 sm:grid-cols-3">
|
|
||||||
<FeedbackButton
|
|
||||||
type="accept"
|
|
||||||
icon={<ThumbsUp className="mr-2 h-4 w-4" />}
|
|
||||||
label={pickAppText(locale, '接受', 'Accept')}
|
|
||||||
actionBusy={actionBusy}
|
|
||||||
disabled={!canSubmit}
|
|
||||||
onClick={() => submit('accept', comment.trim() || undefined)}
|
|
||||||
/>
|
|
||||||
<FeedbackButton
|
|
||||||
type="revise"
|
|
||||||
icon={<RefreshCw className="mr-2 h-4 w-4" />}
|
|
||||||
label={pickAppText(locale, '需要修改', 'Needs revision')}
|
|
||||||
actionBusy={actionBusy}
|
|
||||||
disabled={!canSubmit || !comment.trim()}
|
|
||||||
onClick={() => submit('revise', comment.trim())}
|
|
||||||
/>
|
|
||||||
<FeedbackButton
|
|
||||||
type="abandon"
|
|
||||||
icon={<XCircle className="mr-2 h-4 w-4" />}
|
|
||||||
label={pickAppText(locale, '放弃', 'Abandon')}
|
|
||||||
actionBusy={actionBusy}
|
|
||||||
disabled={!canSubmit}
|
|
||||||
onClick={() => submit('abandon', comment.trim() || undefined)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Textarea
|
|
||||||
value={comment}
|
|
||||||
onChange={(event) => setComment(event.target.value)}
|
|
||||||
disabled={Boolean(recordedFeedback) || isFinalized || Boolean(actionBusy)}
|
|
||||||
placeholder={pickAppText(locale, '需要修改时写下具体要求;接受或放弃可选填说明。', 'Describe requested changes; notes are optional for accept or abandon.')}
|
|
||||||
/>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{pickAppText(locale, '验收将记录到当前任务运行:', 'Acceptance will be recorded on run: ')}
|
|
||||||
<span className="font-mono">{runId || '-'}</span>
|
|
||||||
<span className="mx-1">·</span>
|
|
||||||
{pickAppText(locale, '会话:', 'Session: ')}
|
|
||||||
<span className="font-mono">{sessionId}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FeedbackButton({
|
|
||||||
type,
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
actionBusy,
|
|
||||||
disabled,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
type: TaskFeedbackType;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
label: string;
|
|
||||||
actionBusy: string | null;
|
|
||||||
disabled: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
}) {
|
|
||||||
const isBusy = Boolean(actionBusy?.endsWith(type));
|
|
||||||
return (
|
|
||||||
<Button type="button" variant="outline" className="w-full justify-center" disabled={disabled || Boolean(actionBusy)} onClick={onClick}>
|
|
||||||
{isBusy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : icon}
|
|
||||||
{label}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BackendRunConversation({ run, index }: { run: BackendTaskRun; index: number }) {
|
|
||||||
const { locale } = useAppI18n();
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-border bg-background p-4">
|
|
||||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{run.title || pickAppText(locale, `Agent ${index + 1}`, `Agent ${index + 1}`)}</div>
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{run.started_at ? formatTaskRuntimeTime(run.started_at, locale) : pickAppText(locale, '时间未知', 'Unknown time')}
|
|
||||||
{run.finish_reason ? ` · ${humanFinishReason(run.finish_reason, locale)}` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge variant={run.success === false ? 'destructive' : 'secondary'}>
|
|
||||||
{run.success === false ? pickAppText(locale, '失败', 'Failed') : pickAppText(locale, '已完成', 'Done')}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{run.messages.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">{run.task_text || pickAppText(locale, '这次运行没有可见对话消息。', 'This run has no visible conversation messages.')}</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{run.messages.map((message, messageIndex) => {
|
|
||||||
const isAssistant = message.role === 'assistant';
|
|
||||||
const isTool = message.role === 'tool';
|
|
||||||
const Icon = isAssistant ? Bot : isTool ? FileText : User;
|
|
||||||
return (
|
|
||||||
<div key={`${message.role}:${message.created_at}:${messageIndex}`} className="flex gap-3">
|
|
||||||
<div className="mt-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted">
|
|
||||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="mb-1 flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<span>{isAssistant ? run.title || pickAppText(locale, 'Agent 回复', 'Agent reply') : isTool ? message.tool_name || pickAppText(locale, '工具结果', 'Tool result') : pickAppText(locale, '用户要求', 'User request')}</span>
|
|
||||||
{message.created_at ? <span>{formatTaskRuntimeTime(message.created_at, locale)}</span> : null}
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-pre-wrap rounded-md border border-border bg-muted/20 px-3 py-2 text-sm leading-6">
|
|
||||||
{message.content}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function humanTaskStatus(status: string, locale: 'zh-CN' | 'en-US') {
|
|
||||||
const map: Record<string, [string, string]> = {
|
|
||||||
open: ['已创建', 'Open'],
|
|
||||||
running: ['执行中', 'Running'],
|
|
||||||
awaiting_acceptance: ['等待验收', 'Awaiting acceptance'],
|
|
||||||
needs_revision: ['需要修改', 'Needs revision'],
|
|
||||||
closed: ['已完成', 'Closed'],
|
|
||||||
abandoned: ['已放弃', 'Abandoned'],
|
|
||||||
};
|
|
||||||
const item = map[status];
|
|
||||||
return item ? pickAppText(locale, item[0], item[1]) : status;
|
|
||||||
}
|
|
||||||
|
|
||||||
function humanFeedback(type: string, locale: 'zh-CN' | 'en-US') {
|
|
||||||
if (type === 'accept' || type === 'satisfied') return pickAppText(locale, '接受', 'Accepted');
|
|
||||||
if (type === 'revise') return pickAppText(locale, '请求修改', 'Revision requested');
|
|
||||||
if (type === 'abandon') return pickAppText(locale, '放弃任务', 'Abandoned');
|
|
||||||
return type || pickAppText(locale, '验收', 'Acceptance');
|
|
||||||
}
|
|
||||||
|
|
||||||
function humanFinishReason(reason: string, locale: 'zh-CN' | 'en-US') {
|
|
||||||
if (reason === 'stop') return pickAppText(locale, '正常结束', 'Completed');
|
|
||||||
if (reason === 'error') return pickAppText(locale, '执行出错', 'Error');
|
|
||||||
if (reason === 'cancelled') return pickAppText(locale, '已取消', 'Cancelled');
|
|
||||||
return reason;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickFeedbackRunId(task: BackendTask): string | null {
|
function pickFeedbackRunId(task: BackendTask): string | null {
|
||||||
const runIds = task.run_ids.filter(Boolean);
|
const runIds = task.run_ids.filter(Boolean);
|
||||||
if (runIds.length > 0) return runIds[runIds.length - 1];
|
if (runIds.length > 0) return runIds[runIds.length - 1];
|
||||||
@ -783,12 +253,9 @@ function pickFeedbackRunId(task: BackendTask): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function feedbackForRun(items: TaskFeedbackItem[], runId: string | null): TaskFeedbackItem | null {
|
function taskDurationMs(task: BackendTask): number | null {
|
||||||
if (!runId) return null;
|
const start = new Date(task.created_at).getTime();
|
||||||
const ordered = [...items].reverse();
|
const end = new Date(task.closed_at || task.updated_at).getTime();
|
||||||
return ordered.find((item) => String(item.run_id || '') === runId) ?? null;
|
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
|
||||||
}
|
return Math.max(0, end - start);
|
||||||
|
|
||||||
function latestFeedback(items: TaskFeedbackItem[]): TaskFeedbackItem | null {
|
|
||||||
return [...items].reverse()[0] ?? null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user