@bolloon/bolloon-agent 0.3.1 → 0.3.3

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,6 +12,7 @@ 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
17
  import { SESSION_CACHE_PATH, IPFS_ENDPOINT, } from './server-types.js';
17
18
  // 同时也 re-export 出去 (其它地方可能从 './server.js' 引用)
@@ -1824,7 +1825,11 @@ export async function createWebServer(port = 3000, options = {}) {
1824
1825
  const app = express();
1825
1826
  const server = createServer(app);
1826
1827
  await ensureSessionDirs();
1827
- app.use(express.json());
1828
+ // 2026-07-15 修 Bug 3 续: attachment 路由需要单独大 limit body parser.
1829
+ // 关键: 必须挂在主 app.use(express.json()) 之前, 否则 4MB attachment 在主 100KB parser 阶段就被拒.
1830
+ // path-prefix 让它只对 /api/attachments/* 生效, 不污染其他端点的限制.
1831
+ app.use('/api/attachments', express.json({ limit: '15mb' }));
1832
+ app.use(express.json({ limit: '100kb' }));
1828
1833
  app.use((req, res, next) => {
1829
1834
  res.setHeader('Access-Control-Allow-Origin', '*');
1830
1835
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
@@ -1924,6 +1929,125 @@ export async function createWebServer(port = 3000, options = {}) {
1924
1929
  res.status(400).json({ ok: false, reason: e?.message ?? 'parse error' });
1925
1930
  }
1926
1931
  });
1932
+ // 2026-07-15 修 Bug 3: 拖拽文件上传
1933
+ // - POST /api/attachments/upload body: { filename, mimeType, content: base64 }
1934
+ // - 文件落到 ~/.bolloon/attachments/<YYYY-MM>/<uuid>__<safeName>
1935
+ // - 返回 { ok, attachmentId, url, size, mimeType, filename }
1936
+ // - 前端拿到 attachmentId 后, 在消息文本里插一个 [attachment:id] 标记
1937
+ // (这条消息发出去时 server 端 /message 把它解析成 contextHint + 行内下载链接)
1938
+ // 没用 multer/formidable — 复用 base64 JSON 简化, 跟 existing /api/judgments/import 同样的传输方式
1939
+ // body parser 在上面 path-prefix 中间件已挂, 这里直接 handler 即可.
1940
+ const ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024; // 10MB 单文件硬上限
1941
+ app.post('/api/attachments/upload', async (req, res) => {
1942
+ try {
1943
+ const body = req.body ?? {};
1944
+ const filename = String(body.filename || '').trim();
1945
+ const mimeType = String(body.mimeType || 'application/octet-stream');
1946
+ const content = String(body.content || '');
1947
+ if (!filename || !content) {
1948
+ return res.status(400).json({ ok: false, error: 'filename 与 content 必填' });
1949
+ }
1950
+ // base64 → bytes
1951
+ const buf = Buffer.from(content, 'base64');
1952
+ if (buf.length === 0) {
1953
+ return res.status(400).json({ ok: false, error: '文件为空' });
1954
+ }
1955
+ if (buf.length > ATTACHMENT_MAX_BYTES) {
1956
+ return res.status(413).json({
1957
+ ok: false,
1958
+ error: `文件超过 ${ATTACHMENT_MAX_BYTES / 1024 / 1024}MB 上限`,
1959
+ });
1960
+ }
1961
+ // safeName: 跟 safeChannelName 同样规则, 防路径穿越 + Windows 非法字符
1962
+ // 注意: 把 . 也加进替换 (不然 "../../" 被转成 "..__" 还残留 .. 序列), 整个文件 base 安全
1963
+ const safeName = filename
1964
+ .replace(/[\\/:*?"<>|\x00-\x1f.]/g, '_')
1965
+ .replace(/^_+|_+$/g, '')
1966
+ .slice(0, 120) || 'unnamed';
1967
+ if (safeName !== filename) {
1968
+ console.log(`[attachments] safeName 转换: "${filename}" → "${safeName}"`);
1969
+ }
1970
+ const month = new Date().toISOString().slice(0, 7); // YYYY-MM
1971
+ const attachmentsDir = path.join(process.env.HOME || '/tmp', '.bolloon', 'attachments', month);
1972
+ await fs.mkdir(attachmentsDir, { recursive: true });
1973
+ const attachmentId = `${Date.now().toString(36)}_${crypto.randomBytes(6).toString('hex')}`;
1974
+ const storedFilename = `${attachmentId}__${safeName}`;
1975
+ const fullPath = path.join(attachmentsDir, storedFilename);
1976
+ await fs.writeFile(fullPath, buf);
1977
+ const urlPath = `/api/attachments/${attachmentId}`;
1978
+ console.log(`[attachments] 上传 ${filename} (${buf.length}B, ${mimeType}) → ${fullPath}`);
1979
+ res.json({
1980
+ ok: true,
1981
+ attachmentId,
1982
+ url: urlPath,
1983
+ filename,
1984
+ storedFilename,
1985
+ size: buf.length,
1986
+ mimeType,
1987
+ });
1988
+ }
1989
+ catch (e) {
1990
+ console.error('[attachments] upload failed:', e?.message || e);
1991
+ res.status(500).json({ ok: false, error: e?.message || '上传失败' });
1992
+ }
1993
+ });
1994
+ // GET /api/attachments/:id — 下载 (按 attachmentId 找当月目录; 月份遍历回退)
1995
+ app.get('/api/attachments/:id', async (req, res) => {
1996
+ try {
1997
+ const id = String(req.params.id || '').replace(/[^a-zA-Z0-9_]/g, '');
1998
+ if (!id)
1999
+ return res.status(400).type('text/plain').send('invalid id');
2000
+ const attachmentsRoot = path.join(process.env.HOME || '/tmp', '.bolloon', 'attachments');
2001
+ // 先按当前月 → 前一月 → …→ 全部月份列表 (3 个月够历史用)
2002
+ const candidates = [];
2003
+ const now = new Date();
2004
+ for (let i = 0; i < 6; i++) {
2005
+ const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
2006
+ const month = d.toISOString().slice(0, 7);
2007
+ try {
2008
+ const files = await fs.readdir(path.join(attachmentsRoot, month));
2009
+ for (const f of files) {
2010
+ if (f.startsWith(id + '__'))
2011
+ candidates.push(path.join(attachmentsRoot, month, f));
2012
+ }
2013
+ }
2014
+ catch { /* month dir 可能不存在, 跳过 */ }
2015
+ if (candidates.length > 0)
2016
+ break;
2017
+ }
2018
+ if (candidates.length === 0) {
2019
+ return res.status(404).type('text/plain').send('attachment not found');
2020
+ }
2021
+ const fullPath = candidates[0];
2022
+ const stat = await fs.stat(fullPath).catch(() => null);
2023
+ if (!stat || !stat.isFile()) {
2024
+ return res.status(404).type('text/plain').send('attachment missing');
2025
+ }
2026
+ // 从文件名还原原 mimeType: 按扩展名猜
2027
+ const fileBase = path.basename(fullPath);
2028
+ const origName = fileBase.substring(id.length + 2); // 跳过 "<id>__"
2029
+ const ext = path.extname(origName).toLowerCase();
2030
+ const mimeMap = {
2031
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
2032
+ '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
2033
+ '.pdf': 'application/pdf',
2034
+ '.txt': 'text/plain', '.md': 'text/markdown', '.json': 'application/json',
2035
+ '.csv': 'text/csv', '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
2036
+ '.zip': 'application/zip',
2037
+ '.mp3': 'audio/mpeg', '.mp4': 'video/mp4', '.webm': 'video/webm',
2038
+ };
2039
+ const mime = mimeMap[ext] || 'application/octet-stream';
2040
+ const buf = await fs.readFile(fullPath);
2041
+ res.setHeader('Content-Length', String(buf.length));
2042
+ res.setHeader('Content-Type', mime);
2043
+ res.setHeader('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(origName)}`);
2044
+ res.send(buf);
2045
+ }
2046
+ catch (e) {
2047
+ console.error('[attachments] download failed:', e?.message || e);
2048
+ res.status(500).type('text/plain').send('download error: ' + (e?.message || ''));
2049
+ }
2050
+ });
1927
2051
  // 全局兜底: 任何 next(err) 走到这里, 给出结构化 4xx/5xx 而不是默认 HTML
1928
2052
  app.use((err, req, res, _next) => {
1929
2053
  console.error('[server] unhandled error on', req.method, req.path, '-', err?.message || err);
@@ -1955,10 +2079,29 @@ export async function createWebServer(port = 3000, options = {}) {
1955
2079
  });
1956
2080
  });
1957
2081
  app.post('/message', async (req, res) => {
1958
- const { text, channelId, channelDid } = req.body;
2082
+ const { text, channelId, channelDid, attachments } = req.body;
1959
2083
  if (!text) {
1960
2084
  return res.status(400).json({ error: 'No text provided' });
1961
2085
  }
2086
+ // 2026-07-15 修 Bug 3: 拖拽附件 — LLM 在 contextHint 里看到文件清单, 用户文本保持可读
2087
+ // 替代方案: 把 [attachment:id] 标记塞 text 里, 这里解析回 attachments 数组
2088
+ let parsedAttachments = [];
2089
+ if (Array.isArray(attachments) && attachments.length > 0) {
2090
+ parsedAttachments = attachments.filter((a) => a && typeof a.attachmentId === 'string' && a.attachmentId.length > 0);
2091
+ }
2092
+ else {
2093
+ // 兼容老前端: 从 text 里抽 [attachment:<id>] 标记
2094
+ const rx = /\[attachment:([a-zA-Z0-9_]+)\]/g;
2095
+ let m;
2096
+ while ((m = rx.exec(String(text))) !== null) {
2097
+ parsedAttachments.push({ attachmentId: m[1] });
2098
+ }
2099
+ }
2100
+ const attachmentContext = parsedAttachments.length > 0
2101
+ ? `[系统上下文] 用户上传了 ${parsedAttachments.length} 个附件: ` +
2102
+ parsedAttachments.map(a => `${a.filename || a.attachmentId} (id=${a.attachmentId}, mime=${a.mimeType || '?'}, size=${a.size ?? '?'}B, URL=/api/attachments/${a.attachmentId})`).join('; ') +
2103
+ `\n你需要时可以调用文件读工具 (curl /api/attachments/<id>) 拉取真实内容。\n\n`
2104
+ : '';
1962
2105
  if (!channelId) {
1963
2106
  return res.status(400).json({ error: 'No channelId provided' });
1964
2107
  }
@@ -1990,9 +2133,17 @@ export async function createWebServer(port = 3000, options = {}) {
1990
2133
  // per-channel queue 检查: 已在跑就入队, 等当前跑完自动接上
1991
2134
  const runState = getOrCreateRunState(channelId);
1992
2135
  if (runState.running) {
1993
- runState.queue.push({ channelId, text, boundWalletAddress, autoToolsEnabled });
2136
+ // 2026-07-15 Bug 8: 入队时保留 attachments + channelDid, 否则下一轮执行时会丢附件
2137
+ runState.queue.push({
2138
+ channelId,
2139
+ text,
2140
+ boundWalletAddress,
2141
+ autoToolsEnabled,
2142
+ attachments: parsedAttachments,
2143
+ channelDid,
2144
+ });
1994
2145
  broadcastQueueUpdate(channelId);
1995
- console.log(`[queue] /message 入队 channel=${channelId}, queue len=${runState.queue.length}`);
2146
+ console.log(`[queue] /message 入队 channel=${channelId}, queue len=${runState.queue.length}, attach=${parsedAttachments.length}`);
1996
2147
  return;
1997
2148
  }
1998
2149
  runState.running = true;
@@ -2267,7 +2418,8 @@ export async function createWebServer(port = 3000, options = {}) {
2267
2418
  extraHint = hint + '\n\n';
2268
2419
  nextPromptHints.delete(channelId);
2269
2420
  }
2270
- const markedPrompt = `${extraHint}【本轮用户请求】\n${text}\n【请求结束】\n\n${contextHint}`;
2421
+ // 2026-07-15 Bug 3: 拖拽附件 — attachmentContext 提到 contextHint 最前, LLM 第一眼看到文件清单
2422
+ const markedPrompt = `${extraHint}【本轮用户请求】\n${text}\n【请求结束】\n\n${attachmentContext}${contextHint}`;
2271
2423
  fullResponse = await agent.promptStream(markedPrompt, streamCallback, runState.abortController?.signal, channelId);
2272
2424
  }
2273
2425
  catch (err) {
@@ -2310,7 +2462,15 @@ export async function createWebServer(port = 3000, options = {}) {
2310
2462
  const session = existingSession || { channelId, sessionId: currentSessionId, messages: [], lastUpdated: new Date().toISOString() };
2311
2463
  session.sessionId = currentSessionId;
2312
2464
  // v3: 加 source 标记 (local = 内部 owner, remote = 远端访客)
2313
- session.messages.push({ id: crypto.randomUUID(), type: 'user', content: text, timestamp: new Date().toISOString(), source: 'local' });
2465
+ // 2026-07-15 Bug 4: client.ts sendMessage 已经通过 persistLastMessageToServer PATCH /sessions/.../...
2466
+ // 把 user msg 落盘一次 (立即落, 切走再切回不丢). 这里再 push 一次 → session.messages 出现两条相同的 user msg,
2467
+ // loadSession 重渲染时两条都上屏, 表现"每条 user 气泡重复两次".
2468
+ // 修法: 持久化以 client PATCH 为准, /message 这边只 push ai 消息. 同时去重检查上次的 user 避免极端竞态 (并行 PATCH).
2469
+ const lastMsg = session.messages[session.messages.length - 1];
2470
+ const userAlreadyPushed = lastMsg && lastMsg.type === 'user' && lastMsg.content === text;
2471
+ if (!userAlreadyPushed) {
2472
+ session.messages.push({ id: crypto.randomUUID(), type: 'user', content: text, timestamp: new Date().toISOString(), source: 'local' });
2473
+ }
2314
2474
  session.messages.push({
2315
2475
  id: crypto.randomUUID(),
2316
2476
  type: 'ai',
@@ -2499,12 +2659,26 @@ export async function createWebServer(port = 3000, options = {}) {
2499
2659
  // 能直接拿到引用.
2500
2660
  clearTimeout(forceTimeout);
2501
2661
  // queue dequeue: 跑完或失败都要清状态
2502
- // 当前实现: 自动接下一条需要把 ~200 行 try 块抽函数, 暂不抽.
2503
- // 替代: 用户点 [队列 +N] 按钮时, 客户端发起一个特殊的 HTTP 请求触发下一条
2504
- // (在 client.js 实现). 这里只清状态 + 广播.
2505
2662
  runState.running = false;
2506
2663
  runState.abortController = null;
2507
2664
  broadcastQueueUpdate(channelId);
2665
+ // 2026-07-15 修 Bug 8: 队列自动 drain — 之前只清状态不抽下一条, 用户连续发的所有
2666
+ // 第二轮起全卡在 queue 里出不来, 表现"第二轮没反应".
2667
+ // 修复: finally 里查 queue, 非空就异步 fire-and-forget 起下一轮.
2668
+ // 实现要点:
2669
+ // 1. 用 setImmediate / Promise.resolve() 让 res.headersSent 干净
2670
+ // 2. 不能 await (否则阻塞 finally 后面的 saveSession; 而且这就是 fire-and-forget)
2671
+ // 3. 重新 build contextHint, 走相同 LLM 路径
2672
+ if (runState.queue.length > 0) {
2673
+ const next = runState.queue.shift();
2674
+ console.log(`[queue-drain] channel=${next.channelId} text="${next.text.slice(0, 30)}" attach=${(next.attachments?.length ?? 0)}`);
2675
+ // 异步跑下一条 (fire-and-forget)
2676
+ setImmediate(() => {
2677
+ void runMessageFromQueue(next).catch((e) => {
2678
+ console.error('[queue-drain] error:', e?.message?.slice(0, 200));
2679
+ });
2680
+ });
2681
+ }
2508
2682
  // 2026-07-01 (v0.2.5): 持久化当前 messageHistory — 让 web 用户跨刷新保留对话.
2509
2683
  // saveCurrentSession 失败静默, 不阻塞 channel 状态清理.
2510
2684
  // saveCurrentSession 内部走 SessionStore (默认 ~/.bolloon/sessions/cache/<sessionKey>.json).
@@ -2514,7 +2688,7 @@ export async function createWebServer(port = 3000, options = {}) {
2514
2688
  await agent.saveCurrentSession(sessionKey);
2515
2689
  }
2516
2690
  catch (saveErr) {
2517
- console.warn(`[web] saveCurrentSession failed (non-fatal): ${saveErr?.message?.slice(0, 100)}`);
2691
+ console.warn(`[web] saveCurrentSession failed (non-fatal): ${saveErr.message?.slice(0, 100)}`);
2518
2692
  }
2519
2693
  }
2520
2694
  }
@@ -2535,6 +2709,115 @@ export async function createWebServer(port = 3000, options = {}) {
2535
2709
  }
2536
2710
  return s;
2537
2711
  }
2712
+ /** 抽离 attachment contextHint 出来 — 给 queue-drain 用; /message 主路径有 inline 等价版避免重复 hoist 错误 */
2713
+ function buildAttachmentContextForQueue(parsedAttachments) {
2714
+ if (!parsedAttachments || parsedAttachments.length === 0)
2715
+ return '';
2716
+ return `[系统上下文] 用户上传了 ${parsedAttachments.length} 个附件: ` +
2717
+ parsedAttachments.map((a) => `${a.filename || a.attachmentId} (id=${a.attachmentId}, mime=${a.mimeType || '?'}, size=${a.size ?? '?'}B, URL=/api/attachments/${a.attachmentId})`).join('; ') +
2718
+ `\n你可以调用文件读工具 (curl /api/attachments/<id>) 拉取真实内容。\n\n`;
2719
+ }
2720
+ /**
2721
+ * 2026-07-15 修 Bug 8: 队列消息的执行器 — finally 排空 queue 时调这里
2722
+ * 跑下一条 queued 消息.
2723
+ *
2724
+ * 设计: 这是个 fire-and-forget wrapper, 复用 /message 主路径的 broadcast / save / etc.
2725
+ * 简化路径 (跟主路径 500 行 try 块相比):
2726
+ * - 不重新建载 judgment hint / persona / context — server.ts 这次明确把这些容
2727
+ * 易"廉价放"在 /message 主路径, queue 路径只用基本标识
2728
+ * - 仍然是合法: agent.promptStream → broadcast(type:user) → broadcast(type:ai) → done
2729
+ * - 处理 attachments: 跟主路径一样
2730
+ *
2731
+ * 如果要 1:1 复刻主路径的所有 hooks (judgment hint / persona / manifest / etc),
2732
+ * 后续可以把 /message 主路径的 try 块抽成 runPromptChannel 共享.
2733
+ */
2734
+ async function runMessageFromQueue(queued) {
2735
+ const { channelId, text, attachments, channelDid: reqChannelDid, boundWalletAddress, autoToolsEnabled } = queued;
2736
+ const runState = getOrCreateRunState(channelId);
2737
+ if (runState.running)
2738
+ return; // 防重入 — queue 已经并发去重
2739
+ runState.running = true;
2740
+ runState.abortController = new AbortController();
2741
+ const currentSessionId = (await loadChannels()).find(c => c.id === channelId)?.currentSessionId || 'default';
2742
+ const realChannelDid = reqChannelDid || (await loadChannels()).find(c => c.id === channelId)?.did || '';
2743
+ const parsedAttachments = Array.isArray(attachments) ? attachments : [];
2744
+ const attachmentContext = buildAttachmentContextForQueue(parsedAttachments);
2745
+ const sessionKey = `${channelId}:${currentSessionId}`;
2746
+ // 防 LLM hang 安全网
2747
+ const PIVOT_FORCE_TIMEOUT_MS = 5 * 60 * 1000;
2748
+ const forceTimeout = setTimeout(() => {
2749
+ console.warn(`[server] queue-drain pivot 强制 timeout, aborting`);
2750
+ runState.abortController?.abort();
2751
+ }, PIVOT_FORCE_TIMEOUT_MS);
2752
+ let agent = null;
2753
+ try {
2754
+ // 1) broadcast user 给前端 (跟主路径一致)
2755
+ broadcast({ type: 'user', content: text }, channelId);
2756
+ // 2) 取 agent + session
2757
+ agent = await getAgentForChannel(channelId, currentSessionId).catch(() => null);
2758
+ if (!agent) {
2759
+ throw new Error(`No agent for channel=${channelId}`);
2760
+ }
2761
+ // 3) 重 build contextHint (基本版, 不全 500 行 hook)
2762
+ // 原因: queue-drain 是常见调试路径, 全量 hooks 性能大. 关键是 attachments 带上.
2763
+ const contextHint = attachmentContext + `[系统上下文] 队列消息 (auto-drain)\n`;
2764
+ // 4) promptStream
2765
+ const markedPrompt = `【本轮用户请求】\n${text}\n【请求结束】\n\n${contextHint}`;
2766
+ const fullResponse = await agent.promptStream(markedPrompt, () => { }, runState.abortController?.signal, channelId);
2767
+ if (!fullResponse.trim()) {
2768
+ broadcast({ type: 'error', content: '⚠️ AI 未返回内容' }, channelId);
2769
+ }
2770
+ else {
2771
+ broadcast({ type: 'ai', content: fullResponse }, channelId);
2772
+ // 落 session
2773
+ try {
2774
+ const existing = await loadSession(channelId, currentSessionId);
2775
+ const session = existing || { channelId, sessionId: currentSessionId, messages: [], lastUpdated: new Date().toISOString() };
2776
+ session.sessionId = currentSessionId;
2777
+ // 跟主路径相同: 不重复 push user (主路径已 broadcast/push), 只 push ai
2778
+ session.messages.push({
2779
+ id: crypto.randomUUID(),
2780
+ type: 'ai',
2781
+ content: fullResponse,
2782
+ timestamp: new Date().toISOString(),
2783
+ source: 'local',
2784
+ });
2785
+ session.lastUpdated = new Date().toISOString();
2786
+ await saveSession(session);
2787
+ }
2788
+ catch (e) {
2789
+ console.warn('[queue-drain] saveSession failed:', e?.message?.slice(0, 100));
2790
+ }
2791
+ }
2792
+ broadcast({ type: 'done' }, channelId);
2793
+ }
2794
+ catch (err) {
2795
+ console.warn('[queue-drain] failed:', err?.message?.slice(0, 200));
2796
+ broadcast({ type: 'error', content: 'queue-drain: ' + (err?.message || 'failed') }, channelId);
2797
+ }
2798
+ finally {
2799
+ clearTimeout(forceTimeout);
2800
+ runState.running = false;
2801
+ runState.abortController = null;
2802
+ broadcastQueueUpdate(channelId);
2803
+ // 递归 drain — 同 /message 主路径 finally 行为保持一致
2804
+ if (runState.queue.length > 0) {
2805
+ const next = runState.queue.shift();
2806
+ console.log(`[queue-drain-recursive] channel=${next.channelId} text="${next.text.slice(0, 30)}"`);
2807
+ setImmediate(() => {
2808
+ void runMessageFromQueue(next).catch((e) => {
2809
+ console.error('[queue-drain-recursive] error:', e?.message?.slice(0, 200));
2810
+ });
2811
+ });
2812
+ }
2813
+ if (agent) {
2814
+ try {
2815
+ await agent.saveCurrentSession(sessionKey);
2816
+ }
2817
+ catch { }
2818
+ }
2819
+ }
2820
+ }
2538
2821
  function broadcastQueueUpdate(channelId) {
2539
2822
  const s = channelRunState.get(channelId);
2540
2823
  const queueLength = s ? s.queue.length : 0;
@@ -2855,6 +3138,41 @@ export async function createWebServer(port = 3000, options = {}) {
2855
3138
  channels.push(channel);
2856
3139
  await saveChannels(channels);
2857
3140
  await saveSession({ channelId: id, sessionId: 'default', messages: [], lastUpdated: new Date().toISOString() });
3141
+ // 2026-07-15 修 Bug 6: 同步把 agent 定义写进 ~/.bolloon/agents/agents.json
3142
+ // 之前 channel 只落 channels.json, 不会出现在 agents.json 里.
3143
+ // 重新启动 server 时 loadLocalSubAgents 只读 agents.json — 用户以为"智能体没保存".
3144
+ // 修法: 直接读 + append (idempotent) 写 agents.json, 用 channel.agentId 作为主键 (跟 channels.json 引用对齐).
3145
+ // 不走 SubAgentManager.registerAgent 因为它会自己生成新 id, 跟 channel.agentId 对不上.
3146
+ try {
3147
+ const agentsPath = path.join(process.env.HOME || '/tmp', '.bolloon', 'agents', 'agents.json');
3148
+ await fs.mkdir(path.dirname(agentsPath), { recursive: true });
3149
+ let arr = [];
3150
+ try {
3151
+ arr = JSON.parse(await fs.readFile(agentsPath, 'utf-8'));
3152
+ }
3153
+ catch { }
3154
+ if (!Array.isArray(arr))
3155
+ arr = [];
3156
+ const exists = arr.some(a => a && a.id === agentId);
3157
+ if (!exists) {
3158
+ arr.push({
3159
+ id: agentId,
3160
+ name,
3161
+ did: `did:local:${id}`,
3162
+ description: `Agent ${name} (auto-registered from channel ${id})`,
3163
+ capabilities: Array.isArray(channelPersona?.capabilities) ? channelPersona.capabilities : [],
3164
+ status: 'active',
3165
+ createdAt: new Date().toISOString(),
3166
+ lastActive: new Date().toISOString(),
3167
+ channelId: id,
3168
+ });
3169
+ await fs.writeFile(agentsPath, JSON.stringify(arr, null, 2), 'utf-8');
3170
+ console.log(`[创建频道] agent 写进 agents.json: name=${name} id=${agentId}`);
3171
+ }
3172
+ }
3173
+ catch (e) {
3174
+ console.warn('[创建频道] 写 agents.json 失败 (非致命):', e?.message?.slice(0, 120));
3175
+ }
2858
3176
  res.json(channel);
2859
3177
  // 后台生成 DID — 用统一的修复队列, 避免每个 POST 都启动独立 setTimeout
2860
3178
  console.log(`[创建频道] 加入 DID 修复队列...`);
@@ -2993,6 +3311,32 @@ export async function createWebServer(port = 3000, options = {}) {
2993
3311
  }
2994
3312
  catch { }
2995
3313
  }
3314
+ // 2026-07-15 修 Bug 7: 同步清理 agents.json 里挂在这个 channel 下的 agent 定义
3315
+ // 之前 v0.3.6 (Bug 6) 创建频道时同步往 agents.json append, 删频道却没删回来
3316
+ // → agents.json 里残留孤儿 agent, 重启后 loadLocalSubAgents 还能读到这些 — 看起来像"删不掉"
3317
+ // 修法: 用 channel.agentId 找, 同步从 agents.json 删一条
3318
+ try {
3319
+ const agentsPath = path.join(process.env.HOME || '/tmp', '.bolloon', 'agents', 'agents.json');
3320
+ const raw = await fs.readFile(agentsPath, 'utf-8').catch(() => '');
3321
+ if (raw) {
3322
+ let arr = [];
3323
+ try {
3324
+ arr = JSON.parse(raw);
3325
+ }
3326
+ catch { }
3327
+ if (!Array.isArray(arr))
3328
+ arr = [];
3329
+ const before = arr.length;
3330
+ arr = arr.filter(a => !(a && (a.id === channel.agentId || a.channelId === channelId)));
3331
+ if (arr.length !== before) {
3332
+ await fs.writeFile(agentsPath, JSON.stringify(arr, null, 2), 'utf-8');
3333
+ console.log(`[删除频道] agents.json 清掉 ${before - arr.length} 条 orphan agent (channel=${channelId})`);
3334
+ }
3335
+ }
3336
+ }
3337
+ catch (e) {
3338
+ console.warn('[删除频道] 清理 agents.json 失败 (非致命):', e?.message?.slice(0, 120));
3339
+ }
2996
3340
  res.json({ ok: true });
2997
3341
  }
2998
3342
  catch (err) {
@@ -4665,6 +5009,8 @@ export async function createWebServer(port = 3000, options = {}) {
4665
5009
  });
4666
5010
  // 2026-07-06: judgments / self-improve / permission-mode 路由抽到 ./routes-judgments.ts
4667
5011
  registerJudgmentsRoutes(app);
5012
+ // 2026-07-15: judgeness · hearth 主路由 + peer 4 类写 API
5013
+ registerHearthRoutes(app);
4668
5014
  // ==================== Self-Improve 端点 ====================
4669
5015
  // 查看当前策略 (白名单 / 黑名单)
4670
5016
  app.get('/api/self-improve/policy', async (_req, res) => {
@@ -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
  }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * judgeness / dual-mode.ts
3
+ *
4
+ * Accept 协商 + JSON-LD 生成.
5
+ * - 默认 `application/ld+json` 给 agent (头等公民)
6
+ * - `text/html` 或 `?view=human` 给人类
7
+ *
8
+ * 复用 bolloon 现有 src/web/util/safe-name.ts (兜底防 undefined/null/NaN 渲染).
9
+ */
10
+ const HEARTH_LD_CONTEXT = 'https://judgeness.bolloon.com/schema/v1';
11
+ /** 最简 UA 检测: 已知 agent 名 / 字符串里含 'bot'/'agent' 视为 agent.
12
+ * 防御期 placeholder — 反攻期可接 allowlist UA detection. */
13
+ function isLikelyAgentUA(ua) {
14
+ if (!ua)
15
+ return false;
16
+ const s = ua.toLowerCase();
17
+ return /bot|agent|crawler|spider/.test(s);
18
+ }
19
+ export function negotiateAudience(input) {
20
+ // ?view=human 显式
21
+ const v = input.query?.['view'];
22
+ if (typeof v === 'string' && v.toLowerCase() === 'human')
23
+ return 'human';
24
+ const accept = (input.accept ?? '').toLowerCase();
25
+ // text/html 优先
26
+ if (accept.includes('text/html') && !accept.includes('application/ld+json'))
27
+ return 'human';
28
+ // UA 启发式
29
+ if (isLikelyAgentUA(input.userAgent) && !accept.includes('text/html'))
30
+ return 'agent';
31
+ // 缺省: agent (头等公民)
32
+ return 'agent';
33
+ }
34
+ export function descriptionToJsonLd(d) {
35
+ return {
36
+ '@context': HEARTH_LD_CONTEXT,
37
+ '@type': 'JudgenessDescription',
38
+ '@id': `urn:judgeness:description:${d.descriptionId}`,
39
+ name: `Description ${d.descriptionId}`,
40
+ description: `Judgment ref: ${d.judgmentRef}`,
41
+ facets: d.facets,
42
+ scope: d.scope,
43
+ visibility: d.visibility,
44
+ openState: d.openState,
45
+ by: d.by,
46
+ createdAt: d.createdAt,
47
+ };
48
+ }
49
+ // ---------------------------------------------------------------------------
50
+ // HTML 渲染 (人类视图, 极简, 防 XSS escape)
51
+ // ---------------------------------------------------------------------------
52
+ function escapeHtml(s) {
53
+ return s
54
+ .replace(/&/g, '&amp;')
55
+ .replace(/</g, '&lt;')
56
+ .replace(/>/g, '&gt;')
57
+ .replace(/"/g, '&quot;')
58
+ .replace(/'/g, '&#39;');
59
+ }
60
+ export function renderHumanHtml(title, body) {
61
+ return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>${escapeHtml(title)}</title></head><body><h1>${escapeHtml(title)}</h1>${body}</body></html>`;
62
+ }
63
+ export function descriptionToHumanHtml(d) {
64
+ const facetsLines = d.facets
65
+ ? Object.entries(d.facets)
66
+ .filter(([, v]) => v !== undefined)
67
+ .map(([k, v]) => `<li>${escapeHtml(k)}: ${escapeHtml(String(v))}</li>`)
68
+ .join('')
69
+ : '<li>(no facets visible at your access level)</li>';
70
+ const body = `<section><h2>Description ${escapeHtml(d.descriptionId)}</h2><p>Judgment ref: <code>${escapeHtml(d.judgmentRef)}</code></p><ul>${facetsLines}</ul><p>visibility: <strong>${escapeHtml(d.visibility)}</strong> · openState: <strong>${escapeHtml(d.openState)}</strong></p></section>`;
71
+ return renderHumanHtml(`Hearth · ${d.descriptionId}`, body);
72
+ }
73
+ export function dualRender(input, humanHtml, jsonLdProducer) {
74
+ const aud = negotiateAudience(input);
75
+ if (aud === 'human') {
76
+ return { status: 200, contentType: 'text/html; charset=utf-8', body: humanHtml() };
77
+ }
78
+ const obj = jsonLdProducer();
79
+ return {
80
+ status: 200,
81
+ contentType: 'application/ld+json; charset=utf-8',
82
+ body: JSON.stringify(obj),
83
+ };
84
+ }
85
+ export function dualRenderList(input, humanHtmlProducer, jsonLdProducer) {
86
+ return dualRender(input, humanHtmlProducer, () => jsonLdProducer());
87
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bolloon/bolloon-agent",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "type": "module",
5
5
  "description": "P2P AI Document Agent - 全局安装后执行 `bolloon` 启动产品",
6
6
  "main": "dist/cli-entry.js",
@@ -48,7 +48,7 @@
48
48
  "src/constraint-runtime"
49
49
  ],
50
50
  "dependencies": {
51
- "@bolloon/bolloon-agent": "^0.3.1",
51
+ "@bolloon/bolloon-agent": "^0.3.3",
52
52
  "@bolloon/constraint-runtime": "0.1.0",
53
53
  "@capacitor/core": "^8.4.1",
54
54
  "@capacitor/ios": "^8.4.1",