chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成
包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、 文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
@@ -28,6 +28,7 @@ import {
|
||||
} from '../lib/health-check';
|
||||
import { useConfigStore } from './configStore';
|
||||
import { createLogger } from '../lib/logger';
|
||||
import { secureStorage } from '../lib/secure-storage';
|
||||
|
||||
const log = createLogger('ConnectionStore');
|
||||
|
||||
@@ -38,6 +39,7 @@ const log = createLogger('ConnectionStore');
|
||||
// === Custom Models Helpers ===
|
||||
|
||||
const CUSTOM_MODELS_STORAGE_KEY = 'zclaw-custom-models';
|
||||
const MODEL_KEY_SECURE_PREFIX = 'zclaw-secure-model-key:';
|
||||
|
||||
interface CustomModel {
|
||||
id: string;
|
||||
@@ -51,7 +53,8 @@ interface CustomModel {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom models from localStorage
|
||||
* Get custom models from localStorage.
|
||||
* NOTE: apiKeys are stripped from localStorage. Use getCustomModelApiKey() to retrieve them.
|
||||
*/
|
||||
function loadCustomModels(): CustomModel[] {
|
||||
try {
|
||||
@@ -66,13 +69,147 @@ function loadCustomModels(): CustomModel[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default model configuration
|
||||
* 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();
|
||||
|
||||
@@ -234,7 +371,14 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
|
||||
// Health check via GET /api/v1/relay/models
|
||||
try {
|
||||
await saasClient.listModels();
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
// Handle expired session — clear auth and trigger re-login
|
||||
const status = (err as { status?: number })?.status;
|
||||
if (status === 401) {
|
||||
const { useSaaSStore } = await import('./saasStore');
|
||||
useSaaSStore.getState().logout();
|
||||
throw new Error('SaaS 会话已过期,请重新登录');
|
||||
}
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`SaaS 平台连接失败: ${errMsg}`);
|
||||
}
|
||||
@@ -253,8 +397,8 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
|
||||
log.debug('Using internal ZCLAW Kernel (no external process needed)');
|
||||
const kernelClient = getKernelClient();
|
||||
|
||||
// Get model config from custom models settings
|
||||
const modelConfig = getDefaultModelConfig();
|
||||
// Get model config from custom models settings (async for secure key retrieval)
|
||||
const modelConfig = await getDefaultModelConfigAsync();
|
||||
|
||||
if (!modelConfig) {
|
||||
throw new Error('请先在"模型与 API"设置页面添加自定义模型配置');
|
||||
|
||||
Reference in New Issue
Block a user