Files
zclaw_openfang/desktop/src/store/connectionStore.ts
iven 76d36f62a6
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
fix(desktop): 模型自动路由 — 首次登录自动选择可用模型
- saasStore: fetchAvailableModels 处理 currentModel 为空的情况,自动选择第一个可用模型
- connectionStore: SaaS relay 连接成功后同步 currentModel 到 conversationStore
- 同时覆盖 Tauri 和浏览器两条 SaaS relay 路径
- 修复首次登录用户需手动选模型的问题
2026-04-15 01:45:36 +08:00

892 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
// 延迟加载 conversationStore 避免循环依赖
// connect() 是 async 函数,在其中 await import() 是安全的
let _conversationStore: typeof import('./chat/conversationStore') | null = null;
async function loadConversationStore() {
if (!_conversationStore) {
try { _conversationStore = await import('./chat/conversationStore'); } catch { /* not loaded yet */ }
}
return _conversationStore;
}
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<void> {
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<string | null> {
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<void> {
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<void> {
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<void>;
disconnect: () => void;
clearLogs: () => void;
refreshLocalGateway: () => Promise<LocalGatewayStatus>;
startLocalGateway: () => Promise<LocalGatewayStatus | undefined>;
stopLocalGateway: () => Promise<LocalGatewayStatus | undefined>;
restartLocalGateway: () => Promise<LocalGatewayStatus | undefined>;
}
export interface ConnectionStore extends ConnectionStateSlice, ConnectionActionsSlice {
client: GatewayClient | KernelClient;
}
// === Store Implementation ===
export const useConnectionStore = create<ConnectionStore>((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 <jwt>
// Models already fetched during health check above
if (!relayModels || relayModels.length === 0) {
throw new Error('暂时没有可用的 AI 模型,请稍后再试或联系管理员');
}
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 cs = await loadConversationStore();
preferredModel = cs?.useConversationStore.getState().currentModel;
} catch {
// conversationStore 可能尚未初始化
}
const fallbackId = fallbackModel?.id;
if (!fallbackId) {
throw new Error('可用模型数据格式异常,请刷新页面重试');
}
// 验证 preferredModel 是否在 SaaS 可用模型列表中
// 避免使用上一次非 SaaS 会话残留的模型 ID
const validModelIds = new Set(
relayModels.flatMap(m => [m.id, ...(m.alias ? [m.alias] : [])])
);
const modelToUse = (preferredModel && validModelIds.has(preferredModel))
? 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' });
// 同步 modelToUse 到 conversationStore首次登录时 currentModel 可能为空)
try {
const cs = await loadConversationStore();
const currentInStore = cs?.useConversationStore.getState().currentModel;
if (!currentInStore && modelToUse) {
cs?.useConversationStore.getState().setCurrentModel(modelToUse);
log.info(`Synced currentModel after SaaS relay connect: ${modelToUse}`);
}
} catch { /* non-critical */ }
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('可用模型数据格式异常,请刷新页面重试');
}
// 浏览器路径也验证模型是否在 SaaS 列表中
const validBrowserModelIds = new Set(
relayModels.flatMap(m => [m.id, ...(m.alias ? [m.alias] : [])])
);
const relayClient = createSaaSRelayGatewayClient(session.saasUrl, () => {
// 每次调用时读取 conversationStore 的 currentModelfallback 到第一个可用模型
// 注意:这里不能用 await同步回调但 conversationStore 已在上方 loadConversationStore() 中加载
const current = _conversationStore?.useConversationStore.getState().currentModel;
return (current && validBrowserModelIds.has(current)) ? current : 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 });
// 同步 currentModel 到 conversationStore浏览器路径
try {
const cs = await loadConversationStore();
const currentInStore = cs?.useConversationStore.getState().currentModel;
if (!currentInStore && fallbackModelId) {
cs?.useConversationStore.getState().setCurrentModel(fallbackModelId);
log.info(`Synced currentModel after browser SaaS relay connect: ${fallbackModelId}`);
}
} catch { /* non-critical */ }
}
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<string[]> => {
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;