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:
xuhdandClaude Opus 4.7 committed 2026-05-14 12:00:54 +08:00
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"}
+41
View File
@@ -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>;
}
+69
View File
@@ -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,
};
}
+38
View File
@@ -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>
);
}
+9
View File
@@ -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 />}