/** * 平台配置(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 CapabilityConfig { /** 开发期默认 true;接入真实身份后应为 false,由授权表逐条给出 */ defaultAllow: boolean; /** 只读模式:屏蔽写能力 */ denyWrite: boolean; /** 授权表:谁能看到哪些能力(按 id 前缀) */ grants: Array<{ userId?: string; tenantId?: string; allow: string[] }>; } export interface EngineConfig { /** fake = 假引擎(默认);pi = Pi 子进程(A3.3) */ kind: 'fake' | 'pi'; } export interface PlatformConfig { llm: LlmConfig; engine: EngineConfig; capabilities: CapabilityConfig; database: { path: string }; /** 凭据主密钥文件(A4.3):只存密钥,凭据密文在平台库里 */ security: { credentialKeyPath: 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; engine?: Partial; capabilities?: Partial; database?: { path?: string }; security?: { credentialKeyPath?: 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 keyPath = env.APS_CREDENTIAL_KEY_FILE ?? file.security?.credentialKeyPath ?? join(input.homeDir ?? homedir(), '.aps', 'secret.key'); const port = Number.parseInt(env.APS_PORT ?? '', 10); // 引擎选择:显式配置优先;没配就按「有没有密钥」自动决定——没密钥只能用假引擎(ADR-0011) const requestedEngine = (env.APS_ENGINE ?? file.engine?.kind ?? '').toLowerCase(); const engine: EngineConfig = { kind: requestedEngine === 'pi' || requestedEngine === 'fake' ? (requestedEngine as EngineConfig['kind']) : apiKey.trim() !== '' ? 'pi' : 'fake', }; return { llm: { provider, baseUrl, model, apiKey }, engine, capabilities: { // 环境变量优先;1/true 为真,0/false 为假 defaultAllow: !['0', 'false'].includes(String(env.APS_CAPABILITY_DEFAULT_ALLOW ?? file.capabilities?.defaultAllow ?? true).toLowerCase()), denyWrite: ['1', 'true'].includes(String(env.APS_CAPABILITY_DENY_WRITE ?? file.capabilities?.denyWrite ?? false).toLowerCase()), grants: file.capabilities?.grants ?? [], }, database: { path: dbPath }, security: { credentialKeyPath: keyPath }, 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 { engine: { kind: config.engine.kind }, capabilities: { defaultAllow: config.capabilities.defaultAllow, denyWrite: config.capabilities.denyWrite, grants: config.capabilities.grants.length }, 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 }, security: { credentialKeyPath: config.security.credentialKeyPath }, server: { host: config.server.host, port: config.server.port }, }; }