@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.
- package/dist/web/client.js +132 -46
- package/dist/web/components/wallet-viem.mjs +118 -0
- package/dist/web/index.html +1 -0
- package/dist/web/server.js +61 -0
- package/dist/web/style.css +37 -27
- package/package.json +3 -2
- package/scripts/build-web.ts +14 -0
- package/src/web/client.js +132 -46
- package/src/web/components/wallet-viem.mjs +118 -0
- package/src/web/index.html +1 -0
- package/src/web/server.ts +69 -0
- package/src/web/style.css +37 -27
package/dist/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
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
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
|
-
|
|
2057
|
-
|
|
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
|
-
/**
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
return
|
|
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
|
|
2264
|
-
agentAddWallet.value = address;
|
|
2265
|
-
pendingWalletSecret =
|
|
2266
|
-
|
|
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;
|
package/dist/web/index.html
CHANGED
package/dist/web/server.js
CHANGED
|
@@ -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);
|
package/dist/web/style.css
CHANGED
|
@@ -576,33 +576,8 @@ body {
|
|
|
576
576
|
}
|
|
577
577
|
|
|
578
578
|
.agent-new-session {
|
|
579
|
-
|
|
580
|
-
|
|
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.
|
|
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",
|
package/scripts/build-web.ts
CHANGED
|
@@ -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
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
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
|
-
|
|
2057
|
-
|
|
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
|
-
/**
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
return
|
|
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
|
|
2264
|
-
agentAddWallet.value = address;
|
|
2265
|
-
pendingWalletSecret =
|
|
2266
|
-
|
|
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;
|
package/src/web/index.html
CHANGED
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
|
-
|
|
580
|
-
|
|
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;
|