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"),