aicq-chat-plugin 3.9.0 → 3.9.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 +80 -80
- package/SKILL.md +78 -78
- package/cli.cjs +356 -356
- package/index.js +417 -385
- package/lib/chat.js +854 -971
- package/lib/crypto.js +168 -168
- package/lib/database.js +455 -455
- package/lib/file-transfer.js +266 -266
- package/lib/handshake.js +147 -147
- package/lib/identity.js +165 -165
- package/lib/package.json +3 -3
- package/lib/server-client.js +380 -337
- package/openclaw.plugin.json +170 -168
- package/package.json +87 -87
- package/postinstall.cjs +27 -27
- package/public/favicon.ico +0 -0
- package/public/icon-16.png +0 -0
- package/public/icon-32.png +0 -0
- package/public/index.html +1468 -1468
- package/public/logo-512.png +0 -0
- package/setup-entry.js +14 -14
- package/src/channel.js +616 -637
- package/src/ui-routes.js +647 -594
package/public/index.html
CHANGED
|
@@ -1,1468 +1,1468 @@
|
|
|
1
|
-
<!DOCTYPE html>
|
|
2
|
-
<html lang="zh-CN">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="UTF-8">
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
-
<meta name="theme-color" content="#D97757">
|
|
7
|
-
<title>AICQ 加密聊天</title>
|
|
8
|
-
<link rel="icon" type="image/png" sizes="32x32" href="/icon-32.png">
|
|
9
|
-
<link rel="icon" type="image/png" sizes="16x16" href="/icon-16.png">
|
|
10
|
-
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
|
11
|
-
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
|
12
|
-
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
|
13
|
-
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
|
|
14
|
-
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.0/marked.min.js"></script>
|
|
15
|
-
<style>
|
|
16
|
-
*{margin:0;padding:0;box-sizing:border-box}
|
|
17
|
-
:root{--primary:#D97757;--primary-light:#C4613F;--primary-lighter:rgba(217,119,87,0.1);--accent:#D97757;--accent-hover:#C4613F;--accent-light:rgba(217,119,87,0.1);--bg:#FAF9F6;--bg2:#F5F1EB;--bg-warm:#F5F1EB;--bg3:#FFFFFF;--bg-card:#FFFFFF;--text:#2D2A26;--text2:#6B6560;--text-sec:#6B6560;--text-muted:#9B958E;--border:#E8DFD3;--beige:#E8DFD3;--success:#4CAF7D;--green:#4CAF7D;--danger:#E05555;--red:#E05555;--warning:#E5A54B;--amber:#E5A54B;--brown:#8B6F4E;--brown-light:rgba(139,111,78,0.1);--cream:#F0EAE0;--info:#5B8DEF;--blue:#5B8DEF;--purple:#7B6CB0;--bubble-me:#D97757;--bubble-them:#FFFFFF;--shadow-sm:0 1px 3px rgba(45,42,38,0.06);--shadow-md:0 4px 16px rgba(45,42,38,0.08);--shadow-lg:0 8px 32px rgba(45,42,38,0.1);--radius:12px;--radius-sm:8px;--radius-lg:20px;--transition:all 0.25s cubic-bezier(0.4,0,0.2,1)}
|
|
18
|
-
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans SC",sans-serif;background:var(--bg);color:var(--text);display:flex;height:100vh;overflow:hidden;-webkit-font-smoothing:antialiased}
|
|
19
|
-
/* Layout */
|
|
20
|
-
.app{display:flex;width:100%;height:100%}
|
|
21
|
-
.right-panel{width:280px;background:var(--bg3);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0}
|
|
22
|
-
.main-panel{flex:1;display:flex;flex-direction:column;min-width:0}
|
|
23
|
-
.chat-area{flex:1;overflow:hidden;display:flex;flex-direction:column}
|
|
24
|
-
/* Right Panel */
|
|
25
|
-
.agent-select{padding:12px;border-bottom:1px solid var(--border)}
|
|
26
|
-
.agent-select select{width:100%;padding:8px 12px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:10px;font-size:14px;cursor:pointer}
|
|
27
|
-
.action-buttons{display:flex;gap:6px;padding:10px 12px;border-bottom:1px solid var(--border)}
|
|
28
|
-
.action-btn{flex:1;padding:7px 4px;background:var(--cream);color:var(--text2);border:1px solid var(--border);border-radius:10px;cursor:pointer;font-size:12px;display:flex;flex-direction:column;align-items:center;gap:3px;transition:var(--transition)}
|
|
29
|
-
.action-btn:hover{background:var(--primary);color:#fff;border-color:var(--primary)}
|
|
30
|
-
.action-btn .icon{font-size:18px}
|
|
31
|
-
.list-section{flex:1;overflow-y:auto}
|
|
32
|
-
.list-section h4{padding:10px 12px 6px;color:var(--text-muted);font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1.5px}
|
|
33
|
-
.friend-item,.group-item{display:flex;align-items:center;gap:10px;padding:10px 12px;cursor:pointer;transition:var(--transition);border-left:3px solid transparent}
|
|
34
|
-
.friend-item:hover,.group-item:hover{background:var(--cream)}
|
|
35
|
-
.friend-item.active,.group-item.active{background:var(--primary-lighter);border-left-color:var(--primary)}
|
|
36
|
-
.avatar{width:36px;height:36px;border-radius:12px;background:linear-gradient(135deg,var(--primary),var(--brown));display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:600;flex-shrink:0;color:#fff;overflow:hidden}
|
|
37
|
-
.avatar img{width:100%;height:100%;object-fit:cover;border-radius:12px}
|
|
38
|
-
.avatar.online{box-shadow:0 0 0 2px var(--success)}
|
|
39
|
-
.info{flex:1;min-width:0}
|
|
40
|
-
.info .name{font-size:14px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
41
|
-
.info .status{font-size:11px;color:var(--text2)}
|
|
42
|
-
.badge-online{color:var(--success);font-size:10px}
|
|
43
|
-
.badge-offline{color:var(--text2);font-size:10px}
|
|
44
|
-
.silent-badge{font-size:9px;background:var(--amber);color:#fff;padding:1px 4px;border-radius:4px;margin-left:4px}
|
|
45
|
-
/* Chat Header */
|
|
46
|
-
.chat-header{padding:12px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;background:var(--bg3)}
|
|
47
|
-
.chat-header .avatar{width:40px;height:40px;font-size:16px}
|
|
48
|
-
.chat-header .info .name{font-size:16px}
|
|
49
|
-
.chat-header .actions{margin-left:auto;display:flex;gap:8px}
|
|
50
|
-
.chat-header .actions button{background:none;border:none;color:var(--text2);cursor:pointer;font-size:18px;padding:4px 8px;border-radius:var(--radius-sm);transition:var(--transition)}
|
|
51
|
-
.chat-header .actions button:hover{background:var(--cream);color:var(--primary)}
|
|
52
|
-
/* Messages */
|
|
53
|
-
.messages-container{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:4px;background:var(--bg2)}
|
|
54
|
-
.load-more{text-align:center;padding:8px;color:var(--text2);font-size:12px;cursor:pointer}
|
|
55
|
-
.load-more:hover{color:var(--primary)}
|
|
56
|
-
.msg-row{display:flex;max-width:75%;gap:8px}
|
|
57
|
-
.msg-row.me{margin-left:auto;flex-direction:row-reverse}
|
|
58
|
-
.msg-row.them{margin-right:auto}
|
|
59
|
-
.msg-bubble{padding:10px 14px;border-radius:14px;font-size:14px;line-height:1.6;word-break:break-word;position:relative;max-width:100%}
|
|
60
|
-
.msg-row.me .msg-bubble{background:var(--bubble-me);color:#fff;border-top-right-radius:4px}
|
|
61
|
-
.msg-row.them .msg-bubble{background:var(--bubble-them);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px}
|
|
62
|
-
.msg-bubble img{max-width:300px;max-height:300px;border-radius:8px;margin-top:6px;cursor:pointer}
|
|
63
|
-
.msg-bubble .file-link{display:flex;align-items:center;gap:6px;padding:6px 10px;background:rgba(255,255,255,.1);border-radius:6px;margin-top:6px;cursor:pointer;color:inherit;text-decoration:none}
|
|
64
|
-
.msg-bubble .file-link:hover{background:rgba(255,255,255,.2)}
|
|
65
|
-
.msg-time{font-size:10px;color:var(--text2);margin-top:4px;text-align:right}
|
|
66
|
-
.msg-row.me .msg-time{color:rgba(255,255,255,0.6)}
|
|
67
|
-
.msg-row.them .msg-time{text-align:left}
|
|
68
|
-
.msg-actions{position:absolute;top:-8px;right:-4px;display:none;gap:2px}
|
|
69
|
-
.msg-row.me .msg-actions{right:auto;left:-4px}
|
|
70
|
-
.msg-row:hover .msg-actions{display:flex}
|
|
71
|
-
.msg-action-btn{width:24px;height:24px;border-radius:var(--radius-sm);background:var(--bg3);border:1px solid var(--border);color:var(--text2);cursor:pointer;font-size:11px;display:flex;align-items:center;justify-content:center;transition:var(--transition)}
|
|
72
|
-
.msg-action-btn:hover{background:var(--primary);color:#fff;border-color:var(--primary)}
|
|
73
|
-
.msg-action-btn.danger:hover{background:var(--danger);border-color:var(--danger)}
|
|
74
|
-
/* Mention */
|
|
75
|
-
.mention{color:var(--info);font-weight:600;cursor:pointer}
|
|
76
|
-
.mention:hover{text-decoration:underline}
|
|
77
|
-
/* Input Area */
|
|
78
|
-
.input-area{padding:12px 16px;border-top:1px solid var(--border);background:var(--bg3);display:flex;flex-direction:column;gap:8px}
|
|
79
|
-
.input-top{display:flex;gap:8px;align-items:center}
|
|
80
|
-
.input-top input{flex:1;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:12px;color:var(--text);font-size:14px;outline:none;transition:var(--transition)}
|
|
81
|
-
.input-top input:focus{border-color:var(--primary);box-shadow:0 0 0 3px var(--primary-lighter)}
|
|
82
|
-
.send-btn{padding:10px 20px;background:var(--primary);color:#fff;border:none;border-radius:10px;cursor:pointer;font-size:14px;font-weight:500;box-shadow:0 4px 16px rgba(217,119,87,0.3);transition:var(--transition)}
|
|
83
|
-
.send-btn:hover{background:var(--primary-light);transform:scale(1.05)}
|
|
84
|
-
.send-btn:disabled{opacity:.5;cursor:not-allowed;transform:none}
|
|
85
|
-
.input-toolbar{display:flex;gap:4px}
|
|
86
|
-
.tool-btn{background:none;border:none;color:var(--text2);cursor:pointer;font-size:16px;padding:4px 8px;border-radius:var(--radius-sm);transition:var(--transition)}
|
|
87
|
-
.tool-btn:hover{background:var(--cream);color:var(--primary)}
|
|
88
|
-
/* Modals */
|
|
89
|
-
.modal-overlay{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(45,42,38,0.4);display:none;align-items:center;justify-content:center;z-index:1000}
|
|
90
|
-
.modal-overlay.show{display:flex}
|
|
91
|
-
.modal{background:var(--bg3);border:1px solid var(--border);border-radius:20px;padding:24px;max-width:480px;width:90%;max-height:80vh;overflow-y:auto;box-shadow:var(--shadow-lg)}
|
|
92
|
-
.modal h3{margin-bottom:16px;font-size:18px}
|
|
93
|
-
.modal .form-group{margin-bottom:14px}
|
|
94
|
-
.modal label{display:block;margin-bottom:6px;font-size:13px;color:var(--text2)}
|
|
95
|
-
.modal input,.modal select,.modal textarea{width:100%;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:10px;color:var(--text);font-size:14px;transition:var(--transition)}
|
|
96
|
-
.modal input:focus,.modal select:focus,.modal textarea:focus{border-color:var(--primary);box-shadow:0 0 0 3px var(--primary-lighter);outline:none}
|
|
97
|
-
.modal textarea{min-height:80px;resize:vertical}
|
|
98
|
-
.modal .btn-row{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
|
|
99
|
-
.modal .btn{padding:8px 16px;border:none;border-radius:10px;cursor:pointer;font-size:14px;transition:var(--transition)}
|
|
100
|
-
.modal .btn-primary{background:var(--primary);color:#fff}
|
|
101
|
-
.modal .btn-primary:hover{background:var(--primary-light)}
|
|
102
|
-
.modal .btn-secondary{background:var(--cream);color:var(--text);border:1px solid var(--border)}
|
|
103
|
-
.modal .btn-secondary:hover{background:var(--primary-lighter);border-color:var(--primary);color:var(--primary)}
|
|
104
|
-
.modal .btn-danger{background:var(--danger);color:#fff}
|
|
105
|
-
.qr-container{text-align:center;margin:16px 0}
|
|
106
|
-
.qr-container img{max-width:256px;border-radius:8px}
|
|
107
|
-
.key-display{background:var(--bg);padding:10px;border-radius:var(--radius-sm);font-family:monospace;font-size:12px;word-break:break-all;margin:8px 0;color:var(--text-muted)}
|
|
108
|
-
.warning-box{background:rgba(224,85,85,0.08);border:1px solid var(--danger);border-radius:var(--radius-sm);padding:12px;margin:12px 0;color:var(--danger);font-size:13px}
|
|
109
|
-
/* Empty State */
|
|
110
|
-
.empty-state{flex:1;display:flex;align-items:center;justify-content:center;color:var(--text-muted);font-size:16px;flex-direction:column;gap:12px}
|
|
111
|
-
.empty-state .icon{font-size:48px;opacity:.3}
|
|
112
|
-
/* Mention dropdown */
|
|
113
|
-
.mention-dropdown{position:absolute;bottom:100%;left:0;background:var(--bg3);border:1px solid var(--border);border-radius:10px;max-height:200px;overflow-y:auto;z-index:100;display:none;min-width:200px;box-shadow:var(--shadow-md)}
|
|
114
|
-
.mention-dropdown.show{display:block}
|
|
115
|
-
.mention-option{padding:8px 12px;cursor:pointer;font-size:13px;display:flex;align-items:center;gap:8px}
|
|
116
|
-
.mention-option:hover{background:var(--cream)}
|
|
117
|
-
/* Scrollbar */
|
|
118
|
-
::-webkit-scrollbar{width:6px}
|
|
119
|
-
::-webkit-scrollbar-track{background:transparent}
|
|
120
|
-
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
|
121
|
-
::-webkit-scrollbar-thumb:hover{background:var(--text-muted)}
|
|
122
|
-
/* Markdown in bubbles */
|
|
123
|
-
.msg-bubble p{margin:4px 0}
|
|
124
|
-
.msg-row.them .msg-bubble code{background:rgba(0,0,0,0.05);padding:1px 4px;border-radius:3px;font-size:13px}
|
|
125
|
-
.msg-row.me .msg-bubble code{background:rgba(255,255,255,0.2);padding:1px 4px;border-radius:3px;font-size:13px}
|
|
126
|
-
.msg-row.them .msg-bubble pre{background:rgba(0,0,0,0.04);padding:8px;border-radius:6px;overflow-x:auto;margin:6px 0}
|
|
127
|
-
.msg-row.me .msg-bubble pre{background:rgba(255,255,255,0.15);padding:8px;border-radius:6px;overflow-x:auto;margin:6px 0}
|
|
128
|
-
.msg-bubble pre code{background:none;padding:0}
|
|
129
|
-
.msg-bubble blockquote{border-left:3px solid var(--primary);padding-left:10px;margin:6px 0;color:var(--text2)}
|
|
130
|
-
.msg-bubble ul,.msg-bubble ol{padding-left:20px;margin:4px 0}
|
|
131
|
-
.msg-row.them .msg-bubble a{color:var(--primary)}
|
|
132
|
-
.msg-row.me .msg-bubble a{color:#fff;text-decoration:underline}
|
|
133
|
-
.msg-bubble table{border-collapse:collapse;margin:6px 0}
|
|
134
|
-
.msg-bubble th{background:var(--cream);border:1px solid var(--border);padding:4px 8px;font-size:13px}
|
|
135
|
-
.msg-bubble td{border:1px solid var(--border);padding:4px 8px;font-size:13px}
|
|
136
|
-
/* Toast */
|
|
137
|
-
.toast{position:fixed;top:20px;left:50%;transform:translateX(-50%);background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:12px 24px;border-radius:var(--radius);font-size:14px;z-index:9999;box-shadow:var(--shadow-lg);max-width:90%;text-align:center;opacity:0;transition:opacity .3s;pointer-events:none}
|
|
138
|
-
.toast.show{opacity:1}
|
|
139
|
-
.toast.warning{border-color:var(--warning);background:rgba(229,165,75,0.1);color:var(--warning)}
|
|
140
|
-
/* Backup section */
|
|
141
|
-
.backup-section{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);padding:12px;margin-top:8px}
|
|
142
|
-
.backup-section h4{font-size:14px;margin-bottom:8px;color:var(--text)}
|
|
143
|
-
.backup-section p{font-size:12px;color:var(--text2);line-height:1.6;margin:4px 0}
|
|
144
|
-
.backup-section .warning-box{font-size:12px;padding:8px;margin:8px 0}
|
|
145
|
-
.backup-btns{display:flex;gap:8px;margin-top:10px}
|
|
146
|
-
.backup-btns .btn{flex:1;text-align:center}
|
|
147
|
-
/* Key match row */
|
|
148
|
-
.key-match-row{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid var(--border)}
|
|
149
|
-
.key-match-row:last-child{border-bottom:none}
|
|
150
|
-
.key-match-row label{font-size:13px;color:var(--text2);min-width:120px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
151
|
-
.key-match-row select{flex:1;padding:6px 10px;background:var(--bg);border:1px solid var(--border);border-radius:10px;color:var(--text);font-size:13px}
|
|
152
|
-
</style>
|
|
153
|
-
</head>
|
|
154
|
-
<body>
|
|
155
|
-
<!-- Toast -->
|
|
156
|
-
<div class="toast" id="toast"></div>
|
|
157
|
-
|
|
158
|
-
<div class="app">
|
|
159
|
-
<!-- Right Panel -->
|
|
160
|
-
<div class="right-panel">
|
|
161
|
-
<div class="agent-select">
|
|
162
|
-
<select id="agentSelect" onchange="switchAgent(this.value)">
|
|
163
|
-
<option value="">选择 Agent...</option>
|
|
164
|
-
</select>
|
|
165
|
-
</div>
|
|
166
|
-
<div class="action-buttons">
|
|
167
|
-
<button class="action-btn" onclick="showModal('addFriend')">
|
|
168
|
-
<span class="icon">👤</span>添加好友
|
|
169
|
-
</button>
|
|
170
|
-
<button class="action-btn" onclick="showModal('joinGroup')">
|
|
171
|
-
<span class="icon">👥</span>加入群聊
|
|
172
|
-
</button>
|
|
173
|
-
<button class="action-btn" onclick="showModal('settings')">
|
|
174
|
-
<span class="icon">⚙️</span>设置
|
|
175
|
-
</button>
|
|
176
|
-
<button class="action-btn" onclick="confirmLogout()">
|
|
177
|
-
<span class="icon">🚪</span>登出
|
|
178
|
-
</button>
|
|
179
|
-
</div>
|
|
180
|
-
<div class="list-section">
|
|
181
|
-
<div style="padding:8px 12px;font-size:11px;color:#8B6914;background:rgba(212,151,59,0.08);border-bottom:1px solid rgba(212,151,59,0.2);display:flex;align-items:flex-start;gap:6px">
|
|
182
|
-
<span style="flex-shrink:0">⚠️</span>
|
|
183
|
-
<span>若连续30天没有登录,好友关系和主人关系将被清理,到时候需重新绑定主人和添加好友。</span>
|
|
184
|
-
</div>
|
|
185
|
-
<h4>好友</h4>
|
|
186
|
-
<div id="friendsList"></div>
|
|
187
|
-
<h4>群聊</h4>
|
|
188
|
-
<div id="groupsList"></div>
|
|
189
|
-
</div>
|
|
190
|
-
</div>
|
|
191
|
-
|
|
192
|
-
<!-- Main Panel -->
|
|
193
|
-
<div class="main-panel">
|
|
194
|
-
<div id="emptyState" class="empty-state">
|
|
195
|
-
<div class="icon">💬</div>
|
|
196
|
-
<div>选择好友或群聊开始对话</div>
|
|
197
|
-
</div>
|
|
198
|
-
<div id="chatView" class="chat-area" style="display:none">
|
|
199
|
-
<div class="chat-header">
|
|
200
|
-
<div class="avatar" id="chatAvatar">U</div>
|
|
201
|
-
<div class="info">
|
|
202
|
-
<div class="name" id="chatName">-</div>
|
|
203
|
-
<div class="status" id="chatStatus">-</div>
|
|
204
|
-
</div>
|
|
205
|
-
<div class="actions">
|
|
206
|
-
<button onclick="toggleSilent()" id="silentBtn" title="静默模式">🔔</button>
|
|
207
|
-
<button onclick="showChatInfo()" title="信息">ℹ️</button>
|
|
208
|
-
</div>
|
|
209
|
-
</div>
|
|
210
|
-
<div class="messages-container" id="messagesContainer">
|
|
211
|
-
<div class="load-more" id="loadMore" onclick="loadMoreMessages()" style="display:none">加载更多消息</div>
|
|
212
|
-
</div>
|
|
213
|
-
<div class="input-area" style="position:relative">
|
|
214
|
-
<div class="mention-dropdown" id="mentionDropdown"></div>
|
|
215
|
-
<div class="input-top">
|
|
216
|
-
<input type="text" id="messageInput" placeholder="输入消息... (@提及)" onkeydown="handleInputKey(event)" oninput="handleInputChange()">
|
|
217
|
-
<button class="send-btn" onclick="sendMessage()">发送</button>
|
|
218
|
-
</div>
|
|
219
|
-
<div class="input-toolbar">
|
|
220
|
-
<button class="tool-btn" onclick="document.getElementById('fileInput').click()" title="上传文件">📎</button>
|
|
221
|
-
<button class="tool-btn" onclick="document.getElementById('imageInput').click()" title="上传图片">🖼️</button>
|
|
222
|
-
<button class="tool-btn" onclick="insertMarkdown('**','**')" title="粗体">𝐁</button>
|
|
223
|
-
<button class="tool-btn" onclick="insertMarkdown('`','`')" title="代码">⟨⟩</button>
|
|
224
|
-
<button class="tool-btn" onclick="insertMarkdown('$','$')" title="LaTeX">∑</button>
|
|
225
|
-
</div>
|
|
226
|
-
<input type="file" id="fileInput" style="display:none" onchange="uploadFile(this,'file')">
|
|
227
|
-
<input type="file" id="imageInput" style="display:none" accept="image/*" onchange="uploadFile(this,'image')">
|
|
228
|
-
</div>
|
|
229
|
-
</div>
|
|
230
|
-
</div>
|
|
231
|
-
</div>
|
|
232
|
-
|
|
233
|
-
<!-- Modals -->
|
|
234
|
-
<div class="modal-overlay" id="modal-addFriend">
|
|
235
|
-
<div class="modal">
|
|
236
|
-
<h3>添加好友</h3>
|
|
237
|
-
<div class="form-group">
|
|
238
|
-
<label>好友码 / 临时号码</label>
|
|
239
|
-
<input type="text" id="friendCodeInput" placeholder="输入6位好友码或临时号码">
|
|
240
|
-
</div>
|
|
241
|
-
<div class="btn-row">
|
|
242
|
-
<button class="btn btn-secondary" onclick="hideModal('addFriend')">取消</button>
|
|
243
|
-
<button class="btn btn-primary" onclick="addFriendByCode()">添加</button>
|
|
244
|
-
</div>
|
|
245
|
-
</div>
|
|
246
|
-
</div>
|
|
247
|
-
|
|
248
|
-
<div class="modal-overlay" id="modal-joinGroup">
|
|
249
|
-
<div class="modal">
|
|
250
|
-
<h3>加入群聊</h3>
|
|
251
|
-
<div class="form-group">
|
|
252
|
-
<label>创建新群聊</label>
|
|
253
|
-
<input type="text" id="newGroupName" placeholder="群聊名称">
|
|
254
|
-
</div>
|
|
255
|
-
<div class="btn-row">
|
|
256
|
-
<button class="btn btn-secondary" onclick="hideModal('joinGroup')">取消</button>
|
|
257
|
-
<button class="btn btn-primary" onclick="createGroup()">创建</button>
|
|
258
|
-
</div>
|
|
259
|
-
</div>
|
|
260
|
-
</div>
|
|
261
|
-
|
|
262
|
-
<div class="modal-overlay" id="modal-settings">
|
|
263
|
-
<div class="modal">
|
|
264
|
-
<h3>设置</h3>
|
|
265
|
-
<div style="display:flex;flex-direction:column;gap:12px">
|
|
266
|
-
<div style="text-align:center;padding:8px 0">
|
|
267
|
-
<div style="position:relative;display:inline-block">
|
|
268
|
-
<div class="avatar" id="settingsAvatar" style="width:64px;height:64px;font-size:24px;cursor:pointer" onclick="document.getElementById('avatarUploadInput').click()">A</div>
|
|
269
|
-
<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>
|
|
270
|
-
</div>
|
|
271
|
-
<input type="file" id="avatarUploadInput" accept="image/*" style="display:none" onchange="handlePluginAvatarUpload(this)">
|
|
272
|
-
<div style="font-size:11px;color:var(--text2);margin-top:4px">点击更换头像</div>
|
|
273
|
-
</div>
|
|
274
|
-
<button class="btn btn-secondary" onclick="showQRCode()" style="width:100%;text-align:left">📱 二维码 — 客户端扫描添加好友</button>
|
|
275
|
-
<button class="btn btn-secondary" onclick="showNicknameModal()" style="width:100%;text-align:left">✏️ 修改昵称</button>
|
|
276
|
-
<button class="btn btn-secondary" onclick="generateFriendCode()" style="width:100%;text-align:left">🔢 生成好友码 (24小时有效)</button>
|
|
277
|
-
<button class="btn btn-secondary" onclick="showKeysModal()" style="width:100%;text-align:left">🔑 显示/重新生成密钥</button>
|
|
278
|
-
<button class="btn btn-secondary" onclick="createNewAgent()" style="width:100%;text-align:left">➕ 创建新 Agent</button>
|
|
279
|
-
<button class="btn btn-secondary" onclick="syncData()" style="width:100%;text-align:left">🔄 同步服务器数据</button>
|
|
280
|
-
<div class="backup-section">
|
|
281
|
-
<h4>💾 数据备份</h4>
|
|
282
|
-
<p><strong>导出内容:</strong>聊天消息、好友关系、群聊信息、Agent 身份密钥(含私钥)</p>
|
|
283
|
-
<div class="warning-box">⚠️ 私钥包含在导出文件中!请妥善保管备份文件,切勿分享给他人。拥有私钥的人可以冒充你的身份发送消息。</div>
|
|
284
|
-
<p><strong>数据丢失场景:</strong></p>
|
|
285
|
-
<p>• 清除浏览器缓存数据</p>
|
|
286
|
-
<p>• 删除浏览器数据/历史记录</p>
|
|
287
|
-
<p>• 使用无痕/隐身模式(关闭窗口后数据清除)</p>
|
|
288
|
-
<p>• 更换浏览器或设备</p>
|
|
289
|
-
<p><strong>恢复方法:</strong>点击「导入备份」选择之前导出的 JSON 文件。若备份中的 Agent 私钥与当前 Agent 不匹配,系统会提示你手动对应。</p>
|
|
290
|
-
<div class="backup-btns">
|
|
291
|
-
<button class="btn btn-primary" onclick="exportBackup()">📤 导出备份</button>
|
|
292
|
-
<button class="btn btn-secondary" onclick="triggerImportBackup()">📥 导入备份</button>
|
|
293
|
-
</div>
|
|
294
|
-
<input type="file" id="backupFileInput" accept=".json" style="display:none" onchange="handleImportBackup(this)">
|
|
295
|
-
</div>
|
|
296
|
-
</div>
|
|
297
|
-
<div class="btn-row" style="margin-top:20px">
|
|
298
|
-
<button class="btn btn-danger" onclick="confirmLogout()">🚪 登出</button>
|
|
299
|
-
<button class="btn btn-primary" onclick="hideModal('settings')">关闭</button>
|
|
300
|
-
</div>
|
|
301
|
-
</div>
|
|
302
|
-
</div>
|
|
303
|
-
|
|
304
|
-
<div class="modal-overlay" id="modal-qr">
|
|
305
|
-
<div class="modal">
|
|
306
|
-
<h3>二维码 — 扫码添加好友</h3>
|
|
307
|
-
<div class="qr-container" id="qrContainer">加载中...</div>
|
|
308
|
-
<p style="font-size:12px;color:var(--text2);text-align:center">客户端扫描此二维码,通过验证后即可添加该 Agent 为好友</p>
|
|
309
|
-
<div class="btn-row">
|
|
310
|
-
<button class="btn btn-primary" onclick="hideModal('qr')">关闭</button>
|
|
311
|
-
</div>
|
|
312
|
-
</div>
|
|
313
|
-
</div>
|
|
314
|
-
|
|
315
|
-
<div class="modal-overlay" id="modal-nickname">
|
|
316
|
-
<div class="modal">
|
|
317
|
-
<h3>修改昵称</h3>
|
|
318
|
-
<div class="form-group">
|
|
319
|
-
<label>新昵称</label>
|
|
320
|
-
<input type="text" id="nicknameInput" placeholder="输入新昵称">
|
|
321
|
-
</div>
|
|
322
|
-
<div class="btn-row">
|
|
323
|
-
<button class="btn btn-secondary" onclick="hideModal('nickname')">取消</button>
|
|
324
|
-
<button class="btn btn-primary" onclick="saveNickname()">保存</button>
|
|
325
|
-
</div>
|
|
326
|
-
</div>
|
|
327
|
-
</div>
|
|
328
|
-
|
|
329
|
-
<div class="modal-overlay" id="modal-friendCode">
|
|
330
|
-
<div class="modal">
|
|
331
|
-
<h3>好友码</h3>
|
|
332
|
-
<div id="friendCodeDisplay" style="text-align:center;padding:20px">生成中...</div>
|
|
333
|
-
<p style="font-size:12px;color:var(--text2);text-align:center">24小时有效的6位数字好友码,对方添加此号码即可自动连接服务器并添加好友</p>
|
|
334
|
-
<div class="btn-row">
|
|
335
|
-
<button class="btn btn-primary" onclick="hideModal('friendCode')">关闭</button>
|
|
336
|
-
</div>
|
|
337
|
-
</div>
|
|
338
|
-
</div>
|
|
339
|
-
|
|
340
|
-
<div class="modal-overlay" id="modal-keys">
|
|
341
|
-
<div class="modal">
|
|
342
|
-
<h3>密钥管理</h3>
|
|
343
|
-
<div class="warning-box">⚠️ 警告:私钥用于执行模式聊天。拥有私钥的人可以通过聊天安排任务执行、调用工具。请勿泄露私钥!</div>
|
|
344
|
-
<div id="keysDisplay">加载中...</div>
|
|
345
|
-
<div class="btn-row">
|
|
346
|
-
<button class="btn btn-danger" onclick="confirmRotateKeys()">重新生成密钥</button>
|
|
347
|
-
<button class="btn btn-primary" onclick="hideModal('keys')">关闭</button>
|
|
348
|
-
</div>
|
|
349
|
-
</div>
|
|
350
|
-
</div>
|
|
351
|
-
|
|
352
|
-
<div class="modal-overlay" id="modal-confirmRotate">
|
|
353
|
-
<div class="modal">
|
|
354
|
-
<h3>⚠️ 确认重新生成密钥</h3>
|
|
355
|
-
<div class="warning-box">重新生成密钥后,旧密钥将立即失效。所有好友需要重新建立连接。此操作不可撤销!</div>
|
|
356
|
-
<div class="btn-row">
|
|
357
|
-
<button class="btn btn-secondary" onclick="hideModal('confirmRotate')">取消</button>
|
|
358
|
-
<button class="btn btn-danger" onclick="rotateKeys()">确认重新生成</button>
|
|
359
|
-
</div>
|
|
360
|
-
</div>
|
|
361
|
-
</div>
|
|
362
|
-
|
|
363
|
-
<div class="modal-overlay" id="modal-createAgent">
|
|
364
|
-
<div class="modal">
|
|
365
|
-
<h3>创建新 Agent</h3>
|
|
366
|
-
<div class="form-group">
|
|
367
|
-
<label>Agent ID</label>
|
|
368
|
-
<input type="text" id="newAgentId" placeholder="输入唯一标识">
|
|
369
|
-
</div>
|
|
370
|
-
<div class="form-group">
|
|
371
|
-
<label>昵称</label>
|
|
372
|
-
<input type="text" id="newAgentName" placeholder="输入昵称">
|
|
373
|
-
</div>
|
|
374
|
-
<div class="btn-row">
|
|
375
|
-
<button class="btn btn-secondary" onclick="hideModal('createAgent')">取消</button>
|
|
376
|
-
<button class="btn btn-primary" onclick="doCreateAgent()">创建</button>
|
|
377
|
-
</div>
|
|
378
|
-
</div>
|
|
379
|
-
</div>
|
|
380
|
-
|
|
381
|
-
<div class="modal-overlay" id="modal-deleteMsg">
|
|
382
|
-
<div class="modal">
|
|
383
|
-
<h3>确认删除</h3>
|
|
384
|
-
<p>确定要删除这条消息吗?此操作不可撤销。</p>
|
|
385
|
-
<div class="btn-row">
|
|
386
|
-
<button class="btn btn-secondary" onclick="hideModal('deleteMsg')">取消</button>
|
|
387
|
-
<button class="btn btn-danger" onclick="doDeleteMessage()">删除</button>
|
|
388
|
-
</div>
|
|
389
|
-
</div>
|
|
390
|
-
</div>
|
|
391
|
-
|
|
392
|
-
<!-- Image Preview Modal -->
|
|
393
|
-
<div class="modal-overlay" id="modal-imagePreview">
|
|
394
|
-
<div class="modal" style="max-width:90vw;max-height:90vh;padding:8px">
|
|
395
|
-
<img id="previewImage" style="max-width:100%;max-height:85vh;border-radius:8px">
|
|
396
|
-
<div class="btn-row"><button class="btn btn-primary" onclick="hideModal('imagePreview')">关闭</button></div>
|
|
397
|
-
</div>
|
|
398
|
-
</div>
|
|
399
|
-
|
|
400
|
-
<!-- Backup Key Match Modal -->
|
|
401
|
-
<div class="modal-overlay" id="modal-backupMatch">
|
|
402
|
-
<div class="modal" style="max-width:560px">
|
|
403
|
-
<h3>🔑 Agent 密钥匹配</h3>
|
|
404
|
-
<div class="warning-box">备份中的 Agent 私钥与当前 Agent 不匹配,请手动选择对应关系。未匹配的聊天记录将不会导入。</div>
|
|
405
|
-
<p style="font-size:13px;color:var(--text2);margin-bottom:12px">左侧为备份中的 Agent,右侧选择当前对应的 Agent:</p>
|
|
406
|
-
<div id="backupMatchList"></div>
|
|
407
|
-
<div class="btn-row">
|
|
408
|
-
<button class="btn btn-secondary" onclick="skipBackupMatch()">跳过不匹配项</button>
|
|
409
|
-
<button class="btn btn-primary" onclick="applyBackupMatch()">确认匹配并导入</button>
|
|
410
|
-
</div>
|
|
411
|
-
</div>
|
|
412
|
-
</div>
|
|
413
|
-
|
|
414
|
-
<script>
|
|
415
|
-
// ─── State ──────────────────────────────────────────────────────────
|
|
416
|
-
let currentAgentId = '';
|
|
417
|
-
let currentTarget = null; // { id, name, type: 'friend'|'group', isOnline, silent }
|
|
418
|
-
let chatMessages = [];
|
|
419
|
-
let oldestTimestamp = null;
|
|
420
|
-
let wsReconnectTimer = null;
|
|
421
|
-
let pendingDeleteMsgId = null;
|
|
422
|
-
|
|
423
|
-
const API = '';
|
|
424
|
-
|
|
425
|
-
// ─── API Helper ─────────────────────────────────────────────────────
|
|
426
|
-
function errMsg(err) {
|
|
427
|
-
if (!err) return '未知错误';
|
|
428
|
-
if (typeof err === 'string') return err;
|
|
429
|
-
if (err.message) return err.message;
|
|
430
|
-
if (err.error && typeof err.error === 'string') return err.error;
|
|
431
|
-
if (err.error && typeof err.error === 'object') return errMsg(err.error);
|
|
432
|
-
try { return JSON.stringify(err); } catch { return '未知错误'; }
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
async function api(method, path, body = null) {
|
|
436
|
-
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
|
437
|
-
if (body) opts.body = JSON.stringify(body);
|
|
438
|
-
const resp = await fetch(API + path, opts);
|
|
439
|
-
const data = await resp.json();
|
|
440
|
-
if (!resp.ok && !data.success) {
|
|
441
|
-
throw new Error(errMsg(data.error) || `HTTP ${resp.status}`);
|
|
442
|
-
}
|
|
443
|
-
return data;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
// ─── Agents ─────────────────────────────────────────────────────────
|
|
447
|
-
async function loadAgents() {
|
|
448
|
-
const data = await api('GET', '/api/agents');
|
|
449
|
-
const sel = document.getElementById('agentSelect');
|
|
450
|
-
sel.innerHTML = '<option value="">选择 Agent...</option>';
|
|
451
|
-
for (const a of data.agents || []) {
|
|
452
|
-
const opt = document.createElement('option');
|
|
453
|
-
opt.value = a.agent_id;
|
|
454
|
-
opt.textContent = a.nickname || a.agent_id;
|
|
455
|
-
if (a.agent_id === currentAgentId) opt.selected = true;
|
|
456
|
-
sel.appendChild(opt);
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
async function switchAgent(agentId) {
|
|
461
|
-
if (!agentId) return;
|
|
462
|
-
saveChatToLocalStorage();
|
|
463
|
-
currentAgentId = agentId;
|
|
464
|
-
currentTarget = null;
|
|
465
|
-
showEmptyState();
|
|
466
|
-
await loadFriends();
|
|
467
|
-
await loadGroups();
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
function showEmptyState() {
|
|
471
|
-
document.getElementById('emptyState').style.display = 'flex';
|
|
472
|
-
document.getElementById('chatView').style.display = 'none';
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
function showChatView() {
|
|
476
|
-
document.getElementById('emptyState').style.display = 'none';
|
|
477
|
-
document.getElementById('chatView').style.display = 'flex';
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
async function createNewAgent() {
|
|
481
|
-
hideModal('settings');
|
|
482
|
-
showModal('createAgent');
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
async function doCreateAgent() {
|
|
486
|
-
const id = document.getElementById('newAgentId').value.trim();
|
|
487
|
-
const name = document.getElementById('newAgentName').value.trim();
|
|
488
|
-
if (!id) return alert('请输入 Agent ID');
|
|
489
|
-
await api('POST', '/api/agents', { agent_id: id, nickname: name });
|
|
490
|
-
hideModal('createAgent');
|
|
491
|
-
currentAgentId = id;
|
|
492
|
-
await loadAgents();
|
|
493
|
-
await loadFriends();
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
// ─── Friends ────────────────────────────────────────────────────────
|
|
497
|
-
async function loadFriends() {
|
|
498
|
-
if (!currentAgentId) return;
|
|
499
|
-
const data = await api('GET', `/api/friends?agent_id=${currentAgentId}`);
|
|
500
|
-
const container = document.getElementById('friendsList');
|
|
501
|
-
container.innerHTML = '';
|
|
502
|
-
for (const f of data.friends || []) {
|
|
503
|
-
const div = document.createElement('div');
|
|
504
|
-
div.className = 'friend-item' + (currentTarget?.id === f.id && currentTarget?.type === 'friend' ? ' active' : '');
|
|
505
|
-
div.onclick = () => selectTarget(f.id, f.ai_name || f.fingerprint?.slice(0,8) || f.id.slice(0,8), 'friend', !!f.is_online);
|
|
506
|
-
div.innerHTML = `
|
|
507
|
-
<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>
|
|
508
|
-
<div class="info">
|
|
509
|
-
<div class="name">${f.ai_name || f.fingerprint?.slice(0,16) || f.id.slice(0,12)}</div>
|
|
510
|
-
<div class="status"><span class="${f.is_online ? 'badge-online' : 'badge-offline'}">●</span> ${f.is_online ? '在线' : '离线'}</div>
|
|
511
|
-
</div>`;
|
|
512
|
-
container.appendChild(div);
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
// ─── Groups ─────────────────────────────────────────────────────────
|
|
517
|
-
async function loadGroups() {
|
|
518
|
-
if (!currentAgentId) return;
|
|
519
|
-
const data = await api('GET', `/api/groups?agent_id=${currentAgentId}`);
|
|
520
|
-
const container = document.getElementById('groupsList');
|
|
521
|
-
container.innerHTML = '';
|
|
522
|
-
for (const g of data.groups || []) {
|
|
523
|
-
const silent = false; // will check
|
|
524
|
-
const div = document.createElement('div');
|
|
525
|
-
div.className = 'group-item' + (currentTarget?.id === g.id && currentTarget?.type === 'group' ? ' active' : '');
|
|
526
|
-
div.onclick = () => selectTarget(g.id, g.name, 'group', true, silent);
|
|
527
|
-
div.innerHTML = `
|
|
528
|
-
<div class="avatar">👥</div>
|
|
529
|
-
<div class="info">
|
|
530
|
-
<div class="name">${g.name}${silent ? '<span class="silent-badge">静默</span>' : ''}</div>
|
|
531
|
-
<div class="status">${(JSON.parse(g.members_json||'[]')).length} 成员</div>
|
|
532
|
-
</div>`;
|
|
533
|
-
container.appendChild(div);
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
// ─── Select Chat Target ────────────────────────────────────────────
|
|
538
|
-
async function selectTarget(id, name, type, isOnline = false, silent = false) {
|
|
539
|
-
currentTarget = { id, name, type, isOnline, silent };
|
|
540
|
-
showChatView();
|
|
541
|
-
|
|
542
|
-
// Update header
|
|
543
|
-
const chatAvatar = document.getElementById('chatAvatar');
|
|
544
|
-
// Try to get avatar from friend data
|
|
545
|
-
let friendAvatar = null;
|
|
546
|
-
if (type === 'friend') {
|
|
547
|
-
const friends = document.querySelectorAll('.friend-item');
|
|
548
|
-
// We'll use the currentTarget data
|
|
549
|
-
}
|
|
550
|
-
if (type === 'group') {
|
|
551
|
-
chatAvatar.innerHTML = '👥';
|
|
552
|
-
} else {
|
|
553
|
-
const name = currentTarget?.name || id;
|
|
554
|
-
chatAvatar.textContent = name.charAt(0).toUpperCase();
|
|
555
|
-
chatAvatar.style.background = '';
|
|
556
|
-
}
|
|
557
|
-
// If friend has avatar, try to find it in loaded friends
|
|
558
|
-
try {
|
|
559
|
-
const friendData = await api('GET', `/api/friends?agent_id=${currentAgentId}`);
|
|
560
|
-
const friend = (friendData.friends || []).find(f => f.id === id);
|
|
561
|
-
if (friend && friend.ai_avatar) {
|
|
562
|
-
chatAvatar.innerHTML = `<img src="${friend.ai_avatar}" alt="">`;
|
|
563
|
-
chatAvatar.style.background = 'none';
|
|
564
|
-
}
|
|
565
|
-
} catch(e) {}
|
|
566
|
-
document.getElementById('chatName').textContent = name;
|
|
567
|
-
document.getElementById('chatStatus').textContent = type === 'group' ? '群聊' : (isOnline ? '在线' : '离线');
|
|
568
|
-
document.getElementById('silentBtn').textContent = silent ? '🔕' : '🔔';
|
|
569
|
-
document.getElementById('silentBtn').style.display = type === 'group' ? '' : 'none';
|
|
570
|
-
|
|
571
|
-
// Reload lists to show active state
|
|
572
|
-
await loadFriends();
|
|
573
|
-
await loadGroups();
|
|
574
|
-
|
|
575
|
-
// Load chat history
|
|
576
|
-
await loadMessages();
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
// ─── Messages ───────────────────────────────────────────────────────
|
|
580
|
-
async function loadMessages() {
|
|
581
|
-
if (!currentTarget || !currentAgentId) return;
|
|
582
|
-
chatMessages = [];
|
|
583
|
-
oldestTimestamp = null;
|
|
584
|
-
const container = document.getElementById('messagesContainer');
|
|
585
|
-
// Keep loadMore button, clear rest
|
|
586
|
-
container.innerHTML = '<div class="load-more" id="loadMore" onclick="loadMoreMessages()" style="display:none">加载更多消息</div>';
|
|
587
|
-
|
|
588
|
-
const data = await api('GET', `/api/chat/${currentTarget.id}?agent_id=${currentAgentId}&limit=50`);
|
|
589
|
-
const msgs = (data.messages || []).reverse();
|
|
590
|
-
if (msgs.length > 0) {
|
|
591
|
-
oldestTimestamp = msgs[0].timestamp;
|
|
592
|
-
document.getElementById('loadMore').style.display = msgs.length >= 50 ? 'block' : 'none';
|
|
593
|
-
}
|
|
594
|
-
chatMessages = msgs;
|
|
595
|
-
renderMessages();
|
|
596
|
-
scrollToBottom();
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
async function loadMoreMessages() {
|
|
600
|
-
if (!currentTarget || !oldestTimestamp) return;
|
|
601
|
-
const data = await api('GET', `/api/chat/${currentTarget.id}?agent_id=${currentAgentId}&limit=50&before=${oldestTimestamp}`);
|
|
602
|
-
const msgs = (data.messages || []).reverse();
|
|
603
|
-
if (msgs.length > 0) {
|
|
604
|
-
oldestTimestamp = msgs[0].timestamp;
|
|
605
|
-
chatMessages = [...msgs, ...chatMessages];
|
|
606
|
-
document.getElementById('loadMore').style.display = msgs.length >= 50 ? 'block' : 'none';
|
|
607
|
-
renderMessages();
|
|
608
|
-
} else {
|
|
609
|
-
document.getElementById('loadMore').style.display = 'none';
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
function renderMessages() {
|
|
614
|
-
const container = document.getElementById('messagesContainer');
|
|
615
|
-
const loadMore = document.getElementById('loadMore');
|
|
616
|
-
// Clear except loadMore
|
|
617
|
-
while (container.children.length > 1) container.removeChild(container.lastChild);
|
|
618
|
-
|
|
619
|
-
for (const msg of chatMessages) {
|
|
620
|
-
const isMe = msg.from_id === currentAgentId;
|
|
621
|
-
const row = document.createElement('div');
|
|
622
|
-
row.className = 'msg-row ' + (isMe ? 'me' : 'them');
|
|
623
|
-
row.dataset.msgId = msg.id;
|
|
624
|
-
|
|
625
|
-
const bubble = document.createElement('div');
|
|
626
|
-
bubble.className = 'msg-bubble';
|
|
627
|
-
|
|
628
|
-
// Render content based on type
|
|
629
|
-
let content = msg.content || '';
|
|
630
|
-
if (msg.type === 'image' && msg.file_url) {
|
|
631
|
-
content = renderMarkdown(content) + `<img src="${msg.file_url}" onclick="previewImage('${msg.file_url}')" alt="图片">`;
|
|
632
|
-
} else if (msg.type === 'file' && msg.file_url) {
|
|
633
|
-
content = renderMarkdown(content) + `<a class="file-link" href="${msg.file_url}" download>📄 ${msg.file_name || '下载文件'}</a>`;
|
|
634
|
-
} else {
|
|
635
|
-
// Process mentions
|
|
636
|
-
content = processMentions(content);
|
|
637
|
-
content = renderMarkdown(content);
|
|
638
|
-
content = renderLatex(content);
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
bubble.innerHTML = content;
|
|
642
|
-
|
|
643
|
-
// Time
|
|
644
|
-
const timeDiv = document.createElement('div');
|
|
645
|
-
timeDiv.className = 'msg-time';
|
|
646
|
-
timeDiv.textContent = formatTime(msg.timestamp);
|
|
647
|
-
|
|
648
|
-
// Action buttons
|
|
649
|
-
const actions = document.createElement('div');
|
|
650
|
-
actions.className = 'msg-actions';
|
|
651
|
-
actions.innerHTML = `
|
|
652
|
-
<button class="msg-action-btn" onclick="copyMessage('${msg.id}')" title="复制">📋</button>
|
|
653
|
-
<button class="msg-action-btn danger" onclick="confirmDeleteMessage('${msg.id}')" title="删除">🗑️</button>`;
|
|
654
|
-
|
|
655
|
-
bubble.appendChild(actions);
|
|
656
|
-
bubble.appendChild(timeDiv);
|
|
657
|
-
row.appendChild(bubble);
|
|
658
|
-
container.appendChild(row);
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
// Re-render KaTeX
|
|
662
|
-
renderAllLatex();
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
function renderMarkdown(text) {
|
|
666
|
-
try {
|
|
667
|
-
return marked.parse(text, { breaks: true, gfm: true });
|
|
668
|
-
} catch (e) {
|
|
669
|
-
return text.replace(/</g, '<').replace(/>/g, '>');
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
function renderLatex(html) {
|
|
674
|
-
// Inline and block LaTeX with $$...$$ and $...$
|
|
675
|
-
return html.replace(/\$\$([\s\S]+?)\$\$/g, (m, tex) => {
|
|
676
|
-
try { return katex.renderToString(tex.trim(), { displayMode: true, throwOnError: false }); }
|
|
677
|
-
catch (e) { return m; }
|
|
678
|
-
}).replace(/\$([^\$]+?)\$/g, (m, tex) => {
|
|
679
|
-
try { return katex.renderToString(tex.trim(), { displayMode: false, throwOnError: false }); }
|
|
680
|
-
catch (e) { return m; }
|
|
681
|
-
});
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
function renderAllLatex() {
|
|
685
|
-
// Re-render any remaining LaTeX
|
|
686
|
-
try {
|
|
687
|
-
document.querySelectorAll('.msg-bubble').forEach(el => {
|
|
688
|
-
renderMathInElement(el, {
|
|
689
|
-
delimiters: [
|
|
690
|
-
{left: '$$', right: '$$', display: true},
|
|
691
|
-
{left: '$', right: '$', display: false},
|
|
692
|
-
],
|
|
693
|
-
throwOnError: false
|
|
694
|
-
});
|
|
695
|
-
});
|
|
696
|
-
} catch(e) {}
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
function processMentions(text) {
|
|
700
|
-
// Highlight @mentions
|
|
701
|
-
return text.replace(/@(\S+)/g, '<span class="mention">@$1</span>');
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
function formatTime(ts) {
|
|
705
|
-
try {
|
|
706
|
-
const d = new Date(ts);
|
|
707
|
-
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
|
708
|
-
} catch { return ts; }
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
function scrollToBottom() {
|
|
712
|
-
const container = document.getElementById('messagesContainer');
|
|
713
|
-
container.scrollTop = container.scrollHeight;
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
// ─── Send Message ───────────────────────────────────────────────────
|
|
717
|
-
async function sendMessage() {
|
|
718
|
-
const input = document.getElementById('messageInput');
|
|
719
|
-
const content = input.value.trim();
|
|
720
|
-
if (!content || !currentTarget || !currentAgentId) return;
|
|
721
|
-
|
|
722
|
-
input.value = '';
|
|
723
|
-
try {
|
|
724
|
-
await api('POST', '/api/chat/send', {
|
|
725
|
-
agent_id: currentAgentId,
|
|
726
|
-
targetId: currentTarget.id,
|
|
727
|
-
content,
|
|
728
|
-
type: 'text',
|
|
729
|
-
isGroup: currentTarget.type === 'group',
|
|
730
|
-
mentions: extractMentions(content),
|
|
731
|
-
});
|
|
732
|
-
await loadMessages();
|
|
733
|
-
saveChatToLocalStorage();
|
|
734
|
-
} catch (e) {
|
|
735
|
-
alert('发送失败: ' + e.message);
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
function extractMentions(text) {
|
|
740
|
-
const mentions = [];
|
|
741
|
-
const re = /@(\S+)/g;
|
|
742
|
-
let m;
|
|
743
|
-
while ((m = re.exec(text)) !== null) {
|
|
744
|
-
mentions.push(m[1]);
|
|
745
|
-
}
|
|
746
|
-
return mentions;
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
function handleInputKey(e) {
|
|
750
|
-
if (e.key === 'Enter' && !e.shiftKey) {
|
|
751
|
-
e.preventDefault();
|
|
752
|
-
sendMessage();
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
function handleInputChange() {
|
|
757
|
-
const input = document.getElementById('messageInput');
|
|
758
|
-
const val = input.value;
|
|
759
|
-
const cursorPos = input.selectionStart;
|
|
760
|
-
const textBefore = val.substring(0, cursorPos);
|
|
761
|
-
const atMatch = textBefore.match(/@(\S*)$/);
|
|
762
|
-
const dropdown = document.getElementById('mentionDropdown');
|
|
763
|
-
|
|
764
|
-
if (atMatch && currentTarget?.type === 'group') {
|
|
765
|
-
// Show mention dropdown
|
|
766
|
-
const query = atMatch[1].toLowerCase();
|
|
767
|
-
// Get group members from the group info
|
|
768
|
-
dropdown.innerHTML = '';
|
|
769
|
-
dropdown.innerHTML += `<div class="mention-option" onclick="insertMention('all')">📢 @所有人</div>`;
|
|
770
|
-
// Add friend suggestions
|
|
771
|
-
const friends = document.querySelectorAll('.friend-item .name');
|
|
772
|
-
friends.forEach(f => {
|
|
773
|
-
const name = f.textContent;
|
|
774
|
-
if (!query || name.toLowerCase().includes(query)) {
|
|
775
|
-
dropdown.innerHTML += `<div class="mention-option" onclick="insertMention('${name}')">👤 ${name}</div>`;
|
|
776
|
-
}
|
|
777
|
-
});
|
|
778
|
-
dropdown.classList.add('show');
|
|
779
|
-
} else {
|
|
780
|
-
dropdown.classList.remove('show');
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
function insertMention(name) {
|
|
785
|
-
const input = document.getElementById('messageInput');
|
|
786
|
-
const val = input.value;
|
|
787
|
-
const cursorPos = input.selectionStart;
|
|
788
|
-
const textBefore = val.substring(0, cursorPos);
|
|
789
|
-
const textAfter = val.substring(cursorPos);
|
|
790
|
-
const newText = textBefore.replace(/@\S*$/, '@' + name + ' ') + textAfter;
|
|
791
|
-
input.value = newText;
|
|
792
|
-
input.focus();
|
|
793
|
-
document.getElementById('mentionDropdown').classList.remove('show');
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
// ─── File Upload ────────────────────────────────────────────────────
|
|
797
|
-
async function uploadFile(input, type) {
|
|
798
|
-
if (!input.files[0] || !currentTarget) return;
|
|
799
|
-
const formData = new FormData();
|
|
800
|
-
formData.append('file', input.files[0]);
|
|
801
|
-
formData.append('agent_id', currentAgentId);
|
|
802
|
-
formData.append('targetId', currentTarget.id);
|
|
803
|
-
formData.append('isGroup', currentTarget.type === 'group' ? 'true' : 'false');
|
|
804
|
-
|
|
805
|
-
try {
|
|
806
|
-
const resp = await fetch(API + '/api/upload', { method: 'POST', body: formData });
|
|
807
|
-
const data = await resp.json();
|
|
808
|
-
if (data.success) {
|
|
809
|
-
await loadMessages();
|
|
810
|
-
} else {
|
|
811
|
-
alert('上传失败: ' + errMsg(data.error));
|
|
812
|
-
}
|
|
813
|
-
} catch (e) {
|
|
814
|
-
alert('上传失败: ' + e.message);
|
|
815
|
-
}
|
|
816
|
-
input.value = '';
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
// ─── Message Actions ────────────────────────────────────────────────
|
|
820
|
-
function copyMessage(msgId) {
|
|
821
|
-
const row = document.querySelector(`.msg-row[data-msg-id="${msgId}"]`);
|
|
822
|
-
if (!row) return;
|
|
823
|
-
const bubble = row.querySelector('.msg-bubble');
|
|
824
|
-
// Get text content, excluding action buttons and time
|
|
825
|
-
const clone = bubble.cloneNode(true);
|
|
826
|
-
clone.querySelectorAll('.msg-actions, .msg-time').forEach(el => el.remove());
|
|
827
|
-
navigator.clipboard.writeText(clone.textContent.trim()).then(() => {
|
|
828
|
-
// Brief visual feedback
|
|
829
|
-
});
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
function confirmDeleteMessage(msgId) {
|
|
833
|
-
pendingDeleteMsgId = msgId;
|
|
834
|
-
showModal('deleteMsg');
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
async function doDeleteMessage() {
|
|
838
|
-
if (!pendingDeleteMsgId) return;
|
|
839
|
-
await api('DELETE', `/api/chat/${pendingDeleteMsgId}?agent_id=${currentAgentId}`);
|
|
840
|
-
hideModal('deleteMsg');
|
|
841
|
-
pendingDeleteMsgId = null;
|
|
842
|
-
await loadMessages();
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
// ─── Markdown Toolbar ───────────────────────────────────────────────
|
|
846
|
-
function insertMarkdown(before, after) {
|
|
847
|
-
const input = document.getElementById('messageInput');
|
|
848
|
-
const start = input.selectionStart;
|
|
849
|
-
const end = input.selectionEnd;
|
|
850
|
-
const selected = input.value.substring(start, end);
|
|
851
|
-
const replacement = before + (selected || 'text') + after;
|
|
852
|
-
input.value = input.value.substring(0, start) + replacement + input.value.substring(end);
|
|
853
|
-
input.focus();
|
|
854
|
-
input.setSelectionRange(start + before.length, start + before.length + (selected || 'text').length);
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
// ─── Image Preview ──────────────────────────────────────────────────
|
|
858
|
-
function previewImage(url) {
|
|
859
|
-
document.getElementById('previewImage').src = url;
|
|
860
|
-
showModal('imagePreview');
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
// ─── Silent Mode ────────────────────────────────────────────────────
|
|
864
|
-
async function toggleSilent() {
|
|
865
|
-
if (!currentTarget || currentTarget.type !== 'group') return;
|
|
866
|
-
const newSilent = !currentTarget.silent;
|
|
867
|
-
await api('PUT', `/api/groups/${currentTarget.id}/silent?agent_id=${currentAgentId}`, { silent: newSilent });
|
|
868
|
-
currentTarget.silent = newSilent;
|
|
869
|
-
document.getElementById('silentBtn').textContent = newSilent ? '🔕' : '🔔';
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
// ─── Modal Helpers ──────────────────────────────────────────────────
|
|
873
|
-
function showModal(name) {
|
|
874
|
-
document.getElementById('modal-' + name).classList.add('show');
|
|
875
|
-
// Update settings avatar when opening settings modal
|
|
876
|
-
if (name === 'settings' && currentAgentId) {
|
|
877
|
-
api('GET', `/api/identity?agent_id=${currentAgentId}`).then(info => {
|
|
878
|
-
const avatarEl = document.getElementById('settingsAvatar');
|
|
879
|
-
if (info.avatar) {
|
|
880
|
-
avatarEl.innerHTML = `<img src="${info.avatar}" alt="头像">`;
|
|
881
|
-
avatarEl.style.background = 'none';
|
|
882
|
-
} else {
|
|
883
|
-
avatarEl.textContent = (info.nickname || 'A').charAt(0).toUpperCase();
|
|
884
|
-
}
|
|
885
|
-
}).catch(() => {});
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
function hideModal(name) { document.getElementById('modal-' + name).classList.remove('show'); }
|
|
889
|
-
|
|
890
|
-
// ─── Add Friend ─────────────────────────────────────────────────────
|
|
891
|
-
async function addFriendByCode() {
|
|
892
|
-
const code = document.getElementById('friendCodeInput').value.trim();
|
|
893
|
-
if (!code) return alert('请输入好友码');
|
|
894
|
-
try {
|
|
895
|
-
const result = await api('POST', '/api/friends/add', { temp_number: code, agent_id: currentAgentId });
|
|
896
|
-
if (result.success) {
|
|
897
|
-
alert('好友添加成功!');
|
|
898
|
-
hideModal('addFriend');
|
|
899
|
-
document.getElementById('friendCodeInput').value = '';
|
|
900
|
-
await loadFriends();
|
|
901
|
-
} else {
|
|
902
|
-
alert('添加失败: ' + errMsg(result.error));
|
|
903
|
-
}
|
|
904
|
-
} catch (e) {
|
|
905
|
-
alert('添加失败: ' + e.message);
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
// ─── Create Group ───────────────────────────────────────────────────
|
|
910
|
-
async function createGroup() {
|
|
911
|
-
const name = document.getElementById('newGroupName').value.trim();
|
|
912
|
-
if (!name) return alert('请输入群聊名称');
|
|
913
|
-
try {
|
|
914
|
-
await api('POST', '/api/groups', { name, agent_id: currentAgentId });
|
|
915
|
-
hideModal('joinGroup');
|
|
916
|
-
document.getElementById('newGroupName').value = '';
|
|
917
|
-
await loadGroups();
|
|
918
|
-
} catch (e) {
|
|
919
|
-
alert('创建失败: ' + e.message);
|
|
920
|
-
}
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
// ─── Settings Actions ───────────────────────────────────────────────
|
|
924
|
-
async function showQRCode() {
|
|
925
|
-
hideModal('settings');
|
|
926
|
-
try {
|
|
927
|
-
const data = await api('GET', `/api/identity/qr?agent_id=${currentAgentId}`);
|
|
928
|
-
document.getElementById('qrContainer').innerHTML = data.qr
|
|
929
|
-
? `<img src="${data.qr}" alt="QR Code">`
|
|
930
|
-
: '<p>生成失败</p>';
|
|
931
|
-
} catch (e) {
|
|
932
|
-
document.getElementById('qrContainer').innerHTML = '<p>生成失败: ' + e.message + '</p>';
|
|
933
|
-
}
|
|
934
|
-
showModal('qr');
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
function showNicknameModal() {
|
|
938
|
-
hideModal('settings');
|
|
939
|
-
showModal('nickname');
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
async function saveNickname() {
|
|
943
|
-
const nickname = document.getElementById('nicknameInput').value.trim();
|
|
944
|
-
if (!nickname) return alert('请输入昵称');
|
|
945
|
-
await api('POST', '/api/identity/nickname', { agent_id: currentAgentId, nickname });
|
|
946
|
-
hideModal('nickname');
|
|
947
|
-
await loadAgents();
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
async function generateFriendCode() {
|
|
951
|
-
hideModal('settings');
|
|
952
|
-
try {
|
|
953
|
-
const data = await api('POST', '/api/identity/friend-code', { agent_id: currentAgentId });
|
|
954
|
-
document.getElementById('friendCodeDisplay').innerHTML = data.code
|
|
955
|
-
? `<div style="font-size:48px;font-weight:700;letter-spacing:8px;color:var(--primary)">${data.code}</div><div style="font-size:12px;color:var(--text2);margin-top:8px">有效期至: ${data.expires_at || '24小时'}</div>`
|
|
956
|
-
: '<p>生成失败</p>';
|
|
957
|
-
} catch (e) {
|
|
958
|
-
document.getElementById('friendCodeDisplay').innerHTML = '<p>生成失败: ' + e.message + '</p>';
|
|
959
|
-
}
|
|
960
|
-
showModal('friendCode');
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
async function showKeysModal() {
|
|
964
|
-
hideModal('settings');
|
|
965
|
-
try {
|
|
966
|
-
const data = await api('GET', `/api/identity/keys?agent_id=${currentAgentId}`);
|
|
967
|
-
document.getElementById('keysDisplay').innerHTML = `
|
|
968
|
-
<div><strong>Agent ID:</strong></div>
|
|
969
|
-
<div class="key-display">${data.agent_id || '-'}</div>
|
|
970
|
-
<div><strong>昵称:</strong> ${data.nickname || '-'}</div>
|
|
971
|
-
<div><strong>指纹:</strong></div>
|
|
972
|
-
<div class="key-display">${data.fingerprint || '-'}</div>
|
|
973
|
-
<div><strong>公钥 (签名):</strong> <small style="color:var(--text2)">用于连接服务端,获取好友列表</small></div>
|
|
974
|
-
<div class="key-display">${data.signing_public_key || '-'}</div>
|
|
975
|
-
<div><strong>公钥 (交换):</strong></div>
|
|
976
|
-
<div class="key-display">${data.exchange_public_key || '-'}</div>
|
|
977
|
-
<details style="margin-top:12px"><summary style="cursor:pointer;color:var(--warning)">🔑 显示私钥 (危险)</summary>
|
|
978
|
-
<div class="warning-box" style="margin-top:8px">⚠️ 私钥用于执行模式的聊天。拥有此私钥的人可以通过聊天安排任务执行、调用工具。请勿泄露!</div>
|
|
979
|
-
<div><strong>私钥 (签名):</strong></div>
|
|
980
|
-
<div class="key-display">${data.signing_secret_key || '-'}</div>
|
|
981
|
-
<div><strong>私钥 (交换):</strong></div>
|
|
982
|
-
<div class="key-display">${data.exchange_secret_key || '-'}</div>
|
|
983
|
-
</details>`;
|
|
984
|
-
} catch (e) {
|
|
985
|
-
document.getElementById('keysDisplay').innerHTML = '<p>加载失败: ' + e.message + '</p>';
|
|
986
|
-
}
|
|
987
|
-
showModal('keys');
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
function confirmRotateKeys() {
|
|
991
|
-
hideModal('keys');
|
|
992
|
-
showModal('confirmRotate');
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
async function rotateKeys() {
|
|
996
|
-
try {
|
|
997
|
-
await api('POST', '/api/identity/rotate-keys', { agent_id: currentAgentId });
|
|
998
|
-
hideModal('confirmRotate');
|
|
999
|
-
await showKeysModal();
|
|
1000
|
-
} catch (e) {
|
|
1001
|
-
alert('密钥轮换失败: ' + e.message);
|
|
1002
|
-
}
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
async function syncData() {
|
|
1006
|
-
hideModal('settings');
|
|
1007
|
-
try {
|
|
1008
|
-
await api('POST', '/api/sync', { agent_id: currentAgentId });
|
|
1009
|
-
await loadFriends();
|
|
1010
|
-
await loadGroups();
|
|
1011
|
-
alert('同步完成');
|
|
1012
|
-
} catch (e) {
|
|
1013
|
-
alert('同步失败: ' + e.message);
|
|
1014
|
-
}
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
function showChatInfo() {
|
|
1018
|
-
if (!currentTarget) return;
|
|
1019
|
-
alert(currentTarget.type === 'group'
|
|
1020
|
-
? `群聊: ${currentTarget.name}\nID: ${currentTarget.id}`
|
|
1021
|
-
: `好友: ${currentTarget.name}\nID: ${currentTarget.id}\n状态: ${currentTarget.isOnline ? '在线' : '离线'}`);
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
// ─── Image Resize Utility ───────────────────────────────────────────
|
|
1025
|
-
function resizePluginImage(file,maxW,maxH,quality=0.8){
|
|
1026
|
-
return new Promise((resolve,reject)=>{
|
|
1027
|
-
if(!file.type.startsWith('image/')){reject(new Error('Not an image'));return;}
|
|
1028
|
-
const img=new Image();const url=URL.createObjectURL(file);
|
|
1029
|
-
img.onload=()=>{
|
|
1030
|
-
let w=img.width,h=img.height;
|
|
1031
|
-
if(w<=maxW&&h<=maxH&&file.size<=512*1024){URL.revokeObjectURL(url);resolve(file);return;}
|
|
1032
|
-
const ratio=Math.min(maxW/w,maxH/h,1);w=Math.round(w*ratio);h=Math.round(h*ratio);
|
|
1033
|
-
const canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
|
|
1034
|
-
const ctx=canvas.getContext('2d');ctx.drawImage(img,0,0,w,h);
|
|
1035
|
-
canvas.toBlob(blob=>{URL.revokeObjectURL(url);if(!blob){reject(new Error('Resize failed'));return;}resolve(new File([blob],file.name.replace(/\.\w+$/,'.jpg'),{type:'image/jpeg',lastModified:Date.now()}));},'image/jpeg',quality);
|
|
1036
|
-
};
|
|
1037
|
-
img.onerror=()=>{URL.revokeObjectURL(url);reject(new Error('Image load failed'));};
|
|
1038
|
-
img.src=url;
|
|
1039
|
-
});
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
// ─── Avatar Upload ──────────────────────────────────────────────────
|
|
1043
|
-
async function handlePluginAvatarUpload(input) {
|
|
1044
|
-
const rawFile = input.files && input.files[0];
|
|
1045
|
-
if (!rawFile || !currentAgentId) return;
|
|
1046
|
-
if (!rawFile.type.startsWith('image/')) { alert('请选择图片文件'); return; }
|
|
1047
|
-
try {
|
|
1048
|
-
const file = await resizePluginImage(rawFile, 256, 256, 0.85); // resize for avatar
|
|
1049
|
-
const formData = new FormData();
|
|
1050
|
-
formData.append('avatar', file);
|
|
1051
|
-
formData.append('agent_id', currentAgentId);
|
|
1052
|
-
const resp = await fetch(API + '/api/identity/avatar', { method: 'POST', body: formData });
|
|
1053
|
-
const data = await resp.json();
|
|
1054
|
-
if (data.success || data.avatar) {
|
|
1055
|
-
const avatarUrl = data.avatar || (data.account && data.account.avatar);
|
|
1056
|
-
if (avatarUrl) {
|
|
1057
|
-
document.getElementById('settingsAvatar').innerHTML = `<img src="${avatarUrl}" alt="头像">`;
|
|
1058
|
-
}
|
|
1059
|
-
alert('头像已更新');
|
|
1060
|
-
} else {
|
|
1061
|
-
alert('上传失败: ' + errMsg(data.error));
|
|
1062
|
-
}
|
|
1063
|
-
} catch (e) {
|
|
1064
|
-
alert('头像上传失败: ' + e.message);
|
|
1065
|
-
}
|
|
1066
|
-
input.value = '';
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
// ─── Toast Notification ─────────────────────────────────────────────
|
|
1070
|
-
let toastTimer = null;
|
|
1071
|
-
function showToast(message, type = '', duration = 5000) {
|
|
1072
|
-
const el = document.getElementById('toast');
|
|
1073
|
-
el.textContent = message;
|
|
1074
|
-
el.className = 'toast show' + (type ? ' ' + type : '');
|
|
1075
|
-
if (toastTimer) clearTimeout(toastTimer);
|
|
1076
|
-
toastTimer = setTimeout(() => { el.className = 'toast'; }, duration);
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
// ─── localStorage Chat Cache ───────────────────────────────────────
|
|
1080
|
-
const CACHE_KEY = 'aicq_plugin_chat_cache';
|
|
1081
|
-
|
|
1082
|
-
function saveChatToLocalStorage() {
|
|
1083
|
-
try {
|
|
1084
|
-
const cache = {
|
|
1085
|
-
currentAgentId,
|
|
1086
|
-
currentTarget,
|
|
1087
|
-
chatMessages,
|
|
1088
|
-
oldestTimestamp,
|
|
1089
|
-
savedAt: Date.now()
|
|
1090
|
-
};
|
|
1091
|
-
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
|
|
1092
|
-
} catch (e) {
|
|
1093
|
-
// localStorage might be full or unavailable
|
|
1094
|
-
console.warn('Failed to save chat cache:', e);
|
|
1095
|
-
}
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
function loadChatFromLocalStorage() {
|
|
1099
|
-
try {
|
|
1100
|
-
const raw = localStorage.getItem(CACHE_KEY);
|
|
1101
|
-
if (!raw) return null;
|
|
1102
|
-
return JSON.parse(raw);
|
|
1103
|
-
} catch (e) {
|
|
1104
|
-
return null;
|
|
1105
|
-
}
|
|
1106
|
-
}
|
|
1107
|
-
|
|
1108
|
-
function clearChatCache() {
|
|
1109
|
-
try { localStorage.removeItem(CACHE_KEY); } catch(e) {}
|
|
1110
|
-
}
|
|
1111
|
-
|
|
1112
|
-
// ─── Logout ─────────────────────────────────────────────────────────
|
|
1113
|
-
function confirmLogout() {
|
|
1114
|
-
if (confirm('确定要登出吗?登出前会自动保存聊天缓存到本地。')) {
|
|
1115
|
-
saveChatToLocalStorage();
|
|
1116
|
-
// Clear state
|
|
1117
|
-
currentAgentId = '';
|
|
1118
|
-
currentTarget = null;
|
|
1119
|
-
chatMessages = [];
|
|
1120
|
-
oldestTimestamp = null;
|
|
1121
|
-
document.getElementById('agentSelect').value = '';
|
|
1122
|
-
showEmptyState();
|
|
1123
|
-
document.getElementById('friendsList').innerHTML = '';
|
|
1124
|
-
document.getElementById('groupsList').innerHTML = '';
|
|
1125
|
-
hideModal('settings');
|
|
1126
|
-
showToast('已登出,聊天缓存已保存到本地');
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
// ─── Backup Export ──────────────────────────────────────────────────
|
|
1131
|
-
async function exportBackup() {
|
|
1132
|
-
try {
|
|
1133
|
-
showToast('正在生成备份...');
|
|
1134
|
-
// Get all agents
|
|
1135
|
-
const agentsData = await api('GET', '/api/agents');
|
|
1136
|
-
const agents = agentsData.agents || [];
|
|
1137
|
-
|
|
1138
|
-
// Collect data for each agent
|
|
1139
|
-
const backupAgents = [];
|
|
1140
|
-
for (const agent of agents) {
|
|
1141
|
-
// Get identity/keys for this agent (includes private keys)
|
|
1142
|
-
let keys = {};
|
|
1143
|
-
try {
|
|
1144
|
-
keys = await api('GET', `/api/identity/keys?agent_id=${agent.agent_id}`);
|
|
1145
|
-
} catch(e) {}
|
|
1146
|
-
|
|
1147
|
-
// Get friends
|
|
1148
|
-
let friends = [];
|
|
1149
|
-
try {
|
|
1150
|
-
const fd = await api('GET', `/api/friends?agent_id=${agent.agent_id}`);
|
|
1151
|
-
friends = fd.friends || [];
|
|
1152
|
-
} catch(e) {}
|
|
1153
|
-
|
|
1154
|
-
// Get groups
|
|
1155
|
-
let groups = [];
|
|
1156
|
-
try {
|
|
1157
|
-
const gd = await api('GET', `/api/groups?agent_id=${agent.agent_id}`);
|
|
1158
|
-
groups = gd.groups || [];
|
|
1159
|
-
} catch(e) {}
|
|
1160
|
-
|
|
1161
|
-
// Get chat messages for each friend and group
|
|
1162
|
-
const chatData = {};
|
|
1163
|
-
for (const f of friends) {
|
|
1164
|
-
try {
|
|
1165
|
-
const md = await api('GET', `/api/chat/${f.id}?agent_id=${agent.agent_id}&limit=9999`);
|
|
1166
|
-
chatData['friend_' + f.id] = md.messages || [];
|
|
1167
|
-
} catch(e) {}
|
|
1168
|
-
}
|
|
1169
|
-
for (const g of groups) {
|
|
1170
|
-
try {
|
|
1171
|
-
const md = await api('GET', `/api/chat/${g.id}?agent_id=${agent.agent_id}&limit=9999`);
|
|
1172
|
-
chatData['group_' + g.id] = md.messages || [];
|
|
1173
|
-
} catch(e) {}
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
backupAgents.push({
|
|
1177
|
-
agent_id: agent.agent_id,
|
|
1178
|
-
nickname: agent.nickname || agent.agent_id,
|
|
1179
|
-
avatar: agent.avatar || null,
|
|
1180
|
-
signing_public_key: keys.signing_public_key || null,
|
|
1181
|
-
exchange_public_key: keys.exchange_public_key || null,
|
|
1182
|
-
signing_secret_key: keys.signing_secret_key || null,
|
|
1183
|
-
exchange_secret_key: keys.exchange_secret_key || null,
|
|
1184
|
-
fingerprint: keys.fingerprint || null,
|
|
1185
|
-
friends,
|
|
1186
|
-
groups,
|
|
1187
|
-
chatData
|
|
1188
|
-
});
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
const backup = {
|
|
1192
|
-
version: 'aicq-plugin-backup-v1',
|
|
1193
|
-
exportedAt: new Date().toISOString(),
|
|
1194
|
-
agents: backupAgents
|
|
1195
|
-
};
|
|
1196
|
-
|
|
1197
|
-
// Download as JSON file
|
|
1198
|
-
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' });
|
|
1199
|
-
const url = URL.createObjectURL(blob);
|
|
1200
|
-
const a = document.createElement('a');
|
|
1201
|
-
a.href = url;
|
|
1202
|
-
a.download = `aicq-backup-${new Date().toISOString().slice(0,10)}.json`;
|
|
1203
|
-
document.body.appendChild(a);
|
|
1204
|
-
a.click();
|
|
1205
|
-
document.body.removeChild(a);
|
|
1206
|
-
URL.revokeObjectURL(url);
|
|
1207
|
-
|
|
1208
|
-
showToast('备份已导出!请妥善保管此文件,内含私钥。', 'warning', 6000);
|
|
1209
|
-
} catch (e) {
|
|
1210
|
-
alert('导出失败: ' + e.message);
|
|
1211
|
-
}
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
// ─── Backup Import ──────────────────────────────────────────────────
|
|
1215
|
-
function triggerImportBackup() {
|
|
1216
|
-
document.getElementById('backupFileInput').click();
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
// Store pending backup for matching
|
|
1220
|
-
let pendingBackup = null;
|
|
1221
|
-
let pendingMatchMap = {};
|
|
1222
|
-
|
|
1223
|
-
async function handleImportBackup(input) {
|
|
1224
|
-
if (!input.files || !input.files[0]) return;
|
|
1225
|
-
const file = input.files[0];
|
|
1226
|
-
input.value = '';
|
|
1227
|
-
|
|
1228
|
-
try {
|
|
1229
|
-
const text = await file.text();
|
|
1230
|
-
const backup = JSON.parse(text);
|
|
1231
|
-
|
|
1232
|
-
if (!backup.version || !backup.agents || !Array.isArray(backup.agents)) {
|
|
1233
|
-
alert('无效的备份文件格式');
|
|
1234
|
-
return;
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
await processImport(backup);
|
|
1238
|
-
} catch (e) {
|
|
1239
|
-
alert('读取备份文件失败: ' + e.message);
|
|
1240
|
-
}
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
async function processImport(backup) {
|
|
1244
|
-
// Get current agents
|
|
1245
|
-
const agentsData = await api('GET', '/api/agents');
|
|
1246
|
-
const currentAgents = agentsData.agents || [];
|
|
1247
|
-
|
|
1248
|
-
// Get current agents' keys for comparison
|
|
1249
|
-
const currentAgentKeys = {};
|
|
1250
|
-
for (const agent of currentAgents) {
|
|
1251
|
-
try {
|
|
1252
|
-
const keys = await api('GET', `/api/identity/keys?agent_id=${agent.agent_id}`);
|
|
1253
|
-
currentAgentKeys[agent.agent_id] = {
|
|
1254
|
-
agent_id: agent.agent_id,
|
|
1255
|
-
nickname: agent.nickname || agent.agent_id,
|
|
1256
|
-
signing_public_key: keys.signing_public_key,
|
|
1257
|
-
fingerprint: keys.fingerprint
|
|
1258
|
-
};
|
|
1259
|
-
} catch(e) {
|
|
1260
|
-
currentAgentKeys[agent.agent_id] = {
|
|
1261
|
-
agent_id: agent.agent_id,
|
|
1262
|
-
nickname: agent.nickname || agent.agent_id
|
|
1263
|
-
};
|
|
1264
|
-
}
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
// Check which backup agents don't match current agents
|
|
1268
|
-
const matchedAgents = []; // { backupAgent, currentAgent }
|
|
1269
|
-
const unmatchedBackupAgents = []; // backup agents with no key match
|
|
1270
|
-
|
|
1271
|
-
for (const ba of backup.agents) {
|
|
1272
|
-
// Try to find matching current agent by signing_public_key or fingerprint
|
|
1273
|
-
let matched = null;
|
|
1274
|
-
for (const [cid, ck] of Object.entries(currentAgentKeys)) {
|
|
1275
|
-
if (ba.signing_public_key && ck.signing_public_key && ba.signing_public_key === ck.signing_public_key) {
|
|
1276
|
-
matched = ck;
|
|
1277
|
-
break;
|
|
1278
|
-
}
|
|
1279
|
-
if (ba.fingerprint && ck.fingerprint && ba.fingerprint === ck.fingerprint) {
|
|
1280
|
-
matched = ck;
|
|
1281
|
-
break;
|
|
1282
|
-
}
|
|
1283
|
-
// Also match by agent_id
|
|
1284
|
-
if (ba.agent_id === cid) {
|
|
1285
|
-
matched = ck;
|
|
1286
|
-
break;
|
|
1287
|
-
}
|
|
1288
|
-
}
|
|
1289
|
-
|
|
1290
|
-
if (matched) {
|
|
1291
|
-
matchedAgents.push({ backupAgent: ba, currentAgent: matched });
|
|
1292
|
-
} else {
|
|
1293
|
-
unmatchedBackupAgents.push(ba);
|
|
1294
|
-
}
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
// If there are unmatched agents, show matching modal
|
|
1298
|
-
if (unmatchedBackupAgents.length > 0 && Object.keys(currentAgentKeys).length > 0) {
|
|
1299
|
-
pendingBackup = backup;
|
|
1300
|
-
pendingMatchMap = {};
|
|
1301
|
-
|
|
1302
|
-
// Auto-add matched ones
|
|
1303
|
-
for (const m of matchedAgents) {
|
|
1304
|
-
pendingMatchMap[m.backupAgent.agent_id] = m.currentAgent.agent_id;
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
|
-
showBackupMatchModal(unmatchedBackupAgents, Object.values(currentAgentKeys));
|
|
1308
|
-
} else {
|
|
1309
|
-
// All matched or no current agents, proceed directly
|
|
1310
|
-
await doImportBackup(backup, matchedAgents.reduce((map, m) => { map[m.backupAgent.agent_id] = m.currentAgent.agent_id; return map; }, {}));
|
|
1311
|
-
}
|
|
1312
|
-
}
|
|
1313
|
-
|
|
1314
|
-
function showBackupMatchModal(unmatchedAgents, currentAgentList) {
|
|
1315
|
-
const container = document.getElementById('backupMatchList');
|
|
1316
|
-
container.innerHTML = '';
|
|
1317
|
-
|
|
1318
|
-
for (const ba of unmatchedAgents) {
|
|
1319
|
-
const row = document.createElement('div');
|
|
1320
|
-
row.className = 'key-match-row';
|
|
1321
|
-
row.innerHTML = `
|
|
1322
|
-
<label title="${ba.agent_id}">${ba.nickname || ba.agent_id}</label>
|
|
1323
|
-
<select id="match-${ba.agent_id}" data-backup-agent="${ba.agent_id}">
|
|
1324
|
-
<option value="">-- 不导入 --</option>
|
|
1325
|
-
${currentAgentList.map(ca => `<option value="${ca.agent_id}">${ca.nickname || ca.agent_id}</option>`).join('')}
|
|
1326
|
-
</select>
|
|
1327
|
-
`;
|
|
1328
|
-
container.appendChild(row);
|
|
1329
|
-
}
|
|
1330
|
-
|
|
1331
|
-
showModal('backupMatch');
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
async function applyBackupMatch() {
|
|
1335
|
-
// Collect user selections
|
|
1336
|
-
const selects = document.querySelectorAll('#backupMatchList select');
|
|
1337
|
-
for (const sel of selects) {
|
|
1338
|
-
const backupAgentId = sel.dataset.backupAgent;
|
|
1339
|
-
const chosenAgentId = sel.value;
|
|
1340
|
-
if (chosenAgentId) {
|
|
1341
|
-
pendingMatchMap[backupAgentId] = chosenAgentId;
|
|
1342
|
-
}
|
|
1343
|
-
}
|
|
1344
|
-
|
|
1345
|
-
hideModal('backupMatch');
|
|
1346
|
-
await doImportBackup(pendingBackup, pendingMatchMap);
|
|
1347
|
-
pendingBackup = null;
|
|
1348
|
-
pendingMatchMap = {};
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
async function skipBackupMatch() {
|
|
1352
|
-
hideModal('backupMatch');
|
|
1353
|
-
// Import only the already-matched agents (without the unmatched ones)
|
|
1354
|
-
await doImportBackup(pendingBackup, pendingMatchMap);
|
|
1355
|
-
pendingBackup = null;
|
|
1356
|
-
pendingMatchMap = {};
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
|
-
async function doImportBackup(backup, matchMap) {
|
|
1360
|
-
try {
|
|
1361
|
-
showToast('正在导入备份...', '', 8000);
|
|
1362
|
-
let importedCount = 0;
|
|
1363
|
-
let importedMessages = 0;
|
|
1364
|
-
|
|
1365
|
-
for (const ba of backup.agents) {
|
|
1366
|
-
const targetAgentId = matchMap[ba.agent_id];
|
|
1367
|
-
if (!targetAgentId) continue;
|
|
1368
|
-
|
|
1369
|
-
// Import friends (via API if possible, or just save to cache)
|
|
1370
|
-
// The plugin uses local SQLite, so we store to localStorage cache
|
|
1371
|
-
for (const friend of (ba.friends || [])) {
|
|
1372
|
-
try {
|
|
1373
|
-
// Try to add friend via API
|
|
1374
|
-
await api('POST', '/api/friends/add-by-fingerprint', {
|
|
1375
|
-
agent_id: targetAgentId,
|
|
1376
|
-
fingerprint: friend.fingerprint,
|
|
1377
|
-
ai_name: friend.ai_name,
|
|
1378
|
-
ai_avatar: friend.ai_avatar
|
|
1379
|
-
});
|
|
1380
|
-
} catch(e) {
|
|
1381
|
-
// Friend might already exist, that's fine
|
|
1382
|
-
}
|
|
1383
|
-
}
|
|
1384
|
-
|
|
1385
|
-
// Import chat messages to localStorage cache
|
|
1386
|
-
if (ba.chatData) {
|
|
1387
|
-
const chatCacheKey = `aicq_chat_${targetAgentId}`;
|
|
1388
|
-
try {
|
|
1389
|
-
const existing = JSON.parse(localStorage.getItem(chatCacheKey) || '{}');
|
|
1390
|
-
for (const [targetKey, messages] of Object.entries(ba.chatData)) {
|
|
1391
|
-
existing[targetKey] = messages;
|
|
1392
|
-
}
|
|
1393
|
-
localStorage.setItem(chatCacheKey, JSON.stringify(existing));
|
|
1394
|
-
importedMessages += Object.keys(ba.chatData).length;
|
|
1395
|
-
} catch(e) {}
|
|
1396
|
-
}
|
|
1397
|
-
|
|
1398
|
-
importedCount++;
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
|
-
// Refresh UI
|
|
1402
|
-
await loadAgents();
|
|
1403
|
-
await loadFriends();
|
|
1404
|
-
await loadGroups();
|
|
1405
|
-
|
|
1406
|
-
hideModal('settings');
|
|
1407
|
-
showToast(`导入完成!已导入 ${importedCount} 个 Agent 的数据,${importedMessages} 个会话记录。`, '', 6000);
|
|
1408
|
-
} catch (e) {
|
|
1409
|
-
alert('导入失败: ' + e.message);
|
|
1410
|
-
}
|
|
1411
|
-
}
|
|
1412
|
-
|
|
1413
|
-
// ─── Init ───────────────────────────────────────────────────────────
|
|
1414
|
-
async function init() {
|
|
1415
|
-
await loadAgents();
|
|
1416
|
-
await loadFriends();
|
|
1417
|
-
await loadGroups();
|
|
1418
|
-
// Auto-select first agent
|
|
1419
|
-
const sel = document.getElementById('agentSelect');
|
|
1420
|
-
if (sel.options.length > 1) {
|
|
1421
|
-
currentAgentId = sel.options[1].value;
|
|
1422
|
-
sel.value = currentAgentId;
|
|
1423
|
-
}
|
|
1424
|
-
|
|
1425
|
-
// Load chat cache from localStorage
|
|
1426
|
-
const cached = loadChatFromLocalStorage();
|
|
1427
|
-
if (cached && cached.currentAgentId) {
|
|
1428
|
-
// Restore cache if agent still exists
|
|
1429
|
-
const sel2 = document.getElementById('agentSelect');
|
|
1430
|
-
let agentExists = false;
|
|
1431
|
-
for (let i = 0; i < sel2.options.length; i++) {
|
|
1432
|
-
if (sel2.options[i].value === cached.currentAgentId) {
|
|
1433
|
-
agentExists = true;
|
|
1434
|
-
break;
|
|
1435
|
-
}
|
|
1436
|
-
}
|
|
1437
|
-
if (agentExists) {
|
|
1438
|
-
currentAgentId = cached.currentAgentId;
|
|
1439
|
-
sel2.value = currentAgentId;
|
|
1440
|
-
if (cached.currentTarget) {
|
|
1441
|
-
currentTarget = cached.currentTarget;
|
|
1442
|
-
await loadFriends();
|
|
1443
|
-
await loadGroups();
|
|
1444
|
-
// Will load messages from server, which is more reliable
|
|
1445
|
-
if (currentTarget) {
|
|
1446
|
-
selectTarget(currentTarget.id, currentTarget.name, currentTarget.type, currentTarget.isOnline, currentTarget.silent);
|
|
1447
|
-
}
|
|
1448
|
-
}
|
|
1449
|
-
}
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
// Show login reminder toast
|
|
1453
|
-
showToast('提醒:聊天记录仅保存在本地浏览器中,清除缓存数据或删除浏览器内容将丢失', 'warning', 8000);
|
|
1454
|
-
}
|
|
1455
|
-
|
|
1456
|
-
init();
|
|
1457
|
-
|
|
1458
|
-
// Periodic refresh and auto-save
|
|
1459
|
-
setInterval(async () => {
|
|
1460
|
-
if (currentAgentId) {
|
|
1461
|
-
await loadFriends();
|
|
1462
|
-
await loadGroups();
|
|
1463
|
-
saveChatToLocalStorage();
|
|
1464
|
-
}
|
|
1465
|
-
}, 30000);
|
|
1466
|
-
</script>
|
|
1467
|
-
</body>
|
|
1468
|
-
</html>
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<meta name="theme-color" content="#D97757">
|
|
7
|
+
<title>AICQ 加密聊天</title>
|
|
8
|
+
<link rel="icon" type="image/png" sizes="32x32" href="/icon-32.png">
|
|
9
|
+
<link rel="icon" type="image/png" sizes="16x16" href="/icon-16.png">
|
|
10
|
+
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
|
11
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
|
12
|
+
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
|
13
|
+
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"></script>
|
|
14
|
+
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.0/marked.min.js"></script>
|
|
15
|
+
<style>
|
|
16
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
17
|
+
:root{--primary:#D97757;--primary-light:#C4613F;--primary-lighter:rgba(217,119,87,0.1);--accent:#D97757;--accent-hover:#C4613F;--accent-light:rgba(217,119,87,0.1);--bg:#FAF9F6;--bg2:#F5F1EB;--bg-warm:#F5F1EB;--bg3:#FFFFFF;--bg-card:#FFFFFF;--text:#2D2A26;--text2:#6B6560;--text-sec:#6B6560;--text-muted:#9B958E;--border:#E8DFD3;--beige:#E8DFD3;--success:#4CAF7D;--green:#4CAF7D;--danger:#E05555;--red:#E05555;--warning:#E5A54B;--amber:#E5A54B;--brown:#8B6F4E;--brown-light:rgba(139,111,78,0.1);--cream:#F0EAE0;--info:#5B8DEF;--blue:#5B8DEF;--purple:#7B6CB0;--bubble-me:#D97757;--bubble-them:#FFFFFF;--shadow-sm:0 1px 3px rgba(45,42,38,0.06);--shadow-md:0 4px 16px rgba(45,42,38,0.08);--shadow-lg:0 8px 32px rgba(45,42,38,0.1);--radius:12px;--radius-sm:8px;--radius-lg:20px;--transition:all 0.25s cubic-bezier(0.4,0,0.2,1)}
|
|
18
|
+
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans SC",sans-serif;background:var(--bg);color:var(--text);display:flex;height:100vh;overflow:hidden;-webkit-font-smoothing:antialiased}
|
|
19
|
+
/* Layout */
|
|
20
|
+
.app{display:flex;width:100%;height:100%}
|
|
21
|
+
.right-panel{width:280px;background:var(--bg3);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0}
|
|
22
|
+
.main-panel{flex:1;display:flex;flex-direction:column;min-width:0}
|
|
23
|
+
.chat-area{flex:1;overflow:hidden;display:flex;flex-direction:column}
|
|
24
|
+
/* Right Panel */
|
|
25
|
+
.agent-select{padding:12px;border-bottom:1px solid var(--border)}
|
|
26
|
+
.agent-select select{width:100%;padding:8px 12px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:10px;font-size:14px;cursor:pointer}
|
|
27
|
+
.action-buttons{display:flex;gap:6px;padding:10px 12px;border-bottom:1px solid var(--border)}
|
|
28
|
+
.action-btn{flex:1;padding:7px 4px;background:var(--cream);color:var(--text2);border:1px solid var(--border);border-radius:10px;cursor:pointer;font-size:12px;display:flex;flex-direction:column;align-items:center;gap:3px;transition:var(--transition)}
|
|
29
|
+
.action-btn:hover{background:var(--primary);color:#fff;border-color:var(--primary)}
|
|
30
|
+
.action-btn .icon{font-size:18px}
|
|
31
|
+
.list-section{flex:1;overflow-y:auto}
|
|
32
|
+
.list-section h4{padding:10px 12px 6px;color:var(--text-muted);font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1.5px}
|
|
33
|
+
.friend-item,.group-item{display:flex;align-items:center;gap:10px;padding:10px 12px;cursor:pointer;transition:var(--transition);border-left:3px solid transparent}
|
|
34
|
+
.friend-item:hover,.group-item:hover{background:var(--cream)}
|
|
35
|
+
.friend-item.active,.group-item.active{background:var(--primary-lighter);border-left-color:var(--primary)}
|
|
36
|
+
.avatar{width:36px;height:36px;border-radius:12px;background:linear-gradient(135deg,var(--primary),var(--brown));display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:600;flex-shrink:0;color:#fff;overflow:hidden}
|
|
37
|
+
.avatar img{width:100%;height:100%;object-fit:cover;border-radius:12px}
|
|
38
|
+
.avatar.online{box-shadow:0 0 0 2px var(--success)}
|
|
39
|
+
.info{flex:1;min-width:0}
|
|
40
|
+
.info .name{font-size:14px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
41
|
+
.info .status{font-size:11px;color:var(--text2)}
|
|
42
|
+
.badge-online{color:var(--success);font-size:10px}
|
|
43
|
+
.badge-offline{color:var(--text2);font-size:10px}
|
|
44
|
+
.silent-badge{font-size:9px;background:var(--amber);color:#fff;padding:1px 4px;border-radius:4px;margin-left:4px}
|
|
45
|
+
/* Chat Header */
|
|
46
|
+
.chat-header{padding:12px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;background:var(--bg3)}
|
|
47
|
+
.chat-header .avatar{width:40px;height:40px;font-size:16px}
|
|
48
|
+
.chat-header .info .name{font-size:16px}
|
|
49
|
+
.chat-header .actions{margin-left:auto;display:flex;gap:8px}
|
|
50
|
+
.chat-header .actions button{background:none;border:none;color:var(--text2);cursor:pointer;font-size:18px;padding:4px 8px;border-radius:var(--radius-sm);transition:var(--transition)}
|
|
51
|
+
.chat-header .actions button:hover{background:var(--cream);color:var(--primary)}
|
|
52
|
+
/* Messages */
|
|
53
|
+
.messages-container{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:4px;background:var(--bg2)}
|
|
54
|
+
.load-more{text-align:center;padding:8px;color:var(--text2);font-size:12px;cursor:pointer}
|
|
55
|
+
.load-more:hover{color:var(--primary)}
|
|
56
|
+
.msg-row{display:flex;max-width:75%;gap:8px}
|
|
57
|
+
.msg-row.me{margin-left:auto;flex-direction:row-reverse}
|
|
58
|
+
.msg-row.them{margin-right:auto}
|
|
59
|
+
.msg-bubble{padding:10px 14px;border-radius:14px;font-size:14px;line-height:1.6;word-break:break-word;position:relative;max-width:100%}
|
|
60
|
+
.msg-row.me .msg-bubble{background:var(--bubble-me);color:#fff;border-top-right-radius:4px}
|
|
61
|
+
.msg-row.them .msg-bubble{background:var(--bubble-them);border:1px solid var(--border);color:var(--text);border-top-left-radius:4px}
|
|
62
|
+
.msg-bubble img{max-width:300px;max-height:300px;border-radius:8px;margin-top:6px;cursor:pointer}
|
|
63
|
+
.msg-bubble .file-link{display:flex;align-items:center;gap:6px;padding:6px 10px;background:rgba(255,255,255,.1);border-radius:6px;margin-top:6px;cursor:pointer;color:inherit;text-decoration:none}
|
|
64
|
+
.msg-bubble .file-link:hover{background:rgba(255,255,255,.2)}
|
|
65
|
+
.msg-time{font-size:10px;color:var(--text2);margin-top:4px;text-align:right}
|
|
66
|
+
.msg-row.me .msg-time{color:rgba(255,255,255,0.6)}
|
|
67
|
+
.msg-row.them .msg-time{text-align:left}
|
|
68
|
+
.msg-actions{position:absolute;top:-8px;right:-4px;display:none;gap:2px}
|
|
69
|
+
.msg-row.me .msg-actions{right:auto;left:-4px}
|
|
70
|
+
.msg-row:hover .msg-actions{display:flex}
|
|
71
|
+
.msg-action-btn{width:24px;height:24px;border-radius:var(--radius-sm);background:var(--bg3);border:1px solid var(--border);color:var(--text2);cursor:pointer;font-size:11px;display:flex;align-items:center;justify-content:center;transition:var(--transition)}
|
|
72
|
+
.msg-action-btn:hover{background:var(--primary);color:#fff;border-color:var(--primary)}
|
|
73
|
+
.msg-action-btn.danger:hover{background:var(--danger);border-color:var(--danger)}
|
|
74
|
+
/* Mention */
|
|
75
|
+
.mention{color:var(--info);font-weight:600;cursor:pointer}
|
|
76
|
+
.mention:hover{text-decoration:underline}
|
|
77
|
+
/* Input Area */
|
|
78
|
+
.input-area{padding:12px 16px;border-top:1px solid var(--border);background:var(--bg3);display:flex;flex-direction:column;gap:8px}
|
|
79
|
+
.input-top{display:flex;gap:8px;align-items:center}
|
|
80
|
+
.input-top input{flex:1;padding:10px 14px;background:var(--bg);border:1px solid var(--border);border-radius:12px;color:var(--text);font-size:14px;outline:none;transition:var(--transition)}
|
|
81
|
+
.input-top input:focus{border-color:var(--primary);box-shadow:0 0 0 3px var(--primary-lighter)}
|
|
82
|
+
.send-btn{padding:10px 20px;background:var(--primary);color:#fff;border:none;border-radius:10px;cursor:pointer;font-size:14px;font-weight:500;box-shadow:0 4px 16px rgba(217,119,87,0.3);transition:var(--transition)}
|
|
83
|
+
.send-btn:hover{background:var(--primary-light);transform:scale(1.05)}
|
|
84
|
+
.send-btn:disabled{opacity:.5;cursor:not-allowed;transform:none}
|
|
85
|
+
.input-toolbar{display:flex;gap:4px}
|
|
86
|
+
.tool-btn{background:none;border:none;color:var(--text2);cursor:pointer;font-size:16px;padding:4px 8px;border-radius:var(--radius-sm);transition:var(--transition)}
|
|
87
|
+
.tool-btn:hover{background:var(--cream);color:var(--primary)}
|
|
88
|
+
/* Modals */
|
|
89
|
+
.modal-overlay{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(45,42,38,0.4);display:none;align-items:center;justify-content:center;z-index:1000}
|
|
90
|
+
.modal-overlay.show{display:flex}
|
|
91
|
+
.modal{background:var(--bg3);border:1px solid var(--border);border-radius:20px;padding:24px;max-width:480px;width:90%;max-height:80vh;overflow-y:auto;box-shadow:var(--shadow-lg)}
|
|
92
|
+
.modal h3{margin-bottom:16px;font-size:18px}
|
|
93
|
+
.modal .form-group{margin-bottom:14px}
|
|
94
|
+
.modal label{display:block;margin-bottom:6px;font-size:13px;color:var(--text2)}
|
|
95
|
+
.modal input,.modal select,.modal textarea{width:100%;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:10px;color:var(--text);font-size:14px;transition:var(--transition)}
|
|
96
|
+
.modal input:focus,.modal select:focus,.modal textarea:focus{border-color:var(--primary);box-shadow:0 0 0 3px var(--primary-lighter);outline:none}
|
|
97
|
+
.modal textarea{min-height:80px;resize:vertical}
|
|
98
|
+
.modal .btn-row{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
|
|
99
|
+
.modal .btn{padding:8px 16px;border:none;border-radius:10px;cursor:pointer;font-size:14px;transition:var(--transition)}
|
|
100
|
+
.modal .btn-primary{background:var(--primary);color:#fff}
|
|
101
|
+
.modal .btn-primary:hover{background:var(--primary-light)}
|
|
102
|
+
.modal .btn-secondary{background:var(--cream);color:var(--text);border:1px solid var(--border)}
|
|
103
|
+
.modal .btn-secondary:hover{background:var(--primary-lighter);border-color:var(--primary);color:var(--primary)}
|
|
104
|
+
.modal .btn-danger{background:var(--danger);color:#fff}
|
|
105
|
+
.qr-container{text-align:center;margin:16px 0}
|
|
106
|
+
.qr-container img{max-width:256px;border-radius:8px}
|
|
107
|
+
.key-display{background:var(--bg);padding:10px;border-radius:var(--radius-sm);font-family:monospace;font-size:12px;word-break:break-all;margin:8px 0;color:var(--text-muted)}
|
|
108
|
+
.warning-box{background:rgba(224,85,85,0.08);border:1px solid var(--danger);border-radius:var(--radius-sm);padding:12px;margin:12px 0;color:var(--danger);font-size:13px}
|
|
109
|
+
/* Empty State */
|
|
110
|
+
.empty-state{flex:1;display:flex;align-items:center;justify-content:center;color:var(--text-muted);font-size:16px;flex-direction:column;gap:12px}
|
|
111
|
+
.empty-state .icon{font-size:48px;opacity:.3}
|
|
112
|
+
/* Mention dropdown */
|
|
113
|
+
.mention-dropdown{position:absolute;bottom:100%;left:0;background:var(--bg3);border:1px solid var(--border);border-radius:10px;max-height:200px;overflow-y:auto;z-index:100;display:none;min-width:200px;box-shadow:var(--shadow-md)}
|
|
114
|
+
.mention-dropdown.show{display:block}
|
|
115
|
+
.mention-option{padding:8px 12px;cursor:pointer;font-size:13px;display:flex;align-items:center;gap:8px}
|
|
116
|
+
.mention-option:hover{background:var(--cream)}
|
|
117
|
+
/* Scrollbar */
|
|
118
|
+
::-webkit-scrollbar{width:6px}
|
|
119
|
+
::-webkit-scrollbar-track{background:transparent}
|
|
120
|
+
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
|
121
|
+
::-webkit-scrollbar-thumb:hover{background:var(--text-muted)}
|
|
122
|
+
/* Markdown in bubbles */
|
|
123
|
+
.msg-bubble p{margin:4px 0}
|
|
124
|
+
.msg-row.them .msg-bubble code{background:rgba(0,0,0,0.05);padding:1px 4px;border-radius:3px;font-size:13px}
|
|
125
|
+
.msg-row.me .msg-bubble code{background:rgba(255,255,255,0.2);padding:1px 4px;border-radius:3px;font-size:13px}
|
|
126
|
+
.msg-row.them .msg-bubble pre{background:rgba(0,0,0,0.04);padding:8px;border-radius:6px;overflow-x:auto;margin:6px 0}
|
|
127
|
+
.msg-row.me .msg-bubble pre{background:rgba(255,255,255,0.15);padding:8px;border-radius:6px;overflow-x:auto;margin:6px 0}
|
|
128
|
+
.msg-bubble pre code{background:none;padding:0}
|
|
129
|
+
.msg-bubble blockquote{border-left:3px solid var(--primary);padding-left:10px;margin:6px 0;color:var(--text2)}
|
|
130
|
+
.msg-bubble ul,.msg-bubble ol{padding-left:20px;margin:4px 0}
|
|
131
|
+
.msg-row.them .msg-bubble a{color:var(--primary)}
|
|
132
|
+
.msg-row.me .msg-bubble a{color:#fff;text-decoration:underline}
|
|
133
|
+
.msg-bubble table{border-collapse:collapse;margin:6px 0}
|
|
134
|
+
.msg-bubble th{background:var(--cream);border:1px solid var(--border);padding:4px 8px;font-size:13px}
|
|
135
|
+
.msg-bubble td{border:1px solid var(--border);padding:4px 8px;font-size:13px}
|
|
136
|
+
/* Toast */
|
|
137
|
+
.toast{position:fixed;top:20px;left:50%;transform:translateX(-50%);background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:12px 24px;border-radius:var(--radius);font-size:14px;z-index:9999;box-shadow:var(--shadow-lg);max-width:90%;text-align:center;opacity:0;transition:opacity .3s;pointer-events:none}
|
|
138
|
+
.toast.show{opacity:1}
|
|
139
|
+
.toast.warning{border-color:var(--warning);background:rgba(229,165,75,0.1);color:var(--warning)}
|
|
140
|
+
/* Backup section */
|
|
141
|
+
.backup-section{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);padding:12px;margin-top:8px}
|
|
142
|
+
.backup-section h4{font-size:14px;margin-bottom:8px;color:var(--text)}
|
|
143
|
+
.backup-section p{font-size:12px;color:var(--text2);line-height:1.6;margin:4px 0}
|
|
144
|
+
.backup-section .warning-box{font-size:12px;padding:8px;margin:8px 0}
|
|
145
|
+
.backup-btns{display:flex;gap:8px;margin-top:10px}
|
|
146
|
+
.backup-btns .btn{flex:1;text-align:center}
|
|
147
|
+
/* Key match row */
|
|
148
|
+
.key-match-row{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid var(--border)}
|
|
149
|
+
.key-match-row:last-child{border-bottom:none}
|
|
150
|
+
.key-match-row label{font-size:13px;color:var(--text2);min-width:120px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
151
|
+
.key-match-row select{flex:1;padding:6px 10px;background:var(--bg);border:1px solid var(--border);border-radius:10px;color:var(--text);font-size:13px}
|
|
152
|
+
</style>
|
|
153
|
+
</head>
|
|
154
|
+
<body>
|
|
155
|
+
<!-- Toast -->
|
|
156
|
+
<div class="toast" id="toast"></div>
|
|
157
|
+
|
|
158
|
+
<div class="app">
|
|
159
|
+
<!-- Right Panel -->
|
|
160
|
+
<div class="right-panel">
|
|
161
|
+
<div class="agent-select">
|
|
162
|
+
<select id="agentSelect" onchange="switchAgent(this.value)">
|
|
163
|
+
<option value="">选择 Agent...</option>
|
|
164
|
+
</select>
|
|
165
|
+
</div>
|
|
166
|
+
<div class="action-buttons">
|
|
167
|
+
<button class="action-btn" onclick="showModal('addFriend')">
|
|
168
|
+
<span class="icon">👤</span>添加好友
|
|
169
|
+
</button>
|
|
170
|
+
<button class="action-btn" onclick="showModal('joinGroup')">
|
|
171
|
+
<span class="icon">👥</span>加入群聊
|
|
172
|
+
</button>
|
|
173
|
+
<button class="action-btn" onclick="showModal('settings')">
|
|
174
|
+
<span class="icon">⚙️</span>设置
|
|
175
|
+
</button>
|
|
176
|
+
<button class="action-btn" onclick="confirmLogout()">
|
|
177
|
+
<span class="icon">🚪</span>登出
|
|
178
|
+
</button>
|
|
179
|
+
</div>
|
|
180
|
+
<div class="list-section">
|
|
181
|
+
<div style="padding:8px 12px;font-size:11px;color:#8B6914;background:rgba(212,151,59,0.08);border-bottom:1px solid rgba(212,151,59,0.2);display:flex;align-items:flex-start;gap:6px">
|
|
182
|
+
<span style="flex-shrink:0">⚠️</span>
|
|
183
|
+
<span>若连续30天没有登录,好友关系和主人关系将被清理,到时候需重新绑定主人和添加好友。</span>
|
|
184
|
+
</div>
|
|
185
|
+
<h4>好友</h4>
|
|
186
|
+
<div id="friendsList"></div>
|
|
187
|
+
<h4>群聊</h4>
|
|
188
|
+
<div id="groupsList"></div>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
<!-- Main Panel -->
|
|
193
|
+
<div class="main-panel">
|
|
194
|
+
<div id="emptyState" class="empty-state">
|
|
195
|
+
<div class="icon">💬</div>
|
|
196
|
+
<div>选择好友或群聊开始对话</div>
|
|
197
|
+
</div>
|
|
198
|
+
<div id="chatView" class="chat-area" style="display:none">
|
|
199
|
+
<div class="chat-header">
|
|
200
|
+
<div class="avatar" id="chatAvatar">U</div>
|
|
201
|
+
<div class="info">
|
|
202
|
+
<div class="name" id="chatName">-</div>
|
|
203
|
+
<div class="status" id="chatStatus">-</div>
|
|
204
|
+
</div>
|
|
205
|
+
<div class="actions">
|
|
206
|
+
<button onclick="toggleSilent()" id="silentBtn" title="静默模式">🔔</button>
|
|
207
|
+
<button onclick="showChatInfo()" title="信息">ℹ️</button>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
<div class="messages-container" id="messagesContainer">
|
|
211
|
+
<div class="load-more" id="loadMore" onclick="loadMoreMessages()" style="display:none">加载更多消息</div>
|
|
212
|
+
</div>
|
|
213
|
+
<div class="input-area" style="position:relative">
|
|
214
|
+
<div class="mention-dropdown" id="mentionDropdown"></div>
|
|
215
|
+
<div class="input-top">
|
|
216
|
+
<input type="text" id="messageInput" placeholder="输入消息... (@提及)" onkeydown="handleInputKey(event)" oninput="handleInputChange()">
|
|
217
|
+
<button class="send-btn" onclick="sendMessage()">发送</button>
|
|
218
|
+
</div>
|
|
219
|
+
<div class="input-toolbar">
|
|
220
|
+
<button class="tool-btn" onclick="document.getElementById('fileInput').click()" title="上传文件">📎</button>
|
|
221
|
+
<button class="tool-btn" onclick="document.getElementById('imageInput').click()" title="上传图片">🖼️</button>
|
|
222
|
+
<button class="tool-btn" onclick="insertMarkdown('**','**')" title="粗体">𝐁</button>
|
|
223
|
+
<button class="tool-btn" onclick="insertMarkdown('`','`')" title="代码">⟨⟩</button>
|
|
224
|
+
<button class="tool-btn" onclick="insertMarkdown('$','$')" title="LaTeX">∑</button>
|
|
225
|
+
</div>
|
|
226
|
+
<input type="file" id="fileInput" style="display:none" onchange="uploadFile(this,'file')">
|
|
227
|
+
<input type="file" id="imageInput" style="display:none" accept="image/*" onchange="uploadFile(this,'image')">
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<!-- Modals -->
|
|
234
|
+
<div class="modal-overlay" id="modal-addFriend">
|
|
235
|
+
<div class="modal">
|
|
236
|
+
<h3>添加好友</h3>
|
|
237
|
+
<div class="form-group">
|
|
238
|
+
<label>好友码 / 临时号码</label>
|
|
239
|
+
<input type="text" id="friendCodeInput" placeholder="输入6位好友码或临时号码">
|
|
240
|
+
</div>
|
|
241
|
+
<div class="btn-row">
|
|
242
|
+
<button class="btn btn-secondary" onclick="hideModal('addFriend')">取消</button>
|
|
243
|
+
<button class="btn btn-primary" onclick="addFriendByCode()">添加</button>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
<div class="modal-overlay" id="modal-joinGroup">
|
|
249
|
+
<div class="modal">
|
|
250
|
+
<h3>加入群聊</h3>
|
|
251
|
+
<div class="form-group">
|
|
252
|
+
<label>创建新群聊</label>
|
|
253
|
+
<input type="text" id="newGroupName" placeholder="群聊名称">
|
|
254
|
+
</div>
|
|
255
|
+
<div class="btn-row">
|
|
256
|
+
<button class="btn btn-secondary" onclick="hideModal('joinGroup')">取消</button>
|
|
257
|
+
<button class="btn btn-primary" onclick="createGroup()">创建</button>
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
<div class="modal-overlay" id="modal-settings">
|
|
263
|
+
<div class="modal">
|
|
264
|
+
<h3>设置</h3>
|
|
265
|
+
<div style="display:flex;flex-direction:column;gap:12px">
|
|
266
|
+
<div style="text-align:center;padding:8px 0">
|
|
267
|
+
<div style="position:relative;display:inline-block">
|
|
268
|
+
<div class="avatar" id="settingsAvatar" style="width:64px;height:64px;font-size:24px;cursor:pointer" onclick="document.getElementById('avatarUploadInput').click()">A</div>
|
|
269
|
+
<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>
|
|
270
|
+
</div>
|
|
271
|
+
<input type="file" id="avatarUploadInput" accept="image/*" style="display:none" onchange="handlePluginAvatarUpload(this)">
|
|
272
|
+
<div style="font-size:11px;color:var(--text2);margin-top:4px">点击更换头像</div>
|
|
273
|
+
</div>
|
|
274
|
+
<button class="btn btn-secondary" onclick="showQRCode()" style="width:100%;text-align:left">📱 二维码 — 客户端扫描添加好友</button>
|
|
275
|
+
<button class="btn btn-secondary" onclick="showNicknameModal()" style="width:100%;text-align:left">✏️ 修改昵称</button>
|
|
276
|
+
<button class="btn btn-secondary" onclick="generateFriendCode()" style="width:100%;text-align:left">🔢 生成好友码 (24小时有效)</button>
|
|
277
|
+
<button class="btn btn-secondary" onclick="showKeysModal()" style="width:100%;text-align:left">🔑 显示/重新生成密钥</button>
|
|
278
|
+
<button class="btn btn-secondary" onclick="createNewAgent()" style="width:100%;text-align:left">➕ 创建新 Agent</button>
|
|
279
|
+
<button class="btn btn-secondary" onclick="syncData()" style="width:100%;text-align:left">🔄 同步服务器数据</button>
|
|
280
|
+
<div class="backup-section">
|
|
281
|
+
<h4>💾 数据备份</h4>
|
|
282
|
+
<p><strong>导出内容:</strong>聊天消息、好友关系、群聊信息、Agent 身份密钥(含私钥)</p>
|
|
283
|
+
<div class="warning-box">⚠️ 私钥包含在导出文件中!请妥善保管备份文件,切勿分享给他人。拥有私钥的人可以冒充你的身份发送消息。</div>
|
|
284
|
+
<p><strong>数据丢失场景:</strong></p>
|
|
285
|
+
<p>• 清除浏览器缓存数据</p>
|
|
286
|
+
<p>• 删除浏览器数据/历史记录</p>
|
|
287
|
+
<p>• 使用无痕/隐身模式(关闭窗口后数据清除)</p>
|
|
288
|
+
<p>• 更换浏览器或设备</p>
|
|
289
|
+
<p><strong>恢复方法:</strong>点击「导入备份」选择之前导出的 JSON 文件。若备份中的 Agent 私钥与当前 Agent 不匹配,系统会提示你手动对应。</p>
|
|
290
|
+
<div class="backup-btns">
|
|
291
|
+
<button class="btn btn-primary" onclick="exportBackup()">📤 导出备份</button>
|
|
292
|
+
<button class="btn btn-secondary" onclick="triggerImportBackup()">📥 导入备份</button>
|
|
293
|
+
</div>
|
|
294
|
+
<input type="file" id="backupFileInput" accept=".json" style="display:none" onchange="handleImportBackup(this)">
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
297
|
+
<div class="btn-row" style="margin-top:20px">
|
|
298
|
+
<button class="btn btn-danger" onclick="confirmLogout()">🚪 登出</button>
|
|
299
|
+
<button class="btn btn-primary" onclick="hideModal('settings')">关闭</button>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
|
|
304
|
+
<div class="modal-overlay" id="modal-qr">
|
|
305
|
+
<div class="modal">
|
|
306
|
+
<h3>二维码 — 扫码添加好友</h3>
|
|
307
|
+
<div class="qr-container" id="qrContainer">加载中...</div>
|
|
308
|
+
<p style="font-size:12px;color:var(--text2);text-align:center">客户端扫描此二维码,通过验证后即可添加该 Agent 为好友</p>
|
|
309
|
+
<div class="btn-row">
|
|
310
|
+
<button class="btn btn-primary" onclick="hideModal('qr')">关闭</button>
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
|
|
315
|
+
<div class="modal-overlay" id="modal-nickname">
|
|
316
|
+
<div class="modal">
|
|
317
|
+
<h3>修改昵称</h3>
|
|
318
|
+
<div class="form-group">
|
|
319
|
+
<label>新昵称</label>
|
|
320
|
+
<input type="text" id="nicknameInput" placeholder="输入新昵称">
|
|
321
|
+
</div>
|
|
322
|
+
<div class="btn-row">
|
|
323
|
+
<button class="btn btn-secondary" onclick="hideModal('nickname')">取消</button>
|
|
324
|
+
<button class="btn btn-primary" onclick="saveNickname()">保存</button>
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
|
|
329
|
+
<div class="modal-overlay" id="modal-friendCode">
|
|
330
|
+
<div class="modal">
|
|
331
|
+
<h3>好友码</h3>
|
|
332
|
+
<div id="friendCodeDisplay" style="text-align:center;padding:20px">生成中...</div>
|
|
333
|
+
<p style="font-size:12px;color:var(--text2);text-align:center">24小时有效的6位数字好友码,对方添加此号码即可自动连接服务器并添加好友</p>
|
|
334
|
+
<div class="btn-row">
|
|
335
|
+
<button class="btn btn-primary" onclick="hideModal('friendCode')">关闭</button>
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
|
|
340
|
+
<div class="modal-overlay" id="modal-keys">
|
|
341
|
+
<div class="modal">
|
|
342
|
+
<h3>密钥管理</h3>
|
|
343
|
+
<div class="warning-box">⚠️ 警告:私钥用于执行模式聊天。拥有私钥的人可以通过聊天安排任务执行、调用工具。请勿泄露私钥!</div>
|
|
344
|
+
<div id="keysDisplay">加载中...</div>
|
|
345
|
+
<div class="btn-row">
|
|
346
|
+
<button class="btn btn-danger" onclick="confirmRotateKeys()">重新生成密钥</button>
|
|
347
|
+
<button class="btn btn-primary" onclick="hideModal('keys')">关闭</button>
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
|
|
352
|
+
<div class="modal-overlay" id="modal-confirmRotate">
|
|
353
|
+
<div class="modal">
|
|
354
|
+
<h3>⚠️ 确认重新生成密钥</h3>
|
|
355
|
+
<div class="warning-box">重新生成密钥后,旧密钥将立即失效。所有好友需要重新建立连接。此操作不可撤销!</div>
|
|
356
|
+
<div class="btn-row">
|
|
357
|
+
<button class="btn btn-secondary" onclick="hideModal('confirmRotate')">取消</button>
|
|
358
|
+
<button class="btn btn-danger" onclick="rotateKeys()">确认重新生成</button>
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
|
|
363
|
+
<div class="modal-overlay" id="modal-createAgent">
|
|
364
|
+
<div class="modal">
|
|
365
|
+
<h3>创建新 Agent</h3>
|
|
366
|
+
<div class="form-group">
|
|
367
|
+
<label>Agent ID</label>
|
|
368
|
+
<input type="text" id="newAgentId" placeholder="输入唯一标识">
|
|
369
|
+
</div>
|
|
370
|
+
<div class="form-group">
|
|
371
|
+
<label>昵称</label>
|
|
372
|
+
<input type="text" id="newAgentName" placeholder="输入昵称">
|
|
373
|
+
</div>
|
|
374
|
+
<div class="btn-row">
|
|
375
|
+
<button class="btn btn-secondary" onclick="hideModal('createAgent')">取消</button>
|
|
376
|
+
<button class="btn btn-primary" onclick="doCreateAgent()">创建</button>
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
</div>
|
|
380
|
+
|
|
381
|
+
<div class="modal-overlay" id="modal-deleteMsg">
|
|
382
|
+
<div class="modal">
|
|
383
|
+
<h3>确认删除</h3>
|
|
384
|
+
<p>确定要删除这条消息吗?此操作不可撤销。</p>
|
|
385
|
+
<div class="btn-row">
|
|
386
|
+
<button class="btn btn-secondary" onclick="hideModal('deleteMsg')">取消</button>
|
|
387
|
+
<button class="btn btn-danger" onclick="doDeleteMessage()">删除</button>
|
|
388
|
+
</div>
|
|
389
|
+
</div>
|
|
390
|
+
</div>
|
|
391
|
+
|
|
392
|
+
<!-- Image Preview Modal -->
|
|
393
|
+
<div class="modal-overlay" id="modal-imagePreview">
|
|
394
|
+
<div class="modal" style="max-width:90vw;max-height:90vh;padding:8px">
|
|
395
|
+
<img id="previewImage" style="max-width:100%;max-height:85vh;border-radius:8px">
|
|
396
|
+
<div class="btn-row"><button class="btn btn-primary" onclick="hideModal('imagePreview')">关闭</button></div>
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
|
|
400
|
+
<!-- Backup Key Match Modal -->
|
|
401
|
+
<div class="modal-overlay" id="modal-backupMatch">
|
|
402
|
+
<div class="modal" style="max-width:560px">
|
|
403
|
+
<h3>🔑 Agent 密钥匹配</h3>
|
|
404
|
+
<div class="warning-box">备份中的 Agent 私钥与当前 Agent 不匹配,请手动选择对应关系。未匹配的聊天记录将不会导入。</div>
|
|
405
|
+
<p style="font-size:13px;color:var(--text2);margin-bottom:12px">左侧为备份中的 Agent,右侧选择当前对应的 Agent:</p>
|
|
406
|
+
<div id="backupMatchList"></div>
|
|
407
|
+
<div class="btn-row">
|
|
408
|
+
<button class="btn btn-secondary" onclick="skipBackupMatch()">跳过不匹配项</button>
|
|
409
|
+
<button class="btn btn-primary" onclick="applyBackupMatch()">确认匹配并导入</button>
|
|
410
|
+
</div>
|
|
411
|
+
</div>
|
|
412
|
+
</div>
|
|
413
|
+
|
|
414
|
+
<script>
|
|
415
|
+
// ─── State ──────────────────────────────────────────────────────────
|
|
416
|
+
let currentAgentId = '';
|
|
417
|
+
let currentTarget = null; // { id, name, type: 'friend'|'group', isOnline, silent }
|
|
418
|
+
let chatMessages = [];
|
|
419
|
+
let oldestTimestamp = null;
|
|
420
|
+
let wsReconnectTimer = null;
|
|
421
|
+
let pendingDeleteMsgId = null;
|
|
422
|
+
|
|
423
|
+
const API = '';
|
|
424
|
+
|
|
425
|
+
// ─── API Helper ─────────────────────────────────────────────────────
|
|
426
|
+
function errMsg(err) {
|
|
427
|
+
if (!err) return '未知错误';
|
|
428
|
+
if (typeof err === 'string') return err;
|
|
429
|
+
if (err.message) return err.message;
|
|
430
|
+
if (err.error && typeof err.error === 'string') return err.error;
|
|
431
|
+
if (err.error && typeof err.error === 'object') return errMsg(err.error);
|
|
432
|
+
try { return JSON.stringify(err); } catch { return '未知错误'; }
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function api(method, path, body = null) {
|
|
436
|
+
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
|
437
|
+
if (body) opts.body = JSON.stringify(body);
|
|
438
|
+
const resp = await fetch(API + path, opts);
|
|
439
|
+
const data = await resp.json();
|
|
440
|
+
if (!resp.ok && !data.success) {
|
|
441
|
+
throw new Error(errMsg(data.error) || `HTTP ${resp.status}`);
|
|
442
|
+
}
|
|
443
|
+
return data;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ─── Agents ─────────────────────────────────────────────────────────
|
|
447
|
+
async function loadAgents() {
|
|
448
|
+
const data = await api('GET', '/api/agents');
|
|
449
|
+
const sel = document.getElementById('agentSelect');
|
|
450
|
+
sel.innerHTML = '<option value="">选择 Agent...</option>';
|
|
451
|
+
for (const a of data.agents || []) {
|
|
452
|
+
const opt = document.createElement('option');
|
|
453
|
+
opt.value = a.agent_id;
|
|
454
|
+
opt.textContent = a.nickname || a.agent_id;
|
|
455
|
+
if (a.agent_id === currentAgentId) opt.selected = true;
|
|
456
|
+
sel.appendChild(opt);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function switchAgent(agentId) {
|
|
461
|
+
if (!agentId) return;
|
|
462
|
+
saveChatToLocalStorage();
|
|
463
|
+
currentAgentId = agentId;
|
|
464
|
+
currentTarget = null;
|
|
465
|
+
showEmptyState();
|
|
466
|
+
await loadFriends();
|
|
467
|
+
await loadGroups();
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function showEmptyState() {
|
|
471
|
+
document.getElementById('emptyState').style.display = 'flex';
|
|
472
|
+
document.getElementById('chatView').style.display = 'none';
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function showChatView() {
|
|
476
|
+
document.getElementById('emptyState').style.display = 'none';
|
|
477
|
+
document.getElementById('chatView').style.display = 'flex';
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async function createNewAgent() {
|
|
481
|
+
hideModal('settings');
|
|
482
|
+
showModal('createAgent');
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function doCreateAgent() {
|
|
486
|
+
const id = document.getElementById('newAgentId').value.trim();
|
|
487
|
+
const name = document.getElementById('newAgentName').value.trim();
|
|
488
|
+
if (!id) return alert('请输入 Agent ID');
|
|
489
|
+
await api('POST', '/api/agents', { agent_id: id, nickname: name });
|
|
490
|
+
hideModal('createAgent');
|
|
491
|
+
currentAgentId = id;
|
|
492
|
+
await loadAgents();
|
|
493
|
+
await loadFriends();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// ─── Friends ────────────────────────────────────────────────────────
|
|
497
|
+
async function loadFriends() {
|
|
498
|
+
if (!currentAgentId) return;
|
|
499
|
+
const data = await api('GET', `/api/friends?agent_id=${currentAgentId}`);
|
|
500
|
+
const container = document.getElementById('friendsList');
|
|
501
|
+
container.innerHTML = '';
|
|
502
|
+
for (const f of data.friends || []) {
|
|
503
|
+
const div = document.createElement('div');
|
|
504
|
+
div.className = 'friend-item' + (currentTarget?.id === f.id && currentTarget?.type === 'friend' ? ' active' : '');
|
|
505
|
+
div.onclick = () => selectTarget(f.id, f.ai_name || f.fingerprint?.slice(0,8) || f.id.slice(0,8), 'friend', !!f.is_online);
|
|
506
|
+
div.innerHTML = `
|
|
507
|
+
<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>
|
|
508
|
+
<div class="info">
|
|
509
|
+
<div class="name">${f.ai_name || f.fingerprint?.slice(0,16) || f.id.slice(0,12)}</div>
|
|
510
|
+
<div class="status"><span class="${f.is_online ? 'badge-online' : 'badge-offline'}">●</span> ${f.is_online ? '在线' : '离线'}</div>
|
|
511
|
+
</div>`;
|
|
512
|
+
container.appendChild(div);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ─── Groups ─────────────────────────────────────────────────────────
|
|
517
|
+
async function loadGroups() {
|
|
518
|
+
if (!currentAgentId) return;
|
|
519
|
+
const data = await api('GET', `/api/groups?agent_id=${currentAgentId}`);
|
|
520
|
+
const container = document.getElementById('groupsList');
|
|
521
|
+
container.innerHTML = '';
|
|
522
|
+
for (const g of data.groups || []) {
|
|
523
|
+
const silent = false; // will check
|
|
524
|
+
const div = document.createElement('div');
|
|
525
|
+
div.className = 'group-item' + (currentTarget?.id === g.id && currentTarget?.type === 'group' ? ' active' : '');
|
|
526
|
+
div.onclick = () => selectTarget(g.id, g.name, 'group', true, silent);
|
|
527
|
+
div.innerHTML = `
|
|
528
|
+
<div class="avatar">👥</div>
|
|
529
|
+
<div class="info">
|
|
530
|
+
<div class="name">${g.name}${silent ? '<span class="silent-badge">静默</span>' : ''}</div>
|
|
531
|
+
<div class="status">${(JSON.parse(g.members_json||'[]')).length} 成员</div>
|
|
532
|
+
</div>`;
|
|
533
|
+
container.appendChild(div);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ─── Select Chat Target ────────────────────────────────────────────
|
|
538
|
+
async function selectTarget(id, name, type, isOnline = false, silent = false) {
|
|
539
|
+
currentTarget = { id, name, type, isOnline, silent };
|
|
540
|
+
showChatView();
|
|
541
|
+
|
|
542
|
+
// Update header
|
|
543
|
+
const chatAvatar = document.getElementById('chatAvatar');
|
|
544
|
+
// Try to get avatar from friend data
|
|
545
|
+
let friendAvatar = null;
|
|
546
|
+
if (type === 'friend') {
|
|
547
|
+
const friends = document.querySelectorAll('.friend-item');
|
|
548
|
+
// We'll use the currentTarget data
|
|
549
|
+
}
|
|
550
|
+
if (type === 'group') {
|
|
551
|
+
chatAvatar.innerHTML = '👥';
|
|
552
|
+
} else {
|
|
553
|
+
const name = currentTarget?.name || id;
|
|
554
|
+
chatAvatar.textContent = name.charAt(0).toUpperCase();
|
|
555
|
+
chatAvatar.style.background = '';
|
|
556
|
+
}
|
|
557
|
+
// If friend has avatar, try to find it in loaded friends
|
|
558
|
+
try {
|
|
559
|
+
const friendData = await api('GET', `/api/friends?agent_id=${currentAgentId}`);
|
|
560
|
+
const friend = (friendData.friends || []).find(f => f.id === id);
|
|
561
|
+
if (friend && friend.ai_avatar) {
|
|
562
|
+
chatAvatar.innerHTML = `<img src="${friend.ai_avatar}" alt="">`;
|
|
563
|
+
chatAvatar.style.background = 'none';
|
|
564
|
+
}
|
|
565
|
+
} catch(e) {}
|
|
566
|
+
document.getElementById('chatName').textContent = name;
|
|
567
|
+
document.getElementById('chatStatus').textContent = type === 'group' ? '群聊' : (isOnline ? '在线' : '离线');
|
|
568
|
+
document.getElementById('silentBtn').textContent = silent ? '🔕' : '🔔';
|
|
569
|
+
document.getElementById('silentBtn').style.display = type === 'group' ? '' : 'none';
|
|
570
|
+
|
|
571
|
+
// Reload lists to show active state
|
|
572
|
+
await loadFriends();
|
|
573
|
+
await loadGroups();
|
|
574
|
+
|
|
575
|
+
// Load chat history
|
|
576
|
+
await loadMessages();
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ─── Messages ───────────────────────────────────────────────────────
|
|
580
|
+
async function loadMessages() {
|
|
581
|
+
if (!currentTarget || !currentAgentId) return;
|
|
582
|
+
chatMessages = [];
|
|
583
|
+
oldestTimestamp = null;
|
|
584
|
+
const container = document.getElementById('messagesContainer');
|
|
585
|
+
// Keep loadMore button, clear rest
|
|
586
|
+
container.innerHTML = '<div class="load-more" id="loadMore" onclick="loadMoreMessages()" style="display:none">加载更多消息</div>';
|
|
587
|
+
|
|
588
|
+
const data = await api('GET', `/api/chat/${currentTarget.id}?agent_id=${currentAgentId}&limit=50`);
|
|
589
|
+
const msgs = (data.messages || []).reverse();
|
|
590
|
+
if (msgs.length > 0) {
|
|
591
|
+
oldestTimestamp = msgs[0].timestamp;
|
|
592
|
+
document.getElementById('loadMore').style.display = msgs.length >= 50 ? 'block' : 'none';
|
|
593
|
+
}
|
|
594
|
+
chatMessages = msgs;
|
|
595
|
+
renderMessages();
|
|
596
|
+
scrollToBottom();
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async function loadMoreMessages() {
|
|
600
|
+
if (!currentTarget || !oldestTimestamp) return;
|
|
601
|
+
const data = await api('GET', `/api/chat/${currentTarget.id}?agent_id=${currentAgentId}&limit=50&before=${oldestTimestamp}`);
|
|
602
|
+
const msgs = (data.messages || []).reverse();
|
|
603
|
+
if (msgs.length > 0) {
|
|
604
|
+
oldestTimestamp = msgs[0].timestamp;
|
|
605
|
+
chatMessages = [...msgs, ...chatMessages];
|
|
606
|
+
document.getElementById('loadMore').style.display = msgs.length >= 50 ? 'block' : 'none';
|
|
607
|
+
renderMessages();
|
|
608
|
+
} else {
|
|
609
|
+
document.getElementById('loadMore').style.display = 'none';
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function renderMessages() {
|
|
614
|
+
const container = document.getElementById('messagesContainer');
|
|
615
|
+
const loadMore = document.getElementById('loadMore');
|
|
616
|
+
// Clear except loadMore
|
|
617
|
+
while (container.children.length > 1) container.removeChild(container.lastChild);
|
|
618
|
+
|
|
619
|
+
for (const msg of chatMessages) {
|
|
620
|
+
const isMe = msg.from_id === currentAgentId;
|
|
621
|
+
const row = document.createElement('div');
|
|
622
|
+
row.className = 'msg-row ' + (isMe ? 'me' : 'them');
|
|
623
|
+
row.dataset.msgId = msg.id;
|
|
624
|
+
|
|
625
|
+
const bubble = document.createElement('div');
|
|
626
|
+
bubble.className = 'msg-bubble';
|
|
627
|
+
|
|
628
|
+
// Render content based on type
|
|
629
|
+
let content = msg.content || '';
|
|
630
|
+
if (msg.type === 'image' && msg.file_url) {
|
|
631
|
+
content = renderMarkdown(content) + `<img src="${msg.file_url}" onclick="previewImage('${msg.file_url}')" alt="图片">`;
|
|
632
|
+
} else if (msg.type === 'file' && msg.file_url) {
|
|
633
|
+
content = renderMarkdown(content) + `<a class="file-link" href="${msg.file_url}" download>📄 ${msg.file_name || '下载文件'}</a>`;
|
|
634
|
+
} else {
|
|
635
|
+
// Process mentions
|
|
636
|
+
content = processMentions(content);
|
|
637
|
+
content = renderMarkdown(content);
|
|
638
|
+
content = renderLatex(content);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
bubble.innerHTML = content;
|
|
642
|
+
|
|
643
|
+
// Time
|
|
644
|
+
const timeDiv = document.createElement('div');
|
|
645
|
+
timeDiv.className = 'msg-time';
|
|
646
|
+
timeDiv.textContent = formatTime(msg.timestamp);
|
|
647
|
+
|
|
648
|
+
// Action buttons
|
|
649
|
+
const actions = document.createElement('div');
|
|
650
|
+
actions.className = 'msg-actions';
|
|
651
|
+
actions.innerHTML = `
|
|
652
|
+
<button class="msg-action-btn" onclick="copyMessage('${msg.id}')" title="复制">📋</button>
|
|
653
|
+
<button class="msg-action-btn danger" onclick="confirmDeleteMessage('${msg.id}')" title="删除">🗑️</button>`;
|
|
654
|
+
|
|
655
|
+
bubble.appendChild(actions);
|
|
656
|
+
bubble.appendChild(timeDiv);
|
|
657
|
+
row.appendChild(bubble);
|
|
658
|
+
container.appendChild(row);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Re-render KaTeX
|
|
662
|
+
renderAllLatex();
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function renderMarkdown(text) {
|
|
666
|
+
try {
|
|
667
|
+
return marked.parse(text, { breaks: true, gfm: true });
|
|
668
|
+
} catch (e) {
|
|
669
|
+
return text.replace(/</g, '<').replace(/>/g, '>');
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function renderLatex(html) {
|
|
674
|
+
// Inline and block LaTeX with $$...$$ and $...$
|
|
675
|
+
return html.replace(/\$\$([\s\S]+?)\$\$/g, (m, tex) => {
|
|
676
|
+
try { return katex.renderToString(tex.trim(), { displayMode: true, throwOnError: false }); }
|
|
677
|
+
catch (e) { return m; }
|
|
678
|
+
}).replace(/\$([^\$]+?)\$/g, (m, tex) => {
|
|
679
|
+
try { return katex.renderToString(tex.trim(), { displayMode: false, throwOnError: false }); }
|
|
680
|
+
catch (e) { return m; }
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function renderAllLatex() {
|
|
685
|
+
// Re-render any remaining LaTeX
|
|
686
|
+
try {
|
|
687
|
+
document.querySelectorAll('.msg-bubble').forEach(el => {
|
|
688
|
+
renderMathInElement(el, {
|
|
689
|
+
delimiters: [
|
|
690
|
+
{left: '$$', right: '$$', display: true},
|
|
691
|
+
{left: '$', right: '$', display: false},
|
|
692
|
+
],
|
|
693
|
+
throwOnError: false
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
} catch(e) {}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function processMentions(text) {
|
|
700
|
+
// Highlight @mentions
|
|
701
|
+
return text.replace(/@(\S+)/g, '<span class="mention">@$1</span>');
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function formatTime(ts) {
|
|
705
|
+
try {
|
|
706
|
+
const d = new Date(ts);
|
|
707
|
+
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
|
708
|
+
} catch { return ts; }
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function scrollToBottom() {
|
|
712
|
+
const container = document.getElementById('messagesContainer');
|
|
713
|
+
container.scrollTop = container.scrollHeight;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// ─── Send Message ───────────────────────────────────────────────────
|
|
717
|
+
async function sendMessage() {
|
|
718
|
+
const input = document.getElementById('messageInput');
|
|
719
|
+
const content = input.value.trim();
|
|
720
|
+
if (!content || !currentTarget || !currentAgentId) return;
|
|
721
|
+
|
|
722
|
+
input.value = '';
|
|
723
|
+
try {
|
|
724
|
+
await api('POST', '/api/chat/send', {
|
|
725
|
+
agent_id: currentAgentId,
|
|
726
|
+
targetId: currentTarget.id,
|
|
727
|
+
content,
|
|
728
|
+
type: 'text',
|
|
729
|
+
isGroup: currentTarget.type === 'group',
|
|
730
|
+
mentions: extractMentions(content),
|
|
731
|
+
});
|
|
732
|
+
await loadMessages();
|
|
733
|
+
saveChatToLocalStorage();
|
|
734
|
+
} catch (e) {
|
|
735
|
+
alert('发送失败: ' + e.message);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function extractMentions(text) {
|
|
740
|
+
const mentions = [];
|
|
741
|
+
const re = /@(\S+)/g;
|
|
742
|
+
let m;
|
|
743
|
+
while ((m = re.exec(text)) !== null) {
|
|
744
|
+
mentions.push(m[1]);
|
|
745
|
+
}
|
|
746
|
+
return mentions;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function handleInputKey(e) {
|
|
750
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
751
|
+
e.preventDefault();
|
|
752
|
+
sendMessage();
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function handleInputChange() {
|
|
757
|
+
const input = document.getElementById('messageInput');
|
|
758
|
+
const val = input.value;
|
|
759
|
+
const cursorPos = input.selectionStart;
|
|
760
|
+
const textBefore = val.substring(0, cursorPos);
|
|
761
|
+
const atMatch = textBefore.match(/@(\S*)$/);
|
|
762
|
+
const dropdown = document.getElementById('mentionDropdown');
|
|
763
|
+
|
|
764
|
+
if (atMatch && currentTarget?.type === 'group') {
|
|
765
|
+
// Show mention dropdown
|
|
766
|
+
const query = atMatch[1].toLowerCase();
|
|
767
|
+
// Get group members from the group info
|
|
768
|
+
dropdown.innerHTML = '';
|
|
769
|
+
dropdown.innerHTML += `<div class="mention-option" onclick="insertMention('all')">📢 @所有人</div>`;
|
|
770
|
+
// Add friend suggestions
|
|
771
|
+
const friends = document.querySelectorAll('.friend-item .name');
|
|
772
|
+
friends.forEach(f => {
|
|
773
|
+
const name = f.textContent;
|
|
774
|
+
if (!query || name.toLowerCase().includes(query)) {
|
|
775
|
+
dropdown.innerHTML += `<div class="mention-option" onclick="insertMention('${name}')">👤 ${name}</div>`;
|
|
776
|
+
}
|
|
777
|
+
});
|
|
778
|
+
dropdown.classList.add('show');
|
|
779
|
+
} else {
|
|
780
|
+
dropdown.classList.remove('show');
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function insertMention(name) {
|
|
785
|
+
const input = document.getElementById('messageInput');
|
|
786
|
+
const val = input.value;
|
|
787
|
+
const cursorPos = input.selectionStart;
|
|
788
|
+
const textBefore = val.substring(0, cursorPos);
|
|
789
|
+
const textAfter = val.substring(cursorPos);
|
|
790
|
+
const newText = textBefore.replace(/@\S*$/, '@' + name + ' ') + textAfter;
|
|
791
|
+
input.value = newText;
|
|
792
|
+
input.focus();
|
|
793
|
+
document.getElementById('mentionDropdown').classList.remove('show');
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// ─── File Upload ────────────────────────────────────────────────────
|
|
797
|
+
async function uploadFile(input, type) {
|
|
798
|
+
if (!input.files[0] || !currentTarget) return;
|
|
799
|
+
const formData = new FormData();
|
|
800
|
+
formData.append('file', input.files[0]);
|
|
801
|
+
formData.append('agent_id', currentAgentId);
|
|
802
|
+
formData.append('targetId', currentTarget.id);
|
|
803
|
+
formData.append('isGroup', currentTarget.type === 'group' ? 'true' : 'false');
|
|
804
|
+
|
|
805
|
+
try {
|
|
806
|
+
const resp = await fetch(API + '/api/upload', { method: 'POST', body: formData });
|
|
807
|
+
const data = await resp.json();
|
|
808
|
+
if (data.success) {
|
|
809
|
+
await loadMessages();
|
|
810
|
+
} else {
|
|
811
|
+
alert('上传失败: ' + errMsg(data.error));
|
|
812
|
+
}
|
|
813
|
+
} catch (e) {
|
|
814
|
+
alert('上传失败: ' + e.message);
|
|
815
|
+
}
|
|
816
|
+
input.value = '';
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// ─── Message Actions ────────────────────────────────────────────────
|
|
820
|
+
function copyMessage(msgId) {
|
|
821
|
+
const row = document.querySelector(`.msg-row[data-msg-id="${msgId}"]`);
|
|
822
|
+
if (!row) return;
|
|
823
|
+
const bubble = row.querySelector('.msg-bubble');
|
|
824
|
+
// Get text content, excluding action buttons and time
|
|
825
|
+
const clone = bubble.cloneNode(true);
|
|
826
|
+
clone.querySelectorAll('.msg-actions, .msg-time').forEach(el => el.remove());
|
|
827
|
+
navigator.clipboard.writeText(clone.textContent.trim()).then(() => {
|
|
828
|
+
// Brief visual feedback
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function confirmDeleteMessage(msgId) {
|
|
833
|
+
pendingDeleteMsgId = msgId;
|
|
834
|
+
showModal('deleteMsg');
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
async function doDeleteMessage() {
|
|
838
|
+
if (!pendingDeleteMsgId) return;
|
|
839
|
+
await api('DELETE', `/api/chat/${pendingDeleteMsgId}?agent_id=${currentAgentId}`);
|
|
840
|
+
hideModal('deleteMsg');
|
|
841
|
+
pendingDeleteMsgId = null;
|
|
842
|
+
await loadMessages();
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// ─── Markdown Toolbar ───────────────────────────────────────────────
|
|
846
|
+
function insertMarkdown(before, after) {
|
|
847
|
+
const input = document.getElementById('messageInput');
|
|
848
|
+
const start = input.selectionStart;
|
|
849
|
+
const end = input.selectionEnd;
|
|
850
|
+
const selected = input.value.substring(start, end);
|
|
851
|
+
const replacement = before + (selected || 'text') + after;
|
|
852
|
+
input.value = input.value.substring(0, start) + replacement + input.value.substring(end);
|
|
853
|
+
input.focus();
|
|
854
|
+
input.setSelectionRange(start + before.length, start + before.length + (selected || 'text').length);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// ─── Image Preview ──────────────────────────────────────────────────
|
|
858
|
+
function previewImage(url) {
|
|
859
|
+
document.getElementById('previewImage').src = url;
|
|
860
|
+
showModal('imagePreview');
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// ─── Silent Mode ────────────────────────────────────────────────────
|
|
864
|
+
async function toggleSilent() {
|
|
865
|
+
if (!currentTarget || currentTarget.type !== 'group') return;
|
|
866
|
+
const newSilent = !currentTarget.silent;
|
|
867
|
+
await api('PUT', `/api/groups/${currentTarget.id}/silent?agent_id=${currentAgentId}`, { silent: newSilent });
|
|
868
|
+
currentTarget.silent = newSilent;
|
|
869
|
+
document.getElementById('silentBtn').textContent = newSilent ? '🔕' : '🔔';
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// ─── Modal Helpers ──────────────────────────────────────────────────
|
|
873
|
+
function showModal(name) {
|
|
874
|
+
document.getElementById('modal-' + name).classList.add('show');
|
|
875
|
+
// Update settings avatar when opening settings modal
|
|
876
|
+
if (name === 'settings' && currentAgentId) {
|
|
877
|
+
api('GET', `/api/identity?agent_id=${currentAgentId}`).then(info => {
|
|
878
|
+
const avatarEl = document.getElementById('settingsAvatar');
|
|
879
|
+
if (info.avatar) {
|
|
880
|
+
avatarEl.innerHTML = `<img src="${info.avatar}" alt="头像">`;
|
|
881
|
+
avatarEl.style.background = 'none';
|
|
882
|
+
} else {
|
|
883
|
+
avatarEl.textContent = (info.nickname || 'A').charAt(0).toUpperCase();
|
|
884
|
+
}
|
|
885
|
+
}).catch(() => {});
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
function hideModal(name) { document.getElementById('modal-' + name).classList.remove('show'); }
|
|
889
|
+
|
|
890
|
+
// ─── Add Friend ─────────────────────────────────────────────────────
|
|
891
|
+
async function addFriendByCode() {
|
|
892
|
+
const code = document.getElementById('friendCodeInput').value.trim();
|
|
893
|
+
if (!code) return alert('请输入好友码');
|
|
894
|
+
try {
|
|
895
|
+
const result = await api('POST', '/api/friends/add', { temp_number: code, agent_id: currentAgentId });
|
|
896
|
+
if (result.success) {
|
|
897
|
+
alert('好友添加成功!');
|
|
898
|
+
hideModal('addFriend');
|
|
899
|
+
document.getElementById('friendCodeInput').value = '';
|
|
900
|
+
await loadFriends();
|
|
901
|
+
} else {
|
|
902
|
+
alert('添加失败: ' + errMsg(result.error));
|
|
903
|
+
}
|
|
904
|
+
} catch (e) {
|
|
905
|
+
alert('添加失败: ' + e.message);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// ─── Create Group ───────────────────────────────────────────────────
|
|
910
|
+
async function createGroup() {
|
|
911
|
+
const name = document.getElementById('newGroupName').value.trim();
|
|
912
|
+
if (!name) return alert('请输入群聊名称');
|
|
913
|
+
try {
|
|
914
|
+
await api('POST', '/api/groups', { name, agent_id: currentAgentId });
|
|
915
|
+
hideModal('joinGroup');
|
|
916
|
+
document.getElementById('newGroupName').value = '';
|
|
917
|
+
await loadGroups();
|
|
918
|
+
} catch (e) {
|
|
919
|
+
alert('创建失败: ' + e.message);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// ─── Settings Actions ───────────────────────────────────────────────
|
|
924
|
+
async function showQRCode() {
|
|
925
|
+
hideModal('settings');
|
|
926
|
+
try {
|
|
927
|
+
const data = await api('GET', `/api/identity/qr?agent_id=${currentAgentId}`);
|
|
928
|
+
document.getElementById('qrContainer').innerHTML = data.qr
|
|
929
|
+
? `<img src="${data.qr}" alt="QR Code">`
|
|
930
|
+
: '<p>生成失败</p>';
|
|
931
|
+
} catch (e) {
|
|
932
|
+
document.getElementById('qrContainer').innerHTML = '<p>生成失败: ' + e.message + '</p>';
|
|
933
|
+
}
|
|
934
|
+
showModal('qr');
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function showNicknameModal() {
|
|
938
|
+
hideModal('settings');
|
|
939
|
+
showModal('nickname');
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
async function saveNickname() {
|
|
943
|
+
const nickname = document.getElementById('nicknameInput').value.trim();
|
|
944
|
+
if (!nickname) return alert('请输入昵称');
|
|
945
|
+
await api('POST', '/api/identity/nickname', { agent_id: currentAgentId, nickname });
|
|
946
|
+
hideModal('nickname');
|
|
947
|
+
await loadAgents();
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
async function generateFriendCode() {
|
|
951
|
+
hideModal('settings');
|
|
952
|
+
try {
|
|
953
|
+
const data = await api('POST', '/api/identity/friend-code', { agent_id: currentAgentId });
|
|
954
|
+
document.getElementById('friendCodeDisplay').innerHTML = data.code
|
|
955
|
+
? `<div style="font-size:48px;font-weight:700;letter-spacing:8px;color:var(--primary)">${data.code}</div><div style="font-size:12px;color:var(--text2);margin-top:8px">有效期至: ${data.expires_at || '24小时'}</div>`
|
|
956
|
+
: '<p>生成失败</p>';
|
|
957
|
+
} catch (e) {
|
|
958
|
+
document.getElementById('friendCodeDisplay').innerHTML = '<p>生成失败: ' + e.message + '</p>';
|
|
959
|
+
}
|
|
960
|
+
showModal('friendCode');
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
async function showKeysModal() {
|
|
964
|
+
hideModal('settings');
|
|
965
|
+
try {
|
|
966
|
+
const data = await api('GET', `/api/identity/keys?agent_id=${currentAgentId}`);
|
|
967
|
+
document.getElementById('keysDisplay').innerHTML = `
|
|
968
|
+
<div><strong>Agent ID:</strong></div>
|
|
969
|
+
<div class="key-display">${data.agent_id || '-'}</div>
|
|
970
|
+
<div><strong>昵称:</strong> ${data.nickname || '-'}</div>
|
|
971
|
+
<div><strong>指纹:</strong></div>
|
|
972
|
+
<div class="key-display">${data.fingerprint || '-'}</div>
|
|
973
|
+
<div><strong>公钥 (签名):</strong> <small style="color:var(--text2)">用于连接服务端,获取好友列表</small></div>
|
|
974
|
+
<div class="key-display">${data.signing_public_key || '-'}</div>
|
|
975
|
+
<div><strong>公钥 (交换):</strong></div>
|
|
976
|
+
<div class="key-display">${data.exchange_public_key || '-'}</div>
|
|
977
|
+
<details style="margin-top:12px"><summary style="cursor:pointer;color:var(--warning)">🔑 显示私钥 (危险)</summary>
|
|
978
|
+
<div class="warning-box" style="margin-top:8px">⚠️ 私钥用于执行模式的聊天。拥有此私钥的人可以通过聊天安排任务执行、调用工具。请勿泄露!</div>
|
|
979
|
+
<div><strong>私钥 (签名):</strong></div>
|
|
980
|
+
<div class="key-display">${data.signing_secret_key || '-'}</div>
|
|
981
|
+
<div><strong>私钥 (交换):</strong></div>
|
|
982
|
+
<div class="key-display">${data.exchange_secret_key || '-'}</div>
|
|
983
|
+
</details>`;
|
|
984
|
+
} catch (e) {
|
|
985
|
+
document.getElementById('keysDisplay').innerHTML = '<p>加载失败: ' + e.message + '</p>';
|
|
986
|
+
}
|
|
987
|
+
showModal('keys');
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function confirmRotateKeys() {
|
|
991
|
+
hideModal('keys');
|
|
992
|
+
showModal('confirmRotate');
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
async function rotateKeys() {
|
|
996
|
+
try {
|
|
997
|
+
await api('POST', '/api/identity/rotate-keys', { agent_id: currentAgentId });
|
|
998
|
+
hideModal('confirmRotate');
|
|
999
|
+
await showKeysModal();
|
|
1000
|
+
} catch (e) {
|
|
1001
|
+
alert('密钥轮换失败: ' + e.message);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
async function syncData() {
|
|
1006
|
+
hideModal('settings');
|
|
1007
|
+
try {
|
|
1008
|
+
await api('POST', '/api/sync', { agent_id: currentAgentId });
|
|
1009
|
+
await loadFriends();
|
|
1010
|
+
await loadGroups();
|
|
1011
|
+
alert('同步完成');
|
|
1012
|
+
} catch (e) {
|
|
1013
|
+
alert('同步失败: ' + e.message);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function showChatInfo() {
|
|
1018
|
+
if (!currentTarget) return;
|
|
1019
|
+
alert(currentTarget.type === 'group'
|
|
1020
|
+
? `群聊: ${currentTarget.name}\nID: ${currentTarget.id}`
|
|
1021
|
+
: `好友: ${currentTarget.name}\nID: ${currentTarget.id}\n状态: ${currentTarget.isOnline ? '在线' : '离线'}`);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// ─── Image Resize Utility ───────────────────────────────────────────
|
|
1025
|
+
function resizePluginImage(file,maxW,maxH,quality=0.8){
|
|
1026
|
+
return new Promise((resolve,reject)=>{
|
|
1027
|
+
if(!file.type.startsWith('image/')){reject(new Error('Not an image'));return;}
|
|
1028
|
+
const img=new Image();const url=URL.createObjectURL(file);
|
|
1029
|
+
img.onload=()=>{
|
|
1030
|
+
let w=img.width,h=img.height;
|
|
1031
|
+
if(w<=maxW&&h<=maxH&&file.size<=512*1024){URL.revokeObjectURL(url);resolve(file);return;}
|
|
1032
|
+
const ratio=Math.min(maxW/w,maxH/h,1);w=Math.round(w*ratio);h=Math.round(h*ratio);
|
|
1033
|
+
const canvas=document.createElement('canvas');canvas.width=w;canvas.height=h;
|
|
1034
|
+
const ctx=canvas.getContext('2d');ctx.drawImage(img,0,0,w,h);
|
|
1035
|
+
canvas.toBlob(blob=>{URL.revokeObjectURL(url);if(!blob){reject(new Error('Resize failed'));return;}resolve(new File([blob],file.name.replace(/\.\w+$/,'.jpg'),{type:'image/jpeg',lastModified:Date.now()}));},'image/jpeg',quality);
|
|
1036
|
+
};
|
|
1037
|
+
img.onerror=()=>{URL.revokeObjectURL(url);reject(new Error('Image load failed'));};
|
|
1038
|
+
img.src=url;
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// ─── Avatar Upload ──────────────────────────────────────────────────
|
|
1043
|
+
async function handlePluginAvatarUpload(input) {
|
|
1044
|
+
const rawFile = input.files && input.files[0];
|
|
1045
|
+
if (!rawFile || !currentAgentId) return;
|
|
1046
|
+
if (!rawFile.type.startsWith('image/')) { alert('请选择图片文件'); return; }
|
|
1047
|
+
try {
|
|
1048
|
+
const file = await resizePluginImage(rawFile, 256, 256, 0.85); // resize for avatar
|
|
1049
|
+
const formData = new FormData();
|
|
1050
|
+
formData.append('avatar', file);
|
|
1051
|
+
formData.append('agent_id', currentAgentId);
|
|
1052
|
+
const resp = await fetch(API + '/api/identity/avatar', { method: 'POST', body: formData });
|
|
1053
|
+
const data = await resp.json();
|
|
1054
|
+
if (data.success || data.avatar) {
|
|
1055
|
+
const avatarUrl = data.avatar || (data.account && data.account.avatar);
|
|
1056
|
+
if (avatarUrl) {
|
|
1057
|
+
document.getElementById('settingsAvatar').innerHTML = `<img src="${avatarUrl}" alt="头像">`;
|
|
1058
|
+
}
|
|
1059
|
+
alert('头像已更新');
|
|
1060
|
+
} else {
|
|
1061
|
+
alert('上传失败: ' + errMsg(data.error));
|
|
1062
|
+
}
|
|
1063
|
+
} catch (e) {
|
|
1064
|
+
alert('头像上传失败: ' + e.message);
|
|
1065
|
+
}
|
|
1066
|
+
input.value = '';
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// ─── Toast Notification ─────────────────────────────────────────────
|
|
1070
|
+
let toastTimer = null;
|
|
1071
|
+
function showToast(message, type = '', duration = 5000) {
|
|
1072
|
+
const el = document.getElementById('toast');
|
|
1073
|
+
el.textContent = message;
|
|
1074
|
+
el.className = 'toast show' + (type ? ' ' + type : '');
|
|
1075
|
+
if (toastTimer) clearTimeout(toastTimer);
|
|
1076
|
+
toastTimer = setTimeout(() => { el.className = 'toast'; }, duration);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// ─── localStorage Chat Cache ───────────────────────────────────────
|
|
1080
|
+
const CACHE_KEY = 'aicq_plugin_chat_cache';
|
|
1081
|
+
|
|
1082
|
+
function saveChatToLocalStorage() {
|
|
1083
|
+
try {
|
|
1084
|
+
const cache = {
|
|
1085
|
+
currentAgentId,
|
|
1086
|
+
currentTarget,
|
|
1087
|
+
chatMessages,
|
|
1088
|
+
oldestTimestamp,
|
|
1089
|
+
savedAt: Date.now()
|
|
1090
|
+
};
|
|
1091
|
+
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
|
|
1092
|
+
} catch (e) {
|
|
1093
|
+
// localStorage might be full or unavailable
|
|
1094
|
+
console.warn('Failed to save chat cache:', e);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function loadChatFromLocalStorage() {
|
|
1099
|
+
try {
|
|
1100
|
+
const raw = localStorage.getItem(CACHE_KEY);
|
|
1101
|
+
if (!raw) return null;
|
|
1102
|
+
return JSON.parse(raw);
|
|
1103
|
+
} catch (e) {
|
|
1104
|
+
return null;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function clearChatCache() {
|
|
1109
|
+
try { localStorage.removeItem(CACHE_KEY); } catch(e) {}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// ─── Logout ─────────────────────────────────────────────────────────
|
|
1113
|
+
function confirmLogout() {
|
|
1114
|
+
if (confirm('确定要登出吗?登出前会自动保存聊天缓存到本地。')) {
|
|
1115
|
+
saveChatToLocalStorage();
|
|
1116
|
+
// Clear state
|
|
1117
|
+
currentAgentId = '';
|
|
1118
|
+
currentTarget = null;
|
|
1119
|
+
chatMessages = [];
|
|
1120
|
+
oldestTimestamp = null;
|
|
1121
|
+
document.getElementById('agentSelect').value = '';
|
|
1122
|
+
showEmptyState();
|
|
1123
|
+
document.getElementById('friendsList').innerHTML = '';
|
|
1124
|
+
document.getElementById('groupsList').innerHTML = '';
|
|
1125
|
+
hideModal('settings');
|
|
1126
|
+
showToast('已登出,聊天缓存已保存到本地');
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// ─── Backup Export ──────────────────────────────────────────────────
|
|
1131
|
+
async function exportBackup() {
|
|
1132
|
+
try {
|
|
1133
|
+
showToast('正在生成备份...');
|
|
1134
|
+
// Get all agents
|
|
1135
|
+
const agentsData = await api('GET', '/api/agents');
|
|
1136
|
+
const agents = agentsData.agents || [];
|
|
1137
|
+
|
|
1138
|
+
// Collect data for each agent
|
|
1139
|
+
const backupAgents = [];
|
|
1140
|
+
for (const agent of agents) {
|
|
1141
|
+
// Get identity/keys for this agent (includes private keys)
|
|
1142
|
+
let keys = {};
|
|
1143
|
+
try {
|
|
1144
|
+
keys = await api('GET', `/api/identity/keys?agent_id=${agent.agent_id}`);
|
|
1145
|
+
} catch(e) {}
|
|
1146
|
+
|
|
1147
|
+
// Get friends
|
|
1148
|
+
let friends = [];
|
|
1149
|
+
try {
|
|
1150
|
+
const fd = await api('GET', `/api/friends?agent_id=${agent.agent_id}`);
|
|
1151
|
+
friends = fd.friends || [];
|
|
1152
|
+
} catch(e) {}
|
|
1153
|
+
|
|
1154
|
+
// Get groups
|
|
1155
|
+
let groups = [];
|
|
1156
|
+
try {
|
|
1157
|
+
const gd = await api('GET', `/api/groups?agent_id=${agent.agent_id}`);
|
|
1158
|
+
groups = gd.groups || [];
|
|
1159
|
+
} catch(e) {}
|
|
1160
|
+
|
|
1161
|
+
// Get chat messages for each friend and group
|
|
1162
|
+
const chatData = {};
|
|
1163
|
+
for (const f of friends) {
|
|
1164
|
+
try {
|
|
1165
|
+
const md = await api('GET', `/api/chat/${f.id}?agent_id=${agent.agent_id}&limit=9999`);
|
|
1166
|
+
chatData['friend_' + f.id] = md.messages || [];
|
|
1167
|
+
} catch(e) {}
|
|
1168
|
+
}
|
|
1169
|
+
for (const g of groups) {
|
|
1170
|
+
try {
|
|
1171
|
+
const md = await api('GET', `/api/chat/${g.id}?agent_id=${agent.agent_id}&limit=9999`);
|
|
1172
|
+
chatData['group_' + g.id] = md.messages || [];
|
|
1173
|
+
} catch(e) {}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
backupAgents.push({
|
|
1177
|
+
agent_id: agent.agent_id,
|
|
1178
|
+
nickname: agent.nickname || agent.agent_id,
|
|
1179
|
+
avatar: agent.avatar || null,
|
|
1180
|
+
signing_public_key: keys.signing_public_key || null,
|
|
1181
|
+
exchange_public_key: keys.exchange_public_key || null,
|
|
1182
|
+
signing_secret_key: keys.signing_secret_key || null,
|
|
1183
|
+
exchange_secret_key: keys.exchange_secret_key || null,
|
|
1184
|
+
fingerprint: keys.fingerprint || null,
|
|
1185
|
+
friends,
|
|
1186
|
+
groups,
|
|
1187
|
+
chatData
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
const backup = {
|
|
1192
|
+
version: 'aicq-plugin-backup-v1',
|
|
1193
|
+
exportedAt: new Date().toISOString(),
|
|
1194
|
+
agents: backupAgents
|
|
1195
|
+
};
|
|
1196
|
+
|
|
1197
|
+
// Download as JSON file
|
|
1198
|
+
const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' });
|
|
1199
|
+
const url = URL.createObjectURL(blob);
|
|
1200
|
+
const a = document.createElement('a');
|
|
1201
|
+
a.href = url;
|
|
1202
|
+
a.download = `aicq-backup-${new Date().toISOString().slice(0,10)}.json`;
|
|
1203
|
+
document.body.appendChild(a);
|
|
1204
|
+
a.click();
|
|
1205
|
+
document.body.removeChild(a);
|
|
1206
|
+
URL.revokeObjectURL(url);
|
|
1207
|
+
|
|
1208
|
+
showToast('备份已导出!请妥善保管此文件,内含私钥。', 'warning', 6000);
|
|
1209
|
+
} catch (e) {
|
|
1210
|
+
alert('导出失败: ' + e.message);
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// ─── Backup Import ──────────────────────────────────────────────────
|
|
1215
|
+
function triggerImportBackup() {
|
|
1216
|
+
document.getElementById('backupFileInput').click();
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// Store pending backup for matching
|
|
1220
|
+
let pendingBackup = null;
|
|
1221
|
+
let pendingMatchMap = {};
|
|
1222
|
+
|
|
1223
|
+
async function handleImportBackup(input) {
|
|
1224
|
+
if (!input.files || !input.files[0]) return;
|
|
1225
|
+
const file = input.files[0];
|
|
1226
|
+
input.value = '';
|
|
1227
|
+
|
|
1228
|
+
try {
|
|
1229
|
+
const text = await file.text();
|
|
1230
|
+
const backup = JSON.parse(text);
|
|
1231
|
+
|
|
1232
|
+
if (!backup.version || !backup.agents || !Array.isArray(backup.agents)) {
|
|
1233
|
+
alert('无效的备份文件格式');
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
await processImport(backup);
|
|
1238
|
+
} catch (e) {
|
|
1239
|
+
alert('读取备份文件失败: ' + e.message);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
async function processImport(backup) {
|
|
1244
|
+
// Get current agents
|
|
1245
|
+
const agentsData = await api('GET', '/api/agents');
|
|
1246
|
+
const currentAgents = agentsData.agents || [];
|
|
1247
|
+
|
|
1248
|
+
// Get current agents' keys for comparison
|
|
1249
|
+
const currentAgentKeys = {};
|
|
1250
|
+
for (const agent of currentAgents) {
|
|
1251
|
+
try {
|
|
1252
|
+
const keys = await api('GET', `/api/identity/keys?agent_id=${agent.agent_id}`);
|
|
1253
|
+
currentAgentKeys[agent.agent_id] = {
|
|
1254
|
+
agent_id: agent.agent_id,
|
|
1255
|
+
nickname: agent.nickname || agent.agent_id,
|
|
1256
|
+
signing_public_key: keys.signing_public_key,
|
|
1257
|
+
fingerprint: keys.fingerprint
|
|
1258
|
+
};
|
|
1259
|
+
} catch(e) {
|
|
1260
|
+
currentAgentKeys[agent.agent_id] = {
|
|
1261
|
+
agent_id: agent.agent_id,
|
|
1262
|
+
nickname: agent.nickname || agent.agent_id
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
// Check which backup agents don't match current agents
|
|
1268
|
+
const matchedAgents = []; // { backupAgent, currentAgent }
|
|
1269
|
+
const unmatchedBackupAgents = []; // backup agents with no key match
|
|
1270
|
+
|
|
1271
|
+
for (const ba of backup.agents) {
|
|
1272
|
+
// Try to find matching current agent by signing_public_key or fingerprint
|
|
1273
|
+
let matched = null;
|
|
1274
|
+
for (const [cid, ck] of Object.entries(currentAgentKeys)) {
|
|
1275
|
+
if (ba.signing_public_key && ck.signing_public_key && ba.signing_public_key === ck.signing_public_key) {
|
|
1276
|
+
matched = ck;
|
|
1277
|
+
break;
|
|
1278
|
+
}
|
|
1279
|
+
if (ba.fingerprint && ck.fingerprint && ba.fingerprint === ck.fingerprint) {
|
|
1280
|
+
matched = ck;
|
|
1281
|
+
break;
|
|
1282
|
+
}
|
|
1283
|
+
// Also match by agent_id
|
|
1284
|
+
if (ba.agent_id === cid) {
|
|
1285
|
+
matched = ck;
|
|
1286
|
+
break;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
if (matched) {
|
|
1291
|
+
matchedAgents.push({ backupAgent: ba, currentAgent: matched });
|
|
1292
|
+
} else {
|
|
1293
|
+
unmatchedBackupAgents.push(ba);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// If there are unmatched agents, show matching modal
|
|
1298
|
+
if (unmatchedBackupAgents.length > 0 && Object.keys(currentAgentKeys).length > 0) {
|
|
1299
|
+
pendingBackup = backup;
|
|
1300
|
+
pendingMatchMap = {};
|
|
1301
|
+
|
|
1302
|
+
// Auto-add matched ones
|
|
1303
|
+
for (const m of matchedAgents) {
|
|
1304
|
+
pendingMatchMap[m.backupAgent.agent_id] = m.currentAgent.agent_id;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
showBackupMatchModal(unmatchedBackupAgents, Object.values(currentAgentKeys));
|
|
1308
|
+
} else {
|
|
1309
|
+
// All matched or no current agents, proceed directly
|
|
1310
|
+
await doImportBackup(backup, matchedAgents.reduce((map, m) => { map[m.backupAgent.agent_id] = m.currentAgent.agent_id; return map; }, {}));
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function showBackupMatchModal(unmatchedAgents, currentAgentList) {
|
|
1315
|
+
const container = document.getElementById('backupMatchList');
|
|
1316
|
+
container.innerHTML = '';
|
|
1317
|
+
|
|
1318
|
+
for (const ba of unmatchedAgents) {
|
|
1319
|
+
const row = document.createElement('div');
|
|
1320
|
+
row.className = 'key-match-row';
|
|
1321
|
+
row.innerHTML = `
|
|
1322
|
+
<label title="${ba.agent_id}">${ba.nickname || ba.agent_id}</label>
|
|
1323
|
+
<select id="match-${ba.agent_id}" data-backup-agent="${ba.agent_id}">
|
|
1324
|
+
<option value="">-- 不导入 --</option>
|
|
1325
|
+
${currentAgentList.map(ca => `<option value="${ca.agent_id}">${ca.nickname || ca.agent_id}</option>`).join('')}
|
|
1326
|
+
</select>
|
|
1327
|
+
`;
|
|
1328
|
+
container.appendChild(row);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
showModal('backupMatch');
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
async function applyBackupMatch() {
|
|
1335
|
+
// Collect user selections
|
|
1336
|
+
const selects = document.querySelectorAll('#backupMatchList select');
|
|
1337
|
+
for (const sel of selects) {
|
|
1338
|
+
const backupAgentId = sel.dataset.backupAgent;
|
|
1339
|
+
const chosenAgentId = sel.value;
|
|
1340
|
+
if (chosenAgentId) {
|
|
1341
|
+
pendingMatchMap[backupAgentId] = chosenAgentId;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
hideModal('backupMatch');
|
|
1346
|
+
await doImportBackup(pendingBackup, pendingMatchMap);
|
|
1347
|
+
pendingBackup = null;
|
|
1348
|
+
pendingMatchMap = {};
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
async function skipBackupMatch() {
|
|
1352
|
+
hideModal('backupMatch');
|
|
1353
|
+
// Import only the already-matched agents (without the unmatched ones)
|
|
1354
|
+
await doImportBackup(pendingBackup, pendingMatchMap);
|
|
1355
|
+
pendingBackup = null;
|
|
1356
|
+
pendingMatchMap = {};
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
async function doImportBackup(backup, matchMap) {
|
|
1360
|
+
try {
|
|
1361
|
+
showToast('正在导入备份...', '', 8000);
|
|
1362
|
+
let importedCount = 0;
|
|
1363
|
+
let importedMessages = 0;
|
|
1364
|
+
|
|
1365
|
+
for (const ba of backup.agents) {
|
|
1366
|
+
const targetAgentId = matchMap[ba.agent_id];
|
|
1367
|
+
if (!targetAgentId) continue;
|
|
1368
|
+
|
|
1369
|
+
// Import friends (via API if possible, or just save to cache)
|
|
1370
|
+
// The plugin uses local SQLite, so we store to localStorage cache
|
|
1371
|
+
for (const friend of (ba.friends || [])) {
|
|
1372
|
+
try {
|
|
1373
|
+
// Try to add friend via API
|
|
1374
|
+
await api('POST', '/api/friends/add-by-fingerprint', {
|
|
1375
|
+
agent_id: targetAgentId,
|
|
1376
|
+
fingerprint: friend.fingerprint,
|
|
1377
|
+
ai_name: friend.ai_name,
|
|
1378
|
+
ai_avatar: friend.ai_avatar
|
|
1379
|
+
});
|
|
1380
|
+
} catch(e) {
|
|
1381
|
+
// Friend might already exist, that's fine
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// Import chat messages to localStorage cache
|
|
1386
|
+
if (ba.chatData) {
|
|
1387
|
+
const chatCacheKey = `aicq_chat_${targetAgentId}`;
|
|
1388
|
+
try {
|
|
1389
|
+
const existing = JSON.parse(localStorage.getItem(chatCacheKey) || '{}');
|
|
1390
|
+
for (const [targetKey, messages] of Object.entries(ba.chatData)) {
|
|
1391
|
+
existing[targetKey] = messages;
|
|
1392
|
+
}
|
|
1393
|
+
localStorage.setItem(chatCacheKey, JSON.stringify(existing));
|
|
1394
|
+
importedMessages += Object.keys(ba.chatData).length;
|
|
1395
|
+
} catch(e) {}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
importedCount++;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// Refresh UI
|
|
1402
|
+
await loadAgents();
|
|
1403
|
+
await loadFriends();
|
|
1404
|
+
await loadGroups();
|
|
1405
|
+
|
|
1406
|
+
hideModal('settings');
|
|
1407
|
+
showToast(`导入完成!已导入 ${importedCount} 个 Agent 的数据,${importedMessages} 个会话记录。`, '', 6000);
|
|
1408
|
+
} catch (e) {
|
|
1409
|
+
alert('导入失败: ' + e.message);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
// ─── Init ───────────────────────────────────────────────────────────
|
|
1414
|
+
async function init() {
|
|
1415
|
+
await loadAgents();
|
|
1416
|
+
await loadFriends();
|
|
1417
|
+
await loadGroups();
|
|
1418
|
+
// Auto-select first agent
|
|
1419
|
+
const sel = document.getElementById('agentSelect');
|
|
1420
|
+
if (sel.options.length > 1) {
|
|
1421
|
+
currentAgentId = sel.options[1].value;
|
|
1422
|
+
sel.value = currentAgentId;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// Load chat cache from localStorage
|
|
1426
|
+
const cached = loadChatFromLocalStorage();
|
|
1427
|
+
if (cached && cached.currentAgentId) {
|
|
1428
|
+
// Restore cache if agent still exists
|
|
1429
|
+
const sel2 = document.getElementById('agentSelect');
|
|
1430
|
+
let agentExists = false;
|
|
1431
|
+
for (let i = 0; i < sel2.options.length; i++) {
|
|
1432
|
+
if (sel2.options[i].value === cached.currentAgentId) {
|
|
1433
|
+
agentExists = true;
|
|
1434
|
+
break;
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
if (agentExists) {
|
|
1438
|
+
currentAgentId = cached.currentAgentId;
|
|
1439
|
+
sel2.value = currentAgentId;
|
|
1440
|
+
if (cached.currentTarget) {
|
|
1441
|
+
currentTarget = cached.currentTarget;
|
|
1442
|
+
await loadFriends();
|
|
1443
|
+
await loadGroups();
|
|
1444
|
+
// Will load messages from server, which is more reliable
|
|
1445
|
+
if (currentTarget) {
|
|
1446
|
+
selectTarget(currentTarget.id, currentTarget.name, currentTarget.type, currentTarget.isOnline, currentTarget.silent);
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// Show login reminder toast
|
|
1453
|
+
showToast('提醒:聊天记录仅保存在本地浏览器中,清除缓存数据或删除浏览器内容将丢失', 'warning', 8000);
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
init();
|
|
1457
|
+
|
|
1458
|
+
// Periodic refresh and auto-save
|
|
1459
|
+
setInterval(async () => {
|
|
1460
|
+
if (currentAgentId) {
|
|
1461
|
+
await loadFriends();
|
|
1462
|
+
await loadGroups();
|
|
1463
|
+
saveChatToLocalStorage();
|
|
1464
|
+
}
|
|
1465
|
+
}, 30000);
|
|
1466
|
+
</script>
|
|
1467
|
+
</body>
|
|
1468
|
+
</html>
|