@bolloon/bolloon-agent 0.1.27 → 0.1.28
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 +290 -43
- package/dist/web/index.html +8 -13
- package/dist/web/server.js +486 -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 +290 -43
- package/src/web/index.html +8 -13
- package/src/web/server.ts +488 -18
package/src/web/client.js
CHANGED
|
@@ -106,6 +106,42 @@ 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
|
+
if (log) {
|
|
122
|
+
const bubble = document.createElement('div');
|
|
123
|
+
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;';
|
|
124
|
+
bubble.textContent = msg.text || '(空回复)';
|
|
125
|
+
if (msg.error) {
|
|
126
|
+
bubble.textContent = '(错误: ' + msg.error + ')';
|
|
127
|
+
bubble.style.background = '#fca5a5';
|
|
128
|
+
}
|
|
129
|
+
log.appendChild(bubble);
|
|
130
|
+
log.scrollTop = log.scrollHeight;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} catch (err) {
|
|
134
|
+
console.error('[v3] 全局 SSE 解析失败:', err);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
v3GlobalEventSource.onerror = (e) => {
|
|
138
|
+
console.warn('[v3] 全局 SSE 错误');
|
|
139
|
+
};
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.error('[v3] 启动全局 SSE 失败:', err);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
109
145
|
async function createChannel(name) {
|
|
110
146
|
if (!name.trim()) return;
|
|
111
147
|
try {
|
|
@@ -2020,12 +2056,6 @@ function hideJudgmentsModal() {
|
|
|
2020
2056
|
if (judgmentsModal) judgmentsModal.classList.remove('active');
|
|
2021
2057
|
}
|
|
2022
2058
|
|
|
2023
|
-
function escapeHtml(s) {
|
|
2024
|
-
return String(s || '').replace(/[&<>"']/g, c => ({
|
|
2025
|
-
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
2026
|
-
}[c]));
|
|
2027
|
-
}
|
|
2028
|
-
|
|
2029
2059
|
let currentJudgmentTab = 'channel'; // 'channel' | 'global'
|
|
2030
2060
|
let lastJudgmentsCache = []; // 最近一次 loadJudgments 拿到的原始列表, 切 tab / 切 channel 时复用
|
|
2031
2061
|
|
|
@@ -2492,15 +2522,17 @@ loadJudgments();
|
|
|
2492
2522
|
setInterval(loadJudgments, 10000);
|
|
2493
2523
|
|
|
2494
2524
|
// ============================================================================
|
|
2495
|
-
// v3:
|
|
2525
|
+
// v3: P2P 好友 (known peers) + 收到的分享
|
|
2496
2526
|
// ============================================================================
|
|
2497
2527
|
|
|
2528
|
+
let knownPeers = []; // { name, publicKey, lastConnectedAt, addedAt }
|
|
2529
|
+
|
|
2498
2530
|
async function loadRemoteChannels() {
|
|
2499
2531
|
try {
|
|
2500
|
-
const res = await fetch('/api/
|
|
2532
|
+
const res = await fetch('/api/p2p-peers');
|
|
2501
2533
|
if (!res.ok) return;
|
|
2502
2534
|
const data = await res.json();
|
|
2503
|
-
|
|
2535
|
+
knownPeers = Array.isArray(data.peers) ? data.peers : [];
|
|
2504
2536
|
renderRemoteChannels();
|
|
2505
2537
|
} catch (err) {
|
|
2506
2538
|
console.error('[v3] loadRemoteChannels 失败:', err);
|
|
@@ -2510,62 +2542,277 @@ async function loadRemoteChannels() {
|
|
|
2510
2542
|
function renderRemoteChannels() {
|
|
2511
2543
|
const list = document.getElementById('remote-channel-list');
|
|
2512
2544
|
if (!list) return;
|
|
2513
|
-
|
|
2514
|
-
|
|
2545
|
+
|
|
2546
|
+
// Phase 3 重做: 好友列表 + 收到的 channel 分组
|
|
2547
|
+
if (knownPeers.length === 0) {
|
|
2548
|
+
list.innerHTML = '<li style="color:var(--text-muted);font-size:11px;padding:8px 4px;text-align:center;">(暂无好友, 点 + 添加)</li>';
|
|
2515
2549
|
return;
|
|
2516
2550
|
}
|
|
2517
|
-
|
|
2518
|
-
|
|
2551
|
+
|
|
2552
|
+
// 按 peerId 分组 channels
|
|
2553
|
+
const channelsByPeer = {};
|
|
2554
|
+
for (const p of remoteChannels) {
|
|
2555
|
+
channelsByPeer[p.peerId] = p.channels || [];
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
const html = knownPeers.map(peer => {
|
|
2559
|
+
const peerChannels = channelsByPeer[peer.publicKey] || [];
|
|
2560
|
+
const lastConn = peer.lastConnectedAt
|
|
2561
|
+
? new Date(peer.lastConnectedAt).toLocaleDateString()
|
|
2562
|
+
: '从未连接';
|
|
2519
2563
|
return `
|
|
2520
|
-
<li class="remote-peer-group" style="margin-bottom:
|
|
2521
|
-
<div
|
|
2522
|
-
|
|
2523
|
-
<span
|
|
2524
|
-
<span>${
|
|
2564
|
+
<li class="remote-peer-group" style="margin-bottom:10px;">
|
|
2565
|
+
<div class="remote-peer-header" data-peer-name="${escapeHtml(peer.name)}" data-peer-pk="${escapeHtml(peer.publicKey)}"
|
|
2566
|
+
style="display:flex;align-items:center;gap:6px;padding:4px 6px;background:var(--bg-hover);border-radius:4px;cursor:pointer;">
|
|
2567
|
+
<span style="font-size:12px;">👤</span>
|
|
2568
|
+
<span style="flex:1;font-size:12px;font-weight:600;" title="${escapeHtml(peer.publicKey)}">${escapeHtml(peer.name)}</span>
|
|
2569
|
+
<span style="font-size:9px;color:var(--text-muted);">${lastConn}</span>
|
|
2570
|
+
</div>
|
|
2571
|
+
<div class="remote-peer-channels" style="margin-top:4px;margin-left:8px;">
|
|
2572
|
+
${peerChannels.length === 0
|
|
2573
|
+
? '<div style="font-size:10px;color:var(--text-muted);padding:2px 4px;">(对方还没分享 channel 给你)</div>'
|
|
2574
|
+
: peerChannels.map(c => `
|
|
2575
|
+
<div class="remote-channel-row" data-peer-id="${escapeHtml(peer.publicKey)}" data-channel-id="${escapeHtml(c.id)}"
|
|
2576
|
+
style="display:flex;align-items:center;gap:6px;padding:4px 6px;cursor:pointer;border-radius:4px;font-size:12px;">
|
|
2577
|
+
<span>🤖</span>
|
|
2578
|
+
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escapeHtml(c.name || '')}">${escapeHtml(c.name || '(未命名)')}</span>
|
|
2579
|
+
<span title="对方 judgment 数 (不会同步到本地)" style="font-size:9px;color:var(--text-muted);">🧠${c.boundJudgmentCount || 0}</span>
|
|
2580
|
+
</div>
|
|
2581
|
+
`).join('')
|
|
2582
|
+
}
|
|
2525
2583
|
</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
2584
|
</li>
|
|
2536
2585
|
`;
|
|
2537
2586
|
}).join('');
|
|
2538
2587
|
list.innerHTML = html;
|
|
2539
2588
|
|
|
2540
|
-
//
|
|
2589
|
+
// 绑定: 点击 channel → 弹聊天窗口
|
|
2541
2590
|
list.querySelectorAll('.remote-channel-row').forEach(row => {
|
|
2542
2591
|
row.addEventListener('click', () => {
|
|
2543
2592
|
const peerId = row.dataset.peerId;
|
|
2544
2593
|
const channelId = row.dataset.channelId;
|
|
2594
|
+
const channelName = row.querySelector('span[title]')?.getAttribute('title') || channelId;
|
|
2545
2595
|
console.log('[v3] 点击远端 channel:', peerId.substring(0,12), channelId);
|
|
2546
|
-
|
|
2596
|
+
openRemoteChannelChat(peerId, channelId, channelName);
|
|
2547
2597
|
});
|
|
2548
2598
|
});
|
|
2599
|
+
// 绑定: 点击 peer 头部 → 弹分享 modal (让 A 决定分享本机哪些 channel 给这个 peer)
|
|
2600
|
+
list.querySelectorAll('.remote-peer-header').forEach(row => {
|
|
2601
|
+
row.addEventListener('click', () => {
|
|
2602
|
+
const peerName = row.dataset.peerName;
|
|
2603
|
+
const peerPk = row.dataset.peerPk;
|
|
2604
|
+
openShareToPeerModal(peerName, peerPk);
|
|
2605
|
+
});
|
|
2606
|
+
});
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
/** v3: 分享 channel 给指定 peer 的 modal (A 侧用) */
|
|
2610
|
+
async function openShareToPeerModal(peerName, peerPublicKey) {
|
|
2611
|
+
document.getElementById('share-to-peer-modal')?.remove();
|
|
2612
|
+
let allChannels = [];
|
|
2613
|
+
try {
|
|
2614
|
+
const res = await fetch('/channels');
|
|
2615
|
+
if (res.ok) allChannels = await res.json();
|
|
2616
|
+
} catch (err) { console.error('openShareToPeerModal:', err); }
|
|
2617
|
+
const rows = allChannels.length === 0
|
|
2618
|
+
? '<div style="color:#6b7280;padding:12px;text-align:center;">还没有 channel</div>'
|
|
2619
|
+
: allChannels.map(ch => {
|
|
2620
|
+
const isShared = Array.isArray(ch.shared_with_peers) && ch.shared_with_peers.includes(peerPublicKey);
|
|
2621
|
+
return `
|
|
2622
|
+
<label style="display:flex;align-items:flex-start;gap:8px;padding:6px 4px;cursor:pointer;border-bottom:1px solid #f3f4f6;">
|
|
2623
|
+
<input type="checkbox" data-cid="${escapeHtml(ch.id)}" ${isShared ? 'checked' : ''} style="margin-top:4px;cursor:pointer;">
|
|
2624
|
+
<div style="flex:1;min-width:0;">
|
|
2625
|
+
<div style="font-size:13px;font-weight:500;">${escapeHtml(ch.name)}</div>
|
|
2626
|
+
<div style="font-size:10px;color:#9ca3af;margin-top:2px;">
|
|
2627
|
+
${isShared ? '✓ 已分享' : '未分享'} · ${ch.id}
|
|
2628
|
+
</div>
|
|
2629
|
+
</div>
|
|
2630
|
+
</label>
|
|
2631
|
+
`;
|
|
2632
|
+
}).join('');
|
|
2633
|
+
const html = `
|
|
2634
|
+
<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;">
|
|
2635
|
+
<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);">
|
|
2636
|
+
<div style="padding:12px 16px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;justify-content:space-between;">
|
|
2637
|
+
<div>
|
|
2638
|
+
<div style="font-size:15px;font-weight:600;">分享 channel 给 ${escapeHtml(peerName)}</div>
|
|
2639
|
+
<div style="font-size:11px;color:#6b7280;margin-top:2px;">${escapeHtml(peerPublicKey.substring(0,16))}…</div>
|
|
2640
|
+
</div>
|
|
2641
|
+
<button id="spm-close" style="background:none;border:none;font-size:20px;color:#6b7280;cursor:pointer;">×</button>
|
|
2642
|
+
</div>
|
|
2643
|
+
<div style="padding:8px 12px;background:#f9fafb;font-size:12px;color:#6b7280;">勾选要分享的 channel, 对方才能看到</div>
|
|
2644
|
+
<div id="spm-list" style="flex:1;overflow-y:auto;padding:8px 16px;">${rows}</div>
|
|
2645
|
+
<div style="padding:12px 20px;border-top:1px solid #e5e7eb;display:flex;justify-content:flex-end;gap:8px;">
|
|
2646
|
+
<button id="spm-cancel" style="padding:6px 14px;border:1px solid #d1d5db;background:#fff;border-radius:4px;cursor:pointer;font-size:13px;">取消</button>
|
|
2647
|
+
<button id="spm-save" style="padding:6px 14px;background:#2563eb;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:13px;">保存分享</button>
|
|
2648
|
+
</div>
|
|
2649
|
+
</div>
|
|
2650
|
+
</div>
|
|
2651
|
+
`;
|
|
2652
|
+
document.body.insertAdjacentHTML('beforeend', html);
|
|
2653
|
+
const overlay = document.getElementById('share-to-peer-modal');
|
|
2654
|
+
document.getElementById('spm-close').onclick = () => overlay.remove();
|
|
2655
|
+
document.getElementById('spm-cancel').onclick = () => overlay.remove();
|
|
2656
|
+
document.getElementById('spm-save').onclick = async () => {
|
|
2657
|
+
const checkedIds = [...overlay.querySelectorAll('input[type=checkbox][data-cid]:checked')].map(el => el.dataset.cid);
|
|
2658
|
+
// 对每个 channel 单独 PATCH — 设 shared_with_peers 为 checked 列表
|
|
2659
|
+
let ok = 0, fail = 0;
|
|
2660
|
+
for (const ch of allChannels) {
|
|
2661
|
+
const shouldShare = checkedIds.includes(ch.id);
|
|
2662
|
+
const wasShared = Array.isArray(ch.shared_with_peers) && ch.shared_with_peers.includes(peerPublicKey);
|
|
2663
|
+
if (shouldShare === wasShared) continue; // 没变化跳过
|
|
2664
|
+
const newList = (ch.shared_with_peers || []).filter((p) => p !== peerPublicKey);
|
|
2665
|
+
if (shouldShare) newList.push(peerPublicKey);
|
|
2666
|
+
try {
|
|
2667
|
+
const res = await fetch(`/channels/${encodeURIComponent(ch.id)}`, {
|
|
2668
|
+
method: 'PATCH',
|
|
2669
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2670
|
+
body: JSON.stringify({ shared_with_peers: newList })
|
|
2671
|
+
});
|
|
2672
|
+
if (res.ok) ok++; else fail++;
|
|
2673
|
+
} catch { fail++; }
|
|
2674
|
+
}
|
|
2675
|
+
alert(`分享更新完成: 成功 ${ok}, 失败 ${fail}`);
|
|
2676
|
+
overlay.remove();
|
|
2677
|
+
};
|
|
2549
2678
|
}
|
|
2550
2679
|
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2680
|
+
/** v3: 跟远端 channel 聊天的简易弹窗 */
|
|
2681
|
+
function openRemoteChannelChat(peerPublicKey, channelId, channelName) {
|
|
2682
|
+
// 移除已有 modal
|
|
2683
|
+
document.getElementById('remote-chat-modal')?.remove();
|
|
2684
|
+
const html = `
|
|
2685
|
+
<div id="remote-chat-modal" style="position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10002;display:flex;align-items:center;justify-content:center;">
|
|
2686
|
+
<div style="background:#fff;border-radius:8px;width:520px;max-width:92vw;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 10px 40px rgba(0,0,0,0.2);">
|
|
2687
|
+
<div style="padding:12px 16px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;justify-content:space-between;">
|
|
2688
|
+
<div>
|
|
2689
|
+
<div style="font-size:15px;font-weight:600;">跟 ${escapeHtml(channelName)} 聊天</div>
|
|
2690
|
+
<div style="font-size:11px;color:#6b7280;margin-top:2px;">远端 peer: ${escapeHtml(peerPublicKey.substring(0,16))}… · channel: ${escapeHtml(channelId)}</div>
|
|
2691
|
+
</div>
|
|
2692
|
+
<button id="rcm-close" style="background:none;border:none;font-size:20px;color:#6b7280;cursor:pointer;">×</button>
|
|
2693
|
+
</div>
|
|
2694
|
+
<div id="rcm-log" style="flex:1;overflow-y:auto;padding:12px 16px;min-height:240px;background:#f9fafb;"></div>
|
|
2695
|
+
<div style="padding:10px 12px;border-top:1px solid #e5e7eb;display:flex;gap:6px;">
|
|
2696
|
+
<input id="rcm-input" type="text" placeholder="输入消息, 发送到远端 channel..."
|
|
2697
|
+
style="flex:1;padding:8px 10px;border:1px solid #d1d5db;border-radius:4px;font-size:13px;">
|
|
2698
|
+
<button id="rcm-send" style="padding:8px 14px;background:#2563eb;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:13px;">发送</button>
|
|
2699
|
+
</div>
|
|
2700
|
+
</div>
|
|
2701
|
+
</div>
|
|
2702
|
+
`;
|
|
2703
|
+
document.body.insertAdjacentHTML('beforeend', html);
|
|
2704
|
+
|
|
2705
|
+
const log = document.getElementById('rcm-log');
|
|
2706
|
+
const inputEl = document.getElementById('rcm-input');
|
|
2707
|
+
const sendBtn = document.getElementById('rcm-send');
|
|
2708
|
+
document.getElementById('rcm-close').onclick = () => document.getElementById('remote-chat-modal').remove();
|
|
2709
|
+
|
|
2710
|
+
const append = (text, isUser) => {
|
|
2711
|
+
const bubble = document.createElement('div');
|
|
2712
|
+
bubble.style.cssText = `padding:8px 10px;margin:4px 0;border-radius:6px;font-size:13px;line-height:1.4;max-width:80%;word-wrap:break-word;${
|
|
2713
|
+
isUser ? 'background:#2563eb;color:#fff;margin-left:auto;text-align:left;'
|
|
2714
|
+
: 'background:#e5e7eb;color:#111;'
|
|
2715
|
+
}`;
|
|
2716
|
+
bubble.textContent = text;
|
|
2717
|
+
log.appendChild(bubble);
|
|
2718
|
+
log.scrollTop = log.scrollHeight;
|
|
2719
|
+
};
|
|
2720
|
+
|
|
2721
|
+
const doSend = async () => {
|
|
2722
|
+
const text = inputEl.value.trim();
|
|
2723
|
+
if (!text) return;
|
|
2724
|
+
append(text, true);
|
|
2725
|
+
inputEl.value = '';
|
|
2726
|
+
sendBtn.disabled = true;
|
|
2727
|
+
sendBtn.textContent = '...';
|
|
2557
2728
|
try {
|
|
2558
|
-
const res = await fetch('/api/remote-channels/
|
|
2729
|
+
const res = await fetch('/api/remote-channels/chat-send', {
|
|
2730
|
+
method: 'POST',
|
|
2731
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2732
|
+
body: JSON.stringify({ targetPublicKey: peerPublicKey, channelId, text })
|
|
2733
|
+
});
|
|
2559
2734
|
const data = await res.json();
|
|
2560
|
-
|
|
2561
|
-
setTimeout(loadRemoteChannels, 1500);
|
|
2735
|
+
if (!res.ok) throw new Error(data.error || 'send failed');
|
|
2562
2736
|
} catch (err) {
|
|
2563
|
-
|
|
2737
|
+
append('(发送失败: ' + (err.message || err) + ')', false);
|
|
2564
2738
|
} finally {
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2739
|
+
sendBtn.disabled = false;
|
|
2740
|
+
sendBtn.textContent = '发送';
|
|
2741
|
+
}
|
|
2742
|
+
};
|
|
2743
|
+
sendBtn.onclick = doSend;
|
|
2744
|
+
inputEl.onkeydown = (e) => { if (e.key === 'Enter') doSend(); };
|
|
2745
|
+
inputEl.focus();
|
|
2746
|
+
startV3GlobalSSE();
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
// Phase 3: 我的 ID 按钮 → 弹窗显示并支持复制自己的 P2PDirect publicKey
|
|
2750
|
+
const showMyIdBtn = document.getElementById('show-my-p2p-id-btn');
|
|
2751
|
+
if (showMyIdBtn) {
|
|
2752
|
+
showMyIdBtn.addEventListener('click', async (e) => {
|
|
2753
|
+
e.stopPropagation();
|
|
2754
|
+
try {
|
|
2755
|
+
const res = await fetch('/api/p2p-publickey');
|
|
2756
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
2757
|
+
const data = await res.json();
|
|
2758
|
+
const pk = data.publicKey || '';
|
|
2759
|
+
if (!pk || pk.length !== 64) {
|
|
2760
|
+
alert('P2PDirect 还没启动, 刷新页面稍后再试');
|
|
2761
|
+
return;
|
|
2762
|
+
}
|
|
2763
|
+
// 显示 + 一键复制
|
|
2764
|
+
const ok = confirm(
|
|
2765
|
+
`我的 P2P publicKey (64 字符 hex):\n\n${pk}\n\n` +
|
|
2766
|
+
`点 "确定" 复制到剪贴板, 发给好友.\n` +
|
|
2767
|
+
`好友点 "+ 好友" 粘贴这个 ID 就能加我.`
|
|
2768
|
+
);
|
|
2769
|
+
if (ok) {
|
|
2770
|
+
try {
|
|
2771
|
+
await navigator.clipboard.writeText(pk);
|
|
2772
|
+
alert('✓ 已复制到剪贴板');
|
|
2773
|
+
} catch {
|
|
2774
|
+
// 旧浏览器 fallback
|
|
2775
|
+
const ta = document.createElement('textarea');
|
|
2776
|
+
ta.value = pk;
|
|
2777
|
+
document.body.appendChild(ta);
|
|
2778
|
+
ta.select();
|
|
2779
|
+
document.execCommand('copy');
|
|
2780
|
+
document.body.removeChild(ta);
|
|
2781
|
+
alert('✓ 已复制到剪贴板 (fallback)');
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
} catch (err) {
|
|
2785
|
+
alert('获取 publicKey 失败: ' + (err.message || err));
|
|
2786
|
+
}
|
|
2787
|
+
});
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
// Phase 3 重做: + 添加好友按钮 → 弹窗输入 publicKey + name, 同时 joinPeer
|
|
2791
|
+
const addPeerBtn = document.getElementById('add-p2p-peer-btn');
|
|
2792
|
+
if (addPeerBtn) {
|
|
2793
|
+
addPeerBtn.addEventListener('click', async (e) => {
|
|
2794
|
+
e.stopPropagation();
|
|
2795
|
+
const name = prompt('给这个 P2P 好友起个名字 (如: 同事-张磊)');
|
|
2796
|
+
if (!name) return;
|
|
2797
|
+
const publicKey = prompt('粘贴对方的 P2PDirect publicKey (64 字符 hex):\n\n获取方式: 对方在 http://localhost:54188/api/p2p-publickey');
|
|
2798
|
+
if (!publicKey) return;
|
|
2799
|
+
if (publicKey.length !== 64) {
|
|
2800
|
+
alert('publicKey 长度不对, 应该是 64 字符 hex');
|
|
2801
|
+
return;
|
|
2802
|
+
}
|
|
2803
|
+
try {
|
|
2804
|
+
// 调 p2p-connect (会 joinPeer + 持久化)
|
|
2805
|
+
const res = await fetch('/api/remote-channels/p2p-connect', {
|
|
2806
|
+
method: 'POST',
|
|
2807
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2808
|
+
body: JSON.stringify({ targetPublicKey: publicKey, name, persist: true })
|
|
2809
|
+
});
|
|
2810
|
+
const data = await res.json();
|
|
2811
|
+
if (!res.ok) throw new Error(data.error || 'connect failed');
|
|
2812
|
+
alert(`已添加好友: ${name} (${publicKey.substring(0, 12)}...)\n重启后会自动重连`);
|
|
2813
|
+
await loadRemoteChannels();
|
|
2814
|
+
} catch (err) {
|
|
2815
|
+
alert('添加失败: ' + (err.message || err));
|
|
2569
2816
|
}
|
|
2570
2817
|
});
|
|
2571
2818
|
}
|
package/src/web/index.html
CHANGED
|
@@ -69,17 +69,20 @@
|
|
|
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="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
82
|
</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
83
|
</div>
|
|
81
84
|
<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;">(
|
|
85
|
+
<li style="color:var(--text-muted);font-size:11px;padding:8px 4px;text-align:center;">(暂无好友, 点 + 添加)</li>
|
|
83
86
|
</ul>
|
|
84
87
|
</div>
|
|
85
88
|
|
|
@@ -120,15 +123,7 @@
|
|
|
120
123
|
<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
124
|
</svg>
|
|
122
125
|
</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>
|
|
126
|
+
<!-- v3: 顶栏 P2P 按钮 (旧 iroh/Diap 路径) 已移除, 改用侧边栏 "P2P 好友" → "+ 好友" -->
|
|
132
127
|
<button id="wallet-btn" class="header-action" title="钱包管理">
|
|
133
128
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
134
129
|
<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>
|