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
- saasStore: fetchAvailableModels 处理 currentModel 为空的情况,自动选择第一个可用模型 - connectionStore: SaaS relay 连接成功后同步 currentModel 到 conversationStore - 同时覆盖 Tauri 和浏览器两条 SaaS relay 路径 - 修复首次登录用户需手动选模型的问题
892 lines
30 KiB
TypeScript
892 lines
30 KiB
TypeScript
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 的 currentModel,fallback 到第一个可用模型
|
||
// 注意:这里不能用 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;
|