@bolloon/bolloon-agent 0.1.25 → 0.1.27
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/index.js +3 -3
- package/dist/web/client.js +158 -0
- package/dist/web/index.html +19 -0
- package/dist/web/server.js +185 -12
- package/dist/web/style.css +35 -0
- package/package.json +1 -1
- package/src/index.ts +3 -3
- package/src/web/client.js +158 -0
- package/src/web/index.html +19 -0
- package/src/web/server.ts +186 -12
- package/src/web/style.css +35 -0
package/dist/index.js
CHANGED
|
@@ -1426,9 +1426,9 @@ async function main() {
|
|
|
1426
1426
|
const port = parseInt(process.env.PORT || '54188');
|
|
1427
1427
|
const { createWebServer, openBrowser } = await import('./web/server.js');
|
|
1428
1428
|
s.info(`启动 Web 服务端口 ${port}...`);
|
|
1429
|
-
await createWebServer(port);
|
|
1430
|
-
s.success(`浏览器已打开 → http://localhost:${
|
|
1431
|
-
openBrowser(`http://localhost:${
|
|
1429
|
+
const { port: actualPort } = await createWebServer(port);
|
|
1430
|
+
s.success(`浏览器已打开 → http://localhost:${actualPort}`);
|
|
1431
|
+
openBrowser(`http://localhost:${actualPort}`);
|
|
1432
1432
|
}
|
|
1433
1433
|
else if (isNonInteractive) {
|
|
1434
1434
|
console.log = originalLog;
|
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)}...`);
|
|
@@ -2671,19 +2811,52 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
2671
2811
|
}
|
|
2672
2812
|
// 安装自改总线 -> SSE 桥
|
|
2673
2813
|
void installSelfImproveHook();
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2814
|
+
// 端口冲突时自动找下一个可用端口(最多 10 次),避免 EADDRINUSE 直接崩溃
|
|
2815
|
+
return new Promise((resolve, reject) => {
|
|
2816
|
+
const maxAttempts = 10;
|
|
2817
|
+
const startPort = port;
|
|
2818
|
+
let currentPort = startPort;
|
|
2819
|
+
let attempt = 0;
|
|
2820
|
+
// 局部可变 server 引用 — listen 失败后必须重新 createServer 再 listen
|
|
2821
|
+
let currentServer = server;
|
|
2822
|
+
const tryListen = () => {
|
|
2823
|
+
currentServer.removeAllListeners('error');
|
|
2824
|
+
currentServer.once('error', onError);
|
|
2825
|
+
currentServer.listen(currentPort, () => {
|
|
2826
|
+
if (currentPort !== startPort) {
|
|
2827
|
+
console.warn(`⚠ 端口 ${startPort} 被占用,已自动切换到 ${currentPort}`);
|
|
2683
2828
|
}
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2829
|
+
console.log(`Web 服务器启动完成: http://localhost:${currentPort}`);
|
|
2830
|
+
console.log('服务器已监听');
|
|
2831
|
+
// 安装 chat bus -> SSE 桥 (供前端 inbox UI 实时刷新)
|
|
2832
|
+
void installChatBusHook();
|
|
2833
|
+
setInterval(() => {
|
|
2834
|
+
for (const client of sseClients) {
|
|
2835
|
+
client.res.write(': ping\n\n');
|
|
2836
|
+
}
|
|
2837
|
+
}, 30000);
|
|
2838
|
+
resolve({ app, server: currentServer, port: currentPort });
|
|
2839
|
+
});
|
|
2840
|
+
};
|
|
2841
|
+
const onError = (err) => {
|
|
2842
|
+
if (err && err.code === 'EADDRINUSE' && attempt < maxAttempts - 1) {
|
|
2843
|
+
attempt += 1;
|
|
2844
|
+
const nextPort = currentPort + 1;
|
|
2845
|
+
console.log(`⚠ 端口 ${currentPort} 被占用,尝试 ${nextPort}...`);
|
|
2846
|
+
try {
|
|
2847
|
+
currentServer.close();
|
|
2848
|
+
}
|
|
2849
|
+
catch { /* ignore */ }
|
|
2850
|
+
// 重新创建 server 实例(listen 失败后原 server 无法再次 listen)
|
|
2851
|
+
currentServer = createServer(app);
|
|
2852
|
+
currentPort = nextPort;
|
|
2853
|
+
tryListen();
|
|
2854
|
+
}
|
|
2855
|
+
else {
|
|
2856
|
+
reject(err);
|
|
2857
|
+
}
|
|
2858
|
+
};
|
|
2859
|
+
tryListen();
|
|
2687
2860
|
});
|
|
2688
2861
|
}
|
|
2689
2862
|
function broadcast(data, channelId) {
|
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/index.ts
CHANGED
|
@@ -1629,10 +1629,10 @@ async function main() {
|
|
|
1629
1629
|
const { createWebServer, openBrowser } = await import('./web/server.js');
|
|
1630
1630
|
|
|
1631
1631
|
s.info(`启动 Web 服务端口 ${port}...`);
|
|
1632
|
-
await createWebServer(port);
|
|
1632
|
+
const { port: actualPort } = await createWebServer(port);
|
|
1633
1633
|
|
|
1634
|
-
s.success(`浏览器已打开 → http://localhost:${
|
|
1635
|
-
openBrowser(`http://localhost:${
|
|
1634
|
+
s.success(`浏览器已打开 → http://localhost:${actualPort}`);
|
|
1635
|
+
openBrowser(`http://localhost:${actualPort}`);
|
|
1636
1636
|
} else if (isNonInteractive) {
|
|
1637
1637
|
console.log = originalLog;
|
|
1638
1638
|
console.info = originalInfo;
|
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)}...`);
|
|
@@ -3009,19 +3151,51 @@ app.get('/channels', async (_req, res) => {
|
|
|
3009
3151
|
// 安装自改总线 -> SSE 桥
|
|
3010
3152
|
void installSelfImproveHook();
|
|
3011
3153
|
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3154
|
+
// 端口冲突时自动找下一个可用端口(最多 10 次),避免 EADDRINUSE 直接崩溃
|
|
3155
|
+
return new Promise<{ app: express.Express; server: ReturnType<typeof createServer>; port: number }>((resolve, reject) => {
|
|
3156
|
+
const maxAttempts = 10;
|
|
3157
|
+
const startPort = port;
|
|
3158
|
+
let currentPort = startPort;
|
|
3159
|
+
let attempt = 0;
|
|
3160
|
+
// 局部可变 server 引用 — listen 失败后必须重新 createServer 再 listen
|
|
3161
|
+
let currentServer: ReturnType<typeof createServer> = server;
|
|
3162
|
+
|
|
3163
|
+
const tryListen = () => {
|
|
3164
|
+
currentServer.removeAllListeners('error');
|
|
3165
|
+
currentServer.once('error', onError);
|
|
3166
|
+
currentServer.listen(currentPort, () => {
|
|
3167
|
+
if (currentPort !== startPort) {
|
|
3168
|
+
console.warn(`⚠ 端口 ${startPort} 被占用,已自动切换到 ${currentPort}`);
|
|
3021
3169
|
}
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3170
|
+
console.log(`Web 服务器启动完成: http://localhost:${currentPort}`);
|
|
3171
|
+
console.log('服务器已监听');
|
|
3172
|
+
// 安装 chat bus -> SSE 桥 (供前端 inbox UI 实时刷新)
|
|
3173
|
+
void installChatBusHook();
|
|
3174
|
+
setInterval(() => {
|
|
3175
|
+
for (const client of sseClients) {
|
|
3176
|
+
client.res.write(': ping\n\n');
|
|
3177
|
+
}
|
|
3178
|
+
}, 30000);
|
|
3179
|
+
resolve({ app, server: currentServer, port: currentPort });
|
|
3180
|
+
});
|
|
3181
|
+
};
|
|
3182
|
+
|
|
3183
|
+
const onError = (err: NodeJS.ErrnoException) => {
|
|
3184
|
+
if (err && err.code === 'EADDRINUSE' && attempt < maxAttempts - 1) {
|
|
3185
|
+
attempt += 1;
|
|
3186
|
+
const nextPort = currentPort + 1;
|
|
3187
|
+
console.log(`⚠ 端口 ${currentPort} 被占用,尝试 ${nextPort}...`);
|
|
3188
|
+
try { currentServer.close(); } catch { /* ignore */ }
|
|
3189
|
+
// 重新创建 server 实例(listen 失败后原 server 无法再次 listen)
|
|
3190
|
+
currentServer = createServer(app);
|
|
3191
|
+
currentPort = nextPort;
|
|
3192
|
+
tryListen();
|
|
3193
|
+
} else {
|
|
3194
|
+
reject(err);
|
|
3195
|
+
}
|
|
3196
|
+
};
|
|
3197
|
+
|
|
3198
|
+
tryListen();
|
|
3025
3199
|
});
|
|
3026
3200
|
}
|
|
3027
3201
|
|
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;
|