aps-agent/server/config.ts

141 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 平台配置(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 };
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 };
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);
// 引擎选择:显式配置优先;没配就按「有没有密钥」自动决定——没密钥只能用假引擎(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 },
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 },
server: { host: config.server.host, port: config.server.port },
};
}