import { create } from 'zustand'; import { DEFAULT_GATEWAY_URL, FALLBACK_GATEWAY_URLS, GatewayClient, ConnectionState, getGatewayClient, getStoredGatewayToken, getStoredGatewayUrl, setStoredGatewayUrl, } from '../lib/gateway-client'; import { isTauriRuntime, getLocalGatewayStatus as fetchLocalGatewayStatus, startLocalGateway as startLocalGatewayCommand, stopLocalGateway as stopLocalGatewayCommand, restartLocalGateway as restartLocalGatewayCommand, getUnsupportedLocalGatewayStatus, type LocalGatewayStatus, } from '../lib/tauri-gateway'; import { KernelClient, getKernelClient, } from '../lib/kernel-client'; import { type HealthCheckResult, type HealthStatus, } from '../lib/health-check'; import { useConfigStore } from './configStore'; import { createLogger } from '../lib/logger'; import { secureStorage } from '../lib/secure-storage'; const log = createLogger('ConnectionStore'); // === Mode Selection === // IMPORTANT: Check isTauriRuntime() at RUNTIME (inside functions), not at module load time. // At module load time, window.__TAURI_INTERNALS__ may not be set yet by Tauri. // === Custom Models Helpers === const CUSTOM_MODELS_STORAGE_KEY = 'zclaw-custom-models'; const MODEL_KEY_SECURE_PREFIX = 'zclaw-secure-model-key:'; interface CustomModel { id: string; name: string; provider: string; apiKey?: string; apiProtocol: 'openai' | 'anthropic' | 'custom'; baseUrl?: string; isDefault?: boolean; createdAt: string; } /** * Get custom models from localStorage. * NOTE: apiKeys are stripped from localStorage. Use getCustomModelApiKey() to retrieve them. */ function loadCustomModels(): CustomModel[] { try { const stored = localStorage.getItem(CUSTOM_MODELS_STORAGE_KEY); if (stored) { return JSON.parse(stored); } } catch (err) { log.error('Failed to parse models:', err); } return []; } /** * Save custom models to localStorage. API keys are stripped before saving. * Use saveCustomModelApiKey() separately to persist the key securely. */ function saveCustomModels(models: CustomModel[]): void { try { // Strip apiKeys before persisting to localStorage const sanitized = models.map(m => { const { apiKey: _, ...rest } = m; return rest; }); localStorage.setItem(CUSTOM_MODELS_STORAGE_KEY, JSON.stringify(sanitized)); } catch (err) { log.error('Failed to save models:', err); } } /** * Save an API key for a custom model to secure storage. */ export async function saveCustomModelApiKey(modelId: string, apiKey: string): Promise { if (!apiKey.trim()) { await secureStorage.delete(MODEL_KEY_SECURE_PREFIX + modelId); return; } await secureStorage.set(MODEL_KEY_SECURE_PREFIX + modelId, apiKey.trim()); } /** * Retrieve an API key for a custom model from secure storage. * Falls back to localStorage if secure storage is empty (migration path). */ export async function getCustomModelApiKey(modelId: string): Promise { const secureKey = await secureStorage.get(MODEL_KEY_SECURE_PREFIX + modelId); if (secureKey) { return secureKey; } return null; } /** * Delete an API key for a custom model from secure storage. */ export async function deleteCustomModelApiKey(modelId: string): Promise { await secureStorage.delete(MODEL_KEY_SECURE_PREFIX + modelId); } /** * Migrate all plaintext API keys from localStorage custom models to secure storage. * This is idempotent -- running it multiple times is safe. * After migration, apiKeys are stripped from localStorage. */ export async function migrateModelApiKeysToSecureStorage(): Promise { try { const stored = localStorage.getItem(CUSTOM_MODELS_STORAGE_KEY); if (!stored) return; const models: CustomModel[] = JSON.parse(stored); let hasPlaintextKeys = false; for (const model of models) { if (model.apiKey && model.apiKey.trim()) { hasPlaintextKeys = true; // Check if secure storage already has this key (skip if migrated) const existing = await secureStorage.get(MODEL_KEY_SECURE_PREFIX + model.id); if (!existing) { await secureStorage.set(MODEL_KEY_SECURE_PREFIX + model.id, model.apiKey.trim()); log.debug('Migrated API key for model:', model.id); } } } if (hasPlaintextKeys) { // Re-save without apiKeys to clear them from localStorage saveCustomModels(models); log.info('Migrated', models.length, 'model API keys to secure storage'); } } catch (err) { log.warn('Failed to migrate model API keys:', err); } } /** * Get the default model configuration (async version). * Retrieves apiKey from secure storage. * * Priority: * 1. Model with isDefault: true * 2. Model matching chatStore's currentModel * 3. First model in the list */ export async function getDefaultModelConfigAsync(): Promise<{ provider: string; model: string; apiKey: string; baseUrl: string; apiProtocol: string } | null> { const models = loadCustomModels(); // Priority 1: Find model with isDefault: true let defaultModel = models.find(m => m.isDefault === true); // Priority 2: Find model matching chatStore's currentModel if (!defaultModel) { try { const chatStoreData = localStorage.getItem('zclaw-chat-storage'); if (chatStoreData) { const parsed = JSON.parse(chatStoreData); const currentModelId = parsed?.state?.currentModel; if (currentModelId) { defaultModel = models.find(m => m.id === currentModelId); } } } catch (err) { log.warn('Failed to read chatStore:', err); } } // Priority 3: First model if (!defaultModel) { defaultModel = models[0]; } if (defaultModel) { // Retrieve apiKey from secure storage const apiKey = await getCustomModelApiKey(defaultModel.id); return { provider: defaultModel.provider, model: defaultModel.id, apiKey: apiKey || '', baseUrl: defaultModel.baseUrl || '', apiProtocol: defaultModel.apiProtocol || 'openai', }; } return null; } /** * Get the default model configuration (sync fallback). * NOTE: This version cannot retrieve apiKeys from secure storage. * Use getDefaultModelConfigAsync() when possible. * * @deprecated Use getDefaultModelConfigAsync() instead. This sync version * is kept only for backward compatibility and will NOT return apiKeys that * were stored via secure storage. */ export function getDefaultModelConfig(): { provider: string; model: string; apiKey: string; baseUrl: string; apiProtocol: string } | null { const models = loadCustomModels(); // Priority 1: Find model with isDefault: true let defaultModel = models.find(m => m.isDefault === true); // Priority 2: Find model matching chatStore's currentModel if (!defaultModel) { try { const chatStoreData = localStorage.getItem('zclaw-chat-storage'); if (chatStoreData) { const parsed = JSON.parse(chatStoreData); const currentModelId = parsed?.state?.currentModel; if (currentModelId) { defaultModel = models.find(m => m.id === currentModelId); } } } catch (err) { log.warn('Failed to read chatStore:', err); } } // Priority 3: First model if (!defaultModel) { defaultModel = models[0]; } if (defaultModel) { return { provider: defaultModel.provider, model: defaultModel.id, apiKey: defaultModel.apiKey || '', baseUrl: defaultModel.baseUrl || '', apiProtocol: defaultModel.apiProtocol || 'openai', }; } return null; } // === Types === export interface GatewayLog { timestamp: number; level: string; message: string; } // === Helper Functions === /** * Check if an error indicates we connection should retry with another candidate. */ function shouldRetryGatewayCandidate(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error || ''); return ( message === 'WebSocket connection failed' || message.startsWith('Gateway handshake timed out') || message.startsWith('WebSocket closed before handshake completed') || message.startsWith('Connection refused') || message.includes('ECONNREFUSED') || message.includes('Failed to fetch') || message.includes('Network error') || message.includes('pairing required') ); } /** * Normalize a gateway URL candidate. */ function normalizeGatewayUrlCandidate(url: string): string { return url.trim().replace(/\/+$/, ''); } // === Store Interface === export interface ConnectionStateSlice { connectionState: ConnectionState; gatewayVersion: string | null; error: string | null; logs: GatewayLog[]; localGateway: LocalGatewayStatus; localGatewayBusy: boolean; isLoading: boolean; healthStatus: HealthStatus; healthCheckResult: HealthCheckResult | null; } export interface ConnectionActionsSlice { connect: (url?: string, token?: string) => Promise; disconnect: () => void; clearLogs: () => void; refreshLocalGateway: () => Promise; startLocalGateway: () => Promise; stopLocalGateway: () => Promise; restartLocalGateway: () => Promise; } export interface ConnectionStore extends ConnectionStateSlice, ConnectionActionsSlice { client: GatewayClient | KernelClient; } // === Store Implementation === export const useConnectionStore = create((set, get) => { // Initialize with external gateway client by default. // Will switch to internal kernel client at connect time if in Tauri. const client: GatewayClient | KernelClient = getGatewayClient(); // Wire up state change callback client.onStateChange = (state: ConnectionState) => { set({ connectionState: state }); }; // Wire up log callback client.onLog = (level, message) => { set((s) => ({ logs: [...s.logs.slice(-99), { timestamp: Date.now(), level, message }], })); }; return { // === Initial State === connectionState: 'disconnected', gatewayVersion: null, error: null, logs: [], localGateway: getUnsupportedLocalGatewayStatus(), localGatewayBusy: false, isLoading: false, healthStatus: 'unknown', healthCheckResult: null, client, // === Actions === connect: async (url?: string, token?: string) => { try { set({ error: null }); // === Admin Routing Priority === // Admin-configured llm_routing takes priority over localStorage connectionMode. // This allows admins to force all clients to use relay or local mode. let adminForceLocal = false; try { const raw = localStorage.getItem('zclaw-saas-account'); if (raw) { const parsed = JSON.parse(raw); // Type-safe parsing: only accept 'relay' | 'local' as valid values if (parsed && typeof parsed === 'object' && 'llm_routing' in parsed) { const adminRouting = parsed.llm_routing; if (adminRouting === 'relay') { // Force SaaS Relay mode — admin override localStorage.setItem('zclaw-connection-mode', 'saas'); log.debug('Admin llm_routing=relay: forcing SaaS relay mode'); } else if (adminRouting === 'local' && isTauriRuntime()) { // Force local Kernel mode — skip SaaS relay entirely adminForceLocal = true; localStorage.setItem('zclaw-connection-mode', 'tauri'); log.debug('Admin llm_routing=local: forcing local Kernel mode'); } // Other values (including undefined/null/invalid) are ignored, fall through to default logic } } } catch (e) { log.warn('Failed to parse admin routing from localStorage, using default', e); } // === Internal Kernel Mode: Admin forced local === // If admin forced local mode, skip directly to Tauri Kernel section if (adminForceLocal) { const kernelClient = getKernelClient(); const modelConfig = await getDefaultModelConfigAsync(); if (!modelConfig) { throw new Error('请先在"模型与 API"设置页面添加自定义模型配置'); } if (!modelConfig.apiKey) { throw new Error(`模型 ${modelConfig.model} 未配置 API Key,请在"模型与 API"设置页面配置`); } kernelClient.setConfig({ provider: modelConfig.provider, model: modelConfig.model, apiKey: modelConfig.apiKey, baseUrl: modelConfig.baseUrl, apiProtocol: modelConfig.apiProtocol, }); kernelClient.onStateChange = (state: ConnectionState) => { set({ connectionState: state }); }; kernelClient.onLog = (level, message) => { set((s) => ({ logs: [...s.logs.slice(-99), { timestamp: Date.now(), level, message }], })); }; set({ client: kernelClient }); const { initializeStores } = await import('./index'); initializeStores(); await kernelClient.connect(); set({ gatewayVersion: '0.1.0-internal' }); log.debug('Connected to internal ZCLAW Kernel (admin forced local)'); return; } // === SaaS Relay Mode === // Check connection mode from localStorage (set by saasStore). // When SaaS is unreachable, gracefully degrade to local kernel mode // so the desktop app remains functional. const savedMode = localStorage.getItem('zclaw-connection-mode'); let saasDegraded = false; if (savedMode === 'saas') { const { loadSaaSSession, saasClient } = await import('../lib/saas-client'); const session = await loadSaaSSession(); if (!session || !session.saasUrl) { throw new Error('SaaS 模式未登录,请先在设置中登录 SaaS 平台'); } log.debug('Using SaaS relay mode:', session.saasUrl); // Configure the singleton client (cookie auth — no token needed) saasClient.setBaseUrl(session.saasUrl); // Health check + model list: merged single listModels() call let relayModels: Array<{ id: string; alias?: string }> | null = null; try { relayModels = await saasClient.listModels(); } catch (err: unknown) { // Handle expired session — clear auth and trigger re-login const status = (err as { status?: number })?.status; if (status === 401) { const { useSaaSStore } = await import('./saasStore'); useSaaSStore.getState().logout(); throw new Error('SaaS 会话已过期,请重新登录'); } // SaaS unreachable — degrade to local kernel mode const errMsg = err instanceof Error ? err.message : String(err); log.warn(`SaaS 平台连接失败: ${errMsg} — 降级到本地 Kernel 模式`); // Mark SaaS as unreachable in store try { const { useSaaSStore } = await import('./saasStore'); useSaaSStore.setState({ saasReachable: false }); } catch { /* non-critical */ } saasDegraded = true; } if (!saasDegraded) { // === SaaS Relay via Kernel === // Route LLM calls through SaaS relay (Key Pool) while keeping // agent management local via KernelClient. // baseUrl = saasUrl + /api/v1/relay → kernel appends /chat/completions // apiKey = SaaS JWT token → sent as Authorization: Bearer // Models already fetched during health check above if (!relayModels || relayModels.length === 0) { throw new Error('SaaS 平台没有可用模型,请先在管理后台配置 Provider 和模型'); } if (isTauriRuntime()) { if (!session.token) { throw new Error('SaaS 中转模式需要认证令牌,请重新登录 SaaS 平台'); } const kernelClient = getKernelClient(); // Use first available model as fallback; prefer conversationStore.currentModel if set const fallbackModel = relayModels[0]; // 优先使用 conversationStore 的 currentModel,如果设置了的话 let preferredModel: string | undefined; try { const { useConversationStore } = require('./chat/conversationStore'); preferredModel = useConversationStore.getState().currentModel; } catch { // conversationStore 可能尚未初始化 } const fallbackId = fallbackModel?.id; if (!fallbackId) { throw new Error('可用模型数据格式异常,请刷新页面重试'); } const modelToUse = preferredModel || fallbackId; kernelClient.setConfig({ provider: 'custom', model: modelToUse, apiKey: session.token, baseUrl: `${session.saasUrl}/api/v1/relay`, apiProtocol: 'openai', }); kernelClient.onStateChange = (state: ConnectionState) => { set({ connectionState: state }); }; kernelClient.onLog = (level: string, message: string) => { set((s) => ({ logs: [...s.logs.slice(-99), { timestamp: Date.now(), level, message }], })); }; set({ client: kernelClient }); const { initializeStores } = await import('./index'); initializeStores(); await kernelClient.connect(); set({ gatewayVersion: 'saas-relay', connectionState: 'connected' }); log.debug('Connected via SaaS relay (kernel backend):', { model: modelToUse, baseUrl: `${session.saasUrl}/api/v1/relay`, }); } else { // Non-Tauri (browser) — use SaaS relay gateway client for agent listing + chat const { createSaaSRelayGatewayClient } = await import('../lib/saas-relay-client'); const fallbackModelId = relayModels[0]?.id; if (!fallbackModelId) { throw new Error('可用模型数据格式异常,请刷新页面重试'); } const relayClient = createSaaSRelayGatewayClient(session.saasUrl, () => { // 每次调用时读取 conversationStore 的 currentModel,fallback 到第一个可用模型 try { const { useConversationStore } = require('./chat/conversationStore'); const current = useConversationStore.getState().currentModel; return current || fallbackModelId; } catch { return fallbackModelId; } }); set({ connectionState: 'connected', gatewayVersion: 'saas-relay', client: relayClient as unknown as GatewayClient, }); const { initializeStores } = await import('./index'); initializeStores(); log.debug('Connected to SaaS relay (browser mode)', { relayModel: fallbackModelId }); } return; } // Fall through to Tauri Kernel / Gateway mode } // === Internal Kernel Mode (Tauri) === // Check at RUNTIME, not at module load time, to ensure __TAURI_INTERNALS__ is available const useInternalKernel = isTauriRuntime(); log.debug('isTauriRuntime():', useInternalKernel); if (useInternalKernel) { log.debug('Using internal ZCLAW Kernel (no external process needed)'); const kernelClient = getKernelClient(); // Get model config from custom models settings (async for secure key retrieval) const modelConfig = await getDefaultModelConfigAsync(); if (!modelConfig) { throw new Error('请先在"模型与 API"设置页面添加自定义模型配置'); } if (!modelConfig.apiKey) { throw new Error(`模型 ${modelConfig.model} 未配置 API Key,请在"模型与 API"设置页面配置`); } log.debug('Model config:', { provider: modelConfig.provider, model: modelConfig.model, hasApiKey: !!modelConfig.apiKey, baseUrl: modelConfig.baseUrl, apiProtocol: modelConfig.apiProtocol, }); kernelClient.setConfig({ provider: modelConfig.provider, model: modelConfig.model, apiKey: modelConfig.apiKey, baseUrl: modelConfig.baseUrl, apiProtocol: modelConfig.apiProtocol, }); // Wire up state change callback kernelClient.onStateChange = (state: ConnectionState) => { set({ connectionState: state }); }; // Wire up log callback kernelClient.onLog = (level, message) => { set((s) => ({ logs: [...s.logs.slice(-99), { timestamp: Date.now(), level, message }], })); }; // Update the stored client reference set({ client: kernelClient }); // Re-inject client to all stores so they get the kernel client const { initializeStores } = await import('./index'); initializeStores(); // Connect to internal kernel await kernelClient.connect(); // Set version set({ gatewayVersion: '0.1.0-internal' }); log.debug('Connected to internal ZCLAW Kernel'); return; } // === External Gateway Mode (non-Tauri or fallback) === const c = get().client; // Resolve connection URL candidates const resolveCandidates = async (): Promise => { const explicitUrl = url?.trim(); if (explicitUrl) { return [normalizeGatewayUrlCandidate(explicitUrl)]; } const candidates: string[] = []; // Add quick config gateway URL if available const quickConfigGatewayUrl = useConfigStore.getState().quickConfig?.gatewayUrl?.trim(); if (quickConfigGatewayUrl) { candidates.push(quickConfigGatewayUrl); } // Add stored URL, default, and fallbacks candidates.push( getStoredGatewayUrl(), DEFAULT_GATEWAY_URL, ...FALLBACK_GATEWAY_URLS ); // Return unique, non-empty candidates return Array.from( new Set( candidates .filter(Boolean) .map(normalizeGatewayUrlCandidate) ) ); }; // Resolve effective token const effectiveToken = token || useConfigStore.getState().quickConfig?.gatewayToken || getStoredGatewayToken(); log.debug('Connecting with token:', effectiveToken ? '[REDACTED]' : '(empty)'); const candidateUrls = await resolveCandidates(); let lastError: unknown = null; let connectedUrl: string | null = null; // Try each candidate URL for (const candidateUrl of candidateUrls) { try { c.updateOptions({ url: candidateUrl, token: effectiveToken, }); await c.connect(); connectedUrl = candidateUrl; break; } catch (err) { lastError = err; // Check if we should try next candidate if (!shouldRetryGatewayCandidate(err)) { throw err; } } } if (!connectedUrl) { throw (lastError instanceof Error ? lastError : new Error('Failed to connect to any available Gateway')); } // Store successful URL setStoredGatewayUrl(connectedUrl); // Fetch gateway version try { const health = await c.health(); set({ gatewayVersion: health?.version }); } catch { /* health may not return version */ } log.debug('Connected to:', connectedUrl); } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); set({ error: errorMessage }); throw err; } }, disconnect: () => { get().client.disconnect(); set({ connectionState: 'disconnected', gatewayVersion: null, error: null, }); }, clearLogs: () => set({ logs: [] }), refreshLocalGateway: async () => { if (!isTauriRuntime()) { const unsupported = getUnsupportedLocalGatewayStatus(); set({ localGateway: unsupported, localGatewayBusy: false }); return unsupported; } set({ localGatewayBusy: true }); try { const status = await fetchLocalGatewayStatus(); set({ localGateway: status, localGatewayBusy: false }); return status; } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Failed to read local Gateway status'; const nextStatus = { ...get().localGateway, supported: true, error: message, }; set({ localGateway: nextStatus, localGatewayBusy: false, error: message }); return nextStatus; } }, startLocalGateway: async () => { if (!isTauriRuntime()) { const unsupported = getUnsupportedLocalGatewayStatus(); set({ localGateway: unsupported, localGatewayBusy: false }); return unsupported; } set({ localGatewayBusy: true, error: null }); try { const status = await startLocalGatewayCommand(); set({ localGateway: status, localGatewayBusy: false }); return status; } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Failed to start local Gateway'; const nextStatus = { ...get().localGateway, supported: true, error: message, }; set({ localGateway: nextStatus, localGatewayBusy: false, error: message }); return undefined; } }, stopLocalGateway: async () => { if (!isTauriRuntime()) { const unsupported = getUnsupportedLocalGatewayStatus(); set({ localGateway: unsupported, localGatewayBusy: false }); return unsupported; } set({ localGatewayBusy: true, error: null }); try { const status = await stopLocalGatewayCommand(); set({ localGateway: status, localGatewayBusy: false }); return status; } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Failed to stop local Gateway'; const nextStatus = { ...get().localGateway, supported: true, error: message, }; set({ localGateway: nextStatus, localGatewayBusy: false, error: message }); return undefined; } }, restartLocalGateway: async () => { if (!isTauriRuntime()) { const unsupported = getUnsupportedLocalGatewayStatus(); set({ localGateway: unsupported, localGatewayBusy: false }); return unsupported; } set({ localGatewayBusy: true, error: null }); try { const status = await restartLocalGatewayCommand(); set({ localGateway: status, localGatewayBusy: false }); return status; } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Failed to restart local Gateway'; const nextStatus = { ...get().localGateway, supported: true, error: message, }; set({ localGateway: nextStatus, localGatewayBusy: false, error: message }); return undefined; } }, }; }); // === Exported Accessors for Coordinator === /** * Get current connection state. */ export const getConnectionState = () => useConnectionStore.getState().connectionState; /** * Get gateway client instance. */ export const getClient = () => useConnectionStore.getState().client; /** * Get current error message. */ export const getConnectionError = () => useConnectionStore.getState().error; /** * Get local gateway status. */ export const getLocalGatewayStatus = () => useConnectionStore.getState().localGateway; /** * Get gateway version. */ export const getGatewayVersion = () => useConnectionStore.getState().gatewayVersion;