aicq-chat-plugin 2.2.0 → 2.4.0

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/README.md CHANGED
File without changes
package/index.js CHANGED
@@ -426,6 +426,88 @@ app.post('/api/identity/rotate-keys', (req, res) => {
426
426
  }
427
427
  });
428
428
 
429
+ // Avatar upload
430
+ const avatarUpload = multer({
431
+ storage: multer.memoryStorage(),
432
+ limits: { fileSize: 2 * 1024 * 1024 }, // 2MB max
433
+ fileFilter: (req, file, cb) => {
434
+ if (file.mimetype && file.mimetype.startsWith('image/')) {
435
+ cb(null, true);
436
+ } else {
437
+ cb(new Error('Only image files are allowed'));
438
+ }
439
+ },
440
+ });
441
+
442
+ app.post('/api/identity/avatar', avatarUpload.single('avatar'), async (req, res) => {
443
+ try {
444
+ if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
445
+ const agentId = req.body.agent_id || currentAgentId;
446
+
447
+ // Save avatar locally
448
+ const avatarsDir = path.join(DATA_DIR, 'avatars');
449
+ fs.mkdirSync(avatarsDir, { recursive: true });
450
+ const ext = req.file.mimetype.split('/')[1] || 'png';
451
+ const avatarId = Date.now() + '-' + Math.random().toString(36).slice(2, 8);
452
+ const filename = `${avatarId}.${ext}`;
453
+ const filePath = path.join(avatarsDir, filename);
454
+ fs.writeFileSync(filePath, req.file.buffer);
455
+
456
+ // Update local identity
457
+ const avatarUrl = `/api/identity/avatars/${filename}`;
458
+ identity.updateAvatar(agentId, avatarUrl);
459
+
460
+ // Try to upload to server too
461
+ try {
462
+ await serverClient.ensureAuth(agentId);
463
+ const FormData = (await import('form-data')).default;
464
+ const form = new FormData();
465
+ form.append('avatar', req.file.buffer, {
466
+ filename: req.file.originalname || 'avatar.' + ext,
467
+ contentType: req.file.mimetype,
468
+ });
469
+ // Use node-fetch to upload to server
470
+ const fetch = (await import('node-fetch')).default;
471
+ const serverUrl = SERVER_URL + '/api/v1/accounts/avatar';
472
+ const serverResp = await fetch(serverUrl, {
473
+ method: 'POST',
474
+ body: form,
475
+ headers: {
476
+ ...form.getHeaders(),
477
+ 'Authorization': 'Bearer ' + serverClient.getAccessToken(agentId),
478
+ },
479
+ });
480
+ if (serverResp.ok) {
481
+ const serverData = await serverResp.json();
482
+ if (serverData.avatar) {
483
+ identity.updateAvatar(agentId, serverData.avatar);
484
+ return res.json({ success: true, avatar: serverData.avatar });
485
+ }
486
+ }
487
+ } catch (e) {
488
+ console.error('[AICQ] Server avatar upload failed:', e.message);
489
+ }
490
+
491
+ res.json({ success: true, avatar: avatarUrl });
492
+ } catch (e) {
493
+ res.status(500).json({ error: e.message });
494
+ }
495
+ });
496
+
497
+ app.get('/api/identity/avatars/:filename', (req, res) => {
498
+ const filename = req.params.filename;
499
+ // Security: prevent directory traversal
500
+ if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
501
+ return res.status(400).json({ error: 'Invalid filename' });
502
+ }
503
+ const filePath = path.join(DATA_DIR, 'avatars', filename);
504
+ if (fs.existsSync(filePath)) {
505
+ res.sendFile(filePath);
506
+ } else {
507
+ res.status(404).json({ error: 'Avatar not found' });
508
+ }
509
+ });
510
+
429
511
  app.get('/api/identity/keys', (req, res) => {
430
512
  const agentId = getAgentId(req);
431
513
  const info = identity.loadAgent(agentId);
package/lib/chat.js CHANGED
File without changes
package/lib/crypto.js CHANGED
@@ -31,7 +31,13 @@ function generateExchangeKeypair() {
31
31
 
32
32
  function signMessage(message, secretKeyHex) {
33
33
  const secretKey = Buffer.from(secretKeyHex, 'hex');
34
- const messageBytes = naclUtil.decodeUTF8(message);
34
+ // If message looks like hex (64 chars), treat as raw bytes to match server's bytes.fromhex()
35
+ let messageBytes;
36
+ if (/^[0-9a-fA-F]{64}$/.test(message)) {
37
+ messageBytes = Buffer.from(message, 'hex');
38
+ } else {
39
+ messageBytes = naclUtil.decodeUTF8(message);
40
+ }
35
41
  const signature = nacl.sign.detached(messageBytes, secretKey);
36
42
  return Buffer.from(signature).toString('hex');
37
43
  }
@@ -39,7 +45,13 @@ function signMessage(message, secretKeyHex) {
39
45
  function verifySignature(message, signatureHex, publicKeyHex) {
40
46
  try {
41
47
  const publicKey = Buffer.from(publicKeyHex, 'hex');
42
- const messageBytes = naclUtil.decodeUTF8(message);
48
+ // If message looks like hex (64 chars), treat as raw bytes to match server
49
+ let messageBytes;
50
+ if (/^[0-9a-fA-F]{64}$/.test(message)) {
51
+ messageBytes = Buffer.from(message, 'hex');
52
+ } else {
53
+ messageBytes = naclUtil.decodeUTF8(message);
54
+ }
43
55
  const signature = Buffer.from(signatureHex, 'hex');
44
56
  return nacl.sign.detached.verify(messageBytes, signature, publicKey);
45
57
  } catch (e) {
package/lib/database.js CHANGED
@@ -24,6 +24,7 @@ class PluginDatabase {
24
24
  CREATE TABLE IF NOT EXISTS identity (
25
25
  agent_id TEXT PRIMARY KEY,
26
26
  nickname TEXT NOT NULL DEFAULT '',
27
+ avatar TEXT NOT NULL DEFAULT '',
27
28
  signing_public_key TEXT NOT NULL,
28
29
  signing_secret_key TEXT NOT NULL,
29
30
  exchange_public_key TEXT NOT NULL,
@@ -150,6 +151,21 @@ class PluginDatabase {
150
151
  this.db.prepare('UPDATE identity SET nickname = ?, updated_at = ? WHERE agent_id = ?').run(nickname, now, agentId);
151
152
  }
152
153
 
154
+ updateAvatar(agentId, avatarUrl) {
155
+ const now = new Date().toISOString();
156
+ // Ensure column exists (migration for existing databases)
157
+ try {
158
+ this.db.prepare('UPDATE identity SET avatar = ?, updated_at = ? WHERE agent_id = ?').run(avatarUrl, now, agentId);
159
+ } catch (e) {
160
+ if (e.message.includes('no column named avatar')) {
161
+ this.db.exec('ALTER TABLE identity ADD COLUMN avatar TEXT NOT NULL DEFAULT ""');
162
+ this.db.prepare('UPDATE identity SET avatar = ?, updated_at = ? WHERE agent_id = ?').run(avatarUrl, now, agentId);
163
+ } else {
164
+ throw e;
165
+ }
166
+ }
167
+ }
168
+
153
169
  // ─── Friends ───────────────────────────────────────────────────────
154
170
 
155
171
  addFriend({ agent_id, id, public_key, fingerprint, friend_type = 'ai', ai_name = '', permissions = ['chat'] }) {
File without changes
package/lib/handshake.js CHANGED
File without changes
package/lib/identity.js CHANGED
@@ -135,6 +135,16 @@ class IdentityManager {
135
135
  return this._cache[agentId];
136
136
  }
137
137
 
138
+ /**
139
+ * Update agent avatar
140
+ */
141
+ updateAvatar(agentId, avatarUrl) {
142
+ this.db.updateAvatar(agentId, avatarUrl);
143
+ if (this._cache[agentId]) {
144
+ this._cache[agentId].avatar = avatarUrl;
145
+ }
146
+ }
147
+
138
148
  /**
139
149
  * Get identity info (public keys only, no secrets)
140
150
  */
@@ -144,6 +154,7 @@ class IdentityManager {
144
154
  return {
145
155
  agent_id: identity.agent_id,
146
156
  nickname: identity.nickname,
157
+ avatar: identity.avatar || null,
147
158
  signing_public_key: identity.signing_public_key,
148
159
  exchange_public_key: identity.exchange_public_key,
149
160
  fingerprint: identity.fingerprint,
@@ -53,9 +53,13 @@ class ServerClient {
53
53
  public_key: identity.signing_public_key,
54
54
  agent_name: identity.nickname || agentId,
55
55
  });
56
- if (data.accessToken) {
57
- this.jwtToken = data.accessToken;
56
+ if (data.access_token || data.accessToken) {
57
+ this.jwtToken = data.access_token || data.accessToken;
58
58
  this.currentAgentId = agentId;
59
+ // Store server-side account ID for WS auth (nodeId must match JWT sub)
60
+ if (data.account && data.account.id) {
61
+ this.serverAccountId = data.account.id;
62
+ }
59
63
  }
60
64
  return data;
61
65
  }
@@ -83,9 +87,13 @@ class ServerClient {
83
87
  challenge,
84
88
  });
85
89
 
86
- if (loginData.accessToken) {
87
- this.jwtToken = loginData.accessToken;
90
+ if (loginData.access_token || loginData.accessToken) {
91
+ this.jwtToken = loginData.access_token || loginData.accessToken;
88
92
  this.currentAgentId = agentId;
93
+ // Store server-side account ID for WS auth (nodeId must match JWT sub)
94
+ if (loginData.account && loginData.account.id) {
95
+ this.serverAccountId = loginData.account.id;
96
+ }
89
97
  }
90
98
  return loginData;
91
99
  }
@@ -198,7 +206,7 @@ class ServerClient {
198
206
  console.log('[WS] Connected, sending auth...');
199
207
  this.ws.send(JSON.stringify({
200
208
  type: 'online',
201
- nodeId: this.currentAgentId,
209
+ nodeId: this.serverAccountId || this.currentAgentId,
202
210
  token: this.jwtToken,
203
211
  }));
204
212
  });
@@ -317,6 +325,13 @@ class ServerClient {
317
325
  this._running = false;
318
326
  this.disconnect();
319
327
  }
328
+
329
+ /**
330
+ * Get the current JWT access token for a given agent
331
+ */
332
+ getAccessToken(agentId) {
333
+ return this.jwtToken || '';
334
+ }
320
335
  }
321
336
 
322
337
  module.exports = ServerClient;
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aicq-chat-plugin",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "description": "AICQ End-to-end Encrypted Chat Plugin for OpenClaw — Full UI with friend management, group chat, file transfer, and AI agent communication",
5
5
  "main": "index.js",
6
6
  "bin": {
package/postinstall.js CHANGED
File without changes
package/public/index.html CHANGED
@@ -29,7 +29,8 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
29
29
  .friend-item,.group-item{display:flex;align-items:center;gap:10px;padding:10px 12px;cursor:pointer;transition:background .15s;border-left:3px solid transparent}
30
30
  .friend-item:hover,.group-item:hover{background:var(--bg3)}
31
31
  .friend-item.active,.group-item.active{background:rgba(79,70,229,.2);border-left-color:var(--primary)}
32
- .avatar{width:36px;height:36px;border-radius:50%;background:var(--primary);display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:600;flex-shrink:0;color:#fff}
32
+ .avatar{width:36px;height:36px;border-radius:50%;background:var(--primary);display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:600;flex-shrink:0;color:#fff;overflow:hidden}
33
+ .avatar img{width:100%;height:100%;object-fit:cover;border-radius:50%}
33
34
  .avatar.online{box-shadow:0 0 0 2px var(--success)}
34
35
  .info{flex:1;min-width:0}
35
36
  .info .name{font-size:14px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -121,9 +122,28 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
121
122
  .msg-bubble a{color:var(--info)}
122
123
  .msg-bubble table{border-collapse:collapse;margin:6px 0}
123
124
  .msg-bubble th,.msg-bubble td{border:1px solid var(--border);padding:4px 8px;font-size:13px}
125
+ /* Toast */
126
+ .toast{position:fixed;top:20px;left:50%;transform:translateX(-50%);background:var(--bg2);border:1px solid var(--border);color:var(--text);padding:12px 24px;border-radius:8px;font-size:14px;z-index:9999;box-shadow:0 4px 20px rgba(0,0,0,.5);max-width:90%;text-align:center;opacity:0;transition:opacity .3s;pointer-events:none}
127
+ .toast.show{opacity:1}
128
+ .toast.warning{border-color:var(--warning);background:rgba(245,158,11,.15);color:#fcd34d}
129
+ /* Backup section */
130
+ .backup-section{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;margin-top:8px}
131
+ .backup-section h4{font-size:14px;margin-bottom:8px;color:var(--text)}
132
+ .backup-section p{font-size:12px;color:var(--text2);line-height:1.6;margin:4px 0}
133
+ .backup-section .warning-box{font-size:12px;padding:8px;margin:8px 0}
134
+ .backup-btns{display:flex;gap:8px;margin-top:10px}
135
+ .backup-btns .btn{flex:1;text-align:center}
136
+ /* Key match row */
137
+ .key-match-row{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid var(--border)}
138
+ .key-match-row:last-child{border-bottom:none}
139
+ .key-match-row label{font-size:13px;color:var(--text2);min-width:120px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
140
+ .key-match-row select{flex:1;padding:6px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px}
124
141
  </style>
125
142
  </head>
126
143
  <body>
144
+ <!-- Toast -->
145
+ <div class="toast" id="toast"></div>
146
+
127
147
  <div class="app">
128
148
  <!-- Right Panel -->
129
149
  <div class="right-panel">
@@ -142,6 +162,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
142
162
  <button class="action-btn" onclick="showModal('settings')">
143
163
  <span class="icon">⚙️</span>设置
144
164
  </button>
165
+ <button class="action-btn" onclick="confirmLogout()">
166
+ <span class="icon">🚪</span>登出
167
+ </button>
145
168
  </div>
146
169
  <div class="list-section">
147
170
  <h4>好友</h4>
@@ -225,14 +248,39 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
225
248
  <div class="modal">
226
249
  <h3>设置</h3>
227
250
  <div style="display:flex;flex-direction:column;gap:12px">
251
+ <div style="text-align:center;padding:8px 0">
252
+ <div style="position:relative;display:inline-block">
253
+ <div class="avatar" id="settingsAvatar" style="width:64px;height:64px;font-size:24px;cursor:pointer" onclick="document.getElementById('avatarUploadInput').click()">A</div>
254
+ <div style="position:absolute;bottom:-2px;right:-2px;width:20px;height:20px;background:var(--primary);border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;font-size:10px;cursor:pointer" onclick="document.getElementById('avatarUploadInput').click()">📷</div>
255
+ </div>
256
+ <input type="file" id="avatarUploadInput" accept="image/*" style="display:none" onchange="handlePluginAvatarUpload(this)">
257
+ <div style="font-size:11px;color:var(--text2);margin-top:4px">点击更换头像</div>
258
+ </div>
228
259
  <button class="btn btn-secondary" onclick="showQRCode()" style="width:100%;text-align:left">📱 二维码 — 客户端扫描添加好友</button>
229
260
  <button class="btn btn-secondary" onclick="showNicknameModal()" style="width:100%;text-align:left">✏️ 修改昵称</button>
230
261
  <button class="btn btn-secondary" onclick="generateFriendCode()" style="width:100%;text-align:left">🔢 生成好友码 (24小时有效)</button>
231
262
  <button class="btn btn-secondary" onclick="showKeysModal()" style="width:100%;text-align:left">🔑 显示/重新生成密钥</button>
232
263
  <button class="btn btn-secondary" onclick="createNewAgent()" style="width:100%;text-align:left">➕ 创建新 Agent</button>
233
264
  <button class="btn btn-secondary" onclick="syncData()" style="width:100%;text-align:left">🔄 同步服务器数据</button>
265
+ <div class="backup-section">
266
+ <h4>💾 数据备份</h4>
267
+ <p><strong>导出内容:</strong>聊天消息、好友关系、群聊信息、Agent 身份密钥(含私钥)</p>
268
+ <div class="warning-box">⚠️ 私钥包含在导出文件中!请妥善保管备份文件,切勿分享给他人。拥有私钥的人可以冒充你的身份发送消息。</div>
269
+ <p><strong>数据丢失场景:</strong></p>
270
+ <p>• 清除浏览器缓存数据</p>
271
+ <p>• 删除浏览器数据/历史记录</p>
272
+ <p>• 使用无痕/隐身模式(关闭窗口后数据清除)</p>
273
+ <p>• 更换浏览器或设备</p>
274
+ <p><strong>恢复方法:</strong>点击「导入备份」选择之前导出的 JSON 文件。若备份中的 Agent 私钥与当前 Agent 不匹配,系统会提示你手动对应。</p>
275
+ <div class="backup-btns">
276
+ <button class="btn btn-primary" onclick="exportBackup()">📤 导出备份</button>
277
+ <button class="btn btn-secondary" onclick="triggerImportBackup()">📥 导入备份</button>
278
+ </div>
279
+ <input type="file" id="backupFileInput" accept=".json" style="display:none" onchange="handleImportBackup(this)">
280
+ </div>
234
281
  </div>
235
282
  <div class="btn-row" style="margin-top:20px">
283
+ <button class="btn btn-danger" onclick="confirmLogout()">🚪 登出</button>
236
284
  <button class="btn btn-primary" onclick="hideModal('settings')">关闭</button>
237
285
  </div>
238
286
  </div>
@@ -334,6 +382,20 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
334
382
  </div>
335
383
  </div>
336
384
 
385
+ <!-- Backup Key Match Modal -->
386
+ <div class="modal-overlay" id="modal-backupMatch">
387
+ <div class="modal" style="max-width:560px">
388
+ <h3>🔑 Agent 密钥匹配</h3>
389
+ <div class="warning-box">备份中的 Agent 私钥与当前 Agent 不匹配,请手动选择对应关系。未匹配的聊天记录将不会导入。</div>
390
+ <p style="font-size:13px;color:var(--text2);margin-bottom:12px">左侧为备份中的 Agent,右侧选择当前对应的 Agent:</p>
391
+ <div id="backupMatchList"></div>
392
+ <div class="btn-row">
393
+ <button class="btn btn-secondary" onclick="skipBackupMatch()">跳过不匹配项</button>
394
+ <button class="btn btn-primary" onclick="applyBackupMatch()">确认匹配并导入</button>
395
+ </div>
396
+ </div>
397
+ </div>
398
+
337
399
  <script>
338
400
  // ─── State ──────────────────────────────────────────────────────────
339
401
  let currentAgentId = '';
@@ -353,19 +415,6 @@ async function api(method, path, body = null) {
353
415
  return resp.json();
354
416
  }
355
417
 
356
- // ─── Initialize ─────────────────────────────────────────────────────
357
- async function init() {
358
- await loadAgents();
359
- await loadFriends();
360
- await loadGroups();
361
- // Auto-select first agent
362
- const sel = document.getElementById('agentSelect');
363
- if (sel.options.length > 1) {
364
- currentAgentId = sel.options[1].value;
365
- sel.value = currentAgentId;
366
- }
367
- }
368
-
369
418
  // ─── Agents ─────────────────────────────────────────────────────────
370
419
  async function loadAgents() {
371
420
  const data = await api('GET', '/api/agents');
@@ -382,6 +431,7 @@ async function loadAgents() {
382
431
 
383
432
  async function switchAgent(agentId) {
384
433
  if (!agentId) return;
434
+ saveChatToLocalStorage();
385
435
  currentAgentId = agentId;
386
436
  currentTarget = null;
387
437
  showEmptyState();
@@ -426,7 +476,7 @@ async function loadFriends() {
426
476
  div.className = 'friend-item' + (currentTarget?.id === f.id && currentTarget?.type === 'friend' ? ' active' : '');
427
477
  div.onclick = () => selectTarget(f.id, f.ai_name || f.fingerprint?.slice(0,8) || f.id.slice(0,8), 'friend', !!f.is_online);
428
478
  div.innerHTML = `
429
- <div class="avatar ${f.is_online ? 'online' : ''}">${(f.ai_name||f.id).charAt(0).toUpperCase()}</div>
479
+ <div class="avatar ${f.is_online ? 'online' : ''}" ${f.ai_avatar ? 'style="background:none"' : ''}>${f.ai_avatar ? `<img src="${f.ai_avatar}" alt="">` : (f.ai_name||f.id).charAt(0).toUpperCase()}</div>
430
480
  <div class="info">
431
481
  <div class="name">${f.ai_name || f.fingerprint?.slice(0,16) || f.id.slice(0,12)}</div>
432
482
  <div class="status"><span class="${f.is_online ? 'badge-online' : 'badge-offline'}">●</span> ${f.is_online ? '在线' : '离线'}</div>
@@ -462,7 +512,29 @@ async function selectTarget(id, name, type, isOnline = false, silent = false) {
462
512
  showChatView();
463
513
 
464
514
  // Update header
465
- document.getElementById('chatAvatar').textContent = type === 'group' ? '👥' : name.charAt(0).toUpperCase();
515
+ const chatAvatar = document.getElementById('chatAvatar');
516
+ // Try to get avatar from friend data
517
+ let friendAvatar = null;
518
+ if (type === 'friend') {
519
+ const friends = document.querySelectorAll('.friend-item');
520
+ // We'll use the currentTarget data
521
+ }
522
+ if (type === 'group') {
523
+ chatAvatar.innerHTML = '👥';
524
+ } else {
525
+ const name = currentTarget?.name || id;
526
+ chatAvatar.textContent = name.charAt(0).toUpperCase();
527
+ chatAvatar.style.background = '';
528
+ }
529
+ // If friend has avatar, try to find it in loaded friends
530
+ try {
531
+ const friendData = await api('GET', `/api/friends?agent_id=${currentAgentId}`);
532
+ const friend = (friendData.friends || []).find(f => f.id === id);
533
+ if (friend && friend.ai_avatar) {
534
+ chatAvatar.innerHTML = `<img src="${friend.ai_avatar}" alt="">`;
535
+ chatAvatar.style.background = 'none';
536
+ }
537
+ } catch(e) {}
466
538
  document.getElementById('chatName').textContent = name;
467
539
  document.getElementById('chatStatus').textContent = type === 'group' ? '群聊' : (isOnline ? '在线' : '离线');
468
540
  document.getElementById('silentBtn').textContent = silent ? '🔕' : '🔔';
@@ -630,6 +702,7 @@ async function sendMessage() {
630
702
  mentions: extractMentions(content),
631
703
  });
632
704
  await loadMessages();
705
+ saveChatToLocalStorage();
633
706
  } catch (e) {
634
707
  alert('发送失败: ' + e.message);
635
708
  }
@@ -769,7 +842,21 @@ async function toggleSilent() {
769
842
  }
770
843
 
771
844
  // ─── Modal Helpers ──────────────────────────────────────────────────
772
- function showModal(name) { document.getElementById('modal-' + name).classList.add('show'); }
845
+ function showModal(name) {
846
+ document.getElementById('modal-' + name).classList.add('show');
847
+ // Update settings avatar when opening settings modal
848
+ if (name === 'settings' && currentAgentId) {
849
+ api('GET', `/api/identity?agent_id=${currentAgentId}`).then(info => {
850
+ const avatarEl = document.getElementById('settingsAvatar');
851
+ if (info.avatar) {
852
+ avatarEl.innerHTML = `<img src="${info.avatar}" alt="头像">`;
853
+ avatarEl.style.background = 'none';
854
+ } else {
855
+ avatarEl.textContent = (info.nickname || 'A').charAt(0).toUpperCase();
856
+ }
857
+ }).catch(() => {});
858
+ }
859
+ }
773
860
  function hideModal(name) { document.getElementById('modal-' + name).classList.remove('show'); }
774
861
 
775
862
  // ─── Add Friend ─────────────────────────────────────────────────────
@@ -906,14 +993,429 @@ function showChatInfo() {
906
993
  : `好友: ${currentTarget.name}\nID: ${currentTarget.id}\n状态: ${currentTarget.isOnline ? '在线' : '离线'}`);
907
994
  }
908
995
 
996
+ // ─── Avatar Upload ──────────────────────────────────────────────────
997
+ async function handlePluginAvatarUpload(input) {
998
+ const file = input.files && input.files[0];
999
+ if (!file || !currentAgentId) return;
1000
+ if (!file.type.startsWith('image/')) { alert('请选择图片文件'); return; }
1001
+ if (file.size > 2 * 1024 * 1024) { alert('图片不能超过2MB'); return; }
1002
+ try {
1003
+ const formData = new FormData();
1004
+ formData.append('avatar', file);
1005
+ formData.append('agent_id', currentAgentId);
1006
+ const resp = await fetch(API + '/api/identity/avatar', { method: 'POST', body: formData });
1007
+ const data = await resp.json();
1008
+ if (data.success || data.avatar) {
1009
+ // Update the settings avatar display
1010
+ const avatarUrl = data.avatar || (data.account && data.account.avatar);
1011
+ if (avatarUrl) {
1012
+ document.getElementById('settingsAvatar').innerHTML = `<img src="${avatarUrl}" alt="头像">`;
1013
+ }
1014
+ alert('头像已更新');
1015
+ } else {
1016
+ alert('上传失败: ' + (data.error || '未知错误'));
1017
+ }
1018
+ } catch (e) {
1019
+ alert('头像上传失败: ' + e.message);
1020
+ }
1021
+ input.value = '';
1022
+ }
1023
+
1024
+ // ─── Toast Notification ─────────────────────────────────────────────
1025
+ let toastTimer = null;
1026
+ function showToast(message, type = '', duration = 5000) {
1027
+ const el = document.getElementById('toast');
1028
+ el.textContent = message;
1029
+ el.className = 'toast show' + (type ? ' ' + type : '');
1030
+ if (toastTimer) clearTimeout(toastTimer);
1031
+ toastTimer = setTimeout(() => { el.className = 'toast'; }, duration);
1032
+ }
1033
+
1034
+ // ─── localStorage Chat Cache ───────────────────────────────────────
1035
+ const CACHE_KEY = 'aicq_plugin_chat_cache';
1036
+
1037
+ function saveChatToLocalStorage() {
1038
+ try {
1039
+ const cache = {
1040
+ currentAgentId,
1041
+ currentTarget,
1042
+ chatMessages,
1043
+ oldestTimestamp,
1044
+ savedAt: Date.now()
1045
+ };
1046
+ localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
1047
+ } catch (e) {
1048
+ // localStorage might be full or unavailable
1049
+ console.warn('Failed to save chat cache:', e);
1050
+ }
1051
+ }
1052
+
1053
+ function loadChatFromLocalStorage() {
1054
+ try {
1055
+ const raw = localStorage.getItem(CACHE_KEY);
1056
+ if (!raw) return null;
1057
+ return JSON.parse(raw);
1058
+ } catch (e) {
1059
+ return null;
1060
+ }
1061
+ }
1062
+
1063
+ function clearChatCache() {
1064
+ try { localStorage.removeItem(CACHE_KEY); } catch(e) {}
1065
+ }
1066
+
1067
+ // ─── Logout ─────────────────────────────────────────────────────────
1068
+ function confirmLogout() {
1069
+ if (confirm('确定要登出吗?登出前会自动保存聊天缓存到本地。')) {
1070
+ saveChatToLocalStorage();
1071
+ // Clear state
1072
+ currentAgentId = '';
1073
+ currentTarget = null;
1074
+ chatMessages = [];
1075
+ oldestTimestamp = null;
1076
+ document.getElementById('agentSelect').value = '';
1077
+ showEmptyState();
1078
+ document.getElementById('friendsList').innerHTML = '';
1079
+ document.getElementById('groupsList').innerHTML = '';
1080
+ hideModal('settings');
1081
+ showToast('已登出,聊天缓存已保存到本地');
1082
+ }
1083
+ }
1084
+
1085
+ // ─── Backup Export ──────────────────────────────────────────────────
1086
+ async function exportBackup() {
1087
+ try {
1088
+ showToast('正在生成备份...');
1089
+ // Get all agents
1090
+ const agentsData = await api('GET', '/api/agents');
1091
+ const agents = agentsData.agents || [];
1092
+
1093
+ // Collect data for each agent
1094
+ const backupAgents = [];
1095
+ for (const agent of agents) {
1096
+ // Get identity/keys for this agent (includes private keys)
1097
+ let keys = {};
1098
+ try {
1099
+ keys = await api('GET', `/api/identity/keys?agent_id=${agent.agent_id}`);
1100
+ } catch(e) {}
1101
+
1102
+ // Get friends
1103
+ let friends = [];
1104
+ try {
1105
+ const fd = await api('GET', `/api/friends?agent_id=${agent.agent_id}`);
1106
+ friends = fd.friends || [];
1107
+ } catch(e) {}
1108
+
1109
+ // Get groups
1110
+ let groups = [];
1111
+ try {
1112
+ const gd = await api('GET', `/api/groups?agent_id=${agent.agent_id}`);
1113
+ groups = gd.groups || [];
1114
+ } catch(e) {}
1115
+
1116
+ // Get chat messages for each friend and group
1117
+ const chatData = {};
1118
+ for (const f of friends) {
1119
+ try {
1120
+ const md = await api('GET', `/api/chat/${f.id}?agent_id=${agent.agent_id}&limit=9999`);
1121
+ chatData['friend_' + f.id] = md.messages || [];
1122
+ } catch(e) {}
1123
+ }
1124
+ for (const g of groups) {
1125
+ try {
1126
+ const md = await api('GET', `/api/chat/${g.id}?agent_id=${agent.agent_id}&limit=9999`);
1127
+ chatData['group_' + g.id] = md.messages || [];
1128
+ } catch(e) {}
1129
+ }
1130
+
1131
+ backupAgents.push({
1132
+ agent_id: agent.agent_id,
1133
+ nickname: agent.nickname || agent.agent_id,
1134
+ avatar: agent.avatar || null,
1135
+ signing_public_key: keys.signing_public_key || null,
1136
+ exchange_public_key: keys.exchange_public_key || null,
1137
+ signing_secret_key: keys.signing_secret_key || null,
1138
+ exchange_secret_key: keys.exchange_secret_key || null,
1139
+ fingerprint: keys.fingerprint || null,
1140
+ friends,
1141
+ groups,
1142
+ chatData
1143
+ });
1144
+ }
1145
+
1146
+ const backup = {
1147
+ version: 'aicq-plugin-backup-v1',
1148
+ exportedAt: new Date().toISOString(),
1149
+ agents: backupAgents
1150
+ };
1151
+
1152
+ // Download as JSON file
1153
+ const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' });
1154
+ const url = URL.createObjectURL(blob);
1155
+ const a = document.createElement('a');
1156
+ a.href = url;
1157
+ a.download = `aicq-backup-${new Date().toISOString().slice(0,10)}.json`;
1158
+ document.body.appendChild(a);
1159
+ a.click();
1160
+ document.body.removeChild(a);
1161
+ URL.revokeObjectURL(url);
1162
+
1163
+ showToast('备份已导出!请妥善保管此文件,内含私钥。', 'warning', 6000);
1164
+ } catch (e) {
1165
+ alert('导出失败: ' + e.message);
1166
+ }
1167
+ }
1168
+
1169
+ // ─── Backup Import ──────────────────────────────────────────────────
1170
+ function triggerImportBackup() {
1171
+ document.getElementById('backupFileInput').click();
1172
+ }
1173
+
1174
+ // Store pending backup for matching
1175
+ let pendingBackup = null;
1176
+ let pendingMatchMap = {};
1177
+
1178
+ async function handleImportBackup(input) {
1179
+ if (!input.files || !input.files[0]) return;
1180
+ const file = input.files[0];
1181
+ input.value = '';
1182
+
1183
+ try {
1184
+ const text = await file.text();
1185
+ const backup = JSON.parse(text);
1186
+
1187
+ if (!backup.version || !backup.agents || !Array.isArray(backup.agents)) {
1188
+ alert('无效的备份文件格式');
1189
+ return;
1190
+ }
1191
+
1192
+ await processImport(backup);
1193
+ } catch (e) {
1194
+ alert('读取备份文件失败: ' + e.message);
1195
+ }
1196
+ }
1197
+
1198
+ async function processImport(backup) {
1199
+ // Get current agents
1200
+ const agentsData = await api('GET', '/api/agents');
1201
+ const currentAgents = agentsData.agents || [];
1202
+
1203
+ // Get current agents' keys for comparison
1204
+ const currentAgentKeys = {};
1205
+ for (const agent of currentAgents) {
1206
+ try {
1207
+ const keys = await api('GET', `/api/identity/keys?agent_id=${agent.agent_id}`);
1208
+ currentAgentKeys[agent.agent_id] = {
1209
+ agent_id: agent.agent_id,
1210
+ nickname: agent.nickname || agent.agent_id,
1211
+ signing_public_key: keys.signing_public_key,
1212
+ fingerprint: keys.fingerprint
1213
+ };
1214
+ } catch(e) {
1215
+ currentAgentKeys[agent.agent_id] = {
1216
+ agent_id: agent.agent_id,
1217
+ nickname: agent.nickname || agent.agent_id
1218
+ };
1219
+ }
1220
+ }
1221
+
1222
+ // Check which backup agents don't match current agents
1223
+ const matchedAgents = []; // { backupAgent, currentAgent }
1224
+ const unmatchedBackupAgents = []; // backup agents with no key match
1225
+
1226
+ for (const ba of backup.agents) {
1227
+ // Try to find matching current agent by signing_public_key or fingerprint
1228
+ let matched = null;
1229
+ for (const [cid, ck] of Object.entries(currentAgentKeys)) {
1230
+ if (ba.signing_public_key && ck.signing_public_key && ba.signing_public_key === ck.signing_public_key) {
1231
+ matched = ck;
1232
+ break;
1233
+ }
1234
+ if (ba.fingerprint && ck.fingerprint && ba.fingerprint === ck.fingerprint) {
1235
+ matched = ck;
1236
+ break;
1237
+ }
1238
+ // Also match by agent_id
1239
+ if (ba.agent_id === cid) {
1240
+ matched = ck;
1241
+ break;
1242
+ }
1243
+ }
1244
+
1245
+ if (matched) {
1246
+ matchedAgents.push({ backupAgent: ba, currentAgent: matched });
1247
+ } else {
1248
+ unmatchedBackupAgents.push(ba);
1249
+ }
1250
+ }
1251
+
1252
+ // If there are unmatched agents, show matching modal
1253
+ if (unmatchedBackupAgents.length > 0 && Object.keys(currentAgentKeys).length > 0) {
1254
+ pendingBackup = backup;
1255
+ pendingMatchMap = {};
1256
+
1257
+ // Auto-add matched ones
1258
+ for (const m of matchedAgents) {
1259
+ pendingMatchMap[m.backupAgent.agent_id] = m.currentAgent.agent_id;
1260
+ }
1261
+
1262
+ showBackupMatchModal(unmatchedBackupAgents, Object.values(currentAgentKeys));
1263
+ } else {
1264
+ // All matched or no current agents, proceed directly
1265
+ await doImportBackup(backup, matchedAgents.reduce((map, m) => { map[m.backupAgent.agent_id] = m.currentAgent.agent_id; return map; }, {}));
1266
+ }
1267
+ }
1268
+
1269
+ function showBackupMatchModal(unmatchedAgents, currentAgentList) {
1270
+ const container = document.getElementById('backupMatchList');
1271
+ container.innerHTML = '';
1272
+
1273
+ for (const ba of unmatchedAgents) {
1274
+ const row = document.createElement('div');
1275
+ row.className = 'key-match-row';
1276
+ row.innerHTML = `
1277
+ <label title="${ba.agent_id}">${ba.nickname || ba.agent_id}</label>
1278
+ <select id="match-${ba.agent_id}" data-backup-agent="${ba.agent_id}">
1279
+ <option value="">-- 不导入 --</option>
1280
+ ${currentAgentList.map(ca => `<option value="${ca.agent_id}">${ca.nickname || ca.agent_id}</option>`).join('')}
1281
+ </select>
1282
+ `;
1283
+ container.appendChild(row);
1284
+ }
1285
+
1286
+ showModal('backupMatch');
1287
+ }
1288
+
1289
+ async function applyBackupMatch() {
1290
+ // Collect user selections
1291
+ const selects = document.querySelectorAll('#backupMatchList select');
1292
+ for (const sel of selects) {
1293
+ const backupAgentId = sel.dataset.backupAgent;
1294
+ const chosenAgentId = sel.value;
1295
+ if (chosenAgentId) {
1296
+ pendingMatchMap[backupAgentId] = chosenAgentId;
1297
+ }
1298
+ }
1299
+
1300
+ hideModal('backupMatch');
1301
+ await doImportBackup(pendingBackup, pendingMatchMap);
1302
+ pendingBackup = null;
1303
+ pendingMatchMap = {};
1304
+ }
1305
+
1306
+ async function skipBackupMatch() {
1307
+ hideModal('backupMatch');
1308
+ // Import only the already-matched agents (without the unmatched ones)
1309
+ await doImportBackup(pendingBackup, pendingMatchMap);
1310
+ pendingBackup = null;
1311
+ pendingMatchMap = {};
1312
+ }
1313
+
1314
+ async function doImportBackup(backup, matchMap) {
1315
+ try {
1316
+ showToast('正在导入备份...', '', 8000);
1317
+ let importedCount = 0;
1318
+ let importedMessages = 0;
1319
+
1320
+ for (const ba of backup.agents) {
1321
+ const targetAgentId = matchMap[ba.agent_id];
1322
+ if (!targetAgentId) continue;
1323
+
1324
+ // Import friends (via API if possible, or just save to cache)
1325
+ // The plugin uses local SQLite, so we store to localStorage cache
1326
+ for (const friend of (ba.friends || [])) {
1327
+ try {
1328
+ // Try to add friend via API
1329
+ await api('POST', '/api/friends/add-by-fingerprint', {
1330
+ agent_id: targetAgentId,
1331
+ fingerprint: friend.fingerprint,
1332
+ ai_name: friend.ai_name,
1333
+ ai_avatar: friend.ai_avatar
1334
+ });
1335
+ } catch(e) {
1336
+ // Friend might already exist, that's fine
1337
+ }
1338
+ }
1339
+
1340
+ // Import chat messages to localStorage cache
1341
+ if (ba.chatData) {
1342
+ const chatCacheKey = `aicq_chat_${targetAgentId}`;
1343
+ try {
1344
+ const existing = JSON.parse(localStorage.getItem(chatCacheKey) || '{}');
1345
+ for (const [targetKey, messages] of Object.entries(ba.chatData)) {
1346
+ existing[targetKey] = messages;
1347
+ }
1348
+ localStorage.setItem(chatCacheKey, JSON.stringify(existing));
1349
+ importedMessages += Object.keys(ba.chatData).length;
1350
+ } catch(e) {}
1351
+ }
1352
+
1353
+ importedCount++;
1354
+ }
1355
+
1356
+ // Refresh UI
1357
+ await loadAgents();
1358
+ await loadFriends();
1359
+ await loadGroups();
1360
+
1361
+ hideModal('settings');
1362
+ showToast(`导入完成!已导入 ${importedCount} 个 Agent 的数据,${importedMessages} 个会话记录。`, '', 6000);
1363
+ } catch (e) {
1364
+ alert('导入失败: ' + e.message);
1365
+ }
1366
+ }
1367
+
909
1368
  // ─── Init ───────────────────────────────────────────────────────────
1369
+ async function init() {
1370
+ await loadAgents();
1371
+ await loadFriends();
1372
+ await loadGroups();
1373
+ // Auto-select first agent
1374
+ const sel = document.getElementById('agentSelect');
1375
+ if (sel.options.length > 1) {
1376
+ currentAgentId = sel.options[1].value;
1377
+ sel.value = currentAgentId;
1378
+ }
1379
+
1380
+ // Load chat cache from localStorage
1381
+ const cached = loadChatFromLocalStorage();
1382
+ if (cached && cached.currentAgentId) {
1383
+ // Restore cache if agent still exists
1384
+ const sel2 = document.getElementById('agentSelect');
1385
+ let agentExists = false;
1386
+ for (let i = 0; i < sel2.options.length; i++) {
1387
+ if (sel2.options[i].value === cached.currentAgentId) {
1388
+ agentExists = true;
1389
+ break;
1390
+ }
1391
+ }
1392
+ if (agentExists) {
1393
+ currentAgentId = cached.currentAgentId;
1394
+ sel2.value = currentAgentId;
1395
+ if (cached.currentTarget) {
1396
+ currentTarget = cached.currentTarget;
1397
+ await loadFriends();
1398
+ await loadGroups();
1399
+ // Will load messages from server, which is more reliable
1400
+ if (currentTarget) {
1401
+ selectTarget(currentTarget.id, currentTarget.name, currentTarget.type, currentTarget.isOnline, currentTarget.silent);
1402
+ }
1403
+ }
1404
+ }
1405
+ }
1406
+
1407
+ // Show login reminder toast
1408
+ showToast('提醒:聊天记录仅保存在本地浏览器中,清除缓存数据或删除浏览器内容将丢失', 'warning', 8000);
1409
+ }
1410
+
910
1411
  init();
911
1412
 
912
- // Periodic refresh
1413
+ // Periodic refresh and auto-save
913
1414
  setInterval(async () => {
914
1415
  if (currentAgentId) {
915
1416
  await loadFriends();
916
1417
  await loadGroups();
1418
+ saveChatToLocalStorage();
917
1419
  }
918
1420
  }, 30000);
919
1421
  </script>