diff --git a/desktop/renderer/env.d.ts b/desktop/renderer/env.d.ts index cbd7be7..0173d40 100644 --- a/desktop/renderer/env.d.ts +++ b/desktop/renderer/env.d.ts @@ -121,7 +121,7 @@ interface OpenClawAPI { }; settings: { get(): Promise; - set(key: string, value: any): Promise; + set(key: K, value: AppSettings[K]): Promise; }; updates: { check(): Promise; @@ -167,6 +167,9 @@ interface OpenClawAPI { }; agents: { list(): Promise<{ agents?: { id: string; name: string; description?: string }[] }>; + add(agentId: string): Promise<{ + agents: { id: string; name: string; description?: string }[]; + }>; }; channels: { list(): Promise<{ diff --git a/desktop/renderer/src/App.vue b/desktop/renderer/src/App.vue index e5e9d3c..bc7f431 100644 --- a/desktop/renderer/src/App.vue +++ b/desktop/renderer/src/App.vue @@ -469,6 +469,7 @@ onMounted(async () => { } // Fetch scheduled tasks now that gateway is connected taskStore.fetchTasks(); + agentStore.fetchAgents(); // Refresh WeChat login status (important after gateway restart) gateway.refreshWeixinStatus(); }); @@ -503,6 +504,7 @@ onMounted(async () => { sessionStore.reconcileEmptySessions(chatStore.sessionKey, "main"); sessionStore.ensureSession(chatStore.sessionKey, "main"); chatStore.loadHistory(); + agentStore.fetchAgents(); } }); diff --git a/desktop/renderer/src/components/agent/AgentCard.vue b/desktop/renderer/src/components/agent/AgentCard.vue index e505b47..5e0f146 100644 --- a/desktop/renderer/src/components/agent/AgentCard.vue +++ b/desktop/renderer/src/components/agent/AgentCard.vue @@ -13,10 +13,8 @@
{{ tag }}
- {{ - t("agentMarket.added") - }} - @@ -35,7 +33,7 @@ defineProps<{ }>(); defineEmits<{ - toggle: [agentId: string]; + add: [agentId: string]; }>(); @@ -85,7 +83,6 @@ defineEmits<{ font-size: 12px; font-weight: 600; color: var(--success); - cursor: pointer; } .agent-card-avatar { diff --git a/desktop/renderer/src/i18n/en-US.ts b/desktop/renderer/src/i18n/en-US.ts index f29267a..a34c47a 100644 --- a/desktop/renderer/src/i18n/en-US.ts +++ b/desktop/renderer/src/i18n/en-US.ts @@ -732,11 +732,27 @@ export default { "agentMarket.pageTitle": "Popular Agents", "agentMarket.add": "Add", "agentMarket.added": "Added", + "agentMarket.addFailed": "Couldn't add the agent to OpenClaw. Please try again.", // ── Agent: quick tasks expand ── "agent.quickTasks": "Quick Tasks", - // ── Agents (mock data) ── + // ── Agents ── + "agent.masterArchive.name": "Master Archive", + "agent.masterArchive.desc": "Local file organization, batch conversion & document digests", + "agent.masterArchive.tag.1": "File Organization", + "agent.masterArchive.tag.2": "Batch Conversion", + "agent.masterArchive.tag.3": "Document Digests", + "agent.masterArchive.task.1.title": "Convert the Word files in this folder to PDF", + "agent.masterArchive.task.1.desc": + "Batch-convert matching documents while preserving the originals.", + "agent.masterArchive.task.2.title": "Organize this folder - show me the plan first", + "agent.masterArchive.task.2.desc": + "Inventory the files and preview a safe folder structure before moving anything.", + "agent.masterArchive.task.3.title": "Extract deadlines and action items from these documents", + "agent.masterArchive.task.3.desc": + "Consolidate owners, due dates, and source references into a clean checklist.", + "agent.coder.name": "Coder", "agent.coder.desc": "Code development, debugging & code review", "agent.coder.tag.1": "Development", diff --git a/desktop/renderer/src/i18n/zh-CN.ts b/desktop/renderer/src/i18n/zh-CN.ts index 16a6271..6ad5bb9 100644 --- a/desktop/renderer/src/i18n/zh-CN.ts +++ b/desktop/renderer/src/i18n/zh-CN.ts @@ -705,11 +705,24 @@ export default { "agentMarket.pageTitle": "热门 Agent", "agentMarket.add": "添加", "agentMarket.added": "已添加", + "agentMarket.addFailed": "无法将 Agent 添加到 OpenClaw,请重试。", // ── Agent: quick tasks expand ── "agent.quickTasks": "快捷任务", - // ── Agents (mock data) ── + // ── Agents ── + "agent.masterArchive.name": "归藏大师", + "agent.masterArchive.desc": "本地文件整理、批量转换与文档摘要", + "agent.masterArchive.tag.1": "文件整理", + "agent.masterArchive.tag.2": "批量转换", + "agent.masterArchive.tag.3": "文档摘要", + "agent.masterArchive.task.1.title": "把这个文件夹里的 Word 批量转成 PDF", + "agent.masterArchive.task.1.desc": "保留原文件,并批量转换符合条件的文档。", + "agent.masterArchive.task.2.title": "整理这个文件夹,先给我看方案", + "agent.masterArchive.task.2.desc": "先盘点文件并预览安全的目录结构,确认后再移动。", + "agent.masterArchive.task.3.title": "汇总这些文档中的截止日期和待办", + "agent.masterArchive.task.3.desc": "整理负责人、截止日期和来源,生成清晰的任务清单。", + "agent.coder.name": "程序猿", "agent.coder.desc": "代码开发、调试与代码审查", "agent.coder.tag.1": "代码开发", diff --git a/desktop/renderer/src/main.ts b/desktop/renderer/src/main.ts index 290745a..7f1cd84 100644 --- a/desktop/renderer/src/main.ts +++ b/desktop/renderer/src/main.ts @@ -36,9 +36,27 @@ if (isBrowserDev) { generateSnapshot: noopAsync, }, settings: { - get: () => Promise.resolve(null), + get: () => + Promise.resolve({ + language: "en-US", + autoStart: false, + startMinimized: false, + themeMode: "light", + accentColor: "#1e1f25", + privacyLevel: "balanced", + }), set: noopAsync, }, + agents: { + list: () => Promise.resolve({ agents: [] }), + add: (agentId: string) => + Promise.resolve({ + agents: [ + { id: "main", name: "Assistant" }, + { id: agentId, name: agentId }, + ], + }), + }, updates: { check: () => Promise.resolve({ diff --git a/desktop/renderer/src/stores/agents.test.ts b/desktop/renderer/src/stores/agents.test.ts new file mode 100644 index 0000000..9651630 --- /dev/null +++ b/desktop/renderer/src/stores/agents.test.ts @@ -0,0 +1,92 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { setLocale } from "@/i18n"; +import { useAgentStore } from "./agents"; + +describe("agent store", () => { + const agentsList = vi.fn(); + const agentsAdd = vi.fn(); + + beforeEach(() => { + setActivePinia(createPinia()); + setLocale("en-US"); + agentsList + .mockReset() + .mockResolvedValue({ agents: [{ id: "main", name: "Assistant" }] }); + agentsAdd.mockReset(); + window.openclaw = { + agents: { list: agentsList, add: agentsAdd }, + } as unknown as typeof window.openclaw; + }); + + it("shows only the OpenClaw roster in the sidebar", async () => { + const store = useAgentStore(); + + expect(store.agents.map((agent) => agent.id)).toEqual(["main"]); + + agentsList.mockResolvedValueOnce({ + agents: [ + { id: "main", name: "Assistant" }, + { id: "custom", name: "Custom Agent" }, + ], + }); + await store.fetchAgents(); + expect(store.agents.map((agent) => agent.id)).toEqual(["main", "custom"]); + }); + + it("adds Master Archive to OpenClaw before showing it in the sidebar", async () => { + const store = useAgentStore(); + const marketAgent = store.marketAgents.find( + (entry) => entry.id === "master-archive", + ); + + expect(marketAgent).toMatchObject({ + name: "Master Archive", + isAdded: false, + tags: ["File Organization", "Batch Conversion", "Document Digests"], + }); + expect(marketAgent?.quickTasks).toHaveLength(3); + + agentsAdd.mockResolvedValueOnce({ + agents: [ + { id: "main", name: "Assistant" }, + { id: "master-archive", name: "Master Archive" }, + ], + }); + await store.addAgent("master-archive"); + + expect(agentsAdd).toHaveBeenCalledWith("master-archive"); + expect(store.agents.map((agent) => agent.id)).toEqual([ + "main", + "master-archive", + ]); + expect( + store.marketAgents.find((agent) => agent.id === "master-archive")?.isAdded, + ).toBe(true); + }); + + it("keeps the roster unchanged when Add fails", async () => { + const store = useAgentStore(); + agentsAdd.mockRejectedValueOnce(new Error("Gateway restart failed")); + + await expect(store.addAgent("master-archive")).rejects.toThrow( + "Gateway restart failed", + ); + expect(store.agents.map((agent) => agent.id)).toEqual(["main"]); + expect( + store.marketAgents.find((agent) => agent.id === "master-archive")?.isAdded, + ).toBe(false); + }); + + it("preserves the last real roster when refresh fails", async () => { + const store = useAgentStore(); + agentsList.mockResolvedValueOnce({ + agents: [{ id: "custom", name: "Custom Agent" }], + }); + await store.fetchAgents(); + agentsList.mockRejectedValueOnce(new Error("Gateway not connected")); + await store.fetchAgents(); + + expect(store.agents.map((agent) => agent.id)).toEqual(["custom"]); + }); +}); diff --git a/desktop/renderer/src/stores/agents.ts b/desktop/renderer/src/stores/agents.ts index c48c660..ed3ea65 100644 --- a/desktop/renderer/src/stores/agents.ts +++ b/desktop/renderer/src/stores/agents.ts @@ -1,6 +1,7 @@ import { defineStore } from "pinia"; import { ref, computed } from "vue"; import { t, locale } from "../i18n"; +import { AGENT_CATALOG, type AgentCatalogEntry } from "../../../src/agent-catalog"; export interface QuickTask { title: string; @@ -18,16 +19,10 @@ export interface Agent { isAdded?: boolean; } -/** Raw agent definition — stores i18n keys, not translated strings. */ -interface AgentDef { +interface RuntimeAgent { id: string; - nameKey: string; - descKey: string; - avatar: string; - image: string; - tagKeys: string[]; - taskKeys: { titleKey: string; descKey: string }[]; - isAdded: boolean; + name: string; + description?: string; } // Static import of avatar assets @@ -42,180 +37,112 @@ function getAvatarUrl(filename: string): string { return avatarModules[key] || ""; } -/** Resolve an AgentDef into an Agent with translated strings (reactive via computed). */ -function resolveAgent(def: AgentDef): Agent { +/** Resolve a catalog entry into an Agent with translated strings. */ +function resolveCatalogAgent(def: AgentCatalogEntry, isAdded: boolean): Agent { return { id: def.id, name: t(def.nameKey), description: t(def.descKey), - avatar: def.avatar, - image: def.image, + avatar: getAvatarUrl(def.avatar), + image: getAvatarUrl(def.image), tags: def.tagKeys.map((k) => t(k)), quickTasks: def.taskKeys.map((tk) => ({ title: t(tk.titleKey), desc: t(tk.descKey), })), - isAdded: def.isAdded, + isAdded, }; } -const AGENT_DEFS: AgentDef[] = [ - { - id: "main", - nameKey: "store.defaultAgent", - descKey: "store.defaultAgentDesc", - avatar: getAvatarUrl("normal.png"), - image: getAvatarUrl("normal.png"), - tagKeys: [], - taskKeys: [ - { titleKey: "agent.main.task.1.title", descKey: "agent.main.task.1.desc" }, - { titleKey: "agent.main.task.2.title", descKey: "agent.main.task.2.desc" }, - { titleKey: "agent.main.task.3.title", descKey: "agent.main.task.3.desc" }, - ], - isAdded: true, - }, - { - id: "coder", - nameKey: "agent.coder.name", - descKey: "agent.coder.desc", - avatar: getAvatarUrl("程序猿.png"), - image: getAvatarUrl("Coder.png"), - tagKeys: ["agent.coder.tag.1", "agent.coder.tag.2", "agent.coder.tag.3"], - taskKeys: [ - { titleKey: "agent.coder.task.1.title", descKey: "agent.coder.task.1.desc" }, - { titleKey: "agent.coder.task.2.title", descKey: "agent.coder.task.2.desc" }, - { titleKey: "agent.coder.task.3.title", descKey: "agent.coder.task.3.desc" }, - ], - isAdded: true, - }, - { - id: "painter", - nameKey: "agent.painter.name", - descKey: "agent.painter.desc", - avatar: getAvatarUrl("梵高.png"), - image: getAvatarUrl("Painter.png"), - tagKeys: ["agent.painter.tag.1", "agent.painter.tag.2", "agent.painter.tag.3"], - taskKeys: [ - { titleKey: "agent.painter.task.1.title", descKey: "agent.painter.task.1.desc" }, - { titleKey: "agent.painter.task.2.title", descKey: "agent.painter.task.2.desc" }, - { titleKey: "agent.painter.task.3.title", descKey: "agent.painter.task.3.desc" }, - ], - isAdded: false, - }, - { - id: "master", - nameKey: "agent.master.name", - descKey: "agent.master.desc", - avatar: getAvatarUrl("大师.png"), - image: getAvatarUrl("Diviner.png"), - tagKeys: ["agent.master.tag.1", "agent.master.tag.2", "agent.master.tag.3"], - taskKeys: [ - { titleKey: "agent.master.task.1.title", descKey: "agent.master.task.1.desc" }, - { titleKey: "agent.master.task.2.title", descKey: "agent.master.task.2.desc" }, - { titleKey: "agent.master.task.3.title", descKey: "agent.master.task.3.desc" }, - ], - isAdded: false, - }, - { - id: "growth-hacker", - nameKey: "agent.growthHacker.name", - descKey: "agent.growthHacker.desc", - avatar: getAvatarUrl("增长黑客.png"), - image: getAvatarUrl("Scientist.png"), - tagKeys: ["agent.growthHacker.tag.1", "agent.growthHacker.tag.2", "agent.growthHacker.tag.3"], - taskKeys: [ - { titleKey: "agent.growthHacker.task.1.title", descKey: "agent.growthHacker.task.1.desc" }, - { titleKey: "agent.growthHacker.task.2.title", descKey: "agent.growthHacker.task.2.desc" }, - { titleKey: "agent.growthHacker.task.3.title", descKey: "agent.growthHacker.task.3.desc" }, - ], - isAdded: false, - }, - { - id: "leopard", - nameKey: "agent.leopard.name", - descKey: "agent.leopard.desc", - avatar: getAvatarUrl("金钱豹.png"), - image: getAvatarUrl("stock.png"), - tagKeys: ["agent.leopard.tag.1", "agent.leopard.tag.2", "agent.leopard.tag.3"], - taskKeys: [ - { titleKey: "agent.leopard.task.1.title", descKey: "agent.leopard.task.1.desc" }, - { titleKey: "agent.leopard.task.2.title", descKey: "agent.leopard.task.2.desc" }, - { titleKey: "agent.leopard.task.3.title", descKey: "agent.leopard.task.3.desc" }, - ], - isAdded: false, - }, - { - id: "singer", - nameKey: "agent.singer.name", - descKey: "agent.singer.desc", - avatar: getAvatarUrl("Singer.png"), - image: getAvatarUrl("Singer.png"), - tagKeys: ["agent.singer.tag.1", "agent.singer.tag.2", "agent.singer.tag.3"], - taskKeys: [ - { titleKey: "agent.singer.task.1.title", descKey: "agent.singer.task.1.desc" }, - { titleKey: "agent.singer.task.2.title", descKey: "agent.singer.task.2.desc" }, - { titleKey: "agent.singer.task.3.title", descKey: "agent.singer.task.3.desc" }, - ], - isAdded: false, - }, -]; +function normalizeRuntimeAgents(value: unknown): RuntimeAgent[] { + if (!Array.isArray(value)) return []; + return value.flatMap((candidate) => { + if ( + typeof candidate !== "object" || + candidate === null || + typeof (candidate as { id?: unknown }).id !== "string" + ) { + return []; + } + const id = (candidate as { id: string }).id; + const nameValue = (candidate as { name?: unknown }).name; + const descriptionValue = (candidate as { description?: unknown }).description; + return [ + { + id, + name: typeof nameValue === "string" && nameValue ? nameValue : id, + ...(typeof descriptionValue === "string" + ? { description: descriptionValue } + : {}), + }, + ]; + }); +} export const useAgentStore = defineStore("agents", () => { - const agentIsAdded = ref>( - Object.fromEntries(AGENT_DEFS.map((d) => [d.id, d.isAdded])), - ); - - const remoteAgents = ref([]); + const remoteAgents = ref([{ id: "main", name: "Assistant" }]); + const currentAgentId = ref("main"); + const pendingAgentAdds = new Set(); + const installedAgentIds = computed(() => new Set(remoteAgents.value.map((agent) => agent.id))); - /** Reactively translated agent list — re-evaluates when locale changes. */ const agents = computed(() => { - void locale.value; // explicit dependency on locale for reactivity - const localAgents = AGENT_DEFS.map((def) => ({ - ...resolveAgent(def), - isAdded: agentIsAdded.value[def.id] ?? def.isAdded, - })); - if (remoteAgents.value.length === 0) return localAgents; - // The gateway's agents.list only carries id/name, not the rich local - // metadata (avatars, tags, quick tasks). Keep the local persona for any - // id we know about and only append genuinely custom remote agents. - const localIds = new Set(localAgents.map((a) => a.id)); - const extraRemote = remoteAgents.value.filter((a) => !localIds.has(a.id)); - return [...localAgents, ...extraRemote]; + void locale.value; + return remoteAgents.value.map((runtimeAgent) => { + const catalogAgent = AGENT_CATALOG.find((agent) => agent.id === runtimeAgent.id); + if (catalogAgent) { + return resolveCatalogAgent(catalogAgent, true); + } + return { + ...runtimeAgent, + avatar: getAvatarUrl("normal.png"), + image: getAvatarUrl("normal.png"), + isAdded: true, + }; + }); }); - const currentAgentId = ref("main"); - - const addedAgents = computed(() => agents.value.filter((a) => a.isAdded)); - const marketAgents = computed(() => agents.value.filter((a) => a.id !== "main")); - - function toggleAgent(agentId: string) { - if (agentId in agentIsAdded.value) { - agentIsAdded.value[agentId] = !agentIsAdded.value[agentId]; - } - } + const marketAgents = computed(() => { + void locale.value; + return AGENT_CATALOG.filter((agent) => agent.id !== "main").map((agent) => + resolveCatalogAgent(agent, installedAgentIds.value.has(agent.id)), + ); + }); function selectAgent(agentId: string) { currentAgentId.value = agentId; } + async function addAgent(agentId: string) { + if (installedAgentIds.value.has(agentId) || pendingAgentAdds.has(agentId)) return; + pendingAgentAdds.add(agentId); + try { + const result = await window.openclaw.agents.add(agentId); + if (!Array.isArray(result.agents)) { + throw new Error("OpenClaw returned an invalid agent roster"); + } + remoteAgents.value = normalizeRuntimeAgents(result.agents); + } finally { + pendingAgentAdds.delete(agentId); + } + } + async function fetchAgents() { try { const result = await window.openclaw.agents.list(); - if (result.agents && result.agents.length > 0) { - remoteAgents.value = result.agents as Agent[]; + if (Array.isArray(result.agents)) { + remoteAgents.value = normalizeRuntimeAgents(result.agents); } - } catch { - // Gateway may not support agents.list yet — keep defaults + } catch (error) { + console.warn("[agents] Failed to refresh gateway agents:", error); } } return { agents, currentAgentId, - addedAgents, marketAgents, - toggleAgent, selectAgent, + addAgent, fetchAgents, }; }); diff --git a/desktop/renderer/src/views/AgentMarketView.vue b/desktop/renderer/src/views/AgentMarketView.vue index e936f11..c6825bf 100644 --- a/desktop/renderer/src/views/AgentMarketView.vue +++ b/desktop/renderer/src/views/AgentMarketView.vue @@ -9,7 +9,7 @@ v-for="agent in filteredAgents" :key="agent.id" :agent="agent" - @toggle="agentStore.toggleAgent" + @add="handleAdd" /> @@ -17,6 +17,7 @@