@bolloon/bolloon-agent 0.1.28 → 0.1.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,45 +2857,76 @@ 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
- // Phase 3: 我的 ID 按钮 → 弹窗显示并支持复制自己的 P2PDirect publicKey
2865
+ // Phase 3: 我的 ID 按钮 → modal (避免 confirm 在某些环境被禁用)
2750
2866
  const showMyIdBtn = document.getElementById('show-my-p2p-id-btn');
2751
2867
  if (showMyIdBtn) {
2752
2868
  showMyIdBtn.addEventListener('click', async (e) => {
2753
2869
  e.stopPropagation();
2870
+ // 移除已有 modal
2871
+ document.getElementById('my-p2p-id-modal')?.remove();
2872
+ // 立即弹出 loading 状态 modal
2873
+ const html = `
2874
+ <div id="my-p2p-id-modal" style="position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10003;display:flex;align-items:center;justify-content:center;">
2875
+ <div style="background:#fff;border-radius:8px;width:480px;max-width:92vw;display:flex;flex-direction:column;box-shadow:0 10px 40px rgba(0,0,0,0.2);">
2876
+ <div style="padding:14px 18px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;justify-content:space-between;">
2877
+ <div style="font-size:15px;font-weight:600;">🪪 我的 P2P 身份</div>
2878
+ <button id="mpim-close" style="background:none;border:none;font-size:20px;color:#6b7280;cursor:pointer;">×</button>
2879
+ </div>
2880
+ <div id="mpim-body" style="padding:16px 18px;">
2881
+ <div style="color:#6b7280;font-size:13px;margin-bottom:10px;">正在获取 publicKey…</div>
2882
+ </div>
2883
+ </div>
2884
+ </div>
2885
+ `;
2886
+ document.body.insertAdjacentHTML('beforeend', html);
2887
+ document.getElementById('mpim-close').onclick = () => document.getElementById('my-p2p-id-modal').remove();
2888
+
2754
2889
  try {
2755
2890
  const res = await fetch('/api/p2p-publickey');
2756
2891
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
2757
2892
  const data = await res.json();
2758
2893
  const pk = data.publicKey || '';
2894
+ const body = document.getElementById('mpim-body');
2759
2895
  if (!pk || pk.length !== 64) {
2760
- alert('P2PDirect 还没启动, 刷新页面稍后再试');
2896
+ body.innerHTML = `<div style="color:#b91c1c;font-size:13px;">✗ P2PDirect 还没启动, 刷新页面稍后再试</div>`;
2761
2897
  return;
2762
2898
  }
2763
- // 显示 + 一键复制
2764
- const ok = confirm(
2765
- `我的 P2P publicKey (64 字符 hex):\n\n${pk}\n\n` +
2766
- `点 "确定" 复制到剪贴板, 发给好友.\n` +
2767
- `好友点 "+ 好友" 粘贴这个 ID 就能加我.`
2768
- );
2769
- if (ok) {
2899
+ body.innerHTML = `
2900
+ <div style="font-size:12px;color:#6b7280;margin-bottom:8px;">把下面这串发给好友, 好友在 P2P 好友区点 "+ 好友" 粘贴即可加你:</div>
2901
+ <div style="display:flex;gap:6px;align-items:center;margin-bottom:12px;">
2902
+ <code id="mpim-pk" style="flex:1;padding:8px 10px;background:#f3f4f6;border:1px solid #d1d5db;border-radius:4px;font-family:monospace;font-size:11px;word-break:break-all;line-height:1.4;">${escapeHtml(pk)}</code>
2903
+ <button id="mpim-copy" style="padding:8px 14px;background:#2563eb;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:13px;white-space:nowrap;">📋 复制</button>
2904
+ </div>
2905
+ <div id="mpim-status" style="font-size:12px;color:#059669;min-height:16px;"></div>
2906
+ <div style="margin-top:14px;padding-top:12px;border-top:1px solid #e5e7eb;font-size:11px;color:#6b7280;">
2907
+ 💡 同一个 role 重启后 publicKey 不会变, 好友不需要重新加你.
2908
+ </div>
2909
+ `;
2910
+ document.getElementById('mpim-copy').onclick = async () => {
2911
+ const statusEl = document.getElementById('mpim-status');
2770
2912
  try {
2771
2913
  await navigator.clipboard.writeText(pk);
2772
- alert('✓ 已复制到剪贴板');
2914
+ statusEl.textContent = '✓ 已复制到剪贴板';
2773
2915
  } catch {
2774
- // 旧浏览器 fallback
2775
2916
  const ta = document.createElement('textarea');
2776
2917
  ta.value = pk;
2918
+ ta.style.position = 'fixed';
2919
+ ta.style.opacity = '0';
2777
2920
  document.body.appendChild(ta);
2778
2921
  ta.select();
2779
- document.execCommand('copy');
2922
+ try { document.execCommand('copy'); statusEl.textContent = '✓ 已复制 (fallback)'; }
2923
+ catch { statusEl.textContent = '✗ 复制失败, 请手动选中复制'; }
2780
2924
  document.body.removeChild(ta);
2781
- alert('✓ 已复制到剪贴板 (fallback)');
2782
2925
  }
2783
- }
2926
+ };
2784
2927
  } catch (err) {
2785
- alert('获取 publicKey 失败: ' + (err.message || err));
2928
+ const body = document.getElementById('mpim-body');
2929
+ if (body) body.innerHTML = `<div style="color:#b91c1c;font-size:13px;">✗ 获取失败: ${escapeHtml(err.message || String(err))}</div>`;
2786
2930
  }
2787
2931
  });
2788
2932
  }
@@ -2817,6 +2961,31 @@ if (addPeerBtn) {
2817
2961
  });
2818
2962
  }
2819
2963
 
2964
+ // v3 双向刷新: 主动向所有好友发 agent.meta.list, 拿到 ta 们分享给我的 channel
2965
+ const refreshSharedBtn = document.getElementById('refresh-shared-btn');
2966
+ if (refreshSharedBtn) {
2967
+ refreshSharedBtn.addEventListener('click', async (e) => {
2968
+ e.stopPropagation();
2969
+ const originalText = refreshSharedBtn.textContent;
2970
+ refreshSharedBtn.disabled = true;
2971
+ refreshSharedBtn.textContent = '...';
2972
+ try {
2973
+ const res = await fetch('/api/remote-channels/refresh', { method: 'POST' });
2974
+ const data = await res.json();
2975
+ if (!res.ok) throw new Error(data.error || 'refresh failed');
2976
+ // 等 1.5s 让 RPC 回复回来 (向所有 peer 广播)
2977
+ await new Promise(r => setTimeout(r, 1500));
2978
+ await loadRemoteChannels();
2979
+ console.log(`[v3] 双向刷新: 向 ${data.peerCount || 0} 个好友发 list 请求`);
2980
+ } catch (err) {
2981
+ alert('刷新失败: ' + (err.message || err));
2982
+ } finally {
2983
+ refreshSharedBtn.disabled = false;
2984
+ refreshSharedBtn.textContent = originalText;
2985
+ }
2986
+ });
2987
+ }
2988
+
2820
2989
  // 启动时拉一次 + 定期轮询 (SSE 接收 P2P reply 后也会更新)
2821
2990
  loadRemoteChannels();
2822
2991
  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>