2026-09-28 16:01:04 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 平台配置(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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-28 20:21:24 +08:00
|
|
|
|
export interface EngineConfig {
|
|
|
|
|
|
/** fake = 假引擎(默认);pi = Pi 子进程(A3.3) */
|
|
|
|
|
|
kind: 'fake' | 'pi';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-28 16:01:04 +08:00
|
|
|
|
export interface PlatformConfig {
|
|
|
|
|
|
llm: LlmConfig;
|
2026-09-28 20:21:24 +08:00
|
|
|
|
engine: EngineConfig;
|
2026-09-28 16:01:04 +08:00
|
|
|
|
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>;
|
2026-09-28 20:21:24 +08:00
|
|
|
|
engine?: Partial<EngineConfig>;
|
2026-09-28 16:01:04 +08:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-09-28 20:21:24 +08:00
|
|
|
|
// 引擎选择:显式配置优先;没配就按「有没有密钥」自动决定——没密钥只能用假引擎(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',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-28 16:01:04 +08:00
|
|
|
|
return {
|
|
|
|
|
|
llm: { provider, baseUrl, model, apiKey },
|
2026-09-28 20:21:24 +08:00
|
|
|
|
engine,
|
2026-09-28 16:01:04 +08:00
|
|
|
|
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 {
|
|
|
|
|
|
llm: {
|
2026-09-28 20:21:24 +08:00
|
|
|
|
engine: { kind: config.engine.kind },
|
2026-09-28 16:01:04 +08:00
|
|
|
|
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 },
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|