后端:
- 新增 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>
457 lines
13 KiB
TypeScript
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;
|
|
}
|
|
}
|