diff --git a/frontend-web/src/components/workspace/settings/settings-dialog.tsx b/frontend-web/src/components/workspace/settings/settings-dialog.tsx index 3543c34a..fb3b80ae 100644 --- a/frontend-web/src/components/workspace/settings/settings-dialog.tsx +++ b/frontend-web/src/components/workspace/settings/settings-dialog.tsx @@ -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" && } {activeSection === "memory" && } {activeSection === "tools" && } - {activeSection === "skills" && ( - props.onOpenChange?.(false)} - /> - )} {activeSection === "notification" && } {activeSection === "about" && } diff --git a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx index c42d77c1..de263b4c 100644 --- a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx @@ -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({ + { + onClose?.(); + router.push("/workspace/skills/evolution"); + }} + > + + {t.settings.skills.goToEvolution} + + + + {tt.title} + + {tt.description} + + + router.push("/workspace/skills")} + > + + {tt.backToSkills} + + + + + + + + ); +} + +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 ( + + + + {tt.title} + {status && ( + + + {status.enabled ? tt.enabled : tt.disabled} + + {status.paused && ( + {tt.paused} + )} + + )} + + + {runCurator.isPending ? ( + + ) : ( + + )} + {tt.runNow} + + + + {isLoading ? ( + + {t.common.loading} + + ) : status ? ( + + + {status.run_count} + {tt.runCountUnit && ( + + {tt.runCountUnit} + + )} + + {lastRunDisplay} + + + {status.last_run_summary ?? tt.lastRunSummaryEmpty} + + + + + + {tt.total}: {status.agent_created_skills.total} + + + {tt.active}: {status.agent_created_skills.active} + + + {tt.stale}: {status.agent_created_skills.stale} + + + {tt.archived}: {status.agent_created_skills.archived} + + + + + ) : null} + + + ); +} + +function StatRow({ + label, + className, + children, +}: { + label: string; + className?: string; + children: React.ReactNode; +}) { + return ( + + {label} + {children} + + ); +} + +function ConfigCard() { + const { t } = useI18n(); + const tt = t.settings.skillEvolution.config; + const { config, isLoading } = useCuratorConfig(); + const updateConfig = useUpdateCuratorConfig(); + + const [draft, setDraft] = useState(null); + useEffect(() => { + if (config && !draft) { + setDraft(config); + } + }, [config, draft]); + + if (isLoading || !draft) { + return ( + + + {tt.title} + + + + {t.common.loading} + + + + ); + } + + 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 ( + + + {tt.title} + {tt.description} + + + + setDraft({ ...draft, enabled: v })} + /> + + + + setDraft({ + ...draft, + curator: { ...draft.curator, enabled: v }, + }) + } + /> + + + + setDraft({ + ...draft, + curator: { + ...draft.curator, + interval_hours: Math.max(1, Number(e.target.value) || 1), + }, + }) + } + /> + + + + setDraft({ + ...draft, + curator: { + ...draft.curator, + stale_after_days: Math.max(1, Number(e.target.value) || 1), + }, + }) + } + /> + + + + setDraft({ + ...draft, + curator: { + ...draft.curator, + archive_after_days: Math.max(1, Number(e.target.value) || 1), + }, + }) + } + /> + + + + setDraft({ + ...draft, + moderation_model_name: + e.target.value.trim() === "" ? null : e.target.value, + }) + } + /> + + + + config && setDraft(config)} + > + {tt.reset} + + + {updateConfig.isPending ? ( + + ) : ( + + )} + {tt.save} + + + + + ); +} + +function FieldRow({ + label, + helper, + children, +}: { + label: string; + helper?: string; + children: React.ReactNode; +}) { + return ( + + + {label} + {helper && ( + {helper} + )} + + {children} + + ); +} + +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 ( + + + + {tt.title} + {tt.description} + + + + + {tt.autoRefresh} + + void refetch()}> + + {tt.refresh} + + + + + {isLoading && records.length === 0 ? ( + + {t.common.loading} + + ) : records.length === 0 ? ( + {tt.empty} + ) : ( + + {records.map((record, idx) => ( + + + {formatTime(record.ts)} + + + {record.level} + + + {record.message} + + + ))} + + )} + + + ); +} + +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; + } +} diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx index f6dc0858..b5c96f58 100644 --- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx @@ -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() { + + + + + 技能 + + + { + const response = await apiFetch(`${getBackendBaseURL()}/api/curator/status`); + return response.json() as Promise; +} + +export async function getCuratorConfig(): Promise { + const response = await apiFetch(`${getBackendBaseURL()}/api/curator/config`); + return response.json() as Promise; +} + +export async function updateCuratorConfig( + payload: CuratorConfig, +): Promise { + const response = await apiFetch(`${getBackendBaseURL()}/api/curator/config`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + return response.json() as Promise; +} + +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 { + const response = await apiFetch(`${getBackendBaseURL()}/api/curator/logs`); + return response.json() as Promise; +} diff --git a/frontend-web/src/core/curator/hooks.ts b/frontend-web/src/core/curator/hooks.ts new file mode 100644 index 00000000..191641c3 --- /dev/null +++ b/frontend-web/src/core/curator/hooks.ts @@ -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, + }; +} diff --git a/frontend-web/src/core/curator/types.ts b/frontend-web/src/core/curator/types.ts new file mode 100644 index 00000000..6269e0d2 --- /dev/null +++ b/frontend-web/src/core/curator/types.ts @@ -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; +} diff --git a/frontend-web/src/core/i18n/locales/en-US.ts b/frontend-web/src/core/i18n/locales/en-US.ts index 25dc471b..daf01d8a 100644 --- a/frontend-web/src/core/i18n/locales/en-US.ts +++ b/frontend-web/src/core/i18n/locales/en-US.ts @@ -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: diff --git a/frontend-web/src/core/i18n/locales/types.ts b/frontend-web/src/core/i18n/locales/types.ts index 5551939c..9dafb71d 100644 --- a/frontend-web/src/core/i18n/locales/types.ts +++ b/frontend-web/src/core/i18n/locales/types.ts @@ -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; diff --git a/frontend-web/src/core/i18n/locales/zh-CN.ts b/frontend-web/src/core/i18n/locales/zh-CN.ts index 64d52a81..97895bb3 100644 --- a/frontend-web/src/core/i18n/locales/zh-CN.ts +++ b/frontend-web/src/core/i18n/locales/zh-CN.ts @@ -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: diff --git a/frontend-web/src/pages/SkillEvolutionPage.tsx b/frontend-web/src/pages/SkillEvolutionPage.tsx new file mode 100644 index 00000000..61b15e85 --- /dev/null +++ b/frontend-web/src/pages/SkillEvolutionPage.tsx @@ -0,0 +1,9 @@ +import { SkillEvolutionPanel } from "@/components/workspace/skills/skill-evolution-panel"; + +export default function SkillEvolutionPage() { + return ( + + + + ); +} diff --git a/frontend-web/src/pages/SkillsPage.tsx b/frontend-web/src/pages/SkillsPage.tsx new file mode 100644 index 00000000..08f9586a --- /dev/null +++ b/frontend-web/src/pages/SkillsPage.tsx @@ -0,0 +1,9 @@ +import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page"; + +export default function SkillsPage() { + return ( + + + + ); +} diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index 15b7eb1e..d2ef68b1 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -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({ } /> + + + + } + /> + + + + } + /> }
+ {tt.description} +