Files
beaver_project/app-instance/frontend/app/(app)/office/[taskId]/page.tsx
steven_li 29dfd14aa6 ```
feat(agent): 添加对持久化子智能体的支持并增强委派管理

添加了持久化子智能体的完整生命周期管理功能,包括创建、更新、删除和查询API接口。
新增了子智能体的JSON-RPC通信协议支持,实现了远程调用和任务管理功能。

同时增强了委派管理器的功能:
- 添加了对本地委派、插件委派和本地回退的开关控制
- 实现了持久化子智能体任务的自动检测和本地执行保护
- 增加了对不同委派类型的权限验证机制

修改了智能体注册表以支持插件智能体的条件性包含,并更新了工具注册逻辑以支持可选工具。

BREAKING CHANGE: 委派管理器的构造函数签名已更改,添加了新的控制参数。
```
2026-03-27 10:15:35 +08:00

533 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import React from 'react';
import {
ArrowLeft,
ArrowRight,
Boxes,
FolderOutput,
ListTree,
MessageSquare,
PanelRightOpen,
Siren,
Users,
} from 'lucide-react';
import {
OfficeStatusBadge,
formatOfficeDuration,
formatOfficeTime,
progressPercent,
} from '@/components/office/OfficeShared';
import { OfficePhaserCanvas } from '@/components/office/OfficePhaserCanvas';
import { TaskManagementTabs } from '@/components/task-management/TaskManagementTabs';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { ScrollArea } from '@/components/ui/scroll-area';
import { buildOfficeView, isOfficeTaskTerminal } from '@/lib/office';
import { useChatStore } from '@/lib/store';
function PixelPanel({
title,
subtitle,
children,
icon: Icon,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
icon?: React.ComponentType<{ className?: string }>;
}) {
return (
<div className="rounded-none border-4 border-[#0e1119] bg-[#141722] p-4 text-slate-100 shadow-[0_0_0_2px_#1a1b2f_inset]">
<div className="flex items-center gap-2 font-mono text-sm font-bold uppercase tracking-[0.18em] text-[#fef3c7]">
{Icon ? <Icon className="h-4 w-4" /> : null}
{title}
</div>
{subtitle ? (
<div className="mt-2 text-xs text-slate-400">{subtitle}</div>
) : null}
<div className="mt-4">{children}</div>
</div>
);
}
function BoardPanel({
icon: Icon,
title,
description,
children,
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
description?: string;
children: React.ReactNode;
}) {
return (
<Card className="rounded-none border-4 border-[#0e1119] bg-[#141722] text-slate-100 shadow-[0_0_0_2px_#1a1b2f_inset]">
<CardHeader className="border-b border-[#262a3d] pb-3">
<CardTitle className="flex items-center gap-2 text-base text-[#fef3c7]">
<Icon className="h-4 w-4" />
{title}
</CardTitle>
{description ? <CardDescription className="text-slate-400">{description}</CardDescription> : null}
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
export default function OfficeDetailPage() {
const params = useParams<{ taskId: string }>();
const taskId = decodeURIComponent(Array.isArray(params?.taskId) ? params.taskId[0] : params?.taskId ?? '');
const sessions = useChatStore((state) => state.sessions);
const processRuns = useChatStore((state) => state.processRuns);
const processEvents = useChatStore((state) => state.processEvents);
const processArtifacts = useChatStore((state) => state.processArtifacts);
const office = React.useMemo(
() => buildOfficeView(taskId, { sessions, processRuns, processEvents, processArtifacts }),
[processArtifacts, processEvents, processRuns, sessions, taskId]
);
const [selectedRunId, setSelectedRunId] = React.useState<string | null>(null);
const [detailOpen, setDetailOpen] = React.useState(false);
React.useEffect(() => {
setSelectedRunId(office?.rootRunId ?? null);
setDetailOpen(false);
}, [office?.rootRunId]);
const selectedTask = React.useMemo(
() => office?.tasks.find((task) => task.runId === selectedRunId) ?? office?.tasks[0] ?? null,
[office?.tasks, selectedRunId]
);
const selectedEvents = React.useMemo(
() => processEvents
.filter((event) => event.run_id === selectedTask?.runId)
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.slice(0, 8),
[processEvents, selectedTask?.runId]
);
const selectedArtifacts = React.useMemo(
() => processArtifacts
.filter((artifact) => artifact.run_id === selectedTask?.runId)
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()),
[processArtifacts, selectedTask?.runId]
);
const openRunDetail = React.useCallback((runId: string) => {
setSelectedRunId(runId);
setDetailOpen(true);
}, []);
if (!office) {
return (
<div className="mx-auto flex max-w-4xl flex-col gap-4 p-6">
<Button asChild variant="outline" className="w-fit">
<Link href="/office">
<ArrowLeft className="mr-2 h-4 w-4" />
Office
</Link>
</Button>
<Card className="border-dashed">
<CardContent className="py-16 text-center">
<h1 className="text-2xl font-semibold"></h1>
<p className="mt-2 text-sm text-muted-foreground">
store task Office
</p>
</CardContent>
</Card>
</div>
);
}
const progressValue = progressPercent(office.progress.value, office.progress.max);
return (
<div className="mx-auto max-w-[1720px] space-y-6 p-6">
<TaskManagementTabs />
<div className="flex flex-wrap items-center gap-3">
<Button asChild variant="outline" size="sm">
<Link href="/office">
<ArrowLeft className="mr-2 h-4 w-4" />
Office
</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<Link href="/">
<MessageSquare className="mr-2 h-4 w-4" />
</Link>
</Button>
</div>
<div className="space-y-5">
<div className="mx-auto max-w-[1280px] rounded-none border-4 border-[#0e1119] bg-[#141522] p-4 shadow-[0_0_0_2px_#241d36_inset]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-3">
<h1 className="truncate font-mono text-3xl font-bold uppercase tracking-[0.18em] text-[#fef3c7]">
{office.title}
</h1>
<OfficeStatusBadge status={office.status} className="bg-black/20" />
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 font-mono text-xs uppercase tracking-[0.14em] text-slate-400">
<span>Lead: {office.rootActorName}</span>
<span>Session: {office.sourceSessionLabel}</span>
<span>Started: {formatOfficeTime(office.createdAt)}</span>
<span>Duration: {formatOfficeDuration(office.durationMs)}</span>
</div>
</div>
<div className="grid min-w-[320px] gap-3 sm:grid-cols-2 lg:w-[430px]">
<MetricTile label="运行实例" value={String(office.stats.totalRuns)} />
<MetricTile label="参与成员" value={String(office.stats.memberCount)} />
<MetricTile label="产物数量" value={String(office.stats.artifactCount)} />
<MetricTile label="告警数量" value={String(office.alerts.length)} />
</div>
</div>
</div>
<div className="mx-auto max-w-[1280px]">
<OfficePhaserCanvas
office={office}
selectedRunId={selectedTask?.runId ?? null}
onRunSelect={openRunDetail}
showMetaBar={false}
/>
</div>
<div className="mx-auto grid max-w-[1280px] gap-5 xl:grid-cols-[390px_minmax(0,1fr)_390px]">
<PixelPanel
title="昨日小记"
subtitle="用任务摘要、告警和最近更新来替代原版 memo 区。"
>
<div className="space-y-3 text-sm leading-6 text-slate-300">
<div className="rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3">
{selectedTask?.summary || '当前选中任务没有摘要,先从右侧任务看板切一个具体 run 看现场。'}
</div>
{office.alerts.slice(0, 2).map((alert) => (
<button
key={alert.id}
type="button"
disabled={!alert.runId}
onClick={() => alert.runId && openRunDetail(alert.runId)}
className="block w-full rounded-none border-2 border-[#40202a] bg-[#201118] px-3 py-3 text-left transition-colors enabled:hover:border-[#fb7185] disabled:cursor-default"
>
<div className="font-medium text-rose-200">{alert.title}</div>
{alert.description ? <div className="mt-1 text-xs text-slate-400">{alert.description}</div> : null}
</button>
))}
</div>
</PixelPanel>
<PixelPanel
title="任务控制台"
subtitle="保留原版中间控制栏的位置,但改成适配 task runtime 的真实数据。"
>
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<MiniMetric label="当前阶段" value={office.progress.stageLabel ?? office.currentStageLabel ?? '-'} />
<MiniMetric label="活跃实例" value={String(office.stats.activeRuns)} />
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3 font-mono text-[11px] uppercase tracking-[0.14em] text-slate-400">
<span>{office.progress.label}</span>
<span>{progressValue}%</span>
</div>
<div className="h-4 rounded-none border-2 border-[#263144] bg-[#0f1420] p-[2px]">
<div
className="h-full bg-[linear-gradient(90deg,#22d3ee,#fde047,#fb7185)] transition-all"
style={{ width: `${progressValue}%` }}
/>
</div>
</div>
{selectedTask ? (
<div className="rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3">
<div className="font-mono text-[11px] uppercase tracking-[0.14em] text-slate-400"></div>
<div className="mt-2 text-sm font-semibold text-slate-100">{selectedTask.title}</div>
<div className="mt-1 text-xs text-slate-400">
{selectedTask.actorName} · {selectedTask.stageLabel ?? '无阶段标签'}
</div>
</div>
) : null}
<div className="grid gap-2 sm:grid-cols-2">
<Button
onClick={() => setDetailOpen(true)}
className="w-full rounded-none border-2 border-[#2f3b16] bg-[#78a340] text-[#f3ffe6] hover:bg-[#8fbe4a]"
>
<PanelRightOpen className="ml-2 h-4 w-4" />
</Button>
<Button
asChild
variant="outline"
className="w-full rounded-none border-2 border-[#30364d] bg-[#171b29] text-slate-100 hover:bg-[#21283a]"
>
<Link href="/">
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
{isOfficeTaskTerminal(office.status) ? (
<div className="rounded-none border-2 border-[#365443] bg-[#12221d] px-3 py-3 text-sm text-emerald-200">
</div>
) : null}
</div>
</PixelPanel>
<PixelPanel
title="办公人员名单"
subtitle="原版 visitor 区的替代,这里展示当前参与 task 的 agent 成员。"
>
<div className="space-y-2">
{office.members.map((member) => (
<button
key={member.memberId}
type="button"
onClick={() => openRunDetail(member.currentRunId)}
className="flex w-full items-center justify-between gap-3 rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3 text-left transition-colors hover:border-[#64748b]"
>
<div className="min-w-0">
<div className="truncate font-medium text-slate-100">{member.actorName}</div>
<div className="truncate text-xs text-slate-400">{member.currentTitle}</div>
</div>
<OfficeStatusBadge status={member.status} />
</button>
))}
</div>
</PixelPanel>
</div>
<div className="mx-auto grid max-w-[1280px] gap-5 xl:grid-cols-[1.08fr_0.92fr]">
<BoardPanel
icon={ListTree}
title="任务看板"
description="当前 task 下所有 run 的结构化列表。"
>
<div className="space-y-3">
{office.tasks.map((task) => (
<button
key={task.runId}
type="button"
onClick={() => openRunDetail(task.runId)}
className={`w-full rounded-none border-2 px-4 py-3 text-left transition-colors ${
selectedTask?.runId === task.runId
? 'border-[#facc15] bg-[#201922]'
: 'border-[#2d3348] bg-[#0f1420] hover:border-[#64748b]'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-slate-100">{task.title}</span>
{task.isRoot ? (
<span className="rounded-none border border-[#4a3c17] bg-[#3b2f12] px-2 py-0.5 font-mono text-[10px] text-[#fef3c7]">
ROOT
</span>
) : null}
</div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-slate-400">
<span>{task.actorName}</span>
<span>{formatOfficeTime(task.updatedAt)}</span>
<span>{task.artifactCount} </span>
</div>
</div>
<OfficeStatusBadge status={task.status} />
</div>
</button>
))}
</div>
</BoardPanel>
<div className="space-y-5">
<BoardPanel
icon={Boxes}
title="分工关系"
description="主 Agent 到子 Agent 的委派关系。"
>
<div className="space-y-2">
{office.assignments.length === 0 ? (
<div className="rounded-none border-2 border-dashed border-[#30364d] bg-[#0f1420] px-3 py-4 text-sm text-slate-400">
</div>
) : (
office.assignments.map((assignment) => (
<button
key={assignment.ownerRunId}
type="button"
onClick={() => openRunDetail(assignment.ownerRunId)}
className="w-full rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3 text-left text-sm transition-colors hover:border-[#64748b]"
>
<div className="font-medium text-slate-100">{assignment.label}</div>
<div className="mt-1 text-slate-400">{assignment.assigneeActorNames.join(' / ')}</div>
</button>
))
)}
</div>
</BoardPanel>
<BoardPanel
icon={Siren}
title="现场告警"
description="优先展示失败、阻塞和较高风险的任务信号。"
>
<div className="space-y-2">
{office.alerts.length === 0 ? (
<div className="rounded-none border-2 border-dashed border-[#30364d] bg-[#0f1420] px-3 py-4 text-sm text-slate-400">
</div>
) : (
office.alerts.map((alert) => (
<button
key={alert.id}
type="button"
disabled={!alert.runId}
onClick={() => alert.runId && openRunDetail(alert.runId)}
className="w-full rounded-none border-2 border-[#40202a] bg-[#201118] px-3 py-3 text-left transition-colors enabled:hover:border-[#fb7185] disabled:cursor-default"
>
<div className="font-medium text-rose-200">{alert.title}</div>
{alert.description ? <div className="mt-1 text-sm text-slate-400">{alert.description}</div> : null}
</button>
))
)}
</div>
</BoardPanel>
</div>
</div>
</div>
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
<SheetContent side="right" className="w-full border-l border-border sm:max-w-3xl">
<SheetHeader className="pr-8">
<SheetTitle>{selectedTask?.title ?? '任务详情'}</SheetTitle>
<SheetDescription>
{selectedTask
? `${selectedTask.actorName} · ${selectedTask.stageLabel ?? '无阶段标签'}`
: '当前没有选中的任务实例。'}
</SheetDescription>
</SheetHeader>
{!selectedTask ? (
<div className="mt-6 rounded-xl border border-dashed border-border/60 px-4 py-6 text-sm text-muted-foreground">
</div>
) : (
<ScrollArea className="mt-6 h-[calc(100vh-8.5rem)] pr-3">
<div className="space-y-4 pb-6">
<div className="rounded-xl border border-border/60 px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium">{selectedTask.title}</div>
<div className="mt-1 text-xs text-muted-foreground">{selectedTask.actorName}</div>
</div>
<OfficeStatusBadge status={selectedTask.status} />
</div>
<div className="mt-3 grid gap-2 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground"></span>
<span>{formatOfficeTime(selectedTask.startedAt)}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground"></span>
<span>{formatOfficeTime(selectedTask.updatedAt)}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground"></span>
<span>{selectedTask.stageLabel ?? '-'}</span>
</div>
</div>
{selectedTask.summary ? (
<div className="mt-3 rounded-lg bg-muted/40 px-3 py-3 text-sm text-muted-foreground">
{selectedTask.summary}
</div>
) : null}
</div>
<div className="grid gap-4 lg:grid-cols-[0.95fr_1.05fr]">
<div className="rounded-xl border border-border/60">
<div className="border-b border-border/60 px-4 py-3 text-sm font-medium"></div>
<div className="space-y-2 p-4">
{selectedArtifacts.length === 0 ? (
<div className="text-sm text-muted-foreground"></div>
) : (
selectedArtifacts.map((artifact) => (
<div key={artifact.artifact_id} className="rounded-lg border border-border/60 px-3 py-3">
<div className="font-medium">{artifact.title}</div>
<div className="mt-1 text-xs text-muted-foreground">
{artifact.artifact_type} · {formatOfficeTime(artifact.created_at)}
</div>
</div>
))
)}
</div>
</div>
<div className="rounded-xl border border-border/60">
<div className="border-b border-border/60 px-4 py-3 text-sm font-medium"></div>
<div className="space-y-2 p-4">
{selectedEvents.length === 0 ? (
<div className="text-sm text-muted-foreground"></div>
) : (
selectedEvents.map((event) => (
<div key={event.event_id} className="rounded-lg border border-border/60 px-3 py-3">
<div className="flex items-center justify-between gap-3">
<div className="text-xs uppercase tracking-wide text-muted-foreground">{event.kind}</div>
<div className="text-xs text-muted-foreground">{formatOfficeTime(event.created_at)}</div>
</div>
<div className="mt-2 text-sm text-foreground/90">
{event.text || '结构化更新'}
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
</ScrollArea>
)}
</SheetContent>
</Sheet>
</div>
);
}
function MetricTile({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-4 py-4 text-slate-100">
<div className="font-mono text-[11px] uppercase tracking-[0.14em] text-slate-400">{label}</div>
<div className="mt-2 text-xl font-semibold">{value}</div>
</div>
);
}
function MiniMetric({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-none border-2 border-[#2d3348] bg-[#0f1420] px-3 py-3 text-slate-100">
<div className="font-mono text-[11px] uppercase tracking-[0.14em] text-slate-400">{label}</div>
<div className="mt-2 text-sm font-semibold">{value}</div>
</div>
);
}