147 lines
5.8 KiB
TypeScript
147 lines
5.8 KiB
TypeScript
/**
|
||
* 平台配置(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<string, string> = {
|
||
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<LlmConfig>;
|
||
engine?: Partial<EngineConfig>;
|
||
capabilities?: Partial<CapabilityConfig>;
|
||
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<string, unknown> {
|
||
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 },
|
||
};
|
||
}
|