156 lines
4.6 KiB
TypeScript
156 lines
4.6 KiB
TypeScript
/**
|
|
* 应用配置持久化。
|
|
*
|
|
* - 落盘位置: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<StoredConfig> {
|
|
if (cache) return cache
|
|
try {
|
|
const raw = await fs.readFile(filePath(), 'utf8')
|
|
cache = { ...DEFAULTS, ...(JSON.parse(raw) as Partial<StoredConfig>) }
|
|
} catch {
|
|
cache = { ...DEFAULTS }
|
|
}
|
|
return cache
|
|
}
|
|
|
|
async function persist(next: StoredConfig): Promise<void> {
|
|
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<GatewayConfig> {
|
|
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<string> {
|
|
const stored = await load()
|
|
return stored.workspaceRoot || join(app.getPath('home'), 'YCodeWorkspace')
|
|
}
|
|
|
|
export async function setWorkspaceRoot(root: string): Promise<void> {
|
|
const stored = await load()
|
|
await persist({ ...stored, workspaceRoot: root })
|
|
}
|
|
|
|
/** 供渲染进程使用:脱敏视图 */
|
|
export async function getConfigView(): Promise<GatewayConfigView> {
|
|
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<GatewayConfigView> {
|
|
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))
|
|
}
|