移除了所有Hermes相关的命名引用,包括: - 从.gitignore中清理相关构建缓存文件 - 将README中的beaver-home路径配置更新 - 完善backend/README.md文档说明Beaver后端主线实现 - 移除Hermes风格的相关注释和兼容性代码 - 清理nanobot环境变量兼容性处理 - 删除技能迁移和服务迁移相关功能代码 - 更新测试用例中相关命名和函数名 BREAKING CHANGE: 移除了Hermes迁移相关API和CLI命令,不再支持nanobot环境变量兼容性
540 lines
18 KiB
TypeScript
540 lines
18 KiB
TypeScript
'use client';
|
|
|
|
import React from 'react';
|
|
import { CheckCircle2, Loader2, Sparkles } from 'lucide-react';
|
|
|
|
import type { ProcessArtifact, ProcessEvent, ProcessRun } from '@/types';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { appArtifactPreview, appFeedRoleLabel, appStatusLabel } from '@/lib/i18n/common';
|
|
import { pickAppText } from '@/lib/i18n/core';
|
|
import { useAppI18n } from '@/lib/i18n/provider';
|
|
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-[#BCC4CE] bg-[#E4E7EB]/45',
|
|
title: 'text-[#697281]',
|
|
dot: 'bg-[#8C96A3]',
|
|
result: 'border-[#BCC4CE] bg-[#E4E7EB]/55',
|
|
},
|
|
{
|
|
frame: 'border-[#B7C2B5] bg-[#E3E8E2]/45',
|
|
title: 'text-[#657162]',
|
|
dot: 'bg-[#869683]',
|
|
result: 'border-[#B7C2B5] bg-[#E3E8E2]/55',
|
|
},
|
|
{
|
|
frame: 'border-[#B8AEA8] bg-[#E7E2DE]/55',
|
|
title: 'text-[#5F5550]',
|
|
dot: 'bg-[#8B7E77]',
|
|
result: 'border-[#B8AEA8] bg-[#E7E2DE]/65',
|
|
},
|
|
{
|
|
frame: 'border-[#D8D2CE] bg-[#ECE8E5]/70',
|
|
title: 'text-[#4F4642]',
|
|
dot: 'bg-[#6A5E58]',
|
|
result: 'border-[#D8D2CE] bg-[#ECE8E5]/80',
|
|
},
|
|
] as const;
|
|
|
|
function accentFor(index: number) {
|
|
return AGENT_ACCENTS[index % AGENT_ACCENTS.length];
|
|
}
|
|
|
|
function statusTone(status: ProcessRun['status']) {
|
|
if (status === 'done') return 'border-[#B7C2B5] bg-[#E3E8E2] text-[#657162]';
|
|
if (status === 'error') return 'border-[#B8AEA8] bg-[#E7E2DE] text-[#342E2B]';
|
|
if (status === 'cancelled') return 'border-[#D8D2CE] bg-[#ECE8E5] text-[#6A5E58]';
|
|
if (status === 'waiting') return 'border-[#B8AEA8] bg-[#E7E2DE] text-[#5F5550]';
|
|
if (status === 'queued') return 'border-[#D8D2CE] bg-[#ECE8E5] text-[#4F4642]';
|
|
return 'border-[#BCC4CE] bg-[#E4E7EB] text-[#697281]';
|
|
}
|
|
|
|
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 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[],
|
|
locale: 'zh-CN' | 'en-US',
|
|
): 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: appArtifactPreview(artifact, locale),
|
|
});
|
|
}
|
|
|
|
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[], locale: 'zh-CN' | 'en-US'): string {
|
|
if (run.summary?.trim()) {
|
|
return run.summary.trim();
|
|
}
|
|
const latestAssistant = [...feed].reverse().find((item) => item.role === 'assistant' || item.role === 'tool');
|
|
return latestAssistant?.text || pickAppText(locale, '已完成子任务处理', 'Subtask processing completed');
|
|
}
|
|
|
|
function SkillChips({ metadata }: { metadata?: Record<string, unknown> }) {
|
|
const rawSelected = metadata?.selected_skill_names;
|
|
const rawEphemeral = metadata?.ephemeral_skill_names;
|
|
const selected = Array.isArray(rawSelected) ? rawSelected.map(String).filter(Boolean) : [];
|
|
const ephemeral = Array.isArray(rawEphemeral) ? rawEphemeral.map(String).filter(Boolean) : [];
|
|
const guidanceId = typeof metadata?.ephemeral_guidance_id === 'string' ? metadata.ephemeral_guidance_id : '';
|
|
if (selected.length === 0 && ephemeral.length === 0 && !guidanceId) {
|
|
return null;
|
|
}
|
|
return (
|
|
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
|
{selected.map((name) => (
|
|
<Badge key={`skill:${name}`} variant="secondary" className="max-w-[128px] truncate text-[10px]">
|
|
skill:{name}
|
|
</Badge>
|
|
))}
|
|
{ephemeral.map((name) => (
|
|
<Badge key={`ephemeral:${name}`} variant="outline" className="max-w-[128px] truncate text-[10px]">
|
|
ephemeral:{name}
|
|
</Badge>
|
|
))}
|
|
{guidanceId && (
|
|
<Badge variant="outline" className="text-[10px]">
|
|
guidance:{guidanceId.slice(0, 8)}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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,
|
|
locale,
|
|
}: {
|
|
item: AgentFeedItem;
|
|
locale: 'zh-CN' | 'en-US';
|
|
}) {
|
|
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>{appFeedRoleLabel(item.role, locale)}</span>
|
|
</div>
|
|
<div className="whitespace-pre-wrap break-words">{item.text}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function LiveAgentCard({
|
|
run,
|
|
feed,
|
|
artifactCount,
|
|
selected,
|
|
phase,
|
|
accentIndex,
|
|
onSelect,
|
|
locale,
|
|
}: {
|
|
run: ProcessRun;
|
|
feed: AgentFeedItem[];
|
|
artifactCount: number;
|
|
selected: boolean;
|
|
phase: RunCardPhase;
|
|
accentIndex: number;
|
|
onSelect: () => void;
|
|
locale: 'zh-CN' | 'en-US';
|
|
}) {
|
|
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>{pickAppText(locale, '子任务', 'Subtask')}</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>
|
|
<SkillChips metadata={run.metadata} />
|
|
</div>
|
|
<Badge variant="outline" className={cn('border', statusTone(run.status))}>
|
|
{appStatusLabel(run.status, locale)}
|
|
</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">
|
|
{pickAppText(locale, '等待子任务输出...', 'Waiting for subtask output...')}
|
|
</div>
|
|
)}
|
|
{feed.map((item) => (
|
|
<AgentBubble key={item.key} item={item} locale={locale} />
|
|
))}
|
|
</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" />
|
|
{pickAppText(locale, '运行中', 'Running')}
|
|
</span>
|
|
)}
|
|
{artifactCount > 0 && <span>{pickAppText(locale, `${artifactCount} 个输出`, `${artifactCount} outputs`)}</span>}
|
|
{typeof run.source === 'string' && run.source.trim() && <span>{run.source}</span>}
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function ResultCard({
|
|
run,
|
|
summary,
|
|
artifactCount,
|
|
selected,
|
|
accentIndex,
|
|
onSelect,
|
|
locale,
|
|
}: {
|
|
run: ProcessRun;
|
|
summary: string;
|
|
artifactCount: number;
|
|
selected: boolean;
|
|
accentIndex: number;
|
|
onSelect: () => void;
|
|
locale: 'zh-CN' | 'en-US';
|
|
}) {
|
|
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">{pickAppText(locale, '结果', '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-[#657162]" />
|
|
</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))}>
|
|
{appStatusLabel(run.status, locale)}
|
|
</Badge>
|
|
{artifactCount > 0 && <span>{pickAppText(locale, `${artifactCount} 个输出`, `${artifactCount} outputs`)}</span>}
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function AgentTeamBlock({
|
|
rootRun,
|
|
memberRuns,
|
|
events,
|
|
artifacts,
|
|
selectedRunId,
|
|
onSelectRun,
|
|
}: {
|
|
rootRun: ProcessRun;
|
|
memberRuns: ProcessRun[];
|
|
events: ProcessEvent[];
|
|
artifacts: ProcessArtifact[];
|
|
selectedRunId: string | null;
|
|
onSelectRun: (runId: string) => void;
|
|
}) {
|
|
const { locale } = useAppI18n();
|
|
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;
|
|
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" />
|
|
{pickAppText(locale, '智能体结果', '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, locale);
|
|
return (
|
|
<ResultCard
|
|
key={run.run_id}
|
|
run={run}
|
|
summary={runSummary(run, feed, locale)}
|
|
artifactCount={runArtifacts.length}
|
|
selected={selectedRunId === run.run_id}
|
|
accentIndex={index}
|
|
onSelect={() => onSelectRun(run.run_id)}
|
|
locale={locale}
|
|
/>
|
|
);
|
|
})}
|
|
</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" />
|
|
{pickAppText(locale, '任务子流程', 'Task subprocess')}
|
|
</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
|
|
? pickAppText(locale, `主 Agent 正在协调 ${liveCount} 个运行中的子任务`, `Main Agent is coordinating ${liveCount} running subtasks`)
|
|
: pickAppText(locale, '子任务已完成,结果已折叠为摘要卡片', 'Subtasks are done. Results are folded into summary cards')}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline" className="border-border/70 bg-background/55 text-foreground/85">
|
|
{pickAppText(locale, `${memberRuns.length} 个子任务`, `${memberRuns.length} subtasks`)}
|
|
</Badge>
|
|
<Badge variant="outline" className={cn('border', statusTone(rootRun.status))}>
|
|
{appStatusLabel(rootRun.status, locale)}
|
|
</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, locale);
|
|
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)}
|
|
locale={locale}
|
|
/>
|
|
);
|
|
})}
|
|
</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, locale);
|
|
return (
|
|
<ResultCard
|
|
key={run.run_id}
|
|
run={run}
|
|
summary={runSummary(run, feed, locale)}
|
|
artifactCount={runArtifacts.length}
|
|
selected={selectedRunId === run.run_id}
|
|
accentIndex={index}
|
|
onSelect={() => onSelectRun(run.run_id)}
|
|
locale={locale}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|