@bolloon/bolloon-agent 0.1.25 → 0.1.26
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/web/client.js +158 -0
- package/dist/web/index.html +19 -0
- package/dist/web/server.js +140 -0
- package/dist/web/style.css +35 -0
- package/package.json +1 -1
- package/src/web/client.js +158 -0
- package/src/web/index.html +19 -0
- package/src/web/server.ts +142 -0
- package/src/web/style.css +35 -0
package/dist/web/client.js
CHANGED
|
@@ -23,6 +23,7 @@ let eventSources = new Map(); // channelId -> EventSource
|
|
|
23
23
|
let currentChannelId = null;
|
|
24
24
|
let currentAgentId = '';
|
|
25
25
|
let channels = [];
|
|
26
|
+
let remoteChannels = []; // v3: 远端 channel UI 元数据 (按 peer 分组)
|
|
26
27
|
let isSidebarCollapsed = false;
|
|
27
28
|
let reconnectAttempts = new Map(); // channelId -> attempts
|
|
28
29
|
let reconnectTimers = new Map(); // channelId -> timer
|
|
@@ -2490,6 +2491,163 @@ loadJudgments();
|
|
|
2490
2491
|
// 后台定期刷新 (与 modal 打开/关闭无关, 任何时候都保持徽章新鲜)
|
|
2491
2492
|
setInterval(loadJudgments, 10000);
|
|
2492
2493
|
|
|
2494
|
+
// ============================================================================
|
|
2495
|
+
// v3: 远端智能体 (从 P2P 连接的 peer 拉取的 channel UI 元数据)
|
|
2496
|
+
// ============================================================================
|
|
2497
|
+
|
|
2498
|
+
async function loadRemoteChannels() {
|
|
2499
|
+
try {
|
|
2500
|
+
const res = await fetch('/api/remote-channels');
|
|
2501
|
+
if (!res.ok) return;
|
|
2502
|
+
const data = await res.json();
|
|
2503
|
+
remoteChannels = Array.isArray(data.peers) ? data.peers : [];
|
|
2504
|
+
renderRemoteChannels();
|
|
2505
|
+
} catch (err) {
|
|
2506
|
+
console.error('[v3] loadRemoteChannels 失败:', err);
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
function renderRemoteChannels() {
|
|
2511
|
+
const list = document.getElementById('remote-channel-list');
|
|
2512
|
+
if (!list) return;
|
|
2513
|
+
if (remoteChannels.length === 0) {
|
|
2514
|
+
list.innerHTML = '<li style="color:var(--text-muted);font-size:11px;padding:8px 4px;text-align:center;">(暂无, 点 ↻ 刷新)</li>';
|
|
2515
|
+
return;
|
|
2516
|
+
}
|
|
2517
|
+
const html = remoteChannels.map(p => {
|
|
2518
|
+
const peerShort = p.peerId.substring(0, 12) + '…';
|
|
2519
|
+
return `
|
|
2520
|
+
<li class="remote-peer-group" style="margin-bottom:8px;">
|
|
2521
|
+
<div style="font-size:10px;color:var(--text-muted);padding:2px 4px;display:flex;align-items:center;gap:4px;">
|
|
2522
|
+
<span>🌐</span><span title="${escapeHtml(p.peerId)}">${escapeHtml(peerShort)}</span>
|
|
2523
|
+
<span>·</span>
|
|
2524
|
+
<span>${p.channels.length} 个</span>
|
|
2525
|
+
</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
|
+
</li>
|
|
2536
|
+
`;
|
|
2537
|
+
}).join('');
|
|
2538
|
+
list.innerHTML = html;
|
|
2539
|
+
|
|
2540
|
+
// 绑定点击 — 暂时只是 console.log + 提示, Phase 2 接 chat
|
|
2541
|
+
list.querySelectorAll('.remote-channel-row').forEach(row => {
|
|
2542
|
+
row.addEventListener('click', () => {
|
|
2543
|
+
const peerId = row.dataset.peerId;
|
|
2544
|
+
const channelId = row.dataset.channelId;
|
|
2545
|
+
console.log('[v3] 点击远端 channel:', peerId.substring(0,12), channelId);
|
|
2546
|
+
alert(`远端智能体点击 — Phase 2 实现 chat 调用\n\nPeer: ${peerId}\nChannel: ${channelId}`);
|
|
2547
|
+
});
|
|
2548
|
+
});
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
const refreshRemoteBtn = document.getElementById('refresh-remote-agents-btn');
|
|
2552
|
+
if (refreshRemoteBtn) {
|
|
2553
|
+
refreshRemoteBtn.addEventListener('click', async (e) => {
|
|
2554
|
+
e.stopPropagation(); // 防止冒泡触发折叠
|
|
2555
|
+
refreshRemoteBtn.disabled = true;
|
|
2556
|
+
refreshRemoteBtn.textContent = '⏳';
|
|
2557
|
+
try {
|
|
2558
|
+
const res = await fetch('/api/remote-channels/refresh', { method: 'POST' });
|
|
2559
|
+
const data = await res.json();
|
|
2560
|
+
console.log('[v3] 刷新远端智能体:', data);
|
|
2561
|
+
setTimeout(loadRemoteChannels, 1500);
|
|
2562
|
+
} catch (err) {
|
|
2563
|
+
console.error('[v3] 刷新失败:', err);
|
|
2564
|
+
} finally {
|
|
2565
|
+
setTimeout(() => {
|
|
2566
|
+
refreshRemoteBtn.disabled = false;
|
|
2567
|
+
refreshRemoteBtn.textContent = '↻ 刷新';
|
|
2568
|
+
}, 2000);
|
|
2569
|
+
}
|
|
2570
|
+
});
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
// 启动时拉一次 + 定期轮询 (SSE 接收 P2P reply 后也会更新)
|
|
2574
|
+
loadRemoteChannels();
|
|
2575
|
+
setInterval(loadRemoteChannels, 8000);
|
|
2576
|
+
|
|
2577
|
+
// ============ v3: 折叠 + 拖拽分隔线 ============
|
|
2578
|
+
|
|
2579
|
+
// 给本地/远端 section 加 flex 修饰类 (CSS variable 驱动比例)
|
|
2580
|
+
const localSection = document.querySelector('.sidebar-section'); // 第一个 section = 本地 channel
|
|
2581
|
+
const remoteSection = document.getElementById('remote-agents-section');
|
|
2582
|
+
if (localSection) localSection.classList.add('local-flex');
|
|
2583
|
+
if (remoteSection) remoteSection.classList.add('remote-flex');
|
|
2584
|
+
|
|
2585
|
+
// 折叠: 点 header 切换 collapsed 类
|
|
2586
|
+
const remoteHeader = document.getElementById('remote-agents-header');
|
|
2587
|
+
if (remoteHeader && remoteSection) {
|
|
2588
|
+
remoteHeader.addEventListener('click', (e) => {
|
|
2589
|
+
// 阻止刷新按钮的事件冒泡在 refreshRemoteBtn 里已处理
|
|
2590
|
+
remoteSection.classList.toggle('collapsed');
|
|
2591
|
+
});
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
// 拖拽分隔线: 鼠标按下开始拖, mousemove 改 --local-flex / --remote-flex, mouseup 结束
|
|
2595
|
+
const splitHandle = document.getElementById('sidebar-split-handle');
|
|
2596
|
+
if (splitHandle && localSection && remoteSection) {
|
|
2597
|
+
// 初始化等分
|
|
2598
|
+
const updateFlexVars = (localRatio, remoteRatio) => {
|
|
2599
|
+
localSection.style.setProperty('--local-flex', String(localRatio));
|
|
2600
|
+
remoteSection.style.setProperty('--remote-flex', String(remoteRatio));
|
|
2601
|
+
};
|
|
2602
|
+
updateFlexVars(1, 1);
|
|
2603
|
+
|
|
2604
|
+
let isDragging = false;
|
|
2605
|
+
let dragStartY = 0;
|
|
2606
|
+
let startLocalFlex = 1;
|
|
2607
|
+
let startRemoteFlex = 1;
|
|
2608
|
+
let sidebarHeight = 0;
|
|
2609
|
+
|
|
2610
|
+
splitHandle.addEventListener('mousedown', (e) => {
|
|
2611
|
+
isDragging = true;
|
|
2612
|
+
splitHandle.classList.add('dragging');
|
|
2613
|
+
dragStartY = e.clientY;
|
|
2614
|
+
// 读当前 CSS variable 拿真实 flex 值
|
|
2615
|
+
const lf = parseFloat(getComputedStyle(localSection).getPropertyValue('--local-flex')) || 1;
|
|
2616
|
+
const rf = parseFloat(getComputedStyle(remoteSection).getPropertyValue('--remote-flex')) || 1;
|
|
2617
|
+
startLocalFlex = lf;
|
|
2618
|
+
startRemoteFlex = rf;
|
|
2619
|
+
// 父容器可用高度 = sidebar-section 总和 (本地+远端+handle)
|
|
2620
|
+
const sidebar = document.querySelector('.sidebar');
|
|
2621
|
+
if (sidebar) sidebarHeight = sidebar.clientHeight;
|
|
2622
|
+
e.preventDefault();
|
|
2623
|
+
document.body.style.cursor = 'ns-resize';
|
|
2624
|
+
});
|
|
2625
|
+
|
|
2626
|
+
document.addEventListener('mousemove', (e) => {
|
|
2627
|
+
if (!isDragging) return;
|
|
2628
|
+
const deltaY = e.clientY - dragStartY;
|
|
2629
|
+
if (sidebarHeight <= 0) return;
|
|
2630
|
+
// deltaY 正 = 鼠标下移 = 拉大本地 / 缩小远端
|
|
2631
|
+
// 转换: 1 像素 ≈ sidebarHeight 中 0.005 的比例
|
|
2632
|
+
const deltaRatio = deltaY / sidebarHeight * 4; // 4 倍灵敏
|
|
2633
|
+
let newLocal = Math.max(0.1, startLocalFlex + deltaRatio);
|
|
2634
|
+
let newRemote = Math.max(0.1, startRemoteFlex - deltaRatio);
|
|
2635
|
+
updateFlexVars(newLocal, newRemote);
|
|
2636
|
+
});
|
|
2637
|
+
|
|
2638
|
+
document.addEventListener('mouseup', () => {
|
|
2639
|
+
if (!isDragging) return;
|
|
2640
|
+
isDragging = false;
|
|
2641
|
+
splitHandle.classList.remove('dragging');
|
|
2642
|
+
document.body.style.cursor = '';
|
|
2643
|
+
});
|
|
2644
|
+
|
|
2645
|
+
// 双击分隔线 = 重置为等分
|
|
2646
|
+
splitHandle.addEventListener('dblclick', () => {
|
|
2647
|
+
updateFlexVars(1, 1);
|
|
2648
|
+
});
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2493
2651
|
if (taskModal) {
|
|
2494
2652
|
taskModal.addEventListener('click', (e) => {
|
|
2495
2653
|
if (e.target === taskModal) {
|
package/dist/web/index.html
CHANGED
|
@@ -64,6 +64,25 @@
|
|
|
64
64
|
<ul class="channel-list" id="channel-list"></ul>
|
|
65
65
|
</div>
|
|
66
66
|
|
|
67
|
+
<!-- v3: 本地/远端分隔线 — 可拖拽改变两边高度 -->
|
|
68
|
+
<div id="sidebar-split-handle" title="拖动调整上方/下方高度">
|
|
69
|
+
<div class="split-handle-grip"></div>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<!-- v3: 远端智能体 — 从 P2P 连接的 peer 拉取的 channel UI 元数据 -->
|
|
73
|
+
<div class="sidebar-section" id="remote-agents-section">
|
|
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
|
+
<span style="display:flex;align-items:center;gap:6px;">
|
|
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);">远端智能体</span>
|
|
78
|
+
</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
|
+
</div>
|
|
81
|
+
<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;">(暂无, 点 ↻ 刷新)</li>
|
|
83
|
+
</ul>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
67
86
|
<div class="sidebar-footer">
|
|
68
87
|
<div class="agent-status">
|
|
69
88
|
<span class="status-dot"></span>
|
package/dist/web/server.js
CHANGED
|
@@ -254,6 +254,9 @@ async function executeTask(task, channelId) {
|
|
|
254
254
|
executionTaskId = null;
|
|
255
255
|
}
|
|
256
256
|
let sseClients = new Set();
|
|
257
|
+
// v3: 远端 channel UI 元数据缓存 — key: peerId, value: sanitize 过的 channel 列表
|
|
258
|
+
// in-memory only, 进程重启清空 (judgment 内容永远不在这里)
|
|
259
|
+
let remoteChannelCache = new Map();
|
|
257
260
|
let channelSessions = new Map(); // key: channelId
|
|
258
261
|
let sessionMessages = new Map(); // key: channelId + sessionId
|
|
259
262
|
/**
|
|
@@ -263,6 +266,24 @@ let sessionMessages = new Map(); // key: channelId + sessionId
|
|
|
263
266
|
* 返回 "" 表示完全没数据; 否则返回完整 "[系统上下文] ..." 块 (含尾部换行)
|
|
264
267
|
* 失败非致命 — 任何异常都返回空串, 保证 LLM 调用不被阻塞
|
|
265
268
|
*/
|
|
269
|
+
/**
|
|
270
|
+
* v3: 过滤 channel 元数据, 只返回对远端 peer 安全的字段.
|
|
271
|
+
* 关键: bound_judgment_ids / walletBinding / autoInvokeTools 内部状态不外传.
|
|
272
|
+
* judgment 内容永远不会出现在 RPC 响应里 (judgment 始终在 A 节点内存, 由 A 跑 LLM).
|
|
273
|
+
*/
|
|
274
|
+
function sanitizeChannelForPeer(ch) {
|
|
275
|
+
return {
|
|
276
|
+
id: ch.id,
|
|
277
|
+
name: ch.name,
|
|
278
|
+
did: ch.did,
|
|
279
|
+
publicKey: ch.publicKey,
|
|
280
|
+
createdAt: ch.createdAt,
|
|
281
|
+
updatedAt: ch.updatedAt,
|
|
282
|
+
hasWallet: !!ch.walletAddress, // 只告诉 B "有没有钱包", 不传地址
|
|
283
|
+
boundJudgmentCount: Array.isArray(ch.bound_judgment_ids) ? ch.bound_judgment_ids.length : 0,
|
|
284
|
+
// 🔒 不返回: bound_judgment_ids, walletAddress, walletBinding, autoInvokeTools, sessions
|
|
285
|
+
};
|
|
286
|
+
}
|
|
266
287
|
async function buildJudgmentHint(channel, channelIdForLog) {
|
|
267
288
|
try {
|
|
268
289
|
const { loadAllJudgments, initializeValueStore } = await import('../pi-ecosystem-judgment/human-value-store.js');
|
|
@@ -714,6 +735,40 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
714
735
|
res.status(500).json({ error: err.message });
|
|
715
736
|
}
|
|
716
737
|
});
|
|
738
|
+
// v3: 列出本节点缓存的远端 channel (按 peerId 分组)
|
|
739
|
+
app.get('/api/remote-channels', async (_req, res) => {
|
|
740
|
+
try {
|
|
741
|
+
const out = [];
|
|
742
|
+
for (const [peerId, list] of remoteChannelCache.entries()) {
|
|
743
|
+
out.push({ peerId, channels: list });
|
|
744
|
+
}
|
|
745
|
+
res.json({ count: out.length, peers: out });
|
|
746
|
+
}
|
|
747
|
+
catch (err) {
|
|
748
|
+
res.status(500).json({ error: err.message });
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
// v3: 主动向所有已连接 P2P peer 拉 channel 列表
|
|
752
|
+
// 用法: B 端用户点 "刷新远端智能体" → 触发本 endpoint
|
|
753
|
+
app.post('/api/remote-channels/refresh', async (_req, res) => {
|
|
754
|
+
try {
|
|
755
|
+
const peers = irohTransport.getPeers ? irohTransport.getPeers() : [];
|
|
756
|
+
const peerIds = Array.isArray(peers) ? peers.map((p) => p.nodeId || p) : [];
|
|
757
|
+
if (peerIds.length === 0) {
|
|
758
|
+
return res.json({ ok: true, sent: 0, note: 'no connected peers' });
|
|
759
|
+
}
|
|
760
|
+
let sent = 0;
|
|
761
|
+
for (const peerId of peerIds) {
|
|
762
|
+
const ok = await irohTransport.sendMessage(peerId, 'agent.meta.list', new TextEncoder().encode('{}'));
|
|
763
|
+
if (ok)
|
|
764
|
+
sent++;
|
|
765
|
+
}
|
|
766
|
+
res.json({ ok: true, sent, total: peerIds.length });
|
|
767
|
+
}
|
|
768
|
+
catch (err) {
|
|
769
|
+
res.status(500).json({ error: err.message });
|
|
770
|
+
}
|
|
771
|
+
});
|
|
717
772
|
app.post('/channels', async (req, res) => {
|
|
718
773
|
try {
|
|
719
774
|
const { name, agentId, walletAddress, autoInvokeTools, bound_judgment_ids } = req.body;
|
|
@@ -1590,6 +1645,91 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1590
1645
|
timestamp: Date.now()
|
|
1591
1646
|
}, 'p2p-global');
|
|
1592
1647
|
});
|
|
1648
|
+
// ============ v3: 跨用户 channel 元数据 RPC ============
|
|
1649
|
+
// 设计原则: judgment / bound_judgment_ids / wallet 等敏感字段绝不出现在 RPC 响应里.
|
|
1650
|
+
// 收到 'agent.meta.list' → 返回本节点所有 channel 的 UI 元数据 (无 judgment)
|
|
1651
|
+
// 收到 'agent.meta.get' + channelId → 返回单条 channel 的 UI 元数据
|
|
1652
|
+
// B 节点收到响应 → 存到远端 cache → 渲染到 "远端智能体" 区域
|
|
1653
|
+
// B 侧: 收到对端的 list/get 回复 → 更新远端 cache → SSE 推给前端
|
|
1654
|
+
irohTransport.onMessage('agent.meta.list.reply', (msg) => {
|
|
1655
|
+
try {
|
|
1656
|
+
const data = JSON.parse(new TextDecoder().decode(msg.payload));
|
|
1657
|
+
if (!data.ok)
|
|
1658
|
+
return;
|
|
1659
|
+
const peerId = msg.from;
|
|
1660
|
+
const list = Array.isArray(data.channels) ? data.channels : [];
|
|
1661
|
+
remoteChannelCache.set(peerId, list);
|
|
1662
|
+
console.log(`[v3] 缓存远端 peer ${peerId.substring(0, 12)}... 的 ${list.length} 个 channel`);
|
|
1663
|
+
broadcast({
|
|
1664
|
+
type: 'remote-channel-update',
|
|
1665
|
+
peerId,
|
|
1666
|
+
channels: list
|
|
1667
|
+
}, 'p2p-global');
|
|
1668
|
+
}
|
|
1669
|
+
catch (err) {
|
|
1670
|
+
console.error('[v3] 处理 agent.meta.list.reply 失败:', err);
|
|
1671
|
+
}
|
|
1672
|
+
});
|
|
1673
|
+
irohTransport.onMessage('agent.meta.get.reply', (msg) => {
|
|
1674
|
+
try {
|
|
1675
|
+
const data = JSON.parse(new TextDecoder().decode(msg.payload));
|
|
1676
|
+
if (!data.ok || !data.channel)
|
|
1677
|
+
return;
|
|
1678
|
+
const peerId = msg.from;
|
|
1679
|
+
const ch = data.channel;
|
|
1680
|
+
const list = remoteChannelCache.get(peerId) || [];
|
|
1681
|
+
const idx = list.findIndex(c => c.id === ch.id);
|
|
1682
|
+
if (idx >= 0)
|
|
1683
|
+
list[idx] = ch;
|
|
1684
|
+
else
|
|
1685
|
+
list.push(ch);
|
|
1686
|
+
remoteChannelCache.set(peerId, list);
|
|
1687
|
+
broadcast({
|
|
1688
|
+
type: 'remote-channel-update',
|
|
1689
|
+
peerId,
|
|
1690
|
+
channels: list
|
|
1691
|
+
}, 'p2p-global');
|
|
1692
|
+
}
|
|
1693
|
+
catch (err) {
|
|
1694
|
+
console.error('[v3] 处理 agent.meta.get.reply 失败:', err);
|
|
1695
|
+
}
|
|
1696
|
+
});
|
|
1697
|
+
// A 侧: 收到对端的 list/get 请求
|
|
1698
|
+
irohTransport.onMessage('agent.meta.list', async (msg) => {
|
|
1699
|
+
console.log(`[v3] 收到 agent.meta.list from ${msg.from.substring(0, 12)}...`);
|
|
1700
|
+
try {
|
|
1701
|
+
const channels = await loadChannels();
|
|
1702
|
+
const publicMeta = channels.map(sanitizeChannelForPeer);
|
|
1703
|
+
const response = JSON.stringify({ ok: true, channels: publicMeta });
|
|
1704
|
+
const encoded = new TextEncoder().encode(response);
|
|
1705
|
+
// 沿用 msg.from 路由回去
|
|
1706
|
+
irohTransport.sendMessage(msg.from, 'agent.meta.list.reply', encoded).catch(err => {
|
|
1707
|
+
console.error('[v3] 发送 agent.meta.list.reply 失败:', err);
|
|
1708
|
+
});
|
|
1709
|
+
}
|
|
1710
|
+
catch (err) {
|
|
1711
|
+
console.error('[v3] 处理 agent.meta.list 失败:', err);
|
|
1712
|
+
}
|
|
1713
|
+
});
|
|
1714
|
+
irohTransport.onMessage('agent.meta.get', async (msg) => {
|
|
1715
|
+
try {
|
|
1716
|
+
const req = JSON.parse(new TextDecoder().decode(msg.payload));
|
|
1717
|
+
const channelId = req.channelId;
|
|
1718
|
+
console.log(`[v3] 收到 agent.meta.get for ${channelId} from ${msg.from.substring(0, 12)}...`);
|
|
1719
|
+
const channels = await loadChannels();
|
|
1720
|
+
const ch = channels.find(c => c.id === channelId);
|
|
1721
|
+
if (!ch) {
|
|
1722
|
+
const response = JSON.stringify({ ok: false, error: 'channel not found' });
|
|
1723
|
+
irohTransport.sendMessage(msg.from, 'agent.meta.get.reply', new TextEncoder().encode(response));
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
const response = JSON.stringify({ ok: true, channel: sanitizeChannelForPeer(ch) });
|
|
1727
|
+
irohTransport.sendMessage(msg.from, 'agent.meta.get.reply', new TextEncoder().encode(response));
|
|
1728
|
+
}
|
|
1729
|
+
catch (err) {
|
|
1730
|
+
console.error('[v3] 处理 agent.meta.get 失败:', err);
|
|
1731
|
+
}
|
|
1732
|
+
});
|
|
1593
1733
|
irohTransport.onMessage('ai-dialogue', (msg) => {
|
|
1594
1734
|
const content = new TextDecoder().decode(msg.payload);
|
|
1595
1735
|
console.log(`[iroh] 收到 AI 对话 from ${msg.from.substring(0, 12)}...`);
|
package/dist/web/style.css
CHANGED
|
@@ -269,6 +269,41 @@ body {
|
|
|
269
269
|
padding: 16px;
|
|
270
270
|
position: relative;
|
|
271
271
|
}
|
|
272
|
+
|
|
273
|
+
/* v3: 本地/远端 flex 比例由 CSS variable 驱动, 拖拽分隔线时改这两个值 */
|
|
274
|
+
.sidebar-section.local-flex { flex: var(--local-flex, 1) 1 0; }
|
|
275
|
+
.sidebar-section.remote-flex { flex: var(--remote-flex, 1) 1 0; transition: flex 0.15s; }
|
|
276
|
+
|
|
277
|
+
/* v3: 可拖拽分隔线 */
|
|
278
|
+
#sidebar-split-handle {
|
|
279
|
+
flex: 0 0 auto;
|
|
280
|
+
height: 6px;
|
|
281
|
+
cursor: ns-resize;
|
|
282
|
+
display: flex;
|
|
283
|
+
align-items: center;
|
|
284
|
+
justify-content: center;
|
|
285
|
+
position: relative;
|
|
286
|
+
background: transparent;
|
|
287
|
+
user-select: none;
|
|
288
|
+
}
|
|
289
|
+
#sidebar-split-handle:hover { background: var(--bg-hover); }
|
|
290
|
+
#sidebar-split-handle.dragging { background: var(--accent-glow); }
|
|
291
|
+
#sidebar-split-handle .split-handle-grip {
|
|
292
|
+
width: 32px;
|
|
293
|
+
height: 3px;
|
|
294
|
+
border-radius: 2px;
|
|
295
|
+
background: var(--border-light);
|
|
296
|
+
pointer-events: none;
|
|
297
|
+
}
|
|
298
|
+
#sidebar-split-handle:hover .split-handle-grip,
|
|
299
|
+
#sidebar-split-handle.dragging .split-handle-grip { background: var(--accent); }
|
|
300
|
+
|
|
301
|
+
/* v3: 远端区域折叠状态 */
|
|
302
|
+
#remote-agents-section.collapsed .channel-list { display: none; }
|
|
303
|
+
#remote-agents-section.collapsed { flex: 0 0 auto; padding-top: 0; padding-bottom: 0; }
|
|
304
|
+
#remote-agents-section.collapsed .section-header { margin-bottom: 0; }
|
|
305
|
+
#remote-agents-section.collapsed #remote-agents-toggle { transform: rotate(-90deg); }
|
|
306
|
+
|
|
272
307
|
.section-header {
|
|
273
308
|
display: flex;
|
|
274
309
|
align-items: center;
|
package/package.json
CHANGED
package/src/web/client.js
CHANGED
|
@@ -23,6 +23,7 @@ let eventSources = new Map(); // channelId -> EventSource
|
|
|
23
23
|
let currentChannelId = null;
|
|
24
24
|
let currentAgentId = '';
|
|
25
25
|
let channels = [];
|
|
26
|
+
let remoteChannels = []; // v3: 远端 channel UI 元数据 (按 peer 分组)
|
|
26
27
|
let isSidebarCollapsed = false;
|
|
27
28
|
let reconnectAttempts = new Map(); // channelId -> attempts
|
|
28
29
|
let reconnectTimers = new Map(); // channelId -> timer
|
|
@@ -2490,6 +2491,163 @@ loadJudgments();
|
|
|
2490
2491
|
// 后台定期刷新 (与 modal 打开/关闭无关, 任何时候都保持徽章新鲜)
|
|
2491
2492
|
setInterval(loadJudgments, 10000);
|
|
2492
2493
|
|
|
2494
|
+
// ============================================================================
|
|
2495
|
+
// v3: 远端智能体 (从 P2P 连接的 peer 拉取的 channel UI 元数据)
|
|
2496
|
+
// ============================================================================
|
|
2497
|
+
|
|
2498
|
+
async function loadRemoteChannels() {
|
|
2499
|
+
try {
|
|
2500
|
+
const res = await fetch('/api/remote-channels');
|
|
2501
|
+
if (!res.ok) return;
|
|
2502
|
+
const data = await res.json();
|
|
2503
|
+
remoteChannels = Array.isArray(data.peers) ? data.peers : [];
|
|
2504
|
+
renderRemoteChannels();
|
|
2505
|
+
} catch (err) {
|
|
2506
|
+
console.error('[v3] loadRemoteChannels 失败:', err);
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
function renderRemoteChannels() {
|
|
2511
|
+
const list = document.getElementById('remote-channel-list');
|
|
2512
|
+
if (!list) return;
|
|
2513
|
+
if (remoteChannels.length === 0) {
|
|
2514
|
+
list.innerHTML = '<li style="color:var(--text-muted);font-size:11px;padding:8px 4px;text-align:center;">(暂无, 点 ↻ 刷新)</li>';
|
|
2515
|
+
return;
|
|
2516
|
+
}
|
|
2517
|
+
const html = remoteChannels.map(p => {
|
|
2518
|
+
const peerShort = p.peerId.substring(0, 12) + '…';
|
|
2519
|
+
return `
|
|
2520
|
+
<li class="remote-peer-group" style="margin-bottom:8px;">
|
|
2521
|
+
<div style="font-size:10px;color:var(--text-muted);padding:2px 4px;display:flex;align-items:center;gap:4px;">
|
|
2522
|
+
<span>🌐</span><span title="${escapeHtml(p.peerId)}">${escapeHtml(peerShort)}</span>
|
|
2523
|
+
<span>·</span>
|
|
2524
|
+
<span>${p.channels.length} 个</span>
|
|
2525
|
+
</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
|
+
</li>
|
|
2536
|
+
`;
|
|
2537
|
+
}).join('');
|
|
2538
|
+
list.innerHTML = html;
|
|
2539
|
+
|
|
2540
|
+
// 绑定点击 — 暂时只是 console.log + 提示, Phase 2 接 chat
|
|
2541
|
+
list.querySelectorAll('.remote-channel-row').forEach(row => {
|
|
2542
|
+
row.addEventListener('click', () => {
|
|
2543
|
+
const peerId = row.dataset.peerId;
|
|
2544
|
+
const channelId = row.dataset.channelId;
|
|
2545
|
+
console.log('[v3] 点击远端 channel:', peerId.substring(0,12), channelId);
|
|
2546
|
+
alert(`远端智能体点击 — Phase 2 实现 chat 调用\n\nPeer: ${peerId}\nChannel: ${channelId}`);
|
|
2547
|
+
});
|
|
2548
|
+
});
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
const refreshRemoteBtn = document.getElementById('refresh-remote-agents-btn');
|
|
2552
|
+
if (refreshRemoteBtn) {
|
|
2553
|
+
refreshRemoteBtn.addEventListener('click', async (e) => {
|
|
2554
|
+
e.stopPropagation(); // 防止冒泡触发折叠
|
|
2555
|
+
refreshRemoteBtn.disabled = true;
|
|
2556
|
+
refreshRemoteBtn.textContent = '⏳';
|
|
2557
|
+
try {
|
|
2558
|
+
const res = await fetch('/api/remote-channels/refresh', { method: 'POST' });
|
|
2559
|
+
const data = await res.json();
|
|
2560
|
+
console.log('[v3] 刷新远端智能体:', data);
|
|
2561
|
+
setTimeout(loadRemoteChannels, 1500);
|
|
2562
|
+
} catch (err) {
|
|
2563
|
+
console.error('[v3] 刷新失败:', err);
|
|
2564
|
+
} finally {
|
|
2565
|
+
setTimeout(() => {
|
|
2566
|
+
refreshRemoteBtn.disabled = false;
|
|
2567
|
+
refreshRemoteBtn.textContent = '↻ 刷新';
|
|
2568
|
+
}, 2000);
|
|
2569
|
+
}
|
|
2570
|
+
});
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
// 启动时拉一次 + 定期轮询 (SSE 接收 P2P reply 后也会更新)
|
|
2574
|
+
loadRemoteChannels();
|
|
2575
|
+
setInterval(loadRemoteChannels, 8000);
|
|
2576
|
+
|
|
2577
|
+
// ============ v3: 折叠 + 拖拽分隔线 ============
|
|
2578
|
+
|
|
2579
|
+
// 给本地/远端 section 加 flex 修饰类 (CSS variable 驱动比例)
|
|
2580
|
+
const localSection = document.querySelector('.sidebar-section'); // 第一个 section = 本地 channel
|
|
2581
|
+
const remoteSection = document.getElementById('remote-agents-section');
|
|
2582
|
+
if (localSection) localSection.classList.add('local-flex');
|
|
2583
|
+
if (remoteSection) remoteSection.classList.add('remote-flex');
|
|
2584
|
+
|
|
2585
|
+
// 折叠: 点 header 切换 collapsed 类
|
|
2586
|
+
const remoteHeader = document.getElementById('remote-agents-header');
|
|
2587
|
+
if (remoteHeader && remoteSection) {
|
|
2588
|
+
remoteHeader.addEventListener('click', (e) => {
|
|
2589
|
+
// 阻止刷新按钮的事件冒泡在 refreshRemoteBtn 里已处理
|
|
2590
|
+
remoteSection.classList.toggle('collapsed');
|
|
2591
|
+
});
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
// 拖拽分隔线: 鼠标按下开始拖, mousemove 改 --local-flex / --remote-flex, mouseup 结束
|
|
2595
|
+
const splitHandle = document.getElementById('sidebar-split-handle');
|
|
2596
|
+
if (splitHandle && localSection && remoteSection) {
|
|
2597
|
+
// 初始化等分
|
|
2598
|
+
const updateFlexVars = (localRatio, remoteRatio) => {
|
|
2599
|
+
localSection.style.setProperty('--local-flex', String(localRatio));
|
|
2600
|
+
remoteSection.style.setProperty('--remote-flex', String(remoteRatio));
|
|
2601
|
+
};
|
|
2602
|
+
updateFlexVars(1, 1);
|
|
2603
|
+
|
|
2604
|
+
let isDragging = false;
|
|
2605
|
+
let dragStartY = 0;
|
|
2606
|
+
let startLocalFlex = 1;
|
|
2607
|
+
let startRemoteFlex = 1;
|
|
2608
|
+
let sidebarHeight = 0;
|
|
2609
|
+
|
|
2610
|
+
splitHandle.addEventListener('mousedown', (e) => {
|
|
2611
|
+
isDragging = true;
|
|
2612
|
+
splitHandle.classList.add('dragging');
|
|
2613
|
+
dragStartY = e.clientY;
|
|
2614
|
+
// 读当前 CSS variable 拿真实 flex 值
|
|
2615
|
+
const lf = parseFloat(getComputedStyle(localSection).getPropertyValue('--local-flex')) || 1;
|
|
2616
|
+
const rf = parseFloat(getComputedStyle(remoteSection).getPropertyValue('--remote-flex')) || 1;
|
|
2617
|
+
startLocalFlex = lf;
|
|
2618
|
+
startRemoteFlex = rf;
|
|
2619
|
+
// 父容器可用高度 = sidebar-section 总和 (本地+远端+handle)
|
|
2620
|
+
const sidebar = document.querySelector('.sidebar');
|
|
2621
|
+
if (sidebar) sidebarHeight = sidebar.clientHeight;
|
|
2622
|
+
e.preventDefault();
|
|
2623
|
+
document.body.style.cursor = 'ns-resize';
|
|
2624
|
+
});
|
|
2625
|
+
|
|
2626
|
+
document.addEventListener('mousemove', (e) => {
|
|
2627
|
+
if (!isDragging) return;
|
|
2628
|
+
const deltaY = e.clientY - dragStartY;
|
|
2629
|
+
if (sidebarHeight <= 0) return;
|
|
2630
|
+
// deltaY 正 = 鼠标下移 = 拉大本地 / 缩小远端
|
|
2631
|
+
// 转换: 1 像素 ≈ sidebarHeight 中 0.005 的比例
|
|
2632
|
+
const deltaRatio = deltaY / sidebarHeight * 4; // 4 倍灵敏
|
|
2633
|
+
let newLocal = Math.max(0.1, startLocalFlex + deltaRatio);
|
|
2634
|
+
let newRemote = Math.max(0.1, startRemoteFlex - deltaRatio);
|
|
2635
|
+
updateFlexVars(newLocal, newRemote);
|
|
2636
|
+
});
|
|
2637
|
+
|
|
2638
|
+
document.addEventListener('mouseup', () => {
|
|
2639
|
+
if (!isDragging) return;
|
|
2640
|
+
isDragging = false;
|
|
2641
|
+
splitHandle.classList.remove('dragging');
|
|
2642
|
+
document.body.style.cursor = '';
|
|
2643
|
+
});
|
|
2644
|
+
|
|
2645
|
+
// 双击分隔线 = 重置为等分
|
|
2646
|
+
splitHandle.addEventListener('dblclick', () => {
|
|
2647
|
+
updateFlexVars(1, 1);
|
|
2648
|
+
});
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2493
2651
|
if (taskModal) {
|
|
2494
2652
|
taskModal.addEventListener('click', (e) => {
|
|
2495
2653
|
if (e.target === taskModal) {
|
package/src/web/index.html
CHANGED
|
@@ -64,6 +64,25 @@
|
|
|
64
64
|
<ul class="channel-list" id="channel-list"></ul>
|
|
65
65
|
</div>
|
|
66
66
|
|
|
67
|
+
<!-- v3: 本地/远端分隔线 — 可拖拽改变两边高度 -->
|
|
68
|
+
<div id="sidebar-split-handle" title="拖动调整上方/下方高度">
|
|
69
|
+
<div class="split-handle-grip"></div>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<!-- v3: 远端智能体 — 从 P2P 连接的 peer 拉取的 channel UI 元数据 -->
|
|
73
|
+
<div class="sidebar-section" id="remote-agents-section">
|
|
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
|
+
<span style="display:flex;align-items:center;gap:6px;">
|
|
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);">远端智能体</span>
|
|
78
|
+
</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
|
+
</div>
|
|
81
|
+
<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;">(暂无, 点 ↻ 刷新)</li>
|
|
83
|
+
</ul>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
67
86
|
<div class="sidebar-footer">
|
|
68
87
|
<div class="agent-status">
|
|
69
88
|
<span class="status-dot"></span>
|
package/src/web/server.ts
CHANGED
|
@@ -382,6 +382,9 @@ interface SSEClient {
|
|
|
382
382
|
}
|
|
383
383
|
|
|
384
384
|
let sseClients: Set<SSEClient> = new Set();
|
|
385
|
+
// v3: 远端 channel UI 元数据缓存 — key: peerId, value: sanitize 过的 channel 列表
|
|
386
|
+
// in-memory only, 进程重启清空 (judgment 内容永远不在这里)
|
|
387
|
+
let remoteChannelCache: Map<string, Array<Record<string, unknown>>> = new Map();
|
|
385
388
|
let channelSessions: Map<string, AgentSession> = new Map(); // key: channelId
|
|
386
389
|
let sessionMessages: Map<string, any[]> = new Map(); // key: channelId + sessionId
|
|
387
390
|
|
|
@@ -392,6 +395,26 @@ let sessionMessages: Map<string, any[]> = new Map(); // key: channelId + session
|
|
|
392
395
|
* 返回 "" 表示完全没数据; 否则返回完整 "[系统上下文] ..." 块 (含尾部换行)
|
|
393
396
|
* 失败非致命 — 任何异常都返回空串, 保证 LLM 调用不被阻塞
|
|
394
397
|
*/
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* v3: 过滤 channel 元数据, 只返回对远端 peer 安全的字段.
|
|
401
|
+
* 关键: bound_judgment_ids / walletBinding / autoInvokeTools 内部状态不外传.
|
|
402
|
+
* judgment 内容永远不会出现在 RPC 响应里 (judgment 始终在 A 节点内存, 由 A 跑 LLM).
|
|
403
|
+
*/
|
|
404
|
+
function sanitizeChannelForPeer(ch: Channel): Record<string, unknown> {
|
|
405
|
+
return {
|
|
406
|
+
id: ch.id,
|
|
407
|
+
name: ch.name,
|
|
408
|
+
did: ch.did,
|
|
409
|
+
publicKey: ch.publicKey,
|
|
410
|
+
createdAt: ch.createdAt,
|
|
411
|
+
updatedAt: ch.updatedAt,
|
|
412
|
+
hasWallet: !!ch.walletAddress, // 只告诉 B "有没有钱包", 不传地址
|
|
413
|
+
boundJudgmentCount: Array.isArray(ch.bound_judgment_ids) ? ch.bound_judgment_ids.length : 0,
|
|
414
|
+
// 🔒 不返回: bound_judgment_ids, walletAddress, walletBinding, autoInvokeTools, sessions
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
395
418
|
async function buildJudgmentHint(
|
|
396
419
|
channel: Channel | undefined | null,
|
|
397
420
|
channelIdForLog: string
|
|
@@ -904,6 +927,43 @@ app.get('/channels', async (_req, res) => {
|
|
|
904
927
|
}
|
|
905
928
|
});
|
|
906
929
|
|
|
930
|
+
// v3: 列出本节点缓存的远端 channel (按 peerId 分组)
|
|
931
|
+
app.get('/api/remote-channels', async (_req, res) => {
|
|
932
|
+
try {
|
|
933
|
+
const out: Array<{ peerId: string; channels: Array<Record<string, unknown>> }> = [];
|
|
934
|
+
for (const [peerId, list] of remoteChannelCache.entries()) {
|
|
935
|
+
out.push({ peerId, channels: list });
|
|
936
|
+
}
|
|
937
|
+
res.json({ count: out.length, peers: out });
|
|
938
|
+
} catch (err: any) {
|
|
939
|
+
res.status(500).json({ error: err.message });
|
|
940
|
+
}
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
// v3: 主动向所有已连接 P2P peer 拉 channel 列表
|
|
944
|
+
// 用法: B 端用户点 "刷新远端智能体" → 触发本 endpoint
|
|
945
|
+
app.post('/api/remote-channels/refresh', async (_req, res) => {
|
|
946
|
+
try {
|
|
947
|
+
const peers = irohTransport.getPeers ? irohTransport.getPeers() : [];
|
|
948
|
+
const peerIds = Array.isArray(peers) ? peers.map((p: any) => p.nodeId || p) : [];
|
|
949
|
+
if (peerIds.length === 0) {
|
|
950
|
+
return res.json({ ok: true, sent: 0, note: 'no connected peers' });
|
|
951
|
+
}
|
|
952
|
+
let sent = 0;
|
|
953
|
+
for (const peerId of peerIds) {
|
|
954
|
+
const ok = await irohTransport.sendMessage(
|
|
955
|
+
peerId,
|
|
956
|
+
'agent.meta.list',
|
|
957
|
+
new TextEncoder().encode('{}')
|
|
958
|
+
);
|
|
959
|
+
if (ok) sent++;
|
|
960
|
+
}
|
|
961
|
+
res.json({ ok: true, sent, total: peerIds.length });
|
|
962
|
+
} catch (err: any) {
|
|
963
|
+
res.status(500).json({ error: err.message });
|
|
964
|
+
}
|
|
965
|
+
});
|
|
966
|
+
|
|
907
967
|
app.post('/channels', async (req, res) => {
|
|
908
968
|
try {
|
|
909
969
|
const { name, agentId, walletAddress, autoInvokeTools, bound_judgment_ids } = req.body;
|
|
@@ -1858,6 +1918,88 @@ app.get('/channels', async (_req, res) => {
|
|
|
1858
1918
|
}, 'p2p-global');
|
|
1859
1919
|
});
|
|
1860
1920
|
|
|
1921
|
+
// ============ v3: 跨用户 channel 元数据 RPC ============
|
|
1922
|
+
// 设计原则: judgment / bound_judgment_ids / wallet 等敏感字段绝不出现在 RPC 响应里.
|
|
1923
|
+
// 收到 'agent.meta.list' → 返回本节点所有 channel 的 UI 元数据 (无 judgment)
|
|
1924
|
+
// 收到 'agent.meta.get' + channelId → 返回单条 channel 的 UI 元数据
|
|
1925
|
+
// B 节点收到响应 → 存到远端 cache → 渲染到 "远端智能体" 区域
|
|
1926
|
+
|
|
1927
|
+
// B 侧: 收到对端的 list/get 回复 → 更新远端 cache → SSE 推给前端
|
|
1928
|
+
irohTransport.onMessage('agent.meta.list.reply', (msg) => {
|
|
1929
|
+
try {
|
|
1930
|
+
const data = JSON.parse(new TextDecoder().decode(msg.payload));
|
|
1931
|
+
if (!data.ok) return;
|
|
1932
|
+
const peerId = msg.from;
|
|
1933
|
+
const list = Array.isArray(data.channels) ? data.channels : [];
|
|
1934
|
+
remoteChannelCache.set(peerId, list);
|
|
1935
|
+
console.log(`[v3] 缓存远端 peer ${peerId.substring(0, 12)}... 的 ${list.length} 个 channel`);
|
|
1936
|
+
broadcast({
|
|
1937
|
+
type: 'remote-channel-update',
|
|
1938
|
+
peerId,
|
|
1939
|
+
channels: list
|
|
1940
|
+
}, 'p2p-global');
|
|
1941
|
+
} catch (err) {
|
|
1942
|
+
console.error('[v3] 处理 agent.meta.list.reply 失败:', err);
|
|
1943
|
+
}
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1946
|
+
irohTransport.onMessage('agent.meta.get.reply', (msg) => {
|
|
1947
|
+
try {
|
|
1948
|
+
const data = JSON.parse(new TextDecoder().decode(msg.payload));
|
|
1949
|
+
if (!data.ok || !data.channel) return;
|
|
1950
|
+
const peerId = msg.from;
|
|
1951
|
+
const ch = data.channel;
|
|
1952
|
+
const list = remoteChannelCache.get(peerId) || [];
|
|
1953
|
+
const idx = list.findIndex(c => c.id === ch.id);
|
|
1954
|
+
if (idx >= 0) list[idx] = ch;
|
|
1955
|
+
else list.push(ch);
|
|
1956
|
+
remoteChannelCache.set(peerId, list);
|
|
1957
|
+
broadcast({
|
|
1958
|
+
type: 'remote-channel-update',
|
|
1959
|
+
peerId,
|
|
1960
|
+
channels: list
|
|
1961
|
+
}, 'p2p-global');
|
|
1962
|
+
} catch (err) {
|
|
1963
|
+
console.error('[v3] 处理 agent.meta.get.reply 失败:', err);
|
|
1964
|
+
}
|
|
1965
|
+
});
|
|
1966
|
+
|
|
1967
|
+
// A 侧: 收到对端的 list/get 请求
|
|
1968
|
+
irohTransport.onMessage('agent.meta.list', async (msg) => {
|
|
1969
|
+
console.log(`[v3] 收到 agent.meta.list from ${msg.from.substring(0, 12)}...`);
|
|
1970
|
+
try {
|
|
1971
|
+
const channels = await loadChannels();
|
|
1972
|
+
const publicMeta = channels.map(sanitizeChannelForPeer);
|
|
1973
|
+
const response = JSON.stringify({ ok: true, channels: publicMeta });
|
|
1974
|
+
const encoded = new TextEncoder().encode(response);
|
|
1975
|
+
// 沿用 msg.from 路由回去
|
|
1976
|
+
irohTransport.sendMessage(msg.from, 'agent.meta.list.reply', encoded).catch(err => {
|
|
1977
|
+
console.error('[v3] 发送 agent.meta.list.reply 失败:', err);
|
|
1978
|
+
});
|
|
1979
|
+
} catch (err) {
|
|
1980
|
+
console.error('[v3] 处理 agent.meta.list 失败:', err);
|
|
1981
|
+
}
|
|
1982
|
+
});
|
|
1983
|
+
|
|
1984
|
+
irohTransport.onMessage('agent.meta.get', async (msg) => {
|
|
1985
|
+
try {
|
|
1986
|
+
const req = JSON.parse(new TextDecoder().decode(msg.payload));
|
|
1987
|
+
const channelId = req.channelId;
|
|
1988
|
+
console.log(`[v3] 收到 agent.meta.get for ${channelId} from ${msg.from.substring(0, 12)}...`);
|
|
1989
|
+
const channels = await loadChannels();
|
|
1990
|
+
const ch = channels.find(c => c.id === channelId);
|
|
1991
|
+
if (!ch) {
|
|
1992
|
+
const response = JSON.stringify({ ok: false, error: 'channel not found' });
|
|
1993
|
+
irohTransport.sendMessage(msg.from, 'agent.meta.get.reply', new TextEncoder().encode(response));
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
const response = JSON.stringify({ ok: true, channel: sanitizeChannelForPeer(ch) });
|
|
1997
|
+
irohTransport.sendMessage(msg.from, 'agent.meta.get.reply', new TextEncoder().encode(response));
|
|
1998
|
+
} catch (err) {
|
|
1999
|
+
console.error('[v3] 处理 agent.meta.get 失败:', err);
|
|
2000
|
+
}
|
|
2001
|
+
});
|
|
2002
|
+
|
|
1861
2003
|
irohTransport.onMessage('ai-dialogue', (msg) => {
|
|
1862
2004
|
const content = new TextDecoder().decode(msg.payload);
|
|
1863
2005
|
console.log(`[iroh] 收到 AI 对话 from ${msg.from.substring(0, 12)}...`);
|
package/src/web/style.css
CHANGED
|
@@ -269,6 +269,41 @@ body {
|
|
|
269
269
|
padding: 16px;
|
|
270
270
|
position: relative;
|
|
271
271
|
}
|
|
272
|
+
|
|
273
|
+
/* v3: 本地/远端 flex 比例由 CSS variable 驱动, 拖拽分隔线时改这两个值 */
|
|
274
|
+
.sidebar-section.local-flex { flex: var(--local-flex, 1) 1 0; }
|
|
275
|
+
.sidebar-section.remote-flex { flex: var(--remote-flex, 1) 1 0; transition: flex 0.15s; }
|
|
276
|
+
|
|
277
|
+
/* v3: 可拖拽分隔线 */
|
|
278
|
+
#sidebar-split-handle {
|
|
279
|
+
flex: 0 0 auto;
|
|
280
|
+
height: 6px;
|
|
281
|
+
cursor: ns-resize;
|
|
282
|
+
display: flex;
|
|
283
|
+
align-items: center;
|
|
284
|
+
justify-content: center;
|
|
285
|
+
position: relative;
|
|
286
|
+
background: transparent;
|
|
287
|
+
user-select: none;
|
|
288
|
+
}
|
|
289
|
+
#sidebar-split-handle:hover { background: var(--bg-hover); }
|
|
290
|
+
#sidebar-split-handle.dragging { background: var(--accent-glow); }
|
|
291
|
+
#sidebar-split-handle .split-handle-grip {
|
|
292
|
+
width: 32px;
|
|
293
|
+
height: 3px;
|
|
294
|
+
border-radius: 2px;
|
|
295
|
+
background: var(--border-light);
|
|
296
|
+
pointer-events: none;
|
|
297
|
+
}
|
|
298
|
+
#sidebar-split-handle:hover .split-handle-grip,
|
|
299
|
+
#sidebar-split-handle.dragging .split-handle-grip { background: var(--accent); }
|
|
300
|
+
|
|
301
|
+
/* v3: 远端区域折叠状态 */
|
|
302
|
+
#remote-agents-section.collapsed .channel-list { display: none; }
|
|
303
|
+
#remote-agents-section.collapsed { flex: 0 0 auto; padding-top: 0; padding-bottom: 0; }
|
|
304
|
+
#remote-agents-section.collapsed .section-header { margin-bottom: 0; }
|
|
305
|
+
#remote-agents-section.collapsed #remote-agents-toggle { transform: rotate(-90deg); }
|
|
306
|
+
|
|
272
307
|
.section-header {
|
|
273
308
|
display: flex;
|
|
274
309
|
align-items: center;
|