@bolloon/bolloon-agent 0.3.1 → 0.3.4

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.
@@ -12,10 +12,11 @@ import { segmentChatReply } from '../agents/chat-segmenter.js';
12
12
  import { registerJudgmentsRoutes } from './routes-judgments.js';
13
13
  import { registerLlmConfigRoutes } from './routes-llm-config.js';
14
14
  import { registerTaskRoutes } from './routes-tasks.js';
15
+ import { registerHearthRoutes } from './routes-hearth.js';
15
16
  // 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
+ import { SESSION_CACHE_PATH, SHARED_SESSION_PATH, IPFS_ENDPOINT, } from './server-types.js';
17
18
  // 同时也 re-export 出去 (其它地方可能从 './server.js' 引用)
18
- export { CHANNELS_PATH, SESSION_CACHE_PATH, THEME_PATH, TASK_QUEUE_PATH, IPFS_ENDPOINT, } from './server-types.js';
19
+ export { CHANNELS_PATH, SESSION_CACHE_PATH, SHARED_SESSION_PATH, THEME_PATH, TASK_QUEUE_PATH, IPFS_ENDPOINT, } from './server-types.js';
19
20
  // 读自身 package.json 拿 version (health endpoint 用)
20
21
  // 路径: src/web/server.ts → ../../package.json (编译后 dist/web/server.js)
21
22
  let cachedVersion = null;
@@ -107,7 +108,6 @@ function resolveWebRoot() {
107
108
  }
108
109
  const webRoot = resolveWebRoot();
109
110
  console.log(`[web] webRoot = ${webRoot}`);
110
- const SHARED_SESSION_PATH = path.join(process.env.HOME || '/tmp', '.bolloon', 'sessions');
111
111
  // iroh P2P 状态
112
112
  let irohNodeInfo = null;
113
113
  let irohInitialized = false;
@@ -1229,12 +1229,73 @@ async function getAgentForChannel(channelId, channelDid, channelName, channelDid
1229
1229
  }
1230
1230
  // 2026-07-06: CreateWebServerOptions 抽到 ./server-types.ts (顶部 re-export)
1231
1231
  let selfImproveEnabled = false;
1232
+ // ========== 端口锁 + 优雅关闭 ==========
1233
+ const LOCK_PATH = path.join(os.homedir(), '.bolloon', 'port.lock.json');
1234
+ let activeServer = null;
1235
+ let cleanupDone = false;
1236
+ function cleanupAndExit(signal) {
1237
+ if (cleanupDone)
1238
+ return;
1239
+ cleanupDone = true;
1240
+ console.log(`[server] 收到 ${signal}, 开始清理...`);
1241
+ try {
1242
+ fsSync.unlinkSync(LOCK_PATH);
1243
+ }
1244
+ catch (e) {
1245
+ if (e?.code !== 'ENOENT')
1246
+ console.warn(`[port-lock] 删锁失败:`, e?.message);
1247
+ }
1248
+ if (activeServer) {
1249
+ activeServer.close(() => { process.exit(0); });
1250
+ setTimeout(() => process.exit(0), 5000);
1251
+ }
1252
+ else {
1253
+ process.exit(0);
1254
+ }
1255
+ }
1256
+ function writeLock(port) {
1257
+ try {
1258
+ fsSync.writeFileSync(LOCK_PATH, JSON.stringify({ port, pid: process.pid, startedAt: new Date().toISOString() }));
1259
+ }
1260
+ catch (e) {
1261
+ console.warn(`[port-lock] 写锁文件失败:`, e?.message);
1262
+ }
1263
+ }
1264
+ function checkStaleLock(startPort) {
1265
+ try {
1266
+ const raw = fsSync.readFileSync(LOCK_PATH, 'utf-8');
1267
+ const lock = JSON.parse(raw);
1268
+ if (!lock?.pid || lock.pid === process.pid)
1269
+ return;
1270
+ if (lock.port < startPort || lock.port > startPort + 10)
1271
+ return;
1272
+ try {
1273
+ process.kill(lock.pid, 0);
1274
+ console.warn(`⚠ 旧进程 PID ${lock.pid} 仍存活 (端口 ${lock.port}), 尝试终止...`);
1275
+ process.kill(lock.pid, 'SIGTERM');
1276
+ }
1277
+ catch (e2) {
1278
+ if (e2?.code === 'ESRCH') {
1279
+ console.log(`[port-lock] 上一实例 (PID ${lock.pid}) 已结束`);
1280
+ }
1281
+ }
1282
+ }
1283
+ catch (e) {
1284
+ if (e?.code !== 'ENOENT')
1285
+ console.warn(`[port-lock] 读取失败:`, e?.message);
1286
+ }
1287
+ }
1232
1288
  export async function createWebServer(port = 3000, options = {}) {
1233
1289
  selfImproveEnabled = options.selfImprove ?? false;
1234
1290
  // 防止 P2P DHT 超时等错误导致进程崩溃
1235
1291
  process.on('unhandledRejection', (reason, promise) => {
1236
1292
  console.error('[警告] 未处理的 Promise 拒绝:', reason);
1237
1293
  });
1294
+ // 优雅关闭信号
1295
+ process.on('SIGTERM', () => cleanupAndExit('SIGTERM'));
1296
+ process.on('SIGINT', () => cleanupAndExit('SIGINT'));
1297
+ // 启动前检查残存锁文件
1298
+ checkStaleLock(port);
1238
1299
  // Bolloon Bootstrap (幂等, 重复调不会重复挂定时器)
1239
1300
  // 这里独立调一次以保证 CLI-only 模式 (无 index.ts 引导) 也能 bootstrap
1240
1301
  try {
@@ -1824,7 +1885,11 @@ export async function createWebServer(port = 3000, options = {}) {
1824
1885
  const app = express();
1825
1886
  const server = createServer(app);
1826
1887
  await ensureSessionDirs();
1827
- app.use(express.json());
1888
+ // 2026-07-15 修 Bug 3 续: attachment 路由需要单独大 limit body parser.
1889
+ // 关键: 必须挂在主 app.use(express.json()) 之前, 否则 4MB attachment 在主 100KB parser 阶段就被拒.
1890
+ // path-prefix 让它只对 /api/attachments/* 生效, 不污染其他端点的限制.
1891
+ app.use('/api/attachments', express.json({ limit: '15mb' }));
1892
+ app.use(express.json({ limit: '100kb' }));
1828
1893
  app.use((req, res, next) => {
1829
1894
  res.setHeader('Access-Control-Allow-Origin', '*');
1830
1895
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
@@ -1924,6 +1989,125 @@ export async function createWebServer(port = 3000, options = {}) {
1924
1989
  res.status(400).json({ ok: false, reason: e?.message ?? 'parse error' });
1925
1990
  }
1926
1991
  });
1992
+ // 2026-07-15 修 Bug 3: 拖拽文件上传
1993
+ // - POST /api/attachments/upload body: { filename, mimeType, content: base64 }
1994
+ // - 文件落到 ~/.bolloon/attachments/<YYYY-MM>/<uuid>__<safeName>
1995
+ // - 返回 { ok, attachmentId, url, size, mimeType, filename }
1996
+ // - 前端拿到 attachmentId 后, 在消息文本里插一个 [attachment:id] 标记
1997
+ // (这条消息发出去时 server 端 /message 把它解析成 contextHint + 行内下载链接)
1998
+ // 没用 multer/formidable — 复用 base64 JSON 简化, 跟 existing /api/judgments/import 同样的传输方式
1999
+ // body parser 在上面 path-prefix 中间件已挂, 这里直接 handler 即可.
2000
+ const ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024; // 10MB 单文件硬上限
2001
+ app.post('/api/attachments/upload', async (req, res) => {
2002
+ try {
2003
+ const body = req.body ?? {};
2004
+ const filename = String(body.filename || '').trim();
2005
+ const mimeType = String(body.mimeType || 'application/octet-stream');
2006
+ const content = String(body.content || '');
2007
+ if (!filename || !content) {
2008
+ return res.status(400).json({ ok: false, error: 'filename 与 content 必填' });
2009
+ }
2010
+ // base64 → bytes
2011
+ const buf = Buffer.from(content, 'base64');
2012
+ if (buf.length === 0) {
2013
+ return res.status(400).json({ ok: false, error: '文件为空' });
2014
+ }
2015
+ if (buf.length > ATTACHMENT_MAX_BYTES) {
2016
+ return res.status(413).json({
2017
+ ok: false,
2018
+ error: `文件超过 ${ATTACHMENT_MAX_BYTES / 1024 / 1024}MB 上限`,
2019
+ });
2020
+ }
2021
+ // safeName: 跟 safeChannelName 同样规则, 防路径穿越 + Windows 非法字符
2022
+ // 注意: 把 . 也加进替换 (不然 "../../" 被转成 "..__" 还残留 .. 序列), 整个文件 base 安全
2023
+ const safeName = filename
2024
+ .replace(/[\\/:*?"<>|\x00-\x1f.]/g, '_')
2025
+ .replace(/^_+|_+$/g, '')
2026
+ .slice(0, 120) || 'unnamed';
2027
+ if (safeName !== filename) {
2028
+ console.log(`[attachments] safeName 转换: "${filename}" → "${safeName}"`);
2029
+ }
2030
+ const month = new Date().toISOString().slice(0, 7); // YYYY-MM
2031
+ const attachmentsDir = path.join(process.env.HOME || '/tmp', '.bolloon', 'attachments', month);
2032
+ await fs.mkdir(attachmentsDir, { recursive: true });
2033
+ const attachmentId = `${Date.now().toString(36)}_${crypto.randomBytes(6).toString('hex')}`;
2034
+ const storedFilename = `${attachmentId}__${safeName}`;
2035
+ const fullPath = path.join(attachmentsDir, storedFilename);
2036
+ await fs.writeFile(fullPath, buf);
2037
+ const urlPath = `/api/attachments/${attachmentId}`;
2038
+ console.log(`[attachments] 上传 ${filename} (${buf.length}B, ${mimeType}) → ${fullPath}`);
2039
+ res.json({
2040
+ ok: true,
2041
+ attachmentId,
2042
+ url: urlPath,
2043
+ filename,
2044
+ storedFilename,
2045
+ size: buf.length,
2046
+ mimeType,
2047
+ });
2048
+ }
2049
+ catch (e) {
2050
+ console.error('[attachments] upload failed:', e?.message || e);
2051
+ res.status(500).json({ ok: false, error: e?.message || '上传失败' });
2052
+ }
2053
+ });
2054
+ // GET /api/attachments/:id — 下载 (按 attachmentId 找当月目录; 月份遍历回退)
2055
+ app.get('/api/attachments/:id', async (req, res) => {
2056
+ try {
2057
+ const id = String(req.params.id || '').replace(/[^a-zA-Z0-9_]/g, '');
2058
+ if (!id)
2059
+ return res.status(400).type('text/plain').send('invalid id');
2060
+ const attachmentsRoot = path.join(process.env.HOME || '/tmp', '.bolloon', 'attachments');
2061
+ // 先按当前月 → 前一月 → …→ 全部月份列表 (3 个月够历史用)
2062
+ const candidates = [];
2063
+ const now = new Date();
2064
+ for (let i = 0; i < 6; i++) {
2065
+ const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
2066
+ const month = d.toISOString().slice(0, 7);
2067
+ try {
2068
+ const files = await fs.readdir(path.join(attachmentsRoot, month));
2069
+ for (const f of files) {
2070
+ if (f.startsWith(id + '__'))
2071
+ candidates.push(path.join(attachmentsRoot, month, f));
2072
+ }
2073
+ }
2074
+ catch { /* month dir 可能不存在, 跳过 */ }
2075
+ if (candidates.length > 0)
2076
+ break;
2077
+ }
2078
+ if (candidates.length === 0) {
2079
+ return res.status(404).type('text/plain').send('attachment not found');
2080
+ }
2081
+ const fullPath = candidates[0];
2082
+ const stat = await fs.stat(fullPath).catch(() => null);
2083
+ if (!stat || !stat.isFile()) {
2084
+ return res.status(404).type('text/plain').send('attachment missing');
2085
+ }
2086
+ // 从文件名还原原 mimeType: 按扩展名猜
2087
+ const fileBase = path.basename(fullPath);
2088
+ const origName = fileBase.substring(id.length + 2); // 跳过 "<id>__"
2089
+ const ext = path.extname(origName).toLowerCase();
2090
+ const mimeMap = {
2091
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
2092
+ '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
2093
+ '.pdf': 'application/pdf',
2094
+ '.txt': 'text/plain', '.md': 'text/markdown', '.json': 'application/json',
2095
+ '.csv': 'text/csv', '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
2096
+ '.zip': 'application/zip',
2097
+ '.mp3': 'audio/mpeg', '.mp4': 'video/mp4', '.webm': 'video/webm',
2098
+ };
2099
+ const mime = mimeMap[ext] || 'application/octet-stream';
2100
+ const buf = await fs.readFile(fullPath);
2101
+ res.setHeader('Content-Length', String(buf.length));
2102
+ res.setHeader('Content-Type', mime);
2103
+ res.setHeader('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(origName)}`);
2104
+ res.send(buf);
2105
+ }
2106
+ catch (e) {
2107
+ console.error('[attachments] download failed:', e?.message || e);
2108
+ res.status(500).type('text/plain').send('download error: ' + (e?.message || ''));
2109
+ }
2110
+ });
1927
2111
  // 全局兜底: 任何 next(err) 走到这里, 给出结构化 4xx/5xx 而不是默认 HTML
1928
2112
  app.use((err, req, res, _next) => {
1929
2113
  console.error('[server] unhandled error on', req.method, req.path, '-', err?.message || err);
@@ -1955,10 +2139,29 @@ export async function createWebServer(port = 3000, options = {}) {
1955
2139
  });
1956
2140
  });
1957
2141
  app.post('/message', async (req, res) => {
1958
- const { text, channelId, channelDid } = req.body;
2142
+ const { text, channelId, channelDid, attachments } = req.body;
1959
2143
  if (!text) {
1960
2144
  return res.status(400).json({ error: 'No text provided' });
1961
2145
  }
2146
+ // 2026-07-15 修 Bug 3: 拖拽附件 — LLM 在 contextHint 里看到文件清单, 用户文本保持可读
2147
+ // 替代方案: 把 [attachment:id] 标记塞 text 里, 这里解析回 attachments 数组
2148
+ let parsedAttachments = [];
2149
+ if (Array.isArray(attachments) && attachments.length > 0) {
2150
+ parsedAttachments = attachments.filter((a) => a && typeof a.attachmentId === 'string' && a.attachmentId.length > 0);
2151
+ }
2152
+ else {
2153
+ // 兼容老前端: 从 text 里抽 [attachment:<id>] 标记
2154
+ const rx = /\[attachment:([a-zA-Z0-9_]+)\]/g;
2155
+ let m;
2156
+ while ((m = rx.exec(String(text))) !== null) {
2157
+ parsedAttachments.push({ attachmentId: m[1] });
2158
+ }
2159
+ }
2160
+ const attachmentContext = parsedAttachments.length > 0
2161
+ ? `[系统上下文] 用户上传了 ${parsedAttachments.length} 个附件: ` +
2162
+ parsedAttachments.map(a => `${a.filename || a.attachmentId} (id=${a.attachmentId}, mime=${a.mimeType || '?'}, size=${a.size ?? '?'}B, URL=/api/attachments/${a.attachmentId})`).join('; ') +
2163
+ `\n你需要时可以调用文件读工具 (curl /api/attachments/<id>) 拉取真实内容。\n\n`
2164
+ : '';
1962
2165
  if (!channelId) {
1963
2166
  return res.status(400).json({ error: 'No channelId provided' });
1964
2167
  }
@@ -1990,9 +2193,17 @@ export async function createWebServer(port = 3000, options = {}) {
1990
2193
  // per-channel queue 检查: 已在跑就入队, 等当前跑完自动接上
1991
2194
  const runState = getOrCreateRunState(channelId);
1992
2195
  if (runState.running) {
1993
- runState.queue.push({ channelId, text, boundWalletAddress, autoToolsEnabled });
2196
+ // 2026-07-15 Bug 8: 入队时保留 attachments + channelDid, 否则下一轮执行时会丢附件
2197
+ runState.queue.push({
2198
+ channelId,
2199
+ text,
2200
+ boundWalletAddress,
2201
+ autoToolsEnabled,
2202
+ attachments: parsedAttachments,
2203
+ channelDid,
2204
+ });
1994
2205
  broadcastQueueUpdate(channelId);
1995
- console.log(`[queue] /message 入队 channel=${channelId}, queue len=${runState.queue.length}`);
2206
+ console.log(`[queue] /message 入队 channel=${channelId}, queue len=${runState.queue.length}, attach=${parsedAttachments.length}`);
1996
2207
  return;
1997
2208
  }
1998
2209
  runState.running = true;
@@ -2267,7 +2478,8 @@ export async function createWebServer(port = 3000, options = {}) {
2267
2478
  extraHint = hint + '\n\n';
2268
2479
  nextPromptHints.delete(channelId);
2269
2480
  }
2270
- const markedPrompt = `${extraHint}【本轮用户请求】\n${text}\n【请求结束】\n\n${contextHint}`;
2481
+ // 2026-07-15 Bug 3: 拖拽附件 — attachmentContext 提到 contextHint 最前, LLM 第一眼看到文件清单
2482
+ const markedPrompt = `${extraHint}【本轮用户请求】\n${text}\n【请求结束】\n\n${attachmentContext}${contextHint}`;
2271
2483
  fullResponse = await agent.promptStream(markedPrompt, streamCallback, runState.abortController?.signal, channelId);
2272
2484
  }
2273
2485
  catch (err) {
@@ -2310,7 +2522,15 @@ export async function createWebServer(port = 3000, options = {}) {
2310
2522
  const session = existingSession || { channelId, sessionId: currentSessionId, messages: [], lastUpdated: new Date().toISOString() };
2311
2523
  session.sessionId = currentSessionId;
2312
2524
  // v3: 加 source 标记 (local = 内部 owner, remote = 远端访客)
2313
- session.messages.push({ id: crypto.randomUUID(), type: 'user', content: text, timestamp: new Date().toISOString(), source: 'local' });
2525
+ // 2026-07-15 Bug 4: client.ts sendMessage 已经通过 persistLastMessageToServer PATCH /sessions/.../...
2526
+ // 把 user msg 落盘一次 (立即落, 切走再切回不丢). 这里再 push 一次 → session.messages 出现两条相同的 user msg,
2527
+ // loadSession 重渲染时两条都上屏, 表现"每条 user 气泡重复两次".
2528
+ // 修法: 持久化以 client PATCH 为准, /message 这边只 push ai 消息. 同时去重检查上次的 user 避免极端竞态 (并行 PATCH).
2529
+ const lastMsg = session.messages[session.messages.length - 1];
2530
+ const userAlreadyPushed = lastMsg && lastMsg.type === 'user' && lastMsg.content === text;
2531
+ if (!userAlreadyPushed) {
2532
+ session.messages.push({ id: crypto.randomUUID(), type: 'user', content: text, timestamp: new Date().toISOString(), source: 'local' });
2533
+ }
2314
2534
  session.messages.push({
2315
2535
  id: crypto.randomUUID(),
2316
2536
  type: 'ai',
@@ -2499,12 +2719,26 @@ export async function createWebServer(port = 3000, options = {}) {
2499
2719
  // 能直接拿到引用.
2500
2720
  clearTimeout(forceTimeout);
2501
2721
  // queue dequeue: 跑完或失败都要清状态
2502
- // 当前实现: 自动接下一条需要把 ~200 行 try 块抽函数, 暂不抽.
2503
- // 替代: 用户点 [队列 +N] 按钮时, 客户端发起一个特殊的 HTTP 请求触发下一条
2504
- // (在 client.js 实现). 这里只清状态 + 广播.
2505
2722
  runState.running = false;
2506
2723
  runState.abortController = null;
2507
2724
  broadcastQueueUpdate(channelId);
2725
+ // 2026-07-15 修 Bug 8: 队列自动 drain — 之前只清状态不抽下一条, 用户连续发的所有
2726
+ // 第二轮起全卡在 queue 里出不来, 表现"第二轮没反应".
2727
+ // 修复: finally 里查 queue, 非空就异步 fire-and-forget 起下一轮.
2728
+ // 实现要点:
2729
+ // 1. 用 setImmediate / Promise.resolve() 让 res.headersSent 干净
2730
+ // 2. 不能 await (否则阻塞 finally 后面的 saveSession; 而且这就是 fire-and-forget)
2731
+ // 3. 重新 build contextHint, 走相同 LLM 路径
2732
+ if (runState.queue.length > 0) {
2733
+ const next = runState.queue.shift();
2734
+ console.log(`[queue-drain] channel=${next.channelId} text="${next.text.slice(0, 30)}" attach=${(next.attachments?.length ?? 0)}`);
2735
+ // 异步跑下一条 (fire-and-forget)
2736
+ setImmediate(() => {
2737
+ void runMessageFromQueue(next).catch((e) => {
2738
+ console.error('[queue-drain] error:', e?.message?.slice(0, 200));
2739
+ });
2740
+ });
2741
+ }
2508
2742
  // 2026-07-01 (v0.2.5): 持久化当前 messageHistory — 让 web 用户跨刷新保留对话.
2509
2743
  // saveCurrentSession 失败静默, 不阻塞 channel 状态清理.
2510
2744
  // saveCurrentSession 内部走 SessionStore (默认 ~/.bolloon/sessions/cache/<sessionKey>.json).
@@ -2514,7 +2748,7 @@ export async function createWebServer(port = 3000, options = {}) {
2514
2748
  await agent.saveCurrentSession(sessionKey);
2515
2749
  }
2516
2750
  catch (saveErr) {
2517
- console.warn(`[web] saveCurrentSession failed (non-fatal): ${saveErr?.message?.slice(0, 100)}`);
2751
+ console.warn(`[web] saveCurrentSession failed (non-fatal): ${saveErr.message?.slice(0, 100)}`);
2518
2752
  }
2519
2753
  }
2520
2754
  }
@@ -2535,6 +2769,115 @@ export async function createWebServer(port = 3000, options = {}) {
2535
2769
  }
2536
2770
  return s;
2537
2771
  }
2772
+ /** 抽离 attachment contextHint 出来 — 给 queue-drain 用; /message 主路径有 inline 等价版避免重复 hoist 错误 */
2773
+ function buildAttachmentContextForQueue(parsedAttachments) {
2774
+ if (!parsedAttachments || parsedAttachments.length === 0)
2775
+ return '';
2776
+ return `[系统上下文] 用户上传了 ${parsedAttachments.length} 个附件: ` +
2777
+ parsedAttachments.map((a) => `${a.filename || a.attachmentId} (id=${a.attachmentId}, mime=${a.mimeType || '?'}, size=${a.size ?? '?'}B, URL=/api/attachments/${a.attachmentId})`).join('; ') +
2778
+ `\n你可以调用文件读工具 (curl /api/attachments/<id>) 拉取真实内容。\n\n`;
2779
+ }
2780
+ /**
2781
+ * 2026-07-15 修 Bug 8: 队列消息的执行器 — finally 排空 queue 时调这里
2782
+ * 跑下一条 queued 消息.
2783
+ *
2784
+ * 设计: 这是个 fire-and-forget wrapper, 复用 /message 主路径的 broadcast / save / etc.
2785
+ * 简化路径 (跟主路径 500 行 try 块相比):
2786
+ * - 不重新建载 judgment hint / persona / context — server.ts 这次明确把这些容
2787
+ * 易"廉价放"在 /message 主路径, queue 路径只用基本标识
2788
+ * - 仍然是合法: agent.promptStream → broadcast(type:user) → broadcast(type:ai) → done
2789
+ * - 处理 attachments: 跟主路径一样
2790
+ *
2791
+ * 如果要 1:1 复刻主路径的所有 hooks (judgment hint / persona / manifest / etc),
2792
+ * 后续可以把 /message 主路径的 try 块抽成 runPromptChannel 共享.
2793
+ */
2794
+ async function runMessageFromQueue(queued) {
2795
+ const { channelId, text, attachments, channelDid: reqChannelDid, boundWalletAddress, autoToolsEnabled } = queued;
2796
+ const runState = getOrCreateRunState(channelId);
2797
+ if (runState.running)
2798
+ return; // 防重入 — queue 已经并发去重
2799
+ runState.running = true;
2800
+ runState.abortController = new AbortController();
2801
+ const currentSessionId = (await loadChannels()).find(c => c.id === channelId)?.currentSessionId || 'default';
2802
+ const realChannelDid = reqChannelDid || (await loadChannels()).find(c => c.id === channelId)?.did || '';
2803
+ const parsedAttachments = Array.isArray(attachments) ? attachments : [];
2804
+ const attachmentContext = buildAttachmentContextForQueue(parsedAttachments);
2805
+ const sessionKey = `${channelId}:${currentSessionId}`;
2806
+ // 防 LLM hang 安全网
2807
+ const PIVOT_FORCE_TIMEOUT_MS = 5 * 60 * 1000;
2808
+ const forceTimeout = setTimeout(() => {
2809
+ console.warn(`[server] queue-drain pivot 强制 timeout, aborting`);
2810
+ runState.abortController?.abort();
2811
+ }, PIVOT_FORCE_TIMEOUT_MS);
2812
+ let agent = null;
2813
+ try {
2814
+ // 1) broadcast user 给前端 (跟主路径一致)
2815
+ broadcast({ type: 'user', content: text }, channelId);
2816
+ // 2) 取 agent + session
2817
+ agent = await getAgentForChannel(channelId, currentSessionId).catch(() => null);
2818
+ if (!agent) {
2819
+ throw new Error(`No agent for channel=${channelId}`);
2820
+ }
2821
+ // 3) 重 build contextHint (基本版, 不全 500 行 hook)
2822
+ // 原因: queue-drain 是常见调试路径, 全量 hooks 性能大. 关键是 attachments 带上.
2823
+ const contextHint = attachmentContext + `[系统上下文] 队列消息 (auto-drain)\n`;
2824
+ // 4) promptStream
2825
+ const markedPrompt = `【本轮用户请求】\n${text}\n【请求结束】\n\n${contextHint}`;
2826
+ const fullResponse = await agent.promptStream(markedPrompt, () => { }, runState.abortController?.signal, channelId);
2827
+ if (!fullResponse.trim()) {
2828
+ broadcast({ type: 'error', content: '⚠️ AI 未返回内容' }, channelId);
2829
+ }
2830
+ else {
2831
+ broadcast({ type: 'ai', content: fullResponse }, channelId);
2832
+ // 落 session
2833
+ try {
2834
+ const existing = await loadSession(channelId, currentSessionId);
2835
+ const session = existing || { channelId, sessionId: currentSessionId, messages: [], lastUpdated: new Date().toISOString() };
2836
+ session.sessionId = currentSessionId;
2837
+ // 跟主路径相同: 不重复 push user (主路径已 broadcast/push), 只 push ai
2838
+ session.messages.push({
2839
+ id: crypto.randomUUID(),
2840
+ type: 'ai',
2841
+ content: fullResponse,
2842
+ timestamp: new Date().toISOString(),
2843
+ source: 'local',
2844
+ });
2845
+ session.lastUpdated = new Date().toISOString();
2846
+ await saveSession(session);
2847
+ }
2848
+ catch (e) {
2849
+ console.warn('[queue-drain] saveSession failed:', e?.message?.slice(0, 100));
2850
+ }
2851
+ }
2852
+ broadcast({ type: 'done' }, channelId);
2853
+ }
2854
+ catch (err) {
2855
+ console.warn('[queue-drain] failed:', err?.message?.slice(0, 200));
2856
+ broadcast({ type: 'error', content: 'queue-drain: ' + (err?.message || 'failed') }, channelId);
2857
+ }
2858
+ finally {
2859
+ clearTimeout(forceTimeout);
2860
+ runState.running = false;
2861
+ runState.abortController = null;
2862
+ broadcastQueueUpdate(channelId);
2863
+ // 递归 drain — 同 /message 主路径 finally 行为保持一致
2864
+ if (runState.queue.length > 0) {
2865
+ const next = runState.queue.shift();
2866
+ console.log(`[queue-drain-recursive] channel=${next.channelId} text="${next.text.slice(0, 30)}"`);
2867
+ setImmediate(() => {
2868
+ void runMessageFromQueue(next).catch((e) => {
2869
+ console.error('[queue-drain-recursive] error:', e?.message?.slice(0, 200));
2870
+ });
2871
+ });
2872
+ }
2873
+ if (agent) {
2874
+ try {
2875
+ await agent.saveCurrentSession(sessionKey);
2876
+ }
2877
+ catch { }
2878
+ }
2879
+ }
2880
+ }
2538
2881
  function broadcastQueueUpdate(channelId) {
2539
2882
  const s = channelRunState.get(channelId);
2540
2883
  const queueLength = s ? s.queue.length : 0;
@@ -2855,6 +3198,41 @@ export async function createWebServer(port = 3000, options = {}) {
2855
3198
  channels.push(channel);
2856
3199
  await saveChannels(channels);
2857
3200
  await saveSession({ channelId: id, sessionId: 'default', messages: [], lastUpdated: new Date().toISOString() });
3201
+ // 2026-07-15 修 Bug 6: 同步把 agent 定义写进 ~/.bolloon/agents/agents.json
3202
+ // 之前 channel 只落 channels.json, 不会出现在 agents.json 里.
3203
+ // 重新启动 server 时 loadLocalSubAgents 只读 agents.json — 用户以为"智能体没保存".
3204
+ // 修法: 直接读 + append (idempotent) 写 agents.json, 用 channel.agentId 作为主键 (跟 channels.json 引用对齐).
3205
+ // 不走 SubAgentManager.registerAgent 因为它会自己生成新 id, 跟 channel.agentId 对不上.
3206
+ try {
3207
+ const agentsPath = path.join(process.env.HOME || '/tmp', '.bolloon', 'agents', 'agents.json');
3208
+ await fs.mkdir(path.dirname(agentsPath), { recursive: true });
3209
+ let arr = [];
3210
+ try {
3211
+ arr = JSON.parse(await fs.readFile(agentsPath, 'utf-8'));
3212
+ }
3213
+ catch { }
3214
+ if (!Array.isArray(arr))
3215
+ arr = [];
3216
+ const exists = arr.some(a => a && a.id === agentId);
3217
+ if (!exists) {
3218
+ arr.push({
3219
+ id: agentId,
3220
+ name,
3221
+ did: `did:local:${id}`,
3222
+ description: `Agent ${name} (auto-registered from channel ${id})`,
3223
+ capabilities: Array.isArray(channelPersona?.capabilities) ? channelPersona.capabilities : [],
3224
+ status: 'active',
3225
+ createdAt: new Date().toISOString(),
3226
+ lastActive: new Date().toISOString(),
3227
+ channelId: id,
3228
+ });
3229
+ await fs.writeFile(agentsPath, JSON.stringify(arr, null, 2), 'utf-8');
3230
+ console.log(`[创建频道] agent 写进 agents.json: name=${name} id=${agentId}`);
3231
+ }
3232
+ }
3233
+ catch (e) {
3234
+ console.warn('[创建频道] 写 agents.json 失败 (非致命):', e?.message?.slice(0, 120));
3235
+ }
2858
3236
  res.json(channel);
2859
3237
  // 后台生成 DID — 用统一的修复队列, 避免每个 POST 都启动独立 setTimeout
2860
3238
  console.log(`[创建频道] 加入 DID 修复队列...`);
@@ -2993,6 +3371,32 @@ export async function createWebServer(port = 3000, options = {}) {
2993
3371
  }
2994
3372
  catch { }
2995
3373
  }
3374
+ // 2026-07-15 修 Bug 7: 同步清理 agents.json 里挂在这个 channel 下的 agent 定义
3375
+ // 之前 v0.3.6 (Bug 6) 创建频道时同步往 agents.json append, 删频道却没删回来
3376
+ // → agents.json 里残留孤儿 agent, 重启后 loadLocalSubAgents 还能读到这些 — 看起来像"删不掉"
3377
+ // 修法: 用 channel.agentId 找, 同步从 agents.json 删一条
3378
+ try {
3379
+ const agentsPath = path.join(process.env.HOME || '/tmp', '.bolloon', 'agents', 'agents.json');
3380
+ const raw = await fs.readFile(agentsPath, 'utf-8').catch(() => '');
3381
+ if (raw) {
3382
+ let arr = [];
3383
+ try {
3384
+ arr = JSON.parse(raw);
3385
+ }
3386
+ catch { }
3387
+ if (!Array.isArray(arr))
3388
+ arr = [];
3389
+ const before = arr.length;
3390
+ arr = arr.filter(a => !(a && (a.id === channel.agentId || a.channelId === channelId)));
3391
+ if (arr.length !== before) {
3392
+ await fs.writeFile(agentsPath, JSON.stringify(arr, null, 2), 'utf-8');
3393
+ console.log(`[删除频道] agents.json 清掉 ${before - arr.length} 条 orphan agent (channel=${channelId})`);
3394
+ }
3395
+ }
3396
+ }
3397
+ catch (e) {
3398
+ console.warn('[删除频道] 清理 agents.json 失败 (非致命):', e?.message?.slice(0, 120));
3399
+ }
2996
3400
  res.json({ ok: true });
2997
3401
  }
2998
3402
  catch (err) {
@@ -4665,6 +5069,8 @@ export async function createWebServer(port = 3000, options = {}) {
4665
5069
  });
4666
5070
  // 2026-07-06: judgments / self-improve / permission-mode 路由抽到 ./routes-judgments.ts
4667
5071
  registerJudgmentsRoutes(app);
5072
+ // 2026-07-15: judgeness · hearth 主路由 + peer 4 类写 API
5073
+ registerHearthRoutes(app);
4668
5074
  // ==================== Self-Improve 端点 ====================
4669
5075
  // 查看当前策略 (白名单 / 黑名单)
4670
5076
  app.get('/api/self-improve/policy', async (_req, res) => {
@@ -4980,6 +5386,8 @@ export async function createWebServer(port = 3000, options = {}) {
4980
5386
  }
4981
5387
  catch { }
4982
5388
  });
5389
+ activeServer = currentServer;
5390
+ writeLock(currentPort);
4983
5391
  resolve({ app, server: currentServer, port: currentPort });
4984
5392
  });
4985
5393
  };
@@ -60,7 +60,7 @@ function escapeHtml(s) {
60
60
  "'": "&#39;"
61
61
  })[c]);
62
62
  }
63
- function addMessage(content, type, save = true, container, usedJudgmentIds = [], ctx = { messagesEl: null, messagesContainers: /* @__PURE__ */ new Map(), currentChannelId: null }) {
63
+ function addMessage(content, type, save = true, container, usedJudgmentIds = [], ctx = { messagesEl: null, messagesContainers: /* @__PURE__ */ new Map(), currentChannelId: null }, timestamp) {
64
64
  const messagesEl = ctx.messagesEl || (typeof document !== "undefined" ? document.getElementById("messages") : null);
65
65
  const messagesContainers = ctx.messagesContainers || /* @__PURE__ */ new Map();
66
66
  const currentChannelId = ctx.currentChannelId;
@@ -129,9 +129,20 @@ function addMessage(content, type, save = true, container, usedJudgmentIds = [],
129
129
  return;
130
130
  }
131
131
  const rawContent = segments.filter((s) => s.type === "text" || s.type === "final").map((s) => s.content || "").join("\n");
132
+ let timeLabel = "";
133
+ try {
134
+ if (timestamp !== void 0 && timestamp !== null) {
135
+ const d = timestamp instanceof Date ? timestamp : new Date(timestamp);
136
+ if (!isNaN(d.getTime())) {
137
+ timeLabel = d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
138
+ }
139
+ }
140
+ } catch {
141
+ }
142
+ if (!timeLabel) timeLabel = (/* @__PURE__ */ new Date()).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
132
143
  const time = document.createElement("div");
133
144
  time.className = "time";
134
- time.textContent = (/* @__PURE__ */ new Date()).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
145
+ time.textContent = timeLabel;
135
146
  if (type === "ai") {
136
147
  div.appendChild(buildMessageActions(div, rawContent, ctx));
137
148
  }