refactor(desktop): connectionStore 拆分 — 模型配置提取为 lib/model-config.ts
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

- 提取 213 行模型配置逻辑到独立模块: CustomModel 接口/API Key 管理/默认模型解析
- connectionStore 通过 re-export 保持向后兼容, 外部导入无需变更
- 消除 ModelsAPI.tsx 中 loadCustomModelsBase/saveCustomModelsBase 的重复逻辑 (待后续对接)
- connectionStore 891→693 行 (-22%), model-config.ts 225 行
- TypeScript 类型检查通过
This commit is contained in:
iven
2026-04-21 23:07:15 +08:00
parent 191cc3097c
commit 27006157da
2 changed files with 242 additions and 215 deletions

View File

@@ -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<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. */
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);
}
// ---------------------------------------------------------------------------
// Migration
// ---------------------------------------------------------------------------
/**
* Migrate plaintext API keys from localStorage to secure storage.
* Idempotent — safe to run multiple times.
*/
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;
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<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) {
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;
}