@bolloon/bolloon-agent 0.1.32 → 0.1.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/dist/agents/pi-sdk.js +10 -1
- package/dist/bollharness-integration/index.js +8 -1
- package/dist/heartbeat/Watchdog.js +9 -1
- package/dist/llm/audio-config-store.js +199 -0
- package/dist/llm/config-store.js +20 -10
- package/dist/llm/pi-ai.js +2 -2
- package/dist/llm/video-config-store.js +31 -1
- package/dist/network/p2p-direct.js +59 -2
- package/dist/pi-ecosystem/index.js +10 -7
- package/dist/pi-ecosystem-judgment/decision.js +5 -2
- package/dist/social/heartbeat.js +19 -2
- package/dist/web/api-config.html +16 -4
- package/dist/web/client.js +1017 -137
- package/dist/web/index.html +10 -27
- package/dist/web/server.js +865 -52
- package/dist/web/style.css +370 -0
- package/package.json +2 -1
- package/src/agents/pi-sdk.ts +9 -1
- package/src/bollharness-integration/index.ts +8 -32
- package/src/heartbeat/Watchdog.ts +9 -1
- package/src/llm/audio-config-store.ts +6 -1
- package/src/llm/config-store.ts +21 -11
- package/src/llm/pi-ai.ts +2 -2
- package/src/llm/video-config-store.ts +7 -1
- package/src/network/p2p-direct.ts +59 -3
- package/src/social/ant-colony/index.js +19 -0
- package/src/social/heartbeat.ts +18 -2
- package/src/web/api-config.html +16 -4
- package/src/web/client.js +1017 -137
- package/src/web/index.html +10 -27
- package/src/web/server.ts +810 -47
- package/src/web/style.css +370 -0
- package/src/social/ant-colony/AdaptiveHeartbeat.ts +0 -131
- package/src/social/ant-colony/PheromoneEngine.ts +0 -302
- package/src/social/ant-colony/index.ts +0 -18
- package/src/social/ant-colony/types.ts +0 -94
package/src/web/client.js
CHANGED
|
@@ -29,6 +29,62 @@ let reconnectAttempts = new Map(); // channelId -> attempts
|
|
|
29
29
|
let reconnectTimers = new Map(); // channelId -> timer
|
|
30
30
|
let heartbeatTimers = new Map(); // channelId -> setInterval handle (防止泄漏)
|
|
31
31
|
let lastUserCommand = ''; // 防止用户消息重复显示
|
|
32
|
+
|
|
33
|
+
// 2026-06-10: P2P peer-group 折叠状态持久化 (跨刷新)
|
|
34
|
+
// key = bolloon.p2p.collapsedPeers, value = JSON array of publicKey hex
|
|
35
|
+
const COLLAPSED_PEERS_KEY = 'bolloon.p2p.collapsedPeers';
|
|
36
|
+
const SEEN_PEERS_KEY = 'bolloon.p2p.seenPeers';
|
|
37
|
+
let collapsedPeers = (function loadCollapsed() {
|
|
38
|
+
try {
|
|
39
|
+
const raw = localStorage.getItem(COLLAPSED_PEERS_KEY);
|
|
40
|
+
return new Set(raw ? JSON.parse(raw) : []);
|
|
41
|
+
} catch { return new Set(); }
|
|
42
|
+
})();
|
|
43
|
+
let seenPeers = (function loadSeen() {
|
|
44
|
+
try {
|
|
45
|
+
const raw = localStorage.getItem(SEEN_PEERS_KEY);
|
|
46
|
+
return new Set(raw ? JSON.parse(raw) : []);
|
|
47
|
+
} catch { return new Set(); }
|
|
48
|
+
})();
|
|
49
|
+
function saveCollapsedPeers() {
|
|
50
|
+
try { localStorage.setItem(COLLAPSED_PEERS_KEY, JSON.stringify([...collapsedPeers])); } catch {}
|
|
51
|
+
}
|
|
52
|
+
function saveSeenPeers() {
|
|
53
|
+
try { localStorage.setItem(SEEN_PEERS_KEY, JSON.stringify([...seenPeers])); } catch {}
|
|
54
|
+
}
|
|
55
|
+
function togglePeerCollapsed(peerPk) {
|
|
56
|
+
if (collapsedPeers.has(peerPk)) {
|
|
57
|
+
collapsedPeers.delete(peerPk);
|
|
58
|
+
} else {
|
|
59
|
+
collapsedPeers.add(peerPk);
|
|
60
|
+
}
|
|
61
|
+
saveCollapsedPeers();
|
|
62
|
+
renderRemoteChannels();
|
|
63
|
+
// 2026-06-10: 通知 header 切换按钮同步图标
|
|
64
|
+
if (typeof window.__syncP2PToggleAllBtn === 'function') window.__syncP2PToggleAllBtn();
|
|
65
|
+
}
|
|
66
|
+
// 2026-06-10: 一键展开/折叠所有 P2P peer (header 按钮调用)
|
|
67
|
+
function expandAllPeers() {
|
|
68
|
+
// 从 remoteChannels + knownPeers 收集所有 publicKey
|
|
69
|
+
const allPks = new Set([
|
|
70
|
+
...knownPeers.map(p => p.publicKey),
|
|
71
|
+
...remoteChannels.map(g => g.peerId)
|
|
72
|
+
]);
|
|
73
|
+
for (const pk of allPks) collapsedPeers.delete(pk);
|
|
74
|
+
saveCollapsedPeers();
|
|
75
|
+
renderRemoteChannels();
|
|
76
|
+
if (typeof window.__syncP2PToggleAllBtn === 'function') window.__syncP2PToggleAllBtn();
|
|
77
|
+
}
|
|
78
|
+
function collapseAllPeers() {
|
|
79
|
+
const allPks = new Set([
|
|
80
|
+
...knownPeers.map(p => p.publicKey),
|
|
81
|
+
...remoteChannels.map(g => g.peerId)
|
|
82
|
+
]);
|
|
83
|
+
for (const pk of allPks) collapsedPeers.add(pk);
|
|
84
|
+
saveCollapsedPeers();
|
|
85
|
+
renderRemoteChannels();
|
|
86
|
+
if (typeof window.__syncP2PToggleAllBtn === 'function') window.__syncP2PToggleAllBtn();
|
|
87
|
+
}
|
|
32
88
|
let lastAiContent = ''; // 防止 AI 消息重复显示
|
|
33
89
|
let messagesContainers = new Map(); // channelId -> messages container div
|
|
34
90
|
let sessionMessages = new Map(); // channelId:sessionId -> messages array
|
|
@@ -100,6 +156,8 @@ async function loadChannels() {
|
|
|
100
156
|
channels.forEach((ch, i) => {
|
|
101
157
|
console.log(` [${i}] ${ch.name} - did: "${ch.did}"`);
|
|
102
158
|
});
|
|
159
|
+
// 2026-06-11: 全部默认不展开 (用户需要手动点 caret 展开 session 列表)
|
|
160
|
+
// 之前默认展开第一个会喧宾夺主, 用户看不到完整 channel 列表
|
|
103
161
|
renderChannels();
|
|
104
162
|
} catch (err) {
|
|
105
163
|
console.error('[加载频道] 失败:', err);
|
|
@@ -116,20 +174,34 @@ function startV3GlobalSSE() {
|
|
|
116
174
|
try {
|
|
117
175
|
const msg = JSON.parse(e.data);
|
|
118
176
|
if (msg.type === 'remote-chat-reply') {
|
|
119
|
-
//
|
|
177
|
+
// 2026-06-10: 复用本地 addMessage 渲染 — 自动 marked + 剥 think/env + 主题样式
|
|
178
|
+
// 之前是 textContent 硬编码灰底, 跟 Step 3 重写的 modal 风格不一致,
|
|
179
|
+
// 而且 SSE 异步回到时 modal 可能已被切到 thinking 占满, 用户看不到 reply.
|
|
120
180
|
const log = document.getElementById('rcm-log');
|
|
121
181
|
const thinkingEl = document.getElementById('rcm-thinking');
|
|
122
182
|
if (thinkingEl) thinkingEl.style.display = 'none'; // 思考结束, 隐藏
|
|
183
|
+
// 也清掉 "对方正在思考..." 行 (流式 token 留下的)
|
|
184
|
+
const liveThinking = document.getElementById('rcm-thinking-live');
|
|
185
|
+
if (liveThinking) liveThinking.remove();
|
|
123
186
|
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
187
|
if (msg.error) {
|
|
128
|
-
|
|
129
|
-
|
|
188
|
+
// 错误用 sysmsg 样式 (跟 modal 风格一致)
|
|
189
|
+
const errEl = document.createElement('div');
|
|
190
|
+
errEl.className = 'remote-chat-sysmsg remote-chat-sysmsg-error';
|
|
191
|
+
errEl.textContent = `❌ 对方回复出错: ${msg.error}`;
|
|
192
|
+
log.appendChild(errEl);
|
|
193
|
+
} else {
|
|
194
|
+
// 走本地 addMessage, 跟主聊天框完全一致 (marked + think/env 折叠 + 主题色)
|
|
195
|
+
const prefix = `🤖 远端 AI 回复\n\n`;
|
|
196
|
+
addMessage(prefix + (msg.text || '(空回复)'), 'ai', false, log);
|
|
130
197
|
}
|
|
131
|
-
log.appendChild(bubble);
|
|
132
198
|
log.scrollTop = log.scrollHeight;
|
|
199
|
+
} else {
|
|
200
|
+
// modal 没开 → 用右下 toast 提示用户"对方回了, 打开聊天看"
|
|
201
|
+
if (typeof showSimpleToast === 'function') {
|
|
202
|
+
const preview = (msg.text || '').slice(0, 50);
|
|
203
|
+
showSimpleToast(`💬 远端 channel 有新回复: ${preview}${msg.text && msg.text.length > 50 ? '…' : ''}`);
|
|
204
|
+
}
|
|
133
205
|
}
|
|
134
206
|
} else if (msg.type === 'remote-chat-thinking') {
|
|
135
207
|
// v3 新增: B 端实时显示 A 节点的思考过程
|
|
@@ -169,6 +241,57 @@ function startV3GlobalSSE() {
|
|
|
169
241
|
log.scrollTop = log.scrollHeight;
|
|
170
242
|
}
|
|
171
243
|
}
|
|
244
|
+
} else if (msg.type === 'cross-mention-received') {
|
|
245
|
+
// v3 新增: A 节点上, 某个 channel 的 LLM @-mention 了另一个 channel, SSE 推过来
|
|
246
|
+
// 在所有打开的 chat modal 上显示"AI 跨渠道 @-mention" 提示
|
|
247
|
+
const allModals = document.querySelectorAll('.rcm-mention-toast, [id^="rcm-log"]');
|
|
248
|
+
for (const log of allModals) {
|
|
249
|
+
if (!log.id) continue;
|
|
250
|
+
const toast = document.createElement('div');
|
|
251
|
+
toast.style.cssText = 'margin:6px 0;padding:8px 10px;background:#fce7f3;border-left:3px solid #ec4899;border-radius:4px;font-size:12px;color:#831843;';
|
|
252
|
+
const fromTxt = msg.source === 'ai-mention-remote' ? `远端节点 ${(msg.fromPublicKey || '').substring(0, 8)}… 的 ${msg.originChannelName}` : `${msg.originChannelName} (本地)`;
|
|
253
|
+
toast.innerHTML = `📡 <b>${fromTxt}</b> @-mention → 当前 channel: <i>${escapeHtml((msg.text || '').slice(0, 100))}</i>${msg.text && msg.text.length > 100 ? '…' : ''}`;
|
|
254
|
+
log.appendChild(toast);
|
|
255
|
+
log.scrollTop = log.scrollHeight;
|
|
256
|
+
}
|
|
257
|
+
} else if (msg.type === 'remote-channel-update') {
|
|
258
|
+
// v3 新增: 远端节点发来新分享 / 删除 / 改名, 立即更新本地 cache
|
|
259
|
+
const peerId = msg.peerId;
|
|
260
|
+
const channels = msg.channels || [];
|
|
261
|
+
const peerName = msg.peerName || null; // 2026-06-10: 同步接收对方名字
|
|
262
|
+
let group = remoteChannels.find(g => g.peerId === peerId);
|
|
263
|
+
if (!group) {
|
|
264
|
+
group = { peerId, channels: [], peerName: peerName || ('peer-' + peerId.substring(0, 8)) };
|
|
265
|
+
remoteChannels.push(group);
|
|
266
|
+
} else if (peerName) {
|
|
267
|
+
group.peerName = peerName; // 更新名字
|
|
268
|
+
}
|
|
269
|
+
group.channels = channels;
|
|
270
|
+
// 2026-06-10: 如果对面告知名字, 同步刷新 knownPeers 列表, 避免陌生 peer 状态
|
|
271
|
+
if (peerName && !knownPeers.find(p => p.publicKey === peerId)) {
|
|
272
|
+
knownPeers.push({
|
|
273
|
+
publicKey: peerId,
|
|
274
|
+
name: peerName,
|
|
275
|
+
addedAt: new Date().toISOString(),
|
|
276
|
+
lastConnectedAt: new Date().toISOString(),
|
|
277
|
+
});
|
|
278
|
+
console.log(`[v3] 远端 ${peerId.substring(0,12)}... 自报名字 = ${peerName}, 已加到 knownPeers`);
|
|
279
|
+
}
|
|
280
|
+
renderRemoteChannels();
|
|
281
|
+
console.log(`[v3] 收到远端 ${peerId.substring(0,12)}... 的 ${channels.length} 个 channel 更新 (name=${peerName || '?'})`);
|
|
282
|
+
} else if (msg.type === 'friend-request') {
|
|
283
|
+
// v3 新增: 收到好友申请
|
|
284
|
+
showFriendRequestModal(msg);
|
|
285
|
+
} else if (msg.type === 'friend-request-ack') {
|
|
286
|
+
// 2026-06-10: 收到对方 ack, 给发送方提示"已送达"
|
|
287
|
+
const pending = window.__pendingFriendRequests;
|
|
288
|
+
if (pending && msg.requestId && pending.has(msg.requestId)) {
|
|
289
|
+
const { name } = pending.get(msg.requestId);
|
|
290
|
+
pending.delete(msg.requestId);
|
|
291
|
+
console.log(`[v3-friend] ✅ ack 收到: ${name} 已收到好友申请`);
|
|
292
|
+
// 简短 toast (右下角), 不阻塞
|
|
293
|
+
showSimpleToast(`📬 ${name} 已收到你的好友申请, 等对方接受`);
|
|
294
|
+
}
|
|
172
295
|
}
|
|
173
296
|
} catch (err) {
|
|
174
297
|
console.error('[v3] 全局 SSE 解析失败:', err);
|
|
@@ -476,6 +599,50 @@ function toggleAgentExpand(channelId, e) {
|
|
|
476
599
|
renderChannels();
|
|
477
600
|
}
|
|
478
601
|
|
|
602
|
+
/**
|
|
603
|
+
* 2026-06-11 性能优化: 切 channel 时用轻量 patch, 不重建整个 sidebar 列表
|
|
604
|
+
* 只更新: (1) active class (2) 当前 session label + count (3) expanded 状态
|
|
605
|
+
* 避免每次切 channel 都 innerHTML='' + 重建 ~10 个 channel 节点
|
|
606
|
+
*/
|
|
607
|
+
function renderChannelsLite(activeChannelId, activeSessionId) {
|
|
608
|
+
if (!channelList) return;
|
|
609
|
+
// 1. 更新所有 .agent-row 的 active class
|
|
610
|
+
channelList.querySelectorAll('.agent-row').forEach(row => {
|
|
611
|
+
const li = row.closest('.agent-group');
|
|
612
|
+
const chId = li?.dataset.channelId;
|
|
613
|
+
row.classList.toggle('active', chId === activeChannelId);
|
|
614
|
+
});
|
|
615
|
+
// 2. 当前 channel 的展开状态: 强制展开, 其他不动
|
|
616
|
+
if (activeChannelId) expandedAgents.add(activeChannelId);
|
|
617
|
+
// 3. 当前 channel 行展开 + 只切 session-item 的 active class (不再 innerHTML 重渲!)
|
|
618
|
+
// 原因: 重渲 innerHTML 会清掉原始 renderChannels 绑的 session-item click handler,
|
|
619
|
+
// 即使补绑也会因为 lite HTML 结构 (.session-dot + .session-msg-count) 跟原始不同
|
|
620
|
+
// 导致"第 1 次点不动 (原始), 第 2 次点才能用 (lite)" 现象
|
|
621
|
+
// 修法: 完全不动 session-list DOM, 只 toggle .active
|
|
622
|
+
const activeLi = channelList.querySelector(`.agent-group[data-channel-id="${activeChannelId}"]`);
|
|
623
|
+
if (activeLi) {
|
|
624
|
+
activeLi.classList.add('expanded');
|
|
625
|
+
// 只切 active class, 不动 innerHTML (避免清掉原始 click handler)
|
|
626
|
+
const ch = channels.find(c => c.id === activeChannelId);
|
|
627
|
+
// 2026-06-11: 原始 renderChannels 已经给 session-item 加了 data-session-id (line 791),
|
|
628
|
+
// 这里先清空所有 .active 再设新的, 避免多个 active 共存 (因为 renderChannels 初始 DOM
|
|
629
|
+
// 上会有一个 active 标记旧 session, 新切 session 容易出现两个 active)
|
|
630
|
+
activeLi.querySelectorAll('.session-item').forEach(sessLi => {
|
|
631
|
+
const sessId = sessLi.dataset.sessionId;
|
|
632
|
+
const shouldBeActive = sessId === activeSessionId;
|
|
633
|
+
sessLi.classList.toggle('active', shouldBeActive);
|
|
634
|
+
});
|
|
635
|
+
// 更新顶部 current session label
|
|
636
|
+
if (ch) {
|
|
637
|
+
const currentSess = Array.isArray(ch.sessions) ? ch.sessions.find(s => s.id === activeSessionId) : null;
|
|
638
|
+
const labelEl = activeLi.querySelector('.agent-current-session');
|
|
639
|
+
if (labelEl) {
|
|
640
|
+
labelEl.textContent = currentSess ? '· ' + formatSessionName(currentSess) : '';
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
479
646
|
function renderChannels() {
|
|
480
647
|
if (!channelList) return;
|
|
481
648
|
channelList.innerHTML = '';
|
|
@@ -519,12 +686,9 @@ function renderChannels() {
|
|
|
519
686
|
const currentSessLabel = currentSess ? formatSessionName(currentSess) : '';
|
|
520
687
|
const sessionCount = Array.isArray(ch.sessions) ? ch.sessions.length : 0;
|
|
521
688
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
const toolsBadge = ch.autoInvokeTools
|
|
526
|
-
? `<span class="agent-tools-badge" title="自动工具调用已开启">⚡</span>`
|
|
527
|
-
: '';
|
|
689
|
+
// 2026-06-10: 隐藏 channel 行右侧的勋章 (钱包 / 工具) — UI 简洁
|
|
690
|
+
const walletBadge = '';
|
|
691
|
+
const toolsBadge = '';
|
|
528
692
|
|
|
529
693
|
row.innerHTML = `
|
|
530
694
|
<svg class="agent-caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
@@ -608,6 +772,7 @@ function renderChannels() {
|
|
|
608
772
|
const sessLi = document.createElement('li');
|
|
609
773
|
const isActive = ch.id === currentChannelId && sess.id === ch.currentSessionId;
|
|
610
774
|
sessLi.className = `session-item ${isActive ? 'active' : ''}`;
|
|
775
|
+
sessLi.dataset.sessionId = sess.id; // 2026-06-11: 给 session-item 加上 data-session-id, renderChannelsLite 才能 toggle active class
|
|
611
776
|
sessLi.innerHTML = `
|
|
612
777
|
<span class="session-name" title="${escapeHtml(formatSessionName(sess))}">${escapeHtml(formatSessionName(sess))}</span>
|
|
613
778
|
<button class="session-delete" title="删除会话">×</button>
|
|
@@ -718,7 +883,11 @@ async function selectChannel(channelId, targetSessionId = null) {
|
|
|
718
883
|
console.log('[selectChannel] 频道:', channel.name, 'session:', currentSessionId);
|
|
719
884
|
}
|
|
720
885
|
|
|
721
|
-
|
|
886
|
+
// 2026-06-11 提速: 切 channel 时 sidebar 渲染降级 — 只更新 active 样式, 不重渲整列表
|
|
887
|
+
// renderChannels() 仍然要调 (current session label 等可能变了), 但加一层判断: 如果只是切 channel (没增删), 走 patch 路径
|
|
888
|
+
const t0 = performance.now();
|
|
889
|
+
renderChannelsLite(channelId, currentSessionId);
|
|
890
|
+
console.log(`[selectChannel] renderChannelsLite 耗时 ${(performance.now() - t0).toFixed(1)}ms`);
|
|
722
891
|
|
|
723
892
|
// 确保该频道有消息容器
|
|
724
893
|
const container = ensureMessageContainer(channelId);
|
|
@@ -737,10 +906,19 @@ async function selectChannel(channelId, targetSessionId = null) {
|
|
|
737
906
|
try {
|
|
738
907
|
const res = await fetch(`/sessions/${channelId}?sessionId=${encodeURIComponent(currentSessionId)}`);
|
|
739
908
|
const session = await res.json();
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
909
|
+
const msgs = session.messages || [];
|
|
910
|
+
if (msgs.length > 0) {
|
|
911
|
+
// 2026-06-11 提速: 用 DocumentFragment 一次性 append 避免多次 reflow
|
|
912
|
+
const frag = document.createDocumentFragment();
|
|
913
|
+
const tmpContainer = document.createElement('div');
|
|
914
|
+
tmpContainer.style.display = 'none';
|
|
915
|
+
for (const msg of msgs) {
|
|
916
|
+
addMessage(msg.content, msg.type, false, tmpContainer);
|
|
917
|
+
}
|
|
918
|
+
while (tmpContainer.firstChild) {
|
|
919
|
+
frag.appendChild(tmpContainer.firstChild);
|
|
920
|
+
}
|
|
921
|
+
container.appendChild(frag);
|
|
744
922
|
} else {
|
|
745
923
|
addMessage('你好!我是 Bolloon Agent。有什么我可以帮你的吗?', 'ai', false, container);
|
|
746
924
|
}
|
|
@@ -1351,7 +1529,7 @@ function handleWorkflowLoopEvent(data, container) {
|
|
|
1351
1529
|
// 用户命令可视化 - 当用户发送命令时调用
|
|
1352
1530
|
let userCommandDisplayEl = null;
|
|
1353
1531
|
|
|
1354
|
-
function showUserCommand(command, container) {
|
|
1532
|
+
function showUserCommand(command, container, opts) {
|
|
1355
1533
|
const msgContainer = container || messagesContainers.get(currentChannelId) || messagesEl;
|
|
1356
1534
|
// 先移除之前的消息中的 user bubble(如果有重复的话)
|
|
1357
1535
|
const existingUserBubbles = msgContainer.querySelectorAll('.message-user');
|
|
@@ -1365,12 +1543,17 @@ function showUserCommand(command, container) {
|
|
|
1365
1543
|
// 创建美化版本的命令显示
|
|
1366
1544
|
userCommandDisplayEl = document.createElement('div');
|
|
1367
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
|
+
: '';
|
|
1368
1550
|
userCommandDisplayEl.innerHTML = `
|
|
1369
1551
|
<div class="user-command-display">
|
|
1370
1552
|
<div class="command-prompt">
|
|
1371
1553
|
<span class="prompt-icon">›</span>
|
|
1372
1554
|
<span class="prompt-text">${command}</span>
|
|
1373
1555
|
</div>
|
|
1556
|
+
${sourceTag}
|
|
1374
1557
|
</div>
|
|
1375
1558
|
`;
|
|
1376
1559
|
|
|
@@ -1477,7 +1660,19 @@ function connect(channelId) {
|
|
|
1477
1660
|
const container = messagesContainers.get(msgChannelId) || messagesEl;
|
|
1478
1661
|
|
|
1479
1662
|
if (data.type === 'user') {
|
|
1480
|
-
showUserCommand(
|
|
1663
|
+
// 2026-06-11 修: 不再走 showUserCommand (› 装饰条) 路径, 因为:
|
|
1664
|
+
// 1. sendMessage 已经在客户端 addMessage(text, 'user', true) 渲染成 .bubble-user 气泡
|
|
1665
|
+
// 2. SSE 推 user 又调 showUserCommand → 同时出现气泡 + 装饰条 (双重显示)
|
|
1666
|
+
// 3. 第二次切 channel 时, showUserCommand 会 remove 已有 .message-user 元素 (line 1477),
|
|
1667
|
+
// 但 .bubble-user class 不是 .message-user → 残留装饰条, 表现"模式变了"
|
|
1668
|
+
// 改法: SSE 收到 user 后, 跳过显示 (lastUserCommand 已经匹配, addMessage(save=true) 内部去重)
|
|
1669
|
+
// 但要确保 lastUserCommand 已经设过 — sendMessage 调 addMessage(true) 时会设
|
|
1670
|
+
// 远端 user (source === 'remote') 不会被 sendMessage 渲染, 需要走 addMessage 一次
|
|
1671
|
+
if (data.source === 'remote') {
|
|
1672
|
+
// 远端访客 (B 通过 P2P 发来的), sendMessage 没渲染它, 这里补上气泡
|
|
1673
|
+
addMessage(data.content, 'user', true, container);
|
|
1674
|
+
}
|
|
1675
|
+
// 本地 user 已经由 sendMessage 渲染 + 去重, 这里不再显示
|
|
1481
1676
|
} else if (data.type === 'ai') {
|
|
1482
1677
|
addMessage(data.content, 'ai', true, container);
|
|
1483
1678
|
hideTyping();
|
|
@@ -1530,6 +1725,14 @@ async function sendMessage() {
|
|
|
1530
1725
|
const text = input.value.trim();
|
|
1531
1726
|
if (!text) return;
|
|
1532
1727
|
|
|
1728
|
+
// 2026-06-11: 立即把用户消息渲染成气泡上屏 (走 .bubble-user, 跟本地聊天一致)
|
|
1729
|
+
// 之前只靠 SSE `type: user` 回调显示, 但 addMessage(user) 默认 save=true 走去重, 容易跟 SSE 二次显示冲突/丢失
|
|
1730
|
+
// 现在: sendMessage 自己上屏, SSE `user` 回调来时因为 lastUserCommand 已匹配, 自动跳过 → 不重复
|
|
1731
|
+
const container = messagesContainers.get(currentChannelId) || messagesEl;
|
|
1732
|
+
addMessage(text, 'user', true, container);
|
|
1733
|
+
// 滚动到底
|
|
1734
|
+
if (container) container.scrollTop = container.scrollHeight;
|
|
1735
|
+
|
|
1533
1736
|
input.value = '';
|
|
1534
1737
|
showTyping();
|
|
1535
1738
|
|
|
@@ -1586,6 +1789,347 @@ input.addEventListener('keydown', (e) => {
|
|
|
1586
1789
|
}
|
|
1587
1790
|
});
|
|
1588
1791
|
|
|
1792
|
+
// ============ v3 新增: @-mention 单选自动补全 (主聊天框 #input) ============
|
|
1793
|
+
let mentionChannels = []; // { id, name, source: 'local'|'remote', ownerPublicKey? }
|
|
1794
|
+
let mentionDropdownEl = null;
|
|
1795
|
+
let mentionHighlightIdx = -1;
|
|
1796
|
+
let mentionQuery = null;
|
|
1797
|
+
let mentionAnchor = -1; // @ 字符的绝对位置 (固定, 直到 dropdown 关闭)
|
|
1798
|
+
let mentionBlockEnd = -1; // 插入区块的终点 (单选模式下 = anchor + 1 + query)
|
|
1799
|
+
let mentionDocMousedownBound = false; // 防止重复注册 document mousedown
|
|
1800
|
+
|
|
1801
|
+
function ensureMentionDocMousedown() {
|
|
1802
|
+
if (mentionDocMousedownBound) return;
|
|
1803
|
+
mentionDocMousedownBound = true;
|
|
1804
|
+
document.addEventListener('mousedown', (e) => {
|
|
1805
|
+
if (mentionDropdownEl && !mentionDropdownEl.contains(e.target) && e.target !== input) {
|
|
1806
|
+
closeMentionDropdown();
|
|
1807
|
+
}
|
|
1808
|
+
});
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
async function refreshMentionChannels() {
|
|
1812
|
+
try {
|
|
1813
|
+
const res = await fetch('/channels');
|
|
1814
|
+
const local = res.ok ? await res.json() : [];
|
|
1815
|
+
const r2 = await fetch('/api/remote-channels');
|
|
1816
|
+
const remoteData = r2.ok ? await r2.json() : { peers: [] };
|
|
1817
|
+
const remote = [];
|
|
1818
|
+
for (const p of (remoteData.peers || [])) {
|
|
1819
|
+
for (const c of (p.channels || [])) {
|
|
1820
|
+
remote.push({ id: c.id, name: c.name, source: 'remote', ownerPublicKey: p.peerId });
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
mentionChannels = [
|
|
1824
|
+
...(Array.isArray(local) ? local.map(c => ({ id: c.id, name: c.name, source: 'local' })) : []),
|
|
1825
|
+
...remote
|
|
1826
|
+
];
|
|
1827
|
+
} catch (err) {
|
|
1828
|
+
console.warn('[mention] 加载渠道列表失败:', err);
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
function closeMentionDropdown() {
|
|
1833
|
+
if (mentionDropdownEl) { mentionDropdownEl.remove(); mentionDropdownEl = null; }
|
|
1834
|
+
mentionHighlightIdx = -1;
|
|
1835
|
+
mentionQuery = null;
|
|
1836
|
+
mentionAnchor = -1;
|
|
1837
|
+
mentionBlockEnd = -1;
|
|
1838
|
+
// 不重置 mentionDocMousedownBound — 监听器是空操作 (mentionDropdownEl === null) 留着无妨, 避免重复绑
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
function getCurrentMentionQuery() {
|
|
1842
|
+
const pos = input.selectionStart || input.value.length;
|
|
1843
|
+
const before = input.value.slice(0, pos);
|
|
1844
|
+
const m = before.match(/@([一-龥A-Za-z0-9_\-]{0,30})$/);
|
|
1845
|
+
return m ? { query: m[1], anchor: pos - m[0].length } : null;
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
function renderMentionDropdown(items) {
|
|
1849
|
+
if (!mentionDropdownEl) {
|
|
1850
|
+
mentionDropdownEl = document.createElement('div');
|
|
1851
|
+
mentionDropdownEl.id = 'mention-dropdown';
|
|
1852
|
+
mentionDropdownEl.style.cssText = 'position:fixed;background:#fff;border:1px solid #d1d5db;border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,0.15);max-height:240px;overflow-y:auto;z-index:10000;font-size:13px;min-width:240px;';
|
|
1853
|
+
document.body.appendChild(mentionDropdownEl);
|
|
1854
|
+
ensureMentionDocMousedown();
|
|
1855
|
+
}
|
|
1856
|
+
// v3 简化: 单选 + 立即填入输入框
|
|
1857
|
+
const headerHtml = `<div style="padding:6px 10px;background:#f9fafb;border-bottom:1px solid #e5e7eb;font-size:11px;color:#6b7280;display:flex;justify-content:space-between;align-items:center;">
|
|
1858
|
+
<span>💡 点击或回车选中 → 自动填入输入框</span>
|
|
1859
|
+
<span style="color:#9ca3af;">↑↓ 移动</span>
|
|
1860
|
+
</div>`;
|
|
1861
|
+
|
|
1862
|
+
if (items.length === 0) {
|
|
1863
|
+
mentionDropdownEl.innerHTML = headerHtml + '<div style="padding:10px 12px;color:#6b7280;font-size:12px;">没有匹配的渠道</div>';
|
|
1864
|
+
} else {
|
|
1865
|
+
const rows = items.map((c, i) => {
|
|
1866
|
+
const isLocal = c.source === 'local';
|
|
1867
|
+
const tag = isLocal ? '🏠 本地' : '🌐 远端';
|
|
1868
|
+
const owner = !isLocal && c.ownerPublicKey ? ` <span style="color:#9ca3af;font-size:11px;">(${c.ownerPublicKey.substring(0, 8)}…)</span>` : '';
|
|
1869
|
+
// 浅蓝 = 键盘高亮, 白 = 普通
|
|
1870
|
+
const bg = i === mentionHighlightIdx ? '#eff6ff' : '#fff';
|
|
1871
|
+
const borderLeft = i === mentionHighlightIdx ? '3px solid #93c5fd' : '3px solid transparent';
|
|
1872
|
+
return `<div class="mention-item" data-idx="${i}" data-channel-id="${escapeHtml(c.id)}" data-channel-name="${escapeHtml(c.name)}" style="padding:8px 12px;cursor:pointer;background:${bg};border-bottom:1px solid #f3f4f6;display:flex;align-items:center;gap:8px;border-left:${borderLeft};">
|
|
1873
|
+
<span style="font-size:10px;color:${isLocal ? '#059669' : '#2563eb'};background:${isLocal ? '#d1fae5' : '#dbeafe'};padding:1px 6px;border-radius:3px;white-space:nowrap;">${tag}</span>
|
|
1874
|
+
<span style="flex:1;">${escapeHtml(c.name)}</span>${owner}
|
|
1875
|
+
</div>`;
|
|
1876
|
+
}).join('');
|
|
1877
|
+
mentionDropdownEl.innerHTML = headerHtml + rows;
|
|
1878
|
+
mentionDropdownEl.querySelectorAll('.mention-item').forEach((el) => {
|
|
1879
|
+
const idx = parseInt(el.getAttribute('data-idx'));
|
|
1880
|
+
el.onclick = () => {
|
|
1881
|
+
// 单击 → 立即填入输入框 + 关闭 dropdown
|
|
1882
|
+
applyMention(items[idx]);
|
|
1883
|
+
};
|
|
1884
|
+
// v3 关键修复: mouseenter 只更新高亮, 不重建 dropdown — 否则用户实际点击的 element 被销毁,
|
|
1885
|
+
// click 事件落到新 element, 但实际触发的是新 element 的 onclick (空), 而不是被销毁前那个
|
|
1886
|
+
el.onmouseenter = () => {
|
|
1887
|
+
if (mentionHighlightIdx === idx) return;
|
|
1888
|
+
mentionHighlightIdx = idx;
|
|
1889
|
+
// 只更新背景色 + 左边框, 不重建 innerHTML
|
|
1890
|
+
const itemEls = mentionDropdownEl.querySelectorAll('.mention-item');
|
|
1891
|
+
itemEls.forEach((ie, ii) => {
|
|
1892
|
+
const isHi = ii === idx;
|
|
1893
|
+
ie.style.background = isHi ? '#eff6ff' : '#fff';
|
|
1894
|
+
ie.style.borderLeft = isHi ? '3px solid #93c5fd' : '3px solid transparent';
|
|
1895
|
+
});
|
|
1896
|
+
};
|
|
1897
|
+
});
|
|
1898
|
+
}
|
|
1899
|
+
const rect = input.getBoundingClientRect();
|
|
1900
|
+
mentionDropdownEl.style.left = rect.left + 'px';
|
|
1901
|
+
mentionDropdownEl.style.top = 'auto';
|
|
1902
|
+
mentionDropdownEl.style.bottom = (window.innerHeight - rect.top + 4) + 'px';
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
/** v3 单选: 把 @xxx 替换为 @渠道名 + 空格, 关闭 dropdown, 光标放空格后 */
|
|
1906
|
+
function applyMention(channel) {
|
|
1907
|
+
const anchor = mentionAnchor;
|
|
1908
|
+
const blockEnd = mentionBlockEnd >= 0 ? mentionBlockEnd : (anchor + 1 + (mentionQuery || '').length);
|
|
1909
|
+
if (anchor < 0 || anchor > input.value.length || input.value[anchor] !== '@') {
|
|
1910
|
+
closeMentionDropdown();
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
const before = input.value.slice(0, anchor); // 含 @
|
|
1914
|
+
const after = input.value.slice(blockEnd); // query 之后 (可能用户已输入正文)
|
|
1915
|
+
const insert = `@${channel.name} `;
|
|
1916
|
+
input.value = before + insert + after;
|
|
1917
|
+
const newPos = before.length + insert.length;
|
|
1918
|
+
input.focus();
|
|
1919
|
+
input.setSelectionRange(newPos, newPos);
|
|
1920
|
+
closeMentionDropdown();
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
function updateMentionDropdown() {
|
|
1924
|
+
// 2026-06-10 修: 数组空时主动刷一次, 不再静默 return
|
|
1925
|
+
// 之前 `if (!mentionChannels.length) return` 导致初始化 0-8s 窗口按 @ 看不到任何 item
|
|
1926
|
+
if (!mentionChannels.length) {
|
|
1927
|
+
refreshMentionChannels().then(() => {
|
|
1928
|
+
// 拉完再重试一次 (异步, 不阻塞当前键击)
|
|
1929
|
+
if (mentionChannels.length) updateMentionDropdown();
|
|
1930
|
+
});
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
const m = getCurrentMentionQuery();
|
|
1934
|
+
if (!m) { closeMentionDropdown(); return; }
|
|
1935
|
+
// 只在 dropdown 刚打开时设置 anchor (blockEnd 跟着 insert 走)
|
|
1936
|
+
if (mentionAnchor === -1) {
|
|
1937
|
+
mentionAnchor = m.anchor;
|
|
1938
|
+
mentionBlockEnd = m.anchor + 1 + (m.query || '').length;
|
|
1939
|
+
// dropdown 首次打开 → 强制刷一次, 保证 remote 列表最新
|
|
1940
|
+
refreshMentionChannels();
|
|
1941
|
+
}
|
|
1942
|
+
mentionQuery = m.query;
|
|
1943
|
+
const q = m.query.toLowerCase();
|
|
1944
|
+
const items = mentionChannels.filter(c => c.name.toLowerCase().includes(q)).slice(0, 8);
|
|
1945
|
+
mentionHighlightIdx = items.length > 0 ? 0 : -1;
|
|
1946
|
+
renderMentionDropdown(items);
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
input.addEventListener('input', () => {
|
|
1950
|
+
updateMentionDropdown();
|
|
1951
|
+
});
|
|
1952
|
+
input.addEventListener('keydown', (e) => {
|
|
1953
|
+
if (!mentionDropdownEl) return;
|
|
1954
|
+
const items = mentionDropdownEl.querySelectorAll('.mention-item');
|
|
1955
|
+
if (e.key === 'ArrowDown') {
|
|
1956
|
+
e.preventDefault();
|
|
1957
|
+
if (items.length === 0) return;
|
|
1958
|
+
mentionHighlightIdx = (mentionHighlightIdx + 1) % items.length;
|
|
1959
|
+
const q = (mentionQuery || '').toLowerCase();
|
|
1960
|
+
const filtered = mentionChannels.filter(c => c.name.toLowerCase().includes(q)).slice(0, 8);
|
|
1961
|
+
renderMentionDropdown(filtered);
|
|
1962
|
+
} else if (e.key === 'ArrowUp') {
|
|
1963
|
+
e.preventDefault();
|
|
1964
|
+
if (items.length === 0) return;
|
|
1965
|
+
mentionHighlightIdx = (mentionHighlightIdx - 1 + items.length) % items.length;
|
|
1966
|
+
const q = (mentionQuery || '').toLowerCase();
|
|
1967
|
+
const filtered = mentionChannels.filter(c => c.name.toLowerCase().includes(q)).slice(0, 8);
|
|
1968
|
+
renderMentionDropdown(filtered);
|
|
1969
|
+
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
|
1970
|
+
// 单选: Enter/Tab 立即填入 + 关闭 dropdown
|
|
1971
|
+
if (items.length > 0) {
|
|
1972
|
+
e.preventDefault();
|
|
1973
|
+
e.stopPropagation();
|
|
1974
|
+
const q = (mentionQuery || '').toLowerCase();
|
|
1975
|
+
const filtered = mentionChannels.filter(c => c.name.toLowerCase().includes(q)).slice(0, 8);
|
|
1976
|
+
const cur = filtered[mentionHighlightIdx];
|
|
1977
|
+
if (cur) applyMention(cur);
|
|
1978
|
+
}
|
|
1979
|
+
} else if (e.key === 'Escape') {
|
|
1980
|
+
e.preventDefault();
|
|
1981
|
+
closeMentionDropdown();
|
|
1982
|
+
}
|
|
1983
|
+
}, true); // capture phase, 先于 sendMessage 那个 keydown
|
|
1984
|
+
|
|
1985
|
+
// 初始化
|
|
1986
|
+
refreshMentionChannels();
|
|
1987
|
+
// 定时刷新 (channel 列表可能变化)
|
|
1988
|
+
setInterval(refreshMentionChannels, 5000);
|
|
1989
|
+
// 远端 channel 列表变化时也刷新 (loadRemoteChannels 是 function declaration, 不能重新赋值)
|
|
1990
|
+
// 用 setInterval 兜底: 每 5s 刷一次 (已经有定时器, 这里不重复)
|
|
1991
|
+
// 实际上 refreshMentionChannels() 已经在 setInterval 里跑了
|
|
1992
|
+
|
|
1993
|
+
// v3 新增: 通用版 @-autocomplete (任意 input 元素都能挂, 比如 B 端的 #rcm-input)
|
|
1994
|
+
function setupMentionAutocomplete(inputEl) {
|
|
1995
|
+
if (!inputEl || inputEl.__mentionBound) return;
|
|
1996
|
+
inputEl.__mentionBound = true;
|
|
1997
|
+
let localQuery = null;
|
|
1998
|
+
let localAnchor = -1; // @ 字符的绝对位置 (固定, 直到 dropdown 关闭)
|
|
1999
|
+
let localBlockEnd = -1; // 插入区块的终点
|
|
2000
|
+
let localHighlight = -1;
|
|
2001
|
+
|
|
2002
|
+
function closeLocal() {
|
|
2003
|
+
if (inputEl.__mentionDD) { inputEl.__mentionDD.remove(); inputEl.__mentionDD = null; }
|
|
2004
|
+
localHighlight = -1; localQuery = null; localAnchor = -1; localBlockEnd = -1;
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
function detectQuery() {
|
|
2008
|
+
const pos = inputEl.selectionStart || inputEl.value.length;
|
|
2009
|
+
const before = inputEl.value.slice(0, pos);
|
|
2010
|
+
const m = before.match(/@([一-龥A-Za-z0-9_\-]{0,30})$/);
|
|
2011
|
+
return m ? { query: m[1], anchor: pos - m[0].length } : null;
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
// v3 单选: 点击 / Enter 立即填入输入框 + 关闭 dropdown
|
|
2015
|
+
function applyLocal(channel) {
|
|
2016
|
+
const anchor = localAnchor;
|
|
2017
|
+
const blockEnd = localBlockEnd >= 0 ? localBlockEnd : (anchor + 1 + (localQuery || '').length);
|
|
2018
|
+
if (anchor < 0 || anchor > inputEl.value.length || inputEl.value[anchor] !== '@') {
|
|
2019
|
+
closeLocal();
|
|
2020
|
+
return;
|
|
2021
|
+
}
|
|
2022
|
+
const before = inputEl.value.slice(0, anchor); // 含 @
|
|
2023
|
+
const after = inputEl.value.slice(blockEnd);
|
|
2024
|
+
const insert = `@${channel.name} `;
|
|
2025
|
+
inputEl.value = before + insert + after;
|
|
2026
|
+
const newPos = before.length + insert.length;
|
|
2027
|
+
inputEl.focus();
|
|
2028
|
+
inputEl.setSelectionRange(newPos, newPos);
|
|
2029
|
+
closeLocal();
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
function renderLocal(items) {
|
|
2033
|
+
if (!inputEl.__mentionDD) {
|
|
2034
|
+
inputEl.__mentionDD = document.createElement('div');
|
|
2035
|
+
inputEl.__mentionDD.style.cssText = 'position:fixed;background:#fff;border:1px solid #d1d5db;border-radius:6px;box-shadow:0 4px 16px rgba(0,0,0,0.15);max-height:240px;overflow-y:auto;z-index:10001;font-size:13px;min-width:240px;';
|
|
2036
|
+
document.body.appendChild(inputEl.__mentionDD);
|
|
2037
|
+
}
|
|
2038
|
+
const headerHtml = `<div style="padding:6px 10px;background:#f9fafb;border-bottom:1px solid #e5e7eb;font-size:11px;color:#6b7280;display:flex;justify-content:space-between;align-items:center;">
|
|
2039
|
+
<span>💡 点击或回车选中 → 自动填入输入框</span>
|
|
2040
|
+
<span style="color:#9ca3af;">↑↓ 移动</span>
|
|
2041
|
+
</div>`;
|
|
2042
|
+
if (items.length === 0) {
|
|
2043
|
+
inputEl.__mentionDD.innerHTML = headerHtml + '<div style="padding:10px 12px;color:#6b7280;font-size:12px;">没有匹配的渠道</div>';
|
|
2044
|
+
} else {
|
|
2045
|
+
inputEl.__mentionDD.innerHTML = headerHtml + items.map((c, i) => {
|
|
2046
|
+
const isLocal = c.source === 'local';
|
|
2047
|
+
const tag = isLocal ? '🏠 本地' : '🌐 远端';
|
|
2048
|
+
const owner = !isLocal && c.ownerPublicKey ? ` <span style="color:#9ca3af;font-size:11px;">(${c.ownerPublicKey.substring(0, 8)}…)</span>` : '';
|
|
2049
|
+
const bg = i === localHighlight ? '#eff6ff' : '#fff';
|
|
2050
|
+
const borderLeft = i === localHighlight ? '3px solid #93c5fd' : '3px solid transparent';
|
|
2051
|
+
return `<div class="mention-item" data-idx="${i}" data-channel-id="${escapeHtml(c.id)}" data-channel-name="${escapeHtml(c.name)}" style="padding:8px 12px;cursor:pointer;background:${bg};border-bottom:1px solid #f3f4f6;display:flex;align-items:center;gap:8px;border-left:${borderLeft};">
|
|
2052
|
+
<span style="font-size:10px;color:${isLocal ? '#059669' : '#2563eb'};background:${isLocal ? '#d1fae5' : '#dbeafe'};padding:1px 6px;border-radius:3px;white-space:nowrap;">${tag}</span>
|
|
2053
|
+
<span style="flex:1;">${escapeHtml(c.name)}</span>${owner}
|
|
2054
|
+
</div>`;
|
|
2055
|
+
}).join('');
|
|
2056
|
+
inputEl.__mentionDD.querySelectorAll('.mention-item').forEach((el) => {
|
|
2057
|
+
const idx = parseInt(el.getAttribute('data-idx'));
|
|
2058
|
+
el.onclick = () => applyLocal(items[idx]);
|
|
2059
|
+
// v3 关键修复: mouseenter 只更新高亮, 不重建 dropdown (同主 input)
|
|
2060
|
+
el.onmouseenter = () => {
|
|
2061
|
+
if (localHighlight === idx) return;
|
|
2062
|
+
localHighlight = idx;
|
|
2063
|
+
const itemEls = inputEl.__mentionDD.querySelectorAll('.mention-item');
|
|
2064
|
+
itemEls.forEach((ie, ii) => {
|
|
2065
|
+
const isHi = ii === idx;
|
|
2066
|
+
ie.style.background = isHi ? '#eff6ff' : '#fff';
|
|
2067
|
+
ie.style.borderLeft = isHi ? '3px solid #93c5fd' : '3px solid transparent';
|
|
2068
|
+
});
|
|
2069
|
+
};
|
|
2070
|
+
});
|
|
2071
|
+
}
|
|
2072
|
+
const rect = inputEl.getBoundingClientRect();
|
|
2073
|
+
inputEl.__mentionDD.style.left = rect.left + 'px';
|
|
2074
|
+
inputEl.__mentionDD.style.top = 'auto';
|
|
2075
|
+
inputEl.__mentionDD.style.bottom = (window.innerHeight - rect.top + 4) + 'px';
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
function update() {
|
|
2079
|
+
// 2026-06-10 修: 与主输入框同步 — 数组空时主动刷新, 首次打开 dropdown 强制刷新
|
|
2080
|
+
if (!mentionChannels.length) {
|
|
2081
|
+
refreshMentionChannels().then(() => {
|
|
2082
|
+
if (mentionChannels.length) update();
|
|
2083
|
+
});
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
const m = detectQuery();
|
|
2087
|
+
if (!m) { closeLocal(); return; }
|
|
2088
|
+
if (localAnchor === -1) {
|
|
2089
|
+
localAnchor = m.anchor;
|
|
2090
|
+
localBlockEnd = m.anchor + 1 + (m.query || '').length;
|
|
2091
|
+
// dropdown 首次打开 → 强制刷一次保证 remote 最新
|
|
2092
|
+
refreshMentionChannels();
|
|
2093
|
+
}
|
|
2094
|
+
localQuery = m.query;
|
|
2095
|
+
const q = m.query.toLowerCase();
|
|
2096
|
+
const items = mentionChannels.filter(c => c.name.toLowerCase().includes(q)).slice(0, 8);
|
|
2097
|
+
localHighlight = items.length > 0 ? 0 : -1;
|
|
2098
|
+
renderLocal(items);
|
|
2099
|
+
}
|
|
2100
|
+
|
|
2101
|
+
inputEl.addEventListener('input', update);
|
|
2102
|
+
inputEl.addEventListener('keydown', (e) => {
|
|
2103
|
+
if (!inputEl.__mentionDD) return;
|
|
2104
|
+
const items = inputEl.__mentionDD.querySelectorAll('.mention-item');
|
|
2105
|
+
if (e.key === 'ArrowDown') {
|
|
2106
|
+
e.preventDefault();
|
|
2107
|
+
if (items.length === 0) return;
|
|
2108
|
+
localHighlight = (localHighlight + 1) % items.length;
|
|
2109
|
+
const q = (localQuery || '').toLowerCase();
|
|
2110
|
+
renderLocal(mentionChannels.filter(c => c.name.toLowerCase().includes(q)).slice(0, 8));
|
|
2111
|
+
} else if (e.key === 'ArrowUp') {
|
|
2112
|
+
e.preventDefault();
|
|
2113
|
+
if (items.length === 0) return;
|
|
2114
|
+
localHighlight = (localHighlight - 1 + items.length) % items.length;
|
|
2115
|
+
const q = (localQuery || '').toLowerCase();
|
|
2116
|
+
renderLocal(mentionChannels.filter(c => c.name.toLowerCase().includes(q)).slice(0, 8));
|
|
2117
|
+
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
|
2118
|
+
if (items.length > 0) {
|
|
2119
|
+
e.preventDefault();
|
|
2120
|
+
e.stopPropagation();
|
|
2121
|
+
const q = (localQuery || '').toLowerCase();
|
|
2122
|
+
const filtered = mentionChannels.filter(c => c.name.toLowerCase().includes(q)).slice(0, 8);
|
|
2123
|
+
const cur = filtered[localHighlight];
|
|
2124
|
+
if (cur) applyLocal(cur);
|
|
2125
|
+
}
|
|
2126
|
+
} else if (e.key === 'Escape') {
|
|
2127
|
+
e.preventDefault();
|
|
2128
|
+
closeLocal();
|
|
2129
|
+
}
|
|
2130
|
+
}, true);
|
|
2131
|
+
}
|
|
2132
|
+
|
|
1589
2133
|
// 拖拽落点: 把判断库里的判断拖到输入框, 直接作为指令发给 AI (走"代我决定"路径).
|
|
1590
2134
|
// 用户拖进来后输入框被预填, 点发送就把这条判断作为指令交给当前 agent.
|
|
1591
2135
|
const inputArea = document.querySelector('.input-area');
|
|
@@ -2183,7 +2727,6 @@ function renderJudgmentItems(items, opts) {
|
|
|
2183
2727
|
<input type="checkbox" class="judgment-select-cb" data-id="${escapeHtml(j.id)}" style="cursor:pointer;" onclick="event.stopPropagation();">
|
|
2184
2728
|
</label>
|
|
2185
2729
|
<div class="task-item-title">
|
|
2186
|
-
<span>🛡️</span>
|
|
2187
2730
|
<span class="judgment-decision">${escapeHtml(j.decision)}</span>
|
|
2188
2731
|
</div>
|
|
2189
2732
|
<span class="task-item-status completed">${stakes}</span>
|
|
@@ -2569,11 +3112,33 @@ let knownPeers = []; // { name, publicKey, lastConnectedAt, addedAt }
|
|
|
2569
3112
|
|
|
2570
3113
|
async function loadRemoteChannels() {
|
|
2571
3114
|
try {
|
|
3115
|
+
// 1) 拉 known peers (好友列表)
|
|
2572
3116
|
const res = await fetch('/api/p2p-peers');
|
|
2573
|
-
if (
|
|
2574
|
-
|
|
2575
|
-
|
|
3117
|
+
if (res.ok) {
|
|
3118
|
+
const data = await res.json();
|
|
3119
|
+
knownPeers = Array.isArray(data.peers) ? data.peers : [];
|
|
3120
|
+
}
|
|
3121
|
+
// 2) 2026-06-10 修: 同时拉 /api/remote-channels, 兜底 SSE 推送漏掉的情况
|
|
3122
|
+
// (页面刷新后 remoteChannels[] = [], 必须主动拉一次才有数据)
|
|
3123
|
+
const r2 = await fetch('/api/remote-channels');
|
|
3124
|
+
if (r2.ok) {
|
|
3125
|
+
const data2 = await r2.json();
|
|
3126
|
+
const peers = Array.isArray(data2.peers) ? data2.peers : [];
|
|
3127
|
+
// 合并到 remoteChannels[]: 按 peerId 覆盖
|
|
3128
|
+
for (const p of peers) {
|
|
3129
|
+
let group = remoteChannels.find(g => g.peerId === p.peerId);
|
|
3130
|
+
if (!group) {
|
|
3131
|
+
group = { peerId: p.peerId, channels: [], peerName: ('peer-' + p.peerId.substring(0, 8)) };
|
|
3132
|
+
remoteChannels.push(group);
|
|
3133
|
+
}
|
|
3134
|
+
group.channels = p.channels || [];
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
2576
3137
|
renderRemoteChannels();
|
|
3138
|
+
// 3) 远端数据可能变化, 同步 @-mention 列表
|
|
3139
|
+
if (typeof refreshMentionChannels === 'function') {
|
|
3140
|
+
refreshMentionChannels();
|
|
3141
|
+
}
|
|
2577
3142
|
} catch (err) {
|
|
2578
3143
|
console.error('[v3] loadRemoteChannels 失败:', err);
|
|
2579
3144
|
}
|
|
@@ -2583,30 +3148,60 @@ function renderRemoteChannels() {
|
|
|
2583
3148
|
const list = document.getElementById('remote-channel-list');
|
|
2584
3149
|
if (!list) return;
|
|
2585
3150
|
|
|
2586
|
-
// Phase 3 重做: 好友列表 + 收到的 channel 分组
|
|
2587
|
-
if (knownPeers.length === 0) {
|
|
2588
|
-
list.innerHTML = '<li style="color:var(--text-muted);font-size:11px;padding:8px 4px;text-align:center;">(暂无好友, 点 + 添加)</li>';
|
|
2589
|
-
return;
|
|
2590
|
-
}
|
|
2591
|
-
|
|
2592
3151
|
// 按 peerId 分组 channels
|
|
2593
3152
|
const channelsByPeer = {};
|
|
2594
3153
|
for (const p of remoteChannels) {
|
|
2595
3154
|
channelsByPeer[p.peerId] = p.channels || [];
|
|
2596
3155
|
}
|
|
2597
3156
|
|
|
2598
|
-
|
|
3157
|
+
// 2026-06-10 修: 之前 UI 只渲染 knownPeers, 但对面 publicKey 可能跟本机 known_peers 不匹配
|
|
3158
|
+
// (例如对面重启 / 换 role / 第一次相连还没加为好友), 导致 remoteChannels 里有数据 UI 却空白.
|
|
3159
|
+
// 修法: 把 remoteChannels 里的 "陌生 peer" (不在 known_peers 里) 也渲染出来, 标记为未加好友.
|
|
3160
|
+
const knownPks = new Set(knownPeers.map(p => p.publicKey));
|
|
3161
|
+
const strangerPeers = remoteChannels
|
|
3162
|
+
.filter(p => !knownPks.has(p.peerId))
|
|
3163
|
+
.map(p => ({
|
|
3164
|
+
publicKey: p.peerId,
|
|
3165
|
+
name: p.peerName || ('未授权 ' + p.peerId.substring(0, 8)),
|
|
3166
|
+
lastConnectedAt: null,
|
|
3167
|
+
_isStranger: true
|
|
3168
|
+
}));
|
|
3169
|
+
const allPeers = [...knownPeers, ...strangerPeers];
|
|
3170
|
+
|
|
3171
|
+
if (allPeers.length === 0) {
|
|
3172
|
+
list.innerHTML = '<li style="color:var(--text-muted);font-size:11px;padding:8px 4px;text-align:center;">(暂无好友, 点 + 添加)</li>';
|
|
3173
|
+
return;
|
|
3174
|
+
}
|
|
3175
|
+
|
|
3176
|
+
const html = allPeers.map(peer => {
|
|
2599
3177
|
const peerChannels = channelsByPeer[peer.publicKey] || [];
|
|
2600
3178
|
const lastConn = peer.lastConnectedAt
|
|
2601
3179
|
? new Date(peer.lastConnectedAt).toLocaleDateString()
|
|
2602
|
-
: '从未连接';
|
|
3180
|
+
: (peer._isStranger ? '陌生 peer' : '从未连接');
|
|
3181
|
+
const strangerStyle = peer._isStranger ? 'border:1px dashed var(--border-light);' : '';
|
|
3182
|
+
const strangerIcon = peer._isStranger ? '❔' : '👤';
|
|
3183
|
+
// 2026-06-11: 折叠逻辑 (全不展开)
|
|
3184
|
+
// - 所有 peer 首次见都默认 *折叠* (包括 known_peers 第一个) — 用户一进来看到完整 peer 列表
|
|
3185
|
+
// - 标题栏右侧 "X ch" 提示有内容, 用户点 caret 展开
|
|
3186
|
+
// - 已见过: 沿用 collapsedPeers (用户上次选择)
|
|
3187
|
+
// - "全部展开/折叠" 按钮在 P2P header (id=p2p-toggle-all-btn)
|
|
3188
|
+
if (!seenPeers.has(peer.publicKey)) {
|
|
3189
|
+
seenPeers.add(peer.publicKey);
|
|
3190
|
+
collapsedPeers.add(peer.publicKey); // 全部默认折叠
|
|
3191
|
+
saveSeenPeers();
|
|
3192
|
+
saveCollapsedPeers();
|
|
3193
|
+
}
|
|
3194
|
+
const isCollapsed = collapsedPeers.has(peer.publicKey);
|
|
3195
|
+
const caretChar = '▾'; // CSS rotate -90deg 处理折叠态
|
|
2603
3196
|
return `
|
|
2604
|
-
<li class="remote-peer-group" style="margin-bottom:10px
|
|
3197
|
+
<li class="remote-peer-group ${isCollapsed ? 'collapsed' : ''}" style="margin-bottom:10px;${strangerStyle}">
|
|
2605
3198
|
<div class="remote-peer-header" data-peer-name="${escapeHtml(peer.name)}" data-peer-pk="${escapeHtml(peer.publicKey)}"
|
|
2606
|
-
style="display:flex;align-items:center;gap:6px;padding:
|
|
2607
|
-
<
|
|
2608
|
-
|
|
2609
|
-
<span style="font-size:
|
|
3199
|
+
style="display:flex;align-items:center;gap:6px;padding:6px 8px;background:var(--bg-hover);border-radius:4px;cursor:pointer;">
|
|
3200
|
+
<button class="peer-caret-btn" data-toggle-peer="${escapeHtml(peer.publicKey)}" title="折叠/展开"
|
|
3201
|
+
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>
|
|
3202
|
+
<span style="font-size:13px;">${strangerIcon}</span>
|
|
3203
|
+
<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>
|
|
3204
|
+
<span style="font-size:9px;color:var(--text-muted);">${peerChannels.length > 0 ? `${peerChannels.length} ch · ` : ''}${lastConn}</span>
|
|
2610
3205
|
</div>
|
|
2611
3206
|
<div class="remote-peer-channels" style="margin-top:4px;margin-left:8px;">
|
|
2612
3207
|
${peerChannels.length === 0
|
|
@@ -2616,7 +3211,6 @@ function renderRemoteChannels() {
|
|
|
2616
3211
|
style="display:flex;align-items:center;gap:6px;padding:4px 6px;cursor:pointer;border-radius:4px;font-size:12px;">
|
|
2617
3212
|
<span>🤖</span>
|
|
2618
3213
|
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escapeHtml(c.name || '')}">${escapeHtml(c.name || '(未命名)')}</span>
|
|
2619
|
-
<span title="对方 judgment 数 (不会同步到本地)" style="font-size:9px;color:var(--text-muted);">🧠${c.boundJudgmentCount || 0}</span>
|
|
2620
3214
|
</div>
|
|
2621
3215
|
`).join('')
|
|
2622
3216
|
}
|
|
@@ -2626,6 +3220,15 @@ function renderRemoteChannels() {
|
|
|
2626
3220
|
}).join('');
|
|
2627
3221
|
list.innerHTML = html;
|
|
2628
3222
|
|
|
3223
|
+
// 2026-06-10: 折叠按钮点击 → 切折叠 (stopPropagation 防止冒泡触发 header 的分享 modal)
|
|
3224
|
+
list.querySelectorAll('.peer-caret-btn[data-toggle-peer]').forEach(btn => {
|
|
3225
|
+
btn.addEventListener('click', (e) => {
|
|
3226
|
+
e.stopPropagation();
|
|
3227
|
+
const pk = btn.getAttribute('data-toggle-peer');
|
|
3228
|
+
togglePeerCollapsed(pk);
|
|
3229
|
+
});
|
|
3230
|
+
});
|
|
3231
|
+
|
|
2629
3232
|
// 绑定: 点击 channel → 弹聊天窗口
|
|
2630
3233
|
list.querySelectorAll('.remote-channel-row').forEach(row => {
|
|
2631
3234
|
row.addEventListener('click', () => {
|
|
@@ -2638,15 +3241,104 @@ function renderRemoteChannels() {
|
|
|
2638
3241
|
});
|
|
2639
3242
|
// 绑定: 点击 peer 头部 → 弹分享 modal (让 A 决定分享本机哪些 channel 给这个 peer)
|
|
2640
3243
|
list.querySelectorAll('.remote-peer-header').forEach(row => {
|
|
2641
|
-
row.addEventListener('click', () => {
|
|
3244
|
+
row.addEventListener('click', (e) => {
|
|
3245
|
+
// 2026-06-10: 防御 — 点 caret 时已 stopPropagation, 但万一冒泡逃逸再挡一道
|
|
3246
|
+
if (e.target.closest('.peer-caret')) return;
|
|
2642
3247
|
const peerName = row.dataset.peerName;
|
|
2643
3248
|
const peerPk = row.dataset.peerPk;
|
|
2644
3249
|
openShareToPeerModal(peerName, peerPk);
|
|
2645
3250
|
});
|
|
2646
3251
|
});
|
|
3252
|
+
|
|
3253
|
+
// 2026-06-10: 每个 peer 头部双击 → 改名字 / 改备注
|
|
3254
|
+
list.querySelectorAll('.remote-peer-header').forEach(row => {
|
|
3255
|
+
row.addEventListener('dblclick', (e) => {
|
|
3256
|
+
if (e.target.closest('.peer-caret-btn')) return;
|
|
3257
|
+
const peerName = row.dataset.peerName;
|
|
3258
|
+
const peerPk = row.dataset.peerPk;
|
|
3259
|
+
openEditPeerModal(peerName, peerPk);
|
|
3260
|
+
});
|
|
3261
|
+
});
|
|
3262
|
+
|
|
3263
|
+
// 2026-06-10: 渲染完成后同步 header 切换按钮图标
|
|
3264
|
+
if (typeof window.__syncP2PToggleAllBtn === 'function') window.__syncP2PToggleAllBtn();
|
|
3265
|
+
}
|
|
3266
|
+
|
|
3267
|
+
/** v3: 改 peer 名字 / 备注 modal (持久化到 known_peers.json) */
|
|
3268
|
+
async function openEditPeerModal(peerName, peerPublicKey) {
|
|
3269
|
+
document.getElementById('edit-peer-modal')?.remove();
|
|
3270
|
+
// 先读 known_peers 拿到现有 notes
|
|
3271
|
+
let currentNotes = '';
|
|
3272
|
+
let currentName = peerName;
|
|
3273
|
+
try {
|
|
3274
|
+
const r = await fetch('/api/p2p-peers');
|
|
3275
|
+
if (r.ok) {
|
|
3276
|
+
const d = await r.json();
|
|
3277
|
+
const entry = (d.peers || []).find(p => p.publicKey === peerPublicKey);
|
|
3278
|
+
if (entry) {
|
|
3279
|
+
currentName = entry.name || peerName;
|
|
3280
|
+
currentNotes = entry.notes || '';
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
3283
|
+
} catch {}
|
|
3284
|
+
const html = `
|
|
3285
|
+
<div id="edit-peer-modal" class="friend-req-overlay">
|
|
3286
|
+
<div class="friend-req-shell" style="width:520px;">
|
|
3287
|
+
<div class="friend-req-header">
|
|
3288
|
+
<span style="font-size:18px;">✏️</span>
|
|
3289
|
+
<div style="flex:1;min-width:0;">
|
|
3290
|
+
<div class="friend-req-title">编辑好友</div>
|
|
3291
|
+
<div class="friend-req-meta">publicKey: ${escapeHtml(peerPublicKey.substring(0,16))}…</div>
|
|
3292
|
+
</div>
|
|
3293
|
+
</div>
|
|
3294
|
+
<div class="friend-req-body">
|
|
3295
|
+
<label style="display:block;margin-bottom:6px;font-size:12px;color:var(--text-secondary);">显示名字</label>
|
|
3296
|
+
<input id="epm-name" type="text" value="${escapeHtml(currentName)}"
|
|
3297
|
+
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;">
|
|
3298
|
+
<label style="display:block;margin-bottom:6px;font-size:12px;color:var(--text-secondary);">备注 (自由文本, 例如合作领域 / 怎么认识的)</label>
|
|
3299
|
+
<textarea id="epm-notes" rows="4" placeholder="例如: 2026-06 合作 LLM 代发验证"
|
|
3300
|
+
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>
|
|
3301
|
+
</div>
|
|
3302
|
+
<div class="friend-req-actions">
|
|
3303
|
+
<button id="epm-cancel" class="friend-req-btn-deny">取消</button>
|
|
3304
|
+
<button id="epm-save" class="friend-req-btn-accept">保存</button>
|
|
3305
|
+
</div>
|
|
3306
|
+
</div>
|
|
3307
|
+
</div>
|
|
3308
|
+
`;
|
|
3309
|
+
document.body.insertAdjacentHTML('beforeend', html);
|
|
3310
|
+
const close = () => document.getElementById('edit-peer-modal')?.remove();
|
|
3311
|
+
document.getElementById('epm-cancel').onclick = close;
|
|
3312
|
+
document.getElementById('epm-save').onclick = async () => {
|
|
3313
|
+
const newName = document.getElementById('epm-name').value.trim() || currentName;
|
|
3314
|
+
const newNotes = document.getElementById('epm-notes').value;
|
|
3315
|
+
try {
|
|
3316
|
+
const r = await fetch(`/api/p2p-peers/${encodeURIComponent(peerName)}`, {
|
|
3317
|
+
method: 'PATCH',
|
|
3318
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3319
|
+
body: JSON.stringify({ name: newName, notes: newNotes })
|
|
3320
|
+
});
|
|
3321
|
+
const data = await r.json();
|
|
3322
|
+
if (!r.ok) throw new Error(data.error || 'save failed');
|
|
3323
|
+
console.log('[v3] 改 peer 成功:', newName, '备注:', newNotes);
|
|
3324
|
+
showSimpleToast(`✅ 已保存 ${newName}`);
|
|
3325
|
+
close();
|
|
3326
|
+
// 重新拉 known_peers + 远程 channels 重新渲染
|
|
3327
|
+
const r2 = await fetch('/api/p2p-peers');
|
|
3328
|
+
if (r2.ok) {
|
|
3329
|
+
const d2 = await r2.json();
|
|
3330
|
+
knownPeers = Array.isArray(d2.peers) ? d2.peers : [];
|
|
3331
|
+
}
|
|
3332
|
+
renderRemoteChannels();
|
|
3333
|
+
} catch (err) {
|
|
3334
|
+
console.error('[v3] 保存 peer 失败:', err);
|
|
3335
|
+
alert('保存失败: ' + (err.message || err));
|
|
3336
|
+
}
|
|
3337
|
+
};
|
|
2647
3338
|
}
|
|
2648
3339
|
|
|
2649
3340
|
/** v3: 分享 channel 给指定 peer 的 modal (A 侧用) */
|
|
3341
|
+
/** v3: 分享 channel 给指定 peer 的 modal (A 侧用) — 2026-06-11 改用 Step 3 风格 class */
|
|
2650
3342
|
async function openShareToPeerModal(peerName, peerPublicKey) {
|
|
2651
3343
|
document.getElementById('share-to-peer-modal')?.remove();
|
|
2652
3344
|
let allChannels = [];
|
|
@@ -2655,36 +3347,37 @@ async function openShareToPeerModal(peerName, peerPublicKey) {
|
|
|
2655
3347
|
if (res.ok) allChannels = await res.json();
|
|
2656
3348
|
} catch (err) { console.error('openShareToPeerModal:', err); }
|
|
2657
3349
|
const rows = allChannels.length === 0
|
|
2658
|
-
? '<div
|
|
3350
|
+
? '<div class="share-modal-empty">还没有 channel</div>'
|
|
2659
3351
|
: allChannels.map(ch => {
|
|
2660
3352
|
const isShared = Array.isArray(ch.shared_with_peers) && ch.shared_with_peers.includes(peerPublicKey);
|
|
2661
3353
|
return `
|
|
2662
|
-
<label
|
|
2663
|
-
<input type="checkbox" data-cid="${escapeHtml(ch.id)}" ${isShared ? 'checked' : ''}
|
|
2664
|
-
<div
|
|
2665
|
-
<div
|
|
2666
|
-
<div
|
|
2667
|
-
${isShared ? '✓ 已分享' : '未分享'} · ${ch.id}
|
|
3354
|
+
<label class="share-modal-row">
|
|
3355
|
+
<input type="checkbox" data-cid="${escapeHtml(ch.id)}" ${isShared ? 'checked' : ''} class="share-modal-cb">
|
|
3356
|
+
<div class="share-modal-row-info">
|
|
3357
|
+
<div class="share-modal-row-name">${escapeHtml(ch.name)}</div>
|
|
3358
|
+
<div class="share-modal-row-meta">
|
|
3359
|
+
${isShared ? '✓ 已分享' : '未分享'} · ${escapeHtml(ch.id.slice(0, 24))}…
|
|
2668
3360
|
</div>
|
|
2669
3361
|
</div>
|
|
2670
3362
|
</label>
|
|
2671
3363
|
`;
|
|
2672
3364
|
}).join('');
|
|
2673
3365
|
const html = `
|
|
2674
|
-
<div id="share-to-peer-modal"
|
|
2675
|
-
<div
|
|
2676
|
-
<div
|
|
2677
|
-
<
|
|
2678
|
-
|
|
2679
|
-
<div
|
|
3366
|
+
<div id="share-to-peer-modal" class="friend-req-overlay">
|
|
3367
|
+
<div class="friend-req-shell share-modal-shell">
|
|
3368
|
+
<div class="friend-req-header">
|
|
3369
|
+
<span style="font-size:18px;">📤</span>
|
|
3370
|
+
<div style="flex:1;min-width:0;">
|
|
3371
|
+
<div class="friend-req-title">分享 channel 给 ${escapeHtml(peerName)}</div>
|
|
3372
|
+
<div class="friend-req-meta">${escapeHtml(peerPublicKey.substring(0,16))}…</div>
|
|
2680
3373
|
</div>
|
|
2681
|
-
<button id="spm-close"
|
|
3374
|
+
<button id="spm-close" class="friend-req-btn-close">×</button>
|
|
2682
3375
|
</div>
|
|
2683
|
-
<div
|
|
2684
|
-
<div id="spm-list"
|
|
2685
|
-
<div
|
|
2686
|
-
<button id="spm-cancel"
|
|
2687
|
-
<button id="spm-save"
|
|
3376
|
+
<div class="share-modal-hint">勾选要分享的 channel, 对方才能看到</div>
|
|
3377
|
+
<div id="spm-list" class="share-modal-list">${rows}</div>
|
|
3378
|
+
<div class="friend-req-actions">
|
|
3379
|
+
<button id="spm-cancel" class="friend-req-btn-deny">取消</button>
|
|
3380
|
+
<button id="spm-save" class="friend-req-btn-accept">保存分享</button>
|
|
2688
3381
|
</div>
|
|
2689
3382
|
</div>
|
|
2690
3383
|
</div>
|
|
@@ -2700,7 +3393,7 @@ async function openShareToPeerModal(peerName, peerPublicKey) {
|
|
|
2700
3393
|
for (const ch of allChannels) {
|
|
2701
3394
|
const shouldShare = checkedIds.includes(ch.id);
|
|
2702
3395
|
const wasShared = Array.isArray(ch.shared_with_peers) && ch.shared_with_peers.includes(peerPublicKey);
|
|
2703
|
-
if (shouldShare === wasShared) continue;
|
|
3396
|
+
if (shouldShare === wasShared) continue;
|
|
2704
3397
|
const newList = (ch.shared_with_peers || []).filter((p) => p !== peerPublicKey);
|
|
2705
3398
|
if (shouldShare) newList.push(peerPublicKey);
|
|
2706
3399
|
try {
|
|
@@ -2712,34 +3405,36 @@ async function openShareToPeerModal(peerName, peerPublicKey) {
|
|
|
2712
3405
|
if (res.ok) ok++; else fail++;
|
|
2713
3406
|
} catch { fail++; }
|
|
2714
3407
|
}
|
|
2715
|
-
|
|
3408
|
+
showSimpleToast(`分享更新完成: 成功 ${ok}, 失败 ${fail}`, ok > 0 ? 'info' : (fail > 0 ? 'error' : 'info'));
|
|
2716
3409
|
overlay.remove();
|
|
2717
3410
|
};
|
|
2718
3411
|
}
|
|
2719
3412
|
|
|
2720
|
-
/** v3: 跟远端 channel 聊天的简易弹窗
|
|
3413
|
+
/** v3: 跟远端 channel 聊天的简易弹窗
|
|
3414
|
+
* 2026-06-10 重写: UI 完全对齐本地聊天 (复用 addMessage / .messages / .bubble 整套样式),
|
|
3415
|
+
* marked.parse + cleanThink + cleanEnv 自动生效, 不再裸文本.
|
|
3416
|
+
*/
|
|
2721
3417
|
function openRemoteChannelChat(peerPublicKey, channelId, channelName) {
|
|
2722
3418
|
// 移除已有 modal
|
|
2723
3419
|
document.getElementById('remote-chat-modal')?.remove();
|
|
2724
3420
|
const html = `
|
|
2725
|
-
<div id="remote-chat-modal"
|
|
2726
|
-
<div
|
|
2727
|
-
<div
|
|
3421
|
+
<div id="remote-chat-modal" class="remote-chat-overlay">
|
|
3422
|
+
<div class="remote-chat-shell">
|
|
3423
|
+
<div class="remote-chat-header">
|
|
2728
3424
|
<div style="flex:1;min-width:0;">
|
|
2729
|
-
<div
|
|
2730
|
-
<div
|
|
3425
|
+
<div class="remote-chat-title">🌐 跟 ${escapeHtml(channelName)} 聊天</div>
|
|
3426
|
+
<div class="remote-chat-meta">远端 peer: ${escapeHtml(peerPublicKey.substring(0,16))}… · ${escapeHtml(channelId)}</div>
|
|
2731
3427
|
</div>
|
|
2732
|
-
<button id="rcm-refresh-history" title="重新拉历史"
|
|
2733
|
-
<button id="rcm-close"
|
|
3428
|
+
<button id="rcm-refresh-history" title="重新拉历史" class="remote-chat-btn-secondary">↻ 历史</button>
|
|
3429
|
+
<button id="rcm-close" class="remote-chat-btn-close">×</button>
|
|
2734
3430
|
</div>
|
|
2735
|
-
<div id="rcm-thinking" style="display:none;
|
|
3431
|
+
<div id="rcm-thinking" class="remote-chat-thinking" style="display:none;">
|
|
2736
3432
|
📥 正在从远端拉历史 + 判断力…
|
|
2737
3433
|
</div>
|
|
2738
|
-
<div id="rcm-log"
|
|
2739
|
-
<div
|
|
2740
|
-
<input id="rcm-input" type="text" placeholder="输入消息, 发送到远端 channel..."
|
|
2741
|
-
|
|
2742
|
-
<button id="rcm-send" style="padding:8px 14px;background:#2563eb;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:13px;">发送</button>
|
|
3434
|
+
<div id="rcm-log" class="messages remote-chat-log"></div>
|
|
3435
|
+
<div class="remote-chat-input-row">
|
|
3436
|
+
<input id="rcm-input" type="text" placeholder="输入消息, 发送到远端 channel..." class="remote-chat-input">
|
|
3437
|
+
<button id="rcm-send" class="remote-chat-btn-send">发送</button>
|
|
2743
3438
|
</div>
|
|
2744
3439
|
</div>
|
|
2745
3440
|
</div>
|
|
@@ -2750,40 +3445,51 @@ function openRemoteChannelChat(peerPublicKey, channelId, channelName) {
|
|
|
2750
3445
|
const inputEl = document.getElementById('rcm-input');
|
|
2751
3446
|
const sendBtn = document.getElementById('rcm-send');
|
|
2752
3447
|
const thinkingEl = document.getElementById('rcm-thinking');
|
|
2753
|
-
|
|
2754
|
-
document.getElementById('rcm-
|
|
3448
|
+
let historyRefreshTimer = null;
|
|
3449
|
+
document.getElementById('rcm-close').onclick = () => {
|
|
3450
|
+
if (historyRefreshTimer) { clearInterval(historyRefreshTimer); historyRefreshTimer = null; }
|
|
3451
|
+
document.getElementById('remote-chat-modal').remove();
|
|
3452
|
+
};
|
|
3453
|
+
document.getElementById('rcm-refresh-history').onclick = () => loadHistory(false);
|
|
2755
3454
|
|
|
3455
|
+
// 2026-06-10 改: 直接复用本地 addMessage, 自动获得 marked + think 折叠 + env 折叠 + 主题变量
|
|
2756
3456
|
const append = (text, role) => {
|
|
2757
|
-
|
|
2758
|
-
const isUser = role === 'user';
|
|
2759
|
-
bubble.style.cssText = `padding:8px 10px;margin:4px 0;border-radius:6px;font-size:13px;line-height:1.4;max-width:80%;word-wrap:break-word;${
|
|
2760
|
-
isUser ? 'background:#2563eb;color:#fff;margin-left:auto;text-align:left;'
|
|
2761
|
-
: 'background:#e5e7eb;color:#111;'
|
|
2762
|
-
}`;
|
|
2763
|
-
bubble.textContent = text;
|
|
2764
|
-
log.appendChild(bubble);
|
|
3457
|
+
addMessage(text, role === 'user' ? 'user' : 'ai', false, log);
|
|
2765
3458
|
log.scrollTop = log.scrollHeight;
|
|
2766
3459
|
};
|
|
2767
3460
|
|
|
3461
|
+
// 系统提示用更轻量的样式 (不走 addMessage, 避免被当聊天记录裁剪)
|
|
2768
3462
|
const appendSystem = (text, kind = 'info') => {
|
|
2769
3463
|
const el = document.createElement('div');
|
|
2770
|
-
|
|
2771
|
-
info: { bg: '#dbeafe', fg: '#1e40af' },
|
|
2772
|
-
warn: { bg: '#fef3c7', fg: '#92400e' },
|
|
2773
|
-
error: { bg: '#fca5a5', fg: '#7f1d1d' }
|
|
2774
|
-
};
|
|
2775
|
-
const c = colors[kind] || colors.info;
|
|
2776
|
-
el.style.cssText = `margin:6px 0;padding:6px 10px;background:${c.bg};color:${c.fg};border-radius:4px;font-size:11px;text-align:center;`;
|
|
3464
|
+
el.className = `remote-chat-sysmsg remote-chat-sysmsg-${kind}`;
|
|
2777
3465
|
el.textContent = text;
|
|
2778
3466
|
log.appendChild(el);
|
|
2779
3467
|
log.scrollTop = log.scrollHeight;
|
|
2780
3468
|
};
|
|
2781
3469
|
|
|
2782
3470
|
// v3 新增: 拉 A 端的 channel 历史 (含 messages + judgments)
|
|
2783
|
-
async function loadHistory() {
|
|
3471
|
+
async function loadHistory(isSilent) {
|
|
3472
|
+
if (!document.getElementById('remote-chat-modal')) return; // modal 已关闭
|
|
3473
|
+
|
|
3474
|
+
if (isSilent) {
|
|
3475
|
+
try {
|
|
3476
|
+
const res = await fetch(`/api/remote-channels/chat-history?targetPublicKey=${encodeURIComponent(peerPublicKey)}&channelId=${encodeURIComponent(channelId)}`);
|
|
3477
|
+
if (!res.ok || !document.getElementById('remote-chat-modal')) return;
|
|
3478
|
+
const data = await res.json();
|
|
3479
|
+
const newMsgs = data.messages || [];
|
|
3480
|
+
const oldCount = log.querySelectorAll('.message').length;
|
|
3481
|
+
if (newMsgs.length === oldCount) return;
|
|
3482
|
+
const scrollWasAtBottom = log.scrollTop + log.clientHeight >= log.scrollHeight - 30;
|
|
3483
|
+
renderHistory(data);
|
|
3484
|
+
if (scrollWasAtBottom) {
|
|
3485
|
+
setTimeout(() => { log.scrollTop = log.scrollHeight; }, 50);
|
|
3486
|
+
}
|
|
3487
|
+
} catch (_) { /* 静默失败 */ }
|
|
3488
|
+
return;
|
|
3489
|
+
}
|
|
3490
|
+
|
|
2784
3491
|
thinkingEl.style.display = 'block';
|
|
2785
3492
|
log.innerHTML = '';
|
|
2786
|
-
appendSystem('正在拉取远端 channel 的历史和判断力...', 'info');
|
|
2787
3493
|
try {
|
|
2788
3494
|
const res = await fetch(`/api/remote-channels/chat-history?targetPublicKey=${encodeURIComponent(peerPublicKey)}&channelId=${encodeURIComponent(channelId)}`);
|
|
2789
3495
|
const data = await res.json();
|
|
@@ -2792,37 +3498,7 @@ function openRemoteChannelChat(peerPublicKey, channelId, channelName) {
|
|
|
2792
3498
|
thinkingEl.style.display = 'none';
|
|
2793
3499
|
return;
|
|
2794
3500
|
}
|
|
2795
|
-
|
|
2796
|
-
log.innerHTML = '';
|
|
2797
|
-
|
|
2798
|
-
// 1. 显示 judgment 依据 (header)
|
|
2799
|
-
const judgments = data.judgments || { bound: [], candidates: [] };
|
|
2800
|
-
if (judgments.bound && judgments.bound.length > 0) {
|
|
2801
|
-
const jh = document.createElement('div');
|
|
2802
|
-
jh.style.cssText = 'margin:0 0 8px;padding:8px 10px;background:#fef3c7;border-left:3px solid #f59e0b;border-radius:4px;font-size:12px;';
|
|
2803
|
-
let h = `<div style="font-weight:600;color:#92400e;margin-bottom:4px;">🛡️ 对方 channel 绑定的判断力 (${judgments.bound.length} 条硬约束)</div>`;
|
|
2804
|
-
for (const j of judgments.bound) {
|
|
2805
|
-
h += `<div style="margin:3px 0;padding-left:8px;">• <b>${escapeHtml((j.decision || '').slice(0, 100))}</b>${j.domain ? `<span style="color:#a16207;font-size:10px;"> [${escapeHtml(j.domain)}${j.stakes ? '/' + escapeHtml(j.stakes) : ''}]</span>` : ''}${j.reasons && j.reasons.length ? '<br><span style="color:#92400e;font-size:11px;">理由: ' + escapeHtml(j.reasons.join('; ').slice(0, 100)) + '</span>' : ''}</div>`;
|
|
2806
|
-
}
|
|
2807
|
-
if (judgments.candidates && judgments.candidates.length > 0) {
|
|
2808
|
-
h += `<div style="margin-top:6px;color:#92400e;font-size:11px;">+ ${judgments.candidates.length} 条候选判断力 (LLM 可自选参考)</div>`;
|
|
2809
|
-
}
|
|
2810
|
-
jh.innerHTML = h;
|
|
2811
|
-
log.appendChild(jh);
|
|
2812
|
-
}
|
|
2813
|
-
|
|
2814
|
-
// 2. 显示历史 messages
|
|
2815
|
-
const msgs = data.messages || [];
|
|
2816
|
-
if (msgs.length === 0) {
|
|
2817
|
-
appendSystem('还没有历史消息, 在下面发第一条吧', 'info');
|
|
2818
|
-
} else {
|
|
2819
|
-
appendSystem(`从远端拉到 ${msgs.length} 条历史消息`, 'info');
|
|
2820
|
-
for (const m of msgs) {
|
|
2821
|
-
append(m.content || '', m.type === 'user' ? 'user' : 'ai');
|
|
2822
|
-
}
|
|
2823
|
-
// 滚到底部
|
|
2824
|
-
setTimeout(() => { log.scrollTop = log.scrollHeight; }, 50);
|
|
2825
|
-
}
|
|
3501
|
+
renderHistory(data);
|
|
2826
3502
|
} catch (err) {
|
|
2827
3503
|
appendSystem(`拉取异常: ${err.message}`, 'error');
|
|
2828
3504
|
} finally {
|
|
@@ -2830,6 +3506,50 @@ function openRemoteChannelChat(peerPublicKey, channelId, channelName) {
|
|
|
2830
3506
|
}
|
|
2831
3507
|
}
|
|
2832
3508
|
|
|
3509
|
+
function renderHistory(data) {
|
|
3510
|
+
log.innerHTML = '';
|
|
3511
|
+
|
|
3512
|
+
// 1. 显示 judgment 依据 (header) — 保留, 但用 class 化样式
|
|
3513
|
+
const judgments = data.judgments || { bound: [], candidates: [] };
|
|
3514
|
+
if (judgments.bound && judgments.bound.length > 0) {
|
|
3515
|
+
const jh = document.createElement('div');
|
|
3516
|
+
jh.className = 'remote-chat-judgments';
|
|
3517
|
+
let h = `<div class="remote-chat-judgments-title">🛡️ 对方 channel 绑定的判断力 (${judgments.bound.length} 条硬约束)</div>`;
|
|
3518
|
+
for (const j of judgments.bound) {
|
|
3519
|
+
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>`;
|
|
3520
|
+
}
|
|
3521
|
+
if (judgments.candidates && judgments.candidates.length > 0) {
|
|
3522
|
+
h += `<div class="remote-chat-judgments-foot">+ ${judgments.candidates.length} 条候选判断力 (LLM 可自选参考)</div>`;
|
|
3523
|
+
}
|
|
3524
|
+
jh.innerHTML = h;
|
|
3525
|
+
log.appendChild(jh);
|
|
3526
|
+
}
|
|
3527
|
+
|
|
3528
|
+
// 2. 显示历史 messages — 完全复用本地 addMessage 渲染
|
|
3529
|
+
const msgs = data.messages || [];
|
|
3530
|
+
if (msgs.length === 0) {
|
|
3531
|
+
appendSystem('还没有历史消息, 在下面发第一条吧', 'info');
|
|
3532
|
+
} else {
|
|
3533
|
+
for (const m of msgs) {
|
|
3534
|
+
// 远端 owner 的 user 消息 vs 远端访客 (B) 的 user 消息 vs A 的 LLM 回复
|
|
3535
|
+
// 全部走 addMessage, 让 marked/think/env 自动处理. 来源用一个小 prefix 标记.
|
|
3536
|
+
const type = m.type === 'user' ? 'user' : 'ai';
|
|
3537
|
+
let prefix = '';
|
|
3538
|
+
if (m.type === 'user') {
|
|
3539
|
+
if (m.source === 'remote') {
|
|
3540
|
+
prefix = `🌐 远端访客${m.fromPublicKey ? ' (' + m.fromPublicKey.substring(0, 8) + '…)' : ''}\n\n`;
|
|
3541
|
+
} else {
|
|
3542
|
+
prefix = `👤 A (内部 owner)\n\n`;
|
|
3543
|
+
}
|
|
3544
|
+
} else {
|
|
3545
|
+
prefix = `🤖 A 的 LLM\n\n`;
|
|
3546
|
+
}
|
|
3547
|
+
addMessage(prefix + (m.content || ''), type, false, log);
|
|
3548
|
+
}
|
|
3549
|
+
setTimeout(() => { log.scrollTop = log.scrollHeight; }, 50);
|
|
3550
|
+
}
|
|
3551
|
+
}
|
|
3552
|
+
|
|
2833
3553
|
const doSend = async () => {
|
|
2834
3554
|
const text = inputEl.value.trim();
|
|
2835
3555
|
if (!text) return;
|
|
@@ -2845,9 +3565,9 @@ function openRemoteChannelChat(peerPublicKey, channelId, channelName) {
|
|
|
2845
3565
|
});
|
|
2846
3566
|
const data = await res.json();
|
|
2847
3567
|
if (!res.ok) throw new Error(data.error || 'send failed');
|
|
2848
|
-
appendSystem('
|
|
3568
|
+
// 不再 appendSystem('已发送...') —— 用户看到自己消息已上屏就知道, 系统提示是噪音
|
|
2849
3569
|
} catch (err) {
|
|
2850
|
-
|
|
3570
|
+
appendSystem('发送失败: ' + (err.message || err), 'error');
|
|
2851
3571
|
} finally {
|
|
2852
3572
|
sendBtn.disabled = false;
|
|
2853
3573
|
sendBtn.textContent = '发送';
|
|
@@ -2855,11 +3575,16 @@ function openRemoteChannelChat(peerPublicKey, channelId, channelName) {
|
|
|
2855
3575
|
};
|
|
2856
3576
|
sendBtn.onclick = doSend;
|
|
2857
3577
|
inputEl.onkeydown = (e) => { if (e.key === 'Enter') doSend(); };
|
|
3578
|
+
// v3 新增: B 端远端 chat 也支持 @-autocomplete
|
|
3579
|
+
setupMentionAutocomplete(inputEl);
|
|
2858
3580
|
inputEl.focus();
|
|
2859
3581
|
startV3GlobalSSE();
|
|
2860
3582
|
|
|
2861
3583
|
// 打开时立即拉历史
|
|
2862
|
-
loadHistory();
|
|
3584
|
+
loadHistory(false);
|
|
3585
|
+
|
|
3586
|
+
// 每 15 秒自动静默刷新, 同步远端 owner 或其他访客的新消息
|
|
3587
|
+
historyRefreshTimer = setInterval(() => loadHistory(true), 15000);
|
|
2863
3588
|
}
|
|
2864
3589
|
|
|
2865
3590
|
// Phase 3: 我的 ID 按钮 → 真 modal (避免 confirm 在某些环境被禁用)
|
|
@@ -2945,20 +3670,166 @@ if (addPeerBtn) {
|
|
|
2945
3670
|
return;
|
|
2946
3671
|
}
|
|
2947
3672
|
try {
|
|
2948
|
-
//
|
|
2949
|
-
|
|
3673
|
+
// v3 新增: 改用 friend-request RPC — 不光 joinPeer, 还发申请到对方
|
|
3674
|
+
// 对方会收到 SSE friend-request 事件, 弹一个申请 modal
|
|
3675
|
+
const res = await fetch('/api/friend-request', {
|
|
2950
3676
|
method: 'POST',
|
|
2951
3677
|
headers: { 'Content-Type': 'application/json' },
|
|
2952
|
-
body: JSON.stringify({ targetPublicKey: publicKey, name,
|
|
3678
|
+
body: JSON.stringify({ targetPublicKey: publicKey, name, message: '想加你为 P2P 好友, 共享 channel 协作' })
|
|
2953
3679
|
});
|
|
2954
3680
|
const data = await res.json();
|
|
3681
|
+
if (res.status === 502) {
|
|
3682
|
+
// 2026-06-10: 区分"对方不在线"和"写失败" — 让用户知道是否需要重试
|
|
3683
|
+
const reason = data.code === 'NO_CONN' ? '对方未在线或 P2P 握手超时' : '写入 P2P 通道失败';
|
|
3684
|
+
alert(`好友申请发送失败: ${reason}\n\n本地已记住对方 publicKey (${publicKey.substring(0,8)}...), 等对方上线后可在 P2P 面板手动重试.`);
|
|
3685
|
+
await loadRemoteChannels();
|
|
3686
|
+
return;
|
|
3687
|
+
}
|
|
2955
3688
|
if (!res.ok) throw new Error(data.error || 'connect failed');
|
|
2956
|
-
|
|
3689
|
+
// 成功 — 但不阻塞地等 ack (ack 经 SSE 'friend-request-ack' 推回, 由 v3GlobalEventSource 处理)
|
|
3690
|
+
window.__pendingFriendRequests = window.__pendingFriendRequests || new Map();
|
|
3691
|
+
if (data.requestId) {
|
|
3692
|
+
window.__pendingFriendRequests.set(data.requestId, { name, publicKey, at: Date.now() });
|
|
3693
|
+
// 8s 后还没 ack → 提示用户对方可能跑旧版 (无 ack 协议)
|
|
3694
|
+
setTimeout(() => {
|
|
3695
|
+
if (window.__pendingFriendRequests.has(data.requestId)) {
|
|
3696
|
+
window.__pendingFriendRequests.delete(data.requestId);
|
|
3697
|
+
console.warn(`[v3-friend] 申请超时未收到 ack (requestId=${data.requestId.substring(0,8)})`);
|
|
3698
|
+
showSimpleToast(`⚠️ 对方未确认收到 (可能是旧版客户端, 申请已发出但无法验证)`, 'warn');
|
|
3699
|
+
}
|
|
3700
|
+
}, 8000);
|
|
3701
|
+
}
|
|
3702
|
+
alert(`已发送好友申请给 ${name} (${publicKey.substring(0, 12)}...)\n对方收到后自己端弹申请 modal, 接受后会出现在 P2P 好友区.`);
|
|
2957
3703
|
await loadRemoteChannels();
|
|
2958
3704
|
} catch (err) {
|
|
2959
|
-
alert('
|
|
3705
|
+
alert('申请失败: ' + (err.message || err));
|
|
3706
|
+
}
|
|
3707
|
+
});
|
|
3708
|
+
}
|
|
3709
|
+
|
|
3710
|
+
/**
|
|
3711
|
+
* v3 新增: 收到好友申请时, 弹一个 modal 让用户接受或拒绝
|
|
3712
|
+
*/
|
|
3713
|
+
function showFriendRequestModal(req) {
|
|
3714
|
+
// 移除已有 modal
|
|
3715
|
+
document.getElementById('friend-request-modal')?.remove();
|
|
3716
|
+
// 2026-06-10: 同 Step 3 远端 chat modal 一样, 改用 class + CSS 变量, 跟本地风格统一
|
|
3717
|
+
const html = `
|
|
3718
|
+
<div id="friend-request-modal" class="friend-req-overlay">
|
|
3719
|
+
<div class="friend-req-shell">
|
|
3720
|
+
<div class="friend-req-header">
|
|
3721
|
+
<span style="font-size:20px;">🤝</span>
|
|
3722
|
+
<div style="flex:1;min-width:0;">
|
|
3723
|
+
<div class="friend-req-title">好友申请</div>
|
|
3724
|
+
<div class="friend-req-meta">来自 ${escapeHtml(req.fromName)} (${escapeHtml(req.fromPublicKey.substring(0, 16))}…)</div>
|
|
3725
|
+
</div>
|
|
3726
|
+
</div>
|
|
3727
|
+
<div class="friend-req-body">
|
|
3728
|
+
<p style="margin:0 0 8px;">${escapeHtml(req.message || '想加你为 P2P 好友')}</p>
|
|
3729
|
+
<p style="margin:0;color:var(--text-muted);font-size:11px;">接受后: 双方互加好友, 对方分享的 channel 会自动出现在 P2P 好友区.</p>
|
|
3730
|
+
</div>
|
|
3731
|
+
<div class="friend-req-actions">
|
|
3732
|
+
<button id="frm-deny" class="friend-req-btn-deny">拒绝</button>
|
|
3733
|
+
<button id="frm-accept" class="friend-req-btn-accept">接受</button>
|
|
3734
|
+
</div>
|
|
3735
|
+
</div>
|
|
3736
|
+
</div>
|
|
3737
|
+
`;
|
|
3738
|
+
document.body.insertAdjacentHTML('beforeend', html);
|
|
3739
|
+
const close = () => document.getElementById('friend-request-modal')?.remove();
|
|
3740
|
+
document.getElementById('frm-deny').onclick = close;
|
|
3741
|
+
document.getElementById('frm-accept').onclick = async () => {
|
|
3742
|
+
close();
|
|
3743
|
+
try {
|
|
3744
|
+
const res = await fetch('/api/friend-accept', {
|
|
3745
|
+
method: 'POST',
|
|
3746
|
+
headers: { 'Content-Type': 'application/json' },
|
|
3747
|
+
body: JSON.stringify({ fromPublicKey: req.fromPublicKey, name: req.fromName })
|
|
3748
|
+
});
|
|
3749
|
+
const data = await res.json();
|
|
3750
|
+
if (!res.ok) throw new Error(data.error || 'accept failed');
|
|
3751
|
+
console.log('[v3-friend] 接受了好友申请:', req.fromName);
|
|
3752
|
+
// 立刻拉一次 — 对方刚 accept, ta 的 channel 列表会被推到我们这
|
|
3753
|
+
setTimeout(loadRemoteChannels, 1000);
|
|
3754
|
+
showSimpleToast(`✅ 已接受 ${req.fromName} 的好友申请`);
|
|
3755
|
+
} catch (err) {
|
|
3756
|
+
console.error('[v3-friend] accept 失败:', err);
|
|
3757
|
+
alert('接受失败: ' + (err.message || err));
|
|
2960
3758
|
}
|
|
3759
|
+
};
|
|
3760
|
+
}
|
|
3761
|
+
|
|
3762
|
+
/**
|
|
3763
|
+
* 2026-06-10: 简单的右下 toast, 3s 自动消失. 用于 ack / 接受好友 等非阻塞反馈
|
|
3764
|
+
*/
|
|
3765
|
+
function showSimpleToast(text, kind = 'info') {
|
|
3766
|
+
const containerId = 'simple-toast-container';
|
|
3767
|
+
let container = document.getElementById(containerId);
|
|
3768
|
+
if (!container) {
|
|
3769
|
+
container = document.createElement('div');
|
|
3770
|
+
container.id = containerId;
|
|
3771
|
+
container.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:10005;display:flex;flex-direction:column;gap:8px;max-width:320px;';
|
|
3772
|
+
document.body.appendChild(container);
|
|
3773
|
+
}
|
|
3774
|
+
const el = document.createElement('div');
|
|
3775
|
+
el.className = `simple-toast simple-toast-${kind}`;
|
|
3776
|
+
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;`;
|
|
3777
|
+
el.textContent = text;
|
|
3778
|
+
container.appendChild(el);
|
|
3779
|
+
setTimeout(() => {
|
|
3780
|
+
el.style.transition = 'opacity .3s, transform .3s';
|
|
3781
|
+
el.style.opacity = '0';
|
|
3782
|
+
el.style.transform = 'translateX(20px)';
|
|
3783
|
+
setTimeout(() => el.remove(), 320);
|
|
3784
|
+
}, 3000);
|
|
3785
|
+
}
|
|
3786
|
+
|
|
3787
|
+
// 2026-06-10: P2P 全部展开/折叠切换按钮 (单按钮, 根据当前多数态切换)
|
|
3788
|
+
const p2pToggleAllBtn = document.getElementById('p2p-toggle-all-btn');
|
|
3789
|
+
if (p2pToggleAllBtn) {
|
|
3790
|
+
// 同步图标/文字: 多数 peer 折叠 → 显示 "⊞ 展开"; 多数展开 → 显示 "⊟ 折叠"
|
|
3791
|
+
function syncToggleAllBtn() {
|
|
3792
|
+
const allPks = new Set([
|
|
3793
|
+
...knownPeers.map(p => p.publicKey),
|
|
3794
|
+
...remoteChannels.map(g => g.peerId)
|
|
3795
|
+
]);
|
|
3796
|
+
if (allPks.size === 0) {
|
|
3797
|
+
p2pToggleAllBtn.textContent = '⊞ 展开';
|
|
3798
|
+
p2pToggleAllBtn.title = '切换全部展开/折叠';
|
|
3799
|
+
return;
|
|
3800
|
+
}
|
|
3801
|
+
let collapsedCount = 0;
|
|
3802
|
+
for (const pk of allPks) if (collapsedPeers.has(pk)) collapsedCount++;
|
|
3803
|
+
const majorityCollapsed = collapsedCount >= allPks.size / 2;
|
|
3804
|
+
if (majorityCollapsed) {
|
|
3805
|
+
p2pToggleAllBtn.textContent = '⊞ 展开';
|
|
3806
|
+
p2pToggleAllBtn.title = '点击展开所有 P2P 好友';
|
|
3807
|
+
} else {
|
|
3808
|
+
p2pToggleAllBtn.textContent = '⊟ 折叠';
|
|
3809
|
+
p2pToggleAllBtn.title = '点击折叠所有 P2P 好友';
|
|
3810
|
+
}
|
|
3811
|
+
}
|
|
3812
|
+
p2pToggleAllBtn.addEventListener('click', (e) => {
|
|
3813
|
+
e.stopPropagation();
|
|
3814
|
+
const allPks = new Set([
|
|
3815
|
+
...knownPeers.map(p => p.publicKey),
|
|
3816
|
+
...remoteChannels.map(g => g.peerId)
|
|
3817
|
+
]);
|
|
3818
|
+
if (allPks.size === 0) return;
|
|
3819
|
+
// 多数折叠 → 全展开; 否则全折叠
|
|
3820
|
+
let collapsedCount = 0;
|
|
3821
|
+
for (const pk of allPks) if (collapsedPeers.has(pk)) collapsedCount++;
|
|
3822
|
+
const majorityCollapsed = collapsedCount >= allPks.size / 2;
|
|
3823
|
+
if (majorityCollapsed) {
|
|
3824
|
+
expandAllPeers();
|
|
3825
|
+
} else {
|
|
3826
|
+
collapseAllPeers();
|
|
3827
|
+
}
|
|
3828
|
+
syncToggleAllBtn();
|
|
2961
3829
|
});
|
|
3830
|
+
// 暴露给 renderRemoteChannels 渲染后调用 (保持图标跟实际状态一致)
|
|
3831
|
+
window.__syncP2PToggleAllBtn = syncToggleAllBtn;
|
|
3832
|
+
syncToggleAllBtn(); // 首次同步
|
|
2962
3833
|
}
|
|
2963
3834
|
|
|
2964
3835
|
// v3 双向刷新: 主动向所有好友发 agent.meta.list, 拿到 ta 们分享给我的 channel
|
|
@@ -2989,6 +3860,8 @@ if (refreshSharedBtn) {
|
|
|
2989
3860
|
// 启动时拉一次 + 定期轮询 (SSE 接收 P2P reply 后也会更新)
|
|
2990
3861
|
loadRemoteChannels();
|
|
2991
3862
|
setInterval(loadRemoteChannels, 8000);
|
|
3863
|
+
// 全局 SSE — 接收 remote-channel-update / remote-chat-reply / friend-request
|
|
3864
|
+
startV3GlobalSSE();
|
|
2992
3865
|
|
|
2993
3866
|
// ============ v3: 折叠 + 拖拽分隔线 ============
|
|
2994
3867
|
|
|
@@ -3407,19 +4280,23 @@ function openAgentAddModal(existingChannel) {
|
|
|
3407
4280
|
if (existingChannel) {
|
|
3408
4281
|
agentAddTitle.textContent = '配置智能体:' + existingChannel.name;
|
|
3409
4282
|
agentAddName.value = existingChannel.name || '';
|
|
3410
|
-
agentAddName.readOnly =
|
|
4283
|
+
agentAddName.readOnly = false; // v3: 名字改成可编辑 (PATCH 支持更新 name)
|
|
4284
|
+
agentAddName.placeholder = '输入新名称';
|
|
3411
4285
|
agentAddWallet.value = existingChannel.walletAddress || '';
|
|
3412
4286
|
agentAddAutoTools.checked = !!existingChannel.autoInvokeTools;
|
|
3413
4287
|
agentAddConfirmBtn.dataset.mode = 'update';
|
|
3414
4288
|
agentAddConfirmBtn.dataset.channelId = existingChannel.id;
|
|
4289
|
+
agentAddConfirmBtn.dataset.originalName = existingChannel.name || '';
|
|
3415
4290
|
} else {
|
|
3416
4291
|
agentAddTitle.textContent = '添加智能体';
|
|
3417
4292
|
agentAddName.value = '';
|
|
3418
4293
|
agentAddName.readOnly = false;
|
|
4294
|
+
agentAddName.placeholder = '例如: 交易助手';
|
|
3419
4295
|
agentAddWallet.value = '';
|
|
3420
4296
|
agentAddAutoTools.checked = true;
|
|
3421
4297
|
agentAddConfirmBtn.dataset.mode = 'create';
|
|
3422
4298
|
delete agentAddConfirmBtn.dataset.channelId;
|
|
4299
|
+
delete agentAddConfirmBtn.dataset.originalName;
|
|
3423
4300
|
}
|
|
3424
4301
|
agentAddWalletInfo.style.display = 'none';
|
|
3425
4302
|
agentAddWalletInfo.innerHTML = '';
|
|
@@ -3531,13 +4408,16 @@ if (agentAddConfirmBtn) {
|
|
|
3531
4408
|
} else {
|
|
3532
4409
|
// update
|
|
3533
4410
|
const channelId = agentAddConfirmBtn.dataset.channelId;
|
|
4411
|
+
const originalName = agentAddConfirmBtn.dataset.originalName || '';
|
|
4412
|
+
// v3 新增: 名字改了才发 (没改就不发, 保持原状)
|
|
4413
|
+
const body = { walletAddress: walletAddress || null, autoInvokeTools };
|
|
4414
|
+
if (name && name !== originalName) {
|
|
4415
|
+
body.name = name;
|
|
4416
|
+
}
|
|
3534
4417
|
const res = await fetch(`/channels/${channelId}`, {
|
|
3535
4418
|
method: 'PATCH',
|
|
3536
4419
|
headers: { 'Content-Type': 'application/json' },
|
|
3537
|
-
body: JSON.stringify(
|
|
3538
|
-
walletAddress: walletAddress || null,
|
|
3539
|
-
autoInvokeTools
|
|
3540
|
-
})
|
|
4420
|
+
body: JSON.stringify(body)
|
|
3541
4421
|
});
|
|
3542
4422
|
if (!res.ok) throw new Error('update failed');
|
|
3543
4423
|
const updated = await res.json();
|