前端恢复模型选择器,后端允许 model_name 写入 per-user skill_evolution.json。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
540 lines
17 KiB
TypeScript
540 lines
17 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 {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
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 { useModels } from "@/core/models/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 { config } = useCuratorConfig();
|
||
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]);
|
||
|
||
// 总开关或 curator 子开关任一关闭时,手动触发跑不起来 —— 直接禁用按钮。
|
||
const runDisabled = !config || !config.enabled || !config.curator.enabled;
|
||
|
||
const handleRun = async () => {
|
||
try {
|
||
const res = await runCurator.mutateAsync();
|
||
if (res.triggered) {
|
||
toast.success(tt.runStarted);
|
||
} else {
|
||
// 后端预检拦下了(开关未开 / 已有任务在跑)—— 给出对应原因,而不是误报"已开始"。
|
||
const reasonMsg: Record<string, string> = {
|
||
evolution_disabled: tt.runBlockedEvolutionDisabled,
|
||
curator_disabled: tt.runBlockedCuratorDisabled,
|
||
already_running: tt.runBlockedAlreadyRunning,
|
||
};
|
||
toast.warning(
|
||
reasonMsg[res.reason ?? ""] ?? tt.runBlockedEvolutionDisabled,
|
||
);
|
||
}
|
||
} 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 || runDisabled}
|
||
title={runDisabled ? tt.runDisabledHint : undefined}
|
||
>
|
||
{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>
|
||
{tt.pinned}: {status.agent_created_skills.pinned}
|
||
</span>
|
||
</span>
|
||
</StatRow>
|
||
<StatRow label={tt.lastActions} className="sm:col-span-2">
|
||
<div className="flex flex-col gap-2 text-xs">
|
||
<div>
|
||
<div className="font-medium">{tt.llmSuggested}</div>
|
||
{status.last_consolidation_suggestions.consolidations.length ===
|
||
0 &&
|
||
status.last_consolidation_suggestions.prunings.length === 0 ? (
|
||
<span className="text-muted-foreground">
|
||
{tt.noActions}
|
||
</span>
|
||
) : (
|
||
<ul className="text-muted-foreground mt-0.5 list-disc pl-4">
|
||
{status.last_consolidation_suggestions.consolidations.map(
|
||
(c, i) => (
|
||
<li key={`c${i}`}>
|
||
{(c.from ?? "?") + " → " + (c.into ?? "?")}
|
||
{c.reason ? `(${c.reason})` : ""}
|
||
</li>
|
||
),
|
||
)}
|
||
{status.last_consolidation_suggestions.prunings.map(
|
||
(p, i) => (
|
||
<li key={`p${i}`}>
|
||
{p.name ?? "?"}
|
||
{p.reason ? `(${p.reason})` : ""}
|
||
</li>
|
||
),
|
||
)}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<div className="font-medium">{tt.actuallyDid}</div>
|
||
{status.last_actual_actions.length === 0 ? (
|
||
<span className="text-muted-foreground">
|
||
{tt.noActions}
|
||
</span>
|
||
) : (
|
||
<ul className="mt-0.5 flex flex-col gap-1">
|
||
{status.last_actual_actions.map((a, i) => (
|
||
<li
|
||
key={i}
|
||
className="flex flex-wrap items-center gap-1.5"
|
||
>
|
||
<span className="font-mono">{a.tool ?? "?"}</span>
|
||
{typeof a.args?.name === "string" && (
|
||
<span className="text-muted-foreground">
|
||
{String(a.args.name)}
|
||
</span>
|
||
)}
|
||
<Badge
|
||
variant={
|
||
a.status === "error"
|
||
? "destructive"
|
||
: "secondary"
|
||
}
|
||
className="px-1 text-[10px]"
|
||
>
|
||
{a.status}
|
||
</Badge>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</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>
|
||
);
|
||
}
|
||
|
||
interface CuratorDraft {
|
||
interval_hours: number;
|
||
stale_after_days: number;
|
||
archive_after_days: number;
|
||
model_name: string | null;
|
||
}
|
||
|
||
const DEFAULT_MODEL_VALUE = "__default__";
|
||
|
||
function toDraft(config: CuratorConfig): CuratorDraft {
|
||
return {
|
||
interval_hours: config.curator.interval_hours,
|
||
stale_after_days: config.curator.stale_after_days,
|
||
archive_after_days: config.curator.archive_after_days,
|
||
model_name: config.curator.model_name,
|
||
};
|
||
}
|
||
|
||
function ConfigCard() {
|
||
const { t } = useI18n();
|
||
const tt = t.settings.skillEvolution.config;
|
||
const { config, isLoading } = useCuratorConfig();
|
||
const updateConfig = useUpdateCuratorConfig();
|
||
const { models } = useModels();
|
||
|
||
const [draft, setDraft] = useState<CuratorDraft | null>(null);
|
||
useEffect(() => {
|
||
if (config && !draft) {
|
||
setDraft(toDraft(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 original = config ? toDraft(config) : null;
|
||
const isDirty = !!original && JSON.stringify(draft) !== JSON.stringify(original);
|
||
|
||
const handleSave = async () => {
|
||
try {
|
||
await updateConfig.mutateAsync(draft);
|
||
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.intervalHoursLabel}
|
||
helper={tt.intervalHoursHelper}
|
||
>
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
className="max-w-[10rem]"
|
||
value={draft.interval_hours}
|
||
onChange={(e) =>
|
||
setDraft({ ...draft, 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.stale_after_days}
|
||
onChange={(e) =>
|
||
setDraft({ ...draft, 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.archive_after_days}
|
||
onChange={(e) =>
|
||
setDraft({ ...draft, archive_after_days: Math.max(1, Number(e.target.value) || 1) })
|
||
}
|
||
/>
|
||
</FieldRow>
|
||
<FieldRow
|
||
label={tt.curatorModelLabel}
|
||
helper={tt.curatorModelHelper}
|
||
>
|
||
<Select
|
||
value={draft.model_name ?? DEFAULT_MODEL_VALUE}
|
||
onValueChange={(v) =>
|
||
setDraft({ ...draft, model_name: v === DEFAULT_MODEL_VALUE ? null : v })
|
||
}
|
||
>
|
||
<SelectTrigger
|
||
className={cn(
|
||
"max-w-[20rem]",
|
||
"dark:border-[#1B477E] dark:bg-[#0D2D54] dark:text-[#AACEFF] dark:data-[placeholder]:text-[#566B7F] dark:hover:bg-[#0D2D54]",
|
||
)}
|
||
>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent position="popper" side="bottom" sideOffset={4}>
|
||
<SelectItem value={DEFAULT_MODEL_VALUE}>
|
||
{tt.curatorModelDefault}
|
||
</SelectItem>
|
||
{models.map((m) => (
|
||
<SelectItem key={m.name} value={m.name}>
|
||
{m.display_name || m.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</FieldRow>
|
||
|
||
<div className="flex justify-end gap-2 pt-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={!isDirty || updateConfig.isPending}
|
||
onClick={() => original && setDraft(original)}
|
||
>
|
||
{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;
|
||
}
|
||
}
|