aicq-chat-plugin 2.3.0 → 2.4.1

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
@@ -350,6 +350,48 @@ app.delete('/api/chat/:messageId', (req, res) => {
350
350
  res.json({ success: true });
351
351
  });
352
352
 
353
+ // Streaming endpoints — for external systems / OpenClaw agent output
354
+ app.post('/api/chat/stream-chunk', (req, res) => {
355
+ try {
356
+ const { targetId, friend_id, chunk_type, chunkType, data } = req.body;
357
+ const streamTarget = targetId || friend_id;
358
+ if (!streamTarget) return res.status(400).json({ error: 'targetId or friend_id is required' });
359
+ if (!data) return res.status(400).json({ error: 'data is required' });
360
+ const type = chunk_type || chunkType || 'text';
361
+ if (!['text', 'reasoning', 'tool_call', 'tool_result'].includes(type)) {
362
+ return res.status(400).json({ error: `Invalid chunk_type: ${type}` });
363
+ }
364
+ const sent = serverClient.sendWS({
365
+ type: 'stream_chunk',
366
+ to: streamTarget,
367
+ chunkType: type,
368
+ data: data,
369
+ });
370
+ if (!sent) return res.status(503).json({ error: 'Not connected to server', success: false });
371
+ res.json({ success: true });
372
+ } catch (e) {
373
+ res.status(500).json({ error: e.message });
374
+ }
375
+ });
376
+
377
+ app.post('/api/chat/stream-end', (req, res) => {
378
+ try {
379
+ const { targetId, friend_id, message_id, messageId } = req.body;
380
+ const streamTarget = targetId || friend_id;
381
+ if (!streamTarget) return res.status(400).json({ error: 'targetId or friend_id is required' });
382
+ const msgId = message_id || messageId || ('msg_' + Date.now() + '_' + Math.random().toString(36).substr(2, 6));
383
+ const sent = serverClient.sendWS({
384
+ type: 'stream_end',
385
+ to: streamTarget,
386
+ messageId: msgId,
387
+ });
388
+ if (!sent) return res.status(503).json({ error: 'Not connected to server', success: false });
389
+ res.json({ success: true, messageId: msgId });
390
+ } catch (e) {
391
+ res.status(500).json({ error: e.message });
392
+ }
393
+ });
394
+
353
395
  // File upload
354
396
  app.post('/api/upload', upload.single('file'), async (req, res) => {
355
397
  try {
@@ -575,6 +617,33 @@ async function handleGatewayCall(method, kwargs = {}) {
575
617
  return await chat.sendMessage(currentAgentId, kwargs.targetId, kwargs.content, { isGroup: kwargs.isGroup });
576
618
  case 'aicq.chat.history':
577
619
  return { messages: db.getChatHistory(currentAgentId, kwargs.targetId, { limit: kwargs.limit || 50 }) };
620
+ case 'aicq.chat.streamChunk': {
621
+ if (!kwargs.friend_id && !kwargs.targetId) return { error: 'friend_id or targetId is required' };
622
+ if (!kwargs.data) return { error: 'data is required' };
623
+ const chunkType = kwargs.chunk_type || kwargs.chunkType || 'text';
624
+ if (!['text', 'reasoning', 'tool_call', 'tool_result'].includes(chunkType)) return { error: `Invalid chunk_type: ${chunkType}` };
625
+ const streamTarget = kwargs.friend_id || kwargs.targetId;
626
+ const sent = serverClient.sendWS({
627
+ type: 'stream_chunk',
628
+ to: streamTarget,
629
+ chunkType: chunkType,
630
+ data: kwargs.data,
631
+ });
632
+ if (!sent) return { error: 'Not connected to server', success: false };
633
+ return { success: true };
634
+ }
635
+ case 'aicq.chat.streamEnd': {
636
+ if (!kwargs.friend_id && !kwargs.targetId) return { error: 'friend_id or targetId is required' };
637
+ const endTarget = kwargs.friend_id || kwargs.targetId;
638
+ const msgId = kwargs.message_id || kwargs.messageId || ('msg_' + Date.now() + '_' + Math.random().toString(36).substr(2, 6));
639
+ const endSent = serverClient.sendWS({
640
+ type: 'stream_end',
641
+ to: endTarget,
642
+ messageId: msgId,
643
+ });
644
+ if (!endSent) return { error: 'Not connected to server', success: false };
645
+ return { success: true, messageId: msgId };
646
+ }
578
647
  default:
579
648
  return { error: `Unknown method: ${method}` };
580
649
  }
package/lib/chat.js CHANGED
@@ -21,6 +21,8 @@ class ChatManager {
21
21
  this.server.onMessage('handshake_initiate', (data) => this._handleHandshakeRequest(data));
22
22
  this.server.onMessage('presence', (data) => this._handlePresence(data));
23
23
  this.server.onMessage('file_chunk', (data) => this._handleFileChunk(data));
24
+ this.server.onMessage('stream_chunk', (data) => this._handleStreamChunk(data));
25
+ this.server.onMessage('stream_end', (data) => this._handleStreamEnd(data));
24
26
  }
25
27
 
26
28
  setOnNewMessage(callback) {
@@ -208,6 +210,46 @@ class ChatManager {
208
210
  console.log('[Chat] File chunk from', data.from);
209
211
  }
210
212
 
213
+ _handleStreamChunk(data) {
214
+ // Incoming streaming chunk from another agent
215
+ const agentId = this.server.currentAgentId;
216
+ if (!agentId) return;
217
+
218
+ const fromId = data.from;
219
+ const chunkType = data.chunkType || 'text';
220
+ const chunkData = data.data;
221
+
222
+ // Notify callback so OpenClaw agent can process streaming input
223
+ if (this._onNewMessage) {
224
+ this._onNewMessage({
225
+ type: 'stream_chunk',
226
+ from_id: fromId,
227
+ chunk_type: chunkType,
228
+ data: chunkData,
229
+ });
230
+ }
231
+ console.log('[Chat] Stream chunk from', fromId, 'type:', chunkType);
232
+ }
233
+
234
+ _handleStreamEnd(data) {
235
+ // Incoming stream end signal from another agent
236
+ const agentId = this.server.currentAgentId;
237
+ if (!agentId) return;
238
+
239
+ const fromId = data.from;
240
+ const messageId = data.messageId || '';
241
+
242
+ // Notify callback so OpenClaw agent knows stream is complete
243
+ if (this._onNewMessage) {
244
+ this._onNewMessage({
245
+ type: 'stream_end',
246
+ from_id: fromId,
247
+ message_id: messageId,
248
+ });
249
+ }
250
+ console.log('[Chat] Stream end from', fromId, 'messageId:', messageId);
251
+ }
252
+
211
253
  // ─── Chat History ─────────────────────────────────────────────────
212
254
 
213
255
  getHistory(agentId, targetId, { limit = 50, before = null } = {}) {
package/lib/crypto.js CHANGED
File without changes
package/lib/database.js CHANGED
File without changes
File without changes
package/lib/handshake.js CHANGED
File without changes
package/lib/identity.js CHANGED
File without changes
File without changes
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "aicq-chat",
3
3
  "name": "AICQ Encrypted Chat",
4
- "version": "2.1.0",
4
+ "version": "2.2.0",
5
5
  "description": "End-to-end encrypted chat plugin for OpenClaw agents — Node.js implementation with full UI",
6
6
  "entry": "index.js",
7
7
  "activation": {
@@ -25,6 +25,8 @@
25
25
  "aicq.chat.history",
26
26
  "aicq.chat.send",
27
27
  "aicq.chat.delete",
28
+ "aicq.chat.streamChunk",
29
+ "aicq.chat.streamEnd",
28
30
  "aicq.groups.list",
29
31
  "aicq.groups.create",
30
32
  "aicq.groups.join",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aicq-chat-plugin",
3
- "version": "2.3.0",
3
+ "version": "2.4.1",
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
@@ -122,9 +122,28 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
122
122
  .msg-bubble a{color:var(--info)}
123
123
  .msg-bubble table{border-collapse:collapse;margin:6px 0}
124
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}
125
141
  </style>
126
142
  </head>
127
143
  <body>
144
+ <!-- Toast -->
145
+ <div class="toast" id="toast"></div>
146
+
128
147
  <div class="app">
129
148
  <!-- Right Panel -->
130
149
  <div class="right-panel">
@@ -143,6 +162,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
143
162
  <button class="action-btn" onclick="showModal('settings')">
144
163
  <span class="icon">⚙️</span>设置
145
164
  </button>
165
+ <button class="action-btn" onclick="confirmLogout()">
166
+ <span class="icon">🚪</span>登出
167
+ </button>
146
168
  </div>
147
169
  <div class="list-section">
148
170
  <h4>好友</h4>
@@ -240,8 +262,25 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
240
262
  <button class="btn btn-secondary" onclick="showKeysModal()" style="width:100%;text-align:left">🔑 显示/重新生成密钥</button>
241
263
  <button class="btn btn-secondary" onclick="createNewAgent()" style="width:100%;text-align:left">➕ 创建新 Agent</button>
242
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>
243
281
  </div>
244
282
  <div class="btn-row" style="margin-top:20px">
283
+ <button class="btn btn-danger" onclick="confirmLogout()">🚪 登出</button>
245
284
  <button class="btn btn-primary" onclick="hideModal('settings')">关闭</button>
246
285
  </div>
247
286
  </div>
@@ -343,6 +382,20 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans S
343
382
  </div>
344
383
  </div>
345
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
+
346
399
  <script>
347
400
  // ─── State ──────────────────────────────────────────────────────────
348
401
  let currentAgentId = '';
@@ -362,19 +415,6 @@ async function api(method, path, body = null) {
362
415
  return resp.json();
363
416
  }
364
417
 
365
- // ─── Initialize ─────────────────────────────────────────────────────
366
- async function init() {
367
- await loadAgents();
368
- await loadFriends();
369
- await loadGroups();
370
- // Auto-select first agent
371
- const sel = document.getElementById('agentSelect');
372
- if (sel.options.length > 1) {
373
- currentAgentId = sel.options[1].value;
374
- sel.value = currentAgentId;
375
- }
376
- }
377
-
378
418
  // ─── Agents ─────────────────────────────────────────────────────────
379
419
  async function loadAgents() {
380
420
  const data = await api('GET', '/api/agents');
@@ -391,6 +431,7 @@ async function loadAgents() {
391
431
 
392
432
  async function switchAgent(agentId) {
393
433
  if (!agentId) return;
434
+ saveChatToLocalStorage();
394
435
  currentAgentId = agentId;
395
436
  currentTarget = null;
396
437
  showEmptyState();
@@ -661,6 +702,7 @@ async function sendMessage() {
661
702
  mentions: extractMentions(content),
662
703
  });
663
704
  await loadMessages();
705
+ saveChatToLocalStorage();
664
706
  } catch (e) {
665
707
  alert('发送失败: ' + e.message);
666
708
  }
@@ -979,14 +1021,401 @@ async function handlePluginAvatarUpload(input) {
979
1021
  input.value = '';
980
1022
  }
981
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
+
982
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
+
983
1411
  init();
984
1412
 
985
- // Periodic refresh
1413
+ // Periodic refresh and auto-save
986
1414
  setInterval(async () => {
987
1415
  if (currentAgentId) {
988
1416
  await loadFriends();
989
1417
  await loadGroups();
1418
+ saveChatToLocalStorage();
990
1419
  }
991
1420
  }, 30000);
992
1421
  </script>