linco-connect 1.0.4 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -35
- package/bin/linco-connect.js +13 -15
- package/package.json +3 -2
- package/src/agents/hermes.js +6 -9
- package/src/config.js +14 -1
- package/src/hermesGateway.js +342 -0
- package/src/serverApp.js +14 -0
package/README.md
CHANGED
|
@@ -106,16 +106,51 @@ linco-connect init \
|
|
|
106
106
|
|
|
107
107
|
### Hermes Agent 初始化
|
|
108
108
|
|
|
109
|
-
Hermes
|
|
109
|
+
Hermes 使用本机 Hermes Gateway。默认情况下,`ddchat-connect` 会在第一次 Hermes 对话前自动检查 Gateway;如果未运行,会参考 Hermes Web UI 的方式自动补齐当前 Hermes profile 的 `platforms.api_server` 配置,生成本机 API key,并执行 `hermes gateway run --replace`。
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
111
|
+
最小配置只需要启用 Hermes:
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{
|
|
115
|
+
"agents": {
|
|
116
|
+
"hermes": {
|
|
117
|
+
"enabled": true
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
如需覆盖默认值,可配置:
|
|
124
|
+
|
|
125
|
+
```json
|
|
126
|
+
{
|
|
127
|
+
"agents": {
|
|
128
|
+
"hermes": {
|
|
129
|
+
"enabled": true,
|
|
130
|
+
"gatewayUrl": "http://127.0.0.1:8642",
|
|
131
|
+
"autoStartGateway": true,
|
|
132
|
+
"hermesBin": "hermes",
|
|
133
|
+
"profile": "default"
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
116
137
|
```
|
|
117
138
|
|
|
118
|
-
|
|
139
|
+
如果已经有外部 Gateway,可关闭自动启动:
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
{
|
|
143
|
+
"agents": {
|
|
144
|
+
"hermes": {
|
|
145
|
+
"enabled": true,
|
|
146
|
+
"gatewayUrl": "http://127.0.0.1:9000",
|
|
147
|
+
"autoStartGateway": false
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
可通过 `linco-connect doctor` 检查 Hermes Gateway 状态;Gateway 未运行但自动启动开启时,发送 Hermes 消息会自动启动。
|
|
119
154
|
|
|
120
155
|
#### 接口鉴权
|
|
121
156
|
|
|
@@ -138,7 +173,7 @@ hermes gateway restart
|
|
|
138
173
|
export LINCO_HERMES_API_KEY=你的API_SERVER_KEY值
|
|
139
174
|
```
|
|
140
175
|
|
|
141
|
-
如果连接时报 `401
|
|
176
|
+
如果连接时报 `401`,请检查 Hermes `api_server.key` 是否与 `LINCO_HERMES_API_KEY` / `agents.hermes.apiKey` 一致。自动启动会保留用户已有 key;如果 key 缺失,会生成新的本机 API key,当前和后续连接器进程会从 Hermes 配置中读取并使用。
|
|
142
177
|
|
|
143
178
|
## 配置文件
|
|
144
179
|
|
|
@@ -356,6 +391,11 @@ outbox 目录: .../outbox
|
|
|
356
391
|
| `LINCO_CLAUDE_ENABLED` | 无配置文件时为启用 | 是否启用 Claude 连接器 |
|
|
357
392
|
| `LINCO_CODEX_ENABLED` | `false` | 是否启用 Codex 连接器 |
|
|
358
393
|
| `LINCO_HERMES_ENABLED` | `false` | 是否启用 Hermes 连接器 |
|
|
394
|
+
| `LINCO_HERMES_GATEWAY_URL` | `http://127.0.0.1:8642` | Hermes Gateway 地址 |
|
|
395
|
+
| `LINCO_HERMES_AUTO_START_GATEWAY` | `true` | Gateway 未运行时是否自动补齐配置并启动 |
|
|
396
|
+
| `LINCO_HERMES_BIN` / `HERMES_BIN` | `hermes` | Hermes CLI 命令或路径 |
|
|
397
|
+
| `LINCO_HERMES_PROFILE` | `default` | Hermes profile 名称 |
|
|
398
|
+
| `LINCO_HERMES_HOME` / `HERMES_HOME` | 自动检测 | Hermes home 目录 |
|
|
359
399
|
| `LINCO_HERMES_API_KEY` | 无 | Hermes Gateway 接口鉴权 Key |
|
|
360
400
|
| `LINCO_CLAUDE_WS_URL` | `wss://chat.ddjf.info/socket/ai/claude` | Claude 端点覆盖 |
|
|
361
401
|
| `LINCO_CODEX_WS_URL` | `wss://chat.ddjf.info/socket/ai/codex` | Codex 端点覆盖 |
|
|
@@ -418,50 +458,41 @@ outbox 目录: .../outbox
|
|
|
418
458
|
|
|
419
459
|
### Hermes 模式报 "fetch failed" 错误
|
|
420
460
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
`ddchat-connect` 通过 HTTP 请求 `http://127.0.0.1:8642/v1/runs` 与 Hermes Gateway 通信,需要确保 `api_server` 平台已启用。
|
|
424
|
-
|
|
425
|
-
**修复方法**:
|
|
461
|
+
默认情况下,`ddchat-connect` 会在第一次 Hermes 对话前自动检查并启动 Hermes Gateway。如果仍然报 **"Hermes 错误: fetch failed"**,通常是 Hermes CLI 不可用、Hermes 自身模型配置异常、端口被占用,或配置了外部 Gateway 但未运行。
|
|
426
462
|
|
|
427
|
-
|
|
463
|
+
先运行:
|
|
428
464
|
|
|
429
465
|
```bash
|
|
430
|
-
|
|
431
|
-
hermes config set platforms.api_server.host 127.0.0.1
|
|
432
|
-
hermes config set platforms.api_server.port 8642
|
|
433
|
-
hermes gateway stop
|
|
434
|
-
hermes gateway run
|
|
466
|
+
linco-connect doctor
|
|
435
467
|
```
|
|
436
468
|
|
|
437
|
-
|
|
469
|
+
排查方向:
|
|
470
|
+
|
|
471
|
+
- 确认终端可执行 `hermes`,或通过 `agents.hermes.hermesBin` / `LINCO_HERMES_BIN` 配置 Hermes 可执行文件路径。
|
|
472
|
+
- 确认 Hermes 自身模型、账号或 provider 配置可用。
|
|
473
|
+
- 确认 `gatewayUrl` 对应端口未被其他程序占用。
|
|
474
|
+
- 如果使用 hermes-web-ui 已启动的 Gateway,确认 `gatewayUrl` 和 `profile` 与 web-ui 当前 Gateway 一致。
|
|
475
|
+
- 如果返回 `401`,确认 `agents.hermes.apiKey` / `LINCO_HERMES_API_KEY` 与 Hermes `api_server.key` 一致。
|
|
438
476
|
|
|
439
|
-
|
|
477
|
+
高级排障时可手动检查 Hermes profile 的 `config.yaml`,Gateway 配置应使用 `extra.host` / `extra.port`:
|
|
440
478
|
|
|
441
479
|
```yaml
|
|
442
480
|
platforms:
|
|
443
|
-
linco:
|
|
444
|
-
enabled: true
|
|
445
481
|
api_server:
|
|
446
482
|
enabled: true
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
```bash
|
|
454
|
-
hermes gateway stop
|
|
455
|
-
hermes gateway run
|
|
483
|
+
key: '<本机 API key>'
|
|
484
|
+
cors_origins: 'http://127.0.0.1:*'
|
|
485
|
+
extra:
|
|
486
|
+
host: 127.0.0.1
|
|
487
|
+
port: 8642
|
|
456
488
|
```
|
|
457
489
|
|
|
458
|
-
|
|
490
|
+
确认 Gateway 健康:
|
|
459
491
|
|
|
460
492
|
```bash
|
|
461
|
-
|
|
493
|
+
curl http://127.0.0.1:8642/health
|
|
462
494
|
```
|
|
463
495
|
|
|
464
|
-
输出应包含 `127.0.0.1:8642` 处于 LISTENING 状态。
|
|
465
496
|
|
|
466
497
|
## 安全注意事项
|
|
467
498
|
|
package/bin/linco-connect.js
CHANGED
|
@@ -15,6 +15,7 @@ const {
|
|
|
15
15
|
saveUserConfig,
|
|
16
16
|
} = require('../src/config');
|
|
17
17
|
const { ensureLocalToken, localUrlWithToken } = require('../src/localAuth');
|
|
18
|
+
const { checkGatewayHealth, resolveHermesGatewayOptions } = require('../src/hermesGateway');
|
|
18
19
|
const pkg = require('../package.json');
|
|
19
20
|
|
|
20
21
|
const rootDir = path.resolve(__dirname, '..');
|
|
@@ -412,9 +413,18 @@ async function doctorCommand() {
|
|
|
412
413
|
for (const [agentType, agent] of Object.entries(config.agents || {})) {
|
|
413
414
|
checks.push([`${agentType} Agent`, true, agent.enabled ? `已启用 ${safeUrlForDisplay(agent.wsUrl)}` : `未启用 ${safeUrlForDisplay(agent.wsUrl)}`]);
|
|
414
415
|
if (agentType === 'hermes') {
|
|
415
|
-
const
|
|
416
|
-
const
|
|
417
|
-
|
|
416
|
+
const options = resolveHermesGatewayOptions(agent);
|
|
417
|
+
const health = await checkGatewayHealth(options.gatewayUrl, agent, { timeoutMs: 3000 });
|
|
418
|
+
if (health.ok) {
|
|
419
|
+
checks.push([`${agentType} Gateway`, true, `${options.gatewayUrl}/health`]);
|
|
420
|
+
} else if (agent.autoStartGateway !== false) {
|
|
421
|
+
const hasCli = !agent.enabled || (path.isAbsolute(options.hermesBin) ? fs.existsSync(options.hermesBin) : commandExists(options.hermesBin));
|
|
422
|
+
checks.push([`${agentType} Gateway`, true, `未运行,发送 Hermes 消息时将自动启动 ${options.gatewayUrl}`]);
|
|
423
|
+
checks.push([`${agentType} CLI`, hasCli, options.hermesBin]);
|
|
424
|
+
checks.push([`${agentType} Profile`, true, options.profileDir]);
|
|
425
|
+
} else {
|
|
426
|
+
checks.push([`${agentType} Gateway`, false, `未运行,且 autoStartGateway=false ${options.gatewayUrl}`]);
|
|
427
|
+
}
|
|
418
428
|
} else {
|
|
419
429
|
checks.push([`${agentType} CLI`, !agent.enabled || (path.isAbsolute(agent.bin) ? fs.existsSync(agent.bin) : commandExists(agent.bin)), agent.bin]);
|
|
420
430
|
}
|
|
@@ -434,18 +444,6 @@ async function doctorCommand() {
|
|
|
434
444
|
if (!ok) process.exitCode = 1;
|
|
435
445
|
}
|
|
436
446
|
|
|
437
|
-
async function checkHermesGateway(gatewayUrl) {
|
|
438
|
-
try {
|
|
439
|
-
const controller = new AbortController();
|
|
440
|
-
const timer = setTimeout(() => controller.abort(), 3000);
|
|
441
|
-
const response = await fetch(gatewayUrl, { signal: controller.signal });
|
|
442
|
-
clearTimeout(timer);
|
|
443
|
-
return response.ok || response.status === 404;
|
|
444
|
-
} catch {
|
|
445
|
-
return false;
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
|
|
449
447
|
function safeUrlForDisplay(value) {
|
|
450
448
|
try {
|
|
451
449
|
const url = new URL(value);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "linco-connect",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"description": "自研 IM 桥接多 Agent 服务",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"node": ">=18"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"ws": "^8.16.0"
|
|
23
|
+
"ws": "^8.16.0",
|
|
24
|
+
"yaml": "^2.9.0"
|
|
24
25
|
}
|
|
25
26
|
}
|
package/src/agents/hermes.js
CHANGED
|
@@ -3,8 +3,7 @@ const { send, sendError, sendSystem } = require('../protocol');
|
|
|
3
3
|
const { persistAgentSessionId, stopAgentProcess: stopSessionProcess, updateAgentSessionHistory, createAgentSessionEntry, saveSessionMetadata } = require('../session');
|
|
4
4
|
const { getOutboxDir } = require('../outgoingAttachmentHandler');
|
|
5
5
|
const { createTextStreamBuffer, appendTextStream, flushTextStream, resetTextStream } = require('../streamBuffer');
|
|
6
|
-
|
|
7
|
-
const DEFAULT_GATEWAY_URL = 'http://127.0.0.1:8642';
|
|
6
|
+
const { DEFAULT_GATEWAY_URL, ensureHermesGateway, normalizeGatewayUrl } = require('../hermesGateway');
|
|
8
7
|
|
|
9
8
|
function extractText(input) {
|
|
10
9
|
if (!Array.isArray(input)) return String(input || '');
|
|
@@ -47,11 +46,12 @@ async function runHermesTurn(input, ws, session, config) {
|
|
|
47
46
|
resetHermesAssistantText(session);
|
|
48
47
|
|
|
49
48
|
const agentConfig = config.agents?.hermes || {};
|
|
50
|
-
const gatewayUrl = normalizeGatewayUrl(agentConfig.gatewayUrl || agentConfig.baseUrl || DEFAULT_GATEWAY_URL);
|
|
51
49
|
const hermesSessionId = ensureHermesSessionId(session);
|
|
52
50
|
const inputWithOutbox = maybeAddOutboxHint(input, session, config);
|
|
53
51
|
|
|
54
52
|
try {
|
|
53
|
+
const gatewayUrl = await ensureHermesGateway(agentConfig, config.logger);
|
|
54
|
+
session.hermesGatewayUrl = gatewayUrl;
|
|
55
55
|
const run = await createRun(gatewayUrl, agentConfig, inputWithOutbox, hermesSessionId);
|
|
56
56
|
if (!run?.run_id) throw new Error('Hermes Gateway 未返回 run_id');
|
|
57
57
|
session.hermesRunId = run.run_id;
|
|
@@ -219,6 +219,7 @@ function finishTurn(ws, session, config, options = {}) {
|
|
|
219
219
|
}
|
|
220
220
|
session.hermesAbortController = null;
|
|
221
221
|
session.hermesRunId = null;
|
|
222
|
+
session.hermesGatewayUrl = null;
|
|
222
223
|
session.isTurnActive = false;
|
|
223
224
|
session.currentInputForNoOutput = null;
|
|
224
225
|
session.pendingPermission = null;
|
|
@@ -362,7 +363,7 @@ async function resolvePendingPermission(approved, ws, session, config) {
|
|
|
362
363
|
if (!pending || pending.provider !== 'hermes') return false;
|
|
363
364
|
|
|
364
365
|
const agentConfig = config.agents?.hermes || {};
|
|
365
|
-
const gatewayUrl = normalizeGatewayUrl(agentConfig.gatewayUrl || agentConfig.baseUrl || DEFAULT_GATEWAY_URL);
|
|
366
|
+
const gatewayUrl = session.hermesGatewayUrl || normalizeGatewayUrl(agentConfig.gatewayUrl || agentConfig.baseUrl || DEFAULT_GATEWAY_URL);
|
|
366
367
|
const runId = pending.runId || session.hermesRunId;
|
|
367
368
|
session.pendingPermission = null;
|
|
368
369
|
|
|
@@ -387,7 +388,7 @@ function stop(session, options = {}) {
|
|
|
387
388
|
} catch {}
|
|
388
389
|
}
|
|
389
390
|
if (runId) {
|
|
390
|
-
const gatewayUrl = normalizeGatewayUrl(agentConfig.gatewayUrl || agentConfig.baseUrl || DEFAULT_GATEWAY_URL);
|
|
391
|
+
const gatewayUrl = session.hermesGatewayUrl || normalizeGatewayUrl(agentConfig.gatewayUrl || agentConfig.baseUrl || DEFAULT_GATEWAY_URL);
|
|
391
392
|
fetchJson(`${gatewayUrl}/v1/runs/${encodeURIComponent(runId)}/stop`, agentConfig, { method: 'POST' }).catch(() => {});
|
|
392
393
|
}
|
|
393
394
|
session.hermesAbortController = null;
|
|
@@ -424,10 +425,6 @@ function drainQueue(ws, session, config) {
|
|
|
424
425
|
setImmediate(() => execute(next, ws, session, config));
|
|
425
426
|
}
|
|
426
427
|
|
|
427
|
-
function normalizeGatewayUrl(value) {
|
|
428
|
-
return String(value || DEFAULT_GATEWAY_URL).replace(/\/+$/, '');
|
|
429
|
-
}
|
|
430
|
-
|
|
431
428
|
function buildHeaders(agentConfig) {
|
|
432
429
|
const headers = { Accept: 'application/json' };
|
|
433
430
|
if (agentConfig.apiKey) headers.Authorization = `Bearer ${agentConfig.apiKey}`;
|
package/src/config.js
CHANGED
|
@@ -267,6 +267,9 @@ function agentConfig(userConfig, imConfig, agentType, defaults = {}) {
|
|
|
267
267
|
const instructionsEnv = `LINCO_${agentType.toUpperCase()}_INSTRUCTIONS`;
|
|
268
268
|
const appIdEnv = `LINCO_${agentType.toUpperCase()}_APP_ID`;
|
|
269
269
|
const appSecretEnv = `LINCO_${agentType.toUpperCase()}_APP_SECRET`;
|
|
270
|
+
const hermesBin = agentType === 'hermes'
|
|
271
|
+
? resolveCommand(stringFromEnv('LINCO_HERMES_BIN', process.env.HERMES_BIN || configured.hermesBin || configured.bin || defaults.hermesBin || defaults.bin || 'hermes'))
|
|
272
|
+
: undefined;
|
|
270
273
|
|
|
271
274
|
return {
|
|
272
275
|
type: agentType,
|
|
@@ -275,9 +278,16 @@ function agentConfig(userConfig, imConfig, agentType, defaults = {}) {
|
|
|
275
278
|
wsUrl: stringFromEnv(wsEnv, configured.wsUrl || defaults.wsUrl),
|
|
276
279
|
mode: stringFromEnv(`LINCO_${agentType.toUpperCase()}_MODE`, configured.mode || defaults.mode),
|
|
277
280
|
model: stringFromEnv(`LINCO_${agentType.toUpperCase()}_MODEL`, configured.model || defaults.model),
|
|
278
|
-
gatewayUrl: stringFromEnv(gatewayEnv, configured.gatewayUrl || defaults.gatewayUrl),
|
|
281
|
+
gatewayUrl: stringFromEnv(gatewayEnv, configured.gatewayUrl || configured.baseUrl || defaults.gatewayUrl),
|
|
279
282
|
apiKey: stringFromEnv(apiKeyEnv, configured.apiKey || defaults.apiKey),
|
|
280
283
|
instructions: stringFromEnv(instructionsEnv, configured.instructions || defaults.instructions),
|
|
284
|
+
...(agentType === 'hermes' ? {
|
|
285
|
+
autoStartGateway: booleanFromEnv('LINCO_HERMES_AUTO_START_GATEWAY', configured.autoStartGateway ?? defaults.autoStartGateway ?? true),
|
|
286
|
+
hermesBin,
|
|
287
|
+
profile: stringFromEnv('LINCO_HERMES_PROFILE', configured.profile || defaults.profile || 'default'),
|
|
288
|
+
hermesHome: stringFromEnv('LINCO_HERMES_HOME', process.env.HERMES_HOME || configured.hermesHome || defaults.hermesHome),
|
|
289
|
+
gatewayStartTimeoutMs: numberFromEnv('LINCO_HERMES_GATEWAY_START_TIMEOUT_MS', configured.gatewayStartTimeoutMs || defaults.gatewayStartTimeoutMs || 15000),
|
|
290
|
+
} : {}),
|
|
281
291
|
appId: stringFromEnv(appIdEnv, channelAccount?.appId || configured.appId),
|
|
282
292
|
appSecret: stringFromEnv(appSecretEnv, channelAccount?.appSecret || configured.appSecret),
|
|
283
293
|
};
|
|
@@ -320,6 +330,9 @@ function loadConfig(rootDir) {
|
|
|
320
330
|
hermes: agentConfig(userConfig, imConfig, 'hermes', {
|
|
321
331
|
wsUrl: hermesAccountConfig.wsUrl || DEFAULT_AGENT_WS_URLS.hermes,
|
|
322
332
|
gatewayUrl: 'http://127.0.0.1:8642',
|
|
333
|
+
autoStartGateway: true,
|
|
334
|
+
hermesBin: 'hermes',
|
|
335
|
+
profile: 'default',
|
|
323
336
|
}),
|
|
324
337
|
};
|
|
325
338
|
const accountAgent = agents[accountAgentType];
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const { spawn } = require('child_process');
|
|
6
|
+
const YAML = require('yaml');
|
|
7
|
+
|
|
8
|
+
const DEFAULT_GATEWAY_URL = 'http://127.0.0.1:8642';
|
|
9
|
+
const DEFAULT_GATEWAY_HOST = '127.0.0.1';
|
|
10
|
+
const DEFAULT_GATEWAY_PORT = 8642;
|
|
11
|
+
const DEFAULT_START_TIMEOUT_MS = 30000;
|
|
12
|
+
const DEFAULT_HEALTH_TIMEOUT_MS = 1500;
|
|
13
|
+
const DEFAULT_POLL_INTERVAL_MS = 500;
|
|
14
|
+
|
|
15
|
+
const bootstrapPromises = new Map();
|
|
16
|
+
const ownedGateways = new Map();
|
|
17
|
+
let cleanupRegistered = false;
|
|
18
|
+
|
|
19
|
+
function normalizeGatewayUrl(value) {
|
|
20
|
+
const raw = String(value || DEFAULT_GATEWAY_URL).trim() || DEFAULT_GATEWAY_URL;
|
|
21
|
+
let parsed;
|
|
22
|
+
try {
|
|
23
|
+
parsed = new URL(raw);
|
|
24
|
+
} catch {
|
|
25
|
+
throw new Error(`Hermes Gateway 地址无效:${raw}`);
|
|
26
|
+
}
|
|
27
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
28
|
+
throw new Error(`Hermes Gateway 地址无效:${raw}`);
|
|
29
|
+
}
|
|
30
|
+
return parsed.toString().replace(/\/+$/, '');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveHermesGatewayOptions(agentConfig = {}) {
|
|
34
|
+
const gatewayUrl = normalizeGatewayUrl(agentConfig.gatewayUrl || agentConfig.baseUrl || DEFAULT_GATEWAY_URL);
|
|
35
|
+
const url = new URL(gatewayUrl);
|
|
36
|
+
const host = url.hostname || DEFAULT_GATEWAY_HOST;
|
|
37
|
+
const port = Number(url.port || (url.protocol === 'https:' ? 443 : DEFAULT_GATEWAY_PORT));
|
|
38
|
+
const profile = agentConfig.profile || process.env.LINCO_HERMES_PROFILE || 'default';
|
|
39
|
+
const profileDir = resolveHermesProfileDir({ ...agentConfig, profile });
|
|
40
|
+
const hermesBin = resolveHermesBin(agentConfig);
|
|
41
|
+
const autoStartGateway = agentConfig.autoStartGateway !== false;
|
|
42
|
+
const startTimeoutMs = Number(agentConfig.gatewayStartTimeoutMs) > 0
|
|
43
|
+
? Number(agentConfig.gatewayStartTimeoutMs)
|
|
44
|
+
: DEFAULT_START_TIMEOUT_MS;
|
|
45
|
+
|
|
46
|
+
return { gatewayUrl, host, port, profile, profileDir, hermesBin, autoStartGateway, startTimeoutMs };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function resolveHermesBin(agentConfig = {}) {
|
|
50
|
+
return process.env.LINCO_HERMES_BIN
|
|
51
|
+
|| process.env.HERMES_BIN
|
|
52
|
+
|| agentConfig.hermesBin
|
|
53
|
+
|| agentConfig.bin
|
|
54
|
+
|| 'hermes';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resolveHermesProfileDir(agentConfig = {}) {
|
|
58
|
+
const baseHome = path.resolve(
|
|
59
|
+
agentConfig.hermesHome
|
|
60
|
+
|| process.env.LINCO_HERMES_HOME
|
|
61
|
+
|| process.env.HERMES_HOME
|
|
62
|
+
|| defaultHermesHome()
|
|
63
|
+
);
|
|
64
|
+
const profile = agentConfig.profile || process.env.LINCO_HERMES_PROFILE || 'default';
|
|
65
|
+
if (!profile || profile === 'default') return baseHome;
|
|
66
|
+
return path.join(baseHome, 'profiles', profile);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function defaultHermesHome() {
|
|
70
|
+
return path.join(os.homedir(), '.hermes');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function ensureHermesGateway(agentConfig = {}, logger) {
|
|
74
|
+
const options = resolveHermesGatewayOptions(agentConfig);
|
|
75
|
+
let health = await checkGatewayHealth(options.gatewayUrl, agentConfig);
|
|
76
|
+
if (health.authFailed && !agentConfig.apiKey) {
|
|
77
|
+
const configKey = readApiServerKey(options.profileDir);
|
|
78
|
+
if (configKey) {
|
|
79
|
+
agentConfig.apiKey = configKey;
|
|
80
|
+
health = await checkGatewayHealth(options.gatewayUrl, agentConfig);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (health.ok) return options.gatewayUrl;
|
|
84
|
+
if (health.authFailed) throw authError();
|
|
85
|
+
if (!options.autoStartGateway) {
|
|
86
|
+
throw new Error(`Hermes Gateway 未运行:${options.gatewayUrl}。当前 autoStartGateway=false,请启动外部 Gateway 或启用自动启动。`);
|
|
87
|
+
}
|
|
88
|
+
if (!isLoopbackHost(options.host)) {
|
|
89
|
+
throw new Error(`Hermes Gateway 自动启动仅支持本机地址:${options.gatewayUrl}。请使用 127.0.0.1/localhost,或设置 autoStartGateway=false 使用外部 Gateway。`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const key = `${options.gatewayUrl}:${options.profileDir}`;
|
|
93
|
+
if (!bootstrapPromises.has(key)) {
|
|
94
|
+
bootstrapPromises.set(key, bootstrapGateway(options, agentConfig, logger).finally(() => {
|
|
95
|
+
bootstrapPromises.delete(key);
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
await bootstrapPromises.get(key);
|
|
99
|
+
return options.gatewayUrl;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function bootstrapGateway(options, agentConfig, logger) {
|
|
103
|
+
logger?.info?.('hermes gateway bootstrap starting', {
|
|
104
|
+
gatewayUrl: options.gatewayUrl,
|
|
105
|
+
profile: options.profile,
|
|
106
|
+
profileDir: options.profileDir,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const apiKey = ensureApiServerConfig({ profileDir: options.profileDir, host: options.host, port: options.port, apiKey: agentConfig.apiKey });
|
|
110
|
+
const gatewayAgentConfig = { ...agentConfig, apiKey };
|
|
111
|
+
if (!agentConfig.apiKey) agentConfig.apiKey = apiKey;
|
|
112
|
+
cleanStaleGatewayLock(options.profileDir, logger);
|
|
113
|
+
const child = startGateway({ hermesBin: options.hermesBin, profileDir: options.profileDir, gatewayUrl: options.gatewayUrl, logger });
|
|
114
|
+
await waitForGatewayReady(options.gatewayUrl, gatewayAgentConfig, {
|
|
115
|
+
timeoutMs: options.startTimeoutMs,
|
|
116
|
+
child,
|
|
117
|
+
});
|
|
118
|
+
logger?.info?.('hermes gateway bootstrap ready', { gatewayUrl: options.gatewayUrl });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function checkGatewayHealth(gatewayUrl, agentConfig = {}, options = {}) {
|
|
122
|
+
const normalized = normalizeGatewayUrl(gatewayUrl);
|
|
123
|
+
const timeoutMs = options.timeoutMs || DEFAULT_HEALTH_TIMEOUT_MS;
|
|
124
|
+
const controller = new AbortController();
|
|
125
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
126
|
+
try {
|
|
127
|
+
const response = await fetch(`${normalized}/health`, {
|
|
128
|
+
method: 'GET',
|
|
129
|
+
headers: buildHealthHeaders(agentConfig),
|
|
130
|
+
signal: controller.signal,
|
|
131
|
+
});
|
|
132
|
+
return {
|
|
133
|
+
ok: response.ok,
|
|
134
|
+
status: response.status,
|
|
135
|
+
authFailed: response.status === 401,
|
|
136
|
+
error: null,
|
|
137
|
+
};
|
|
138
|
+
} catch (err) {
|
|
139
|
+
return { ok: false, status: null, authFailed: false, error: err };
|
|
140
|
+
} finally {
|
|
141
|
+
clearTimeout(timer);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function buildHealthHeaders(agentConfig) {
|
|
146
|
+
const headers = { Accept: 'application/json' };
|
|
147
|
+
if (agentConfig.apiKey) headers.Authorization = `Bearer ${agentConfig.apiKey}`;
|
|
148
|
+
return headers;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function ensureApiServerConfig({ profileDir, host, port, apiKey }) {
|
|
152
|
+
fs.mkdirSync(profileDir, { recursive: true });
|
|
153
|
+
const configFile = path.join(profileDir, 'config.yaml');
|
|
154
|
+
let cfg = {};
|
|
155
|
+
if (fs.existsSync(configFile)) {
|
|
156
|
+
try {
|
|
157
|
+
cfg = YAML.parse(fs.readFileSync(configFile, 'utf8')) || {};
|
|
158
|
+
} catch (err) {
|
|
159
|
+
throw new Error(`无法读取 Hermes 配置文件:${configFile}。${err.message}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) cfg = {};
|
|
164
|
+
cfg.platforms = objectValue(cfg.platforms);
|
|
165
|
+
cfg.platforms.api_server = objectValue(cfg.platforms.api_server);
|
|
166
|
+
const apiServer = cfg.platforms.api_server;
|
|
167
|
+
apiServer.extra = objectValue(apiServer.extra);
|
|
168
|
+
|
|
169
|
+
apiServer.enabled = true;
|
|
170
|
+
if (apiServer.key == null || apiServer.key === '') apiServer.key = apiKey || crypto.randomBytes(24).toString('hex');
|
|
171
|
+
apiServer.cors_origins = apiServer.cors_origins || 'http://127.0.0.1:*';
|
|
172
|
+
apiServer.extra.host = host;
|
|
173
|
+
apiServer.extra.port = port;
|
|
174
|
+
delete apiServer.host;
|
|
175
|
+
delete apiServer.port;
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
fs.writeFileSync(configFile, YAML.stringify(cfg, { lineWidth: 0 }));
|
|
179
|
+
} catch (err) {
|
|
180
|
+
throw new Error(`无法写入 Hermes 配置文件:${configFile}。请检查文件权限。${err.message}`);
|
|
181
|
+
}
|
|
182
|
+
return apiServer.key;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function readApiServerKey(profileDir) {
|
|
186
|
+
const configFile = path.join(profileDir, 'config.yaml');
|
|
187
|
+
if (!fs.existsSync(configFile)) return '';
|
|
188
|
+
try {
|
|
189
|
+
const cfg = YAML.parse(fs.readFileSync(configFile, 'utf8')) || {};
|
|
190
|
+
return cfg.platforms?.api_server?.key || '';
|
|
191
|
+
} catch {
|
|
192
|
+
return '';
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function objectValue(value) {
|
|
197
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function cleanStaleGatewayLock(profileDir, logger) {
|
|
201
|
+
if (process.platform !== 'win32') return;
|
|
202
|
+
const lockFile = path.join(profileDir, 'gateway.lock');
|
|
203
|
+
if (!fs.existsSync(lockFile)) return;
|
|
204
|
+
try {
|
|
205
|
+
const raw = fs.readFileSync(lockFile, 'utf8');
|
|
206
|
+
const lock = JSON.parse(raw || '{}');
|
|
207
|
+
const pid = Number(lock.pid);
|
|
208
|
+
if (pid && isProcessAlive(pid)) return;
|
|
209
|
+
fs.unlinkSync(lockFile);
|
|
210
|
+
} catch (err) {
|
|
211
|
+
logger?.warn?.('failed to clean stale hermes gateway lock', { lockFile, error: err.message });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function startGateway({ hermesBin, profileDir, gatewayUrl, logger }) {
|
|
216
|
+
let child;
|
|
217
|
+
try {
|
|
218
|
+
child = spawn(hermesBin, ['gateway', 'run', '--replace'], {
|
|
219
|
+
stdio: 'ignore',
|
|
220
|
+
windowsHide: true,
|
|
221
|
+
env: {
|
|
222
|
+
...process.env,
|
|
223
|
+
HERMES_HOME: profileDir,
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
} catch (err) {
|
|
227
|
+
if (err.code === 'ENOENT') throw hermesBinError(hermesBin);
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!child.pid) throw hermesBinError(hermesBin);
|
|
232
|
+
const record = { child, gatewayUrl, profileDir, ready: false, earlyExit: null };
|
|
233
|
+
ownedGateways.set(`${gatewayUrl}:${profileDir}`, record);
|
|
234
|
+
registerCleanup();
|
|
235
|
+
|
|
236
|
+
child.once('error', err => {
|
|
237
|
+
record.earlyExit = err;
|
|
238
|
+
if (err.code === 'ENOENT') {
|
|
239
|
+
logger?.error?.('hermes gateway command not found', { hermesBin });
|
|
240
|
+
} else {
|
|
241
|
+
logger?.error?.('hermes gateway process error', { error: err.message });
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
child.once('exit', (code, signal) => {
|
|
245
|
+
if (!record.ready) record.earlyExit = new Error(`exit ${code ?? signal ?? 'unknown'}`);
|
|
246
|
+
logger?.info?.('hermes gateway process exited', { code, signal });
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
return child;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function waitForGatewayReady(gatewayUrl, agentConfig = {}, options = {}) {
|
|
253
|
+
const timeoutMs = options.timeoutMs || DEFAULT_START_TIMEOUT_MS;
|
|
254
|
+
const pollIntervalMs = options.pollIntervalMs || DEFAULT_POLL_INTERVAL_MS;
|
|
255
|
+
const deadline = Date.now() + timeoutMs;
|
|
256
|
+
while (Date.now() < deadline) {
|
|
257
|
+
const ownedRecord = getOwnedGatewayRecord(gatewayUrl);
|
|
258
|
+
if (ownedRecord?.earlyExit) {
|
|
259
|
+
if (ownedRecord.earlyExit.code === 'ENOENT') throw hermesBinError(options.child?.spawnfile || 'hermes');
|
|
260
|
+
throw new Error('Hermes Gateway 启动失败:hermes gateway run --replace 进程提前退出。');
|
|
261
|
+
}
|
|
262
|
+
if (options.child?.exitCode != null || options.child?.signalCode) {
|
|
263
|
+
throw new Error('Hermes Gateway 启动失败:hermes gateway run --replace 进程提前退出。');
|
|
264
|
+
}
|
|
265
|
+
const health = await checkGatewayHealth(gatewayUrl, agentConfig, { timeoutMs: options.healthTimeoutMs || DEFAULT_HEALTH_TIMEOUT_MS });
|
|
266
|
+
if (health.ok) {
|
|
267
|
+
markOwnedGatewayReady(gatewayUrl);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (health.authFailed) throw authError();
|
|
271
|
+
await delay(pollIntervalMs);
|
|
272
|
+
}
|
|
273
|
+
throw new Error(`Hermes Gateway 启动超时:${normalizeGatewayUrl(gatewayUrl)}/health 未就绪。已尝试启用 api_server 并执行 hermes gateway run --replace,请检查 Hermes 配置、模型配置或端口占用。`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function getOwnedGatewayRecord(gatewayUrl) {
|
|
277
|
+
const normalized = normalizeGatewayUrl(gatewayUrl);
|
|
278
|
+
for (const record of ownedGateways.values()) {
|
|
279
|
+
if (record.gatewayUrl === normalized) return record;
|
|
280
|
+
}
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function markOwnedGatewayReady(gatewayUrl) {
|
|
285
|
+
const record = getOwnedGatewayRecord(gatewayUrl);
|
|
286
|
+
if (record) record.ready = true;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function hermesBinError(hermesBin) {
|
|
290
|
+
return new Error(`未找到 ${hermesBin} 命令。请先安装 Hermes Agent,或在 agents.hermes.hermesBin / LINCO_HERMES_BIN 中配置 Hermes 可执行文件路径。`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function isLoopbackHost(host) {
|
|
294
|
+
const value = String(host || '').toLowerCase();
|
|
295
|
+
return value === 'localhost' || value === '127.0.0.1' || value === '::1' || value === '[::1]';
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function authError() {
|
|
299
|
+
return new Error('Hermes Gateway 返回 401。请检查 agents.hermes.apiKey / LINCO_HERMES_API_KEY 是否与 Hermes api_server.key 一致。');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function isProcessAlive(pid) {
|
|
303
|
+
try {
|
|
304
|
+
process.kill(pid, 0);
|
|
305
|
+
return true;
|
|
306
|
+
} catch {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function registerCleanup() {
|
|
312
|
+
if (cleanupRegistered) return;
|
|
313
|
+
cleanupRegistered = true;
|
|
314
|
+
const cleanup = () => {
|
|
315
|
+
for (const record of ownedGateways.values()) {
|
|
316
|
+
try {
|
|
317
|
+
if (record.child && !record.child.killed) record.child.kill();
|
|
318
|
+
} catch {}
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
process.once('exit', cleanup);
|
|
322
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
323
|
+
process.once(signal, () => {
|
|
324
|
+
cleanup();
|
|
325
|
+
process.kill(process.pid, signal);
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function delay(ms) {
|
|
331
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
module.exports = {
|
|
335
|
+
DEFAULT_GATEWAY_URL,
|
|
336
|
+
checkGatewayHealth,
|
|
337
|
+
ensureApiServerConfig,
|
|
338
|
+
ensureHermesGateway,
|
|
339
|
+
normalizeGatewayUrl,
|
|
340
|
+
resolveHermesGatewayOptions,
|
|
341
|
+
resolveHermesProfileDir,
|
|
342
|
+
};
|
package/src/serverApp.js
CHANGED
|
@@ -5,6 +5,18 @@ const { startImConnectors } = require('./imConnector');
|
|
|
5
5
|
const { createLogger } = require('./logger');
|
|
6
6
|
const { attachWebSocketServer } = require('./wsServer');
|
|
7
7
|
|
|
8
|
+
async function prewarmHermesGateway(config) {
|
|
9
|
+
const hermes = config.agents?.hermes;
|
|
10
|
+
if (!hermes?.enabled || hermes.autoStartGateway === false) return;
|
|
11
|
+
try {
|
|
12
|
+
const { ensureHermesGateway } = require('./hermesGateway');
|
|
13
|
+
await ensureHermesGateway(hermes, config.logger);
|
|
14
|
+
config.logger?.info('hermes gateway pre-warmed', { gatewayUrl: hermes.gatewayUrl || 'http://127.0.0.1:8642' });
|
|
15
|
+
} catch (err) {
|
|
16
|
+
config.logger?.warn('hermes gateway pre-warm failed', { error: err.message });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
8
20
|
function startServer(rootDir, options = {}) {
|
|
9
21
|
const config = options.config || loadConfig(rootDir);
|
|
10
22
|
config.logger = options.logger || config.logger || createLogger(config);
|
|
@@ -52,6 +64,7 @@ function startServer(rootDir, options = {}) {
|
|
|
52
64
|
const enabledAgents = Object.entries(config.agents || {}).filter(([, agent]) => agent.enabled).map(([type]) => type).join(', ');
|
|
53
65
|
console.log(` 远端 IM: 已启用 (${config.im.channel}/${config.im.account}; agents: ${enabledAgents || 'none'})`);
|
|
54
66
|
}
|
|
67
|
+
prewarmHermesGateway(config);
|
|
55
68
|
options.onListening?.(server, config);
|
|
56
69
|
});
|
|
57
70
|
|
|
@@ -77,6 +90,7 @@ function startServer(rootDir, options = {}) {
|
|
|
77
90
|
}
|
|
78
91
|
console.log('');
|
|
79
92
|
console.log('💡 如需开启本地测试页,请运行 linco-connect start --local-im');
|
|
93
|
+
prewarmHermesGateway(config);
|
|
80
94
|
options.onListening?.(null, config);
|
|
81
95
|
return null;
|
|
82
96
|
}
|