@bolloon/bolloon-agent 0.1.27 → 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.
@@ -106,6 +106,82 @@ async function loadChannels() {
106
106
  }
107
107
  }
108
108
 
109
+ // v3: 全局 SSE 监听 (p2p-global channel) - 接收远端 chat.reply 等事件
110
+ let v3GlobalEventSource = null;
111
+ function startV3GlobalSSE() {
112
+ if (v3GlobalEventSource) return;
113
+ try {
114
+ v3GlobalEventSource = new EventSource('/events?channelId=p2p-global');
115
+ v3GlobalEventSource.onmessage = (e) => {
116
+ try {
117
+ const msg = JSON.parse(e.data);
118
+ if (msg.type === 'remote-chat-reply') {
119
+ // 找到当前打开的远端 chat modal 的 log
120
+ const log = document.getElementById('rcm-log');
121
+ const thinkingEl = document.getElementById('rcm-thinking');
122
+ if (thinkingEl) thinkingEl.style.display = 'none'; // 思考结束, 隐藏
123
+ if (log) {
124
+ const bubble = document.createElement('div');
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;';
126
+ bubble.textContent = msg.text || '(空回复)';
127
+ if (msg.error) {
128
+ bubble.textContent = '(错误: ' + msg.error + ')';
129
+ bubble.style.background = '#fca5a5';
130
+ }
131
+ log.appendChild(bubble);
132
+ log.scrollTop = log.scrollHeight;
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
+ }
172
+ }
173
+ } catch (err) {
174
+ console.error('[v3] 全局 SSE 解析失败:', err);
175
+ }
176
+ };
177
+ v3GlobalEventSource.onerror = (e) => {
178
+ console.warn('[v3] 全局 SSE 错误');
179
+ };
180
+ } catch (err) {
181
+ console.error('[v3] 启动全局 SSE 失败:', err);
182
+ }
183
+ }
184
+
109
185
  async function createChannel(name) {
110
186
  if (!name.trim()) return;
111
187
  try {
@@ -2020,12 +2096,6 @@ function hideJudgmentsModal() {
2020
2096
  if (judgmentsModal) judgmentsModal.classList.remove('active');
2021
2097
  }
2022
2098
 
2023
- function escapeHtml(s) {
2024
- return String(s || '').replace(/[&<>"']/g, c => ({
2025
- '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
2026
- }[c]));
2027
- }
2028
-
2029
2099
  let currentJudgmentTab = 'channel'; // 'channel' | 'global'
2030
2100
  let lastJudgmentsCache = []; // 最近一次 loadJudgments 拿到的原始列表, 切 tab / 切 channel 时复用
2031
2101
 
@@ -2492,15 +2562,17 @@ loadJudgments();
2492
2562
  setInterval(loadJudgments, 10000);
2493
2563
 
2494
2564
  // ============================================================================
2495
- // v3: 远端智能体 (从 P2P 连接的 peer 拉取的 channel UI 元数据)
2565
+ // v3: P2P 好友 (known peers) + 收到的分享
2496
2566
  // ============================================================================
2497
2567
 
2568
+ let knownPeers = []; // { name, publicKey, lastConnectedAt, addedAt }
2569
+
2498
2570
  async function loadRemoteChannels() {
2499
2571
  try {
2500
- const res = await fetch('/api/remote-channels');
2572
+ const res = await fetch('/api/p2p-peers');
2501
2573
  if (!res.ok) return;
2502
2574
  const data = await res.json();
2503
- remoteChannels = Array.isArray(data.peers) ? data.peers : [];
2575
+ knownPeers = Array.isArray(data.peers) ? data.peers : [];
2504
2576
  renderRemoteChannels();
2505
2577
  } catch (err) {
2506
2578
  console.error('[v3] loadRemoteChannels 失败:', err);
@@ -2510,62 +2582,378 @@ async function loadRemoteChannels() {
2510
2582
  function renderRemoteChannels() {
2511
2583
  const list = document.getElementById('remote-channel-list');
2512
2584
  if (!list) return;
2513
- if (remoteChannels.length === 0) {
2514
- list.innerHTML = '<li style="color:var(--text-muted);font-size:11px;padding:8px 4px;text-align:center;">(暂无, 刷新)</li>';
2585
+
2586
+ // Phase 3 重做: 好友列表 + 收到的 channel 分组
2587
+ if (knownPeers.length === 0) {
2588
+ list.innerHTML = '<li style="color:var(--text-muted);font-size:11px;padding:8px 4px;text-align:center;">(暂无好友, 点 + 添加)</li>';
2515
2589
  return;
2516
2590
  }
2517
- const html = remoteChannels.map(p => {
2518
- const peerShort = p.peerId.substring(0, 12) + '…';
2591
+
2592
+ // peerId 分组 channels
2593
+ const channelsByPeer = {};
2594
+ for (const p of remoteChannels) {
2595
+ channelsByPeer[p.peerId] = p.channels || [];
2596
+ }
2597
+
2598
+ const html = knownPeers.map(peer => {
2599
+ const peerChannels = channelsByPeer[peer.publicKey] || [];
2600
+ const lastConn = peer.lastConnectedAt
2601
+ ? new Date(peer.lastConnectedAt).toLocaleDateString()
2602
+ : '从未连接';
2519
2603
  return `
2520
- <li class="remote-peer-group" style="margin-bottom:8px;">
2521
- <div style="font-size:10px;color:var(--text-muted);padding:2px 4px;display:flex;align-items:center;gap:4px;">
2522
- <span>🌐</span><span title="${escapeHtml(p.peerId)}">${escapeHtml(peerShort)}</span>
2523
- <span>·</span>
2524
- <span>${p.channels.length} 个</span>
2604
+ <li class="remote-peer-group" style="margin-bottom:10px;">
2605
+ <div class="remote-peer-header" data-peer-name="${escapeHtml(peer.name)}" data-peer-pk="${escapeHtml(peer.publicKey)}"
2606
+ style="display:flex;align-items:center;gap:6px;padding:4px 6px;background:var(--bg-hover);border-radius:4px;cursor:pointer;">
2607
+ <span style="font-size:12px;">👤</span>
2608
+ <span style="flex:1;font-size:12px;font-weight:600;" title="${escapeHtml(peer.publicKey)}">${escapeHtml(peer.name)}</span>
2609
+ <span style="font-size:9px;color:var(--text-muted);">${lastConn}</span>
2610
+ </div>
2611
+ <div class="remote-peer-channels" style="margin-top:4px;margin-left:8px;">
2612
+ ${peerChannels.length === 0
2613
+ ? '<div style="font-size:10px;color:var(--text-muted);padding:2px 4px;">(对方还没分享 channel 给你)</div>'
2614
+ : peerChannels.map(c => `
2615
+ <div class="remote-channel-row" data-peer-id="${escapeHtml(peer.publicKey)}" data-channel-id="${escapeHtml(c.id)}"
2616
+ style="display:flex;align-items:center;gap:6px;padding:4px 6px;cursor:pointer;border-radius:4px;font-size:12px;">
2617
+ <span>🤖</span>
2618
+ <span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escapeHtml(c.name || '')}">${escapeHtml(c.name || '(未命名)')}</span>
2619
+ <span title="对方 judgment 数 (不会同步到本地)" style="font-size:9px;color:var(--text-muted);">🧠${c.boundJudgmentCount || 0}</span>
2620
+ </div>
2621
+ `).join('')
2622
+ }
2525
2623
  </div>
2526
- ${p.channels.map(c => `
2527
- <div class="remote-channel-row" data-peer-id="${escapeHtml(p.peerId)}" data-channel-id="${escapeHtml(c.id)}"
2528
- style="display:flex;align-items:center;gap:6px;padding:4px 6px;cursor:pointer;border-radius:4px;font-size:12px;color:var(--text);">
2529
- <span>🤖</span>
2530
- <span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escapeHtml(c.name || '')}">${escapeHtml(c.name || '(未命名)')}</span>
2531
- ${c.hasWallet ? '<span title="绑定钱包">⛓</span>' : ''}
2532
- <span title="绑定判断力数" style="font-size:10px;color:var(--text-muted);">🧠${c.boundJudgmentCount || 0}</span>
2533
- </div>
2534
- `).join('')}
2535
2624
  </li>
2536
2625
  `;
2537
2626
  }).join('');
2538
2627
  list.innerHTML = html;
2539
2628
 
2540
- // 绑定点击 暂时只是 console.log + 提示, Phase 2 接 chat
2629
+ // 绑定: 点击 channel 弹聊天窗口
2541
2630
  list.querySelectorAll('.remote-channel-row').forEach(row => {
2542
2631
  row.addEventListener('click', () => {
2543
2632
  const peerId = row.dataset.peerId;
2544
2633
  const channelId = row.dataset.channelId;
2634
+ const channelName = row.querySelector('span[title]')?.getAttribute('title') || channelId;
2545
2635
  console.log('[v3] 点击远端 channel:', peerId.substring(0,12), channelId);
2546
- alert(`远端智能体点击 — Phase 2 实现 chat 调用\n\nPeer: ${peerId}\nChannel: ${channelId}`);
2636
+ openRemoteChannelChat(peerId, channelId, channelName);
2637
+ });
2638
+ });
2639
+ // 绑定: 点击 peer 头部 → 弹分享 modal (让 A 决定分享本机哪些 channel 给这个 peer)
2640
+ list.querySelectorAll('.remote-peer-header').forEach(row => {
2641
+ row.addEventListener('click', () => {
2642
+ const peerName = row.dataset.peerName;
2643
+ const peerPk = row.dataset.peerPk;
2644
+ openShareToPeerModal(peerName, peerPk);
2547
2645
  });
2548
2646
  });
2549
2647
  }
2550
2648
 
2551
- const refreshRemoteBtn = document.getElementById('refresh-remote-agents-btn');
2552
- if (refreshRemoteBtn) {
2553
- refreshRemoteBtn.addEventListener('click', async (e) => {
2554
- e.stopPropagation(); // 防止冒泡触发折叠
2555
- refreshRemoteBtn.disabled = true;
2556
- refreshRemoteBtn.textContent = '';
2649
+ /** v3: 分享 channel 给指定 peer 的 modal (A 侧用) */
2650
+ async function openShareToPeerModal(peerName, peerPublicKey) {
2651
+ document.getElementById('share-to-peer-modal')?.remove();
2652
+ let allChannels = [];
2653
+ try {
2654
+ const res = await fetch('/channels');
2655
+ if (res.ok) allChannels = await res.json();
2656
+ } catch (err) { console.error('openShareToPeerModal:', err); }
2657
+ const rows = allChannels.length === 0
2658
+ ? '<div style="color:#6b7280;padding:12px;text-align:center;">还没有 channel</div>'
2659
+ : allChannels.map(ch => {
2660
+ const isShared = Array.isArray(ch.shared_with_peers) && ch.shared_with_peers.includes(peerPublicKey);
2661
+ return `
2662
+ <label style="display:flex;align-items:flex-start;gap:8px;padding:6px 4px;cursor:pointer;border-bottom:1px solid #f3f4f6;">
2663
+ <input type="checkbox" data-cid="${escapeHtml(ch.id)}" ${isShared ? 'checked' : ''} style="margin-top:4px;cursor:pointer;">
2664
+ <div style="flex:1;min-width:0;">
2665
+ <div style="font-size:13px;font-weight:500;">${escapeHtml(ch.name)}</div>
2666
+ <div style="font-size:10px;color:#9ca3af;margin-top:2px;">
2667
+ ${isShared ? '✓ 已分享' : '未分享'} · ${ch.id}
2668
+ </div>
2669
+ </div>
2670
+ </label>
2671
+ `;
2672
+ }).join('');
2673
+ const html = `
2674
+ <div id="share-to-peer-modal" style="position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:10003;display:flex;align-items:center;justify-content:center;">
2675
+ <div style="background:#fff;border-radius:8px;width:480px;max-width:92vw;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 10px 40px rgba(0,0,0,0.2);">
2676
+ <div style="padding:12px 16px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;justify-content:space-between;">
2677
+ <div>
2678
+ <div style="font-size:15px;font-weight:600;">分享 channel 给 ${escapeHtml(peerName)}</div>
2679
+ <div style="font-size:11px;color:#6b7280;margin-top:2px;">${escapeHtml(peerPublicKey.substring(0,16))}…</div>
2680
+ </div>
2681
+ <button id="spm-close" style="background:none;border:none;font-size:20px;color:#6b7280;cursor:pointer;">×</button>
2682
+ </div>
2683
+ <div style="padding:8px 12px;background:#f9fafb;font-size:12px;color:#6b7280;">勾选要分享的 channel, 对方才能看到</div>
2684
+ <div id="spm-list" style="flex:1;overflow-y:auto;padding:8px 16px;">${rows}</div>
2685
+ <div style="padding:12px 20px;border-top:1px solid #e5e7eb;display:flex;justify-content:flex-end;gap:8px;">
2686
+ <button id="spm-cancel" style="padding:6px 14px;border:1px solid #d1d5db;background:#fff;border-radius:4px;cursor:pointer;font-size:13px;">取消</button>
2687
+ <button id="spm-save" style="padding:6px 14px;background:#2563eb;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:13px;">保存分享</button>
2688
+ </div>
2689
+ </div>
2690
+ </div>
2691
+ `;
2692
+ document.body.insertAdjacentHTML('beforeend', html);
2693
+ const overlay = document.getElementById('share-to-peer-modal');
2694
+ document.getElementById('spm-close').onclick = () => overlay.remove();
2695
+ document.getElementById('spm-cancel').onclick = () => overlay.remove();
2696
+ document.getElementById('spm-save').onclick = async () => {
2697
+ const checkedIds = [...overlay.querySelectorAll('input[type=checkbox][data-cid]:checked')].map(el => el.dataset.cid);
2698
+ // 对每个 channel 单独 PATCH — 设 shared_with_peers 为 checked 列表
2699
+ let ok = 0, fail = 0;
2700
+ for (const ch of allChannels) {
2701
+ const shouldShare = checkedIds.includes(ch.id);
2702
+ const wasShared = Array.isArray(ch.shared_with_peers) && ch.shared_with_peers.includes(peerPublicKey);
2703
+ if (shouldShare === wasShared) continue; // 没变化跳过
2704
+ const newList = (ch.shared_with_peers || []).filter((p) => p !== peerPublicKey);
2705
+ if (shouldShare) newList.push(peerPublicKey);
2706
+ try {
2707
+ const res = await fetch(`/channels/${encodeURIComponent(ch.id)}`, {
2708
+ method: 'PATCH',
2709
+ headers: { 'Content-Type': 'application/json' },
2710
+ body: JSON.stringify({ shared_with_peers: newList })
2711
+ });
2712
+ if (res.ok) ok++; else fail++;
2713
+ } catch { fail++; }
2714
+ }
2715
+ alert(`分享更新完成: 成功 ${ok}, 失败 ${fail}`);
2716
+ overlay.remove();
2717
+ };
2718
+ }
2719
+
2720
+ /** v3: 跟远端 channel 聊天的简易弹窗 */
2721
+ function openRemoteChannelChat(peerPublicKey, channelId, channelName) {
2722
+ // 移除已有 modal
2723
+ document.getElementById('remote-chat-modal')?.remove();
2724
+ const html = `
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;">
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);">
2727
+ <div style="padding:12px 16px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;justify-content:space-between;">
2728
+ <div style="flex:1;min-width:0;">
2729
+ <div style="font-size:15px;font-weight:600;">跟 ${escapeHtml(channelName)} 聊天</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>
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>
2733
+ <button id="rcm-close" style="background:none;border:none;font-size:20px;color:#6b7280;cursor:pointer;">×</button>
2734
+ </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>
2739
+ <div style="padding:10px 12px;border-top:1px solid #e5e7eb;display:flex;gap:6px;">
2740
+ <input id="rcm-input" type="text" placeholder="输入消息, 发送到远端 channel..."
2741
+ style="flex:1;padding:8px 10px;border:1px solid #d1d5db;border-radius:4px;font-size:13px;">
2742
+ <button id="rcm-send" style="padding:8px 14px;background:#2563eb;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:13px;">发送</button>
2743
+ </div>
2744
+ </div>
2745
+ </div>
2746
+ `;
2747
+ document.body.insertAdjacentHTML('beforeend', html);
2748
+
2749
+ const log = document.getElementById('rcm-log');
2750
+ const inputEl = document.getElementById('rcm-input');
2751
+ const sendBtn = document.getElementById('rcm-send');
2752
+ const thinkingEl = document.getElementById('rcm-thinking');
2753
+ document.getElementById('rcm-close').onclick = () => document.getElementById('remote-chat-modal').remove();
2754
+ document.getElementById('rcm-refresh-history').onclick = () => loadHistory();
2755
+
2756
+ const append = (text, role) => {
2757
+ const bubble = document.createElement('div');
2758
+ const isUser = role === 'user';
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;${
2760
+ isUser ? 'background:#2563eb;color:#fff;margin-left:auto;text-align:left;'
2761
+ : 'background:#e5e7eb;color:#111;'
2762
+ }`;
2763
+ bubble.textContent = text;
2764
+ log.appendChild(bubble);
2765
+ log.scrollTop = log.scrollHeight;
2766
+ };
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
+
2833
+ const doSend = async () => {
2834
+ const text = inputEl.value.trim();
2835
+ if (!text) return;
2836
+ append(text, 'user');
2837
+ inputEl.value = '';
2838
+ sendBtn.disabled = true;
2839
+ sendBtn.textContent = '...';
2840
+ try {
2841
+ const res = await fetch('/api/remote-channels/chat-send', {
2842
+ method: 'POST',
2843
+ headers: { 'Content-Type': 'application/json' },
2844
+ body: JSON.stringify({ targetPublicKey: peerPublicKey, channelId, text })
2845
+ });
2846
+ const data = await res.json();
2847
+ if (!res.ok) throw new Error(data.error || 'send failed');
2848
+ appendSystem('已发送, 等待对方回复...', 'info');
2849
+ } catch (err) {
2850
+ append('(发送失败: ' + (err.message || err) + ')', 'ai');
2851
+ } finally {
2852
+ sendBtn.disabled = false;
2853
+ sendBtn.textContent = '发送';
2854
+ }
2855
+ };
2856
+ sendBtn.onclick = doSend;
2857
+ inputEl.onkeydown = (e) => { if (e.key === 'Enter') doSend(); };
2858
+ inputEl.focus();
2859
+ startV3GlobalSSE();
2860
+
2861
+ // 打开时立即拉历史
2862
+ loadHistory();
2863
+ }
2864
+
2865
+ // Phase 3: 我的 ID 按钮 → 弹窗显示并支持复制自己的 P2PDirect publicKey
2866
+ const showMyIdBtn = document.getElementById('show-my-p2p-id-btn');
2867
+ if (showMyIdBtn) {
2868
+ showMyIdBtn.addEventListener('click', async (e) => {
2869
+ e.stopPropagation();
2870
+ try {
2871
+ const res = await fetch('/api/p2p-publickey');
2872
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
2873
+ const data = await res.json();
2874
+ const pk = data.publicKey || '';
2875
+ if (!pk || pk.length !== 64) {
2876
+ alert('P2PDirect 还没启动, 刷新页面稍后再试');
2877
+ return;
2878
+ }
2879
+ // 显示 + 一键复制
2880
+ const ok = confirm(
2881
+ `我的 P2P publicKey (64 字符 hex):\n\n${pk}\n\n` +
2882
+ `点 "确定" 复制到剪贴板, 发给好友.\n` +
2883
+ `好友点 "+ 好友" 粘贴这个 ID 就能加我.`
2884
+ );
2885
+ if (ok) {
2886
+ try {
2887
+ await navigator.clipboard.writeText(pk);
2888
+ alert('✓ 已复制到剪贴板');
2889
+ } catch {
2890
+ // 旧浏览器 fallback
2891
+ const ta = document.createElement('textarea');
2892
+ ta.value = pk;
2893
+ document.body.appendChild(ta);
2894
+ ta.select();
2895
+ document.execCommand('copy');
2896
+ document.body.removeChild(ta);
2897
+ alert('✓ 已复制到剪贴板 (fallback)');
2898
+ }
2899
+ }
2900
+ } catch (err) {
2901
+ alert('获取 publicKey 失败: ' + (err.message || err));
2902
+ }
2903
+ });
2904
+ }
2905
+
2906
+ // Phase 3 重做: + 添加好友按钮 → 弹窗输入 publicKey + name, 同时 joinPeer
2907
+ const addPeerBtn = document.getElementById('add-p2p-peer-btn');
2908
+ if (addPeerBtn) {
2909
+ addPeerBtn.addEventListener('click', async (e) => {
2910
+ e.stopPropagation();
2911
+ const name = prompt('给这个 P2P 好友起个名字 (如: 同事-张磊)');
2912
+ if (!name) return;
2913
+ const publicKey = prompt('粘贴对方的 P2PDirect publicKey (64 字符 hex):\n\n获取方式: 对方在 http://localhost:54188/api/p2p-publickey');
2914
+ if (!publicKey) return;
2915
+ if (publicKey.length !== 64) {
2916
+ alert('publicKey 长度不对, 应该是 64 字符 hex');
2917
+ return;
2918
+ }
2919
+ try {
2920
+ // 调 p2p-connect (会 joinPeer + 持久化)
2921
+ const res = await fetch('/api/remote-channels/p2p-connect', {
2922
+ method: 'POST',
2923
+ headers: { 'Content-Type': 'application/json' },
2924
+ body: JSON.stringify({ targetPublicKey: publicKey, name, persist: true })
2925
+ });
2926
+ const data = await res.json();
2927
+ if (!res.ok) throw new Error(data.error || 'connect failed');
2928
+ alert(`已添加好友: ${name} (${publicKey.substring(0, 12)}...)\n重启后会自动重连`);
2929
+ await loadRemoteChannels();
2930
+ } catch (err) {
2931
+ alert('添加失败: ' + (err.message || err));
2932
+ }
2933
+ });
2934
+ }
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 = '...';
2557
2944
  try {
2558
2945
  const res = await fetch('/api/remote-channels/refresh', { method: 'POST' });
2559
2946
  const data = await res.json();
2560
- console.log('[v3] 刷新远端智能体:', data);
2561
- setTimeout(loadRemoteChannels, 1500);
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 请求`);
2562
2952
  } catch (err) {
2563
- console.error('[v3] 刷新失败:', err);
2953
+ alert('刷新失败: ' + (err.message || err));
2564
2954
  } finally {
2565
- setTimeout(() => {
2566
- refreshRemoteBtn.disabled = false;
2567
- refreshRemoteBtn.textContent = '↻ 刷新';
2568
- }, 2000);
2955
+ refreshSharedBtn.disabled = false;
2956
+ refreshSharedBtn.textContent = originalText;
2569
2957
  }
2570
2958
  });
2571
2959
  }
@@ -69,17 +69,21 @@
69
69
  <div class="split-handle-grip"></div>
70
70
  </div>
71
71
 
72
- <!-- v3: 远端智能体 — 从 P2P 连接的 peer 拉取的 channel UI 元数据 -->
72
+ <!-- v3 远端 P2P 好友 显式分享 + 持久化连接 -->
73
73
  <div class="sidebar-section" id="remote-agents-section">
74
74
  <div class="section-header" id="remote-agents-header" style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;cursor:pointer;user-select:none;">
75
75
  <span style="display:flex;align-items:center;gap:6px;">
76
76
  <span id="remote-agents-toggle" style="font-size:10px;color:var(--text-muted);transition:transform 0.2s;">▼</span>
77
- <span class="section-title" style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);">远端智能体</span>
77
+ <span class="section-title" style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);">P2P 好友</span>
78
+ </span>
79
+ <span style="display:flex;gap:4px;">
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>
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>
78
83
  </span>
79
- <button id="refresh-remote-agents-btn" title="从已连接 P2P peer 拉取" 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>
80
84
  </div>
81
85
  <ul class="channel-list" id="remote-channel-list" style="list-style:none;padding:0;margin:0;">
82
- <li style="color:var(--text-muted);font-size:11px;padding:8px 4px;text-align:center;">(暂无, 刷新)</li>
86
+ <li style="color:var(--text-muted);font-size:11px;padding:8px 4px;text-align:center;">(暂无好友,+ 添加)</li>
83
87
  </ul>
84
88
  </div>
85
89
 
@@ -120,15 +124,7 @@
120
124
  <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
121
125
  </svg>
122
126
  </button>
123
- <button id="p2p-network-btn" class="header-action" title="P2P 网络">
124
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
125
- <circle cx="12" cy="5" r="3"></circle>
126
- <circle cx="5" cy="19" r="3"></circle>
127
- <circle cx="19" cy="19" r="3"></circle>
128
- <line x1="12" y1="8" x2="5" y2="16"></line>
129
- <line x1="12" y1="8" x2="19" y2="16"></line>
130
- </svg>
131
- </button>
127
+ <!-- v3: 顶栏 P2P 按钮 (旧 iroh/Diap 路径) 已移除, 改用侧边栏 "P2P 好友" "+ 好友" -->
132
128
  <button id="wallet-btn" class="header-action" title="钱包管理">
133
129
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
134
130
  <path d="M21 12V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-3"></path>