From 3d7511e70bea75945798af994dffa960cf46b330 Mon Sep 17 00:00:00 2001 From: "Tao Sun (from Dev Box)" Date: Wed, 29 Jul 2026 19:16:23 +0800 Subject: [PATCH] Add dev-only per-agent Skills panel (agent-skills step 4) Add a DEV-ONLY "Skills" tab to ChatView that lets developers toggle each agent's skill set at runtime and reload the gateway once, without editing code. The tab and panel are gated on import.meta.env.DEV so they are absent from production builds. - ChatView: extend viewMode union with "skills"; add a v-if="isDev" toggle button and render SkillsDevPanel when selected. - SkillsDevPanel: agent picker (catalog agents), 23 skill toggles reflecting config.agents.list[id].skills, select/clear all, Apply & reload with applying/applied/failed feedback. - main.ts: new skills:set-agent-skills IPC that validates the agent id and skill ids, writes openclaw.json atomically, and restarts the gateway with rollback on failure. Exposed via preload as skills.setAgentSkills. - agent-catalog: add pure isKnownSkillId / sanitizeAgentSkillIds helpers. - agent-personas: change catalog-skill seeding from authoritative overwrite to fill-if-missing so dev-edited skills survive gateway reloads. Fresh installs still seed the full catalog list. - i18n: add skills.dev.* keys in en-US and zh-CN. - Update/extend unit tests for the seeding change and new helpers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1fe75171-fea2-45ef-9401-bf8a4ea0f2ba --- desktop/renderer/env.d.ts | 1 + .../src/components/skills/SkillsDevPanel.vue | 344 ++++++++++++++++++ desktop/renderer/src/i18n/en-US.ts | 15 + desktop/renderer/src/i18n/zh-CN.ts | 15 + desktop/renderer/src/views/ChatView.vue | 17 +- desktop/src/agent-catalog.test.ts | 29 +- desktop/src/agent-catalog.ts | 31 ++ desktop/src/agent-personas.test.ts | 6 +- desktop/src/agent-personas.ts | 5 +- desktop/src/main.ts | 70 ++++ desktop/src/preload.ts | 5 + 11 files changed, 532 insertions(+), 6 deletions(-) create mode 100644 desktop/renderer/src/components/skills/SkillsDevPanel.vue diff --git a/desktop/renderer/env.d.ts b/desktop/renderer/env.d.ts index 5e83b3f..a3d3a41 100644 --- a/desktop/renderer/env.d.ts +++ b/desktop/renderer/env.d.ts @@ -131,6 +131,7 @@ interface OpenClawAPI { refresh(): Promise<{ builtin: SkillEntry[]; custom: SkillEntry[]; managed: SkillEntry[] }>; updateAllowlist(allowBundled: string[]): Promise; updateManagedEntries(entries: Record): Promise; + setAgentSkills(agentId: string, skillIds: string[]): Promise<{ agentId: string; skills: string[] }>; integrityCheck(): Promise; pendingIntegrityResult(): Promise; acceptIntegrityChanges(): Promise; diff --git a/desktop/renderer/src/components/skills/SkillsDevPanel.vue b/desktop/renderer/src/components/skills/SkillsDevPanel.vue new file mode 100644 index 0000000..91d4472 --- /dev/null +++ b/desktop/renderer/src/components/skills/SkillsDevPanel.vue @@ -0,0 +1,344 @@ + + + + + diff --git a/desktop/renderer/src/i18n/en-US.ts b/desktop/renderer/src/i18n/en-US.ts index 579c463..43b2a48 100644 --- a/desktop/renderer/src/i18n/en-US.ts +++ b/desktop/renderer/src/i18n/en-US.ts @@ -699,6 +699,21 @@ export default { "usage.noSessions": "No sessions in this time range", "usage.footer": "Data sourced from OpenClaw Gateway. Requires the gateway to be running.", + // ── Skills (dev-only panel) ── + "skills.dev.tab": "Skills", + "skills.dev.title": "Agent Skills (Dev)", + "skills.dev.subtitle": "Toggle which skills each agent can use, then apply and reload the gateway. Development builds only.", + "skills.dev.agent": "Agent", + "skills.dev.selectAll": "Select all", + "skills.dev.clearAll": "Clear all", + "skills.dev.enabledCount": "{count} of {total} enabled", + "skills.dev.loading": "Loading agent skills…", + "skills.dev.loadFailed": "Failed to load skills: {error}", + "skills.dev.apply": "Apply & reload", + "skills.dev.applying": "Applying…", + "skills.dev.applied": "Applied. Gateway reloaded.", + "skills.dev.applyFailed": "Failed to apply: {error}", + // ── Studio ── "studio.switchToStudio": "Studio", "studio.switchToChat": "Chat", diff --git a/desktop/renderer/src/i18n/zh-CN.ts b/desktop/renderer/src/i18n/zh-CN.ts index 5b23978..fbbdd41 100644 --- a/desktop/renderer/src/i18n/zh-CN.ts +++ b/desktop/renderer/src/i18n/zh-CN.ts @@ -672,6 +672,21 @@ export default { "usage.noSessions": "此时间范围内暂无会话", "usage.footer": "数据来源于 OpenClaw Gateway,需要 Gateway 运行中。", + // ── Skills (dev-only panel) ── + "skills.dev.tab": "技能", + "skills.dev.title": "智能体技能(开发)", + "skills.dev.subtitle": "切换每个智能体可使用的技能,然后应用并重载网关。仅限开发版本。", + "skills.dev.agent": "智能体", + "skills.dev.selectAll": "全选", + "skills.dev.clearAll": "清空", + "skills.dev.enabledCount": "已启用 {count} / {total}", + "skills.dev.loading": "正在加载智能体技能……", + "skills.dev.loadFailed": "加载技能失败:{error}", + "skills.dev.apply": "应用并重载", + "skills.dev.applying": "正在应用……", + "skills.dev.applied": "已应用,网关已重载。", + "skills.dev.applyFailed": "应用失败:{error}", + // ── Studio ── "studio.switchToStudio": "工作室", "studio.switchToChat": "对话", diff --git a/desktop/renderer/src/views/ChatView.vue b/desktop/renderer/src/views/ChatView.vue index 0f50b73..846afb0 100644 --- a/desktop/renderer/src/views/ChatView.vue +++ b/desktop/renderer/src/views/ChatView.vue @@ -16,6 +16,14 @@ > {{ t("studio.switchToStudio") }} + @@ -199,6 +207,11 @@ + + + @@ -215,6 +228,7 @@ import StudioGame from "@/components/studio/StudioGame.vue"; import StudioChatPanel from "@/components/studio/StudioChatPanel.vue"; import StudioStatusPanel from "@/components/studio/StudioStatusPanel.vue"; import ModelSetupDialog from "@/components/ModelSetupDialog.vue"; +import SkillsDevPanel from "@/components/skills/SkillsDevPanel.vue"; import { useRoute } from "vue-router"; const route = useRoute(); @@ -222,7 +236,8 @@ const chatStore = useChatStore(); const agentStore = useAgentStore(); const studioStore = useStudioStore(); -const viewMode = ref<"chat" | "studio">("chat"); +const viewMode = ref<"chat" | "studio" | "skills">("chat"); +const isDev = import.meta.env.DEV; const inputText = ref(""); const inputRef = ref(); const threadRef = ref(); diff --git a/desktop/src/agent-catalog.test.ts b/desktop/src/agent-catalog.test.ts index 33ae59b..62e81de 100644 --- a/desktop/src/agent-catalog.test.ts +++ b/desktop/src/agent-catalog.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { AGENT_CATALOG, ALL_SKILL_IDS, getAgentSkills } from "./agent-catalog"; +import { + AGENT_CATALOG, + ALL_SKILL_IDS, + getAgentSkills, + isKnownSkillId, + sanitizeAgentSkillIds, +} from "./agent-catalog"; const EXPECTED_SKILL_IDS = [ "1password", @@ -71,4 +77,25 @@ describe("agent catalog skills binding", () => { expect(getAgentSkills("coder")).toEqual(ALL_SKILL_IDS); expect(getAgentSkills("does-not-exist")).toEqual([]); }); + + it("recognizes known skill ids and rejects unknown ones", () => { + expect(isKnownSkillId("canvas")).toBe(true); + expect(isKnownSkillId("not-a-skill")).toBe(false); + }); + + it("sanitizes requested skill ids into sorted, de-duplicated known ids", () => { + expect(sanitizeAgentSkillIds(["canvas", "1password", "canvas"])).toEqual([ + "1password", + "canvas", + ]); + expect(sanitizeAgentSkillIds([])).toEqual([]); + }); + + it("throws when a requested skill id is unknown or malformed", () => { + expect(() => sanitizeAgentSkillIds(["canvas", "bogus"])).toThrow(/Unknown skill id/); + expect(() => sanitizeAgentSkillIds(["", "canvas"])).toThrow(/non-empty strings/); + expect(() => sanitizeAgentSkillIds("canvas" as unknown as string[])).toThrow( + /must be an array/, + ); + }); }); diff --git a/desktop/src/agent-catalog.ts b/desktop/src/agent-catalog.ts index 49e994a..72f4188 100644 --- a/desktop/src/agent-catalog.ts +++ b/desktop/src/agent-catalog.ts @@ -215,3 +215,34 @@ export function getAgentSkills(agentId: string): readonly string[] { const agent = AGENT_CATALOG.find((entry) => entry.id === agentId); return agent ? agent.skills : []; } + +const ALL_SKILL_ID_SET: ReadonlySet = new Set(ALL_SKILL_IDS); + +/** + * Returns true when the given id is one of the known catalog skill ids. + */ +export function isKnownSkillId(skillId: string): boolean { + return ALL_SKILL_ID_SET.has(skillId); +} + +/** + * Validates and normalizes a requested list of skill ids into a sorted, + * de-duplicated array of known catalog skill ids. Throws when the input is not + * an array, contains non-string / empty entries, or references an unknown id. + */ +export function sanitizeAgentSkillIds(skillIds: readonly string[]): string[] { + if (!Array.isArray(skillIds)) { + throw new Error("skillIds must be an array"); + } + const unique = new Set(); + for (const skillId of skillIds) { + if (typeof skillId !== "string" || skillId.length === 0) { + throw new Error("skillIds must contain only non-empty strings"); + } + if (!ALL_SKILL_ID_SET.has(skillId)) { + throw new Error(`Unknown skill id: ${skillId}`); + } + unique.add(skillId); + } + return [...unique].sort(); +} diff --git a/desktop/src/agent-personas.test.ts b/desktop/src/agent-personas.test.ts index 9dbee97..c024e17 100644 --- a/desktop/src/agent-personas.test.ts +++ b/desktop/src/agent-personas.test.ts @@ -254,7 +254,7 @@ describe("agent personas", () => { expect(getAgentSkills("main")).not.toContain("mutated"); }); - it("overwrites a stale skills value for a catalog-known agent", () => { + it("preserves an existing skills value for a catalog-known agent", () => { const stateDir = createStateDir(); const config = { agents: { @@ -265,8 +265,8 @@ describe("agent personas", () => { const result = ensureAgentPersonasConfig(config, stateDir); const mainEntry = agentList(config).find((entry) => entry.id === "main"); - expect(mainEntry?.skills).toEqual(getAgentSkills("main")); - expect(result.changed).toBe(true); + expect(mainEntry?.skills).toEqual(["stale-skill"]); + expect(result.changed).toBe(false); }); it("leaves a custom non-catalog agent without an injected skills field", () => { diff --git a/desktop/src/agent-personas.ts b/desktop/src/agent-personas.ts index 520092f..c756746 100644 --- a/desktop/src/agent-personas.ts +++ b/desktop/src/agent-personas.ts @@ -260,7 +260,10 @@ export function ensureAgentPersonasConfig( ); for (const entry of entries) { const catalogSkills = catalogSkillsById.get(entry.id); - if (catalogSkills !== undefined) { + // Seed catalog skills ONLY when the entry has no skills key yet + // (fill-if-missing). Existing skills arrays — including dev-edited ones from + // the runtime Skills panel — are preserved across gateway reloads. + if (catalogSkills !== undefined && !Object.hasOwn(entry, "skills")) { entry.skills = [...catalogSkills]; } } diff --git a/desktop/src/main.ts b/desktop/src/main.ts index d7086f4..1c31e19 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -90,6 +90,7 @@ import { type AgentPersona, type AgentRosterConfig, } from "./agent-personas"; +import { sanitizeAgentSkillIds } from "./agent-catalog"; /** * Normalize a directory path for comparison/storage. @@ -3133,6 +3134,75 @@ function registerIpcHandlers(): void { pendingIntegrityResult = null; }); + ipcMain.handle( + "skills:set-agent-skills", + async ( + _event, + agentId: string, + skillIds: string[], + ): Promise<{ agentId: string; skills: string[] }> => { + if (typeof agentId !== "string" || agentId.length === 0) { + throw new Error("A non-empty agentId is required"); + } + const skills = sanitizeAgentSkillIds(skillIds ?? []); + + const configPath = getConfigPath(); + const originalConfigText = fs.readFileSync(configPath, "utf-8"); + const config = readConfig(); + if (!config) throw new Error("OpenClaw configuration is unavailable"); + + const agents = config.agents; + if (!agents || !Array.isArray(agents.list)) { + throw new Error("No configured agents to update"); + } + const entry = agents.list.find( + (candidate: unknown): candidate is Record => + typeof candidate === "object" && + candidate !== null && + (candidate as { id?: unknown }).id === agentId, + ); + if (!entry) { + throw new Error(`Unknown agent "${agentId}"`); + } + entry.skills = skills; + + const alreadyRunning = await checkExistingGateway(gatewayPort); + const managedGateway = gatewaySpawnedByUs && gatewayProcess !== null; + if ( + requiresExternalGatewayStop( + alreadyRunning, + true, + gatewaySpawnedByUs, + gatewayProcess !== null, + ) + ) { + throw new Error( + "Cannot update agent skills while MicroClaw is connected to an externally managed Gateway", + ); + } + + let restartAttempted = false; + try { + writeConfigTextAtomically(JSON.stringify(config, null, 2)); + restartAttempted = true; + await restartManagedGateway(`Updating skills for agent ${agentId}`); + return { agentId, skills }; + } catch (error) { + try { + writeConfigTextAtomically(originalConfigText); + if (restartAttempted && managedGateway) { + await restartManagedGateway( + `Rolling back failed skills update for ${agentId}`, + ); + } + } catch { + // Preserve the original failure; rollback is best-effort. + } + throw error; + } + }, + ); + // --- Chat (WebSocket gateway protocol) --- ipcMain.handle( "chat:send-message", diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index c193cf3..7809b37 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -57,6 +57,11 @@ contextBridge.exposeInMainWorld("openclaw", { ipcRenderer.invoke("skills:update-allowlist", allowBundled), updateManagedEntries: (entries: Record) => ipcRenderer.invoke("skills:update-managed-entries", entries), + setAgentSkills: (agentId: string, skillIds: string[]) => + ipcRenderer.invoke("skills:set-agent-skills", agentId, skillIds) as Promise<{ + agentId: string; + skills: string[]; + }>, integrityCheck: () => ipcRenderer.invoke("skills:integrity-check"), pendingIntegrityResult: () => ipcRenderer.invoke("skills:pending-integrity-result"), acceptIntegrityChanges: () => ipcRenderer.invoke("skills:accept-integrity-changes"),