@bolloon/bolloon-agent 0.1.27 → 0.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/network/iroh-transport.js +21 -4
- package/dist/network/known-peers.js +81 -0
- package/dist/network/p2p-direct.js +151 -0
- package/dist/network/p2p-secret.js +126 -0
- package/dist/web/client.js +430 -42
- package/dist/web/index.html +9 -13
- package/dist/web/server.js +733 -15
- package/package.json +1 -1
- package/src/network/iroh-transport.ts +20 -4
- package/src/network/known-peers.ts +102 -0
- package/src/network/p2p-direct.ts +184 -0
- package/src/network/p2p-secret.ts +153 -0
- package/src/web/client.js +430 -42
- package/src/web/index.html +9 -13
- package/src/web/server.ts +747 -18
package/dist/web/client.js
CHANGED
|
@@ -106,6 +106,82 @@ async function loadChannels() {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// v3: 全局 SSE 监听 (p2p-global channel) - 接收远端 chat.reply 等事件
|
|
110
|
+
let v3GlobalEventSource = null;
|
|
111
|
+
function startV3GlobalSSE() {
|
|
112
|
+
if (v3GlobalEventSource) return;
|
|
113
|
+
try {
|
|
114
|
+
v3GlobalEventSource = new EventSource('/events?channelId=p2p-global');
|
|
115
|
+
v3GlobalEventSource.onmessage = (e) => {
|
|
116
|
+
try {
|
|
117
|
+
const msg = JSON.parse(e.data);
|
|
118
|
+
if (msg.type === 'remote-chat-reply') {
|
|
119
|
+
// 找到当前打开的远端 chat modal 的 log
|
|
120
|
+
const log = document.getElementById('rcm-log');
|
|
121
|
+
const thinkingEl = document.getElementById('rcm-thinking');
|
|
122
|
+
if (thinkingEl) thinkingEl.style.display = 'none'; // 思考结束, 隐藏
|
|
123
|
+
if (log) {
|
|
124
|
+
const bubble = document.createElement('div');
|
|
125
|
+
bubble.style.cssText = 'padding:8px 10px;margin:4px 0;border-radius:6px;font-size:13px;line-height:1.4;max-width:80%;word-wrap:break-word;background:#e5e7eb;color:#111;';
|
|
126
|
+
bubble.textContent = msg.text || '(空回复)';
|
|
127
|
+
if (msg.error) {
|
|
128
|
+
bubble.textContent = '(错误: ' + msg.error + ')';
|
|
129
|
+
bubble.style.background = '#fca5a5';
|
|
130
|
+
}
|
|
131
|
+
log.appendChild(bubble);
|
|
132
|
+
log.scrollTop = log.scrollHeight;
|
|
133
|
+
}
|
|
134
|
+
} else if (msg.type === 'remote-chat-thinking') {
|
|
135
|
+
// v3 新增: B 端实时显示 A 节点的思考过程
|
|
136
|
+
const phase = msg.phase;
|
|
137
|
+
const log = document.getElementById('rcm-log');
|
|
138
|
+
if (!log) return;
|
|
139
|
+
|
|
140
|
+
if (phase === 'start') {
|
|
141
|
+
// 头部插入"判断力依据"区块 (只第一次)
|
|
142
|
+
const judgments = msg.usedJudgments || { bound: [], candidates: [] };
|
|
143
|
+
const judgmentBlock = document.createElement('div');
|
|
144
|
+
judgmentBlock.className = 'rcm-judgment-block';
|
|
145
|
+
judgmentBlock.style.cssText = 'margin:6px 0;padding:8px 10px;background:#fef3c7;border-left:3px solid #f59e0b;border-radius:4px;font-size:12px;';
|
|
146
|
+
let jh = '<div style="font-weight:600;color:#92400e;margin-bottom:4px;">🛡️ 对方使用的判断力 (来自 ta 的 channel)</div>';
|
|
147
|
+
if (judgments.bound && judgments.bound.length > 0) {
|
|
148
|
+
jh += '<div style="color:#78350f;margin-bottom:4px;"><b>硬绑定</b> (必须遵循):</div>';
|
|
149
|
+
for (const j of judgments.bound) {
|
|
150
|
+
jh += `<div style="margin:2px 0;padding-left:8px;">• <b>${escapeHtml((j.decision || '').slice(0, 80))}</b>${j.reasons && j.reasons.length ? '<br><span style="color:#92400e;font-size:11px;">理由: ' + escapeHtml(j.reasons.join('; ').slice(0, 80)) + '</span>' : ''}</div>`;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (judgments.candidates && judgments.candidates.length > 0) {
|
|
154
|
+
jh += `<div style="color:#78350f;margin-top:4px;"><b>候选池</b> (${judgments.candidates.length} 条, LLM 自选)</div>`;
|
|
155
|
+
}
|
|
156
|
+
log.appendChild(judgmentBlock);
|
|
157
|
+
// "思考中" 区块
|
|
158
|
+
const thinkingEl = document.createElement('div');
|
|
159
|
+
thinkingEl.id = 'rcm-thinking-live';
|
|
160
|
+
thinkingEl.style.cssText = 'margin:6px 0;padding:8px 10px;background:#ede9fe;border-left:3px solid #8b5cf6;border-radius:4px;font-size:12px;color:#5b21b6;font-style:italic;';
|
|
161
|
+
thinkingEl.textContent = '💭 对方正在思考...';
|
|
162
|
+
log.appendChild(thinkingEl);
|
|
163
|
+
log.scrollTop = log.scrollHeight;
|
|
164
|
+
} else if (phase === 'token') {
|
|
165
|
+
// 实时更新思考中的 partial
|
|
166
|
+
const thinkingEl = document.getElementById('rcm-thinking-live');
|
|
167
|
+
if (thinkingEl) {
|
|
168
|
+
thinkingEl.textContent = '💭 对方正在思考: ' + (msg.partial || '').slice(-200);
|
|
169
|
+
log.scrollTop = log.scrollHeight;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} catch (err) {
|
|
174
|
+
console.error('[v3] 全局 SSE 解析失败:', err);
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
v3GlobalEventSource.onerror = (e) => {
|
|
178
|
+
console.warn('[v3] 全局 SSE 错误');
|
|
179
|
+
};
|
|
180
|
+
} catch (err) {
|
|
181
|
+
console.error('[v3] 启动全局 SSE 失败:', err);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
109
185
|
async function createChannel(name) {
|
|
110
186
|
if (!name.trim()) return;
|
|
111
187
|
try {
|
|
@@ -2020,12 +2096,6 @@ function hideJudgmentsModal() {
|
|
|
2020
2096
|
if (judgmentsModal) judgmentsModal.classList.remove('active');
|
|
2021
2097
|
}
|
|
2022
2098
|
|
|
2023
|
-
function escapeHtml(s) {
|
|
2024
|
-
return String(s || '').replace(/[&<>"']/g, c => ({
|
|
2025
|
-
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
2026
|
-
}[c]));
|
|
2027
|
-
}
|
|
2028
|
-
|
|
2029
2099
|
let currentJudgmentTab = 'channel'; // 'channel' | 'global'
|
|
2030
2100
|
let lastJudgmentsCache = []; // 最近一次 loadJudgments 拿到的原始列表, 切 tab / 切 channel 时复用
|
|
2031
2101
|
|
|
@@ -2492,15 +2562,17 @@ loadJudgments();
|
|
|
2492
2562
|
setInterval(loadJudgments, 10000);
|
|
2493
2563
|
|
|
2494
2564
|
// ============================================================================
|
|
2495
|
-
// v3:
|
|
2565
|
+
// v3: P2P 好友 (known peers) + 收到的分享
|
|
2496
2566
|
// ============================================================================
|
|
2497
2567
|
|
|
2568
|
+
let knownPeers = []; // { name, publicKey, lastConnectedAt, addedAt }
|
|
2569
|
+
|
|
2498
2570
|
async function loadRemoteChannels() {
|
|
2499
2571
|
try {
|
|
2500
|
-
const res = await fetch('/api/
|
|
2572
|
+
const res = await fetch('/api/p2p-peers');
|
|
2501
2573
|
if (!res.ok) return;
|
|
2502
2574
|
const data = await res.json();
|
|
2503
|
-
|
|
2575
|
+
knownPeers = Array.isArray(data.peers) ? data.peers : [];
|
|
2504
2576
|
renderRemoteChannels();
|
|
2505
2577
|
} catch (err) {
|
|
2506
2578
|
console.error('[v3] loadRemoteChannels 失败:', err);
|
|
@@ -2510,62 +2582,378 @@ async function loadRemoteChannels() {
|
|
|
2510
2582
|
function renderRemoteChannels() {
|
|
2511
2583
|
const list = document.getElementById('remote-channel-list');
|
|
2512
2584
|
if (!list) return;
|
|
2513
|
-
|
|
2514
|
-
|
|
2585
|
+
|
|
2586
|
+
// Phase 3 重做: 好友列表 + 收到的 channel 分组
|
|
2587
|
+
if (knownPeers.length === 0) {
|
|
2588
|
+
list.innerHTML = '<li style="color:var(--text-muted);font-size:11px;padding:8px 4px;text-align:center;">(暂无好友, 点 + 添加)</li>';
|
|
2515
2589
|
return;
|
|
2516
2590
|
}
|
|
2517
|
-
|
|
2518
|
-
|
|
2591
|
+
|
|
2592
|
+
// 按 peerId 分组 channels
|
|
2593
|
+
const channelsByPeer = {};
|
|
2594
|
+
for (const p of remoteChannels) {
|
|
2595
|
+
channelsByPeer[p.peerId] = p.channels || [];
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
const html = knownPeers.map(peer => {
|
|
2599
|
+
const peerChannels = channelsByPeer[peer.publicKey] || [];
|
|
2600
|
+
const lastConn = peer.lastConnectedAt
|
|
2601
|
+
? new Date(peer.lastConnectedAt).toLocaleDateString()
|
|
2602
|
+
: '从未连接';
|
|
2519
2603
|
return `
|
|
2520
|
-
<li class="remote-peer-group" style="margin-bottom:
|
|
2521
|
-
<div
|
|
2522
|
-
|
|
2523
|
-
<span
|
|
2524
|
-
<span>${
|
|
2604
|
+
<li class="remote-peer-group" style="margin-bottom:10px;">
|
|
2605
|
+
<div class="remote-peer-header" data-peer-name="${escapeHtml(peer.name)}" data-peer-pk="${escapeHtml(peer.publicKey)}"
|
|
2606
|
+
style="display:flex;align-items:center;gap:6px;padding:4px 6px;background:var(--bg-hover);border-radius:4px;cursor:pointer;">
|
|
2607
|
+
<span style="font-size:12px;">👤</span>
|
|
2608
|
+
<span style="flex:1;font-size:12px;font-weight:600;" title="${escapeHtml(peer.publicKey)}">${escapeHtml(peer.name)}</span>
|
|
2609
|
+
<span style="font-size:9px;color:var(--text-muted);">${lastConn}</span>
|
|
2610
|
+
</div>
|
|
2611
|
+
<div class="remote-peer-channels" style="margin-top:4px;margin-left:8px;">
|
|
2612
|
+
${peerChannels.length === 0
|
|
2613
|
+
? '<div style="font-size:10px;color:var(--text-muted);padding:2px 4px;">(对方还没分享 channel 给你)</div>'
|
|
2614
|
+
: peerChannels.map(c => `
|
|
2615
|
+
<div class="remote-channel-row" data-peer-id="${escapeHtml(peer.publicKey)}" data-channel-id="${escapeHtml(c.id)}"
|
|
2616
|
+
style="display:flex;align-items:center;gap:6px;padding:4px 6px;cursor:pointer;border-radius:4px;font-size:12px;">
|
|
2617
|
+
<span>🤖</span>
|
|
2618
|
+
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escapeHtml(c.name || '')}">${escapeHtml(c.name || '(未命名)')}</span>
|
|
2619
|
+
<span title="对方 judgment 数 (不会同步到本地)" style="font-size:9px;color:var(--text-muted);">🧠${c.boundJudgmentCount || 0}</span>
|
|
2620
|
+
</div>
|
|
2621
|
+
`).join('')
|
|
2622
|
+
}
|
|
2525
2623
|
</div>
|
|
2526
|
-
${p.channels.map(c => `
|
|
2527
|
-
<div class="remote-channel-row" data-peer-id="${escapeHtml(p.peerId)}" data-channel-id="${escapeHtml(c.id)}"
|
|
2528
|
-
style="display:flex;align-items:center;gap:6px;padding:4px 6px;cursor:pointer;border-radius:4px;font-size:12px;color:var(--text);">
|
|
2529
|
-
<span>🤖</span>
|
|
2530
|
-
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escapeHtml(c.name || '')}">${escapeHtml(c.name || '(未命名)')}</span>
|
|
2531
|
-
${c.hasWallet ? '<span title="绑定钱包">⛓</span>' : ''}
|
|
2532
|
-
<span title="绑定判断力数" style="font-size:10px;color:var(--text-muted);">🧠${c.boundJudgmentCount || 0}</span>
|
|
2533
|
-
</div>
|
|
2534
|
-
`).join('')}
|
|
2535
2624
|
</li>
|
|
2536
2625
|
`;
|
|
2537
2626
|
}).join('');
|
|
2538
2627
|
list.innerHTML = html;
|
|
2539
2628
|
|
|
2540
|
-
//
|
|
2629
|
+
// 绑定: 点击 channel → 弹聊天窗口
|
|
2541
2630
|
list.querySelectorAll('.remote-channel-row').forEach(row => {
|
|
2542
2631
|
row.addEventListener('click', () => {
|
|
2543
2632
|
const peerId = row.dataset.peerId;
|
|
2544
2633
|
const channelId = row.dataset.channelId;
|
|
2634
|
+
const channelName = row.querySelector('span[title]')?.getAttribute('title') || channelId;
|
|
2545
2635
|
console.log('[v3] 点击远端 channel:', peerId.substring(0,12), channelId);
|
|
2546
|
-
|
|
2636
|
+
openRemoteChannelChat(peerId, channelId, channelName);
|
|
2637
|
+
});
|
|
2638
|
+
});
|
|
2639
|
+
// 绑定: 点击 peer 头部 → 弹分享 modal (让 A 决定分享本机哪些 channel 给这个 peer)
|
|
2640
|
+
list.querySelectorAll('.remote-peer-header').forEach(row => {
|
|
2641
|
+
row.addEventListener('click', () => {
|
|
2642
|
+
const peerName = row.dataset.peerName;
|
|
2643
|
+
const peerPk = row.dataset.peerPk;
|
|
2644
|
+
openShareToPeerModal(peerName, peerPk);
|
|
2547
2645
|
});
|
|
2548
2646
|
});
|
|
2549
2647
|
}
|
|
2550
2648
|
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2649
|
+
/** v3: 分享 channel 给指定 peer 的 modal (A 侧用) */
|
|
2650
|
+
async function openShareToPeerModal(peerName, peerPublicKey) {
|
|
2651
|
+
document.getElementById('share-to-peer-modal')?.remove();
|
|
2652
|
+
let allChannels = [];
|
|
2653
|
+
try {
|
|
2654
|
+
const res = await fetch('/channels');
|
|
2655
|
+
if (res.ok) allChannels = await res.json();
|
|
2656
|
+
} catch (err) { console.error('openShareToPeerModal:', err); }
|
|
2657
|
+
const rows = allChannels.length === 0
|
|
2658
|
+
? '<div style="color:#6b7280;padding:12px;text-align:center;">还没有 channel</div>'
|
|
2659
|
+
: allChannels.map(ch => {
|
|
2660
|
+
const isShared = Array.isArray(ch.shared_with_peers) && ch.shared_with_peers.includes(peerPublicKey);
|
|
2661
|
+
return `
|
|
2662
|
+
<label style="display:flex;align-items:flex-start;gap:8px;padding:6px 4px;cursor:pointer;border-bottom:1px solid #f3f4f6;">
|
|
2663
|
+
<input type="checkbox" data-cid="${escapeHtml(ch.id)}" ${isShared ? 'checked' : ''} style="margin-top:4px;cursor:pointer;">
|
|
2664
|
+
<div style="flex:1;min-width:0;">
|
|
2665
|
+
<div style="font-size:13px;font-weight:500;">${escapeHtml(ch.name)}</div>
|
|
2666
|
+
<div style="font-size:10px;color:#9ca3af;margin-top:2px;">
|
|
2667
|
+
${isShared ? '✓ 已分享' : '未分享'} · ${ch.id}
|
|
2668
|
+
</div>
|
|
2669
|
+
</div>
|
|
2670
|
+
</label>
|
|
2671
|
+
`;
|
|
2672
|
+
}).join('');
|
|
2673
|
+
const html = `
|
|
2674
|
+
<div id="share-to-peer-modal" style="position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:10003;display:flex;align-items:center;justify-content:center;">
|
|
2675
|
+
<div style="background:#fff;border-radius:8px;width:480px;max-width:92vw;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 10px 40px rgba(0,0,0,0.2);">
|
|
2676
|
+
<div style="padding:12px 16px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;justify-content:space-between;">
|
|
2677
|
+
<div>
|
|
2678
|
+
<div style="font-size:15px;font-weight:600;">分享 channel 给 ${escapeHtml(peerName)}</div>
|
|
2679
|
+
<div style="font-size:11px;color:#6b7280;margin-top:2px;">${escapeHtml(peerPublicKey.substring(0,16))}…</div>
|
|
2680
|
+
</div>
|
|
2681
|
+
<button id="spm-close" style="background:none;border:none;font-size:20px;color:#6b7280;cursor:pointer;">×</button>
|
|
2682
|
+
</div>
|
|
2683
|
+
<div style="padding:8px 12px;background:#f9fafb;font-size:12px;color:#6b7280;">勾选要分享的 channel, 对方才能看到</div>
|
|
2684
|
+
<div id="spm-list" style="flex:1;overflow-y:auto;padding:8px 16px;">${rows}</div>
|
|
2685
|
+
<div style="padding:12px 20px;border-top:1px solid #e5e7eb;display:flex;justify-content:flex-end;gap:8px;">
|
|
2686
|
+
<button id="spm-cancel" style="padding:6px 14px;border:1px solid #d1d5db;background:#fff;border-radius:4px;cursor:pointer;font-size:13px;">取消</button>
|
|
2687
|
+
<button id="spm-save" style="padding:6px 14px;background:#2563eb;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:13px;">保存分享</button>
|
|
2688
|
+
</div>
|
|
2689
|
+
</div>
|
|
2690
|
+
</div>
|
|
2691
|
+
`;
|
|
2692
|
+
document.body.insertAdjacentHTML('beforeend', html);
|
|
2693
|
+
const overlay = document.getElementById('share-to-peer-modal');
|
|
2694
|
+
document.getElementById('spm-close').onclick = () => overlay.remove();
|
|
2695
|
+
document.getElementById('spm-cancel').onclick = () => overlay.remove();
|
|
2696
|
+
document.getElementById('spm-save').onclick = async () => {
|
|
2697
|
+
const checkedIds = [...overlay.querySelectorAll('input[type=checkbox][data-cid]:checked')].map(el => el.dataset.cid);
|
|
2698
|
+
// 对每个 channel 单独 PATCH — 设 shared_with_peers 为 checked 列表
|
|
2699
|
+
let ok = 0, fail = 0;
|
|
2700
|
+
for (const ch of allChannels) {
|
|
2701
|
+
const shouldShare = checkedIds.includes(ch.id);
|
|
2702
|
+
const wasShared = Array.isArray(ch.shared_with_peers) && ch.shared_with_peers.includes(peerPublicKey);
|
|
2703
|
+
if (shouldShare === wasShared) continue; // 没变化跳过
|
|
2704
|
+
const newList = (ch.shared_with_peers || []).filter((p) => p !== peerPublicKey);
|
|
2705
|
+
if (shouldShare) newList.push(peerPublicKey);
|
|
2706
|
+
try {
|
|
2707
|
+
const res = await fetch(`/channels/${encodeURIComponent(ch.id)}`, {
|
|
2708
|
+
method: 'PATCH',
|
|
2709
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2710
|
+
body: JSON.stringify({ shared_with_peers: newList })
|
|
2711
|
+
});
|
|
2712
|
+
if (res.ok) ok++; else fail++;
|
|
2713
|
+
} catch { fail++; }
|
|
2714
|
+
}
|
|
2715
|
+
alert(`分享更新完成: 成功 ${ok}, 失败 ${fail}`);
|
|
2716
|
+
overlay.remove();
|
|
2717
|
+
};
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
/** v3: 跟远端 channel 聊天的简易弹窗 */
|
|
2721
|
+
function openRemoteChannelChat(peerPublicKey, channelId, channelName) {
|
|
2722
|
+
// 移除已有 modal
|
|
2723
|
+
document.getElementById('remote-chat-modal')?.remove();
|
|
2724
|
+
const html = `
|
|
2725
|
+
<div id="remote-chat-modal" style="position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10002;display:flex;align-items:center;justify-content:center;">
|
|
2726
|
+
<div style="background:#fff;border-radius:8px;width:560px;max-width:92vw;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 10px 40px rgba(0,0,0,0.2);">
|
|
2727
|
+
<div style="padding:12px 16px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;justify-content:space-between;">
|
|
2728
|
+
<div style="flex:1;min-width:0;">
|
|
2729
|
+
<div style="font-size:15px;font-weight:600;">跟 ${escapeHtml(channelName)} 聊天</div>
|
|
2730
|
+
<div style="font-size:11px;color:#6b7280;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">远端 peer: ${escapeHtml(peerPublicKey.substring(0,16))}… · ${escapeHtml(channelId)}</div>
|
|
2731
|
+
</div>
|
|
2732
|
+
<button id="rcm-refresh-history" title="重新拉历史" style="background:none;border:1px solid var(--border);color:#6b7280;cursor:pointer;padding:2px 8px;border-radius:4px;font-size:11px;margin-right:6px;">↻ 历史</button>
|
|
2733
|
+
<button id="rcm-close" style="background:none;border:none;font-size:20px;color:#6b7280;cursor:pointer;">×</button>
|
|
2734
|
+
</div>
|
|
2735
|
+
<div id="rcm-thinking" style="display:none;padding:8px 16px;background:#fef3c7;color:#92400e;font-size:12px;border-bottom:1px solid #e5e7eb;">
|
|
2736
|
+
📥 正在从远端拉历史 + 判断力…
|
|
2737
|
+
</div>
|
|
2738
|
+
<div id="rcm-log" style="flex:1;overflow-y:auto;padding:12px 16px;min-height:240px;max-height:60vh;background:#f9fafb;"></div>
|
|
2739
|
+
<div style="padding:10px 12px;border-top:1px solid #e5e7eb;display:flex;gap:6px;">
|
|
2740
|
+
<input id="rcm-input" type="text" placeholder="输入消息, 发送到远端 channel..."
|
|
2741
|
+
style="flex:1;padding:8px 10px;border:1px solid #d1d5db;border-radius:4px;font-size:13px;">
|
|
2742
|
+
<button id="rcm-send" style="padding:8px 14px;background:#2563eb;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:13px;">发送</button>
|
|
2743
|
+
</div>
|
|
2744
|
+
</div>
|
|
2745
|
+
</div>
|
|
2746
|
+
`;
|
|
2747
|
+
document.body.insertAdjacentHTML('beforeend', html);
|
|
2748
|
+
|
|
2749
|
+
const log = document.getElementById('rcm-log');
|
|
2750
|
+
const inputEl = document.getElementById('rcm-input');
|
|
2751
|
+
const sendBtn = document.getElementById('rcm-send');
|
|
2752
|
+
const thinkingEl = document.getElementById('rcm-thinking');
|
|
2753
|
+
document.getElementById('rcm-close').onclick = () => document.getElementById('remote-chat-modal').remove();
|
|
2754
|
+
document.getElementById('rcm-refresh-history').onclick = () => loadHistory();
|
|
2755
|
+
|
|
2756
|
+
const append = (text, role) => {
|
|
2757
|
+
const bubble = document.createElement('div');
|
|
2758
|
+
const isUser = role === 'user';
|
|
2759
|
+
bubble.style.cssText = `padding:8px 10px;margin:4px 0;border-radius:6px;font-size:13px;line-height:1.4;max-width:80%;word-wrap:break-word;${
|
|
2760
|
+
isUser ? 'background:#2563eb;color:#fff;margin-left:auto;text-align:left;'
|
|
2761
|
+
: 'background:#e5e7eb;color:#111;'
|
|
2762
|
+
}`;
|
|
2763
|
+
bubble.textContent = text;
|
|
2764
|
+
log.appendChild(bubble);
|
|
2765
|
+
log.scrollTop = log.scrollHeight;
|
|
2766
|
+
};
|
|
2767
|
+
|
|
2768
|
+
const appendSystem = (text, kind = 'info') => {
|
|
2769
|
+
const el = document.createElement('div');
|
|
2770
|
+
const colors = {
|
|
2771
|
+
info: { bg: '#dbeafe', fg: '#1e40af' },
|
|
2772
|
+
warn: { bg: '#fef3c7', fg: '#92400e' },
|
|
2773
|
+
error: { bg: '#fca5a5', fg: '#7f1d1d' }
|
|
2774
|
+
};
|
|
2775
|
+
const c = colors[kind] || colors.info;
|
|
2776
|
+
el.style.cssText = `margin:6px 0;padding:6px 10px;background:${c.bg};color:${c.fg};border-radius:4px;font-size:11px;text-align:center;`;
|
|
2777
|
+
el.textContent = text;
|
|
2778
|
+
log.appendChild(el);
|
|
2779
|
+
log.scrollTop = log.scrollHeight;
|
|
2780
|
+
};
|
|
2781
|
+
|
|
2782
|
+
// v3 新增: 拉 A 端的 channel 历史 (含 messages + judgments)
|
|
2783
|
+
async function loadHistory() {
|
|
2784
|
+
thinkingEl.style.display = 'block';
|
|
2785
|
+
log.innerHTML = '';
|
|
2786
|
+
appendSystem('正在拉取远端 channel 的历史和判断力...', 'info');
|
|
2787
|
+
try {
|
|
2788
|
+
const res = await fetch(`/api/remote-channels/chat-history?targetPublicKey=${encodeURIComponent(peerPublicKey)}&channelId=${encodeURIComponent(channelId)}`);
|
|
2789
|
+
const data = await res.json();
|
|
2790
|
+
if (!res.ok) {
|
|
2791
|
+
appendSystem(`拉取失败: ${data.error || 'unknown'}`, 'error');
|
|
2792
|
+
thinkingEl.style.display = 'none';
|
|
2793
|
+
return;
|
|
2794
|
+
}
|
|
2795
|
+
// 清掉 loading
|
|
2796
|
+
log.innerHTML = '';
|
|
2797
|
+
|
|
2798
|
+
// 1. 显示 judgment 依据 (header)
|
|
2799
|
+
const judgments = data.judgments || { bound: [], candidates: [] };
|
|
2800
|
+
if (judgments.bound && judgments.bound.length > 0) {
|
|
2801
|
+
const jh = document.createElement('div');
|
|
2802
|
+
jh.style.cssText = 'margin:0 0 8px;padding:8px 10px;background:#fef3c7;border-left:3px solid #f59e0b;border-radius:4px;font-size:12px;';
|
|
2803
|
+
let h = `<div style="font-weight:600;color:#92400e;margin-bottom:4px;">🛡️ 对方 channel 绑定的判断力 (${judgments.bound.length} 条硬约束)</div>`;
|
|
2804
|
+
for (const j of judgments.bound) {
|
|
2805
|
+
h += `<div style="margin:3px 0;padding-left:8px;">• <b>${escapeHtml((j.decision || '').slice(0, 100))}</b>${j.domain ? `<span style="color:#a16207;font-size:10px;"> [${escapeHtml(j.domain)}${j.stakes ? '/' + escapeHtml(j.stakes) : ''}]</span>` : ''}${j.reasons && j.reasons.length ? '<br><span style="color:#92400e;font-size:11px;">理由: ' + escapeHtml(j.reasons.join('; ').slice(0, 100)) + '</span>' : ''}</div>`;
|
|
2806
|
+
}
|
|
2807
|
+
if (judgments.candidates && judgments.candidates.length > 0) {
|
|
2808
|
+
h += `<div style="margin-top:6px;color:#92400e;font-size:11px;">+ ${judgments.candidates.length} 条候选判断力 (LLM 可自选参考)</div>`;
|
|
2809
|
+
}
|
|
2810
|
+
jh.innerHTML = h;
|
|
2811
|
+
log.appendChild(jh);
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
// 2. 显示历史 messages
|
|
2815
|
+
const msgs = data.messages || [];
|
|
2816
|
+
if (msgs.length === 0) {
|
|
2817
|
+
appendSystem('还没有历史消息, 在下面发第一条吧', 'info');
|
|
2818
|
+
} else {
|
|
2819
|
+
appendSystem(`从远端拉到 ${msgs.length} 条历史消息`, 'info');
|
|
2820
|
+
for (const m of msgs) {
|
|
2821
|
+
append(m.content || '', m.type === 'user' ? 'user' : 'ai');
|
|
2822
|
+
}
|
|
2823
|
+
// 滚到底部
|
|
2824
|
+
setTimeout(() => { log.scrollTop = log.scrollHeight; }, 50);
|
|
2825
|
+
}
|
|
2826
|
+
} catch (err) {
|
|
2827
|
+
appendSystem(`拉取异常: ${err.message}`, 'error');
|
|
2828
|
+
} finally {
|
|
2829
|
+
thinkingEl.style.display = 'none';
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
const doSend = async () => {
|
|
2834
|
+
const text = inputEl.value.trim();
|
|
2835
|
+
if (!text) return;
|
|
2836
|
+
append(text, 'user');
|
|
2837
|
+
inputEl.value = '';
|
|
2838
|
+
sendBtn.disabled = true;
|
|
2839
|
+
sendBtn.textContent = '...';
|
|
2840
|
+
try {
|
|
2841
|
+
const res = await fetch('/api/remote-channels/chat-send', {
|
|
2842
|
+
method: 'POST',
|
|
2843
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2844
|
+
body: JSON.stringify({ targetPublicKey: peerPublicKey, channelId, text })
|
|
2845
|
+
});
|
|
2846
|
+
const data = await res.json();
|
|
2847
|
+
if (!res.ok) throw new Error(data.error || 'send failed');
|
|
2848
|
+
appendSystem('已发送, 等待对方回复...', 'info');
|
|
2849
|
+
} catch (err) {
|
|
2850
|
+
append('(发送失败: ' + (err.message || err) + ')', 'ai');
|
|
2851
|
+
} finally {
|
|
2852
|
+
sendBtn.disabled = false;
|
|
2853
|
+
sendBtn.textContent = '发送';
|
|
2854
|
+
}
|
|
2855
|
+
};
|
|
2856
|
+
sendBtn.onclick = doSend;
|
|
2857
|
+
inputEl.onkeydown = (e) => { if (e.key === 'Enter') doSend(); };
|
|
2858
|
+
inputEl.focus();
|
|
2859
|
+
startV3GlobalSSE();
|
|
2860
|
+
|
|
2861
|
+
// 打开时立即拉历史
|
|
2862
|
+
loadHistory();
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
// Phase 3: 我的 ID 按钮 → 弹窗显示并支持复制自己的 P2PDirect publicKey
|
|
2866
|
+
const showMyIdBtn = document.getElementById('show-my-p2p-id-btn');
|
|
2867
|
+
if (showMyIdBtn) {
|
|
2868
|
+
showMyIdBtn.addEventListener('click', async (e) => {
|
|
2869
|
+
e.stopPropagation();
|
|
2870
|
+
try {
|
|
2871
|
+
const res = await fetch('/api/p2p-publickey');
|
|
2872
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
2873
|
+
const data = await res.json();
|
|
2874
|
+
const pk = data.publicKey || '';
|
|
2875
|
+
if (!pk || pk.length !== 64) {
|
|
2876
|
+
alert('P2PDirect 还没启动, 刷新页面稍后再试');
|
|
2877
|
+
return;
|
|
2878
|
+
}
|
|
2879
|
+
// 显示 + 一键复制
|
|
2880
|
+
const ok = confirm(
|
|
2881
|
+
`我的 P2P publicKey (64 字符 hex):\n\n${pk}\n\n` +
|
|
2882
|
+
`点 "确定" 复制到剪贴板, 发给好友.\n` +
|
|
2883
|
+
`好友点 "+ 好友" 粘贴这个 ID 就能加我.`
|
|
2884
|
+
);
|
|
2885
|
+
if (ok) {
|
|
2886
|
+
try {
|
|
2887
|
+
await navigator.clipboard.writeText(pk);
|
|
2888
|
+
alert('✓ 已复制到剪贴板');
|
|
2889
|
+
} catch {
|
|
2890
|
+
// 旧浏览器 fallback
|
|
2891
|
+
const ta = document.createElement('textarea');
|
|
2892
|
+
ta.value = pk;
|
|
2893
|
+
document.body.appendChild(ta);
|
|
2894
|
+
ta.select();
|
|
2895
|
+
document.execCommand('copy');
|
|
2896
|
+
document.body.removeChild(ta);
|
|
2897
|
+
alert('✓ 已复制到剪贴板 (fallback)');
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
} catch (err) {
|
|
2901
|
+
alert('获取 publicKey 失败: ' + (err.message || err));
|
|
2902
|
+
}
|
|
2903
|
+
});
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
// Phase 3 重做: + 添加好友按钮 → 弹窗输入 publicKey + name, 同时 joinPeer
|
|
2907
|
+
const addPeerBtn = document.getElementById('add-p2p-peer-btn');
|
|
2908
|
+
if (addPeerBtn) {
|
|
2909
|
+
addPeerBtn.addEventListener('click', async (e) => {
|
|
2910
|
+
e.stopPropagation();
|
|
2911
|
+
const name = prompt('给这个 P2P 好友起个名字 (如: 同事-张磊)');
|
|
2912
|
+
if (!name) return;
|
|
2913
|
+
const publicKey = prompt('粘贴对方的 P2PDirect publicKey (64 字符 hex):\n\n获取方式: 对方在 http://localhost:54188/api/p2p-publickey');
|
|
2914
|
+
if (!publicKey) return;
|
|
2915
|
+
if (publicKey.length !== 64) {
|
|
2916
|
+
alert('publicKey 长度不对, 应该是 64 字符 hex');
|
|
2917
|
+
return;
|
|
2918
|
+
}
|
|
2919
|
+
try {
|
|
2920
|
+
// 调 p2p-connect (会 joinPeer + 持久化)
|
|
2921
|
+
const res = await fetch('/api/remote-channels/p2p-connect', {
|
|
2922
|
+
method: 'POST',
|
|
2923
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2924
|
+
body: JSON.stringify({ targetPublicKey: publicKey, name, persist: true })
|
|
2925
|
+
});
|
|
2926
|
+
const data = await res.json();
|
|
2927
|
+
if (!res.ok) throw new Error(data.error || 'connect failed');
|
|
2928
|
+
alert(`已添加好友: ${name} (${publicKey.substring(0, 12)}...)\n重启后会自动重连`);
|
|
2929
|
+
await loadRemoteChannels();
|
|
2930
|
+
} catch (err) {
|
|
2931
|
+
alert('添加失败: ' + (err.message || err));
|
|
2932
|
+
}
|
|
2933
|
+
});
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
// v3 双向刷新: 主动向所有好友发 agent.meta.list, 拿到 ta 们分享给我的 channel
|
|
2937
|
+
const refreshSharedBtn = document.getElementById('refresh-shared-btn');
|
|
2938
|
+
if (refreshSharedBtn) {
|
|
2939
|
+
refreshSharedBtn.addEventListener('click', async (e) => {
|
|
2940
|
+
e.stopPropagation();
|
|
2941
|
+
const originalText = refreshSharedBtn.textContent;
|
|
2942
|
+
refreshSharedBtn.disabled = true;
|
|
2943
|
+
refreshSharedBtn.textContent = '...';
|
|
2557
2944
|
try {
|
|
2558
2945
|
const res = await fetch('/api/remote-channels/refresh', { method: 'POST' });
|
|
2559
2946
|
const data = await res.json();
|
|
2560
|
-
|
|
2561
|
-
|
|
2947
|
+
if (!res.ok) throw new Error(data.error || 'refresh failed');
|
|
2948
|
+
// 等 1.5s 让 RPC 回复回来 (向所有 peer 广播)
|
|
2949
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
2950
|
+
await loadRemoteChannels();
|
|
2951
|
+
console.log(`[v3] 双向刷新: 向 ${data.peerCount || 0} 个好友发 list 请求`);
|
|
2562
2952
|
} catch (err) {
|
|
2563
|
-
|
|
2953
|
+
alert('刷新失败: ' + (err.message || err));
|
|
2564
2954
|
} finally {
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
refreshRemoteBtn.textContent = '↻ 刷新';
|
|
2568
|
-
}, 2000);
|
|
2955
|
+
refreshSharedBtn.disabled = false;
|
|
2956
|
+
refreshSharedBtn.textContent = originalText;
|
|
2569
2957
|
}
|
|
2570
2958
|
});
|
|
2571
2959
|
}
|
package/dist/web/index.html
CHANGED
|
@@ -69,17 +69,21 @@
|
|
|
69
69
|
<div class="split-handle-grip"></div>
|
|
70
70
|
</div>
|
|
71
71
|
|
|
72
|
-
<!-- v3
|
|
72
|
+
<!-- v3 远端 P2P 好友 — 显式分享 + 持久化连接 -->
|
|
73
73
|
<div class="sidebar-section" id="remote-agents-section">
|
|
74
74
|
<div class="section-header" id="remote-agents-header" style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;cursor:pointer;user-select:none;">
|
|
75
75
|
<span style="display:flex;align-items:center;gap:6px;">
|
|
76
76
|
<span id="remote-agents-toggle" style="font-size:10px;color:var(--text-muted);transition:transform 0.2s;">▼</span>
|
|
77
|
-
<span class="section-title" style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);"
|
|
77
|
+
<span class="section-title" style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-muted);">P2P 好友</span>
|
|
78
|
+
</span>
|
|
79
|
+
<span style="display:flex;gap:4px;">
|
|
80
|
+
<button id="show-my-p2p-id-btn" title="查看并复制我的 P2P publicKey" style="background:none;border:1px solid var(--border);color:var(--text-secondary);cursor:pointer;padding:2px 6px;border-radius:4px;font-size:11px;line-height:1;">我的 ID</button>
|
|
81
|
+
<button id="refresh-shared-btn" title="向所有好友拉取最新分享列表 (双向: A 拉 B 的, B 拉 A 的)" style="background:none;border:1px solid var(--border);color:var(--text-secondary);cursor:pointer;padding:2px 6px;border-radius:4px;font-size:11px;line-height:1;">↻ 刷新</button>
|
|
82
|
+
<button id="add-p2p-peer-btn" title="添加 P2P 好友 (输入对方的 publicKey)" style="background:none;border:1px solid var(--border);color:var(--text-secondary);cursor:pointer;padding:2px 6px;border-radius:4px;font-size:11px;line-height:1;">+ 好友</button>
|
|
78
83
|
</span>
|
|
79
|
-
<button id="refresh-remote-agents-btn" title="从已连接 P2P peer 拉取" style="background:none;border:1px solid var(--border);color:var(--text-secondary);cursor:pointer;padding:2px 6px;border-radius:4px;font-size:11px;line-height:1;">↻ 刷新</button>
|
|
80
84
|
</div>
|
|
81
85
|
<ul class="channel-list" id="remote-channel-list" style="list-style:none;padding:0;margin:0;">
|
|
82
|
-
<li style="color:var(--text-muted);font-size:11px;padding:8px 4px;text-align:center;">(
|
|
86
|
+
<li style="color:var(--text-muted);font-size:11px;padding:8px 4px;text-align:center;">(暂无好友, 点 + 添加)</li>
|
|
83
87
|
</ul>
|
|
84
88
|
</div>
|
|
85
89
|
|
|
@@ -120,15 +124,7 @@
|
|
|
120
124
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
|
121
125
|
</svg>
|
|
122
126
|
</button>
|
|
123
|
-
|
|
124
|
-
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
125
|
-
<circle cx="12" cy="5" r="3"></circle>
|
|
126
|
-
<circle cx="5" cy="19" r="3"></circle>
|
|
127
|
-
<circle cx="19" cy="19" r="3"></circle>
|
|
128
|
-
<line x1="12" y1="8" x2="5" y2="16"></line>
|
|
129
|
-
<line x1="12" y1="8" x2="19" y2="16"></line>
|
|
130
|
-
</svg>
|
|
131
|
-
</button>
|
|
127
|
+
<!-- v3: 顶栏 P2P 按钮 (旧 iroh/Diap 路径) 已移除, 改用侧边栏 "P2P 好友" → "+ 好友" -->
|
|
132
128
|
<button id="wallet-btn" class="header-action" title="钱包管理">
|
|
133
129
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
134
130
|
<path d="M21 12V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-3"></path>
|