@bolloon/bolloon-agent 0.1.29 → 0.1.32

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.
@@ -20,9 +20,47 @@
20
20
  <a href="/" class="back-link">← 返回主页</a>
21
21
  </div>
22
22
 
23
- <!-- 提供商列表 -->
24
- <div class="provider-list" id="providerList">
25
- <div class="loading-state">加载中...</div>
23
+ <!-- 标签切换 -->
24
+ <div class="api-tabs">
25
+ <button class="api-tab active" data-tab="llm" onclick="switchTab('llm')">
26
+ <span class="api-tab-icon">💬</span>
27
+ <span>LLM 对话</span>
28
+ </button>
29
+ <button class="api-tab" data-tab="video" onclick="switchTab('video')">
30
+ <span class="api-tab-icon">🎬</span>
31
+ <span>视频生成</span>
32
+ </button>
33
+ <button class="api-tab" data-tab="audio" onclick="switchTab('audio')">
34
+ <span class="api-tab-icon">🎵</span>
35
+ <span>音频生成</span>
36
+ </button>
37
+ </div>
38
+
39
+ <!-- LLM 面板 -->
40
+ <div class="api-panel" id="panel-llm" data-panel="llm">
41
+ <div class="provider-list" id="providerList">
42
+ <div class="loading-state">加载中...</div>
43
+ </div>
44
+ </div>
45
+
46
+ <!-- 视频生成面板 -->
47
+ <div class="api-panel" id="panel-video" data-panel="video" style="display: none;">
48
+ <div class="video-intro">
49
+ <p>视频生成走异步任务流(提交任务 → 轮询结果),与 LLM 实时对话不同。Seedance 由字节火山方舟 (ARK) 提供,支持文生视频 (t2v) 与图生视频 (i2v);MiniMax Video 为同 tab 下的另一选项。</p>
50
+ </div>
51
+ <div class="provider-list" id="videoProviderList">
52
+ <div class="loading-state">加载中...</div>
53
+ </div>
54
+ </div>
55
+
56
+ <!-- 音频生成面板 -->
57
+ <div class="api-panel" id="panel-audio" data-panel="audio" style="display: none;">
58
+ <div class="video-intro">
59
+ <p>音频生成:TTS(文生语音)/ Music(文生音乐)。MiniMax 提供 speech-01 与 music-01 模型,TTS 默认走 OpenAI 兼容 <code>/audio/speech</code> 端点,Music 走 MiniMax 自有 <code>/music_generation</code> 端点。</p>
60
+ </div>
61
+ <div class="provider-list" id="audioProviderList">
62
+ <div class="loading-state">加载中...</div>
63
+ </div>
26
64
  </div>
27
65
 
28
66
  <!-- 配置弹窗 -->
@@ -43,7 +81,7 @@
43
81
  <div class="form-group">
44
82
  <label>API Key</label>
45
83
  <input type="password" id="apiKeyInput" placeholder="输入 API Key">
46
- <p class="form-hint">已有 Key 会保留,输入新值以更新</p>
84
+ <p class="form-hint" id="apiKeyHint">已有 Key 会保留,输入新值以更新</p>
47
85
  </div>
48
86
 
49
87
  <div class="form-group">
@@ -52,11 +90,79 @@
52
90
  <p class="form-hint">留空使用默认地址</p>
53
91
  </div>
54
92
 
55
- <div class="form-row">
93
+ <div class="form-group">
94
+ <label>模型</label>
95
+ <input type="text" id="modelInput" placeholder="如 gpt-4">
96
+ <p class="form-hint" id="modelHint"></p>
97
+ </div>
98
+
99
+ <!-- 视频模型专用字段 -->
100
+ <div class="video-only-fields" style="display: none;">
101
+ <div class="form-row">
102
+ <div class="form-group">
103
+ <label>分辨率</label>
104
+ <select id="resolutionInput">
105
+ <option value="480p">480p</option>
106
+ <option value="720p">720p</option>
107
+ <option value="1080p">1080p</option>
108
+ </select>
109
+ </div>
110
+ <div class="form-group">
111
+ <label>宽高比</label>
112
+ <select id="ratioInput">
113
+ <option value="16:9">16:9</option>
114
+ <option value="9:16">9:16</option>
115
+ <option value="1:1">1:1</option>
116
+ <option value="4:3">4:3</option>
117
+ <option value="3:4">3:4</option>
118
+ <option value="21:9">21:9</option>
119
+ </select>
120
+ </div>
121
+ </div>
122
+ <div class="form-group">
123
+ <label>默认时长(秒)</label>
124
+ <input type="number" id="durationInput" min="3" max="12" step="1" value="5">
125
+ <p class="form-hint">Seedance 支持 3-12 秒</p>
126
+ </div>
127
+ </div>
128
+
129
+ <!-- 音频模型专用字段 -->
130
+ <div class="audio-only-fields" style="display: none;">
56
131
  <div class="form-group">
57
- <label>模型</label>
58
- <input type="text" id="modelInput" placeholder=" gpt-4">
132
+ <label>音色 / 风格</label>
133
+ <input type="text" id="voiceInput" placeholder="male-qn-jingying / female-shaonv ...">
134
+ <p class="form-hint" id="voiceHint"></p>
135
+ </div>
136
+ <div class="form-row">
137
+ <div class="form-group">
138
+ <label>语速</label>
139
+ <input type="number" id="speedInput" min="0.5" max="2.0" step="0.1" value="1.0">
140
+ </div>
141
+ <div class="form-group">
142
+ <label>输出格式</label>
143
+ <select id="formatInput">
144
+ <option value="mp3">mp3</option>
145
+ <option value="pcm">pcm</option>
146
+ <option value="wav">wav</option>
147
+ </select>
148
+ </div>
149
+ </div>
150
+ <div class="form-group" id="audioModeGroup" style="display: none;">
151
+ <label>模式</label>
152
+ <select id="modeInput">
153
+ <option value="instrumental">纯音乐</option>
154
+ <option value="lyrics">带歌词</option>
155
+ </select>
156
+ </div>
157
+ <div class="form-group" id="audioDurationGroup" style="display: none;">
158
+ <label>默认时长(秒)</label>
159
+ <input type="number" id="audioDurationInput" min="5" max="120" step="1" value="30">
160
+ <p class="form-hint">music-01 支持 5-120 秒</p>
59
161
  </div>
162
+ </div>
163
+
164
+ <!-- LLM 专用字段 -->
165
+ <div class="llm-only-fields">
60
166
  <div class="form-group form-group-small">
61
167
  <label>温度</label>
62
168
  <input type="number" id="temperatureInput" value="0.7" min="0" max="2" step="0.1">
@@ -80,49 +186,115 @@
80
186
  </div>
81
187
 
82
188
  <script>
83
- // 当前配置数据
84
- let configData = null;
189
+ // 当前 tab
190
+ let currentTab = 'llm';
191
+
192
+ // 缓存三份配置
193
+ let llmConfigData = null;
194
+ let videoConfigData = null;
195
+ let audioConfigData = null;
85
196
  let currentProvider = null;
197
+ let currentProviderType = 'llm'; // 'llm' | 'video' | 'audio'
198
+
199
+ // ==================== Tab 切换 ====================
200
+ function switchTab(tab) {
201
+ currentTab = tab;
202
+ document.querySelectorAll('.api-tab').forEach(btn => {
203
+ btn.classList.toggle('active', btn.dataset.tab === tab);
204
+ });
205
+ document.querySelectorAll('.api-panel').forEach(panel => {
206
+ panel.style.display = panel.dataset.panel === tab ? 'block' : 'none';
207
+ });
208
+ // 切换 tab 时更新计数 badge
209
+ updateCountBadge();
210
+ }
86
211
 
87
- // 加载配置
88
- async function loadConfig() {
212
+ function updateCountBadge() {
213
+ const data = currentTab === 'llm' ? llmConfigData :
214
+ currentTab === 'video' ? videoConfigData : audioConfigData;
215
+ if (!data) {
216
+ document.getElementById('configCount').textContent = '加载中...';
217
+ return;
218
+ }
219
+ let configured = 0, total = 0;
220
+ for (const key in data.providers) {
221
+ total++;
222
+ const p = data.providers[key];
223
+ if (currentTab === 'llm') {
224
+ if (p.enabled && p.apiKey) configured++;
225
+ } else {
226
+ const info = data.providerInfo[key] || {};
227
+ if (p.enabled && (!info.requiresApiKey || p.apiKey)) configured++;
228
+ }
229
+ }
230
+ const labelMap = { llm: '', video: '视频', audio: '音频' };
231
+ const label = labelMap[currentTab] || '';
232
+ document.getElementById('configCount').textContent = configured + '/' + total + ' ' + label + '已配置';
233
+ }
234
+
235
+ // ==================== 加载 ====================
236
+ async function loadAll() {
237
+ await Promise.all([loadLLMConfig(), loadVideoConfig(), loadAudioConfig()]);
238
+ }
239
+
240
+ async function loadLLMConfig() {
89
241
  try {
90
242
  const resp = await fetch('/api/llm-config');
91
- configData = await resp.json();
92
- renderProviders();
243
+ llmConfigData = await resp.json();
244
+ renderProviders('llm');
245
+ if (currentTab === 'llm') updateCountBadge();
93
246
  } catch (err) {
94
247
  document.getElementById('providerList').innerHTML =
95
248
  '<div class="error-state">加载失败: ' + err.message + '</div>';
96
249
  }
97
250
  }
98
251
 
99
- // 渲染提供商列表
100
- function renderProviders() {
101
- const list = document.getElementById('providerList');
102
- const providers = configData.providers;
103
- const info = configData.providerInfo;
252
+ async function loadVideoConfig() {
253
+ try {
254
+ const resp = await fetch('/api/video-config');
255
+ videoConfigData = await resp.json();
256
+ renderProviders('video');
257
+ if (currentTab === 'video') updateCountBadge();
258
+ } catch (err) {
259
+ document.getElementById('videoProviderList').innerHTML =
260
+ '<div class="error-state">加载失败: ' + err.message + '</div>';
261
+ }
262
+ }
104
263
 
105
- // 计算已配置数量
106
- let configured = 0;
107
- let total = 0;
108
- for (const key in providers) {
109
- total++;
110
- if (providers[key].enabled && providers[key].apiKey) {
111
- configured++;
112
- }
264
+ async function loadAudioConfig() {
265
+ try {
266
+ const resp = await fetch('/api/audio-config');
267
+ audioConfigData = await resp.json();
268
+ renderProviders('audio');
269
+ if (currentTab === 'audio') updateCountBadge();
270
+ } catch (err) {
271
+ document.getElementById('audioProviderList').innerHTML =
272
+ '<div class="error-state">加载失败: ' + err.message + '</div>';
113
273
  }
114
- document.getElementById('configCount').textContent = configured + '/' + total + ' 已配置';
274
+ }
275
+
276
+ // ==================== 渲染 ====================
277
+ function renderProviders(type) {
278
+ const data = type === 'llm' ? llmConfigData :
279
+ type === 'video' ? videoConfigData : audioConfigData;
280
+ const listEl = document.getElementById(
281
+ type === 'llm' ? 'providerList' :
282
+ type === 'video' ? 'videoProviderList' : 'audioProviderList'
283
+ );
284
+ if (!data) return;
285
+
286
+ const providers = data.providers;
287
+ const info = data.providerInfo;
288
+ const isActive = (key) => data.activeProvider === key;
115
289
 
116
- // 生成卡片
117
290
  let html = '';
118
291
  for (const key in providers) {
119
292
  const p = providers[key];
120
293
  const i = info[key] || { name: key, description: '供应商', requiresApiKey: true };
121
- const isActive = configData.activeProvider === key;
294
+ const active = isActive(key);
122
295
 
123
- // 状态判断
124
296
  let statusText, statusClass;
125
- if (isActive) {
297
+ if (active) {
126
298
  statusText = '使用中';
127
299
  statusClass = 'status-accent';
128
300
  } else if (p.enabled && p.apiKey) {
@@ -136,16 +308,16 @@
136
308
  statusClass = 'status-muted';
137
309
  }
138
310
 
139
- // 配置状态
140
311
  const isConfigured = p.enabled && p.apiKey;
141
312
 
142
313
  html += `
143
- <div class="provider-card ${isActive ? 'active' : ''}" onclick="openModal('${key}')">
314
+ <div class="provider-card ${active ? 'active' : ''}" onclick="openModal('${type}', '${key}')">
144
315
  <div class="provider-header">
145
316
  <div class="provider-icon">${i.name.charAt(0).toUpperCase()}</div>
146
317
  <div class="provider-info">
147
318
  <h3 class="provider-name">${i.name}</h3>
148
319
  <p class="provider-desc">${i.description}</p>
320
+ ${i.docs ? '<a class="provider-docs" href="' + i.docs + '" target="_blank" onclick="event.stopPropagation()">📖 文档</a>' : ''}
149
321
  </div>
150
322
  <div class="status-badge ${statusClass}">
151
323
  <span class="status-dot"></span>
@@ -163,35 +335,75 @@
163
335
  </div>
164
336
  `;
165
337
  }
166
- list.innerHTML = html;
338
+ listEl.innerHTML = html;
167
339
  }
168
340
 
169
- // 打开弹窗
170
- function openModal(providerKey) {
341
+ // ==================== 弹窗 ====================
342
+ function openModal(type, providerKey) {
343
+ currentProviderType = type;
171
344
  currentProvider = providerKey;
172
- const p = configData.providers[providerKey];
173
- const i = configData.providerInfo[providerKey] || { name: providerKey, description: '供应商' };
345
+ const data = type === 'llm' ? llmConfigData :
346
+ type === 'video' ? videoConfigData : audioConfigData;
347
+ const p = data.providers[providerKey];
348
+ const i = data.providerInfo[providerKey] || { name: providerKey, description: '供应商' };
174
349
 
175
350
  document.getElementById('modalIcon').textContent = i.name.charAt(0).toUpperCase();
176
351
  document.getElementById('modalTitle').textContent = '配置 ' + i.name;
177
352
  document.getElementById('modalSubtitle').textContent = i.description;
178
353
 
179
354
  document.getElementById('apiKeyInput').value = '';
355
+ document.getElementById('apiKeyHint').textContent = p.apiKey
356
+ ? '当前已配置 (***' + (p.apiKey.slice(-4) || '') + '),输入新值以更新'
357
+ : '输入 API Key';
180
358
  document.getElementById('baseUrlInput').value = p.baseUrl || '';
181
359
  document.getElementById('modelInput').value = p.model || '';
182
- document.getElementById('temperatureInput').value = p.temperature || 0.7;
360
+
361
+ // 视频/音频/LLM 专用字段显隐
362
+ const isVideo = type === 'video';
363
+ const isAudio = type === 'audio';
364
+ document.querySelector('.video-only-fields').style.display = isVideo ? 'block' : 'none';
365
+ document.querySelector('.audio-only-fields').style.display = isAudio ? 'block' : 'none';
366
+ document.querySelector('.llm-only-fields').style.display = (!isVideo && !isAudio) ? 'block' : 'none';
367
+
368
+ if (isVideo) {
369
+ document.getElementById('resolutionInput').value = p.resolution || '720p';
370
+ document.getElementById('ratioInput').value = p.ratio || '16:9';
371
+ document.getElementById('durationInput').value = p.duration || 5;
372
+ document.getElementById('modelHint').textContent = '示例: doubao-seedance-1-0-lite-t2v-250428 (文生视频) 或 ...-i2v-... (图生视频)';
373
+ } else if (isAudio) {
374
+ const isMusic = (i.kind === 'music');
375
+ // TTS 字段
376
+ document.getElementById('voiceInput').parentElement.style.display = isMusic ? 'none' : 'block';
377
+ document.getElementById('speedInput').parentElement.parentElement.style.display = isMusic ? 'none' : 'flex';
378
+ document.getElementById('modeInput').parentElement.style.display = isMusic ? 'block' : 'none';
379
+ document.getElementById('audioDurationInput').parentElement.style.display = isMusic ? 'block' : 'none';
380
+
381
+ if (!isMusic) {
382
+ document.getElementById('voiceInput').value = p.voice || 'male-qn-jingying';
383
+ document.getElementById('voiceHint').textContent = '常用音色: male-qn-jingying, female-shaonv, female-yujie, presenter_male, presenter_female';
384
+ document.getElementById('speedInput').value = p.speed || 1.0;
385
+ document.getElementById('formatInput').value = p.format || 'mp3';
386
+ } else {
387
+ document.getElementById('modeInput').value = p.mode || 'instrumental';
388
+ document.getElementById('audioDurationInput').value = p.duration || 30;
389
+ }
390
+ document.getElementById('modelHint').textContent = isMusic
391
+ ? '示例: music-01'
392
+ : '示例: speech-01 (TTS) / asr-01 (语音转写)';
393
+ } else {
394
+ document.getElementById('temperatureInput').value = p.temperature || 0.7;
395
+ }
183
396
 
184
397
  document.getElementById('testResult').style.display = 'none';
185
398
  document.getElementById('configModal').style.display = 'flex';
186
399
  }
187
400
 
188
- // 关闭弹窗
189
401
  function closeModal() {
190
402
  document.getElementById('configModal').style.display = 'none';
191
403
  currentProvider = null;
192
404
  }
193
405
 
194
- // 测试连接
406
+ // ==================== 测试连接 ====================
195
407
  async function testConnection() {
196
408
  const btn = document.getElementById('testBtn');
197
409
  const result = document.getElementById('testResult');
@@ -200,8 +412,12 @@
200
412
  btn.innerHTML = '<span class="spinner"></span> 测试中...';
201
413
  result.style.display = 'none';
202
414
 
415
+ const endpoint = currentProviderType === 'llm' ? '/api/llm-test' :
416
+ currentProviderType === 'video' ? '/api/video-test' :
417
+ '/api/audio-test';
418
+
203
419
  try {
204
- const resp = await fetch('/api/llm-test', {
420
+ const resp = await fetch(endpoint, {
205
421
  method: 'POST',
206
422
  headers: { 'Content-Type': 'application/json' },
207
423
  body: JSON.stringify({ provider: currentProvider })
@@ -222,44 +438,71 @@
222
438
  btn.innerHTML = '⚡ 测试连接';
223
439
  }
224
440
 
225
- // 保存配置
441
+ // ==================== 保存 ====================
226
442
  document.getElementById('configForm').onsubmit = async function(e) {
227
443
  e.preventDefault();
228
444
 
445
+ const data = currentProviderType === 'llm' ? llmConfigData : videoConfigData;
446
+ const existing = data.providers[currentProvider];
447
+ const apiKeyVal = document.getElementById('apiKeyInput').value;
448
+
229
449
  const updateData = {
230
450
  enabled: true,
231
- apiKey: document.getElementById('apiKeyInput').value || configData.providers[currentProvider].apiKey || '',
232
- baseUrl: document.getElementById('baseUrlInput').value || configData.providers[currentProvider].baseUrl || '',
233
- model: document.getElementById('modelInput').value || configData.providers[currentProvider].model || '',
234
- temperature: parseFloat(document.getElementById('temperatureInput').value) || 0.7
451
+ apiKey: apiKeyVal || existing.apiKey || '',
452
+ baseUrl: document.getElementById('baseUrlInput').value || existing.baseUrl || '',
453
+ model: document.getElementById('modelInput').value || existing.model || ''
235
454
  };
236
455
 
456
+ if (currentProviderType === 'video') {
457
+ updateData.resolution = document.getElementById('resolutionInput').value;
458
+ updateData.ratio = document.getElementById('ratioInput').value;
459
+ updateData.duration = parseInt(document.getElementById('durationInput').value) || 5;
460
+ } else if (currentProviderType === 'audio') {
461
+ const info = audioConfigData.providerInfo[currentProvider] || {};
462
+ const isMusic = info.kind === 'music';
463
+ if (isMusic) {
464
+ updateData.mode = document.getElementById('modeInput').value;
465
+ updateData.duration = parseInt(document.getElementById('audioDurationInput').value) || 30;
466
+ } else {
467
+ updateData.voice = document.getElementById('voiceInput').value;
468
+ updateData.speed = parseFloat(document.getElementById('speedInput').value) || 1.0;
469
+ updateData.format = document.getElementById('formatInput').value;
470
+ }
471
+ } else {
472
+ updateData.temperature = parseFloat(document.getElementById('temperatureInput').value) || 0.7;
473
+ }
474
+
475
+ const endpoint = currentProviderType === 'llm' ? '/api/llm-config' :
476
+ currentProviderType === 'video' ? '/api/video-config' :
477
+ '/api/audio-config';
478
+
237
479
  try {
238
- await fetch('/api/llm-config', {
480
+ await fetch(endpoint, {
239
481
  method: 'POST',
240
482
  headers: { 'Content-Type': 'application/json' },
241
483
  body: JSON.stringify({ provider: currentProvider, config: updateData })
242
484
  });
243
485
 
244
- // 显示保存成功
245
486
  const saveBtn = this.querySelector('.btn-save');
246
487
  saveBtn.textContent = '✓ 已保存';
247
488
  setTimeout(() => {
248
489
  closeModal();
249
- loadConfig();
490
+ if (currentProviderType === 'llm') loadLLMConfig();
491
+ else if (currentProviderType === 'video') loadVideoConfig();
492
+ else loadAudioConfig();
250
493
  }, 500);
251
494
  } catch (err) {
252
495
  alert('保存失败: ' + err.message);
253
496
  }
254
497
  };
255
498
 
256
- // 点击遮罩关闭弹窗
499
+ // ==================== 关闭弹窗 ====================
257
500
  document.getElementById('configModal').onclick = function(e) {
258
501
  if (e.target === this) closeModal();
259
502
  };
260
503
 
261
504
  // 启动
262
- loadConfig();
505
+ loadAll();
263
506
  </script>
264
507
  </body>
265
- </html>
508
+ </html>
@@ -2862,43 +2862,71 @@ function openRemoteChannelChat(peerPublicKey, channelId, channelName) {
2862
2862
  loadHistory();
2863
2863
  }
2864
2864
 
2865
- // Phase 3: 我的 ID 按钮 → 弹窗显示并支持复制自己的 P2PDirect publicKey
2865
+ // Phase 3: 我的 ID 按钮 → modal (避免 confirm 在某些环境被禁用)
2866
2866
  const showMyIdBtn = document.getElementById('show-my-p2p-id-btn');
2867
2867
  if (showMyIdBtn) {
2868
2868
  showMyIdBtn.addEventListener('click', async (e) => {
2869
2869
  e.stopPropagation();
2870
+ // 移除已有 modal
2871
+ document.getElementById('my-p2p-id-modal')?.remove();
2872
+ // 立即弹出 loading 状态 modal
2873
+ const html = `
2874
+ <div id="my-p2p-id-modal" style="position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:10003;display:flex;align-items:center;justify-content:center;">
2875
+ <div style="background:#fff;border-radius:8px;width:480px;max-width:92vw;display:flex;flex-direction:column;box-shadow:0 10px 40px rgba(0,0,0,0.2);">
2876
+ <div style="padding:14px 18px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;justify-content:space-between;">
2877
+ <div style="font-size:15px;font-weight:600;">🪪 我的 P2P 身份</div>
2878
+ <button id="mpim-close" style="background:none;border:none;font-size:20px;color:#6b7280;cursor:pointer;">×</button>
2879
+ </div>
2880
+ <div id="mpim-body" style="padding:16px 18px;">
2881
+ <div style="color:#6b7280;font-size:13px;margin-bottom:10px;">正在获取 publicKey…</div>
2882
+ </div>
2883
+ </div>
2884
+ </div>
2885
+ `;
2886
+ document.body.insertAdjacentHTML('beforeend', html);
2887
+ document.getElementById('mpim-close').onclick = () => document.getElementById('my-p2p-id-modal').remove();
2888
+
2870
2889
  try {
2871
2890
  const res = await fetch('/api/p2p-publickey');
2872
2891
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
2873
2892
  const data = await res.json();
2874
2893
  const pk = data.publicKey || '';
2894
+ const body = document.getElementById('mpim-body');
2875
2895
  if (!pk || pk.length !== 64) {
2876
- alert('P2PDirect 还没启动, 刷新页面稍后再试');
2896
+ body.innerHTML = `<div style="color:#b91c1c;font-size:13px;">✗ P2PDirect 还没启动, 刷新页面稍后再试</div>`;
2877
2897
  return;
2878
2898
  }
2879
- // 显示 + 一键复制
2880
- const ok = confirm(
2881
- `我的 P2P publicKey (64 字符 hex):\n\n${pk}\n\n` +
2882
- `点 "确定" 复制到剪贴板, 发给好友.\n` +
2883
- `好友点 "+ 好友" 粘贴这个 ID 就能加我.`
2884
- );
2885
- if (ok) {
2899
+ body.innerHTML = `
2900
+ <div style="font-size:12px;color:#6b7280;margin-bottom:8px;">把下面这串发给好友, 好友在 P2P 好友区点 "+ 好友" 粘贴即可加你:</div>
2901
+ <div style="display:flex;gap:6px;align-items:center;margin-bottom:12px;">
2902
+ <code id="mpim-pk" style="flex:1;padding:8px 10px;background:#f3f4f6;border:1px solid #d1d5db;border-radius:4px;font-family:monospace;font-size:11px;word-break:break-all;line-height:1.4;">${escapeHtml(pk)}</code>
2903
+ <button id="mpim-copy" style="padding:8px 14px;background:#2563eb;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:13px;white-space:nowrap;">📋 复制</button>
2904
+ </div>
2905
+ <div id="mpim-status" style="font-size:12px;color:#059669;min-height:16px;"></div>
2906
+ <div style="margin-top:14px;padding-top:12px;border-top:1px solid #e5e7eb;font-size:11px;color:#6b7280;">
2907
+ 💡 同一个 role 重启后 publicKey 不会变, 好友不需要重新加你.
2908
+ </div>
2909
+ `;
2910
+ document.getElementById('mpim-copy').onclick = async () => {
2911
+ const statusEl = document.getElementById('mpim-status');
2886
2912
  try {
2887
2913
  await navigator.clipboard.writeText(pk);
2888
- alert('✓ 已复制到剪贴板');
2914
+ statusEl.textContent = '✓ 已复制到剪贴板';
2889
2915
  } catch {
2890
- // 旧浏览器 fallback
2891
2916
  const ta = document.createElement('textarea');
2892
2917
  ta.value = pk;
2918
+ ta.style.position = 'fixed';
2919
+ ta.style.opacity = '0';
2893
2920
  document.body.appendChild(ta);
2894
2921
  ta.select();
2895
- document.execCommand('copy');
2922
+ try { document.execCommand('copy'); statusEl.textContent = '✓ 已复制 (fallback)'; }
2923
+ catch { statusEl.textContent = '✗ 复制失败, 请手动选中复制'; }
2896
2924
  document.body.removeChild(ta);
2897
- alert('✓ 已复制到剪贴板 (fallback)');
2898
2925
  }
2899
- }
2926
+ };
2900
2927
  } catch (err) {
2901
- alert('获取 publicKey 失败: ' + (err.message || err));
2928
+ const body = document.getElementById('mpim-body');
2929
+ if (body) body.innerHTML = `<div style="color:#b91c1c;font-size:13px;">✗ 获取失败: ${escapeHtml(err.message || String(err))}</div>`;
2902
2930
  }
2903
2931
  });
2904
2932
  }