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
Remove temporary console.log and eprintln! statements added during troubleshooting the model configuration issue. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
493 lines
15 KiB
TypeScript
493 lines
15 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';
|
||
|
||
// === 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;
|