feat(config): 模型与端点配置层(ADR-0011,决策 D15 / D16)
server/config.ts 按 ADR-0011 读取配置:环境变量 > ~/.aps/config.json > 默认值;默认 DeepSeek(https://api.deepseek.com/v1,deepseek-chat);密钥只在配置对象里出现一次,日志与启动横幅一律走 redactConfig。.env.example 改为平台自己的变量并写清优先级。serve 启动打印脱敏配置与「模型是否已配置」。数据库默认 ~/.aps/aps.sqlite(决策 D16,驱动用 Node 内置 node:sqlite,实测 22.19 免 flag、零原生依赖)。顺带收紧跨库访问检查的边界:平台自己的 APS_DB_PATH 不算越界(补一条用例锁住),避免误报。测试 47 → 54 全绿。
This commit is contained in:
parent
cd883d9ad7
commit
0e1a1bb5c0
103
.env.example
103
.env.example
|
|
@ -1,86 +1,31 @@
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# APS 智能体 · 环境配置模板(复制为 .env 后填写,.env 已被 gitignore)
|
# 多系统智能体平台 · 环境变量模板
|
||||||
# 密钥只存环境,绝不入库(plan.md §14.4)
|
# 复制为 .env 或写进 ~/.aps/config.json;.env 已被 gitignore
|
||||||
|
# 优先级:环境变量 > 配置文件 > 默认值(ADR-0011)
|
||||||
|
# 密钥只进配置文件或环境变量,绝不入库、不进日志
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
# ---- 大模型 Provider(OpenAI 兼容;配置后开放问答/意图语义更强)----
|
# ---- 模型与端点(ADR-0011;开发与首版用 DeepSeek)----
|
||||||
# 可选:deepseek | kimi | moonshot | doubao | openai | compatible
|
# deepseek | openai | compatible
|
||||||
# 留空或密钥缺失时:快路 + 项目盘点离线作答(仍可分析数据,不甩「听不懂」)
|
APS_LLM_PROVIDER=deepseek
|
||||||
LLM_PROVIDER=
|
APS_LLM_BASE_URL=https://api.deepseek.com/v1
|
||||||
# API 密钥
|
APS_LLM_MODEL=deepseek-chat
|
||||||
LLM_API_KEY=
|
# 也写在 ~/.aps/config.json 的 llm.apiKey 里;这里留空表示未配置模型(平台仍可启动,走假引擎)
|
||||||
# 接口地址(可覆盖默认):
|
APS_LLM_API_KEY=
|
||||||
# deepseek → https://api.deepseek.com/v1
|
|
||||||
# kimi → https://api.moonshot.cn/v1
|
|
||||||
# doubao → https://ark.cn-beijing.volces.com/api/v3 (模型填接入点 ID)
|
|
||||||
# openai → https://api.openai.com/v1
|
|
||||||
# 其它兼容网关 → 填完整 /v1 根路径,LLM_PROVIDER=compatible
|
|
||||||
LLM_BASE_URL=
|
|
||||||
# 模型名:deepseek-chat / moonshot-v1-8k / gpt-4o-mini / 豆包 endpoint id 等
|
|
||||||
LLM_MODEL=
|
|
||||||
|
|
||||||
# ---- 运行形态(桌面 ~/.aps,Web 用仓库 data)----
|
# ---- 配置文件位置(默认 ~/.aps/config.json)----
|
||||||
# desktop | web(默认 web)
|
# APS_CONFIG_FILE=
|
||||||
APS_MODE=web
|
|
||||||
# 显式主目录(覆盖默认:desktop→%USERPROFILE%\.aps,web→server/data)
|
|
||||||
# APS_HOME=
|
|
||||||
# Skill 单文件兼容(测试用);正式为 APS_HOME/skills/<id>/manifest.json
|
|
||||||
# APS_SKILLS_PATH=
|
|
||||||
# Web 端演示数据懒播种:1=租户首次打开工作区且无项目时自动创建演示项目(镜像默认开)
|
|
||||||
# APS_WEB_DEMO_SEED=1
|
|
||||||
|
|
||||||
# ---- 后端服务 ----
|
# ---- 服务端 ----
|
||||||
APS_PORT=8000
|
APS_HOST=127.0.0.1
|
||||||
APS_WORLD_PATH=server/data/world.json
|
APS_PORT=5199
|
||||||
|
# 客户端静态产物目录(默认仓库内 client/app/dist)
|
||||||
|
# APS_CLIENT_DIST=
|
||||||
|
|
||||||
# ---- 用户认证与租户隔离(JMS) ----
|
# ---- 数据库(决策 D16:首版用 SQLite)----
|
||||||
# jms:调用真实 JMS 登录接口;unconfigured:关闭 Web 认证和业务 API
|
# 默认 ~/.aps/aps.sqlite;服务端部署时显式指定
|
||||||
# 统一登录开关:1=启用 Web 登录/桌面授权; 0=本地免登录管理员(仅限开发/演示)
|
# APS_DB_PATH=
|
||||||
APS_AUTH_ENABLED=1
|
|
||||||
APS_AUTH_PROVIDER=jms
|
|
||||||
APS_AUTH_COOKIE_SECURE=0
|
|
||||||
APS_AUTH_TTL_SECONDS=28800
|
|
||||||
# 门禁审批队列与一次性执行凭据(file 默认兼容;database 显式启用共享 MySQL)
|
|
||||||
APS_APPROVAL_BACKEND=file
|
|
||||||
# APS_APPROVAL_PATH=
|
|
||||||
# database 后端必须显式配置 APS_DATABASE_URL,且不得同时配置 APS_APPROVAL_PATH;连接失败不回退 file
|
|
||||||
# SQLite 仅限测试:APS_APPROVAL_DATABASE_ALLOW_SQLITE=1(共享部署禁止使用)
|
|
||||||
APS_APPROVAL_TTL_SECONDS=86400
|
|
||||||
APS_EXECUTION_GRANT_TTL_SECONDS=300
|
|
||||||
APS_APPROVAL_INITIATOR_ROLES=planner,approver,admin
|
|
||||||
APS_APPROVAL_ROLES=planner,approver,admin
|
|
||||||
APS_APPROVAL_VIEW_ROLES=planner,approver,admin,auditor
|
|
||||||
JMS_AUTH_BASE_URL=
|
|
||||||
# 可选:固定为单一租户(部署时写死企业,用户仍可手填名称校验)
|
|
||||||
# 公开租户列表已取消;登录页手填企业名称直接提交
|
|
||||||
JMS_AUTH_TENANT_CODE=
|
|
||||||
JMS_AUTH_TENANT_NAME=
|
|
||||||
# 至少 32 个随机字符,只用于签名本系统的 HttpOnly 会话,不发送给 JMS
|
|
||||||
JMS_AUTH_SESSION_SECRET=
|
|
||||||
# 可选:固定 JMS 登录响应中的 tokenName;配置后会严格校验
|
|
||||||
# JMS_AUTH_TOKEN_NAME=
|
|
||||||
JMS_AUTH_TIMEOUT_SECONDS=10
|
|
||||||
# 可选:企业自己的账号申请页面;未配置时提示联系企业管理员开通
|
|
||||||
# JMS_AUTH_ACCOUNT_REQUEST_URL=https://example.com/account-request
|
|
||||||
|
|
||||||
# ---- 桌面客户端授权 ----
|
# ---- 开发期开关 ----
|
||||||
# Web 用户认证与桌面授权相互独立。正式环境接入授权中心前保持 unconfigured(默认拒绝)。
|
# 1=注册假 Provider(用于联调与演示;默认不注册,能力清单为空)
|
||||||
# mock 仅用于本地桌面开发,内置六类可跨开发设备复用的 APS-*-DEMO 授权码。
|
# APS_DEV_PROVIDER=1
|
||||||
APS_LICENSE_PROVIDER=unconfigured
|
|
||||||
# APS_MOCK_LICENSE_CODES={"MY-HOUR-CODE":"hour","MY-PERM-CODE":"permanent"}
|
|
||||||
# mock 模式必须显式配置随机签名密钥和租户标识;npm run dev:desktop 会为本地开发自动生成。
|
|
||||||
# APS_MOCK_LICENSE_SECRET=
|
|
||||||
# APS_MOCK_LICENSE_TENANT_UUID=
|
|
||||||
|
|
||||||
# ---- 现场数据目录(可选;未配置时不绑定任何机器目录)----
|
|
||||||
# KANGNI_DATA_DIR=
|
|
||||||
# KANGNI_ROUTE_XLSX=
|
|
||||||
# RUIYANG_DEMO_DIR=
|
|
||||||
|
|
||||||
# ---- 数据库 ----
|
|
||||||
# 未配置时继续使用 APS_DB_PATH 指定的 SQLite;接入 jms_mes 后填写 MySQL URL
|
|
||||||
# APS_DATABASE_URL=mysql+pymysql://user:password@127.0.0.1:3306/jms_mes?charset=utf8mb4
|
|
||||||
APS_DB_PATH=server/data/master.db
|
|
||||||
|
|
||||||
# 生产环境填写实际前端来源,逗号分隔
|
|
||||||
APS_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
# ADR-0011:模型与端点(DeepSeek + 本机配置文件)
|
||||||
|
|
||||||
|
- **状态**:已接受
|
||||||
|
- **日期**:2026-09-28
|
||||||
|
- **决策人**:技术负责人
|
||||||
|
- **相关**:任务表 A0(决策 D15);ADR-0006(引擎运行形态);ADR-0009(交付形态)
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
引擎要真跑起来就必须调模型。这件事有两个必须现在定的点:用哪个端点,以及密钥放在哪。密钥的处置还牵动一条硬规则——凭据不进模型上下文、不进日志、不进仓库(架构设计 5.1)。
|
||||||
|
|
||||||
|
## 选项
|
||||||
|
|
||||||
|
| 选项 | 做法 | 代价 | 不选的理由 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| A DeepSeek + 本机配置文件 | 端点用 DeepSeek 的 OpenAI 兼容接口;密钥写在本机配置文件里 | 依赖外部服务可用性 | 选定 |
|
||||||
|
| B 内网自建网关 | 部署兼容网关,统一收口 | 需要运维与卡资源 | 首版没有现成网关;接口按 OpenAI 兼容写,将来换端点只改配置 |
|
||||||
|
| C 密钥进环境变量 | 全部走 env | CI 里方便 | 本地开发与桌面端每次都要设环境变量;env 还会被子进程继承,容易外泄 |
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
选 A,并定下配置的三条规则:
|
||||||
|
|
||||||
|
| 项 | 结论 |
|
||||||
|
| --- | --- |
|
||||||
|
| 端点 | `https://api.deepseek.com/v1`(OpenAI 兼容),模型 `deepseek-chat` |
|
||||||
|
| 密钥位置 | 本机配置文件 `~/.aps/config.json` 的 `llm.apiKey`;也可用 `APS_CONFIG_FILE` 指定其它路径 |
|
||||||
|
| 优先级 | 环境变量 > 配置文件 > 默认值(方便 CI 与临时覆盖) |
|
||||||
|
| 泄露防线 | 密钥不进仓库、不进日志、不进 API 响应;配置对象提供脱敏视图供日志使用 |
|
||||||
|
| 不可用时 | 模型未配置不阻塞平台启动:默认走假引擎,需要真引擎时报明确错误(A3.3 落地) |
|
||||||
|
|
||||||
|
## 影响
|
||||||
|
|
||||||
|
- **对代码**:新增 `server/config.ts`;引擎适配层从它取端点与密钥,不自己读环境
|
||||||
|
- **对任务**:A3.3 子进程接入时把配置注入 Pi 子进程的启动环境;A10.8 发版清单要核对密钥不随包分发
|
||||||
|
- **对文档**:`.env.example` 改为平台自己的变量;术语表无需改
|
||||||
|
- **不这么做的后果**:密钥散在代码与 shell 历史里,且子进程继承全部环境变量,泄露面变大
|
||||||
|
|
||||||
|
## 遗留
|
||||||
|
|
||||||
|
- 生产是否改用内网网关:A10 之前定,届时只改 `llm.baseUrl`
|
||||||
|
- 是否支持多模型切换(同一个平台配置多个端点):首版不做,等有第二个使用场景再评估
|
||||||
|
|
@ -48,5 +48,6 @@ ADR 写完不再修改。要改结论就**新写一份**,并在两份里互相
|
||||||
| [0008](./0008-database-ownership-and-topology.md) | 数据库归属与实例拓扑 | 已接受 | 任务表 A0(决策 D13) |
|
| [0008](./0008-database-ownership-and-topology.md) | 数据库归属与实例拓扑 | 已接受 | 任务表 A0(决策 D13) |
|
||||||
| [0009](./0009-delivery-shape.md) | 交付形态:纯平台包 + APS 单独部署 | 已接受 | 任务表 A0(决策 D14) |
|
| [0009](./0009-delivery-shape.md) | 交付形态:纯平台包 + APS 单独部署 | 已接受 | 任务表 A0(决策 D14) |
|
||||||
| [0010](./0010-aps-dev-shape-in-repo.md) | APS 开发期与本仓库同仓不同目录 | 已接受 | 任务表 B0 |
|
| [0010](./0010-aps-dev-shape-in-repo.md) | APS 开发期与本仓库同仓不同目录 | 已接受 | 任务表 B0 |
|
||||||
|
| [0011](./0011-model-endpoint.md) | 模型与端点:DeepSeek + 本机配置文件 | 已接受 | 任务表 A0(决策 D15) |
|
||||||
|
|
||||||
编号在写之前先在本表登记,避免多人并行时撞号。
|
编号在写之前先在本表登记,避免多人并行时撞号。
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,12 @@ export const APS_TABLES = [
|
||||||
'aps_schedule_results',
|
'aps_schedule_results',
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 指向 APS 库的连接串特征 */
|
/**
|
||||||
export const APS_DATABASE_HINTS = [/aps[_-]?db/i, /database\s*=\s*aps/i, /jms_mes/i];
|
* 指向 APS 库的连接串特征。
|
||||||
|
* 注意边界:平台自己的环境变量名(APS_DB_PATH、APS_DATABASE_URL)不算越界,
|
||||||
|
* 只有真正指向业务库的写法(jms_mes、aps_db 这种库名)才拦。
|
||||||
|
*/
|
||||||
|
export const APS_DATABASE_HINTS = [/aps[_-]?db\b/, /\bjms_mes\b/];
|
||||||
|
|
||||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs']);
|
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs']);
|
||||||
const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'coverage']);
|
const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'coverage']);
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,11 @@ test('负例:平台代码里出现 APS 表名被拦下', async () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('正例:平台自己的 APS_DB_PATH 变量不算越界', async () => {
|
||||||
|
await withFixture({ 'server/config.ts': 'const path = process.env.APS_DB_PATH ?? "~/.aps/aps.sqlite";\nexport const p = path;\n' }, async (dir) => {
|
||||||
|
assert.deepEqual(await findCrossDatabaseAccess(dir), [], '平台自己的库配置不应被当成访问 APS 业务库');
|
||||||
|
});
|
||||||
|
});
|
||||||
test('负例:平台代码里出现 APS 库连接串被拦下', async () => {
|
test('负例:平台代码里出现 APS 库连接串被拦下', async () => {
|
||||||
await withFixture(
|
await withFixture(
|
||||||
{ 'server/app/config.ts': 'export const url = "postgres://user:pw@db/aps_db";\n' },
|
{ 'server/app/config.ts': 'export const url = "postgres://user:pw@db/aps_db";\n' },
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
/** 配置加载(ADR-0011 / 决策 D15、D16)的用例。 */
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
|
||||||
|
import { configFilePath, isEngineConfigured, loadConfig, redactConfig } from '@aps/server/config';
|
||||||
|
|
||||||
|
async function withConfigFile(content, run) {
|
||||||
|
const dir = await mkdtemp(join(tmpdir(), 'aps-config-'));
|
||||||
|
const file = join(dir, 'config.json');
|
||||||
|
try {
|
||||||
|
if (content !== undefined) await writeFile(file, content, 'utf8');
|
||||||
|
return await run({ dir, file });
|
||||||
|
} finally {
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('没有配置文件与环境变量时用默认值:DeepSeek,密钥为空,引擎不可用', async () => {
|
||||||
|
await withConfigFile(undefined, async ({ dir, file }) => {
|
||||||
|
const config = loadConfig({ env: { APS_CONFIG_FILE: file }, homeDir: dir });
|
||||||
|
assert.equal(config.llm.provider, 'deepseek');
|
||||||
|
assert.equal(config.llm.baseUrl, 'https://api.deepseek.com/v1');
|
||||||
|
assert.equal(config.llm.model, 'deepseek-chat');
|
||||||
|
assert.equal(config.llm.apiKey, '');
|
||||||
|
assert.equal(isEngineConfigured(config), false, '没有密钥时引擎不应被视为可用');
|
||||||
|
assert.equal(config.database.path, join(dir, '.aps', 'aps.sqlite'), '默认库在用户目录下');
|
||||||
|
assert.equal(config.server.port, 5199);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('配置文件提供端点与密钥', async () => {
|
||||||
|
const content = JSON.stringify({
|
||||||
|
llm: { provider: 'compatible', baseUrl: 'http://gw.internal/v1', model: 'internal-chat', apiKey: 'sk-file-123' },
|
||||||
|
database: { path: 'D:/data/aps.sqlite' },
|
||||||
|
server: { port: 6000 },
|
||||||
|
});
|
||||||
|
await withConfigFile(content, async ({ dir, file }) => {
|
||||||
|
const config = loadConfig({ env: { APS_CONFIG_FILE: file }, homeDir: dir });
|
||||||
|
assert.equal(config.llm.provider, 'compatible');
|
||||||
|
assert.equal(config.llm.baseUrl, 'http://gw.internal/v1');
|
||||||
|
assert.equal(config.llm.model, 'internal-chat');
|
||||||
|
assert.equal(config.llm.apiKey, 'sk-file-123');
|
||||||
|
assert.equal(isEngineConfigured(config), true);
|
||||||
|
assert.equal(config.database.path, 'D:/data/aps.sqlite');
|
||||||
|
assert.equal(config.server.port, 6000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('环境变量优先于配置文件', async () => {
|
||||||
|
const content = JSON.stringify({ llm: { model: 'from-file', apiKey: 'sk-file' }, server: { port: 6000 } });
|
||||||
|
await withConfigFile(content, async ({ dir, file }) => {
|
||||||
|
const config = loadConfig({
|
||||||
|
env: { APS_CONFIG_FILE: file, APS_LLM_MODEL: 'from-env', APS_PORT: '7000' },
|
||||||
|
homeDir: dir,
|
||||||
|
});
|
||||||
|
assert.equal(config.llm.model, 'from-env');
|
||||||
|
assert.equal(config.llm.apiKey, 'sk-file', '未覆盖的字段仍取配置文件');
|
||||||
|
assert.equal(config.server.port, 7000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('脱敏视图不含密钥明文', async () => {
|
||||||
|
const content = JSON.stringify({ llm: { apiKey: 'sk-secret-abcdef' } });
|
||||||
|
await withConfigFile(content, async ({ dir, file }) => {
|
||||||
|
const config = loadConfig({ env: { APS_CONFIG_FILE: file }, homeDir: dir });
|
||||||
|
const redacted = JSON.stringify(redactConfig(config));
|
||||||
|
assert.ok(!redacted.includes('sk-secret-abcdef'), `脱敏后仍能看到密钥:${redacted}`);
|
||||||
|
assert.ok(redacted.includes('已配置'), redacted);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('配置文件损坏时报错,不静默降级', async () => {
|
||||||
|
await withConfigFile('{ 这不是 JSON', async ({ dir, file }) => {
|
||||||
|
assert.throws(() => loadConfig({ env: { APS_CONFIG_FILE: file }, homeDir: dir }), /配置文件解析失败/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('配置文件路径:默认在用户目录,可用 APS_CONFIG_FILE 覆盖', () => {
|
||||||
|
assert.equal(configFilePath({ env: {}, homeDir: 'C:/Users/test' }).replace(/\\/g, '/'), 'C:/Users/test/.aps/config.json');
|
||||||
|
assert.equal(configFilePath({ env: { APS_CONFIG_FILE: 'D:/cfg/aps.json' }, homeDir: 'C:/Users/test' }), 'D:/cfg/aps.json');
|
||||||
|
});
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
/**
|
/**
|
||||||
* 本地启动平台服务:node server/dist/bin/serve.js(或 pnpm dev:server)。
|
* 本地启动平台服务:node server/dist/bin/serve.js(或 pnpm dev:server)。
|
||||||
* A1.5 会把这条命令与界面、数据库串成一条一键启动。
|
* 配置来源见 ADR-0011:环境变量 > ~/.aps/config.json > 默认值。
|
||||||
*/
|
*/
|
||||||
|
import { loadConfig, isEngineConfigured, redactConfig } from '../config.js';
|
||||||
import { startServer } from '../index.js';
|
import { startServer } from '../index.js';
|
||||||
|
|
||||||
const port = Number.parseInt(process.env.APS_PORT ?? '5199', 10);
|
const config = loadConfig();
|
||||||
const host = process.env.APS_HOST ?? '127.0.0.1';
|
const server = await startServer({ host: config.server.host, port: config.server.port });
|
||||||
|
|
||||||
const server = await startServer({ host, port });
|
|
||||||
|
|
||||||
console.log(`平台服务已启动:${server.url}(协议 v1)`);
|
console.log(`平台服务已启动:${server.url}(协议 v1)`);
|
||||||
console.log(`健康检查:${server.url}/v1/health`);
|
console.log(`健康检查:${server.url}/v1/health`);
|
||||||
|
console.log(`配置:${JSON.stringify(redactConfig(config))}`);
|
||||||
|
console.log(isEngineConfigured(config) ? '模型:已配置(真引擎在 A3.3 接入)' : '模型:未配置,当前走假引擎');
|
||||||
|
|
||||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||||
process.on(signal, () => {
|
process.on(signal, () => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
/**
|
||||||
|
* 平台配置(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 PlatformConfig {
|
||||||
|
llm: LlmConfig;
|
||||||
|
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>;
|
||||||
|
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);
|
||||||
|
|
||||||
|
return {
|
||||||
|
llm: { provider, baseUrl, model, apiKey },
|
||||||
|
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: {
|
||||||
|
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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import type { Server } from 'node:http';
|
||||||
import type { AddressInfo } from 'node:net';
|
import type { AddressInfo } from 'node:net';
|
||||||
|
|
||||||
import type { AgentRuntime } from './agent/runtime.js';
|
import type { AgentRuntime } from './agent/runtime.js';
|
||||||
|
import { loadConfig } from './config.js';
|
||||||
import { ApplicationService } from './app/service.js';
|
import { ApplicationService } from './app/service.js';
|
||||||
import { createHttpServer } from './entry/http.js';
|
import { createHttpServer } from './entry/http.js';
|
||||||
|
|
||||||
|
|
@ -33,8 +34,9 @@ export interface RunningServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startServer(options: StartOptions = {}): Promise<RunningServer> {
|
export async function startServer(options: StartOptions = {}): Promise<RunningServer> {
|
||||||
const host = options.host ?? '127.0.0.1';
|
const config = loadConfig();
|
||||||
const port = options.port ?? 5199;
|
const host = options.host ?? config.server.host;
|
||||||
|
const port = options.port ?? config.server.port;
|
||||||
const service = new ApplicationService({
|
const service = new ApplicationService({
|
||||||
...(options.version === undefined ? {} : { version: options.version }),
|
...(options.version === undefined ? {} : { version: options.version }),
|
||||||
...(options.runtime === undefined ? {} : { runtime: options.runtime }),
|
...(options.runtime === undefined ? {} : { runtime: options.runtime }),
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"default": "./dist/index.js"
|
"default": "./dist/index.js"
|
||||||
},
|
},
|
||||||
|
"./config": {
|
||||||
|
"types": "./dist/config.d.ts",
|
||||||
|
"default": "./dist/config.js"
|
||||||
|
},
|
||||||
"./agent/runtime": {
|
"./agent/runtime": {
|
||||||
"types": "./dist/agent/runtime.d.ts",
|
"types": "./dist/agent/runtime.d.ts",
|
||||||
"default": "./dist/agent/runtime.js"
|
"default": "./dist/agent/runtime.js"
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"tsBuildInfoFile": "dist/.tsbuildinfo",
|
"tsBuildInfoFile": "dist/.tsbuildinfo",
|
||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["index.ts", "bin", "entry", "app", "agent", "core", "workflows"],
|
"include": ["index.ts", "config.ts", "bin", "entry", "app", "agent", "core", "workflows"],
|
||||||
"exclude": ["provider-sdk"],
|
"exclude": ["provider-sdk"],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "../contracts" },
|
{ "path": "../contracts" },
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue