Files
deerflow-code/frontend-web/src/components/workspace/skills/skill-evolution-panel.tsx
T
xuhdandClaude Opus 4.7 62d525dae7 feat(skills): 自我进化 - 软归档/恢复 + Agent 归属 + 状态分层 UI
后端:
- 新增 PUT /api/skills/custom/{name}/state 与 /pinned 软归档/恢复 + 钉选 API
- skill_manage 工具: agent 创建写 DB 归属; 删除清理 .usage.json + DB row + cascade 引用; 加 ownership 预校验
- curator 全部软归档(文件保留在 custom/), 加 reason 必填(中文), 修 user_context 跨线程传播 / 协程泄漏 / archive 文件不再移动
- LLM 评审接 ToolErrorHandlingMiddleware, 工具异常自动重试不中止本轮; 替换 create_react_agent 为 create_agent
- skill_list 按当前 agent config.yaml 的 skills 字段过滤(custom agent), lead agent 仍可见全部
- 自我进化提示词对齐 Hermes 风格: 主动询问保存, reason 强制中文且面向用户
- 控制台日志增强: 自动归档/标过时/LLM 合并/删减建议 都按条目输出原因

前端:
- 技能管理页 Tab 改「全部 / 我的」, 正确包含他人发布的 custom 技能(修复 isCustom 互斥 bug)
- 新增「过时 N / 归档 N」右侧抽屉(恢复/查看/立即归档 ⋯ 菜单)
- 新增徽章: 钉选 / 来源(Agent 创建 / 用户上传) / 使用次数展示
- 内置技能徽章修复(原 isBuiltin && isCustom 永远 false)
- 自我进化日志 run_started sentinel 在前端展示为中文分隔条
- 移除冗余 enabled 开关(归档已覆盖此能力, 减少术语)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 00:00:52 +08:00

457 lines
13 KiB
TypeScript

"use client";
import {
ArrowLeftIcon,
Loader2Icon,
PlayIcon,
RefreshCwIcon,
SaveIcon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
useCuratorConfig,
useCuratorLogs,
useCuratorStatus,
useRunCurator,
useUpdateCuratorConfig,
} from "@/core/curator/hooks";
import type { CuratorConfig } from "@/core/curator/types";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
export function SkillEvolutionPanel() {
const { t } = useI18n();
const router = useRouter();
const tt = t.settings.skillEvolution;
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-4">
<header className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold">{tt.title}</h1>
<p className="text-muted-foreground mt-1 text-sm">
{tt.description}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => router.push("/page/workspace/skills")}
>
<ArrowLeftIcon className="size-4" />
{tt.backToSkills}
</Button>
</header>
<StatusCard />
<ConfigCard />
<LogsCard />
</div>
);
}
function StatusCard() {
const { t } = useI18n();
const tt = t.settings.skillEvolution.status;
const { status, isLoading } = useCuratorStatus();
const runCurator = useRunCurator();
const lastRunDisplay = useMemo(() => {
if (!status?.last_run_at) return tt.lastRunNever;
try {
return new Date(status.last_run_at).toLocaleString();
} catch {
return status.last_run_at;
}
}, [status?.last_run_at, tt.lastRunNever]);
const handleRun = async () => {
try {
await runCurator.mutateAsync();
toast.success(tt.runStarted);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle>{tt.title}</CardTitle>
{status && (
<div className="mt-2 flex flex-wrap gap-2">
<Badge variant={status.enabled ? "default" : "secondary"}>
{status.enabled ? tt.enabled : tt.disabled}
</Badge>
{status.paused && (
<Badge variant="outline">{tt.paused}</Badge>
)}
</div>
)}
</div>
<Button
size="sm"
onClick={handleRun}
disabled={runCurator.isPending}
>
{runCurator.isPending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<PlayIcon className="size-4" />
)}
{tt.runNow}
</Button>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-muted-foreground text-sm">
{t.common.loading}
</div>
) : status ? (
<div className="grid gap-3 text-sm sm:grid-cols-2">
<StatRow label={tt.runCount}>
{status.run_count}
{tt.runCountUnit && (
<span className="text-muted-foreground ml-1">
{tt.runCountUnit}
</span>
)}
</StatRow>
<StatRow label={tt.lastRunAt}>{lastRunDisplay}</StatRow>
<StatRow label={tt.lastRunSummary} className="sm:col-span-2">
<span className="text-muted-foreground">
{status.last_run_summary ?? tt.lastRunSummaryEmpty}
</span>
</StatRow>
<StatRow label={tt.skillCounts} className="sm:col-span-2">
<span className="text-muted-foreground inline-flex flex-wrap gap-3">
<span>
{tt.total}: {status.agent_created_skills.total}
</span>
<span>
{tt.active}: {status.agent_created_skills.active}
</span>
<span>
{tt.stale}: {status.agent_created_skills.stale}
</span>
<span>
{tt.archived}: {status.agent_created_skills.archived}
</span>
</span>
</StatRow>
</div>
) : null}
</CardContent>
</Card>
);
}
function StatRow({
label,
className,
children,
}: {
label: string;
className?: string;
children: React.ReactNode;
}) {
return (
<div className={cn("flex items-center gap-2", className)}>
<span className="text-muted-foreground min-w-[6rem]">{label}</span>
<span className="font-medium">{children}</span>
</div>
);
}
function ConfigCard() {
const { t } = useI18n();
const tt = t.settings.skillEvolution.config;
const { config, isLoading } = useCuratorConfig();
const updateConfig = useUpdateCuratorConfig();
const [draft, setDraft] = useState<CuratorConfig | null>(null);
useEffect(() => {
if (config && !draft) {
setDraft(config);
}
}, [config, draft]);
if (isLoading || !draft) {
return (
<Card>
<CardHeader>
<CardTitle>{tt.title}</CardTitle>
</CardHeader>
<CardContent>
<div className="text-muted-foreground text-sm">
{t.common.loading}
</div>
</CardContent>
</Card>
);
}
const isDirty =
!!config &&
JSON.stringify(draft) !== JSON.stringify(config);
const handleSave = async () => {
try {
const updated = await updateConfig.mutateAsync(draft);
setDraft(updated);
toast.success(tt.saveSuccess);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
return (
<Card>
<CardHeader>
<CardTitle>{tt.title}</CardTitle>
<CardDescription>{tt.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<FieldRow
label={tt.enabledLabel}
helper={tt.enabledHelper}
>
<Switch
checked={draft.enabled}
onCheckedChange={(v) => setDraft({ ...draft, enabled: v })}
/>
</FieldRow>
<FieldRow
label={tt.curatorEnabledLabel}
helper={tt.curatorEnabledHelper}
>
<Switch
checked={draft.curator.enabled}
onCheckedChange={(v) =>
setDraft({
...draft,
curator: { ...draft.curator, enabled: v },
})
}
/>
</FieldRow>
<FieldRow
label={tt.intervalHoursLabel}
helper={tt.intervalHoursHelper}
>
<Input
type="number"
min={1}
className="max-w-[10rem]"
value={draft.curator.interval_hours}
onChange={(e) =>
setDraft({
...draft,
curator: {
...draft.curator,
interval_hours: Math.max(1, Number(e.target.value) || 1),
},
})
}
/>
</FieldRow>
<FieldRow
label={tt.staleAfterDaysLabel}
helper={tt.staleAfterDaysHelper}
>
<Input
type="number"
min={1}
className="max-w-[10rem]"
value={draft.curator.stale_after_days}
onChange={(e) =>
setDraft({
...draft,
curator: {
...draft.curator,
stale_after_days: Math.max(1, Number(e.target.value) || 1),
},
})
}
/>
</FieldRow>
<FieldRow
label={tt.archiveAfterDaysLabel}
helper={tt.archiveAfterDaysHelper}
>
<Input
type="number"
min={1}
className="max-w-[10rem]"
value={draft.curator.archive_after_days}
onChange={(e) =>
setDraft({
...draft,
curator: {
...draft.curator,
archive_after_days: Math.max(1, Number(e.target.value) || 1),
},
})
}
/>
</FieldRow>
<FieldRow
label={tt.moderationModelLabel}
helper={tt.moderationModelHelper}
>
<Input
type="text"
className="max-w-[20rem]"
value={draft.moderation_model_name ?? ""}
placeholder=""
onChange={(e) =>
setDraft({
...draft,
moderation_model_name:
e.target.value.trim() === "" ? null : e.target.value,
})
}
/>
</FieldRow>
<div className="flex justify-end gap-2 pt-2">
<Button
variant="outline"
size="sm"
disabled={!isDirty || updateConfig.isPending}
onClick={() => config && setDraft(config)}
>
{tt.reset}
</Button>
<Button
size="sm"
disabled={!isDirty || updateConfig.isPending}
onClick={handleSave}
>
{updateConfig.isPending ? (
<Loader2Icon className="size-4 animate-spin" />
) : (
<SaveIcon className="size-4" />
)}
{tt.save}
</Button>
</div>
</CardContent>
</Card>
);
}
function FieldRow({
label,
helper,
children,
}: {
label: string;
helper?: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-4 border-b pb-4 last:border-b-0 last:pb-0">
<div className="flex-1">
<div className="text-sm font-medium">{label}</div>
{helper && (
<div className="text-muted-foreground mt-1 text-xs">{helper}</div>
)}
</div>
<div className="flex-shrink-0">{children}</div>
</div>
);
}
function LogsCard() {
const { t } = useI18n();
const tt = t.settings.skillEvolution.logs;
const [autoRefresh, setAutoRefresh] = useState(true);
const { logs, isLoading, refetch } = useCuratorLogs({ autoRefresh });
const records = logs?.records ?? [];
return (
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle>{tt.title}</CardTitle>
<CardDescription>{tt.description}</CardDescription>
</div>
<div className="flex items-center gap-3">
<label className="text-muted-foreground flex items-center gap-2 text-xs">
<Switch
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
/>
{tt.autoRefresh}
</label>
<Button size="sm" variant="outline" onClick={() => void refetch()}>
<RefreshCwIcon className="size-3.5" />
{tt.refresh}
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading && records.length === 0 ? (
<div className="text-muted-foreground text-sm">
{t.common.loading}
</div>
) : records.length === 0 ? (
<div className="text-muted-foreground text-sm">{tt.empty}</div>
) : (
<div className="bg-muted/30 max-h-[420px] overflow-auto rounded-md border p-3 font-mono text-xs leading-relaxed">
{records.map((record, idx) => (
<div
key={`${record.ts}-${idx}`}
className={cn(
"flex gap-3",
record.level === "warning" && "text-yellow-700 dark:text-yellow-300",
record.level === "error" && "text-red-600 dark:text-red-300",
)}
>
<span className="text-muted-foreground shrink-0">
{formatTime(record.ts)}
</span>
<span className="text-muted-foreground w-14 shrink-0 uppercase">
{record.level}
</span>
<span className="whitespace-pre-wrap break-all">
{record.message === "run_started"
? "——— 本次策展开始 ———"
: record.message}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
function formatTime(ts: string): string {
try {
const d = new Date(ts);
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
} catch {
return ts;
}
}