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
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:
225
desktop/src/lib/model-config.ts
Normal file
225
desktop/src/lib/model-config.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -28,7 +28,23 @@ import {
|
|||||||
} from '../lib/health-check';
|
} from '../lib/health-check';
|
||||||
import { useConfigStore } from './configStore';
|
import { useConfigStore } from './configStore';
|
||||||
import { createLogger } from '../lib/logger';
|
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 避免循环依赖
|
// 延迟加载 conversationStore 避免循环依赖
|
||||||
// connect() 是 async 函数,在其中 await import() 是安全的
|
// connect() 是 async 函数,在其中 await import() 是安全的
|
||||||
@@ -46,220 +62,6 @@ const log = createLogger('ConnectionStore');
|
|||||||
// IMPORTANT: Check isTauriRuntime() at RUNTIME (inside functions), not at module load time.
|
// 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.
|
// 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 ===
|
// === Types ===
|
||||||
|
|
||||||
export interface GatewayLog {
|
export interface GatewayLog {
|
||||||
|
|||||||
Reference in New Issue
Block a user