@bolloon/bolloon-agent 0.1.13 → 0.1.14

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.
Files changed (52) hide show
  1. package/dist/agents/pi-sdk.js +185 -0
  2. package/dist/agents/shell-guard.js +354 -0
  3. package/dist/agents/shell-tool.js +83 -0
  4. package/dist/agents/skill-loader.js +174 -0
  5. package/dist/bollharness-integration/context-chain-router.js +3 -3
  6. package/dist/bollharness-integration/context-router.js +1 -1
  7. package/dist/heartbeat/Watchdog.js +7 -5
  8. package/dist/heartbeat/index.js +1 -0
  9. package/dist/heartbeat/self-improve-bus.js +85 -0
  10. package/dist/pi-ecosystem-judgment/index.js +1 -2
  11. package/dist/utils/auto-update.js +44 -12
  12. package/dist/web/client.js +839 -103
  13. package/dist/web/components/p2p/P2PModal.js +188 -0
  14. package/dist/web/components/p2p/index.js +264 -226
  15. package/dist/web/components/p2p/p2p-modal.js +657 -0
  16. package/dist/web/components/p2p/p2p-tools.js +248 -0
  17. package/dist/web/index.html +88 -8
  18. package/dist/web/server.js +2360 -0
  19. package/dist/web/style.css +506 -9
  20. package/package.json +2 -2
  21. package/scripts/build-cli.js +11 -1
  22. package/src/agents/pi-sdk.ts +196 -0
  23. package/src/agents/shell-guard.ts +417 -0
  24. package/src/agents/shell-tool.ts +103 -0
  25. package/src/agents/skill-loader.ts +202 -0
  26. package/src/bollharness-integration/context-chain-router.ts +3 -3
  27. package/src/bollharness-integration/context-router.ts +1 -1
  28. package/src/heartbeat/Watchdog.ts +7 -5
  29. package/src/heartbeat/index.ts +1 -0
  30. package/src/heartbeat/self-improve-bus.ts +110 -0
  31. package/src/types.d.ts +12 -0
  32. package/src/utils/auto-update.ts +45 -14
  33. package/src/web/client.js +839 -103
  34. package/src/web/index.html +88 -8
  35. package/src/web/server.ts +427 -101
  36. package/src/web/style.css +506 -9
  37. package/dist/bollharness-integration/bollharness-integration/context-router-judgment.d.ts +0 -48
  38. package/dist/bollharness-integration/bollharness-integration/context-router-judgment.js +0 -261
  39. package/dist/bollharness-integration/bollharness-integration/context-router.d.ts +0 -110
  40. package/dist/bollharness-integration/bollharness-integration/context-router.js +0 -542
  41. package/dist/bollharness-integration/bollharness-integration/gate-state-machine.d.ts +0 -87
  42. package/dist/bollharness-integration/bollharness-integration/gate-state-machine.js +0 -231
  43. package/dist/bollharness-integration/bollharness-integration/gate-transition-hooks.d.ts +0 -30
  44. package/dist/bollharness-integration/bollharness-integration/gate-transition-hooks.js +0 -91
  45. package/dist/bollharness-integration/bollharness-integration/guard-checker.d.ts +0 -105
  46. package/dist/bollharness-integration/bollharness-integration/guard-checker.js +0 -353
  47. package/dist/bollharness-integration/bollharness-integration/index.d.ts +0 -66
  48. package/dist/bollharness-integration/bollharness-integration/index.js +0 -32
  49. package/dist/bollharness-integration/bollharness-integration/integration.d.ts +0 -219
  50. package/dist/bollharness-integration/bollharness-integration/integration.js +0 -420
  51. package/dist/bollharness-integration/bollharness-integration/skill-adapter.d.ts +0 -151
  52. package/dist/bollharness-integration/bollharness-integration/skill-adapter.js +0 -518
@@ -1,3 +1,8 @@
1
+ // marked 库可能从 CDN 加载失败, 这里做安全降级 (避免 ReferenceError 让 addMessage 整体崩溃)
2
+ if (typeof marked === 'undefined') {
3
+ window.marked = { parse: (text) => String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>') };
4
+ }
5
+
1
6
  const messagesEl = document.getElementById('messages');
2
7
  const input = document.getElementById('input');
3
8
  const sendBtn = document.getElementById('send');
@@ -10,7 +15,7 @@ const newChannelInput = document.getElementById('new-channel-input');
10
15
  const channelNameEl = document.getElementById('channel-name');
11
16
  const loadSessionBtn = document.getElementById('load-session-btn');
12
17
  const sessionFileInput = document.getElementById('session-file-input');
13
- const newSessionBtn = document.getElementById('new-session-btn');
18
+ const newSessionBtn = document.getElementById('new-session-btn'); // 兼容旧引用(右上角按钮已移除)
14
19
 
15
20
  let eventSources = new Map(); // channelId -> EventSource
16
21
  let currentChannelId = null;
@@ -19,11 +24,13 @@ let channels = [];
19
24
  let isSidebarCollapsed = false;
20
25
  let reconnectAttempts = new Map(); // channelId -> attempts
21
26
  let reconnectTimers = new Map(); // channelId -> timer
27
+ let heartbeatTimers = new Map(); // channelId -> setInterval handle (防止泄漏)
22
28
  let lastUserCommand = ''; // 防止用户消息重复显示
23
29
  let lastAiContent = ''; // 防止 AI 消息重复显示
24
30
  let messagesContainers = new Map(); // channelId -> messages container div
25
31
  let sessionMessages = new Map(); // channelId:sessionId -> messages array
26
32
  let currentSessionId = null; // 当前显示的 session ID
33
+ let expandedAgents = new Set(); // 当前展开的 agent(channel) id 集合
27
34
 
28
35
  function generateId() {
29
36
  return crypto.randomUUID();
@@ -112,21 +119,13 @@ async function createChannel(name) {
112
119
  channels.push(channel);
113
120
  renderChannels();
114
121
  selectChannel(channel.id);
115
- newChannelInput.value = '';
122
+ if (newChannelInput) newChannelInput.value = '';
116
123
 
117
124
  // 后台更新 DID(如果还没有的话)
118
125
  if (!channel.did || channel.did === 'undefined') {
119
126
  console.log('[创建频道] 后台生成 DID...');
120
- // 触发后台刷新,DID 会在下次请求时更新
121
- setTimeout(() => {
122
- fetch('/channels').then(res => res.json()).then(allChannels => {
123
- channels = allChannels;
124
- const updated = channels.find(c => c.id === channel.id);
125
- if (updated && updated.did && updated.did !== 'undefined') {
126
- console.log('[创建频道] DID 生成完成:', updated.did);
127
- }
128
- });
129
- }, 2000);
127
+ // 复用全局 channelRefreshTimer, 把多个刷新请求合并成一个 1.5s 后的请求
128
+ scheduleChannelsRefresh();
130
129
  }
131
130
  } catch (err) {
132
131
  console.error('Failed to create channel:', err);
@@ -135,15 +134,25 @@ async function createChannel(name) {
135
134
 
136
135
  async function deleteChannel(channelId, e) {
137
136
  e.stopPropagation();
137
+ if (!confirm('确定要删除该智能体及其所有会话吗?此操作不可撤销。')) return;
138
138
  try {
139
139
  await fetch(`/channels/${channelId}`, { method: 'DELETE' });
140
140
  channels = channels.filter(c => c.id !== channelId);
141
+ expandedAgents.delete(channelId);
142
+
143
+ // 释放浏览器侧引用 + DOM, 避免长时间使用后内存累积
144
+ cleanupChannelState(channelId);
145
+
141
146
  if (currentChannelId === channelId) {
142
147
  currentChannelId = channels[0]?.id || null;
148
+ currentSessionId = null;
143
149
  if (currentChannelId) {
144
- await loadSession(currentChannelId);
150
+ const ch = channels.find(c => c.id === currentChannelId);
151
+ if (channelNameEl) channelNameEl.textContent = ch?.name || 'Bolloon Agent';
152
+ await selectChannel(currentChannelId);
145
153
  } else {
146
154
  messagesEl.innerHTML = '';
155
+ if (channelNameEl) channelNameEl.textContent = 'Bolloon Agent';
147
156
  }
148
157
  }
149
158
  renderChannels();
@@ -153,6 +162,38 @@ async function deleteChannel(channelId, e) {
153
162
  }
154
163
  }
155
164
 
165
+ /** 释放一个 channel 在浏览器侧占用的所有资源 (DOM 容器, SSE, 缓存 session 消息) */
166
+ function cleanupChannelState(channelId) {
167
+ // 1. SSE 连接
168
+ if (eventSources.has(channelId)) {
169
+ try { eventSources.get(channelId).close(); } catch {}
170
+ eventSources.delete(channelId);
171
+ }
172
+ // 2. 心跳 + 重连 timer
173
+ if (heartbeatTimers.has(channelId)) {
174
+ clearInterval(heartbeatTimers.get(channelId));
175
+ heartbeatTimers.delete(channelId);
176
+ }
177
+ if (reconnectTimers.has(channelId)) {
178
+ clearTimeout(reconnectTimers.get(channelId));
179
+ reconnectTimers.delete(channelId);
180
+ }
181
+ reconnectAttempts.delete(channelId);
182
+ // 3. 消息容器 DOM — 真从 #messages 里移除, 不只是隐藏
183
+ const container = messagesContainers.get(channelId);
184
+ if (container && container.parentNode) {
185
+ container.parentNode.removeChild(container);
186
+ }
187
+ messagesContainers.delete(channelId);
188
+ // 4. 缓存的所有 session 消息 (按 channel:session 索引)
189
+ const prefix = `${channelId}:`;
190
+ for (const key of sessionMessages.keys()) {
191
+ if (key === channelId || key.startsWith(prefix)) {
192
+ sessionMessages.delete(key);
193
+ }
194
+ }
195
+ }
196
+
156
197
  async function createNewSession() {
157
198
  if (!currentChannelId) {
158
199
  console.log('[新会话] 没有选中的频道');
@@ -160,18 +201,7 @@ async function createNewSession() {
160
201
  }
161
202
  try {
162
203
  // 保存当前 session 的消息
163
- const channel = channels.find(c => c.id === currentChannelId);
164
- if (channel && currentSessionId) {
165
- const container = messagesContainers.get(currentChannelId);
166
- if (container) {
167
- const messages = Array.from(container.querySelectorAll('.message')).map(msg => ({
168
- type: msg.classList.contains('message-user') ? 'user' : 'ai',
169
- content: msg.querySelector('.message-content')?.textContent || ''
170
- }));
171
- sessionMessages.set(`${currentChannelId}:${currentSessionId}`, messages);
172
- console.log('[新会话] 保存旧 session 消息:', messages.length);
173
- }
174
- }
204
+ saveCurrentSessionMessages();
175
205
 
176
206
  const res = await fetch(`/channels/${currentChannelId}/sessions`, {
177
207
  method: 'POST'
@@ -180,6 +210,7 @@ async function createNewSession() {
180
210
  console.log('[新会话] 创建成功:', data);
181
211
 
182
212
  // 更新本地频道数据
213
+ const channel = channels.find(c => c.id === currentChannelId);
183
214
  if (channel) {
184
215
  if (!channel.sessions) channel.sessions = [];
185
216
  channel.sessions.push(data.session);
@@ -187,7 +218,6 @@ async function createNewSession() {
187
218
  }
188
219
 
189
220
  // 切换到新 session
190
- const oldSessionId = currentSessionId;
191
221
  currentSessionId = data.currentSessionId;
192
222
 
193
223
  // 清空容器并加载新 session
@@ -198,41 +228,345 @@ async function createNewSession() {
198
228
  addMessage('你好!新会话已开始,有什么我可以帮你的吗?', 'ai', false, container);
199
229
  }
200
230
 
231
+ // 展开当前智能体,刷新侧边栏让新会话显示出来
232
+ expandedAgents.add(currentChannelId);
233
+ renderChannels();
234
+
201
235
  console.log('[新会话] 已切换到:', data.currentSessionId);
202
236
  } catch (err) {
203
237
  console.error('Failed to create new session:', err);
204
238
  }
205
239
  }
206
240
 
241
+ async function createNewSessionForChannel(channelId, e) {
242
+ if (e) e.stopPropagation();
243
+ if (!channelId) return;
244
+
245
+ // 给自己创建:复用统一的 createNewSession
246
+ if (channelId === currentChannelId) {
247
+ if (currentSessionId) saveCurrentSessionMessages();
248
+ await createNewSession();
249
+ return;
250
+ }
251
+
252
+ // 给别的智能体创建:后端建好后直接 re-fetch 一次保持本地与后端一致
253
+ try {
254
+ const res = await fetch(`/channels/${channelId}/sessions`, { method: 'POST' });
255
+ if (!res.ok) throw new Error('create session failed');
256
+ const data = await res.json();
257
+ const channel = channels.find(c => c.id === channelId);
258
+ if (channel) {
259
+ if (!channel.sessions) channel.sessions = [];
260
+ channel.sessions.push(data.session);
261
+ channel.currentSessionId = data.currentSessionId;
262
+ }
263
+ expandedAgents.add(channelId);
264
+ renderChannels();
265
+ } catch (err) {
266
+ console.error('Failed to create new session:', err);
267
+ }
268
+ }
269
+
270
+ async function switchSession(channelId, sessionId, e) {
271
+ if (e) e.stopPropagation();
272
+ if (!channelId || !sessionId) return;
273
+ if (channelId === currentChannelId && sessionId === currentSessionId) return;
274
+
275
+ // 先保存当前 session 的本地消息
276
+ if (currentChannelId && currentSessionId) {
277
+ saveCurrentSessionMessages();
278
+ }
279
+
280
+ try {
281
+ const res = await fetch(`/channels/${channelId}/sessions/${sessionId}/switch`, { method: 'POST' });
282
+ if (!res.ok) throw new Error('switch failed');
283
+ const channel = channels.find(c => c.id === channelId);
284
+ if (channel) {
285
+ channel.currentSessionId = sessionId;
286
+ await saveChannels();
287
+ }
288
+
289
+ // 切换到目标 agent + session
290
+ await selectChannel(channelId, sessionId);
291
+ renderChannels();
292
+ } catch (err) {
293
+ console.error('Failed to switch session:', err);
294
+ }
295
+ }
296
+
297
+ async function deleteSession(channelId, sessionId, e) {
298
+ if (e) e.stopPropagation();
299
+ if (!confirm('确定要删除该会话吗?此操作不可撤销。')) return;
300
+ try {
301
+ const res = await fetch(`/channels/${channelId}/sessions/${sessionId}`, { method: 'DELETE' });
302
+ if (!res.ok) {
303
+ const err = await res.json().catch(() => ({}));
304
+ alert(err.error || '删除失败');
305
+ return;
306
+ }
307
+ const data = await res.json();
308
+ const channel = channels.find(c => c.id === channelId);
309
+ if (channel) {
310
+ if (channel.sessions) {
311
+ channel.sessions = channel.sessions.filter(s => s.id !== sessionId);
312
+ }
313
+ if (data.currentSessionId) {
314
+ channel.currentSessionId = data.currentSessionId;
315
+ }
316
+ }
317
+
318
+ // 如果删的是当前打开的会话,切换到新的当前会话
319
+ if (channelId === currentChannelId && sessionId === currentSessionId) {
320
+ if (data.currentSessionId) {
321
+ currentSessionId = data.currentSessionId;
322
+ const container = messagesContainers.get(channelId);
323
+ if (container) container.innerHTML = '';
324
+ await loadSession(channelId);
325
+ }
326
+ }
327
+ renderChannels();
328
+ } catch (err) {
329
+ console.error('Failed to delete session:', err);
330
+ }
331
+ }
332
+
333
+ let _saveSessionMessagesDirty = false;
334
+ let _saveSessionMessagesTimer = null;
335
+ function saveCurrentSessionMessages() {
336
+ if (!currentChannelId || !currentSessionId) return;
337
+ // 内存保护: 多次快速调用合并成一个, 避免在切会话时反复 .textContent 读 DOM
338
+ // (每个 textContent 会序列化整棵子树, 200 条消息 = 几百 MB 临时字符串)
339
+ _saveSessionMessagesDirty = true;
340
+ if (_saveSessionMessagesTimer) return;
341
+ _saveSessionMessagesTimer = setTimeout(() => {
342
+ _saveSessionMessagesTimer = null;
343
+ if (!_saveSessionMessagesDirty) return;
344
+ _saveSessionMessagesDirty = false;
345
+ if (!currentChannelId || !currentSessionId) return;
346
+ const container = messagesContainers.get(currentChannelId);
347
+ if (!container) return;
348
+ const messages = Array.from(container.querySelectorAll('.message')).map(msg => ({
349
+ type: msg.classList.contains('message-user') ? 'user' : 'ai',
350
+ content: msg.querySelector('.message-content')?.textContent || ''
351
+ }));
352
+ if (messages.length > 0) {
353
+ sessionMessages.set(`${currentChannelId}:${currentSessionId}`, messages);
354
+ }
355
+ }, 50);
356
+ }
357
+
358
+ async function saveChannels() {
359
+ // 简单地 re-fetch,保持本地 channels 与服务端一致
360
+ // 改成走 scheduleChannelsRefresh, 多个调用合并成一个请求 — 减少内存峰值和后端压力
361
+ scheduleChannelsRefresh();
362
+ await new Promise(r => setTimeout(r, 600));
363
+ }
364
+
365
+ let channelRefreshTimer = null;
366
+ let channelRefreshInFlight = null;
367
+ function scheduleChannelsRefresh() {
368
+ if (channelRefreshTimer) return;
369
+ channelRefreshTimer = setTimeout(async () => {
370
+ channelRefreshTimer = null;
371
+ if (channelRefreshInFlight) return channelRefreshInFlight;
372
+ channelRefreshInFlight = (async () => {
373
+ try {
374
+ const res = await fetch('/channels');
375
+ if (res.ok) {
376
+ const fresh = await res.json();
377
+ channels = fresh;
378
+ renderChannels();
379
+ }
380
+ } catch (err) {
381
+ console.error('Failed to re-fetch channels:', err);
382
+ } finally {
383
+ channelRefreshInFlight = null;
384
+ }
385
+ })();
386
+ return channelRefreshInFlight;
387
+ }, 800);
388
+ }
389
+
390
+ function toggleAgentExpand(channelId, e) {
391
+ if (e) e.stopPropagation();
392
+ if (expandedAgents.has(channelId)) {
393
+ expandedAgents.delete(channelId);
394
+ } else {
395
+ expandedAgents.add(channelId);
396
+ }
397
+ renderChannels();
398
+ }
399
+
207
400
  function renderChannels() {
208
401
  if (!channelList) return;
209
402
  channelList.innerHTML = '';
210
403
 
211
- // 使用 DocumentFragment 减少 DOM 操作
212
404
  const fragment = document.createDocumentFragment();
213
405
 
406
+ // 滚动可见性监听只绑定一次 (channelList 是同一个 DOM 节点,
407
+ // renderChannels 每次清空 innerHTML 都会重渲, 不能重复 addEventListener)
408
+ if (!channelList._scrollListenersBound) {
409
+ const onUserScroll = () => {
410
+ channelList.classList.add('is-scrolling');
411
+ if (channelList._scrollIdleTimer) clearTimeout(channelList._scrollIdleTimer);
412
+ channelList._scrollIdleTimer = setTimeout(() => {
413
+ channelList.classList.remove('is-scrolling');
414
+ }, 1200);
415
+ };
416
+ channelList.addEventListener('wheel', onUserScroll, { passive: true });
417
+ channelList.addEventListener('touchmove', onUserScroll, { passive: true });
418
+ channelList.addEventListener('keydown', (ev) => {
419
+ if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End'].includes(ev.key)) {
420
+ onUserScroll();
421
+ }
422
+ });
423
+ channelList._scrollListenersBound = true;
424
+ }
425
+
214
426
  channels.forEach(ch => {
215
427
  const li = document.createElement('li');
216
- li.className = `channel-item ${ch.id === currentChannelId ? 'active' : ''}`;
217
- li.onclick = () => {
218
- expandSidebar();
219
- selectChannel(ch.id);
220
- };
221
- li.innerHTML = `
428
+ const isExpanded = expandedAgents.has(ch.id);
429
+ li.className = `agent-group ${isExpanded ? 'expanded' : ''}`;
430
+ li.dataset.channelId = ch.id;
431
+
432
+ // --- 智能体行 ---
433
+ const row = document.createElement('div');
434
+ row.className = `agent-row ${ch.id === currentChannelId ? 'active' : ''}`;
435
+
436
+ // 找到当前智能体(如果它是激活的)的当前 session
437
+ const currentSess = (ch.id === currentChannelId && Array.isArray(ch.sessions))
438
+ ? ch.sessions.find(s => s.id === ch.currentSessionId)
439
+ : null;
440
+ const currentSessLabel = currentSess ? formatSessionName(currentSess) : '';
441
+ const sessionCount = Array.isArray(ch.sessions) ? ch.sessions.length : 0;
442
+
443
+ const walletBadge = ch.walletAddress
444
+ ? `<span class="agent-wallet-badge" title="已绑定钱包: ${escapeHtml(ch.walletAddress)}">⛓</span>`
445
+ : '';
446
+ const toolsBadge = ch.autoInvokeTools
447
+ ? `<span class="agent-tools-badge" title="自动工具调用已开启">⚡</span>`
448
+ : '';
449
+
450
+ row.innerHTML = `
451
+ <svg class="agent-caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
452
+ <polyline points="9 18 15 12 9 6"></polyline>
453
+ </svg>
222
454
  <div class="channel-icon">💬</div>
223
- <span class="channel-name">${ch.name}</span>
224
- <button class="channel-delete" data-id="${ch.id}">×</button>
455
+ <span class="channel-name" title="${escapeHtml(ch.name)}">${escapeHtml(ch.name)}</span>
456
+ <span class="agent-row-meta">
457
+ ${walletBadge}
458
+ ${toolsBadge}
459
+ ${sessionCount > 1 ? `<span class="agent-session-count" title="${sessionCount} 个会话">${sessionCount}</span>` : ''}
460
+ ${currentSessLabel ? `<span class="agent-current-session" title="当前会话:${escapeHtml(currentSessLabel)}">· ${escapeHtml(currentSessLabel)}</span>` : ''}
461
+ <button class="agent-config-btn" title="配置智能体 (钱包 / 工具)">
462
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
463
+ <circle cx="12" cy="12" r="3"></circle>
464
+ <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
465
+ </svg>
466
+ </button>
467
+ <button class="channel-delete" title="删除智能体">×</button>
468
+ <button class="agent-new-session" title="新建会话">
469
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
470
+ <line x1="12" y1="5" x2="12" y2="19"></line>
471
+ <line x1="5" y1="12" x2="19" y2="12"></line>
472
+ </svg>
473
+ </button>
474
+ </span>
225
475
  `;
226
476
 
227
- const deleteBtn = li.querySelector('.channel-delete');
228
- if (deleteBtn) {
229
- deleteBtn.onclick = (e) => deleteChannel(ch.id, e);
477
+ // 行点击:切换展开;点击名字/图标区域则切到该智能体
478
+ row.addEventListener('click', (ev) => {
479
+ // 如果点在删除/新会话/配置按钮上, 单独处理
480
+ if (ev.target.closest('.channel-delete')
481
+ || ev.target.closest('.agent-new-session')
482
+ || ev.target.closest('.agent-config-btn')) return;
483
+ if (ev.target.closest('.agent-caret')) {
484
+ toggleAgentExpand(ch.id, ev);
485
+ return;
486
+ }
487
+ toggleAgentExpand(ch.id, ev);
488
+ if (ch.id !== currentChannelId) {
489
+ expandSidebar();
490
+ selectChannel(ch.id);
491
+ }
492
+ });
493
+
494
+ // 智能体删除
495
+ row.querySelector('.channel-delete').addEventListener('click', (ev) => deleteChannel(ch.id, ev));
496
+ // 新会话按钮
497
+ row.querySelector('.agent-new-session').addEventListener('click', (ev) => createNewSessionForChannel(ch.id, ev));
498
+ // 配置按钮: 打开同一个 modal 编辑已有智能体
499
+ row.querySelector('.agent-config-btn').addEventListener('click', (ev) => {
500
+ ev.stopPropagation();
501
+ openAgentAddModal(ch);
502
+ });
503
+
504
+ li.appendChild(row);
505
+
506
+ // --- Session 列表(仅展开时渲染 DOM)---
507
+ const sessionUl = document.createElement('ul');
508
+ sessionUl.className = 'session-list';
509
+ if (isExpanded) {
510
+ const sessions = Array.isArray(ch.sessions) ? ch.sessions : [];
511
+ sessions.forEach(sess => {
512
+ const sessLi = document.createElement('li');
513
+ const isActive = ch.id === currentChannelId && sess.id === ch.currentSessionId;
514
+ sessLi.className = `session-item ${isActive ? 'active' : ''}`;
515
+ sessLi.innerHTML = `
516
+ <span class="session-name" title="${escapeHtml(formatSessionName(sess))}">${escapeHtml(formatSessionName(sess))}</span>
517
+ <button class="session-delete" title="删除会话">×</button>
518
+ `;
519
+ sessLi.addEventListener('click', (ev) => {
520
+ if (ev.target.closest('.session-delete')) return;
521
+ switchSession(ch.id, sess.id, ev);
522
+ });
523
+ sessLi.querySelector('.session-delete').addEventListener('click', (ev) => deleteSession(ch.id, sess.id, ev));
524
+ sessionUl.appendChild(sessLi);
525
+ });
230
526
  }
527
+ li.appendChild(sessionUl);
231
528
 
232
529
  fragment.appendChild(li);
233
530
  });
234
531
 
235
532
  channelList.appendChild(fragment);
533
+
534
+ // header 钱包徽章计数: 只在 channels 变化时刷新, 避免每次 renderChannels 都重算
535
+ refreshWalletBadge();
536
+
537
+ // 把当前激活的 channel 平滑滚到视口内 — 用户切换后不会看不到
538
+ // 只在非用户主动滚动状态下执行, 避免与正在进行的滚动冲突
539
+ if (currentChannelId) {
540
+ requestAnimationFrame(() => scrollActiveChannelIntoView(false));
541
+ }
542
+ }
543
+
544
+ /** 把当前激活的 channel 滚到侧边栏视口内 */
545
+ function scrollActiveChannelIntoView(smooth = true) {
546
+ if (!channelList || !currentChannelId) return;
547
+ const active = channelList.querySelector(`.agent-group[data-channel-id="${currentChannelId}"]`);
548
+ if (!active) return;
549
+ const listRect = channelList.getBoundingClientRect();
550
+ const itemRect = active.getBoundingClientRect();
551
+ const margin = 24; // 视口上下各留 24px
552
+ if (itemRect.top < listRect.top + margin) {
553
+ channelList.scrollBy({ top: itemRect.top - listRect.top - margin, behavior: smooth ? 'smooth' : 'auto' });
554
+ } else if (itemRect.bottom > listRect.bottom - margin) {
555
+ channelList.scrollBy({ top: itemRect.bottom - listRect.bottom + margin, behavior: smooth ? 'smooth' : 'auto' });
556
+ }
557
+ }
558
+
559
+ function formatSessionName(sess) {
560
+ if (!sess) return '新会话';
561
+ if (sess.preview && sess.preview.trim()) return sess.preview.trim();
562
+ const id = sess.id || '';
563
+ return id ? `会话 ${id.slice(-6)}` : '新会话';
564
+ }
565
+
566
+ function escapeHtml(s) {
567
+ return String(s ?? '').replace(/[&<>"']/g, (c) => ({
568
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
569
+ }[c]));
236
570
  }
237
571
 
238
572
  function renderCollapsedChannels() {
@@ -252,7 +586,7 @@ function ensureMessageContainer(channelId) {
252
586
  }
253
587
 
254
588
  function showChannelView(channelId) {
255
- // Hide all channel message containers
589
+ // Hide all channel message containers (不要 innerHTML='' 销毁, 保留以便快速切换)
256
590
  messagesContainers.forEach((container, cid) => {
257
591
  container.style.display = 'none';
258
592
  });
@@ -261,30 +595,10 @@ function showChannelView(channelId) {
261
595
  if (container) {
262
596
  container.style.display = 'block';
263
597
  }
264
- // Update messagesEl reference for functions that use it directly
265
- messagesEl.innerHTML = '';
266
- if (container) {
267
- messagesEl.appendChild(container);
268
- }
269
598
  }
270
599
 
271
- async function selectChannel(channelId) {
272
- console.log('[selectChannel] 开始切换到:', channelId);
273
-
274
- // 保存当前 session 的消息
275
- if (currentChannelId && currentSessionId) {
276
- const container = messagesContainers.get(currentChannelId);
277
- if (container) {
278
- const messages = Array.from(container.querySelectorAll('.message')).map(msg => ({
279
- type: msg.classList.contains('message-user') ? 'user' : 'ai',
280
- content: msg.querySelector('.message-content')?.textContent || ''
281
- }));
282
- if (messages.length > 0) {
283
- sessionMessages.set(`${currentChannelId}:${currentSessionId}`, messages);
284
- console.log('[selectChannel] 保存 session 消息:', messages.length);
285
- }
286
- }
287
- }
600
+ async function selectChannel(channelId, targetSessionId = null) {
601
+ console.log('[selectChannel] 开始切换到:', channelId, 'targetSession:', targetSessionId);
288
602
 
289
603
  // 立即更新当前频道 ID
290
604
  currentChannelId = channelId;
@@ -294,7 +608,12 @@ async function selectChannel(channelId) {
294
608
  const channel = channels.find(c => c.id === channelId);
295
609
  if (channel) {
296
610
  if (channelNameEl) channelNameEl.textContent = channel.name;
297
- currentSessionId = channel.currentSessionId || 'default';
611
+ currentSessionId = targetSessionId || channel.currentSessionId || 'default';
612
+ if (targetSessionId) {
613
+ channel.currentSessionId = targetSessionId;
614
+ }
615
+ // 自动展开当前智能体的会话列表,让用户能切换会话
616
+ expandedAgents.add(channelId);
298
617
  console.log('[selectChannel] 频道:', channel.name, 'session:', currentSessionId);
299
618
  }
300
619
 
@@ -312,40 +631,30 @@ async function selectChannel(channelId) {
312
631
  connect(channelId);
313
632
  }
314
633
 
315
- // 检查是否有保存的 session 消息
316
- const sessionKey = `${channelId}:${currentSessionId}`;
317
- const savedMessages = sessionMessages.get(sessionKey);
318
-
319
- if (savedMessages && savedMessages.length > 0) {
320
- console.log('[selectChannel] 加载已保存的 session 消息:', savedMessages.length);
321
- container.innerHTML = '';
322
- savedMessages.forEach(msg => {
323
- addMessage(msg.content, msg.type, false, container);
324
- });
325
- } else if (container.innerHTML.trim() === '') {
326
- // 如果容器是空的,加载 session
327
- try {
328
- const res = await fetch(`/sessions/${channelId}`);
329
- const session = await res.json();
330
- if (session.messages && session.messages.length > 0) {
331
- session.messages.forEach(msg => {
332
- addMessage(msg.content, msg.type, false, container);
333
- });
334
- } else {
335
- addMessage('你好!我是 Bolloon Agent。有什么我可以帮你的吗?', 'ai', false, container);
336
- }
337
- } catch (err) {
338
- console.error('[selectChannel] 加载 session 失败:', err);
634
+ // 直接从 server 拉 session 消息 (container 跨 session 共享, 先清空再加载)
635
+ container.innerHTML = '';
636
+ try {
637
+ const res = await fetch(`/sessions/${channelId}?sessionId=${encodeURIComponent(currentSessionId)}`);
638
+ const session = await res.json();
639
+ if (session.messages && session.messages.length > 0) {
640
+ session.messages.forEach(msg => {
641
+ addMessage(msg.content, msg.type, false, container);
642
+ });
643
+ } else {
339
644
  addMessage('你好!我是 Bolloon Agent。有什么我可以帮你的吗?', 'ai', false, container);
340
645
  }
646
+ } catch (err) {
647
+ console.error('[selectChannel] 加载 session 失败:', err);
648
+ addMessage('你好!我是 Bolloon Agent。有什么我可以帮你的吗?', 'ai', false, container);
341
649
  }
342
650
  }
343
651
 
344
- async function loadSession(channelId) {
652
+ async function loadSession(channelId, sessionId = null) {
345
653
  const container = messagesContainers.get(channelId);
346
654
  if (!container) return;
655
+ const targetSessionId = sessionId || currentSessionId || 'default';
347
656
  try {
348
- const res = await fetch(`/sessions/${channelId}`);
657
+ const res = await fetch(`/sessions/${channelId}?sessionId=${encodeURIComponent(targetSessionId)}`);
349
658
  const session = await res.json();
350
659
  container.innerHTML = '';
351
660
  if (session.messages && session.messages.length > 0) {
@@ -364,6 +673,18 @@ async function loadSession(channelId) {
364
673
 
365
674
  function addMessage(content, type, save = true, container) {
366
675
  const msgContainer = container || messagesContainers.get(currentChannelId) || messagesEl;
676
+
677
+ // 浏览器侧内存保护: 单个 channel 的消息容器超过 MAX_MESSAGES_PER_CHANNEL
678
+ // 就从最旧的开始淘汰。SSE 流式场景下不淘汰 (save=true 时不裁剪),
679
+ // 因为流消息一般很短; 只对长会话加载 (save=false) 做上限。
680
+ // 上限是 200 条: 大约相当于 100 轮对话, 足够日常使用, 又把 DOM 控制在 5MB 以内.
681
+ if (!save && msgContainer && msgContainer.children.length > 200) {
682
+ const toRemove = msgContainer.children.length - 200;
683
+ for (let i = 0; i < toRemove; i++) {
684
+ const first = msgContainer.firstElementChild;
685
+ if (first) msgContainer.removeChild(first);
686
+ }
687
+ }
367
688
  // 去重:只有 save=true 时(来自 SSE)才去重,save=false 时(来自 session 加载)直接显示
368
689
  if (save) {
369
690
  const lastContent = type === 'user' ? lastUserCommand : lastAiContent;
@@ -937,6 +1258,12 @@ function connect(channelId) {
937
1258
  reconnectTimers.delete(targetChannelId);
938
1259
  }
939
1260
 
1261
+ // 清除该频道的心跳定时器 (防止多次调用 connect 导致 setInterval 累积)
1262
+ if (heartbeatTimers.has(targetChannelId)) {
1263
+ clearInterval(heartbeatTimers.get(targetChannelId));
1264
+ heartbeatTimers.delete(targetChannelId);
1265
+ }
1266
+
940
1267
  // 关闭该频道的旧连接
941
1268
  if (eventSources.has(targetChannelId)) {
942
1269
  eventSources.get(targetChannelId).close();
@@ -958,24 +1285,54 @@ function connect(channelId) {
958
1285
  reconnectAttempts.set(targetChannelId, 0);
959
1286
  };
960
1287
 
1288
+ // 心跳超时: 如果 60s 没收到任何数据 (含 ping), 强制重建
1289
+ // 覆盖网络半开 / 浏览器没触发 onerror 的情况
1290
+ let lastEventTime = Date.now();
1291
+ const heartbeatTimer = setInterval(() => {
1292
+ if (!eventSources.has(targetChannelId)) {
1293
+ clearInterval(heartbeatTimer);
1294
+ return;
1295
+ }
1296
+ if (Date.now() - lastEventTime > 60000) {
1297
+ console.warn('[SSE] 60s 无数据, 强制重建连接:', targetChannelId);
1298
+ clearInterval(heartbeatTimer);
1299
+ try { eventSource.close(); } catch {}
1300
+ eventSources.delete(targetChannelId);
1301
+ // 退避重连 (有上限)
1302
+ const attempts = (reconnectAttempts.get(targetChannelId) || 0) + 1;
1303
+ reconnectAttempts.set(targetChannelId, attempts);
1304
+ const delay = Math.min(1000 * Math.pow(2, attempts - 1), 15000);
1305
+ const timer = setTimeout(() => connect(targetChannelId), delay);
1306
+ reconnectTimers.set(targetChannelId, timer);
1307
+ }
1308
+ }, 10000);
1309
+
1310
+ // onerror: 不要手动 close — 浏览器 EventSource 会自动重连
1311
+ // 我们只需在 readyState 永久 CLOSED 时 (罕见) 才介入
961
1312
  eventSource.onerror = () => {
962
- console.error('[SSE] 连接错误 channelId:', targetChannelId);
963
- eventSource.close();
964
- eventSources.delete(targetChannelId);
965
- const attempts = (reconnectAttempts.get(targetChannelId) || 0) + 1;
966
- reconnectAttempts.set(targetChannelId, attempts);
967
- const timer = setTimeout(() => connect(targetChannelId), Math.min(5000 * attempts, 30000));
968
- reconnectTimers.set(targetChannelId, timer);
1313
+ console.warn('[SSE] 错误, 浏览器自动重连中:', targetChannelId, 'readyState=', eventSource.readyState);
1314
+ if (eventSource.readyState === EventSource.CLOSED) {
1315
+ // 浏览器放弃重连, 我们接手
1316
+ clearInterval(heartbeatTimer);
1317
+ eventSources.delete(targetChannelId);
1318
+ const attempts = (reconnectAttempts.get(targetChannelId) || 0) + 1;
1319
+ reconnectAttempts.set(targetChannelId, attempts);
1320
+ const delay = Math.min(1000 * Math.pow(2, attempts - 1), 15000);
1321
+ const timer = setTimeout(() => connect(targetChannelId), delay);
1322
+ reconnectTimers.set(targetChannelId, timer);
1323
+ }
969
1324
  };
970
1325
 
971
1326
  eventSource.onmessage = (e) => {
1327
+ lastEventTime = Date.now();
972
1328
  try {
973
1329
  const data = JSON.parse(e.data);
974
1330
  const msgChannelId = data.channelId || targetChannelId;
975
1331
  console.log('[SSE] 收到消息:', data.type, 'channelId:', msgChannelId);
976
1332
 
977
- // 路由消息到正确的频道(即使该频道不是当前视图)
978
- if (msgChannelId !== targetChannelId) {
1333
+ // 路由消息到正确的频道
1334
+ // 只有 envelope.channelId 存在且与目标不同时才丢弃 (空/undefined 视为广播给自己)
1335
+ if (msgChannelId && msgChannelId !== targetChannelId) {
979
1336
  console.log('[SSE] 忽略非目标频道消息');
980
1337
  return;
981
1338
  }
@@ -1000,6 +1357,11 @@ function connect(channelId) {
1000
1357
  handleStatusEvent(data, container);
1001
1358
  } else if (data.type === 'done') {
1002
1359
  hideTyping();
1360
+ // AI 回复完, 把最后一条 ai 消息落盘 (兜底, 避免 server saveSession 漏写)
1361
+ const lastAi = container.querySelector('.message-ai:last-of-type .message-content');
1362
+ if (lastAi) {
1363
+ persistLastMessageToServer('ai', lastAi.textContent || '');
1364
+ }
1003
1365
  } else if (data.type === 'renamed') {
1004
1366
  const channel = channels.find(c => c.id === data.channelId);
1005
1367
  if (channel) {
@@ -1032,6 +1394,9 @@ async function sendMessage() {
1032
1394
  input.value = '';
1033
1395
  showTyping();
1034
1396
 
1397
+ // 立即把用户消息落盘, 避免切走再切回时丢失
1398
+ persistLastMessageToServer('user', text);
1399
+
1035
1400
  // 获取当前频道的 DID
1036
1401
  const channel = channels.find(c => c.id === currentChannelId);
1037
1402
  const channelDid = channel?.did || '';
@@ -1059,6 +1424,21 @@ async function sendMessage() {
1059
1424
  }
1060
1425
  }
1061
1426
 
1427
+ // 主动落盘: 把当前 channelId/sessionId 最后一条消息 PATCH 到 server
1428
+ // fire-and-forget, 失败只打日志, 不影响 UI
1429
+ function persistLastMessageToServer(type, content) {
1430
+ if (!currentChannelId || !currentSessionId) return;
1431
+ fetch(`/sessions/${currentChannelId}/${currentSessionId}`, {
1432
+ method: 'PATCH',
1433
+ headers: { 'Content-Type': 'application/json' },
1434
+ body: JSON.stringify({
1435
+ message: { type, content, timestamp: new Date().toISOString() }
1436
+ })
1437
+ }).catch(err => {
1438
+ console.warn('[persist] 落盘失败:', err);
1439
+ });
1440
+ }
1441
+
1062
1442
  sendBtn.addEventListener('click', sendMessage);
1063
1443
  input.addEventListener('keydown', (e) => {
1064
1444
  if (e.key === 'Enter' && !e.shiftKey) {
@@ -1078,6 +1458,24 @@ if (apiConfigBtn) {
1078
1458
  });
1079
1459
  }
1080
1460
 
1461
+ // 钱包管理按钮
1462
+ const walletBtn = document.getElementById('wallet-btn');
1463
+ const walletBadge = document.getElementById('wallet-badge');
1464
+ if (walletBtn) {
1465
+ walletBtn.addEventListener('click', openWalletModal);
1466
+ }
1467
+ /** 刷新 header 钱包徽章: 统计已绑定钱包的智能体数 */
1468
+ function refreshWalletBadge() {
1469
+ if (!walletBadge) return;
1470
+ const count = channels.filter(c => c.walletAddress).length;
1471
+ if (count > 0) {
1472
+ walletBadge.textContent = String(count);
1473
+ walletBadge.style.display = '';
1474
+ } else {
1475
+ walletBadge.style.display = 'none';
1476
+ }
1477
+ }
1478
+
1081
1479
  if (sidebarToggle) {
1082
1480
  sidebarToggle.addEventListener('click', toggleSidebar);
1083
1481
  }
@@ -1125,12 +1523,6 @@ if (newChannelBtn) {
1125
1523
  });
1126
1524
  }
1127
1525
 
1128
- if (newSessionBtn) {
1129
- newSessionBtn.addEventListener('click', () => {
1130
- createNewSession();
1131
- });
1132
- }
1133
-
1134
1526
  if (newChannelInput) {
1135
1527
  newChannelInput.addEventListener('keydown', (e) => {
1136
1528
  if (e.key === 'Enter') {
@@ -1562,6 +1954,350 @@ connect = async function() {
1562
1954
  };
1563
1955
  };
1564
1956
 
1957
+ // =====================================================
1958
+ // 钱包管理 (header 钱包按钮 → 全局管理面板)
1959
+ // =====================================================
1960
+ const walletModal = document.getElementById('wallet-modal');
1961
+ const walletModalClose = document.getElementById('wallet-modal-close');
1962
+ const walletBindAddress = document.getElementById('wallet-bind-address');
1963
+ const walletGenerateBtn = document.getElementById('wallet-generate-btn');
1964
+ const walletAutoTools = document.getElementById('wallet-auto-tools');
1965
+ const walletBindBtn = document.getElementById('wallet-bind-btn');
1966
+ const walletUnbindBtn = document.getElementById('wallet-unbind-btn');
1967
+ const walletNewInfo = document.getElementById('wallet-new-info');
1968
+ const walletListEl = document.getElementById('wallet-list');
1969
+
1970
+ /** 本次会话生成的私钥, 仅用于提示, 永不上传 */
1971
+ let walletModalPendingSecret = null;
1972
+
1973
+ function openWalletModal() {
1974
+ if (!walletModal) return;
1975
+ walletModalPendingSecret = null;
1976
+ walletNewInfo.style.display = 'none';
1977
+ walletNewInfo.innerHTML = '';
1978
+ walletBindAddress.value = '';
1979
+ // 用当前 channel 的状态预填
1980
+ const ch = channels.find(c => c.id === currentChannelId);
1981
+ if (ch) {
1982
+ walletBindAddress.value = ch.walletAddress || '';
1983
+ walletAutoTools.checked = !!ch.autoInvokeTools;
1984
+ }
1985
+ renderWalletList();
1986
+ walletModal.classList.add('active');
1987
+ }
1988
+
1989
+ function closeWalletModal() {
1990
+ if (!walletModal) return;
1991
+ walletModal.classList.remove('active');
1992
+ walletModalPendingSecret = null;
1993
+ }
1994
+
1995
+ if (walletModalClose) walletModalClose.addEventListener('click', closeWalletModal);
1996
+
1997
+ if (walletGenerateBtn) {
1998
+ walletGenerateBtn.addEventListener('click', () => {
1999
+ const { address, privateKeyHex } = generateLocalWallet();
2000
+ walletBindAddress.value = address;
2001
+ walletModalPendingSecret = privateKeyHex;
2002
+ walletNewInfo.style.display = 'block';
2003
+ walletNewInfo.innerHTML = `
2004
+ ✓ 已生成本地钱包<br>
2005
+ <strong>地址:</strong> <code>${escapeHtml(address)}</code><br>
2006
+ <strong>私钥 (本次会话, 刷新即丢):</strong> <code style="color:#f88;">${escapeHtml(privateKeyHex)}</code><br>
2007
+ <small style="color:#f88;">⚠ 关闭页面后无法找回。仅地址会发送到服务端。</small>
2008
+ `;
2009
+ });
2010
+ }
2011
+
2012
+ if (walletBindBtn) {
2013
+ walletBindBtn.addEventListener('click', async () => {
2014
+ if (!currentChannelId) {
2015
+ alert('请先在侧边栏选择一个智能体');
2016
+ return;
2017
+ }
2018
+ const address = (walletBindAddress.value || '').trim();
2019
+ if (!address) {
2020
+ alert('请输入钱包地址或点击「生成」');
2021
+ return;
2022
+ }
2023
+ try {
2024
+ const res = await fetch(`/channels/${currentChannelId}`, {
2025
+ method: 'PATCH',
2026
+ headers: { 'Content-Type': 'application/json' },
2027
+ body: JSON.stringify({
2028
+ walletAddress: address,
2029
+ autoInvokeTools: !!walletAutoTools.checked
2030
+ })
2031
+ });
2032
+ if (!res.ok) {
2033
+ const err = await res.json().catch(() => ({}));
2034
+ throw new Error(err.error || 'bind failed');
2035
+ }
2036
+ const updated = await res.json();
2037
+ const idx = channels.findIndex(c => c.id === currentChannelId);
2038
+ if (idx >= 0) channels[idx] = updated;
2039
+ renderChannels();
2040
+ renderWalletList();
2041
+ walletModalPendingSecret = null;
2042
+ } catch (err) {
2043
+ alert('绑定失败: ' + err.message);
2044
+ }
2045
+ });
2046
+ }
2047
+
2048
+ if (walletUnbindBtn) {
2049
+ walletUnbindBtn.addEventListener('click', async () => {
2050
+ if (!currentChannelId) {
2051
+ alert('请先选择一个智能体');
2052
+ return;
2053
+ }
2054
+ if (!confirm('解绑当前智能体的钱包?')) return;
2055
+ try {
2056
+ const res = await fetch(`/channels/${currentChannelId}`, {
2057
+ method: 'PATCH',
2058
+ headers: { 'Content-Type': 'application/json' },
2059
+ body: JSON.stringify({ walletAddress: null })
2060
+ });
2061
+ if (!res.ok) throw new Error('unbind failed');
2062
+ const updated = await res.json();
2063
+ const idx = channels.findIndex(c => c.id === currentChannelId);
2064
+ if (idx >= 0) channels[idx] = updated;
2065
+ walletBindAddress.value = '';
2066
+ renderChannels();
2067
+ renderWalletList();
2068
+ } catch (err) {
2069
+ alert('解绑失败: ' + err.message);
2070
+ }
2071
+ });
2072
+ }
2073
+
2074
+ /** 渲染"所有已绑定钱包"列表 */
2075
+ function renderWalletList() {
2076
+ if (!walletListEl) return;
2077
+ const bound = channels.filter(c => c.walletAddress);
2078
+ if (bound.length === 0) {
2079
+ walletListEl.innerHTML = '<div class="wallet-empty">暂未绑定钱包</div>';
2080
+ return;
2081
+ }
2082
+ // 用 DocumentFragment 避免多次 reflow
2083
+ const frag = document.createDocumentFragment();
2084
+ bound.forEach(ch => {
2085
+ const isActive = ch.id === currentChannelId;
2086
+ const chain = detectChain(ch.walletAddress);
2087
+ const row = document.createElement('div');
2088
+ row.className = 'wallet-row' + (isActive ? ' is-active' : '');
2089
+ row.innerHTML = `
2090
+ <span class="wallet-chain">${escapeHtml(chain)}</span>
2091
+ <div class="wallet-info">
2092
+ <span class="wallet-agent" title="${escapeHtml(ch.name)}">${escapeHtml(ch.name)}</span>
2093
+ <span class="wallet-address" title="${escapeHtml(ch.walletAddress)}">${escapeHtml(ch.walletAddress)}</span>
2094
+ </div>
2095
+ <div class="wallet-actions">
2096
+ <button class="wallet-mini-btn" data-action="copy" data-addr="${escapeHtml(ch.walletAddress)}" title="复制地址">
2097
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
2098
+ <rect x="9" y="9" width="13" height="13" rx="2"></rect>
2099
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
2100
+ </svg>
2101
+ </button>
2102
+ <button class="wallet-mini-btn" data-action="goto" data-id="${escapeHtml(ch.id)}" title="切换到该智能体">
2103
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
2104
+ <path d="M5 12h14M12 5l7 7-7 7"></path>
2105
+ </svg>
2106
+ </button>
2107
+ <button class="wallet-mini-btn" data-action="unbind" data-id="${escapeHtml(ch.id)}" title="解绑">
2108
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
2109
+ <line x1="18" y1="6" x2="6" y2="18"></line>
2110
+ <line x1="6" y1="6" x2="18" y2="18"></line>
2111
+ </svg>
2112
+ </button>
2113
+ </div>
2114
+ `;
2115
+ frag.appendChild(row);
2116
+ });
2117
+ walletListEl.innerHTML = '';
2118
+ walletListEl.appendChild(frag);
2119
+
2120
+ // 事件委托: 一次绑定处理三个动作
2121
+ walletListEl.onclick = async (ev) => {
2122
+ const btn = ev.target.closest('button[data-action]');
2123
+ if (!btn) return;
2124
+ const action = btn.dataset.action;
2125
+ if (action === 'copy') {
2126
+ try {
2127
+ await navigator.clipboard.writeText(btn.dataset.addr);
2128
+ btn.style.background = 'var(--accent)';
2129
+ btn.style.color = 'var(--bg)';
2130
+ setTimeout(() => { btn.style.background = ''; btn.style.color = ''; }, 800);
2131
+ } catch {}
2132
+ } else if (action === 'goto') {
2133
+ closeWalletModal();
2134
+ selectChannel(btn.dataset.id);
2135
+ } else if (action === 'unbind') {
2136
+ if (!confirm('解绑该智能体的钱包?')) return;
2137
+ try {
2138
+ const res = await fetch(`/channels/${btn.dataset.id}`, {
2139
+ method: 'PATCH',
2140
+ headers: { 'Content-Type': 'application/json' },
2141
+ body: JSON.stringify({ walletAddress: null })
2142
+ });
2143
+ if (!res.ok) throw new Error('unbind failed');
2144
+ const updated = await res.json();
2145
+ const idx = channels.findIndex(c => c.id === btn.dataset.id);
2146
+ if (idx >= 0) channels[idx] = updated;
2147
+ renderChannels();
2148
+ renderWalletList();
2149
+ if (btn.dataset.id === currentChannelId) walletBindAddress.value = '';
2150
+ } catch (err) {
2151
+ alert('解绑失败: ' + err.message);
2152
+ }
2153
+ }
2154
+ };
2155
+ }
2156
+
2157
+ function detectChain(addr) {
2158
+ if (!addr) return '?';
2159
+ if (/^0x[0-9a-fA-F]{40}$/.test(addr)) return 'EVM';
2160
+ if (/^0x[0-9a-fA-F]{64}$/.test(addr)) return 'SUI';
2161
+ if (/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(addr)) return 'SOL';
2162
+ return '?';
2163
+ }
2164
+
1565
2165
  // 启动应用
1566
2166
  init();
1567
2167
 
2168
+ // =====================================================
2169
+ // 智能体目录:catalog add 按钮 + 钱包注册 + 自动工具调用
2170
+ // =====================================================
2171
+ const catalogAddBtn = document.getElementById('catalog-add-btn');
2172
+ const agentAddModal = document.getElementById('agent-add-modal');
2173
+ const agentAddTitle = document.getElementById('agent-add-title');
2174
+ const agentAddModalClose = document.getElementById('agent-add-modal-close');
2175
+ const agentAddName = document.getElementById('agent-add-name');
2176
+ const agentAddWallet = document.getElementById('agent-add-wallet');
2177
+ const agentAddAutoTools = document.getElementById('agent-add-auto-tools');
2178
+ const agentAddConfirmBtn = document.getElementById('agent-add-confirm-btn');
2179
+ const agentAddCancelBtn = document.getElementById('agent-add-cancel-btn');
2180
+ const agentAddWalletInfo = document.getElementById('agent-add-wallet-info');
2181
+ const agentGenerateWalletBtn = document.getElementById('agent-generate-wallet-btn');
2182
+
2183
+ /** 客户端只为提示, 不向服务端发送私钥 */
2184
+ let pendingWalletSecret = null;
2185
+
2186
+ function openAgentAddModal(existingChannel) {
2187
+ if (!agentAddModal) return;
2188
+ if (existingChannel) {
2189
+ agentAddTitle.textContent = '配置智能体:' + existingChannel.name;
2190
+ agentAddName.value = existingChannel.name || '';
2191
+ agentAddName.readOnly = true; // 改名走 PATCH
2192
+ agentAddWallet.value = existingChannel.walletAddress || '';
2193
+ agentAddAutoTools.checked = !!existingChannel.autoInvokeTools;
2194
+ agentAddConfirmBtn.dataset.mode = 'update';
2195
+ agentAddConfirmBtn.dataset.channelId = existingChannel.id;
2196
+ } else {
2197
+ agentAddTitle.textContent = '添加智能体';
2198
+ agentAddName.value = '';
2199
+ agentAddName.readOnly = false;
2200
+ agentAddWallet.value = '';
2201
+ agentAddAutoTools.checked = true;
2202
+ agentAddConfirmBtn.dataset.mode = 'create';
2203
+ delete agentAddConfirmBtn.dataset.channelId;
2204
+ }
2205
+ agentAddWalletInfo.style.display = 'none';
2206
+ agentAddWalletInfo.innerHTML = '';
2207
+ pendingWalletSecret = null;
2208
+ agentAddModal.classList.add('active');
2209
+ }
2210
+
2211
+ function closeAgentAddModal() {
2212
+ if (!agentAddModal) return;
2213
+ agentAddModal.classList.remove('active');
2214
+ pendingWalletSecret = null;
2215
+ }
2216
+
2217
+ /** 本地生成一个 EVM 风格地址 — 仅用于演示; 生产应使用 ethers/wagmi 等真实库 */
2218
+ function generateLocalWallet() {
2219
+ const bytes = new Uint8Array(32);
2220
+ crypto.getRandomValues(bytes);
2221
+ const privateKeyHex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
2222
+ // 简化: 取末 20 字节当 address, 不做 keccak, 仅作为占位
2223
+ const addrBytes = bytes.slice(12);
2224
+ const address = '0x' + Array.from(addrBytes, b => b.toString(16).padStart(2, '0')).join('');
2225
+ return { address, privateKeyHex };
2226
+ }
2227
+
2228
+ if (agentGenerateWalletBtn) {
2229
+ agentGenerateWalletBtn.addEventListener('click', () => {
2230
+ try {
2231
+ const { address, privateKeyHex } = generateLocalWallet();
2232
+ agentAddWallet.value = address;
2233
+ pendingWalletSecret = privateKeyHex;
2234
+ agentAddWalletInfo.style.display = 'block';
2235
+ agentAddWalletInfo.innerHTML = `
2236
+ ✓ 已生成本地钱包<br>
2237
+ <strong>地址:</strong> <code>${escapeHtml(address)}</code><br>
2238
+ <strong>私钥 (仅本次会话, 刷新即丢):</strong> <code style="color:#f88;">${escapeHtml(privateKeyHex)}</code><br>
2239
+ <small style="color:#f88;">⚠ 请抄写并妥善保存私钥, 关闭页面后无法找回。仅地址会发送到服务端。</small>
2240
+ `;
2241
+ } catch (err) {
2242
+ agentAddWalletInfo.style.display = 'block';
2243
+ agentAddWalletInfo.innerHTML = '✗ 生成钱包失败: ' + escapeHtml(err.message);
2244
+ }
2245
+ });
2246
+ }
2247
+
2248
+ if (catalogAddBtn) {
2249
+ catalogAddBtn.addEventListener('click', () => openAgentAddModal(null));
2250
+ }
2251
+ if (agentAddModalClose) agentAddModalClose.addEventListener('click', closeAgentAddModal);
2252
+ if (agentAddCancelBtn) agentAddCancelBtn.addEventListener('click', closeAgentAddModal);
2253
+
2254
+ if (agentAddConfirmBtn) {
2255
+ agentAddConfirmBtn.addEventListener('click', async () => {
2256
+ const mode = agentAddConfirmBtn.dataset.mode || 'create';
2257
+ const name = (agentAddName.value || '').trim();
2258
+ if (!name && mode === 'create') {
2259
+ alert('请输入智能体名称');
2260
+ return;
2261
+ }
2262
+ const walletAddress = (agentAddWallet.value || '').trim();
2263
+ const autoInvokeTools = !!agentAddAutoTools.checked;
2264
+
2265
+ try {
2266
+ if (mode === 'create') {
2267
+ const res = await fetch('/channels', {
2268
+ method: 'POST',
2269
+ headers: { 'Content-Type': 'application/json' },
2270
+ body: JSON.stringify({
2271
+ name,
2272
+ agentId: currentAgentId,
2273
+ walletAddress: walletAddress || undefined,
2274
+ autoInvokeTools
2275
+ })
2276
+ });
2277
+ if (!res.ok) throw new Error('create failed');
2278
+ const channel = await res.json();
2279
+ channels.push(channel);
2280
+ renderChannels();
2281
+ selectChannel(channel.id);
2282
+ } else {
2283
+ // update
2284
+ const channelId = agentAddConfirmBtn.dataset.channelId;
2285
+ const res = await fetch(`/channels/${channelId}`, {
2286
+ method: 'PATCH',
2287
+ headers: { 'Content-Type': 'application/json' },
2288
+ body: JSON.stringify({ walletAddress: walletAddress || null, autoInvokeTools })
2289
+ });
2290
+ if (!res.ok) throw new Error('update failed');
2291
+ const updated = await res.json();
2292
+ const idx = channels.findIndex(c => c.id === channelId);
2293
+ if (idx >= 0) channels[idx] = updated;
2294
+ renderChannels();
2295
+ }
2296
+ closeAgentAddModal();
2297
+ } catch (err) {
2298
+ console.error('Failed to save agent:', err);
2299
+ alert('保存失败: ' + err.message);
2300
+ }
2301
+ });
2302
+ }
2303
+