diff --git a/electron/main/index.ts b/electron/main/index.ts index 3c699b9cc..927c52530 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -1703,6 +1703,92 @@ function registerIpcHandlers() { } ); + // ======================== agent-templates (global Worker Agent templates) ======================== + const AGENT_TEMPLATES_FILE = 'agent-templates.json'; + type AgentTemplateIpcPayload = { + id: string; + name: string; + description: string; + tools: string[]; + mcp_tools: Record; + custom_model_config?: Record; + selected_tools_snapshot?: unknown[]; + updatedAt: number; + }; + + function getAgentTemplatesPath(userId: string): string { + return path.join(os.homedir(), '.eigent', userId, AGENT_TEMPLATES_FILE); + } + + async function loadAgentTemplates(userId: string): Promise<{ + version: number; + templates: AgentTemplateIpcPayload[]; + }> { + const configPath = getAgentTemplatesPath(userId); + const defaultData = { version: 1, templates: [] }; + if (!existsSync(configPath)) { + try { + await fsp.mkdir(path.dirname(configPath), { recursive: true }); + await fsp.writeFile( + configPath, + JSON.stringify(defaultData, null, 2), + 'utf-8' + ); + return defaultData; + } catch (error) { + log.error('Failed to create default agent-templates', error); + return defaultData; + } + } + try { + const content = await fsp.readFile(configPath, 'utf-8'); + const data = JSON.parse(content); + if (!Array.isArray(data.templates)) data.templates = []; + return { version: data.version ?? 1, templates: data.templates }; + } catch (error) { + log.error('Failed to load agent-templates', error); + return defaultData; + } + } + + async function saveAgentTemplates( + userId: string, + data: { version: number; templates: AgentTemplateIpcPayload[] } + ): Promise { + const configPath = getAgentTemplatesPath(userId); + await fsp.mkdir(path.dirname(configPath), { recursive: true }); + await fsp.writeFile(configPath, JSON.stringify(data, null, 2), 'utf-8'); + } + + ipcMain.handle('agent-templates-load', async (_event, userId: string) => { + try { + const data = await loadAgentTemplates(userId); + return { success: true, templates: data.templates }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log.error('agent-templates-load failed', error); + return { success: false, error: message, templates: [] }; + } + }); + + ipcMain.handle( + 'agent-templates-save', + async (_event, userId: string, templates: AgentTemplateIpcPayload[]) => { + try { + const current = await loadAgentTemplates(userId); + await saveAgentTemplates(userId, { + version: current.version, + templates, + }); + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log.error('agent-templates-save failed', error); + return { success: false, error: message }; + } + } + ); + // Initialize skills config for a user (ensures config file exists) ipcMain.handle('skill-config-init', async (_event, userId: string) => { try { diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 910a670d7..e0cf3b180 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -14,6 +14,8 @@ import { contextBridge, ipcRenderer, webUtils } from 'electron'; +type AgentTemplateIpcPayload = Record; + contextBridge.exposeInMainWorld('ipcRenderer', { on(...args: Parameters) { const [channel, listener] = args; @@ -201,6 +203,11 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('skill-config-update', userId, skillName, skillConfig), skillConfigDelete: (userId: string, skillName: string) => ipcRenderer.invoke('skill-config-delete', userId, skillName), + // Global Agent Templates (~/.eigent//agent-templates.json) + agentTemplatesLoad: (userId: string) => + ipcRenderer.invoke('agent-templates-load', userId), + agentTemplatesSave: (userId: string, templates: AgentTemplateIpcPayload[]) => + ipcRenderer.invoke('agent-templates-save', userId, templates), }); // --------- Preload scripts loading --------- diff --git a/src/pages/Agents/index.tsx b/src/pages/Agents/index.tsx index 97d34b194..ba89977fb 100644 --- a/src/pages/Agents/index.tsx +++ b/src/pages/Agents/index.tsx @@ -46,8 +46,8 @@ export default function Capabilities() { return (
-
-
+
+
({ @@ -59,14 +59,14 @@ export default function Capabilities() { } value={activeTab} onValueChange={handleTabChange} - className="min-h-0 gap-0 h-full w-full flex-1" + className="h-full min-h-0 w-full flex-1 gap-0" listClassName="w-full h-full overflow-y-auto" contentClassName="hidden" />
-
+
{activeTab === 'models' && } {activeTab === 'skills' && } {activeTab === 'memory' && } diff --git a/src/store/globalAgentTemplatesStore.ts b/src/store/globalAgentTemplatesStore.ts new file mode 100644 index 000000000..1970edd28 --- /dev/null +++ b/src/store/globalAgentTemplatesStore.ts @@ -0,0 +1,361 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +import { create } from 'zustand'; +import { useAuthStore } from './authStore'; +import { emailToUserId } from './userId'; + +/** + * Only platform + model id are persisted or exported. + * Do not store api_key, api_url, or extra_params in templates — use workspace / env at runtime. + */ +export type GlobalAgentTemplateCustomModelConfig = { + model_platform?: string; + model_type?: string; +}; + +export interface GlobalAgentTemplate { + id: string; + name: string; + description: string; + tools: string[]; + mcp_tools: Record | { mcpServers?: Record }; + custom_model_config?: GlobalAgentTemplateCustomModelConfig; + /** Serialized ToolSelect rows so “Create from template” restores MCP/local tool picks */ + selected_tools_snapshot?: unknown[]; + updatedAt: number; +} + +interface GlobalAgentTemplatesState { + templates: GlobalAgentTemplate[]; + isLoading: boolean; + lastError: string | null; + loadTemplates: () => Promise; + saveTemplates: (templates: GlobalAgentTemplate[]) => Promise; + addTemplate: ( + template: Omit + ) => Promise; + updateTemplate: ( + id: string, + patch: Partial + ) => Promise; + removeTemplate: (id: string) => Promise; + duplicateTemplate: (id: string) => Promise; + getTemplate: (id: string) => GlobalAgentTemplate | undefined; +} + +function generateId(): string { + return `tpl_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; +} + +function cloneValue(value: T): T { + if (typeof structuredClone === 'function') { + return structuredClone(value); + } + return value; +} + +let saveQueue: Promise = Promise.resolve(); + +function enqueueSave(operation: () => Promise): Promise { + const run = saveQueue.then(operation, operation); + saveQueue = run.then( + () => undefined, + () => undefined + ); + return run; +} + +/** Strip secrets and non-persisted fields from imported or legacy JSON. */ +export function sanitizeCustomModelConfigForPersistence( + cfg: unknown +): GlobalAgentTemplateCustomModelConfig | undefined { + if ( + cfg === null || + cfg === undefined || + typeof cfg !== 'object' || + Array.isArray(cfg) + ) { + return undefined; + } + const c = cfg as Record; + const out: GlobalAgentTemplateCustomModelConfig = {}; + if (typeof c.model_platform === 'string' && c.model_platform.trim()) { + out.model_platform = c.model_platform.trim(); + } + if (typeof c.model_type === 'string' && c.model_type.trim()) { + out.model_type = c.model_type.trim(); + } + return Object.keys(out).length ? out : undefined; +} + +export function sanitizeTemplateForPersistence( + t: GlobalAgentTemplate +): GlobalAgentTemplate { + return { + ...t, + custom_model_config: sanitizeCustomModelConfigForPersistence( + t.custom_model_config + ), + }; +} + +function normalizeMcpTools(mcp: unknown): GlobalAgentTemplate['mcp_tools'] { + if (mcp === undefined || mcp === null) return { mcpServers: {} }; + if (typeof mcp !== 'object' || Array.isArray(mcp)) return { mcpServers: {} }; + const obj = mcp as Record; + const servers = obj.mcpServers; + if ( + servers !== undefined && + servers !== null && + typeof servers === 'object' && + !Array.isArray(servers) + ) { + return { mcpServers: servers as Record }; + } + return { mcpServers: {} }; +} + +/** Validate JSON file content for “Import agent template” (export shape or backend-style). */ +export function parseImportedAgentTemplateJson( + data: unknown +): GlobalAgentTemplate | null { + if (data === null || typeof data !== 'object' || Array.isArray(data)) { + return null; + } + const o = data as Record; + const nameRaw = o.name; + if (typeof nameRaw !== 'string' || nameRaw.trim().length === 0) { + return null; + } + if (o.description !== undefined && typeof o.description !== 'string') { + return null; + } + const tools = o.tools; + if (tools !== undefined) { + if (!Array.isArray(tools) || !tools.every((x) => typeof x === 'string')) { + return null; + } + } + const mcp = o.mcp_tools; + if ( + mcp !== undefined && + (mcp === null || typeof mcp !== 'object' || Array.isArray(mcp)) + ) { + return null; + } + const cmc = o.custom_model_config; + if ( + cmc !== undefined && + (cmc === null || typeof cmc !== 'object' || Array.isArray(cmc)) + ) { + return null; + } + const snap = + o.selected_tools_snapshot !== undefined + ? o.selected_tools_snapshot + : o.selectedTools; + if (snap !== undefined && (snap === null || !Array.isArray(snap))) { + return null; + } + return sanitizeTemplateForPersistence({ + id: generateId(), + name: nameRaw.trim(), + description: typeof o.description === 'string' ? o.description : '', + tools: Array.isArray(tools) ? [...tools] : [], + mcp_tools: normalizeMcpTools(mcp), + custom_model_config: + cmc && typeof cmc === 'object' && !Array.isArray(cmc) + ? (cmc as GlobalAgentTemplate['custom_model_config']) + : undefined, + selected_tools_snapshot: Array.isArray(snap) ? cloneValue(snap) : undefined, + updatedAt: Date.now(), + }); +} + +function isPersistedTemplateRow(t: unknown): t is GlobalAgentTemplate { + if (t === null || typeof t !== 'object' || Array.isArray(t)) return false; + const o = t as Record; + if (typeof o.id !== 'string' || !o.id.trim()) return false; + if (typeof o.name !== 'string' || !o.name.trim()) return false; + if (o.description !== undefined && typeof o.description !== 'string') { + return false; + } + if (!Array.isArray(o.tools) || !o.tools.every((x) => typeof x === 'string')) { + return false; + } + if ( + o.mcp_tools === null || + typeof o.mcp_tools !== 'object' || + Array.isArray(o.mcp_tools) + ) { + return false; + } + if (typeof o.updatedAt !== 'number' || Number.isNaN(o.updatedAt)) + return false; + if ( + o.selected_tools_snapshot !== undefined && + !Array.isArray(o.selected_tools_snapshot) + ) { + return false; + } + return true; +} + +function sanitizePersistedTemplates(rows: unknown[]): GlobalAgentTemplate[] { + return rows.filter(isPersistedTemplateRow).map((r) => + sanitizeTemplateForPersistence({ + ...r, + name: r.name.trim(), + description: typeof r.description === 'string' ? r.description : '', + tools: [...r.tools], + mcp_tools: + typeof r.mcp_tools === 'object' && r.mcp_tools !== null + ? cloneValue(r.mcp_tools) + : { mcpServers: {} }, + selected_tools_snapshot: Array.isArray(r.selected_tools_snapshot) + ? cloneValue(r.selected_tools_snapshot) + : undefined, + }) + ); +} + +function hasAgentTemplatesApi(): boolean { + if (typeof window === 'undefined') return false; + const api = ( + window as unknown as { + electronAPI?: { + agentTemplatesLoad?: unknown; + agentTemplatesSave?: unknown; + }; + } + ).electronAPI; + return ( + typeof api?.agentTemplatesLoad === 'function' && + typeof api?.agentTemplatesSave === 'function' + ); +} + +export const useGlobalAgentTemplatesStore = create()( + (set, get) => ({ + templates: [], + isLoading: false, + lastError: null, + + loadTemplates: async () => { + if (!hasAgentTemplatesApi()) return; + const userId = emailToUserId(useAuthStore.getState().email); + if (!userId) return; + set({ isLoading: true, lastError: null }); + try { + const result = await window.electronAPI.agentTemplatesLoad(userId); + if (result.success && result.templates) { + const raw = result.templates as unknown[]; + const cleaned = sanitizePersistedTemplates(raw); + set({ templates: cleaned }); + if (cleaned.length !== raw.length) { + await window.electronAPI.agentTemplatesSave(userId, cleaned); + } + } else { + set({ lastError: result.error ?? 'Failed to load agent templates' }); + } + } catch (error) { + console.error('[GlobalAgentTemplates] Load failed:', error); + set({ lastError: 'Failed to load agent templates' }); + } finally { + set({ isLoading: false }); + } + }, + + saveTemplates: async (templates: GlobalAgentTemplate[]) => { + if (!hasAgentTemplatesApi()) return false; + const userId = emailToUserId(useAuthStore.getState().email); + if (!userId) return false; + const safe = templates.map(sanitizeTemplateForPersistence); + return enqueueSave(async () => { + try { + const result = await window.electronAPI.agentTemplatesSave( + userId, + safe + ); + if (result.success) { + set({ templates: safe, lastError: null }); + } else { + set({ + lastError: result.error ?? 'Failed to save agent templates', + }); + } + return result.success ?? false; + } catch (error) { + console.error('[GlobalAgentTemplates] Save failed:', error); + set({ lastError: 'Failed to save agent templates' }); + return false; + } + }); + }, + + addTemplate: async (template) => { + const tpl = sanitizeTemplateForPersistence({ + ...template, + id: generateId(), + updatedAt: Date.now(), + mcp_tools: template.mcp_tools ?? { mcpServers: {} }, + }); + const templates = [...get().templates, tpl]; + const ok = await get().saveTemplates(templates); + return ok ? tpl : null; + }, + + updateTemplate: async (id: string, patch: Partial) => { + const templates = get().templates.map((t) => { + if (t.id !== id) return t; + return sanitizeTemplateForPersistence({ + ...t, + ...patch, + updatedAt: Date.now(), + }); + }); + return get().saveTemplates(templates); + }, + + removeTemplate: async (id: string) => { + const templates = get().templates.filter((t) => t.id !== id); + return get().saveTemplates(templates); + }, + + duplicateTemplate: async (id: string) => { + const t = get().templates.find((x) => x.id === id); + if (!t) return null; + const copy = sanitizeTemplateForPersistence({ + ...cloneValue(t), + id: generateId(), + name: `${t.name} (copy)`, + updatedAt: Date.now(), + }); + const templates = [...get().templates, copy]; + const ok = await get().saveTemplates(templates); + return ok ? copy : null; + }, + + getTemplate: (id: string) => get().templates.find((t) => t.id === id), + }) +); + +export function getGlobalAgentTemplatesStore() { + return useGlobalAgentTemplatesStore.getState(); +} + +export function hasGlobalAgentTemplatesApi(): boolean { + return hasAgentTemplatesApi(); +} diff --git a/src/store/skillsStore.ts b/src/store/skillsStore.ts index 51a5916e4..6dde69723 100644 --- a/src/store/skillsStore.ts +++ b/src/store/skillsStore.ts @@ -21,16 +21,7 @@ import { import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { useAuthStore } from './authStore'; - -// Helper function to normalize email to user_id format -// Matches the logic in backend's file_save_path -function emailToUserId(email: string | null): string | null { - if (!email) return null; - return email - .split('@')[0] - .replace(/[\\/*?:"<>|\s]/g, '_') - .replace(/^\.+|\.+$/g, ''); -} +import { emailToUserId } from './userId'; // Skill scope interface export interface SkillScope { diff --git a/src/store/userId.ts b/src/store/userId.ts new file mode 100644 index 000000000..8e7e7c863 --- /dev/null +++ b/src/store/userId.ts @@ -0,0 +1,21 @@ +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= + +export function emailToUserId(email: string | null): string | null { + if (!email) return null; + return email + .split('@')[0] + .replace(/[\\/*?:"<>|\s]/g, '_') + .replace(/^\.+|\.+$/g, ''); +} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 778f4a51c..c9265c978 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -22,6 +22,17 @@ interface IpcRenderer { invoke: (channel: string, ...args: any[]) => Promise; } +type AgentTemplateIpcPayload = { + id: string; + name: string; + description: string; + tools: string[]; + mcp_tools: Record; + custom_model_config?: Record; + selected_tools_snapshot?: unknown[]; + updatedAt: number; +}; + interface ElectronAPI { closeWindow: () => void; minimizeWindow: () => void; @@ -207,6 +218,16 @@ interface ElectronAPI { userId: string, skillName: string ) => Promise<{ success: boolean; error?: string }>; + // Global Agent Templates + agentTemplatesLoad: (userId: string) => Promise<{ + success: boolean; + templates?: AgentTemplateIpcPayload[]; + error?: string; + }>; + agentTemplatesSave: ( + userId: string, + templates: AgentTemplateIpcPayload[] + ) => Promise<{ success: boolean; error?: string }>; setBrowserPort: (port: number, isExternal?: boolean) => Promise; getBrowserPort: () => Promise; getCdpBrowsers: () => Promise;