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:
2026-06-26 16:36:29 +08:00
parent 53b13e8eac
commit 520a21a027
360 changed files with 13271 additions and 1848 deletions

View File

@ -10,7 +10,7 @@ export function AppShell({ children }: { children: ReactNode }) {
return (
<div className="min-h-screen bg-background text-foreground">
<Header />
<main className="pt-16">
<main className="pt-14">
<AuthGuard>
<AppRuntimeBridge />
{children}

View File

@ -131,8 +131,8 @@ const Header = () => {
key={item.href}
href={item.href}
onClick={compact ? () => setMobileMenuOpen(false) : undefined}
className={`flex h-11 shrink-0 items-center gap-2 rounded-full text-sm font-medium transition-colors ${
compact ? 'justify-start rounded-lg border border-transparent bg-background px-4' : 'px-4'
className={`flex shrink-0 items-center gap-2 rounded-full text-sm font-medium transition-colors ${
compact ? 'h-11 justify-start rounded-lg border border-transparent bg-background px-4' : 'h-10 px-3.5'
} ${
isActive
? 'bg-primary text-primary-foreground'
@ -151,11 +151,11 @@ const Header = () => {
<>
<header className="fixed left-0 right-0 top-0 z-50 border-b border-[#E6E1DE] bg-[#F7F6F5]/95 backdrop-blur">
<div className="mx-auto max-w-[1720px] px-4 sm:px-6 lg:px-8">
<div className="flex h-16 items-center justify-between gap-3">
<div className="flex h-14 items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<button
type="button"
className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-[#E6E1DE] bg-white text-[#1D1715] transition-colors hover:bg-[#F7F5F4] min-[1800px]:hidden"
className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-[#E6E1DE] bg-white text-[#1D1715] transition-colors hover:bg-[#F7F5F4] xl:hidden"
aria-label={mobileMenuOpen ? pickAppText(locale, '关闭导航', 'Close navigation') : pickAppText(locale, '打开导航', 'Open navigation')}
aria-expanded={mobileMenuOpen}
aria-controls="app-primary-mobile-nav"
@ -163,14 +163,17 @@ const Header = () => {
>
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
<Link href="/" className="hidden h-11 shrink-0 items-center min-[360px]:flex">
<Link href="/" className="hidden h-11 shrink-0 items-center gap-2 min-[360px]:flex">
<span className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-[#1D1715] bg-white">
<Bot className="h-5 w-5 text-[#1D1715]" />
</span>
<span className="font-serif text-[26px] font-semibold leading-none text-[#0B0B0B] sm:text-[28px]">
Beaver
</span>
</Link>
</div>
<nav className="hidden items-center gap-1 rounded-full border border-[#E6E1DE] bg-white px-1.5 py-1 shadow-[0_1px_2px_rgba(0,0,0,0.04)] min-[1800px]:flex">
<nav className="hidden items-center gap-1 rounded-full border border-[#E6E1DE] bg-white px-1.5 py-1 shadow-[0_1px_2px_rgba(0,0,0,0.04)] xl:flex">
{renderNavLinks(false)}
</nav>
@ -245,14 +248,14 @@ const Header = () => {
<>
<button
type="button"
className="fixed inset-x-0 bottom-0 top-16 z-40 bg-black/40 min-[1800px]:hidden"
className="fixed inset-x-0 bottom-0 top-14 z-40 bg-black/40 xl:hidden"
aria-label={pickAppText(locale, '关闭导航', 'Close navigation')}
onClick={() => setMobileMenuOpen(false)}
/>
<nav
id="app-primary-mobile-nav"
aria-label={pickAppText(locale, '主导航', 'Primary navigation')}
className="fixed bottom-0 left-0 top-16 z-[45] isolate w-[min(86vw,320px)] overflow-y-auto border-r border-[#E6E1DE] bg-background text-foreground shadow-[12px_0_32px_rgba(29,23,21,0.24)] animate-in slide-in-from-left-full duration-200 min-[1800px]:hidden"
className="fixed bottom-0 left-0 top-14 z-[45] isolate w-[min(86vw,320px)] overflow-y-auto border-r border-[#E6E1DE] bg-background text-foreground shadow-[12px_0_32px_rgba(29,23,21,0.24)] animate-in slide-in-from-left-full duration-200 xl:hidden"
>
<div className="min-h-full bg-background px-4 py-5">
<div className="grid gap-2 bg-background">

View File

@ -2,7 +2,7 @@
import React from 'react';
import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
import type { ChatMessage } from '@/types';
import { MessageList } from '@/components/chat-workbench/MessageList';
export function ChatWorkbench({
@ -10,11 +10,6 @@ export function ChatWorkbench({
isThinking,
messagesEndRef,
messageViewportRef,
processRuns,
processEvents,
processArtifacts,
selectedRunId,
onSelectRun,
onFeedback,
onRequestRevision,
}: {
@ -22,11 +17,6 @@ export function ChatWorkbench({
isThinking: boolean;
messagesEndRef: React.RefObject<HTMLDivElement>;
messageViewportRef: React.RefObject<HTMLDivElement>;
processRuns: ProcessRun[];
processEvents: ProcessEvent[];
processArtifacts: ProcessArtifact[];
selectedRunId: string | null;
onSelectRun: (runId: string) => void;
onFeedback: (runId: string, feedbackType: 'accept' | 'revise' | 'abandon', comment?: string) => void;
onRequestRevision: (runId: string) => void;
}) {
@ -37,11 +27,6 @@ export function ChatWorkbench({
isThinking={isThinking}
messagesEndRef={messagesEndRef}
viewportRef={messageViewportRef}
processRuns={processRuns}
processEvents={processEvents}
processArtifacts={processArtifacts}
selectedRunId={selectedRunId}
onSelectRun={onSelectRun}
onFeedback={onFeedback}
onRequestRevision={onRequestRevision}
/>

View File

@ -1,24 +1,245 @@
'use client';
import React from 'react';
import { Activity, PanelRightOpen, X } from 'lucide-react';
import { Activity, CheckCircle2, ChevronDown, Circle, FileText, LoaderCircle, PanelRightOpen, Sparkles, TerminalSquare, Users, X } from 'lucide-react';
import { TaskTimeline } from '@/components/task-detail';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { formatTaskRuntimeDuration, formatTaskRuntimeTime } from '@/components/task-runtime/TaskRuntimeShared';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
import type { TaskTimelineCard } from '@/types';
import {
buildTaskUiModel,
taskUiStatusClass,
taskUiStatusLabel,
type TaskUiModel,
type TaskUiStatus,
} from '@/lib/task-ui-model';
import { containedLongTextClass, containedPreservedLongTextClass } from '@/lib/text-wrapping';
import type { BackendTask, SessionProcessProjection, TaskTimelineCard } from '@/types';
function StatusBadge({ status }: { status: TaskUiStatus }) {
const { locale } = useAppI18n();
return (
<Badge variant="outline" className={`h-7 rounded-full px-2.5 text-[11px] ${taskUiStatusClass(status)}`}>
{taskUiStatusLabel(status, locale)}
</Badge>
);
}
function ProgressCard({
icon,
title,
label,
status,
children,
}: {
icon: React.ReactNode;
title: string;
label: string;
status: TaskUiStatus;
children: React.ReactNode;
}) {
const { locale } = useAppI18n();
const [open, setOpen] = React.useState(true);
return (
<section className="min-w-0 rounded-lg border border-[#E6E1DE] bg-white shadow-[0_6px_18px_rgba(31,24,20,0.04)]">
<button
type="button"
onClick={() => setOpen((current) => !current)}
className="flex min-h-[52px] w-full min-w-0 items-center gap-3 px-4 py-2.5 text-left"
aria-expanded={open}
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#F1EFEE] text-[#615854]">{icon}</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-[#1D1715]">{title}</span>
<span className="mt-0.5 block truncate text-xs text-muted-foreground">{label}</span>
</span>
<StatusBadge status={status} />
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open ? (
<div className="border-t border-[#ECE8E5] px-4 py-2.5">
<div className="min-w-0">{children}</div>
<button
type="button"
className="mt-3 flex h-9 w-full items-center justify-center rounded-md border border-[#E6E1DE] bg-[#FBFAF9] text-xs font-medium text-[#615854] hover:bg-[#F4F1EF]"
>
{pickAppText(locale, '详情', 'Details')}
</button>
</div>
) : null}
</section>
);
}
function SummarySection({ model }: { model: TaskUiModel }) {
return (
<p className={`line-clamp-2 text-sm leading-5 text-muted-foreground ${containedPreservedLongTextClass}`}>
{model.summary.summary}
</p>
);
}
function SkillSection({ model }: { model: TaskUiModel }) {
const { locale } = useAppI18n();
if (model.skills.length === 0) {
return <p className="text-sm text-muted-foreground">{pickAppText(locale, '暂无 Skill 选择', 'No skill selected yet')}</p>;
}
const primary = model.skills[0];
return (
<div className="min-w-0">
<div className={`line-clamp-1 text-sm font-medium text-[#1D1715] ${containedLongTextClass}`}>{primary.name}</div>
{primary.createdAt ? <div className="mt-1 text-xs text-muted-foreground">{formatTaskRuntimeTime(primary.createdAt, locale)}</div> : null}
{primary.summary ? (
<p className={`mt-1 line-clamp-1 text-xs leading-5 text-muted-foreground ${containedLongTextClass}`}>{primary.summary}</p>
) : null}
</div>
);
}
function ToolsSection({ model }: { model: TaskUiModel }) {
const { locale } = useAppI18n();
const attempts = model.attempts.filter((attempt) => attempt.tools.length > 0);
if (attempts.length === 0) {
return <p className="text-sm text-muted-foreground">{pickAppText(locale, '暂无工具调用', 'No tool calls yet')}</p>;
}
return (
<div className="space-y-3">
{attempts.map((attempt) => (
<div key={attempt.id} className="min-w-0">
<div className="mb-1.5 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>{attempt.title}</span>
<span>{attempt.tools.length} calls</span>
</div>
<div className="space-y-2">
{attempt.tools.map((tool) => (
<div key={tool.id} className="grid min-w-0 grid-cols-[minmax(0,1fr)_76px_46px] items-center gap-2">
<span className={`text-sm text-[#1D1715] ${containedLongTextClass}`}>
{tool.toolName}
</span>
<span className={`flex items-center gap-1 text-xs ${tool.status === 'done' ? 'text-[#22733A]' : tool.status === 'running' ? 'text-[#B26A00]' : tool.status === 'error' ? 'text-[#9D3D2F]' : 'text-muted-foreground'}`}>
{tool.status === 'done' ? <CheckCircle2 className="h-3.5 w-3.5" /> : tool.status === 'running' ? <LoaderCircle className="h-3.5 w-3.5" /> : <Circle className="h-3.5 w-3.5" />}
{taskUiStatusLabel(tool.status, locale)}
</span>
<span className="text-right text-xs text-muted-foreground">{formatSidebarToolDuration(tool, locale)}</span>
</div>
))}
</div>
</div>
))}
</div>
);
}
function formatSidebarToolDuration(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 flattenAgents(model: TaskUiModel) {
const output: Array<{ id: string; name: string; status: TaskUiStatus; depth: number }> = [];
const visit = (node: TaskUiModel['agentTree'][number], depth: number) => {
output.push({ id: node.runId, name: node.title || node.name, status: node.status, depth });
node.children.forEach((child) => visit(child, depth + 1));
};
model.agentTree.forEach((node) => visit(node, 0));
return output;
}
function AgentSection({ model }: { model: TaskUiModel }) {
const { locale } = useAppI18n();
const rows = flattenAgents(model).slice(0, 6);
if (!model.team.hasTeam) {
return (
<div className="rounded-md border border-[#E6E1DE] bg-[#FBFAF9] px-3 py-2 text-sm text-muted-foreground">
{pickAppText(locale, '本轮为 Main Agent 单线程执行,未启动 Agent Team。', 'This run uses the Main Agent only; no Agent Team was started.')}
</div>
);
}
if (rows.length === 0) {
return <p className="text-sm text-muted-foreground">{pickAppText(locale, 'Agent Team 已启动,等待节点数据', 'Agent Team started; waiting for node data')}</p>;
}
return (
<div className="min-w-0">
<div className="flex items-center gap-2 text-xs text-[#615854]">
<Users className="h-3.5 w-3.5" />
<span>{rows[0]?.name}</span>
</div>
<div className="ml-4 mt-2 space-y-2 border-l border-[#D8D2CE] pl-4">
{rows.map((node) => (
<div key={node.id} className="grid min-w-0 grid-cols-[14px_minmax(0,1fr)_74px] items-center gap-2" style={{ paddingLeft: `${Math.min(node.depth, 3) * 8}px` }}>
<span className="h-px w-3 bg-[#D8D2CE]" />
<span className={`text-xs text-[#1D1715] ${containedLongTextClass}`}>{node.name}</span>
<span className={`flex items-center gap-1 text-xs ${node.status === 'done' ? 'text-[#22733A]' : node.status === 'running' ? 'text-[#B26A00]' : 'text-muted-foreground'}`}>
{node.status === 'done' ? <CheckCircle2 className="h-3.5 w-3.5" /> : node.status === 'running' ? <LoaderCircle className="h-3.5 w-3.5" /> : <Circle className="h-3.5 w-3.5" />}
{taskUiStatusLabel(node.status, locale)}
</span>
</div>
))}
</div>
</div>
);
}
function statusForSummary(task: BackendTask): TaskUiStatus {
if (task.status === 'awaiting_acceptance' || task.status === 'closed') return 'done';
if (task.status === 'running') return 'running';
return 'waiting';
}
function primarySkillName(model: TaskUiModel) {
return model.skills[0]?.name || '';
}
function hasExecutionStructure(model: TaskUiModel): boolean {
return model.team.hasTeam || model.agentTree.length > 0;
}
function toolStatus(model: TaskUiModel): TaskUiStatus {
if (model.tools.some((tool) => tool.status === 'running')) return 'running';
if (model.tools.some((tool) => tool.status === 'error')) return 'error';
return model.tools.length ? 'done' : 'waiting';
}
function agentStatus(model: TaskUiModel): TaskUiStatus {
if (model.agentTree.some((node) => node.status === 'running' || node.children.some((child) => child.status === 'running'))) return 'running';
if (!model.team.hasTeam) return 'waiting';
if (model.agentTree.some((node) => node.status === 'error' || node.children.some((child) => child.status === 'error'))) return 'error';
return model.agentTree.length ? 'done' : model.team.status;
}
function ProgressPanel({
task,
process,
cards,
isLive,
onClose,
}: {
task: BackendTask | null;
process: SessionProcessProjection | null;
cards: TaskTimelineCard[];
isLive: boolean;
onClose?: () => void;
}) {
const { locale } = useAppI18n();
const model = task
? buildTaskUiModel({
task,
process: process ?? { runs: [], events: [], artifacts: [] },
cards,
locale,
})
: null;
return (
<div className="flex h-full min-w-0 flex-col overflow-hidden bg-[#FBFAF9]">
@ -48,9 +269,58 @@ function ProgressPanel({
) : null}
</div>
<ScrollArea className="min-h-0 min-w-0 flex-1 overflow-hidden px-4 py-4">
<ScrollArea className="min-h-0 min-w-0 flex-1 overflow-hidden px-4 py-2.5">
<div className="min-w-0 max-w-full pb-6">
<TaskTimeline cards={cards} isLive={isLive} showHeader={false} />
{model ? (
<div className="space-y-2.5">
<ProgressCard
icon={<FileText className="h-4 w-4" />}
title={pickAppText(locale, '任务摘要', 'Task summary')}
label={model.summary.title}
status={task ? statusForSummary(task) : 'waiting'}
>
<SummarySection model={model} />
</ProgressCard>
{model.skills.length > 0 ? (
<ProgressCard
icon={<Sparkles className="h-4 w-4" />}
title={pickAppText(locale, 'Skill 选择', 'Skill selection')}
label={primarySkillName(model)}
status={model.skills[0]?.status || 'waiting'}
>
<SkillSection model={model} />
</ProgressCard>
) : null}
{model.tools.length > 0 ? (
<ProgressCard
icon={<TerminalSquare className="h-4 w-4" />}
title={pickAppText(locale, '工具调用', 'Tool calls')}
label={pickAppText(locale, `${model.tools.length} 个工具调用`, `${model.tools.length} tool calls`)}
status={toolStatus(model)}
>
<ToolsSection model={model} />
</ProgressCard>
) : null}
{hasExecutionStructure(model) ? (
<ProgressCard
icon={<Users className="h-4 w-4" />}
title={pickAppText(locale, '执行结构', 'Execution structure')}
label={
model.team.hasTeam
? pickAppText(locale, `Agent Team · ${model.team.outcome}`, `Agent Team · ${model.team.outcome}`)
: pickAppText(locale, 'Agent run', 'Agent run')
}
status={agentStatus(model)}
>
<AgentSection model={model} />
</ProgressCard>
) : null}
</div>
) : (
<div className="rounded-lg border border-dashed border-[#DED8D4] bg-white px-4 py-10 text-center text-sm text-muted-foreground">
{pickAppText(locale, '当前会话暂无运行任务', 'No running task in this session')}
</div>
)}
</div>
</ScrollArea>
</div>
@ -58,9 +328,13 @@ function ProgressPanel({
}
export function CurrentSessionProgressSidebar({
task,
process,
cards,
isLive,
}: {
task: BackendTask | null;
process: SessionProcessProjection | null;
cards: TaskTimelineCard[];
isLive: boolean;
}) {
@ -70,7 +344,7 @@ export function CurrentSessionProgressSidebar({
return (
<>
<aside className="hidden h-full w-[380px] min-w-0 shrink-0 overflow-hidden border-l border-[#E6E1DE] xl:flex">
<ProgressPanel cards={cards} isLive={isLive} />
<ProgressPanel task={task} process={process} cards={cards} isLive={isLive} />
</aside>
<button
@ -91,7 +365,7 @@ export function CurrentSessionProgressSidebar({
aria-label={pickAppText(locale, '关闭进度面板', 'Close progress panel')}
/>
<div className="absolute inset-y-0 right-0 w-[min(92vw,390px)] min-w-0 overflow-hidden border-l border-[#E6E1DE] shadow-2xl">
<ProgressPanel cards={cards} isLive={isLive} onClose={() => setMobileOpen(false)} />
<ProgressPanel task={task} process={process} cards={cards} isLive={isLive} onClose={() => setMobileOpen(false)} />
</div>
</div>
) : null}

View File

@ -4,10 +4,9 @@ import React from 'react';
import Link from 'next/link';
import { Bot, CheckCircle2, ChevronRight, Loader2, Paperclip, RefreshCcw, ThumbsUp, User, XCircle } from 'lucide-react';
import type { ChatMessage, ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
import type { ChatMessage } from '@/types';
import { getAccessToken, getFileUrl } from '@/lib/api';
import { getTaskCardMessageIndexes, hasVisibleChatContent, normalizedMessageText, shouldDisplayChatMessage } from '@/lib/chat-messages';
import { AgentTeamBlock } from '@/components/chat-workbench/AgentTeamBlock';
import { MarkdownContent } from '@/components/chat-workbench/MarkdownContent';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
@ -268,14 +267,6 @@ function MessageBubble({
);
}
type AgentTeamGroup = {
rootRun: ProcessRun;
memberRuns: ProcessRun[];
startedAt: string;
};
const TERMINAL_RUN_STATUSES = new Set<ProcessRun['status']>(['done', 'error', 'cancelled']);
function shouldHideSystemAgentMessage(message: ChatMessage): boolean {
if (message.role !== 'assistant' || typeof message.content !== 'string') {
return false;
@ -299,72 +290,11 @@ function shouldHideMessage(message: ChatMessage): boolean {
return !shouldDisplayChatMessage(message);
}
function parseTimelineTime(value?: string | null): number | null {
if (!value) return null;
const parsed = new Date(value).getTime();
return Number.isFinite(parsed) ? parsed : null;
}
function buildAgentTeamGroups(processRuns: ProcessRun[]): AgentTeamGroup[] {
const runMap = new Map(processRuns.map((run) => [run.run_id, run]));
const groups = new Map<string, AgentTeamGroup>();
for (const run of processRuns) {
if (run.actor_type !== 'agent') {
continue;
}
let root = run;
const seen = new Set<string>([run.run_id]);
let parentId = run.parent_run_id ?? null;
while (parentId) {
const parent = runMap.get(parentId);
if (!parent || seen.has(parent.run_id)) {
break;
}
root = parent;
seen.add(parent.run_id);
parentId = parent.parent_run_id ?? null;
}
const existing = groups.get(root.run_id);
if (existing) {
existing.memberRuns.push(run);
continue;
}
groups.set(root.run_id, {
rootRun: root,
memberRuns: [run],
startedAt: root.started_at || run.started_at,
});
}
return Array.from(groups.values())
.map((group) => ({
...group,
memberRuns: [...group.memberRuns].sort((a: ProcessRun, b: ProcessRun) => {
const at = parseTimelineTime(a.started_at) ?? 0;
const bt = parseTimelineTime(b.started_at) ?? 0;
return at - bt;
}),
}))
.sort((a, b) => {
const at = parseTimelineTime(a.startedAt) ?? 0;
const bt = parseTimelineTime(b.startedAt) ?? 0;
return at - bt;
});
}
export function MessageList({
messages,
isThinking,
messagesEndRef,
viewportRef,
processRuns,
processEvents,
processArtifacts,
selectedRunId,
onSelectRun,
onFeedback,
onRequestRevision,
}: {
@ -372,11 +302,6 @@ export function MessageList({
isThinking: boolean;
messagesEndRef: React.RefObject<HTMLDivElement>;
viewportRef: React.RefObject<HTMLDivElement>;
processRuns: ProcessRun[];
processEvents: ProcessEvent[];
processArtifacts: ProcessArtifact[];
selectedRunId: string | null;
onSelectRun: (runId: string) => void;
onFeedback: (runId: string, feedbackType: 'accept' | 'revise' | 'abandon', comment?: string) => void;
onRequestRevision: (runId: string) => void;
}) {
@ -385,37 +310,6 @@ export function MessageList({
() => messages.filter((message) => !shouldHideMessage(message)),
[messages]
);
const teamGroups = React.useMemo(
() =>
buildAgentTeamGroups(processRuns).filter((group) =>
group.memberRuns.some((run) => !TERMINAL_RUN_STATUSES.has(run.status))
),
[processRuns]
);
const timelineItems = React.useMemo(() => {
const messageItems = visibleMessages.map((message, index) => ({
kind: 'message' as const,
key: `${message.role}:${message.timestamp || index}:${index}`,
sortTime: parseTimelineTime(message.timestamp) ?? Number.MAX_SAFE_INTEGER / 2 + index,
order: index,
message,
messageIndex: index,
}));
const teamItems = teamGroups.map((group, index) => ({
kind: 'team' as const,
key: `team:${group.rootRun.run_id}`,
sortTime: parseTimelineTime(group.startedAt) ?? Number.MAX_SAFE_INTEGER / 2 + visibleMessages.length + index,
order: visibleMessages.length + index,
group,
}));
return [...messageItems, ...teamItems].sort((a, b) => {
if (a.sortTime !== b.sortTime) {
return a.sortTime - b.sortTime;
}
return a.order - b.order;
});
}, [teamGroups, visibleMessages]);
const taskCardMessageIndexes = React.useMemo(
() => getTaskCardMessageIndexes(visibleMessages),
[visibleMessages]
@ -439,7 +333,7 @@ export function MessageList({
return (
<ScrollArea className="h-full px-3 sm:px-5 md:px-8" viewportRef={viewportRef}>
<div className="mx-auto max-w-5xl space-y-8 py-6 md:py-10">
{visibleMessages.length === 0 && teamGroups.length === 0 && !isThinking && (
{visibleMessages.length === 0 && !isThinking && (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
<Bot className="w-12 h-12 mb-4 opacity-50" />
<p className="text-lg font-medium text-foreground">Beaver</p>
@ -447,28 +341,16 @@ export function MessageList({
</div>
)}
{timelineItems.map((item) =>
item.kind === 'message' ? (
<MessageBubble
key={item.key}
message={item.message}
showTaskCard={taskCardMessageIndexes.has(item.messageIndex)}
canSendFeedback={item.messageIndex === latestFeedbackMessageIndex}
onFeedback={onFeedback}
onRequestRevision={onRequestRevision}
/>
) : (
<AgentTeamBlock
key={item.key}
rootRun={item.group.rootRun}
memberRuns={item.group.memberRuns}
events={processEvents}
artifacts={processArtifacts}
selectedRunId={selectedRunId}
onSelectRun={onSelectRun}
/>
)
)}
{visibleMessages.map((message, index) => (
<MessageBubble
key={`${message.role}:${message.timestamp || index}:${index}`}
message={message}
showTaskCard={taskCardMessageIndexes.has(index)}
canSendFeedback={index === latestFeedbackMessageIndex}
onFeedback={onFeedback}
onRequestRevision={onRequestRevision}
/>
))}
{isThinking && (
<div className="flex items-center gap-2 text-muted-foreground px-1">

View File

@ -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>
);
}

View File

@ -43,17 +43,17 @@ export function TaskLiveHeader({ task, activeLabel, durationMs, reviewTargetId }
const showReviewLink = Boolean(reviewTargetId && ['awaiting_acceptance', 'needs_revision'].includes(task.status));
return (
<header className="sticky top-[65px] z-20 min-w-0 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:px-6">
<header className="sticky top-14 z-20 min-w-0 border-b border-[#E6E1DE] bg-[#FBFAF9]/95 backdrop-blur supports-[backdrop-filter]:bg-[#FBFAF9]/85">
<div className="mx-auto flex max-w-[1720px] flex-col gap-1.5 px-4 py-3.5 sm:px-6 lg:px-8">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<Button asChild variant="outline" size="sm" className="h-11">
<Button asChild variant="outline" size="sm" className="h-11 rounded-xl border-[#E6E1DE] bg-white px-4 shadow-sm">
<Link href="/tasks">
<ArrowLeft className="mr-2 h-4 w-4" />
{pickAppText(locale, '返回任务', 'Back to tasks')}
</Link>
</Button>
<Button asChild variant="ghost" size="sm" className="h-11">
<Button asChild variant="outline" size="sm" className="h-11 rounded-xl border-[#E6E1DE] bg-white px-4 shadow-sm">
<Link href="/">
<MessageSquare className="mr-2 h-4 w-4" />
{pickAppText(locale, '对话', 'Chat')}
@ -70,7 +70,7 @@ export function TaskLiveHeader({ task, activeLabel, durationMs, reviewTargetId }
)}
{activeLabel ? <Badge variant="secondary">{activeLabel}</Badge> : null}
{showReviewLink ? (
<Button asChild variant="default" size="sm" className="h-11">
<Button asChild variant="default" size="sm" className="h-11 rounded-xl px-5 shadow-[0_12px_24px_rgba(31,24,20,0.18)]">
<a href={`#${reviewTargetId}`}>
<CheckCircle2 className="mr-2 h-4 w-4" />
{pickAppText(locale, '验收', 'Review')}
@ -82,12 +82,12 @@ export function TaskLiveHeader({ task, activeLabel, durationMs, reviewTargetId }
<div className="flex flex-col gap-2 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0">
<h1 className="truncate text-xl font-semibold leading-tight">{title}</h1>
<h1 className="truncate text-[24px] font-semibold leading-[30px]">{title}</h1>
{task.description && task.description !== title ? (
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">{task.description}</p>
<p className="mt-0.5 line-clamp-2 max-w-6xl text-sm leading-[22px] text-muted-foreground">{task.description}</p>
) : null}
</div>
<div className="flex shrink-0 flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<div className="flex shrink-0 flex-wrap gap-x-5 gap-y-1 text-sm text-muted-foreground">
<span>
{pickAppText(locale, '更新', 'Updated')}: {formatTaskRuntimeTime(task.updated_at, locale)}
</span>

View File

@ -1,4 +1,5 @@
export { TaskAcceptanceCard, type TaskFeedbackItem, type TaskFeedbackType } from './TaskAcceptanceCard';
export { TaskExecutionWorkspace } from './TaskExecutionWorkspace';
export { TaskLiveHeader } from './TaskLiveHeader';
export { TaskSideRail } from './TaskSideRail';
export { TaskTimeline } from './TaskTimeline';