@bolloon/bolloon-agent 0.1.33 → 0.1.34
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 +7 -2
- package/dist/bollharness-integration/index.js +8 -1
- package/dist/heartbeat/Watchdog.js +9 -1
- package/dist/network/p2p-direct.js +59 -2
- package/dist/pi-ecosystem/index.js +9 -6
- package/dist/pi-ecosystem-judgment/decision.js +5 -2
- package/dist/social/heartbeat.js +19 -2
- package/dist/web/api-config.html +3 -3
- package/dist/web/client.js +667 -154
- package/dist/web/index.html +10 -27
- package/dist/web/server.js +597 -48
- package/dist/web/style.css +370 -0
- package/package.json +2 -1
- package/src/bollharness-integration/index.ts +8 -32
- package/src/heartbeat/Watchdog.ts +9 -1
- package/src/network/p2p-direct.ts +59 -3
- package/src/social/ant-colony/index.js +19 -0
- package/src/social/heartbeat.ts +18 -2
- package/src/web/api-config.html +3 -3
- package/src/web/client.js +667 -154
- package/src/web/index.html +10 -27
- package/src/web/server.ts +583 -43
- package/src/web/style.css +370 -0
- package/src/social/ant-colony/AdaptiveHeartbeat.ts +0 -131
- package/src/social/ant-colony/PheromoneEngine.ts +0 -302
- package/src/social/ant-colony/index.ts +0 -18
- package/src/social/ant-colony/types.ts +0 -94
package/dist/web/server.js
CHANGED
|
@@ -281,8 +281,54 @@ let sseClients = new Set();
|
|
|
281
281
|
// v3: 远端 channel UI 元数据缓存 — key: peerId, value: sanitize 过的 channel 列表
|
|
282
282
|
// in-memory only, 进程重启清空 (judgment 内容永远不在这里)
|
|
283
283
|
let remoteChannelCache = new Map();
|
|
284
|
+
// 2026-06-10: 持久化 remote channel cache 到 ~/.bolloon/remote-channels-cache.json
|
|
285
|
+
// 之前是纯内存 Map, nodeA 重启后所有对端 channel 列表丢失, 需要等对面再推一次
|
|
286
|
+
const REMOTE_CACHE_FILE = `${process.env.HOME || '/tmp'}/.bolloon/remote-channels-cache.json`;
|
|
287
|
+
async function loadRemoteChannelCacheFromDisk() {
|
|
288
|
+
try {
|
|
289
|
+
const { readFile, mkdir } = await import('fs/promises');
|
|
290
|
+
const { existsSync } = await import('fs');
|
|
291
|
+
if (!existsSync(REMOTE_CACHE_FILE))
|
|
292
|
+
return;
|
|
293
|
+
const raw = await readFile(REMOTE_CACHE_FILE, 'utf-8');
|
|
294
|
+
const obj = JSON.parse(raw);
|
|
295
|
+
if (obj && typeof obj === 'object') {
|
|
296
|
+
for (const [pk, list] of Object.entries(obj)) {
|
|
297
|
+
if (Array.isArray(list)) {
|
|
298
|
+
remoteChannelCache.set(pk, list);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
console.log(`[v3-meta] 从磁盘恢复 ${remoteChannelCache.size} 个 peer 的 channel cache`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
console.warn('[v3-meta] 恢复 remote channel cache 失败 (非致命):', err.message);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
async function persistRemoteChannelCache() {
|
|
309
|
+
try {
|
|
310
|
+
const { writeFile, mkdir } = await import('fs/promises');
|
|
311
|
+
const { existsSync } = await import('fs');
|
|
312
|
+
if (!existsSync(`${process.env.HOME || '/tmp'}/.bolloon`)) {
|
|
313
|
+
await mkdir(`${process.env.HOME || '/tmp'}/.bolloon`, { recursive: true });
|
|
314
|
+
}
|
|
315
|
+
const obj = {};
|
|
316
|
+
for (const [pk, list] of remoteChannelCache.entries()) {
|
|
317
|
+
obj[pk] = list;
|
|
318
|
+
}
|
|
319
|
+
await writeFile(REMOTE_CACHE_FILE, JSON.stringify(obj, null, 2), 'utf-8');
|
|
320
|
+
}
|
|
321
|
+
catch (err) {
|
|
322
|
+
console.warn('[v3-meta] 持久化 remote channel cache 失败 (非致命):', err.message);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// 启动时立即同步读一次 (异步, 不阻塞)
|
|
326
|
+
loadRemoteChannelCacheFromDisk();
|
|
284
327
|
// v3: P2PDirect 引用 (Hyperswarm 薄包装) - 模块级, 因为 web server 闭包里不可用
|
|
285
328
|
let v3P2PRef = null;
|
|
329
|
+
// 2026-06-10: watchdog 提升到 module-level, 让 broadcast() / 模块级业务函数能埋点喂活动
|
|
330
|
+
// 之前在 createWebServer 闭包内, 闭包外的 broadcast() 拿不到 → 误判 30min 无活动 → 自杀.
|
|
331
|
+
let watchdogRef = null;
|
|
286
332
|
// v3: 等待中的 history RPC (B 端 chat-history endpoint 用) — rpcId → { resolve, reject }
|
|
287
333
|
const v3PendingHistoryGets = new Map();
|
|
288
334
|
let channelSessions = new Map(); // key: channelId
|
|
@@ -318,7 +364,6 @@ function sanitizeChannelForPeer(ch, peerPublicKey) {
|
|
|
318
364
|
createdAt: ch.createdAt,
|
|
319
365
|
updatedAt: ch.updatedAt,
|
|
320
366
|
hasWallet: !!ch.walletAddress,
|
|
321
|
-
boundJudgmentCount: Array.isArray(ch.bound_judgment_ids) ? ch.bound_judgment_ids.length : 0,
|
|
322
367
|
share_id: ch.share_id,
|
|
323
368
|
// 🔒 不返回: bound_judgment_ids, walletAddress, walletBinding, autoInvokeTools, sessions, shared_with_peers
|
|
324
369
|
};
|
|
@@ -556,6 +601,14 @@ async function handleV3P2PMessage(parsed, conn, comm) {
|
|
|
556
601
|
catch (saveErr) {
|
|
557
602
|
console.warn(`[v3] 存 user 消息失败 (不影响 chat):`, saveErr.message);
|
|
558
603
|
}
|
|
604
|
+
// v3 修复: 同步给 A 自己的 UI — broadcast SSE 事件让 A 的 owner 实时看到 B 的消息
|
|
605
|
+
broadcast({
|
|
606
|
+
type: 'user',
|
|
607
|
+
content: text,
|
|
608
|
+
channelId,
|
|
609
|
+
source: 'remote',
|
|
610
|
+
fromPublicKey: senderKey
|
|
611
|
+
}, channelId);
|
|
559
612
|
// v3 新增: 告诉 B "我开始想了, 用了哪些 judgment" — 让 B 看到决策依据
|
|
560
613
|
const judgmentHint = await buildJudgmentHint(ch, channelId);
|
|
561
614
|
const usedJudgments = await extractJudgmentsFromHint(ch);
|
|
@@ -638,6 +691,12 @@ async function handleV3P2PMessage(parsed, conn, comm) {
|
|
|
638
691
|
catch (saveErr) {
|
|
639
692
|
console.warn(`[v3] 存 assistant 消息失败 (不影响):`, saveErr.message);
|
|
640
693
|
}
|
|
694
|
+
// v3 修复: 同步给 A 自己的 UI — broadcast AI 回复给 A 的 owner 实时看到
|
|
695
|
+
broadcast({
|
|
696
|
+
type: 'ai',
|
|
697
|
+
content: fullResponse,
|
|
698
|
+
channelId
|
|
699
|
+
}, channelId);
|
|
641
700
|
// 3. 把完整回复发给 B
|
|
642
701
|
const reply = JSON.stringify({
|
|
643
702
|
v: 3, op: 'agent.chat.reply',
|
|
@@ -921,6 +980,50 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
921
980
|
keypair: null
|
|
922
981
|
};
|
|
923
982
|
let p2pCommunicator = null;
|
|
983
|
+
// v3: 定期 broadcast — 每个 peer 只收到分享给他的 channel (按 peer 个性化)
|
|
984
|
+
// 走 known_peers (持久化) + sendTo (自动 joinPeer 重连), 不只 conns
|
|
985
|
+
// 定义在此处 (所有 try 外部), 确保 route handlers 也能访问
|
|
986
|
+
const v3BroadcastOwn = async () => {
|
|
987
|
+
if (!v3P2PRef)
|
|
988
|
+
return { sent: 0, total: 0 };
|
|
989
|
+
const channels = await loadChannels();
|
|
990
|
+
const { listPeers } = await import('../network/known-peers.js');
|
|
991
|
+
const peers = await listPeers();
|
|
992
|
+
const myPk = v3P2PRef.getPublicKey();
|
|
993
|
+
// 2026-06-10: 本机名字一起携带, 对端能直接显示 + 落到自己的 known_peers
|
|
994
|
+
let myName = process.env.BOLLOON_USER_NAME || process.env.USER || 'node';
|
|
995
|
+
try {
|
|
996
|
+
const { readFileSync, existsSync } = await import('fs');
|
|
997
|
+
const cfgPath = `${process.env.HOME || '/tmp'}/.bolloon/config.json`;
|
|
998
|
+
if (existsSync(cfgPath)) {
|
|
999
|
+
const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
|
|
1000
|
+
if (cfg.userName)
|
|
1001
|
+
myName = cfg.userName;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
catch { }
|
|
1005
|
+
let sent = 0;
|
|
1006
|
+
for (const peer of peers) {
|
|
1007
|
+
if (peer.publicKey === myPk)
|
|
1008
|
+
continue;
|
|
1009
|
+
const sharedForPeer = channels
|
|
1010
|
+
.map(ch => sanitizeChannelForPeer(ch, peer.publicKey))
|
|
1011
|
+
.filter((x) => x !== null);
|
|
1012
|
+
if (sharedForPeer.length > 0) {
|
|
1013
|
+
const msg = JSON.stringify({
|
|
1014
|
+
v: 3, op: 'agent.meta.list.reply',
|
|
1015
|
+
payload: { channels: sharedForPeer, name: myName, fromPublicKey: myPk }
|
|
1016
|
+
});
|
|
1017
|
+
const ok = v3P2PRef.sendTo(peer.publicKey, msg);
|
|
1018
|
+
if (ok) {
|
|
1019
|
+
sent++;
|
|
1020
|
+
console.log(`[v3] broadcast: ${peer.name || peer.publicKey.substring(0, 8)} → ${sharedForPeer.length} 个 channel`);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
console.log(`[v3] broadcast 完成: sent=${sent}/${peers.length} 个 peer`);
|
|
1025
|
+
return { sent, total: peers.length };
|
|
1026
|
+
};
|
|
924
1027
|
try {
|
|
925
1028
|
console.log('开始生成 P2P 身份...');
|
|
926
1029
|
// 生成 DIAP 身份
|
|
@@ -1017,6 +1120,82 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1017
1120
|
return Promise.resolve();
|
|
1018
1121
|
}
|
|
1019
1122
|
};
|
|
1123
|
+
// v3 新增: 好友申请 RPC — 任何对端可以发, 推到前端 UI 让用户接受
|
|
1124
|
+
if (parsed.op === 'agent.friend.request') {
|
|
1125
|
+
console.log(`[v3-friend] 收到 ${evt.fromPublicKey.substring(0, 12)}... 的好友申请: ${parsed.payload?.name || '(无名字)'}`);
|
|
1126
|
+
broadcast({
|
|
1127
|
+
type: 'friend-request',
|
|
1128
|
+
fromPublicKey: evt.fromPublicKey,
|
|
1129
|
+
fromName: parsed.payload?.name || ('peer-' + evt.fromPublicKey.substring(0, 8)),
|
|
1130
|
+
message: parsed.payload?.message || '想加你为 P2P 好友',
|
|
1131
|
+
requestId: parsed.payload?.requestId, // 2026-06-10: 透传 requestId 给前端
|
|
1132
|
+
timestamp: Date.now()
|
|
1133
|
+
}, 'p2p-global');
|
|
1134
|
+
// 2026-06-10 新增: 立刻发 ack 回给发送方, 让发送方 UI 知道"对方收到了"
|
|
1135
|
+
try {
|
|
1136
|
+
const ackRpc = JSON.stringify({
|
|
1137
|
+
v: 3,
|
|
1138
|
+
op: 'agent.friend.request.ack',
|
|
1139
|
+
payload: {
|
|
1140
|
+
requestId: parsed.payload?.requestId,
|
|
1141
|
+
receivedBy: v3P2PRef?.getPublicKey(),
|
|
1142
|
+
timestamp: Date.now()
|
|
1143
|
+
}
|
|
1144
|
+
});
|
|
1145
|
+
v3P2PRef?.sendTo(evt.fromPublicKey, ackRpc);
|
|
1146
|
+
}
|
|
1147
|
+
catch (err) {
|
|
1148
|
+
console.warn('[v3-friend] 发 ack 失败 (不阻塞):', err.message);
|
|
1149
|
+
}
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
// 2026-06-10 新增: 发送方收到对方 ack → SSE 推前端, 显示"对方已收到"
|
|
1153
|
+
if (parsed.op === 'agent.friend.request.ack') {
|
|
1154
|
+
console.log(`[v3-friend] 收到 ack: requestId=${(parsed.payload?.requestId || '').substring(0, 8)} 来自 ${evt.fromPublicKey.substring(0, 12)}...`);
|
|
1155
|
+
broadcast({
|
|
1156
|
+
type: 'friend-request-ack',
|
|
1157
|
+
requestId: parsed.payload?.requestId,
|
|
1158
|
+
receivedBy: parsed.payload?.receivedBy,
|
|
1159
|
+
timestamp: Date.now()
|
|
1160
|
+
}, 'p2p-global');
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
// v3 修复: agent.meta.list.reply 也走 v3P2PRef.on('data') (因为 handleV3P2PMessage 只走老通道)
|
|
1164
|
+
if (parsed.op === 'agent.meta.list.reply') {
|
|
1165
|
+
const list = parsed.payload?.channels || [];
|
|
1166
|
+
remoteChannelCache.set(evt.fromPublicKey, list);
|
|
1167
|
+
// 2026-06-10: 持久化到 ~/.bolloon/remote-channels-cache.json, 重启后不丢
|
|
1168
|
+
persistRemoteChannelCache();
|
|
1169
|
+
// 2026-06-10: 接收侧记录对方名字 (来自 list.reply payload.name), 落 known_peers
|
|
1170
|
+
const senderName = parsed.payload?.name;
|
|
1171
|
+
if (senderName && typeof senderName === 'string') {
|
|
1172
|
+
import('../network/known-peers.js').then(({ addOrUpdatePeer }) => addOrUpdatePeer(senderName, evt.fromPublicKey)).catch(err => console.warn('[v3] 记录对端名字失败:', err.message));
|
|
1173
|
+
}
|
|
1174
|
+
console.log(`[v3] 收到 ${evt.fromPublicKey.substring(0, 12)}... 的 ${list.length} 个 channel, 已缓存 (sender=${senderName || '?'})`);
|
|
1175
|
+
broadcast({
|
|
1176
|
+
type: 'remote-channel-update',
|
|
1177
|
+
peerId: evt.fromPublicKey,
|
|
1178
|
+
peerName: senderName, // 2026-06-10: 一并带名字到 UI
|
|
1179
|
+
channels: list
|
|
1180
|
+
}, 'p2p-global');
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
// 2026-06-10: 收到对方请求本机的 channel 列表 (启动时主动发请求, 加速 cache 填充)
|
|
1184
|
+
if (parsed.op === 'agent.meta.list.request') {
|
|
1185
|
+
console.log(`[v3-meta] 收到 ${evt.fromPublicKey.substring(0, 12)}... 的 channel 列表请求 → 立刻回包`);
|
|
1186
|
+
// 不能 await (在 on('data') sync 回调里), 改用 .then 异步处理
|
|
1187
|
+
loadChannels().then(channels => {
|
|
1188
|
+
const sharedForPeer = channels
|
|
1189
|
+
.map(ch => sanitizeChannelForPeer(ch, evt.fromPublicKey))
|
|
1190
|
+
.filter((x) => x !== null);
|
|
1191
|
+
const msg = JSON.stringify({
|
|
1192
|
+
v: 3, op: 'agent.meta.list.reply',
|
|
1193
|
+
payload: { channels: sharedForPeer }
|
|
1194
|
+
});
|
|
1195
|
+
v3P2PRef.sendTo(evt.fromPublicKey, msg);
|
|
1196
|
+
}).catch(err => console.warn('[v3-meta] 回应 channel 列表失败:', err.message));
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1020
1199
|
handleV3P2PMessage(parsed, { id: evt.fromPublicKey, publicKey: evt.fromPublicKey }, commShim);
|
|
1021
1200
|
}
|
|
1022
1201
|
}
|
|
@@ -1026,6 +1205,8 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1026
1205
|
});
|
|
1027
1206
|
// 新连接进来 → 主动发我分享给 ta 的 channel 列表
|
|
1028
1207
|
v3P2PRef.on('connection', (evt) => {
|
|
1208
|
+
// 2026-06-10: 喂 watchdog —— 新连接到来是真实业务活动
|
|
1209
|
+
watchdogRef?.recordActivity?.();
|
|
1029
1210
|
setTimeout(async () => {
|
|
1030
1211
|
try {
|
|
1031
1212
|
const channels = await loadChannels();
|
|
@@ -1066,45 +1247,50 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1066
1247
|
}
|
|
1067
1248
|
// 触发一次 broadcast 推送给所有重连的 peer
|
|
1068
1249
|
setTimeout(() => v3BroadcastOwn(), 2000);
|
|
1250
|
+
// 2026-06-10: 同时主动请求每个 known peer 把 ta 的 channel 列表推过来
|
|
1251
|
+
// 避免对面 publicKey 没变但 cache 丢了(本机重启) → 一直空
|
|
1252
|
+
setTimeout(() => requestChannelsFromAllPeers(), 3500);
|
|
1069
1253
|
}
|
|
1070
1254
|
catch (err) {
|
|
1071
1255
|
console.error('[v3] 自动重连失败:', err.message);
|
|
1072
1256
|
}
|
|
1073
1257
|
}, 5000); // 5s 后再重连, 让 swarm 充分 bootstrap
|
|
1258
|
+
// 2026-06-10 新增: 主动向所有 known peer 发起 channel 列表请求
|
|
1259
|
+
async function requestChannelsFromAllPeers() {
|
|
1260
|
+
if (!v3P2PRef)
|
|
1261
|
+
return;
|
|
1262
|
+
try {
|
|
1263
|
+
const { listPeers } = await import('../network/known-peers.js');
|
|
1264
|
+
const peers = await listPeers();
|
|
1265
|
+
const myPk = v3P2PRef.getPublicKey();
|
|
1266
|
+
const req = JSON.stringify({ v: 3, op: 'agent.meta.list.request', payload: { fromPublicKey: myPk } });
|
|
1267
|
+
let sent = 0;
|
|
1268
|
+
for (const peer of peers) {
|
|
1269
|
+
if (peer.publicKey === myPk)
|
|
1270
|
+
continue;
|
|
1271
|
+
// 用 sendToWithWait, 等 conn 就绪再发 (同 Step 5 sendToWithWait 修复)
|
|
1272
|
+
const r = await v3P2PRef.sendToWithWait(peer.publicKey, req, 3000);
|
|
1273
|
+
if (r === 'SENT')
|
|
1274
|
+
sent++;
|
|
1275
|
+
}
|
|
1276
|
+
console.log(`[v3-meta] requestChannelsFromAllPeers → sent=${sent}/${peers.length - 1}`);
|
|
1277
|
+
}
|
|
1278
|
+
catch (err) {
|
|
1279
|
+
console.warn('[v3-meta] requestChannelsFromAllPeers failed:', err.message);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
// 立即跑一次 + 每 30s 兜底 (跟 v3BroadcastOwn 一样的节奏)
|
|
1283
|
+
setTimeout(requestChannelsFromAllPeers, 4000);
|
|
1284
|
+
setInterval(requestChannelsFromAllPeers, 30000);
|
|
1074
1285
|
}
|
|
1075
1286
|
catch (err) {
|
|
1076
1287
|
console.error('[v3] P2PDirect 启动失败:', err.message);
|
|
1077
1288
|
v3P2PRef = null;
|
|
1078
1289
|
}
|
|
1079
|
-
//
|
|
1080
|
-
const v3BroadcastOwn = () => {
|
|
1081
|
-
if (!v3P2PRef)
|
|
1082
|
-
return;
|
|
1083
|
-
loadChannels().then(channels => {
|
|
1084
|
-
const conns = v3P2PRef.conns;
|
|
1085
|
-
if (!conns)
|
|
1086
|
-
return;
|
|
1087
|
-
for (const [peerPk, conn] of conns.entries()) {
|
|
1088
|
-
if (conn?.destroyed)
|
|
1089
|
-
continue;
|
|
1090
|
-
const sharedForPeer = channels
|
|
1091
|
-
.map(ch => sanitizeChannelForPeer(ch, peerPk))
|
|
1092
|
-
.filter((x) => x !== null);
|
|
1093
|
-
if (sharedForPeer.length > 0) {
|
|
1094
|
-
const msg = JSON.stringify({ v: 3, op: 'agent.meta.list.reply', payload: { channels: sharedForPeer } });
|
|
1095
|
-
try {
|
|
1096
|
-
conn.write(Buffer.from(msg));
|
|
1097
|
-
}
|
|
1098
|
-
catch { }
|
|
1099
|
-
}
|
|
1100
|
-
}
|
|
1101
|
-
console.log(`[v3] broadcast 个性化: ${conns.size} 个 peer, 各自收到分享的 channel`);
|
|
1102
|
-
}).catch(err => console.error('[v3] broadcast 失败:', err.message));
|
|
1103
|
-
};
|
|
1290
|
+
// 首次广播: 等 swarm bootstrap 完成后推一次
|
|
1104
1291
|
setTimeout(v3BroadcastOwn, 3000);
|
|
1105
|
-
setTimeout
|
|
1106
|
-
|
|
1107
|
-
setTimeout(v3BroadcastOwn, 40000);
|
|
1292
|
+
// v3 修复: 用 setInterval 替代一次性 setTimeout, 确保分享变更后能持续推送给 peer
|
|
1293
|
+
setInterval(v3BroadcastOwn, 30000);
|
|
1108
1294
|
// 保留 @diap/sdk 的旧实例 (它的 Hyperswarm 实例能帮 P2PDirect 做 DHT bootstrap)
|
|
1109
1295
|
try {
|
|
1110
1296
|
const rawSeed = crypto.getRandomValues(new Uint8Array(32));
|
|
@@ -1176,7 +1362,10 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1176
1362
|
const channelId = req.query.channelId;
|
|
1177
1363
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
1178
1364
|
res.setHeader('Cache-Control', 'no-cache');
|
|
1179
|
-
|
|
1365
|
+
// 2026-06-11: 改 keep-alive → close
|
|
1366
|
+
// 原因: SSE 长连接占着 keep-alive 槽 (HTTP/1.1 + 浏览器 max 6 并发), 后续同源 fetch 排队 30s+
|
|
1367
|
+
// 设 close 让浏览器把 SSE 当长期流, 不抢占普通请求的 keep-alive 槽
|
|
1368
|
+
res.setHeader('Connection', 'close');
|
|
1180
1369
|
// 反向代理 (nginx/cloudflair) 需要: 禁用缓冲 + 立即 flush
|
|
1181
1370
|
res.setHeader('X-Accel-Buffering', 'no');
|
|
1182
1371
|
res.flushHeaders();
|
|
@@ -1208,6 +1397,13 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1208
1397
|
const realChannelName = channel?.name || '';
|
|
1209
1398
|
const realChannelDidDoc = channel?.didDocRef;
|
|
1210
1399
|
broadcast({ type: 'user', content: text }, channelId);
|
|
1400
|
+
// 2026-06-11: /message 端点立即返回 202, LLM 后续处理挪到 setImmediate 后台跑
|
|
1401
|
+
// 之前 res.json 在 try 块末尾 (line 1815), 需要等 LLM (5-15s) + 落盘 + suggestRename (5-8s) = 13s+
|
|
1402
|
+
// 客户端 fetch 占用 13s, 视觉像"卡死", 切其他 channel 也感觉"无法加载"
|
|
1403
|
+
// 修法: 立即 res.json(202), try 块主体仍跑 (LLM 流 + 落盘) 但不阻塞 HTTP 响应
|
|
1404
|
+
// 关键: res.json 之后不能再调用 res.json (会抛 ERR_HTTP_HEADERS_SENT), 所以 try 块末尾的 res.json 必须用 res.headersSent 守卫
|
|
1405
|
+
res.status(202).json({ ok: true, async: true, channelId, sessionId: currentSessionId });
|
|
1406
|
+
console.log(`[v3-async] /message 立即返回 202, channel=${channelId}, text length=${text.length}`);
|
|
1211
1407
|
// 提前捕获 wallet/autoTools 到本地变量, 避免下面 try 块内的 inner const channel
|
|
1212
1408
|
// (line ~638) 与这里外层的 const channel 形成 shadowing 让 TS 误报"使用前未声明"
|
|
1213
1409
|
const boundWalletAddress = channel?.walletAddress;
|
|
@@ -1256,6 +1452,107 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1256
1452
|
const judgmentHint = await buildJudgmentHint(channelForJudgment, channelId);
|
|
1257
1453
|
if (judgmentHint)
|
|
1258
1454
|
contextHint += judgmentHint;
|
|
1455
|
+
// 2026-06-10: 注入 skills 列表 (本机 ~/.bolloon/skills/ 下所有 skills)
|
|
1456
|
+
// 让 LLM 知道有哪些 skill 可用, 在回复中提示用户
|
|
1457
|
+
try {
|
|
1458
|
+
const { loadSkillsFromPaths, defaultSkillPaths, describeSkill } = await import('../agents/skill-loader.js');
|
|
1459
|
+
const paths = defaultSkillPaths();
|
|
1460
|
+
const skills = await loadSkillsFromPaths(paths);
|
|
1461
|
+
if (skills.length > 0) {
|
|
1462
|
+
contextHint += `[系统上下文] 本机已加载的 skills (${skills.length} 个, 你可以提示用户主动调用):\n`;
|
|
1463
|
+
for (const s of skills.slice(0, 20)) { // 上限 20 避免 prompt 过长
|
|
1464
|
+
const desc = (s.description || '').slice(0, 80);
|
|
1465
|
+
contextHint += ` - /${s.name}${desc ? ' — ' + desc : ''}\n`;
|
|
1466
|
+
}
|
|
1467
|
+
contextHint += '调用语法: 用户说 "/技能名 ..." 或 你回复时建议 "/技能名 ..." 让用户主动触发.\n\n';
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
catch (err) {
|
|
1471
|
+
// 静默失败 — skills 不是核心, 加载失败不阻塞
|
|
1472
|
+
}
|
|
1473
|
+
// 2026-06-10: 注入 human values 摘要 (最常用的 judgment / 价值偏好)
|
|
1474
|
+
// 与 judgment 不同: values 是更宏观的"用户偏好", judgment 是针对具体决策的约束
|
|
1475
|
+
try {
|
|
1476
|
+
const { loadAllJudgments } = await import('../pi-ecosystem-judgment/human-value-store.js');
|
|
1477
|
+
const allJudgments = await loadAllJudgments().catch(() => []);
|
|
1478
|
+
// 把所有 judgment 视作软参考 (跟 buildJudgmentHint 的 candidates 同理)
|
|
1479
|
+
if (Array.isArray(allJudgments) && allJudgments.length > 0) {
|
|
1480
|
+
contextHint += `[系统上下文] 用户的核心价值倾向 (来自 ${allJudgments.length} 条历史 judgment, 软参考, 体现而非复述):\n`;
|
|
1481
|
+
for (const j of allJudgments.slice(0, 8)) {
|
|
1482
|
+
const decision = (j.decision || '').slice(0, 80);
|
|
1483
|
+
contextHint += ` - ${decision}\n`;
|
|
1484
|
+
}
|
|
1485
|
+
contextHint += '\n';
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
catch (err) {
|
|
1489
|
+
// 静默失败
|
|
1490
|
+
}
|
|
1491
|
+
// 2026-06-10: 注入 documents 列表 (本机 documents/ 目录的文档元数据)
|
|
1492
|
+
// 让 LLM 知道有文档存在, 用户可主动要求读
|
|
1493
|
+
try {
|
|
1494
|
+
const { documentStore } = await import('../documents/store.js');
|
|
1495
|
+
const docs = await documentStore.getReceivedDocuments(50).catch(() => []);
|
|
1496
|
+
if (Array.isArray(docs) && docs.length > 0) {
|
|
1497
|
+
contextHint += `[系统上下文] 本机 documents (${docs.length} 篇, 用户可让你读):\n`;
|
|
1498
|
+
for (const d of docs.slice(0, 10)) {
|
|
1499
|
+
const name = d.fileName || d.id || '(未命名)';
|
|
1500
|
+
const size = d.fileSize ? ` (${Math.round(d.fileSize / 1024)}KB)` : '';
|
|
1501
|
+
const sender = d.fromNodeId ? ` [来自 ${d.fromNodeIdShort || d.fromNodeId.substring(0, 8)}…]` : '';
|
|
1502
|
+
contextHint += ` - ${name}${size}${sender}\n`;
|
|
1503
|
+
}
|
|
1504
|
+
contextHint += '用户提到某文档时, 你可以调用读文档工具读取并总结.\n\n';
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
catch (err) {
|
|
1508
|
+
// 静默失败
|
|
1509
|
+
}
|
|
1510
|
+
// 2026-06-11: 注入此 channel 专属的 persona + 关联文档 (从 channel 字段读, LLM 长期记忆)
|
|
1511
|
+
const chPersona = channelForJudgment?.persona;
|
|
1512
|
+
if (chPersona && typeof chPersona === 'object') {
|
|
1513
|
+
contextHint += '[系统上下文] 此 channel 的人设 (你是这个角色):\n';
|
|
1514
|
+
if (chPersona.name)
|
|
1515
|
+
contextHint += ` 名字: ${chPersona.name}\n`;
|
|
1516
|
+
if (chPersona.description)
|
|
1517
|
+
contextHint += ` 描述: ${chPersona.description}\n`;
|
|
1518
|
+
if (chPersona.personality)
|
|
1519
|
+
contextHint += ` 性格: ${chPersona.personality}\n`;
|
|
1520
|
+
if (chPersona.greeting)
|
|
1521
|
+
contextHint += ` 问候: ${chPersona.greeting}\n`;
|
|
1522
|
+
if (Array.isArray(chPersona.capabilities) && chPersona.capabilities.length > 0) {
|
|
1523
|
+
contextHint += ` 能力: ${chPersona.capabilities.join('、')}\n`;
|
|
1524
|
+
}
|
|
1525
|
+
if (Array.isArray(chPersona.interests) && chPersona.interests.length > 0) {
|
|
1526
|
+
contextHint += ` 兴趣: ${chPersona.interests.join('、')}\n`;
|
|
1527
|
+
}
|
|
1528
|
+
contextHint += '回复时应自然体现这个角色 (不要硬搬原文, 像这个角色说话即可).\n\n';
|
|
1529
|
+
}
|
|
1530
|
+
const linkedIds = channelForJudgment?.linkedDocumentIds;
|
|
1531
|
+
if (Array.isArray(linkedIds) && linkedIds.length > 0) {
|
|
1532
|
+
try {
|
|
1533
|
+
const { documentStore } = await import('../documents/store.js');
|
|
1534
|
+
contextHint += `[系统上下文] 此 channel 关联了 ${linkedIds.length} 篇文档 (已自动加载内容, 你应基于它们回答):\n`;
|
|
1535
|
+
let loaded = 0;
|
|
1536
|
+
for (const docId of linkedIds.slice(0, 10)) {
|
|
1537
|
+
const doc = await documentStore.readDocument(docId).catch(() => null);
|
|
1538
|
+
if (!doc)
|
|
1539
|
+
continue;
|
|
1540
|
+
const name = doc.metadata?.fileName || docId;
|
|
1541
|
+
const content = (doc.content || '').slice(0, 1500); // 单篇 1.5KB 上限, 总 prompt 防爆
|
|
1542
|
+
contextHint += `\n--- 文档: ${name} ---\n${content}\n--- 文档结束 ---\n`;
|
|
1543
|
+
loaded++;
|
|
1544
|
+
}
|
|
1545
|
+
if (loaded === 0) {
|
|
1546
|
+
contextHint += `(但加载失败, 文档可能已被删除)\n\n`;
|
|
1547
|
+
}
|
|
1548
|
+
else {
|
|
1549
|
+
contextHint += '\n';
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
catch (err) {
|
|
1553
|
+
console.warn('[v3-persona] 加载关联文档失败 (非致命):', err.message);
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1259
1556
|
// v3 新增: 注入"可用渠道"目录, 让 LLM 知道可以 @-mention 哪些 channel
|
|
1260
1557
|
// - 本地 channels (除了自己)
|
|
1261
1558
|
// - 远端 channels (remoteChannelCache 缓存的)
|
|
@@ -1274,7 +1571,15 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1274
1571
|
for (const c of remoteChannels) {
|
|
1275
1572
|
contextHint += ` - [远端, owner=${(c._ownerPublicKey || '').substring(0, 8)}…] @${c.name} (id=${c.id})\n`;
|
|
1276
1573
|
}
|
|
1277
|
-
contextHint += '语法: 当你想给其他渠道发消息, 在回复中写 "@渠道名 我要说的话" 即可. 消息会持久化到目标 channel 的 session, 你之后能看到"自己"在那里说的话.\n
|
|
1574
|
+
contextHint += '语法: 当你想给其他渠道发消息, 在回复中写 "@渠道名 我要说的话" 即可. 消息会持久化到目标 channel 的 session, 你之后能看到"自己"在那里说的话.\n';
|
|
1575
|
+
// 2026-06-10 强化: 当用户消息里出现 @渠道名, 默认是请你代为转发, 务必在回复里包含对应的 @ 转发
|
|
1576
|
+
if (remoteChannels.length > 0) {
|
|
1577
|
+
contextHint += '重要: 上面列表里 [远端] 标记的 channel 在另一台机器上, 你可以像 @本地 channel 一样 @ 它们 — 我会通过 P2P 自动把消息送达对方智能体, 对方智能体的回复也会同步回来.\n';
|
|
1578
|
+
contextHint += '当用户在消息里 @ 了某个 (本地或远端) channel, 默认意图是希望你代为转发 — 你应该在回复中写出对应的 "@渠道名 转发内容", 否则用户的请求不会被路由出去.\n\n';
|
|
1579
|
+
}
|
|
1580
|
+
else {
|
|
1581
|
+
contextHint += '\n';
|
|
1582
|
+
}
|
|
1278
1583
|
}
|
|
1279
1584
|
if (contextHint)
|
|
1280
1585
|
contextHint += '\n';
|
|
@@ -1292,25 +1597,23 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1292
1597
|
await saveSession(session);
|
|
1293
1598
|
const channels = await loadChannels();
|
|
1294
1599
|
const channel = channels.find(c => c.id === channelId);
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
channel.name = renameSuggestion;
|
|
1299
|
-
await saveChannels(channels);
|
|
1300
|
-
broadcast({ type: 'renamed', channelId, newName: renameSuggestion }, channelId);
|
|
1301
|
-
}
|
|
1302
|
-
}
|
|
1600
|
+
// 2026-06-11: 移除 suggestRename 的二次 LLM 调用 — 之前每次用户发消息, 智能体 channel 都会再调一次 LLM (5-8s) 自动改名
|
|
1601
|
+
// 影响: (1) /message 端点被拖慢 5-8s (2) LLM 客户端排队, 其他 channel 跟着卡
|
|
1602
|
+
// 现在改名逻辑挪到 /api/agent-rename 端点, 用户主动触发才跑
|
|
1303
1603
|
if (channel) {
|
|
1304
1604
|
channel.updatedAt = new Date().toISOString();
|
|
1305
1605
|
await saveChannels(channels);
|
|
1306
1606
|
}
|
|
1307
1607
|
broadcast({ type: 'done' }, channelId);
|
|
1308
|
-
res.json(
|
|
1608
|
+
// 2026-06-11: 202 已发的话, 不要重复 res.json (会抛 ERR_HTTP_HEADERS_SENT)
|
|
1609
|
+
if (!res.headersSent)
|
|
1610
|
+
res.json({ ok: true });
|
|
1309
1611
|
}
|
|
1310
1612
|
catch (err) {
|
|
1311
1613
|
broadcast({ type: 'error', content: err.message }, channelId);
|
|
1312
1614
|
broadcast({ type: 'done' }, channelId);
|
|
1313
|
-
res.
|
|
1615
|
+
if (!res.headersSent)
|
|
1616
|
+
res.status(500).json({ error: err.message });
|
|
1314
1617
|
}
|
|
1315
1618
|
});
|
|
1316
1619
|
// ---------- 频道元数据后台修复队列 ----------
|
|
@@ -1439,8 +1742,21 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1439
1742
|
app.get('/api/remote-channels', async (_req, res) => {
|
|
1440
1743
|
try {
|
|
1441
1744
|
const out = [];
|
|
1745
|
+
// 2026-06-11: 合并 known_peers + cache, 避免 cache 空时 UI 一个 peer 都看不到
|
|
1746
|
+
// (cache 是纯内存, 重启即丢; known_peers 持久化, 至少能让 UI 显示"这些 peer 我认识")
|
|
1747
|
+
const { listPeers } = await import('../network/known-peers.js');
|
|
1748
|
+
const knownPeers = await listPeers();
|
|
1749
|
+
const knownByPk = new Map();
|
|
1750
|
+
for (const p of knownPeers)
|
|
1751
|
+
knownByPk.set(p.publicKey, { name: p.name });
|
|
1442
1752
|
for (const [peerId, list] of remoteChannelCache.entries()) {
|
|
1443
|
-
out.push({ peerId, channels: list });
|
|
1753
|
+
out.push({ peerId, channels: list, peerName: knownByPk.get(peerId)?.name });
|
|
1754
|
+
}
|
|
1755
|
+
// known_peers 里但 cache 没的, 占位推进 out (channels=[]) 让 UI 能渲染 peer header
|
|
1756
|
+
for (const [peerId, info] of knownByPk.entries()) {
|
|
1757
|
+
if (!remoteChannelCache.has(peerId)) {
|
|
1758
|
+
out.push({ peerId, channels: [], peerName: info.name });
|
|
1759
|
+
}
|
|
1444
1760
|
}
|
|
1445
1761
|
res.json({ count: out.length, peers: out });
|
|
1446
1762
|
}
|
|
@@ -1448,6 +1764,27 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1448
1764
|
res.status(500).json({ error: err.message });
|
|
1449
1765
|
}
|
|
1450
1766
|
});
|
|
1767
|
+
// v3 测试专用: 直接注入远端频道缓存 (绕过 P2P)
|
|
1768
|
+
// 仅当 NODE_ENV=test 时可用
|
|
1769
|
+
app.post('/api/test/inject-remote-channel', async (req, res) => {
|
|
1770
|
+
if (process.env.NODE_ENV !== 'test') {
|
|
1771
|
+
return res.status(403).json({ error: 'only available in test mode' });
|
|
1772
|
+
}
|
|
1773
|
+
try {
|
|
1774
|
+
const { peerPublicKey, channel } = req.body || {};
|
|
1775
|
+
if (!peerPublicKey || !channel) {
|
|
1776
|
+
return res.status(400).json({ error: 'peerPublicKey and channel required' });
|
|
1777
|
+
}
|
|
1778
|
+
const list = remoteChannelCache.get(peerPublicKey) || [];
|
|
1779
|
+
list.push(channel);
|
|
1780
|
+
remoteChannelCache.set(peerPublicKey, list);
|
|
1781
|
+
broadcast({ type: 'remote-channel-update', peerId: peerPublicKey, channels: list }, 'p2p-global');
|
|
1782
|
+
res.json({ ok: true, count: list.length });
|
|
1783
|
+
}
|
|
1784
|
+
catch (err) {
|
|
1785
|
+
res.status(500).json({ error: err.message });
|
|
1786
|
+
}
|
|
1787
|
+
});
|
|
1451
1788
|
// v3: 主动向所有已连接 P2P peer 拉 channel 列表
|
|
1452
1789
|
// 用法: B 端用户点 "刷新远端智能体" → 触发本 endpoint
|
|
1453
1790
|
app.post('/api/remote-channels/refresh', async (_req, res) => {
|
|
@@ -1521,7 +1858,7 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1521
1858
|
});
|
|
1522
1859
|
app.post('/channels', async (req, res) => {
|
|
1523
1860
|
try {
|
|
1524
|
-
const { name, agentId, walletAddress, autoInvokeTools, bound_judgment_ids } = req.body;
|
|
1861
|
+
const { name, agentId, walletAddress, autoInvokeTools, bound_judgment_ids, personaOverride, linkedDocumentIds } = req.body;
|
|
1525
1862
|
console.log(`[创建频道] 收到请求: name=${name}, agentId=${agentId}, wallet=${walletAddress ? 'yes' : 'no'}, boundJudgments=${Array.isArray(bound_judgment_ids) ? bound_judgment_ids.length : 0}`);
|
|
1526
1863
|
if (!name || !agentId) {
|
|
1527
1864
|
return res.status(400).json({ error: 'name and agentId required' });
|
|
@@ -1534,6 +1871,42 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1534
1871
|
const safeBoundIds = Array.isArray(bound_judgment_ids)
|
|
1535
1872
|
? bound_judgment_ids.filter((x) => typeof x === 'string' && x.length > 0)
|
|
1536
1873
|
: [];
|
|
1874
|
+
// 2026-06-11: persona 加载 — 优先用 personaOverride, 否则从 ~/.bolloon/persona.json 读全局默认
|
|
1875
|
+
let channelPersona;
|
|
1876
|
+
if (personaOverride && typeof personaOverride === 'object') {
|
|
1877
|
+
channelPersona = {
|
|
1878
|
+
name: personaOverride.name,
|
|
1879
|
+
description: personaOverride.description,
|
|
1880
|
+
personality: personaOverride.personality,
|
|
1881
|
+
greeting: personaOverride.greeting,
|
|
1882
|
+
capabilities: Array.isArray(personaOverride.capabilities) ? personaOverride.capabilities.slice(0, 20) : undefined,
|
|
1883
|
+
interests: Array.isArray(personaOverride.interests) ? personaOverride.interests.slice(0, 20) : undefined,
|
|
1884
|
+
};
|
|
1885
|
+
}
|
|
1886
|
+
else {
|
|
1887
|
+
try {
|
|
1888
|
+
const { readFileSync, existsSync } = await import('fs');
|
|
1889
|
+
const personaPath = `${process.env.HOME || '/tmp'}/.bolloon/persona.json`;
|
|
1890
|
+
if (existsSync(personaPath)) {
|
|
1891
|
+
const p = JSON.parse(readFileSync(personaPath, 'utf-8'));
|
|
1892
|
+
channelPersona = {
|
|
1893
|
+
name: p.name,
|
|
1894
|
+
description: p.description,
|
|
1895
|
+
personality: p.personality,
|
|
1896
|
+
greeting: p.greeting,
|
|
1897
|
+
capabilities: Array.isArray(p.capabilities) ? p.capabilities.slice(0, 20) : undefined,
|
|
1898
|
+
interests: Array.isArray(p.interests) ? p.interests.slice(0, 20) : undefined,
|
|
1899
|
+
};
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
catch (err) {
|
|
1903
|
+
console.warn('[创建频道] 加载 persona.json 失败 (非致命):', err.message);
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
// 过滤 linkedDocumentIds: 只保留 string
|
|
1907
|
+
const safeLinkedDocIds = Array.isArray(linkedDocumentIds)
|
|
1908
|
+
? linkedDocumentIds.filter((x) => typeof x === 'string' && x.length > 0).slice(0, 50)
|
|
1909
|
+
: [];
|
|
1537
1910
|
// 先创建频道(不阻塞等待 DID 生成)
|
|
1538
1911
|
const channel = {
|
|
1539
1912
|
id,
|
|
@@ -1546,6 +1919,8 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1546
1919
|
walletRegisteredAt: validWallet ? new Date().toISOString() : undefined,
|
|
1547
1920
|
autoInvokeTools: autoInvokeTools !== false, // 默认 true
|
|
1548
1921
|
bound_judgment_ids: safeBoundIds,
|
|
1922
|
+
persona: channelPersona,
|
|
1923
|
+
linkedDocumentIds: safeLinkedDocIds,
|
|
1549
1924
|
sessions: [{
|
|
1550
1925
|
id: `sess_${Date.now()}`,
|
|
1551
1926
|
createdAt: new Date().toISOString(),
|
|
@@ -1704,7 +2079,7 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1704
2079
|
app.patch('/channels/:channelId', async (req, res) => {
|
|
1705
2080
|
try {
|
|
1706
2081
|
const { channelId } = req.params;
|
|
1707
|
-
const { name, walletAddress, autoInvokeTools, bound_judgment_ids, shared_with_peers } = req.body;
|
|
2082
|
+
const { name, walletAddress, autoInvokeTools, bound_judgment_ids, shared_with_peers, persona, linkedDocumentIds } = req.body;
|
|
1708
2083
|
const channels = await loadChannels();
|
|
1709
2084
|
const channel = channels.find(c => c.id === channelId);
|
|
1710
2085
|
if (!channel) {
|
|
@@ -1713,6 +2088,26 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1713
2088
|
if (typeof name === 'string' && name.trim()) {
|
|
1714
2089
|
channel.name = name.trim();
|
|
1715
2090
|
}
|
|
2091
|
+
// 2026-06-11: 改 persona (允许 null 重置回全局默认)
|
|
2092
|
+
if (persona !== undefined) {
|
|
2093
|
+
if (persona === null) {
|
|
2094
|
+
channel.persona = undefined;
|
|
2095
|
+
}
|
|
2096
|
+
else if (typeof persona === 'object') {
|
|
2097
|
+
channel.persona = {
|
|
2098
|
+
name: persona.name,
|
|
2099
|
+
description: persona.description,
|
|
2100
|
+
personality: persona.personality,
|
|
2101
|
+
greeting: persona.greeting,
|
|
2102
|
+
capabilities: Array.isArray(persona.capabilities) ? persona.capabilities.slice(0, 20) : channel.persona?.capabilities,
|
|
2103
|
+
interests: Array.isArray(persona.interests) ? persona.interests.slice(0, 20) : channel.persona?.interests,
|
|
2104
|
+
};
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
// 2026-06-11: 改关联文档列表 (数组整体替换, 空数组 = 解绑所有)
|
|
2108
|
+
if (Array.isArray(linkedDocumentIds)) {
|
|
2109
|
+
channel.linkedDocumentIds = linkedDocumentIds.filter((x) => typeof x === 'string' && x.length > 0).slice(0, 50);
|
|
2110
|
+
}
|
|
1716
2111
|
// walletAddress 允许 null/'' 来解绑
|
|
1717
2112
|
if (walletAddress !== undefined) {
|
|
1718
2113
|
if (walletAddress === null || walletAddress === '') {
|
|
@@ -1765,6 +2160,10 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1765
2160
|
}
|
|
1766
2161
|
channel.updatedAt = new Date().toISOString();
|
|
1767
2162
|
await saveChannels(channels);
|
|
2163
|
+
// v3 修复: 分享变更后立即广播给所有 peer, 不用等对方手动刷新
|
|
2164
|
+
if (shared_with_peers !== undefined) {
|
|
2165
|
+
v3BroadcastOwn().catch(err => console.error('[v3] broadcast after share update failed:', err));
|
|
2166
|
+
}
|
|
1768
2167
|
res.json(channel);
|
|
1769
2168
|
}
|
|
1770
2169
|
catch (err) {
|
|
@@ -2427,13 +2826,26 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
2427
2826
|
res.status(500).json({ error: err.message });
|
|
2428
2827
|
}
|
|
2429
2828
|
});
|
|
2430
|
-
// v3: 暴露 P2PDirect 自己的 publicKey
|
|
2829
|
+
// v3: 暴露 P2PDirect 自己的 publicKey + 本机名字, 对方可用它主动 connect 并自动取名
|
|
2431
2830
|
app.get('/api/p2p-publickey', async (_req, res) => {
|
|
2432
2831
|
try {
|
|
2433
2832
|
if (!v3P2PRef) {
|
|
2434
2833
|
return res.status(503).json({ error: 'P2PDirect not started' });
|
|
2435
2834
|
}
|
|
2436
|
-
|
|
2835
|
+
const publicKey = v3P2PRef.getPublicKey();
|
|
2836
|
+
// 2026-06-10: 把本机 user/agent name 一起返回, 对方拿到后能直接用
|
|
2837
|
+
let name = process.env.BOLLOON_USER_NAME || process.env.USER || 'node';
|
|
2838
|
+
try {
|
|
2839
|
+
const { readFileSync, existsSync } = await import('fs');
|
|
2840
|
+
const cfgPath = `${process.env.HOME || '/tmp'}/.bolloon/config.json`;
|
|
2841
|
+
if (existsSync(cfgPath)) {
|
|
2842
|
+
const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
|
|
2843
|
+
if (cfg.userName)
|
|
2844
|
+
name = cfg.userName;
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2847
|
+
catch { }
|
|
2848
|
+
res.json({ publicKey, name, role: v3P2PRef.getRole() });
|
|
2437
2849
|
}
|
|
2438
2850
|
catch (err) {
|
|
2439
2851
|
res.status(500).json({ error: err.message });
|
|
@@ -2477,6 +2889,40 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
2477
2889
|
res.status(500).json({ error: err.message });
|
|
2478
2890
|
}
|
|
2479
2891
|
});
|
|
2892
|
+
// 2026-06-10: PATCH 重命名 / 改备注 / 同时影响 publicKey
|
|
2893
|
+
// 用法: PATCH /api/p2p-peers/:name { name?, notes?, publicKey? }
|
|
2894
|
+
app.patch('/api/p2p-peers/:name', async (req, res) => {
|
|
2895
|
+
try {
|
|
2896
|
+
const { addOrUpdatePeer, removePeer } = await import('../network/known-peers.js');
|
|
2897
|
+
const { readFile, writeFile } = await import('fs/promises');
|
|
2898
|
+
const { existsSync } = await import('fs');
|
|
2899
|
+
const filePath = `${process.env.HOME || '/tmp'}/.bolloon/known_peers.json`;
|
|
2900
|
+
if (!existsSync(filePath))
|
|
2901
|
+
return res.status(404).json({ error: 'no known_peers.json' });
|
|
2902
|
+
const data = JSON.parse(await readFile(filePath, 'utf-8'));
|
|
2903
|
+
const oldName = req.params.name;
|
|
2904
|
+
const oldEntry = data.peers[oldName];
|
|
2905
|
+
if (!oldEntry)
|
|
2906
|
+
return res.status(404).json({ error: `peer "${oldName}" not found` });
|
|
2907
|
+
const { name: newName, notes, publicKey: newPk } = req.body || {};
|
|
2908
|
+
const finalName = newName || oldName;
|
|
2909
|
+
const finalPk = newPk || oldEntry.publicKey;
|
|
2910
|
+
if (finalName !== oldName) {
|
|
2911
|
+
delete data.peers[oldName];
|
|
2912
|
+
}
|
|
2913
|
+
data.peers[finalName] = {
|
|
2914
|
+
...oldEntry,
|
|
2915
|
+
publicKey: finalPk,
|
|
2916
|
+
name: finalName,
|
|
2917
|
+
notes: notes !== undefined ? notes : oldEntry.notes
|
|
2918
|
+
};
|
|
2919
|
+
await writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
|
2920
|
+
res.json({ ok: true, peer: data.peers[finalName] });
|
|
2921
|
+
}
|
|
2922
|
+
catch (err) {
|
|
2923
|
+
res.status(500).json({ error: err.message });
|
|
2924
|
+
}
|
|
2925
|
+
});
|
|
2480
2926
|
// v3: 主动 connect 到对端的 P2PDirect publicKey
|
|
2481
2927
|
// 用法: POST /api/remote-channels/p2p-connect { targetPublicKey: "<hex>" }
|
|
2482
2928
|
app.post('/api/remote-channels/p2p-connect', async (req, res) => {
|
|
@@ -2513,6 +2959,99 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
2513
2959
|
res.status(500).json({ error: err.message });
|
|
2514
2960
|
}
|
|
2515
2961
|
});
|
|
2962
|
+
// v3: 主动给对端发好友申请 — 推到对端 UI 让对方接受
|
|
2963
|
+
// 用法: POST /api/friend-request { targetPublicKey, name, message }
|
|
2964
|
+
// 2026-06-10 改: 用 sendToWithWait 等握手完成, 不再 fire-and-forget; 返回结构化 code 让前端知道失败
|
|
2965
|
+
app.post('/api/friend-request', async (req, res) => {
|
|
2966
|
+
try {
|
|
2967
|
+
if (!v3P2PRef) {
|
|
2968
|
+
return res.status(503).json({ ok: false, code: 'P2P_NOT_STARTED', error: 'P2PDirect not started' });
|
|
2969
|
+
}
|
|
2970
|
+
const { targetPublicKey, name, message } = req.body || {};
|
|
2971
|
+
if (!targetPublicKey || typeof targetPublicKey !== 'string' || targetPublicKey.length !== 64) {
|
|
2972
|
+
return res.status(400).json({ ok: false, code: 'BAD_REQUEST', error: 'targetPublicKey (64 hex) required' });
|
|
2973
|
+
}
|
|
2974
|
+
// 先 joinPeer 触发握手 (注意: joinPeer 不阻塞到 conn 就绪)
|
|
2975
|
+
const swarm = v3P2PRef.swarm;
|
|
2976
|
+
if (swarm) {
|
|
2977
|
+
try {
|
|
2978
|
+
await swarm.joinPeer(Buffer.from(targetPublicKey, 'hex'));
|
|
2979
|
+
}
|
|
2980
|
+
catch { }
|
|
2981
|
+
}
|
|
2982
|
+
// 主动把对方加为本机 known_peers (本地视角认为对方是朋友)
|
|
2983
|
+
const { addOrUpdatePeer, findNameByPublicKey } = await import('../network/known-peers.js');
|
|
2984
|
+
const existing = await findNameByPublicKey(targetPublicKey);
|
|
2985
|
+
const peerName = name || existing || `peer-${targetPublicKey.substring(0, 8)}`;
|
|
2986
|
+
await addOrUpdatePeer(peerName, targetPublicKey);
|
|
2987
|
+
// 构造 RPC, 推到对端 — 对端会 SSE 推 friend-request 到前端
|
|
2988
|
+
const myPk = v3P2PRef.getPublicKey();
|
|
2989
|
+
const requestId = crypto.randomUUID();
|
|
2990
|
+
const rpc = JSON.stringify({
|
|
2991
|
+
v: 3,
|
|
2992
|
+
op: 'agent.friend.request',
|
|
2993
|
+
payload: {
|
|
2994
|
+
requestId, // 2026-06-10: 加 requestId, ack 时回带
|
|
2995
|
+
fromPublicKey: myPk,
|
|
2996
|
+
name: peerName,
|
|
2997
|
+
message: message || '想加你为 P2P 好友, 共享 channel 协作'
|
|
2998
|
+
}
|
|
2999
|
+
});
|
|
3000
|
+
// 2026-06-10: 用 sendToWithWait, 等 conn 真就绪后再发, 默认 5s 超时
|
|
3001
|
+
const result = await v3P2PRef.sendToWithWait(targetPublicKey, rpc, 5000);
|
|
3002
|
+
console.log(`[v3-friend] ${myPk.substring(0, 12)}... 发送好友申请给 ${targetPublicKey.substring(0, 12)}... (result=${result}, requestId=${requestId.substring(0, 8)})`);
|
|
3003
|
+
if (result !== 'SENT') {
|
|
3004
|
+
return res.status(502).json({
|
|
3005
|
+
ok: false,
|
|
3006
|
+
code: result, // NO_CONN / WRITE_FAIL
|
|
3007
|
+
error: result === 'NO_CONN'
|
|
3008
|
+
? '对方未在线, 请确认对方已启动 bolloon 并互联'
|
|
3009
|
+
: '写入 P2P 通道失败, 请重试',
|
|
3010
|
+
persistedAs: peerName // 本地仍持久化, 等对方上线再 retry 即可
|
|
3011
|
+
});
|
|
3012
|
+
}
|
|
3013
|
+
res.json({ ok: true, sent: true, code: 'SENT', persistedAs: peerName, requestId });
|
|
3014
|
+
}
|
|
3015
|
+
catch (err) {
|
|
3016
|
+
console.error('[v3-friend] friend-request 失败:', err);
|
|
3017
|
+
res.status(500).json({ ok: false, code: 'EXCEPTION', error: err.message });
|
|
3018
|
+
}
|
|
3019
|
+
});
|
|
3020
|
+
// v3: 接受对方的好友申请 — 把对方加为 known_peers, 立即推我的 channel 列表给 ta
|
|
3021
|
+
// 用法: POST /api/friend-accept { fromPublicKey, name }
|
|
3022
|
+
app.post('/api/friend-accept', async (req, res) => {
|
|
3023
|
+
try {
|
|
3024
|
+
if (!v3P2PRef) {
|
|
3025
|
+
return res.status(503).json({ error: 'P2PDirect not started' });
|
|
3026
|
+
}
|
|
3027
|
+
const { fromPublicKey, name } = req.body || {};
|
|
3028
|
+
if (!fromPublicKey || typeof fromPublicKey !== 'string' || fromPublicKey.length !== 64) {
|
|
3029
|
+
return res.status(400).json({ error: 'fromPublicKey (64 hex) required' });
|
|
3030
|
+
}
|
|
3031
|
+
// 持久化
|
|
3032
|
+
const { addOrUpdatePeer, findNameByPublicKey } = await import('../network/known-peers.js');
|
|
3033
|
+
const existing = await findNameByPublicKey(fromPublicKey);
|
|
3034
|
+
const peerName = name || existing || `peer-${fromPublicKey.substring(0, 8)}`;
|
|
3035
|
+
await addOrUpdatePeer(peerName, fromPublicKey);
|
|
3036
|
+
// joinPeer 确保连接存在 (连接可能已在 friend-request 时建立, 这里可能是 no-op)
|
|
3037
|
+
const swarm = v3P2PRef.swarm;
|
|
3038
|
+
if (swarm) {
|
|
3039
|
+
try {
|
|
3040
|
+
await swarm.joinPeer(Buffer.from(fromPublicKey, 'hex'));
|
|
3041
|
+
}
|
|
3042
|
+
catch { }
|
|
3043
|
+
}
|
|
3044
|
+
// v3 修复: 主动广播自己的 channel 列表给新好友,
|
|
3045
|
+
// 不能依赖 connection handler, 因为连接在 friend-request 阶段已建立, 不会触发新 connection 事件
|
|
3046
|
+
v3BroadcastOwn().catch(err => console.error('[v3] broadcast after friend-accept failed:', err));
|
|
3047
|
+
console.log(`[v3-friend] 接受好友申请: ${fromPublicKey.substring(0, 12)}... → ${peerName}`);
|
|
3048
|
+
res.json({ ok: true, persistedAs: peerName });
|
|
3049
|
+
}
|
|
3050
|
+
catch (err) {
|
|
3051
|
+
console.error('[v3-friend] friend-accept 失败:', err);
|
|
3052
|
+
res.status(500).json({ error: err.message });
|
|
3053
|
+
}
|
|
3054
|
+
});
|
|
2516
3055
|
// v3: 给远端 channel 发消息 (B 节点) - 通过 P2PDirect 转发到 A, A 跑 LLM, 回 B
|
|
2517
3056
|
// 用法: POST /api/remote-channels/chat-send
|
|
2518
3057
|
// { targetPublicKey, channelId, text }
|
|
@@ -2540,6 +3079,8 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
2540
3079
|
error: 'peer not connected. POST /api/remote-channels/p2p-connect first.'
|
|
2541
3080
|
});
|
|
2542
3081
|
}
|
|
3082
|
+
// 2026-06-10: 喂 watchdog — chat-send 成功是真实业务活动
|
|
3083
|
+
watchdogRef?.recordActivity?.();
|
|
2543
3084
|
console.log(`[v3] chat-send 转发到 ${targetPublicKey.substring(0, 12)}... (channelId=${channelId})`);
|
|
2544
3085
|
res.json({ ok: true, sent: true });
|
|
2545
3086
|
}
|
|
@@ -3415,6 +3956,8 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
3415
3956
|
healthMonitor = createHealthMonitor();
|
|
3416
3957
|
// 把 watchdog 静默阈值拉到 30 分钟, 避免开发期 / 用户空闲时被误杀
|
|
3417
3958
|
watchdog = createWatchdog({ silentThresholdMs: 30 * 60 * 1000 });
|
|
3959
|
+
// 2026-06-10: 同步到 module-level, 让 broadcast() / P2P handler / chat-send 都能喂活动
|
|
3960
|
+
watchdogRef = watchdog;
|
|
3418
3961
|
console.log('[24h] Heartbeat modules loaded');
|
|
3419
3962
|
}
|
|
3420
3963
|
catch (err) {
|
|
@@ -3759,8 +4302,12 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
3759
4302
|
// level 1 (内存爆) → 进程自杀, 依赖外层 supervisor / 用户重启 (Windows 任务计划/手动)
|
|
3760
4303
|
// 否则 Node.js 高 GC 压力下 HTTP 响应丢失, 客户端 fetch 永远 pending
|
|
3761
4304
|
watchdog.registerRestartStrategy(1, () => {
|
|
3762
|
-
|
|
3763
|
-
|
|
4305
|
+
// 2026-06-10: 改为不退出, 因为我们直接后台 tsx 启动没有外层 supervisor.
|
|
4306
|
+
// 误判主要因 recordActivity 仅在显式调用时刷新, 而 broadcast/SSE/连接均不触发.
|
|
4307
|
+
// 退出策略原文保留在注释里:
|
|
4308
|
+
// console.error('[Watchdog] memory critical, 进程退出 (期望外层重启)');
|
|
4309
|
+
// setTimeout(() => process.exit(1), 100);
|
|
4310
|
+
console.warn('[Watchdog] silentThreshold 触发, 但跳过 process.exit (无 supervisor)');
|
|
3764
4311
|
});
|
|
3765
4312
|
watchdog.start();
|
|
3766
4313
|
console.log('[24h] Watchdog started');
|
|
@@ -3919,6 +4466,8 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
3919
4466
|
});
|
|
3920
4467
|
}
|
|
3921
4468
|
function broadcast(data, channelId) {
|
|
4469
|
+
// 2026-06-10: 喂 watchdog, 避免 30min 空闲被误判 (recordActivity 内有 5s 去抖)
|
|
4470
|
+
watchdogRef?.recordActivity?.();
|
|
3922
4471
|
const envelope = { ...data, channelId };
|
|
3923
4472
|
const message = `data: ${JSON.stringify(envelope)}\n\n`;
|
|
3924
4473
|
console.log(`[broadcast] type=${data.type}, channelId=${channelId}, clients=${sseClients.size}`);
|