Files
zclaw_openfang/desktop/src/store/connectionStore.ts
iven cbd3da46a3
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
chore: remove debug logging
Remove temporary console.log and eprintln! statements added during
troubleshooting the model configuration issue.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 23:06:20 +08:00

493 lines
15 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';
// === 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<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 });
// === 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<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();
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;