```
feat(agent): 添加对持久化子智能体的支持并增强委派管理 添加了持久化子智能体的完整生命周期管理功能,包括创建、更新、删除和查询API接口。 新增了子智能体的JSON-RPC通信协议支持,实现了远程调用和任务管理功能。 同时增强了委派管理器的功能: - 添加了对本地委派、插件委派和本地回退的开关控制 - 实现了持久化子智能体任务的自动检测和本地执行保护 - 增加了对不同委派类型的权限验证机制 修改了智能体注册表以支持插件智能体的条件性包含,并更新了工具注册逻辑以支持可选工具。 BREAKING CHANGE: 委派管理器的构造函数签名已更改,添加了新的控制参数。 ```
This commit is contained in:
@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user