diff --git a/electron/main/index.ts b/electron/main/index.ts index 1b9d5da40..39ac09784 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -1688,6 +1688,88 @@ function registerIpcHandlers() { } ); + // ======================== agent-templates (global Worker Agent templates) ======================== + const AGENT_TEMPLATES_FILE = 'agent-templates.json'; + + function getAgentTemplatesPath(userId: string): string { + return path.join(os.homedir(), '.eigent', userId, AGENT_TEMPLATES_FILE); + } + + async function loadAgentTemplates(userId: string): Promise<{ + version: number; + templates: Array<{ + id: string; + name: string; + description: string; + tools: string[]; + mcp_tools: any; + custom_model_config?: any; + updatedAt: number; + }>; + }> { + 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: any) { + 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: any) { + log.error('Failed to load agent-templates', error); + return defaultData; + } + } + + async function saveAgentTemplates( + userId: string, + data: { version: number; templates: any[] } + ): 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: any) { + log.error('agent-templates-load failed', error); + return { success: false, error: error?.message, templates: [] }; + } + }); + + ipcMain.handle( + 'agent-templates-save', + async (_event, userId: string, templates: any[]) => { + try { + const current = await loadAgentTemplates(userId); + await saveAgentTemplates(userId, { + version: current.version, + templates, + }); + return { success: true }; + } catch (error: any) { + log.error('agent-templates-save failed', error); + return { success: false, error: 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..2668fc1c2 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -201,6 +201,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: any[]) => + ipcRenderer.invoke('agent-templates-save', userId, templates), }); // --------- Preload scripts loading --------- diff --git a/src/components/AddWorker/index.tsx b/src/components/AddWorker/index.tsx index 64fc05f49..4e43cafbd 100644 --- a/src/components/AddWorker/index.tsx +++ b/src/components/AddWorker/index.tsx @@ -35,9 +35,22 @@ import { Textarea } from '@/components/ui/textarea'; import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; import { INIT_PROVODERS } from '@/lib/llm'; import { useAuthStore, useWorkerList } from '@/store/authStore'; -import { Bot, ChevronDown, ChevronUp, Edit, Eye, EyeOff } from 'lucide-react'; -import { useRef, useState } from 'react'; +import { + hasGlobalAgentTemplatesApi, + useGlobalAgentTemplatesStore, +} from '@/store/globalAgentTemplatesStore'; +import { + Bot, + ChevronDown, + ChevronUp, + Download, + Edit, + Eye, + EyeOff, +} from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; import ToolSelect from './ToolSelect'; interface EnvValue { @@ -62,18 +75,51 @@ interface McpItem { mcp_name?: string; } +/** Restore ToolSelect rows from template export / disk (drops invalid entries). */ +function parseSelectedToolsSnapshot(raw: unknown): McpItem[] { + if (!Array.isArray(raw)) return []; + const out: McpItem[] = []; + for (const item of raw) { + if (!item || typeof item !== 'object' || Array.isArray(item)) continue; + const o = item as Record; + if ( + typeof o.id !== 'number' || + typeof o.name !== 'string' || + typeof o.key !== 'string' + ) { + continue; + } + out.push({ + id: o.id, + name: o.name, + key: o.key, + description: typeof o.description === 'string' ? o.description : '', + category: o.category as McpItem['category'], + home_page: typeof o.home_page === 'string' ? o.home_page : undefined, + install_command: o.install_command as McpItem['install_command'], + toolkit: typeof o.toolkit === 'string' ? o.toolkit : undefined, + isLocal: o.isLocal === true, + mcp_name: typeof o.mcp_name === 'string' ? o.mcp_name : undefined, + }); + } + return out; +} + export function AddWorker({ edit = false, workerInfo = null, variant: _variant = 'default', isOpen, onOpenChange, + initialTemplateId = null, }: { edit?: boolean; workerInfo?: Agent | null; variant?: 'default' | 'icon'; isOpen?: boolean; onOpenChange?: (open: boolean) => void; + /** When opening from workforce "Add ▼ template", prefills name/description/model from disk */ + initialTemplateId?: string | null; }) { const { t } = useTranslation(); const [internalOpen, setInternalOpen] = useState(false); @@ -94,8 +140,9 @@ export function AddWorker({ const toolSelectRef = useRef<{ installMcp: (id: number, env?: any, activeMcp?: any) => Promise; } | null>(null); - const { email, setWorkerList } = useAuthStore(); + const { email, setWorkerList, modelType } = useAuthStore(); const workerList = useWorkerList(); + const useWorkspaceCloudModel = modelType === 'cloud'; // save AddWorker form data const [workerName, setWorkerName] = useState(''); const [workerDescription, setWorkerDescription] = useState(''); @@ -110,13 +157,113 @@ export function AddWorker({ const [customModelPlatform, setCustomModelPlatform] = useState(''); const [customModelType, setCustomModelType] = useState(''); - if (!chatStore) { - return null; - } + const [saveAsGlobalTemplate, setSaveAsGlobalTemplate] = useState(false); + const { addTemplate: addGlobalTemplate, getTemplate: getGlobalTemplate } = + useGlobalAgentTemplatesStore(); + const hasGlobalTemplatesApi = hasGlobalAgentTemplatesApi(); + + const resetForm = useCallback(() => { + setWorkerName(''); + setWorkerDescription(''); + setSelectedTools([]); + setShowEnvConfig(false); + setActiveMcp(null); + setEnvValues({}); + setSecretVisible({}); + setNameError(''); + setShowModelConfig(false); + setUseCustomModel(false); + setCustomModelPlatform(''); + setCustomModelType(''); + setSaveAsGlobalTemplate(false); + }, []); + + useEffect(() => { + if (!dialogOpen || edit) return; + resetForm(); + if (!initialTemplateId) return; + const tpl = getGlobalTemplate(initialTemplateId); + if (!tpl) return; + setWorkerName(tpl.name); + setWorkerDescription(tpl.description); + setSelectedTools(parseSelectedToolsSnapshot(tpl.selected_tools_snapshot)); + if (tpl.custom_model_config && !useWorkspaceCloudModel) { + setUseCustomModel(true); + setShowModelConfig(true); + setCustomModelPlatform(tpl.custom_model_config.model_platform ?? ''); + setCustomModelType(tpl.custom_model_config.model_type ?? ''); + } + }, [ + dialogOpen, + edit, + initialTemplateId, + getGlobalTemplate, + resetForm, + useWorkspaceCloudModel, + ]); + + const handleExportConfig = useCallback(() => { + const localTool: string[] = []; + const mcpList: string[] = []; + selectedTools.forEach((tool: McpItem) => { + if (tool.isLocal) { + localTool.push(tool.toolkit as string); + } else { + mcpList.push(tool?.key || tool?.mcp_name || ''); + } + }); + let mcpLocal: Record = { mcpServers: {} }; + selectedTools.forEach((tool: McpItem) => { + if (!tool.isLocal && tool.key) { + (mcpLocal.mcpServers as Record)[tool.key] = {}; + } + }); + const custom_model_config = + !useWorkspaceCloudModel && useCustomModel && customModelPlatform + ? { + model_platform: customModelPlatform, + model_type: customModelType || undefined, + } + : undefined; + const blob = new Blob( + [ + JSON.stringify( + { + name: workerName, + description: workerDescription, + tools: localTool, + mcp_tools: mcpLocal, + custom_model_config, + selected_tools_snapshot: JSON.parse(JSON.stringify(selectedTools)), + }, + null, + 2 + ), + ], + { type: 'application/json' } + ); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `agent-${workerName || 'config'}.json`; + a.click(); + URL.revokeObjectURL(url); + toast.success(t('workforce.export-agent-success')); + }, [ + selectedTools, + workerName, + workerDescription, + useCustomModel, + customModelPlatform, + customModelType, + useWorkspaceCloudModel, + selectedTools, + t, + ]); const activeProjectId = projectStore.activeProjectId; - const activeTaskId = chatStore.activeTaskId; - const tasks = chatStore.tasks; + const activeTaskId = chatStore?.activeTaskId; + const tasks = chatStore?.tasks; // environment variable management const initializeEnvValues = (mcp: McpItem) => { @@ -256,21 +403,6 @@ export function AddWorker({ setSelectedTools(tools); }; - const resetForm = () => { - setWorkerName(''); - setWorkerDescription(''); - setSelectedTools([]); - setShowEnvConfig(false); - setActiveMcp(null); - setEnvValues({}); - setSecretVisible({}); - setNameError(''); - setShowModelConfig(false); - setUseCustomModel(false); - setCustomModelPlatform(''); - setCustomModelType(''); - }; - // tool function const getCategoryIcon = (categoryName?: string) => { if (!categoryName) return ; @@ -374,9 +506,8 @@ export function AddWorker({ }; setWorkerList([...workerList, worker]); } else { - // Build custom model config if custom model is enabled const customModelConfig = - useCustomModel && customModelPlatform + !useWorkspaceCloudModel && useCustomModel && customModelPlatform ? { model_platform: customModelPlatform, model_type: customModelType || undefined, @@ -409,6 +540,25 @@ export function AddWorker({ setWorkerList([...workerList, worker]); } + if (saveAsGlobalTemplate && hasGlobalTemplatesApi) { + const customModelConfig = + !useWorkspaceCloudModel && useCustomModel && customModelPlatform + ? { + model_platform: customModelPlatform, + model_type: customModelType || undefined, + } + : undefined; + await addGlobalTemplate({ + name: workerName, + description: workerDescription, + tools: localTool, + mcp_tools: mcpLocal, + custom_model_config: customModelConfig, + selected_tools_snapshot: JSON.parse(JSON.stringify(selectedTools)), + }); + toast.success(t('agents.global-agent-template-saved')); + } + setDialogOpen(false); // reset form @@ -598,8 +748,23 @@ export function AddWorker({ placeholder={t('layout.im-an-agent-specially-designed-for')} value={workerDescription} onChange={(e) => setWorkerDescription(e.target.value)} + className="min-h-[120px] resize-y" /> + {edit && ( +
+ +
+ )} + - {/* Model Configuration Section */} -
- - - {showModelConfig && ( -
- - - {useCustomModel && ( - <> -
- - -
- -
- - - setCustomModelType(e.target.value) - } - /> -
- - )} + {selectedTools.length > 0 && ( +
+
+ {t('workforce.agent-tool')} ({selectedTools.length})
- )} -
+
+ {selectedTools.map((tool, idx) => ( + + {tool.name || + tool.mcp_name || + tool.key || + `Tool ${idx + 1}`} + + ))} +
+
+ )} + + {hasGlobalTemplatesApi && !edit && ( +
+ +

+ {t('agents.global-agents-import-hint')} +

+
+ )} + + {!useWorkspaceCloudModel && ( +
+ + + {showModelConfig && ( +
+

+ {t('workforce.advanced-model-config-hint')} +

+ + + {useCustomModel && ( + <> +
+ + +
+ +
+ + + setCustomModelType(e.target.value) + } + /> +
+ + )} +
+ )} +
+ )} [w.type, w.name].filter(Boolean) as string[]) + ); + let candidate = `${baseName} (copy)`; + let i = 2; + while (used.has(candidate)) { + candidate = `${baseName} (copy ${i})`; + i += 1; + } + return candidate; +} + interface NodeProps { id: string; data: { @@ -456,6 +473,50 @@ export function Node({ id, data }: NodeProps) { workerInfo={data.agent as Agent} /> + + + + +
+ + {isLoading ? ( +
+ + Loading… +
+ ) : templates.length === 0 ? ( +
+

+ {t('agents.global-agents-empty')} +

+
+ ) : ( +
+ {templates.map((template) => ( +
+
+
+ {template.name} +
+
+ {template.description || '—'} +
+
+ + {t('agents.global-agent-tools-count', { + count: template.tools?.length ?? 0, + })} + + + {t('agents.global-agent-last-edited')}:{' '} + {formatDate(template.updatedAt)} + +
+
+ + + + + + { + const copy = await duplicateTemplate(template.id); + if (copy) { + toast.success( + t('agents.global-agent-duplicate-success') + ); + } + }} + > + + {t('agents.global-agent-duplicate')} + + exportTemplate(template)}> + + {t('agents.global-agent-export-file')} + + setDeleteId(template.id)} + > + + {t('agents.global-agent-delete')} + + + +
+ ))} +
+ )} + + setDeleteId(null)} + onConfirm={async () => { + if (deleteId) { + await removeTemplate(deleteId); + setDeleteId(null); + toast.success(t('agents.global-agent-delete-success')); + } + }} + title={t('agents.delete-skill')} + message={t('agents.delete-skill-confirmation', { + name: deleteId + ? (templates.find((x) => x.id === deleteId)?.name ?? '') + : '', + })} + confirmText={t('layout.delete')} + cancelText={t('workforce.cancel')} + /> + + ); +} diff --git a/src/pages/Agents/index.tsx b/src/pages/Agents/index.tsx index 97d34b194..9d4130f9d 100644 --- a/src/pages/Agents/index.tsx +++ b/src/pages/Agents/index.tsx @@ -17,6 +17,7 @@ import VerticalNavigation, { } from '@/components/Navigation'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import GlobalAgents from './GlobalAgents'; import Memory from './Memory'; import Models from './Models'; import Skills from './Skills'; @@ -26,6 +27,10 @@ export default function Capabilities() { const [activeTab, setActiveTab] = useState('models'); const menuItems = [ + { + id: 'global-agents', + name: t('agents.global-agents'), + }, { id: 'models', name: t('setting.models'), @@ -66,7 +71,8 @@ export default function Capabilities() {
-
+
+ {activeTab === 'global-agents' && } {activeTab === 'models' && } {activeTab === 'skills' && } {activeTab === 'memory' && } diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index f3d7aaf25..fde812f1c 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -31,14 +31,33 @@ import { } from '@/components/MenuButton/MenuButton'; import { TriggerDialog } from '@/components/Trigger/TriggerDialog'; import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { useAuthStore } from '@/store/authStore'; +import { + hasGlobalAgentTemplatesApi, + useGlobalAgentTemplatesStore, +} from '@/store/globalAgentTemplatesStore'; import { usePageTabStore } from '@/store/pageTabStore'; import { useTriggerStore, WebSocketConnectionStatus, } from '@/store/triggerStore'; -import { Inbox, LayoutGrid, Plus, RefreshCw, Zap, ZapOff } from 'lucide-react'; +import { + ChevronDown, + Inbox, + LayoutGrid, + Plus, + RefreshCw, + Zap, + ZapOff, +} from 'lucide-react'; import Overview from './Project/Triggers'; import BottomBar from '@/components/BottomBar'; @@ -137,6 +156,13 @@ export default function Home() { const [activeWebviewId, setActiveWebviewId] = useState(null); const [isChatBoxVisible, setIsChatBoxVisible] = useState(true); const [addWorkerDialogOpen, setAddWorkerDialogOpen] = useState(false); + const [pendingWorkerTemplateId, setPendingWorkerTemplateId] = useState< + string | null + >(null); + const { + templates: globalAgentTemplates, + loadTemplates: loadGlobalAgentTemplates, + } = useGlobalAgentTemplatesStore(); const [triggerDialogOpen, setTriggerDialogOpen] = useState(false); const fileInputRef = useRef(null); @@ -186,6 +212,10 @@ export default function Home() { checkLocalServerStale(); }, []); + useEffect(() => { + if (hasGlobalAgentTemplatesApi()) loadGlobalAgentTemplates(); + }, [loadGlobalAgentTemplates]); + // Detect files and triggers when project loads useEffect(() => { const detectAgentFiles = async () => { @@ -603,24 +633,66 @@ export default function Home() {
- {activeWorkspaceTab !== 'inbox' && ( + {activeWorkspaceTab === 'workforce' && ( +
+ + {hasGlobalAgentTemplatesApi() && + globalAgentTemplates.length > 0 && ( + + + + + + + {t( + 'agents.global-agent-create-from-template' + )} + + {globalAgentTemplates.map((tpl) => ( + { + setPendingWorkerTemplateId(tpl.id); + setAddWorkerDialogOpen(true); + }} + > + {tpl.name} + + ))} + + + )} +
+ )} + {activeWorkspaceTab === 'triggers' && ( )}
@@ -637,7 +709,11 @@ export default function Home() { {/* AddWorker Dialog */} { + setAddWorkerDialogOpen(open); + if (!open) setPendingWorkerTemplateId(null); + }} + initialTemplateId={pendingWorkerTemplateId} /> {/* TriggerDialog */} @@ -763,24 +839,66 @@ export default function Home() {
- {activeWorkspaceTab !== 'inbox' && ( + {activeWorkspaceTab === 'workforce' && ( +
+ + {hasGlobalAgentTemplatesApi() && + globalAgentTemplates.length > 0 && ( + + + + + + + {t( + 'agents.global-agent-create-from-template' + )} + + {globalAgentTemplates.map((tpl) => ( + { + setPendingWorkerTemplateId(tpl.id); + setAddWorkerDialogOpen(true); + }} + > + {tpl.name} + + ))} + + + )} +
+ )} + {activeWorkspaceTab === 'triggers' && ( )}
@@ -796,7 +914,11 @@ export default function Home() { {/* AddWorker Dialog */} { + setAddWorkerDialogOpen(open); + if (!open) setPendingWorkerTemplateId(null); + }} + initialTemplateId={pendingWorkerTemplateId} /> {/* TriggerDialog */} diff --git a/src/store/globalAgentTemplatesStore.ts b/src/store/globalAgentTemplatesStore.ts new file mode 100644 index 000000000..59245455c --- /dev/null +++ b/src/store/globalAgentTemplatesStore.ts @@ -0,0 +1,285 @@ +// ========= 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'; + +function emailToUserId(email: string | null): string | null { + if (!email) return null; + return email + .split('@')[0] + .replace(/[\\/*?:"<>|\s]/g, '_') + .replace(/^\.+|\.+$/g, ''); +} + +export interface GlobalAgentTemplate { + id: string; + name: string; + description: string; + tools: string[]; + mcp_tools: Record | { mcpServers?: Record }; + custom_model_config?: { + model_platform?: string; + model_type?: string; + api_key?: string; + api_url?: string; + extra_params?: Record; + }; + /** Serialized ToolSelect rows so “Create from template” restores MCP/local tool picks */ + selected_tools_snapshot?: unknown[]; + updatedAt: number; +} + +interface GlobalAgentTemplatesState { + templates: GlobalAgentTemplate[]; + isLoading: boolean; + 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 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 { + 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) + ? JSON.parse(JSON.stringify(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) => ({ + ...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 + ? JSON.parse(JSON.stringify(r.mcp_tools)) + : { mcpServers: {} }, + selected_tools_snapshot: Array.isArray(r.selected_tools_snapshot) + ? JSON.parse(JSON.stringify(r.selected_tools_snapshot)) + : undefined, + })); +} + +function hasAgentTemplatesApi(): boolean { + return ( + typeof window !== 'undefined' && + !!(window as unknown as { electronAPI?: { agentTemplatesLoad?: unknown } }) + .electronAPI?.agentTemplatesLoad + ); +} + +export const useGlobalAgentTemplatesStore = create()( + (set, get) => ({ + templates: [], + isLoading: false, + + loadTemplates: async () => { + if (!hasAgentTemplatesApi()) return; + const userId = emailToUserId(useAuthStore.getState().email); + if (!userId) return; + set({ isLoading: true }); + 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); + } + } + } catch (error) { + console.error('[GlobalAgentTemplates] Load failed:', error); + } finally { + set({ isLoading: false }); + } + }, + + saveTemplates: async (templates: GlobalAgentTemplate[]) => { + if (!hasAgentTemplatesApi()) return false; + const userId = emailToUserId(useAuthStore.getState().email); + if (!userId) return false; + try { + const result = await window.electronAPI.agentTemplatesSave( + userId, + templates + ); + if (result.success) set({ templates }); + return result.success ?? false; + } catch (error) { + console.error('[GlobalAgentTemplates] Save failed:', error); + return false; + } + }, + + addTemplate: async (template) => { + const tpl: GlobalAgentTemplate = { + ...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) => + t.id === id ? { ...t, ...patch, updatedAt: Date.now() } : t + ); + 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: GlobalAgentTemplate = { + ...JSON.parse(JSON.stringify(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/types/electron.d.ts b/src/types/electron.d.ts index 778f4a51c..5b5a68b36 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -207,6 +207,24 @@ interface ElectronAPI { userId: string, skillName: string ) => Promise<{ success: boolean; error?: string }>; + // Global Agent Templates + agentTemplatesLoad: (userId: string) => Promise<{ + success: boolean; + templates?: Array<{ + id: string; + name: string; + description: string; + tools: string[]; + mcp_tools: any; + custom_model_config?: any; + updatedAt: number; + }>; + error?: string; + }>; + agentTemplatesSave: ( + userId: string, + templates: any[] + ) => Promise<{ success: boolean; error?: string }>; setBrowserPort: (port: number, isExternal?: boolean) => Promise; getBrowserPort: () => Promise; getCdpBrowsers: () => Promise;