feat(coordinator): 添加团队节点默认最大工具迭代次数配置
添加 DEFAULT_TEAM_NODE_MAX_TOOL_ITERATIONS 配置项以控制团队节点的最大工具迭代次数, 并修改 LocalAgentRunner 中的逻辑来使用此默认值当 envelope 中未指定时。 fix(runtime): 修复团队节点运行成功判断逻辑 更新运行成功判断条件,将 finish_reason 为 "max_tool_iterations_finalized" 的情况 视为运行失败,并添加对原始工具调用输出的检测,避免将其误判为成功完成。 feat(mcp): 添加团队工作流MCP工具类别支持 增加新的本地MCP工具类别 "team_workflow" 及其对应的工具创建功能, 为团队工作流提供本地工具支持。 refactor(engine): 调整AgentLoop最大工具迭代次数设置 将 AgentProfile 中的默认 max_tool_iterations 从 30 增加到 100, 同时移除 TaskExecutionPlanner 构造函数中的重复参数传递。 perf(mcp): 优化MCP连接管理避免重复连接 添加 mcp_connected 标志来跟踪MCP连接状态,确保 connect_all 只执行一次, 提高性能并避免不必要的重复连接。 refactor(skills): 移除技能团队模板相关功能 移除与技能团队模板相关的代码,包括解析、存储和处理逻辑, 简化技能记录结构和加载流程。 feat(process): 增强会话过程投影器功能 添加技能激活快照事件处理,改进团队运行完成消息显示, 并增强技能激活事件的时间戳记录功能。 refactor(tasks): 简化任务尝试编排器团队执行逻辑 移除团队执行相关代码,将所有任务统一按单步执行处理, 简化任务编排器的复杂度并提升执行效率。 fix(evidence): 修复节点证据评估中需求验证逻辑 更新节点证据评估逻辑,跳过自然语言证据需求的确定性验证, 只执行机器可读的需求验证,避免因自然语言需求导致的节点失败。
This commit is contained in:
@ -0,0 +1,638 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
BarChart3,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Clock3,
|
||||
Database,
|
||||
Download,
|
||||
Eye,
|
||||
FileImage,
|
||||
FileJson,
|
||||
FileText,
|
||||
Globe2,
|
||||
Grid2X2,
|
||||
ListFilter,
|
||||
Network,
|
||||
PackageOpen,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
Table2,
|
||||
UserRound,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { TaskFeedbackType } from '@/components/task-detail/TaskAcceptanceCard';
|
||||
import type { TaskResultAcceptance } from '@/components/task-detail/TaskTimelineCard';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { formatTaskRuntimeDuration, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
|
||||
import { pickAppText } from '@/lib/i18n/core';
|
||||
import { useAppI18n } from '@/lib/i18n/provider';
|
||||
import {
|
||||
buildTaskUiModel,
|
||||
taskUiStatusClass,
|
||||
taskUiStatusLabel,
|
||||
type TaskUiAgentNode,
|
||||
type TaskUiArtifact,
|
||||
type TaskUiAttempt,
|
||||
type TaskUiModel,
|
||||
type TaskUiStatus,
|
||||
type TaskUiStep,
|
||||
} from '@/lib/task-ui-model';
|
||||
import { containedLongTextClass, containedPreservedLongTextClass } from '@/lib/text-wrapping';
|
||||
import type { BackendTask, SessionProcessProjection, TaskTimelineCard } from '@/types';
|
||||
|
||||
type Props = {
|
||||
task: BackendTask;
|
||||
process: SessionProcessProjection;
|
||||
cards: TaskTimelineCard[];
|
||||
isLive: boolean;
|
||||
resultAcceptance?: TaskResultAcceptance;
|
||||
reviewTargetId?: string;
|
||||
};
|
||||
|
||||
function StatusBadge({ status, compact = false }: { status: TaskUiStatus; compact?: boolean }) {
|
||||
const { locale } = useAppI18n();
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${compact ? 'h-6 px-2 text-[11px]' : 'h-7 px-3 text-xs'} rounded-full font-medium ${taskUiStatusClass(status)}`}
|
||||
>
|
||||
{taskUiStatusLabel(status, locale)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
children,
|
||||
action,
|
||||
className = '',
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className={`min-w-0 rounded-lg border border-[#E6E1DE] bg-white ${className}`}>
|
||||
<div className="flex min-h-[50px] items-center justify-between gap-3 px-5 py-2.5">
|
||||
<h2 className="text-base font-semibold text-[#1D1715]">{title}</h2>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-[120px] flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-[#DED8D4] bg-[#FBFAF9] px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
<PackageOpen className="h-5 w-5 text-[#8D8782]" />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function iconForStep(kind: TaskUiStep['kind']) {
|
||||
if (kind === 'skill') return Grid2X2;
|
||||
if (kind === 'tool') return Clock3;
|
||||
if (kind === 'agent') return Network;
|
||||
if (kind === 'artifact') return FileText;
|
||||
if (kind === 'result') return BarChart3;
|
||||
return FileText;
|
||||
}
|
||||
|
||||
function statusDotClass(status: TaskUiStatus) {
|
||||
if (status === 'done') return 'bg-[#22733A]';
|
||||
if (status === 'running') return 'bg-[#C47B00]';
|
||||
if (status === 'error') return 'bg-[#9D3D2F]';
|
||||
if (status === 'cancelled') return 'bg-[#756A64]';
|
||||
return 'bg-[#8D8782]';
|
||||
}
|
||||
|
||||
function ExecutionFlow({ model }: { model: TaskUiModel }) {
|
||||
const { locale } = useAppI18n();
|
||||
const steps = model.steps.slice(0, 6);
|
||||
const columnClass = steps.length >= 6 ? 'grid-cols-6' : steps.length >= 4 ? 'grid-cols-4' : steps.length >= 2 ? 'grid-cols-2' : 'grid-cols-1';
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="任务执行流程"
|
||||
action={
|
||||
<Button variant="outline" size="sm" className="h-9 rounded-lg border-[#E6E1DE] bg-white text-xs">
|
||||
查看详情
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="px-5 pb-4">
|
||||
<div className={`relative grid gap-5 ${columnClass}`}>
|
||||
{steps.length > 1 ? <div className="absolute left-8 right-8 top-[17px] h-px bg-[#CFC8C3]" /> : null}
|
||||
{steps.map((step) => {
|
||||
const Icon = iconForStep(step.kind);
|
||||
return (
|
||||
<div key={step.id} className="relative min-w-0">
|
||||
<div className="relative z-10 flex h-10 items-center">
|
||||
<span className={`flex h-5 w-5 items-center justify-center rounded-full ${statusDotClass(step.status)}`}>
|
||||
{step.status === 'done' ? <CheckCircle2 className="h-3.5 w-3.5 text-white" /> : <span className="h-1.5 w-1.5 rounded-full bg-white" />}
|
||||
</span>
|
||||
<span className="ml-5 flex h-10 w-10 items-center justify-center rounded-full border border-[#D8D2CE] bg-[#F8F6F4]">
|
||||
<Icon className="h-5 w-5 text-[#1D1715]" />
|
||||
</span>
|
||||
</div>
|
||||
<h3 className={`text-sm font-semibold text-[#1D1715] ${containedLongTextClass}`}>{step.title}</h3>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
{step.createdAt ? <span className="text-xs text-muted-foreground">{formatTaskRuntimeTime(step.createdAt, locale)}</span> : null}
|
||||
<StatusBadge status={step.status} compact />
|
||||
</div>
|
||||
{step.summary ? (
|
||||
<p className={`mt-1 line-clamp-4 text-xs leading-[17px] text-[#4F4642] ${containedPreservedLongTextClass}`}>{step.summary}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function progressColor(status: TaskUiStatus) {
|
||||
if (status === 'done') return '#137333';
|
||||
if (status === 'running') return '#D48500';
|
||||
if (status === 'error') return '#9D3D2F';
|
||||
if (status === 'cancelled') return '#756A64';
|
||||
return '#E3DFDC';
|
||||
}
|
||||
|
||||
function AgentCard({ agent, root = false }: { agent: TaskUiAgentNode; root?: boolean }) {
|
||||
return (
|
||||
<div className={`relative rounded-lg border border-[#E1DCD8] bg-white px-3 py-3 ${root ? 'h-[68px] w-60 shadow-[0_2px_8px_rgba(31,24,20,0.05)]' : 'h-[90px] w-[150px]'}`}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<UserRound className="h-4 w-4 shrink-0 text-[#1D1715]" />
|
||||
<span className="min-w-0 truncate text-[13px] font-semibold leading-5">{agent.title || agent.name}</span>
|
||||
</div>
|
||||
<div className={root ? '' : 'absolute right-3 top-8'}>
|
||||
<StatusBadge status={agent.status} compact />
|
||||
</div>
|
||||
</div>
|
||||
<div className={root ? 'mt-3 h-1.5 rounded-full bg-[#ECE8E5]' : 'mt-8 h-1.5 rounded-full bg-[#ECE8E5]'}>
|
||||
<div className="h-full rounded-full" style={{ width: `${agent.progress}%`, backgroundColor: progressColor(agent.status) }} />
|
||||
</div>
|
||||
<div className="mt-2 text-right text-xs text-[#4F4642]">{agent.progress}%</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentDAG({ model }: { model: TaskUiModel }) {
|
||||
const { locale } = useAppI18n();
|
||||
const roots = model.agentTree;
|
||||
const root = roots.find((node) => node.children.length > 0) ?? roots[0];
|
||||
const children = root?.children.length ? root.children : roots.filter((node) => node.runId !== root?.runId);
|
||||
const visibleChildren = children.slice(0, 5);
|
||||
|
||||
if (!model.team.hasTeam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Section
|
||||
title={pickAppText(locale, 'Agent Team 执行图', 'Agent Team execution graph')}
|
||||
action={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className="rounded-full border-[#E1DCD8] bg-[#FBFAF9] text-[11px]">
|
||||
{model.team.outcome}
|
||||
</Badge>
|
||||
<StatusBadge status={model.team.status} compact />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="px-5 pb-5">
|
||||
{roots.length === 0 ? (
|
||||
<EmptyState>{pickAppText(locale, '暂无 Agent Team 数据', 'No Agent Team data yet')}</EmptyState>
|
||||
) : (
|
||||
<div className="relative min-h-[196px]">
|
||||
{visibleChildren.length > 0 ? (
|
||||
<>
|
||||
<div className="absolute left-1/2 top-[70px] h-10 w-px -translate-x-1/2 bg-[#1D1715]" />
|
||||
<div className="absolute left-[9%] right-[9%] top-[110px] h-px bg-[#1D1715]" />
|
||||
{visibleChildren.map((child, index) => (
|
||||
<div
|
||||
key={child.runId}
|
||||
className="absolute top-[110px] h-7 w-px bg-[#1D1715]"
|
||||
style={{ left: `${9 + index * (82 / Math.max(visibleChildren.length - 1, 1))}%` }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
<div className="flex justify-center">
|
||||
<AgentCard agent={root} root />
|
||||
</div>
|
||||
{visibleChildren.length > 0 ? (
|
||||
<div className="absolute bottom-0 left-0 right-0 flex items-end justify-between gap-4 px-6">
|
||||
{visibleChildren.map((agent) => (
|
||||
<AgentCard key={agent.runId} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function RunPath({
|
||||
model,
|
||||
selectedAttemptId,
|
||||
onSelectAttempt,
|
||||
}: {
|
||||
model: TaskUiModel;
|
||||
selectedAttemptId: string | null;
|
||||
onSelectAttempt: (attemptId: string) => void;
|
||||
}) {
|
||||
const { locale } = useAppI18n();
|
||||
const [expandedIds, setExpandedIds] = React.useState<Set<string>>(() => new Set());
|
||||
const attempts = model.attempts.filter((attempt) => attempt.runs.length > 0 || attempt.tools.length > 0 || attempt.result);
|
||||
|
||||
if (attempts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Section
|
||||
title={pickAppText(locale, '运行路径与结果版本', 'Run path and result versions')}
|
||||
action={
|
||||
<Badge variant="outline" className="h-7 rounded-full border-[#E1DCD8] bg-[#FBFAF9] text-[11px]">
|
||||
{attempts.length} runs
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3 border-t border-[#ECE8E5] p-4">
|
||||
{attempts.map((attempt) => (
|
||||
<div
|
||||
key={attempt.id}
|
||||
onClick={() => onSelectAttempt(attempt.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onSelectAttempt(attempt.id);
|
||||
}
|
||||
}}
|
||||
role="group"
|
||||
tabIndex={0}
|
||||
aria-label={pickAppText(locale, `选择${attempt.title}`, `Select ${attempt.title}`)}
|
||||
className={`rounded-lg border p-4 transition-colors ${
|
||||
selectedAttemptId === attempt.id
|
||||
? 'border-[#1D1715] bg-white shadow-[0_6px_18px_rgba(31,24,20,0.06)]'
|
||||
: 'border-[#E1DCD8] bg-[#FBFAF9]'
|
||||
} cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1D1715] focus-visible:ring-offset-2`}
|
||||
>
|
||||
<div className="flex w-full min-w-0 items-center justify-between gap-3 text-left">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-[#1D1715]">{attempt.title}</h3>
|
||||
<StatusBadge status={attempt.status} compact />
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{formatTaskRuntimeTime(attempt.startedAt, locale)}
|
||||
{attempt.finishedAt ? ` · ${formatAttemptDuration(attempt.startedAt, attempt.finishedAt, locale)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-xs text-muted-foreground">
|
||||
{attempt.tools.length} tools
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{attempt.runs.length > 0 ? (
|
||||
<div className="mt-3 flex min-w-0 flex-wrap items-center gap-2">
|
||||
{attempt.runs.map((run, index) => (
|
||||
<React.Fragment key={run.runId}>
|
||||
{index > 0 ? <span className="text-xs text-[#B8AEA8]">→</span> : null}
|
||||
<span className="inline-flex max-w-[220px] items-center gap-1.5 rounded-full border border-[#E1DCD8] bg-white px-2.5 py-1 text-xs text-[#4F4642]">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${run.status === 'done' ? 'bg-[#22733A]' : run.status === 'error' ? 'bg-[#9D3D2F]' : run.status === 'running' ? 'bg-[#C47B00]' : 'bg-[#8D8782]'}`} />
|
||||
<span className="truncate">{run.actorName || run.title}</span>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectAttempt(attempt.id);
|
||||
setExpandedIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(attempt.id)) next.delete(attempt.id);
|
||||
else next.add(attempt.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className="mt-3 flex h-8 items-center gap-1 rounded-md px-2 text-xs font-medium text-[#615854] hover:bg-[#F4F1EF]"
|
||||
aria-expanded={expandedIds.has(attempt.id)}
|
||||
>
|
||||
{expandedIds.has(attempt.id)
|
||||
? pickAppText(locale, '收起结果', 'Collapse result')
|
||||
: pickAppText(locale, '展开结果', 'Expand result')}
|
||||
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${expandedIds.has(attempt.id) ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{attempt.result && expandedIds.has(attempt.id) ? (
|
||||
<div className="mt-3 rounded-md border border-[#E6E1DE] bg-white p-3">
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium text-[#615854]">
|
||||
{pickAppText(locale, '本次结果', 'Attempt result')}
|
||||
</span>
|
||||
<StatusBadge status={attempt.result.status} compact />
|
||||
</div>
|
||||
<p className={`line-clamp-3 text-xs leading-5 text-[#4F4642] ${containedPreservedLongTextClass}`}>
|
||||
{attempt.result.summary || attempt.result.title}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function formatAttemptDuration(startedAt: string, finishedAt: string, locale: string): string {
|
||||
const startMs = new Date(startedAt).getTime();
|
||||
const finishMs = new Date(finishedAt).getTime();
|
||||
if (Number.isNaN(startMs) || Number.isNaN(finishMs) || finishMs < startMs) return '-';
|
||||
return formatTaskRuntimeDuration(finishMs - startMs, locale);
|
||||
}
|
||||
|
||||
function toolsForAttempt(model: TaskUiModel, selectedAttemptId: string | null): TaskUiAttempt {
|
||||
return (
|
||||
model.attempts.find((attempt) => attempt.id === selectedAttemptId) ??
|
||||
model.attempts.at(-1) ??
|
||||
{
|
||||
id: 'all',
|
||||
index: 1,
|
||||
title: 'Agent',
|
||||
status: 'waiting',
|
||||
startedAt: '',
|
||||
runs: [],
|
||||
tools: model.tools,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function ToolCalls({ model, selectedAttemptId }: { model: TaskUiModel; selectedAttemptId: string | null }) {
|
||||
const { locale } = useAppI18n();
|
||||
const selectedAttempt = toolsForAttempt(model, selectedAttemptId);
|
||||
const agents = Array.from(new Set(selectedAttempt.tools.map((tool) => tool.actorName || 'Agent')));
|
||||
const [selectedAgent, setSelectedAgent] = React.useState<string | null>(null);
|
||||
const activeAgent = selectedAgent && agents.includes(selectedAgent) ? selectedAgent : agents[0] ?? 'Agent';
|
||||
const visibleTools = selectedAttempt.tools.filter((tool) => (tool.actorName || 'Agent') === activeAgent);
|
||||
|
||||
return (
|
||||
<Section
|
||||
title={pickAppText(locale, '运行摘要', 'Run summary')}
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="outline" className="h-7 rounded-full border-[#E1DCD8] bg-[#FBFAF9] text-[11px]">
|
||||
{selectedAttempt.title}
|
||||
</Badge>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{selectedAttempt.tools.length === 0 ? (
|
||||
<div className="border-t border-[#ECE8E5] p-5">
|
||||
<EmptyState>{pickAppText(locale, '暂无工具调用', 'No tool calls yet')}</EmptyState>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[170px_minmax(0,1fr)] border-t border-[#ECE8E5]">
|
||||
<aside className="border-r border-[#ECE8E5] bg-[#FBFAF9] p-4">
|
||||
{agents.map((name) => {
|
||||
const count = selectedAttempt.tools.filter((tool) => (tool.actorName || 'Agent') === name).length;
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => setSelectedAgent(name)}
|
||||
className={`flex min-h-10 w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm ${
|
||||
activeAgent === name ? 'bg-white text-[#1D1715] ring-1 ring-[#E1DCD8]' : 'text-[#615854] hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
<Network className="h-3.5 w-3.5 text-[#615854]" />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate">{model.team.hasTeam ? name : 'Agent'}</span>
|
||||
<span className="block text-xs text-muted-foreground">{count} calls</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</aside>
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<div className="grid grid-cols-[150px_minmax(0,1fr)_96px_96px] border-b border-[#ECE8E5] px-4 py-3 text-xs font-medium text-[#615854]">
|
||||
<span>工具名称</span>
|
||||
<span>摘要</span>
|
||||
<span>状态</span>
|
||||
<span>运行时间</span>
|
||||
</div>
|
||||
{visibleTools.map((tool) => (
|
||||
<div key={tool.id} className="grid min-h-[56px] grid-cols-[150px_minmax(0,1fr)_96px_96px] items-center gap-3 border-b border-[#F0ECE9] px-4 text-sm last:border-b-0">
|
||||
<span className="truncate font-medium text-[#1D1715]">{tool.toolName}</span>
|
||||
<span className={`text-[#4F4642] ${containedLongTextClass}`}>
|
||||
{tool.summary}
|
||||
</span>
|
||||
<StatusBadge status={tool.status} compact />
|
||||
<span className="text-[#4F4642]">{formatToolDuration(tool, locale)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function formatToolDuration(tool: TaskUiModel['tools'][number], locale: string): string {
|
||||
if (typeof tool.durationMs === 'number') {
|
||||
return formatTaskRuntimeDuration(tool.durationMs, locale);
|
||||
}
|
||||
if (tool.status === 'running' && tool.createdAt) {
|
||||
const startMs = new Date(tool.createdAt).getTime();
|
||||
if (!Number.isNaN(startMs)) {
|
||||
return formatTaskRuntimeDuration(Date.now() - startMs, locale);
|
||||
}
|
||||
}
|
||||
return '-';
|
||||
}
|
||||
|
||||
function iconForArtifact(artifact: TaskUiArtifact) {
|
||||
if (artifact.type === 'json') return FileJson;
|
||||
if (artifact.type === 'image') return FileImage;
|
||||
return FileText;
|
||||
}
|
||||
|
||||
function WorkspaceFiles({ model }: { model: TaskUiModel }) {
|
||||
const { locale } = useAppI18n();
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Workspace 文件"
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-lg" aria-label={pickAppText(locale, '刷新', 'Refresh')}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9 rounded-lg border-[#E6E1DE] bg-white text-xs">
|
||||
打开 Workspace
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="border-t border-[#ECE8E5]">
|
||||
<div className="flex h-12 items-end gap-8 px-5 text-sm">
|
||||
<button className="h-10 border-b-2 border-[#1D1715] font-medium text-[#1D1715]">参考文件</button>
|
||||
<button className="h-10 text-[#615854]">输出文件</button>
|
||||
<button className="h-10 text-[#615854]">日志</button>
|
||||
</div>
|
||||
{model.artifacts.length === 0 ? (
|
||||
<div className="p-5">
|
||||
<EmptyState>{pickAppText(locale, '暂无 Workspace 文件', 'No workspace files yet')}</EmptyState>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[minmax(0,1.4fr)_70px_78px_82px_96px] border-y border-[#ECE8E5] px-5 py-3 text-xs font-medium text-[#615854]">
|
||||
<span>文件名</span>
|
||||
<span>类型</span>
|
||||
<span>大小</span>
|
||||
<span>状态</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
<div className="px-5 py-2">
|
||||
{model.artifacts.slice(0, 6).map((artifact) => {
|
||||
const Icon = iconForArtifact(artifact);
|
||||
return (
|
||||
<div key={artifact.id} className="grid min-h-[48px] grid-cols-[minmax(0,1.4fr)_70px_78px_82px_96px] items-center text-sm">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded border border-[#E1DCD8] bg-[#F8F6F4]">
|
||||
<Icon className="h-4 w-4 text-[#3F7D54]" />
|
||||
</span>
|
||||
<span className={`font-medium ${containedLongTextClass}`}>{artifact.title}</span>
|
||||
</div>
|
||||
<span className="text-[#4F4642]">{artifact.type.toUpperCase()}</span>
|
||||
<span className="text-[#4F4642]">{artifact.sizeLabel || '-'}</span>
|
||||
<StatusBadge status={artifact.status} compact />
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" className="h-8 rounded-lg border-[#E6E1DE] bg-white px-2 text-xs" disabled={!artifact.url && !artifact.fileId}>
|
||||
<Eye className="mr-1 h-3.5 w-3.5" />
|
||||
预览
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" className="h-8 w-8 rounded-lg border-[#E6E1DE] bg-white" disabled={!artifact.url && !artifact.fileId}>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{model.artifacts.length > 6 ? (
|
||||
<button className="mt-1 h-9 text-sm font-medium text-[#1F6FEB]">
|
||||
{pickAppText(locale, `查看全部 (${model.artifacts.length})`, `View all (${model.artifacts.length})`)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultPanel({
|
||||
model,
|
||||
resultAcceptance,
|
||||
reviewTargetId,
|
||||
}: {
|
||||
model: TaskUiModel;
|
||||
resultAcceptance?: TaskResultAcceptance;
|
||||
reviewTargetId?: string;
|
||||
}) {
|
||||
const { locale } = useAppI18n();
|
||||
const [busyAction, setBusyAction] = React.useState<TaskFeedbackType | null>(null);
|
||||
const submit = async (type: TaskFeedbackType) => {
|
||||
if (!resultAcceptance || busyAction) return;
|
||||
setBusyAction(type);
|
||||
try {
|
||||
await resultAcceptance.onSubmit(type);
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Section title="本轮结果(摘要)" action={<StatusBadge status={model.result.status} compact />}>
|
||||
<div id={reviewTargetId} className="space-y-4 border-t border-[#ECE8E5] p-4 scroll-mt-44">
|
||||
{model.result.summary ? (
|
||||
<div className="rounded-lg border border-[#E1DCD8] bg-[#FBFAF9] p-4">
|
||||
<p className="max-h-[240px] overflow-auto pr-2 text-sm leading-6 text-[#1D1715]">{model.result.summary}</p>
|
||||
{model.result.bullets.length > 0 ? (
|
||||
<div className="mt-5 space-y-4 text-sm text-[#1D1715]">
|
||||
{model.result.bullets.map((item, index) => {
|
||||
const Icon = [Globe2, Table2, BarChart3, ShieldCheck][index % 4];
|
||||
return (
|
||||
<div key={`${item}:${index}`} className="flex gap-3">
|
||||
<Icon className="h-5 w-5 shrink-0" />
|
||||
<span>{item}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState>{pickAppText(locale, '暂无本轮结果', 'No result for this run yet')}</EmptyState>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button className="h-11 rounded-lg px-5" disabled={!resultAcceptance || Boolean(busyAction)} onClick={() => void submit('accept')}>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
接受结果
|
||||
</Button>
|
||||
<Button variant="outline" className="h-11 rounded-lg border-[#E6E1DE] bg-white px-5" disabled={!resultAcceptance || Boolean(busyAction)} onClick={() => void submit('revise')}>
|
||||
<ListFilter className="mr-2 h-4 w-4" />
|
||||
需要修改
|
||||
</Button>
|
||||
<Button variant="outline" className="h-11 rounded-lg border-[#E6E1DE] bg-white px-5" disabled={!resultAcceptance || Boolean(busyAction)} onClick={() => void submit('abandon')}>
|
||||
<Database className="mr-2 h-4 w-4" />
|
||||
放弃
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskExecutionWorkspace({ task, process, cards, resultAcceptance, reviewTargetId }: Props) {
|
||||
const { locale } = useAppI18n();
|
||||
const model = React.useMemo(
|
||||
() => buildTaskUiModel({ task, process, cards, locale }),
|
||||
[cards, locale, process, task],
|
||||
);
|
||||
const latestAttemptId = model.attempts.at(-1)?.id ?? null;
|
||||
const [selectedAttemptState, setSelectedAttemptState] = React.useState<string | null>(latestAttemptId);
|
||||
const selectedAttemptId = model.attempts.some((attempt) => attempt.id === selectedAttemptState)
|
||||
? selectedAttemptState
|
||||
: latestAttemptId;
|
||||
|
||||
return (
|
||||
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_514px] gap-5">
|
||||
<div className="min-w-0 space-y-3">
|
||||
<ExecutionFlow model={model} />
|
||||
<AgentDAG model={model} />
|
||||
<RunPath model={model} selectedAttemptId={selectedAttemptId} onSelectAttempt={setSelectedAttemptState} />
|
||||
<ToolCalls model={model} selectedAttemptId={selectedAttemptId} />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-3">
|
||||
<WorkspaceFiles model={model} />
|
||||
<ResultPanel model={model} resultAcceptance={resultAcceptance} reviewTargetId={reviewTargetId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user