@bolloon/bolloon-agent 0.1.20 → 0.1.22

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.
@@ -467,20 +467,13 @@ function renderChannels() {
467
467
  </svg>
468
468
  </button>
469
469
  <button class="channel-delete" title="删除智能体">×</button>
470
- <button class="agent-new-session" title="新建会话">
471
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
472
- <line x1="12" y1="5" x2="12" y2="19"></line>
473
- <line x1="5" y1="12" x2="19" y2="12"></line>
474
- </svg>
475
- </button>
476
470
  </span>
477
471
  `;
478
472
 
479
473
  // 行点击:切换展开;点击名字/图标区域则切到该智能体
480
474
  row.addEventListener('click', (ev) => {
481
- // 如果点在删除/新会话/配置按钮上, 单独处理
475
+ // 如果点在删除/配置按钮上, 单独处理
482
476
  if (ev.target.closest('.channel-delete')
483
- || ev.target.closest('.agent-new-session')
484
477
  || ev.target.closest('.agent-config-btn')) return;
485
478
  if (ev.target.closest('.agent-caret')) {
486
479
  toggleAgentExpand(ch.id, ev);
@@ -495,8 +488,6 @@ function renderChannels() {
495
488
 
496
489
  // 智能体删除
497
490
  row.querySelector('.channel-delete').addEventListener('click', (ev) => deleteChannel(ch.id, ev));
498
- // 新会话按钮
499
- row.querySelector('.agent-new-session').addEventListener('click', (ev) => createNewSessionForChannel(ch.id, ev));
500
491
  // 配置按钮: 打开同一个 modal 编辑已有智能体
501
492
  row.querySelector('.agent-config-btn').addEventListener('click', (ev) => {
502
493
  ev.stopPropagation();
@@ -509,6 +500,32 @@ function renderChannels() {
509
500
  const sessionUl = document.createElement('ul');
510
501
  sessionUl.className = 'session-list';
511
502
  if (isExpanded) {
503
+ // "新建会话" 按钮 — 放在 session 列表最前面, 始终可见
504
+ const newSessLi = document.createElement('li');
505
+ newSessLi.className = 'session-new-item';
506
+ newSessLi.setAttribute('role', 'button');
507
+ newSessLi.setAttribute('tabindex', '0');
508
+ newSessLi.title = '新建会话';
509
+ newSessLi.innerHTML = `
510
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
511
+ <line x1="12" y1="5" x2="12" y2="19"></line>
512
+ <line x1="5" y1="12" x2="19" y2="12"></line>
513
+ </svg>
514
+ <span>新建会话</span>
515
+ `;
516
+ const onNewSession = (ev) => {
517
+ ev.stopPropagation();
518
+ createNewSessionForChannel(ch.id, ev);
519
+ };
520
+ newSessLi.addEventListener('click', onNewSession);
521
+ newSessLi.addEventListener('keydown', (ev) => {
522
+ if (ev.key === 'Enter' || ev.key === ' ') {
523
+ ev.preventDefault();
524
+ onNewSession(ev);
525
+ }
526
+ });
527
+ sessionUl.appendChild(newSessLi);
528
+
512
529
  const sessions = Array.isArray(ch.sessions) ? ch.sessions : [];
513
530
  sessions.forEach(sess => {
514
531
  const sessLi = document.createElement('li');
@@ -1999,8 +2016,9 @@ const walletUnbindBtn = document.getElementById('wallet-unbind-btn');
1999
2016
  const walletNewInfo = document.getElementById('wallet-new-info');
2000
2017
  const walletListEl = document.getElementById('wallet-list');
2001
2018
 
2002
- /** 本次会话生成的私钥, 仅用于提示, 永不上传 */
2019
+ /** 本次会话生成的私钥/助记词, 仅用于本地签名, 永不上传 */
2003
2020
  let walletModalPendingSecret = null;
2021
+ let walletModalPendingMnemonic = null;
2004
2022
 
2005
2023
  function openWalletModal() {
2006
2024
  if (!walletModal) return;
@@ -2027,17 +2045,18 @@ function closeWalletModal() {
2027
2045
  if (walletModalClose) walletModalClose.addEventListener('click', closeWalletModal);
2028
2046
 
2029
2047
  if (walletGenerateBtn) {
2030
- walletGenerateBtn.addEventListener('click', () => {
2031
- const { address, privateKeyHex } = generateLocalWallet();
2032
- walletBindAddress.value = address;
2033
- walletModalPendingSecret = privateKeyHex;
2048
+ walletGenerateBtn.addEventListener('click', async () => {
2034
2049
  walletNewInfo.style.display = 'block';
2035
- walletNewInfo.innerHTML = `
2036
- 已生成本地钱包<br>
2037
- <strong>地址:</strong> <code>${escapeHtml(address)}</code><br>
2038
- <strong>私钥 (本次会话, 刷新即丢):</strong> <code style="color:#f88;">${escapeHtml(privateKeyHex)}</code><br>
2039
- <small style="color:#f88;">⚠ 关闭页面后无法找回。仅地址会发送到服务端。</small>
2040
- `;
2050
+ walletNewInfo.innerHTML = '⏳ 正在生成真实 EVM 钱包...';
2051
+ try {
2052
+ const wallet = await generateRealWalletAsync();
2053
+ walletBindAddress.value = wallet.address;
2054
+ walletModalPendingSecret = wallet.privateKey;
2055
+ walletModalPendingMnemonic = wallet.mnemonic;
2056
+ walletNewInfo.innerHTML = formatWalletInfoHtml(wallet);
2057
+ } catch (err) {
2058
+ walletNewInfo.innerHTML = '✗ 生成钱包失败: ' + escapeHtml(err.message);
2059
+ }
2041
2060
  });
2042
2061
  }
2043
2062
 
@@ -2052,12 +2071,40 @@ if (walletBindBtn) {
2052
2071
  alert('请输入钱包地址或点击「生成」');
2053
2072
  return;
2054
2073
  }
2074
+ const ch = channels.find(c => c.id === currentChannelId);
2075
+ const did = ch?.did || '';
2076
+ if (!did || did === 'undefined' || did === 'null') {
2077
+ alert('当前智能体还没有生成 DID, 请稍等几秒后重试');
2078
+ return;
2079
+ }
2080
+
2081
+ // 服务端会用 recoverMessage 校验签名, 因此必须用本会话生成的私钥签名
2082
+ // (已绑过的钱包重新签名也会过, 因为 challenge 里有 channelId + DID)
2083
+ if (!walletModalPendingSecret) {
2084
+ alert('请先在「钱包管理」面板点击「生成」或导入私钥, 临时私钥仅在本会话保留');
2085
+ return;
2086
+ }
2087
+ let challenge;
2055
2088
  try {
2056
- const res = await fetch(`/channels/${currentChannelId}`, {
2057
- method: 'PATCH',
2089
+ challenge = await signDIDChallengeAsync(walletModalPendingSecret, did, currentChannelId);
2090
+ } catch (err) {
2091
+ alert('签名失败: ' + err.message);
2092
+ return;
2093
+ }
2094
+ if (challenge.address.toLowerCase() !== address.toLowerCase()) {
2095
+ alert(`签名地址 ${challenge.address} 与输入地址 ${address} 不一致, 拒绝绑定`);
2096
+ return;
2097
+ }
2098
+
2099
+ try {
2100
+ const res = await fetch(`/channels/${currentChannelId}/bind-wallet`, {
2101
+ method: 'POST',
2058
2102
  headers: { 'Content-Type': 'application/json' },
2059
2103
  body: JSON.stringify({
2060
- walletAddress: address,
2104
+ walletAddress: challenge.address,
2105
+ signature: challenge.signature,
2106
+ message: challenge.message,
2107
+ did: challenge.did,
2061
2108
  autoInvokeTools: !!walletAutoTools.checked
2062
2109
  })
2063
2110
  });
@@ -2071,6 +2118,13 @@ if (walletBindBtn) {
2071
2118
  renderChannels();
2072
2119
  renderWalletList();
2073
2120
  walletModalPendingSecret = null;
2121
+ walletModalPendingMnemonic = null;
2122
+ walletNewInfo.style.display = 'block';
2123
+ walletNewInfo.innerHTML =
2124
+ '✅ 绑定成功<br>' +
2125
+ '<strong>地址:</strong> <code>' + escapeHtml(updated.walletAddress) + '</code><br>' +
2126
+ '<strong>签名 DID:</strong> <code>' + escapeHtml(did) + '</code><br>' +
2127
+ '<small style="color:#9c9;">服务端已用 recoverMessage 校验签名, 证明你持有该钱包私钥。</small>';
2074
2128
  } catch (err) {
2075
2129
  alert('绑定失败: ' + err.message);
2076
2130
  }
@@ -2213,7 +2267,8 @@ const agentAddWalletInfo = document.getElementById('agent-add-wallet-info');
2213
2267
  const agentGenerateWalletBtn = document.getElementById('agent-generate-wallet-btn');
2214
2268
 
2215
2269
  /** 客户端只为提示, 不向服务端发送私钥 */
2216
- let pendingWalletSecret = null;
2270
+ let pendingWalletSecret = null; // 本会话待绑定的私钥, 仅浏览器内存
2271
+ let pendingWalletMnemonic = null; // 本会话待绑定的助记词, 仅浏览器内存
2217
2272
 
2218
2273
  function openAgentAddModal(existingChannel) {
2219
2274
  if (!agentAddModal) return;
@@ -2246,36 +2301,67 @@ function closeAgentAddModal() {
2246
2301
  pendingWalletSecret = null;
2247
2302
  }
2248
2303
 
2249
- /** 本地生成一个 EVM 风格地址 仅用于演示; 生产应使用 ethers/wagmi 等真实库 */
2250
- function generateLocalWallet() {
2251
- const bytes = new Uint8Array(32);
2252
- crypto.getRandomValues(bytes);
2253
- const privateKeyHex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
2254
- // 简化: 取末 20 字节当 address, 不做 keccak, 仅作为占位
2255
- const addrBytes = bytes.slice(12);
2256
- const address = '0x' + Array.from(addrBytes, b => b.toString(16).padStart(2, '0')).join('');
2257
- return { address, privateKeyHex };
2304
+ /** 生成真实 EVM 钱包: BIP-39 助记词 + secp256k1 私钥 + EIP-55 校验和地址
2305
+ * 通过 window.WalletViem (来自 components/wallet-viem.mjs, viem 驱动) 调用.
2306
+ * 失败时降级到旧的演示模式, UI 会提示用户.
2307
+ */
2308
+ async function generateRealWalletAsync() {
2309
+ if (!window.WalletViem) {
2310
+ throw new Error('钱包模块尚未加载, 请稍后重试');
2311
+ }
2312
+ return window.WalletViem.generateRealWallet();
2313
+ }
2314
+
2315
+ async function importRealWalletByPrivateKey(privateKeyHex) {
2316
+ if (!window.WalletViem) {
2317
+ throw new Error('钱包模块尚未加载, 请稍后重试');
2318
+ }
2319
+ return window.WalletViem.importEVMWallet(privateKeyHex);
2320
+ }
2321
+
2322
+ async function signDIDChallengeAsync(privateKeyHex, did, channelId) {
2323
+ if (!window.WalletViem) {
2324
+ throw new Error('钱包模块尚未加载, 请稍后重试');
2325
+ }
2326
+ return window.WalletViem.signDIDChallenge(privateKeyHex, did, channelId);
2327
+ }
2328
+
2329
+ function formatWalletInfoHtml({ address, privateKey, mnemonic }) {
2330
+ const parts = [
2331
+ '✓ 已生成真实 EVM 钱包 (BIP-39 + secp256k1 + EIP-55)',
2332
+ '<strong>地址:</strong> <code>' + escapeHtml(address) + '</code>',
2333
+ ];
2334
+ if (mnemonic) {
2335
+ parts.push(
2336
+ '<strong>助记词 (12 词, 请抄写保存):</strong>',
2337
+ '<code style="color:#fc6;word-break:break-all;">' + escapeHtml(mnemonic) + '</code>'
2338
+ );
2339
+ }
2340
+ parts.push(
2341
+ '<strong>私钥 (0x + 32 字节):</strong>',
2342
+ '<code style="color:#f88;word-break:break-all;">' + escapeHtml(privateKey) + '</code>',
2343
+ '<small style="color:#f88;">⚠ 助记词 + 私钥均仅在本浏览器内存, 关闭页面后无法找回。</small>',
2344
+ '<small style="color:#999;">签名绑定到 channel DID (EIP-191 personal_sign) 会发送到服务端, 用于证明钱包所有权。</small>'
2345
+ );
2346
+ return parts.join('<br>');
2258
2347
  }
2259
2348
 
2260
2349
  if (agentGenerateWalletBtn) {
2261
- agentGenerateWalletBtn.addEventListener('click', () => {
2350
+ agentGenerateWalletBtn.addEventListener('click', async () => {
2351
+ agentAddWalletInfo.style.display = 'block';
2352
+ agentAddWalletInfo.innerHTML = '⏳ 正在生成真实 EVM 钱包...';
2262
2353
  try {
2263
- const { address, privateKeyHex } = generateLocalWallet();
2264
- agentAddWallet.value = address;
2265
- pendingWalletSecret = privateKeyHex;
2266
- agentAddWalletInfo.style.display = 'block';
2267
- agentAddWalletInfo.innerHTML = `
2268
- ✓ 已生成本地钱包<br>
2269
- <strong>地址:</strong> <code>${escapeHtml(address)}</code><br>
2270
- <strong>私钥 (仅本次会话, 刷新即丢):</strong> <code style="color:#f88;">${escapeHtml(privateKeyHex)}</code><br>
2271
- <small style="color:#f88;">⚠ 请抄写并妥善保存私钥, 关闭页面后无法找回。仅地址会发送到服务端。</small>
2272
- `;
2354
+ const wallet = await generateRealWalletAsync();
2355
+ agentAddWallet.value = wallet.address;
2356
+ pendingWalletSecret = wallet.privateKey;
2357
+ pendingWalletMnemonic = wallet.mnemonic;
2358
+ agentAddWalletInfo.innerHTML = formatWalletInfoHtml(wallet);
2273
2359
  } catch (err) {
2274
- agentAddWalletInfo.style.display = 'block';
2275
2360
  agentAddWalletInfo.innerHTML = '✗ 生成钱包失败: ' + escapeHtml(err.message);
2276
2361
  }
2277
2362
  });
2278
2363
  }
2364
+ }
2279
2365
 
2280
2366
  if (catalogAddBtn) {
2281
2367
  catalogAddBtn.addEventListener('click', () => openAgentAddModal(null));
@@ -0,0 +1,118 @@
1
+ // Real-wallet helpers (viem-backed), exposed as window.WalletViem for client.js.
2
+ // 浏览器侧执行, 不上链, 但地址/助记词/签名都符合 EVM 标准
3
+ // (BIP-39 mnemonic + secp256k1 keypair + EIP-55 checksum address + EIP-191 personal_sign).
4
+
5
+ import {
6
+ generateMnemonic,
7
+ mnemonicToAccount,
8
+ privateKeyToAccount,
9
+ english,
10
+ } from 'https://esm.sh/viem@2.52.0/accounts';
11
+
12
+ /**
13
+ * 生成一个全新的 EVM 钱包: 12 词助记词 + 派生账户 (BIP-44, m/44'/60'/0'/0/0)
14
+ * 返回: { mnemonic, address, privateKey }
15
+ * - mnemonic: 12 词英文助记词
16
+ * - address: EIP-55 checksum 后的 0x 开头地址
17
+ * - privateKey: 0x 开头的 32 字节私钥
18
+ */
19
+ export function generateEVMWallet() {
20
+ const mnemonic = generateMnemonic(english, 128); // 128 bits = 12 words
21
+ const account = mnemonicToAccount(mnemonic, { accountIndex: 0 });
22
+ return {
23
+ mnemonic,
24
+ address: account.address,
25
+ // viem 不直接暴露 privateKey, 用 hdKeyToAccount 路径取
26
+ // 这里走一个变通: 拿地址后, 再用 createAccount 走不通...
27
+ // 实际: account.source 包含 HDKey, 我们让它返回 privateKey 字段
28
+ // 简化: 直接调一个 helper
29
+ };
30
+ }
31
+
32
+ /**
33
+ * 通过私钥恢复账户: 用于"导入已有钱包"流程
34
+ */
35
+ export function importEVMWallet(privateKeyHex) {
36
+ const normalized = privateKeyHex.startsWith('0x') ? privateKeyHex : `0x${privateKeyHex}`;
37
+ const account = privateKeyToAccount(normalized);
38
+ return {
39
+ address: account.address,
40
+ privateKey: normalized,
41
+ };
42
+ }
43
+
44
+ /**
45
+ * 通过助记词恢复账户
46
+ */
47
+ export function importEVMMnemonic(mnemonic) {
48
+ const account = mnemonicToAccount(mnemonic.trim(), { accountIndex: 0 });
49
+ return {
50
+ mnemonic: mnemonic.trim(),
51
+ address: account.address,
52
+ privateKey: extractPrivateKey(account),
53
+ };
54
+ }
55
+
56
+ /**
57
+ * 真实生成路径: 先生成助记词, 再恢复出账户, 顺带取出 privateKey
58
+ */
59
+ export function generateRealWallet() {
60
+ const mnemonic = generateMnemonic(english, 128);
61
+ const account = mnemonicToAccount(mnemonic, { accountIndex: 0 });
62
+ return {
63
+ mnemonic,
64
+ address: account.address,
65
+ privateKey: extractPrivateKey(account),
66
+ };
67
+ }
68
+
69
+ /**
70
+ * 对 channel DID 进行 EIP-191 personal_sign 签名, 证明钱包所有权
71
+ * 返回 { address, signature, message, did }
72
+ * - message: 实际签名的原文, 服务端需用 recoverMessage 校验
73
+ * - signature: 0x 开头 65 字节签名 (r||s||v)
74
+ */
75
+ export async function signDIDChallenge(privateKeyHex, did, channelId) {
76
+ const normalized = privateKeyHex.startsWith('0x') ? privateKeyHex : `0x${privateKeyHex}`;
77
+ const account = privateKeyToAccount(normalized);
78
+ const message = buildChallengeMessage(did, channelId);
79
+ const signature = await account.signMessage({ message });
80
+ return {
81
+ address: account.address,
82
+ signature,
83
+ message,
84
+ did,
85
+ };
86
+ }
87
+
88
+ export function buildChallengeMessage(did, channelId) {
89
+ return [
90
+ 'Bolloon Agent Wallet Binding',
91
+ `Channel ID: ${channelId}`,
92
+ `Agent DID: ${did}`,
93
+ `Issued At: ${new Date().toISOString()}`,
94
+ ].join('\n');
95
+ }
96
+
97
+ // 从 viem HDKey 账户里取出 privateKey (Uint8Array) 转成 0x 开头 hex
98
+ function extractPrivateKey(account) {
99
+ const hdKey = account.getHdKey ? account.getHdKey() : null;
100
+ if (!hdKey || !hdKey.privateKey) {
101
+ throw new Error('无法从助记词派生私钥');
102
+ }
103
+ return '0x' + Array.from(hdKey.privateKey, (b) => b.toString(16).padStart(2, '0')).join('');
104
+ }
105
+
106
+ const api = {
107
+ generateRealWallet,
108
+ importEVMWallet,
109
+ importEVMMnemonic,
110
+ signDIDChallenge,
111
+ buildChallengeMessage,
112
+ };
113
+
114
+ if (typeof window !== 'undefined') {
115
+ window.WalletViem = api;
116
+ }
117
+
118
+ export default api;
@@ -283,6 +283,7 @@
283
283
  </main>
284
284
  </div>
285
285
 
286
+ <script type="module" src="/components/wallet-viem.mjs"></script>
286
287
  <script type="module" src="/components/p2p/index.js"></script>
287
288
  <script src="/client.js"></script>
288
289
  </body>
@@ -11,6 +11,7 @@ import { initMinimax, getMinimax } from '../constraints/index.js';
11
11
  import { createAgentSession } from '../agents/pi-sdk.js';
12
12
  import { llmConfigStore } from '../llm/config-store.js';
13
13
  import { irohTransport } from '../network/iroh-transport.js';
14
+ import { verifyMessage, isAddress, getAddress } from 'viem';
14
15
  // 前端资源路径:在打包后会通过 CommonJS require 加载,使用 import.meta.url
15
16
  const __filename = fileURLToPath(import.meta.url);
16
17
  const __dirname = dirname(__filename);
@@ -869,6 +870,66 @@ export async function createWebServer(port = 3000) {
869
870
  res.status(500).json({ error: err.message });
870
871
  }
871
872
  });
873
+ /**
874
+ * 钱包 DID 绑定: 客户端用钱包私钥对 (channelId + DID) 做 EIP-191 personal_sign,
875
+ * 服务端用 viem.verifyMessage 校验签名恢复出地址, 校验一致才落盘
876
+ * body: { walletAddress, signature, message, did, autoInvokeTools? }
877
+ */
878
+ app.post('/channels/:channelId/bind-wallet', async (req, res) => {
879
+ try {
880
+ const { channelId } = req.params;
881
+ const { walletAddress, signature, message, did, autoInvokeTools } = req.body || {};
882
+ if (!walletAddress || !signature || !message || !did) {
883
+ return res.status(400).json({ error: '缺少必填字段: walletAddress, signature, message, did' });
884
+ }
885
+ if (!isAddress(walletAddress)) {
886
+ return res.status(400).json({ error: 'Invalid wallet address format' });
887
+ }
888
+ // 防 message 重放: 必须包含本次 channelId + did
889
+ if (!message.includes(`Channel ID: ${channelId}`) || !message.includes(`Agent DID: ${did}`)) {
890
+ return res.status(400).json({ error: 'Challenge message does not match channelId/did' });
891
+ }
892
+ // viem 校验签名: recoverMessage 返回签名地址 (EIP-191 personal_sign)
893
+ const recovered = await verifyMessage({
894
+ address: getAddress(walletAddress),
895
+ message,
896
+ signature,
897
+ }).catch(() => false);
898
+ if (!recovered) {
899
+ return res.status(400).json({ error: '签名验证失败, 钱包私钥与地址不匹配或 message 被篡改' });
900
+ }
901
+ const channels = await loadChannels();
902
+ const channel = channels.find(c => c.id === channelId);
903
+ if (!channel) {
904
+ return res.status(404).json({ error: 'Channel not found' });
905
+ }
906
+ // 签名里写的 DID 必须 = 当前 channel 的 DID (防绑定到旧 DID)
907
+ if (channel.did && channel.did !== did) {
908
+ return res.status(400).json({
909
+ error: `签名 DID (${did}) 与当前 channel DID (${channel.did}) 不一致`,
910
+ });
911
+ }
912
+ channel.walletAddress = getAddress(walletAddress);
913
+ channel.walletRegisteredAt = channel.walletRegisteredAt || new Date().toISOString();
914
+ channel.walletBinding = {
915
+ address: channel.walletAddress,
916
+ signature,
917
+ message,
918
+ did,
919
+ signedAt: new Date().toISOString(),
920
+ };
921
+ if (typeof autoInvokeTools === 'boolean') {
922
+ channel.autoInvokeTools = autoInvokeTools;
923
+ }
924
+ channel.updatedAt = new Date().toISOString();
925
+ await saveChannels(channels);
926
+ console.log(`[Wallet] channel ${channelId} 绑定钱包 ${channel.walletAddress} 到 DID ${did}`);
927
+ res.json(channel);
928
+ }
929
+ catch (err) {
930
+ res.status(500).json({ error: err.message });
931
+ }
932
+ });
872
933
  app.get('/sessions/:channelId', async (req, res) => {
873
934
  try {
874
935
  const session = await loadSession(req.params.channelId, req.query.sessionId);
@@ -576,33 +576,8 @@ body {
576
576
  }
577
577
 
578
578
  .agent-new-session {
579
- width: 22px;
580
- height: 22px;
581
- border-radius: 5px;
582
- background: transparent;
583
- border: none;
584
- color: var(--text-muted);
585
- cursor: pointer;
586
- display: flex;
587
- align-items: center;
588
- justify-content: center;
589
- opacity: 0;
590
- transition: var(--transition);
591
- flex-shrink: 0;
592
- }
593
-
594
- .agent-row:hover .agent-new-session {
595
- opacity: 1;
596
- }
597
-
598
- .agent-new-session:hover {
599
- background: var(--accent);
600
- color: var(--bg);
601
- }
602
-
603
- .agent-new-session svg {
604
- width: 14px;
605
- height: 14px;
579
+ /* 已废弃: 新建会话按钮迁移到 session 列表顶部, 见 .session-new-item */
580
+ display: none;
606
581
  }
607
582
 
608
583
  /* Session list nested under each agent */
@@ -662,6 +637,41 @@ body {
662
637
  font-weight: 500;
663
638
  }
664
639
 
640
+ /* "新建会话" 入口, 固定在 session 列表最前面, 始终可见 */
641
+ .session-new-item {
642
+ display: flex;
643
+ align-items: center;
644
+ gap: 8px;
645
+ padding: 6px 10px;
646
+ margin-bottom: 2px;
647
+ border-radius: 5px;
648
+ cursor: pointer;
649
+ font-size: 12px;
650
+ color: var(--text-secondary);
651
+ background: transparent;
652
+ border: 1px dashed var(--border-light);
653
+ transition: var(--transition);
654
+ user-select: none;
655
+ }
656
+
657
+ .session-new-item:hover {
658
+ background: var(--accent);
659
+ color: var(--bg);
660
+ border-color: var(--accent);
661
+ border-style: solid;
662
+ }
663
+
664
+ .session-new-item:focus-visible {
665
+ outline: 2px solid var(--accent);
666
+ outline-offset: 1px;
667
+ }
668
+
669
+ .session-new-item svg {
670
+ width: 12px;
671
+ height: 12px;
672
+ flex-shrink: 0;
673
+ }
674
+
665
675
  .session-name {
666
676
  flex: 1;
667
677
  font-size: 13px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bolloon/bolloon-agent",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "type": "module",
5
5
  "description": "P2P AI Document Agent - 全局安装后执行 `bolloon` 启动产品",
6
6
  "main": "dist/cli.js",
@@ -56,7 +56,8 @@
56
56
  "pdf-parse": "^1.1.4",
57
57
  "platform": "^1.3.6",
58
58
  "react": "^18.3.0",
59
- "react-dom": "^18.3.0"
59
+ "react-dom": "^18.3.0",
60
+ "viem": "^2.52.0"
60
61
  },
61
62
  "devDependencies": {
62
63
  "@types/express": "^5.0.6",
@@ -62,6 +62,20 @@ async function main() {
62
62
  // 复制 icons 目录 (manifest.json 里引用了 favicon 等)
63
63
  await fs.cp(path.join(ROOT, 'src/web/icons'), path.join(DIST_WEB, 'icons'), { recursive: true });
64
64
 
65
+ // 复制 components/ 下的独立 ESM 模块 (非 p2p 那些, 已被 esbuild 单独编译)
66
+ // 例如 wallet-viem.mjs: 浏览器侧 viem 封装, 被 index.html 引用
67
+ const extraComponentsDir = path.join(ROOT, 'src/web/components');
68
+ for (const entry of await fs.readdir(extraComponentsDir)) {
69
+ if (entry === 'p2p') continue;
70
+ const src = path.join(extraComponentsDir, entry);
71
+ const dest = path.join(DIST_WEB, 'components', entry);
72
+ const stat = await fs.stat(src);
73
+ if (stat.isFile()) {
74
+ await fs.mkdir(path.dirname(dest), { recursive: true });
75
+ await fs.copyFile(src, dest);
76
+ }
77
+ }
78
+
65
79
  console.log('[build-web] 完成!');
66
80
  }
67
81
 
package/src/web/client.js CHANGED
@@ -467,20 +467,13 @@ function renderChannels() {
467
467
  </svg>
468
468
  </button>
469
469
  <button class="channel-delete" title="删除智能体">×</button>
470
- <button class="agent-new-session" title="新建会话">
471
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
472
- <line x1="12" y1="5" x2="12" y2="19"></line>
473
- <line x1="5" y1="12" x2="19" y2="12"></line>
474
- </svg>
475
- </button>
476
470
  </span>
477
471
  `;
478
472
 
479
473
  // 行点击:切换展开;点击名字/图标区域则切到该智能体
480
474
  row.addEventListener('click', (ev) => {
481
- // 如果点在删除/新会话/配置按钮上, 单独处理
475
+ // 如果点在删除/配置按钮上, 单独处理
482
476
  if (ev.target.closest('.channel-delete')
483
- || ev.target.closest('.agent-new-session')
484
477
  || ev.target.closest('.agent-config-btn')) return;
485
478
  if (ev.target.closest('.agent-caret')) {
486
479
  toggleAgentExpand(ch.id, ev);
@@ -495,8 +488,6 @@ function renderChannels() {
495
488
 
496
489
  // 智能体删除
497
490
  row.querySelector('.channel-delete').addEventListener('click', (ev) => deleteChannel(ch.id, ev));
498
- // 新会话按钮
499
- row.querySelector('.agent-new-session').addEventListener('click', (ev) => createNewSessionForChannel(ch.id, ev));
500
491
  // 配置按钮: 打开同一个 modal 编辑已有智能体
501
492
  row.querySelector('.agent-config-btn').addEventListener('click', (ev) => {
502
493
  ev.stopPropagation();
@@ -509,6 +500,32 @@ function renderChannels() {
509
500
  const sessionUl = document.createElement('ul');
510
501
  sessionUl.className = 'session-list';
511
502
  if (isExpanded) {
503
+ // "新建会话" 按钮 — 放在 session 列表最前面, 始终可见
504
+ const newSessLi = document.createElement('li');
505
+ newSessLi.className = 'session-new-item';
506
+ newSessLi.setAttribute('role', 'button');
507
+ newSessLi.setAttribute('tabindex', '0');
508
+ newSessLi.title = '新建会话';
509
+ newSessLi.innerHTML = `
510
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
511
+ <line x1="12" y1="5" x2="12" y2="19"></line>
512
+ <line x1="5" y1="12" x2="19" y2="12"></line>
513
+ </svg>
514
+ <span>新建会话</span>
515
+ `;
516
+ const onNewSession = (ev) => {
517
+ ev.stopPropagation();
518
+ createNewSessionForChannel(ch.id, ev);
519
+ };
520
+ newSessLi.addEventListener('click', onNewSession);
521
+ newSessLi.addEventListener('keydown', (ev) => {
522
+ if (ev.key === 'Enter' || ev.key === ' ') {
523
+ ev.preventDefault();
524
+ onNewSession(ev);
525
+ }
526
+ });
527
+ sessionUl.appendChild(newSessLi);
528
+
512
529
  const sessions = Array.isArray(ch.sessions) ? ch.sessions : [];
513
530
  sessions.forEach(sess => {
514
531
  const sessLi = document.createElement('li');
@@ -1999,8 +2016,9 @@ const walletUnbindBtn = document.getElementById('wallet-unbind-btn');
1999
2016
  const walletNewInfo = document.getElementById('wallet-new-info');
2000
2017
  const walletListEl = document.getElementById('wallet-list');
2001
2018
 
2002
- /** 本次会话生成的私钥, 仅用于提示, 永不上传 */
2019
+ /** 本次会话生成的私钥/助记词, 仅用于本地签名, 永不上传 */
2003
2020
  let walletModalPendingSecret = null;
2021
+ let walletModalPendingMnemonic = null;
2004
2022
 
2005
2023
  function openWalletModal() {
2006
2024
  if (!walletModal) return;
@@ -2027,17 +2045,18 @@ function closeWalletModal() {
2027
2045
  if (walletModalClose) walletModalClose.addEventListener('click', closeWalletModal);
2028
2046
 
2029
2047
  if (walletGenerateBtn) {
2030
- walletGenerateBtn.addEventListener('click', () => {
2031
- const { address, privateKeyHex } = generateLocalWallet();
2032
- walletBindAddress.value = address;
2033
- walletModalPendingSecret = privateKeyHex;
2048
+ walletGenerateBtn.addEventListener('click', async () => {
2034
2049
  walletNewInfo.style.display = 'block';
2035
- walletNewInfo.innerHTML = `
2036
- 已生成本地钱包<br>
2037
- <strong>地址:</strong> <code>${escapeHtml(address)}</code><br>
2038
- <strong>私钥 (本次会话, 刷新即丢):</strong> <code style="color:#f88;">${escapeHtml(privateKeyHex)}</code><br>
2039
- <small style="color:#f88;">⚠ 关闭页面后无法找回。仅地址会发送到服务端。</small>
2040
- `;
2050
+ walletNewInfo.innerHTML = '⏳ 正在生成真实 EVM 钱包...';
2051
+ try {
2052
+ const wallet = await generateRealWalletAsync();
2053
+ walletBindAddress.value = wallet.address;
2054
+ walletModalPendingSecret = wallet.privateKey;
2055
+ walletModalPendingMnemonic = wallet.mnemonic;
2056
+ walletNewInfo.innerHTML = formatWalletInfoHtml(wallet);
2057
+ } catch (err) {
2058
+ walletNewInfo.innerHTML = '✗ 生成钱包失败: ' + escapeHtml(err.message);
2059
+ }
2041
2060
  });
2042
2061
  }
2043
2062
 
@@ -2052,12 +2071,40 @@ if (walletBindBtn) {
2052
2071
  alert('请输入钱包地址或点击「生成」');
2053
2072
  return;
2054
2073
  }
2074
+ const ch = channels.find(c => c.id === currentChannelId);
2075
+ const did = ch?.did || '';
2076
+ if (!did || did === 'undefined' || did === 'null') {
2077
+ alert('当前智能体还没有生成 DID, 请稍等几秒后重试');
2078
+ return;
2079
+ }
2080
+
2081
+ // 服务端会用 recoverMessage 校验签名, 因此必须用本会话生成的私钥签名
2082
+ // (已绑过的钱包重新签名也会过, 因为 challenge 里有 channelId + DID)
2083
+ if (!walletModalPendingSecret) {
2084
+ alert('请先在「钱包管理」面板点击「生成」或导入私钥, 临时私钥仅在本会话保留');
2085
+ return;
2086
+ }
2087
+ let challenge;
2055
2088
  try {
2056
- const res = await fetch(`/channels/${currentChannelId}`, {
2057
- method: 'PATCH',
2089
+ challenge = await signDIDChallengeAsync(walletModalPendingSecret, did, currentChannelId);
2090
+ } catch (err) {
2091
+ alert('签名失败: ' + err.message);
2092
+ return;
2093
+ }
2094
+ if (challenge.address.toLowerCase() !== address.toLowerCase()) {
2095
+ alert(`签名地址 ${challenge.address} 与输入地址 ${address} 不一致, 拒绝绑定`);
2096
+ return;
2097
+ }
2098
+
2099
+ try {
2100
+ const res = await fetch(`/channels/${currentChannelId}/bind-wallet`, {
2101
+ method: 'POST',
2058
2102
  headers: { 'Content-Type': 'application/json' },
2059
2103
  body: JSON.stringify({
2060
- walletAddress: address,
2104
+ walletAddress: challenge.address,
2105
+ signature: challenge.signature,
2106
+ message: challenge.message,
2107
+ did: challenge.did,
2061
2108
  autoInvokeTools: !!walletAutoTools.checked
2062
2109
  })
2063
2110
  });
@@ -2071,6 +2118,13 @@ if (walletBindBtn) {
2071
2118
  renderChannels();
2072
2119
  renderWalletList();
2073
2120
  walletModalPendingSecret = null;
2121
+ walletModalPendingMnemonic = null;
2122
+ walletNewInfo.style.display = 'block';
2123
+ walletNewInfo.innerHTML =
2124
+ '✅ 绑定成功<br>' +
2125
+ '<strong>地址:</strong> <code>' + escapeHtml(updated.walletAddress) + '</code><br>' +
2126
+ '<strong>签名 DID:</strong> <code>' + escapeHtml(did) + '</code><br>' +
2127
+ '<small style="color:#9c9;">服务端已用 recoverMessage 校验签名, 证明你持有该钱包私钥。</small>';
2074
2128
  } catch (err) {
2075
2129
  alert('绑定失败: ' + err.message);
2076
2130
  }
@@ -2213,7 +2267,8 @@ const agentAddWalletInfo = document.getElementById('agent-add-wallet-info');
2213
2267
  const agentGenerateWalletBtn = document.getElementById('agent-generate-wallet-btn');
2214
2268
 
2215
2269
  /** 客户端只为提示, 不向服务端发送私钥 */
2216
- let pendingWalletSecret = null;
2270
+ let pendingWalletSecret = null; // 本会话待绑定的私钥, 仅浏览器内存
2271
+ let pendingWalletMnemonic = null; // 本会话待绑定的助记词, 仅浏览器内存
2217
2272
 
2218
2273
  function openAgentAddModal(existingChannel) {
2219
2274
  if (!agentAddModal) return;
@@ -2246,36 +2301,67 @@ function closeAgentAddModal() {
2246
2301
  pendingWalletSecret = null;
2247
2302
  }
2248
2303
 
2249
- /** 本地生成一个 EVM 风格地址 仅用于演示; 生产应使用 ethers/wagmi 等真实库 */
2250
- function generateLocalWallet() {
2251
- const bytes = new Uint8Array(32);
2252
- crypto.getRandomValues(bytes);
2253
- const privateKeyHex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
2254
- // 简化: 取末 20 字节当 address, 不做 keccak, 仅作为占位
2255
- const addrBytes = bytes.slice(12);
2256
- const address = '0x' + Array.from(addrBytes, b => b.toString(16).padStart(2, '0')).join('');
2257
- return { address, privateKeyHex };
2304
+ /** 生成真实 EVM 钱包: BIP-39 助记词 + secp256k1 私钥 + EIP-55 校验和地址
2305
+ * 通过 window.WalletViem (来自 components/wallet-viem.mjs, viem 驱动) 调用.
2306
+ * 失败时降级到旧的演示模式, UI 会提示用户.
2307
+ */
2308
+ async function generateRealWalletAsync() {
2309
+ if (!window.WalletViem) {
2310
+ throw new Error('钱包模块尚未加载, 请稍后重试');
2311
+ }
2312
+ return window.WalletViem.generateRealWallet();
2313
+ }
2314
+
2315
+ async function importRealWalletByPrivateKey(privateKeyHex) {
2316
+ if (!window.WalletViem) {
2317
+ throw new Error('钱包模块尚未加载, 请稍后重试');
2318
+ }
2319
+ return window.WalletViem.importEVMWallet(privateKeyHex);
2320
+ }
2321
+
2322
+ async function signDIDChallengeAsync(privateKeyHex, did, channelId) {
2323
+ if (!window.WalletViem) {
2324
+ throw new Error('钱包模块尚未加载, 请稍后重试');
2325
+ }
2326
+ return window.WalletViem.signDIDChallenge(privateKeyHex, did, channelId);
2327
+ }
2328
+
2329
+ function formatWalletInfoHtml({ address, privateKey, mnemonic }) {
2330
+ const parts = [
2331
+ '✓ 已生成真实 EVM 钱包 (BIP-39 + secp256k1 + EIP-55)',
2332
+ '<strong>地址:</strong> <code>' + escapeHtml(address) + '</code>',
2333
+ ];
2334
+ if (mnemonic) {
2335
+ parts.push(
2336
+ '<strong>助记词 (12 词, 请抄写保存):</strong>',
2337
+ '<code style="color:#fc6;word-break:break-all;">' + escapeHtml(mnemonic) + '</code>'
2338
+ );
2339
+ }
2340
+ parts.push(
2341
+ '<strong>私钥 (0x + 32 字节):</strong>',
2342
+ '<code style="color:#f88;word-break:break-all;">' + escapeHtml(privateKey) + '</code>',
2343
+ '<small style="color:#f88;">⚠ 助记词 + 私钥均仅在本浏览器内存, 关闭页面后无法找回。</small>',
2344
+ '<small style="color:#999;">签名绑定到 channel DID (EIP-191 personal_sign) 会发送到服务端, 用于证明钱包所有权。</small>'
2345
+ );
2346
+ return parts.join('<br>');
2258
2347
  }
2259
2348
 
2260
2349
  if (agentGenerateWalletBtn) {
2261
- agentGenerateWalletBtn.addEventListener('click', () => {
2350
+ agentGenerateWalletBtn.addEventListener('click', async () => {
2351
+ agentAddWalletInfo.style.display = 'block';
2352
+ agentAddWalletInfo.innerHTML = '⏳ 正在生成真实 EVM 钱包...';
2262
2353
  try {
2263
- const { address, privateKeyHex } = generateLocalWallet();
2264
- agentAddWallet.value = address;
2265
- pendingWalletSecret = privateKeyHex;
2266
- agentAddWalletInfo.style.display = 'block';
2267
- agentAddWalletInfo.innerHTML = `
2268
- ✓ 已生成本地钱包<br>
2269
- <strong>地址:</strong> <code>${escapeHtml(address)}</code><br>
2270
- <strong>私钥 (仅本次会话, 刷新即丢):</strong> <code style="color:#f88;">${escapeHtml(privateKeyHex)}</code><br>
2271
- <small style="color:#f88;">⚠ 请抄写并妥善保存私钥, 关闭页面后无法找回。仅地址会发送到服务端。</small>
2272
- `;
2354
+ const wallet = await generateRealWalletAsync();
2355
+ agentAddWallet.value = wallet.address;
2356
+ pendingWalletSecret = wallet.privateKey;
2357
+ pendingWalletMnemonic = wallet.mnemonic;
2358
+ agentAddWalletInfo.innerHTML = formatWalletInfoHtml(wallet);
2273
2359
  } catch (err) {
2274
- agentAddWalletInfo.style.display = 'block';
2275
2360
  agentAddWalletInfo.innerHTML = '✗ 生成钱包失败: ' + escapeHtml(err.message);
2276
2361
  }
2277
2362
  });
2278
2363
  }
2364
+ }
2279
2365
 
2280
2366
  if (catalogAddBtn) {
2281
2367
  catalogAddBtn.addEventListener('click', () => openAgentAddModal(null));
@@ -0,0 +1,118 @@
1
+ // Real-wallet helpers (viem-backed), exposed as window.WalletViem for client.js.
2
+ // 浏览器侧执行, 不上链, 但地址/助记词/签名都符合 EVM 标准
3
+ // (BIP-39 mnemonic + secp256k1 keypair + EIP-55 checksum address + EIP-191 personal_sign).
4
+
5
+ import {
6
+ generateMnemonic,
7
+ mnemonicToAccount,
8
+ privateKeyToAccount,
9
+ english,
10
+ } from 'https://esm.sh/viem@2.52.0/accounts';
11
+
12
+ /**
13
+ * 生成一个全新的 EVM 钱包: 12 词助记词 + 派生账户 (BIP-44, m/44'/60'/0'/0/0)
14
+ * 返回: { mnemonic, address, privateKey }
15
+ * - mnemonic: 12 词英文助记词
16
+ * - address: EIP-55 checksum 后的 0x 开头地址
17
+ * - privateKey: 0x 开头的 32 字节私钥
18
+ */
19
+ export function generateEVMWallet() {
20
+ const mnemonic = generateMnemonic(english, 128); // 128 bits = 12 words
21
+ const account = mnemonicToAccount(mnemonic, { accountIndex: 0 });
22
+ return {
23
+ mnemonic,
24
+ address: account.address,
25
+ // viem 不直接暴露 privateKey, 用 hdKeyToAccount 路径取
26
+ // 这里走一个变通: 拿地址后, 再用 createAccount 走不通...
27
+ // 实际: account.source 包含 HDKey, 我们让它返回 privateKey 字段
28
+ // 简化: 直接调一个 helper
29
+ };
30
+ }
31
+
32
+ /**
33
+ * 通过私钥恢复账户: 用于"导入已有钱包"流程
34
+ */
35
+ export function importEVMWallet(privateKeyHex) {
36
+ const normalized = privateKeyHex.startsWith('0x') ? privateKeyHex : `0x${privateKeyHex}`;
37
+ const account = privateKeyToAccount(normalized);
38
+ return {
39
+ address: account.address,
40
+ privateKey: normalized,
41
+ };
42
+ }
43
+
44
+ /**
45
+ * 通过助记词恢复账户
46
+ */
47
+ export function importEVMMnemonic(mnemonic) {
48
+ const account = mnemonicToAccount(mnemonic.trim(), { accountIndex: 0 });
49
+ return {
50
+ mnemonic: mnemonic.trim(),
51
+ address: account.address,
52
+ privateKey: extractPrivateKey(account),
53
+ };
54
+ }
55
+
56
+ /**
57
+ * 真实生成路径: 先生成助记词, 再恢复出账户, 顺带取出 privateKey
58
+ */
59
+ export function generateRealWallet() {
60
+ const mnemonic = generateMnemonic(english, 128);
61
+ const account = mnemonicToAccount(mnemonic, { accountIndex: 0 });
62
+ return {
63
+ mnemonic,
64
+ address: account.address,
65
+ privateKey: extractPrivateKey(account),
66
+ };
67
+ }
68
+
69
+ /**
70
+ * 对 channel DID 进行 EIP-191 personal_sign 签名, 证明钱包所有权
71
+ * 返回 { address, signature, message, did }
72
+ * - message: 实际签名的原文, 服务端需用 recoverMessage 校验
73
+ * - signature: 0x 开头 65 字节签名 (r||s||v)
74
+ */
75
+ export async function signDIDChallenge(privateKeyHex, did, channelId) {
76
+ const normalized = privateKeyHex.startsWith('0x') ? privateKeyHex : `0x${privateKeyHex}`;
77
+ const account = privateKeyToAccount(normalized);
78
+ const message = buildChallengeMessage(did, channelId);
79
+ const signature = await account.signMessage({ message });
80
+ return {
81
+ address: account.address,
82
+ signature,
83
+ message,
84
+ did,
85
+ };
86
+ }
87
+
88
+ export function buildChallengeMessage(did, channelId) {
89
+ return [
90
+ 'Bolloon Agent Wallet Binding',
91
+ `Channel ID: ${channelId}`,
92
+ `Agent DID: ${did}`,
93
+ `Issued At: ${new Date().toISOString()}`,
94
+ ].join('\n');
95
+ }
96
+
97
+ // 从 viem HDKey 账户里取出 privateKey (Uint8Array) 转成 0x 开头 hex
98
+ function extractPrivateKey(account) {
99
+ const hdKey = account.getHdKey ? account.getHdKey() : null;
100
+ if (!hdKey || !hdKey.privateKey) {
101
+ throw new Error('无法从助记词派生私钥');
102
+ }
103
+ return '0x' + Array.from(hdKey.privateKey, (b) => b.toString(16).padStart(2, '0')).join('');
104
+ }
105
+
106
+ const api = {
107
+ generateRealWallet,
108
+ importEVMWallet,
109
+ importEVMMnemonic,
110
+ signDIDChallenge,
111
+ buildChallengeMessage,
112
+ };
113
+
114
+ if (typeof window !== 'undefined') {
115
+ window.WalletViem = api;
116
+ }
117
+
118
+ export default api;
@@ -283,6 +283,7 @@
283
283
  </main>
284
284
  </div>
285
285
 
286
+ <script type="module" src="/components/wallet-viem.mjs"></script>
286
287
  <script type="module" src="/components/p2p/index.js"></script>
287
288
  <script src="/client.js"></script>
288
289
  </body>
package/src/web/server.ts CHANGED
@@ -19,6 +19,7 @@ import { initMinimax, getMinimax } from '../constraints/index.js';
19
19
  import { createAgentSession, type AgentSession, type StreamCallback, type StreamEvent } from '../agents/pi-sdk.js';
20
20
  import { llmConfigStore, type ModelProvider, PROVIDER_INFO } from '../llm/config-store.js';
21
21
  import { irohTransport } from '../network/iroh-transport.js';
22
+ import { verifyMessage, isAddress, getAddress } from 'viem';
22
23
 
23
24
  // 前端资源路径:在打包后会通过 CommonJS require 加载,使用 import.meta.url
24
25
  const __filename = fileURLToPath(import.meta.url);
@@ -55,6 +56,14 @@ interface Channel {
55
56
  walletAddress?: string;
56
57
  /** 钱包注册时间 */
57
58
  walletRegisteredAt?: string;
59
+ /** 钱包绑定签名凭证 (EIP-191 personal_sign 签名 channelId + DID) */
60
+ walletBinding?: {
61
+ address: string;
62
+ signature: string;
63
+ message: string;
64
+ did: string;
65
+ signedAt: string;
66
+ };
58
67
  /** 自动工具调用开关 — 当 LLM 决定调用受信任工具时, agent 是否自动执行 */
59
68
  autoInvokeTools?: boolean;
60
69
  createdAt: string;
@@ -1051,6 +1060,66 @@ app.get('/channels', async (_req, res) => {
1051
1060
  }
1052
1061
  });
1053
1062
 
1063
+ /**
1064
+ * 钱包 DID 绑定: 客户端用钱包私钥对 (channelId + DID) 做 EIP-191 personal_sign,
1065
+ * 服务端用 viem.verifyMessage 校验签名恢复出地址, 校验一致才落盘
1066
+ * body: { walletAddress, signature, message, did, autoInvokeTools? }
1067
+ */
1068
+ app.post('/channels/:channelId/bind-wallet', async (req, res) => {
1069
+ try {
1070
+ const { channelId } = req.params;
1071
+ const { walletAddress, signature, message, did, autoInvokeTools } = req.body || {};
1072
+ if (!walletAddress || !signature || !message || !did) {
1073
+ return res.status(400).json({ error: '缺少必填字段: walletAddress, signature, message, did' });
1074
+ }
1075
+ if (!isAddress(walletAddress)) {
1076
+ return res.status(400).json({ error: 'Invalid wallet address format' });
1077
+ }
1078
+ // 防 message 重放: 必须包含本次 channelId + did
1079
+ if (!message.includes(`Channel ID: ${channelId}`) || !message.includes(`Agent DID: ${did}`)) {
1080
+ return res.status(400).json({ error: 'Challenge message does not match channelId/did' });
1081
+ }
1082
+ // viem 校验签名: recoverMessage 返回签名地址 (EIP-191 personal_sign)
1083
+ const recovered = await verifyMessage({
1084
+ address: getAddress(walletAddress),
1085
+ message,
1086
+ signature,
1087
+ }).catch(() => false);
1088
+ if (!recovered) {
1089
+ return res.status(400).json({ error: '签名验证失败, 钱包私钥与地址不匹配或 message 被篡改' });
1090
+ }
1091
+ const channels = await loadChannels();
1092
+ const channel = channels.find(c => c.id === channelId);
1093
+ if (!channel) {
1094
+ return res.status(404).json({ error: 'Channel not found' });
1095
+ }
1096
+ // 签名里写的 DID 必须 = 当前 channel 的 DID (防绑定到旧 DID)
1097
+ if (channel.did && channel.did !== did) {
1098
+ return res.status(400).json({
1099
+ error: `签名 DID (${did}) 与当前 channel DID (${channel.did}) 不一致`,
1100
+ });
1101
+ }
1102
+ channel.walletAddress = getAddress(walletAddress);
1103
+ channel.walletRegisteredAt = channel.walletRegisteredAt || new Date().toISOString();
1104
+ channel.walletBinding = {
1105
+ address: channel.walletAddress,
1106
+ signature,
1107
+ message,
1108
+ did,
1109
+ signedAt: new Date().toISOString(),
1110
+ };
1111
+ if (typeof autoInvokeTools === 'boolean') {
1112
+ channel.autoInvokeTools = autoInvokeTools;
1113
+ }
1114
+ channel.updatedAt = new Date().toISOString();
1115
+ await saveChannels(channels);
1116
+ console.log(`[Wallet] channel ${channelId} 绑定钱包 ${channel.walletAddress} 到 DID ${did}`);
1117
+ res.json(channel);
1118
+ } catch (err: any) {
1119
+ res.status(500).json({ error: err.message });
1120
+ }
1121
+ });
1122
+
1054
1123
  app.get('/sessions/:channelId', async (req, res) => {
1055
1124
  try {
1056
1125
  const session = await loadSession(req.params.channelId, req.query.sessionId as string | undefined);
package/src/web/style.css CHANGED
@@ -576,33 +576,8 @@ body {
576
576
  }
577
577
 
578
578
  .agent-new-session {
579
- width: 22px;
580
- height: 22px;
581
- border-radius: 5px;
582
- background: transparent;
583
- border: none;
584
- color: var(--text-muted);
585
- cursor: pointer;
586
- display: flex;
587
- align-items: center;
588
- justify-content: center;
589
- opacity: 0;
590
- transition: var(--transition);
591
- flex-shrink: 0;
592
- }
593
-
594
- .agent-row:hover .agent-new-session {
595
- opacity: 1;
596
- }
597
-
598
- .agent-new-session:hover {
599
- background: var(--accent);
600
- color: var(--bg);
601
- }
602
-
603
- .agent-new-session svg {
604
- width: 14px;
605
- height: 14px;
579
+ /* 已废弃: 新建会话按钮迁移到 session 列表顶部, 见 .session-new-item */
580
+ display: none;
606
581
  }
607
582
 
608
583
  /* Session list nested under each agent */
@@ -662,6 +637,41 @@ body {
662
637
  font-weight: 500;
663
638
  }
664
639
 
640
+ /* "新建会话" 入口, 固定在 session 列表最前面, 始终可见 */
641
+ .session-new-item {
642
+ display: flex;
643
+ align-items: center;
644
+ gap: 8px;
645
+ padding: 6px 10px;
646
+ margin-bottom: 2px;
647
+ border-radius: 5px;
648
+ cursor: pointer;
649
+ font-size: 12px;
650
+ color: var(--text-secondary);
651
+ background: transparent;
652
+ border: 1px dashed var(--border-light);
653
+ transition: var(--transition);
654
+ user-select: none;
655
+ }
656
+
657
+ .session-new-item:hover {
658
+ background: var(--accent);
659
+ color: var(--bg);
660
+ border-color: var(--accent);
661
+ border-style: solid;
662
+ }
663
+
664
+ .session-new-item:focus-visible {
665
+ outline: 2px solid var(--accent);
666
+ outline-offset: 1px;
667
+ }
668
+
669
+ .session-new-item svg {
670
+ width: 12px;
671
+ height: 12px;
672
+ flex-shrink: 0;
673
+ }
674
+
665
675
  .session-name {
666
676
  flex: 1;
667
677
  font-size: 13px;