@bolloon/bolloon-agent 0.1.28 → 0.1.29

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.
@@ -118,6 +118,8 @@ function startV3GlobalSSE() {
118
118
  if (msg.type === 'remote-chat-reply') {
119
119
  // 找到当前打开的远端 chat modal 的 log
120
120
  const log = document.getElementById('rcm-log');
121
+ const thinkingEl = document.getElementById('rcm-thinking');
122
+ if (thinkingEl) thinkingEl.style.display = 'none'; // 思考结束, 隐藏
121
123
  if (log) {
122
124
  const bubble = document.createElement('div');
123
125
  bubble.style.cssText = 'padding:8px 10px;margin:4px 0;border-radius:6px;font-size:13px;line-height:1.4;max-width:80%;word-wrap:break-word;background:#e5e7eb;color:#111;';
@@ -129,6 +131,44 @@ function startV3GlobalSSE() {
129
131
  log.appendChild(bubble);
130
132
  log.scrollTop = log.scrollHeight;
131
133
  }
134
+ } else if (msg.type === 'remote-chat-thinking') {
135
+ // v3 新增: B 端实时显示 A 节点的思考过程
136
+ const phase = msg.phase;
137
+ const log = document.getElementById('rcm-log');
138
+ if (!log) return;
139
+
140
+ if (phase === 'start') {
141
+ // 头部插入"判断力依据"区块 (只第一次)
142
+ const judgments = msg.usedJudgments || { bound: [], candidates: [] };
143
+ const judgmentBlock = document.createElement('div');
144
+ judgmentBlock.className = 'rcm-judgment-block';
145
+ judgmentBlock.style.cssText = 'margin:6px 0;padding:8px 10px;background:#fef3c7;border-left:3px solid #f59e0b;border-radius:4px;font-size:12px;';
146
+ let jh = '<div style="font-weight:600;color:#92400e;margin-bottom:4px;">🛡️ 对方使用的判断力 (来自 ta 的 channel)</div>';
147
+ if (judgments.bound && judgments.bound.length > 0) {
148
+ jh += '<div style="color:#78350f;margin-bottom:4px;"><b>硬绑定</b> (必须遵循):</div>';
149
+ for (const j of judgments.bound) {
150
+ jh += `<div style="margin:2px 0;padding-left:8px;">• <b>${escapeHtml((j.decision || '').slice(0, 80))}</b>${j.reasons && j.reasons.length ? '<br><span style="color:#92400e;font-size:11px;">理由: ' + escapeHtml(j.reasons.join('; ').slice(0, 80)) + '</span>' : ''}</div>`;
151
+ }
152
+ }
153
+ if (judgments.candidates && judgments.candidates.length > 0) {
154
+ jh += `<div style="color:#78350f;margin-top:4px;"><b>候选池</b> (${judgments.candidates.length} 条, LLM 自选)</div>`;
155
+ }
156
+ log.appendChild(judgmentBlock);
157
+ // "思考中" 区块
158
+ const thinkingEl = document.createElement('div');
159
+ thinkingEl.id = 'rcm-thinking-live';
160
+ thinkingEl.style.cssText = 'margin:6px 0;padding:8px 10px;background:#ede9fe;border-left:3px solid #8b5cf6;border-radius:4px;font-size:12px;color:#5b21b6;font-style:italic;';
161
+ thinkingEl.textContent = '💭 对方正在思考...';
162
+ log.appendChild(thinkingEl);
163
+ log.scrollTop = log.scrollHeight;
164
+ } else if (phase === 'token') {
165
+ // 实时更新思考中的 partial
166
+ const thinkingEl = document.getElementById('rcm-thinking-live');
167
+ if (thinkingEl) {
168
+ thinkingEl.textContent = '💭 对方正在思考: ' + (msg.partial || '').slice(-200);
169
+ log.scrollTop = log.scrollHeight;
170
+ }
171
+ }
132
172
  }
133
173
  } catch (err) {
134
174
  console.error('[v3] 全局 SSE 解析失败:', err);
@@ -2683,15 +2723,19 @@ function openRemoteChannelChat(peerPublicKey, channelId, channelName) {
2683
2723
  document.getElementById('remote-chat-modal')?.remove();
2684
2724
  const html = `
2685
2725
  <div id="remote-chat-modal" style="position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10002;display:flex;align-items:center;justify-content:center;">
2686
- <div style="background:#fff;border-radius:8px;width:520px;max-width:92vw;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 10px 40px rgba(0,0,0,0.2);">
2726
+ <div style="background:#fff;border-radius:8px;width:560px;max-width:92vw;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 10px 40px rgba(0,0,0,0.2);">
2687
2727
  <div style="padding:12px 16px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;justify-content:space-between;">
2688
- <div>
2728
+ <div style="flex:1;min-width:0;">
2689
2729
  <div style="font-size:15px;font-weight:600;">跟 ${escapeHtml(channelName)} 聊天</div>
2690
- <div style="font-size:11px;color:#6b7280;margin-top:2px;">远端 peer: ${escapeHtml(peerPublicKey.substring(0,16))}… · channel: ${escapeHtml(channelId)}</div>
2730
+ <div style="font-size:11px;color:#6b7280;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">远端 peer: ${escapeHtml(peerPublicKey.substring(0,16))}… · ${escapeHtml(channelId)}</div>
2691
2731
  </div>
2732
+ <button id="rcm-refresh-history" title="重新拉历史" style="background:none;border:1px solid var(--border);color:#6b7280;cursor:pointer;padding:2px 8px;border-radius:4px;font-size:11px;margin-right:6px;">↻ 历史</button>
2692
2733
  <button id="rcm-close" style="background:none;border:none;font-size:20px;color:#6b7280;cursor:pointer;">×</button>
2693
2734
  </div>
2694
- <div id="rcm-log" style="flex:1;overflow-y:auto;padding:12px 16px;min-height:240px;background:#f9fafb;"></div>
2735
+ <div id="rcm-thinking" style="display:none;padding:8px 16px;background:#fef3c7;color:#92400e;font-size:12px;border-bottom:1px solid #e5e7eb;">
2736
+ 📥 正在从远端拉历史 + 判断力…
2737
+ </div>
2738
+ <div id="rcm-log" style="flex:1;overflow-y:auto;padding:12px 16px;min-height:240px;max-height:60vh;background:#f9fafb;"></div>
2695
2739
  <div style="padding:10px 12px;border-top:1px solid #e5e7eb;display:flex;gap:6px;">
2696
2740
  <input id="rcm-input" type="text" placeholder="输入消息, 发送到远端 channel..."
2697
2741
  style="flex:1;padding:8px 10px;border:1px solid #d1d5db;border-radius:4px;font-size:13px;">
@@ -2705,10 +2749,13 @@ function openRemoteChannelChat(peerPublicKey, channelId, channelName) {
2705
2749
  const log = document.getElementById('rcm-log');
2706
2750
  const inputEl = document.getElementById('rcm-input');
2707
2751
  const sendBtn = document.getElementById('rcm-send');
2752
+ const thinkingEl = document.getElementById('rcm-thinking');
2708
2753
  document.getElementById('rcm-close').onclick = () => document.getElementById('remote-chat-modal').remove();
2754
+ document.getElementById('rcm-refresh-history').onclick = () => loadHistory();
2709
2755
 
2710
- const append = (text, isUser) => {
2756
+ const append = (text, role) => {
2711
2757
  const bubble = document.createElement('div');
2758
+ const isUser = role === 'user';
2712
2759
  bubble.style.cssText = `padding:8px 10px;margin:4px 0;border-radius:6px;font-size:13px;line-height:1.4;max-width:80%;word-wrap:break-word;${
2713
2760
  isUser ? 'background:#2563eb;color:#fff;margin-left:auto;text-align:left;'
2714
2761
  : 'background:#e5e7eb;color:#111;'
@@ -2718,10 +2765,75 @@ function openRemoteChannelChat(peerPublicKey, channelId, channelName) {
2718
2765
  log.scrollTop = log.scrollHeight;
2719
2766
  };
2720
2767
 
2768
+ const appendSystem = (text, kind = 'info') => {
2769
+ const el = document.createElement('div');
2770
+ const colors = {
2771
+ info: { bg: '#dbeafe', fg: '#1e40af' },
2772
+ warn: { bg: '#fef3c7', fg: '#92400e' },
2773
+ error: { bg: '#fca5a5', fg: '#7f1d1d' }
2774
+ };
2775
+ const c = colors[kind] || colors.info;
2776
+ el.style.cssText = `margin:6px 0;padding:6px 10px;background:${c.bg};color:${c.fg};border-radius:4px;font-size:11px;text-align:center;`;
2777
+ el.textContent = text;
2778
+ log.appendChild(el);
2779
+ log.scrollTop = log.scrollHeight;
2780
+ };
2781
+
2782
+ // v3 新增: 拉 A 端的 channel 历史 (含 messages + judgments)
2783
+ async function loadHistory() {
2784
+ thinkingEl.style.display = 'block';
2785
+ log.innerHTML = '';
2786
+ appendSystem('正在拉取远端 channel 的历史和判断力...', 'info');
2787
+ try {
2788
+ const res = await fetch(`/api/remote-channels/chat-history?targetPublicKey=${encodeURIComponent(peerPublicKey)}&channelId=${encodeURIComponent(channelId)}`);
2789
+ const data = await res.json();
2790
+ if (!res.ok) {
2791
+ appendSystem(`拉取失败: ${data.error || 'unknown'}`, 'error');
2792
+ thinkingEl.style.display = 'none';
2793
+ return;
2794
+ }
2795
+ // 清掉 loading
2796
+ log.innerHTML = '';
2797
+
2798
+ // 1. 显示 judgment 依据 (header)
2799
+ const judgments = data.judgments || { bound: [], candidates: [] };
2800
+ if (judgments.bound && judgments.bound.length > 0) {
2801
+ const jh = document.createElement('div');
2802
+ jh.style.cssText = 'margin:0 0 8px;padding:8px 10px;background:#fef3c7;border-left:3px solid #f59e0b;border-radius:4px;font-size:12px;';
2803
+ let h = `<div style="font-weight:600;color:#92400e;margin-bottom:4px;">🛡️ 对方 channel 绑定的判断力 (${judgments.bound.length} 条硬约束)</div>`;
2804
+ for (const j of judgments.bound) {
2805
+ h += `<div style="margin:3px 0;padding-left:8px;">• <b>${escapeHtml((j.decision || '').slice(0, 100))}</b>${j.domain ? `<span style="color:#a16207;font-size:10px;"> [${escapeHtml(j.domain)}${j.stakes ? '/' + escapeHtml(j.stakes) : ''}]</span>` : ''}${j.reasons && j.reasons.length ? '<br><span style="color:#92400e;font-size:11px;">理由: ' + escapeHtml(j.reasons.join('; ').slice(0, 100)) + '</span>' : ''}</div>`;
2806
+ }
2807
+ if (judgments.candidates && judgments.candidates.length > 0) {
2808
+ h += `<div style="margin-top:6px;color:#92400e;font-size:11px;">+ ${judgments.candidates.length} 条候选判断力 (LLM 可自选参考)</div>`;
2809
+ }
2810
+ jh.innerHTML = h;
2811
+ log.appendChild(jh);
2812
+ }
2813
+
2814
+ // 2. 显示历史 messages
2815
+ const msgs = data.messages || [];
2816
+ if (msgs.length === 0) {
2817
+ appendSystem('还没有历史消息, 在下面发第一条吧', 'info');
2818
+ } else {
2819
+ appendSystem(`从远端拉到 ${msgs.length} 条历史消息`, 'info');
2820
+ for (const m of msgs) {
2821
+ append(m.content || '', m.type === 'user' ? 'user' : 'ai');
2822
+ }
2823
+ // 滚到底部
2824
+ setTimeout(() => { log.scrollTop = log.scrollHeight; }, 50);
2825
+ }
2826
+ } catch (err) {
2827
+ appendSystem(`拉取异常: ${err.message}`, 'error');
2828
+ } finally {
2829
+ thinkingEl.style.display = 'none';
2830
+ }
2831
+ }
2832
+
2721
2833
  const doSend = async () => {
2722
2834
  const text = inputEl.value.trim();
2723
2835
  if (!text) return;
2724
- append(text, true);
2836
+ append(text, 'user');
2725
2837
  inputEl.value = '';
2726
2838
  sendBtn.disabled = true;
2727
2839
  sendBtn.textContent = '...';
@@ -2733,8 +2845,9 @@ function openRemoteChannelChat(peerPublicKey, channelId, channelName) {
2733
2845
  });
2734
2846
  const data = await res.json();
2735
2847
  if (!res.ok) throw new Error(data.error || 'send failed');
2848
+ appendSystem('已发送, 等待对方回复...', 'info');
2736
2849
  } catch (err) {
2737
- append('(发送失败: ' + (err.message || err) + ')', false);
2850
+ append('(发送失败: ' + (err.message || err) + ')', 'ai');
2738
2851
  } finally {
2739
2852
  sendBtn.disabled = false;
2740
2853
  sendBtn.textContent = '发送';
@@ -2744,6 +2857,9 @@ function openRemoteChannelChat(peerPublicKey, channelId, channelName) {
2744
2857
  inputEl.onkeydown = (e) => { if (e.key === 'Enter') doSend(); };
2745
2858
  inputEl.focus();
2746
2859
  startV3GlobalSSE();
2860
+
2861
+ // 打开时立即拉历史
2862
+ loadHistory();
2747
2863
  }
2748
2864
 
2749
2865
  // Phase 3: 我的 ID 按钮 → 弹窗显示并支持复制自己的 P2PDirect publicKey
@@ -2817,6 +2933,31 @@ if (addPeerBtn) {
2817
2933
  });
2818
2934
  }
2819
2935
 
2936
+ // v3 双向刷新: 主动向所有好友发 agent.meta.list, 拿到 ta 们分享给我的 channel
2937
+ const refreshSharedBtn = document.getElementById('refresh-shared-btn');
2938
+ if (refreshSharedBtn) {
2939
+ refreshSharedBtn.addEventListener('click', async (e) => {
2940
+ e.stopPropagation();
2941
+ const originalText = refreshSharedBtn.textContent;
2942
+ refreshSharedBtn.disabled = true;
2943
+ refreshSharedBtn.textContent = '...';
2944
+ try {
2945
+ const res = await fetch('/api/remote-channels/refresh', { method: 'POST' });
2946
+ const data = await res.json();
2947
+ if (!res.ok) throw new Error(data.error || 'refresh failed');
2948
+ // 等 1.5s 让 RPC 回复回来 (向所有 peer 广播)
2949
+ await new Promise(r => setTimeout(r, 1500));
2950
+ await loadRemoteChannels();
2951
+ console.log(`[v3] 双向刷新: 向 ${data.peerCount || 0} 个好友发 list 请求`);
2952
+ } catch (err) {
2953
+ alert('刷新失败: ' + (err.message || err));
2954
+ } finally {
2955
+ refreshSharedBtn.disabled = false;
2956
+ refreshSharedBtn.textContent = originalText;
2957
+ }
2958
+ });
2959
+ }
2960
+
2820
2961
  // 启动时拉一次 + 定期轮询 (SSE 接收 P2P reply 后也会更新)
2821
2962
  loadRemoteChannels();
2822
2963
  setInterval(loadRemoteChannels, 8000);
@@ -78,6 +78,7 @@
78
78
  </span>
79
79
  <span style="display:flex;gap:4px;">
80
80
  <button id="show-my-p2p-id-btn" title="查看并复制我的 P2P publicKey" style="background:none;border:1px solid var(--border);color:var(--text-secondary);cursor:pointer;padding:2px 6px;border-radius:4px;font-size:11px;line-height:1;">我的 ID</button>
81
+ <button id="refresh-shared-btn" title="向所有好友拉取最新分享列表 (双向: A 拉 B 的, B 拉 A 的)" style="background:none;border:1px solid var(--border);color:var(--text-secondary);cursor:pointer;padding:2px 6px;border-radius:4px;font-size:11px;line-height:1;">↻ 刷新</button>
81
82
  <button id="add-p2p-peer-btn" title="添加 P2P 好友 (输入对方的 publicKey)" style="background:none;border:1px solid var(--border);color:var(--text-secondary);cursor:pointer;padding:2px 6px;border-radius:4px;font-size:11px;line-height:1;">+ 好友</button>
82
83
  </span>
83
84
  </div>
@@ -259,6 +259,8 @@ let sseClients = new Set();
259
259
  let remoteChannelCache = new Map();
260
260
  // v3: P2PDirect 引用 (Hyperswarm 薄包装) - 模块级, 因为 web server 闭包里不可用
261
261
  let v3P2PRef = null;
262
+ // v3: 等待中的 history RPC (B 端 chat-history endpoint 用) — rpcId → { resolve, reject }
263
+ const v3PendingHistoryGets = new Map();
262
264
  let channelSessions = new Map(); // key: channelId
263
265
  let sessionMessages = new Map(); // key: channelId + sessionId
264
266
  /**
@@ -297,6 +299,11 @@ function sanitizeChannelForPeer(ch, peerPublicKey) {
297
299
  // 🔒 不返回: bound_judgment_ids, walletAddress, walletBinding, autoInvokeTools, sessions, shared_with_peers
298
300
  };
299
301
  }
302
+ /** v3 新增: 判断 channel 是否分享给 peerPublicKey */
303
+ function isSharedWith(ch, peerPublicKey) {
304
+ const shared = Array.isArray(ch.shared_with_peers) ? ch.shared_with_peers : [];
305
+ return shared.includes(peerPublicKey);
306
+ }
300
307
  /**
301
308
  * v3: 处理 Hyperswarm 通道收到的 v3 RPC 消息
302
309
  * 设计: 用 HyperswarmCommunicator (DHT topic 自动发现) 取代 iroh 直接 connect
@@ -382,7 +389,8 @@ async function handleV3P2PMessage(parsed, conn, comm) {
382
389
  console.warn(`[v3] agent.chat.send 缺少 channelId/text`);
383
390
  return;
384
391
  }
385
- console.log(`[v3] 收到 ${fromPublicKey?.substring(0, 12) || peerKey.substring(0, 12)}... 对 channel ${channelId} 的 chat: "${text.substring(0, 40)}..."`);
392
+ const senderKey = fromPublicKey || peerKey;
393
+ console.log(`[v3] 收到 ${senderKey.substring(0, 12)}... 对 channel ${channelId} 的 chat: "${text.substring(0, 40)}..."`);
386
394
  try {
387
395
  // 1. 找到 channel
388
396
  const channels = await loadChannels();
@@ -395,20 +403,84 @@ async function handleV3P2PMessage(parsed, conn, comm) {
395
403
  await comm.sendToConnection(conn.id, reply);
396
404
  return;
397
405
  }
398
- // 2. LLM (复用 Phase 1buildJudgmentHint注入 channel 的 judgment)
406
+ // v3 新增: 持久化 B user 消息到 A session让历史可拉
407
+ try {
408
+ const existing = await loadSession(channelId, 'default');
409
+ const session = existing || {
410
+ channelId, sessionId: 'default', messages: [], lastUpdated: new Date().toISOString()
411
+ };
412
+ session.messages.push({
413
+ id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
414
+ type: 'user',
415
+ content: text,
416
+ timestamp: new Date().toISOString()
417
+ });
418
+ session.lastUpdated = new Date().toISOString();
419
+ await saveSession(session);
420
+ console.log(`[v3] (${channelId}) 存 user 消息 (${text.length} chars) 到 A 的 session`);
421
+ }
422
+ catch (saveErr) {
423
+ console.warn(`[v3] 存 user 消息失败 (不影响 chat):`, saveErr.message);
424
+ }
425
+ // v3 新增: 告诉 B "我开始想了, 用了哪些 judgment" — 让 B 看到决策依据
399
426
  const judgmentHint = await buildJudgmentHint(ch, channelId);
427
+ const usedJudgments = await extractJudgmentsFromHint(ch);
428
+ try {
429
+ const thinkingStart = JSON.stringify({
430
+ v: 3, op: 'agent.chat.thinking',
431
+ payload: {
432
+ channelId,
433
+ phase: 'start',
434
+ fromPublicKey: v3P2PRef?.getPublicKey() || '',
435
+ hint: judgmentHint,
436
+ usedJudgments,
437
+ userText: text
438
+ }
439
+ });
440
+ await comm.sendToConnection(conn.id, thinkingStart);
441
+ }
442
+ catch { }
443
+ // 2. 跑 LLM (复用 Phase 1 的 buildJudgmentHint — 注入 channel 的 judgment)
400
444
  const { getMinimax } = await import('../constraints/index.js');
401
445
  const llm = getMinimax();
402
446
  const fullPrompt = `${judgmentHint}${text}`;
403
447
  let fullResponse = '';
448
+ // v3 新增: 流式 token 节流推给 B — 让 B 看到过程
449
+ let lastFlushAt = 0;
404
450
  const streamCallback = (event) => {
405
- // 流式 token, 不广播给 B (避免半成品噪音), 只记 A 自己的日志
406
451
  if (event.type === 'token') {
407
452
  fullResponse += event.content;
453
+ if (fullResponse.length - lastFlushAt >= 20) {
454
+ lastFlushAt = fullResponse.length;
455
+ const msg = JSON.stringify({
456
+ v: 3, op: 'agent.chat.thinking',
457
+ payload: { channelId, phase: 'token', partial: fullResponse, fromPublicKey: v3P2PRef?.getPublicKey() || '' }
458
+ });
459
+ comm.sendToConnection(conn.id, msg).catch(() => { });
460
+ }
408
461
  }
409
462
  };
410
463
  const agent = await getAgentForChannel(channelId, ch.did || '', ch.name, ch.didDocRef);
411
464
  fullResponse = await agent.promptStream(fullPrompt, streamCallback);
465
+ // v3 新增: 存 A 的 assistant 消息到 session — B 拉历史时能看到完整对话
466
+ try {
467
+ const existing = await loadSession(channelId, 'default');
468
+ const session = existing || {
469
+ channelId, sessionId: 'default', messages: [], lastUpdated: new Date().toISOString()
470
+ };
471
+ session.messages.push({
472
+ id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
473
+ type: 'ai',
474
+ content: fullResponse,
475
+ timestamp: new Date().toISOString()
476
+ });
477
+ session.lastUpdated = new Date().toISOString();
478
+ await saveSession(session);
479
+ console.log(`[v3] (${channelId}) 存 assistant 回复 (${fullResponse.length} chars) 到 A 的 session`);
480
+ }
481
+ catch (saveErr) {
482
+ console.warn(`[v3] 存 assistant 消息失败 (不影响):`, saveErr.message);
483
+ }
412
484
  // 3. 把完整回复发给 B
413
485
  const reply = JSON.stringify({
414
486
  v: 3, op: 'agent.chat.reply',
@@ -419,7 +491,7 @@ async function handleV3P2PMessage(parsed, conn, comm) {
419
491
  }
420
492
  });
421
493
  await comm.sendToConnection(conn.id, reply);
422
- console.log(`[v3] 回 chat.reply 给 ${fromPublicKey?.substring(0, 12) || peerKey.substring(0, 12)}... (${fullResponse.length} chars)`);
494
+ console.log(`[v3] 回 chat.reply 给 ${senderKey.substring(0, 12)}... (${fullResponse.length} chars)`);
423
495
  }
424
496
  catch (err) {
425
497
  console.error(`[v3] agent.chat.send 处理失败:`, err.message);
@@ -434,6 +506,66 @@ async function handleV3P2PMessage(parsed, conn, comm) {
434
506
  }
435
507
  return;
436
508
  }
509
+ if (op === 'agent.history.get') {
510
+ // v3 新增: B 拉 A 的 channel 历史 (含所有 message + judgment hint)
511
+ // 共享过滤: 只返回 B 可见的 channel + 包含的 judgment
512
+ const { channelId, rpcId, fromPublicKey } = parsed.payload || {};
513
+ if (!channelId || !rpcId) {
514
+ console.warn(`[v3] agent.history.get 缺少 channelId/rpcId`);
515
+ return;
516
+ }
517
+ try {
518
+ const channels = await loadChannels();
519
+ const ch = channels.find(c => c.id === channelId);
520
+ if (!ch) {
521
+ const err = JSON.stringify({
522
+ v: 3, op: 'agent.history.get.reply',
523
+ payload: { rpcId, error: 'channel not found', messages: [], judgments: { bound: [], candidates: [] } }
524
+ });
525
+ await comm.sendToConnection(conn.id, err);
526
+ return;
527
+ }
528
+ // 共享过滤: 必须 peerKey 在 shared_with_peers 里 (避免泄露未分享的 channel)
529
+ const peerKey = fromPublicKey;
530
+ if (!peerKey || !isSharedWith(ch, peerKey)) {
531
+ const err = JSON.stringify({
532
+ v: 3, op: 'agent.history.get.reply',
533
+ payload: { rpcId, error: 'channel not shared with you', messages: [], judgments: { bound: [], candidates: [] } }
534
+ });
535
+ await comm.sendToConnection(conn.id, err);
536
+ return;
537
+ }
538
+ // 加载 A 端 session
539
+ const session = await loadSession(channelId, 'default');
540
+ // 加载 channel 用到的 judgment
541
+ const judgments = await extractJudgmentsFromHint(ch);
542
+ const reply = JSON.stringify({
543
+ v: 3, op: 'agent.history.get.reply',
544
+ payload: {
545
+ rpcId,
546
+ channelId,
547
+ messages: session?.messages || [],
548
+ lastUpdated: session?.lastUpdated,
549
+ judgments,
550
+ channelName: ch.name
551
+ }
552
+ });
553
+ await comm.sendToConnection(conn.id, reply);
554
+ console.log(`[v3] 回 history.reply 给 ${peerKey.substring(0, 12)}... (channelId=${channelId}, ${session?.messages?.length || 0} messages)`);
555
+ }
556
+ catch (err) {
557
+ console.error(`[v3] agent.history.get 处理失败:`, err.message);
558
+ try {
559
+ const errMsg = JSON.stringify({
560
+ v: 3, op: 'agent.history.get.reply',
561
+ payload: { rpcId, error: err.message, messages: [], judgments: { bound: [], candidates: [] } }
562
+ });
563
+ await comm.sendToConnection(conn.id, errMsg);
564
+ }
565
+ catch { }
566
+ }
567
+ return;
568
+ }
437
569
  console.log(`[v3] 收到未知 op: ${op}`);
438
570
  }
439
571
  async function buildJudgmentHint(channel, channelIdForLog) {
@@ -477,6 +609,38 @@ async function buildJudgmentHint(channel, channelIdForLog) {
477
609
  return '';
478
610
  }
479
611
  }
612
+ /**
613
+ * v3 新增: 把 channel 当前用到的 judgment 提取成结构化数据, 给 B 端 UI 显示.
614
+ * 返回 { bound: [...], candidates: [...] } — bound 是硬绑定, candidates 是参考池.
615
+ */
616
+ async function extractJudgmentsFromHint(channel) {
617
+ try {
618
+ const { loadAllJudgments, initializeValueStore } = await import('../pi-ecosystem-judgment/human-value-store.js');
619
+ await initializeValueStore();
620
+ const allJudgments = await loadAllJudgments();
621
+ if (allJudgments.length === 0)
622
+ return { bound: [], candidates: [] };
623
+ const boundIds = new Set(channel && Array.isArray(channel.bound_judgment_ids) ? channel.bound_judgment_ids : []);
624
+ const summarize = (j) => ({
625
+ id: j.id,
626
+ decision: (j.decision || '').toString().slice(0, 200),
627
+ reasons: Array.isArray(j.reasons) ? j.reasons : [],
628
+ domain: j.domain,
629
+ stakes: j.stakes
630
+ });
631
+ const bound = allJudgments
632
+ .filter((j) => j.id !== undefined && boundIds.has(j.id))
633
+ .map(summarize);
634
+ const candidates = allJudgments
635
+ .filter((j) => j.id !== undefined && !boundIds.has(j.id))
636
+ .map(summarize);
637
+ return { bound, candidates };
638
+ }
639
+ catch (err) {
640
+ console.warn(`[v3] extractJudgmentsFromHint 失败:`, err.message);
641
+ return { bound: [], candidates: [] };
642
+ }
643
+ }
480
644
  async function getAgentForChannel(channelId, channelDid, channelName, channelDidDoc) {
481
645
  // 获取当前 channel 的 currentSessionId
482
646
  const channels = await loadChannels();
@@ -604,6 +768,45 @@ export async function createWebServer(port = 3000, options = {}) {
604
768
  }, 'p2p-global');
605
769
  return;
606
770
  }
771
+ // v3 新增: B 端收到 A 的 thinking (开始 + 流式 token)
772
+ if (parsed.op === 'agent.chat.thinking') {
773
+ const phase = parsed.payload?.phase;
774
+ if (phase === 'start') {
775
+ console.log(`[v3] 收到来自 ${evt.fromPublicKey.substring(0, 12)}... 的 thinking start (judgments: bound=${(parsed.payload?.usedJudgments?.bound || []).length}, candidates=${(parsed.payload?.usedJudgments?.candidates || []).length})`);
776
+ }
777
+ broadcast({
778
+ type: 'remote-chat-thinking',
779
+ fromPublicKey: evt.fromPublicKey,
780
+ channelId: parsed.payload?.channelId,
781
+ phase: parsed.payload?.phase,
782
+ partial: parsed.payload?.partial,
783
+ hint: parsed.payload?.hint,
784
+ usedJudgments: parsed.payload?.usedJudgments,
785
+ userText: parsed.payload?.userText
786
+ }, 'p2p-global');
787
+ return;
788
+ }
789
+ // v3 新增: B 端收到 A 的 history reply → resolve pending promise
790
+ if (parsed.op === 'agent.history.get.reply') {
791
+ const rpcId = parsed.payload?.rpcId;
792
+ if (rpcId && v3PendingHistoryGets.has(rpcId)) {
793
+ const pending = v3PendingHistoryGets.get(rpcId);
794
+ v3PendingHistoryGets.delete(rpcId);
795
+ if (parsed.payload?.error) {
796
+ pending.reject(new Error(parsed.payload.error));
797
+ }
798
+ else {
799
+ pending.resolve({
800
+ channelId: parsed.payload.channelId,
801
+ messages: parsed.payload.messages || [],
802
+ lastUpdated: parsed.payload.lastUpdated,
803
+ judgments: parsed.payload.judgments || { bound: [], candidates: [] },
804
+ channelName: parsed.payload.channelName
805
+ });
806
+ }
807
+ }
808
+ return;
809
+ }
607
810
  const commShim = {
608
811
  sendToConnection: (_id, data) => {
609
812
  v3P2PRef.sendTo(evt.fromPublicKey, data);
@@ -2004,6 +2207,50 @@ export async function createWebServer(port = 3000, options = {}) {
2004
2207
  res.status(500).json({ error: err.message });
2005
2208
  }
2006
2209
  });
2210
+ // v3 新增: B 拉 A 的 channel 历史 + 用了哪些 judgment
2211
+ // GET /api/remote-channels/chat-history?targetPublicKey=...&channelId=...
2212
+ // 实现: B → POST 给 A 一个 agent.history.get RPC → A 把 session 返回 → B 渲染
2213
+ app.get('/api/remote-channels/chat-history', async (req, res) => {
2214
+ try {
2215
+ if (!v3P2PRef) {
2216
+ return res.status(503).json({ error: 'P2PDirect not started' });
2217
+ }
2218
+ const targetPublicKey = String(req.query.targetPublicKey || '');
2219
+ const channelId = String(req.query.channelId || '');
2220
+ if (!targetPublicKey || !channelId) {
2221
+ return res.status(400).json({ error: 'targetPublicKey, channelId required' });
2222
+ }
2223
+ // 通过 RPC 拉 A 的 session — A 端收到后异步回复
2224
+ const fromPk = v3P2PRef.getPublicKey();
2225
+ const rpcId = `hist-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
2226
+ const msg = JSON.stringify({
2227
+ v: 3,
2228
+ op: 'agent.history.get',
2229
+ payload: { rpcId, channelId, fromPublicKey: fromPk }
2230
+ });
2231
+ const ok = v3P2PRef.sendTo(targetPublicKey, msg);
2232
+ if (!ok) {
2233
+ return res.status(502).json({ error: 'peer not connected' });
2234
+ }
2235
+ // 等待 A 异步回复 (15s timeout) — 用一个 Promise 等
2236
+ const result = await new Promise((resolve, reject) => {
2237
+ const timer = setTimeout(() => {
2238
+ v3PendingHistoryGets.delete(rpcId);
2239
+ reject(new Error('A 端 15s 内未回复, 可能未分享该 channel'));
2240
+ }, 15000);
2241
+ v3PendingHistoryGets.set(rpcId, {
2242
+ resolve: (data) => { clearTimeout(timer); resolve(data); },
2243
+ reject: (err) => { clearTimeout(timer); reject(err); }
2244
+ });
2245
+ });
2246
+ console.log(`[v3] chat-history 从 ${targetPublicKey.substring(0, 12)}... 拉到 ${(result.messages || []).length} 条`);
2247
+ res.json(result);
2248
+ }
2249
+ catch (err) {
2250
+ console.error('[v3] chat-history 失败:', err.message);
2251
+ res.status(504).json({ error: err.message });
2252
+ }
2253
+ });
2007
2254
  // 获取已连接的节点
2008
2255
  app.get('/api/peers', async (_req, res) => {
2009
2256
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bolloon/bolloon-agent",
3
- "version": "0.1.28",
3
+ "version": "0.1.29",
4
4
  "type": "module",
5
5
  "description": "P2P AI Document Agent - 全局安装后执行 `bolloon` 启动产品",
6
6
  "main": "dist/cli.js",
package/src/web/client.js CHANGED
@@ -118,6 +118,8 @@ function startV3GlobalSSE() {
118
118
  if (msg.type === 'remote-chat-reply') {
119
119
  // 找到当前打开的远端 chat modal 的 log
120
120
  const log = document.getElementById('rcm-log');
121
+ const thinkingEl = document.getElementById('rcm-thinking');
122
+ if (thinkingEl) thinkingEl.style.display = 'none'; // 思考结束, 隐藏
121
123
  if (log) {
122
124
  const bubble = document.createElement('div');
123
125
  bubble.style.cssText = 'padding:8px 10px;margin:4px 0;border-radius:6px;font-size:13px;line-height:1.4;max-width:80%;word-wrap:break-word;background:#e5e7eb;color:#111;';
@@ -129,6 +131,44 @@ function startV3GlobalSSE() {
129
131
  log.appendChild(bubble);
130
132
  log.scrollTop = log.scrollHeight;
131
133
  }
134
+ } else if (msg.type === 'remote-chat-thinking') {
135
+ // v3 新增: B 端实时显示 A 节点的思考过程
136
+ const phase = msg.phase;
137
+ const log = document.getElementById('rcm-log');
138
+ if (!log) return;
139
+
140
+ if (phase === 'start') {
141
+ // 头部插入"判断力依据"区块 (只第一次)
142
+ const judgments = msg.usedJudgments || { bound: [], candidates: [] };
143
+ const judgmentBlock = document.createElement('div');
144
+ judgmentBlock.className = 'rcm-judgment-block';
145
+ judgmentBlock.style.cssText = 'margin:6px 0;padding:8px 10px;background:#fef3c7;border-left:3px solid #f59e0b;border-radius:4px;font-size:12px;';
146
+ let jh = '<div style="font-weight:600;color:#92400e;margin-bottom:4px;">🛡️ 对方使用的判断力 (来自 ta 的 channel)</div>';
147
+ if (judgments.bound && judgments.bound.length > 0) {
148
+ jh += '<div style="color:#78350f;margin-bottom:4px;"><b>硬绑定</b> (必须遵循):</div>';
149
+ for (const j of judgments.bound) {
150
+ jh += `<div style="margin:2px 0;padding-left:8px;">• <b>${escapeHtml((j.decision || '').slice(0, 80))}</b>${j.reasons && j.reasons.length ? '<br><span style="color:#92400e;font-size:11px;">理由: ' + escapeHtml(j.reasons.join('; ').slice(0, 80)) + '</span>' : ''}</div>`;
151
+ }
152
+ }
153
+ if (judgments.candidates && judgments.candidates.length > 0) {
154
+ jh += `<div style="color:#78350f;margin-top:4px;"><b>候选池</b> (${judgments.candidates.length} 条, LLM 自选)</div>`;
155
+ }
156
+ log.appendChild(judgmentBlock);
157
+ // "思考中" 区块
158
+ const thinkingEl = document.createElement('div');
159
+ thinkingEl.id = 'rcm-thinking-live';
160
+ thinkingEl.style.cssText = 'margin:6px 0;padding:8px 10px;background:#ede9fe;border-left:3px solid #8b5cf6;border-radius:4px;font-size:12px;color:#5b21b6;font-style:italic;';
161
+ thinkingEl.textContent = '💭 对方正在思考...';
162
+ log.appendChild(thinkingEl);
163
+ log.scrollTop = log.scrollHeight;
164
+ } else if (phase === 'token') {
165
+ // 实时更新思考中的 partial
166
+ const thinkingEl = document.getElementById('rcm-thinking-live');
167
+ if (thinkingEl) {
168
+ thinkingEl.textContent = '💭 对方正在思考: ' + (msg.partial || '').slice(-200);
169
+ log.scrollTop = log.scrollHeight;
170
+ }
171
+ }
132
172
  }
133
173
  } catch (err) {
134
174
  console.error('[v3] 全局 SSE 解析失败:', err);
@@ -2683,15 +2723,19 @@ function openRemoteChannelChat(peerPublicKey, channelId, channelName) {
2683
2723
  document.getElementById('remote-chat-modal')?.remove();
2684
2724
  const html = `
2685
2725
  <div id="remote-chat-modal" style="position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10002;display:flex;align-items:center;justify-content:center;">
2686
- <div style="background:#fff;border-radius:8px;width:520px;max-width:92vw;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 10px 40px rgba(0,0,0,0.2);">
2726
+ <div style="background:#fff;border-radius:8px;width:560px;max-width:92vw;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 10px 40px rgba(0,0,0,0.2);">
2687
2727
  <div style="padding:12px 16px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;justify-content:space-between;">
2688
- <div>
2728
+ <div style="flex:1;min-width:0;">
2689
2729
  <div style="font-size:15px;font-weight:600;">跟 ${escapeHtml(channelName)} 聊天</div>
2690
- <div style="font-size:11px;color:#6b7280;margin-top:2px;">远端 peer: ${escapeHtml(peerPublicKey.substring(0,16))}… · channel: ${escapeHtml(channelId)}</div>
2730
+ <div style="font-size:11px;color:#6b7280;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">远端 peer: ${escapeHtml(peerPublicKey.substring(0,16))}… · ${escapeHtml(channelId)}</div>
2691
2731
  </div>
2732
+ <button id="rcm-refresh-history" title="重新拉历史" style="background:none;border:1px solid var(--border);color:#6b7280;cursor:pointer;padding:2px 8px;border-radius:4px;font-size:11px;margin-right:6px;">↻ 历史</button>
2692
2733
  <button id="rcm-close" style="background:none;border:none;font-size:20px;color:#6b7280;cursor:pointer;">×</button>
2693
2734
  </div>
2694
- <div id="rcm-log" style="flex:1;overflow-y:auto;padding:12px 16px;min-height:240px;background:#f9fafb;"></div>
2735
+ <div id="rcm-thinking" style="display:none;padding:8px 16px;background:#fef3c7;color:#92400e;font-size:12px;border-bottom:1px solid #e5e7eb;">
2736
+ 📥 正在从远端拉历史 + 判断力…
2737
+ </div>
2738
+ <div id="rcm-log" style="flex:1;overflow-y:auto;padding:12px 16px;min-height:240px;max-height:60vh;background:#f9fafb;"></div>
2695
2739
  <div style="padding:10px 12px;border-top:1px solid #e5e7eb;display:flex;gap:6px;">
2696
2740
  <input id="rcm-input" type="text" placeholder="输入消息, 发送到远端 channel..."
2697
2741
  style="flex:1;padding:8px 10px;border:1px solid #d1d5db;border-radius:4px;font-size:13px;">
@@ -2705,10 +2749,13 @@ function openRemoteChannelChat(peerPublicKey, channelId, channelName) {
2705
2749
  const log = document.getElementById('rcm-log');
2706
2750
  const inputEl = document.getElementById('rcm-input');
2707
2751
  const sendBtn = document.getElementById('rcm-send');
2752
+ const thinkingEl = document.getElementById('rcm-thinking');
2708
2753
  document.getElementById('rcm-close').onclick = () => document.getElementById('remote-chat-modal').remove();
2754
+ document.getElementById('rcm-refresh-history').onclick = () => loadHistory();
2709
2755
 
2710
- const append = (text, isUser) => {
2756
+ const append = (text, role) => {
2711
2757
  const bubble = document.createElement('div');
2758
+ const isUser = role === 'user';
2712
2759
  bubble.style.cssText = `padding:8px 10px;margin:4px 0;border-radius:6px;font-size:13px;line-height:1.4;max-width:80%;word-wrap:break-word;${
2713
2760
  isUser ? 'background:#2563eb;color:#fff;margin-left:auto;text-align:left;'
2714
2761
  : 'background:#e5e7eb;color:#111;'
@@ -2718,10 +2765,75 @@ function openRemoteChannelChat(peerPublicKey, channelId, channelName) {
2718
2765
  log.scrollTop = log.scrollHeight;
2719
2766
  };
2720
2767
 
2768
+ const appendSystem = (text, kind = 'info') => {
2769
+ const el = document.createElement('div');
2770
+ const colors = {
2771
+ info: { bg: '#dbeafe', fg: '#1e40af' },
2772
+ warn: { bg: '#fef3c7', fg: '#92400e' },
2773
+ error: { bg: '#fca5a5', fg: '#7f1d1d' }
2774
+ };
2775
+ const c = colors[kind] || colors.info;
2776
+ el.style.cssText = `margin:6px 0;padding:6px 10px;background:${c.bg};color:${c.fg};border-radius:4px;font-size:11px;text-align:center;`;
2777
+ el.textContent = text;
2778
+ log.appendChild(el);
2779
+ log.scrollTop = log.scrollHeight;
2780
+ };
2781
+
2782
+ // v3 新增: 拉 A 端的 channel 历史 (含 messages + judgments)
2783
+ async function loadHistory() {
2784
+ thinkingEl.style.display = 'block';
2785
+ log.innerHTML = '';
2786
+ appendSystem('正在拉取远端 channel 的历史和判断力...', 'info');
2787
+ try {
2788
+ const res = await fetch(`/api/remote-channels/chat-history?targetPublicKey=${encodeURIComponent(peerPublicKey)}&channelId=${encodeURIComponent(channelId)}`);
2789
+ const data = await res.json();
2790
+ if (!res.ok) {
2791
+ appendSystem(`拉取失败: ${data.error || 'unknown'}`, 'error');
2792
+ thinkingEl.style.display = 'none';
2793
+ return;
2794
+ }
2795
+ // 清掉 loading
2796
+ log.innerHTML = '';
2797
+
2798
+ // 1. 显示 judgment 依据 (header)
2799
+ const judgments = data.judgments || { bound: [], candidates: [] };
2800
+ if (judgments.bound && judgments.bound.length > 0) {
2801
+ const jh = document.createElement('div');
2802
+ jh.style.cssText = 'margin:0 0 8px;padding:8px 10px;background:#fef3c7;border-left:3px solid #f59e0b;border-radius:4px;font-size:12px;';
2803
+ let h = `<div style="font-weight:600;color:#92400e;margin-bottom:4px;">🛡️ 对方 channel 绑定的判断力 (${judgments.bound.length} 条硬约束)</div>`;
2804
+ for (const j of judgments.bound) {
2805
+ h += `<div style="margin:3px 0;padding-left:8px;">• <b>${escapeHtml((j.decision || '').slice(0, 100))}</b>${j.domain ? `<span style="color:#a16207;font-size:10px;"> [${escapeHtml(j.domain)}${j.stakes ? '/' + escapeHtml(j.stakes) : ''}]</span>` : ''}${j.reasons && j.reasons.length ? '<br><span style="color:#92400e;font-size:11px;">理由: ' + escapeHtml(j.reasons.join('; ').slice(0, 100)) + '</span>' : ''}</div>`;
2806
+ }
2807
+ if (judgments.candidates && judgments.candidates.length > 0) {
2808
+ h += `<div style="margin-top:6px;color:#92400e;font-size:11px;">+ ${judgments.candidates.length} 条候选判断力 (LLM 可自选参考)</div>`;
2809
+ }
2810
+ jh.innerHTML = h;
2811
+ log.appendChild(jh);
2812
+ }
2813
+
2814
+ // 2. 显示历史 messages
2815
+ const msgs = data.messages || [];
2816
+ if (msgs.length === 0) {
2817
+ appendSystem('还没有历史消息, 在下面发第一条吧', 'info');
2818
+ } else {
2819
+ appendSystem(`从远端拉到 ${msgs.length} 条历史消息`, 'info');
2820
+ for (const m of msgs) {
2821
+ append(m.content || '', m.type === 'user' ? 'user' : 'ai');
2822
+ }
2823
+ // 滚到底部
2824
+ setTimeout(() => { log.scrollTop = log.scrollHeight; }, 50);
2825
+ }
2826
+ } catch (err) {
2827
+ appendSystem(`拉取异常: ${err.message}`, 'error');
2828
+ } finally {
2829
+ thinkingEl.style.display = 'none';
2830
+ }
2831
+ }
2832
+
2721
2833
  const doSend = async () => {
2722
2834
  const text = inputEl.value.trim();
2723
2835
  if (!text) return;
2724
- append(text, true);
2836
+ append(text, 'user');
2725
2837
  inputEl.value = '';
2726
2838
  sendBtn.disabled = true;
2727
2839
  sendBtn.textContent = '...';
@@ -2733,8 +2845,9 @@ function openRemoteChannelChat(peerPublicKey, channelId, channelName) {
2733
2845
  });
2734
2846
  const data = await res.json();
2735
2847
  if (!res.ok) throw new Error(data.error || 'send failed');
2848
+ appendSystem('已发送, 等待对方回复...', 'info');
2736
2849
  } catch (err) {
2737
- append('(发送失败: ' + (err.message || err) + ')', false);
2850
+ append('(发送失败: ' + (err.message || err) + ')', 'ai');
2738
2851
  } finally {
2739
2852
  sendBtn.disabled = false;
2740
2853
  sendBtn.textContent = '发送';
@@ -2744,6 +2857,9 @@ function openRemoteChannelChat(peerPublicKey, channelId, channelName) {
2744
2857
  inputEl.onkeydown = (e) => { if (e.key === 'Enter') doSend(); };
2745
2858
  inputEl.focus();
2746
2859
  startV3GlobalSSE();
2860
+
2861
+ // 打开时立即拉历史
2862
+ loadHistory();
2747
2863
  }
2748
2864
 
2749
2865
  // Phase 3: 我的 ID 按钮 → 弹窗显示并支持复制自己的 P2PDirect publicKey
@@ -2817,6 +2933,31 @@ if (addPeerBtn) {
2817
2933
  });
2818
2934
  }
2819
2935
 
2936
+ // v3 双向刷新: 主动向所有好友发 agent.meta.list, 拿到 ta 们分享给我的 channel
2937
+ const refreshSharedBtn = document.getElementById('refresh-shared-btn');
2938
+ if (refreshSharedBtn) {
2939
+ refreshSharedBtn.addEventListener('click', async (e) => {
2940
+ e.stopPropagation();
2941
+ const originalText = refreshSharedBtn.textContent;
2942
+ refreshSharedBtn.disabled = true;
2943
+ refreshSharedBtn.textContent = '...';
2944
+ try {
2945
+ const res = await fetch('/api/remote-channels/refresh', { method: 'POST' });
2946
+ const data = await res.json();
2947
+ if (!res.ok) throw new Error(data.error || 'refresh failed');
2948
+ // 等 1.5s 让 RPC 回复回来 (向所有 peer 广播)
2949
+ await new Promise(r => setTimeout(r, 1500));
2950
+ await loadRemoteChannels();
2951
+ console.log(`[v3] 双向刷新: 向 ${data.peerCount || 0} 个好友发 list 请求`);
2952
+ } catch (err) {
2953
+ alert('刷新失败: ' + (err.message || err));
2954
+ } finally {
2955
+ refreshSharedBtn.disabled = false;
2956
+ refreshSharedBtn.textContent = originalText;
2957
+ }
2958
+ });
2959
+ }
2960
+
2820
2961
  // 启动时拉一次 + 定期轮询 (SSE 接收 P2P reply 后也会更新)
2821
2962
  loadRemoteChannels();
2822
2963
  setInterval(loadRemoteChannels, 8000);
@@ -78,6 +78,7 @@
78
78
  </span>
79
79
  <span style="display:flex;gap:4px;">
80
80
  <button id="show-my-p2p-id-btn" title="查看并复制我的 P2P publicKey" style="background:none;border:1px solid var(--border);color:var(--text-secondary);cursor:pointer;padding:2px 6px;border-radius:4px;font-size:11px;line-height:1;">我的 ID</button>
81
+ <button id="refresh-shared-btn" title="向所有好友拉取最新分享列表 (双向: A 拉 B 的, B 拉 A 的)" style="background:none;border:1px solid var(--border);color:var(--text-secondary);cursor:pointer;padding:2px 6px;border-radius:4px;font-size:11px;line-height:1;">↻ 刷新</button>
81
82
  <button id="add-p2p-peer-btn" title="添加 P2P 好友 (输入对方的 publicKey)" style="background:none;border:1px solid var(--border);color:var(--text-secondary);cursor:pointer;padding:2px 6px;border-radius:4px;font-size:11px;line-height:1;">+ 好友</button>
82
83
  </span>
83
84
  </div>
package/src/web/server.ts CHANGED
@@ -391,6 +391,8 @@ let sseClients: Set<SSEClient> = new Set();
391
391
  let remoteChannelCache: Map<string, Array<Record<string, unknown>>> = new Map();
392
392
  // v3: P2PDirect 引用 (Hyperswarm 薄包装) - 模块级, 因为 web server 闭包里不可用
393
393
  let v3P2PRef: import('../network/p2p-direct.js').P2PDirect | null = null;
394
+ // v3: 等待中的 history RPC (B 端 chat-history endpoint 用) — rpcId → { resolve, reject }
395
+ const v3PendingHistoryGets: Map<string, { resolve: (data: any) => void; reject: (err: Error) => void }> = new Map();
394
396
  let channelSessions: Map<string, AgentSession> = new Map(); // key: channelId
395
397
  let sessionMessages: Map<string, any[]> = new Map(); // key: channelId + sessionId
396
398
 
@@ -435,6 +437,12 @@ function sanitizeChannelForPeer(
435
437
  };
436
438
  }
437
439
 
440
+ /** v3 新增: 判断 channel 是否分享给 peerPublicKey */
441
+ function isSharedWith(ch: Channel, peerPublicKey: string): boolean {
442
+ const shared = Array.isArray(ch.shared_with_peers) ? ch.shared_with_peers : [];
443
+ return shared.includes(peerPublicKey);
444
+ }
445
+
438
446
  /**
439
447
  * v3: 处理 Hyperswarm 通道收到的 v3 RPC 消息
440
448
  * 设计: 用 HyperswarmCommunicator (DHT topic 自动发现) 取代 iroh 直接 connect
@@ -521,7 +529,8 @@ async function handleV3P2PMessage(parsed: any, conn: P2PConnection, comm: Hypers
521
529
  console.warn(`[v3] agent.chat.send 缺少 channelId/text`);
522
530
  return;
523
531
  }
524
- console.log(`[v3] 收到 ${fromPublicKey?.substring(0,12) || peerKey.substring(0,12)}... 对 channel ${channelId} 的 chat: "${text.substring(0, 40)}..."`);
532
+ const senderKey = fromPublicKey || peerKey;
533
+ console.log(`[v3] 收到 ${senderKey.substring(0,12)}... 对 channel ${channelId} 的 chat: "${text.substring(0, 40)}..."`);
525
534
  try {
526
535
  // 1. 找到 channel
527
536
  const channels = await loadChannels();
@@ -534,20 +543,85 @@ async function handleV3P2PMessage(parsed: any, conn: P2PConnection, comm: Hypers
534
543
  await comm.sendToConnection(conn.id, reply);
535
544
  return;
536
545
  }
537
- // 2. LLM (复用 Phase 1buildJudgmentHint注入 channel 的 judgment)
546
+ // v3 新增: 持久化 B user 消息到 A session让历史可拉
547
+ try {
548
+ const existing = await loadSession(channelId, 'default');
549
+ const session: Session = existing || {
550
+ channelId, sessionId: 'default', messages: [], lastUpdated: new Date().toISOString()
551
+ };
552
+ session.messages.push({
553
+ id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
554
+ type: 'user',
555
+ content: text,
556
+ timestamp: new Date().toISOString()
557
+ });
558
+ session.lastUpdated = new Date().toISOString();
559
+ await saveSession(session);
560
+ console.log(`[v3] (${channelId}) 存 user 消息 (${text.length} chars) 到 A 的 session`);
561
+ } catch (saveErr) {
562
+ console.warn(`[v3] 存 user 消息失败 (不影响 chat):`, (saveErr as Error).message);
563
+ }
564
+
565
+ // v3 新增: 告诉 B "我开始想了, 用了哪些 judgment" — 让 B 看到决策依据
538
566
  const judgmentHint = await buildJudgmentHint(ch, channelId);
567
+ const usedJudgments = await extractJudgmentsFromHint(ch);
568
+ try {
569
+ const thinkingStart = JSON.stringify({
570
+ v: 3, op: 'agent.chat.thinking',
571
+ payload: {
572
+ channelId,
573
+ phase: 'start',
574
+ fromPublicKey: v3P2PRef?.getPublicKey() || '',
575
+ hint: judgmentHint,
576
+ usedJudgments,
577
+ userText: text
578
+ }
579
+ });
580
+ await comm.sendToConnection(conn.id, thinkingStart);
581
+ } catch {}
582
+
583
+ // 2. 跑 LLM (复用 Phase 1 的 buildJudgmentHint — 注入 channel 的 judgment)
539
584
  const { getMinimax } = await import('../constraints/index.js');
540
585
  const llm = getMinimax();
541
586
  const fullPrompt = `${judgmentHint}${text}`;
542
587
  let fullResponse = '';
588
+ // v3 新增: 流式 token 节流推给 B — 让 B 看到过程
589
+ let lastFlushAt = 0;
543
590
  const streamCallback: any = (event: any) => {
544
- // 流式 token, 不广播给 B (避免半成品噪音), 只记 A 自己的日志
545
591
  if (event.type === 'token') {
546
592
  fullResponse += event.content;
593
+ if (fullResponse.length - lastFlushAt >= 20) {
594
+ lastFlushAt = fullResponse.length;
595
+ const msg = JSON.stringify({
596
+ v: 3, op: 'agent.chat.thinking',
597
+ payload: { channelId, phase: 'token', partial: fullResponse, fromPublicKey: v3P2PRef?.getPublicKey() || '' }
598
+ });
599
+ comm.sendToConnection(conn.id, msg).catch(() => {});
600
+ }
547
601
  }
548
602
  };
549
603
  const agent = await getAgentForChannel(channelId, ch.did || '', ch.name, ch.didDocRef);
550
604
  fullResponse = await agent.promptStream(fullPrompt, streamCallback);
605
+
606
+ // v3 新增: 存 A 的 assistant 消息到 session — B 拉历史时能看到完整对话
607
+ try {
608
+ const existing = await loadSession(channelId, 'default');
609
+ const session: Session = existing || {
610
+ channelId, sessionId: 'default', messages: [], lastUpdated: new Date().toISOString()
611
+ };
612
+ session.messages.push({
613
+ id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
614
+ type: 'ai',
615
+ content: fullResponse,
616
+ timestamp: new Date().toISOString()
617
+ });
618
+ session.lastUpdated = new Date().toISOString();
619
+ await saveSession(session);
620
+ console.log(`[v3] (${channelId}) 存 assistant 回复 (${fullResponse.length} chars) 到 A 的 session`);
621
+ } catch (saveErr) {
622
+ console.warn(`[v3] 存 assistant 消息失败 (不影响):`, (saveErr as Error).message);
623
+ }
624
+
551
625
  // 3. 把完整回复发给 B
552
626
  const reply = JSON.stringify({
553
627
  v: 3, op: 'agent.chat.reply',
@@ -558,7 +632,7 @@ async function handleV3P2PMessage(parsed: any, conn: P2PConnection, comm: Hypers
558
632
  }
559
633
  });
560
634
  await comm.sendToConnection(conn.id, reply);
561
- console.log(`[v3] 回 chat.reply 给 ${fromPublicKey?.substring(0,12) || peerKey.substring(0,12)}... (${fullResponse.length} chars)`);
635
+ console.log(`[v3] 回 chat.reply 给 ${senderKey.substring(0,12)}... (${fullResponse.length} chars)`);
562
636
  } catch (err) {
563
637
  console.error(`[v3] agent.chat.send 处理失败:`, (err as Error).message);
564
638
  try {
@@ -572,6 +646,65 @@ async function handleV3P2PMessage(parsed: any, conn: P2PConnection, comm: Hypers
572
646
  return;
573
647
  }
574
648
 
649
+ if (op === 'agent.history.get') {
650
+ // v3 新增: B 拉 A 的 channel 历史 (含所有 message + judgment hint)
651
+ // 共享过滤: 只返回 B 可见的 channel + 包含的 judgment
652
+ const { channelId, rpcId, fromPublicKey } = parsed.payload || {};
653
+ if (!channelId || !rpcId) {
654
+ console.warn(`[v3] agent.history.get 缺少 channelId/rpcId`);
655
+ return;
656
+ }
657
+ try {
658
+ const channels = await loadChannels();
659
+ const ch = channels.find(c => c.id === channelId);
660
+ if (!ch) {
661
+ const err = JSON.stringify({
662
+ v: 3, op: 'agent.history.get.reply',
663
+ payload: { rpcId, error: 'channel not found', messages: [], judgments: { bound: [], candidates: [] } }
664
+ });
665
+ await comm.sendToConnection(conn.id, err);
666
+ return;
667
+ }
668
+ // 共享过滤: 必须 peerKey 在 shared_with_peers 里 (避免泄露未分享的 channel)
669
+ const peerKey = fromPublicKey;
670
+ if (!peerKey || !isSharedWith(ch, peerKey)) {
671
+ const err = JSON.stringify({
672
+ v: 3, op: 'agent.history.get.reply',
673
+ payload: { rpcId, error: 'channel not shared with you', messages: [], judgments: { bound: [], candidates: [] } }
674
+ });
675
+ await comm.sendToConnection(conn.id, err);
676
+ return;
677
+ }
678
+ // 加载 A 端 session
679
+ const session = await loadSession(channelId, 'default');
680
+ // 加载 channel 用到的 judgment
681
+ const judgments = await extractJudgmentsFromHint(ch);
682
+ const reply = JSON.stringify({
683
+ v: 3, op: 'agent.history.get.reply',
684
+ payload: {
685
+ rpcId,
686
+ channelId,
687
+ messages: session?.messages || [],
688
+ lastUpdated: session?.lastUpdated,
689
+ judgments,
690
+ channelName: ch.name
691
+ }
692
+ });
693
+ await comm.sendToConnection(conn.id, reply);
694
+ console.log(`[v3] 回 history.reply 给 ${peerKey.substring(0,12)}... (channelId=${channelId}, ${session?.messages?.length || 0} messages)`);
695
+ } catch (err) {
696
+ console.error(`[v3] agent.history.get 处理失败:`, (err as Error).message);
697
+ try {
698
+ const errMsg = JSON.stringify({
699
+ v: 3, op: 'agent.history.get.reply',
700
+ payload: { rpcId, error: (err as Error).message, messages: [], judgments: { bound: [], candidates: [] } }
701
+ });
702
+ await comm.sendToConnection(conn.id, errMsg);
703
+ } catch {}
704
+ }
705
+ return;
706
+ }
707
+
575
708
  console.log(`[v3] 收到未知 op: ${op}`);
576
709
  }
577
710
 
@@ -629,6 +762,47 @@ async function buildJudgmentHint(
629
762
  }
630
763
  }
631
764
 
765
+ /**
766
+ * v3 新增: 把 channel 当前用到的 judgment 提取成结构化数据, 给 B 端 UI 显示.
767
+ * 返回 { bound: [...], candidates: [...] } — bound 是硬绑定, candidates 是参考池.
768
+ */
769
+ async function extractJudgmentsFromHint(
770
+ channel: Channel | undefined | null
771
+ ): Promise<{ bound: any[]; candidates: any[] }> {
772
+ try {
773
+ const { loadAllJudgments, initializeValueStore } = await import(
774
+ '../pi-ecosystem-judgment/human-value-store.js'
775
+ );
776
+ await initializeValueStore();
777
+ const allJudgments = await loadAllJudgments();
778
+ if (allJudgments.length === 0) return { bound: [], candidates: [] };
779
+
780
+ const boundIds = new Set(
781
+ channel && Array.isArray(channel.bound_judgment_ids) ? channel.bound_judgment_ids : []
782
+ );
783
+
784
+ const summarize = (j: any) => ({
785
+ id: j.id,
786
+ decision: (j.decision || '').toString().slice(0, 200),
787
+ reasons: Array.isArray(j.reasons) ? j.reasons : [],
788
+ domain: j.domain,
789
+ stakes: j.stakes
790
+ });
791
+
792
+ const bound = allJudgments
793
+ .filter((j: any) => j.id !== undefined && boundIds.has(j.id))
794
+ .map(summarize);
795
+ const candidates = allJudgments
796
+ .filter((j: any) => j.id !== undefined && !boundIds.has(j.id))
797
+ .map(summarize);
798
+
799
+ return { bound, candidates };
800
+ } catch (err) {
801
+ console.warn(`[v3] extractJudgmentsFromHint 失败:`, (err as Error).message);
802
+ return { bound: [], candidates: [] };
803
+ }
804
+ }
805
+
632
806
  async function getAgentForChannel(
633
807
  channelId: string,
634
808
  channelDid?: string,
@@ -787,6 +961,44 @@ export async function createWebServer(port: number = 3000, options: CreateWebSer
787
961
  }, 'p2p-global');
788
962
  return;
789
963
  }
964
+ // v3 新增: B 端收到 A 的 thinking (开始 + 流式 token)
965
+ if (parsed.op === 'agent.chat.thinking') {
966
+ const phase = parsed.payload?.phase;
967
+ if (phase === 'start') {
968
+ console.log(`[v3] 收到来自 ${evt.fromPublicKey.substring(0,12)}... 的 thinking start (judgments: bound=${(parsed.payload?.usedJudgments?.bound || []).length}, candidates=${(parsed.payload?.usedJudgments?.candidates || []).length})`);
969
+ }
970
+ broadcast({
971
+ type: 'remote-chat-thinking',
972
+ fromPublicKey: evt.fromPublicKey,
973
+ channelId: parsed.payload?.channelId,
974
+ phase: parsed.payload?.phase,
975
+ partial: parsed.payload?.partial,
976
+ hint: parsed.payload?.hint,
977
+ usedJudgments: parsed.payload?.usedJudgments,
978
+ userText: parsed.payload?.userText
979
+ }, 'p2p-global');
980
+ return;
981
+ }
982
+ // v3 新增: B 端收到 A 的 history reply → resolve pending promise
983
+ if (parsed.op === 'agent.history.get.reply') {
984
+ const rpcId = parsed.payload?.rpcId;
985
+ if (rpcId && v3PendingHistoryGets.has(rpcId)) {
986
+ const pending = v3PendingHistoryGets.get(rpcId)!;
987
+ v3PendingHistoryGets.delete(rpcId);
988
+ if (parsed.payload?.error) {
989
+ pending.reject(new Error(parsed.payload.error));
990
+ } else {
991
+ pending.resolve({
992
+ channelId: parsed.payload.channelId,
993
+ messages: parsed.payload.messages || [],
994
+ lastUpdated: parsed.payload.lastUpdated,
995
+ judgments: parsed.payload.judgments || { bound: [], candidates: [] },
996
+ channelName: parsed.payload.channelName
997
+ });
998
+ }
999
+ }
1000
+ return;
1001
+ }
790
1002
  const commShim = {
791
1003
  sendToConnection: (_id: string, data: string) => {
792
1004
  v3P2PRef!.sendTo(evt.fromPublicKey, data);
@@ -2265,6 +2477,53 @@ app.get('/channels', async (_req, res) => {
2265
2477
  }
2266
2478
  });
2267
2479
 
2480
+ // v3 新增: B 拉 A 的 channel 历史 + 用了哪些 judgment
2481
+ // GET /api/remote-channels/chat-history?targetPublicKey=...&channelId=...
2482
+ // 实现: B → POST 给 A 一个 agent.history.get RPC → A 把 session 返回 → B 渲染
2483
+ app.get('/api/remote-channels/chat-history', async (req, res) => {
2484
+ try {
2485
+ if (!v3P2PRef) {
2486
+ return res.status(503).json({ error: 'P2PDirect not started' });
2487
+ }
2488
+ const targetPublicKey = String(req.query.targetPublicKey || '');
2489
+ const channelId = String(req.query.channelId || '');
2490
+ if (!targetPublicKey || !channelId) {
2491
+ return res.status(400).json({ error: 'targetPublicKey, channelId required' });
2492
+ }
2493
+
2494
+ // 通过 RPC 拉 A 的 session — A 端收到后异步回复
2495
+ const fromPk = v3P2PRef.getPublicKey();
2496
+ const rpcId = `hist-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
2497
+ const msg = JSON.stringify({
2498
+ v: 3,
2499
+ op: 'agent.history.get',
2500
+ payload: { rpcId, channelId, fromPublicKey: fromPk }
2501
+ });
2502
+ const ok = v3P2PRef.sendTo(targetPublicKey, msg);
2503
+ if (!ok) {
2504
+ return res.status(502).json({ error: 'peer not connected' });
2505
+ }
2506
+
2507
+ // 等待 A 异步回复 (15s timeout) — 用一个 Promise 等
2508
+ const result = await new Promise<any>((resolve, reject) => {
2509
+ const timer = setTimeout(() => {
2510
+ v3PendingHistoryGets.delete(rpcId);
2511
+ reject(new Error('A 端 15s 内未回复, 可能未分享该 channel'));
2512
+ }, 15000);
2513
+ v3PendingHistoryGets.set(rpcId, {
2514
+ resolve: (data) => { clearTimeout(timer); resolve(data); },
2515
+ reject: (err) => { clearTimeout(timer); reject(err); }
2516
+ });
2517
+ });
2518
+
2519
+ console.log(`[v3] chat-history 从 ${targetPublicKey.substring(0,12)}... 拉到 ${(result.messages || []).length} 条`);
2520
+ res.json(result);
2521
+ } catch (err: any) {
2522
+ console.error('[v3] chat-history 失败:', err.message);
2523
+ res.status(504).json({ error: err.message });
2524
+ }
2525
+ });
2526
+
2268
2527
  // 获取已连接的节点
2269
2528
  app.get('/api/peers', async (_req, res) => {
2270
2529
  try {