aps-agent/server/config.ts

173 lines
7.1 KiB
TypeScript
Raw Permalink Normal View History

/**
* 平台配置(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 ProviderInstanceConfig {
/** 包名或路径:平台按它动态 import,包需导出 createProvider(options)(A5/A7 的可插拔位) */
module: string;
/** 该实例的服务地址 */
baseUrl: string;
token?: string;
timeoutMs?: number;
}
export interface PlatformConfig {
llm: LlmConfig;
engine: EngineConfig;
capabilities: CapabilityConfig;
database: { path: string };
/** 凭据主密钥文件(A4.3):只存密钥,凭据密文在平台库里 */
security: { credentialKeyPath: string };
/** 已配置的 Provider 实例(A5):启动时按 module 动态加载并注册 */
providers: ProviderInstanceConfig[];
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 {
providers?: ProviderInstanceConfig[];
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 });
}
}
const DEFAULT_APS_PROVIDER_MODULE = '@aps/provider-aps';
/** 已配置的 Provider 实例:文件配置优先,环境变量可加一个 APS 实例(开发期常用) */
function configuredProviders(env: NodeJS.ProcessEnv, file: FileConfig): ProviderInstanceConfig[] {
const fromFile = file.providers ?? [];
const url = env.APS_PROVIDER_APS_URL;
if (url === undefined || url.trim() === '') return fromFile;
const moduleName = env.APS_PROVIDER_APS_MODULE ?? DEFAULT_APS_PROVIDER_MODULE;
const others = fromFile.filter((provider) => provider.module !== moduleName);
return [ ...others, { module: moduleName, baseUrl: url.trim(), ...(env.APS_PROVIDER_APS_TOKEN === undefined ? {} : { token: env.APS_PROVIDER_APS_TOKEN }) } ];
}
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 },
providers: configuredProviders(env, file),
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 },
providers: config.providers.map((provider) => ({ module: provider.module, baseUrl: provider.baseUrl })),
server: { host: config.server.host, port: config.server.port },
};
}