@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.
@@ -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) {
@@ -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>
@@ -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)}...`);
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bolloon/bolloon-agent",
3
- "version": "0.1.25",
3
+ "version": "0.1.26",
4
4
  "type": "module",
5
5
  "description": "P2P AI Document Agent - 全局安装后执行 `bolloon` 启动产品",
6
6
  "main": "dist/cli.js",
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) {
@@ -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;