superagent-ai-agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +27 -0
- package/LICENSE +21 -0
- package/README.md +147 -0
- package/README.zh-CN.md +147 -0
- package/agent-config.json +4 -0
- package/agent-persona.md +67 -0
- package/bin/postinstall.js +26 -0
- package/bin/superagent.js +283 -0
- package/package.json +43 -0
- package/src/agent.js +114 -0
- package/src/config.js +103 -0
- package/src/public/index.html +1889 -0
- package/src/query.js +239 -0
- package/src/server.js +303 -0
- package/src/web-tool.js +174 -0
|
@@ -0,0 +1,1889 @@
|
|
|
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" />
|
|
6
|
+
<link id="favicon" rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
|
+
<title>个人助理</title>
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
color-scheme: light dark;
|
|
11
|
+
/* Luna 主题:薰衣草紫 + 月光粉 */
|
|
12
|
+
--primary: #9b87d4;
|
|
13
|
+
--primary-hover: #7d68b8;
|
|
14
|
+
--primary-soft: #f3eefe;
|
|
15
|
+
--primary-soft-2: #e5dcf5;
|
|
16
|
+
--accent-pink: #f0c2dc;
|
|
17
|
+
--bg: #faf8fd;
|
|
18
|
+
--surface: #ffffff;
|
|
19
|
+
--surface-elev: #ffffff;
|
|
20
|
+
--border: #ece8f3;
|
|
21
|
+
--border-strong: #d8d2e5;
|
|
22
|
+
--text: #1d1d1f;
|
|
23
|
+
--muted: #8e8b9a;
|
|
24
|
+
--tool-bg: #f5f3f9;
|
|
25
|
+
--shadow-sm: 0 1px 3px rgba(155, 135, 212, 0.06);
|
|
26
|
+
--shadow-md: 0 6px 20px rgba(155, 135, 212, 0.10);
|
|
27
|
+
--radius-bubble: 18px;
|
|
28
|
+
--radius-card: 14px;
|
|
29
|
+
}
|
|
30
|
+
@media (prefers-color-scheme: dark) {
|
|
31
|
+
:root {
|
|
32
|
+
--primary: #b29ce0;
|
|
33
|
+
--primary-hover: #c5b0ee;
|
|
34
|
+
--primary-soft: #2a233a;
|
|
35
|
+
--primary-soft-2: #38304a;
|
|
36
|
+
--accent-pink: #c39ab8;
|
|
37
|
+
--bg: #1a1820;
|
|
38
|
+
--surface: #25222e;
|
|
39
|
+
--surface-elev: #2c2935;
|
|
40
|
+
--border: #322f3d;
|
|
41
|
+
--border-strong: #403d4f;
|
|
42
|
+
--text: #f2f0f5;
|
|
43
|
+
--muted: #a09cae;
|
|
44
|
+
--tool-bg: #2a2735;
|
|
45
|
+
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
|
|
46
|
+
--shadow-md: 0 6px 20px rgba(0, 0, 0, 0.4);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
* { box-sizing: border-box; }
|
|
51
|
+
html, body { height: 100%; margin: 0; }
|
|
52
|
+
body {
|
|
53
|
+
font: 14px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", system-ui, sans-serif;
|
|
54
|
+
color: var(--text);
|
|
55
|
+
background: var(--bg);
|
|
56
|
+
display: flex;
|
|
57
|
+
flex-direction: row;
|
|
58
|
+
}
|
|
59
|
+
.main-col {
|
|
60
|
+
flex: 1;
|
|
61
|
+
min-width: 0;
|
|
62
|
+
display: flex;
|
|
63
|
+
flex-direction: column;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* ------- Sidebar (会话历史) ------- */
|
|
67
|
+
.sidebar {
|
|
68
|
+
width: 240px;
|
|
69
|
+
min-width: 240px;
|
|
70
|
+
border-right: 1px solid var(--border);
|
|
71
|
+
background: var(--surface);
|
|
72
|
+
display: flex;
|
|
73
|
+
flex-direction: column;
|
|
74
|
+
overflow: hidden;
|
|
75
|
+
}
|
|
76
|
+
.sidebar-header {
|
|
77
|
+
padding: 14px 16px;
|
|
78
|
+
border-bottom: 1px solid var(--border);
|
|
79
|
+
font-weight: 700;
|
|
80
|
+
font-size: 13px;
|
|
81
|
+
color: var(--text);
|
|
82
|
+
display: flex;
|
|
83
|
+
align-items: center;
|
|
84
|
+
justify-content: space-between;
|
|
85
|
+
}
|
|
86
|
+
.sidebar-header .count {
|
|
87
|
+
font-weight: 400;
|
|
88
|
+
font-size: 11px;
|
|
89
|
+
color: var(--muted);
|
|
90
|
+
display: hidden;
|
|
91
|
+
}
|
|
92
|
+
.sidebar-newchat {
|
|
93
|
+
padding: 10px 12px 6px;
|
|
94
|
+
}
|
|
95
|
+
.sidebar-newchat button {
|
|
96
|
+
width: 100%;
|
|
97
|
+
display: flex;
|
|
98
|
+
align-items: center;
|
|
99
|
+
justify-content: center;
|
|
100
|
+
gap: 8px;
|
|
101
|
+
padding: 10px 12px;
|
|
102
|
+
border: 0;
|
|
103
|
+
border-radius: 10px;
|
|
104
|
+
background: linear-gradient(135deg, var(--primary), var(--accent-pink));
|
|
105
|
+
color: #fff;
|
|
106
|
+
font: inherit;
|
|
107
|
+
font-weight: 600;
|
|
108
|
+
font-size: 13px;
|
|
109
|
+
cursor: pointer;
|
|
110
|
+
box-shadow: 0 2px 8px rgba(155, 135, 212, 0.35);
|
|
111
|
+
transition: transform 0.1s, box-shadow 0.15s, filter 0.15s;
|
|
112
|
+
}
|
|
113
|
+
.sidebar-newchat button:hover { filter: brightness(1.05); box-shadow: 0 4px 12px rgba(155, 135, 212, 0.45); }
|
|
114
|
+
.sidebar-newchat button:active { transform: scale(0.98); }
|
|
115
|
+
.sidebar-newchat button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
116
|
+
.sidebar-newchat svg { width: 14px; height: 14px; }
|
|
117
|
+
.sidebar-list {
|
|
118
|
+
flex: 1;
|
|
119
|
+
overflow-y: auto;
|
|
120
|
+
padding: 8px;
|
|
121
|
+
}
|
|
122
|
+
.sidebar-empty {
|
|
123
|
+
color: var(--muted);
|
|
124
|
+
text-align: center;
|
|
125
|
+
font-size: 12px;
|
|
126
|
+
padding: 28px 16px;
|
|
127
|
+
}
|
|
128
|
+
.session-item {
|
|
129
|
+
display: flex;
|
|
130
|
+
align-items: center;
|
|
131
|
+
gap: 6px;
|
|
132
|
+
padding: 10px 12px;
|
|
133
|
+
border-radius: 8px;
|
|
134
|
+
cursor: pointer;
|
|
135
|
+
transition: background 0.12s;
|
|
136
|
+
margin-bottom: 4px;
|
|
137
|
+
border: 1px solid transparent;
|
|
138
|
+
}
|
|
139
|
+
.session-item:hover { background: var(--primary-soft); }
|
|
140
|
+
.session-item.active {
|
|
141
|
+
background: var(--primary-soft);
|
|
142
|
+
border-color: var(--primary-soft-2);
|
|
143
|
+
}
|
|
144
|
+
.session-item-body { flex: 1; min-width: 0; }
|
|
145
|
+
.session-item-title {
|
|
146
|
+
font-size: 13px;
|
|
147
|
+
font-weight: 500;
|
|
148
|
+
overflow: hidden;
|
|
149
|
+
text-overflow: ellipsis;
|
|
150
|
+
white-space: nowrap;
|
|
151
|
+
color: var(--text);
|
|
152
|
+
}
|
|
153
|
+
.session-item-time {
|
|
154
|
+
font-size: 11px;
|
|
155
|
+
color: var(--muted);
|
|
156
|
+
margin-top: 2px;
|
|
157
|
+
}
|
|
158
|
+
.session-item-del {
|
|
159
|
+
flex-shrink: 0;
|
|
160
|
+
width: 22px;
|
|
161
|
+
height: 22px;
|
|
162
|
+
border: 0;
|
|
163
|
+
background: transparent;
|
|
164
|
+
color: var(--muted);
|
|
165
|
+
border-radius: 4px;
|
|
166
|
+
cursor: pointer;
|
|
167
|
+
display: grid;
|
|
168
|
+
place-items: center;
|
|
169
|
+
font-size: 16px;
|
|
170
|
+
opacity: 0;
|
|
171
|
+
transition: opacity 0.12s, background 0.12s, color 0.12s;
|
|
172
|
+
line-height: 1;
|
|
173
|
+
}
|
|
174
|
+
.session-item:hover .session-item-del,
|
|
175
|
+
.session-item.active .session-item-del { opacity: 1; }
|
|
176
|
+
.session-item-del:hover { background: var(--border-strong); color: var(--text); }
|
|
177
|
+
|
|
178
|
+
/* ------- App bar ------- */
|
|
179
|
+
.appbar {
|
|
180
|
+
display: flex;
|
|
181
|
+
align-items: center;
|
|
182
|
+
justify-content: space-between;
|
|
183
|
+
padding: 12px 24px;
|
|
184
|
+
background: var(--surface);
|
|
185
|
+
border-bottom: 1px solid var(--border);
|
|
186
|
+
box-shadow: var(--shadow-sm);
|
|
187
|
+
z-index: 2;
|
|
188
|
+
}
|
|
189
|
+
.brand { display: flex; align-items: center; gap: 10px; }
|
|
190
|
+
.brand .logo {
|
|
191
|
+
min-width: 30px;
|
|
192
|
+
height: 30px;
|
|
193
|
+
padding: 0 10px;
|
|
194
|
+
border-radius: 9px;
|
|
195
|
+
background: linear-gradient(135deg, var(--primary), var(--accent-pink));
|
|
196
|
+
display: grid;
|
|
197
|
+
place-items: center;
|
|
198
|
+
color: #fff;
|
|
199
|
+
font-weight: 800;
|
|
200
|
+
font-size: 13px;
|
|
201
|
+
white-space: nowrap;
|
|
202
|
+
box-shadow: 0 2px 8px rgba(155, 135, 212, 0.35);
|
|
203
|
+
}
|
|
204
|
+
.brand .title { font-weight: 700; font-size: 15px; letter-spacing: 0.02em; }
|
|
205
|
+
.brand .subtitle { color: var(--muted); font-size: 12px; margin-left: 6px; }
|
|
206
|
+
|
|
207
|
+
.appbar .meta { display: flex; align-items: center; gap: 8px; }
|
|
208
|
+
.session-pill {
|
|
209
|
+
font-size: 12px;
|
|
210
|
+
color: var(--muted);
|
|
211
|
+
padding: 4px 10px;
|
|
212
|
+
background: var(--bg);
|
|
213
|
+
border-radius: 999px;
|
|
214
|
+
border: 1px solid var(--border);
|
|
215
|
+
}
|
|
216
|
+
.session-pill.active { color: var(--primary); border-color: var(--primary-soft-2); background: var(--primary-soft); }
|
|
217
|
+
.icon-btn {
|
|
218
|
+
width: 32px; height: 32px;
|
|
219
|
+
border-radius: 8px;
|
|
220
|
+
border: 1px solid var(--border);
|
|
221
|
+
background: var(--surface);
|
|
222
|
+
color: var(--muted);
|
|
223
|
+
cursor: pointer;
|
|
224
|
+
display: grid; place-items: center;
|
|
225
|
+
transition: all 0.15s;
|
|
226
|
+
}
|
|
227
|
+
.icon-btn:hover { color: var(--primary); border-color: var(--primary); background: var(--primary-soft); }
|
|
228
|
+
.icon-btn svg { width: 16px; height: 16px; }
|
|
229
|
+
|
|
230
|
+
/* ------- Chat log ------- */
|
|
231
|
+
#log {
|
|
232
|
+
flex: 1;
|
|
233
|
+
overflow-y: auto;
|
|
234
|
+
padding: 28px 20px 12px;
|
|
235
|
+
}
|
|
236
|
+
.stack {
|
|
237
|
+
max-width: 820px;
|
|
238
|
+
margin: 0 auto;
|
|
239
|
+
display: flex;
|
|
240
|
+
flex-direction: column;
|
|
241
|
+
gap: 18px;
|
|
242
|
+
}
|
|
243
|
+
.empty {
|
|
244
|
+
text-align: center;
|
|
245
|
+
color: var(--muted);
|
|
246
|
+
padding: 60px 20px;
|
|
247
|
+
}
|
|
248
|
+
.empty .hint {
|
|
249
|
+
display: inline-block;
|
|
250
|
+
margin-top: 12px;
|
|
251
|
+
padding: 6px 12px;
|
|
252
|
+
background: var(--primary-soft);
|
|
253
|
+
color: var(--primary);
|
|
254
|
+
border-radius: 999px;
|
|
255
|
+
font-size: 12px;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/* ------- Messages ------- */
|
|
259
|
+
.msg {
|
|
260
|
+
display: flex;
|
|
261
|
+
gap: 10px;
|
|
262
|
+
align-items: flex-start;
|
|
263
|
+
animation: fadeIn 0.2s ease-out;
|
|
264
|
+
}
|
|
265
|
+
.msg.user { flex-direction: row-reverse; }
|
|
266
|
+
.avatar {
|
|
267
|
+
flex-shrink: 0;
|
|
268
|
+
min-width: 32px; height: 32px;
|
|
269
|
+
padding: 0 9px;
|
|
270
|
+
border-radius: 9px;
|
|
271
|
+
display: grid; place-items: center;
|
|
272
|
+
font-size: 12px;
|
|
273
|
+
font-weight: 700;
|
|
274
|
+
color: #fff;
|
|
275
|
+
white-space: nowrap;
|
|
276
|
+
letter-spacing: 0.02em;
|
|
277
|
+
}
|
|
278
|
+
.avatar.assistant { background: linear-gradient(135deg, var(--primary), var(--accent-pink)); }
|
|
279
|
+
.avatar.user { background: linear-gradient(135deg, #6b6680, #918aa5); }
|
|
280
|
+
|
|
281
|
+
.msg .content {
|
|
282
|
+
max-width: calc(100% - 50px);
|
|
283
|
+
display: flex;
|
|
284
|
+
flex-direction: column;
|
|
285
|
+
gap: 8px;
|
|
286
|
+
align-items: flex-start;
|
|
287
|
+
}
|
|
288
|
+
.msg.user .content { align-items: flex-end; }
|
|
289
|
+
|
|
290
|
+
.bubble {
|
|
291
|
+
padding: 12px 16px;
|
|
292
|
+
border-radius: var(--radius-bubble);
|
|
293
|
+
white-space: pre-wrap;
|
|
294
|
+
word-break: normal; /* 不要按字符断中文 */
|
|
295
|
+
overflow-wrap: break-word; /* 只在没办法时才断 */
|
|
296
|
+
line-height: 1.6;
|
|
297
|
+
box-shadow: var(--shadow-sm);
|
|
298
|
+
width: fit-content; /* 关键:按内容宽度,不被父级 flex 撑大也不被压缩 */
|
|
299
|
+
max-width: 100%;
|
|
300
|
+
}
|
|
301
|
+
/* 用户气泡:max-width 限制最大宽度(短消息按内容,长消息截到 560) */
|
|
302
|
+
.msg.user .bubble {
|
|
303
|
+
background: linear-gradient(135deg, var(--primary-soft), var(--primary-soft-2));
|
|
304
|
+
color: var(--text);
|
|
305
|
+
border: 1px solid var(--primary-soft-2);
|
|
306
|
+
border-top-right-radius: 6px;
|
|
307
|
+
max-width: min(560px, 100%);
|
|
308
|
+
}
|
|
309
|
+
.msg.assistant .bubble {
|
|
310
|
+
background: var(--surface);
|
|
311
|
+
border: 1px solid var(--border);
|
|
312
|
+
border-top-left-radius: 6px;
|
|
313
|
+
white-space: normal; /* assistant: 走 markdown,由 HTML 结构决定换行 */
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/* ------- Markdown rendering inside assistant bubbles ------- */
|
|
317
|
+
.msg.assistant .bubble > *:first-child { margin-top: 0; }
|
|
318
|
+
.msg.assistant .bubble > *:last-child { margin-bottom: 0; }
|
|
319
|
+
.msg.assistant .bubble p { margin: 0.5em 0; }
|
|
320
|
+
.msg.assistant .bubble h1,
|
|
321
|
+
.msg.assistant .bubble h2,
|
|
322
|
+
.msg.assistant .bubble h3,
|
|
323
|
+
.msg.assistant .bubble h4 {
|
|
324
|
+
margin: 0.8em 0 0.4em;
|
|
325
|
+
line-height: 1.3;
|
|
326
|
+
}
|
|
327
|
+
.msg.assistant .bubble h1 { font-size: 1.3em; }
|
|
328
|
+
.msg.assistant .bubble h2 { font-size: 1.18em; }
|
|
329
|
+
.msg.assistant .bubble h3 { font-size: 1.08em; }
|
|
330
|
+
.msg.assistant .bubble h4 { font-size: 1em; }
|
|
331
|
+
.msg.assistant .bubble ul,
|
|
332
|
+
.msg.assistant .bubble ol {
|
|
333
|
+
margin: 0.4em 0;
|
|
334
|
+
padding-left: 1.6em;
|
|
335
|
+
}
|
|
336
|
+
.msg.assistant .bubble li { margin: 0.18em 0; }
|
|
337
|
+
.msg.assistant .bubble li > p { margin: 0.18em 0; }
|
|
338
|
+
.msg.assistant .bubble code {
|
|
339
|
+
background: var(--tool-bg);
|
|
340
|
+
border: 1px solid var(--border);
|
|
341
|
+
border-radius: 4px;
|
|
342
|
+
padding: 1px 5px;
|
|
343
|
+
font-family: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, monospace;
|
|
344
|
+
font-size: 0.9em;
|
|
345
|
+
}
|
|
346
|
+
.msg.assistant .bubble pre {
|
|
347
|
+
background: var(--tool-bg);
|
|
348
|
+
border: 1px solid var(--border);
|
|
349
|
+
border-radius: 8px;
|
|
350
|
+
padding: 10px 12px;
|
|
351
|
+
overflow-x: auto;
|
|
352
|
+
margin: 0.6em 0;
|
|
353
|
+
}
|
|
354
|
+
.msg.assistant .bubble pre code {
|
|
355
|
+
background: none;
|
|
356
|
+
border: 0;
|
|
357
|
+
padding: 0;
|
|
358
|
+
font-size: 0.88em;
|
|
359
|
+
line-height: 1.5;
|
|
360
|
+
}
|
|
361
|
+
.msg.assistant .bubble blockquote {
|
|
362
|
+
margin: 0.5em 0;
|
|
363
|
+
padding: 4px 12px;
|
|
364
|
+
border-left: 3px solid var(--border-strong);
|
|
365
|
+
color: var(--muted);
|
|
366
|
+
}
|
|
367
|
+
.msg.assistant .bubble hr {
|
|
368
|
+
border: 0;
|
|
369
|
+
border-top: 1px solid var(--border);
|
|
370
|
+
margin: 0.8em 0;
|
|
371
|
+
}
|
|
372
|
+
.msg.assistant .bubble a {
|
|
373
|
+
color: var(--primary);
|
|
374
|
+
text-decoration: none;
|
|
375
|
+
border-bottom: 1px dashed var(--primary);
|
|
376
|
+
}
|
|
377
|
+
.msg.assistant .bubble a:hover { border-bottom-style: solid; }
|
|
378
|
+
.msg.assistant .bubble table {
|
|
379
|
+
border-collapse: collapse;
|
|
380
|
+
margin: 0.5em 0;
|
|
381
|
+
font-size: 0.95em;
|
|
382
|
+
}
|
|
383
|
+
.msg.assistant .bubble th,
|
|
384
|
+
.msg.assistant .bubble td {
|
|
385
|
+
border: 1px solid var(--border);
|
|
386
|
+
padding: 4px 10px;
|
|
387
|
+
}
|
|
388
|
+
.msg.assistant .bubble th {
|
|
389
|
+
background: var(--tool-bg);
|
|
390
|
+
font-weight: 600;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/* ------- Tool blocks ------- */
|
|
394
|
+
.tool {
|
|
395
|
+
background: var(--tool-bg);
|
|
396
|
+
border: 1px solid var(--border);
|
|
397
|
+
border-radius: var(--radius-card);
|
|
398
|
+
padding: 8px 12px;
|
|
399
|
+
font-family: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, monospace;
|
|
400
|
+
font-size: 12px;
|
|
401
|
+
max-width: 100%;
|
|
402
|
+
width: 100%;
|
|
403
|
+
}
|
|
404
|
+
.tool summary {
|
|
405
|
+
cursor: pointer;
|
|
406
|
+
color: var(--muted);
|
|
407
|
+
display: flex;
|
|
408
|
+
align-items: center;
|
|
409
|
+
gap: 6px;
|
|
410
|
+
list-style: none;
|
|
411
|
+
}
|
|
412
|
+
.tool summary::-webkit-details-marker { display: none; }
|
|
413
|
+
.tool summary::before {
|
|
414
|
+
content: "▸";
|
|
415
|
+
font-size: 10px;
|
|
416
|
+
transition: transform 0.15s;
|
|
417
|
+
color: var(--muted);
|
|
418
|
+
}
|
|
419
|
+
.tool[open] summary::before { transform: rotate(90deg); }
|
|
420
|
+
.tool summary .tag {
|
|
421
|
+
font-weight: 600;
|
|
422
|
+
color: var(--text);
|
|
423
|
+
padding: 1px 6px;
|
|
424
|
+
background: var(--surface);
|
|
425
|
+
border-radius: 4px;
|
|
426
|
+
border: 1px solid var(--border);
|
|
427
|
+
}
|
|
428
|
+
.tool summary .tag.result { color: #2a8a4a; }
|
|
429
|
+
.tool summary .tag.error { color: var(--primary); }
|
|
430
|
+
.tool summary .tag.recall { color: #7a4ed1; background: #f1eafd; border-color: #ddd0f3; }
|
|
431
|
+
.tool summary .tag.plan { color: #2563eb; background: #eef4ff; border-color: #cfe0ff; }
|
|
432
|
+
.tool summary .tag.reflection { color: #b9740f; background: #fdf3e6; border-color: #f0dcb6; }
|
|
433
|
+
|
|
434
|
+
/* ------- Plan / Reflection cards ------- */
|
|
435
|
+
.plan-steps { margin: 8px 0 0; padding: 0; list-style: none; }
|
|
436
|
+
.plan-steps li {
|
|
437
|
+
display: flex;
|
|
438
|
+
gap: 8px;
|
|
439
|
+
padding: 5px 0;
|
|
440
|
+
border-bottom: 1px dashed var(--border);
|
|
441
|
+
font-size: 12.5px;
|
|
442
|
+
line-height: 1.45;
|
|
443
|
+
}
|
|
444
|
+
.plan-steps li:last-child { border-bottom: 0; }
|
|
445
|
+
.plan-steps .step-num {
|
|
446
|
+
flex-shrink: 0;
|
|
447
|
+
min-width: 18px; height: 18px;
|
|
448
|
+
border-radius: 50%;
|
|
449
|
+
background: var(--primary);
|
|
450
|
+
color: #fff;
|
|
451
|
+
font-size: 10px;
|
|
452
|
+
font-weight: 700;
|
|
453
|
+
display: grid; place-items: center;
|
|
454
|
+
margin-top: 1px;
|
|
455
|
+
}
|
|
456
|
+
.plan-steps .step-body { flex: 1; min-width: 0; }
|
|
457
|
+
.plan-steps .step-goal { color: var(--text); }
|
|
458
|
+
.plan-steps .step-verify {
|
|
459
|
+
color: var(--muted);
|
|
460
|
+
font-size: 11px;
|
|
461
|
+
margin-top: 2px;
|
|
462
|
+
}
|
|
463
|
+
.reflection-failure {
|
|
464
|
+
margin: 6px 0 0;
|
|
465
|
+
padding: 8px 10px;
|
|
466
|
+
background: var(--surface);
|
|
467
|
+
border: 1px solid var(--border);
|
|
468
|
+
border-left: 3px solid #b9740f;
|
|
469
|
+
border-radius: 6px;
|
|
470
|
+
font-size: 11.5px;
|
|
471
|
+
color: var(--muted);
|
|
472
|
+
white-space: pre-wrap;
|
|
473
|
+
max-height: 160px;
|
|
474
|
+
overflow-y: auto;
|
|
475
|
+
}
|
|
476
|
+
@media (prefers-color-scheme: dark) {
|
|
477
|
+
.tool summary .tag.plan { background: #1e2a44; border-color: #2d3e63; }
|
|
478
|
+
.tool summary .tag.reflection { background: #2e2517; border-color: #4a3a22; }
|
|
479
|
+
.reflection-failure { border-left-color: #d4973a; }
|
|
480
|
+
}
|
|
481
|
+
.tool pre {
|
|
482
|
+
margin: 8px 0 0;
|
|
483
|
+
white-space: pre-wrap;
|
|
484
|
+
word-wrap: break-word;
|
|
485
|
+
max-height: 320px;
|
|
486
|
+
overflow-y: auto;
|
|
487
|
+
color: var(--text);
|
|
488
|
+
background: var(--surface);
|
|
489
|
+
padding: 8px 10px;
|
|
490
|
+
border-radius: 6px;
|
|
491
|
+
border: 1px solid var(--border);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/* ------- Error ------- */
|
|
495
|
+
.err {
|
|
496
|
+
color: var(--primary);
|
|
497
|
+
background: var(--primary-soft);
|
|
498
|
+
border: 1px solid var(--primary-soft-2);
|
|
499
|
+
border-radius: var(--radius-card);
|
|
500
|
+
padding: 8px 12px;
|
|
501
|
+
font-size: 13px;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/* ------- Thinking indicator ------- */
|
|
505
|
+
.thinking-bubble {
|
|
506
|
+
padding: 14px 16px;
|
|
507
|
+
background: var(--surface);
|
|
508
|
+
border: 1px solid var(--border);
|
|
509
|
+
border-radius: var(--radius-bubble);
|
|
510
|
+
border-top-left-radius: 4px;
|
|
511
|
+
box-shadow: var(--shadow-sm);
|
|
512
|
+
display: inline-flex;
|
|
513
|
+
align-items: center;
|
|
514
|
+
gap: 6px;
|
|
515
|
+
min-height: 40px;
|
|
516
|
+
}
|
|
517
|
+
.thinking-bubble .dot {
|
|
518
|
+
width: 7px; height: 7px;
|
|
519
|
+
border-radius: 50%;
|
|
520
|
+
background: var(--primary);
|
|
521
|
+
opacity: 0.4;
|
|
522
|
+
animation: dotBounce 1.2s infinite ease-in-out;
|
|
523
|
+
}
|
|
524
|
+
.thinking-bubble .dot:nth-child(2) { animation-delay: 0.15s; }
|
|
525
|
+
.thinking-bubble .dot:nth-child(3) { animation-delay: 0.3s; }
|
|
526
|
+
.thinking-label {
|
|
527
|
+
margin-left: 8px;
|
|
528
|
+
font-size: 12px;
|
|
529
|
+
color: var(--muted);
|
|
530
|
+
font-style: italic;
|
|
531
|
+
}
|
|
532
|
+
/* 思考内容展示 */
|
|
533
|
+
.thinking-block {
|
|
534
|
+
background: var(--tool-bg); border: 1px solid var(--border);
|
|
535
|
+
border-radius: 10px; padding: 0; margin: 6px 0; font-size: 0.82rem;
|
|
536
|
+
overflow: hidden;
|
|
537
|
+
}
|
|
538
|
+
.thinking-block summary {
|
|
539
|
+
cursor: pointer; padding: 6px 12px; color: var(--muted);
|
|
540
|
+
font-style: italic; user-select: none; list-style: none;
|
|
541
|
+
}
|
|
542
|
+
.thinking-block summary::before { content: '💭 '; }
|
|
543
|
+
.thinking-block summary::marker { display: none; }
|
|
544
|
+
.thinking-block[open] summary { border-bottom: 1px solid var(--border); }
|
|
545
|
+
.thinking-block .thinking-content {
|
|
546
|
+
padding: 8px 12px; color: var(--text); white-space: pre-wrap;
|
|
547
|
+
word-break: break-word; max-height: 300px; overflow-y: auto;
|
|
548
|
+
font-size: 0.8rem; line-height: 1.5; opacity: 0.85;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
@keyframes dotBounce {
|
|
552
|
+
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
|
553
|
+
30% { transform: translateY(-5px); opacity: 1; }
|
|
554
|
+
}
|
|
555
|
+
@keyframes fadeIn {
|
|
556
|
+
from { opacity: 0; transform: translateY(4px); }
|
|
557
|
+
to { opacity: 1; transform: translateY(0); }
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/* ------- Composer ------- */
|
|
561
|
+
.composer {
|
|
562
|
+
background: transparent;
|
|
563
|
+
padding: 12px 20px 20px;
|
|
564
|
+
}
|
|
565
|
+
.composer form {
|
|
566
|
+
max-width: 820px;
|
|
567
|
+
margin: 0 auto;
|
|
568
|
+
}
|
|
569
|
+
.composer-card {
|
|
570
|
+
background: var(--surface);
|
|
571
|
+
border: 1px solid var(--border);
|
|
572
|
+
border-radius: 18px;
|
|
573
|
+
padding: 8px;
|
|
574
|
+
box-shadow: var(--shadow-md);
|
|
575
|
+
transition: border-color 0.15s, box-shadow 0.15s;
|
|
576
|
+
}
|
|
577
|
+
.composer-card:focus-within {
|
|
578
|
+
border-color: var(--primary);
|
|
579
|
+
box-shadow: 0 4px 18px rgba(231, 76, 60, 0.15);
|
|
580
|
+
}
|
|
581
|
+
.composer-card.dragover {
|
|
582
|
+
border-color: var(--primary);
|
|
583
|
+
background: var(--primary-soft);
|
|
584
|
+
}
|
|
585
|
+
.composer-row {
|
|
586
|
+
display: flex;
|
|
587
|
+
align-items: flex-end;
|
|
588
|
+
gap: 8px;
|
|
589
|
+
padding-left: 8px;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/* ---- pending image previews above textarea ---- */
|
|
593
|
+
.img-preview-strip {
|
|
594
|
+
display: flex;
|
|
595
|
+
flex-wrap: wrap;
|
|
596
|
+
gap: 6px;
|
|
597
|
+
padding: 4px 4px 8px 8px;
|
|
598
|
+
}
|
|
599
|
+
.img-preview-strip:empty { display: none; }
|
|
600
|
+
.img-preview {
|
|
601
|
+
position: relative;
|
|
602
|
+
width: 60px; height: 60px;
|
|
603
|
+
border-radius: 8px;
|
|
604
|
+
overflow: hidden;
|
|
605
|
+
border: 1px solid var(--border);
|
|
606
|
+
background: var(--tool-bg);
|
|
607
|
+
}
|
|
608
|
+
.img-preview img {
|
|
609
|
+
width: 100%; height: 100%;
|
|
610
|
+
object-fit: cover;
|
|
611
|
+
display: block;
|
|
612
|
+
}
|
|
613
|
+
.img-preview .remove-btn {
|
|
614
|
+
position: absolute;
|
|
615
|
+
top: 2px; right: 2px;
|
|
616
|
+
width: 18px; height: 18px;
|
|
617
|
+
border: 0;
|
|
618
|
+
border-radius: 50%;
|
|
619
|
+
background: rgba(0,0,0,0.6);
|
|
620
|
+
color: #fff;
|
|
621
|
+
font-size: 12px;
|
|
622
|
+
line-height: 1;
|
|
623
|
+
cursor: pointer;
|
|
624
|
+
display: grid;
|
|
625
|
+
place-items: center;
|
|
626
|
+
padding: 0;
|
|
627
|
+
}
|
|
628
|
+
.img-preview .remove-btn:hover { background: var(--primary); }
|
|
629
|
+
|
|
630
|
+
/* ---- thumbnails in user message bubbles ---- */
|
|
631
|
+
.msg.user .img-strip {
|
|
632
|
+
display: flex;
|
|
633
|
+
flex-wrap: wrap;
|
|
634
|
+
gap: 6px;
|
|
635
|
+
justify-content: flex-end;
|
|
636
|
+
max-width: 100%;
|
|
637
|
+
}
|
|
638
|
+
.msg.user .img-strip img {
|
|
639
|
+
width: 120px; height: 120px;
|
|
640
|
+
object-fit: cover;
|
|
641
|
+
border-radius: 10px;
|
|
642
|
+
border: 1px solid var(--border);
|
|
643
|
+
cursor: zoom-in;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/* ---- attach button ---- */
|
|
647
|
+
.attach-btn {
|
|
648
|
+
width: 36px; height: 36px;
|
|
649
|
+
border: 0;
|
|
650
|
+
border-radius: 12px;
|
|
651
|
+
background: transparent;
|
|
652
|
+
color: var(--muted);
|
|
653
|
+
cursor: pointer;
|
|
654
|
+
display: grid; place-items: center;
|
|
655
|
+
transition: color 0.15s, background 0.15s;
|
|
656
|
+
flex-shrink: 0;
|
|
657
|
+
}
|
|
658
|
+
.attach-btn:hover { color: var(--primary); background: var(--primary-soft); }
|
|
659
|
+
.attach-btn svg { width: 18px; height: 18px; }
|
|
660
|
+
.composer textarea {
|
|
661
|
+
flex: 1;
|
|
662
|
+
resize: none;
|
|
663
|
+
background: transparent;
|
|
664
|
+
border: 0;
|
|
665
|
+
outline: 0;
|
|
666
|
+
font: inherit;
|
|
667
|
+
color: inherit;
|
|
668
|
+
padding: 8px 0;
|
|
669
|
+
max-height: 200px;
|
|
670
|
+
line-height: 1.5;
|
|
671
|
+
}
|
|
672
|
+
.composer textarea::placeholder { color: var(--muted); }
|
|
673
|
+
.send-btn {
|
|
674
|
+
width: 36px; height: 36px;
|
|
675
|
+
border: 0;
|
|
676
|
+
border-radius: 12px;
|
|
677
|
+
background: var(--primary);
|
|
678
|
+
color: #fff;
|
|
679
|
+
cursor: pointer;
|
|
680
|
+
display: grid; place-items: center;
|
|
681
|
+
transition: background 0.15s, transform 0.1s;
|
|
682
|
+
flex-shrink: 0;
|
|
683
|
+
}
|
|
684
|
+
.send-btn:hover:not(:disabled) { background: var(--primary-hover); }
|
|
685
|
+
.send-btn:active:not(:disabled) { transform: scale(0.94); }
|
|
686
|
+
.send-btn:disabled { background: var(--border-strong); cursor: not-allowed; }
|
|
687
|
+
.send-btn svg { width: 16px; height: 16px; }
|
|
688
|
+
.send-btn svg.stop-icon { display: none; }
|
|
689
|
+
.send-btn.busy { animation: pulse 1.5s infinite; }
|
|
690
|
+
.send-btn.busy svg.send-icon { display: none; }
|
|
691
|
+
.send-btn.busy svg.stop-icon { display: block; width: 12px; height: 12px; }
|
|
692
|
+
@keyframes pulse {
|
|
693
|
+
0%, 100% { box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.5); }
|
|
694
|
+
50% { box-shadow: 0 0 0 6px rgba(231, 76, 60, 0); }
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
.composer-tip {
|
|
698
|
+
text-align: center;
|
|
699
|
+
font-size: 11px;
|
|
700
|
+
color: var(--muted);
|
|
701
|
+
margin-top: 8px;
|
|
702
|
+
}
|
|
703
|
+
.composer-tip kbd {
|
|
704
|
+
background: var(--surface);
|
|
705
|
+
border: 1px solid var(--border);
|
|
706
|
+
border-radius: 3px;
|
|
707
|
+
padding: 1px 5px;
|
|
708
|
+
font-family: inherit;
|
|
709
|
+
font-size: 10px;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/* 队列条 */
|
|
713
|
+
.queue-strip { max-width: 820px; margin: 0 auto; }
|
|
714
|
+
.queue-strip:empty { display: none; }
|
|
715
|
+
.queue-item {
|
|
716
|
+
display: flex; align-items: center; gap: 8px;
|
|
717
|
+
background: var(--primary-soft); border: 1px solid var(--border);
|
|
718
|
+
border-radius: 10px; padding: 6px 12px; margin-bottom: 6px;
|
|
719
|
+
font-size: 0.85rem; color: var(--text); animation: fadeIn 0.15s;
|
|
720
|
+
}
|
|
721
|
+
.queue-item .q-badge {
|
|
722
|
+
flex-shrink: 0; font-size: 0.7rem; color: var(--muted);
|
|
723
|
+
background: var(--surface); border-radius: 4px; padding: 2px 6px;
|
|
724
|
+
}
|
|
725
|
+
.queue-item .q-text {
|
|
726
|
+
flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
727
|
+
outline: none; border: none; background: transparent; font: inherit; color: inherit;
|
|
728
|
+
}
|
|
729
|
+
.queue-item .q-text:focus {
|
|
730
|
+
white-space: normal; word-break: break-all;
|
|
731
|
+
background: var(--surface); border-radius: 4px; padding: 2px 4px;
|
|
732
|
+
}
|
|
733
|
+
.queue-item .q-del {
|
|
734
|
+
flex-shrink: 0; width: 20px; height: 20px; border: 0; background: transparent;
|
|
735
|
+
color: var(--muted); cursor: pointer; font-size: 14px; line-height: 20px; text-align: center;
|
|
736
|
+
border-radius: 50%; transition: background 0.1s;
|
|
737
|
+
}
|
|
738
|
+
.queue-item .q-del:hover { background: var(--primary-soft-2); color: var(--text); }
|
|
739
|
+
</style>
|
|
740
|
+
</head>
|
|
741
|
+
<body>
|
|
742
|
+
<aside class="sidebar">
|
|
743
|
+
<div class="sidebar-newchat">
|
|
744
|
+
<button id="new-chat-btn" type="button" title="开启新会话(归档当前)">
|
|
745
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
|
|
746
|
+
<line x1="12" y1="5" x2="12" y2="19" />
|
|
747
|
+
<line x1="5" y1="12" x2="19" y2="12" />
|
|
748
|
+
</svg>
|
|
749
|
+
<span>新会话</span>
|
|
750
|
+
</button>
|
|
751
|
+
</div>
|
|
752
|
+
<div class="sidebar-header">
|
|
753
|
+
<span>会话历史</span>
|
|
754
|
+
<span class="count" id="session-count"></span>
|
|
755
|
+
</div>
|
|
756
|
+
<div class="sidebar-list" id="sidebar-list">
|
|
757
|
+
<div class="sidebar-empty">暂无历史</div>
|
|
758
|
+
</div>
|
|
759
|
+
</aside>
|
|
760
|
+
<div class="main-col">
|
|
761
|
+
<header class="appbar">
|
|
762
|
+
<div class="brand">
|
|
763
|
+
<span class="logo" id="brand-logo">个人助理</span>
|
|
764
|
+
<span class="subtitle">本地</span>
|
|
765
|
+
</div>
|
|
766
|
+
<div class="meta">
|
|
767
|
+
<span id="session-meta" class="session-pill">未开始</span>
|
|
768
|
+
<button id="clear-btn" class="icon-btn" title="清空聊天历史" aria-label="清空聊天历史">
|
|
769
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
770
|
+
<path d="M3 6h18" />
|
|
771
|
+
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
772
|
+
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
|
773
|
+
<line x1="10" y1="11" x2="10" y2="17" />
|
|
774
|
+
<line x1="14" y1="11" x2="14" y2="17" />
|
|
775
|
+
</svg>
|
|
776
|
+
</button>
|
|
777
|
+
<button id="reset-btn" class="icon-btn" title="新会话" aria-label="新会话">
|
|
778
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
779
|
+
<path d="M3 12a9 9 0 1 0 3-6.7" />
|
|
780
|
+
<polyline points="3 4 3 9 8 9" />
|
|
781
|
+
</svg>
|
|
782
|
+
</button>
|
|
783
|
+
</div>
|
|
784
|
+
</header>
|
|
785
|
+
|
|
786
|
+
<main id="log">
|
|
787
|
+
<div class="stack" id="stack">
|
|
788
|
+
<div class="empty">
|
|
789
|
+
<div style="font-size: 36px;">👋</div>
|
|
790
|
+
<div style="margin-top: 6px;">说点什么开始对话</div>
|
|
791
|
+
<div class="hint">能读写当前目录、执行 shell 命令</div>
|
|
792
|
+
</div>
|
|
793
|
+
</div>
|
|
794
|
+
</main>
|
|
795
|
+
|
|
796
|
+
<footer class="composer">
|
|
797
|
+
<form id="form">
|
|
798
|
+
<div class="queue-strip" id="queue-strip"></div>
|
|
799
|
+
<div class="composer-card" id="composer-card">
|
|
800
|
+
<div class="img-preview-strip" id="img-preview-strip"></div>
|
|
801
|
+
<div class="composer-row">
|
|
802
|
+
<button id="attach-btn" class="attach-btn" type="button" aria-label="添加图片" title="添加图片">
|
|
803
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
804
|
+
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
|
805
|
+
</svg>
|
|
806
|
+
</button>
|
|
807
|
+
<input id="file-input" type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple hidden />
|
|
808
|
+
<textarea
|
|
809
|
+
id="input"
|
|
810
|
+
rows="1"
|
|
811
|
+
placeholder="发消息(可拖拽 / 粘贴 / 点📎加图)"
|
|
812
|
+
autofocus
|
|
813
|
+
></textarea>
|
|
814
|
+
<button id="send" class="send-btn" type="submit" aria-label="发送" title="发送">
|
|
815
|
+
<svg class="send-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
|
816
|
+
<path d="M5 12h14" />
|
|
817
|
+
<path d="M13 6l6 6-6 6" />
|
|
818
|
+
</svg>
|
|
819
|
+
<svg class="stop-icon" viewBox="0 0 24 24" fill="currentColor">
|
|
820
|
+
<rect x="4" y="4" width="16" height="16" rx="2" />
|
|
821
|
+
</svg>
|
|
822
|
+
</button>
|
|
823
|
+
</div>
|
|
824
|
+
</div>
|
|
825
|
+
<div class="composer-tip"><kbd>Enter</kbd> 发送 · <kbd>Shift</kbd>+<kbd>Enter</kbd> 换行 · 支持拖拽/粘贴图片</div>
|
|
826
|
+
</form>
|
|
827
|
+
</footer>
|
|
828
|
+
</div>
|
|
829
|
+
|
|
830
|
+
<script src="/vendor/marked.umd.js"></script>
|
|
831
|
+
<script type="module">
|
|
832
|
+
// 配置 marked:GFM + 换行直转 <br>(贴合 Claude 输出习惯)
|
|
833
|
+
if (typeof marked !== 'undefined') {
|
|
834
|
+
marked.setOptions({ gfm: true, breaks: true });
|
|
835
|
+
}
|
|
836
|
+
function renderMarkdown(text) {
|
|
837
|
+
if (typeof marked === 'undefined') return text;
|
|
838
|
+
try {
|
|
839
|
+
const html = marked.parse(text);
|
|
840
|
+
// 所有链接都在新标签打开,并加 noopener/noreferrer 防钓鱼
|
|
841
|
+
const tmp = document.createElement('div');
|
|
842
|
+
tmp.innerHTML = html;
|
|
843
|
+
tmp.querySelectorAll('a[href]').forEach((a) => {
|
|
844
|
+
a.target = '_blank';
|
|
845
|
+
a.rel = 'noopener noreferrer';
|
|
846
|
+
});
|
|
847
|
+
return tmp.innerHTML;
|
|
848
|
+
} catch { return text; }
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const stack = document.getElementById('stack');
|
|
852
|
+
const form = document.getElementById('form');
|
|
853
|
+
const input = document.getElementById('input');
|
|
854
|
+
const sendBtn = document.getElementById('send');
|
|
855
|
+
const sessionMeta = document.getElementById('session-meta');
|
|
856
|
+
const resetBtn = document.getElementById('reset-btn');
|
|
857
|
+
const clearBtn = document.getElementById('clear-btn');
|
|
858
|
+
const sidebarList = document.getElementById('sidebar-list');
|
|
859
|
+
const sessionCount = document.getElementById('session-count');
|
|
860
|
+
const newChatBtn = document.getElementById('new-chat-btn');
|
|
861
|
+
|
|
862
|
+
let sessionId = null;
|
|
863
|
+
let busy = false;
|
|
864
|
+
// ---------- 消息队列:busy 时新指令排队,执行完自动消费 ----------
|
|
865
|
+
const messageQueue = [];
|
|
866
|
+
|
|
867
|
+
// ---------- Server 自愈:发现 server 重启就自动 reload ----------
|
|
868
|
+
(async function autoReloadOnServerRestart() {
|
|
869
|
+
let initialStartTime = null;
|
|
870
|
+
try {
|
|
871
|
+
const r = await fetch('/api/version', { cache: 'no-store' });
|
|
872
|
+
initialStartTime = (await r.json()).startTime;
|
|
873
|
+
} catch { return; } // 拿不到就不启用
|
|
874
|
+
|
|
875
|
+
let unreachableCount = 0;
|
|
876
|
+
setInterval(async () => {
|
|
877
|
+
try {
|
|
878
|
+
const r = await fetch('/api/version', { cache: 'no-store' });
|
|
879
|
+
const { startTime } = await r.json();
|
|
880
|
+
unreachableCount = 0;
|
|
881
|
+
if (startTime !== initialStartTime) {
|
|
882
|
+
console.log('[Luna] server 重启,1 秒后刷新页面');
|
|
883
|
+
setTimeout(() => location.reload(), 1000);
|
|
884
|
+
}
|
|
885
|
+
} catch {
|
|
886
|
+
unreachableCount++;
|
|
887
|
+
// 连续 4 次(~12s)连不上才提示,避免短暂网络抖动
|
|
888
|
+
if (unreachableCount === 4) {
|
|
889
|
+
console.warn('[Luna] server 短暂不可达,等待恢复...');
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}, 3000);
|
|
893
|
+
})();
|
|
894
|
+
|
|
895
|
+
// ---------- Names / config (单一来源 agent-config.json) ----------
|
|
896
|
+
const brandLogo = document.getElementById('brand-logo');
|
|
897
|
+
const attachBtn = document.getElementById('attach-btn');
|
|
898
|
+
const fileInput = document.getElementById('file-input');
|
|
899
|
+
const previewStrip = document.getElementById('img-preview-strip');
|
|
900
|
+
const composerCard = document.getElementById('composer-card');
|
|
901
|
+
let config = { agentName: '个人助理', userName: '你' };
|
|
902
|
+
|
|
903
|
+
// 待发送图片队列:{id, name, mediaType, base64, dataUrl}
|
|
904
|
+
let pendingImages = [];
|
|
905
|
+
const MAX_IMAGES = 8;
|
|
906
|
+
const MAX_PER_IMAGE_BYTES = 5 * 1024 * 1024; // 5MB 原图上限
|
|
907
|
+
const ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']);
|
|
908
|
+
|
|
909
|
+
// 取一个字符串的第一个「字符」(正确处理多字节中文/emoji)
|
|
910
|
+
function firstChar(s) {
|
|
911
|
+
if (!s) return '';
|
|
912
|
+
return [...String(s)][0] || '';
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function applyConfig(c) {
|
|
916
|
+
if (!c) return;
|
|
917
|
+
const prevName = config.agentName;
|
|
918
|
+
config = { ...config, ...c };
|
|
919
|
+
brandLogo.textContent = config.agentName || '助';
|
|
920
|
+
document.title = config.agentName;
|
|
921
|
+
// agentName 变化时,加 cache-bust 重新拉 favicon(浏览器对 favicon 缓存很狠)
|
|
922
|
+
if (config.agentName !== prevName) {
|
|
923
|
+
const fav = document.getElementById('favicon');
|
|
924
|
+
if (fav) fav.href = `/favicon.svg?v=${Date.now()}`;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
async function loadConfig() {
|
|
929
|
+
try {
|
|
930
|
+
const r = await fetch('/api/config', { cache: 'no-store' });
|
|
931
|
+
if (r.ok) applyConfig(await r.json());
|
|
932
|
+
} catch { /* 静默,用默认值 */ }
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// ---------- Persistence (last 10 user-turns survive refresh) ----------
|
|
936
|
+
const STORAGE_KEY = 'agent-state-v1';
|
|
937
|
+
const MAX_USER_TURNS = 10;
|
|
938
|
+
let historyItems = []; // {kind: 'user'|'assistant_text'|'tool'|'error', ...}
|
|
939
|
+
let pendingAssistantHistory = null; // ref to in-progress assistant_text entry
|
|
940
|
+
|
|
941
|
+
function trimHistory() {
|
|
942
|
+
const userIdxs = [];
|
|
943
|
+
for (let i = 0; i < historyItems.length; i++) {
|
|
944
|
+
if (historyItems[i].kind === 'user') userIdxs.push(i);
|
|
945
|
+
}
|
|
946
|
+
if (userIdxs.length > MAX_USER_TURNS) {
|
|
947
|
+
historyItems.splice(0, userIdxs[userIdxs.length - MAX_USER_TURNS]);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function saveState() {
|
|
952
|
+
try {
|
|
953
|
+
localStorage.setItem(
|
|
954
|
+
STORAGE_KEY,
|
|
955
|
+
JSON.stringify({ sessionId, history: historyItems }),
|
|
956
|
+
);
|
|
957
|
+
} catch { /* quota / disabled */ }
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function loadState() {
|
|
961
|
+
try {
|
|
962
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
963
|
+
return raw ? JSON.parse(raw) : null;
|
|
964
|
+
} catch { return null; }
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// ---------- 历史会话归档(写入磁盘) ----------
|
|
968
|
+
// 从 historyItems 第一条 user 消息生成标题
|
|
969
|
+
function deriveTitle() {
|
|
970
|
+
for (const it of historyItems) {
|
|
971
|
+
if (it.kind === 'user' && it.text) {
|
|
972
|
+
return it.text.replace(/\s+/g, ' ').slice(0, 24);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
return '(无标题)';
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function formatTime(ts) {
|
|
979
|
+
if (!ts) return '';
|
|
980
|
+
const d = new Date(ts);
|
|
981
|
+
const now = new Date();
|
|
982
|
+
const diffMs = now - d;
|
|
983
|
+
if (diffMs < 60000) return '刚刚';
|
|
984
|
+
if (diffMs < 3600000) return `${Math.floor(diffMs / 60000)} 分钟前`;
|
|
985
|
+
const isToday = d.toDateString() === now.toDateString();
|
|
986
|
+
const hh = String(d.getHours()).padStart(2, '0');
|
|
987
|
+
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
988
|
+
if (isToday) return `今天 ${hh}:${mm}`;
|
|
989
|
+
const sameYear = d.getFullYear() === now.getFullYear();
|
|
990
|
+
if (sameYear) return `${d.getMonth() + 1}-${d.getDate()} ${hh}:${mm}`;
|
|
991
|
+
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// 把当前 historyItems 写到磁盘
|
|
995
|
+
// 返回 true = 已归档,false = 跳过
|
|
996
|
+
async function archiveCurrent() {
|
|
997
|
+
if (historyItems.length === 0) return false;
|
|
998
|
+
// 至少要有一条 user 消息才值得存(避免存空对话)
|
|
999
|
+
if (!historyItems.some((it) => it.kind === 'user')) return false;
|
|
1000
|
+
// sessionId 可能还没从 SDK 回来(流式没结束就点新会话 / 历史里 hydrate 出来的)
|
|
1001
|
+
// 用 fallback ID 兜底,保证不丢
|
|
1002
|
+
const idToUse = sessionId || `local-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1003
|
+
try {
|
|
1004
|
+
const r = await fetch('/api/sessions', {
|
|
1005
|
+
method: 'POST',
|
|
1006
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1007
|
+
body: JSON.stringify({
|
|
1008
|
+
sessionId: idToUse,
|
|
1009
|
+
title: deriveTitle(),
|
|
1010
|
+
history: historyItems,
|
|
1011
|
+
}),
|
|
1012
|
+
});
|
|
1013
|
+
if (!r.ok) {
|
|
1014
|
+
console.warn('[archive] POST 失败', r.status, await r.text().catch(() => ''));
|
|
1015
|
+
}
|
|
1016
|
+
return r.ok;
|
|
1017
|
+
} catch (e) {
|
|
1018
|
+
console.warn('[archive] 异常', e);
|
|
1019
|
+
return false;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
async function loadSessionsList() {
|
|
1024
|
+
try {
|
|
1025
|
+
const r = await fetch('/api/sessions');
|
|
1026
|
+
if (!r.ok) return;
|
|
1027
|
+
const items = await r.json();
|
|
1028
|
+
renderSessionsList(items);
|
|
1029
|
+
} catch { /* 静默 */ }
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function renderSessionsList(items) {
|
|
1033
|
+
sidebarList.innerHTML = '';
|
|
1034
|
+
if (!items || items.length === 0) {
|
|
1035
|
+
const empty = el('div', 'sidebar-empty', '暂无历史');
|
|
1036
|
+
sidebarList.appendChild(empty);
|
|
1037
|
+
sessionCount.textContent = '';
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
sessionCount.textContent = items.length;
|
|
1041
|
+
for (const it of items) {
|
|
1042
|
+
const item = el('div', 'session-item');
|
|
1043
|
+
if (it.sessionId === sessionId) item.classList.add('active');
|
|
1044
|
+
item.dataset.id = it.sessionId;
|
|
1045
|
+
|
|
1046
|
+
const body = el('div', 'session-item-body');
|
|
1047
|
+
body.appendChild(el('div', 'session-item-title', it.title || '(无标题)'));
|
|
1048
|
+
body.appendChild(el('div', 'session-item-time', formatTime(it.updatedAt)));
|
|
1049
|
+
item.appendChild(body);
|
|
1050
|
+
|
|
1051
|
+
const del = el('button', 'session-item-del', '×');
|
|
1052
|
+
del.type = 'button';
|
|
1053
|
+
del.title = '删除';
|
|
1054
|
+
del.addEventListener('click', async (e) => {
|
|
1055
|
+
e.stopPropagation();
|
|
1056
|
+
if (!confirm(`删除「${it.title || '(无标题)'}」?`)) return;
|
|
1057
|
+
try {
|
|
1058
|
+
await fetch(`/api/sessions/${encodeURIComponent(it.sessionId)}`, { method: 'DELETE' });
|
|
1059
|
+
} catch {}
|
|
1060
|
+
// 删的是当前会话 → 同时清空 UI
|
|
1061
|
+
if (it.sessionId === sessionId) doResetUi();
|
|
1062
|
+
await loadSessionsList();
|
|
1063
|
+
});
|
|
1064
|
+
item.appendChild(del);
|
|
1065
|
+
|
|
1066
|
+
item.addEventListener('click', () => switchToSession(it.sessionId));
|
|
1067
|
+
sidebarList.appendChild(item);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
async function switchToSession(id) {
|
|
1072
|
+
if (busy) return;
|
|
1073
|
+
if (id === sessionId) return;
|
|
1074
|
+
// 切换前归档当前会话(如果有内容)
|
|
1075
|
+
await archiveCurrent();
|
|
1076
|
+
try {
|
|
1077
|
+
const r = await fetch(`/api/sessions/${encodeURIComponent(id)}`);
|
|
1078
|
+
if (!r.ok) return;
|
|
1079
|
+
const obj = await r.json();
|
|
1080
|
+
sessionId = obj.sessionId;
|
|
1081
|
+
historyItems = Array.isArray(obj.history) ? obj.history : [];
|
|
1082
|
+
pendingAssistantHistory = null;
|
|
1083
|
+
currentAssistantBubble = null;
|
|
1084
|
+
saveState();
|
|
1085
|
+
renderAllHistory();
|
|
1086
|
+
sessionMeta.textContent = `session ${sessionId.slice(0, 8)}…`;
|
|
1087
|
+
sessionMeta.classList.add('active');
|
|
1088
|
+
await loadSessionsList();
|
|
1089
|
+
} catch { /* 静默 */ }
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function renderAllHistory() {
|
|
1093
|
+
stack.innerHTML = '';
|
|
1094
|
+
if (historyItems.length === 0) {
|
|
1095
|
+
showEmpty();
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
for (const item of historyItems) {
|
|
1099
|
+
currentAssistantBubble = null;
|
|
1100
|
+
if (item.kind === 'user') {
|
|
1101
|
+
const ghosts = (item.imageNames || []).map((n) => ({ name: n, dataUrl: '' }));
|
|
1102
|
+
appendUser(item.text, ghosts, false);
|
|
1103
|
+
} else if (item.kind === 'assistant_text') {
|
|
1104
|
+
const bubble = getAssistantBubble();
|
|
1105
|
+
bubble._raw = item.text;
|
|
1106
|
+
bubble.innerHTML = renderMarkdown(item.text);
|
|
1107
|
+
} else if (item.kind === 'tool') {
|
|
1108
|
+
appendToolCard(item.subtype, item.title, item.payload, false);
|
|
1109
|
+
} else if (item.kind === 'plan') {
|
|
1110
|
+
appendPlanCard({ needPlan: true, steps: item.steps, reason: item.reason }, false);
|
|
1111
|
+
} else if (item.kind === 'reflection') {
|
|
1112
|
+
appendReflectionCard({ phase: 'start', step: item.step, goal: item.goal, failure: item.failure }, false);
|
|
1113
|
+
} else if (item.kind === 'error') {
|
|
1114
|
+
appendError(item.message, false);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
currentAssistantBubble = null;
|
|
1118
|
+
pendingAssistantHistory = null;
|
|
1119
|
+
scrollToBottom();
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function showEmpty() {
|
|
1123
|
+
stack.innerHTML = '';
|
|
1124
|
+
const empty = el('div', 'empty');
|
|
1125
|
+
empty.innerHTML = `
|
|
1126
|
+
<div style="font-size: 36px;">👋</div>
|
|
1127
|
+
<div style="margin-top: 6px;">新会话</div>
|
|
1128
|
+
<div class="hint">说点什么开始</div>
|
|
1129
|
+
`;
|
|
1130
|
+
stack.appendChild(empty);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function doResetUi() {
|
|
1134
|
+
sessionId = null;
|
|
1135
|
+
historyItems = [];
|
|
1136
|
+
pendingAssistantHistory = null;
|
|
1137
|
+
currentAssistantBubble = null;
|
|
1138
|
+
try { localStorage.removeItem(STORAGE_KEY); } catch {}
|
|
1139
|
+
sessionMeta.textContent = '未开始';
|
|
1140
|
+
sessionMeta.classList.remove('active');
|
|
1141
|
+
showEmpty();
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// ---------- DOM helpers ----------
|
|
1145
|
+
function el(tag, cls, text) {
|
|
1146
|
+
const e = document.createElement(tag);
|
|
1147
|
+
if (cls) e.className = cls;
|
|
1148
|
+
if (text != null) e.textContent = text;
|
|
1149
|
+
return e;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function clearEmpty() {
|
|
1153
|
+
const empty = stack.querySelector('.empty');
|
|
1154
|
+
if (empty) empty.remove();
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function scrollToBottom() {
|
|
1158
|
+
document.getElementById('log').scrollTop = document.getElementById('log').scrollHeight;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// ---------- Thinking indicator ----------
|
|
1162
|
+
let thinkingNode = null;
|
|
1163
|
+
|
|
1164
|
+
function showThinking(label = 'Luna 正在思考…') {
|
|
1165
|
+
if (thinkingNode) return;
|
|
1166
|
+
const msg = el('div', 'msg assistant');
|
|
1167
|
+
msg.appendChild(buildAvatar('assistant'));
|
|
1168
|
+
const content = el('div', 'content');
|
|
1169
|
+
const bubble = el('div', 'thinking-bubble');
|
|
1170
|
+
bubble.appendChild(el('span', 'dot'));
|
|
1171
|
+
bubble.appendChild(el('span', 'dot'));
|
|
1172
|
+
bubble.appendChild(el('span', 'dot'));
|
|
1173
|
+
bubble.appendChild(el('span', 'thinking-label', label));
|
|
1174
|
+
content.appendChild(bubble);
|
|
1175
|
+
msg.appendChild(content);
|
|
1176
|
+
stack.appendChild(msg);
|
|
1177
|
+
thinkingNode = msg;
|
|
1178
|
+
scrollToBottom();
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function hideThinking() {
|
|
1182
|
+
if (thinkingNode) {
|
|
1183
|
+
thinkingNode.remove();
|
|
1184
|
+
thinkingNode = null;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// Insert a node ABOVE the thinking indicator (so it stays anchored at bottom)
|
|
1189
|
+
function insertBeforeThinking(node) {
|
|
1190
|
+
if (thinkingNode && thinkingNode.parentNode === stack) {
|
|
1191
|
+
stack.insertBefore(node, thinkingNode);
|
|
1192
|
+
} else {
|
|
1193
|
+
stack.appendChild(node);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// ---------- Message builders ----------
|
|
1198
|
+
function buildAvatar(role) {
|
|
1199
|
+
// assistant 角色且 config.avatar 配了图片 → 用图片头像
|
|
1200
|
+
if (role === 'assistant' && config.avatar) {
|
|
1201
|
+
const wrap = el('div', 'avatar assistant avatar-img');
|
|
1202
|
+
const img = document.createElement('img');
|
|
1203
|
+
img.src = `/avatar?v=${encodeURIComponent(config.avatar)}`;
|
|
1204
|
+
img.alt = config.agentName || '助手';
|
|
1205
|
+
img.style.cssText = 'width:32px;height:32px;border-radius:9px;object-fit:cover;display:block;cursor:pointer;';
|
|
1206
|
+
wrap.style.cssText = 'padding:0;background:none;';
|
|
1207
|
+
img.addEventListener('click', () => showAvatarModal(img.src));
|
|
1208
|
+
wrap.appendChild(img);
|
|
1209
|
+
return wrap;
|
|
1210
|
+
}
|
|
1211
|
+
const name = role === 'user'
|
|
1212
|
+
? (config.userName || '你')
|
|
1213
|
+
: (config.agentName || '助手');
|
|
1214
|
+
return el('div', `avatar ${role}`, name);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// 头像放大弹窗
|
|
1218
|
+
function showAvatarModal(src) {
|
|
1219
|
+
const overlay = document.createElement('div');
|
|
1220
|
+
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:grid;place-items:center;z-index:9999;cursor:zoom-out;animation:fadeIn 0.15s;';
|
|
1221
|
+
const img = document.createElement('img');
|
|
1222
|
+
img.src = src;
|
|
1223
|
+
img.style.cssText = 'max-width:80vw;max-height:80vh;border-radius:16px;box-shadow:0 20px 60px rgba(0,0,0,0.4);';
|
|
1224
|
+
overlay.appendChild(img);
|
|
1225
|
+
overlay.addEventListener('click', () => overlay.remove());
|
|
1226
|
+
document.body.appendChild(overlay);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
function appendUser(text, images = [], _record = true) {
|
|
1230
|
+
clearEmpty();
|
|
1231
|
+
const msg = el('div', 'msg user');
|
|
1232
|
+
msg.appendChild(buildAvatar('user'));
|
|
1233
|
+
const content = el('div', 'content');
|
|
1234
|
+
|
|
1235
|
+
if (images && images.length) {
|
|
1236
|
+
const strip = el('div', 'img-strip');
|
|
1237
|
+
for (const img of images) {
|
|
1238
|
+
const i = document.createElement('img');
|
|
1239
|
+
i.src = img.dataUrl || ''; // hydrated 时可能没 dataUrl
|
|
1240
|
+
i.alt = img.name || 'image';
|
|
1241
|
+
i.title = img.name || '';
|
|
1242
|
+
if (img.dataUrl) i.addEventListener('click', () => window.open(img.dataUrl, '_blank'));
|
|
1243
|
+
strip.appendChild(i);
|
|
1244
|
+
}
|
|
1245
|
+
content.appendChild(strip);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
if (text) content.appendChild(el('div', 'bubble', text));
|
|
1249
|
+
|
|
1250
|
+
msg.appendChild(content);
|
|
1251
|
+
stack.appendChild(msg);
|
|
1252
|
+
scrollToBottom();
|
|
1253
|
+
if (_record) {
|
|
1254
|
+
pendingAssistantHistory = null;
|
|
1255
|
+
// 历史里**不存** base64(localStorage 容量有限);只存文件名
|
|
1256
|
+
historyItems.push({
|
|
1257
|
+
kind: 'user',
|
|
1258
|
+
text,
|
|
1259
|
+
imageNames: (images || []).map((i) => i.name),
|
|
1260
|
+
});
|
|
1261
|
+
trimHistory();
|
|
1262
|
+
saveState();
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// Either reuse current assistant bubble (for streaming text) or create new one
|
|
1267
|
+
let currentAssistantBubble = null;
|
|
1268
|
+
function getAssistantBubble() {
|
|
1269
|
+
if (currentAssistantBubble) return currentAssistantBubble;
|
|
1270
|
+
clearEmpty();
|
|
1271
|
+
const msg = el('div', 'msg assistant');
|
|
1272
|
+
msg.appendChild(buildAvatar('assistant'));
|
|
1273
|
+
const content = el('div', 'content');
|
|
1274
|
+
const bubble = el('div', 'bubble');
|
|
1275
|
+
content.appendChild(bubble);
|
|
1276
|
+
msg.appendChild(content);
|
|
1277
|
+
insertBeforeThinking(msg);
|
|
1278
|
+
currentAssistantBubble = bubble;
|
|
1279
|
+
return bubble;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
function appendToolCard(kind, title, payload, _record = true) {
|
|
1283
|
+
clearEmpty();
|
|
1284
|
+
const tagCls =
|
|
1285
|
+
kind === 'result' ? 'tag result' :
|
|
1286
|
+
kind === 'error' ? 'tag error' :
|
|
1287
|
+
kind === 'recall' ? 'tag recall' : 'tag';
|
|
1288
|
+
const tagText =
|
|
1289
|
+
kind === 'result' ? 'tool_result' :
|
|
1290
|
+
kind === 'error' ? 'tool_error' :
|
|
1291
|
+
kind === 'recall' ? 'memory' : 'tool_use';
|
|
1292
|
+
|
|
1293
|
+
// Tools render as their own row (no bubble), but with an assistant avatar lane
|
|
1294
|
+
const msg = el('div', 'msg assistant');
|
|
1295
|
+
msg.appendChild(buildAvatar('assistant'));
|
|
1296
|
+
const content = el('div', 'content');
|
|
1297
|
+
|
|
1298
|
+
const det = el('details', 'tool');
|
|
1299
|
+
const sum = el('summary');
|
|
1300
|
+
const tag = el('span', tagCls, tagText);
|
|
1301
|
+
sum.appendChild(tag);
|
|
1302
|
+
sum.appendChild(el('span', null, ` ${title}`));
|
|
1303
|
+
det.appendChild(sum);
|
|
1304
|
+
const pre = el('pre');
|
|
1305
|
+
pre.textContent = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
|
|
1306
|
+
det.appendChild(pre);
|
|
1307
|
+
content.appendChild(det);
|
|
1308
|
+
msg.appendChild(content);
|
|
1309
|
+
|
|
1310
|
+
insertBeforeThinking(msg);
|
|
1311
|
+
|
|
1312
|
+
// Tool blocks break the assistant text stream — next text starts in a new bubble
|
|
1313
|
+
currentAssistantBubble = null;
|
|
1314
|
+
pendingAssistantHistory = null;
|
|
1315
|
+
scrollToBottom();
|
|
1316
|
+
if (_record) {
|
|
1317
|
+
const payloadText = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
|
|
1318
|
+
historyItems.push({ kind: 'tool', subtype: kind, title, payload: payloadText });
|
|
1319
|
+
saveState();
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// ---------- Plan card ----------
|
|
1324
|
+
function appendPlanCard(plan, _record = true) {
|
|
1325
|
+
if (!plan || !plan.needPlan || !Array.isArray(plan.steps) || plan.steps.length === 0) return;
|
|
1326
|
+
clearEmpty();
|
|
1327
|
+
const msg = el('div', 'msg assistant');
|
|
1328
|
+
msg.appendChild(buildAvatar('assistant'));
|
|
1329
|
+
const content = el('div', 'content');
|
|
1330
|
+
|
|
1331
|
+
const det = el('details', 'tool');
|
|
1332
|
+
det.open = true; // 计划默认展开,让主人看清执行路径
|
|
1333
|
+
const sum = el('summary');
|
|
1334
|
+
sum.appendChild(el('span', 'tag plan', 'plan'));
|
|
1335
|
+
sum.appendChild(el('span', null, ` 执行计划 · ${plan.steps.length} 步${plan.reason ? ` · ${plan.reason}` : ''}`));
|
|
1336
|
+
det.appendChild(sum);
|
|
1337
|
+
|
|
1338
|
+
const ol = el('ol', 'plan-steps');
|
|
1339
|
+
for (const s of plan.steps) {
|
|
1340
|
+
const li = el('li');
|
|
1341
|
+
li.appendChild(el('span', 'step-num', String(s.id ?? '?')));
|
|
1342
|
+
const body = el('div', 'step-body');
|
|
1343
|
+
body.appendChild(el('div', 'step-goal', s.goal || '(无目标)'));
|
|
1344
|
+
if (s.verifyHint) body.appendChild(el('div', 'step-verify', `验证: ${s.verifyHint}`));
|
|
1345
|
+
li.appendChild(body);
|
|
1346
|
+
ol.appendChild(li);
|
|
1347
|
+
}
|
|
1348
|
+
det.appendChild(ol);
|
|
1349
|
+
content.appendChild(det);
|
|
1350
|
+
msg.appendChild(content);
|
|
1351
|
+
insertBeforeThinking(msg);
|
|
1352
|
+
|
|
1353
|
+
currentAssistantBubble = null; // 计划卡后正文起新 bubble
|
|
1354
|
+
pendingAssistantHistory = null;
|
|
1355
|
+
scrollToBottom();
|
|
1356
|
+
if (_record) {
|
|
1357
|
+
historyItems.push({ kind: 'plan', steps: plan.steps, reason: plan.reason || '' });
|
|
1358
|
+
saveState();
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// ---------- Reflection card ----------
|
|
1363
|
+
function appendReflectionCard(data, _record = true) {
|
|
1364
|
+
if (!data) return;
|
|
1365
|
+
clearEmpty();
|
|
1366
|
+
const msg = el('div', 'msg assistant');
|
|
1367
|
+
msg.appendChild(buildAvatar('assistant'));
|
|
1368
|
+
const content = el('div', 'content');
|
|
1369
|
+
|
|
1370
|
+
const det = el('details', 'tool');
|
|
1371
|
+
const sum = el('summary');
|
|
1372
|
+
if (data.phase === 'end') {
|
|
1373
|
+
det.open = false;
|
|
1374
|
+
sum.appendChild(el('span', 'tag reflection', 'reflection'));
|
|
1375
|
+
sum.appendChild(el('span', null, ` step ${data.step} 反思完成`));
|
|
1376
|
+
} else {
|
|
1377
|
+
det.open = true;
|
|
1378
|
+
sum.appendChild(el('span', 'tag reflection', 'reflection'));
|
|
1379
|
+
sum.appendChild(el('span', null, ` step ${data.step ?? ''} 失败 → 反思中${data.goal ? ` · ${data.goal}` : ''}`));
|
|
1380
|
+
}
|
|
1381
|
+
det.appendChild(sum);
|
|
1382
|
+
if (data.failure) {
|
|
1383
|
+
const pre = el('div', 'reflection-failure', data.failure);
|
|
1384
|
+
det.appendChild(pre);
|
|
1385
|
+
}
|
|
1386
|
+
content.appendChild(det);
|
|
1387
|
+
msg.appendChild(content);
|
|
1388
|
+
insertBeforeThinking(msg);
|
|
1389
|
+
|
|
1390
|
+
currentAssistantBubble = null;
|
|
1391
|
+
pendingAssistantHistory = null;
|
|
1392
|
+
scrollToBottom();
|
|
1393
|
+
if (_record && data.phase !== 'end') {
|
|
1394
|
+
historyItems.push({ kind: 'reflection', step: data.step, goal: data.goal || '', failure: data.failure || '' });
|
|
1395
|
+
saveState();
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
function appendError(message, _record = true) {
|
|
1400
|
+
clearEmpty();
|
|
1401
|
+
const wrap = el('div', 'msg assistant');
|
|
1402
|
+
wrap.appendChild(buildAvatar('assistant'));
|
|
1403
|
+
const content = el('div', 'content');
|
|
1404
|
+
content.appendChild(el('div', 'err', `[错误] ${message}`));
|
|
1405
|
+
wrap.appendChild(content);
|
|
1406
|
+
insertBeforeThinking(wrap);
|
|
1407
|
+
scrollToBottom();
|
|
1408
|
+
if (_record) {
|
|
1409
|
+
pendingAssistantHistory = null;
|
|
1410
|
+
historyItems.push({ kind: 'error', message });
|
|
1411
|
+
saveState();
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
// ---------- Thinking 内容展示(可折叠) ----------
|
|
1416
|
+
function appendThinkingBlock(text) {
|
|
1417
|
+
if (!text || !text.trim()) return;
|
|
1418
|
+
const details = document.createElement('details');
|
|
1419
|
+
details.className = 'thinking-block';
|
|
1420
|
+
const summary = document.createElement('summary');
|
|
1421
|
+
// 截取前 40 字做摘要
|
|
1422
|
+
const preview = text.trim().replace(/\s+/g, ' ').slice(0, 40) + (text.length > 40 ? '…' : '');
|
|
1423
|
+
summary.textContent = preview;
|
|
1424
|
+
details.appendChild(summary);
|
|
1425
|
+
const content = el('div', 'thinking-content', text);
|
|
1426
|
+
details.appendChild(content);
|
|
1427
|
+
insertBeforeThinking(details);
|
|
1428
|
+
scrollToBottom();
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// ---------- SDK message dispatch ----------
|
|
1432
|
+
function handleSdkMessage(msg) {
|
|
1433
|
+
if (!msg || typeof msg !== 'object') return;
|
|
1434
|
+
|
|
1435
|
+
if (msg.type === 'assistant') {
|
|
1436
|
+
const blocks = msg.message?.content ?? [];
|
|
1437
|
+
for (const b of blocks) {
|
|
1438
|
+
if (b.type === 'thinking' && b.thinking) {
|
|
1439
|
+
// 扩展思考块:不展示(已禁用)
|
|
1440
|
+
// appendThinkingBlock(b.thinking);
|
|
1441
|
+
} else if (b.type === 'text' && b.text) {
|
|
1442
|
+
const bubble = getAssistantBubble();
|
|
1443
|
+
// 累积原始 markdown,每个 chunk 都重渲染一次(marked 容错足够好)
|
|
1444
|
+
bubble._raw = (bubble._raw || '') + b.text;
|
|
1445
|
+
bubble.innerHTML = renderMarkdown(bubble._raw);
|
|
1446
|
+
// 同步累积到 history(每个连续 text 段共用一条 assistant_text)
|
|
1447
|
+
if (!pendingAssistantHistory) {
|
|
1448
|
+
pendingAssistantHistory = { kind: 'assistant_text', text: '' };
|
|
1449
|
+
historyItems.push(pendingAssistantHistory);
|
|
1450
|
+
}
|
|
1451
|
+
pendingAssistantHistory.text += b.text;
|
|
1452
|
+
trimHistory();
|
|
1453
|
+
saveState();
|
|
1454
|
+
scrollToBottom();
|
|
1455
|
+
} else if (b.type === 'tool_use') {
|
|
1456
|
+
appendToolCard('use', b.name, b.input);
|
|
1457
|
+
// 自测中:看到 web 工具被调用就标记
|
|
1458
|
+
if (selfTestActive && typeof b.name === 'string' && b.name.includes('web')) {
|
|
1459
|
+
selfTestToolSeen = true;
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
} else if (msg.type === 'user') {
|
|
1464
|
+
const blocks = msg.message?.content ?? [];
|
|
1465
|
+
for (const b of blocks) {
|
|
1466
|
+
if (b.type === 'tool_result') {
|
|
1467
|
+
const text =
|
|
1468
|
+
typeof b.content === 'string'
|
|
1469
|
+
? b.content
|
|
1470
|
+
: (b.content ?? []).map((c) => c.text ?? JSON.stringify(c)).join('\n');
|
|
1471
|
+
appendToolCard(b.is_error ? 'error' : 'result', '', text);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
} else if (msg.type === 'result') {
|
|
1475
|
+
if (msg.session_id) {
|
|
1476
|
+
sessionId = msg.session_id;
|
|
1477
|
+
sessionMeta.textContent = `session ${sessionId.slice(0, 8)}…`;
|
|
1478
|
+
sessionMeta.classList.add('active');
|
|
1479
|
+
saveState();
|
|
1480
|
+
}
|
|
1481
|
+
} else if (msg.type === 'system' && msg.subtype === 'memory_recall') {
|
|
1482
|
+
const items = (msg.memories ?? []).map((m) => {
|
|
1483
|
+
const name = (m.path ?? '').split('/').pop() || m.path || '(memory)';
|
|
1484
|
+
const body = m.content ? `\n${m.content}` : '';
|
|
1485
|
+
return `• [${m.scope}] ${name}${body}`;
|
|
1486
|
+
});
|
|
1487
|
+
appendToolCard(
|
|
1488
|
+
'recall',
|
|
1489
|
+
`🧠 Recalled from memory (${msg.mode})`,
|
|
1490
|
+
items.join('\n\n') || '(empty)',
|
|
1491
|
+
);
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// ---------- SSE streaming ----------
|
|
1496
|
+
let currentAbortController = null;
|
|
1497
|
+
|
|
1498
|
+
async function chat(prompt, images = []) {
|
|
1499
|
+
const payload = { prompt, sessionId };
|
|
1500
|
+
if (images.length) {
|
|
1501
|
+
payload.images = images.map((i) => ({ mediaType: i.mediaType, base64: i.base64 }));
|
|
1502
|
+
}
|
|
1503
|
+
currentAbortController = new AbortController();
|
|
1504
|
+
const res = await fetch('/api/chat', {
|
|
1505
|
+
method: 'POST',
|
|
1506
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1507
|
+
body: JSON.stringify(payload),
|
|
1508
|
+
signal: currentAbortController.signal,
|
|
1509
|
+
});
|
|
1510
|
+
|
|
1511
|
+
if (!res.ok) {
|
|
1512
|
+
let detail = '';
|
|
1513
|
+
try { detail = (await res.json()).error ?? ''; } catch {}
|
|
1514
|
+
throw new Error(`HTTP ${res.status} ${detail}`);
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
const reader = res.body.getReader();
|
|
1518
|
+
const decoder = new TextDecoder();
|
|
1519
|
+
let buf = '';
|
|
1520
|
+
let event = 'message';
|
|
1521
|
+
let data = '';
|
|
1522
|
+
|
|
1523
|
+
const flush = () => {
|
|
1524
|
+
if (!data) return;
|
|
1525
|
+
let parsed;
|
|
1526
|
+
try { parsed = JSON.parse(data); } catch { parsed = data; }
|
|
1527
|
+
if (event === 'message') {
|
|
1528
|
+
handleSdkMessage(parsed);
|
|
1529
|
+
} else if (event === 'plan') {
|
|
1530
|
+
appendPlanCard(parsed);
|
|
1531
|
+
} else if (event === 'reflection') {
|
|
1532
|
+
appendReflectionCard(parsed);
|
|
1533
|
+
} else if (event === 'error') {
|
|
1534
|
+
appendError(parsed.message ?? data);
|
|
1535
|
+
}
|
|
1536
|
+
event = 'message';
|
|
1537
|
+
data = '';
|
|
1538
|
+
};
|
|
1539
|
+
|
|
1540
|
+
while (true) {
|
|
1541
|
+
const { done, value } = await reader.read();
|
|
1542
|
+
if (done) break;
|
|
1543
|
+
buf += decoder.decode(value, { stream: true });
|
|
1544
|
+
const lines = buf.split('\n');
|
|
1545
|
+
buf = lines.pop() ?? '';
|
|
1546
|
+
for (const line of lines) {
|
|
1547
|
+
if (line === '') {
|
|
1548
|
+
flush();
|
|
1549
|
+
} else if (line.startsWith('event: ')) {
|
|
1550
|
+
event = line.slice(7).trim();
|
|
1551
|
+
} else if (line.startsWith('data: ')) {
|
|
1552
|
+
data = line.slice(6);
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
flush();
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// ---------- Image handling ----------
|
|
1560
|
+
function readFileAsDataUrl(file) {
|
|
1561
|
+
return new Promise((resolve, reject) => {
|
|
1562
|
+
const r = new FileReader();
|
|
1563
|
+
r.onload = () => resolve(r.result);
|
|
1564
|
+
r.onerror = () => reject(r.error);
|
|
1565
|
+
r.readAsDataURL(file);
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
function renderPreviews() {
|
|
1570
|
+
previewStrip.innerHTML = '';
|
|
1571
|
+
for (const img of pendingImages) {
|
|
1572
|
+
const box = el('div', 'img-preview');
|
|
1573
|
+
const i = document.createElement('img');
|
|
1574
|
+
i.src = img.dataUrl;
|
|
1575
|
+
box.appendChild(i);
|
|
1576
|
+
const btn = el('button', 'remove-btn', '×');
|
|
1577
|
+
btn.type = 'button';
|
|
1578
|
+
btn.title = '移除';
|
|
1579
|
+
btn.addEventListener('click', () => {
|
|
1580
|
+
pendingImages = pendingImages.filter((x) => x.id !== img.id);
|
|
1581
|
+
renderPreviews();
|
|
1582
|
+
});
|
|
1583
|
+
box.appendChild(btn);
|
|
1584
|
+
previewStrip.appendChild(box);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
async function addFiles(fileList) {
|
|
1589
|
+
const files = [...(fileList || [])].filter((f) => ALLOWED_TYPES.has(f.type));
|
|
1590
|
+
for (const f of files) {
|
|
1591
|
+
if (pendingImages.length >= MAX_IMAGES) {
|
|
1592
|
+
appendError(`图片最多 ${MAX_IMAGES} 张,后续已忽略`);
|
|
1593
|
+
break;
|
|
1594
|
+
}
|
|
1595
|
+
if (f.size > MAX_PER_IMAGE_BYTES) {
|
|
1596
|
+
appendError(`${f.name} 超过 5MB,已跳过`);
|
|
1597
|
+
continue;
|
|
1598
|
+
}
|
|
1599
|
+
try {
|
|
1600
|
+
const dataUrl = await readFileAsDataUrl(f);
|
|
1601
|
+
const comma = dataUrl.indexOf(',');
|
|
1602
|
+
pendingImages.push({
|
|
1603
|
+
id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
1604
|
+
name: f.name || 'image',
|
|
1605
|
+
mediaType: f.type,
|
|
1606
|
+
base64: dataUrl.slice(comma + 1),
|
|
1607
|
+
dataUrl,
|
|
1608
|
+
});
|
|
1609
|
+
} catch (e) {
|
|
1610
|
+
appendError(`读取 ${f.name} 失败:${e.message ?? e}`);
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
renderPreviews();
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
attachBtn.addEventListener('click', () => fileInput.click());
|
|
1617
|
+
fileInput.addEventListener('change', async (e) => {
|
|
1618
|
+
await addFiles(e.target.files);
|
|
1619
|
+
fileInput.value = '';
|
|
1620
|
+
});
|
|
1621
|
+
|
|
1622
|
+
// 拖拽
|
|
1623
|
+
['dragenter', 'dragover'].forEach((ev) =>
|
|
1624
|
+
composerCard.addEventListener(ev, (e) => {
|
|
1625
|
+
if (e.dataTransfer?.types?.includes('Files')) {
|
|
1626
|
+
e.preventDefault();
|
|
1627
|
+
composerCard.classList.add('dragover');
|
|
1628
|
+
}
|
|
1629
|
+
}),
|
|
1630
|
+
);
|
|
1631
|
+
['dragleave', 'drop'].forEach((ev) =>
|
|
1632
|
+
composerCard.addEventListener(ev, (e) => {
|
|
1633
|
+
e.preventDefault();
|
|
1634
|
+
composerCard.classList.remove('dragover');
|
|
1635
|
+
}),
|
|
1636
|
+
);
|
|
1637
|
+
composerCard.addEventListener('drop', async (e) => {
|
|
1638
|
+
if (e.dataTransfer?.files?.length) await addFiles(e.dataTransfer.files);
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
// 粘贴(截图直接 Cmd+V)
|
|
1642
|
+
input.addEventListener('paste', async (e) => {
|
|
1643
|
+
const items = e.clipboardData?.items;
|
|
1644
|
+
if (!items) return;
|
|
1645
|
+
const files = [];
|
|
1646
|
+
for (const it of items) {
|
|
1647
|
+
if (it.kind === 'file' && ALLOWED_TYPES.has(it.type)) {
|
|
1648
|
+
const f = it.getAsFile();
|
|
1649
|
+
if (f) files.push(f);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
if (files.length) await addFiles(files);
|
|
1653
|
+
});
|
|
1654
|
+
|
|
1655
|
+
// ---------- Form ----------
|
|
1656
|
+
function autoGrow() {
|
|
1657
|
+
input.style.height = 'auto';
|
|
1658
|
+
input.style.height = Math.min(input.scrollHeight, 200) + 'px';
|
|
1659
|
+
}
|
|
1660
|
+
input.addEventListener('input', autoGrow);
|
|
1661
|
+
|
|
1662
|
+
form.addEventListener('submit', async (e) => {
|
|
1663
|
+
e.preventDefault();
|
|
1664
|
+
const prompt = input.value.trim();
|
|
1665
|
+
const imagesToSend = pendingImages.slice();
|
|
1666
|
+
if (!prompt && imagesToSend.length === 0) return;
|
|
1667
|
+
|
|
1668
|
+
// 清空输入框(无论是否 busy 都立刻清,给用户"已收到"的反馈)
|
|
1669
|
+
input.value = '';
|
|
1670
|
+
autoGrow();
|
|
1671
|
+
pendingImages = [];
|
|
1672
|
+
renderPreviews();
|
|
1673
|
+
|
|
1674
|
+
// busy 时入队,渲染到输入框上方
|
|
1675
|
+
if (busy) {
|
|
1676
|
+
messageQueue.push({ prompt, images: imagesToSend });
|
|
1677
|
+
renderQueue();
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
await processMessage(prompt, imagesToSend, true);
|
|
1682
|
+
});
|
|
1683
|
+
|
|
1684
|
+
// ---------- 队列 UI:渲染到输入框上方,支持编辑/删除 ----------
|
|
1685
|
+
const queueStrip = document.getElementById('queue-strip');
|
|
1686
|
+
|
|
1687
|
+
function renderQueue() {
|
|
1688
|
+
queueStrip.innerHTML = '';
|
|
1689
|
+
messageQueue.forEach((item, idx) => {
|
|
1690
|
+
const row = el('div', 'queue-item');
|
|
1691
|
+
// 序号
|
|
1692
|
+
const badge = el('span', 'q-badge', `#${idx + 1}`);
|
|
1693
|
+
row.appendChild(badge);
|
|
1694
|
+
// 可编辑文本
|
|
1695
|
+
const txt = document.createElement('input');
|
|
1696
|
+
txt.className = 'q-text';
|
|
1697
|
+
txt.type = 'text';
|
|
1698
|
+
txt.value = item.prompt;
|
|
1699
|
+
txt.title = '点击编辑指令';
|
|
1700
|
+
txt.addEventListener('change', () => {
|
|
1701
|
+
messageQueue[idx].prompt = txt.value.trim() || item.prompt;
|
|
1702
|
+
});
|
|
1703
|
+
row.appendChild(txt);
|
|
1704
|
+
// 图片数量提示
|
|
1705
|
+
if (item.images && item.images.length) {
|
|
1706
|
+
const imgBadge = el('span', 'q-badge', `📎${item.images.length}`);
|
|
1707
|
+
row.appendChild(imgBadge);
|
|
1708
|
+
}
|
|
1709
|
+
// 删除按钮
|
|
1710
|
+
const del = el('button', 'q-del', '×');
|
|
1711
|
+
del.type = 'button';
|
|
1712
|
+
del.title = '移除此指令';
|
|
1713
|
+
del.addEventListener('click', () => {
|
|
1714
|
+
messageQueue.splice(idx, 1);
|
|
1715
|
+
renderQueue();
|
|
1716
|
+
});
|
|
1717
|
+
row.appendChild(del);
|
|
1718
|
+
queueStrip.appendChild(row);
|
|
1719
|
+
});
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
function clearQueue() {
|
|
1723
|
+
queueStrip.innerHTML = '';
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
async function processMessage(prompt, images, showUser = true) {
|
|
1727
|
+
busy = true;
|
|
1728
|
+
// 注意:不再 disable button —— busy 时按钮变停止键,要可点
|
|
1729
|
+
sendBtn.classList.add('busy');
|
|
1730
|
+
sendBtn.title = '停止';
|
|
1731
|
+
sendBtn.setAttribute('aria-label', '停止');
|
|
1732
|
+
if (showUser) appendUser(prompt, images);
|
|
1733
|
+
currentAssistantBubble = null;
|
|
1734
|
+
showThinking();
|
|
1735
|
+
|
|
1736
|
+
try {
|
|
1737
|
+
await chat(prompt, images);
|
|
1738
|
+
} catch (err) {
|
|
1739
|
+
// 用户点停止 -> AbortError;不当错误显示,加一条灰色提示即可
|
|
1740
|
+
if (err?.name === 'AbortError') {
|
|
1741
|
+
appendError('已停止');
|
|
1742
|
+
} else {
|
|
1743
|
+
appendError(err.message ?? String(err));
|
|
1744
|
+
}
|
|
1745
|
+
} finally {
|
|
1746
|
+
hideThinking();
|
|
1747
|
+
busy = false;
|
|
1748
|
+
currentAbortController = null;
|
|
1749
|
+
sendBtn.classList.remove('busy');
|
|
1750
|
+
sendBtn.title = '发送';
|
|
1751
|
+
sendBtn.setAttribute('aria-label', '发送');
|
|
1752
|
+
currentAssistantBubble = null;
|
|
1753
|
+
loadConfig();
|
|
1754
|
+
|
|
1755
|
+
// ---------- 消费队列中的下一条 ----------
|
|
1756
|
+
if (messageQueue.length > 0) {
|
|
1757
|
+
const next = messageQueue.shift();
|
|
1758
|
+
renderQueue(); // 刷新队列 UI(减少一条)
|
|
1759
|
+
// showUser=true: 队列消费时显示用户消息到对话区
|
|
1760
|
+
await processMessage(next.prompt, next.images, true);
|
|
1761
|
+
} else {
|
|
1762
|
+
clearQueue();
|
|
1763
|
+
input.focus();
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
// busy 时点按钮 = 中止;非 busy 时点 = 触发 form submit(默认)
|
|
1769
|
+
sendBtn.addEventListener('click', (e) => {
|
|
1770
|
+
if (busy && currentAbortController) {
|
|
1771
|
+
e.preventDefault();
|
|
1772
|
+
currentAbortController.abort();
|
|
1773
|
+
}
|
|
1774
|
+
});
|
|
1775
|
+
|
|
1776
|
+
// 拼音/中文 IME 组词期间,Enter 是给输入法用的(选候选/切英文),不能触发发送
|
|
1777
|
+
let isComposing = false;
|
|
1778
|
+
input.addEventListener('compositionstart', () => { isComposing = true; });
|
|
1779
|
+
input.addEventListener('compositionend', () => { isComposing = false; });
|
|
1780
|
+
|
|
1781
|
+
input.addEventListener('keydown', (e) => {
|
|
1782
|
+
if (e.key !== 'Enter' || e.shiftKey) return;
|
|
1783
|
+
// 三重判断保兼容(原生 isComposing / IME keyCode 229 / 自己维护的 flag)
|
|
1784
|
+
if (isComposing || e.isComposing || e.keyCode === 229) return;
|
|
1785
|
+
e.preventDefault();
|
|
1786
|
+
form.requestSubmit();
|
|
1787
|
+
});
|
|
1788
|
+
|
|
1789
|
+
clearBtn.addEventListener('click', () => {
|
|
1790
|
+
if (busy) return;
|
|
1791
|
+
if (!confirm('确定清空所有聊天历史?此操作不可恢复。')) return;
|
|
1792
|
+
try { localStorage.clear(); } catch {}
|
|
1793
|
+
location.reload();
|
|
1794
|
+
});
|
|
1795
|
+
|
|
1796
|
+
async function handleNewChat() {
|
|
1797
|
+
if (busy) return;
|
|
1798
|
+
await archiveCurrent();
|
|
1799
|
+
doResetUi();
|
|
1800
|
+
await loadSessionsList();
|
|
1801
|
+
input.focus();
|
|
1802
|
+
}
|
|
1803
|
+
resetBtn.addEventListener('click', handleNewChat);
|
|
1804
|
+
newChatBtn.addEventListener('click', handleNewChat);
|
|
1805
|
+
|
|
1806
|
+
// ---------- 初始化:先拿 config(名称),再 hydrate 历史 ----------
|
|
1807
|
+
(async function init() {
|
|
1808
|
+
await loadConfig(); // 等名称就绪,这样 hydrate 出的头像也是正确的字
|
|
1809
|
+
loadSessionsList(); // 异步拉左侧历史,不阻塞主流程
|
|
1810
|
+
const state = loadState();
|
|
1811
|
+
if (!state || !Array.isArray(state.history) || state.history.length === 0) return;
|
|
1812
|
+
|
|
1813
|
+
sessionId = state.sessionId || null;
|
|
1814
|
+
historyItems = state.history;
|
|
1815
|
+
|
|
1816
|
+
if (sessionId) {
|
|
1817
|
+
sessionMeta.textContent = `session ${sessionId.slice(0, 8)}…`;
|
|
1818
|
+
sessionMeta.classList.add('active');
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
clearEmpty();
|
|
1822
|
+
for (const item of historyItems) {
|
|
1823
|
+
currentAssistantBubble = null; // 每条都新起一个 bubble,不会跨条粘在一起
|
|
1824
|
+
if (item.kind === 'user') {
|
|
1825
|
+
// 恢复时 dataUrl 已丢,只用文件名展示一个占位 chip
|
|
1826
|
+
const ghosts = (item.imageNames || []).map((n) => ({ name: n, dataUrl: '' }));
|
|
1827
|
+
appendUser(item.text, ghosts, false);
|
|
1828
|
+
} else if (item.kind === 'assistant_text') {
|
|
1829
|
+
const bubble = getAssistantBubble();
|
|
1830
|
+
bubble._raw = item.text;
|
|
1831
|
+
bubble.innerHTML = renderMarkdown(item.text);
|
|
1832
|
+
} else if (item.kind === 'tool') {
|
|
1833
|
+
appendToolCard(item.subtype, item.title, item.payload, false);
|
|
1834
|
+
} else if (item.kind === 'plan') {
|
|
1835
|
+
appendPlanCard({ needPlan: true, steps: item.steps, reason: item.reason }, false);
|
|
1836
|
+
} else if (item.kind === 'reflection') {
|
|
1837
|
+
appendReflectionCard({ phase: 'start', step: item.step, goal: item.goal, failure: item.failure }, false);
|
|
1838
|
+
} else if (item.kind === 'error') {
|
|
1839
|
+
appendError(item.message, false);
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
currentAssistantBubble = null;
|
|
1843
|
+
pendingAssistantHistory = null;
|
|
1844
|
+
scrollToBottom();
|
|
1845
|
+
})();
|
|
1846
|
+
|
|
1847
|
+
// ---------- 重启后自动自测 ----------
|
|
1848
|
+
// Luna 改完代码重启前会写 .self-test-pending.json;页面加载时拉取,
|
|
1849
|
+
// 有 pending 就自动发一条测试消息,验证 web 工具是否真能被调用,完成后上报清除
|
|
1850
|
+
let selfTestActive = false;
|
|
1851
|
+
let selfTestToolSeen = false;
|
|
1852
|
+
async function runSelfTestIfPending() {
|
|
1853
|
+
try {
|
|
1854
|
+
const r = await fetch('/api/self-test', { cache: 'no-store' });
|
|
1855
|
+
const task = await r.json();
|
|
1856
|
+
if (!task || !task.pending || !task.prompt) return;
|
|
1857
|
+
// 等 init 跑完再发,避免和 history restore 抢 bubble
|
|
1858
|
+
await new Promise((res) => setTimeout(res, 300));
|
|
1859
|
+
console.log('[Luna] 检测到待自测任务,自动发送:', task.prompt);
|
|
1860
|
+
selfTestActive = true;
|
|
1861
|
+
selfTestToolSeen = false;
|
|
1862
|
+
appendUser(task.prompt, [], true);
|
|
1863
|
+
try {
|
|
1864
|
+
await chat(task.prompt, []);
|
|
1865
|
+
} catch (e) {
|
|
1866
|
+
appendError(`自测发送失败: ${e?.message ?? e}`);
|
|
1867
|
+
}
|
|
1868
|
+
// chat 跑完,检查是否真的调用了 web 工具
|
|
1869
|
+
if (selfTestToolSeen) {
|
|
1870
|
+
const bubble = getAssistantBubble();
|
|
1871
|
+
bubble._raw = (bubble._raw || '') + '\n\n✅ **自测通过**:web 工具调用正常。';
|
|
1872
|
+
bubble.innerHTML = renderMarkdown(bubble._raw);
|
|
1873
|
+
selfTestActive = false;
|
|
1874
|
+
await fetch('/api/self-test/done', { method: 'POST' });
|
|
1875
|
+
} else {
|
|
1876
|
+
appendError('⚠️ 自测未检测到 web 工具调用。最可能原因:当前是旧 session(resume),工具列表锁定在旧值。请在左侧点"新对话"开全新 session,pending 任务会保留,新会话里会自动重新自测。');
|
|
1877
|
+
selfTestActive = false;
|
|
1878
|
+
// 失败不清 pending,等用户开新会话时再自动触发(见 init 后的逻辑)
|
|
1879
|
+
// 不在旧 session 上定时重试,避免空转烧 token
|
|
1880
|
+
}
|
|
1881
|
+
} catch (e) {
|
|
1882
|
+
console.warn('[Luna] 自测流程异常:', e);
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
// 启动时触发一次
|
|
1886
|
+
runSelfTestIfPending();
|
|
1887
|
+
</script>
|
|
1888
|
+
</body>
|
|
1889
|
+
</html>
|