/** * 应用配置持久化。 * * - 落盘位置:userData/config.json * - API Key 优先用 Electron safeStorage 加密(Windows 走 DPAPI), * 若系统不支持则降级为明文并在 UI 上如实标注。 * - 渲染进程只能拿到 GatewayConfigView(无明文 Key)。 */ import { app, safeStorage } from 'electron' import { promises as fs } from 'node:fs' import { join } from 'node:path' import type { GatewayConfig, GatewayConfigView } from '@shared/types' interface StoredConfig { speechBaseUrl?: string speechModel?: string baseUrl: string model: string temperature: number maxToolRounds: number apiKeyEnc?: string apiKeyPlain?: string workspaceRoot?: string } const DEFAULTS: StoredConfig = { baseUrl: 'https://api.deepseek.com/v1', model: 'deepseek-chat', temperature: 0.7, maxToolRounds: 6 } let cache: StoredConfig | null = null function filePath(): string { return join(app.getPath('userData'), 'config.json') } async function load(): Promise { if (cache) return cache try { const raw = await fs.readFile(filePath(), 'utf8') cache = { ...DEFAULTS, ...(JSON.parse(raw) as Partial) } } catch { cache = { ...DEFAULTS } } return cache } async function persist(next: StoredConfig): Promise { cache = next const file = filePath() await fs.mkdir(join(file, '..'), { recursive: true }) await fs.writeFile(file, JSON.stringify(next, null, 2), 'utf8') } function decryptKey(stored: StoredConfig): { key: string; storage: 'encrypted' | 'plaintext' | 'none' } { if (stored.apiKeyEnc) { try { return { key: safeStorage.decryptString(Buffer.from(stored.apiKeyEnc, 'base64')), storage: 'encrypted' } } catch { return { key: '', storage: 'none' } } } if (stored.apiKeyPlain) return { key: stored.apiKeyPlain, storage: 'plaintext' } return { key: '', storage: 'none' } } /** 供主进程内部使用:拿到可直接请求上游的完整配置 */ export async function getGatewayConfig(): Promise { const stored = await load() const { key } = decryptKey(stored) return { speechBaseUrl: stored.speechBaseUrl, speechModel: stored.speechModel, baseUrl: stored.baseUrl, model: stored.model, temperature: stored.temperature, maxToolRounds: stored.maxToolRounds, apiKey: key } } export async function getWorkspaceRoot(): Promise { const stored = await load() return stored.workspaceRoot || join(app.getPath('home'), 'YCodeWorkspace') } export async function setWorkspaceRoot(root: string): Promise { const stored = await load() await persist({ ...stored, workspaceRoot: root }) } /** 供渲染进程使用:脱敏视图 */ export async function getConfigView(): Promise { const stored = await load() const { key, storage } = decryptKey(stored) return { speechBaseUrl: stored.speechBaseUrl, speechModel: stored.speechModel, baseUrl: stored.baseUrl, model: stored.model, temperature: stored.temperature, maxToolRounds: stored.maxToolRounds, hasApiKey: key.length > 0, keyStorage: key.length > 0 ? storage : 'none' } } export interface ConfigPatch { speechBaseUrl?: string speechModel?: string baseUrl?: string model?: string temperature?: number maxToolRounds?: number /** 传空字符串表示清除 Key;不传表示保持原值 */ apiKey?: string } export async function updateConfig(patch: ConfigPatch): Promise { const stored = await load() const next: StoredConfig = { ...stored } if (patch.speechBaseUrl !== undefined) next.speechBaseUrl = patch.speechBaseUrl.trim() if (patch.speechModel !== undefined) next.speechModel = patch.speechModel.trim() if (patch.baseUrl !== undefined) next.baseUrl = patch.baseUrl.trim() if (patch.model !== undefined) next.model = patch.model.trim() if (patch.temperature !== undefined) next.temperature = clamp(patch.temperature, 0, 2) if (patch.maxToolRounds !== undefined) next.maxToolRounds = clamp(Math.round(patch.maxToolRounds), 1, 24) if (patch.apiKey !== undefined) { const key = patch.apiKey.trim() if (!key) { delete next.apiKeyEnc delete next.apiKeyPlain } else if (safeStorage.isEncryptionAvailable()) { next.apiKeyEnc = safeStorage.encryptString(key).toString('base64') delete next.apiKeyPlain } else { next.apiKeyPlain = key delete next.apiKeyEnc } } await persist(next) return getConfigView() } function clamp(value: number, min: number, max: number): number { if (Number.isNaN(value)) return min return Math.min(max, Math.max(min, value)) }