Files
zclaw_openfang/desktop/src/store/connectionStore.ts
iven f79560a911 refactor(desktop): split kernel_commands/pipeline_commands into modules, add SaaS client libs and gateway modules
Split monolithic kernel_commands.rs (2185 lines) and pipeline_commands.rs (1391 lines)
into focused sub-modules under kernel_commands/ and pipeline_commands/ directories.
Add gateway module (commands, config, io, runtime), health_check, and 15 new
TypeScript client libraries for SaaS relay, auth, admin, telemetry, and kernel
sub-systems (a2a, agent, chat, hands, skills, triggers).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 11:12:47 +08:00

817 lines
26 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';
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 storedAccount = JSON.parse(raw);
// 类型安全解析: 仅接受 'relay' | 'local' 两个合法值
const adminRouting = storedAccount?.account?.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');
}
// 其他值(含 undefined/null/非法值)忽略,走默认逻辑
}
} catch { /* ignore parse errors, fall through to default logic */ }
// === 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 via GET /api/v1/relay/models
try {
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>
if (isTauriRuntime()) {
if (!session.token) {
throw new Error('SaaS 中转模式需要认证令牌,请重新登录 SaaS 平台');
}
const kernelClient = getKernelClient();
// Fetch available models from SaaS relay
let models: Array<{ id: string }>;
try {
models = await saasClient.listModels();
} catch {
throw new Error('无法获取可用模型列表,请确认管理后台已配置 Provider 和模型');
}
if (models.length === 0) {
throw new Error('SaaS 平台没有可用模型,请先在管理后台配置 Provider 和模型');
}
// Use first available model (TODO: let user choose preferred model)
const relayModel = models[0];
kernelClient.setConfig({
provider: 'custom',
model: relayModel.id,
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: relayModel.id,
baseUrl: `${session.saasUrl}/api/v1/relay`,
});
} else {
// Non-Tauri (browser) — simple connected state without kernel
set({ connectionState: 'connected', gatewayVersion: 'saas-relay' });
log.debug('Connected to SaaS relay (browser mode)');
}
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;