feat(skills): 自我进化前端 — Skills 独立侧边栏 + /skills/evolution 页面
UI 重构:
- settings-dialog.tsx:把 Skills section 从设置弹窗移除
- workspace-nav-chat-list.tsx + WorkspaceRoutes.tsx + SkillsPage.tsx:
Skills 提升为独立侧边栏导航项,/workspace/skills 路由直接渲染
SkillSettingsPage(同事原型已就绪)
- skill-settings-page.tsx:顶部追加 "自我进化" 跳转按钮
自我进化页面:
- /workspace/skills/evolution 子路由 → SkillEvolutionPage 包装
- skill-evolution-panel.tsx:三段式
· 状态卡:启用/暂停徽章 + 累计运行次数 + 上次运行时间/摘要 +
技能计数 (total/active/stale/archived) + 立即执行按钮
· 配置表单:enabled / curator.enabled / interval_hours /
stale_after_days / archive_after_days / moderation_model_name
本地暂存 → 保存调 PUT,热生效
· 日志面板:拉 GET /api/curator/logs,2 秒自动刷新,monospace
展示,warning 黄、error 红
- core/curator/api.ts + hooks.ts + types.ts:TanStack Query 包装
status / config (GET/PUT) / run / logs
i18n:
- types.ts 新增 settings.skillEvolution.* 类型签名
- zh-CN.ts / en-US.ts:状态卡 / 配置表单 / 日志面板文案,
以及 settings.skills.goToEvolution
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
9b6b94d919
commit
5c6ac06656
13 files changed
+826
-12
No files matched your search
@@ -5,7 +5,6 @@ import {
|
||||
InfoIcon,
|
||||
BrainIcon,
|
||||
PaletteIcon,
|
||||
SparklesIcon,
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -21,7 +20,6 @@ import { AboutSettingsPage } from "@/components/workspace/settings/about-setting
|
||||
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
|
||||
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
|
||||
import { NotificationSettingsPage } from "@/components/workspace/settings/notification-settings-page";
|
||||
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
|
||||
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -30,7 +28,6 @@ type SettingsSection =
|
||||
| "appearance"
|
||||
| "memory"
|
||||
| "tools"
|
||||
| "skills"
|
||||
| "notification"
|
||||
| "about";
|
||||
|
||||
@@ -70,14 +67,12 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
icon: BrainIcon,
|
||||
},
|
||||
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||
{ id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
|
||||
// { id: "about", label: t.settings.sections.about, icon: InfoIcon },
|
||||
],
|
||||
[
|
||||
t.settings.sections.appearance,
|
||||
t.settings.sections.memory,
|
||||
t.settings.sections.tools,
|
||||
t.settings.sections.skills,
|
||||
t.settings.sections.notification,
|
||||
t.settings.sections.about,
|
||||
],
|
||||
@@ -127,11 +122,6 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
{activeSection === "appearance" && <AppearanceSettingsPage />}
|
||||
{activeSection === "memory" && <MemorySettingsPage />}
|
||||
{activeSection === "tools" && <ToolSettingsPage />}
|
||||
{activeSection === "skills" && (
|
||||
<SkillSettingsPage
|
||||
onClose={() => props.onOpenChange?.(false)}
|
||||
/>
|
||||
)}
|
||||
{activeSection === "notification" && <NotificationSettingsPage />}
|
||||
{activeSection === "about" && <AboutSettingsPage />}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, CheckCircle2, FileArchive, Loader2, SparklesIcon, Upload } from "lucide-react";
|
||||
import { AlertCircle, CheckCircle2, FileArchive, Loader2, SparklesIcon, TrendingUpIcon, Upload } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -96,6 +96,17 @@ function SkillSettingsList({
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onClose?.();
|
||||
router.push("/workspace/skills/evolution");
|
||||
}}
|
||||
>
|
||||
<TrendingUpIcon className="size-4" />
|
||||
{t.settings.skills.goToEvolution}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
"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("/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}
|
||||
</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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare } from "lucide-react";
|
||||
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
@@ -48,6 +48,17 @@ export function WorkspaceNavChatList() {
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/skills")}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-sidebar-foreground" href="/page/workspace/skills">
|
||||
<SparklesIcon />
|
||||
<span>技能</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type {
|
||||
CuratorConfig,
|
||||
CuratorLogs,
|
||||
CuratorStatus,
|
||||
} from "./types";
|
||||
|
||||
export async function getCuratorStatus(): Promise<CuratorStatus> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/status`);
|
||||
return response.json() as Promise<CuratorStatus>;
|
||||
}
|
||||
|
||||
export async function getCuratorConfig(): Promise<CuratorConfig> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/config`);
|
||||
return response.json() as Promise<CuratorConfig>;
|
||||
}
|
||||
|
||||
export async function updateCuratorConfig(
|
||||
payload: CuratorConfig,
|
||||
): Promise<CuratorConfig> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/config`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return response.json() as Promise<CuratorConfig>;
|
||||
}
|
||||
|
||||
export async function runCurator(): Promise<{ triggered: boolean; message: string }> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/run`, {
|
||||
method: "POST",
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getCuratorLogs(): Promise<CuratorLogs> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/logs`);
|
||||
return response.json() as Promise<CuratorLogs>;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
getCuratorConfig,
|
||||
getCuratorLogs,
|
||||
getCuratorStatus,
|
||||
runCurator,
|
||||
updateCuratorConfig,
|
||||
} from "./api";
|
||||
import type { CuratorConfig } from "./types";
|
||||
|
||||
const STATUS_KEY = ["curator", "status"] as const;
|
||||
const CONFIG_KEY = ["curator", "config"] as const;
|
||||
const LOGS_KEY = ["curator", "logs"] as const;
|
||||
|
||||
export function useCuratorStatus() {
|
||||
const query = useQuery({
|
||||
queryKey: STATUS_KEY,
|
||||
queryFn: getCuratorStatus,
|
||||
});
|
||||
return { status: query.data, isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
export function useCuratorConfig() {
|
||||
const query = useQuery({
|
||||
queryKey: CONFIG_KEY,
|
||||
queryFn: getCuratorConfig,
|
||||
});
|
||||
return { config: query.data, isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
export function useUpdateCuratorConfig() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (payload: CuratorConfig) => updateCuratorConfig(payload),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: CONFIG_KEY });
|
||||
void queryClient.invalidateQueries({ queryKey: STATUS_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRunCurator() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => runCurator(),
|
||||
onSuccess: () => {
|
||||
// Logs start from scratch on a new run — refresh so the UI clears
|
||||
// immediately and then picks up the new run_started record.
|
||||
void queryClient.invalidateQueries({ queryKey: LOGS_KEY });
|
||||
void queryClient.invalidateQueries({ queryKey: STATUS_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCuratorLogs(options?: { autoRefresh?: boolean }) {
|
||||
const autoRefresh = options?.autoRefresh ?? true;
|
||||
const query = useQuery({
|
||||
queryKey: LOGS_KEY,
|
||||
queryFn: getCuratorLogs,
|
||||
refetchInterval: autoRefresh ? 2000 : false,
|
||||
});
|
||||
return {
|
||||
logs: query.data,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export interface CuratorSubConfig {
|
||||
enabled: boolean;
|
||||
interval_hours: number;
|
||||
stale_after_days: number;
|
||||
archive_after_days: number;
|
||||
}
|
||||
|
||||
export interface CuratorConfig {
|
||||
enabled: boolean;
|
||||
moderation_model_name: string | null;
|
||||
curator: CuratorSubConfig;
|
||||
}
|
||||
|
||||
export interface CuratorStatus {
|
||||
enabled: boolean;
|
||||
run_count: number;
|
||||
last_run_at: string | null;
|
||||
last_run_summary: string | null;
|
||||
paused: boolean;
|
||||
agent_created_skills: {
|
||||
total: number;
|
||||
active: number;
|
||||
stale: number;
|
||||
archived: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CuratorLogRecord {
|
||||
ts: string;
|
||||
level: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface CuratorLogs {
|
||||
records: CuratorLogRecord[];
|
||||
run_count: number;
|
||||
last_run_at: string | null;
|
||||
}
|
||||
@@ -434,6 +434,7 @@ export const enUS: Translations = {
|
||||
"Manage the configuration and enabled status of the agent skills.",
|
||||
createSkill: "Create skill",
|
||||
installSkill: "Install skill",
|
||||
goToEvolution: "Skill evolution",
|
||||
emptyTitle: "No agent skill yet",
|
||||
emptyDescription:
|
||||
"Put your agent skill folders under the `/skills/custom` folder under the root folder of CM Assistant.",
|
||||
@@ -461,6 +462,65 @@ export const enUS: Translations = {
|
||||
rejectInvalidExt: "Only .zip or .skill files are accepted.",
|
||||
},
|
||||
},
|
||||
skillEvolution: {
|
||||
title: "Skill Evolution",
|
||||
description:
|
||||
"Manage the skill curator: let the agent age out, archive, and consolidate its self-created skills in the background.",
|
||||
backToSkills: "Back to skills",
|
||||
status: {
|
||||
title: "Status",
|
||||
enabled: "Enabled",
|
||||
disabled: "Disabled",
|
||||
paused: "Paused",
|
||||
runCount: "Total runs",
|
||||
runCountUnit: "",
|
||||
lastRunAt: "Last run",
|
||||
lastRunNever: "Never",
|
||||
lastRunSummary: "Last summary",
|
||||
lastRunSummaryEmpty: "—",
|
||||
skillCounts: "Skills",
|
||||
active: "Active",
|
||||
stale: "Stale",
|
||||
archived: "Archived",
|
||||
total: "Total",
|
||||
runNow: "Run now",
|
||||
runStarted: "Background run started",
|
||||
},
|
||||
config: {
|
||||
title: "Configuration",
|
||||
description:
|
||||
"Saves to skill_evolution.json and hot-reloads — no restart required.",
|
||||
enabledLabel: "Enable skill evolution",
|
||||
enabledHelper:
|
||||
"When off, the agent cannot create or modify skills under custom/.",
|
||||
curatorEnabledLabel: "Curator runs automatically",
|
||||
curatorEnabledHelper:
|
||||
"When off, the curator only runs when triggered from this page.",
|
||||
intervalHoursLabel: "Run interval (hours)",
|
||||
intervalHoursHelper:
|
||||
"Minimum hours between automatic curator runs.",
|
||||
staleAfterDaysLabel: "Stale threshold (days)",
|
||||
staleAfterDaysHelper:
|
||||
"Skills inactive for this many days are flagged stale.",
|
||||
archiveAfterDaysLabel: "Archive threshold (days)",
|
||||
archiveAfterDaysHelper:
|
||||
"Stale skills inactive for this many additional days are archived.",
|
||||
moderationModelLabel: "Moderation model (optional)",
|
||||
moderationModelHelper:
|
||||
"Leave blank to use the default chat model.",
|
||||
save: "Save changes",
|
||||
saveSuccess: "Configuration updated",
|
||||
reset: "Reset",
|
||||
},
|
||||
logs: {
|
||||
title: "Recent run logs",
|
||||
description:
|
||||
"Latest curator run only; cleared on backend restart.",
|
||||
empty: "No logs yet. Clicking 'Run now' will populate this panel.",
|
||||
autoRefresh: "Auto-refresh (2s)",
|
||||
refresh: "Refresh now",
|
||||
},
|
||||
},
|
||||
notification: {
|
||||
title: "Notification",
|
||||
description:
|
||||
|
||||
@@ -355,6 +355,7 @@ export interface Translations {
|
||||
description: string;
|
||||
createSkill: string;
|
||||
installSkill: string;
|
||||
goToEvolution: string;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
emptyButton: string;
|
||||
@@ -379,6 +380,56 @@ export interface Translations {
|
||||
rejectInvalidExt: string;
|
||||
};
|
||||
};
|
||||
skillEvolution: {
|
||||
title: string;
|
||||
description: string;
|
||||
backToSkills: string;
|
||||
status: {
|
||||
title: string;
|
||||
enabled: string;
|
||||
disabled: string;
|
||||
paused: string;
|
||||
runCount: string;
|
||||
runCountUnit: string;
|
||||
lastRunAt: string;
|
||||
lastRunNever: string;
|
||||
lastRunSummary: string;
|
||||
lastRunSummaryEmpty: string;
|
||||
skillCounts: string;
|
||||
active: string;
|
||||
stale: string;
|
||||
archived: string;
|
||||
total: string;
|
||||
runNow: string;
|
||||
runStarted: string;
|
||||
};
|
||||
config: {
|
||||
title: string;
|
||||
description: string;
|
||||
enabledLabel: string;
|
||||
enabledHelper: string;
|
||||
curatorEnabledLabel: string;
|
||||
curatorEnabledHelper: string;
|
||||
intervalHoursLabel: string;
|
||||
intervalHoursHelper: string;
|
||||
staleAfterDaysLabel: string;
|
||||
staleAfterDaysHelper: string;
|
||||
archiveAfterDaysLabel: string;
|
||||
archiveAfterDaysHelper: string;
|
||||
moderationModelLabel: string;
|
||||
moderationModelHelper: string;
|
||||
save: string;
|
||||
saveSuccess: string;
|
||||
reset: string;
|
||||
};
|
||||
logs: {
|
||||
title: string;
|
||||
description: string;
|
||||
empty: string;
|
||||
autoRefresh: string;
|
||||
refresh: string;
|
||||
};
|
||||
};
|
||||
notification: {
|
||||
title: string;
|
||||
description: string;
|
||||
|
||||
@@ -415,6 +415,7 @@ export const zhCN: Translations = {
|
||||
description: "管理 Agent Skill 配置和启用状态。",
|
||||
createSkill: "新建技能",
|
||||
installSkill: "安装技能",
|
||||
goToEvolution: "自我进化",
|
||||
emptyTitle: "还没有技能",
|
||||
emptyDescription:
|
||||
"将你的 Agent Skill 文件夹放在 CM 助手 根目录下的 `/skills/custom` 文件夹中。",
|
||||
@@ -441,6 +442,58 @@ export const zhCN: Translations = {
|
||||
rejectInvalidExt: "只接受 .zip 或 .skill 文件。",
|
||||
},
|
||||
},
|
||||
skillEvolution: {
|
||||
title: "自我进化",
|
||||
description:
|
||||
"管理技能 curator:让 agent 在后台对自创建的技能做老化标记、归档和合并建议。",
|
||||
backToSkills: "返回技能",
|
||||
status: {
|
||||
title: "当前状态",
|
||||
enabled: "已启用",
|
||||
disabled: "已停用",
|
||||
paused: "已暂停",
|
||||
runCount: "累计运行",
|
||||
runCountUnit: "次",
|
||||
lastRunAt: "上次运行",
|
||||
lastRunNever: "尚未运行",
|
||||
lastRunSummary: "上次摘要",
|
||||
lastRunSummaryEmpty: "—",
|
||||
skillCounts: "技能",
|
||||
active: "活跃",
|
||||
stale: "陈旧",
|
||||
archived: "已归档",
|
||||
total: "合计",
|
||||
runNow: "立即执行",
|
||||
runStarted: "已触发后台执行",
|
||||
},
|
||||
config: {
|
||||
title: "配置",
|
||||
description:
|
||||
"保存后会写入 skill_evolution.json 并热更新,无需重启。",
|
||||
enabledLabel: "启用自我进化",
|
||||
enabledHelper: "关闭后 agent 不会创建或修改 custom/ 下的技能。",
|
||||
curatorEnabledLabel: "curator 自动运行",
|
||||
curatorEnabledHelper: "关闭后只能在此页面手动触发。",
|
||||
intervalHoursLabel: "运行间隔(小时)",
|
||||
intervalHoursHelper: "两次自动运行之间至少经过的小时数。",
|
||||
staleAfterDaysLabel: "陈旧阈值(天)",
|
||||
staleAfterDaysHelper: "技能长时间不活跃后被标记为陈旧。",
|
||||
archiveAfterDaysLabel: "归档阈值(天)",
|
||||
archiveAfterDaysHelper: "标记陈旧后再经过这么多天会被自动归档。",
|
||||
moderationModelLabel: "安全审核模型(可选)",
|
||||
moderationModelHelper: "为空时使用默认主模型。",
|
||||
save: "保存修改",
|
||||
saveSuccess: "配置已更新",
|
||||
reset: "重置",
|
||||
},
|
||||
logs: {
|
||||
title: "最近运行日志",
|
||||
description: "展示最近一次 curator 运行的日志,服务重启会清空。",
|
||||
empty: "暂无日志。点击「立即执行」会写入新的运行记录。",
|
||||
autoRefresh: "自动刷新 (2s)",
|
||||
refresh: "立即刷新",
|
||||
},
|
||||
},
|
||||
notification: {
|
||||
title: "通知",
|
||||
description:
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SkillEvolutionPanel } from "@/components/workspace/skills/skill-evolution-panel";
|
||||
|
||||
export default function SkillEvolutionPage() {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-auto p-6">
|
||||
<SkillEvolutionPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
|
||||
|
||||
export default function SkillsPage() {
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-auto p-6">
|
||||
<SkillSettingsPage />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import AgentChatPage from "./AgentChatPage";
|
||||
import NewAgentPage from "./NewAgentPage";
|
||||
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||
import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||
import SkillEvolutionPage from "./SkillEvolutionPage";
|
||||
import SkillsPage from "./SkillsPage";
|
||||
|
||||
function WorkspaceLayout({
|
||||
children,
|
||||
@@ -111,6 +113,22 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="skills"
|
||||
element={
|
||||
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
|
||||
<SkillsPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="skills/evolution"
|
||||
element={
|
||||
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}>
|
||||
<SkillEvolutionPage />
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="scheduled"
|
||||
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
|
||||
|
||||
Reference in new issue
Block a user