@bolloon/bolloon-agent 0.2.12 → 0.2.13
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 +495 -467
- package/bin/bolloon-cli.cjs +183 -183
- package/bin/bolloon-daemon.sh +207 -0
- package/bin/bolloon.cjs +0 -0
- package/bin/ipfs +0 -0
- package/dist/agents/agent-manifest-protocol.js +52 -0
- package/dist/agents/chat-segmenter.js +298 -0
- package/dist/agents/constraint-layer.js +19 -19
- package/dist/agents/judgment-protocol.js +479 -0
- package/dist/agents/parse-tool-call.js +304 -0
- package/dist/agents/peer-manifest-loader.js +210 -0
- package/dist/agents/pi-sdk-session-factory.js +110 -0
- package/dist/agents/pi-sdk-session-manager.js +297 -0
- package/dist/agents/pi-sdk-tools.js +1288 -0
- package/dist/agents/pi-sdk-types.js +13 -0
- package/dist/agents/pi-sdk.js +553 -1306
- package/dist/agents/react-loop.js +120 -0
- package/dist/agents/session-store.js +171 -0
- package/dist/agents/shell-guard.js +2 -2
- package/dist/agents/tool-registry.js +136 -0
- package/dist/agents/workflow-pivot-loop.js +200 -22
- package/dist/bollharness/src/scripts/context_router.js +7 -0
- package/dist/bollharness/src/scripts/deploy-guard.js +8 -8
- package/dist/bollharness-integration/context-router-judgment.js +4 -4
- package/dist/bollharness-integration/context-router.js +292 -292
- package/dist/bollharness-integration/gate-state-machine.js +26 -13
- package/dist/bollharness-integration/integration.js +71 -0
- package/dist/bollharness-integration/llm-judgment-engine.js +134 -134
- package/dist/bollharness-integration/skill-adapter.js +57 -127
- package/dist/bootstrap/chat-archiver.js +276 -0
- package/dist/bootstrap/context-collector.js +6 -3
- package/dist/bootstrap/event-log.js +160 -0
- package/dist/bootstrap/lifecycle-hooks.js +15 -1
- package/dist/bootstrap/memory-compressor.js +170 -0
- package/dist/bootstrap/persona-loader.js +94 -0
- package/dist/bootstrap/project-state.js +159 -0
- package/dist/bootstrap/remote-mirror.js +97 -0
- package/dist/bootstrap/session-window.js +121 -0
- package/dist/bootstrap/vector-index.js +248 -0
- package/dist/cli/loading-tui.js +40 -0
- package/dist/cli-entry.js +41 -37
- package/dist/context-compaction/auto-compact.js +7 -7
- package/dist/electron/config.js +21 -0
- package/dist/electron/config.js.map +1 -0
- package/dist/electron/dialogs.js +108 -0
- package/dist/electron/dialogs.js.map +1 -0
- package/dist/electron/first-run.js +170 -0
- package/dist/electron/first-run.js.map +1 -0
- package/dist/electron/ipc.js +20 -0
- package/dist/electron/ipc.js.map +1 -0
- package/dist/electron/logger.js +114 -0
- package/dist/electron/logger.js.map +1 -0
- package/dist/electron/main.js +63 -0
- package/dist/electron/main.js.map +1 -0
- package/dist/electron/menu.js +145 -0
- package/dist/electron/menu.js.map +1 -0
- package/dist/electron/paths.js +75 -0
- package/dist/electron/paths.js.map +1 -0
- package/dist/electron/server.js +119 -0
- package/dist/electron/server.js.map +1 -0
- package/dist/electron/tray.js +111 -0
- package/dist/electron/tray.js.map +1 -0
- package/dist/electron/window.js +111 -0
- package/dist/electron/window.js.map +1 -0
- package/dist/electron-preload.js +26 -6
- package/dist/electron-preload.js.map +1 -1
- package/dist/electron.js +6 -168
- package/dist/electron.js.map +1 -1
- package/dist/index.js +323 -266
- package/dist/llm/config-store.js +24 -8
- package/dist/llm/llm-judgment-client.js +102 -102
- package/dist/llm/pi-ai.js +137 -85
- package/dist/llm/system-prompt/layers/channel/local.md +14 -14
- package/dist/llm/system-prompt/layers/channel/p2p-agent.md +18 -18
- package/dist/llm/system-prompt/layers/channel/p2p-visitor.md +19 -19
- package/dist/llm/system-prompt/layers/core/artifacts_storage.md +89 -89
- package/dist/llm/system-prompt/layers/core/evenhandedness.md +21 -21
- package/dist/llm/system-prompt/layers/core/hibs_reminders.md +15 -15
- package/dist/llm/system-prompt/layers/core/identity.md +37 -37
- package/dist/llm/system-prompt/layers/core/knowledge.md +17 -17
- package/dist/llm/system-prompt/layers/core/memory_system.md +12 -12
- package/dist/llm/system-prompt/layers/core/network_filesystem.md +28 -28
- package/dist/llm/system-prompt/layers/core/refusal.md +37 -37
- package/dist/llm/system-prompt/layers/core/tone.md +31 -31
- package/dist/llm/system-prompt/layers/core/tools.thin.md +13 -13
- package/dist/llm/system-prompt/layers/core/wellbeing.md +41 -41
- package/dist/llm/system-prompt/layers/role/architect.md +20 -20
- package/dist/llm/system-prompt/layers/role/expert.md +19 -19
- package/dist/llm/system-prompt/layers/role/implementer.md +15 -15
- package/dist/llm/system-prompt/layers/role/security.md +15 -15
- package/dist/llm/system-prompt/layers/tool/artifacts.md +72 -72
- package/dist/llm/system-prompt/layers/tool/bash.md +25 -25
- package/dist/llm/system-prompt/layers/tool/hibs_api.md +171 -171
- package/dist/llm/system-prompt/layers/tool/image_search.md +70 -70
- package/dist/llm/system-prompt/layers/tool/manifest.md +89 -89
- package/dist/llm/system-prompt/layers/tool/mcp_apps.md +53 -53
- package/dist/llm/system-prompt/layers/tool/web_search.md +83 -83
- package/dist/llm/tool-manifest/ask_user_input.js +1 -1
- package/dist/llm/tool-manifest/bash.js +3 -3
- package/dist/llm/tool-manifest/create_file.js +4 -4
- package/dist/llm/tool-manifest/fetch_sports_data.js +3 -3
- package/dist/llm/tool-manifest/image_search.js +2 -2
- package/dist/llm/tool-manifest/mcp.js +2 -2
- package/dist/llm/tool-manifest/message_compose.js +3 -3
- package/dist/llm/tool-manifest/places.js +2 -2
- package/dist/llm/tool-manifest/present_files.js +1 -1
- package/dist/llm/tool-manifest/recipe.js +2 -2
- package/dist/llm/tool-manifest/str_replace.js +5 -5
- package/dist/llm/tool-manifest/view.js +3 -3
- package/dist/llm/tool-manifest/weather.js +4 -4
- package/dist/llm/tool-manifest/web.js +4 -4
- package/dist/network/local-inbox-bus.js +73 -0
- package/dist/network/p2p-outbox.js +161 -0
- package/dist/network/peer-fs.js +420 -0
- package/dist/network/peer-resource-bridge.js +216 -0
- package/dist/pi-ecosystem-judgment/causal-judge.js +51 -51
- package/dist/pi-ecosystem-judgment/cleanup.js +126 -0
- package/dist/pi-ecosystem-judgment/distill-prompt.js +42 -42
- package/dist/pi-ecosystem-judgment/distillation.js +14 -14
- package/dist/pi-ecosystem-judgment/human-value-store.js +45 -11
- package/dist/pi-ecosystem-judgment/monitor-gate.js +28 -28
- package/dist/pi-ecosystem-judgment/value-injection.js +102 -102
- package/dist/security/context-router-tool.js +15 -15
- package/dist/security/tool-gate.js +11 -0
- package/dist/web/api-config.html +520 -520
- package/dist/web/client-loop-status.js +220 -0
- package/dist/web/client.js +22 -5
- package/dist/web/client.js.map +7 -0
- package/dist/web/components/wallet-viem.mjs +118 -118
- package/dist/web/index.html +375 -375
- package/dist/web/input-validator.js +103 -0
- package/dist/web/manifest.json +20 -20
- package/dist/web/routes-judgments.js +738 -0
- package/dist/web/routes-llm-config.js +279 -0
- package/dist/web/routes-tasks.js +222 -0
- package/dist/web/server-sse.js +121 -0
- package/dist/web/server-storage.js +154 -0
- package/dist/web/server-types.js +15 -0
- package/dist/web/server-v3-p2p.js +224 -0
- package/dist/web/server.js +1070 -1307
- package/dist/web/style.css +4742 -4742
- package/dist/web/util/safe-name.js +32 -0
- package/package.json +162 -160
- package/scripts/build-cli.js +215 -215
- package/scripts/build-web.ts +130 -130
- package/scripts/postinstall.js +152 -152
- package/bin/bolloon.js +0 -157
- package/dist/constraint-runtime/src/_archive_helper.js +0 -9
- package/dist/constraint-runtime/src/agent/coordinator.js +0 -48
- package/dist/constraint-runtime/src/agent/index.js +0 -1
- package/dist/constraint-runtime/src/assistant/index.js +0 -12
- package/dist/constraint-runtime/src/bootstrap/index.js +0 -12
- package/dist/constraint-runtime/src/bootstrap_graph.js +0 -13
- package/dist/constraint-runtime/src/bridge/index.js +0 -12
- package/dist/constraint-runtime/src/buddy/index.js +0 -12
- package/dist/constraint-runtime/src/cli/index.js +0 -12
- package/dist/constraint-runtime/src/command_graph.js +0 -10
- package/dist/constraint-runtime/src/commands.js +0 -60
- package/dist/constraint-runtime/src/components/index.js +0 -12
- package/dist/constraint-runtime/src/constants/index.js +0 -12
- package/dist/constraint-runtime/src/constraint/budget.js +0 -22
- package/dist/constraint-runtime/src/constraint/index.js +0 -2
- package/dist/constraint-runtime/src/constraint/permission.js +0 -20
- package/dist/constraint-runtime/src/context.js +0 -30
- package/dist/constraint-runtime/src/coordinator/index.js +0 -12
- package/dist/constraint-runtime/src/cost_hook.js +0 -4
- package/dist/constraint-runtime/src/cost_tracker.js +0 -8
- package/dist/constraint-runtime/src/deferred_init.js +0 -10
- package/dist/constraint-runtime/src/direct_modes.js +0 -6
- package/dist/constraint-runtime/src/dynamic-tool-loader.js +0 -85
- package/dist/constraint-runtime/src/entrypoints/index.js +0 -12
- package/dist/constraint-runtime/src/execution_registry.js +0 -44
- package/dist/constraint-runtime/src/history.js +0 -9
- package/dist/constraint-runtime/src/hooks/index.js +0 -12
- package/dist/constraint-runtime/src/index.js +0 -26
- package/dist/constraint-runtime/src/ink.js +0 -4
- package/dist/constraint-runtime/src/keybindings/index.js +0 -12
- package/dist/constraint-runtime/src/memdir/index.js +0 -12
- package/dist/constraint-runtime/src/migrations/index.js +0 -12
- package/dist/constraint-runtime/src/models.js +0 -1
- package/dist/constraint-runtime/src/moreright/index.js +0 -12
- package/dist/constraint-runtime/src/native_ts/index.js +0 -12
- package/dist/constraint-runtime/src/output_styles/index.js +0 -12
- package/dist/constraint-runtime/src/parity_audit.js +0 -12
- package/dist/constraint-runtime/src/plugins/index.js +0 -12
- package/dist/constraint-runtime/src/port_manifest.js +0 -11
- package/dist/constraint-runtime/src/prefetch.js +0 -9
- package/dist/constraint-runtime/src/query.js +0 -1
- package/dist/constraint-runtime/src/remote/index.js +0 -12
- package/dist/constraint-runtime/src/remote_runtime.js +0 -9
- package/dist/constraint-runtime/src/runtime/index.js +0 -1
- package/dist/constraint-runtime/src/runtime/session.js +0 -35
- package/dist/constraint-runtime/src/schemas/index.js +0 -12
- package/dist/constraint-runtime/src/screens/index.js +0 -12
- package/dist/constraint-runtime/src/server/index.js +0 -12
- package/dist/constraint-runtime/src/services/index.js +0 -12
- package/dist/constraint-runtime/src/session_store.js +0 -22
- package/dist/constraint-runtime/src/setup.js +0 -30
- package/dist/constraint-runtime/src/skills/index.js +0 -1
- package/dist/constraint-runtime/src/skills/skill-registry.js +0 -28
- package/dist/constraint-runtime/src/state/index.js +0 -12
- package/dist/constraint-runtime/src/system_init.js +0 -20
- package/dist/constraint-runtime/src/thinking/engine.js +0 -42
- package/dist/constraint-runtime/src/thinking/index.js +0 -1
- package/dist/constraint-runtime/src/tool_pool.js +0 -8
- package/dist/constraint-runtime/src/tools/OpenCLI/execAdapter.js +0 -7
- package/dist/constraint-runtime/src/tools/OpenCLI/listAdapters.js +0 -7
- package/dist/constraint-runtime/src/tools/OpenCLI/runCommand.js +0 -7
- package/dist/constraint-runtime/src/tools/SafeSDK/confirmTransaction.js +0 -6
- package/dist/constraint-runtime/src/tools/SafeSDK/createTransaction.js +0 -8
- package/dist/constraint-runtime/src/tools/SafeSDK/executeTransaction.js +0 -6
- package/dist/constraint-runtime/src/tools/SafeSDK/getBalance.js +0 -6
- package/dist/constraint-runtime/src/tools/SafeSDK/getPendingTransactions.js +0 -6
- package/dist/constraint-runtime/src/tools/SafeSDK/proposeTransaction.js +0 -6
- package/dist/constraint-runtime/src/tools.js +0 -80
- package/dist/constraint-runtime/src/transcript.js +0 -19
- package/dist/constraint-runtime/src/types/index.js +0 -12
- package/dist/constraint-runtime/src/upstream_proxy/index.js +0 -12
- package/dist/constraint-runtime/src/utils/index.js +0 -12
- package/dist/constraint-runtime/src/vim/index.js +0 -12
- package/dist/constraint-runtime/src/voice/index.js +0 -12
- package/dist/constraint-runtime/tests/agent.test.js +0 -16
- package/dist/constraint-runtime/tests/constraint.test.js +0 -41
- package/dist/constraint-runtime/tests/skill.test.js +0 -19
- package/dist/constraint-runtime/tests/thinking.test.js +0 -22
- package/dist/pi-ecosystem-colony/index.js +0 -365
- package/dist/social/ant-colony/AdaptiveHeartbeat.js +0 -101
- package/dist/social/ant-colony/PheromoneEngine.js +0 -227
- package/dist/social/ant-colony/index.js +0 -6
- package/dist/social/ant-colony/types.js +0 -24
- package/dist/test/ai-judgment-test.js +0 -80
- package/dist/test/bollharness-integration.test.js +0 -318
- package/dist/test/channel-agent-multi-dialogue.js +0 -205
- package/dist/test/channel-heartbeat-agent-test.js +0 -201
- package/dist/test/constraint-layer.test.js +0 -164
- package/dist/test/diap-identity-test.js +0 -172
- package/dist/test/diap-quick-test.js +0 -62
- package/dist/test/global-shared-context.test.js +0 -315
- package/dist/test/harness-judgment-injection.test.js +0 -246
- package/dist/test/harness-workflow-integrator-test.js +0 -228
- package/dist/test/human-value-store.test.js +0 -243
- package/dist/test/hybrid-integration-test.js +0 -118
- package/dist/test/hybrid-messenger-verify.js +0 -55
- package/dist/test/iroh-bistream-debug.js +0 -38
- package/dist/test/iroh-communication.test.js +0 -66
- package/dist/test/iroh-debug-test.js +0 -57
- package/dist/test/iroh-diap-test.js +0 -71
- package/dist/test/iroh-direct-connect.js +0 -55
- package/dist/test/iroh-e2e-fixed.js +0 -89
- package/dist/test/iroh-e2e-same-process.js +0 -63
- package/dist/test/iroh-e2e.js +0 -66
- package/dist/test/iroh-final-e2e.js +0 -72
- package/dist/test/iroh-relay-test.js +0 -37
- package/dist/test/iroh-simple-test.js +0 -41
- package/dist/test/iroh-transport-verify.js +0 -54
- package/dist/test/iroh-transport.test.js +0 -37
- package/dist/test/iroh-two-nodes.js +0 -70
- package/dist/test/iroh-verify.js +0 -44
- package/dist/test/judgment-decision.test.js +0 -219
- package/dist/test/llm-judgment-integration.test.js +0 -220
- package/dist/test/p2p-agent-complex-dialogue.js +0 -385
- package/dist/test/p2p-agent-dialogue.js +0 -341
- package/dist/test/p2p-agent-full-bidirectional.js +0 -510
- package/dist/test/p2p-agent-harness-flow.js +0 -437
- package/dist/test/p2p-agent-harness-single.js +0 -143
- package/dist/test/p2p-ai-dialogue-test.js +0 -318
- package/dist/test/p2p-cid-connect-test.js +0 -195
- package/dist/test/p2p-connect-receiver.js +0 -69
- package/dist/test/p2p-doc-transfer.js +0 -110
- package/dist/test/p2p-identity-page-test.js +0 -77
- package/dist/test/p2p-iroh-test.js +0 -171
- package/dist/test/p2p-minimal-test.js +0 -241
- package/dist/test/p2p-node-1.js +0 -148
- package/dist/test/p2p-node-2.js +0 -148
- package/dist/test/p2p-server.js +0 -281
- package/dist/test/p2p-two-nodes-test.js +0 -438
- package/dist/test/pi-sdk.test.js +0 -44
- package/dist/test/set-persona.js +0 -40
- package/dist/test/simple.test.js +0 -9
- package/dist/test/storage-integration.test.js +0 -150
- package/dist/test/subagent-manager.test.js +0 -276
- package/dist/test/test-gate-flow.test.js +0 -81
- package/dist/test/workflow-engine.test.js +0 -87
- package/dist/test/workflow-pivot-loop.test.js +0 -246
package/dist/web/server.js
CHANGED
|
@@ -6,6 +6,41 @@ import * as fs from 'fs/promises';
|
|
|
6
6
|
import * as fsSync from 'fs';
|
|
7
7
|
import * as path from 'path';
|
|
8
8
|
import * as os from 'os';
|
|
9
|
+
import * as crypto from 'crypto';
|
|
10
|
+
import { validateMessageInput, validateChannelInput, healthCheck, } from './input-validator.js';
|
|
11
|
+
import { segmentChatReply } from '../agents/chat-segmenter.js';
|
|
12
|
+
import { registerJudgmentsRoutes } from './routes-judgments.js';
|
|
13
|
+
import { registerLlmConfigRoutes } from './routes-llm-config.js';
|
|
14
|
+
import { registerTaskRoutes } from './routes-tasks.js';
|
|
15
|
+
// 2026-07-06: 类型抽到 ./server-types.ts (channel / session / task / sse client / iroh info / paths)
|
|
16
|
+
import { SESSION_CACHE_PATH, IPFS_ENDPOINT, } from './server-types.js';
|
|
17
|
+
// 同时也 re-export 出去 (其它地方可能从 './server.js' 引用)
|
|
18
|
+
export { CHANNELS_PATH, SESSION_CACHE_PATH, THEME_PATH, TASK_QUEUE_PATH, IPFS_ENDPOINT, } from './server-types.js';
|
|
19
|
+
// 读自身 package.json 拿 version (health endpoint 用)
|
|
20
|
+
// 路径: src/web/server.ts → ../../package.json (编译后 dist/web/server.js)
|
|
21
|
+
let cachedVersion = null;
|
|
22
|
+
function getPackageVersion() {
|
|
23
|
+
if (cachedVersion)
|
|
24
|
+
return cachedVersion;
|
|
25
|
+
try {
|
|
26
|
+
const here = fileURLToPath(import.meta.url);
|
|
27
|
+
const hereDir = path.dirname(here);
|
|
28
|
+
// 试 ../package.json (相对 dist/web/) 或 ../../package.json (相对 src/web/)
|
|
29
|
+
let raw = null;
|
|
30
|
+
for (const rel of ['../package.json', '../../package.json']) {
|
|
31
|
+
try {
|
|
32
|
+
raw = fsSync.readFileSync(path.join(hereDir, rel), 'utf-8');
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
catch { }
|
|
36
|
+
}
|
|
37
|
+
cachedVersion = raw ? (JSON.parse(raw).version ?? '0.0.0') : 'unknown';
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
cachedVersion = 'unknown';
|
|
41
|
+
}
|
|
42
|
+
return cachedVersion;
|
|
43
|
+
}
|
|
9
44
|
// 2026-06-17: 终端静默 — server.ts 高频 spam (LLM 流式每个 token 一次 broadcast,
|
|
10
45
|
// 每次 P2P 收发 [v3]/[v3-meta]/[v3-cross]/[v3-friend] 各一次) 全部走 console.log proxy,
|
|
11
46
|
// 默认 (BOLLOON_VERBOSE != '1') 完全不打;VERBOSE=1 时恢复.
|
|
@@ -37,16 +72,15 @@ console.log = (...args) => {
|
|
|
37
72
|
return _origConsoleLog(...args);
|
|
38
73
|
};
|
|
39
74
|
import { createHyperswarmCommunicator, createTopic, KeyManager, AgentAuthManager, } from '@diap/sdk';
|
|
40
|
-
import {
|
|
41
|
-
import { initMinimax, getMinimax } from '../constraints/index.js';
|
|
75
|
+
import { initMinimax } from '../constraints/index.js';
|
|
42
76
|
import { createAgentSession } from '../agents/pi-sdk.js';
|
|
43
|
-
import { llmConfigStore } from '../llm/config-store.js';
|
|
44
|
-
import { videoConfigStore } from '../llm/video-config-store.js';
|
|
45
|
-
import { audioConfigStore } from '../llm/audio-config-store.js';
|
|
46
77
|
import { irohTransport } from '../network/iroh-transport.js';
|
|
47
78
|
import { createAgentDelegateApp } from './agent-delegate-server.js';
|
|
48
79
|
import { createIrohDelegateTransport } from './iroh-delegate-transport.js';
|
|
49
80
|
import { verifyMessage, isAddress, getAddress } from 'viem';
|
|
81
|
+
// 2026-07-05: peer 目录管理 + manifest 协议
|
|
82
|
+
import * as peerFs from '../network/peer-fs.js';
|
|
83
|
+
import { loadLocalResources, writeRemoteResources } from '../network/peer-resource-bridge.js';
|
|
50
84
|
// 前端资源路径: 兼容 src 运行 + dist 运行 + npm 全局安装
|
|
51
85
|
// - src 跑 (tsx): __dirname = .../src/web → .../dist/web
|
|
52
86
|
// - dist 跑 (npm): __dirname = .../dist/web → 自身就是 web 根
|
|
@@ -74,10 +108,7 @@ function resolveWebRoot() {
|
|
|
74
108
|
const webRoot = resolveWebRoot();
|
|
75
109
|
console.log(`[web] webRoot = ${webRoot}`);
|
|
76
110
|
const SHARED_SESSION_PATH = path.join(process.env.HOME || '/tmp', '.bolloon', 'sessions');
|
|
77
|
-
|
|
78
|
-
const CHANNELS_PATH = path.join(SHARED_SESSION_PATH, 'channels.json');
|
|
79
|
-
const THEME_PATH = path.join(SHARED_SESSION_PATH, 'theme.json');
|
|
80
|
-
const IPFS_ENDPOINT = 'http://127.0.0.1:5001';
|
|
111
|
+
// iroh P2P 状态
|
|
81
112
|
let irohNodeInfo = null;
|
|
82
113
|
let irohInitialized = false;
|
|
83
114
|
async function ensureSessionDirs() {
|
|
@@ -101,163 +132,45 @@ function isValidWalletAddress(addr) {
|
|
|
101
132
|
return a;
|
|
102
133
|
return '';
|
|
103
134
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
await fs.writeFile(CHANNELS_PATH, jsonStr);
|
|
128
|
-
// 写盘即令缓存失效: 用 lastChannelsWriteAt 标记, getChannelsWithDID 会检查
|
|
129
|
-
lastChannelsWriteAt = Date.now();
|
|
130
|
-
}
|
|
131
|
-
// 写盘去重: 上次写盘内容, 用于跳过幂等调用
|
|
132
|
-
let lastChannelsJson = '';
|
|
133
|
-
// 模块级: 最近一次 channels.json 写盘时间. saveChannels 在模块顶层,
|
|
134
|
-
// getChannelsWithDID 在 createWebServer 内部, 跨作用域用模块变量桥接.
|
|
135
|
-
let lastChannelsWriteAt = 0;
|
|
136
|
-
async function loadSession(channelId, sessionId) {
|
|
137
|
-
// sessionId is optional for backward compatibility; if provided, load specific session
|
|
138
|
-
const key = sessionId ? `${channelId}:${sessionId}` : channelId;
|
|
139
|
-
const sessionPath = path.join(SESSION_CACHE_PATH, `${key}.json`);
|
|
140
|
-
try {
|
|
141
|
-
// 内存保护: 拒绝加载过大的 session 文件 (> 50MB 视为异常, 避免 OOM)
|
|
142
|
-
const stat = await fs.stat(sessionPath);
|
|
143
|
-
if (stat.size > 50 * 1024 * 1024) {
|
|
144
|
-
console.warn(`[loadSession] session 过大 (${stat.size} bytes): ${key}`);
|
|
145
|
-
return null;
|
|
146
|
-
}
|
|
147
|
-
const data = await fs.readFile(sessionPath, 'utf-8');
|
|
148
|
-
return JSON.parse(data);
|
|
149
|
-
}
|
|
150
|
-
catch {
|
|
151
|
-
return null;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
async function saveSession(session) {
|
|
155
|
-
const key = session.sessionId ? `${session.channelId}:${session.sessionId}` : session.channelId;
|
|
156
|
-
const sessionPath = path.join(SESSION_CACHE_PATH, `${key}.json`);
|
|
157
|
-
await fs.writeFile(sessionPath, JSON.stringify(session, null, 2));
|
|
158
|
-
}
|
|
159
|
-
async function loadTheme() {
|
|
160
|
-
try {
|
|
161
|
-
const data = await fs.readFile(THEME_PATH, 'utf-8');
|
|
162
|
-
return JSON.parse(data);
|
|
163
|
-
}
|
|
164
|
-
catch {
|
|
165
|
-
return { theme: 'light', agentId: '' };
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
async function saveTheme(theme, agentId) {
|
|
169
|
-
await fs.writeFile(THEME_PATH, JSON.stringify({ theme, agentId }, null, 2));
|
|
170
|
-
}
|
|
171
|
-
// ==================== Task Queue & Workflow System ====================
|
|
172
|
-
const TASK_QUEUE_PATH = path.join(SHARED_SESSION_PATH, 'task-queue.json');
|
|
173
|
-
async function loadTaskQueue() {
|
|
135
|
+
// 2026-07-06: loadChannels / saveChannels / loadSession / saveSession / loadTheme / saveTheme
|
|
136
|
+
// 已抽到 ./server-storage.ts — 这里只保留旧引用供 createWebServer 内部闭包使用
|
|
137
|
+
// TODO: 后续让所有调用走 import, 然后删除这段冗余定义
|
|
138
|
+
import { loadChannels as _loadChannels, saveChannels as _saveChannels, loadSession as _loadSession, saveSession as _saveSession, loadTheme as _loadTheme, saveTheme as _saveTheme, getLastChannelsWriteAt, } from './server-storage.js';
|
|
139
|
+
// 包装成闭包内可用的形式, 保持 createWebServer 内代码不用改
|
|
140
|
+
const loadChannels = _loadChannels;
|
|
141
|
+
const saveChannels = _saveChannels;
|
|
142
|
+
const loadSession = _loadSession;
|
|
143
|
+
const saveSession = _saveSession;
|
|
144
|
+
const loadTheme = _loadTheme;
|
|
145
|
+
const saveTheme = _saveTheme;
|
|
146
|
+
// 2026-07-06: Task Queue 已抽到 ./routes-tasks.ts + ./server-storage.ts
|
|
147
|
+
let sseClients = new Set();
|
|
148
|
+
// v3: 远端 channel UI 元数据缓存 — key: peerId, value: sanitize 过的 channel 列表
|
|
149
|
+
// in-memory only, 进程重启清空 (judgment 内容永远不在这里)
|
|
150
|
+
let remoteChannelCache = new Map();
|
|
151
|
+
// 2026-07-05: 一次性 prompt 附加块 — key: channelId, value: 下一次 LLM prompt 时 prepend 的内容
|
|
152
|
+
// 用于 manifest-loader 加载对方能力后, 仅影响本次对话, 不污染主 prompt
|
|
153
|
+
const nextPromptHints = new Map();
|
|
154
|
+
// 2026-07-05: 直接读 ~/.bolloon/agents/agents.json, 跳过 subagent-manager 的 lazy init
|
|
155
|
+
// 原因: getSubAgentManager().getAllAgents() 只在 SubAgentManager.initialize() 后才加载,
|
|
156
|
+
// 但没人调 initialize(), 永远返回 []. 这里用 raw 文件 IO 直接拿数据.
|
|
157
|
+
async function loadLocalSubAgents() {
|
|
174
158
|
try {
|
|
175
|
-
const
|
|
176
|
-
|
|
159
|
+
const fsPromises = await import('fs/promises');
|
|
160
|
+
const path = await import('path');
|
|
161
|
+
const home = process.env.BOLLOON_HOME || process.env.HOME || '/tmp';
|
|
162
|
+
const file = path.join(home, '.bolloon', 'agents', 'agents.json');
|
|
163
|
+
const raw = await fsPromises.readFile(file, 'utf-8');
|
|
164
|
+
const parsed = JSON.parse(raw);
|
|
165
|
+
const arr = Array.isArray(parsed) ? parsed : [];
|
|
166
|
+
process.stderr.write(`[LOADED] ${arr.length} agents from ${file}\n`);
|
|
167
|
+
return arr;
|
|
177
168
|
}
|
|
178
|
-
catch {
|
|
169
|
+
catch (e) {
|
|
170
|
+
process.stderr.write(`[LOAD FAIL] ${e?.message}\n`);
|
|
179
171
|
return [];
|
|
180
172
|
}
|
|
181
173
|
}
|
|
182
|
-
async function saveTaskQueue(tasks) {
|
|
183
|
-
await fs.writeFile(TASK_QUEUE_PATH, JSON.stringify(tasks, null, 2));
|
|
184
|
-
}
|
|
185
|
-
let isExecutingTask = false;
|
|
186
|
-
let executionTaskId = null;
|
|
187
|
-
async function executeTask(task, channelId) {
|
|
188
|
-
if (isExecutingTask)
|
|
189
|
-
return;
|
|
190
|
-
isExecutingTask = true;
|
|
191
|
-
executionTaskId = task.id;
|
|
192
|
-
const agent = await getAgentForChannel(channelId);
|
|
193
|
-
const tasks = await loadTaskQueue();
|
|
194
|
-
const taskIndex = tasks.findIndex(t => t.id === task.id);
|
|
195
|
-
if (taskIndex >= 0) {
|
|
196
|
-
tasks[taskIndex].status = 'running';
|
|
197
|
-
tasks[taskIndex].updatedAt = new Date().toISOString();
|
|
198
|
-
await saveTaskQueue(tasks);
|
|
199
|
-
}
|
|
200
|
-
broadcast({ type: 'task_status', taskId: task.id, status: 'running', progress: 0 }, channelId);
|
|
201
|
-
try {
|
|
202
|
-
let result = '';
|
|
203
|
-
switch (task.type) {
|
|
204
|
-
case 'chat':
|
|
205
|
-
if (task.description) {
|
|
206
|
-
broadcast({ type: 'status', content: `执行任务: ${task.title}` }, channelId);
|
|
207
|
-
result = await agent.prompt(task.description);
|
|
208
|
-
}
|
|
209
|
-
break;
|
|
210
|
-
case 'read':
|
|
211
|
-
if (task.description) {
|
|
212
|
-
broadcast({ type: 'status', content: `读取文档: ${task.description}` }, channelId);
|
|
213
|
-
const content = await documentReader.read(task.description);
|
|
214
|
-
result = `📄 文档读取完成\n\n${content.text.substring(0, 500)}${content.text.length > 500 ? '...' : ''}`;
|
|
215
|
-
}
|
|
216
|
-
break;
|
|
217
|
-
case 'summarize':
|
|
218
|
-
if (task.description) {
|
|
219
|
-
broadcast({ type: 'status', content: `总结文档: ${task.description}` }, channelId);
|
|
220
|
-
const content = await documentReader.read(task.description);
|
|
221
|
-
const llm = getMinimax();
|
|
222
|
-
const summary = await llm.summarize(content.text);
|
|
223
|
-
result = `📝 文档总结:\n\n${summary.summary}`;
|
|
224
|
-
}
|
|
225
|
-
break;
|
|
226
|
-
default:
|
|
227
|
-
result = '未知任务类型';
|
|
228
|
-
}
|
|
229
|
-
// 更新任务状态
|
|
230
|
-
const tasks = await loadTaskQueue();
|
|
231
|
-
const idx = tasks.findIndex(t => t.id === task.id);
|
|
232
|
-
if (idx >= 0) {
|
|
233
|
-
tasks[idx].status = 'completed';
|
|
234
|
-
tasks[idx].progress = 100;
|
|
235
|
-
tasks[idx].result = result;
|
|
236
|
-
tasks[idx].updatedAt = new Date().toISOString();
|
|
237
|
-
await saveTaskQueue(tasks);
|
|
238
|
-
}
|
|
239
|
-
broadcast({ type: 'task_status', taskId: task.id, status: 'completed', progress: 100, result }, channelId);
|
|
240
|
-
broadcast({ type: 'ai', content: result }, channelId);
|
|
241
|
-
}
|
|
242
|
-
catch (error) {
|
|
243
|
-
const tasks = await loadTaskQueue();
|
|
244
|
-
const idx = tasks.findIndex(t => t.id === task.id);
|
|
245
|
-
if (idx >= 0) {
|
|
246
|
-
tasks[idx].status = 'failed';
|
|
247
|
-
tasks[idx].error = error.message;
|
|
248
|
-
tasks[idx].updatedAt = new Date().toISOString();
|
|
249
|
-
await saveTaskQueue(tasks);
|
|
250
|
-
}
|
|
251
|
-
broadcast({ type: 'task_status', taskId: task.id, status: 'failed', error: error.message }, channelId);
|
|
252
|
-
broadcast({ type: 'error', content: `任务执行失败: ${error.message}` }, channelId);
|
|
253
|
-
}
|
|
254
|
-
isExecutingTask = false;
|
|
255
|
-
executionTaskId = null;
|
|
256
|
-
}
|
|
257
|
-
let sseClients = new Set();
|
|
258
|
-
// v3: 远端 channel UI 元数据缓存 — key: peerId, value: sanitize 过的 channel 列表
|
|
259
|
-
// in-memory only, 进程重启清空 (judgment 内容永远不在这里)
|
|
260
|
-
let remoteChannelCache = new Map();
|
|
261
174
|
// 2026-06-10: 持久化 remote channel cache 到 ~/.bolloon/remote-channels-cache.json
|
|
262
175
|
// 之前是纯内存 Map, nodeA 重启后所有对端 channel 列表丢失, 需要等对面再推一次
|
|
263
176
|
const REMOTE_CACHE_FILE = `${process.env.HOME || '/tmp'}/.bolloon/remote-channels-cache.json`;
|
|
@@ -427,22 +340,25 @@ async function routeMentionsInReply(originChannelId, replyText, localChannels, r
|
|
|
427
340
|
continue;
|
|
428
341
|
}
|
|
429
342
|
try {
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
if (ok) {
|
|
343
|
+
const rpcPayload = {
|
|
344
|
+
targetChannelId: remoteTarget.id,
|
|
345
|
+
targetChannelName: remoteTarget.name,
|
|
346
|
+
originChannelId,
|
|
347
|
+
originChannelName,
|
|
348
|
+
text,
|
|
349
|
+
fromPublicKey: v3P2PRef.getPublicKey()
|
|
350
|
+
};
|
|
351
|
+
// 2026-07-05: 用 outbox.sendOrQueue 兜底 — 对方不在线时自动入队, 上线后批量重发
|
|
352
|
+
const { sendOrQueue } = await import('../network/p2p-outbox.js');
|
|
353
|
+
const r = await sendOrQueue(ownerPk, 'agent.cross.post', rpcPayload, v3P2PRef);
|
|
354
|
+
if (r === 'SENT') {
|
|
443
355
|
console.log(`[v3-cross] (${originChannelName}) @${targetName} → 远端 peer ${ownerPk.substring(0, 12)}... (channelId=${remoteTarget.id})`);
|
|
444
356
|
results.push({ targetName, targetId: remoteTarget.id, source: 'remote', text, status: 'sent' });
|
|
445
357
|
}
|
|
358
|
+
else if (r === 'QUEUED') {
|
|
359
|
+
console.log(`[v3-cross] (${originChannelName}) @${targetName} → 远端 peer ${ownerPk.substring(0, 12)}... 已入队 (对方不在线)`);
|
|
360
|
+
results.push({ targetName, targetId: remoteTarget.id, source: 'remote', text, status: 'queued' });
|
|
361
|
+
}
|
|
446
362
|
else {
|
|
447
363
|
results.push({ targetName, targetId: remoteTarget.id, source: 'remote', text, status: 'failed' });
|
|
448
364
|
}
|
|
@@ -578,6 +494,28 @@ async function handleV3P2PMessage(parsed, conn, comm) {
|
|
|
578
494
|
catch (saveErr) {
|
|
579
495
|
console.warn(`[v3] 存 user 消息失败 (不影响 chat):`, saveErr.message);
|
|
580
496
|
}
|
|
497
|
+
// 2026-07-05: 同时追加到 sender 的 peer 月度归档 — 让 A 本地的"与 sender 对话历史"也能离线查看
|
|
498
|
+
try {
|
|
499
|
+
const { appendChatArchive } = await import('../bootstrap/chat-archiver.js');
|
|
500
|
+
const chanObj = (await loadChannels()).find(c => c.id === channelId);
|
|
501
|
+
await appendChatArchive({
|
|
502
|
+
publicKey: senderKey,
|
|
503
|
+
entry: {
|
|
504
|
+
ts: new Date().toISOString(),
|
|
505
|
+
source: 'remote',
|
|
506
|
+
channelId, channelName: chanObj?.name,
|
|
507
|
+
text,
|
|
508
|
+
fromPublicKey: senderKey,
|
|
509
|
+
msgType: 'user',
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
// 顺手把 sender 加入 known peers (如果没有)
|
|
513
|
+
const { addOrUpdatePeer } = await import('../network/known-peers.js');
|
|
514
|
+
await addOrUpdatePeer(undefined, senderKey);
|
|
515
|
+
}
|
|
516
|
+
catch (archiveErr) {
|
|
517
|
+
console.warn('[chat-archive] remote user 归档失败 (non-fatal):', archiveErr?.message?.slice(0, 200));
|
|
518
|
+
}
|
|
581
519
|
// v3 修复: 同步给 A 自己的 UI — broadcast SSE 事件让 A 的 owner 实时看到 B 的消息
|
|
582
520
|
broadcast({
|
|
583
521
|
type: 'user',
|
|
@@ -658,6 +596,10 @@ async function handleV3P2PMessage(parsed, conn, comm) {
|
|
|
658
596
|
};
|
|
659
597
|
const agent = await getAgentForChannel(channelId, ch.did || '', ch.name, ch.didDocRef);
|
|
660
598
|
fullResponse = await agent.promptStream(fullPrompt, streamCallback, undefined, channelId);
|
|
599
|
+
// 2026-07-06: 防御性兜底
|
|
600
|
+
if (!fullResponse.trim()) {
|
|
601
|
+
fullResponse = '⚠️ AI 未返回内容, 请重试';
|
|
602
|
+
}
|
|
661
603
|
// v3 新增: 存 A 的 assistant 消息到 session — B 拉历史时能看到完整对话
|
|
662
604
|
try {
|
|
663
605
|
const existing = await loadSession(channelId, 'default');
|
|
@@ -678,6 +620,25 @@ async function handleV3P2PMessage(parsed, conn, comm) {
|
|
|
678
620
|
catch (saveErr) {
|
|
679
621
|
console.warn(`[v3] 存 assistant 消息失败 (不影响):`, saveErr.message);
|
|
680
622
|
}
|
|
623
|
+
// 2026-07-05: A 的 assistant 回复也归档到 sender 的月度 — 这样 sender 本地也有一份"与 A 的对话"
|
|
624
|
+
try {
|
|
625
|
+
const { appendChatArchive } = await import('../bootstrap/chat-archiver.js');
|
|
626
|
+
const chanObj = (await loadChannels()).find(c => c.id === channelId);
|
|
627
|
+
await appendChatArchive({
|
|
628
|
+
publicKey: senderKey,
|
|
629
|
+
entry: {
|
|
630
|
+
ts: new Date().toISOString(),
|
|
631
|
+
source: 'ai-mention-remote',
|
|
632
|
+
channelId, channelName: chanObj?.name,
|
|
633
|
+
text: `[${v3P2PRef?.getPublicKey()?.slice(0, 12)}…] ${fullResponse.slice(0, 1500)}`,
|
|
634
|
+
fromPublicKey: v3P2PRef?.getPublicKey(),
|
|
635
|
+
msgType: 'ai',
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
catch (archiveErr) {
|
|
640
|
+
console.warn('[chat-archive] remote ai 归档失败 (non-fatal):', archiveErr?.message?.slice(0, 200));
|
|
641
|
+
}
|
|
681
642
|
// v3 修复: 同步给 A 自己的 UI — broadcast AI 回复给 A 的 owner 实时看到
|
|
682
643
|
broadcast({
|
|
683
644
|
type: 'ai',
|
|
@@ -846,6 +807,26 @@ async function handleV3P2PMessage(parsed, conn, comm) {
|
|
|
846
807
|
session.lastUpdated = new Date().toISOString();
|
|
847
808
|
await saveSession(session);
|
|
848
809
|
console.log(`[v3-cross] 收到远端 @-mention: ${originChannelName} → 本地 ${targetChannelName} (${text.length} chars)`);
|
|
810
|
+
// 2026-07-05: 跨渠道 @-mention 也归档到 fromPublicKey 的月度 — 本节点 owner 看月度历史能还原跨节点对话
|
|
811
|
+
if (fromPublicKey) {
|
|
812
|
+
try {
|
|
813
|
+
const { appendChatArchive } = await import('../bootstrap/chat-archiver.js');
|
|
814
|
+
await appendChatArchive({
|
|
815
|
+
publicKey: fromPublicKey,
|
|
816
|
+
entry: {
|
|
817
|
+
ts: new Date().toISOString(),
|
|
818
|
+
source: 'ai-mention-remote',
|
|
819
|
+
channelId: targetChannelId, channelName: targetChannelName,
|
|
820
|
+
text: `[跨渠道] from ${originChannelName}: ${text.slice(0, 1500)}`,
|
|
821
|
+
fromPublicKey,
|
|
822
|
+
msgType: 'ai',
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
catch (archiveErr) {
|
|
827
|
+
console.warn('[chat-archive] cross.post 归档失败 (non-fatal):', archiveErr?.message?.slice(0, 200));
|
|
828
|
+
}
|
|
829
|
+
}
|
|
849
830
|
// 推 SSE 让本地 UI 知道有跨渠道消息到达
|
|
850
831
|
broadcast({
|
|
851
832
|
type: 'cross-mention-received',
|
|
@@ -860,6 +841,201 @@ async function handleV3P2PMessage(parsed, conn, comm) {
|
|
|
860
841
|
}
|
|
861
842
|
return;
|
|
862
843
|
}
|
|
844
|
+
// ============== 2026-07-05: agent.manifest.exchange ==============
|
|
845
|
+
// 对方问: "给我你的 agent 清单 + capabilities"
|
|
846
|
+
// 我们回: agent.manifest.exchange.reply, 含本地 SubAgent + persona 描述
|
|
847
|
+
if (op === 'agent.manifest.exchange') {
|
|
848
|
+
try {
|
|
849
|
+
// 直接读 agents.json, 跳过 subagent-manager 的 lazy initialize
|
|
850
|
+
const localAgents = await loadLocalSubAgents();
|
|
851
|
+
// 转成 AgentManifestEntry
|
|
852
|
+
const entries = localAgents.map((a) => ({
|
|
853
|
+
id: a.id,
|
|
854
|
+
name: a.name,
|
|
855
|
+
capabilities: a.capabilities || [],
|
|
856
|
+
status: a.status || 'active',
|
|
857
|
+
peerId: a.peerId,
|
|
858
|
+
irohNodeId: a.irohNodeId,
|
|
859
|
+
sessionId: a.sessionId,
|
|
860
|
+
cid: a.cid,
|
|
861
|
+
ipnsName: a.ipnsName,
|
|
862
|
+
}));
|
|
863
|
+
// 读本地 persona 拿 owner 名字 + 简介
|
|
864
|
+
let ownerName = '';
|
|
865
|
+
let ownerDescription = '';
|
|
866
|
+
try {
|
|
867
|
+
const { readFileSync, existsSync } = await import('fs');
|
|
868
|
+
const p = path.join(process.env.HOME || '/tmp', '.bolloon', 'persona.json');
|
|
869
|
+
if (existsSync(p)) {
|
|
870
|
+
const pj = JSON.parse(readFileSync(p, 'utf-8'));
|
|
871
|
+
ownerName = pj.name || '';
|
|
872
|
+
ownerDescription = pj.description || '';
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
catch { }
|
|
876
|
+
const manifest = {
|
|
877
|
+
ownerName,
|
|
878
|
+
ownerDescription,
|
|
879
|
+
ownerPublicKey: v3P2PRef?.getPublicKey() || '',
|
|
880
|
+
agents: entries,
|
|
881
|
+
publishedAt: Date.now(),
|
|
882
|
+
...(await loadLocalResources()),
|
|
883
|
+
};
|
|
884
|
+
const reply = JSON.stringify({
|
|
885
|
+
v: 3, op: 'agent.manifest.exchange.reply',
|
|
886
|
+
payload: {
|
|
887
|
+
manifest,
|
|
888
|
+
since: parsed.payload?.since || 0,
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
await comm.sendToConnection(conn.id, reply);
|
|
892
|
+
// 2026-07-05: P2PDirect.sendToWithWait 双发 (兼容同机 P2PDirect 互不相连场景)
|
|
893
|
+
if (v3P2PRef && peerKey) {
|
|
894
|
+
try {
|
|
895
|
+
const r = await v3P2PRef.sendToWithWait(peerKey, reply, 2000);
|
|
896
|
+
console.log(`[v3-manifest] 回 ${peerKey.substring(0, 12)}... manifest (comm=${await comm.sendToConnection.length}, p2p=${r}, ${entries.length} agents)`);
|
|
897
|
+
}
|
|
898
|
+
catch { }
|
|
899
|
+
}
|
|
900
|
+
else {
|
|
901
|
+
console.log(`[v3-manifest] 回 ${peerKey.substring(0, 12)}... manifest (${entries.length} agents, owner=${ownerName || '?'})`);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
catch (err) {
|
|
905
|
+
console.error('[v3-manifest] 处理 agent.manifest.exchange 失败:', err.message);
|
|
906
|
+
}
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
// 对方推 manifest 过来 (我们这边是接收方)
|
|
910
|
+
if (op === 'agent.manifest.exchange.reply') {
|
|
911
|
+
try {
|
|
912
|
+
const manifest = parsed.payload?.manifest;
|
|
913
|
+
if (!manifest || !manifest.ownerPublicKey) {
|
|
914
|
+
console.warn('[v3-manifest] manifest.exchange.reply 缺 manifest/ownerPublicKey');
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
// 2026-07-05 修复: 跳过自己的 manifest (Hyperswarm 内部 peer discovery 会推送自己)
|
|
918
|
+
const peerKey2 = manifest.ownerPublicKey;
|
|
919
|
+
if (v3P2PRef && peerKey2 === v3P2PRef.getPublicKey()) {
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
// 增量: 如果 since >= 本地最新 ts, 跳过
|
|
923
|
+
const existing = await peerFs.readPeerIndex(peerKey2);
|
|
924
|
+
if (existing?.manifestTs && existing.manifestTs >= manifest.publishedAt && parsed.payload?.since) {
|
|
925
|
+
console.log(`[v3-manifest] ${peerKey2.substring(0, 12)}... manifest 已是最新 (ts=${manifest.publishedAt}), 跳过`);
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
// 写 peer.json + _index.json + 每个 agent 的 md
|
|
929
|
+
await peerFs.upsertPeer({
|
|
930
|
+
publicKey: peerKey2,
|
|
931
|
+
name: manifest.ownerName,
|
|
932
|
+
lastSeenAt: new Date().toISOString(),
|
|
933
|
+
lastManifestTs: manifest.publishedAt,
|
|
934
|
+
manifestCount: (await peerFs.getPeer(peerKey2))?.manifestCount ?? 0 + 1,
|
|
935
|
+
});
|
|
936
|
+
// 拼 _index.json
|
|
937
|
+
const ownerDescription = manifest.ownerDescription || '';
|
|
938
|
+
const idx = {
|
|
939
|
+
version: 1,
|
|
940
|
+
publicKey: peerKey2,
|
|
941
|
+
ownerName: manifest.ownerName,
|
|
942
|
+
ownerDescription,
|
|
943
|
+
agents: manifest.agents,
|
|
944
|
+
groups: manifest.groups,
|
|
945
|
+
functions: manifest.functions,
|
|
946
|
+
exportments: manifest.exportments,
|
|
947
|
+
updatedAt: new Date().toISOString(),
|
|
948
|
+
manifestTs: manifest.publishedAt,
|
|
949
|
+
};
|
|
950
|
+
await peerFs.writePeerIndex(peerKey2, idx);
|
|
951
|
+
// 每个 agent 写一份 markdown
|
|
952
|
+
for (const a of manifest.agents) {
|
|
953
|
+
await peerFs.writeAgentDescription(peerKey2, a, ownerDescription);
|
|
954
|
+
}
|
|
955
|
+
// 2026-07-05: 4 类资源 (groups/function/exportment/science) 也落盘
|
|
956
|
+
const counts = await writeRemoteResources(peerKey2, manifest);
|
|
957
|
+
// capability-index.md (≤500 字, 进 prompt)
|
|
958
|
+
await peerFs.writeCapabilityIndex(peerKey2, idx);
|
|
959
|
+
console.log(`[v3-manifest] 收到 ${peerKey2.substring(0, 12)}... manifest (${manifest.agents.length} agents, owner=${manifest.ownerName || '?'}, +g${counts.groups}/f${counts.functions}/e${counts.exportments}/s${counts.sciences}) → 落盘`);
|
|
960
|
+
// 推 SSE 让前端知道"对方能力刷新了"
|
|
961
|
+
broadcast({
|
|
962
|
+
type: 'peer-manifest-updated',
|
|
963
|
+
fromPublicKey: peerKey2,
|
|
964
|
+
ownerName: manifest.ownerName,
|
|
965
|
+
agentCount: manifest.agents.length,
|
|
966
|
+
capabilityIndex: await peerFs.readCapabilityIndex(peerKey2),
|
|
967
|
+
}, 'p2p-global');
|
|
968
|
+
}
|
|
969
|
+
catch (err) {
|
|
970
|
+
console.error('[v3-manifest] 处理 manifest.exchange.reply 失败:', err.message);
|
|
971
|
+
}
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
// 对方问: "给我某个 agent 的详细描述" (markdown 全文)
|
|
975
|
+
if (op === 'agent.resource.get') {
|
|
976
|
+
try {
|
|
977
|
+
const agentId = parsed.payload?.agentId;
|
|
978
|
+
if (!agentId)
|
|
979
|
+
return;
|
|
980
|
+
// 2026-07-05: 4 类资源 (group/function/exportment/science) 也走同一个 RPC
|
|
981
|
+
// 识别方式: id 前缀 group:/fn:/game:/exp: → 读 ~/.bolloon/local-resources/<cat>/<id>.md
|
|
982
|
+
let body = '';
|
|
983
|
+
const prefixMatch = String(agentId).match(/^(group|fn|game|exp):(.+)$/);
|
|
984
|
+
if (prefixMatch) {
|
|
985
|
+
const cat = prefixMatch[1] === 'fn' ? 'functions'
|
|
986
|
+
: prefixMatch[1] === 'game' ? 'exportments'
|
|
987
|
+
: prefixMatch[1] === 'exp' ? 'sciences'
|
|
988
|
+
: 'groups';
|
|
989
|
+
try {
|
|
990
|
+
const fsPromises = await import('fs/promises');
|
|
991
|
+
const safe = String(prefixMatch[2]).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
|
|
992
|
+
const file = path.join(process.env.HOME || '/tmp', '.bolloon', 'local-resources', cat, `${safe}.md`);
|
|
993
|
+
body = await fsPromises.readFile(file, 'utf-8');
|
|
994
|
+
}
|
|
995
|
+
catch {
|
|
996
|
+
body = '';
|
|
997
|
+
}
|
|
998
|
+
const reply = JSON.stringify({
|
|
999
|
+
v: 3, op: 'agent.resource.get.reply',
|
|
1000
|
+
payload: { agentId, body: body || '(空)', fromPublicKey: v3P2PRef?.getPublicKey() || '' }
|
|
1001
|
+
});
|
|
1002
|
+
await comm.sendToConnection(conn.id, reply);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
// 默认: 读本地 persona/<agentId>/agent.md (6 段 persona 之一)
|
|
1006
|
+
try {
|
|
1007
|
+
const fsPromises = await import('fs/promises');
|
|
1008
|
+
const safeId = agentId.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
|
|
1009
|
+
const file = path.join(process.env.HOME || '/tmp', '.bolloon', 'persona', safeId, 'agent.md');
|
|
1010
|
+
body = await fsPromises.readFile(file, 'utf-8');
|
|
1011
|
+
}
|
|
1012
|
+
catch {
|
|
1013
|
+
body = '';
|
|
1014
|
+
}
|
|
1015
|
+
// fallback: 用 subagent-manager 的 description
|
|
1016
|
+
if (!body) {
|
|
1017
|
+
const all = await loadLocalSubAgents();
|
|
1018
|
+
const a = all.find((x) => x.id === agentId);
|
|
1019
|
+
if (a?.persona?.description)
|
|
1020
|
+
body = a.persona.description;
|
|
1021
|
+
}
|
|
1022
|
+
const reply = JSON.stringify({
|
|
1023
|
+
v: 3, op: 'agent.resource.get.reply',
|
|
1024
|
+
payload: { agentId, body: body || '(空)', fromPublicKey: v3P2PRef?.getPublicKey() || '' }
|
|
1025
|
+
});
|
|
1026
|
+
await comm.sendToConnection(conn.id, reply);
|
|
1027
|
+
}
|
|
1028
|
+
catch (err) {
|
|
1029
|
+
console.error('[v3-manifest] resource.get 失败:', err.message);
|
|
1030
|
+
}
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
// 对方回某 agent 的详细描述
|
|
1034
|
+
if (op === 'agent.resource.get.reply') {
|
|
1035
|
+
// 由 caller 端通过 rpcId 解析 (这里只 log)
|
|
1036
|
+
console.log(`[v3-manifest] 收到 agent.resource.get.reply (agentId=${parsed.payload?.agentId}, body=${(parsed.payload?.body || '').length} chars)`);
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
863
1039
|
console.log(`[v3] 收到未知 op: ${op}`);
|
|
864
1040
|
}
|
|
865
1041
|
async function buildJudgmentHint(channel, channelIdForLog) {
|
|
@@ -873,29 +1049,80 @@ async function buildJudgmentHint(channel, channelIdForLog) {
|
|
|
873
1049
|
const bound = allJudgments.filter(j => j.id !== undefined && boundIds.has(j.id));
|
|
874
1050
|
const others = allJudgments.filter(j => j.id !== undefined && !boundIds.has(j.id));
|
|
875
1051
|
let hint = '';
|
|
1052
|
+
// 2026-07-06: 硬上限 — judgment 总数可能 1875 条 (1.3MB), 全注入会把 LLM context 撑爆
|
|
1053
|
+
// MiniMax-M3 只有 8K context ≈ 32K 字符, 完整 judgment hint 必须 < 8K 字符
|
|
1054
|
+
const HARD_LIMIT = 6000; // 总 hint 字符数 < 6K (留 ~26K 给 user + contextHint 其他部分)
|
|
1055
|
+
const BOUND_MAX = 16; // 绑定最多 16 条
|
|
1056
|
+
const OTHERS_MAX = 24; // 候选最多 24 条
|
|
1057
|
+
let boundIncluded = 0;
|
|
1058
|
+
let othersIncluded = 0;
|
|
1059
|
+
let boundSkipped = 0;
|
|
1060
|
+
let othersSkipped = 0;
|
|
876
1061
|
// 路 1: 用户手动绑定的 judgment — 硬约束, 必须遵循
|
|
877
1062
|
if (bound.length > 0) {
|
|
878
|
-
|
|
1063
|
+
const headerText = `[系统上下文] 此 channel 用户绑定了 ${bound.length} 条判断力, 必须严格遵循:\n`;
|
|
1064
|
+
hint += headerText;
|
|
879
1065
|
for (const j of bound) {
|
|
1066
|
+
if (boundIncluded >= BOUND_MAX) {
|
|
1067
|
+
boundSkipped++;
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
if (hint.length >= HARD_LIMIT) {
|
|
1071
|
+
boundSkipped++;
|
|
1072
|
+
continue;
|
|
1073
|
+
}
|
|
880
1074
|
const decision = (j.decision || '').toString().slice(0, 200);
|
|
881
1075
|
const reasonList = Array.isArray(j.reasons) ? j.reasons : [];
|
|
882
1076
|
const reasonText = reasonList.length > 0
|
|
883
1077
|
? ` (理由: ${reasonList.join('; ').slice(0, 100)})`
|
|
884
1078
|
: '';
|
|
885
|
-
|
|
1079
|
+
const line = `- ${decision}${reasonText}\n`;
|
|
1080
|
+
if (hint.length + line.length > HARD_LIMIT) {
|
|
1081
|
+
boundSkipped++;
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
1084
|
+
hint += line;
|
|
1085
|
+
boundIncluded++;
|
|
886
1086
|
}
|
|
887
1087
|
hint += '\n';
|
|
888
1088
|
}
|
|
889
1089
|
// 路 2: 全局 judgment 候选池 — 软参考, LLM 自己挑
|
|
890
1090
|
if (others.length > 0) {
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
1091
|
+
const headerText = `[系统上下文] 候选判断力 (用户未明确绑定, 你可以按相关性自主选择参考, 共 ${others.length} 条):\n`;
|
|
1092
|
+
const footerText = `\n[系统上下文] 如果你的回复参考了某条候选判断力, 请在回复中自然提及 "我参考了你的判断: <decision 简述>" 即可, 无需复述 id.\n\n`;
|
|
1093
|
+
// 先判断 header + footer 能不能放
|
|
1094
|
+
if (hint.length + headerText.length + footerText.length < HARD_LIMIT) {
|
|
1095
|
+
hint += headerText;
|
|
1096
|
+
// 按 id 排序 (deterministic 取最近 N 条)
|
|
1097
|
+
for (const j of others) {
|
|
1098
|
+
if (othersIncluded >= OTHERS_MAX) {
|
|
1099
|
+
othersSkipped++;
|
|
1100
|
+
continue;
|
|
1101
|
+
}
|
|
1102
|
+
if (hint.length + footerText.length >= HARD_LIMIT) {
|
|
1103
|
+
othersSkipped++;
|
|
1104
|
+
continue;
|
|
1105
|
+
}
|
|
1106
|
+
const decision = (j.decision || '').toString().slice(0, 120);
|
|
1107
|
+
const line = `- [id=${j.id}] ${decision}\n`;
|
|
1108
|
+
if (hint.length + line.length + footerText.length > HARD_LIMIT) {
|
|
1109
|
+
othersSkipped++;
|
|
1110
|
+
continue;
|
|
1111
|
+
}
|
|
1112
|
+
hint += line;
|
|
1113
|
+
othersIncluded++;
|
|
1114
|
+
}
|
|
1115
|
+
hint += footerText;
|
|
1116
|
+
}
|
|
1117
|
+
else {
|
|
1118
|
+
othersSkipped = others.length;
|
|
895
1119
|
}
|
|
896
|
-
hint += `\n[系统上下文] 如果你的回复参考了某条候选判断力, 请在回复中自然提及 "我参考了你的判断: <decision 简述>" 即可, 无需复述 id.\n\n`;
|
|
897
1120
|
}
|
|
898
|
-
|
|
1121
|
+
// 2026-07-06: 加个 truncate hint (在统计日志打印出来)
|
|
1122
|
+
if (boundSkipped > 0 || othersSkipped > 0) {
|
|
1123
|
+
console.log(`[v3] channel ${channelIdForLog} judgment hint truncated: bound=${boundIncluded}/${bound.length} (skip ${boundSkipped}), others=${othersIncluded}/${others.length} (skip ${othersSkipped}), hint=${hint.length} chars / limit ${HARD_LIMIT}`);
|
|
1124
|
+
}
|
|
1125
|
+
console.log(`[v3] channel ${channelIdForLog} 注入: 绑定 ${bound.length} 条, 候选 ${others.length} 条 (实际 ${boundIncluded}+${othersIncluded})`);
|
|
899
1126
|
return hint;
|
|
900
1127
|
}
|
|
901
1128
|
catch (err) {
|
|
@@ -981,6 +1208,8 @@ async function getAgentForChannel(channelId, channelDid, channelName, channelDid
|
|
|
981
1208
|
cwd: process.cwd(),
|
|
982
1209
|
peerId: `channel-${channelId}:${currentSessionId}`,
|
|
983
1210
|
identityDoc,
|
|
1211
|
+
// 2026-07-04: 透传 agentId 让 onSessionStart 加载 persona docs
|
|
1212
|
+
agentId: channel?.agentId,
|
|
984
1213
|
// M2.3 (2026-06-17): 构造时从 session JSON 回灌历史 — 服务重启后 LLM 仍记得前面对话
|
|
985
1214
|
// key 跟 server.ts:240 写入路径保持一致: ~/.bolloon/sessions/cache/<key>.json
|
|
986
1215
|
loadSessionKey: sessionKey,
|
|
@@ -998,6 +1227,7 @@ async function getAgentForChannel(channelId, channelDid, channelName, channelDid
|
|
|
998
1227
|
}
|
|
999
1228
|
return session;
|
|
1000
1229
|
}
|
|
1230
|
+
// 2026-07-06: CreateWebServerOptions 抽到 ./server-types.ts (顶部 re-export)
|
|
1001
1231
|
let selfImproveEnabled = false;
|
|
1002
1232
|
export async function createWebServer(port = 3000, options = {}) {
|
|
1003
1233
|
selfImproveEnabled = options.selfImprove ?? false;
|
|
@@ -1151,13 +1381,31 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1151
1381
|
pending.reject(new Error(parsed.payload.error));
|
|
1152
1382
|
}
|
|
1153
1383
|
else {
|
|
1154
|
-
|
|
1384
|
+
const replyPayload = {
|
|
1155
1385
|
channelId: parsed.payload.channelId,
|
|
1156
1386
|
messages: parsed.payload.messages || [],
|
|
1157
1387
|
lastUpdated: parsed.payload.lastUpdated,
|
|
1158
1388
|
judgments: parsed.payload.judgments || { bound: [], candidates: [] },
|
|
1159
1389
|
channelName: parsed.payload.channelName
|
|
1390
|
+
};
|
|
1391
|
+
// 2026-07-07 P0-C: 远端 channel 历史镜像 (B 端副本, 防 A 端删/损坏即丢)
|
|
1392
|
+
// fire-and-forget — 不阻塞 RPC reply 返回 (上层是 on('data') 同步回调)
|
|
1393
|
+
import('../bootstrap/remote-mirror.js')
|
|
1394
|
+
.then(({ mirrorRemoteHistory }) => mirrorRemoteHistory({
|
|
1395
|
+
targetPublicKey: evt.fromPublicKey,
|
|
1396
|
+
channelId: parsed.payload.channelId,
|
|
1397
|
+
channelName: parsed.payload.channelName,
|
|
1398
|
+
messages: parsed.payload.messages || [],
|
|
1399
|
+
lastUpdated: parsed.payload.lastUpdated,
|
|
1400
|
+
}))
|
|
1401
|
+
.then((r) => {
|
|
1402
|
+
if (!r?.ok)
|
|
1403
|
+
console.warn(`[v3-history] mirror 失败: ${r?.error}`);
|
|
1404
|
+
})
|
|
1405
|
+
.catch((mirrorErr) => {
|
|
1406
|
+
console.warn(`[v3-history] mirror 抛错 (${evt.fromPublicKey.substring(0, 12)}/${parsed.payload.channelId}): ${mirrorErr?.message || mirrorErr}`);
|
|
1160
1407
|
});
|
|
1408
|
+
pending.resolve(replyPayload);
|
|
1161
1409
|
}
|
|
1162
1410
|
}
|
|
1163
1411
|
return;
|
|
@@ -1228,6 +1476,66 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1228
1476
|
}, 'p2p-global');
|
|
1229
1477
|
return;
|
|
1230
1478
|
}
|
|
1479
|
+
// 2026-07-05: v3 P2PDirect 主路径也要独立处理 manifest.exchange.reply — 否则只走老通道就拿不到
|
|
1480
|
+
if (parsed.op === 'agent.manifest.exchange.reply') {
|
|
1481
|
+
const manifest = parsed.payload?.manifest;
|
|
1482
|
+
if (!manifest || !manifest.ownerPublicKey) {
|
|
1483
|
+
console.warn('[v3-manifest] manifest.exchange.reply 缺 manifest/ownerPublicKey');
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
const peerKey2 = manifest.ownerPublicKey;
|
|
1487
|
+
// 2026-07-05 修复: 跳过自己的 manifest
|
|
1488
|
+
if (v3P2PRef && peerKey2 === v3P2PRef.getPublicKey()) {
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
// 异步落盘, 不阻塞 data handler
|
|
1492
|
+
(async () => {
|
|
1493
|
+
try {
|
|
1494
|
+
const existing = await peerFs.readPeerIndex(peerKey2);
|
|
1495
|
+
if (existing?.manifestTs && existing.manifestTs >= manifest.publishedAt && parsed.payload?.since) {
|
|
1496
|
+
console.log(`[v3-manifest] ${peerKey2.substring(0, 12)}... manifest 已是最新, 跳过`);
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
await peerFs.upsertPeer({
|
|
1500
|
+
publicKey: peerKey2,
|
|
1501
|
+
name: manifest.ownerName,
|
|
1502
|
+
lastSeenAt: new Date().toISOString(),
|
|
1503
|
+
lastManifestTs: manifest.publishedAt,
|
|
1504
|
+
});
|
|
1505
|
+
const ownerDescription = manifest.ownerDescription || '';
|
|
1506
|
+
const idx = {
|
|
1507
|
+
version: 1,
|
|
1508
|
+
publicKey: peerKey2,
|
|
1509
|
+
ownerName: manifest.ownerName,
|
|
1510
|
+
ownerDescription,
|
|
1511
|
+
agents: manifest.agents,
|
|
1512
|
+
groups: manifest.groups,
|
|
1513
|
+
functions: manifest.functions,
|
|
1514
|
+
exportments: manifest.exportments,
|
|
1515
|
+
updatedAt: new Date().toISOString(),
|
|
1516
|
+
manifestTs: manifest.publishedAt,
|
|
1517
|
+
};
|
|
1518
|
+
await peerFs.writePeerIndex(peerKey2, idx);
|
|
1519
|
+
for (const a of manifest.agents) {
|
|
1520
|
+
await peerFs.writeAgentDescription(peerKey2, a, ownerDescription);
|
|
1521
|
+
}
|
|
1522
|
+
const counts = await writeRemoteResources(peerKey2, manifest);
|
|
1523
|
+
await peerFs.writeCapabilityIndex(peerKey2, idx);
|
|
1524
|
+
console.log(`[v3-manifest] (P2PDirect) 收到 ${peerKey2.substring(0, 12)}... manifest (${manifest.agents.length} agents, owner=${manifest.ownerName || '?'}, +g${counts.groups}/f${counts.functions}/e${counts.exportments}/s${counts.sciences}) → 落盘`);
|
|
1525
|
+
broadcast({
|
|
1526
|
+
type: 'peer-manifest-updated',
|
|
1527
|
+
fromPublicKey: peerKey2,
|
|
1528
|
+
ownerName: manifest.ownerName,
|
|
1529
|
+
agentCount: manifest.agents.length,
|
|
1530
|
+
capabilityIndex: await peerFs.readCapabilityIndex(peerKey2),
|
|
1531
|
+
}, 'p2p-global');
|
|
1532
|
+
}
|
|
1533
|
+
catch (err) {
|
|
1534
|
+
console.error('[v3-manifest] (P2PDirect) manifest.exchange.reply 失败:', err.message);
|
|
1535
|
+
}
|
|
1536
|
+
})();
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1231
1539
|
// 2026-06-10: 收到对方请求本机的 channel 列表 (启动时主动发请求, 加速 cache 填充)
|
|
1232
1540
|
if (parsed.op === 'agent.meta.list.request') {
|
|
1233
1541
|
console.log(`[v3-meta] 收到 ${evt.fromPublicKey.substring(0, 12)}... 的 channel 列表请求 → 立刻回包`);
|
|
@@ -1269,6 +1577,47 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1269
1577
|
console.error('[v3] 新连接发 list.reply 失败:', err.message);
|
|
1270
1578
|
}
|
|
1271
1579
|
}, 500);
|
|
1580
|
+
// 2026-07-05: 新连接到来立刻推自己的 manifest — 让对方秒知道我有哪些 agent
|
|
1581
|
+
setTimeout(async () => {
|
|
1582
|
+
try {
|
|
1583
|
+
// 跳过自己的连接 (Hyperswarm 内部 peer discovery 会推自己过来, 形成循环)
|
|
1584
|
+
if (v3P2PRef && evt.remotePublicKey === v3P2PRef.getPublicKey()) {
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
const localAgents = await loadLocalSubAgents();
|
|
1588
|
+
const entries = localAgents.map((a) => ({
|
|
1589
|
+
id: a.id, name: a.name,
|
|
1590
|
+
capabilities: a.capabilities || [],
|
|
1591
|
+
status: a.status || 'active',
|
|
1592
|
+
peerId: a.peerId, irohNodeId: a.irohNodeId,
|
|
1593
|
+
sessionId: a.sessionId, cid: a.cid, ipnsName: a.ipnsName,
|
|
1594
|
+
}));
|
|
1595
|
+
let ownerName = '';
|
|
1596
|
+
try {
|
|
1597
|
+
const fsPromises = await import('fs/promises');
|
|
1598
|
+
const p = (process.env.HOME || '/tmp') + '/.bolloon/persona.json';
|
|
1599
|
+
const raw = await fsPromises.readFile(p, 'utf-8');
|
|
1600
|
+
ownerName = JSON.parse(raw).name || '';
|
|
1601
|
+
}
|
|
1602
|
+
catch { }
|
|
1603
|
+
const manifest = {
|
|
1604
|
+
ownerName,
|
|
1605
|
+
ownerPublicKey: v3P2PRef?.getPublicKey() || '',
|
|
1606
|
+
agents: entries,
|
|
1607
|
+
publishedAt: Date.now(),
|
|
1608
|
+
...(await loadLocalResources()),
|
|
1609
|
+
};
|
|
1610
|
+
const msg = JSON.stringify({
|
|
1611
|
+
v: 3, op: 'agent.manifest.exchange.reply',
|
|
1612
|
+
payload: { manifest, since: 0 }
|
|
1613
|
+
});
|
|
1614
|
+
const r = await v3P2PRef.sendToWithWait(evt.remotePublicKey, msg, 3000);
|
|
1615
|
+
console.log(`[v3-manifest] 新连接 ${evt.remotePublicKey.substring(0, 12)}... → 主动推 manifest (${entries.length} agents, p2p=${r})`);
|
|
1616
|
+
}
|
|
1617
|
+
catch (err) {
|
|
1618
|
+
console.error('[v3-manifest] 新连接推 manifest 失败:', err.message);
|
|
1619
|
+
}
|
|
1620
|
+
}, 800);
|
|
1272
1621
|
});
|
|
1273
1622
|
console.log(`[v3] P2PDirect 已启动, role=${v3P2PRef.getRole()}, publicKey=${v3P2PRef.getPublicKey().substring(0, 12)}...`);
|
|
1274
1623
|
// v3: 启动后自动重连 known peers — 让"启动就互联"成为现实
|
|
@@ -1298,6 +1647,10 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1298
1647
|
// 2026-06-10: 同时主动请求每个 known peer 把 ta 的 channel 列表推过来
|
|
1299
1648
|
// 避免对面 publicKey 没变但 cache 丢了(本机重启) → 一直空
|
|
1300
1649
|
setTimeout(() => requestChannelsFromAllPeers(), 3500);
|
|
1650
|
+
// 2026-07-05: 拉每个 known peer 的 manifest (agent 能力清单)
|
|
1651
|
+
setTimeout(() => requestManifestsFromAllPeers(), 4500);
|
|
1652
|
+
// 2026-07-05: 对方刚连上来, 把本地的 outbox 重发出去
|
|
1653
|
+
setTimeout(() => flushAllOutboxes(), 6000);
|
|
1301
1654
|
}
|
|
1302
1655
|
catch (err) {
|
|
1303
1656
|
console.error('[v3] 自动重连失败:', err.message);
|
|
@@ -1330,6 +1683,78 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1330
1683
|
// 立即跑一次 + 每 30s 兜底 (跟 v3BroadcastOwn 一样的节奏)
|
|
1331
1684
|
setTimeout(requestChannelsFromAllPeers, 4000);
|
|
1332
1685
|
setInterval(requestChannelsFromAllPeers, 30000);
|
|
1686
|
+
// 2026-07-05 新增: 主动向所有 known peer 发起 manifest 拉取
|
|
1687
|
+
async function requestManifestsFromAllPeers() {
|
|
1688
|
+
if (!v3P2PRef)
|
|
1689
|
+
return;
|
|
1690
|
+
try {
|
|
1691
|
+
const { listPeers } = await import('../network/known-peers.js');
|
|
1692
|
+
const peers = await listPeers();
|
|
1693
|
+
const myPk = v3P2PRef.getPublicKey();
|
|
1694
|
+
let sent = 0;
|
|
1695
|
+
for (const peer of peers) {
|
|
1696
|
+
if (peer.publicKey === myPk)
|
|
1697
|
+
continue;
|
|
1698
|
+
// 增量: since = 本地已有的 lastManifestTs, 对端 manifestTs <= since 则跳过
|
|
1699
|
+
const peerRec = await peerFs.getPeer(peer.publicKey);
|
|
1700
|
+
const since = peerRec?.lastManifestTs || 0;
|
|
1701
|
+
const req = JSON.stringify({
|
|
1702
|
+
v: 3, op: 'agent.manifest.exchange',
|
|
1703
|
+
payload: { since, fromPublicKey: myPk }
|
|
1704
|
+
});
|
|
1705
|
+
const r = await v3P2PRef.sendToWithWait(peer.publicKey, req, 3000);
|
|
1706
|
+
if (r === 'SENT')
|
|
1707
|
+
sent++;
|
|
1708
|
+
}
|
|
1709
|
+
console.log(`[v3-manifest] requestManifestsFromAllPeers → sent=${sent}/${peers.length - 1}`);
|
|
1710
|
+
}
|
|
1711
|
+
catch (err) {
|
|
1712
|
+
console.warn('[v3-manifest] requestManifestsFromAllPeers failed:', err.message);
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
// 每 5 分钟兜底 (manifest 增量拉取)
|
|
1716
|
+
setInterval(requestManifestsFromAllPeers, 5 * 60 * 1000);
|
|
1717
|
+
// 2026-07-05 新增: 把每个 peer 的 outbox 队列重发出去 (对方上线后)
|
|
1718
|
+
async function flushAllOutboxes() {
|
|
1719
|
+
if (!v3P2PRef)
|
|
1720
|
+
return;
|
|
1721
|
+
try {
|
|
1722
|
+
const { listPeers } = await import('../network/known-peers.js');
|
|
1723
|
+
const peers = await listPeers();
|
|
1724
|
+
const myPk = v3P2PRef.getPublicKey();
|
|
1725
|
+
let totalFlushed = 0;
|
|
1726
|
+
for (const peer of peers) {
|
|
1727
|
+
if (peer.publicKey === myPk)
|
|
1728
|
+
continue;
|
|
1729
|
+
const outbox = await peerFs.readOutbox(peer.publicKey);
|
|
1730
|
+
if (outbox.length === 0)
|
|
1731
|
+
continue;
|
|
1732
|
+
let sent = 0;
|
|
1733
|
+
for (const entry of outbox) {
|
|
1734
|
+
const rpc = JSON.stringify({ v: 3, op: entry.op, payload: entry.payload });
|
|
1735
|
+
const r = await v3P2PRef.sendToWithWait(peer.publicKey, rpc, 3000);
|
|
1736
|
+
if (r === 'SENT')
|
|
1737
|
+
sent++;
|
|
1738
|
+
}
|
|
1739
|
+
if (sent > 0) {
|
|
1740
|
+
// 全部成功 → 清空; 部分成功 → 写回剩余 (简单起见, 全成功才清)
|
|
1741
|
+
if (sent === outbox.length) {
|
|
1742
|
+
await peerFs.clearOutbox(peer.publicKey);
|
|
1743
|
+
totalFlushed += sent;
|
|
1744
|
+
console.log(`[v3-outbox] flush → ${peer.publicKey.substring(0, 12)}... 重发 ${sent} 条 ✓`);
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
if (totalFlushed > 0) {
|
|
1749
|
+
console.log(`[v3-outbox] 累计重发 ${totalFlushed} 条离线消息`);
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
catch (err) {
|
|
1753
|
+
console.warn('[v3-outbox] flushAllOutboxes failed:', err.message);
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
// 每 2 分钟兜底 flush
|
|
1757
|
+
setInterval(flushAllOutboxes, 2 * 60 * 1000);
|
|
1333
1758
|
}
|
|
1334
1759
|
catch (err) {
|
|
1335
1760
|
console.error('[v3] P2PDirect 启动失败:', err.message);
|
|
@@ -1340,6 +1765,9 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1340
1765
|
// v3 修复: 用 setInterval 替代一次性 setTimeout, 确保分享变更后能持续推送给 peer
|
|
1341
1766
|
setInterval(v3BroadcastOwn, 30000);
|
|
1342
1767
|
// 保留 @diap/sdk 的旧实例 (它的 Hyperswarm 实例能帮 P2PDirect 做 DHT bootstrap)
|
|
1768
|
+
// 2026-07-04: hyperswarm 4.x 删除了 Discovery.update() (被 .flushed() 替代).
|
|
1769
|
+
// @diap/sdk 0.1.10 还调 .update(), 是上游 bug (已记录 docs/plans/2026-06-17-supervisor-iter-1.md).
|
|
1770
|
+
// 这里静默 joinTopic 失败, P2PDirect (v3 主路径) 不依赖 @diap/sdk, 不影响功能.
|
|
1343
1771
|
try {
|
|
1344
1772
|
const rawSeed = crypto.getRandomValues(new Uint8Array(32));
|
|
1345
1773
|
p2pCommunicator = createHyperswarmCommunicator({
|
|
@@ -1353,13 +1781,38 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1353
1781
|
// 旧 p2p_message 路径 (非 v3)
|
|
1354
1782
|
const content = new TextDecoder().decode(msg.content);
|
|
1355
1783
|
broadcast({ type: 'p2p_message', from: conn.publicKey.substring(0, 8), content }, undefined);
|
|
1784
|
+
// 2026-07-05: 老通道也要走 v3 RPC handler — 兼容同机/跨机 P2PDirect 互不相连的场景
|
|
1785
|
+
// 实测: P2PDirect 用 hyperswarm 4.x, 同机 2 role 互不相连; 但 @diap/sdk 0.1.10 的 HyperswarmCommunicator 能互通
|
|
1786
|
+
// 所以 manifest/exchange/chat.send 都可能从老通道来, 必须进 handleV3P2PMessage
|
|
1787
|
+
try {
|
|
1788
|
+
const parsed = JSON.parse(content);
|
|
1789
|
+
if (parsed && parsed.v === 3 && parsed.op && p2pCommunicator) {
|
|
1790
|
+
await handleV3P2PMessage(parsed, conn, p2pCommunicator);
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
catch (err) {
|
|
1794
|
+
// 非 v3 JSON 帧 (老 p2p_message), 静默忽略
|
|
1795
|
+
}
|
|
1356
1796
|
});
|
|
1357
1797
|
await p2pCommunicator.start();
|
|
1358
1798
|
// @diap/sdk 也 join topic — 它的 Hyperswarm 实例帮 P2PDirect 做 DHT 引导
|
|
1359
|
-
// @diap/sdk
|
|
1799
|
+
// joinTopic 失败已知 (hyperswarm 4.x + @diap/sdk 0.1.10), catch 静默
|
|
1360
1800
|
const oldTopic = createTopic('bolloon-agent-harness');
|
|
1361
|
-
|
|
1362
|
-
|
|
1801
|
+
try {
|
|
1802
|
+
await p2pCommunicator.joinTopic(oldTopic);
|
|
1803
|
+
console.log(`P2P 老通道已就绪 (DHT bootstrap 帮 P2PDirect, 实际数据走 P2PDirect)`);
|
|
1804
|
+
}
|
|
1805
|
+
catch (joinErr) {
|
|
1806
|
+
// 已知: @diap/sdk 0.1.10 + hyperswarm 4.x → discovery.update is not a function
|
|
1807
|
+
// v3 P2PDirect 是主路径, 此处不阻断
|
|
1808
|
+
const msg = String(joinErr?.message || joinErr);
|
|
1809
|
+
if (msg.includes('discovery.update') || msg.includes('is not a function')) {
|
|
1810
|
+
console.warn(`[v3-legacy] @diap/sdk joinTopic 失败 (已知 hyperswarm 4.x 兼容问题, 已忽略): ${msg}`);
|
|
1811
|
+
}
|
|
1812
|
+
else {
|
|
1813
|
+
throw joinErr;
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1363
1816
|
}
|
|
1364
1817
|
catch (e) {
|
|
1365
1818
|
console.log(`P2P 老通道初始化失败: ${e.message}`);
|
|
@@ -1382,6 +1835,12 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1382
1835
|
next();
|
|
1383
1836
|
});
|
|
1384
1837
|
app.use(express.static(webRoot));
|
|
1838
|
+
// 2026-07-01 (v0.2.6 修复): 前端 message-renderer import 了 src/agents/chat-segmenter.js,
|
|
1839
|
+
// 浏览器加载时按相对路径解析成 /agents/chat-segmenter.js. 这个路径在 webRoot (dist/web) 之外.
|
|
1840
|
+
// 解决: 把 dist/agents 也 mount 到 /agents 静态路径, 让前端 import 解析成功.
|
|
1841
|
+
// 这条不破坏 segmenter 内部 import (Node 走文件系统, 浏览器走 HTTP).
|
|
1842
|
+
const agentsRoot = path.join(webRoot, '..', 'agents');
|
|
1843
|
+
app.use('/agents', express.static(agentsRoot));
|
|
1385
1844
|
app.get('/', (req, res) => {
|
|
1386
1845
|
res.sendFile(join(webRoot, 'index.html'));
|
|
1387
1846
|
});
|
|
@@ -1410,6 +1869,61 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1410
1869
|
}
|
|
1411
1870
|
app.get('/', serveStaticHtml('index.html', 'index.html not found; please run `npm run build:web`', 'index'));
|
|
1412
1871
|
app.get('/api-config', serveStaticHtml('api-config.html', 'api-config.html not found; please run `npm run build:web`', 'api-config'));
|
|
1872
|
+
// 2026-07-01 (v0.2.5): 输入验证 + 健康检查 API
|
|
1873
|
+
// - POST /api/validate-input: 前端发送前预校验, 避免后端 reject
|
|
1874
|
+
// - GET /api/health: liveness probe + validators 清单
|
|
1875
|
+
// 这些端点不依赖 LLM / P2P, 永远可以响应.
|
|
1876
|
+
app.post('/api/validate-input', async (req, res) => {
|
|
1877
|
+
try {
|
|
1878
|
+
const body = req.body ?? {};
|
|
1879
|
+
const kind = body.kind || 'message';
|
|
1880
|
+
let result;
|
|
1881
|
+
if (kind === 'channel') {
|
|
1882
|
+
result = validateChannelInput({ name: body.name, agentId: body.agentId });
|
|
1883
|
+
}
|
|
1884
|
+
else {
|
|
1885
|
+
result = validateMessageInput({ text: body.text, channelId: body.channelId });
|
|
1886
|
+
}
|
|
1887
|
+
res.json({
|
|
1888
|
+
ok: result.ok,
|
|
1889
|
+
severity: result.severity ?? (result.ok ? 'info' : 'block'),
|
|
1890
|
+
reason: result.reason,
|
|
1891
|
+
cleaned: result.cleaned,
|
|
1892
|
+
kind,
|
|
1893
|
+
});
|
|
1894
|
+
}
|
|
1895
|
+
catch (e) {
|
|
1896
|
+
res.status(400).json({ ok: false, severity: 'block', reason: e?.message ?? 'parse error' });
|
|
1897
|
+
}
|
|
1898
|
+
});
|
|
1899
|
+
app.get('/api/health', (_req, res) => {
|
|
1900
|
+
res.json(healthCheck(getPackageVersion()));
|
|
1901
|
+
});
|
|
1902
|
+
// 2026-07-01 (v0.2.6): 前后端分离核心 — 后端切 LLM 输出为结构化 segments
|
|
1903
|
+
// - POST /api/segment-reply { reply, knownTools }
|
|
1904
|
+
// - 返回 ChatSegment[] (think / text / env_details / tool_call / final)
|
|
1905
|
+
// 前端拿到 segments 后只渲染, 不知道任何 LLM 格式 (minimax <invoke>, Hermes <tool_call>, Qwen function_calls, JSON 形式)
|
|
1906
|
+
app.post('/api/segment-reply', async (req, res) => {
|
|
1907
|
+
try {
|
|
1908
|
+
const body = req.body ?? {};
|
|
1909
|
+
const reply = typeof body.reply === 'string' ? body.reply : '';
|
|
1910
|
+
const knownToolsArr = Array.isArray(body.knownTools) ? body.knownTools : [];
|
|
1911
|
+
const knownToolNames = new Set(knownToolsArr.filter((s) => typeof s === 'string'));
|
|
1912
|
+
const segments = segmentChatReply(reply, { knownToolNames });
|
|
1913
|
+
res.json({
|
|
1914
|
+
ok: true,
|
|
1915
|
+
input_length: reply.length,
|
|
1916
|
+
segments,
|
|
1917
|
+
segments_count: segments.length,
|
|
1918
|
+
has_think: segments.some(s => s.type === 'think'),
|
|
1919
|
+
has_tool_call: segments.some(s => s.type === 'tool_call'),
|
|
1920
|
+
has_final: segments.some(s => s.type === 'final'),
|
|
1921
|
+
});
|
|
1922
|
+
}
|
|
1923
|
+
catch (e) {
|
|
1924
|
+
res.status(400).json({ ok: false, reason: e?.message ?? 'parse error' });
|
|
1925
|
+
}
|
|
1926
|
+
});
|
|
1413
1927
|
// 全局兜底: 任何 next(err) 走到这里, 给出结构化 4xx/5xx 而不是默认 HTML
|
|
1414
1928
|
app.use((err, req, res, _next) => {
|
|
1415
1929
|
console.error('[server] unhandled error on', req.method, req.path, '-', err?.message || err);
|
|
@@ -1451,6 +1965,10 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1451
1965
|
// 获取频道信息(只取轻量引用, 不再读完整 DID 文档)
|
|
1452
1966
|
const channels = await loadChannels();
|
|
1453
1967
|
const channel = channels.find(c => c.id === channelId);
|
|
1968
|
+
// 2026-07-07 H2 修复: channel 不存在 → 404 (之前静默通过, 消息写到孤儿目录)
|
|
1969
|
+
if (!channel) {
|
|
1970
|
+
return res.status(404).json({ error: 'channel not found', channelId });
|
|
1971
|
+
}
|
|
1454
1972
|
const currentSessionId = channel?.currentSessionId || 'default';
|
|
1455
1973
|
const realChannelDid = channelDid || channel?.did || '';
|
|
1456
1974
|
const realChannelName = channel?.name || '';
|
|
@@ -1479,9 +1997,23 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1479
1997
|
}
|
|
1480
1998
|
runState.running = true;
|
|
1481
1999
|
runState.abortController = new AbortController();
|
|
2000
|
+
// 2026-07-04: pivot loop safety net — 防止 LLM hang (minimax M3
|
|
2001
|
+
// 偶尔反复 think 不输出 <final gen>, pivot 连 5 次无进展时会 hang 在
|
|
2002
|
+
// quality 评估). setTimeout 让 LLM 客户端收 signal 主动 break.
|
|
2003
|
+
// 2026-07-06: 30s 太短 — minimax M3 单次 LLM 约 10s, pivot 默认 moderate profile 30 iter
|
|
2004
|
+
// 留 ~300s. 之前 30s 自动 abort 时, 一次"你好"调用就被截断, 用户看到 ❌ 循环异常 + 空内容.
|
|
2005
|
+
// 改为 5 分钟 = 5x 实际耗时上限.
|
|
2006
|
+
const PIVOT_FORCE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
2007
|
+
const forceTimeout = setTimeout(() => {
|
|
2008
|
+
console.warn(`[server] /message pivot 强制 timeout (${PIVOT_FORCE_TIMEOUT_MS}ms), aborting`);
|
|
2009
|
+
runState.abortController?.abort();
|
|
2010
|
+
}, PIVOT_FORCE_TIMEOUT_MS);
|
|
1482
2011
|
broadcastQueueUpdate(channelId);
|
|
2012
|
+
// 2026-07-01 (v0.2.5): hoist sessionKey 到 try 外, 让 finally 块的 saveCurrentSession 能用
|
|
2013
|
+
const sessionKey = `${channelId}:${currentSessionId}`;
|
|
2014
|
+
let agent = null;
|
|
1483
2015
|
try {
|
|
1484
|
-
|
|
2016
|
+
agent = await getAgentForChannel(channelId, realChannelDid, realChannelName, realChannelDidDoc);
|
|
1485
2017
|
let fullResponse = '';
|
|
1486
2018
|
// P0.5: 注入门回传的 usedIds, 落 session message metadata, UI 可查
|
|
1487
2019
|
let usedJudgmentIds = [];
|
|
@@ -1511,6 +2043,16 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1511
2043
|
broadcast({ type: 'workflow_step', step: 'AI 思考', content: event.content.substring(0, 100) }, channelId);
|
|
1512
2044
|
}
|
|
1513
2045
|
}
|
|
2046
|
+
else if (event.type === 'reply-preview') {
|
|
2047
|
+
// 2026-07-06: pivot 每 iter 把完整 reply 推给前端 — 前端可以更新临时气泡
|
|
2048
|
+
// 等 loop 退完 type=ai 终文事件覆盖. 不依赖整个 promptWithPivotLoop 返回.
|
|
2049
|
+
// 这样 pivot 中途网络慢 / 5min-long 任务, 用户看到中间内容不是空白 + '任务处理超时'.
|
|
2050
|
+
broadcast({
|
|
2051
|
+
type: 'reply-preview',
|
|
2052
|
+
content: event.content,
|
|
2053
|
+
iteration: event.iteration,
|
|
2054
|
+
}, channelId);
|
|
2055
|
+
}
|
|
1514
2056
|
else if (event.type === 'status' || event.type === 'tool') {
|
|
1515
2057
|
broadcast({ type: 'status', tool: event.tool, content: event.content }, channelId);
|
|
1516
2058
|
broadcast({ type: 'workflow_step', step: event.tool || '系统', content: event.content }, channelId);
|
|
@@ -1701,19 +2243,41 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1701
2243
|
}
|
|
1702
2244
|
if (contextHint)
|
|
1703
2245
|
contextHint += '\n';
|
|
2246
|
+
// 2026-07-06: 全局 contextHint 硬裁 — MiniMax-M3 context window 8K token ≈ 32K 字符
|
|
2247
|
+
// 如果 contextHint 超过 14K 字符 (留 ~18K 给 userText + LLM 输出 + system), 主动截断
|
|
2248
|
+
const CONTEXT_LIMIT = 14000;
|
|
2249
|
+
let contextTruncated = false;
|
|
2250
|
+
if (contextHint.length > CONTEXT_LIMIT) {
|
|
2251
|
+
const originalLen = contextHint.length;
|
|
2252
|
+
contextHint = contextHint.slice(0, CONTEXT_LIMIT);
|
|
2253
|
+
contextHint += `\n[系统上下文] (...剩余 ${originalLen - CONTEXT_LIMIT} 字符因 context window 限制已截断, 完整内容请用 /judgments 查)\n`;
|
|
2254
|
+
contextTruncated = true;
|
|
2255
|
+
console.warn(`[chat] channel=${channelId} contextHint truncated: ${originalLen} -> ${contextHint.length} chars`);
|
|
2256
|
+
}
|
|
1704
2257
|
try {
|
|
1705
2258
|
// 2026-06-15: 把 user text 单独 marker 包起来, LLM 不会被 8K+ 的 system context 吞掉
|
|
1706
2259
|
// (之前 contextHint + text 拼成一整段当 user role, 24 字符的 user input 埋在 8K+ 里看不出)
|
|
1707
2260
|
// 修法: contextHint 当 "背景信息", text 当 "本轮用户请求" — 显式 marker 让 LLM 区分
|
|
1708
2261
|
// 2026-06-15 二次修: 把 text 放在最前 (LLM 看到 input 第一眼是 user text, 不会被 judgmentHint 末尾
|
|
1709
2262
|
// 的 "..." 误判为整个 input 截断)
|
|
1710
|
-
|
|
2263
|
+
// 2026-07-05: prepend nextPromptHints (manifest-loader 加载的对方能力, 仅本次生效)
|
|
2264
|
+
let extraHint = '';
|
|
2265
|
+
const hint = nextPromptHints.get(channelId);
|
|
2266
|
+
if (hint) {
|
|
2267
|
+
extraHint = hint + '\n\n';
|
|
2268
|
+
nextPromptHints.delete(channelId);
|
|
2269
|
+
}
|
|
2270
|
+
const markedPrompt = `${extraHint}【本轮用户请求】\n${text}\n【请求结束】\n\n${contextHint}`;
|
|
1711
2271
|
fullResponse = await agent.promptStream(markedPrompt, streamCallback, runState.abortController?.signal, channelId);
|
|
1712
2272
|
}
|
|
1713
2273
|
catch (err) {
|
|
1714
2274
|
// abort 抛错: 保留已输出的部分 (fullResponse 可能是空字符串)
|
|
1715
2275
|
if (runState.abortController?.signal.aborted || err?.name === 'AbortError') {
|
|
1716
2276
|
console.log(`[chat] aborted channel=${channelId}`);
|
|
2277
|
+
// 2026-07-06: abort 时 fullResponse 可能为空 (LLM 还没输出), 必须给用户一个反馈
|
|
2278
|
+
if (!fullResponse.trim()) {
|
|
2279
|
+
fullResponse = '⚠️ 生成已中断 (用户取消)';
|
|
2280
|
+
}
|
|
1717
2281
|
}
|
|
1718
2282
|
else {
|
|
1719
2283
|
// M1.2 (2026-06-17): 不再 rethrow — 把错误塞到 fullResponse 让 broadcast 出来, 后续 session 仍保存
|
|
@@ -1723,10 +2287,22 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1723
2287
|
broadcast({ type: 'error', content: fullResponse }, channelId);
|
|
1724
2288
|
}
|
|
1725
2289
|
}
|
|
1726
|
-
// abort 模式: 给 partial 拼后缀
|
|
1727
|
-
|
|
2290
|
+
// abort 模式: 给 partial 拼后缀 — 只在 LLM 真有输出时加中断标记,
|
|
2291
|
+
// 跳过空响应 + 跳过 [AI 服务调用失败] fallback (这些本身就是错误占位, 拼
|
|
2292
|
+
// "[生成已中断]" 会误导用户)
|
|
2293
|
+
const hasRealLlmOutput = fullResponse.trim().length > 0 &&
|
|
2294
|
+
!fullResponse.trimStart().startsWith('[AI 服务调用失败]');
|
|
2295
|
+
if (runState.abortController?.signal.aborted && hasRealLlmOutput) {
|
|
1728
2296
|
fullResponse = fullResponse + '\n\n_[生成已中断]_';
|
|
1729
2297
|
}
|
|
2298
|
+
// 2026-06-18: 修 lastFinalReply 没设 → /api/loop/inspect 永远返回空
|
|
2299
|
+
runState.lastFinalReply = fullResponse;
|
|
2300
|
+
// 2026-07-06: 防御性兜底 — fullResponse 为空时, 前端 segmentChatReply('') 返回 [],
|
|
2301
|
+
// 导致气泡不渲染, 用户只看到"思考中"占位符. 这里保证永远有内容可渲染.
|
|
2302
|
+
if (!fullResponse.trim()) {
|
|
2303
|
+
fullResponse = '⚠️ AI 未返回内容, 请重试';
|
|
2304
|
+
console.warn(`[chat] fullResponse empty for channel=${channelId}, using fallback`);
|
|
2305
|
+
}
|
|
1730
2306
|
// v3 新增: 解析 LLM 回复里的 @-mentions, 转发到目标 channel
|
|
1731
2307
|
await routeMentionsInReply(channelId, fullResponse, localChannels, remoteChannels);
|
|
1732
2308
|
broadcast({ type: 'ai', content: fullResponse }, channelId);
|
|
@@ -1746,6 +2322,117 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1746
2322
|
});
|
|
1747
2323
|
session.lastUpdated = new Date().toISOString();
|
|
1748
2324
|
await saveSession(session);
|
|
2325
|
+
// 2026-07-04: 写 session 后, 调 memory-compressor 把消息历史压缩到
|
|
2326
|
+
// ~/.bolloon/memory/<channel.agentId>/sessions/<safe-channel>__<safe-session>.summary.md
|
|
2327
|
+
// 失败静默, 不阻塞 /message 主路径.
|
|
2328
|
+
try {
|
|
2329
|
+
const channelsForMemory = await loadChannels();
|
|
2330
|
+
const channelForMemory = channelsForMemory.find(c => c.id === channelId);
|
|
2331
|
+
if (channelForMemory?.agentId) {
|
|
2332
|
+
const { compressSessionToMemory } = await import('../bootstrap/memory-compressor.js');
|
|
2333
|
+
const compressRes = await compressSessionToMemory({
|
|
2334
|
+
agentId: channelForMemory.agentId,
|
|
2335
|
+
channelId,
|
|
2336
|
+
sessionId: currentSessionId,
|
|
2337
|
+
});
|
|
2338
|
+
if (!compressRes.skipped && compressRes.bytesWritten > 0) {
|
|
2339
|
+
console.log(`[memory] compressed ${compressRes.messagesCount} new messages for ${channelForMemory.agentId}/${channelId} → ${compressRes.summaryPath} (${compressRes.bytesWritten}B)`);
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
catch (memErr) {
|
|
2344
|
+
console.warn('[memory] compressSessionToMemory failed (non-fatal):', memErr?.message?.slice(0, 200));
|
|
2345
|
+
}
|
|
2346
|
+
// 2026-07-05: 把 session 里的 user/ai 消息按涉及到的远端 peer 归档到月度 markdown
|
|
2347
|
+
// 触发条件: 消息里出现过 @远端 channel, 或者 session 历史上与该 peer 通信过
|
|
2348
|
+
// 失败静默, 不阻塞主对话
|
|
2349
|
+
try {
|
|
2350
|
+
const { appendChatArchive } = await import('../bootstrap/chat-archiver.js');
|
|
2351
|
+
// 找到这条消息涉及到的远端 peer (从 routeMentionsInReply 的结果推断 — 简单起见直接遍历 remoteChannels)
|
|
2352
|
+
const localChannels2 = (await loadChannels());
|
|
2353
|
+
const remoteChannels2 = [];
|
|
2354
|
+
for (const [peerPk, list] of remoteChannelCache.entries()) {
|
|
2355
|
+
for (const ch of list) {
|
|
2356
|
+
remoteChannels2.push({ ...ch, _ownerPublicKey: peerPk });
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
// 找出本次 text/fullResponse 里出现 @ 的 channel 对应的 owner pk
|
|
2360
|
+
const peerSet = new Set();
|
|
2361
|
+
const mentionRe = /@([一-龥A-Za-z0-9_\-]{1,30})/g;
|
|
2362
|
+
const mentionedNames = new Set();
|
|
2363
|
+
for (const m of text.matchAll(mentionRe))
|
|
2364
|
+
mentionedNames.add(m[1]);
|
|
2365
|
+
for (const m of fullResponse.matchAll(mentionRe))
|
|
2366
|
+
mentionedNames.add(m[1]);
|
|
2367
|
+
for (const rc of remoteChannels2) {
|
|
2368
|
+
if (mentionedNames.has(rc.name))
|
|
2369
|
+
peerSet.add(rc._ownerPublicKey);
|
|
2370
|
+
}
|
|
2371
|
+
// 追加到每个相关 peer 的月度归档
|
|
2372
|
+
for (const pk of peerSet) {
|
|
2373
|
+
await appendChatArchive({
|
|
2374
|
+
publicKey: pk,
|
|
2375
|
+
entry: {
|
|
2376
|
+
ts: new Date().toISOString(),
|
|
2377
|
+
source: 'local',
|
|
2378
|
+
channelId, channelName: (await loadChannels()).find(c => c.id === channelId)?.name,
|
|
2379
|
+
text: `[本节点] user: ${text.slice(0, 500)}\n[本节点] ai: ${fullResponse.slice(0, 800)}`,
|
|
2380
|
+
fromPublicKey: v3P2PRef?.getPublicKey(),
|
|
2381
|
+
msgType: 'user',
|
|
2382
|
+
}
|
|
2383
|
+
});
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
catch (archiveErr) {
|
|
2387
|
+
console.warn('[chat-archive] append failed (non-fatal):', archiveErr?.message?.slice(0, 200));
|
|
2388
|
+
}
|
|
2389
|
+
// 2026-07-05: 懒加载触发器 — 探测到 @-mention 远端 / 连续失败 / 关键词, 拉对方 manifest
|
|
2390
|
+
// 把 capability-index 拼到下一次 prompt 上下文 (只生效 1 次, 不污染主循环)
|
|
2391
|
+
try {
|
|
2392
|
+
const { detectLoadTrigger, loadPeerManifest, clearFailure } = await import('../agents/peer-manifest-loader.js');
|
|
2393
|
+
const remoteChannelsForDetect = [];
|
|
2394
|
+
for (const [peerPk, list] of remoteChannelCache.entries()) {
|
|
2395
|
+
for (const ch of list) {
|
|
2396
|
+
remoteChannelsForDetect.push({ ...ch, _ownerPublicKey: peerPk });
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
// 检测 + 加载
|
|
2400
|
+
const detection = detectLoadTrigger({
|
|
2401
|
+
text,
|
|
2402
|
+
channelId,
|
|
2403
|
+
remoteChannels: remoteChannelsForDetect,
|
|
2404
|
+
});
|
|
2405
|
+
if (detection.shouldLoad && detection.remotePublicKey && v3P2PRef) {
|
|
2406
|
+
const result = await loadPeerManifest({
|
|
2407
|
+
channelId,
|
|
2408
|
+
channelName: (await loadChannels()).find(c => c.id === channelId)?.name,
|
|
2409
|
+
reason: detection.reason,
|
|
2410
|
+
triggerValue: detection.triggerValue,
|
|
2411
|
+
remotePublicKey: detection.remotePublicKey,
|
|
2412
|
+
}, { p2p: v3P2PRef });
|
|
2413
|
+
if (result && result.promptBlock) {
|
|
2414
|
+
console.log(`[manifest-loader] ${detection.reason} → ${detection.remotePublicKey.slice(0, 12)}... (${result.durationMs}ms, rpc=${result.rpcTriggered}, agents=${result.agentDescriptions.length})`);
|
|
2415
|
+
// 把 promptBlock 推给前端 + 写入一次性的 prompt 附加 (nextPromptHint 全局变量, 在下次 agent.promptStream 前 prepend)
|
|
2416
|
+
broadcast({
|
|
2417
|
+
type: 'peer-manifest-loaded',
|
|
2418
|
+
fromPublicKey: detection.remotePublicKey,
|
|
2419
|
+
reason: detection.reason,
|
|
2420
|
+
promptBlock: result.promptBlock,
|
|
2421
|
+
capabilityIndex: result.capabilityIndex,
|
|
2422
|
+
}, channelId);
|
|
2423
|
+
// 缓存到 channel 维度的 nextPromptHint (下一次 LLM prompt 前 inject)
|
|
2424
|
+
nextPromptHints.set(channelId, (nextPromptHints.get(channelId) || '') + '\n\n' + result.promptBlock);
|
|
2425
|
+
clearFailure(channelId);
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
else {
|
|
2429
|
+
// 没触发, 也清掉连续失败计数 (用户消息说明对话还在正常进行)
|
|
2430
|
+
clearFailure(channelId);
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
catch (loaderErr) {
|
|
2434
|
+
console.warn('[manifest-loader] failed (non-fatal):', loaderErr?.message?.slice(0, 200));
|
|
2435
|
+
}
|
|
1749
2436
|
const channels = await loadChannels();
|
|
1750
2437
|
const channel = channels.find(c => c.id === channelId);
|
|
1751
2438
|
// 2026-06-11: 移除 suggestRename 的二次 LLM 调用 — 之前每次用户发消息, 智能体 channel 都会再调一次 LLM (5-8s) 自动改名
|
|
@@ -1807,6 +2494,10 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1807
2494
|
res.status(500).json({ error: err.message });
|
|
1808
2495
|
}
|
|
1809
2496
|
finally {
|
|
2497
|
+
// 2026-07-04: 清掉 pivot 强制 timeout timer, 避免 hang 情况下 timer 在
|
|
2498
|
+
// process 里一直挂着. forceTimeout 在 try 外创建, finally 在闭包里
|
|
2499
|
+
// 能直接拿到引用.
|
|
2500
|
+
clearTimeout(forceTimeout);
|
|
1810
2501
|
// queue dequeue: 跑完或失败都要清状态
|
|
1811
2502
|
// 当前实现: 自动接下一条需要把 ~200 行 try 块抽函数, 暂不抽.
|
|
1812
2503
|
// 替代: 用户点 [队列 +N] 按钮时, 客户端发起一个特殊的 HTTP 请求触发下一条
|
|
@@ -1814,6 +2505,18 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1814
2505
|
runState.running = false;
|
|
1815
2506
|
runState.abortController = null;
|
|
1816
2507
|
broadcastQueueUpdate(channelId);
|
|
2508
|
+
// 2026-07-01 (v0.2.5): 持久化当前 messageHistory — 让 web 用户跨刷新保留对话.
|
|
2509
|
+
// saveCurrentSession 失败静默, 不阻塞 channel 状态清理.
|
|
2510
|
+
// saveCurrentSession 内部走 SessionStore (默认 ~/.bolloon/sessions/cache/<sessionKey>.json).
|
|
2511
|
+
// agent 可能在 try 抛错后仍为 null (getAgentForChannel 失败), null-guard 跳过 save.
|
|
2512
|
+
if (agent) {
|
|
2513
|
+
try {
|
|
2514
|
+
await agent.saveCurrentSession(sessionKey);
|
|
2515
|
+
}
|
|
2516
|
+
catch (saveErr) {
|
|
2517
|
+
console.warn(`[web] saveCurrentSession failed (non-fatal): ${saveErr?.message?.slice(0, 100)}`);
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
1817
2520
|
}
|
|
1818
2521
|
});
|
|
1819
2522
|
// ---------- 频道元数据后台修复队列 ----------
|
|
@@ -1922,7 +2625,7 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1922
2625
|
async function getChannelsWithDID() {
|
|
1923
2626
|
const now = Date.now();
|
|
1924
2627
|
// 缓存命中: 数据有效 AND 在写盘之后 AND 在 TTL 内
|
|
1925
|
-
if (channelsCache.data && channelsCache.cachedAt >
|
|
2628
|
+
if (channelsCache.data && channelsCache.cachedAt > getLastChannelsWriteAt() && channelsCache.cachedAt + CHANNELS_CACHE_TTL_MS > now) {
|
|
1926
2629
|
return channelsCache.data;
|
|
1927
2630
|
}
|
|
1928
2631
|
const channels = await loadChannels();
|
|
@@ -1946,7 +2649,7 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1946
2649
|
// 2026-06-17: 缓存命中 → 0 行;未命中 → 1 行 summary (上面 console.log proxy 已吃掉 [API] /channels 等旧日志)
|
|
1947
2650
|
const t0 = Date.now();
|
|
1948
2651
|
const now = t0;
|
|
1949
|
-
const hit = !!(channelsCache.data && channelsCache.cachedAt >
|
|
2652
|
+
const hit = !!(channelsCache.data && channelsCache.cachedAt > getLastChannelsWriteAt() && channelsCache.cachedAt + CHANNELS_CACHE_TTL_MS > now);
|
|
1950
2653
|
const channels = await getChannelsWithDID();
|
|
1951
2654
|
if (!hit) {
|
|
1952
2655
|
console.log(`[channels] refresh, n=${channels.length}, t=+${Date.now() - t0}ms`);
|
|
@@ -2450,8 +3153,58 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
2450
3153
|
res.status(500).json({ error: err.message });
|
|
2451
3154
|
}
|
|
2452
3155
|
});
|
|
3156
|
+
// 2026-07-07 P1-C: 列出 channel 的项目事件日志 (L2)
|
|
3157
|
+
// 用于客户端时间线折叠块 + LLM prompt 注入
|
|
3158
|
+
app.get('/api/events/:channelId', async (req, res) => {
|
|
3159
|
+
try {
|
|
3160
|
+
const { listEvents } = await import('../bootstrap/event-log.js');
|
|
3161
|
+
const limit = parseInt(req.query.limit || '20', 10);
|
|
3162
|
+
const type = req.query.type;
|
|
3163
|
+
const events = await listEvents({
|
|
3164
|
+
channelId: req.params.channelId,
|
|
3165
|
+
limit: Math.min(limit, 100),
|
|
3166
|
+
type: type || undefined,
|
|
3167
|
+
});
|
|
3168
|
+
res.json({ events, total: events.length });
|
|
3169
|
+
}
|
|
3170
|
+
catch (err) {
|
|
3171
|
+
console.warn(`[api/events] ${req.params.channelId} 失败: ${err?.message || err}`);
|
|
3172
|
+
res.json({ events: [], total: 0 });
|
|
3173
|
+
}
|
|
3174
|
+
});
|
|
3175
|
+
// 2026-07-07 P1-C: 追加一条事件 (前端按钮 / 自动检测)
|
|
3176
|
+
app.post('/api/events/:channelId', async (req, res) => {
|
|
3177
|
+
try {
|
|
3178
|
+
const { appendEvent, EVENT_TYPES } = await import('../bootstrap/event-log.js');
|
|
3179
|
+
const { type, summary, detail, source, agentId } = req.body || {};
|
|
3180
|
+
if (!type || !EVENT_TYPES.includes(type)) {
|
|
3181
|
+
return res.status(400).json({ error: `type must be one of ${EVENT_TYPES.join(', ')}` });
|
|
3182
|
+
}
|
|
3183
|
+
if (!summary || typeof summary !== 'string') {
|
|
3184
|
+
return res.status(400).json({ error: 'summary required' });
|
|
3185
|
+
}
|
|
3186
|
+
const r = await appendEvent({
|
|
3187
|
+
channelId: req.params.channelId,
|
|
3188
|
+
type,
|
|
3189
|
+
summary: summary.slice(0, 200),
|
|
3190
|
+
detail: detail || {},
|
|
3191
|
+
source: source || 'user',
|
|
3192
|
+
agentId,
|
|
3193
|
+
});
|
|
3194
|
+
res.json({ ok: true, ...r });
|
|
3195
|
+
}
|
|
3196
|
+
catch (err) {
|
|
3197
|
+
console.warn(`[api/events POST] ${req.params.channelId} 失败: ${err?.message || err}`);
|
|
3198
|
+
res.status(500).json({ error: err?.message || String(err) });
|
|
3199
|
+
}
|
|
3200
|
+
});
|
|
2453
3201
|
app.get('/sessions/:channelId', async (req, res) => {
|
|
2454
3202
|
try {
|
|
3203
|
+
// 2026-07-07 H2 修复: channel 不存在 → 404 (之前返回空 Session 导致客户端误以为是空对话)
|
|
3204
|
+
const channels = await loadChannels();
|
|
3205
|
+
if (!channels.find(c => c.id === req.params.channelId)) {
|
|
3206
|
+
return res.status(404).json({ error: 'channel not found', channelId: req.params.channelId });
|
|
3207
|
+
}
|
|
2455
3208
|
const session = await loadSession(req.params.channelId, req.query.sessionId);
|
|
2456
3209
|
res.json(session || { channelId: req.params.channelId, sessionId: req.query.sessionId || 'default', messages: [], lastUpdated: null });
|
|
2457
3210
|
}
|
|
@@ -2566,6 +3319,10 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
2566
3319
|
const regenHint = await buildJudgmentHint(channel, channelId);
|
|
2567
3320
|
const markedRegen = `${regenHint}\n\n【本轮用户请求】\n${userMessage}\n【请求结束】\n`;
|
|
2568
3321
|
fullResponse = await agent.promptStream(markedRegen, streamCallback, undefined, channelId);
|
|
3322
|
+
// 2026-07-06: 防御性兜底 — 同 /message 路径
|
|
3323
|
+
if (!fullResponse.trim()) {
|
|
3324
|
+
fullResponse = '⚠️ AI 未返回内容, 请重试';
|
|
3325
|
+
}
|
|
2569
3326
|
broadcast({ type: 'ai', content: fullResponse }, channelId);
|
|
2570
3327
|
// 更新 session
|
|
2571
3328
|
const existingSession = await loadSession(channelId, currentSessionId);
|
|
@@ -2594,414 +3351,10 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
2594
3351
|
res.status(500).json({ error: err.message });
|
|
2595
3352
|
}
|
|
2596
3353
|
});
|
|
2597
|
-
//
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
const tasks = await loadTaskQueue();
|
|
2602
|
-
res.json(tasks);
|
|
2603
|
-
}
|
|
2604
|
-
catch (err) {
|
|
2605
|
-
res.status(500).json({ error: err.message });
|
|
2606
|
-
}
|
|
2607
|
-
});
|
|
2608
|
-
// 创建新任务
|
|
2609
|
-
app.post('/api/tasks', async (req, res) => {
|
|
2610
|
-
try {
|
|
2611
|
-
const { type, title, description, steps } = req.body;
|
|
2612
|
-
if (!type || !title) {
|
|
2613
|
-
return res.status(400).json({ error: 'type and title required' });
|
|
2614
|
-
}
|
|
2615
|
-
const tasks = await loadTaskQueue();
|
|
2616
|
-
const task = {
|
|
2617
|
-
id: `task_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`,
|
|
2618
|
-
type,
|
|
2619
|
-
title,
|
|
2620
|
-
description,
|
|
2621
|
-
status: 'pending',
|
|
2622
|
-
progress: 0,
|
|
2623
|
-
createdAt: new Date().toISOString(),
|
|
2624
|
-
updatedAt: new Date().toISOString(),
|
|
2625
|
-
steps: steps?.map((s, i) => ({
|
|
2626
|
-
id: `step_${i}`,
|
|
2627
|
-
name: s,
|
|
2628
|
-
status: 'pending'
|
|
2629
|
-
}))
|
|
2630
|
-
};
|
|
2631
|
-
tasks.push(task);
|
|
2632
|
-
await saveTaskQueue(tasks);
|
|
2633
|
-
res.json(task);
|
|
2634
|
-
}
|
|
2635
|
-
catch (err) {
|
|
2636
|
-
res.status(500).json({ error: err.message });
|
|
2637
|
-
}
|
|
2638
|
-
});
|
|
2639
|
-
// 获取单个任务
|
|
2640
|
-
app.get('/api/tasks/:taskId', async (req, res) => {
|
|
2641
|
-
try {
|
|
2642
|
-
const { taskId } = req.params;
|
|
2643
|
-
const tasks = await loadTaskQueue();
|
|
2644
|
-
const task = tasks.find(t => t.id === taskId);
|
|
2645
|
-
if (!task) {
|
|
2646
|
-
return res.status(404).json({ error: 'Task not found' });
|
|
2647
|
-
}
|
|
2648
|
-
res.json(task);
|
|
2649
|
-
}
|
|
2650
|
-
catch (err) {
|
|
2651
|
-
res.status(500).json({ error: err.message });
|
|
2652
|
-
}
|
|
2653
|
-
});
|
|
2654
|
-
// 更新任务
|
|
2655
|
-
app.patch('/api/tasks/:taskId', async (req, res) => {
|
|
2656
|
-
try {
|
|
2657
|
-
const { taskId } = req.params;
|
|
2658
|
-
const { status, currentStep } = req.body;
|
|
2659
|
-
const tasks = await loadTaskQueue();
|
|
2660
|
-
const taskIndex = tasks.findIndex(t => t.id === taskId);
|
|
2661
|
-
if (taskIndex === -1) {
|
|
2662
|
-
return res.status(404).json({ error: 'Task not found' });
|
|
2663
|
-
}
|
|
2664
|
-
if (status) {
|
|
2665
|
-
tasks[taskIndex].status = status;
|
|
2666
|
-
}
|
|
2667
|
-
if (currentStep !== undefined) {
|
|
2668
|
-
tasks[taskIndex].currentStep = currentStep;
|
|
2669
|
-
}
|
|
2670
|
-
tasks[taskIndex].updatedAt = new Date().toISOString();
|
|
2671
|
-
await saveTaskQueue(tasks);
|
|
2672
|
-
res.json(tasks[taskIndex]);
|
|
2673
|
-
}
|
|
2674
|
-
catch (err) {
|
|
2675
|
-
res.status(500).json({ error: err.message });
|
|
2676
|
-
}
|
|
2677
|
-
});
|
|
2678
|
-
// 删除任务
|
|
2679
|
-
app.delete('/api/tasks/:taskId', async (req, res) => {
|
|
2680
|
-
try {
|
|
2681
|
-
const { taskId } = req.params;
|
|
2682
|
-
const tasks = await loadTaskQueue();
|
|
2683
|
-
const filtered = tasks.filter(t => t.id !== taskId);
|
|
2684
|
-
await saveTaskQueue(filtered);
|
|
2685
|
-
res.json({ ok: true });
|
|
2686
|
-
}
|
|
2687
|
-
catch (err) {
|
|
2688
|
-
res.status(500).json({ error: err.message });
|
|
2689
|
-
}
|
|
2690
|
-
});
|
|
2691
|
-
// 执行任务(自动执行下一步)
|
|
2692
|
-
app.post('/api/tasks/:taskId/execute', async (req, res) => {
|
|
2693
|
-
try {
|
|
2694
|
-
const { taskId } = req.params;
|
|
2695
|
-
const { channelId } = req.body;
|
|
2696
|
-
if (!channelId) {
|
|
2697
|
-
return res.status(400).json({ error: 'channelId required' });
|
|
2698
|
-
}
|
|
2699
|
-
const tasks = await loadTaskQueue();
|
|
2700
|
-
const task = tasks.find(t => t.id === taskId);
|
|
2701
|
-
if (!task) {
|
|
2702
|
-
return res.status(404).json({ error: 'Task not found' });
|
|
2703
|
-
}
|
|
2704
|
-
if (isExecutingTask) {
|
|
2705
|
-
return res.status(409).json({ error: 'Another task is currently executing' });
|
|
2706
|
-
}
|
|
2707
|
-
// 异步执行任务
|
|
2708
|
-
executeTask(task, channelId);
|
|
2709
|
-
res.json({ ok: true, taskId: task.id });
|
|
2710
|
-
}
|
|
2711
|
-
catch (err) {
|
|
2712
|
-
res.status(500).json({ error: err.message });
|
|
2713
|
-
}
|
|
2714
|
-
});
|
|
2715
|
-
// 执行下一个待处理任务
|
|
2716
|
-
app.post('/api/tasks/execute-next', async (req, res) => {
|
|
2717
|
-
try {
|
|
2718
|
-
const { channelId } = req.body;
|
|
2719
|
-
if (!channelId) {
|
|
2720
|
-
return res.status(400).json({ error: 'channelId required' });
|
|
2721
|
-
}
|
|
2722
|
-
const tasks = await loadTaskQueue();
|
|
2723
|
-
const nextTask = tasks.find(t => t.status === 'pending');
|
|
2724
|
-
if (!nextTask) {
|
|
2725
|
-
return res.json({ ok: false, message: 'No pending tasks' });
|
|
2726
|
-
}
|
|
2727
|
-
if (isExecutingTask) {
|
|
2728
|
-
return res.status(409).json({ error: 'Another task is currently executing' });
|
|
2729
|
-
}
|
|
2730
|
-
// 异步执行任务
|
|
2731
|
-
executeTask(nextTask, channelId);
|
|
2732
|
-
res.json({ ok: true, taskId: nextTask.id });
|
|
2733
|
-
}
|
|
2734
|
-
catch (err) {
|
|
2735
|
-
res.status(500).json({ error: err.message });
|
|
2736
|
-
}
|
|
2737
|
-
});
|
|
2738
|
-
// ==================== LLM 配置 API ====================
|
|
2739
|
-
// 获取所有 LLM 配置
|
|
2740
|
-
app.get('/api/llm-config', async (req, res) => {
|
|
2741
|
-
try {
|
|
2742
|
-
const config = await llmConfigStore.getConfig();
|
|
2743
|
-
const providerInfo = llmConfigStore.getAllProviderInfo();
|
|
2744
|
-
// 隐藏 API Key
|
|
2745
|
-
const safeConfig = {
|
|
2746
|
-
...config,
|
|
2747
|
-
providers: Object.fromEntries(Object.entries(config.providers).map(([key, val]) => [
|
|
2748
|
-
key,
|
|
2749
|
-
{ ...val, apiKey: val.apiKey ? '***' + val.apiKey.slice(-4) : '' }
|
|
2750
|
-
])),
|
|
2751
|
-
providerInfo
|
|
2752
|
-
};
|
|
2753
|
-
res.json(safeConfig);
|
|
2754
|
-
}
|
|
2755
|
-
catch (err) {
|
|
2756
|
-
res.status(500).json({ error: err.message });
|
|
2757
|
-
}
|
|
2758
|
-
});
|
|
2759
|
-
// 更新 LLM 配置
|
|
2760
|
-
app.post('/api/llm-config', async (req, res) => {
|
|
2761
|
-
try {
|
|
2762
|
-
const { provider, config } = req.body;
|
|
2763
|
-
if (!provider || !config) {
|
|
2764
|
-
return res.status(400).json({ error: 'provider and config required' });
|
|
2765
|
-
}
|
|
2766
|
-
// 如果前端发的是掩码(***xxx),从当前配置里取真实 key
|
|
2767
|
-
const currentConfig = await llmConfigStore.getProvider(provider);
|
|
2768
|
-
if (currentConfig && config.apiKey && config.apiKey.startsWith('***')) {
|
|
2769
|
-
config.apiKey = currentConfig.apiKey;
|
|
2770
|
-
}
|
|
2771
|
-
await llmConfigStore.updateProvider(provider, config);
|
|
2772
|
-
// 如果是活跃供应商,重新初始化 Pi SDK
|
|
2773
|
-
const currentActive = await llmConfigStore.getActiveProvider();
|
|
2774
|
-
if (provider === currentActive) {
|
|
2775
|
-
const newConfig = await llmConfigStore.getActiveProviderConfig();
|
|
2776
|
-
if (newConfig) {
|
|
2777
|
-
initMinimax({
|
|
2778
|
-
provider,
|
|
2779
|
-
apiKey: newConfig.apiKey || undefined,
|
|
2780
|
-
baseUrl: newConfig.baseUrl || undefined,
|
|
2781
|
-
model: newConfig.model || undefined
|
|
2782
|
-
});
|
|
2783
|
-
}
|
|
2784
|
-
}
|
|
2785
|
-
res.json({ ok: true });
|
|
2786
|
-
}
|
|
2787
|
-
catch (err) {
|
|
2788
|
-
res.status(500).json({ error: err.message });
|
|
2789
|
-
}
|
|
2790
|
-
});
|
|
2791
|
-
// 设置活跃供应商
|
|
2792
|
-
app.post('/api/llm-provider', async (req, res) => {
|
|
2793
|
-
try {
|
|
2794
|
-
const { provider } = req.body;
|
|
2795
|
-
if (!provider) {
|
|
2796
|
-
return res.status(400).json({ error: 'provider required' });
|
|
2797
|
-
}
|
|
2798
|
-
await llmConfigStore.setActiveProvider(provider);
|
|
2799
|
-
// 重新初始化 Pi SDK
|
|
2800
|
-
const config = await llmConfigStore.getActiveProviderConfig();
|
|
2801
|
-
if (config) {
|
|
2802
|
-
initMinimax({
|
|
2803
|
-
provider: provider,
|
|
2804
|
-
apiKey: config.apiKey || undefined,
|
|
2805
|
-
baseUrl: config.baseUrl || undefined,
|
|
2806
|
-
model: config.model || undefined
|
|
2807
|
-
});
|
|
2808
|
-
}
|
|
2809
|
-
res.json({ ok: true, provider });
|
|
2810
|
-
}
|
|
2811
|
-
catch (err) {
|
|
2812
|
-
res.status(500).json({ error: err.message });
|
|
2813
|
-
}
|
|
2814
|
-
});
|
|
2815
|
-
// 测试供应商连接
|
|
2816
|
-
app.post('/api/llm-test', async (req, res) => {
|
|
2817
|
-
try {
|
|
2818
|
-
const { provider } = req.body;
|
|
2819
|
-
if (!provider) {
|
|
2820
|
-
return res.status(400).json({ error: 'provider required' });
|
|
2821
|
-
}
|
|
2822
|
-
const result = await llmConfigStore.testProvider(provider);
|
|
2823
|
-
res.json(result);
|
|
2824
|
-
}
|
|
2825
|
-
catch (err) {
|
|
2826
|
-
res.status(500).json({ error: err.message });
|
|
2827
|
-
}
|
|
2828
|
-
});
|
|
2829
|
-
// ==================== 视频生成配置 (Seedance 等) ====================
|
|
2830
|
-
// 获取视频生成配置
|
|
2831
|
-
app.get('/api/video-config', async (req, res) => {
|
|
2832
|
-
try {
|
|
2833
|
-
const config = await videoConfigStore.getConfig();
|
|
2834
|
-
const providerInfo = videoConfigStore.getAllProviderInfo();
|
|
2835
|
-
// 脱敏:不返回 apiKey 明文
|
|
2836
|
-
const masked = Object.fromEntries(Object.entries(config.providers).map(([key, val]) => [
|
|
2837
|
-
key,
|
|
2838
|
-
{ ...val, apiKey: val.apiKey ? '***' + val.apiKey.slice(-4) : '' }
|
|
2839
|
-
]));
|
|
2840
|
-
res.json({
|
|
2841
|
-
activeProvider: config.activeProvider,
|
|
2842
|
-
providers: masked,
|
|
2843
|
-
providerInfo
|
|
2844
|
-
});
|
|
2845
|
-
}
|
|
2846
|
-
catch (err) {
|
|
2847
|
-
res.status(500).json({ error: err.message });
|
|
2848
|
-
}
|
|
2849
|
-
});
|
|
2850
|
-
// 更新视频供应商配置
|
|
2851
|
-
app.post('/api/video-config', async (req, res) => {
|
|
2852
|
-
try {
|
|
2853
|
-
const { provider, config } = req.body;
|
|
2854
|
-
if (!provider || !config) {
|
|
2855
|
-
return res.status(400).json({ error: 'provider and config required' });
|
|
2856
|
-
}
|
|
2857
|
-
// 如果前端发的是掩码(***xxx),从当前配置里取真实 key
|
|
2858
|
-
const currentConfig = await videoConfigStore.getProvider(provider);
|
|
2859
|
-
if (currentConfig && config.apiKey && config.apiKey.startsWith('***')) {
|
|
2860
|
-
config.apiKey = currentConfig.apiKey;
|
|
2861
|
-
}
|
|
2862
|
-
await videoConfigStore.updateProvider(provider, config);
|
|
2863
|
-
res.json({ ok: true });
|
|
2864
|
-
}
|
|
2865
|
-
catch (err) {
|
|
2866
|
-
res.status(500).json({ error: err.message });
|
|
2867
|
-
}
|
|
2868
|
-
});
|
|
2869
|
-
// 测试视频供应商连接
|
|
2870
|
-
app.post('/api/video-test', async (req, res) => {
|
|
2871
|
-
try {
|
|
2872
|
-
const { provider } = req.body;
|
|
2873
|
-
if (!provider) {
|
|
2874
|
-
return res.status(400).json({ error: 'provider required' });
|
|
2875
|
-
}
|
|
2876
|
-
const result = await videoConfigStore.testProvider(provider);
|
|
2877
|
-
res.json(result);
|
|
2878
|
-
}
|
|
2879
|
-
catch (err) {
|
|
2880
|
-
res.status(500).json({ error: err.message });
|
|
2881
|
-
}
|
|
2882
|
-
});
|
|
2883
|
-
// ==================== 音频生成配置 (TTS / Music) ====================
|
|
2884
|
-
// 获取音频配置
|
|
2885
|
-
app.get('/api/audio-config', async (req, res) => {
|
|
2886
|
-
try {
|
|
2887
|
-
const config = await audioConfigStore.getConfig();
|
|
2888
|
-
const providerInfo = audioConfigStore.getAllProviderInfo();
|
|
2889
|
-
const masked = Object.fromEntries(Object.entries(config.providers).map(([key, val]) => [
|
|
2890
|
-
key,
|
|
2891
|
-
{ ...val, apiKey: val.apiKey ? '***' + val.apiKey.slice(-4) : '' }
|
|
2892
|
-
]));
|
|
2893
|
-
res.json({
|
|
2894
|
-
activeProvider: config.activeProvider,
|
|
2895
|
-
providers: masked,
|
|
2896
|
-
providerInfo
|
|
2897
|
-
});
|
|
2898
|
-
}
|
|
2899
|
-
catch (err) {
|
|
2900
|
-
res.status(500).json({ error: err.message });
|
|
2901
|
-
}
|
|
2902
|
-
});
|
|
2903
|
-
// 更新音频供应商配置
|
|
2904
|
-
app.post('/api/audio-config', async (req, res) => {
|
|
2905
|
-
try {
|
|
2906
|
-
const { provider, config } = req.body;
|
|
2907
|
-
if (!provider || !config) {
|
|
2908
|
-
return res.status(400).json({ error: 'provider and config required' });
|
|
2909
|
-
}
|
|
2910
|
-
// 掩码回写真实 key
|
|
2911
|
-
const currentConfig = await audioConfigStore.getProvider(provider);
|
|
2912
|
-
if (currentConfig && config.apiKey && config.apiKey.startsWith('***')) {
|
|
2913
|
-
config.apiKey = currentConfig.apiKey;
|
|
2914
|
-
}
|
|
2915
|
-
await audioConfigStore.updateProvider(provider, config);
|
|
2916
|
-
res.json({ ok: true });
|
|
2917
|
-
}
|
|
2918
|
-
catch (err) {
|
|
2919
|
-
res.status(500).json({ error: err.message });
|
|
2920
|
-
}
|
|
2921
|
-
});
|
|
2922
|
-
// 测试音频供应商连接
|
|
2923
|
-
app.post('/api/audio-test', async (req, res) => {
|
|
2924
|
-
try {
|
|
2925
|
-
const { provider } = req.body;
|
|
2926
|
-
if (!provider) {
|
|
2927
|
-
return res.status(400).json({ error: 'provider required' });
|
|
2928
|
-
}
|
|
2929
|
-
const result = await audioConfigStore.testProvider(provider);
|
|
2930
|
-
res.json(result);
|
|
2931
|
-
}
|
|
2932
|
-
catch (err) {
|
|
2933
|
-
res.status(500).json({ error: err.message });
|
|
2934
|
-
}
|
|
2935
|
-
});
|
|
2936
|
-
// 统一 AI 解析入口:CLI / 接收方节点 调这里完成 LLM + judgment + harness
|
|
2937
|
-
// 入参: { text, mimeType, fileName, fromNodeId, source }
|
|
2938
|
-
// 出参: { summary, qualityScore, judgmentId?, gateArtifact? }
|
|
2939
|
-
app.post('/api/ai-parse', async (req, res) => {
|
|
2940
|
-
try {
|
|
2941
|
-
const { text, mimeType, fileName, fromNodeId, source } = req.body || {};
|
|
2942
|
-
if (!text || !fileName) {
|
|
2943
|
-
return res.status(400).json({ error: 'text and fileName required' });
|
|
2944
|
-
}
|
|
2945
|
-
const truncated = text.length > 6000 ? text.substring(0, 6000) + '...[截断]' : text;
|
|
2946
|
-
const prompt = `请分析以下 ${mimeType || 'text'} 文档,并给出 (1) 一句话中文摘要 (2) 三个关键要点 (3) 质量评分(0-1)。\n\n文件名: ${fileName}\n\n内容:\n${truncated}`;
|
|
2947
|
-
// 1. LLM 解析
|
|
2948
|
-
const llm = getMinimax();
|
|
2949
|
-
const t0 = Date.now();
|
|
2950
|
-
const llmResult = await llm.summarize(prompt);
|
|
2951
|
-
const dt = Date.now() - t0;
|
|
2952
|
-
const out = {
|
|
2953
|
-
ok: true,
|
|
2954
|
-
summary: llmResult.summary,
|
|
2955
|
-
qualityScore: llmResult.qualityScore,
|
|
2956
|
-
latencyMs: dt,
|
|
2957
|
-
mimeType: mimeType || 'text/plain',
|
|
2958
|
-
fileName,
|
|
2959
|
-
};
|
|
2960
|
-
// 2. 蒸馏为 judgment (异步,失败不影响主返回)
|
|
2961
|
-
try {
|
|
2962
|
-
const judgmentMod = await import('../pi-ecosystem-judgment/index.js');
|
|
2963
|
-
await judgmentMod.initializeJudgmentStore();
|
|
2964
|
-
const j = await judgmentMod.createJudgment({
|
|
2965
|
-
type: 'trajectory',
|
|
2966
|
-
content: `AI 解析 ${fileName}: ${llmResult.summary.slice(0, 200)}`,
|
|
2967
|
-
source: 'agent',
|
|
2968
|
-
confidence: Math.min(1, llmResult.qualityScore),
|
|
2969
|
-
context: `ai-parse:${mimeType || 'text'}:${source || 'p2p'}`,
|
|
2970
|
-
evidence: {
|
|
2971
|
-
trajectory: [{
|
|
2972
|
-
timestamp: new Date().toISOString(),
|
|
2973
|
-
action: `parse:${fileName}`,
|
|
2974
|
-
outcome: `score=${llmResult.qualityScore.toFixed(2)}`,
|
|
2975
|
-
approved: true,
|
|
2976
|
-
}],
|
|
2977
|
-
},
|
|
2978
|
-
});
|
|
2979
|
-
out.judgmentId = j.id;
|
|
2980
|
-
}
|
|
2981
|
-
catch (e) {
|
|
2982
|
-
out.judgmentError = e.message;
|
|
2983
|
-
}
|
|
2984
|
-
// 3. 在 harness 落产物 (异步,失败不影响)
|
|
2985
|
-
try {
|
|
2986
|
-
const harnessMod = await import('../bollharness-integration/index.js');
|
|
2987
|
-
const gate = new harnessMod.GateStateMachine();
|
|
2988
|
-
gate.submitArtifact(`ai-parse:${fileName}`, {
|
|
2989
|
-
summary: llmResult.summary,
|
|
2990
|
-
score: llmResult.qualityScore,
|
|
2991
|
-
fromNodeId: fromNodeId || null,
|
|
2992
|
-
parsedAt: Date.now(),
|
|
2993
|
-
});
|
|
2994
|
-
out.gateArtifact = `ai-parse:${fileName}`;
|
|
2995
|
-
}
|
|
2996
|
-
catch (e) {
|
|
2997
|
-
out.gateError = e.message;
|
|
2998
|
-
}
|
|
2999
|
-
res.json(out);
|
|
3000
|
-
}
|
|
3001
|
-
catch (err) {
|
|
3002
|
-
res.status(500).json({ error: err.message });
|
|
3003
|
-
}
|
|
3004
|
-
});
|
|
3354
|
+
// 2026-07-06: Task Queue API 抽到 ./routes-tasks.ts
|
|
3355
|
+
registerTaskRoutes(app, { broadcast, getAgentForChannel });
|
|
3356
|
+
// 2026-07-06: LLM/Video/Audio 配置路由抽到 ./routes-llm-config.ts
|
|
3357
|
+
registerLlmConfigRoutes(app);
|
|
3005
3358
|
// ==================== P2P Network API ====================
|
|
3006
3359
|
// 获取当前身份
|
|
3007
3360
|
app.get('/api/identity', async (_req, res) => {
|
|
@@ -3558,11 +3911,25 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
3558
3911
|
res.json({ initialized: false });
|
|
3559
3912
|
return;
|
|
3560
3913
|
}
|
|
3914
|
+
// 2026-07-04: irohTransport.getNodeId() 在某些环境下返回空字符串 (@rayhanadev/iroh + Windows
|
|
3915
|
+
// native binding 启动延迟). fallback: 用 v3 P2PDirect (hyperswarm) 的 publicKey 兜底,
|
|
3916
|
+
// 保证客户端拿到一个可用的 peer id (v3 是实际数据通道, 不是 mock).
|
|
3917
|
+
let effectiveNodeId = irohNodeInfo.irohNodeId;
|
|
3918
|
+
if (!effectiveNodeId && v3P2PRef) {
|
|
3919
|
+
try {
|
|
3920
|
+
effectiveNodeId = v3P2PRef.getPublicKey() || '';
|
|
3921
|
+
if (effectiveNodeId) {
|
|
3922
|
+
console.log('[iroh API] irohNodeId 为空, fallback 到 v3 P2PDirect publicKey:', effectiveNodeId.substring(0, 16) + '...');
|
|
3923
|
+
}
|
|
3924
|
+
}
|
|
3925
|
+
catch { /* ignore */ }
|
|
3926
|
+
}
|
|
3561
3927
|
res.json({
|
|
3562
3928
|
initialized: true,
|
|
3563
3929
|
did: irohNodeInfo.did,
|
|
3564
3930
|
cid: irohNodeInfo.cid,
|
|
3565
|
-
irohNodeId:
|
|
3931
|
+
irohNodeId: effectiveNodeId,
|
|
3932
|
+
irohNodeIdSource: effectiveNodeId === irohNodeInfo.irohNodeId ? 'iroh' : (effectiveNodeId ? 'v3-p2p-fallback' : 'unavailable'),
|
|
3566
3933
|
name: irohNodeInfo.name
|
|
3567
3934
|
});
|
|
3568
3935
|
});
|
|
@@ -3887,6 +4254,70 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
3887
4254
|
res.status(500).json({ error: err.message });
|
|
3888
4255
|
}
|
|
3889
4256
|
});
|
|
4257
|
+
// 2026-07-06: SSE 断连恢复 — 拉该 channel 从 afterSeq 之后的 AI 回复 + 状态
|
|
4258
|
+
// body: { channelId, sessionId?, afterSeq }
|
|
4259
|
+
app.post('/api/chat/resume', async (req, res) => {
|
|
4260
|
+
try {
|
|
4261
|
+
const { channelId, sessionId, afterSeq } = req.body;
|
|
4262
|
+
if (!channelId)
|
|
4263
|
+
return res.status(400).json({ error: 'channelId required' });
|
|
4264
|
+
const curSeq = channelEventSeq.get(channelId) || 0;
|
|
4265
|
+
// 1) 拉 session.messages — 这部分已经持久化, 不依赖内存 state
|
|
4266
|
+
const sess = await loadSession(channelId, sessionId || 'default');
|
|
4267
|
+
const messages = sess?.messages || [];
|
|
4268
|
+
// 2) 找从 afterSeq 之后发生的 user/ai 消息 (从 messages[] 推导出"事后视角")
|
|
4269
|
+
// 注意: messages[] 是按时间顺序, 没有 seq 字段; 我们用 lastUpdated + AI msgId 列表 推断
|
|
4270
|
+
// 简单方案: 返回 channels 上 >= afterSeq 对应时间的 ai/user messages (按 timestamp 截)
|
|
4271
|
+
// 因为 seq 是事件层, messages 是实体层 — 这里实体层的 fallback 就够用
|
|
4272
|
+
const afterSeqNum = typeof afterSeq === 'number' ? afterSeq : 0;
|
|
4273
|
+
// 启发式: 如果当前 seq 已经超过 afterSeq, 至少漏了一些事件
|
|
4274
|
+
const missedSome = curSeq > afterSeqNum;
|
|
4275
|
+
// 3) 返回给前端的"补发包": 当 missedSome, 拿最近 1 轮 user+ai (最近的 ai message)
|
|
4276
|
+
let resume = {
|
|
4277
|
+
ok: true,
|
|
4278
|
+
channelId,
|
|
4279
|
+
currentSeq: curSeq,
|
|
4280
|
+
missedSome,
|
|
4281
|
+
recoveredMessages: [],
|
|
4282
|
+
};
|
|
4283
|
+
if (missedSome && messages.length > 0) {
|
|
4284
|
+
// 拿最后一条 ai message
|
|
4285
|
+
const lastAi = [...messages].reverse().find(m => m.type === 'ai');
|
|
4286
|
+
if (lastAi) {
|
|
4287
|
+
resume.recoveredMessages.push({
|
|
4288
|
+
msgId: lastAi.id || `recover_${Date.now()}`,
|
|
4289
|
+
type: 'ai',
|
|
4290
|
+
content: lastAi.content,
|
|
4291
|
+
source: lastAi.source,
|
|
4292
|
+
timestamp: lastAi.timestamp,
|
|
4293
|
+
});
|
|
4294
|
+
}
|
|
4295
|
+
// 拿最近一条 user message (如果不是用户本地发的, 也能补全)
|
|
4296
|
+
const lastUser = [...messages].reverse().find(m => m.type === 'user');
|
|
4297
|
+
if (lastUser) {
|
|
4298
|
+
resume.recoveredMessages.push({
|
|
4299
|
+
msgId: lastUser.id || `recover_user_${Date.now()}`,
|
|
4300
|
+
type: 'user',
|
|
4301
|
+
content: lastUser.content,
|
|
4302
|
+
source: lastUser.source,
|
|
4303
|
+
timestamp: lastUser.timestamp,
|
|
4304
|
+
});
|
|
4305
|
+
}
|
|
4306
|
+
// 4) 如果当前还在跑 (channelRunState.running), 通知前端"正在生成中"
|
|
4307
|
+
const runState = channelRunState.get(channelId);
|
|
4308
|
+
if (runState?.running) {
|
|
4309
|
+
resume.stillRunning = true;
|
|
4310
|
+
resume.partialText = runState.lastFinalReply || '';
|
|
4311
|
+
}
|
|
4312
|
+
}
|
|
4313
|
+
console.log(`[resume] channel=${channelId}, afterSeq=${afterSeqNum}, curSeq=${curSeq}, recovered=${resume.recoveredMessages.length}, stillRunning=${resume.stillRunning || false}`);
|
|
4314
|
+
res.json(resume);
|
|
4315
|
+
}
|
|
4316
|
+
catch (err) {
|
|
4317
|
+
console.error('[resume] failed:', err.message?.slice(0, 200));
|
|
4318
|
+
res.status(500).json({ error: err.message });
|
|
4319
|
+
}
|
|
4320
|
+
});
|
|
3890
4321
|
// 用户审阅: 批准 draft
|
|
3891
4322
|
app.post('/api/chat/approve', async (req, res) => {
|
|
3892
4323
|
try {
|
|
@@ -4232,708 +4663,8 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
4232
4663
|
res.status(500).json({ error: err.message });
|
|
4233
4664
|
}
|
|
4234
4665
|
});
|
|
4235
|
-
//
|
|
4236
|
-
|
|
4237
|
-
// GET /api/judgments — 列出所有判断 (新→旧)
|
|
4238
|
-
// 存储: ~/.bolloon/human-values/judgments.json (human-value-store)
|
|
4239
|
-
// 极简版: 只记录 decision + reason; 其它字段可选
|
|
4240
|
-
app.post('/api/judgments', async (req, res) => {
|
|
4241
|
-
try {
|
|
4242
|
-
const { decision, reason, context } = req.body;
|
|
4243
|
-
if (!decision || typeof decision !== 'string' || !decision.trim()) {
|
|
4244
|
-
return res.status(400).json({ error: 'decision required' });
|
|
4245
|
-
}
|
|
4246
|
-
const { storeHumanJudgment, initializeValueStore } = await import('../pi-ecosystem-judgment/human-value-store.js');
|
|
4247
|
-
await initializeValueStore();
|
|
4248
|
-
const j = await storeHumanJudgment({
|
|
4249
|
-
decision: decision.trim(),
|
|
4250
|
-
decision_type: 'approve',
|
|
4251
|
-
reasons: reason ? [reason.trim()] : [],
|
|
4252
|
-
values_derived: [],
|
|
4253
|
-
context: {
|
|
4254
|
-
domain: context?.domain || 'general',
|
|
4255
|
-
complexity: 'moderate',
|
|
4256
|
-
stakes: context?.stakes || 'medium',
|
|
4257
|
-
time_pressure: 'low',
|
|
4258
|
-
},
|
|
4259
|
-
metadata: {
|
|
4260
|
-
source: 'explicit',
|
|
4261
|
-
confidence: 0.8,
|
|
4262
|
-
revisable: true,
|
|
4263
|
-
},
|
|
4264
|
-
});
|
|
4265
|
-
res.json({ ok: true, judgment: j });
|
|
4266
|
-
}
|
|
4267
|
-
catch (err) {
|
|
4268
|
-
console.error('[judgments] POST failed:', err);
|
|
4269
|
-
res.status(500).json({ error: err.message });
|
|
4270
|
-
}
|
|
4271
|
-
});
|
|
4272
|
-
app.get('/api/judgments', async (req, res) => {
|
|
4273
|
-
try {
|
|
4274
|
-
const { listJudgmentsByStatus, initializeValueStore } = await import('../pi-ecosystem-judgment/human-value-store.js');
|
|
4275
|
-
await initializeValueStore();
|
|
4276
|
-
const status = (typeof req.query.status === 'string' ? req.query.status : 'all');
|
|
4277
|
-
const all = await listJudgmentsByStatus(status);
|
|
4278
|
-
all.sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || ''));
|
|
4279
|
-
res.json({ count: all.length, status, judgments: all });
|
|
4280
|
-
}
|
|
4281
|
-
catch (err) {
|
|
4282
|
-
console.error('[judgments] GET failed:', err);
|
|
4283
|
-
res.status(500).json({ error: err.message });
|
|
4284
|
-
}
|
|
4285
|
-
});
|
|
4286
|
-
// 蒸馏 B 触发 (人类点按钮) — 同步执行演化对齐
|
|
4287
|
-
app.post('/api/judgments/distill-from-conversation', async (req, res) => {
|
|
4288
|
-
try {
|
|
4289
|
-
const { channelId, messageId, recentTurns } = req.body;
|
|
4290
|
-
if (!channelId) {
|
|
4291
|
-
return res.status(400).json({ error: 'channelId required' });
|
|
4292
|
-
}
|
|
4293
|
-
// 取 channel 最近的对话
|
|
4294
|
-
const channels = await loadChannels();
|
|
4295
|
-
const channel = channels.find((c) => c.id === channelId);
|
|
4296
|
-
if (!channel)
|
|
4297
|
-
return res.status(404).json({ error: 'channel not found' });
|
|
4298
|
-
const currentSessionId = channel.currentSessionId;
|
|
4299
|
-
if (!currentSessionId) {
|
|
4300
|
-
return res.status(400).json({ error: 'no active session in channel' });
|
|
4301
|
-
}
|
|
4302
|
-
const session = await loadSession(channelId, currentSessionId);
|
|
4303
|
-
if (!session)
|
|
4304
|
-
return res.status(404).json({ error: 'session not found' });
|
|
4305
|
-
// 取最近 N 轮 (默认 10), 转成 DistillTurn 格式
|
|
4306
|
-
const limit = Math.min(Math.max(recentTurns ?? 10, 2), 30);
|
|
4307
|
-
const turns = session.messages.slice(-limit).map((m) => ({
|
|
4308
|
-
role: (m.type === 'user' ? 'human' : 'agent'),
|
|
4309
|
-
content: m.content,
|
|
4310
|
-
}));
|
|
4311
|
-
const { distillAndStoreFromChannel } = await import('../pi-ecosystem-judgment/human-value-pipeline.js');
|
|
4312
|
-
const result = await distillAndStoreFromChannel(turns, { channelId });
|
|
4313
|
-
res.json({
|
|
4314
|
-
ok: true,
|
|
4315
|
-
triggered: result.triggered,
|
|
4316
|
-
reason: result.reason,
|
|
4317
|
-
judgment: result.judgment,
|
|
4318
|
-
evolved: result.evolved,
|
|
4319
|
-
});
|
|
4320
|
-
}
|
|
4321
|
-
catch (err) {
|
|
4322
|
-
console.error('[judgments] distill-from-conversation failed:', err);
|
|
4323
|
-
res.status(500).json({ error: err.message });
|
|
4324
|
-
}
|
|
4325
|
-
});
|
|
4326
|
-
// 蒸馏 D 触发 (AI 被动) — 后台异步,不阻塞 HTTP 响应
|
|
4327
|
-
app.post('/api/judgments/detect-and-distill', async (req, res) => {
|
|
4328
|
-
try {
|
|
4329
|
-
const { channelId, turns } = req.body;
|
|
4330
|
-
// 先立即返回 202, 不等 LLM
|
|
4331
|
-
res.status(202).json({ ok: true, queued: true });
|
|
4332
|
-
if (!channelId || !Array.isArray(turns) || turns.length === 0) {
|
|
4333
|
-
return;
|
|
4334
|
-
}
|
|
4335
|
-
// 异步处理 (不 await, 不阻塞响应)
|
|
4336
|
-
setImmediate(async () => {
|
|
4337
|
-
try {
|
|
4338
|
-
const { detectAndDistillFromChannel } = await import('../pi-ecosystem-judgment/human-value-pipeline.js');
|
|
4339
|
-
const result = await detectAndDistillFromChannel(turns, { channelId });
|
|
4340
|
-
if (result.triggered) {
|
|
4341
|
-
console.log(`[D-hook] ${channelId}: ${result.reason}`, result.evolved);
|
|
4342
|
-
}
|
|
4343
|
-
}
|
|
4344
|
-
catch (err) {
|
|
4345
|
-
console.warn('[D-hook] background failed:', err);
|
|
4346
|
-
}
|
|
4347
|
-
});
|
|
4348
|
-
}
|
|
4349
|
-
catch (err) {
|
|
4350
|
-
console.error('[judgments] detect-and-distill failed:', err);
|
|
4351
|
-
res.status(500).json({ error: err.message });
|
|
4352
|
-
}
|
|
4353
|
-
});
|
|
4354
|
-
// 判断力使用回溯 (P0.5): 给定 judgmentIds, 反查对应的 decision 文本
|
|
4355
|
-
// 用途: UI 上"这条 AI 回复引用了哪些原则"
|
|
4356
|
-
app.post('/api/judgments/resolve-usage', async (req, res) => {
|
|
4357
|
-
try {
|
|
4358
|
-
const { ids } = req.body;
|
|
4359
|
-
if (!Array.isArray(ids) || ids.length === 0) {
|
|
4360
|
-
return res.json({ items: [] });
|
|
4361
|
-
}
|
|
4362
|
-
const { loadAllJudgments } = await import('../pi-ecosystem-judgment/human-value-store.js');
|
|
4363
|
-
const all = await loadAllJudgments();
|
|
4364
|
-
const byId = new Map(all.map((j) => [j.id, j]));
|
|
4365
|
-
const items = ids
|
|
4366
|
-
.map((id) => byId.get(id))
|
|
4367
|
-
.filter((j) => Boolean(j))
|
|
4368
|
-
.map((j) => ({
|
|
4369
|
-
id: j.id,
|
|
4370
|
-
decision: j.decision,
|
|
4371
|
-
status: j.status ?? 'active',
|
|
4372
|
-
timestamp: j.timestamp,
|
|
4373
|
-
}));
|
|
4374
|
-
res.json({ items });
|
|
4375
|
-
}
|
|
4376
|
-
catch (err) {
|
|
4377
|
-
console.error('[judgments] resolve-usage failed:', err);
|
|
4378
|
-
res.status(500).json({ error: err.message });
|
|
4379
|
-
}
|
|
4380
|
-
});
|
|
4381
|
-
// 判断力违规日志 (P3 UI): 读 violations.jsonl
|
|
4382
|
-
app.get('/api/judgments/violations', async (req, res) => {
|
|
4383
|
-
try {
|
|
4384
|
-
const { getRecentViolations } = await import('../pi-ecosystem-judgment/monitor-gate.js');
|
|
4385
|
-
const limit = Math.min(Math.max(parseInt(String(req.query.limit ?? '20'), 10) || 20, 1), 200);
|
|
4386
|
-
const items = await getRecentViolations(limit);
|
|
4387
|
-
res.json({ count: items.length, items });
|
|
4388
|
-
}
|
|
4389
|
-
catch (err) {
|
|
4390
|
-
console.error('[judgments] violations failed:', err);
|
|
4391
|
-
res.status(500).json({ error: err.message });
|
|
4392
|
-
}
|
|
4393
|
-
});
|
|
4394
|
-
// 类 B 自适应扫描: 读 judgments.json + usage.jsonl, 给出 stale/rising/unused 建议
|
|
4395
|
-
// ?force=1 跳过 24h 缓存
|
|
4396
|
-
app.get('/api/judgments/adaptive-suggestions', async (req, res) => {
|
|
4397
|
-
try {
|
|
4398
|
-
const { getCachedScan } = await import('../pi-ecosystem-judgment/adaptive-scan.js');
|
|
4399
|
-
const force = String(req.query.force ?? '') === '1';
|
|
4400
|
-
const result = await getCachedScan(force);
|
|
4401
|
-
res.json(result);
|
|
4402
|
-
}
|
|
4403
|
-
catch (err) {
|
|
4404
|
-
console.error('[judgments] adaptive-scan failed:', err);
|
|
4405
|
-
res.status(500).json({ error: err.message });
|
|
4406
|
-
}
|
|
4407
|
-
});
|
|
4408
|
-
// Bootstrap Context 调试视图: 返出完整 BolloonContext
|
|
4409
|
-
app.get('/api/bolloon/context', async (req, res) => {
|
|
4410
|
-
try {
|
|
4411
|
-
const { getCachedBolloonContext } = await import('../pi-ecosystem-judgment/human-value-pipeline.js');
|
|
4412
|
-
const force = String(req.query.force ?? '') === '1';
|
|
4413
|
-
const ctx = await getCachedBolloonContext({ cwd: process.cwd() }, force);
|
|
4414
|
-
res.json(ctx);
|
|
4415
|
-
}
|
|
4416
|
-
catch (err) {
|
|
4417
|
-
console.error('[bolloon] context failed:', err);
|
|
4418
|
-
res.status(500).json({ error: err.message });
|
|
4419
|
-
}
|
|
4420
|
-
});
|
|
4421
|
-
// 阶段 B: 周报 (weekly-report.ts 产物) — 仅 API 读取, 不做 UI tab
|
|
4422
|
-
// GET /api/reports → { files: ['2026-W24.md', ...] }
|
|
4423
|
-
// GET /api/reports/2026-W24 → { week, content }
|
|
4424
|
-
app.get('/api/reports', async (_req, res) => {
|
|
4425
|
-
try {
|
|
4426
|
-
const dir = path.join(os.homedir(), '.bolloon', 'reports');
|
|
4427
|
-
try {
|
|
4428
|
-
const entries = await fs.readdir(dir);
|
|
4429
|
-
const files = entries
|
|
4430
|
-
.filter((f) => f.endsWith('.md'))
|
|
4431
|
-
.sort()
|
|
4432
|
-
.reverse(); // 新的在前
|
|
4433
|
-
res.json({ dir, files });
|
|
4434
|
-
}
|
|
4435
|
-
catch {
|
|
4436
|
-
res.json({ dir, files: [] });
|
|
4437
|
-
}
|
|
4438
|
-
}
|
|
4439
|
-
catch (err) {
|
|
4440
|
-
res.status(500).json({ error: err.message });
|
|
4441
|
-
}
|
|
4442
|
-
});
|
|
4443
|
-
app.get('/api/reports/:week', async (req, res) => {
|
|
4444
|
-
try {
|
|
4445
|
-
const week = req.params.week;
|
|
4446
|
-
// 严格校验, 防路径穿越
|
|
4447
|
-
if (!/^\d{4}-W\d{1,2}$/.test(week)) {
|
|
4448
|
-
return res.status(400).json({ error: 'week must match YYYY-Www' });
|
|
4449
|
-
}
|
|
4450
|
-
const file = path.join(os.homedir(), '.bolloon', 'reports', `${week}.md`);
|
|
4451
|
-
try {
|
|
4452
|
-
const content = await fs.readFile(file, 'utf-8');
|
|
4453
|
-
res.json({ week, content, length: content.length });
|
|
4454
|
-
}
|
|
4455
|
-
catch {
|
|
4456
|
-
res.status(404).json({ error: 'not found', week });
|
|
4457
|
-
}
|
|
4458
|
-
}
|
|
4459
|
-
catch (err) {
|
|
4460
|
-
res.status(500).json({ error: err.message });
|
|
4461
|
-
}
|
|
4462
|
-
});
|
|
4463
|
-
// 阶段 C 护栏 5: auto-evolve baseline 管理 (无 UI, 仅 API)
|
|
4464
|
-
// GET /api/auto-evolve/baselines → 列出所有 baseline tag
|
|
4465
|
-
// GET /api/auto-evolve/baselines/:tag/diff → 看某 baseline 的 diff 摘要
|
|
4466
|
-
// POST /api/auto-evolve/rollback {tag} → 回滚到指定 baseline
|
|
4467
|
-
app.get('/api/auto-evolve/baselines', async (_req, res) => {
|
|
4468
|
-
try {
|
|
4469
|
-
const { execFile } = await import('child_process');
|
|
4470
|
-
const { promisify } = await import('util');
|
|
4471
|
-
const pExec = promisify(execFile);
|
|
4472
|
-
const { stdout } = await pExec('git', [
|
|
4473
|
-
'tag', '-l', 'auto-evolve-baseline-*', '--format=%(refname:short)|%(contents)|%(objectname:short)|%(taggerdate:iso)',
|
|
4474
|
-
], { cwd: process.cwd() });
|
|
4475
|
-
const tags = stdout.trim().split('\n').filter(Boolean).map((line) => {
|
|
4476
|
-
const [tag, msg, sha, date] = line.split('|');
|
|
4477
|
-
return { tag, message: msg || '', sha, date };
|
|
4478
|
-
});
|
|
4479
|
-
res.json({ tags, count: tags.length });
|
|
4480
|
-
}
|
|
4481
|
-
catch (err) {
|
|
4482
|
-
res.status(500).json({ error: err.message });
|
|
4483
|
-
}
|
|
4484
|
-
});
|
|
4485
|
-
app.get('/api/auto-evolve/baselines/:tag/diff', async (req, res) => {
|
|
4486
|
-
try {
|
|
4487
|
-
const { execFile } = await import('child_process');
|
|
4488
|
-
const { promisify } = await import('util');
|
|
4489
|
-
const pExec = promisify(execFile);
|
|
4490
|
-
const tag = req.params.tag;
|
|
4491
|
-
if (!/^auto-evolve-baseline-[\w-]+$/.test(tag)) {
|
|
4492
|
-
return res.status(400).json({ error: 'tag must match auto-evolve-baseline-*' });
|
|
4493
|
-
}
|
|
4494
|
-
const { stdout } = await pExec('git', ['show', '--stat', '--no-color', tag], { cwd: process.cwd() });
|
|
4495
|
-
res.json({ tag, diff: stdout.slice(0, 5000) }); // 限长 5KB
|
|
4496
|
-
}
|
|
4497
|
-
catch (err) {
|
|
4498
|
-
res.status(500).json({ error: err.message });
|
|
4499
|
-
}
|
|
4500
|
-
});
|
|
4501
|
-
// Bootstrap Context → 拼好的 system prompt 片段 (供调试看注入效果)
|
|
4502
|
-
app.get('/api/bolloon/context/system-prompt', async (req, res) => {
|
|
4503
|
-
try {
|
|
4504
|
-
const { getCachedBolloonContext } = await import('../pi-ecosystem-judgment/human-value-pipeline.js');
|
|
4505
|
-
const { formatContextForSystemPrompt } = await import('../bootstrap/project-context.js');
|
|
4506
|
-
const ctx = await getCachedBolloonContext({ cwd: process.cwd() });
|
|
4507
|
-
const systemAddition = formatContextForSystemPrompt(ctx, {
|
|
4508
|
-
maxChars: parseInt(String(req.query.max ?? '4000'), 10) || 4000,
|
|
4509
|
-
});
|
|
4510
|
-
res.json({ systemAddition, length: systemAddition.length, truncated: systemAddition.includes('截断模式') });
|
|
4511
|
-
}
|
|
4512
|
-
catch (err) {
|
|
4513
|
-
console.error('[bolloon] context/system-prompt failed:', err);
|
|
4514
|
-
res.status(500).json({ error: err.message });
|
|
4515
|
-
}
|
|
4516
|
-
});
|
|
4517
|
-
// ============================================================
|
|
4518
|
-
// system-prompt health (P-Action 2 — Harness Gardening)
|
|
4519
|
-
// 返回每层 lifecycle 状态: ok | stale | overdue-review | missing-frontmatter | dynamic
|
|
4520
|
-
// query: ?activeOnly=1 → 只返回当前 context 激活的层
|
|
4521
|
-
// ============================================================
|
|
4522
|
-
app.get('/api/prompt/health', async (req, res) => {
|
|
4523
|
-
try {
|
|
4524
|
-
const { listLayers } = await import('../llm/system-prompt/registry.js');
|
|
4525
|
-
const { evaluateLayers, markActive } = await import('../llm/system-prompt/health.js');
|
|
4526
|
-
const all = listLayers();
|
|
4527
|
-
const baseReport = evaluateLayers(all);
|
|
4528
|
-
// 如果 query 里有 activeOnly, 跑一次 assembleSystemPrompt 拿激活列表
|
|
4529
|
-
if (String(req.query.activeOnly ?? '') === '1') {
|
|
4530
|
-
const { assembleSystemPrompt } = await import('../llm/system-prompt/registry.js');
|
|
4531
|
-
const channel = String(req.query.channel ?? 'local');
|
|
4532
|
-
const role = req.query.role;
|
|
4533
|
-
const tool = req.query.tool;
|
|
4534
|
-
try {
|
|
4535
|
-
const r = await assembleSystemPrompt({ channel, role, tool });
|
|
4536
|
-
const activeIds = new Set(r.layerIds);
|
|
4537
|
-
res.json(markActive(baseReport, activeIds));
|
|
4538
|
-
}
|
|
4539
|
-
catch (err) {
|
|
4540
|
-
console.warn('[prompt-health] assembleSystemPrompt failed (silent, returning base report):', err);
|
|
4541
|
-
res.json(baseReport);
|
|
4542
|
-
}
|
|
4543
|
-
}
|
|
4544
|
-
else {
|
|
4545
|
-
res.json(baseReport);
|
|
4546
|
-
}
|
|
4547
|
-
}
|
|
4548
|
-
catch (err) {
|
|
4549
|
-
console.error('[prompt-health] failed:', err);
|
|
4550
|
-
res.status(500).json({ error: err.message });
|
|
4551
|
-
}
|
|
4552
|
-
});
|
|
4553
|
-
// 自适应接受/拒绝: 写 evolution.jsonl 留痕, 接受时同时 patch judgments.json
|
|
4554
|
-
// body: { action: 'accept'|'reject'|'revert', suggestion, appliedPatch? }
|
|
4555
|
-
// query: ?auto=1 → 类 B 自动路径, 受 auto-evolve-policy 网关保护
|
|
4556
|
-
// 缺省 → 用户在 UI 手动触发, 不查开关 (避免阻塞用户)
|
|
4557
|
-
app.post('/api/judgments/adaptive-apply', async (req, res) => {
|
|
4558
|
-
try {
|
|
4559
|
-
const isAuto = req.query.auto === '1' || req.query.auto === 'true';
|
|
4560
|
-
const { action, suggestion, appliedPatch } = req.body;
|
|
4561
|
-
if (!action || !suggestion?.judgmentId) {
|
|
4562
|
-
return res.status(400).json({ error: 'action and suggestion.judgmentId required' });
|
|
4563
|
-
}
|
|
4564
|
-
const { updateJudgmentStatus } = await import('../pi-ecosystem-judgment/human-value-store.js');
|
|
4565
|
-
const { logEvolution } = await import('../pi-ecosystem-judgment/adaptive-scan.js');
|
|
4566
|
-
// accept 时: 真正改库
|
|
4567
|
-
if (action === 'accept') {
|
|
4568
|
-
// 阶段 A: 自动路径需先过 auto-evolve-policy 网关
|
|
4569
|
-
if (isAuto) {
|
|
4570
|
-
const { requireDataLayerAutoEvolve } = await import('../utils/auto-evolve-policy.js');
|
|
4571
|
-
try {
|
|
4572
|
-
await requireDataLayerAutoEvolve('adaptive-apply.auto.deprecate');
|
|
4573
|
-
}
|
|
4574
|
-
catch (err) {
|
|
4575
|
-
return res.status(423).json({
|
|
4576
|
-
error: 'data-layer-auto-evolve-disabled',
|
|
4577
|
-
message: err.message,
|
|
4578
|
-
hint: '设 BOLLOON_AUTO_EVOLVE_DATA=1 或在 self-improve-policy.json 加 dataLayerAutoEvolve: true',
|
|
4579
|
-
});
|
|
4580
|
-
}
|
|
4581
|
-
}
|
|
4582
|
-
if (suggestion.action === 'deprecate') {
|
|
4583
|
-
// 标记 superseded (语义: 不再用, 但保留可回滚)
|
|
4584
|
-
await updateJudgmentStatus(suggestion.judgmentId, 'superseded', {
|
|
4585
|
-
evolutionReason: 'merged', // 借 merged 字段表达"被自适应废弃"
|
|
4586
|
-
});
|
|
4587
|
-
}
|
|
4588
|
-
else if (suggestion.action === 'boost') {
|
|
4589
|
-
// boost: 用户手动接受后, 不改库本身 (weight 在 getRelevantValues 里动态算),
|
|
4590
|
-
// 但写 evolution 留痕, 未来可以基于此调整算法
|
|
4591
|
-
// 当前不直接改库, 仅留痕
|
|
4592
|
-
}
|
|
4593
|
-
// 'review' 类不需要自动改库, 仅 log 接受
|
|
4594
|
-
}
|
|
4595
|
-
await logEvolution({
|
|
4596
|
-
ts: new Date().toISOString(),
|
|
4597
|
-
action,
|
|
4598
|
-
suggestion: suggestion,
|
|
4599
|
-
appliedPatch,
|
|
4600
|
-
});
|
|
4601
|
-
res.json({ ok: true });
|
|
4602
|
-
}
|
|
4603
|
-
catch (err) {
|
|
4604
|
-
console.error('[judgments] adaptive-apply failed:', err);
|
|
4605
|
-
res.status(500).json({ error: err.message });
|
|
4606
|
-
}
|
|
4607
|
-
});
|
|
4608
|
-
// 演化日志 (audit / 一键回滚源)
|
|
4609
|
-
app.get('/api/judgments/evolution-log', async (req, res) => {
|
|
4610
|
-
try {
|
|
4611
|
-
const { readEvolutionLog } = await import('../pi-ecosystem-judgment/adaptive-scan.js');
|
|
4612
|
-
const limit = Math.min(Math.max(parseInt(String(req.query.limit ?? '50'), 10) || 50, 1), 200);
|
|
4613
|
-
const items = await readEvolutionLog(limit);
|
|
4614
|
-
res.json({ count: items.length, items });
|
|
4615
|
-
}
|
|
4616
|
-
catch (err) {
|
|
4617
|
-
console.error('[judgments] evolution-log failed:', err);
|
|
4618
|
-
res.status(500).json({ error: err.message });
|
|
4619
|
-
}
|
|
4620
|
-
});
|
|
4621
|
-
// 阶段 2: Causal-judge 4 个 endpoint
|
|
4622
|
-
app.get('/api/judgments/causal/correlation', async (req, res) => {
|
|
4623
|
-
try {
|
|
4624
|
-
const { runCorrelationAnalysis } = await import('../pi-ecosystem-judgment/human-value-pipeline.js');
|
|
4625
|
-
const topN = Math.min(Math.max(parseInt(String(req.query.topN ?? '5'), 10) || 5, 1), 50);
|
|
4626
|
-
const useLLM = String(req.query.useLLM ?? '1') !== '0';
|
|
4627
|
-
const items = await runCorrelationAnalysis({ topN, useLLM });
|
|
4628
|
-
res.json({ count: items.length, items });
|
|
4629
|
-
}
|
|
4630
|
-
catch (err) {
|
|
4631
|
-
console.error('[causal] correlation failed:', err);
|
|
4632
|
-
res.status(500).json({ error: err.message });
|
|
4633
|
-
}
|
|
4634
|
-
});
|
|
4635
|
-
app.get('/api/judgments/causal/intervention', async (req, res) => {
|
|
4636
|
-
try {
|
|
4637
|
-
const { runIntervention } = await import('../pi-ecosystem-judgment/human-value-pipeline.js');
|
|
4638
|
-
const { judgmentId, scenario } = req.query;
|
|
4639
|
-
if (!judgmentId)
|
|
4640
|
-
return res.status(400).json({ error: 'judgmentId required' });
|
|
4641
|
-
const result = await runIntervention(judgmentId, { scenarioContext: scenario });
|
|
4642
|
-
res.json(result);
|
|
4643
|
-
}
|
|
4644
|
-
catch (err) {
|
|
4645
|
-
console.error('[causal] intervention failed:', err);
|
|
4646
|
-
res.status(500).json({ error: err.message });
|
|
4647
|
-
}
|
|
4648
|
-
});
|
|
4649
|
-
app.post('/api/judgments/causal/counterfactual', async (req, res) => {
|
|
4650
|
-
try {
|
|
4651
|
-
const { runCounterfactualAudit } = await import('../pi-ecosystem-judgment/human-value-pipeline.js');
|
|
4652
|
-
const { userInput, aiReply, violatedPrinciples } = req.body;
|
|
4653
|
-
if (!userInput || !aiReply) {
|
|
4654
|
-
return res.status(400).json({ error: 'userInput and aiReply required' });
|
|
4655
|
-
}
|
|
4656
|
-
const audit = await runCounterfactualAudit({
|
|
4657
|
-
userInput,
|
|
4658
|
-
aiReply,
|
|
4659
|
-
violatedPrinciples: violatedPrinciples ?? [],
|
|
4660
|
-
});
|
|
4661
|
-
res.json(audit);
|
|
4662
|
-
}
|
|
4663
|
-
catch (err) {
|
|
4664
|
-
console.error('[causal] counterfactual failed:', err);
|
|
4665
|
-
res.status(500).json({ error: err.message });
|
|
4666
|
-
}
|
|
4667
|
-
});
|
|
4668
|
-
app.get('/api/judgments/causal/audit-log', async (req, res) => {
|
|
4669
|
-
try {
|
|
4670
|
-
const { readCounterfactualLog } = await import('../pi-ecosystem-judgment/human-value-pipeline.js');
|
|
4671
|
-
const limit = Math.min(Math.max(parseInt(String(req.query.limit ?? '20'), 10) || 20, 1), 200);
|
|
4672
|
-
const items = await readCounterfactualLog(limit);
|
|
4673
|
-
res.json({ count: items.length, items });
|
|
4674
|
-
}
|
|
4675
|
-
catch (err) {
|
|
4676
|
-
console.error('[causal] audit-log failed:', err);
|
|
4677
|
-
res.status(500).json({ error: err.message });
|
|
4678
|
-
}
|
|
4679
|
-
});
|
|
4680
|
-
// 导入判断: 接受 { filename, content (base64), context }.
|
|
4681
|
-
// 支持 .json / .yaml / .yml / .md / .txt / .html. 完全离线解析, 不调 LLM.
|
|
4682
|
-
// 解析规则:
|
|
4683
|
-
// - .json: 顶层数组 [{decision, reason?, context?}, ...] 或 {judgments: [...]} 或 {items: [...]}
|
|
4684
|
-
// - .yaml/.yml: 期望顶层数组 (用 js-yaml); 不支持复杂结构
|
|
4685
|
-
// - .md/.txt/.html: 每一段 (按空行分隔) 算一条判断, 首行非空 = decision, 整段 = content
|
|
4686
|
-
// 如果首行是 markdown 标题 (# ...) 则去掉 #, 整段去掉首行后作 reason
|
|
4687
|
-
app.post('/api/judgments/import', async (req, res) => {
|
|
4688
|
-
try {
|
|
4689
|
-
const { filename, content, context } = req.body;
|
|
4690
|
-
if (!filename || !content) {
|
|
4691
|
-
return res.status(400).json({ error: 'filename and content (base64) required' });
|
|
4692
|
-
}
|
|
4693
|
-
let raw;
|
|
4694
|
-
try {
|
|
4695
|
-
raw = Buffer.from(content, 'base64').toString('utf-8');
|
|
4696
|
-
}
|
|
4697
|
-
catch {
|
|
4698
|
-
return res.status(400).json({ error: 'content is not valid base64' });
|
|
4699
|
-
}
|
|
4700
|
-
const lower = filename.toLowerCase();
|
|
4701
|
-
let items = [];
|
|
4702
|
-
if (lower.endsWith('.json')) {
|
|
4703
|
-
try {
|
|
4704
|
-
const parsed = JSON.parse(raw);
|
|
4705
|
-
const arr = Array.isArray(parsed) ? parsed
|
|
4706
|
-
: Array.isArray(parsed?.judgments) ? parsed.judgments
|
|
4707
|
-
: Array.isArray(parsed?.items) ? parsed.items
|
|
4708
|
-
: null;
|
|
4709
|
-
if (!arr)
|
|
4710
|
-
return res.status(400).json({ error: 'JSON must be an array, or {judgments:[]}/{items:[]}' });
|
|
4711
|
-
for (const it of arr) {
|
|
4712
|
-
if (it && typeof it.decision === 'string' && it.decision.trim()) {
|
|
4713
|
-
items.push({ decision: it.decision.trim(), reason: it.reason, context: it.context });
|
|
4714
|
-
}
|
|
4715
|
-
}
|
|
4716
|
-
}
|
|
4717
|
-
catch (e) {
|
|
4718
|
-
return res.status(400).json({ error: 'JSON parse failed: ' + e.message });
|
|
4719
|
-
}
|
|
4720
|
-
}
|
|
4721
|
-
else if (lower.endsWith('.yaml') || lower.endsWith('.yml')) {
|
|
4722
|
-
try {
|
|
4723
|
-
const yaml = (await import('js-yaml')).default;
|
|
4724
|
-
const parsed = yaml.load(raw);
|
|
4725
|
-
if (!Array.isArray(parsed))
|
|
4726
|
-
return res.status(400).json({ error: 'YAML must be a top-level array' });
|
|
4727
|
-
for (const it of parsed) {
|
|
4728
|
-
if (it && typeof it.decision === 'string' && it.decision.trim()) {
|
|
4729
|
-
items.push({ decision: it.decision.trim(), reason: it.reason, context: it.context });
|
|
4730
|
-
}
|
|
4731
|
-
}
|
|
4732
|
-
}
|
|
4733
|
-
catch (e) {
|
|
4734
|
-
return res.status(400).json({ error: 'YAML parse failed: ' + e.message });
|
|
4735
|
-
}
|
|
4736
|
-
}
|
|
4737
|
-
else if (lower.endsWith('.md') || lower.endsWith('.txt') || lower.endsWith('.html') || lower.endsWith('.htm')) {
|
|
4738
|
-
// 通用纯文本: 按空行分段, 每段是一条判断
|
|
4739
|
-
// 对 .html 先剥掉标签, 但保留段落分隔
|
|
4740
|
-
let text = raw;
|
|
4741
|
-
if (lower.endsWith('.html') || lower.endsWith('.htm')) {
|
|
4742
|
-
text = text.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
4743
|
-
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
4744
|
-
// 块级标签 -> 双换行 (保留段落分隔)
|
|
4745
|
-
.replace(/<\/?(p|div|h[1-6]|li|tr|br)[^>]*>/gi, '\n\n')
|
|
4746
|
-
.replace(/<[^>]+>/g, ' ')
|
|
4747
|
-
.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
4748
|
-
}
|
|
4749
|
-
const blocks = text.split(/\n\s*\n/).map(b => b.trim()).filter(b => b.length > 0);
|
|
4750
|
-
for (const block of blocks) {
|
|
4751
|
-
const lines = block.split('\n').map(l => l.trim()).filter(l => l.length > 0);
|
|
4752
|
-
if (lines.length === 0)
|
|
4753
|
-
continue;
|
|
4754
|
-
let decision = lines[0];
|
|
4755
|
-
// 如果首行是 markdown 标题, 去掉 # 前缀
|
|
4756
|
-
decision = decision.replace(/^#+\s*/, '');
|
|
4757
|
-
// 如果整段就是一个短句 (没有换行), 直接当 decision
|
|
4758
|
-
const reason = lines.length > 1 ? lines.slice(1).join(' ').trim() || undefined : undefined;
|
|
4759
|
-
if (decision)
|
|
4760
|
-
items.push({ decision, reason });
|
|
4761
|
-
}
|
|
4762
|
-
}
|
|
4763
|
-
else {
|
|
4764
|
-
return res.status(400).json({ error: 'unsupported file type (use .json .yaml .yml .md .txt .html)' });
|
|
4765
|
-
}
|
|
4766
|
-
if (items.length === 0) {
|
|
4767
|
-
return res.status(400).json({ error: 'no parseable judgments found in file' });
|
|
4768
|
-
}
|
|
4769
|
-
const { storeHumanJudgment, initializeValueStore } = await import('../pi-ecosystem-judgment/human-value-store.js');
|
|
4770
|
-
await initializeValueStore();
|
|
4771
|
-
const imported = [];
|
|
4772
|
-
const errors = [];
|
|
4773
|
-
for (let i = 0; i < items.length; i++) {
|
|
4774
|
-
try {
|
|
4775
|
-
const it = items[i];
|
|
4776
|
-
const j = await storeHumanJudgment({
|
|
4777
|
-
decision: it.decision,
|
|
4778
|
-
decision_type: 'approve',
|
|
4779
|
-
reasons: it.reason ? [String(it.reason)] : [],
|
|
4780
|
-
values_derived: [],
|
|
4781
|
-
context: {
|
|
4782
|
-
domain: it.context?.domain || context?.domain || 'general',
|
|
4783
|
-
complexity: 'moderate',
|
|
4784
|
-
stakes: it.context?.stakes || context?.stakes || 'medium',
|
|
4785
|
-
time_pressure: 'low',
|
|
4786
|
-
},
|
|
4787
|
-
metadata: {
|
|
4788
|
-
source: 'explicit',
|
|
4789
|
-
confidence: 0.8,
|
|
4790
|
-
revisable: true,
|
|
4791
|
-
},
|
|
4792
|
-
});
|
|
4793
|
-
imported.push(j);
|
|
4794
|
-
}
|
|
4795
|
-
catch (e) {
|
|
4796
|
-
errors.push(`#${i + 1} (${items[i].decision.substring(0, 30)}): ${e.message}`);
|
|
4797
|
-
}
|
|
4798
|
-
}
|
|
4799
|
-
res.json({ ok: true, imported: imported.length, failed: errors.length, errors: errors.slice(0, 5), judgments: imported });
|
|
4800
|
-
}
|
|
4801
|
-
catch (err) {
|
|
4802
|
-
console.error('[judgments] import failed:', err);
|
|
4803
|
-
res.status(500).json({ error: err.message });
|
|
4804
|
-
}
|
|
4805
|
-
});
|
|
4806
|
-
// 修改判断 (手动编辑 decision / reasons / context / values_derived)
|
|
4807
|
-
app.patch('/api/judgments/:id', async (req, res) => {
|
|
4808
|
-
try {
|
|
4809
|
-
const { id } = req.params;
|
|
4810
|
-
const { updateJudgment, initializeValueStore } = await import('../pi-ecosystem-judgment/human-value-store.js');
|
|
4811
|
-
await initializeValueStore();
|
|
4812
|
-
const updated = await updateJudgment(id, req.body || {});
|
|
4813
|
-
if (!updated)
|
|
4814
|
-
return res.status(404).json({ error: 'judgment not found' });
|
|
4815
|
-
res.json({ ok: true, judgment: updated });
|
|
4816
|
-
}
|
|
4817
|
-
catch (err) {
|
|
4818
|
-
console.error('[judgments] PATCH failed:', err);
|
|
4819
|
-
res.status(500).json({ error: err.message });
|
|
4820
|
-
}
|
|
4821
|
-
});
|
|
4822
|
-
// 删除判断
|
|
4823
|
-
app.delete('/api/judgments/:id', async (req, res) => {
|
|
4824
|
-
try {
|
|
4825
|
-
const { id } = req.params;
|
|
4826
|
-
const { deleteJudgment, initializeValueStore } = await import('../pi-ecosystem-judgment/human-value-store.js');
|
|
4827
|
-
await initializeValueStore();
|
|
4828
|
-
const ok = await deleteJudgment(id);
|
|
4829
|
-
if (!ok)
|
|
4830
|
-
return res.status(404).json({ error: 'judgment not found' });
|
|
4831
|
-
res.json({ ok: true });
|
|
4832
|
-
}
|
|
4833
|
-
catch (err) {
|
|
4834
|
-
console.error('[judgments] DELETE failed:', err);
|
|
4835
|
-
res.status(500).json({ error: err.message });
|
|
4836
|
-
}
|
|
4837
|
-
});
|
|
4838
|
-
// 批量删除: { ids: ['hv-xxx', ...] } → { ok, deleted, notFound }
|
|
4839
|
-
app.post('/api/judgments/batch-delete', async (req, res) => {
|
|
4840
|
-
try {
|
|
4841
|
-
const ids = (req.body && Array.isArray(req.body.ids)) ? req.body.ids : null;
|
|
4842
|
-
if (!ids)
|
|
4843
|
-
return res.status(400).json({ error: 'ids array required' });
|
|
4844
|
-
const { deleteJudgment, initializeValueStore } = await import('../pi-ecosystem-judgment/human-value-store.js');
|
|
4845
|
-
await initializeValueStore();
|
|
4846
|
-
const idStrs = ids.filter((x) => typeof x === 'string' && x.length > 0);
|
|
4847
|
-
let deleted = 0;
|
|
4848
|
-
const notFound = [];
|
|
4849
|
-
for (const id of idStrs) {
|
|
4850
|
-
const ok = await deleteJudgment(id);
|
|
4851
|
-
if (ok)
|
|
4852
|
-
deleted++;
|
|
4853
|
-
else
|
|
4854
|
-
notFound.push(id);
|
|
4855
|
-
}
|
|
4856
|
-
res.json({ ok: true, deleted, notFound });
|
|
4857
|
-
}
|
|
4858
|
-
catch (err) {
|
|
4859
|
-
console.error('[judgments] batch-delete failed:', err);
|
|
4860
|
-
res.status(500).json({ error: err.message });
|
|
4861
|
-
}
|
|
4862
|
-
});
|
|
4863
|
-
// AI 自动委派: 根据新判断的 capability / context 找最匹配的远端 agent, 委派任务
|
|
4864
|
-
// 由前端在 POST /api/judgments 成功后调用 (fire-and-forget)
|
|
4865
|
-
// 出参: { matched, targetAgent, response | skipped, reason }
|
|
4866
|
-
app.post('/api/judgments/auto-delegate', async (req, res) => {
|
|
4867
|
-
try {
|
|
4868
|
-
const { judgmentId, capability, instruction } = req.body;
|
|
4869
|
-
if (!judgmentId && !capability) {
|
|
4870
|
-
return res.status(400).json({ error: 'judgmentId or capability required' });
|
|
4871
|
-
}
|
|
4872
|
-
const cap = capability || 'general';
|
|
4873
|
-
// 用 agent-manifest-protocol 里的 pickAgent (内存) — 走本节点已经缓存的远端 manifest
|
|
4874
|
-
const manifestMod = await import('../agents/agent-manifest-protocol.js');
|
|
4875
|
-
const picked = manifestMod.pickAgent(cap);
|
|
4876
|
-
if (!picked) {
|
|
4877
|
-
return res.json({ ok: true, matched: false, reason: 'no remote agent matches capability' });
|
|
4878
|
-
}
|
|
4879
|
-
// 命中后, 用 iroh delegate transport 真正发过去
|
|
4880
|
-
// 注: irohDelegateTransport.sendToNode 走的是 sendToNode(publicKey, frame, timeoutMs)
|
|
4881
|
-
// irohTransport 的 sendMessage 不等回包, 所以委托是 fire-and-forget
|
|
4882
|
-
// 想等回包需要新接口. 这里先把 "找得到目标 + 发送成功" 作为成功.
|
|
4883
|
-
// TODO: 接入 requestResponse 等待远端 agent_response
|
|
4884
|
-
try {
|
|
4885
|
-
const idMod = await import('../network/iroh-integration.js');
|
|
4886
|
-
const integ = idMod.getIrohIntegration();
|
|
4887
|
-
if (!integ || !integ.getNodeId()) {
|
|
4888
|
-
return res.json({ ok: true, matched: true, targetAgent: picked.agent, sent: false, reason: 'iroh not initialized' });
|
|
4889
|
-
}
|
|
4890
|
-
// 用 pickAgent 选出来的 agent 关联的 irohNodeId (有的话), 没有就跳到本地自处理
|
|
4891
|
-
const targetIrohNodeId = picked.agent.irohNodeId;
|
|
4892
|
-
if (!targetIrohNodeId) {
|
|
4893
|
-
return res.json({ ok: true, matched: true, targetAgent: picked.agent, sent: false, reason: 'target agent has no irohNodeId (peer identity not bound)' });
|
|
4894
|
-
}
|
|
4895
|
-
const ok = await integ.sendTo(targetIrohNodeId, 'agent_delegate', new TextEncoder().encode(JSON.stringify({
|
|
4896
|
-
type: 'agent_delegate',
|
|
4897
|
-
payload: {
|
|
4898
|
-
capability: cap,
|
|
4899
|
-
instruction: instruction || `请执行我的判断: ${judgmentId}`,
|
|
4900
|
-
fromAgentId: 'local-judgment',
|
|
4901
|
-
},
|
|
4902
|
-
ts: Date.now(),
|
|
4903
|
-
fromDid: '',
|
|
4904
|
-
})));
|
|
4905
|
-
res.json({ ok: true, matched: true, targetAgent: picked.agent, sent: ok });
|
|
4906
|
-
}
|
|
4907
|
-
catch (e) {
|
|
4908
|
-
res.json({ ok: true, matched: true, targetAgent: picked.agent, sent: false, error: e.message });
|
|
4909
|
-
}
|
|
4910
|
-
}
|
|
4911
|
-
catch (err) {
|
|
4912
|
-
console.error('[judgments] auto-delegate failed:', err);
|
|
4913
|
-
res.status(500).json({ error: err.message });
|
|
4914
|
-
}
|
|
4915
|
-
});
|
|
4916
|
-
// (判断的 UI 已合并到主页面 header 的盾牌按钮 + modal, 不再走独立路由)
|
|
4917
|
-
// 启动看门狗监控
|
|
4918
|
-
if (watchdog) {
|
|
4919
|
-
// level 1 (内存爆) → 进程自杀, 依赖外层 supervisor / 用户重启 (Windows 任务计划/手动)
|
|
4920
|
-
// 否则 Node.js 高 GC 压力下 HTTP 响应丢失, 客户端 fetch 永远 pending
|
|
4921
|
-
watchdog.registerRestartStrategy(1, () => {
|
|
4922
|
-
// 2026-06-10: 改为不退出, 因为我们直接后台 tsx 启动没有外层 supervisor.
|
|
4923
|
-
// 误判主要因 recordActivity 仅在显式调用时刷新, 而 broadcast/SSE/连接均不触发.
|
|
4924
|
-
// 退出策略原文保留在注释里:
|
|
4925
|
-
// console.error('[Watchdog] memory critical, 进程退出 (期望外层重启)');
|
|
4926
|
-
// setTimeout(() => process.exit(1), 100);
|
|
4927
|
-
console.warn('[Watchdog] silentThreshold 触发, 但跳过 process.exit (无 supervisor)');
|
|
4928
|
-
});
|
|
4929
|
-
watchdog.start();
|
|
4930
|
-
console.log('[24h] Watchdog started');
|
|
4931
|
-
}
|
|
4932
|
-
// 定期健康检查(不阻塞主服务器启动)
|
|
4933
|
-
if (healthMonitor) {
|
|
4934
|
-
healthMonitor.startPeriodicCheck(60000);
|
|
4935
|
-
console.log('[24h] Health monitor periodic check started');
|
|
4936
|
-
}
|
|
4666
|
+
// 2026-07-06: judgments / self-improve / permission-mode 路由抽到 ./routes-judgments.ts
|
|
4667
|
+
registerJudgmentsRoutes(app);
|
|
4937
4668
|
// ==================== Self-Improve 端点 ====================
|
|
4938
4669
|
// 查看当前策略 (白名单 / 黑名单)
|
|
4939
4670
|
app.get('/api/self-improve/policy', async (_req, res) => {
|
|
@@ -5207,16 +4938,23 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
5207
4938
|
const startPort = port;
|
|
5208
4939
|
let currentPort = startPort;
|
|
5209
4940
|
let attempt = 0;
|
|
4941
|
+
// 2026-06-24: 默认 loopback bind,避免在 LAN 上暴露 (CORS 已经 * 了)。
|
|
4942
|
+
// Electron 包装里强制 127.0.0.1; CLI 用户想 LAN 访问可显式 BOLLOON_HOST=0.0.0.0
|
|
4943
|
+
const bindHost = options.host ?? process.env.BOLLOON_HOST ?? '127.0.0.1';
|
|
5210
4944
|
// 局部可变 server 引用 — listen 失败后必须重新 createServer 再 listen
|
|
5211
4945
|
let currentServer = server;
|
|
5212
4946
|
const tryListen = () => {
|
|
5213
4947
|
currentServer.removeAllListeners('error');
|
|
5214
4948
|
currentServer.once('error', onError);
|
|
5215
|
-
currentServer.listen(currentPort, () => {
|
|
4949
|
+
currentServer.listen(currentPort, bindHost, () => {
|
|
5216
4950
|
if (currentPort !== startPort) {
|
|
5217
4951
|
console.warn(`⚠ 端口 ${startPort} 被占用,已自动切换到 ${currentPort}`);
|
|
5218
4952
|
}
|
|
5219
|
-
|
|
4953
|
+
// 2026-06-24: BOLLOON_PORT=NNNN 是 Electron 主进程解析的契约行 (parseable),
|
|
4954
|
+
// 旧 marker ('服务器已监听') 也保留给日志/调试看。
|
|
4955
|
+
console.log(`BOLLOON_PORT=${currentPort}`);
|
|
4956
|
+
console.log(`BOLLOON_HOST=${bindHost}`);
|
|
4957
|
+
console.log(`Web 服务器启动完成: http://${bindHost}:${currentPort}`);
|
|
5220
4958
|
console.log('服务器已监听');
|
|
5221
4959
|
// 安装 chat bus -> SSE 桥 (供前端 inbox UI 实时刷新)
|
|
5222
4960
|
void installChatBusHook();
|
|
@@ -5266,12 +5004,37 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
5266
5004
|
tryListen();
|
|
5267
5005
|
});
|
|
5268
5006
|
}
|
|
5007
|
+
// 2026-07-06: 每个 channelId 维护递增 sequence + msgId, 让前端能去重 + 重连后 resume
|
|
5008
|
+
const channelEventSeq = new Map();
|
|
5009
|
+
const channelMsgIds = new Map(); // channelId -> 上一条 msgId (uuid)
|
|
5010
|
+
function nextEventSeq(channelId) {
|
|
5011
|
+
if (!channelId)
|
|
5012
|
+
return 0;
|
|
5013
|
+
const cur = channelEventSeq.get(channelId) || 0;
|
|
5014
|
+
const next = cur + 1;
|
|
5015
|
+
channelEventSeq.set(channelId, next);
|
|
5016
|
+
return next;
|
|
5017
|
+
}
|
|
5018
|
+
function nextMsgId(channelId) {
|
|
5019
|
+
const id = `msg_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
|
|
5020
|
+
if (channelId)
|
|
5021
|
+
channelMsgIds.set(channelId, id);
|
|
5022
|
+
return id;
|
|
5023
|
+
}
|
|
5269
5024
|
function broadcast(data, channelId) {
|
|
5270
5025
|
// 2026-06-10: 喂 watchdog, 避免 30min 空闲被误判 (recordActivity 内有 5s 去抖)
|
|
5271
5026
|
watchdogRef?.recordActivity?.();
|
|
5272
|
-
|
|
5027
|
+
// 2026-07-06: 加 seq + msgId, 前端断连重连后可请求 /api/chat/resume?channelId=X&afterSeq=N 拿回
|
|
5028
|
+
const seq = nextEventSeq(channelId);
|
|
5029
|
+
// 2026-07-06: 每次广播都用 crypto randomBytes 4 生成唯一 id — 防止 seq 撞车 + 前端 seenMsgIds 去重
|
|
5030
|
+
// user/ai 用 nextMsgId (按 channelId 缓存最后一条) — 让 SSE onmessage 收到时识别
|
|
5031
|
+
// 其他事件用 evt_<ts>_<rand> 形式 — 让前端 seenMsgIds 集合能跨 emit 去重
|
|
5032
|
+
const msgId = (data.type === 'ai' || data.type === 'user')
|
|
5033
|
+
? nextMsgId(channelId)
|
|
5034
|
+
: `evt_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
|
|
5035
|
+
const envelope = { ...data, channelId, seq, msgId };
|
|
5273
5036
|
const message = `data: ${JSON.stringify(envelope)}\n\n`;
|
|
5274
|
-
console.log(`[broadcast] type=${data.type}, channelId=${channelId}, clients=${sseClients.size}`);
|
|
5037
|
+
console.log(`[broadcast] type=${data.type}, channelId=${channelId}, seq=${seq}, msgId=${msgId}, clients=${sseClients.size}`);
|
|
5275
5038
|
for (const client of sseClients) {
|
|
5276
5039
|
if (!channelId || client.channelId === channelId) {
|
|
5277
5040
|
try {
|