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'; // === 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'; 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 */ function loadCustomModels(): CustomModel[] { try { const stored = localStorage.getItem(CUSTOM_MODELS_STORAGE_KEY); if (stored) { return JSON.parse(stored); } } catch (err) { console.error('[connectionStore] Failed to parse models:', err); } return []; } /** * Get the default model configuration * * Priority: * 1. Model with isDefault: true * 2. Model matching chatStore's currentModel * 3. First model in the list */ 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) { console.warn('[connectionStore] 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 }); // === Internal Kernel Mode (Tauri) === // Check at RUNTIME, not at module load time, to ensure __TAURI_INTERNALS__ is available const useInternalKernel = isTauriRuntime(); console.log('[ConnectionStore] isTauriRuntime():', useInternalKernel); if (useInternalKernel) { console.log('[ConnectionStore] Using internal ZCLAW Kernel (no external process needed)'); const kernelClient = getKernelClient(); // Get model config from custom models settings const modelConfig = getDefaultModelConfig(); if (!modelConfig) { throw new Error('请先在"模型与 API"设置页面添加自定义模型配置'); } if (!modelConfig.apiKey) { throw new Error(`模型 ${modelConfig.model} 未配置 API Key,请在"模型与 API"设置页面配置`); } console.log('[ConnectionStore] 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 }); // Connect to internal kernel await kernelClient.connect(); // Set version set({ gatewayVersion: '0.2.0-internal' }); console.log('[ConnectionStore] 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(); console.log('[ConnectionStore] 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 */ } console.log('[ConnectionStore] 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;