diff --git a/desktop/src/lib/model-config.ts b/desktop/src/lib/model-config.ts new file mode 100644 index 0000000..370f422 --- /dev/null +++ b/desktop/src/lib/model-config.ts @@ -0,0 +1,225 @@ +/** + * Custom model configuration management. + * + * Handles loading, saving, and querying custom model definitions, + * including secure API key storage via OS keyring. + * + * Extracted from connectionStore.ts to decouple model config from + * connection lifecycle. + */ + +import { createLogger } from './logger'; +import { secureStorage } from './secure-storage'; + +const log = createLogger('ModelConfig'); + +const CUSTOM_MODELS_STORAGE_KEY = 'zclaw-custom-models'; +const MODEL_KEY_SECURE_PREFIX = 'zclaw-secure-model-key:'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface CustomModel { + id: string; + name: string; + provider: string; + apiKey?: string; + apiProtocol: 'openai' | 'anthropic' | 'custom'; + baseUrl?: string; + isDefault?: boolean; + createdAt: string; +} + +export interface ModelConfig { + provider: string; + model: string; + apiKey: string; + baseUrl: string; + apiProtocol: string; +} + +// --------------------------------------------------------------------------- +// localStorage helpers +// --------------------------------------------------------------------------- + +/** Load custom models from localStorage (API keys stripped). */ +export 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. */ +export function saveCustomModels(models: CustomModel[]): void { + try { + 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); + } +} + +// --------------------------------------------------------------------------- +// Secure API key storage +// --------------------------------------------------------------------------- + +/** Save an API key for a custom model to secure storage. */ +export async function saveCustomModelApiKey(modelId: string, apiKey: string): Promise { + 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. */ +export async function getCustomModelApiKey(modelId: string): Promise { + 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 { + await secureStorage.delete(MODEL_KEY_SECURE_PREFIX + modelId); +} + +// --------------------------------------------------------------------------- +// Migration +// --------------------------------------------------------------------------- + +/** + * Migrate plaintext API keys from localStorage to secure storage. + * Idempotent — safe to run multiple times. + */ +export async function migrateModelApiKeysToSecureStorage(): Promise { + 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; + 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) { + saveCustomModels(models); + log.info('Migrated', models.length, 'model API keys to secure storage'); + } + } catch (err) { + log.warn('Failed to migrate model API keys:', err); + } +} + +// --------------------------------------------------------------------------- +// Default model resolution +// --------------------------------------------------------------------------- + +/** + * Get the default model configuration (async). + * 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 { + const models = loadCustomModels(); + + let defaultModel = models.find(m => m.isDefault === true); + + 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); + } + } + + if (!defaultModel) { + defaultModel = models[0]; + } + + if (defaultModel) { + 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). + * @deprecated Use getDefaultModelConfigAsync() instead. + */ +export function getDefaultModelConfig(): ModelConfig | null { + const models = loadCustomModels(); + + let defaultModel = models.find(m => m.isDefault === true); + + 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); + } + } + + 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; +} diff --git a/desktop/src/store/connectionStore.ts b/desktop/src/store/connectionStore.ts index b1e242e..9fe2a93 100644 --- a/desktop/src/store/connectionStore.ts +++ b/desktop/src/store/connectionStore.ts @@ -28,7 +28,23 @@ import { } from '../lib/health-check'; import { useConfigStore } from './configStore'; import { createLogger } from '../lib/logger'; -import { secureStorage } from '../lib/secure-storage'; +import { + getDefaultModelConfigAsync, +} from '../lib/model-config'; + +// Re-export model config functions for backward compatibility +export { + type CustomModel, + type ModelConfig, + loadCustomModels, + saveCustomModels, + saveCustomModelApiKey, + getCustomModelApiKey, + deleteCustomModelApiKey, + migrateModelApiKeysToSecureStorage, + getDefaultModelConfig, + getDefaultModelConfigAsync, +} from '../lib/model-config'; // 延迟加载 conversationStore 避免循环依赖 // connect() 是 async 函数,在其中 await import() 是安全的 @@ -46,220 +62,6 @@ const log = createLogger('ConnectionStore'); // 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 { - 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 { - 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 { - 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 { - 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 {