/** * 平台配置(ADR-0011)。 * * 读取顺序:环境变量 > 配置文件 > 默认值。 * 配置文件默认在 ~/.aps/config.json(桌面端与本地开发的惯例位置),可用 APS_CONFIG_FILE 指向别处。 * 密钥只在这里出现一次,其余模块拿到的都是配置对象;日志一律用 redactConfig()。 */ import { existsSync, readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; export type LlmProvider = 'deepseek' | 'openai' | 'compatible'; export interface LlmConfig { provider: LlmProvider; baseUrl: string; model: string; /** 未配置时为空字符串:引擎不可用,但平台照常启动 */ apiKey: string; } export interface PlatformConfig { llm: LlmConfig; database: { path: string }; server: { host: string; port: number }; /** 客户端构建产物目录,可覆盖(默认仓库内 client/app/dist) */ clientDist?: string; } export interface LoadConfigInput { env?: NodeJS.ProcessEnv; /** 便于测试:覆盖用户主目录 */ homeDir?: string; } const DEFAULT_BASE_URLS: Record = { deepseek: 'https://api.deepseek.com/v1', openai: 'https://api.openai.com/v1', }; export function configFilePath(input: LoadConfigInput = {}): string { const env = input.env ?? process.env; const explicit = env.APS_CONFIG_FILE; if (explicit && explicit.trim() !== '') return explicit; return join(input.homeDir ?? homedir(), '.aps', 'config.json'); } interface FileConfig { llm?: Partial; database?: { path?: string }; server?: { host?: string; port?: number }; clientDist?: string; } function readFileConfig(path: string): FileConfig { if (!existsSync(path)) return {}; try { return JSON.parse(readFileSync(path, 'utf8')) as FileConfig; } catch (error) { // 配置文件坏了要吵:静默忽略会让密钥问题变得难以排查 throw new Error(`配置文件解析失败:${path}(${error instanceof Error ? error.message : '未知错误'})`, { cause: error }); } } export function loadConfig(input: LoadConfigInput = {}): PlatformConfig { const env = input.env ?? process.env; const file = readFileConfig(configFilePath(input)); const provider = (env.APS_LLM_PROVIDER ?? file.llm?.provider ?? 'deepseek') as LlmProvider; const baseUrl = env.APS_LLM_BASE_URL ?? file.llm?.baseUrl ?? DEFAULT_BASE_URLS[provider] ?? ''; const model = env.APS_LLM_MODEL ?? file.llm?.model ?? 'deepseek-chat'; const apiKey = env.APS_LLM_API_KEY ?? file.llm?.apiKey ?? ''; const defaultDbPath = join(input.homeDir ?? homedir(), '.aps', 'aps.sqlite'); const dbPath = env.APS_DB_PATH ?? file.database?.path ?? defaultDbPath; const port = Number.parseInt(env.APS_PORT ?? '', 10); return { llm: { provider, baseUrl, model, apiKey }, database: { path: dbPath }, server: { host: env.APS_HOST ?? file.server?.host ?? '127.0.0.1', port: Number.isFinite(port) ? port : (file.server?.port ?? 5199), }, ...(env.APS_CLIENT_DIST ?? file.clientDist ?? '' ? { clientDist: env.APS_CLIENT_DIST ?? file.clientDist ?? '' } : {}), }; } /** 引擎是否可用:只有配了密钥才算可用 */ export function isEngineConfigured(config: PlatformConfig): boolean { return config.llm.apiKey.trim() !== ''; } /** 脱敏视图:日志与启动横幅只能用它 */ export function redactConfig(config: PlatformConfig): Record { return { llm: { provider: config.llm.provider, baseUrl: config.llm.baseUrl, model: config.llm.model, apiKey: config.llm.apiKey === '' ? '(未配置)' : `(已配置,长度 ${config.llm.apiKey.length})`, }, database: { path: config.database.path }, server: { host: config.server.host, port: config.server.port }, }; }