```
feat(engine): 添加技能查看工具并优化异步任务管理 - 添加SkillViewTool到引擎加载器中,增强技能管理功能 - 在AgentLoop中引入_active_direct_task来跟踪活跃任务 - 实现直接任务执行时的同步处理逻辑 - 更新工具实例化方式以支持依赖注入 feat(config): 增加智能体运行时参数配置支持 - 扩展AgentDefaultsConfig添加max_tokens和temperature字段 - 实现配置解析函数_first_config_value处理多个配置源 - 支持通过Web API动态更新智能体运行时参数 - 添加前端页面配置表单和验证逻辑 refactor(provider): 统一最大令牌数参数类型为可选整型 - 将所有LLM提供者的max_tokens参数改为int | None类型 - 为AnthropicProvider实现模型特定的最大令牌数默认值 - 调整参数传递逻辑,优先级:调用参数 > 配置文件 > 模型默认值 - 移除硬编码的默认值,改用条件判断 feat(process): 增强事件投影功能 - 添加工具调用开始/结束事件的映射逻辑 - 实现技能激活事件的识别和展示 - 添加辅助函数处理工具调用名称和参数提取 - 优化运行记录关联逻辑,提升事件匹配准确性 fix(web): 更新网络请求客户端信任环境设置 - 将WebFetchTool和WebSearchTool的trust_env参数设为True - 确保HTTP客户端能够正确使用系统代理配置 - 修复可能的网络连接问题 test: 添加配置加载和事件投影相关测试 - 新增智能体默认参数配置测试用例 - 实现API配置持久化和重载测试 - 添加技能卡片和工具事件的投影测试 ```
This commit is contained in:
@ -15,7 +15,7 @@ import {
|
||||
Settings2,
|
||||
ScrollText,
|
||||
} from 'lucide-react';
|
||||
import { getStatus, updateProviderConfig } from '@/lib/api';
|
||||
import { getStatus, updateAgentConfig, updateProviderConfig } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@ -42,6 +42,12 @@ type ProviderFormState = {
|
||||
requestTimeoutSeconds: string;
|
||||
};
|
||||
|
||||
type AgentFormState = {
|
||||
maxTokens: string;
|
||||
temperature: string;
|
||||
maxToolIterations: string;
|
||||
};
|
||||
|
||||
export default function StatusPage() {
|
||||
const { locale } = useAppI18n();
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
@ -57,6 +63,13 @@ export default function StatusPage() {
|
||||
}));
|
||||
const [savingProvider, setSavingProvider] = useState(false);
|
||||
const [providerError, setProviderError] = useState<string | null>(null);
|
||||
const [agentForm, setAgentForm] = useState<AgentFormState>(() => ({
|
||||
maxTokens: '',
|
||||
temperature: '0.2',
|
||||
maxToolIterations: '30',
|
||||
}));
|
||||
const [savingAgent, setSavingAgent] = useState(false);
|
||||
const [agentError, setAgentError] = useState<string | null>(null);
|
||||
|
||||
const loadStatus = async () => {
|
||||
setLoading(true);
|
||||
@ -64,6 +77,11 @@ export default function StatusPage() {
|
||||
try {
|
||||
const data = await getStatus();
|
||||
setStatus(data);
|
||||
setAgentForm({
|
||||
maxTokens: data.max_tokens == null ? '' : String(data.max_tokens),
|
||||
temperature: String(data.temperature),
|
||||
maxToolIterations: String(data.max_tool_iterations),
|
||||
});
|
||||
} catch (err: any) {
|
||||
setError(err.message || pickAppText(locale, '连接后端失败', 'Failed to connect to the backend'));
|
||||
} finally {
|
||||
@ -115,6 +133,39 @@ export default function StatusPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAgentConfig = async () => {
|
||||
setSavingAgent(true);
|
||||
setAgentError(null);
|
||||
try {
|
||||
const maxTokensText = agentForm.maxTokens.trim();
|
||||
const maxTokens = maxTokensText ? Number(maxTokensText) : null;
|
||||
const temperature = Number(agentForm.temperature.trim());
|
||||
const maxToolIterations = Number(agentForm.maxToolIterations.trim());
|
||||
if (
|
||||
maxTokens !== null &&
|
||||
(!Number.isInteger(maxTokens) || maxTokens <= 0)
|
||||
) {
|
||||
throw new Error(pickAppText(locale, '最大令牌数必须为空或正整数', 'Max tokens must be blank or a positive integer'));
|
||||
}
|
||||
if (!Number.isFinite(temperature) || temperature < 0 || temperature > 2) {
|
||||
throw new Error(pickAppText(locale, '温度必须在 0 到 2 之间', 'Temperature must be between 0 and 2'));
|
||||
}
|
||||
if (!Number.isInteger(maxToolIterations) || maxToolIterations < 0) {
|
||||
throw new Error(pickAppText(locale, '最大工具迭代次数必须是非负整数', 'Max tool iterations must be a non-negative integer'));
|
||||
}
|
||||
await updateAgentConfig({
|
||||
max_tokens: maxTokens,
|
||||
temperature,
|
||||
max_tool_iterations: maxToolIterations,
|
||||
});
|
||||
await loadStatus();
|
||||
} catch (err: any) {
|
||||
setAgentError(err.message || pickAppText(locale, '保存智能体配置失败', 'Failed to save agent configuration'));
|
||||
} finally {
|
||||
setSavingAgent(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
@ -207,14 +258,47 @@ export default function StatusPage() {
|
||||
{pickAppText(locale, '智能体配置', 'Agent configuration')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<CardContent className="space-y-5">
|
||||
<InfoRow label={pickAppText(locale, '模型', 'Model')} value={status.model} />
|
||||
<InfoRow label={pickAppText(locale, '最大令牌数', 'Max tokens')} value={String(status.max_tokens)} />
|
||||
<InfoRow label={pickAppText(locale, '温度', 'Temperature')} value={String(status.temperature)} />
|
||||
<InfoRow
|
||||
label={pickAppText(locale, '最大工具迭代次数', 'Max tool iterations')}
|
||||
value={String(status.max_tool_iterations)}
|
||||
/>
|
||||
<div className="grid gap-4 border-t pt-5 md:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="agent-max-tokens">{pickAppText(locale, '最大令牌数', 'Max tokens')}</Label>
|
||||
<Input
|
||||
id="agent-max-tokens"
|
||||
inputMode="numeric"
|
||||
value={agentForm.maxTokens}
|
||||
onChange={(event) => setAgentForm((prev) => ({ ...prev, maxTokens: event.target.value }))}
|
||||
placeholder={pickAppText(locale, '模型默认', 'Model default')}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="agent-temperature">{pickAppText(locale, '温度', 'Temperature')}</Label>
|
||||
<Input
|
||||
id="agent-temperature"
|
||||
inputMode="decimal"
|
||||
value={agentForm.temperature}
|
||||
onChange={(event) => setAgentForm((prev) => ({ ...prev, temperature: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="agent-max-tool-iterations">
|
||||
{pickAppText(locale, '最大工具迭代次数', 'Max tool iterations')}
|
||||
</Label>
|
||||
<Input
|
||||
id="agent-max-tool-iterations"
|
||||
inputMode="numeric"
|
||||
value={agentForm.maxToolIterations}
|
||||
onChange={(event) => setAgentForm((prev) => ({ ...prev, maxToolIterations: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-sm text-destructive">{agentError || ''}</div>
|
||||
<Button onClick={handleSaveAgentConfig} disabled={savingAgent} className="sm:self-end">
|
||||
{savingAgent ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{pickAppText(locale, '保存智能体配置', 'Save agent config')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@ -6,7 +6,6 @@ import React, { useMemo, useState } from 'react';
|
||||
import { AlertCircle, ArrowLeft, Loader2, Trash2 } from 'lucide-react';
|
||||
|
||||
import {
|
||||
TaskAcceptanceCard,
|
||||
TaskLiveHeader,
|
||||
TaskSideRail,
|
||||
TaskTimeline,
|
||||
@ -19,10 +18,12 @@ import { deleteBackendTask, getBackendTask, submitChatFeedback } from '@/lib/api
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import { useChatStore } from '@/lib/store';
|
||||
import { shouldPollTaskDetail, taskDetailDurationMs } from '@/lib/task-detail-refresh';
|
||||
import { buildTaskTimelineCards } from '@/lib/task-timeline';
|
||||
import type { BackendTask } from '@/types';
|
||||
|
||||
const TERMINAL_TASK_STATUSES = new Set(['closed', 'abandoned', 'cancelled', 'error']);
|
||||
const TASK_RESULT_REVIEW_ID = 'task-result-review';
|
||||
|
||||
export default function TaskDetailPage() {
|
||||
const { locale } = useAppI18n();
|
||||
@ -81,12 +82,12 @@ export default function TaskDetailPage() {
|
||||
const isTaskLive = backendTask ? !TERMINAL_TASK_STATUSES.has(backendTask.status) : false;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isTaskLive || wsStatus === 'connected') return;
|
||||
if (!shouldPollTaskDetail(backendTask)) return;
|
||||
const id = window.setInterval(() => {
|
||||
void loadBackendTask();
|
||||
}, 4000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [isTaskLive, loadBackendTask, wsStatus]);
|
||||
}, [backendTask, loadBackendTask]);
|
||||
|
||||
const taskRunIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
@ -129,7 +130,7 @@ export default function TaskDetailPage() {
|
||||
|
||||
const activeLabel =
|
||||
[...timelineCards].reverse().find((card) => !['acceptance', 'task_created'].includes(card.type))?.title ?? '-';
|
||||
const durationMs = backendTask ? taskDurationMs(backendTask) : null;
|
||||
const durationMs = backendTask ? taskDetailDurationMs(backendTask) : null;
|
||||
const feedbackRunId = backendTask ? pickFeedbackRunId(backendTask) : null;
|
||||
|
||||
const runAction = async (key: string, action: () => Promise<unknown>) => {
|
||||
@ -161,7 +162,7 @@ export default function TaskDetailPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<TaskLiveHeader task={backendTask} activeLabel={activeLabel} durationMs={durationMs} />
|
||||
<TaskLiveHeader task={backendTask} activeLabel={activeLabel} durationMs={durationMs} reviewTargetId={TASK_RESULT_REVIEW_ID} />
|
||||
|
||||
<main className="mx-auto grid max-w-7xl gap-6 p-6 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="space-y-4">
|
||||
@ -187,30 +188,32 @@ export default function TaskDetailPage() {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<TaskTimeline cards={timelineCards} isLive={isTaskLive && wsStatus === 'connected'} />
|
||||
|
||||
<TaskAcceptanceCard
|
||||
sessionId={backendTask.session_id}
|
||||
runId={feedbackRunId}
|
||||
taskStatus={backendTask.status}
|
||||
feedbackItems={feedbackItems as TaskFeedbackItem[]}
|
||||
actionBusy={actionBusy}
|
||||
revision={revision}
|
||||
onRevisionChange={setRevision}
|
||||
onSubmit={(feedbackType: TaskFeedbackType, comment?: string) =>
|
||||
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();
|
||||
})
|
||||
}
|
||||
<TaskTimeline
|
||||
cards={timelineCards}
|
||||
isLive={isTaskLive && wsStatus === 'connected'}
|
||||
reviewTargetId={TASK_RESULT_REVIEW_ID}
|
||||
resultAcceptance={{
|
||||
sessionId: backendTask.session_id,
|
||||
runId: feedbackRunId,
|
||||
taskStatus: backendTask.status,
|
||||
feedbackItems: feedbackItems as TaskFeedbackItem[],
|
||||
actionBusy,
|
||||
revision,
|
||||
onRevisionChange: setRevision,
|
||||
onSubmit: (feedbackType: TaskFeedbackType, comment?: string) =>
|
||||
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();
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -252,10 +255,3 @@ function pickFeedbackRunId(task: BackendTask): string | null {
|
||||
if (runs.length > 0) return runs[runs.length - 1].run_id;
|
||||
return null;
|
||||
}
|
||||
|
||||
function taskDurationMs(task: BackendTask): number | null {
|
||||
const start = new Date(task.created_at).getTime();
|
||||
const end = new Date(task.closed_at || task.updated_at).getTime();
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
|
||||
return Math.max(0, end - start);
|
||||
}
|
||||
|
||||
@ -113,19 +113,6 @@ export function TaskAcceptanceCard({
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const { locale } = useAppI18n();
|
||||
const [localComment, setLocalComment] = React.useState('');
|
||||
const comment = revision ?? localComment;
|
||||
const setComment = onRevisionChange ?? setLocalComment;
|
||||
const isFinalized = taskStatus === 'closed' || taskStatus === 'abandoned';
|
||||
const isReadyForAcceptance = READY_FOR_ACCEPTANCE_STATUSES.has(taskStatus);
|
||||
const recordedFeedback = feedbackForRun(feedbackItems, runId) ?? (isFinalized ? latestFeedback(feedbackItems) : null);
|
||||
const canSubmit = Boolean(runId) && !recordedFeedback && !isFinalized && isReadyForAcceptance && !actionBusy;
|
||||
const trimmedComment = comment.trim();
|
||||
|
||||
const submit = (feedbackType: TaskFeedbackType, nextComment?: string) => {
|
||||
if (!runId || !canSubmit) return;
|
||||
void onSubmit(feedbackType, nextComment);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@ -141,7 +128,49 @@ export function TaskAcceptanceCard({
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent>
|
||||
<TaskAcceptanceControls
|
||||
sessionId={sessionId}
|
||||
runId={runId}
|
||||
taskStatus={taskStatus}
|
||||
feedbackItems={feedbackItems}
|
||||
actionBusy={actionBusy}
|
||||
revision={revision}
|
||||
onRevisionChange={onRevisionChange}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskAcceptanceControls({
|
||||
sessionId,
|
||||
runId,
|
||||
taskStatus,
|
||||
feedbackItems,
|
||||
actionBusy,
|
||||
revision,
|
||||
onRevisionChange,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const { locale } = useAppI18n();
|
||||
const [localComment, setLocalComment] = React.useState('');
|
||||
const comment = revision ?? localComment;
|
||||
const setComment = onRevisionChange ?? setLocalComment;
|
||||
const isFinalized = taskStatus === 'closed' || taskStatus === 'abandoned';
|
||||
const isReadyForAcceptance = READY_FOR_ACCEPTANCE_STATUSES.has(taskStatus);
|
||||
const recordedFeedback = feedbackForRun(feedbackItems, runId) ?? (isFinalized ? latestFeedback(feedbackItems) : null);
|
||||
const canSubmit = Boolean(runId) && !recordedFeedback && !isFinalized && isReadyForAcceptance && !actionBusy;
|
||||
const trimmedComment = comment.trim();
|
||||
|
||||
const submit = (feedbackType: TaskFeedbackType, nextComment?: string) => {
|
||||
if (!runId || !canSubmit) return;
|
||||
void onSubmit(feedbackType, nextComment);
|
||||
};
|
||||
|
||||
return (
|
||||
<div 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">
|
||||
@ -207,7 +236,6 @@ export function TaskAcceptanceCard({
|
||||
{pickAppText(locale, '会话:', 'Session: ')}
|
||||
<span className="font-mono">{sessionId}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, MessageSquare } from 'lucide-react';
|
||||
import { ArrowLeft, CheckCircle2, MessageSquare } from 'lucide-react';
|
||||
|
||||
import { TaskRuntimeStatusBadge, formatTaskRuntimeDuration, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@ -15,6 +15,7 @@ type Props = {
|
||||
task: BackendTask;
|
||||
activeLabel: string;
|
||||
durationMs: number | null;
|
||||
reviewTargetId?: string;
|
||||
};
|
||||
|
||||
const RUNTIME_STATUSES = new Set<string>(['queued', 'running', 'waiting', 'blocked', 'done', 'error', 'cancelled']);
|
||||
@ -36,9 +37,10 @@ function humanTaskStatus(status: string, locale: 'zh-CN' | 'en-US') {
|
||||
return item ? pickAppText(locale, item[0], item[1]) : status;
|
||||
}
|
||||
|
||||
export function TaskLiveHeader({ task, activeLabel, durationMs }: Props) {
|
||||
export function TaskLiveHeader({ task, activeLabel, durationMs, reviewTargetId }: Props) {
|
||||
const { locale } = useAppI18n();
|
||||
const title = task.short_title || String(task.metadata?.short_title || '') || task.description || task.goal || task.task_id;
|
||||
const showReviewLink = Boolean(reviewTargetId && ['awaiting_acceptance', 'needs_revision'].includes(task.status));
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-20 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
@ -67,6 +69,14 @@ export function TaskLiveHeader({ task, activeLabel, durationMs }: Props) {
|
||||
</Badge>
|
||||
)}
|
||||
{activeLabel ? <Badge variant="secondary">{activeLabel}</Badge> : null}
|
||||
{showReviewLink ? (
|
||||
<Button asChild variant="default" size="sm">
|
||||
<a href={`#${reviewTargetId}`}>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
{pickAppText(locale, '验收', 'Review')}
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -34,11 +34,28 @@ function humanTaskStatus(status: string, locale: 'zh-CN' | 'en-US') {
|
||||
needs_revision: ['需要修改', 'Needs revision'],
|
||||
closed: ['已完成', 'Closed'],
|
||||
abandoned: ['已放弃', 'Abandoned'],
|
||||
accept: ['已接受', 'Accepted'],
|
||||
satisfied: ['已接受', 'Accepted'],
|
||||
revise: ['已请求修改', 'Revision requested'],
|
||||
abandon: ['已放弃', 'Abandoned'],
|
||||
};
|
||||
const item = map[status];
|
||||
return item ? pickAppText(locale, item[0], item[1]) : status;
|
||||
}
|
||||
|
||||
function latestFeedback(task: BackendTask): Record<string, unknown> | null {
|
||||
return [...(task.feedback ?? [])].reverse()[0] ?? null;
|
||||
}
|
||||
|
||||
function acceptanceState(task: BackendTask, locale: 'zh-CN' | 'en-US'): string {
|
||||
const feedback = latestFeedback(task);
|
||||
const kind = String(feedback?.acceptance_type || feedback?.feedback_type || '');
|
||||
if (kind) return humanTaskStatus(kind, locale);
|
||||
if (task.status === 'awaiting_acceptance') return pickAppText(locale, '等待验收', 'Awaiting acceptance');
|
||||
if (task.status === 'needs_revision') return pickAppText(locale, '等待修改', 'Awaiting revision');
|
||||
return pickAppText(locale, '未验收', 'No acceptance yet');
|
||||
}
|
||||
|
||||
function toTime(value: string): number {
|
||||
const parsed = new Date(value).getTime();
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
@ -135,6 +152,9 @@ export function TaskSideRail({ task, runs, artifacts, cards }: Props) {
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '更新', 'Updated')}: {formatTaskRuntimeTime(task.updated_at, locale)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '验收', 'Acceptance')}: {acceptanceState(task, locale)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@ -7,14 +7,16 @@ import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import type { TaskTimelineCard as TaskTimelineCardView } from '@/types';
|
||||
|
||||
import { TaskTimelineCard } from './TaskTimelineCard';
|
||||
import { TaskTimelineCard, type TaskResultAcceptance } from './TaskTimelineCard';
|
||||
|
||||
type Props = {
|
||||
cards: TaskTimelineCardView[];
|
||||
isLive: boolean;
|
||||
resultAcceptance?: TaskResultAcceptance;
|
||||
reviewTargetId?: string;
|
||||
};
|
||||
|
||||
export function TaskTimeline({ cards, isLive }: Props) {
|
||||
export function TaskTimeline({ cards, isLive, resultAcceptance, reviewTargetId }: Props) {
|
||||
const { locale } = useAppI18n();
|
||||
|
||||
return (
|
||||
@ -42,7 +44,7 @@ export function TaskTimeline({ cards, isLive }: Props) {
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{cards.map((card) => (
|
||||
<TaskTimelineCard key={card.id} card={card} />
|
||||
<TaskTimelineCard key={card.id} card={card} resultAcceptance={resultAcceptance} reviewTargetId={reviewTargetId} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -6,8 +6,10 @@ import {
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
ClipboardList,
|
||||
ChevronDown,
|
||||
FileText,
|
||||
GitBranch,
|
||||
History,
|
||||
ListChecks,
|
||||
Sparkles,
|
||||
TerminalSquare,
|
||||
@ -24,8 +26,23 @@ import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import type { TaskRuntimeStatus } from '@/lib/task-runtime';
|
||||
import type { TaskTimelineCard as TaskTimelineCardView, TaskTimelineCardType } from '@/types';
|
||||
|
||||
import { TaskAcceptanceControls, type TaskFeedbackItem, type TaskFeedbackType } from './TaskAcceptanceCard';
|
||||
|
||||
type Props = {
|
||||
card: TaskTimelineCardView;
|
||||
resultAcceptance?: TaskResultAcceptance;
|
||||
reviewTargetId?: string;
|
||||
};
|
||||
|
||||
export type TaskResultAcceptance = {
|
||||
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 RUNTIME_STATUSES = new Set<string>(['queued', 'running', 'waiting', 'blocked', 'done', 'error', 'cancelled']);
|
||||
@ -60,6 +77,8 @@ function iconForType(type: TaskTimelineCardType) {
|
||||
return AlertTriangle;
|
||||
case 'result':
|
||||
return CheckCircle2;
|
||||
case 'result_history':
|
||||
return History;
|
||||
case 'acceptance':
|
||||
return ThumbsUp;
|
||||
}
|
||||
@ -87,6 +106,7 @@ function cardTypeLabel(type: TaskTimelineCardType, locale: 'zh-CN' | 'en-US') {
|
||||
artifact: ['产物', 'Artifact'],
|
||||
error: ['异常', 'Error'],
|
||||
result: ['结果', 'Result'],
|
||||
result_history: ['历史结果', 'Result history'],
|
||||
acceptance: ['验收', 'Acceptance'],
|
||||
};
|
||||
const label = labels[type];
|
||||
@ -111,12 +131,57 @@ function humanStatus(status: string, locale: 'zh-CN' | 'en-US') {
|
||||
return label ? pickAppText(locale, label[0], label[1]) : status;
|
||||
}
|
||||
|
||||
export function TaskTimelineCard({ card }: Props) {
|
||||
function historyVersions(details: Record<string, unknown> | undefined): Array<Record<string, unknown>> {
|
||||
const versions = details?.versions;
|
||||
return Array.isArray(versions) ? versions.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object') : [];
|
||||
}
|
||||
|
||||
function renderHistoryStatus(version: Record<string, unknown>, locale: 'zh-CN' | 'en-US') {
|
||||
const status = String(version.acceptanceType || version.status || '');
|
||||
return status ? humanStatus(status, locale) : pickAppText(locale, '历史版本', 'Previous version');
|
||||
}
|
||||
|
||||
function TaskResultHistory({ card }: { card: TaskTimelineCardView }) {
|
||||
const { locale } = useAppI18n();
|
||||
const Icon = iconForType(card.type);
|
||||
const versions = historyVersions(card.details);
|
||||
|
||||
return (
|
||||
<Card className="rounded-md">
|
||||
<details className="mt-3 rounded-md border border-border bg-muted/20 px-3 py-2 text-sm">
|
||||
<summary className="flex cursor-pointer select-none items-center justify-between gap-3 font-medium">
|
||||
<span>{pickAppText(locale, '展开历史版本', 'Show previous versions')}</span>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
{versions.map((version, index) => (
|
||||
<div key={String(version.runId || index)} className="rounded-md border border-border bg-background p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium">
|
||||
{pickAppText(locale, `第 ${index + 1} 轮结果`, `Version ${index + 1}`)}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{renderHistoryStatus(version, locale)}
|
||||
</Badge>
|
||||
</div>
|
||||
{version.result ? <p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{String(version.result)}</p> : null}
|
||||
{version.comment ? (
|
||||
<div className="mt-3 rounded-md bg-muted/35 p-2 text-xs text-muted-foreground">
|
||||
{pickAppText(locale, '修改意见', 'Revision note')}: {String(version.comment)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskTimelineCard({ card, resultAcceptance, reviewTargetId }: Props) {
|
||||
const { locale } = useAppI18n();
|
||||
const Icon = iconForType(card.type);
|
||||
const shouldRenderResultAcceptance = Boolean(card.type === 'result' && resultAcceptance && card.runId === resultAcceptance.runId);
|
||||
|
||||
return (
|
||||
<Card id={shouldRenderResultAcceptance ? reviewTargetId : undefined} className="rounded-md scroll-mt-28">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted">
|
||||
@ -150,7 +215,13 @@ export function TaskTimelineCard({ card }: Props) {
|
||||
|
||||
{card.summary ? <p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{card.summary}</p> : null}
|
||||
|
||||
{card.details ? (
|
||||
{shouldRenderResultAcceptance ? (
|
||||
<div className="mt-4 border-t border-border pt-4">
|
||||
<TaskAcceptanceControls {...resultAcceptance!} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{card.type === 'result_history' ? <TaskResultHistory card={card} /> : card.details ? (
|
||||
<details className="mt-3 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs">
|
||||
<summary className="cursor-pointer select-none font-medium text-muted-foreground">
|
||||
{pickAppText(locale, '详情 JSON', 'Details JSON')}
|
||||
|
||||
@ -4,6 +4,7 @@ import type {
|
||||
AuthzStatus,
|
||||
AuthUser,
|
||||
ActiveTask,
|
||||
AgentConfigPayload,
|
||||
ChatLogsResponse,
|
||||
BackendTask,
|
||||
ChatMessage,
|
||||
@ -620,6 +621,13 @@ export async function getStatus(): Promise<SystemStatus> {
|
||||
return fetchJSON('/api/status');
|
||||
}
|
||||
|
||||
export async function updateAgentConfig(payload: AgentConfigPayload): Promise<{ ok: boolean }> {
|
||||
return fetchJSON('/api/agent-config', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateProviderConfig(
|
||||
providerId: string,
|
||||
payload: ProviderConfigPayload
|
||||
|
||||
37
app-instance/frontend/lib/task-detail-refresh.test.ts
Normal file
37
app-instance/frontend/lib/task-detail-refresh.test.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { shouldPollTaskDetail, taskDetailDurationMs } from '@/lib/task-detail-refresh';
|
||||
import type { BackendTask } from '@/types';
|
||||
|
||||
const baseTask: BackendTask = {
|
||||
task_id: 'task-1',
|
||||
session_id: 'web:test',
|
||||
description: '查找餐厅',
|
||||
goal: '查找餐厅',
|
||||
constraints: [],
|
||||
priority: 0,
|
||||
status: 'running',
|
||||
creator: 'main-agent',
|
||||
created_at: '2026-05-27T02:02:41.000Z',
|
||||
updated_at: '2026-05-27T02:02:41.500Z',
|
||||
run_ids: [],
|
||||
skill_names: [],
|
||||
feedback: [],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
describe('task detail refresh helpers', () => {
|
||||
it('polls executing task details regardless of websocket status', () => {
|
||||
expect(shouldPollTaskDetail({ ...baseTask, status: 'running' })).toBe(true);
|
||||
expect(shouldPollTaskDetail({ ...baseTask, status: 'open' })).toBe(true);
|
||||
expect(shouldPollTaskDetail({ ...baseTask, status: 'awaiting_acceptance' })).toBe(false);
|
||||
expect(shouldPollTaskDetail({ ...baseTask, status: 'closed' })).toBe(false);
|
||||
});
|
||||
|
||||
it('uses current time for active task duration instead of stale updated_at', () => {
|
||||
vi.setSystemTime(new Date('2026-05-27T02:03:41.000Z'));
|
||||
|
||||
expect(taskDetailDurationMs(baseTask)).toBe(60_000);
|
||||
expect(taskDetailDurationMs({ ...baseTask, status: 'awaiting_acceptance', updated_at: '2026-05-27T02:10:55.000Z' })).toBe(494_000);
|
||||
});
|
||||
});
|
||||
18
app-instance/frontend/lib/task-detail-refresh.ts
Normal file
18
app-instance/frontend/lib/task-detail-refresh.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import type { BackendTask } from '@/types';
|
||||
|
||||
const EXECUTING_TASK_STATUSES = new Set(['open', 'queued', 'running']);
|
||||
const FINISHED_FOR_DURATION_STATUSES = new Set(['awaiting_acceptance', 'closed', 'abandoned', 'cancelled', 'error']);
|
||||
|
||||
export function shouldPollTaskDetail(task: Pick<BackendTask, 'status'> | null): boolean {
|
||||
return Boolean(task && EXECUTING_TASK_STATUSES.has(task.status));
|
||||
}
|
||||
|
||||
export function taskDetailDurationMs(task: Pick<BackendTask, 'created_at' | 'updated_at' | 'closed_at' | 'status'>): number | null {
|
||||
const start = new Date(task.created_at).getTime();
|
||||
const end = FINISHED_FOR_DURATION_STATUSES.has(task.status)
|
||||
? new Date(task.closed_at || task.updated_at).getTime()
|
||||
: Date.now();
|
||||
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
|
||||
return Math.max(0, end - start);
|
||||
}
|
||||
@ -166,6 +166,133 @@ describe('buildTaskTimelineCards', () => {
|
||||
expect(cards.at(-1)?.summary).toContain('可以');
|
||||
});
|
||||
|
||||
it('uses the latest assistant message from the acceptance run as the result body', () => {
|
||||
const task = makeTask({
|
||||
status: 'awaiting_acceptance',
|
||||
updated_at: '2026-05-26T10:04:00.000Z',
|
||||
run_ids: ['run-main'],
|
||||
runs: [
|
||||
{
|
||||
run_id: 'run-main',
|
||||
title: '主 Agent',
|
||||
session_id: 'web:default',
|
||||
messages: [
|
||||
{ role: 'assistant', content: 'Draft answer', created_at: '2026-05-26T10:03:00.000Z' },
|
||||
{ role: 'assistant', content: 'Final user-visible answer', created_at: '2026-05-26T10:04:00.000Z' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-result-ready',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: 'task_result_ready',
|
||||
actor_type: 'system',
|
||||
actor_id: 'evidence',
|
||||
actor_name: 'Evidence',
|
||||
text: 'The task result is ready for user acceptance.',
|
||||
created_at: '2026-05-26T10:04:00.000Z',
|
||||
metadata: {
|
||||
result_summary: 'Summary should not replace the final answer.',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const cards = buildTaskTimelineCards({ task, processEvents });
|
||||
const result = cards.find((card) => card.type === 'result');
|
||||
|
||||
expect(result?.summary).toBe('Final user-visible answer');
|
||||
expect(result?.details?.result_summary).toBe('Summary should not replace the final answer.');
|
||||
});
|
||||
|
||||
it('collapses previous result and acceptance cards into a history pack', () => {
|
||||
const task = makeTask({
|
||||
status: 'awaiting_acceptance',
|
||||
updated_at: '2026-05-26T10:12:00.000Z',
|
||||
run_ids: ['run-1', 'run-2'],
|
||||
feedback: [
|
||||
{
|
||||
acceptance_type: 'revise',
|
||||
comment: 'Add decisions',
|
||||
created_at: '2026-05-26T10:06:00.000Z',
|
||||
run_id: 'run-1',
|
||||
},
|
||||
],
|
||||
runs: [
|
||||
{
|
||||
run_id: 'run-1',
|
||||
title: '主 Agent',
|
||||
session_id: 'web:default',
|
||||
messages: [{ role: 'assistant', content: 'Version one answer', created_at: '2026-05-26T10:05:00.000Z' }],
|
||||
},
|
||||
{
|
||||
run_id: 'run-2',
|
||||
title: '主 Agent',
|
||||
session_id: 'web:default',
|
||||
messages: [{ role: 'assistant', content: 'Version two answer', created_at: '2026-05-26T10:12:00.000Z' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-result-1',
|
||||
run_id: 'run-1',
|
||||
parent_run_id: null,
|
||||
kind: 'task_result_ready',
|
||||
actor_type: 'system',
|
||||
actor_id: 'evidence',
|
||||
actor_name: 'Evidence',
|
||||
text: 'Result one ready.',
|
||||
created_at: '2026-05-26T10:05:00.000Z',
|
||||
},
|
||||
{
|
||||
event_id: 'evt-plan-2',
|
||||
run_id: 'run-2',
|
||||
parent_run_id: null,
|
||||
kind: 'task_planned',
|
||||
actor_type: 'system',
|
||||
actor_id: 'planner',
|
||||
actor_name: 'Task Planner',
|
||||
text: 'Second attempt planned.',
|
||||
created_at: '2026-05-26T10:08:00.000Z',
|
||||
},
|
||||
{
|
||||
event_id: 'evt-result-2',
|
||||
run_id: 'run-2',
|
||||
parent_run_id: null,
|
||||
kind: 'task_result_ready',
|
||||
actor_type: 'system',
|
||||
actor_id: 'evidence',
|
||||
actor_name: 'Evidence',
|
||||
text: 'Result two ready.',
|
||||
created_at: '2026-05-26T10:12:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const cards = buildTaskTimelineCards({ task, processEvents });
|
||||
|
||||
expect(cards.map((card) => card.type)).toEqual([
|
||||
'task_created',
|
||||
'result_history',
|
||||
'plan',
|
||||
'result',
|
||||
]);
|
||||
const history = cards.find((card) => card.type === 'result_history');
|
||||
expect(history?.summary).toBe('1 历史结果版本');
|
||||
expect(history?.details?.versions).toEqual([
|
||||
expect.objectContaining({
|
||||
runId: 'run-1',
|
||||
result: 'Version one answer',
|
||||
acceptanceType: 'revise',
|
||||
comment: 'Add decisions',
|
||||
}),
|
||||
]);
|
||||
expect(cards.find((card) => card.id === 'evt-plan-2')).toBeTruthy();
|
||||
expect(cards.at(-1)?.summary).toBe('Version two answer');
|
||||
});
|
||||
|
||||
it('does not add fallback progress when a child run already has progress events', () => {
|
||||
const task = makeTask();
|
||||
const processRuns: ProcessRun[] = [
|
||||
@ -201,6 +328,51 @@ describe('buildTaskTimelineCards', () => {
|
||||
expect(cards.map((card) => card.id)).not.toContain('run-research:fallback-progress');
|
||||
});
|
||||
|
||||
it('marks a tool call as finished when a matching tool result exists', () => {
|
||||
const task = makeTask();
|
||||
const processEvents: ProcessEvent[] = [
|
||||
{
|
||||
event_id: 'evt-tool-start',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: 'tool_call_started',
|
||||
actor_type: 'mcp',
|
||||
actor_id: 'web_search',
|
||||
actor_name: 'web_search',
|
||||
text: 'Calling tool: web_search.',
|
||||
status: 'running',
|
||||
created_at: '2026-05-26T10:02:00.000Z',
|
||||
metadata: {
|
||||
tool_call_id: 'call-1',
|
||||
tool_name: 'web_search',
|
||||
},
|
||||
},
|
||||
{
|
||||
event_id: 'evt-tool-finish',
|
||||
run_id: 'run-main',
|
||||
parent_run_id: null,
|
||||
kind: 'tool_call_finished',
|
||||
actor_type: 'mcp',
|
||||
actor_id: 'web_search',
|
||||
actor_name: 'web_search',
|
||||
text: 'Search failed.',
|
||||
status: 'error',
|
||||
created_at: '2026-05-26T10:03:00.000Z',
|
||||
metadata: {
|
||||
tool_call_id: 'call-1',
|
||||
tool_name: 'web_search',
|
||||
result_summary: 'Search failed.',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const cards = buildTaskTimelineCards({ task, processEvents });
|
||||
|
||||
expect(cards.find((card) => card.id === 'evt-tool-start')?.status).toBe('error');
|
||||
expect(cards.find((card) => card.id === 'evt-tool-finish')?.type).toBe('tool_result');
|
||||
expect(cards.find((card) => card.id === 'evt-tool-finish')?.summary).toBe('Search failed.');
|
||||
});
|
||||
|
||||
it('maps agent_finished events without timeline metadata to agent progress cards', () => {
|
||||
const task = makeTask();
|
||||
const processEvents: ProcessEvent[] = [
|
||||
|
||||
@ -27,6 +27,7 @@ const TIMELINE_CARD_TYPES = new Set<TaskTimelineCardType>([
|
||||
'artifact',
|
||||
'error',
|
||||
'result',
|
||||
'result_history',
|
||||
'acceptance',
|
||||
]);
|
||||
|
||||
@ -77,10 +78,6 @@ function cardTypeForEvent(event: ProcessEvent): TaskTimelineCardType | null {
|
||||
return timelineType;
|
||||
}
|
||||
|
||||
if (event.status === 'error') {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
switch (String(event.kind)) {
|
||||
case 'task_planned':
|
||||
case 'run_started':
|
||||
@ -106,6 +103,9 @@ function cardTypeForEvent(event: ProcessEvent): TaskTimelineCardType | null {
|
||||
case 'task_error':
|
||||
return 'error';
|
||||
default:
|
||||
if (event.status === 'error') {
|
||||
return 'error';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -136,6 +136,8 @@ function titleForCard(type: TaskTimelineCardType, actorName?: string): string {
|
||||
return '执行遇到问题';
|
||||
case 'result':
|
||||
return '本轮结果';
|
||||
case 'result_history':
|
||||
return '历史结果版本';
|
||||
case 'acceptance':
|
||||
return '任务验收';
|
||||
}
|
||||
@ -182,6 +184,22 @@ function resultSummary(task: BackendTask): string | undefined {
|
||||
);
|
||||
}
|
||||
|
||||
function assistantResultForRun(task: BackendTask, runId: string | null | undefined): string | undefined {
|
||||
if (!runId) return undefined;
|
||||
const run = (task.runs ?? []).find((item) => item.run_id === runId);
|
||||
if (!run) return undefined;
|
||||
const assistantMessages = run.messages.filter((message) => message.role === 'assistant' && message.content.trim());
|
||||
return lastItem(assistantMessages)?.content.trim();
|
||||
}
|
||||
|
||||
function resultSummaryForEvent(task: BackendTask, event: ProcessEvent): string | undefined {
|
||||
return firstString(assistantResultForRun(task, event.run_id), summaryForEvent(event));
|
||||
}
|
||||
|
||||
function fallbackResultSummary(task: BackendTask): string | undefined {
|
||||
return firstString(assistantResultForRun(task, lastItem(task.run_ids)), resultSummary(task));
|
||||
}
|
||||
|
||||
function buildRunMap(processRuns: ProcessRun[]): Map<string, ProcessRun> {
|
||||
const map = new Map<string, ProcessRun>();
|
||||
for (const run of processRuns) {
|
||||
@ -239,12 +257,106 @@ function isCoveredByAcceptanceEvent(
|
||||
return matchingTypeEvents.length === 1;
|
||||
}
|
||||
|
||||
function cardTime(card: TaskTimelineCard): number {
|
||||
return toTime(card.createdAt) ?? Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
function cardComment(card: TaskTimelineCard): string | undefined {
|
||||
return firstString(card.details?.comment, card.summary);
|
||||
}
|
||||
|
||||
function toolCallKeyFromEvent(event: ProcessEvent): string | null {
|
||||
const toolCallId = firstString(event.metadata?.tool_call_id);
|
||||
if (toolCallId) return `${event.run_id}:${toolCallId}`;
|
||||
|
||||
const toolName = firstString(event.metadata?.tool_name, event.actor_name, event.actor_id);
|
||||
if (toolName) return `${event.run_id}:${toolName}`;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildToolResultStatusByCall(processEvents: ProcessEvent[]): Map<string, string> {
|
||||
const statuses = new Map<string, string>();
|
||||
for (const event of processEvents) {
|
||||
if (cardTypeForEvent(event) !== 'tool_result') continue;
|
||||
const key = toolCallKeyFromEvent(event);
|
||||
if (!key) continue;
|
||||
statuses.set(key, event.status || 'done');
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
function buildResultHistoryCard(task: BackendTask, resultCards: TaskTimelineCard[], acceptanceCards: TaskTimelineCard[]): TaskTimelineCard {
|
||||
const versions = resultCards.map((resultCard) => {
|
||||
const acceptanceCard = acceptanceCards
|
||||
.filter((card) => card.runId === resultCard.runId)
|
||||
.sort((a, b) => cardTime(a) - cardTime(b))
|
||||
.at(-1);
|
||||
return {
|
||||
runId: resultCard.runId ?? null,
|
||||
result: resultCard.summary ?? '',
|
||||
createdAt: resultCard.createdAt,
|
||||
status: acceptanceCard?.status ?? resultCard.status ?? null,
|
||||
acceptanceType: acceptanceCard?.status ?? null,
|
||||
comment: acceptanceCard ? cardComment(acceptanceCard) ?? '' : '',
|
||||
acceptedAt: acceptanceCard?.createdAt ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: `${task.task_id}:result-history`,
|
||||
taskId: task.task_id,
|
||||
type: 'result_history',
|
||||
title: titleForCard('result_history'),
|
||||
summary: `${resultCards.length} 历史结果版本`,
|
||||
createdAt: resultCards[0]?.createdAt ?? task.created_at,
|
||||
details: { versions },
|
||||
};
|
||||
}
|
||||
|
||||
function collapseHistoricalResults(task: BackendTask, cards: TaskTimelineCard[]): TaskTimelineCard[] {
|
||||
const resultCards = cards.filter((card) => card.type === 'result');
|
||||
if (resultCards.length <= 1) return cards;
|
||||
|
||||
const finalAcceptedRunId = firstString(task.metadata?.final_accepted_run_id);
|
||||
const latestResult =
|
||||
(finalAcceptedRunId ? resultCards.find((card) => card.runId === finalAcceptedRunId) : undefined) ??
|
||||
[...resultCards].sort((a, b) => cardTime(a) - cardTime(b)).at(-1);
|
||||
if (!latestResult) return cards;
|
||||
|
||||
const oldResults = resultCards
|
||||
.filter((card) => card.id !== latestResult.id)
|
||||
.sort((a, b) => cardTime(a) - cardTime(b));
|
||||
if (oldResults.length === 0) return cards;
|
||||
|
||||
const oldRunIds = new Set(oldResults.map((card) => card.runId).filter(Boolean));
|
||||
const oldAcceptances = cards
|
||||
.filter((card) => card.type === 'acceptance' && oldRunIds.has(card.runId))
|
||||
.sort((a, b) => cardTime(a) - cardTime(b));
|
||||
const foldedIds = new Set([...oldResults, ...oldAcceptances].map((card) => card.id));
|
||||
const historyCard = buildResultHistoryCard(task, oldResults, oldAcceptances);
|
||||
const firstOldResultIndex = cards.findIndex((card) => card.id === oldResults[0].id);
|
||||
const output: TaskTimelineCard[] = [];
|
||||
|
||||
for (let index = 0; index < cards.length; index += 1) {
|
||||
if (index === firstOldResultIndex) {
|
||||
output.push(historyCard);
|
||||
}
|
||||
if (!foldedIds.has(cards[index].id)) {
|
||||
output.push(cards[index]);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
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 toolResultStatusByCall = buildToolResultStatusByCall(processEvents);
|
||||
const runsWithProgressEvents = new Set<string>();
|
||||
const acceptanceEvents: AcceptanceEventIdentity[] = [];
|
||||
let hasResultEventCard = false;
|
||||
@ -285,9 +397,12 @@ export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): Task
|
||||
parentRunId: event.parent_run_id,
|
||||
type,
|
||||
title: titleForCard(type, event.actor_name),
|
||||
summary: summaryForEvent(event),
|
||||
summary: type === 'result' ? resultSummaryForEvent(task, event) : summaryForEvent(event),
|
||||
actorName: event.actor_name,
|
||||
status: event.status,
|
||||
status:
|
||||
type === 'tool_call'
|
||||
? toolResultStatusByCall.get(toolCallKeyFromEvent(event) ?? '') ?? event.status
|
||||
: event.status,
|
||||
createdAt: event.created_at,
|
||||
details: detailsForEvent(event),
|
||||
});
|
||||
@ -340,7 +455,7 @@ export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): Task
|
||||
runId: lastItem(task.run_ids),
|
||||
type: 'result',
|
||||
title: titleForCard('result'),
|
||||
summary: resultSummary(task),
|
||||
summary: fallbackResultSummary(task),
|
||||
status: task.status,
|
||||
createdAt: task.closed_at ?? task.updated_at ?? task.created_at,
|
||||
details: task.validation_result ?? undefined,
|
||||
@ -366,8 +481,10 @@ export function buildTaskTimelineCards(input: BuildTaskTimelineCardsInput): Task
|
||||
});
|
||||
}
|
||||
|
||||
return cards
|
||||
const sortedCards = cards
|
||||
.map((card, index) => ({ card, index }))
|
||||
.sort(compareCardsByCreatedAt)
|
||||
.map(({ card }) => card);
|
||||
|
||||
return collapseHistoricalResults(task, sortedCards);
|
||||
}
|
||||
|
||||
@ -142,6 +142,12 @@ export interface ProviderConfigPayload {
|
||||
request_timeout_seconds?: number;
|
||||
}
|
||||
|
||||
export interface AgentConfigPayload {
|
||||
max_tokens: number | null;
|
||||
temperature: number;
|
||||
max_tool_iterations: number;
|
||||
}
|
||||
|
||||
export interface ChannelStatus {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
@ -153,7 +159,7 @@ export interface SystemStatus {
|
||||
workspace: string;
|
||||
workspace_exists: boolean;
|
||||
model: string;
|
||||
max_tokens: number;
|
||||
max_tokens: number | null;
|
||||
max_context_messages?: number;
|
||||
temperature: number;
|
||||
max_tool_iterations: number;
|
||||
@ -794,6 +800,7 @@ export type TaskTimelineCardType =
|
||||
| 'artifact'
|
||||
| 'error'
|
||||
| 'result'
|
||||
| 'result_history'
|
||||
| 'acceptance';
|
||||
|
||||
export interface TaskTimelineCard {
|
||||
|
||||
Reference in New Issue
Block a user