@bolloon/bolloon-agent 0.1.33 → 0.1.35

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.
Files changed (80) hide show
  1. package/.auto-evolve-calls +1 -0
  2. package/.last-auto-evolve-baseline +1 -0
  3. package/Bolloon.md +103 -0
  4. package/README.md +7 -2
  5. package/dist/agents/pi-sdk.js +264 -12
  6. package/dist/bollharness-integration/index.js +8 -1
  7. package/dist/bootstrap/bootstrap.js +114 -0
  8. package/dist/bootstrap/context-collector.js +296 -0
  9. package/dist/bootstrap/lifecycle-hooks.js +109 -0
  10. package/dist/bootstrap/project-context.js +151 -0
  11. package/dist/heartbeat/Watchdog.js +9 -1
  12. package/dist/index.js +11 -0
  13. package/dist/llm/pi-ai.js +31 -21
  14. package/dist/network/p2p-direct.js +59 -2
  15. package/dist/pi-ecosystem/index.js +9 -6
  16. package/dist/pi-ecosystem-judgment/adaptive-scan.js +231 -0
  17. package/dist/pi-ecosystem-judgment/causal-judge.js +449 -0
  18. package/dist/pi-ecosystem-judgment/decision.js +5 -2
  19. package/dist/pi-ecosystem-judgment/detect-hook.js +168 -0
  20. package/dist/pi-ecosystem-judgment/distill-prompt.js +226 -0
  21. package/dist/pi-ecosystem-judgment/evolve-judgment.js +170 -0
  22. package/dist/pi-ecosystem-judgment/human-value-pipeline.js +21 -0
  23. package/dist/pi-ecosystem-judgment/human-value-store.js +283 -22
  24. package/dist/pi-ecosystem-judgment/injection-gate.js +166 -0
  25. package/dist/pi-ecosystem-judgment/monitor-gate.js +188 -0
  26. package/dist/security/builtin-guards.js +124 -0
  27. package/dist/security/context-router-tool.js +106 -0
  28. package/dist/security/react-harness.js +143 -0
  29. package/dist/security/tool-gate.js +235 -0
  30. package/dist/social/heartbeat.js +19 -2
  31. package/dist/utils/auto-evolve-policy.js +117 -0
  32. package/dist/utils/clamp.js +7 -0
  33. package/dist/utils/double.js +6 -0
  34. package/dist/web/api-config.html +3 -3
  35. package/dist/web/client.js +1328 -351
  36. package/dist/web/index.html +34 -31
  37. package/dist/web/server.js +1128 -58
  38. package/dist/web/style.css +370 -0
  39. package/lefthook.yml +29 -0
  40. package/package.json +4 -2
  41. package/scripts/auto-evolve-loop.ts +376 -0
  42. package/scripts/auto-evolve-oneshot.sh +155 -0
  43. package/scripts/auto-evolve-snapshot.sh +136 -0
  44. package/scripts/detect-schema-changes.sh +48 -0
  45. package/scripts/diff-reviewer.ts +159 -0
  46. package/scripts/weekly-report.ts +364 -0
  47. package/src/agents/pi-sdk.ts +293 -15
  48. package/src/bollharness-integration/index.ts +8 -32
  49. package/src/bootstrap/bootstrap.ts +132 -0
  50. package/src/bootstrap/context-collector.ts +342 -0
  51. package/src/bootstrap/lifecycle-hooks.ts +176 -0
  52. package/src/bootstrap/project-context.ts +163 -0
  53. package/src/heartbeat/Watchdog.ts +9 -1
  54. package/src/index.ts +11 -0
  55. package/src/llm/pi-ai.ts +33 -22
  56. package/src/network/p2p-direct.ts +59 -3
  57. package/src/security/builtin-guards.ts +162 -0
  58. package/src/security/context-router-tool.ts +122 -0
  59. package/src/security/react-harness.ts +177 -0
  60. package/src/security/tool-gate.ts +294 -0
  61. package/src/social/ant-colony/index.js +19 -0
  62. package/src/social/heartbeat.ts +18 -2
  63. package/src/utils/auto-evolve-policy.ts +138 -0
  64. package/src/utils/clamp.ts +5 -0
  65. package/src/web/api-config.html +3 -3
  66. package/src/web/client.js +1328 -351
  67. package/src/web/index.html +34 -31
  68. package/src/web/server.ts +1179 -53
  69. package/src/web/style.css +370 -0
  70. package/staging/auto-evolve/clean-001/.review-verdict +9 -0
  71. package/staging/auto-evolve/clean-001/clean-001.patch +14 -0
  72. package/staging/auto-evolve/e2e-001/.patch-id +1 -0
  73. package/staging/auto-evolve/e2e-001/.review-verdict +12 -0
  74. package/staging/auto-evolve/e2e-001/e2e-001.patch +11 -0
  75. package/staging/auto-evolve/test-bad/.review-verdict +12 -0
  76. package/staging/auto-evolve/test-bad/test-bad.patch +11 -0
  77. package/src/social/ant-colony/AdaptiveHeartbeat.ts +0 -131
  78. package/src/social/ant-colony/PheromoneEngine.ts +0 -302
  79. package/src/social/ant-colony/index.ts +0 -18
  80. package/src/social/ant-colony/types.ts +0 -94
package/src/web/client.js CHANGED
@@ -4,8 +4,6 @@ if (typeof marked === 'undefined') {
4
4
  }
5
5
 
6
6
  const messagesEl = document.getElementById('messages');
7
- const agentStatusEl = document.getElementById('agent-status');
8
- const agentStatusTextEl = document.getElementById('agent-status-text');
9
7
  const input = document.getElementById('input');
10
8
  const sendBtn = document.getElementById('send');
11
9
  const sidebar = document.getElementById('sidebar');
@@ -29,6 +27,62 @@ let reconnectAttempts = new Map(); // channelId -> attempts
29
27
  let reconnectTimers = new Map(); // channelId -> timer
30
28
  let heartbeatTimers = new Map(); // channelId -> setInterval handle (防止泄漏)
31
29
  let lastUserCommand = ''; // 防止用户消息重复显示
30
+
31
+ // 2026-06-10: P2P peer-group 折叠状态持久化 (跨刷新)
32
+ // key = bolloon.p2p.collapsedPeers, value = JSON array of publicKey hex
33
+ const COLLAPSED_PEERS_KEY = 'bolloon.p2p.collapsedPeers';
34
+ const SEEN_PEERS_KEY = 'bolloon.p2p.seenPeers';
35
+ let collapsedPeers = (function loadCollapsed() {
36
+ try {
37
+ const raw = localStorage.getItem(COLLAPSED_PEERS_KEY);
38
+ return new Set(raw ? JSON.parse(raw) : []);
39
+ } catch { return new Set(); }
40
+ })();
41
+ let seenPeers = (function loadSeen() {
42
+ try {
43
+ const raw = localStorage.getItem(SEEN_PEERS_KEY);
44
+ return new Set(raw ? JSON.parse(raw) : []);
45
+ } catch { return new Set(); }
46
+ })();
47
+ function saveCollapsedPeers() {
48
+ try { localStorage.setItem(COLLAPSED_PEERS_KEY, JSON.stringify([...collapsedPeers])); } catch {}
49
+ }
50
+ function saveSeenPeers() {
51
+ try { localStorage.setItem(SEEN_PEERS_KEY, JSON.stringify([...seenPeers])); } catch {}
52
+ }
53
+ function togglePeerCollapsed(peerPk) {
54
+ if (collapsedPeers.has(peerPk)) {
55
+ collapsedPeers.delete(peerPk);
56
+ } else {
57
+ collapsedPeers.add(peerPk);
58
+ }
59
+ saveCollapsedPeers();
60
+ renderRemoteChannels();
61
+ // 2026-06-10: 通知 header 切换按钮同步图标
62
+ if (typeof window.__syncP2PToggleAllBtn === 'function') window.__syncP2PToggleAllBtn();
63
+ }
64
+ // 2026-06-10: 一键展开/折叠所有 P2P peer (header 按钮调用)
65
+ function expandAllPeers() {
66
+ // 从 remoteChannels + knownPeers 收集所有 publicKey
67
+ const allPks = new Set([
68
+ ...knownPeers.map(p => p.publicKey),
69
+ ...remoteChannels.map(g => g.peerId)
70
+ ]);
71
+ for (const pk of allPks) collapsedPeers.delete(pk);
72
+ saveCollapsedPeers();
73
+ renderRemoteChannels();
74
+ if (typeof window.__syncP2PToggleAllBtn === 'function') window.__syncP2PToggleAllBtn();
75
+ }
76
+ function collapseAllPeers() {
77
+ const allPks = new Set([
78
+ ...knownPeers.map(p => p.publicKey),
79
+ ...remoteChannels.map(g => g.peerId)
80
+ ]);
81
+ for (const pk of allPks) collapsedPeers.add(pk);
82
+ saveCollapsedPeers();
83
+ renderRemoteChannels();
84
+ if (typeof window.__syncP2PToggleAllBtn === 'function') window.__syncP2PToggleAllBtn();
85
+ }
32
86
  let lastAiContent = ''; // 防止 AI 消息重复显示
33
87
  let messagesContainers = new Map(); // channelId -> messages container div
34
88
  let sessionMessages = new Map(); // channelId:sessionId -> messages array
@@ -100,6 +154,8 @@ async function loadChannels() {
100
154
  channels.forEach((ch, i) => {
101
155
  console.log(` [${i}] ${ch.name} - did: "${ch.did}"`);
102
156
  });
157
+ // 2026-06-11: 全部默认不展开 (用户需要手动点 caret 展开 session 列表)
158
+ // 之前默认展开第一个会喧宾夺主, 用户看不到完整 channel 列表
103
159
  renderChannels();
104
160
  } catch (err) {
105
161
  console.error('[加载频道] 失败:', err);
@@ -116,20 +172,34 @@ function startV3GlobalSSE() {
116
172
  try {
117
173
  const msg = JSON.parse(e.data);
118
174
  if (msg.type === 'remote-chat-reply') {
119
- // 找到当前打开的远端 chat modal log
175
+ // 2026-06-10: 复用本地 addMessage 渲染 — 自动 marked + 剥 think/env + 主题样式
176
+ // 之前是 textContent 硬编码灰底, 跟 Step 3 重写的 modal 风格不一致,
177
+ // 而且 SSE 异步回到时 modal 可能已被切到 thinking 占满, 用户看不到 reply.
120
178
  const log = document.getElementById('rcm-log');
121
179
  const thinkingEl = document.getElementById('rcm-thinking');
122
180
  if (thinkingEl) thinkingEl.style.display = 'none'; // 思考结束, 隐藏
181
+ // 也清掉 "对方正在思考..." 行 (流式 token 留下的)
182
+ const liveThinking = document.getElementById('rcm-thinking-live');
183
+ if (liveThinking) liveThinking.remove();
123
184
  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
185
  if (msg.error) {
128
- bubble.textContent = '(错误: ' + msg.error + ')';
129
- bubble.style.background = '#fca5a5';
186
+ // 错误用 sysmsg 样式 (跟 modal 风格一致)
187
+ const errEl = document.createElement('div');
188
+ errEl.className = 'remote-chat-sysmsg remote-chat-sysmsg-error';
189
+ errEl.textContent = `❌ 对方回复出错: ${msg.error}`;
190
+ log.appendChild(errEl);
191
+ } else {
192
+ // 走本地 addMessage, 跟主聊天框完全一致 (marked + think/env 折叠 + 主题色)
193
+ const prefix = `🤖 远端 AI 回复\n\n`;
194
+ addMessage(prefix + (msg.text || '(空回复)'), 'ai', false, log);
130
195
  }
131
- log.appendChild(bubble);
132
196
  log.scrollTop = log.scrollHeight;
197
+ } else {
198
+ // modal 没开 → 用右下 toast 提示用户"对方回了, 打开聊天看"
199
+ if (typeof showSimpleToast === 'function') {
200
+ const preview = (msg.text || '').slice(0, 50);
201
+ showSimpleToast(`💬 远端 channel 有新回复: ${preview}${msg.text && msg.text.length > 50 ? '…' : ''}`);
202
+ }
133
203
  }
134
204
  } else if (msg.type === 'remote-chat-thinking') {
135
205
  // v3 新增: B 端实时显示 A 节点的思考过程
@@ -182,6 +252,44 @@ function startV3GlobalSSE() {
182
252
  log.appendChild(toast);
183
253
  log.scrollTop = log.scrollHeight;
184
254
  }
255
+ } else if (msg.type === 'remote-channel-update') {
256
+ // v3 新增: 远端节点发来新分享 / 删除 / 改名, 立即更新本地 cache
257
+ const peerId = msg.peerId;
258
+ const channels = msg.channels || [];
259
+ const peerName = msg.peerName || null; // 2026-06-10: 同步接收对方名字
260
+ let group = remoteChannels.find(g => g.peerId === peerId);
261
+ if (!group) {
262
+ group = { peerId, channels: [], peerName: peerName || ('peer-' + peerId.substring(0, 8)) };
263
+ remoteChannels.push(group);
264
+ } else if (peerName) {
265
+ group.peerName = peerName; // 更新名字
266
+ }
267
+ group.channels = channels;
268
+ // 2026-06-10: 如果对面告知名字, 同步刷新 knownPeers 列表, 避免陌生 peer 状态
269
+ if (peerName && !knownPeers.find(p => p.publicKey === peerId)) {
270
+ knownPeers.push({
271
+ publicKey: peerId,
272
+ name: peerName,
273
+ addedAt: new Date().toISOString(),
274
+ lastConnectedAt: new Date().toISOString(),
275
+ });
276
+ console.log(`[v3] 远端 ${peerId.substring(0,12)}... 自报名字 = ${peerName}, 已加到 knownPeers`);
277
+ }
278
+ renderRemoteChannels();
279
+ console.log(`[v3] 收到远端 ${peerId.substring(0,12)}... 的 ${channels.length} 个 channel 更新 (name=${peerName || '?'})`);
280
+ } else if (msg.type === 'friend-request') {
281
+ // v3 新增: 收到好友申请
282
+ showFriendRequestModal(msg);
283
+ } else if (msg.type === 'friend-request-ack') {
284
+ // 2026-06-10: 收到对方 ack, 给发送方提示"已送达"
285
+ const pending = window.__pendingFriendRequests;
286
+ if (pending && msg.requestId && pending.has(msg.requestId)) {
287
+ const { name } = pending.get(msg.requestId);
288
+ pending.delete(msg.requestId);
289
+ console.log(`[v3-friend] ✅ ack 收到: ${name} 已收到好友申请`);
290
+ // 简短 toast (右下角), 不阻塞
291
+ showSimpleToast(`📬 ${name} 已收到你的好友申请, 等对方接受`);
292
+ }
185
293
  }
186
294
  } catch (err) {
187
295
  console.error('[v3] 全局 SSE 解析失败:', err);
@@ -489,6 +597,50 @@ function toggleAgentExpand(channelId, e) {
489
597
  renderChannels();
490
598
  }
491
599
 
600
+ /**
601
+ * 2026-06-11 性能优化: 切 channel 时用轻量 patch, 不重建整个 sidebar 列表
602
+ * 只更新: (1) active class (2) 当前 session label + count (3) expanded 状态
603
+ * 避免每次切 channel 都 innerHTML='' + 重建 ~10 个 channel 节点
604
+ */
605
+ function renderChannelsLite(activeChannelId, activeSessionId) {
606
+ if (!channelList) return;
607
+ // 1. 更新所有 .agent-row 的 active class
608
+ channelList.querySelectorAll('.agent-row').forEach(row => {
609
+ const li = row.closest('.agent-group');
610
+ const chId = li?.dataset.channelId;
611
+ row.classList.toggle('active', chId === activeChannelId);
612
+ });
613
+ // 2. 当前 channel 的展开状态: 强制展开, 其他不动
614
+ if (activeChannelId) expandedAgents.add(activeChannelId);
615
+ // 3. 当前 channel 行展开 + 只切 session-item 的 active class (不再 innerHTML 重渲!)
616
+ // 原因: 重渲 innerHTML 会清掉原始 renderChannels 绑的 session-item click handler,
617
+ // 即使补绑也会因为 lite HTML 结构 (.session-dot + .session-msg-count) 跟原始不同
618
+ // 导致"第 1 次点不动 (原始), 第 2 次点才能用 (lite)" 现象
619
+ // 修法: 完全不动 session-list DOM, 只 toggle .active
620
+ const activeLi = channelList.querySelector(`.agent-group[data-channel-id="${activeChannelId}"]`);
621
+ if (activeLi) {
622
+ activeLi.classList.add('expanded');
623
+ // 只切 active class, 不动 innerHTML (避免清掉原始 click handler)
624
+ const ch = channels.find(c => c.id === activeChannelId);
625
+ // 2026-06-11: 原始 renderChannels 已经给 session-item 加了 data-session-id (line 791),
626
+ // 这里先清空所有 .active 再设新的, 避免多个 active 共存 (因为 renderChannels 初始 DOM
627
+ // 上会有一个 active 标记旧 session, 新切 session 容易出现两个 active)
628
+ activeLi.querySelectorAll('.session-item').forEach(sessLi => {
629
+ const sessId = sessLi.dataset.sessionId;
630
+ const shouldBeActive = sessId === activeSessionId;
631
+ sessLi.classList.toggle('active', shouldBeActive);
632
+ });
633
+ // 更新顶部 current session label
634
+ if (ch) {
635
+ const currentSess = Array.isArray(ch.sessions) ? ch.sessions.find(s => s.id === activeSessionId) : null;
636
+ const labelEl = activeLi.querySelector('.agent-current-session');
637
+ if (labelEl) {
638
+ labelEl.textContent = currentSess ? '· ' + formatSessionName(currentSess) : '';
639
+ }
640
+ }
641
+ }
642
+ }
643
+
492
644
  function renderChannels() {
493
645
  if (!channelList) return;
494
646
  channelList.innerHTML = '';
@@ -532,12 +684,9 @@ function renderChannels() {
532
684
  const currentSessLabel = currentSess ? formatSessionName(currentSess) : '';
533
685
  const sessionCount = Array.isArray(ch.sessions) ? ch.sessions.length : 0;
534
686
 
535
- const walletBadge = ch.walletAddress
536
- ? `<span class="agent-wallet-badge" title="已绑定钱包: ${escapeHtml(ch.walletAddress)}">⛓</span>`
537
- : '';
538
- const toolsBadge = ch.autoInvokeTools
539
- ? `<span class="agent-tools-badge" title="自动工具调用已开启">⚡</span>`
540
- : '';
687
+ // 2026-06-10: 隐藏 channel 行右侧的勋章 (钱包 / 工具) — UI 简洁
688
+ const walletBadge = '';
689
+ const toolsBadge = '';
541
690
 
542
691
  row.innerHTML = `
543
692
  <svg class="agent-caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -621,6 +770,7 @@ function renderChannels() {
621
770
  const sessLi = document.createElement('li');
622
771
  const isActive = ch.id === currentChannelId && sess.id === ch.currentSessionId;
623
772
  sessLi.className = `session-item ${isActive ? 'active' : ''}`;
773
+ sessLi.dataset.sessionId = sess.id; // 2026-06-11: 给 session-item 加上 data-session-id, renderChannelsLite 才能 toggle active class
624
774
  sessLi.innerHTML = `
625
775
  <span class="session-name" title="${escapeHtml(formatSessionName(sess))}">${escapeHtml(formatSessionName(sess))}</span>
626
776
  <button class="session-delete" title="删除会话">×</button>
@@ -731,7 +881,11 @@ async function selectChannel(channelId, targetSessionId = null) {
731
881
  console.log('[selectChannel] 频道:', channel.name, 'session:', currentSessionId);
732
882
  }
733
883
 
734
- renderChannels();
884
+ // 2026-06-11 提速: 切 channel 时 sidebar 渲染降级 — 只更新 active 样式, 不重渲整列表
885
+ // renderChannels() 仍然要调 (current session label 等可能变了), 但加一层判断: 如果只是切 channel (没增删), 走 patch 路径
886
+ const t0 = performance.now();
887
+ renderChannelsLite(channelId, currentSessionId);
888
+ console.log(`[selectChannel] renderChannelsLite 耗时 ${(performance.now() - t0).toFixed(1)}ms`);
735
889
 
736
890
  // 确保该频道有消息容器
737
891
  const container = ensureMessageContainer(channelId);
@@ -750,10 +904,19 @@ async function selectChannel(channelId, targetSessionId = null) {
750
904
  try {
751
905
  const res = await fetch(`/sessions/${channelId}?sessionId=${encodeURIComponent(currentSessionId)}`);
752
906
  const session = await res.json();
753
- if (session.messages && session.messages.length > 0) {
754
- session.messages.forEach(msg => {
755
- addMessage(msg.content, msg.type, false, container);
756
- });
907
+ const msgs = session.messages || [];
908
+ if (msgs.length > 0) {
909
+ // 2026-06-11 提速: 用 DocumentFragment 一次性 append 避免多次 reflow
910
+ const frag = document.createDocumentFragment();
911
+ const tmpContainer = document.createElement('div');
912
+ tmpContainer.style.display = 'none';
913
+ for (const msg of msgs) {
914
+ addMessage(msg.content, msg.type, false, tmpContainer, msg.metadata?.usedJudgmentIds || []);
915
+ }
916
+ while (tmpContainer.firstChild) {
917
+ frag.appendChild(tmpContainer.firstChild);
918
+ }
919
+ container.appendChild(frag);
757
920
  } else {
758
921
  addMessage('你好!我是 Bolloon Agent。有什么我可以帮你的吗?', 'ai', false, container);
759
922
  }
@@ -773,7 +936,7 @@ async function loadSession(channelId, sessionId = null) {
773
936
  container.innerHTML = '';
774
937
  if (session.messages && session.messages.length > 0) {
775
938
  session.messages.forEach(msg => {
776
- addMessage(msg.content, msg.type, false, container);
939
+ addMessage(msg.content, msg.type, false, container, msg.metadata?.usedJudgmentIds || []);
777
940
  });
778
941
  } else {
779
942
  addMessage('你好!我是 Bolloon Agent。有什么我可以帮你的吗?', 'ai', false, container);
@@ -785,7 +948,7 @@ async function loadSession(channelId, sessionId = null) {
785
948
  }
786
949
  }
787
950
 
788
- function addMessage(content, type, save = true, container) {
951
+ function addMessage(content, type, save = true, container, usedJudgmentIds = []) {
789
952
  const msgContainer = container || messagesContainers.get(currentChannelId) || messagesEl;
790
953
 
791
954
  // 浏览器侧内存保护: 单个 channel 的消息容器超过 MAX_MESSAGES_PER_CHANNEL
@@ -816,12 +979,6 @@ function addMessage(content, type, save = true, container) {
816
979
  const div = document.createElement('div');
817
980
  div.className = `message message-${type}`;
818
981
 
819
- // 清理工具结果容器(当新的 AI 消息到达时)
820
- if (type === 'ai' && toolResultContainer) {
821
- toolResultContainer.remove();
822
- toolResultContainer = null;
823
- }
824
-
825
982
  // 清理内容:移除 tool call 标记和其他不应该显示的内容
826
983
  let cleanContent = content
827
984
  .replace(/\[TOOL_CALL\][\s\S]*?\[\/TOOL_CALL\]/g, '')
@@ -1005,12 +1162,13 @@ function addMessage(content, type, save = true, container) {
1005
1162
 
1006
1163
  actionsDiv.appendChild(copyBtn);
1007
1164
 
1008
- // "存为判断" 按钮: 把这条消息正文作为 decision 存到判断库
1165
+ // "存为判断" 按钮: AI 蒸馏 (30-80 字) + 自动演化对齐
1009
1166
  const saveJudgmentBtn = document.createElement('button');
1010
1167
  saveJudgmentBtn.className = 'action-btn save-as-judgment';
1011
- saveJudgmentBtn.title = '把这条消息存为判断';
1012
- saveJudgmentBtn.setAttribute('data-decision', rawContent.substring(0, 800)); // 截断防超长
1013
- saveJudgmentBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L4 6v6c0 5 3.5 9.5 8 10 4.5-.5 8-5 8-10V6l-8-4z"></path><path d="M9 12l2 2 4-4"></path></svg> 存为判断`;
1168
+ saveJudgmentBtn.title = 'AI 蒸馏为 30-80 字判断力 + 自动演化对齐';
1169
+ saveJudgmentBtn.setAttribute('data-decision', rawContent.substring(0, 800));
1170
+ if (currentChannelId) saveJudgmentBtn.setAttribute('data-channel-id', currentChannelId);
1171
+ saveJudgmentBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L4 6v6c0 5 3.5 9.5 8 10 4.5-.5 8-5 8-10V6l-8-4z"></path><path d="M9 12l2 2 4-4"></path></svg> 蒸馏为判断`;
1014
1172
  actionsDiv.appendChild(saveJudgmentBtn);
1015
1173
  if (type === 'ai') {
1016
1174
  actionsDiv.appendChild(regenerateBtn);
@@ -1018,199 +1176,206 @@ function addMessage(content, type, save = true, container) {
1018
1176
  div.appendChild(actionsDiv);
1019
1177
  }
1020
1178
 
1179
+ // P0.5: 反向引用链接 (AI 消息) — 极简, 点击跳判断力 modal
1180
+ if (type === 'ai' && Array.isArray(usedJudgmentIds) && usedJudgmentIds.length > 0) {
1181
+ const link = document.createElement('a');
1182
+ link.className = 'used-judgments-link';
1183
+ link.style.cssText = 'display:inline-block;margin-top:4px;font-size:11px;color:#0369a1;text-decoration:underline;cursor:pointer;';
1184
+ link.textContent = `📎 参考 ${usedJudgmentIds.length} 条原则`;
1185
+ link.onclick = (e) => {
1186
+ e.preventDefault();
1187
+ openJudgmentsModalWithFilter(usedJudgmentIds);
1188
+ };
1189
+ div.appendChild(link);
1190
+ }
1191
+
1021
1192
  div.appendChild(time);
1022
1193
  msgContainer.appendChild(div);
1023
1194
 
1024
1195
  msgContainer.scrollTop = msgContainer.scrollHeight;
1025
1196
  }
1026
1197
 
1027
- // Agent status bar — sits between the message list and the input box.
1028
- // Two visual states: "planning" (spinner) and "executing" (glowing icon).
1029
- // The text alternates to convey the action loop.
1030
- let agentStatusState = null; // 'planning' | 'executing' | null
1031
- let agentStatusTextIdx = 0;
1032
-
1033
- const AGENT_STATUS_TEXTS = {
1034
- planning: ['正在计划', '正在分析', '正在思考'],
1035
- executing: ['正在执行', '正在调用工具', '正在处理'],
1198
+ // ============================================================
1199
+ // Loop timeline panel (Claude Code 风格)
1200
+ // ============================================================
1201
+
1202
+ let timelinePanelEl = null;
1203
+ let timelineRowsEl = null;
1204
+ let currentTokenRow = null;
1205
+ let currentTokenText = '';
1206
+ let lastUsedJudgmentIds = []; // 用于 finalizeTimelineAsMessage 给 addMessage 第 5 参
1207
+
1208
+ const PHASE_TEXT = {
1209
+ gate_compute: '正在检索相关判断力...',
1210
+ gate_done: '已注入 {usedCount} 条原则',
1211
+ d_detect: 'D 触发: 监测对话...',
1212
+ d_distill: 'D 触发: 蒸馏判断力...',
1213
+ d_done: 'D 触发: 已入库',
1214
+ d_skip: 'D 触发: 跳过',
1215
+ d_error: 'D 触发: 错误',
1036
1216
  };
1037
1217
 
1038
- function setAgentStatus(state) {
1039
- if (!agentStatusEl || !agentStatusTextEl) return;
1040
- if (state === null) {
1041
- agentStatusEl.hidden = true;
1042
- agentStatusEl.removeAttribute('data-mode');
1043
- agentStatusState = null;
1044
- return;
1045
- }
1046
- agentStatusEl.hidden = false;
1047
- agentStatusEl.setAttribute('data-mode', state);
1048
- agentStatusState = state;
1049
- // 重排一下文本, 避免长时间停留过于单调
1050
- agentStatusTextIdx = (agentStatusTextIdx + 1) % AGENT_STATUS_TEXTS[state].length;
1051
- agentStatusTextEl.textContent = AGENT_STATUS_TEXTS[state][agentStatusTextIdx];
1218
+ function initTimelinePanel() {
1219
+ if (timelinePanelEl) return;
1220
+ timelinePanelEl = document.getElementById('loop-timeline-panel');
1221
+ timelineRowsEl = document.getElementById('loop-timeline-rows');
1222
+ const abortBtn = document.getElementById('loop-abort-btn');
1223
+ if (abortBtn) abortBtn.addEventListener('click', abortCurrentRun);
1052
1224
  }
1053
1225
 
1054
- function showTyping(container) {
1055
- hideTyping();
1056
- // 兼容旧路径: container 参数保留但不再使用, status bar 是全局唯一的
1057
- void container;
1058
- setAgentStatus('planning');
1226
+ function resetTimeline() {
1227
+ if (timelineRowsEl) timelineRowsEl.innerHTML = '';
1228
+ currentTokenRow = null;
1229
+ currentTokenText = '';
1230
+ lastUsedJudgmentIds = [];
1231
+ const title = document.getElementById('loop-timeline-title');
1232
+ if (title) title.textContent = '▸ 运行中';
1059
1233
  }
1060
1234
 
1061
- function hideTyping() {
1062
- setAgentStatus(null);
1063
- // 兜底: 旧版本的 #typing 元素可能还残留在 DOM 里, 顺手清掉
1064
- const old = document.getElementById('typing');
1065
- if (old) old.remove();
1066
- hideStreaming();
1235
+ function showTimelinePanel() {
1236
+ initTimelinePanel();
1237
+ resetTimeline();
1238
+ if (timelinePanelEl) timelinePanelEl.hidden = false;
1067
1239
  }
1068
1240
 
1069
- let streamingMessageEl = null;
1070
-
1071
- function showStreaming(container) {
1072
- const msgContainer = container || messagesContainers.get(currentChannelId) || messagesEl;
1073
- hideStreaming();
1074
- streamingMessageEl = document.createElement('div');
1075
- streamingMessageEl.className = 'message message-ai';
1076
- streamingMessageEl.id = 'streaming';
1077
- const bubble = document.createElement('div');
1078
- bubble.className = 'bubble bubble-ai streaming-content';
1079
- streamingMessageEl.appendChild(bubble);
1080
- msgContainer.appendChild(streamingMessageEl);
1081
- msgContainer.scrollTop = msgContainer.scrollHeight;
1241
+ function hideTimelinePanel() {
1242
+ if (timelinePanelEl) timelinePanelEl.hidden = true;
1082
1243
  }
1083
1244
 
1084
- function hideStreaming() {
1085
- if (streamingMessageEl) {
1086
- streamingMessageEl.remove();
1087
- streamingMessageEl = null;
1088
- }
1245
+ function scrollTimelineToBottom() {
1246
+ if (timelinePanelEl) timelinePanelEl.scrollTop = timelinePanelEl.scrollHeight;
1089
1247
  }
1090
1248
 
1091
- function updateStreamingContent(content, container) {
1092
- const msgContainer = container || messagesContainers.get(currentChannelId) || messagesEl;
1093
- if (streamingMessageEl) {
1094
- const bubble = streamingMessageEl.querySelector('.streaming-content');
1095
- if (bubble) {
1096
- bubble.textContent = content;
1097
- msgContainer.scrollTop = msgContainer.scrollHeight;
1098
- }
1099
- }
1249
+ function appendPhaseRow(text, status) {
1250
+ if (!timelineRowsEl) return;
1251
+ const row = document.createElement('div');
1252
+ row.className = 'loop-row loop-row-phase';
1253
+ row.style.cssText = 'padding:2px 0;color:#374151;';
1254
+ const icon = status === 'done' ? '✓' : status === 'error' ? '✗' : '●';
1255
+ row.innerHTML = `<span style="color:#6b7280;">${icon}</span> <span>${escapeHtml(text)}</span>`;
1256
+ timelineRowsEl.appendChild(row);
1257
+ scrollTimelineToBottom();
1100
1258
  }
1101
1259
 
1102
- function handleStreamEvent(data, container) {
1103
- const msgContainer = container || messagesContainers.get(currentChannelId) || messagesEl;
1104
- // 始终确保有工作流显示区域
1105
- if (!workflowDisplayEl) {
1106
- workflowDisplayEl = createWorkflowDisplay();
1107
- msgContainer.appendChild(workflowDisplayEl);
1260
+ function appendOrUpdateTokenRow(delta) {
1261
+ if (!timelineRowsEl) return;
1262
+ if (!currentTokenRow) {
1263
+ currentTokenRow = document.createElement('div');
1264
+ currentTokenRow.className = 'loop-row loop-row-token';
1265
+ currentTokenRow.style.cssText = 'padding:2px 0 2px 16px;color:#1f2937;white-space:pre-wrap;word-break:break-word;';
1266
+ currentTokenText = '';
1267
+ timelineRowsEl.appendChild(currentTokenRow);
1108
1268
  }
1109
-
1110
- if (data.streamType === 'thinking') {
1111
- showStreaming(msgContainer);
1112
- updateStreamingContent(data.content || '思考中...', msgContainer);
1113
- } else if (data.streamType === 'token') {
1114
- showStreaming(msgContainer);
1115
- const current = streamingMessageEl?.querySelector('.streaming-content')?.textContent || '';
1116
- updateStreamingContent(current + data.content, msgContainer);
1269
+ currentTokenText += delta;
1270
+ currentTokenRow.textContent = currentTokenText;
1271
+ scrollTimelineToBottom();
1272
+ }
1273
+
1274
+ function appendToolRow(toolName, status) {
1275
+ if (!timelineRowsEl) return;
1276
+ const row = document.createElement('div');
1277
+ row.className = 'loop-row loop-row-tool';
1278
+ row.dataset.status = status;
1279
+ row.style.cssText = 'padding:2px 0;color:#1e40af;cursor:pointer;user-select:none;';
1280
+ const icon = status === 'done' ? '✓' : status === 'error' ? '✗' : '●';
1281
+ row.innerHTML = `<span style="color:#6b7280;">${icon}</span> ${escapeHtml(toolName)} <span class="toggle" style="color:#6b7280;">▸</span>`;
1282
+ row.addEventListener('click', () => {
1283
+ const detail = row.nextElementSibling;
1284
+ if (detail && detail.classList.contains('loop-row-tool-detail')) {
1285
+ const isHidden = detail.style.display === 'none';
1286
+ detail.style.display = isHidden ? 'block' : 'none';
1287
+ const tg = row.querySelector('.toggle');
1288
+ if (tg) tg.textContent = isHidden ? '▾' : '▸';
1289
+ }
1290
+ });
1291
+ timelineRowsEl.appendChild(row);
1292
+ return row;
1293
+ }
1294
+
1295
+ function appendToolDetail(row, content) {
1296
+ if (!row || !timelineRowsEl) return;
1297
+ const detail = document.createElement('div');
1298
+ detail.className = 'loop-row loop-row-tool-detail';
1299
+ detail.style.cssText = 'padding:4px 0 6px 24px;color:#4b5563;font-size:11px;white-space:pre-wrap;word-break:break-word;background:#f3f4f6;border-radius:3px;margin-bottom:4px;';
1300
+ detail.textContent = content;
1301
+ detail.style.display = 'none';
1302
+ timelineRowsEl.insertBefore(detail, row.nextSibling);
1303
+ scrollTimelineToBottom();
1304
+ }
1305
+
1306
+ function findLastPendingToolRow() {
1307
+ if (!timelineRowsEl) return null;
1308
+ const rows = timelineRowsEl.querySelectorAll('.loop-row-tool');
1309
+ for (let i = rows.length - 1; i >= 0; i--) {
1310
+ if (rows[i].dataset.status === 'pending') return rows[i];
1311
+ }
1312
+ return null;
1313
+ }
1314
+
1315
+ function handlePhaseEventTimeline(data) {
1316
+ initTimelinePanel();
1317
+ const tmpl = PHASE_TEXT[data.phase];
1318
+ if (!tmpl) return;
1319
+ if (timelinePanelEl.hidden) showTimelinePanel();
1320
+ let text = tmpl;
1321
+ if (data.usedCount !== undefined) text = text.replace('{usedCount}', String(data.usedCount));
1322
+ if (data.detail && (data.phase === 'd_done' || data.phase === 'd_skip' || data.phase === 'd_error')) {
1323
+ text = `${text} — ${data.detail}`;
1117
1324
  }
1325
+ const status = data.phase.endsWith('_done') || data.phase === 'd_skip' ? 'done'
1326
+ : data.phase === 'd_error' ? 'error' : 'pending';
1327
+ appendPhaseRow(text, status);
1118
1328
  }
1119
1329
 
1120
- function handleStatusEvent(data, container) {
1121
- const msgContainer = container || messagesContainers.get(currentChannelId) || messagesEl;
1122
- // 检查是否是工具调用结果
1330
+ function handleStatusEventTimeline(data) {
1331
+ initTimelinePanel();
1123
1332
  const content = data.content || '';
1124
1333
  const isJsonResult = content.startsWith('{') && content.includes('"success"');
1125
-
1126
- if (isJsonResult) {
1127
- // 工具结果:折叠显示
1128
- showToolResult(data.tool, content, msgContainer);
1334
+ if (data.tool) {
1335
+ // 工具事件: 折叠行 + 可选挂 output
1336
+ const pending = findLastPendingToolRow();
1337
+ if (pending && pending.textContent && pending.textContent.includes(data.tool) && content.length > 100) {
1338
+ // 把 output 挂到上一个同工具的 pending 行
1339
+ pending.dataset.status = 'done';
1340
+ const iconEl = pending.firstElementChild;
1341
+ if (iconEl) iconEl.textContent = '✓';
1342
+ const tg = pending.querySelector('.toggle');
1343
+ if (tg) tg.textContent = '▸';
1344
+ appendToolDetail(pending, content);
1345
+ } else {
1346
+ const status = isJsonResult ? 'done' : 'pending';
1347
+ const row = appendToolRow(data.tool, status);
1348
+ if (isJsonResult) appendToolDetail(row, content);
1349
+ }
1350
+ scrollTimelineToBottom();
1129
1351
  } else {
1130
- // 普通状态:流式显示
1131
- showStreaming(msgContainer);
1132
- const icon = data.tool ? `🔧 ${data.tool}: ` : '';
1133
- updateStreamingContent(icon + data.content, msgContainer);
1352
+ // 普通 status: 当作 phase done
1353
+ if (timelinePanelEl.hidden) showTimelinePanel();
1354
+ const label = (data.tool ? `🔧 ${data.tool}: ` : '') + content;
1355
+ appendPhaseRow(label, 'done');
1134
1356
  }
1135
1357
  }
1136
1358
 
1137
- // 显示工具调用结果(折叠)
1138
- let toolResultContainer = null;
1139
-
1140
- function showToolResult(toolName, resultJson, container) {
1141
- const msgContainer = container || messagesContainers.get(currentChannelId) || messagesEl;
1142
- // 清理之前的流式显示
1143
- hideStreaming();
1144
-
1145
- // 获取或创建工具结果容器
1146
- if (!toolResultContainer) {
1147
- toolResultContainer = document.createElement('div');
1148
- toolResultContainer.className = 'tool-results-container';
1149
- msgContainer.appendChild(toolResultContainer);
1150
- }
1151
-
1152
- // 尝试解析并格式化 JSON
1153
- let formattedResult = resultJson;
1154
- try {
1155
- const parsed = JSON.parse(resultJson);
1156
- formattedResult = formatToolResult(parsed);
1157
- } catch {}
1158
-
1159
- // 创建折叠项
1160
- const resultEl = document.createElement('div');
1161
- resultEl.className = 'tool-result-item collapsed';
1162
-
1163
- const toolDisplayName = toolName || '工具结果';
1164
- const headerEl = document.createElement('div');
1165
- headerEl.className = 'tool-result-header';
1166
- headerEl.innerHTML = `
1167
- <span class="tool-result-icon">🔧</span>
1168
- <span class="tool-result-name">${toolDisplayName}</span>
1169
- <span class="tool-result-toggle">▸</span>
1170
- `;
1171
- // 绑定事件处理器(避免内联 onclick)
1172
- headerEl.addEventListener('click', () => {
1173
- resultEl.classList.toggle('collapsed');
1174
- resultEl.classList.toggle('expanded');
1175
- });
1176
-
1177
- const contentEl = document.createElement('div');
1178
- contentEl.className = 'tool-result-content';
1179
- contentEl.innerHTML = `<pre>${formattedResult}</pre>`;
1180
-
1181
- resultEl.appendChild(headerEl);
1182
- resultEl.appendChild(contentEl);
1183
- toolResultContainer.appendChild(resultEl);
1184
- msgContainer.scrollTop = msgContainer.scrollHeight;
1359
+ function handleStreamTokenEvent(data) {
1360
+ initTimelinePanel();
1361
+ if (timelinePanelEl.hidden) showTimelinePanel();
1362
+ appendOrUpdateTokenRow(data.content || '');
1185
1363
  }
1186
1364
 
1187
- // 格式化工具结果为易读格式
1188
- function formatToolResult(obj, indent = 0) {
1189
- const spaces = ' '.repeat(indent);
1190
-
1191
- if (obj === null || obj === undefined) {
1192
- return 'null';
1193
- }
1194
-
1195
- if (typeof obj === 'object') {
1196
- if (Array.isArray(obj)) {
1197
- if (obj.length === 0) return '[]';
1198
- return obj.map(item => spaces + '- ' + formatToolResult(item, indent + 1)).join('\n');
1199
- }
1200
-
1201
- const keys = Object.keys(obj);
1202
- if (keys.length === 0) return '{}';
1365
+ function handleQueueUpdateTimeline(data) {
1366
+ const title = document.getElementById('loop-timeline-title');
1367
+ if (!title) return;
1368
+ title.textContent = data.queueLength > 0
1369
+ ? `▸ 运行中 · 队列 +${data.queueLength}`
1370
+ : '▸ 运行中';
1371
+ }
1203
1372
 
1204
- return keys.map(key => {
1205
- const value = obj[key];
1206
- if (typeof value === 'object') {
1207
- return `${spaces}${key}:\n${formatToolResult(value, indent + 1)}`;
1208
- }
1209
- return `${spaces}${key}: ${value}`;
1210
- }).join('\n');
1373
+ function finalizeTimelineAsMessage() {
1374
+ const container = messagesContainers.get(currentChannelId) || messagesEl;
1375
+ if (currentTokenText.trim().length > 0) {
1376
+ addMessage(currentTokenText, 'ai', true, container, lastUsedJudgmentIds);
1211
1377
  }
1212
-
1213
- return String(obj);
1378
+ // tool 折叠行保留在 timeline 内, 用户能回看
1214
1379
  }
1215
1380
 
1216
1381
  // 工作流状态显示
@@ -1364,7 +1529,7 @@ function handleWorkflowLoopEvent(data, container) {
1364
1529
  // 用户命令可视化 - 当用户发送命令时调用
1365
1530
  let userCommandDisplayEl = null;
1366
1531
 
1367
- function showUserCommand(command, container) {
1532
+ function showUserCommand(command, container, opts) {
1368
1533
  const msgContainer = container || messagesContainers.get(currentChannelId) || messagesEl;
1369
1534
  // 先移除之前的消息中的 user bubble(如果有重复的话)
1370
1535
  const existingUserBubbles = msgContainer.querySelectorAll('.message-user');
@@ -1378,12 +1543,17 @@ function showUserCommand(command, container) {
1378
1543
  // 创建美化版本的命令显示
1379
1544
  userCommandDisplayEl = document.createElement('div');
1380
1545
  userCommandDisplayEl.className = 'message message-user';
1546
+ // v3 新增: 远端访客消息加 tag (source === 'remote' 表示是 B 通过 P2P 发来的)
1547
+ const sourceTag = (opts && opts.source === 'remote')
1548
+ ? `<div style="font-size:10px;color:#6b7280;margin-bottom:2px;">🌐 远端访客${opts.fromPublicKey ? ' (' + opts.fromPublicKey.substring(0, 8) + '…)' : ''} → A 的 channel</div>`
1549
+ : '';
1381
1550
  userCommandDisplayEl.innerHTML = `
1382
1551
  <div class="user-command-display">
1383
1552
  <div class="command-prompt">
1384
1553
  <span class="prompt-icon">›</span>
1385
1554
  <span class="prompt-text">${command}</span>
1386
1555
  </div>
1556
+ ${sourceTag}
1387
1557
  </div>
1388
1558
  `;
1389
1559
 
@@ -1397,6 +1567,12 @@ function showUserCommand(command, container) {
1397
1567
  msgContainer.scrollTop = msgContainer.scrollHeight;
1398
1568
  }
1399
1569
 
1570
+ // ============================================================
1571
+ // 状态条 + phase + queue + abort (UI 体验补丁) — 旧版, 已被 timeline panel 取代
1572
+ // ============================================================
1573
+
1574
+
1575
+
1400
1576
  function connect(channelId) {
1401
1577
  const targetChannelId = channelId || currentChannelId;
1402
1578
  if (!targetChannelId) return;
@@ -1490,30 +1666,39 @@ function connect(channelId) {
1490
1666
  const container = messagesContainers.get(msgChannelId) || messagesEl;
1491
1667
 
1492
1668
  if (data.type === 'user') {
1493
- showUserCommand(data.content, container);
1669
+ // 2026-06-11 修: 不再走 showUserCommand ( 装饰条) 路径, 因为:
1670
+ // 1. sendMessage 已经在客户端 addMessage(text, 'user', true) 渲染成 .bubble-user 气泡
1671
+ // 2. SSE 推 user 又调 showUserCommand → 同时出现气泡 + 装饰条 (双重显示)
1672
+ // 3. 第二次切 channel 时, showUserCommand 会 remove 已有 .message-user 元素 (line 1477),
1673
+ // 但 .bubble-user class 不是 .message-user → 残留装饰条, 表现"模式变了"
1674
+ // 改法: SSE 收到 user 后, 跳过显示 (lastUserCommand 已经匹配, addMessage(save=true) 内部去重)
1675
+ // 但要确保 lastUserCommand 已经设过 — sendMessage 调 addMessage(true) 时会设
1676
+ // 远端 user (source === 'remote') 不会被 sendMessage 渲染, 需要走 addMessage 一次
1677
+ if (data.source === 'remote') {
1678
+ // 远端访客 (B 通过 P2P 发来的), sendMessage 没渲染它, 这里补上气泡
1679
+ addMessage(data.content, 'user', true, container);
1680
+ }
1681
+ // 本地 user 已经由 sendMessage 渲染 + 去重, 这里不再显示
1494
1682
  } else if (data.type === 'ai') {
1495
- addMessage(data.content, 'ai', true, container);
1496
- hideTyping();
1683
+ addMessage(data.content, 'ai', true, container, lastUsedJudgmentIds);
1497
1684
  } else if (data.type === 'stream') {
1498
- handleStreamEvent(data, container);
1499
- setAgentStatus('executing');
1685
+ if (data.streamType === 'thinking' || data.streamType === 'token') {
1686
+ handleStreamTokenEvent(data);
1687
+ }
1500
1688
  } else if (data.type === 'regenerating') {
1689
+ // 删旧的最后一条 AI 消息, 准备重新生成
1501
1690
  const messages = container.querySelectorAll('.message-ai');
1502
1691
  if (messages.length > 0) {
1503
1692
  const lastAiMsg = messages[messages.length - 1];
1504
1693
  lastAiMsg.remove();
1505
1694
  }
1506
- showTyping(container);
1695
+ showTimelinePanel();
1507
1696
  } else if (data.type === 'status') {
1508
- handleStatusEvent(data, container);
1509
- setAgentStatus('executing');
1697
+ handleStatusEventTimeline(data);
1510
1698
  } else if (data.type === 'done') {
1511
- hideTyping();
1512
- // AI 回复完, 把最后一条 ai 消息落盘 (兜底, 避免 server saveSession 漏写)
1513
- const lastAi = container.querySelector('.message-ai:last-of-type .message-content');
1514
- if (lastAi) {
1515
- persistLastMessageToServer('ai', lastAi.textContent || '');
1516
- }
1699
+ // AI 回复生成完, 从 timeline 拿出 token 文本作为正式消息
1700
+ finalizeTimelineAsMessage();
1701
+ hideTimelinePanel();
1517
1702
  } else if (data.type === 'renamed') {
1518
1703
  const channel = channels.find(c => c.id === data.channelId);
1519
1704
  if (channel) {
@@ -1524,7 +1709,6 @@ function connect(channelId) {
1524
1709
  }
1525
1710
  }
1526
1711
  } else if (data.type === 'error') {
1527
- hideTyping();
1528
1712
  addMessage('错误: ' + data.content, 'ai', true, container);
1529
1713
  } else if (data.type === 'task_status') {
1530
1714
  handleTaskStatusEvent(data, container);
@@ -1532,6 +1716,13 @@ function connect(channelId) {
1532
1716
  handleWorkflowStepEvent(data, container);
1533
1717
  } else if (data.type === 'workflow_loop') {
1534
1718
  handleWorkflowLoopEvent(data, container);
1719
+ } else if (data.type === 'phase') {
1720
+ handlePhaseEventTimeline(data);
1721
+ } else if (data.type === 'queue_update') {
1722
+ handleQueueUpdateTimeline(data);
1723
+ } else if (data.type === 'used_judgments' && Array.isArray(data.usedIds)) {
1724
+ // 注入门回传: 保存 usedIds, finalizeTimelineAsMessage 时给 addMessage
1725
+ lastUsedJudgmentIds = data.usedIds;
1535
1726
  }
1536
1727
  } catch (parseErr) {
1537
1728
  console.error('[SSE] 解析错误', parseErr);
@@ -1543,8 +1734,16 @@ async function sendMessage() {
1543
1734
  const text = input.value.trim();
1544
1735
  if (!text) return;
1545
1736
 
1737
+ // 2026-06-11: 立即把用户消息渲染成气泡上屏 (走 .bubble-user, 跟本地聊天一致)
1738
+ // 之前只靠 SSE `type: user` 回调显示, 但 addMessage(user) 默认 save=true 走去重, 容易跟 SSE 二次显示冲突/丢失
1739
+ // 现在: sendMessage 自己上屏, SSE `user` 回调来时因为 lastUserCommand 已匹配, 自动跳过 → 不重复
1740
+ const container = messagesContainers.get(currentChannelId) || messagesEl;
1741
+ addMessage(text, 'user', true, container);
1742
+ // 滚动到底
1743
+ if (container) container.scrollTop = container.scrollHeight;
1744
+
1546
1745
  input.value = '';
1547
- showTyping();
1746
+ showTimelinePanel();
1548
1747
 
1549
1748
  // 立即把用户消息落盘, 避免切走再切回时丢失
1550
1749
  persistLastMessageToServer('user', text);
@@ -1566,11 +1765,11 @@ async function sendMessage() {
1566
1765
  });
1567
1766
 
1568
1767
  if (!res.ok) {
1569
- hideTyping();
1768
+ hideTimelinePanel();
1570
1769
  addMessage('发送失败', 'ai');
1571
1770
  }
1572
1771
  } catch (err) {
1573
- hideTyping();
1772
+ hideTimelinePanel();
1574
1773
  addMessage('连接错误', 'ai');
1575
1774
  console.error('Send error', err);
1576
1775
  }
@@ -1731,13 +1930,23 @@ function applyMention(channel) {
1731
1930
  }
1732
1931
 
1733
1932
  function updateMentionDropdown() {
1734
- if (!mentionChannels.length) return;
1933
+ // 2026-06-10 修: 数组空时主动刷一次, 不再静默 return
1934
+ // 之前 `if (!mentionChannels.length) return` 导致初始化 0-8s 窗口按 @ 看不到任何 item
1935
+ if (!mentionChannels.length) {
1936
+ refreshMentionChannels().then(() => {
1937
+ // 拉完再重试一次 (异步, 不阻塞当前键击)
1938
+ if (mentionChannels.length) updateMentionDropdown();
1939
+ });
1940
+ return;
1941
+ }
1735
1942
  const m = getCurrentMentionQuery();
1736
1943
  if (!m) { closeMentionDropdown(); return; }
1737
1944
  // 只在 dropdown 刚打开时设置 anchor (blockEnd 跟着 insert 走)
1738
1945
  if (mentionAnchor === -1) {
1739
1946
  mentionAnchor = m.anchor;
1740
1947
  mentionBlockEnd = m.anchor + 1 + (m.query || '').length;
1948
+ // dropdown 首次打开 → 强制刷一次, 保证 remote 列表最新
1949
+ refreshMentionChannels();
1741
1950
  }
1742
1951
  mentionQuery = m.query;
1743
1952
  const q = m.query.toLowerCase();
@@ -1876,12 +2085,20 @@ function setupMentionAutocomplete(inputEl) {
1876
2085
  }
1877
2086
 
1878
2087
  function update() {
1879
- if (!mentionChannels.length) return;
2088
+ // 2026-06-10 修: 与主输入框同步 — 数组空时主动刷新, 首次打开 dropdown 强制刷新
2089
+ if (!mentionChannels.length) {
2090
+ refreshMentionChannels().then(() => {
2091
+ if (mentionChannels.length) update();
2092
+ });
2093
+ return;
2094
+ }
1880
2095
  const m = detectQuery();
1881
2096
  if (!m) { closeLocal(); return; }
1882
2097
  if (localAnchor === -1) {
1883
2098
  localAnchor = m.anchor;
1884
2099
  localBlockEnd = m.anchor + 1 + (m.query || '').length;
2100
+ // dropdown 首次打开 → 强制刷一次保证 remote 最新
2101
+ refreshMentionChannels();
1885
2102
  }
1886
2103
  localQuery = m.query;
1887
2104
  const q = m.query.toLowerCase();
@@ -2428,11 +2645,45 @@ function switchJudgmentTab(tab) {
2428
2645
  renderJudgments(lastJudgmentsCache);
2429
2646
  }
2430
2647
 
2648
+ function switchStatusFilter(status) {
2649
+ currentStatusFilter = status;
2650
+ document.querySelectorAll('.judgment-status-tab').forEach(btn => {
2651
+ const active = btn.dataset.status === status;
2652
+ btn.classList.toggle('active', active);
2653
+ btn.style.background = active ? '#2563eb' : '#e5e7eb';
2654
+ btn.style.color = active ? '#fff' : '#374151';
2655
+ });
2656
+ loadJudgments();
2657
+ }
2658
+
2659
+ /**
2660
+ * P0.5: 打开判断力 modal 并 filter 到指定 ids
2661
+ * - 调 openJudgmentsModal() + 等 loadJudgments() 完成
2662
+ * - 然后用 ids filter lastJudgmentsCache
2663
+ */
2664
+ function openJudgmentsModalWithFilter(ids) {
2665
+ if (!Array.isArray(ids) || ids.length === 0) return;
2666
+ if (typeof openJudgmentsModal === 'function') {
2667
+ openJudgmentsModal();
2668
+ } else if (judgmentsModal) {
2669
+ judgmentsModal.classList.add('active');
2670
+ }
2671
+ // 等 loadJudgments 完成 (它会 await fetch 然后 renderJudgments)
2672
+ setTimeout(() => {
2673
+ if (typeof lastJudgmentsCache === 'undefined') return;
2674
+ lastJudgmentsCache = (lastJudgmentsCache || []).filter((j) => ids.includes(j.id));
2675
+ if (typeof renderJudgments === 'function') {
2676
+ renderJudgments(lastJudgmentsCache);
2677
+ }
2678
+ }, 150);
2679
+ }
2680
+
2431
2681
  function hideJudgmentsModal() {
2432
2682
  if (judgmentsModal) judgmentsModal.classList.remove('active');
2433
2683
  }
2434
2684
 
2435
2685
  let currentJudgmentTab = 'channel'; // 'channel' | 'global'
2686
+ let currentStatusFilter = 'all'; // 'all' | 'active' | 'superseded' | 'violations'
2436
2687
  let lastJudgmentsCache = []; // 最近一次 loadJudgments 拿到的原始列表, 切 tab / 切 channel 时复用
2437
2688
 
2438
2689
  /**
@@ -2504,6 +2755,19 @@ function renderJudgmentItems(items, opts) {
2504
2755
  const reason = (j.reasons && j.reasons[0]) ? escapeHtml(j.reasons[0]) : '';
2505
2756
  const domain = (j.context && j.context.domain) ? escapeHtml(j.context.domain) : 'general';
2506
2757
  const stakes = (j.context && j.context.stakes) ? escapeHtml(j.context.stakes) : 'medium';
2758
+ const isSuperseded = j.status === 'superseded';
2759
+ const isRejected = j.status === 'rejected';
2760
+ const dimmedStyle = isSuperseded || isRejected
2761
+ ? 'opacity:0.55;background:#f3f4f6;'
2762
+ : '';
2763
+ const statusTag = isSuperseded
2764
+ ? `<span style="display:inline-block;background:#fef3c7;color:#92400e;font-size:10px;padding:1px 6px;border-radius:3px;margin-left:6px;" title="已被新判断力演化替代">已过时</span>`
2765
+ : isRejected
2766
+ ? `<span style="display:inline-block;background:#fee2e2;color:#991b1b;font-size:10px;padding:1px 6px;border-radius:3px;margin-left:6px;">已拒绝</span>`
2767
+ : '';
2768
+ const evolveNote = isSuperseded && j.supersededBy
2769
+ ? `<div style="font-size:10px;color:#6b7280;margin-top:2px;">被新条替代 · ${escapeHtml(j.evolutionReason || 'merged')} · ${escapeHtml(j.evolvedAt || '').substring(0,10)}</div>`
2770
+ : '';
2507
2771
  const bindBtn = showBindToggle
2508
2772
  ? isBound
2509
2773
  ? `<button class="judgment-toggle-btn" data-id="${escapeHtml(j.id)}" data-action="unbind" title="从当前 channel 移除" style="background:none;border:1px solid #fca5a5;color:#b91c1c;padding:1px 8px;border-radius:3px;cursor:pointer;font-size:11px;">× 移除</button>`
@@ -2513,18 +2777,18 @@ function renderJudgmentItems(items, opts) {
2513
2777
  <div class="task-item completed judgment-row"
2514
2778
  data-judgment-id="${escapeHtml(j.id)}"
2515
2779
  draggable="true"
2516
- style="cursor:grab;">
2780
+ style="cursor:grab;${dimmedStyle}">
2517
2781
  <div class="task-item-header">
2518
2782
  <label class="judgment-checkbox" style="display:flex;align-items:center;cursor:pointer;margin-right:8px;" onclick="event.stopPropagation();">
2519
2783
  <input type="checkbox" class="judgment-select-cb" data-id="${escapeHtml(j.id)}" style="cursor:pointer;" onclick="event.stopPropagation();">
2520
2784
  </label>
2521
2785
  <div class="task-item-title">
2522
- <span>🛡️</span>
2523
- <span class="judgment-decision">${escapeHtml(j.decision)}</span>
2786
+ <span class="judgment-decision">${escapeHtml(j.decision)}</span>${statusTag}
2524
2787
  </div>
2525
2788
  <span class="task-item-status completed">${stakes}</span>
2526
2789
  </div>
2527
2790
  ${reason ? `<div class="task-item-desc" style="color:#555;font-size:13px;margin-top:4px;">理由: ${reason}</div>` : ''}
2791
+ ${evolveNote}
2528
2792
  <div class="task-item-meta" style="color:#999;font-size:11px;margin-top:4px;display:flex;justify-content:space-between;align-items:center;">
2529
2793
  <span>${domain} · ${escapeHtml(j.timestamp)} · ${escapeHtml(j.id)}</span>
2530
2794
  <span style="display:flex;gap:4px;">
@@ -2541,14 +2805,53 @@ function renderJudgmentItems(items, opts) {
2541
2805
  async function loadJudgments() {
2542
2806
  if (!judgmentsList) return;
2543
2807
  try {
2544
- const res = await fetch('/api/judgments');
2808
+ // P3: 违规记录走单独 API
2809
+ if (currentStatusFilter === 'violations') {
2810
+ const res = await fetch('/api/judgments/violations?limit=50');
2811
+ if (!res.ok) throw new Error('HTTP ' + res.status);
2812
+ const data = await res.json();
2813
+ renderViolations(data.items || []);
2814
+ judgmentsLoaded = true;
2815
+ return;
2816
+ }
2817
+
2818
+ // 类 B: 自适应扫描建议
2819
+ if (currentStatusFilter === 'adaptive') {
2820
+ const res = await fetch('/api/judgments/adaptive-suggestions');
2821
+ if (!res.ok) throw new Error('HTTP ' + res.status);
2822
+ const data = await res.json();
2823
+ renderAdaptiveSuggestions(data);
2824
+ judgmentsLoaded = true;
2825
+ return;
2826
+ }
2827
+
2828
+ // 阶段 2: causal-judge 因果分析
2829
+ if (currentStatusFilter === 'causal') {
2830
+ const res = await fetch('/api/judgments/causal/correlation?topN=10');
2831
+ if (!res.ok) throw new Error('HTTP ' + res.status);
2832
+ const data = await res.json();
2833
+ renderCausalAnalysis(data.items || []);
2834
+ judgmentsLoaded = true;
2835
+ return;
2836
+ }
2837
+
2838
+ const res = await fetch('/api/judgments?status=' + encodeURIComponent(currentStatusFilter));
2545
2839
  if (!res.ok) throw new Error('HTTP ' + res.status);
2546
2840
  const data = await res.json();
2547
2841
  lastJudgmentsCache = data.judgments || [];
2548
2842
  renderJudgments(lastJudgmentsCache);
2549
2843
  if (judgmentsBadge) {
2550
- if (data.count > 0) {
2551
- judgmentsBadge.textContent = data.count;
2844
+ // 徽章永远显示 active 数量; 不跟 filter 变
2845
+ // 当 filter=active 时, 主 fetch 已经返回的就是 active 列表, 直接用 data.count
2846
+ // 当 filter=all/superseded 时, 从主列表本地数 active 条 (status ?? 'active' 兼容老数据)
2847
+ let activeCount;
2848
+ if (currentStatusFilter === 'active') {
2849
+ activeCount = data.count;
2850
+ } else {
2851
+ activeCount = lastJudgmentsCache.filter((j) => (j.status ?? 'active') === 'active').length;
2852
+ }
2853
+ if (activeCount > 0) {
2854
+ judgmentsBadge.textContent = activeCount;
2552
2855
  judgmentsBadge.style.display = '';
2553
2856
  } else {
2554
2857
  judgmentsBadge.style.display = 'none';
@@ -2560,6 +2863,245 @@ async function loadJudgments() {
2560
2863
  }
2561
2864
  }
2562
2865
 
2866
+ /**
2867
+ * P3 渲染违规记录 (与 renderJudgments 同位置, 但内容不同)
2868
+ */
2869
+ function renderViolations(items) {
2870
+ if (!judgmentsList) return;
2871
+ if (!items || items.length === 0) {
2872
+ judgmentsList.innerHTML = '<div class="task-empty">暂无违规记录 (AI 回复未违反注入原则).</div>';
2873
+ return;
2874
+ }
2875
+ judgmentsList.innerHTML = items.map((v) => {
2876
+ const ts = escapeHtml((v.ts || '').substring(0, 19).replace('T', ' '));
2877
+ const userPrev = escapeHtml(v.userInputPreview || '');
2878
+ const aiPrev = escapeHtml(v.aiReplyPreview || '');
2879
+ const principles = (v.result?.violatedPrinciples || []).map((p) =>
2880
+ `<div style="margin-top:3px;padding:4px 8px;background:#fef2f2;border-radius:3px;">
2881
+ <span style="color:#dc2626;">⚠</span> <strong>${escapeHtml(p.principle || '')}</strong>
2882
+ <span style="color:#991b1b;">— ${escapeHtml(p.reason || '')}</span>
2883
+ </div>`
2884
+ ).join('');
2885
+ return `
2886
+ <div class="task-item" style="border-left:3px solid #dc2626;padding:8px 12px;background:#fffbfb;">
2887
+ <div style="font-size:11px;color:#6b7280;margin-bottom:4px;">${ts} · confidence=${escapeHtml(String(v.result?.confidence ?? 0))}</div>
2888
+ <div style="font-size:12px;color:#1f2937;"><strong>用户:</strong> ${userPrev}</div>
2889
+ <div style="font-size:12px;color:#1f2937;margin-top:2px;"><strong>AI:</strong> ${aiPrev}</div>
2890
+ <div style="margin-top:6px;">${principles}</div>
2891
+ </div>
2892
+ `;
2893
+ }).join('');
2894
+ }
2895
+
2896
+ /**
2897
+ * 类 B 自适应建议渲染
2898
+ * - rising (绿色 boost 标记): 7 天使用率高于 30 天均值的 1.5 倍
2899
+ * - stale (黄色 deprecate 标记): 90 天未用 + 总使用 < 3
2900
+ * - unused (灰色 review 标记): 30 天未用 + 总使用 < 5
2901
+ * 每条带 "✓ 接受" / "✗ 拒绝" 按钮, 接受会真改库, 拒绝只留痕
2902
+ */
2903
+ function renderAdaptiveSuggestions(data) {
2904
+ if (!judgmentsList) return;
2905
+ const { judgmentsTotal, usageEntriesScanned, suggestions, scannedAt } = data;
2906
+ const ts = escapeHtml((scannedAt || '').substring(0, 19).replace('T', ' '));
2907
+
2908
+ if (!suggestions || suggestions.length === 0) {
2909
+ judgmentsList.innerHTML = `
2910
+ <div class="task-empty">📊 自适应扫描: 无建议
2911
+ <div style="margin-top:8px;font-size:11px;color:#6b7280;">扫了 ${judgmentsTotal} 条原则, ${usageEntriesScanned} 条使用记录, 都挺健康.</div>
2912
+ <div style="margin-top:4px;font-size:11px;color:#6b7280;">扫描于 ${ts}</div>
2913
+ </div>`;
2914
+ return;
2915
+ }
2916
+
2917
+ const KIND_STYLE = {
2918
+ rising: { color: '#059669', bg: '#ecfdf5', label: '↑ rising', action: 'boost' },
2919
+ stale: { color: '#92400e', bg: '#fef3c7', label: '⏰ stale', action: 'deprecate' },
2920
+ unused: { color: '#6b7280', bg: '#f3f4f6', label: '👀 unused', action: 'review' },
2921
+ };
2922
+
2923
+ const header = `
2924
+ <div style="padding:8px 12px;background:#f9fafb;border-radius:4px;margin-bottom:8px;font-size:11px;color:#374151;">
2925
+ 📊 扫描于 ${ts} · ${judgmentsTotal} 条原则 · ${usageEntriesScanned} 条使用记录 · <strong>${suggestions.length}</strong> 条建议
2926
+ <button class="rescan-btn" style="margin-left:8px;background:none;border:1px solid #6b7280;color:#374151;padding:1px 8px;border-radius:3px;cursor:pointer;font-size:11px;">🔄 重新扫描</button>
2927
+ </div>
2928
+ `;
2929
+
2930
+ const rows = suggestions.map((s) => {
2931
+ const style = KIND_STYLE[s.kind] || KIND_STYLE.unused;
2932
+ const m = s.metrics || {};
2933
+ return `
2934
+ <div class="task-item" data-suggestion-key="${escapeHtml(s.key)}"
2935
+ style="border-left:3px solid ${style.color};padding:8px 12px;background:${style.bg};margin-bottom:6px;">
2936
+ <div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
2937
+ <span style="color:${style.color};font-weight:600;font-size:12px;">${style.label}</span>
2938
+ <span style="font-size:11px;color:#6b7280;">${s.action === 'boost' ? '建议加权' : s.action === 'deprecate' ? '建议废弃' : '建议审视'}</span>
2939
+ </div>
2940
+ <div style="font-size:12px;color:#1f2937;margin-bottom:4px;"><strong>${escapeHtml(s.decision)}</strong></div>
2941
+ <div style="font-size:11px;color:#6b7280;margin-bottom:6px;">${escapeHtml(s.reason)}</div>
2942
+ <div style="font-size:11px;color:#9ca3af;margin-bottom:6px;">
2943
+ 7天 ${m.usage7d || 0} · 30天 ${m.usage30d || 0} · 共 ${m.totalUsage || 0} · 上次用 ${m.daysSinceLastUse || 0} 天前
2944
+ </div>
2945
+ <div style="display:flex;gap:6px;">
2946
+ <button class="adaptive-accept" data-key="${escapeHtml(s.key)}" data-id="${escapeHtml(s.judgmentId)}" data-action-kind="${escapeHtml(s.action)}"
2947
+ style="background:#059669;color:#fff;border:none;padding:2px 10px;border-radius:3px;cursor:pointer;font-size:11px;">✓ 接受</button>
2948
+ <button class="adaptive-reject" data-key="${escapeHtml(s.key)}" data-id="${escapeHtml(s.judgmentId)}" data-action-kind="${escapeHtml(s.action)}"
2949
+ style="background:none;border:1px solid #d1d5db;color:#6b7280;padding:2px 10px;border-radius:3px;cursor:pointer;font-size:11px;">✗ 拒绝</button>
2950
+ </div>
2951
+ </div>
2952
+ `;
2953
+ }).join('');
2954
+
2955
+ judgmentsList.innerHTML = header + rows;
2956
+
2957
+ // 绑定按钮
2958
+ const rescanBtn = judgmentsList.querySelector('.rescan-btn');
2959
+ if (rescanBtn) {
2960
+ rescanBtn.onclick = async () => {
2961
+ rescanBtn.disabled = true;
2962
+ rescanBtn.textContent = '🔄 扫描中...';
2963
+ try {
2964
+ const r = await fetch('/api/judgments/adaptive-suggestions?force=1');
2965
+ if (r.ok) renderAdaptiveSuggestions(await r.json());
2966
+ } catch (err) {
2967
+ console.error('[adaptive] rescan failed:', err);
2968
+ } finally {
2969
+ rescanBtn.disabled = false;
2970
+ rescanBtn.textContent = '🔄 重新扫描';
2971
+ }
2972
+ };
2973
+ }
2974
+ judgmentsList.querySelectorAll('.adaptive-accept').forEach((btn) => {
2975
+ btn.onclick = () => applyAdaptiveSuggestion(btn.dataset.key, btn.dataset.id, btn.dataset.actionKind, 'accept');
2976
+ });
2977
+ judgmentsList.querySelectorAll('.adaptive-reject').forEach((btn) => {
2978
+ btn.onclick = () => applyAdaptiveSuggestion(btn.dataset.key, btn.dataset.id, btn.dataset.actionKind, 'reject');
2979
+ });
2980
+ }
2981
+
2982
+ async function applyAdaptiveSuggestion(key, judgmentId, actionKind, decision) {
2983
+ const row = judgmentsList?.querySelector(`[data-suggestion-key="${key}"]`);
2984
+ if (row) row.style.opacity = '0.5';
2985
+ try {
2986
+ const res = await fetch('/api/judgments/adaptive-apply', {
2987
+ method: 'POST',
2988
+ headers: { 'Content-Type': 'application/json' },
2989
+ body: JSON.stringify({
2990
+ action: decision,
2991
+ suggestion: {
2992
+ key,
2993
+ judgmentId,
2994
+ kind: actionKind,
2995
+ action: actionKind,
2996
+ decision: '',
2997
+ reason: '',
2998
+ metrics: {},
2999
+ scannedAt: new Date().toISOString(),
3000
+ },
3001
+ }),
3002
+ });
3003
+ if (!res.ok) throw new Error('HTTP ' + res.status);
3004
+ // 视觉反馈: 隐藏该行
3005
+ if (row) row.style.display = 'none';
3006
+ } catch (err) {
3007
+ if (row) row.style.opacity = '';
3008
+ console.error('[adaptive] apply failed:', err);
3009
+ alert('操作失败: ' + (err && err.message || 'unknown'));
3010
+ }
3011
+ }
3012
+
3013
+ // ============================================================
3014
+ // 阶段 2: causal-judge 渲染
3015
+ // ============================================================
3016
+
3017
+ /**
3018
+ * 渲染关联分析 (top 5 互信息对)
3019
+ * - 显示每对: judgmentA ↔ judgmentB + 互信息 + co-occurrence + 因果方向
3020
+ * - 每条 judgment 旁加"🔬 跑 do-calculus"按钮, 异步显示 causalEffect
3021
+ */
3022
+ function renderCausalAnalysis(items) {
3023
+ if (!judgmentsList) return;
3024
+ if (!items || items.length === 0) {
3025
+ judgmentsList.innerHTML = `
3026
+ <div class="task-empty">🔍 因果分析: 无高关联对
3027
+ <div style="margin-top:8px;font-size:11px;color:#6b7280;">usage 数据不足 (至少 3 条同现), 或 LLM 不可用. 多用 bolloon 一段时间后重试.</div>
3028
+ </div>`;
3029
+ return;
3030
+ }
3031
+
3032
+ const rows = items.map((p, idx) => `
3033
+ <div class="task-item" data-causal-idx="${idx}" data-judgment-a="${escapeHtml(p.judgmentA)}" data-judgment-b="${escapeHtml(p.judgmentB)}"
3034
+ style="border-left:3px solid #7c3aed;padding:8px 12px;background:#faf5ff;margin-bottom:6px;">
3035
+ <div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
3036
+ <span style="color:#7c3aed;font-weight:600;font-size:12px;">${escapeHtml(p.causalDirection)}</span>
3037
+ <span style="font-size:11px;color:#6b7280;">MI=${p.mutualInfo} · co=${p.coOccurrence}</span>
3038
+ </div>
3039
+ <div style="font-size:11px;color:#374151;margin-bottom:4px;">${escapeHtml(p.explanation || '(无 LLM 解释)')}</div>
3040
+ <div style="font-size:10px;color:#9ca3af;">A: ${escapeHtml(p.judgmentA)} ↔ B: ${escapeHtml(p.judgmentB)}</div>
3041
+ <div style="margin-top:6px;display:flex;gap:6px;">
3042
+ <button class="causal-intervention-a" data-jid="${escapeHtml(p.judgmentA)}"
3043
+ style="background:#7c3aed;color:#fff;border:none;padding:2px 10px;border-radius:3px;cursor:pointer;font-size:11px;">🔬 do(A)</button>
3044
+ <button class="causal-intervention-b" data-jid="${escapeHtml(p.judgmentB)}"
3045
+ style="background:#7c3aed;color:#fff;border:none;padding:2px 10px;border-radius:3px;cursor:pointer;font-size:11px;">🔬 do(B)</button>
3046
+ </div>
3047
+ <div class="causal-result" data-jid="" style="display:none;margin-top:6px;padding:6px;background:#f3e8ff;border-radius:3px;font-size:11px;"></div>
3048
+ </div>
3049
+ `).join('');
3050
+
3051
+ judgmentsList.innerHTML = `
3052
+ <div style="padding:8px 12px;background:#f9fafb;border-radius:4px;margin-bottom:8px;font-size:11px;color:#374151;">
3053
+ 🔍 关联分析 (top ${items.length} 互信息对) · <span style="color:#7c3aed;">LLM 推断方向</span>
3054
+ <button class="causal-refresh" style="margin-left:8px;background:none;border:1px solid #7c3aed;color:#7c3aed;padding:1px 8px;border-radius:3px;cursor:pointer;font-size:11px;">🔄 重新跑</button>
3055
+ </div>
3056
+ ${rows}
3057
+ `;
3058
+
3059
+ // 按钮: 重新跑
3060
+ const refresh = judgmentsList.querySelector('.causal-refresh');
3061
+ if (refresh) {
3062
+ refresh.onclick = async () => {
3063
+ refresh.disabled = true;
3064
+ refresh.textContent = '🔄 跑中...';
3065
+ try {
3066
+ const r = await fetch('/api/judgments/causal/correlation?topN=10');
3067
+ if (r.ok) renderCausalAnalysis((await r.json()).items || []);
3068
+ } finally {
3069
+ refresh.disabled = false;
3070
+ refresh.textContent = '🔄 重新跑';
3071
+ }
3072
+ };
3073
+ }
3074
+
3075
+ // 按钮: 跑 do-calculus
3076
+ judgmentsList.querySelectorAll('.causal-intervention-a, .causal-intervention-b').forEach((btn) => {
3077
+ btn.onclick = async () => {
3078
+ const jid = btn.getAttribute('data-jid');
3079
+ const resultDiv = btn.closest('.task-item')?.querySelector('.causal-result');
3080
+ if (!resultDiv) return;
3081
+ resultDiv.style.display = 'block';
3082
+ resultDiv.textContent = '🔬 跑 do-calculus (LLM 模拟反事实)...';
3083
+ btn.disabled = true;
3084
+ try {
3085
+ const r = await fetch(`/api/judgments/causal/intervention?judgmentId=${encodeURIComponent(jid)}`);
3086
+ if (!r.ok) throw new Error('HTTP ' + r.status);
3087
+ const data = await r.json();
3088
+ const effect = data.causalEffect;
3089
+ const sign = effect > 0 ? '+' : '';
3090
+ const color = Math.abs(effect) > 0.5 ? '#dc2626' : Math.abs(effect) > 0.2 ? '#d97706' : '#059669';
3091
+ resultDiv.innerHTML = `
3092
+ <div style="color:${color};font-weight:600;">do-calculus: causalEffect = ${sign}${effect} (${data.marginalContribution})</div>
3093
+ <div style="color:#374151;margin-top:4px;">${escapeHtml(data.reasoning)}</div>
3094
+ <div style="color:#9ca3af;margin-top:4px;">confidence=${data.confidence}</div>
3095
+ `;
3096
+ } catch (err) {
3097
+ resultDiv.innerHTML = `<div style="color:#dc2626;">失败: ${escapeHtml(err.message)}</div>`;
3098
+ } finally {
3099
+ btn.disabled = false;
3100
+ }
3101
+ };
3102
+ });
3103
+ }
3104
+
2563
3105
  /** 把 judgment id 加进 / 移出当前 channel.bound_judgment_ids, 然后刷新两边 UI */
2564
3106
  async function toggleChannelJudgment(judgmentId, action) {
2565
3107
  if (!currentChannelId) {
@@ -2626,6 +3168,11 @@ if (judgmentsList) {
2626
3168
  btn.addEventListener('click', () => switchJudgmentTab(btn.dataset.tab));
2627
3169
  });
2628
3170
 
3171
+ // status 过滤
3172
+ document.querySelectorAll('.judgment-status-tab').forEach(btn => {
3173
+ btn.addEventListener('click', () => switchStatusFilter(btn.dataset.status));
3174
+ });
3175
+
2629
3176
  // 拖拽: 每条 judgment 是 drag source, dataTransfer 装 decision text
2630
3177
  judgmentsList.addEventListener('dragstart', (e) => {
2631
3178
  const row = e.target.closest && e.target.closest('.judgment-row');
@@ -2866,13 +3413,84 @@ if (judgmentImportFile) {
2866
3413
  });
2867
3414
  }
2868
3415
 
2869
- // --- 从对话里 "存为判断": 事件委托到消息容器, 匹配 .save-as-judgment ---
3416
+ // --- 从对话里 "蒸馏为判断": 事件委托到消息容器, 匹配 .save-as-judgment ---
3417
+ // 两条路径:
3418
+ // 1. 有 data-channel-id → 调 /api/judgments/distill-from-conversation (B 触发, AI 蒸馏 + 演化对齐)
3419
+ // 2. 没有 channelId (老按钮 / 历史数据) → fallback 到老 /api/judgments (直存)
2870
3420
  document.addEventListener('click', async (e) => {
2871
3421
  const btn = e.target.closest && e.target.closest('.save-as-judgment');
2872
3422
  if (!btn) return;
2873
3423
  e.preventDefault();
2874
3424
  e.stopPropagation();
3425
+
3426
+ const channelId = btn.getAttribute('data-channel-id');
2875
3427
  const decision = (btn.getAttribute('data-decision') || '').trim();
3428
+
3429
+ if (channelId) {
3430
+ btn.classList.add('loading');
3431
+ btn.disabled = true;
3432
+ try {
3433
+ const res = await fetch('/api/judgments/distill-from-conversation', {
3434
+ method: 'POST',
3435
+ headers: { 'Content-Type': 'application/json' },
3436
+ body: JSON.stringify({ channelId }),
3437
+ });
3438
+ const out = await res.json();
3439
+ if (!res.ok) throw new Error(out.error || 'HTTP ' + res.status);
3440
+
3441
+ if (!out.triggered) {
3442
+ btn.classList.remove('loading');
3443
+ btn.disabled = false;
3444
+ btn.title = '蒸馏失败: ' + (out.reason || '无内容');
3445
+ return;
3446
+ }
3447
+
3448
+ const j = out.judgment;
3449
+ const ev = out.evolved || { merged: 0, superseded: 0 };
3450
+ btn.classList.remove('loading');
3451
+ btn.classList.add('saved');
3452
+ btn.title = '已蒸馏为判断';
3453
+
3454
+ // inline 确认弹框 (在按钮下方出现, 5 秒后自动消失)
3455
+ showDistillConfirm(btn, {
3456
+ value: j.decision,
3457
+ evidence: (j.reasons && j.reasons[0]) || '',
3458
+ merged: ev.merged,
3459
+ superseded: ev.superseded,
3460
+ onEdit: async (newText) => {
3461
+ try {
3462
+ await fetch('/api/judgments/' + encodeURIComponent(j.id), {
3463
+ method: 'PATCH',
3464
+ headers: { 'Content-Type': 'application/json' },
3465
+ body: JSON.stringify({ decision: newText }),
3466
+ });
3467
+ } catch (err) {
3468
+ console.error('[judgments] edit failed:', err);
3469
+ }
3470
+ },
3471
+ onReject: async () => {
3472
+ try {
3473
+ await fetch('/api/judgments/' + encodeURIComponent(j.id), {
3474
+ method: 'DELETE',
3475
+ });
3476
+ } catch (err) {
3477
+ console.error('[judgments] reject failed:', err);
3478
+ }
3479
+ },
3480
+ });
3481
+
3482
+ // 刷新判断力库缓存
3483
+ setTimeout(() => loadJudgments(), 100);
3484
+ } catch (err) {
3485
+ console.error('[judgments] distill-from-chat failed:', err);
3486
+ btn.classList.remove('loading');
3487
+ btn.disabled = false;
3488
+ btn.title = '蒸馏失败: ' + err.message;
3489
+ }
3490
+ return;
3491
+ }
3492
+
3493
+ // 老路径 fallback (没有 channelId, 直接存原文)
2876
3494
  if (!decision) return;
2877
3495
  try {
2878
3496
  const res = await fetch('/api/judgments', {
@@ -2884,12 +3502,65 @@ document.addEventListener('click', async (e) => {
2884
3502
  if (!out.ok) throw new Error(out.error || 'failed');
2885
3503
  btn.classList.add('saved');
2886
3504
  btn.title = '已存为判断';
2887
- // 顶部徽章会通过 setInterval 拉新数据, 不用手动触发
2888
3505
  } catch (err) {
2889
3506
  console.error('[judgments] save-from-chat failed:', err);
2890
3507
  btn.title = '保存失败: ' + err.message;
2891
3508
  }
2892
3509
  });
3510
+
3511
+ /**
3512
+ * inline 蒸馏确认弹框 — 在按钮下方出现, 显示凝练结果 + 演化结果
3513
+ * 5 秒后自动消失, 用户可点 "编辑" / "拒绝"
3514
+ */
3515
+ function showDistillConfirm(btn, opts) {
3516
+ const { value, evidence, merged, superseded, onEdit, onReject } = opts;
3517
+ const old = document.getElementById('distill-confirm-popup');
3518
+ if (old) old.remove();
3519
+
3520
+ const popup = document.createElement('div');
3521
+ popup.id = 'distill-confirm-popup';
3522
+ popup.style.cssText = `
3523
+ position:absolute; z-index:1000;
3524
+ background:#fff; border:1px solid #d1d5db; border-radius:6px;
3525
+ box-shadow:0 4px 12px rgba(0,0,0,0.1);
3526
+ padding:10px 12px; min-width:280px; max-width:380px;
3527
+ font-size:13px; color:#1f2937;
3528
+ `;
3529
+ let evolveNote = '';
3530
+ if (merged > 0 || superseded > 0) {
3531
+ evolveNote = `<div style="font-size:11px;color:#059669;margin-top:6px;">✓ 演化对齐: ${merged} 条已合并${superseded > 0 ? `, ${superseded} 条已淘汰` : ''}</div>`;
3532
+ }
3533
+ popup.innerHTML = `
3534
+ <div style="font-weight:600;margin-bottom:4px;">已蒸馏为判断力</div>
3535
+ <div style="background:#f9fafb;padding:6px 8px;border-radius:4px;line-height:1.4;">${escapeHtml(value)}</div>
3536
+ ${evidence ? `<div style="font-size:11px;color:#6b7280;margin-top:4px;">证据: ${escapeHtml(evidence)}</div>` : ''}
3537
+ ${evolveNote}
3538
+ <div style="display:flex;gap:6px;margin-top:8px;">
3539
+ <button class="dc-edit" style="background:none;border:1px solid #d1d5db;color:#374151;padding:2px 10px;border-radius:3px;cursor:pointer;font-size:11px;">编辑</button>
3540
+ <button class="dc-reject" style="background:none;border:1px solid #fca5a5;color:#b91c1c;padding:2px 10px;border-radius:3px;cursor:pointer;font-size:11px;">拒绝</button>
3541
+ <button class="dc-close" style="margin-left:auto;background:none;border:none;color:#6b7280;cursor:pointer;font-size:14px;">×</button>
3542
+ </div>
3543
+ `;
3544
+
3545
+ // 定位
3546
+ const rect = btn.getBoundingClientRect();
3547
+ popup.style.top = (window.scrollY + rect.bottom + 4) + 'px';
3548
+ popup.style.left = (window.scrollX + rect.left) + 'px';
3549
+ document.body.appendChild(popup);
3550
+
3551
+ // 绑定按钮
3552
+ popup.querySelector('.dc-edit').onclick = () => {
3553
+ const newText = prompt('编辑判断力:', value);
3554
+ if (newText && newText.trim() && onEdit) onEdit(newText.trim());
3555
+ popup.remove();
3556
+ };
3557
+ popup.querySelector('.dc-reject').onclick = () => {
3558
+ if (onReject) onReject();
3559
+ popup.remove();
3560
+ };
3561
+ popup.querySelector('.dc-close').onclick = () => popup.remove();
3562
+ setTimeout(() => popup.remove(), 5000);
3563
+ }
2893
3564
  if (judgmentSubmitBtn) judgmentSubmitBtn.addEventListener('click', submitJudgment);
2894
3565
 
2895
3566
  // 启动时拉一次, 让徽章显示总数 (不打开 modal 也能看到)
@@ -2905,11 +3576,33 @@ let knownPeers = []; // { name, publicKey, lastConnectedAt, addedAt }
2905
3576
 
2906
3577
  async function loadRemoteChannels() {
2907
3578
  try {
3579
+ // 1) 拉 known peers (好友列表)
2908
3580
  const res = await fetch('/api/p2p-peers');
2909
- if (!res.ok) return;
2910
- const data = await res.json();
2911
- knownPeers = Array.isArray(data.peers) ? data.peers : [];
3581
+ if (res.ok) {
3582
+ const data = await res.json();
3583
+ knownPeers = Array.isArray(data.peers) ? data.peers : [];
3584
+ }
3585
+ // 2) 2026-06-10 修: 同时拉 /api/remote-channels, 兜底 SSE 推送漏掉的情况
3586
+ // (页面刷新后 remoteChannels[] = [], 必须主动拉一次才有数据)
3587
+ const r2 = await fetch('/api/remote-channels');
3588
+ if (r2.ok) {
3589
+ const data2 = await r2.json();
3590
+ const peers = Array.isArray(data2.peers) ? data2.peers : [];
3591
+ // 合并到 remoteChannels[]: 按 peerId 覆盖
3592
+ for (const p of peers) {
3593
+ let group = remoteChannels.find(g => g.peerId === p.peerId);
3594
+ if (!group) {
3595
+ group = { peerId: p.peerId, channels: [], peerName: ('peer-' + p.peerId.substring(0, 8)) };
3596
+ remoteChannels.push(group);
3597
+ }
3598
+ group.channels = p.channels || [];
3599
+ }
3600
+ }
2912
3601
  renderRemoteChannels();
3602
+ // 3) 远端数据可能变化, 同步 @-mention 列表
3603
+ if (typeof refreshMentionChannels === 'function') {
3604
+ refreshMentionChannels();
3605
+ }
2913
3606
  } catch (err) {
2914
3607
  console.error('[v3] loadRemoteChannels 失败:', err);
2915
3608
  }
@@ -2919,30 +3612,60 @@ function renderRemoteChannels() {
2919
3612
  const list = document.getElementById('remote-channel-list');
2920
3613
  if (!list) return;
2921
3614
 
2922
- // Phase 3 重做: 好友列表 + 收到的 channel 分组
2923
- if (knownPeers.length === 0) {
2924
- list.innerHTML = '<li style="color:var(--text-muted);font-size:11px;padding:8px 4px;text-align:center;">(暂无好友, 点 + 添加)</li>';
2925
- return;
2926
- }
2927
-
2928
3615
  // 按 peerId 分组 channels
2929
3616
  const channelsByPeer = {};
2930
3617
  for (const p of remoteChannels) {
2931
3618
  channelsByPeer[p.peerId] = p.channels || [];
2932
3619
  }
2933
3620
 
2934
- const html = knownPeers.map(peer => {
3621
+ // 2026-06-10 修: 之前 UI 只渲染 knownPeers, 但对面 publicKey 可能跟本机 known_peers 不匹配
3622
+ // (例如对面重启 / 换 role / 第一次相连还没加为好友), 导致 remoteChannels 里有数据 UI 却空白.
3623
+ // 修法: 把 remoteChannels 里的 "陌生 peer" (不在 known_peers 里) 也渲染出来, 标记为未加好友.
3624
+ const knownPks = new Set(knownPeers.map(p => p.publicKey));
3625
+ const strangerPeers = remoteChannels
3626
+ .filter(p => !knownPks.has(p.peerId))
3627
+ .map(p => ({
3628
+ publicKey: p.peerId,
3629
+ name: p.peerName || ('未授权 ' + p.peerId.substring(0, 8)),
3630
+ lastConnectedAt: null,
3631
+ _isStranger: true
3632
+ }));
3633
+ const allPeers = [...knownPeers, ...strangerPeers];
3634
+
3635
+ if (allPeers.length === 0) {
3636
+ list.innerHTML = '<li style="color:var(--text-muted);font-size:11px;padding:8px 4px;text-align:center;">(暂无好友, 点 + 添加)</li>';
3637
+ return;
3638
+ }
3639
+
3640
+ const html = allPeers.map(peer => {
2935
3641
  const peerChannels = channelsByPeer[peer.publicKey] || [];
2936
3642
  const lastConn = peer.lastConnectedAt
2937
3643
  ? new Date(peer.lastConnectedAt).toLocaleDateString()
2938
- : '从未连接';
3644
+ : (peer._isStranger ? '陌生 peer' : '从未连接');
3645
+ const strangerStyle = peer._isStranger ? 'border:1px dashed var(--border-light);' : '';
3646
+ const strangerIcon = peer._isStranger ? '❔' : '👤';
3647
+ // 2026-06-11: 折叠逻辑 (全不展开)
3648
+ // - 所有 peer 首次见都默认 *折叠* (包括 known_peers 第一个) — 用户一进来看到完整 peer 列表
3649
+ // - 标题栏右侧 "X ch" 提示有内容, 用户点 caret 展开
3650
+ // - 已见过: 沿用 collapsedPeers (用户上次选择)
3651
+ // - "全部展开/折叠" 按钮在 P2P header (id=p2p-toggle-all-btn)
3652
+ if (!seenPeers.has(peer.publicKey)) {
3653
+ seenPeers.add(peer.publicKey);
3654
+ collapsedPeers.add(peer.publicKey); // 全部默认折叠
3655
+ saveSeenPeers();
3656
+ saveCollapsedPeers();
3657
+ }
3658
+ const isCollapsed = collapsedPeers.has(peer.publicKey);
3659
+ const caretChar = '▾'; // CSS rotate -90deg 处理折叠态
2939
3660
  return `
2940
- <li class="remote-peer-group" style="margin-bottom:10px;">
3661
+ <li class="remote-peer-group ${isCollapsed ? 'collapsed' : ''}" style="margin-bottom:10px;${strangerStyle}">
2941
3662
  <div class="remote-peer-header" data-peer-name="${escapeHtml(peer.name)}" data-peer-pk="${escapeHtml(peer.publicKey)}"
2942
- style="display:flex;align-items:center;gap:6px;padding:4px 6px;background:var(--bg-hover);border-radius:4px;cursor:pointer;">
2943
- <span style="font-size:12px;">👤</span>
2944
- <span style="flex:1;font-size:12px;font-weight:600;" title="${escapeHtml(peer.publicKey)}">${escapeHtml(peer.name)}</span>
2945
- <span style="font-size:9px;color:var(--text-muted);">${lastConn}</span>
3663
+ style="display:flex;align-items:center;gap:6px;padding:6px 8px;background:var(--bg-hover);border-radius:4px;cursor:pointer;">
3664
+ <button class="peer-caret-btn" data-toggle-peer="${escapeHtml(peer.publicKey)}" title="折叠/展开"
3665
+ style="background:var(--bg-active);border:1px solid var(--border);color:var(--text);cursor:pointer;width:22px;height:22px;border-radius:4px;font-size:12px;line-height:1;padding:0;display:flex;align-items:center;justify-content:center;flex:0 0 auto;">${caretChar}</button>
3666
+ <span style="font-size:13px;">${strangerIcon}</span>
3667
+ <span style="flex:1;font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escapeHtml(peer.publicKey)}">${escapeHtml(peer.name)}</span>
3668
+ <span style="font-size:9px;color:var(--text-muted);">${peerChannels.length > 0 ? `${peerChannels.length} ch · ` : ''}${lastConn}</span>
2946
3669
  </div>
2947
3670
  <div class="remote-peer-channels" style="margin-top:4px;margin-left:8px;">
2948
3671
  ${peerChannels.length === 0
@@ -2952,7 +3675,6 @@ function renderRemoteChannels() {
2952
3675
  style="display:flex;align-items:center;gap:6px;padding:4px 6px;cursor:pointer;border-radius:4px;font-size:12px;">
2953
3676
  <span>🤖</span>
2954
3677
  <span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escapeHtml(c.name || '')}">${escapeHtml(c.name || '(未命名)')}</span>
2955
- <span title="对方 judgment 数 (不会同步到本地)" style="font-size:9px;color:var(--text-muted);">🧠${c.boundJudgmentCount || 0}</span>
2956
3678
  </div>
2957
3679
  `).join('')
2958
3680
  }
@@ -2962,6 +3684,15 @@ function renderRemoteChannels() {
2962
3684
  }).join('');
2963
3685
  list.innerHTML = html;
2964
3686
 
3687
+ // 2026-06-10: 折叠按钮点击 → 切折叠 (stopPropagation 防止冒泡触发 header 的分享 modal)
3688
+ list.querySelectorAll('.peer-caret-btn[data-toggle-peer]').forEach(btn => {
3689
+ btn.addEventListener('click', (e) => {
3690
+ e.stopPropagation();
3691
+ const pk = btn.getAttribute('data-toggle-peer');
3692
+ togglePeerCollapsed(pk);
3693
+ });
3694
+ });
3695
+
2965
3696
  // 绑定: 点击 channel → 弹聊天窗口
2966
3697
  list.querySelectorAll('.remote-channel-row').forEach(row => {
2967
3698
  row.addEventListener('click', () => {
@@ -2974,15 +3705,104 @@ function renderRemoteChannels() {
2974
3705
  });
2975
3706
  // 绑定: 点击 peer 头部 → 弹分享 modal (让 A 决定分享本机哪些 channel 给这个 peer)
2976
3707
  list.querySelectorAll('.remote-peer-header').forEach(row => {
2977
- row.addEventListener('click', () => {
3708
+ row.addEventListener('click', (e) => {
3709
+ // 2026-06-10: 防御 — 点 caret 时已 stopPropagation, 但万一冒泡逃逸再挡一道
3710
+ if (e.target.closest('.peer-caret')) return;
2978
3711
  const peerName = row.dataset.peerName;
2979
3712
  const peerPk = row.dataset.peerPk;
2980
3713
  openShareToPeerModal(peerName, peerPk);
2981
3714
  });
2982
3715
  });
3716
+
3717
+ // 2026-06-10: 每个 peer 头部双击 → 改名字 / 改备注
3718
+ list.querySelectorAll('.remote-peer-header').forEach(row => {
3719
+ row.addEventListener('dblclick', (e) => {
3720
+ if (e.target.closest('.peer-caret-btn')) return;
3721
+ const peerName = row.dataset.peerName;
3722
+ const peerPk = row.dataset.peerPk;
3723
+ openEditPeerModal(peerName, peerPk);
3724
+ });
3725
+ });
3726
+
3727
+ // 2026-06-10: 渲染完成后同步 header 切换按钮图标
3728
+ if (typeof window.__syncP2PToggleAllBtn === 'function') window.__syncP2PToggleAllBtn();
3729
+ }
3730
+
3731
+ /** v3: 改 peer 名字 / 备注 modal (持久化到 known_peers.json) */
3732
+ async function openEditPeerModal(peerName, peerPublicKey) {
3733
+ document.getElementById('edit-peer-modal')?.remove();
3734
+ // 先读 known_peers 拿到现有 notes
3735
+ let currentNotes = '';
3736
+ let currentName = peerName;
3737
+ try {
3738
+ const r = await fetch('/api/p2p-peers');
3739
+ if (r.ok) {
3740
+ const d = await r.json();
3741
+ const entry = (d.peers || []).find(p => p.publicKey === peerPublicKey);
3742
+ if (entry) {
3743
+ currentName = entry.name || peerName;
3744
+ currentNotes = entry.notes || '';
3745
+ }
3746
+ }
3747
+ } catch {}
3748
+ const html = `
3749
+ <div id="edit-peer-modal" class="friend-req-overlay">
3750
+ <div class="friend-req-shell" style="width:520px;">
3751
+ <div class="friend-req-header">
3752
+ <span style="font-size:18px;">✏️</span>
3753
+ <div style="flex:1;min-width:0;">
3754
+ <div class="friend-req-title">编辑好友</div>
3755
+ <div class="friend-req-meta">publicKey: ${escapeHtml(peerPublicKey.substring(0,16))}…</div>
3756
+ </div>
3757
+ </div>
3758
+ <div class="friend-req-body">
3759
+ <label style="display:block;margin-bottom:6px;font-size:12px;color:var(--text-secondary);">显示名字</label>
3760
+ <input id="epm-name" type="text" value="${escapeHtml(currentName)}"
3761
+ style="width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:4px;background:var(--bg-main);color:var(--text);font-family:inherit;font-size:13px;box-sizing:border-box;margin-bottom:12px;">
3762
+ <label style="display:block;margin-bottom:6px;font-size:12px;color:var(--text-secondary);">备注 (自由文本, 例如合作领域 / 怎么认识的)</label>
3763
+ <textarea id="epm-notes" rows="4" placeholder="例如: 2026-06 合作 LLM 代发验证"
3764
+ style="width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:4px;background:var(--bg-main);color:var(--text);font-family:inherit;font-size:13px;box-sizing:border-box;resize:vertical;">${escapeHtml(currentNotes)}</textarea>
3765
+ </div>
3766
+ <div class="friend-req-actions">
3767
+ <button id="epm-cancel" class="friend-req-btn-deny">取消</button>
3768
+ <button id="epm-save" class="friend-req-btn-accept">保存</button>
3769
+ </div>
3770
+ </div>
3771
+ </div>
3772
+ `;
3773
+ document.body.insertAdjacentHTML('beforeend', html);
3774
+ const close = () => document.getElementById('edit-peer-modal')?.remove();
3775
+ document.getElementById('epm-cancel').onclick = close;
3776
+ document.getElementById('epm-save').onclick = async () => {
3777
+ const newName = document.getElementById('epm-name').value.trim() || currentName;
3778
+ const newNotes = document.getElementById('epm-notes').value;
3779
+ try {
3780
+ const r = await fetch(`/api/p2p-peers/${encodeURIComponent(peerName)}`, {
3781
+ method: 'PATCH',
3782
+ headers: { 'Content-Type': 'application/json' },
3783
+ body: JSON.stringify({ name: newName, notes: newNotes })
3784
+ });
3785
+ const data = await r.json();
3786
+ if (!r.ok) throw new Error(data.error || 'save failed');
3787
+ console.log('[v3] 改 peer 成功:', newName, '备注:', newNotes);
3788
+ showSimpleToast(`✅ 已保存 ${newName}`);
3789
+ close();
3790
+ // 重新拉 known_peers + 远程 channels 重新渲染
3791
+ const r2 = await fetch('/api/p2p-peers');
3792
+ if (r2.ok) {
3793
+ const d2 = await r2.json();
3794
+ knownPeers = Array.isArray(d2.peers) ? d2.peers : [];
3795
+ }
3796
+ renderRemoteChannels();
3797
+ } catch (err) {
3798
+ console.error('[v3] 保存 peer 失败:', err);
3799
+ alert('保存失败: ' + (err.message || err));
3800
+ }
3801
+ };
2983
3802
  }
2984
3803
 
2985
3804
  /** v3: 分享 channel 给指定 peer 的 modal (A 侧用) */
3805
+ /** v3: 分享 channel 给指定 peer 的 modal (A 侧用) — 2026-06-11 改用 Step 3 风格 class */
2986
3806
  async function openShareToPeerModal(peerName, peerPublicKey) {
2987
3807
  document.getElementById('share-to-peer-modal')?.remove();
2988
3808
  let allChannels = [];
@@ -2991,36 +3811,37 @@ async function openShareToPeerModal(peerName, peerPublicKey) {
2991
3811
  if (res.ok) allChannels = await res.json();
2992
3812
  } catch (err) { console.error('openShareToPeerModal:', err); }
2993
3813
  const rows = allChannels.length === 0
2994
- ? '<div style="color:#6b7280;padding:12px;text-align:center;">还没有 channel</div>'
3814
+ ? '<div class="share-modal-empty">还没有 channel</div>'
2995
3815
  : allChannels.map(ch => {
2996
3816
  const isShared = Array.isArray(ch.shared_with_peers) && ch.shared_with_peers.includes(peerPublicKey);
2997
3817
  return `
2998
- <label style="display:flex;align-items:flex-start;gap:8px;padding:6px 4px;cursor:pointer;border-bottom:1px solid #f3f4f6;">
2999
- <input type="checkbox" data-cid="${escapeHtml(ch.id)}" ${isShared ? 'checked' : ''} style="margin-top:4px;cursor:pointer;">
3000
- <div style="flex:1;min-width:0;">
3001
- <div style="font-size:13px;font-weight:500;">${escapeHtml(ch.name)}</div>
3002
- <div style="font-size:10px;color:#9ca3af;margin-top:2px;">
3003
- ${isShared ? '✓ 已分享' : '未分享'} · ${ch.id}
3818
+ <label class="share-modal-row">
3819
+ <input type="checkbox" data-cid="${escapeHtml(ch.id)}" ${isShared ? 'checked' : ''} class="share-modal-cb">
3820
+ <div class="share-modal-row-info">
3821
+ <div class="share-modal-row-name">${escapeHtml(ch.name)}</div>
3822
+ <div class="share-modal-row-meta">
3823
+ ${isShared ? '✓ 已分享' : '未分享'} · ${escapeHtml(ch.id.slice(0, 24))}
3004
3824
  </div>
3005
3825
  </div>
3006
3826
  </label>
3007
3827
  `;
3008
3828
  }).join('');
3009
3829
  const html = `
3010
- <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;">
3011
- <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);">
3012
- <div style="padding:12px 16px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;justify-content:space-between;">
3013
- <div>
3014
- <div style="font-size:15px;font-weight:600;">分享 channel 给 ${escapeHtml(peerName)}</div>
3015
- <div style="font-size:11px;color:#6b7280;margin-top:2px;">${escapeHtml(peerPublicKey.substring(0,16))}…</div>
3830
+ <div id="share-to-peer-modal" class="friend-req-overlay">
3831
+ <div class="friend-req-shell share-modal-shell">
3832
+ <div class="friend-req-header">
3833
+ <span style="font-size:18px;">📤</span>
3834
+ <div style="flex:1;min-width:0;">
3835
+ <div class="friend-req-title">分享 channel 给 ${escapeHtml(peerName)}</div>
3836
+ <div class="friend-req-meta">${escapeHtml(peerPublicKey.substring(0,16))}…</div>
3016
3837
  </div>
3017
- <button id="spm-close" style="background:none;border:none;font-size:20px;color:#6b7280;cursor:pointer;">×</button>
3838
+ <button id="spm-close" class="friend-req-btn-close">×</button>
3018
3839
  </div>
3019
- <div style="padding:8px 12px;background:#f9fafb;font-size:12px;color:#6b7280;">勾选要分享的 channel, 对方才能看到</div>
3020
- <div id="spm-list" style="flex:1;overflow-y:auto;padding:8px 16px;">${rows}</div>
3021
- <div style="padding:12px 20px;border-top:1px solid #e5e7eb;display:flex;justify-content:flex-end;gap:8px;">
3022
- <button id="spm-cancel" style="padding:6px 14px;border:1px solid #d1d5db;background:#fff;border-radius:4px;cursor:pointer;font-size:13px;">取消</button>
3023
- <button id="spm-save" style="padding:6px 14px;background:#2563eb;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:13px;">保存分享</button>
3840
+ <div class="share-modal-hint">勾选要分享的 channel, 对方才能看到</div>
3841
+ <div id="spm-list" class="share-modal-list">${rows}</div>
3842
+ <div class="friend-req-actions">
3843
+ <button id="spm-cancel" class="friend-req-btn-deny">取消</button>
3844
+ <button id="spm-save" class="friend-req-btn-accept">保存分享</button>
3024
3845
  </div>
3025
3846
  </div>
3026
3847
  </div>
@@ -3036,7 +3857,7 @@ async function openShareToPeerModal(peerName, peerPublicKey) {
3036
3857
  for (const ch of allChannels) {
3037
3858
  const shouldShare = checkedIds.includes(ch.id);
3038
3859
  const wasShared = Array.isArray(ch.shared_with_peers) && ch.shared_with_peers.includes(peerPublicKey);
3039
- if (shouldShare === wasShared) continue; // 没变化跳过
3860
+ if (shouldShare === wasShared) continue;
3040
3861
  const newList = (ch.shared_with_peers || []).filter((p) => p !== peerPublicKey);
3041
3862
  if (shouldShare) newList.push(peerPublicKey);
3042
3863
  try {
@@ -3048,34 +3869,36 @@ async function openShareToPeerModal(peerName, peerPublicKey) {
3048
3869
  if (res.ok) ok++; else fail++;
3049
3870
  } catch { fail++; }
3050
3871
  }
3051
- alert(`分享更新完成: 成功 ${ok}, 失败 ${fail}`);
3872
+ showSimpleToast(`分享更新完成: 成功 ${ok}, 失败 ${fail}`, ok > 0 ? 'info' : (fail > 0 ? 'error' : 'info'));
3052
3873
  overlay.remove();
3053
3874
  };
3054
3875
  }
3055
3876
 
3056
- /** v3: 跟远端 channel 聊天的简易弹窗 */
3877
+ /** v3: 跟远端 channel 聊天的简易弹窗
3878
+ * 2026-06-10 重写: UI 完全对齐本地聊天 (复用 addMessage / .messages / .bubble 整套样式),
3879
+ * marked.parse + cleanThink + cleanEnv 自动生效, 不再裸文本.
3880
+ */
3057
3881
  function openRemoteChannelChat(peerPublicKey, channelId, channelName) {
3058
3882
  // 移除已有 modal
3059
3883
  document.getElementById('remote-chat-modal')?.remove();
3060
3884
  const html = `
3061
- <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;">
3062
- <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);">
3063
- <div style="padding:12px 16px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;justify-content:space-between;">
3885
+ <div id="remote-chat-modal" class="remote-chat-overlay">
3886
+ <div class="remote-chat-shell">
3887
+ <div class="remote-chat-header">
3064
3888
  <div style="flex:1;min-width:0;">
3065
- <div style="font-size:15px;font-weight:600;">跟 ${escapeHtml(channelName)} 聊天</div>
3066
- <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>
3889
+ <div class="remote-chat-title">🌐 ${escapeHtml(channelName)} 聊天</div>
3890
+ <div class="remote-chat-meta">远端 peer: ${escapeHtml(peerPublicKey.substring(0,16))}… · ${escapeHtml(channelId)}</div>
3067
3891
  </div>
3068
- <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>
3069
- <button id="rcm-close" style="background:none;border:none;font-size:20px;color:#6b7280;cursor:pointer;">×</button>
3892
+ <button id="rcm-refresh-history" title="重新拉历史" class="remote-chat-btn-secondary">↻ 历史</button>
3893
+ <button id="rcm-close" class="remote-chat-btn-close">×</button>
3070
3894
  </div>
3071
- <div id="rcm-thinking" style="display:none;padding:8px 16px;background:#fef3c7;color:#92400e;font-size:12px;border-bottom:1px solid #e5e7eb;">
3895
+ <div id="rcm-thinking" class="remote-chat-thinking" style="display:none;">
3072
3896
  📥 正在从远端拉历史 + 判断力…
3073
3897
  </div>
3074
- <div id="rcm-log" style="flex:1;overflow-y:auto;padding:12px 16px;min-height:240px;max-height:60vh;background:#f9fafb;"></div>
3075
- <div style="padding:10px 12px;border-top:1px solid #e5e7eb;display:flex;gap:6px;">
3076
- <input id="rcm-input" type="text" placeholder="输入消息, 发送到远端 channel..."
3077
- style="flex:1;padding:8px 10px;border:1px solid #d1d5db;border-radius:4px;font-size:13px;">
3078
- <button id="rcm-send" style="padding:8px 14px;background:#2563eb;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:13px;">发送</button>
3898
+ <div id="rcm-log" class="messages remote-chat-log"></div>
3899
+ <div class="remote-chat-input-row">
3900
+ <input id="rcm-input" type="text" placeholder="输入消息, 发送到远端 channel..." class="remote-chat-input">
3901
+ <button id="rcm-send" class="remote-chat-btn-send">发送</button>
3079
3902
  </div>
3080
3903
  </div>
3081
3904
  </div>
@@ -3086,40 +3909,51 @@ function openRemoteChannelChat(peerPublicKey, channelId, channelName) {
3086
3909
  const inputEl = document.getElementById('rcm-input');
3087
3910
  const sendBtn = document.getElementById('rcm-send');
3088
3911
  const thinkingEl = document.getElementById('rcm-thinking');
3089
- document.getElementById('rcm-close').onclick = () => document.getElementById('remote-chat-modal').remove();
3090
- document.getElementById('rcm-refresh-history').onclick = () => loadHistory();
3912
+ let historyRefreshTimer = null;
3913
+ document.getElementById('rcm-close').onclick = () => {
3914
+ if (historyRefreshTimer) { clearInterval(historyRefreshTimer); historyRefreshTimer = null; }
3915
+ document.getElementById('remote-chat-modal').remove();
3916
+ };
3917
+ document.getElementById('rcm-refresh-history').onclick = () => loadHistory(false);
3091
3918
 
3919
+ // 2026-06-10 改: 直接复用本地 addMessage, 自动获得 marked + think 折叠 + env 折叠 + 主题变量
3092
3920
  const append = (text, role) => {
3093
- const bubble = document.createElement('div');
3094
- const isUser = role === 'user';
3095
- 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;${
3096
- isUser ? 'background:#2563eb;color:#fff;margin-left:auto;text-align:left;'
3097
- : 'background:#e5e7eb;color:#111;'
3098
- }`;
3099
- bubble.textContent = text;
3100
- log.appendChild(bubble);
3921
+ addMessage(text, role === 'user' ? 'user' : 'ai', false, log);
3101
3922
  log.scrollTop = log.scrollHeight;
3102
3923
  };
3103
3924
 
3925
+ // 系统提示用更轻量的样式 (不走 addMessage, 避免被当聊天记录裁剪)
3104
3926
  const appendSystem = (text, kind = 'info') => {
3105
3927
  const el = document.createElement('div');
3106
- const colors = {
3107
- info: { bg: '#dbeafe', fg: '#1e40af' },
3108
- warn: { bg: '#fef3c7', fg: '#92400e' },
3109
- error: { bg: '#fca5a5', fg: '#7f1d1d' }
3110
- };
3111
- const c = colors[kind] || colors.info;
3112
- el.style.cssText = `margin:6px 0;padding:6px 10px;background:${c.bg};color:${c.fg};border-radius:4px;font-size:11px;text-align:center;`;
3928
+ el.className = `remote-chat-sysmsg remote-chat-sysmsg-${kind}`;
3113
3929
  el.textContent = text;
3114
3930
  log.appendChild(el);
3115
3931
  log.scrollTop = log.scrollHeight;
3116
3932
  };
3117
3933
 
3118
3934
  // v3 新增: 拉 A 端的 channel 历史 (含 messages + judgments)
3119
- async function loadHistory() {
3935
+ async function loadHistory(isSilent) {
3936
+ if (!document.getElementById('remote-chat-modal')) return; // modal 已关闭
3937
+
3938
+ if (isSilent) {
3939
+ try {
3940
+ const res = await fetch(`/api/remote-channels/chat-history?targetPublicKey=${encodeURIComponent(peerPublicKey)}&channelId=${encodeURIComponent(channelId)}`);
3941
+ if (!res.ok || !document.getElementById('remote-chat-modal')) return;
3942
+ const data = await res.json();
3943
+ const newMsgs = data.messages || [];
3944
+ const oldCount = log.querySelectorAll('.message').length;
3945
+ if (newMsgs.length === oldCount) return;
3946
+ const scrollWasAtBottom = log.scrollTop + log.clientHeight >= log.scrollHeight - 30;
3947
+ renderHistory(data);
3948
+ if (scrollWasAtBottom) {
3949
+ setTimeout(() => { log.scrollTop = log.scrollHeight; }, 50);
3950
+ }
3951
+ } catch (_) { /* 静默失败 */ }
3952
+ return;
3953
+ }
3954
+
3120
3955
  thinkingEl.style.display = 'block';
3121
3956
  log.innerHTML = '';
3122
- appendSystem('正在拉取远端 channel 的历史和判断力...', 'info');
3123
3957
  try {
3124
3958
  const res = await fetch(`/api/remote-channels/chat-history?targetPublicKey=${encodeURIComponent(peerPublicKey)}&channelId=${encodeURIComponent(channelId)}`);
3125
3959
  const data = await res.json();
@@ -3128,63 +3962,55 @@ function openRemoteChannelChat(peerPublicKey, channelId, channelName) {
3128
3962
  thinkingEl.style.display = 'none';
3129
3963
  return;
3130
3964
  }
3131
- // 清掉 loading
3132
- log.innerHTML = '';
3133
-
3134
- // 1. 显示 judgment 依据 (header)
3135
- const judgments = data.judgments || { bound: [], candidates: [] };
3136
- if (judgments.bound && judgments.bound.length > 0) {
3137
- const jh = document.createElement('div');
3138
- jh.style.cssText = 'margin:0 0 8px;padding:8px 10px;background:#fef3c7;border-left:3px solid #f59e0b;border-radius:4px;font-size:12px;';
3139
- let h = `<div style="font-weight:600;color:#92400e;margin-bottom:4px;">🛡️ 对方 channel 绑定的判断力 (${judgments.bound.length} 条硬约束)</div>`;
3140
- for (const j of judgments.bound) {
3141
- 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>`;
3142
- }
3143
- if (judgments.candidates && judgments.candidates.length > 0) {
3144
- h += `<div style="margin-top:6px;color:#92400e;font-size:11px;">+ ${judgments.candidates.length} 条候选判断力 (LLM 可自选参考)</div>`;
3145
- }
3146
- jh.innerHTML = h;
3147
- log.appendChild(jh);
3965
+ renderHistory(data);
3966
+ } catch (err) {
3967
+ appendSystem(`拉取异常: ${err.message}`, 'error');
3968
+ } finally {
3969
+ thinkingEl.style.display = 'none';
3970
+ }
3971
+ }
3972
+
3973
+ function renderHistory(data) {
3974
+ log.innerHTML = '';
3975
+
3976
+ // 1. 显示 judgment 依据 (header) — 保留, 但用 class 化样式
3977
+ const judgments = data.judgments || { bound: [], candidates: [] };
3978
+ if (judgments.bound && judgments.bound.length > 0) {
3979
+ const jh = document.createElement('div');
3980
+ jh.className = 'remote-chat-judgments';
3981
+ let h = `<div class="remote-chat-judgments-title">🛡️ 对方 channel 绑定的判断力 (${judgments.bound.length} 条硬约束)</div>`;
3982
+ for (const j of judgments.bound) {
3983
+ h += `<div class="remote-chat-judgment-item">• <b>${escapeHtml((j.decision || '').slice(0, 100))}</b>${j.domain ? `<span class="remote-chat-judgment-tag"> [${escapeHtml(j.domain)}${j.stakes ? '/' + escapeHtml(j.stakes) : ''}]</span>` : ''}${j.reasons && j.reasons.length ? '<br><span class="remote-chat-judgment-reason">理由: ' + escapeHtml(j.reasons.join('; ').slice(0, 100)) + '</span>' : ''}</div>`;
3984
+ }
3985
+ if (judgments.candidates && judgments.candidates.length > 0) {
3986
+ h += `<div class="remote-chat-judgments-foot">+ ${judgments.candidates.length} 条候选判断力 (LLM 可自选参考)</div>`;
3148
3987
  }
3988
+ jh.innerHTML = h;
3989
+ log.appendChild(jh);
3990
+ }
3149
3991
 
3150
- // 2. 显示历史 messages (带 source 标签: 内部 owner vs 远端访客)
3151
- const msgs = data.messages || [];
3152
- if (msgs.length === 0) {
3153
- appendSystem('还没有历史消息, 在下面发第一条吧', 'info');
3154
- } else {
3155
- appendSystem(`从远端拉到 ${msgs.length} 条历史消息 (A 内部 owner + B 远端访客 都会显示)`, 'info');
3156
- for (const m of msgs) {
3157
- const isUser = m.type === 'user';
3158
- // v3: source 标签
3159
- let tag = '';
3160
- if (isUser) {
3161
- if (m.source === 'remote') {
3162
- tag = `<div style="font-size:10px;color:#6b7280;margin-bottom:2px;">🌐 远端访客${m.fromPublicKey ? ' (' + m.fromPublicKey.substring(0, 8) + '…)' : ''} → A 的 channel</div>`;
3163
- } else {
3164
- tag = `<div style="font-size:10px;color:#6b7280;margin-bottom:2px;">👤 A (内部 owner) → A 的 channel</div>`;
3165
- }
3992
+ // 2. 显示历史 messages 完全复用本地 addMessage 渲染
3993
+ const msgs = data.messages || [];
3994
+ if (msgs.length === 0) {
3995
+ appendSystem('还没有历史消息, 在下面发第一条吧', 'info');
3996
+ } else {
3997
+ for (const m of msgs) {
3998
+ // 远端 owner user 消息 vs 远端访客 (B) 的 user 消息 vs A 的 LLM 回复
3999
+ // 全部走 addMessage, marked/think/env 自动处理. 来源用一个小 prefix 标记.
4000
+ const type = m.type === 'user' ? 'user' : 'ai';
4001
+ let prefix = '';
4002
+ if (m.type === 'user') {
4003
+ if (m.source === 'remote') {
4004
+ prefix = `🌐 远端访客${m.fromPublicKey ? ' (' + m.fromPublicKey.substring(0, 8) + '…)' : ''}\n\n`;
3166
4005
  } else {
3167
- tag = `<div style="font-size:10px;color:#6b7280;margin-bottom:2px;">🤖 A 的 LLM ( A 节点上跑)</div>`;
4006
+ prefix = `👤 A (内部 owner)\n\n`;
3168
4007
  }
3169
- const bubble = document.createElement('div');
3170
- const isRemoteUser = isUser && m.source === 'remote';
3171
- 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;${
3172
- isUser
3173
- ? (isRemoteUser
3174
- ? 'background:#dbeafe;color:#1e3a8a;margin-right:auto;text-align:left;border:1px solid #93c5fd;'
3175
- : 'background:#f3f4f6;color:#374151;margin-left:auto;text-align:left;border:1px solid #d1d5db;')
3176
- : 'background:#e5e7eb;color:#111;'
3177
- }`;
3178
- bubble.innerHTML = tag + `<div>${escapeHtml(m.content || '')}</div>`;
3179
- log.appendChild(bubble);
4008
+ } else {
4009
+ prefix = `🤖 A LLM\n\n`;
3180
4010
  }
3181
- // 滚到底部
3182
- setTimeout(() => { log.scrollTop = log.scrollHeight; }, 50);
4011
+ addMessage(prefix + (m.content || ''), type, false, log);
3183
4012
  }
3184
- } catch (err) {
3185
- appendSystem(`拉取异常: ${err.message}`, 'error');
3186
- } finally {
3187
- thinkingEl.style.display = 'none';
4013
+ setTimeout(() => { log.scrollTop = log.scrollHeight; }, 50);
3188
4014
  }
3189
4015
  }
3190
4016
 
@@ -3203,9 +4029,9 @@ function openRemoteChannelChat(peerPublicKey, channelId, channelName) {
3203
4029
  });
3204
4030
  const data = await res.json();
3205
4031
  if (!res.ok) throw new Error(data.error || 'send failed');
3206
- appendSystem('已发送, 等待对方回复...', 'info');
4032
+ // 不再 appendSystem('已发送...') —— 用户看到自己消息已上屏就知道, 系统提示是噪音
3207
4033
  } catch (err) {
3208
- append('(发送失败: ' + (err.message || err) + ')', 'ai');
4034
+ appendSystem('发送失败: ' + (err.message || err), 'error');
3209
4035
  } finally {
3210
4036
  sendBtn.disabled = false;
3211
4037
  sendBtn.textContent = '发送';
@@ -3219,7 +4045,10 @@ function openRemoteChannelChat(peerPublicKey, channelId, channelName) {
3219
4045
  startV3GlobalSSE();
3220
4046
 
3221
4047
  // 打开时立即拉历史
3222
- loadHistory();
4048
+ loadHistory(false);
4049
+
4050
+ // 每 15 秒自动静默刷新, 同步远端 owner 或其他访客的新消息
4051
+ historyRefreshTimer = setInterval(() => loadHistory(true), 15000);
3223
4052
  }
3224
4053
 
3225
4054
  // Phase 3: 我的 ID 按钮 → 真 modal (避免 confirm 在某些环境被禁用)
@@ -3305,20 +4134,166 @@ if (addPeerBtn) {
3305
4134
  return;
3306
4135
  }
3307
4136
  try {
3308
- // p2p-connect (会 joinPeer + 持久化)
3309
- const res = await fetch('/api/remote-channels/p2p-connect', {
4137
+ // v3 新增: 改用 friend-request RPC — 不光 joinPeer, 还发申请到对方
4138
+ // 对方会收到 SSE friend-request 事件, 弹一个申请 modal
4139
+ const res = await fetch('/api/friend-request', {
3310
4140
  method: 'POST',
3311
4141
  headers: { 'Content-Type': 'application/json' },
3312
- body: JSON.stringify({ targetPublicKey: publicKey, name, persist: true })
4142
+ body: JSON.stringify({ targetPublicKey: publicKey, name, message: '想加你为 P2P 好友, 共享 channel 协作' })
3313
4143
  });
3314
4144
  const data = await res.json();
4145
+ if (res.status === 502) {
4146
+ // 2026-06-10: 区分"对方不在线"和"写失败" — 让用户知道是否需要重试
4147
+ const reason = data.code === 'NO_CONN' ? '对方未在线或 P2P 握手超时' : '写入 P2P 通道失败';
4148
+ alert(`好友申请发送失败: ${reason}\n\n本地已记住对方 publicKey (${publicKey.substring(0,8)}...), 等对方上线后可在 P2P 面板手动重试.`);
4149
+ await loadRemoteChannels();
4150
+ return;
4151
+ }
3315
4152
  if (!res.ok) throw new Error(data.error || 'connect failed');
3316
- alert(`已添加好友: ${name} (${publicKey.substring(0, 12)}...)\n重启后会自动重连`);
4153
+ // 成功 — 但不阻塞地等 ack (ack 经 SSE 'friend-request-ack' 推回, 由 v3GlobalEventSource 处理)
4154
+ window.__pendingFriendRequests = window.__pendingFriendRequests || new Map();
4155
+ if (data.requestId) {
4156
+ window.__pendingFriendRequests.set(data.requestId, { name, publicKey, at: Date.now() });
4157
+ // 8s 后还没 ack → 提示用户对方可能跑旧版 (无 ack 协议)
4158
+ setTimeout(() => {
4159
+ if (window.__pendingFriendRequests.has(data.requestId)) {
4160
+ window.__pendingFriendRequests.delete(data.requestId);
4161
+ console.warn(`[v3-friend] 申请超时未收到 ack (requestId=${data.requestId.substring(0,8)})`);
4162
+ showSimpleToast(`⚠️ 对方未确认收到 (可能是旧版客户端, 申请已发出但无法验证)`, 'warn');
4163
+ }
4164
+ }, 8000);
4165
+ }
4166
+ alert(`已发送好友申请给 ${name} (${publicKey.substring(0, 12)}...)\n对方收到后自己端弹申请 modal, 接受后会出现在 P2P 好友区.`);
3317
4167
  await loadRemoteChannels();
3318
4168
  } catch (err) {
3319
- alert('添加失败: ' + (err.message || err));
4169
+ alert('申请失败: ' + (err.message || err));
4170
+ }
4171
+ });
4172
+ }
4173
+
4174
+ /**
4175
+ * v3 新增: 收到好友申请时, 弹一个 modal 让用户接受或拒绝
4176
+ */
4177
+ function showFriendRequestModal(req) {
4178
+ // 移除已有 modal
4179
+ document.getElementById('friend-request-modal')?.remove();
4180
+ // 2026-06-10: 同 Step 3 远端 chat modal 一样, 改用 class + CSS 变量, 跟本地风格统一
4181
+ const html = `
4182
+ <div id="friend-request-modal" class="friend-req-overlay">
4183
+ <div class="friend-req-shell">
4184
+ <div class="friend-req-header">
4185
+ <span style="font-size:20px;">🤝</span>
4186
+ <div style="flex:1;min-width:0;">
4187
+ <div class="friend-req-title">好友申请</div>
4188
+ <div class="friend-req-meta">来自 ${escapeHtml(req.fromName)} (${escapeHtml(req.fromPublicKey.substring(0, 16))}…)</div>
4189
+ </div>
4190
+ </div>
4191
+ <div class="friend-req-body">
4192
+ <p style="margin:0 0 8px;">${escapeHtml(req.message || '想加你为 P2P 好友')}</p>
4193
+ <p style="margin:0;color:var(--text-muted);font-size:11px;">接受后: 双方互加好友, 对方分享的 channel 会自动出现在 P2P 好友区.</p>
4194
+ </div>
4195
+ <div class="friend-req-actions">
4196
+ <button id="frm-deny" class="friend-req-btn-deny">拒绝</button>
4197
+ <button id="frm-accept" class="friend-req-btn-accept">接受</button>
4198
+ </div>
4199
+ </div>
4200
+ </div>
4201
+ `;
4202
+ document.body.insertAdjacentHTML('beforeend', html);
4203
+ const close = () => document.getElementById('friend-request-modal')?.remove();
4204
+ document.getElementById('frm-deny').onclick = close;
4205
+ document.getElementById('frm-accept').onclick = async () => {
4206
+ close();
4207
+ try {
4208
+ const res = await fetch('/api/friend-accept', {
4209
+ method: 'POST',
4210
+ headers: { 'Content-Type': 'application/json' },
4211
+ body: JSON.stringify({ fromPublicKey: req.fromPublicKey, name: req.fromName })
4212
+ });
4213
+ const data = await res.json();
4214
+ if (!res.ok) throw new Error(data.error || 'accept failed');
4215
+ console.log('[v3-friend] 接受了好友申请:', req.fromName);
4216
+ // 立刻拉一次 — 对方刚 accept, ta 的 channel 列表会被推到我们这
4217
+ setTimeout(loadRemoteChannels, 1000);
4218
+ showSimpleToast(`✅ 已接受 ${req.fromName} 的好友申请`);
4219
+ } catch (err) {
4220
+ console.error('[v3-friend] accept 失败:', err);
4221
+ alert('接受失败: ' + (err.message || err));
4222
+ }
4223
+ };
4224
+ }
4225
+
4226
+ /**
4227
+ * 2026-06-10: 简单的右下 toast, 3s 自动消失. 用于 ack / 接受好友 等非阻塞反馈
4228
+ */
4229
+ function showSimpleToast(text, kind = 'info') {
4230
+ const containerId = 'simple-toast-container';
4231
+ let container = document.getElementById(containerId);
4232
+ if (!container) {
4233
+ container = document.createElement('div');
4234
+ container.id = containerId;
4235
+ container.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:10005;display:flex;flex-direction:column;gap:8px;max-width:320px;';
4236
+ document.body.appendChild(container);
4237
+ }
4238
+ const el = document.createElement('div');
4239
+ el.className = `simple-toast simple-toast-${kind}`;
4240
+ el.style.cssText = `background:var(--bg-sidebar);color:var(--text);border:1px solid var(--border);padding:10px 14px;border-radius:6px;font-size:12px;box-shadow:0 4px 16px rgba(0,0,0,0.3);font-family:inherit;animation:toast-in .2s ease-out;`;
4241
+ el.textContent = text;
4242
+ container.appendChild(el);
4243
+ setTimeout(() => {
4244
+ el.style.transition = 'opacity .3s, transform .3s';
4245
+ el.style.opacity = '0';
4246
+ el.style.transform = 'translateX(20px)';
4247
+ setTimeout(() => el.remove(), 320);
4248
+ }, 3000);
4249
+ }
4250
+
4251
+ // 2026-06-10: P2P 全部展开/折叠切换按钮 (单按钮, 根据当前多数态切换)
4252
+ const p2pToggleAllBtn = document.getElementById('p2p-toggle-all-btn');
4253
+ if (p2pToggleAllBtn) {
4254
+ // 同步图标/文字: 多数 peer 折叠 → 显示 "⊞ 展开"; 多数展开 → 显示 "⊟ 折叠"
4255
+ function syncToggleAllBtn() {
4256
+ const allPks = new Set([
4257
+ ...knownPeers.map(p => p.publicKey),
4258
+ ...remoteChannels.map(g => g.peerId)
4259
+ ]);
4260
+ if (allPks.size === 0) {
4261
+ p2pToggleAllBtn.textContent = '⊞ 展开';
4262
+ p2pToggleAllBtn.title = '切换全部展开/折叠';
4263
+ return;
4264
+ }
4265
+ let collapsedCount = 0;
4266
+ for (const pk of allPks) if (collapsedPeers.has(pk)) collapsedCount++;
4267
+ const majorityCollapsed = collapsedCount >= allPks.size / 2;
4268
+ if (majorityCollapsed) {
4269
+ p2pToggleAllBtn.textContent = '⊞ 展开';
4270
+ p2pToggleAllBtn.title = '点击展开所有 P2P 好友';
4271
+ } else {
4272
+ p2pToggleAllBtn.textContent = '⊟ 折叠';
4273
+ p2pToggleAllBtn.title = '点击折叠所有 P2P 好友';
4274
+ }
4275
+ }
4276
+ p2pToggleAllBtn.addEventListener('click', (e) => {
4277
+ e.stopPropagation();
4278
+ const allPks = new Set([
4279
+ ...knownPeers.map(p => p.publicKey),
4280
+ ...remoteChannels.map(g => g.peerId)
4281
+ ]);
4282
+ if (allPks.size === 0) return;
4283
+ // 多数折叠 → 全展开; 否则全折叠
4284
+ let collapsedCount = 0;
4285
+ for (const pk of allPks) if (collapsedPeers.has(pk)) collapsedCount++;
4286
+ const majorityCollapsed = collapsedCount >= allPks.size / 2;
4287
+ if (majorityCollapsed) {
4288
+ expandAllPeers();
4289
+ } else {
4290
+ collapseAllPeers();
3320
4291
  }
4292
+ syncToggleAllBtn();
3321
4293
  });
4294
+ // 暴露给 renderRemoteChannels 渲染后调用 (保持图标跟实际状态一致)
4295
+ window.__syncP2PToggleAllBtn = syncToggleAllBtn;
4296
+ syncToggleAllBtn(); // 首次同步
3322
4297
  }
3323
4298
 
3324
4299
  // v3 双向刷新: 主动向所有好友发 agent.meta.list, 拿到 ta 们分享给我的 channel
@@ -3349,6 +4324,8 @@ if (refreshSharedBtn) {
3349
4324
  // 启动时拉一次 + 定期轮询 (SSE 接收 P2P reply 后也会更新)
3350
4325
  loadRemoteChannels();
3351
4326
  setInterval(loadRemoteChannels, 8000);
4327
+ // 全局 SSE — 接收 remote-channel-update / remote-chat-reply / friend-request
4328
+ startV3GlobalSSE();
3352
4329
 
3353
4330
  // ============ v3: 折叠 + 拖拽分隔线 ============
3354
4331