feat(agent): 添加对持久化子智能体的支持并增强委派管理

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

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

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

BREAKING CHANGE: 委派管理器的构造函数签名已更改,添加了新的控制参数。
```
This commit is contained in:
2026-03-27 10:15:35 +08:00
parent bad1e16ab4
commit 29dfd14aa6
133 changed files with 11656 additions and 220 deletions

View File

@ -0,0 +1,533 @@
'use client';
import React from 'react';
import { CheckCircle2, Loader2, Sparkles, Square } from 'lucide-react';
import type { ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
type RunCardPhase = 'live' | 'exiting' | 'collapsed';
type AgentFeedItem = {
key: string;
created_at: string;
role: 'user' | 'assistant' | 'system' | 'tool';
text: string;
tone?: ProcessRun['status'];
};
const TERMINAL_STATUSES = new Set<ProcessRun['status']>(['done', 'error', 'cancelled']);
const AGENT_ACCENTS = [
{
frame: 'border-sky-500/25 bg-sky-500/[0.05]',
title: 'text-sky-300',
dot: 'bg-sky-400',
result: 'border-sky-500/25 bg-sky-500/[0.08]',
},
{
frame: 'border-emerald-500/25 bg-emerald-500/[0.05]',
title: 'text-emerald-300',
dot: 'bg-emerald-400',
result: 'border-emerald-500/25 bg-emerald-500/[0.08]',
},
{
frame: 'border-amber-500/25 bg-amber-500/[0.05]',
title: 'text-amber-300',
dot: 'bg-amber-400',
result: 'border-amber-500/25 bg-amber-500/[0.08]',
},
{
frame: 'border-fuchsia-500/25 bg-fuchsia-500/[0.05]',
title: 'text-fuchsia-300',
dot: 'bg-fuchsia-400',
result: 'border-fuchsia-500/25 bg-fuchsia-500/[0.08]',
},
] as const;
function accentFor(index: number) {
return AGENT_ACCENTS[index % AGENT_ACCENTS.length];
}
function statusLabel(status: ProcessRun['status']) {
if (status === 'done') return '已完成';
if (status === 'error') return '失败';
if (status === 'cancelled') return '已取消';
if (status === 'waiting') return '等待中';
if (status === 'queued') return '排队中';
return '进行中';
}
function statusTone(status: ProcessRun['status']) {
if (status === 'done') return 'border-emerald-500/20 bg-emerald-500/10 text-emerald-300';
if (status === 'error') return 'border-rose-500/20 bg-rose-500/10 text-rose-300';
if (status === 'cancelled') return 'border-zinc-500/20 bg-zinc-500/10 text-zinc-300';
if (status === 'waiting') return 'border-amber-500/20 bg-amber-500/10 text-amber-300';
if (status === 'queued') return 'border-sky-500/20 bg-sky-500/10 text-sky-300';
return 'border-sky-500/20 bg-sky-500/10 text-sky-300';
}
function roleLabel(role: AgentFeedItem['role']) {
if (role === 'user') return '主 agent';
if (role === 'tool') return '工具输出';
if (role === 'system') return '状态';
return '子 agent';
}
function feedTone(role: AgentFeedItem['role']) {
if (role === 'user') {
return 'ml-6 border-border/70 bg-muted/60 text-foreground';
}
if (role === 'system') {
return 'mx-4 border-border/60 bg-accent/60 text-foreground/85';
}
if (role === 'tool') {
return 'mr-6 border-border/70 bg-background/80 text-foreground';
}
return 'mr-6 border-border/70 bg-background/80 text-foreground';
}
function artifactPreview(artifact: ProcessArtifact): string {
if (artifact.artifact_type === 'link' && artifact.url) {
return `${artifact.title}\n${artifact.url}`;
}
if ((artifact.artifact_type === 'text' || artifact.artifact_type === 'markdown') && artifact.content) {
return `${artifact.title}\n${artifact.content}`;
}
if (artifact.artifact_type === 'json') {
return `${artifact.title}\n已生成结构化结果`;
}
if (artifact.file_id) {
return `${artifact.title}\n已生成文件输出`;
}
return artifact.title;
}
function delegatedTask(run: ProcessRun): string | null {
const value = run.metadata?.delegated_task;
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function buildFeed(
run: ProcessRun,
events: ProcessEvent[],
artifacts: ProcessArtifact[],
): AgentFeedItem[] {
const items: AgentFeedItem[] = [];
let hasLeadBubble = false;
for (const event of events) {
if (!event.text?.trim()) {
continue;
}
if (event.kind === 'run_message') {
const role = event.message_role || 'assistant';
if (role === 'user') {
hasLeadBubble = true;
}
items.push({
key: event.event_id,
created_at: event.created_at,
role,
text: event.text.trim(),
});
continue;
}
if (event.kind === 'run_progress') {
items.push({
key: event.event_id,
created_at: event.created_at,
role: 'assistant',
text: event.text.trim(),
});
continue;
}
if (event.kind === 'run_status' && event.status && event.status !== 'running') {
items.push({
key: event.event_id,
created_at: event.created_at,
role: 'system',
text: event.text.trim(),
tone: event.status,
});
}
}
for (const artifact of artifacts) {
items.push({
key: artifact.artifact_id,
created_at: artifact.created_at,
role: artifact.actor_type === 'mcp' ? 'tool' : 'assistant',
text: artifactPreview(artifact),
});
}
if (!hasLeadBubble) {
const task = delegatedTask(run);
if (task) {
items.push({
key: `${run.run_id}:delegated-task`,
created_at: run.started_at,
role: 'user',
text: task,
});
}
}
return items
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
.slice(-8);
}
function runSummary(run: ProcessRun, feed: AgentFeedItem[]): string {
if (run.summary?.trim()) {
return run.summary.trim();
}
const latestAssistant = [...feed].reverse().find((item) => item.role === 'assistant' || item.role === 'tool');
return latestAssistant?.text || '已完成子任务处理';
}
function useRunCardPhases(runs: ProcessRun[]) {
const [phases, setPhases] = React.useState<Record<string, RunCardPhase>>(() =>
Object.fromEntries(
runs.map((run) => [run.run_id, TERMINAL_STATUSES.has(run.status) ? 'collapsed' : 'live'])
)
);
const timersRef = React.useRef<Record<string, ReturnType<typeof setTimeout>>>({});
React.useEffect(() => {
setPhases((prev) => {
const next = { ...prev };
const seen = new Set<string>();
for (const run of runs) {
seen.add(run.run_id);
const isTerminal = TERMINAL_STATUSES.has(run.status);
const current = next[run.run_id];
if (!current) {
next[run.run_id] = isTerminal ? 'collapsed' : 'live';
continue;
}
if (!isTerminal) {
next[run.run_id] = 'live';
if (timersRef.current[run.run_id]) {
clearTimeout(timersRef.current[run.run_id]);
delete timersRef.current[run.run_id];
}
continue;
}
if (current === 'live') {
next[run.run_id] = 'exiting';
timersRef.current[run.run_id] = setTimeout(() => {
setPhases((snapshot) => {
if (snapshot[run.run_id] !== 'exiting') {
return snapshot;
}
return { ...snapshot, [run.run_id]: 'collapsed' };
});
delete timersRef.current[run.run_id];
}, 420);
}
}
for (const runId of Object.keys(next)) {
if (!seen.has(runId)) {
if (timersRef.current[runId]) {
clearTimeout(timersRef.current[runId]);
delete timersRef.current[runId];
}
delete next[runId];
}
}
return next;
});
return () => {
for (const timer of Object.values(timersRef.current)) {
clearTimeout(timer);
}
timersRef.current = {};
};
}, [runs]);
return phases;
}
function AgentBubble({ item }: { item: AgentFeedItem }) {
return (
<div
className={cn(
'rounded-2xl border px-3 py-2 text-[13px] leading-5 transition-colors',
feedTone(item.role),
item.role === 'system' && item.tone ? statusTone(item.tone) : ''
)}
>
<div className="mb-1 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
<span>{roleLabel(item.role)}</span>
</div>
<div className="whitespace-pre-wrap break-words">{item.text}</div>
</div>
);
}
function LiveAgentCard({
run,
feed,
artifactCount,
selected,
phase,
accentIndex,
onSelect,
}: {
run: ProcessRun;
feed: AgentFeedItem[];
artifactCount: number;
selected: boolean;
phase: RunCardPhase;
accentIndex: number;
onSelect: () => void;
}) {
const showSpinner = !TERMINAL_STATUSES.has(run.status);
const accent = accentFor(accentIndex);
return (
<button
type="button"
onClick={onSelect}
className={cn(
'min-w-[308px] max-w-[308px] rounded-[22px] border bg-card/70 p-3.5 text-left backdrop-blur-sm transition-all duration-300',
accent.frame,
selected ? 'ring-1 ring-primary/40 shadow-[0_18px_36px_-30px_rgba(15,23,42,0.75)]' : 'hover:border-primary/30',
phase === 'exiting' && 'pointer-events-none scale-[0.94] -translate-y-2 opacity-0'
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
<span className={cn('h-2 w-2 rounded-full', accent.dot)} />
<span>Sub-Agent</span>
</div>
<div className={cn('mt-1 truncate text-sm font-semibold', accent.title)}>{run.actor_name}</div>
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{run.title}</div>
</div>
<Badge variant="outline" className={cn('border', statusTone(run.status))}>
{statusLabel(run.status)}
</Badge>
</div>
<div className="mt-3 rounded-[18px] border border-border/60 bg-background/55 p-2.5">
<div className="max-h-[280px] space-y-2.5 overflow-y-auto pr-1">
{feed.length === 0 && (
<div className="rounded-2xl border border-dashed border-border/60 bg-background/60 px-4 py-5 text-center text-sm text-muted-foreground">
agent ...
</div>
)}
{feed.map((item) => (
<AgentBubble key={item.key} item={item} />
))}
</div>
</div>
<div className="mt-3 flex items-center gap-2 text-[11px] text-muted-foreground">
{showSpinner && (
<span className="inline-flex items-center gap-1.5 rounded-full border border-border/60 bg-muted/40 px-2.5 py-1 text-foreground/80">
<Loader2 className="h-3 w-3 animate-spin" />
</span>
)}
{artifactCount > 0 && <span>{artifactCount} </span>}
{typeof run.source === 'string' && run.source.trim() && <span>{run.source}</span>}
</div>
</button>
);
}
function ResultCard({
run,
summary,
artifactCount,
selected,
accentIndex,
onSelect,
}: {
run: ProcessRun;
summary: string;
artifactCount: number;
selected: boolean;
accentIndex: number;
onSelect: () => void;
}) {
const accent = accentFor(accentIndex);
return (
<button
type="button"
onClick={onSelect}
className={cn(
'min-w-[188px] max-w-[228px] rounded-2xl border bg-card/70 px-3.5 py-3 text-left backdrop-blur-sm transition-colors',
accent.result,
selected ? 'ring-1 ring-primary/35' : 'hover:border-primary/25'
)}
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-[10px] font-medium uppercase tracking-[0.18em] text-muted-foreground">Result</div>
<div className={cn('mt-1 truncate text-sm font-semibold', accent.title)}>{run.actor_name}</div>
</div>
<CheckCircle2 className="h-4 w-4 text-emerald-400" />
</div>
<div className="mt-2 line-clamp-3 text-sm text-foreground/80">{summary}</div>
<div className="mt-3 flex items-center gap-2 text-[11px] text-muted-foreground">
<Badge variant="outline" className={cn('border', statusTone(run.status))}>
{statusLabel(run.status)}
</Badge>
{artifactCount > 0 && <span>{artifactCount} </span>}
</div>
</button>
);
}
export function AgentTeamBlock({
rootRun,
memberRuns,
events,
artifacts,
selectedRunId,
onSelectRun,
onCancelRun,
}: {
rootRun: ProcessRun;
memberRuns: ProcessRun[];
events: ProcessEvent[];
artifacts: ProcessArtifact[];
selectedRunId: string | null;
onSelectRun: (runId: string) => void;
onCancelRun: (runId: string) => void;
}) {
const phases = useRunCardPhases(memberRuns);
const sortedRuns = React.useMemo(
() =>
[...memberRuns].sort((a, b) => {
const at = new Date(a.started_at).getTime();
const bt = new Date(b.started_at).getTime();
return at - bt;
}),
[memberRuns]
);
const liveRuns = sortedRuns.filter((run) => phases[run.run_id] === 'live');
const terminalRuns = sortedRuns.filter((run) => TERMINAL_STATUSES.has(run.status));
const collapsedRuns = sortedRuns.filter((run) => phases[run.run_id] === 'collapsed');
const liveCount = liveRuns.filter((run) => !TERMINAL_STATUSES.has(run.status)).length;
const canCancelRoot =
!rootRun.parent_run_id &&
(rootRun.status === 'running' || rootRun.status === 'waiting');
if (liveRuns.length === 0 && terminalRuns.length > 0) {
return (
<div className="inline-flex max-w-full flex-wrap items-start gap-2 rounded-2xl border border-border/60 bg-card/35 px-3 py-3 backdrop-blur-sm">
<div className="mr-1 flex min-h-[68px] min-w-[132px] max-w-[180px] flex-col justify-center">
<div className="inline-flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
<Sparkles className="h-3.5 w-3.5" />
Agent Results
</div>
<div className="mt-1 line-clamp-2 text-sm font-medium text-foreground">{rootRun.title}</div>
</div>
{terminalRuns.map((run, index) => {
const runEvents = events.filter((event) => event.run_id === run.run_id);
const runArtifacts = artifacts.filter((artifact) => artifact.run_id === run.run_id);
const feed = buildFeed(run, runEvents, runArtifacts);
return (
<ResultCard
key={run.run_id}
run={run}
summary={runSummary(run, feed)}
artifactCount={runArtifacts.length}
selected={selectedRunId === run.run_id}
accentIndex={index}
onSelect={() => onSelectRun(run.run_id)}
/>
);
})}
</div>
);
}
return (
<div className="overflow-hidden rounded-[24px] border border-border/70 bg-card/45 p-3.5 backdrop-blur-sm shadow-[0_18px_42px_-34px_rgba(0,0,0,0.55)]">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="inline-flex items-center gap-2 text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
<Sparkles className="h-3.5 w-3.5" />
Agent Team
</div>
<div className="mt-1.5 text-base font-semibold text-foreground">{rootRun.title}</div>
<p className="mt-1 text-sm text-muted-foreground">
{liveCount > 0 ? `主 agent 正在协调 ${liveCount} 个运行中的 sub-agent` : '子 agent 已完成,结果已折叠为摘要卡片'}
</p>
</div>
<div className="flex items-center gap-2">
{canCancelRoot && (
<Button variant="outline" size="sm" className="bg-background/60" onClick={() => onCancelRun(rootRun.run_id)}>
<Square className="mr-1.5 h-3.5 w-3.5" />
</Button>
)}
<Badge variant="outline" className="border-border/70 bg-background/55 text-foreground/85">
{memberRuns.length} sub-agent
</Badge>
<Badge variant="outline" className={cn('border', statusTone(rootRun.status))}>
{statusLabel(rootRun.status)}
</Badge>
</div>
</div>
{liveRuns.length > 0 && (
<div className="mt-3 -mx-1 overflow-x-auto pb-2">
<div className="flex min-w-full gap-3 px-1">
{liveRuns.map((run, index) => {
const runEvents = events.filter((event) => event.run_id === run.run_id);
const runArtifacts = artifacts.filter((artifact) => artifact.run_id === run.run_id);
const feed = buildFeed(run, runEvents, runArtifacts);
return (
<LiveAgentCard
key={run.run_id}
run={run}
feed={feed}
artifactCount={runArtifacts.length}
selected={selectedRunId === run.run_id}
phase={phases[run.run_id] || 'live'}
accentIndex={index}
onSelect={() => onSelectRun(run.run_id)}
/>
);
})}
</div>
</div>
)}
{collapsedRuns.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{collapsedRuns.map((run, index) => {
const runEvents = events.filter((event) => event.run_id === run.run_id);
const runArtifacts = artifacts.filter((artifact) => artifact.run_id === run.run_id);
const feed = buildFeed(run, runEvents, runArtifacts);
return (
<ResultCard
key={run.run_id}
run={run}
summary={runSummary(run, feed)}
artifactCount={runArtifacts.length}
selected={selectedRunId === run.run_id}
accentIndex={index}
onSelect={() => onSelectRun(run.run_id)}
/>
);
})}
</div>
)}
</div>
);
}