fp-webui 0.1.1.dev0__py3-none-any.whl

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.

Potentially problematic release.


This version of fp-webui might be problematic. Click here for more details.

@@ -0,0 +1,2291 @@
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, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
6
+ <meta name="theme-color" content="#1a1a1a">
7
+ <meta name="apple-mobile-web-app-capable" content="yes">
8
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
9
+ <title>Five Pebbles · WebUI</title>
10
+
11
+ <link rel="icon" type="image/png" href="/static/favicon.png">
12
+
13
+ <!-- Markdown 渲染 -->
14
+ <script src="https://cdn.jsdelivr.net/npm/marked@15.0.6/marked.min.js" defer></script>
15
+ <!-- DOMPurify HTML 清洗(防 XSS) -->
16
+ <script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.4/dist/purify.min.js" defer></script>
17
+ <!-- KaTeX 公式渲染 -->
18
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.css">
19
+ <script src="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.js" defer></script>
20
+ <script src="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/contrib/auto-render.min.js" defer></script>
21
+
22
+ <link rel="preconnect" href="https://fonts.googleapis.com">
23
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
24
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&display=swap" rel="stylesheet">
25
+
26
+ <script src="https://cdn.tailwindcss.com"></script>
27
+ <script>
28
+ tailwind.config = {
29
+ theme: {
30
+ extend: {
31
+ colors: {
32
+ concrete: '#2a2a2a',
33
+ concreteDark: '#1a1a1a',
34
+ concreteLight: '#3d3d3d',
35
+ rust: '#8b3a3a',
36
+ rustLight: '#a65e2e',
37
+ terminal: '#4a6b4a',
38
+ terminalBright: '#6b8c6b',
39
+ warning: '#b58900'
40
+ },
41
+ fontFamily: {
42
+ mono: ['"JetBrains Mono"', 'monospace'],
43
+ },
44
+ boxShadow: {
45
+ 'brutal': '6px 6px 0px 0px #0a0a0a',
46
+ 'brutal-sm': '3px 3px 0px 0px #0a0a0a',
47
+ 'inset': 'inset 4px 4px 8px 0px #0a0a0a',
48
+ }
49
+ }
50
+ }
51
+ }
52
+ </script>
53
+
54
+ <style>
55
+ /* ═══════════════════════════════════════════════════════
56
+ FIVE PEBBLES · WEBUI
57
+ 粗野主义(Brutalist)主题 · 混凝土 + 锈色 + 终端绿
58
+ ═══════════════════════════════════════════════════════ */
59
+
60
+ /* ── Reset ── */
61
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
62
+
63
+ /* ── Design Tokens ── */
64
+ :root {
65
+ --bg-primary: #1a1a1a;
66
+ --bg-secondary: #2a2a2a;
67
+ --bg-tertiary: #3d3d3d;
68
+ --bg-hover: #4a4a4a;
69
+ --text-primary: #d4d4d4;
70
+ --text-secondary: #9e9e9e;
71
+ --text-dim: #5a5a5a;
72
+ --accent: #a65e2e;
73
+ --accent-soft: rgba(166,94,46,0.15);
74
+ --accent-dark: #8b3a3a;
75
+ --green: #4a6b4a;
76
+ --green-bright: #6b8c6b;
77
+ --green-soft: rgba(74,107,74,0.15);
78
+ --yellow: #b58900;
79
+ --yellow-soft: rgba(181,137,0,0.15);
80
+ --red: #c84242;
81
+ --red-soft: rgba(200,66,66,0.15);
82
+ --purple: #9b6b9b;
83
+ --border: #3d3d3d;
84
+ --shadow-color: #0a0a0a;
85
+ --font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
86
+ --touch-min: 44px;
87
+ --safe-top: env(safe-area-inset-top, 0px);
88
+ --safe-bottom: env(safe-area-inset-bottom, 0px);
89
+ --safe-left: env(safe-area-inset-left, 0px);
90
+ --safe-right: env(safe-area-inset-right, 0px);
91
+ }
92
+
93
+ /* ── Base ── */
94
+ html {
95
+ height: -webkit-fill-available;
96
+ -webkit-tap-highlight-color: transparent;
97
+ }
98
+
99
+ html, body {
100
+ height: 100dvh;
101
+ background: var(--bg-primary);
102
+ color: var(--text-primary);
103
+ font-family: var(--font-mono);
104
+ font-size: clamp(14px, 1.8vw, 16px);
105
+ line-height: 1.5;
106
+ overflow: hidden;
107
+ -webkit-font-smoothing: antialiased;
108
+ -moz-osx-font-smoothing: grayscale;
109
+ overscroll-behavior: none;
110
+ }
111
+
112
+ a { color: var(--accent); text-decoration: none; }
113
+ a:hover { color: var(--green-bright); text-decoration: underline; }
114
+
115
+ code {
116
+ font-family: var(--font-mono);
117
+ font-size: 0.875em;
118
+ background: var(--bg-primary);
119
+ padding: 0.15em 0.4em;
120
+ border-radius: 0;
121
+ }
122
+ pre {
123
+ font-family: var(--font-mono);
124
+ font-size: 0.85em;
125
+ background: #0d0d0d;
126
+ padding: clamp(8px, 1.5vw, 14px);
127
+ border: 2px solid var(--border);
128
+ overflow-x: auto;
129
+ margin: 8px 0;
130
+ }
131
+
132
+ ::selection { background: var(--accent-dark); color: #fff; }
133
+
134
+ /* ── Brutalist Scrollbar ── */
135
+ ::-webkit-scrollbar { width: 12px; height: 12px; }
136
+ ::-webkit-scrollbar-track { background: var(--bg-primary); border-left: 2px solid var(--border); }
137
+ ::-webkit-scrollbar-thumb { background: var(--bg-tertiary); border: 2px solid var(--bg-primary); }
138
+ ::-webkit-scrollbar-thumb:hover { background: var(--accent-dark); }
139
+
140
+ /* ── Concrete Noise Overlay ── */
141
+ .noise-bg {
142
+ position: fixed; top: 0; left: 0;
143
+ width: 100%; height: 100%;
144
+ pointer-events: none; z-index: 50;
145
+ opacity: 0.06;
146
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
147
+ }
148
+
149
+ /* ── Rust Gradient Overlay ── */
150
+ .rust-overlay {
151
+ position: fixed; top: 0; left: 0;
152
+ width: 100%; height: 100%;
153
+ pointer-events: none; z-index: 40;
154
+ background: radial-gradient(circle at 80% 20%, rgba(139,58,58,0.15) 0%, transparent 40%),
155
+ radial-gradient(circle at 20% 80%, rgba(166,94,46,0.10) 0%, transparent 40%);
156
+ }
157
+
158
+ /* ── Scanline Effect ── */
159
+ .scanlines {
160
+ background: linear-gradient(
161
+ to bottom,
162
+ rgba(255,255,255,0), rgba(255,255,255,0) 50%,
163
+ rgba(0,0,0,0.08) 50%, rgba(0,0,0,0.08)
164
+ );
165
+ background-size: 100% 4px;
166
+ position: fixed; top: 0; left: 0; right: 0; bottom: 0;
167
+ pointer-events: none; z-index: 45;
168
+ opacity: 0.3;
169
+ }
170
+
171
+ /* ── CRT Flicker ── */
172
+ @keyframes flicker {
173
+ 0% { opacity: 0.97; }
174
+ 5% { opacity: 0.95; }
175
+ 10% { opacity: 0.92; }
176
+ 15% { opacity: 0.95; }
177
+ 20% { opacity: 0.99; }
178
+ 50% { opacity: 0.95; }
179
+ 80% { opacity: 0.93; }
180
+ 100% { opacity: 0.97; }
181
+ }
182
+
183
+ .crt-flicker { animation: flicker 4s infinite; }
184
+
185
+ /* ── Focus ── */
186
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
187
+ button:focus-visible, input:focus-visible, textarea:focus-visible {
188
+ outline: 2px solid var(--accent); outline-offset: 1px;
189
+ }
190
+
191
+ /* ═══════════════════════════════════════════════════════
192
+ MARKDOWN 渲染样式
193
+ ═══════════════════════════════════════════════════════ */
194
+ .msg-content.rendered { line-height: 1.6; }
195
+ .msg-content.rendered p { margin: 0.35em 0; }
196
+ .msg-content.rendered p:first-child { margin-top: 0; }
197
+ .msg-content.rendered p:last-child { margin-bottom: 0; }
198
+ .msg-content.rendered ul, .msg-content.rendered ol { margin: 0.3em 0; padding-left: 1.5em; }
199
+ .msg-content.rendered li { margin: 0.15em 0; }
200
+ .msg-content.rendered blockquote {
201
+ margin: 0.4em 0; padding: 0.3em 1em;
202
+ border-left: 3px solid var(--accent);
203
+ background: var(--bg-primary);
204
+ color: var(--text-secondary);
205
+ }
206
+ .msg-content.rendered pre { margin: 0.4em 0; background: #0d0d0d; border: 1px solid var(--border); }
207
+ .msg-content.rendered pre code {
208
+ background: transparent; padding: 0; border-radius: 0;
209
+ font-size: 0.9em; color: var(--text-primary);
210
+ }
211
+ .msg-content.rendered h1, .msg-content.rendered h2,
212
+ .msg-content.rendered h3, .msg-content.rendered h4 {
213
+ margin: 0.55em 0 0.25em; font-weight: 700; line-height: 1.35;
214
+ }
215
+ .msg-content.rendered h1 { font-size: 1.35em; border-bottom: 2px solid var(--border); padding-bottom: 0.2em; }
216
+ .msg-content.rendered h2 { font-size: 1.2em; border-bottom: 1px solid var(--border); padding-bottom: 0.15em; }
217
+ .msg-content.rendered h3 { font-size: 1.1em; }
218
+ /* ── 表格 wrapper(横向滚动) ── */
219
+ .msg-content.rendered .table-wrapper {
220
+ overflow-x: auto;
221
+ -webkit-overflow-scrolling: touch;
222
+ max-width: 100%;
223
+ margin: 0.4em 0;
224
+ }
225
+ .msg-content.rendered .table-wrapper table {
226
+ width: max-content;
227
+ min-width: 100%;
228
+ border-collapse: collapse;
229
+ font-size: 0.9em;
230
+ margin: 0;
231
+ }
232
+ .msg-content.rendered .table-wrapper th,
233
+ .msg-content.rendered .table-wrapper td {
234
+ border: 1px solid var(--border); padding: 5px 8px; text-align: left;
235
+ }
236
+ .msg-content.rendered .table-wrapper th { background: var(--bg-secondary); font-weight: 700; }
237
+ .msg-content.rendered hr { border: none; border-top: 2px solid var(--border); margin: 0.6em 0; }
238
+ .msg-content.rendered img { max-width: 100%; }
239
+ .katex { font-size: 1.1em; }
240
+ .katex-display { margin: 0.6em 0; overflow-x: auto; overflow-y: hidden; }
241
+
242
+ /* ═══════════════════════════════════════════════════════
243
+ 布局容器
244
+ ═══════════════════════════════════════════════════════ */
245
+ .app-container {
246
+ display: flex;
247
+ flex-direction: column;
248
+ height: 100dvh;
249
+ height: -webkit-fill-available;
250
+ max-width: 1280px;
251
+ margin: 0 auto;
252
+ position: relative;
253
+ }
254
+
255
+ /* ── 顶栏 ──────────────────────────────── */
256
+ .topbar {
257
+ display: flex;
258
+ align-items: center;
259
+ justify-content: space-between;
260
+ padding: 8px 16px;
261
+ padding-top: calc(8px + var(--safe-top));
262
+ background: var(--bg-secondary);
263
+ border-bottom: 4px solid var(--bg-tertiary);
264
+ flex-shrink: 0;
265
+ min-height: 52px;
266
+ gap: 8px;
267
+ position: relative;
268
+ z-index: 10;
269
+ box-shadow: 0 4px 0 0 var(--shadow-color);
270
+ }
271
+ .topbar::before {
272
+ content: '';
273
+ position: absolute;
274
+ top: 0; left: 0;
275
+ width: 100%; height: 3px;
276
+ background: linear-gradient(90deg, var(--accent-dark), var(--yellow), var(--accent-dark));
277
+ opacity: 0.5;
278
+ }
279
+ .topbar-left { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
280
+ .topbar-left .logo-icon { flex-shrink: 0; display: flex; align-items: center; }
281
+ .logo {
282
+ font-size: clamp(17px, 2.2vw, 22px);
283
+ font-weight: 800;
284
+ letter-spacing: -0.5px;
285
+ white-space: nowrap;
286
+ color: var(--accent);
287
+ }
288
+ .logo span span { color: var(--text-primary); }
289
+ .logo-full { display: none; }
290
+ .logo-short { display: inline; }
291
+ .model-badge {
292
+ font-size: clamp(10px, 1.1vw, 12px);
293
+ background: var(--bg-primary);
294
+ color: var(--green-bright);
295
+ padding: 2px clamp(6px, 1vw, 10px);
296
+ border: 2px solid var(--bg-tertiary);
297
+ font-family: var(--font-mono);
298
+ white-space: nowrap;
299
+ overflow: hidden;
300
+ text-overflow: ellipsis;
301
+ }
302
+ .topbar-right { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
303
+ .status-dot {
304
+ display: inline-block;
305
+ width: 8px; height: 8px;
306
+ border-radius: 0;
307
+ background: var(--green-bright);
308
+ animation: pulse 2s ease-in-out infinite;
309
+ flex-shrink: 0;
310
+ }
311
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.35} }
312
+ .session-label {
313
+ font-size: clamp(11px, 1.1vw, 13px);
314
+ color: var(--text-dim);
315
+ overflow: hidden;
316
+ text-overflow: ellipsis;
317
+ white-space: nowrap;
318
+ display: none;
319
+ }
320
+
321
+ .topbar-btn {
322
+ display: inline-flex;
323
+ align-items: center;
324
+ justify-content: center;
325
+ gap: 4px;
326
+ background: var(--bg-primary);
327
+ border: 2px solid var(--bg-tertiary);
328
+ color: var(--text-secondary);
329
+ min-width: var(--touch-min);
330
+ min-height: var(--touch-min);
331
+ padding: 6px 10px;
332
+ cursor: pointer;
333
+ font-size: clamp(11px, 1.1vw, 13px);
334
+ font-family: var(--font-mono);
335
+ font-weight: 700;
336
+ transition: all 0.1s ease;
337
+ user-select: none;
338
+ -webkit-user-select: none;
339
+ touch-action: manipulation;
340
+ box-shadow: 3px 3px 0px 0px var(--shadow-color);
341
+ }
342
+ .topbar-btn:hover { background: var(--bg-tertiary); color: var(--text-primary); border-color: var(--accent); }
343
+ .topbar-btn:active {
344
+ transform: translate(2px, 2px);
345
+ box-shadow: 1px 1px 0px 0px var(--shadow-color);
346
+ }
347
+ .topbar-btn.danger:hover { border-color: var(--red); color: var(--red); }
348
+ .topbar-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none !important; box-shadow: none !important; }
349
+
350
+ /* ── 消息区域 ──────────────────────────── */
351
+ .messages-container {
352
+ flex: 1;
353
+ overflow-y: auto;
354
+ overflow-x: hidden;
355
+ padding: clamp(10px, 2vw, 20px) clamp(12px, 2.5vw, 24px);
356
+ padding-bottom: 0;
357
+ display: flex;
358
+ flex-direction: column;
359
+ gap: 0;
360
+ -webkit-overflow-scrolling: touch;
361
+ overscroll-behavior: contain;
362
+ background: var(--bg-primary);
363
+ position: relative;
364
+ }
365
+ /* Grid background */
366
+ .messages-container::before {
367
+ content: '';
368
+ position: absolute;
369
+ inset: 0;
370
+ opacity: 0.08;
371
+ pointer-events: none;
372
+ background-image:
373
+ linear-gradient(var(--bg-tertiary) 1px, transparent 1px),
374
+ linear-gradient(90deg, var(--bg-tertiary) 1px, transparent 1px);
375
+ background-size: 40px 40px;
376
+ }
377
+ .messages-container:empty::after {
378
+ content: '发送一条消息开始对话';
379
+ display: flex;
380
+ align-items: center;
381
+ justify-content: center;
382
+ height: 100%;
383
+ color: var(--text-dim);
384
+ font-size: clamp(13px, 1.5vw, 15px);
385
+ user-select: none;
386
+ letter-spacing: 0.3px;
387
+ position: relative;
388
+ z-index: 1;
389
+ }
390
+
391
+ /* ── 消息组 ────────────────────────────── */
392
+ .msg-group {
393
+ display: flex;
394
+ flex-direction: column;
395
+ gap: 2px;
396
+ padding: 8px 0;
397
+ border-bottom: 1px solid rgba(61,61,61,0.3);
398
+ position: relative;
399
+ z-index: 1;
400
+ }
401
+ .msg-group:last-child { border-bottom: none; }
402
+
403
+ .msg {
404
+ display: flex;
405
+ gap: clamp(8px, 1.5vw, 14px);
406
+ padding: 6px 0;
407
+ animation: msgFadeIn 0.3s ease-out;
408
+ }
409
+ @keyframes msgFadeIn {
410
+ from { opacity: 0; transform: translateY(8px); }
411
+ to { opacity: 1; transform: translateY(0); }
412
+ }
413
+
414
+ .msg-avatar {
415
+ width: clamp(28px, 3vw, 36px);
416
+ height: clamp(28px, 3vw, 36px);
417
+ flex-shrink: 0;
418
+ display: flex;
419
+ align-items: center;
420
+ justify-content: center;
421
+ font-size: clamp(14px, 1.5vw, 18px);
422
+ margin-top: 3px;
423
+ border: 2px solid var(--bg-tertiary);
424
+ }
425
+ .msg-avatar.user {
426
+ background: var(--bg-primary);
427
+ border-color: var(--accent);
428
+ color: var(--accent);
429
+ }
430
+ .msg-avatar.assistant {
431
+ background: var(--bg-primary);
432
+ border-color: var(--green-bright);
433
+ color: var(--green-bright);
434
+ }
435
+ .msg-avatar.tool {
436
+ background: var(--bg-primary);
437
+ border-color: var(--yellow);
438
+ color: var(--yellow);
439
+ font-size: clamp(12px, 1.2vw, 15px);
440
+ }
441
+
442
+ /* ── tool 消息折叠 ──────────────────────── */
443
+ .tool-msg { cursor: pointer; }
444
+ .tool-msg .tool-msg-toggle {
445
+ display: flex; align-items: center; gap: 0.4em;
446
+ user-select: none;
447
+ }
448
+ .tool-msg .tool-msg-toggle:hover { color: var(--accent); }
449
+ .tool-msg .tool-msg-icon {
450
+ display: inline-block; font-size: 0.75em; color: var(--text-dim);
451
+ transition: transform 0.15s; width: 1em; text-align: center;
452
+ flex-shrink: 0;
453
+ }
454
+ .tool-msg:not(.collapsed) .tool-msg-icon { transform: rotate(90deg); }
455
+ .tool-msg .tool-msg-body {
456
+ margin-top: 0.3em;
457
+ border-left: 3px solid var(--yellow);
458
+ padding-left: 0.8em;
459
+ white-space: pre-wrap;
460
+ word-break: break-all;
461
+ font-size: 0.85em;
462
+ font-family: var(--font-mono);
463
+ color: var(--text-secondary);
464
+ }
465
+ .tool-msg.collapsed .tool-msg-body { display: none; }
466
+ .tool-msg .tool-msg-status {
467
+ margin-left: auto;
468
+ font-size: 0.82em;
469
+ color: var(--text-dim);
470
+ }
471
+
472
+ .msg-body {
473
+ flex: 1;
474
+ min-width: 0;
475
+ display: flex;
476
+ flex-direction: column;
477
+ }
478
+
479
+ .msg-header {
480
+ display: flex;
481
+ align-items: center;
482
+ gap: 6px;
483
+ margin-bottom: 3px;
484
+ }
485
+ .msg-author { font-size: clamp(12px, 1.2vw, 14px); font-weight: 700; }
486
+ .msg-author.user { color: var(--accent); }
487
+ .msg-author.assistant { color: var(--green-bright); }
488
+ .msg-author.tool { color: var(--yellow); }
489
+ .msg-time {
490
+ font-size: clamp(10px, 1vw, 12px);
491
+ color: var(--text-dim);
492
+ }
493
+
494
+ .msg-content {
495
+ font-size: clamp(13px, 1.4vw, 15px);
496
+ line-height: 1.55;
497
+ word-wrap: break-word;
498
+ overflow-wrap: break-word;
499
+ white-space: pre-wrap;
500
+ }
501
+
502
+ /* ── 消息左边界装饰 ── */
503
+ .msg.user-msg { border-left: 3px solid var(--accent); padding-left: 12px; background: var(--accent-soft); }
504
+ .msg.assistant-msg { border-left: 3px solid var(--green); padding-left: 12px; background: var(--green-soft); }
505
+
506
+ /* ── 思考中动画 ───────────────────────── */
507
+ .thinking-indicator {
508
+ display: flex;
509
+ align-items: center;
510
+ gap: 8px;
511
+ color: var(--text-secondary);
512
+ font-size: clamp(12px, 1.2vw, 14px);
513
+ padding: 8px 0 8px calc(clamp(28px, 3vw, 36px) + clamp(8px, 1.5vw, 14px));
514
+ animation: msgFadeIn 0.25s ease-out;
515
+ position: relative;
516
+ z-index: 1;
517
+ }
518
+ .thinking-dots span {
519
+ display: inline-block;
520
+ width: 5px; height: 5px;
521
+ background: var(--accent);
522
+ animation: dotBounce 1.4s ease-in-out infinite both;
523
+ margin: 0 2px;
524
+ }
525
+ .thinking-dots span:nth-child(1) { animation-delay: -0.32s; }
526
+ .thinking-dots span:nth-child(2) { animation-delay: -0.16s; }
527
+ .thinking-dots span:nth-child(3) { animation-delay: 0s; }
528
+ @keyframes dotBounce {
529
+ 0%,80%,100% { transform: scale(0); }
530
+ 40% { transform: scale(1); }
531
+ }
532
+
533
+ .thinking-progress {
534
+ margin: 4px 0 4px calc(clamp(28px, 3vw, 36px) + clamp(8px, 1.5vw, 14px));
535
+ height: 2px;
536
+ background: var(--bg-tertiary);
537
+ overflow: hidden;
538
+ animation: msgFadeIn 0.2s ease-out;
539
+ position: relative;
540
+ z-index: 1;
541
+ }
542
+ .thinking-progress-bar {
543
+ height: 100%;
544
+ width: 100%;
545
+ background: linear-gradient(90deg, var(--accent-dark), var(--accent), var(--accent-dark));
546
+ background-size: 200% 100%;
547
+ animation: progressSlide 1.5s linear infinite;
548
+ }
549
+ @keyframes progressSlide {
550
+ 0% { background-position: 200% 0; }
551
+ 100% { background-position: -200% 0; }
552
+ }
553
+
554
+ /* ── 工具调用卡片 ─────────────────────── */
555
+ .tool-card {
556
+ background: var(--bg-secondary);
557
+ border: 2px solid var(--border);
558
+ padding: 8px 12px;
559
+ margin: 4px 0 4px calc(clamp(28px, 3vw, 36px) + clamp(8px, 1.5vw, 14px));
560
+ animation: toolSlideIn 0.2s ease-out;
561
+ font-size: clamp(12px, 1.2vw, 14px);
562
+ box-shadow: 3px 3px 0px 0px var(--shadow-color);
563
+ position: relative;
564
+ z-index: 1;
565
+ }
566
+ @keyframes toolSlideIn {
567
+ from { opacity: 0; transform: translateX(-8px); }
568
+ to { opacity: 1; transform: translateX(0); }
569
+ }
570
+ .tool-card-header {
571
+ display: flex;
572
+ align-items: center;
573
+ gap: 6px;
574
+ color: var(--yellow);
575
+ font-weight: 700;
576
+ font-family: var(--font-mono);
577
+ font-size: clamp(11px, 1.1vw, 13px);
578
+ cursor: pointer;
579
+ user-select: none;
580
+ min-height: var(--touch-min);
581
+ padding: 2px 0;
582
+ }
583
+ .tool-card-header .icon { transition: transform 0.2s; display: inline-block; }
584
+ .tool-card-header .icon.collapsed { transform: rotate(-90deg); }
585
+ .tool-card-body {
586
+ margin-top: 6px;
587
+ padding: 8px;
588
+ background: rgba(0,0,0,0.3);
589
+ font-family: var(--font-mono);
590
+ font-size: clamp(11px, 1.1vw, 13px);
591
+ color: var(--text-secondary);
592
+ max-height: 300px;
593
+ overflow: auto;
594
+ white-space: pre-wrap;
595
+ word-break: break-all;
596
+ -webkit-overflow-scrolling: touch;
597
+ }
598
+ .tool-card-body.hidden { display: none; }
599
+ .tool-result-success { color: var(--green-bright); }
600
+ .tool-result-error { color: var(--red); }
601
+
602
+ /* ── 输入区域 ─────────────────────────── */
603
+ .input-area {
604
+ padding: 10px 16px calc(12px + var(--safe-bottom));
605
+ background: var(--bg-secondary);
606
+ border-top: 4px solid var(--bg-tertiary);
607
+ flex-shrink: 0;
608
+ position: relative;
609
+ z-index: 10;
610
+ box-shadow: 0 -4px 0 0 var(--shadow-color);
611
+ }
612
+ .input-wrapper {
613
+ display: flex;
614
+ gap: 0;
615
+ align-items: flex-end;
616
+ background: var(--bg-primary);
617
+ border: 2px solid var(--bg-tertiary);
618
+ padding: 0;
619
+ transition: border-color 0.2s;
620
+ box-shadow: inset 4px 4px 8px 0px var(--shadow-color);
621
+ }
622
+ .input-wrapper:focus-within {
623
+ border-color: var(--accent);
624
+ box-shadow: inset 4px 4px 8px 0px var(--shadow-color), 0 0 0 2px var(--bg-primary), 0 0 0 4px var(--accent);
625
+ }
626
+ .input-wrapper textarea {
627
+ flex: 1;
628
+ background: transparent;
629
+ border: none;
630
+ outline: none;
631
+ color: var(--text-primary);
632
+ font-family: var(--font-mono);
633
+ font-size: clamp(14px, 1.5vw, 16px);
634
+ line-height: 1.45;
635
+ padding: clamp(8px, 1.2vw, 12px);
636
+ resize: none;
637
+ min-height: 24px;
638
+ max-height: 160px;
639
+ -webkit-appearance: none;
640
+ }
641
+ .input-wrapper textarea::placeholder { color: var(--text-dim); }
642
+ .input-wrapper textarea { font-size: 16px; }
643
+
644
+ .send-btn {
645
+ display: flex;
646
+ align-items: center;
647
+ justify-content: center;
648
+ background: var(--accent-dark);
649
+ border: none;
650
+ border-left: 2px solid var(--bg-tertiary);
651
+ color: #fff;
652
+ width: var(--touch-min);
653
+ min-width: var(--touch-min);
654
+ height: var(--touch-min);
655
+ cursor: pointer;
656
+ font-size: 20px;
657
+ transition: all 0.1s ease;
658
+ flex-shrink: 0;
659
+ touch-action: manipulation;
660
+ box-shadow: -2px 0 0 0 var(--shadow-color);
661
+ }
662
+ .send-btn:hover { background: var(--accent); }
663
+ .send-btn:active {
664
+ transform: scale(0.95);
665
+ }
666
+ .send-btn:disabled { background: var(--text-dim); cursor: not-allowed; transform: none !important; box-shadow: none !important; }
667
+
668
+ .input-hint {
669
+ display: flex;
670
+ justify-content: space-between;
671
+ margin-top: 6px;
672
+ padding: 0 4px;
673
+ font-size: clamp(10px, 1vw, 12px);
674
+ color: var(--text-dim);
675
+ }
676
+ .input-hint kbd {
677
+ background: var(--bg-primary);
678
+ padding: 1px 5px;
679
+ border: 1px solid var(--border);
680
+ font-family: var(--font-mono);
681
+ font-size: 10px;
682
+ }
683
+
684
+ /* ── 错误消息 ─────────────────────────── */
685
+ .error-msg {
686
+ color: var(--red);
687
+ background: var(--red-soft);
688
+ border: 2px solid rgba(200,66,66,0.3);
689
+ padding: 8px 12px;
690
+ margin: 4px 0;
691
+ font-size: clamp(12px, 1.2vw, 14px);
692
+ animation: msgFadeIn 0.2s ease-out;
693
+ font-weight: 700;
694
+ position: relative;
695
+ z-index: 1;
696
+ }
697
+
698
+ /* ── 回溯按钮 ─────────────────────────── */
699
+ .backtrack-btn {
700
+ display: inline-flex;
701
+ align-items: center;
702
+ justify-content: center;
703
+ background: transparent;
704
+ border: 1px solid var(--border);
705
+ color: var(--text-dim);
706
+ font-size: 10px;
707
+ padding: 1px 5px;
708
+ min-height: 20px;
709
+ margin-left: auto;
710
+ cursor: pointer;
711
+ transition: all 0.15s;
712
+ flex-shrink: 0;
713
+ font-family: var(--font-mono);
714
+ white-space: nowrap;
715
+ touch-action: manipulation;
716
+ font-weight: 600;
717
+ line-height: 1;
718
+ opacity: 0.6;
719
+ }
720
+ .backtrack-btn:hover { opacity: 1; background: var(--accent-dark); color: #fff; border-color: var(--accent-dark); }
721
+ .backtrack-btn:active { transform: scale(0.92); }
722
+
723
+ .history-separator {
724
+ display: flex;
725
+ align-items: center;
726
+ justify-content: center;
727
+ gap: 12px;
728
+ padding: 16px 0 10px;
729
+ color: var(--text-dim);
730
+ font-size: clamp(11px, 1.1vw, 13px);
731
+ user-select: none;
732
+ position: relative;
733
+ z-index: 1;
734
+ }
735
+ .history-separator::before,
736
+ .history-separator::after {
737
+ content: '';
738
+ flex: 1;
739
+ max-width: 80px;
740
+ height: 2px;
741
+ background: var(--border);
742
+ }
743
+
744
+ /* ── 系统消息 ─────────────────────────── */
745
+ .sys-msg {
746
+ text-align: center;
747
+ padding: 10px 0;
748
+ color: var(--text-dim);
749
+ font-size: clamp(12px, 1.2vw, 14px);
750
+ animation: msgFadeIn 0.25s ease-out;
751
+ position: relative;
752
+ z-index: 1;
753
+ }
754
+
755
+ /* ═══════════════════════════════════════════════════════
756
+ 登录遮罩层(粗野主义风格)
757
+ ═══════════════════════════════════════════════════════ */
758
+ .auth-overlay {
759
+ position: fixed;
760
+ inset: 0;
761
+ background: rgba(0,0,0,0.85);
762
+ z-index: 9999;
763
+ display: flex;
764
+ align-items: center;
765
+ justify-content: center;
766
+ animation: overlayFadeIn 0.3s ease-out;
767
+ }
768
+ @keyframes overlayFadeIn {
769
+ from { opacity: 0; }
770
+ to { opacity: 1; }
771
+ }
772
+ .auth-box {
773
+ background: var(--bg-secondary);
774
+ border: 4px solid var(--bg-tertiary);
775
+ padding: clamp(28px, 5vw, 44px) clamp(24px, 4vw, 40px);
776
+ width: min(420px, 90vw);
777
+ text-align: center;
778
+ box-shadow: 6px 6px 0px 0px var(--shadow-color);
779
+ animation: msgFadeIn 0.35s ease-out;
780
+ position: relative;
781
+ }
782
+ /* Decorative corners */
783
+ .auth-box::before {
784
+ content: '';
785
+ position: absolute;
786
+ top: -8px; left: -8px;
787
+ width: 20px; height: 20px;
788
+ background: var(--accent-dark);
789
+ border: 2px solid var(--bg-tertiary);
790
+ }
791
+ .auth-box::after {
792
+ content: '';
793
+ position: absolute;
794
+ bottom: -8px; right: -8px;
795
+ width: 20px; height: 20px;
796
+ background: var(--accent-dark);
797
+ border: 2px solid var(--bg-tertiary);
798
+ }
799
+ .auth-box .logo-icon { margin-bottom: 10px; }
800
+ .auth-box h1 {
801
+ font-size: clamp(22px, 3.5vw, 28px);
802
+ margin-bottom: 6px;
803
+ letter-spacing: -0.5px;
804
+ font-weight: 800;
805
+ color: var(--accent);
806
+ }
807
+ .auth-box p {
808
+ color: var(--text-secondary);
809
+ font-size: clamp(13px, 1.3vw, 15px);
810
+ margin-bottom: 20px;
811
+ }
812
+ .auth-box .token-hint {
813
+ font-size: clamp(11px, 1vw, 12px);
814
+ color: var(--text-dim);
815
+ background: var(--bg-primary);
816
+ border: 2px solid var(--bg-tertiary);
817
+ padding: 10px 14px;
818
+ margin-bottom: 20px;
819
+ word-break: break-all;
820
+ font-family: var(--font-mono);
821
+ line-height: 1.5;
822
+ border-left: 3px solid var(--yellow);
823
+ text-align: left;
824
+ }
825
+ .auth-input {
826
+ width: 100%;
827
+ padding: clamp(10px, 1.5vw, 14px) 16px;
828
+ background: var(--bg-primary);
829
+ border: 2px solid var(--bg-tertiary);
830
+ color: var(--green-bright);
831
+ font-size: 16px;
832
+ font-family: var(--font-mono);
833
+ outline: none;
834
+ transition: border-color 0.2s;
835
+ text-align: center;
836
+ letter-spacing: 2px;
837
+ -webkit-appearance: none;
838
+ box-shadow: inset 4px 4px 8px 0px var(--shadow-color);
839
+ }
840
+ .auth-input:focus {
841
+ border-color: var(--accent);
842
+ box-shadow: inset 4px 4px 8px 0px var(--shadow-color), 0 0 0 2px var(--bg-primary), 0 0 0 4px var(--accent);
843
+ }
844
+ .auth-btn {
845
+ width: 100%;
846
+ margin-top: 12px;
847
+ padding: clamp(10px, 1.5vw, 14px);
848
+ min-height: var(--touch-min);
849
+ background: var(--accent-dark);
850
+ border: 2px solid var(--bg-tertiary);
851
+ color: #fff;
852
+ font-size: clamp(14px, 1.5vw, 16px);
853
+ font-weight: 700;
854
+ cursor: pointer;
855
+ transition: all 0.1s ease;
856
+ touch-action: manipulation;
857
+ font-family: var(--font-mono);
858
+ text-transform: uppercase;
859
+ letter-spacing: 2px;
860
+ box-shadow: 3px 3px 0px 0px var(--shadow-color);
861
+ }
862
+ .auth-btn:hover { background: var(--accent); }
863
+ .auth-btn:active {
864
+ transform: translate(2px, 2px);
865
+ box-shadow: 1px 1px 0px 0px var(--shadow-color);
866
+ }
867
+ .auth-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none !important; box-shadow: none !important; }
868
+ .auth-error {
869
+ color: var(--red);
870
+ font-size: clamp(12px, 1.2vw, 14px);
871
+ margin-top: 10px;
872
+ display: none;
873
+ font-weight: 700;
874
+ }
875
+ .auth-error.show { display: block; }
876
+
877
+ /* ═══════════════════════════════════════════════════════
878
+ 会话列表浮层(粗野主义风格)
879
+ ═══════════════════════════════════════════════════════ */
880
+ .modal-overlay {
881
+ display: none;
882
+ position: fixed;
883
+ inset: 0;
884
+ background: rgba(0,0,0,0.7);
885
+ z-index: 100;
886
+ align-items: flex-end;
887
+ justify-content: center;
888
+ animation: overlayFadeIn 0.2s ease-out;
889
+ }
890
+ @keyframes overlayFadeIn {
891
+ from { opacity: 0; }
892
+ to { opacity: 1; }
893
+ }
894
+ .modal-overlay.active { display: flex; }
895
+
896
+ .modal {
897
+ background: var(--bg-secondary);
898
+ border: 4px solid var(--bg-tertiary);
899
+ border-bottom: none;
900
+ padding: 20px 20px calc(20px + var(--safe-bottom));
901
+ width: 100%;
902
+ max-width: 680px;
903
+ max-height: 75vh;
904
+ display: flex;
905
+ flex-direction: column;
906
+ box-shadow: 0 -6px 0px 0px var(--shadow-color);
907
+ animation: modalSlideUp 0.3s ease-out;
908
+ }
909
+ @keyframes modalSlideUp {
910
+ from { transform: translateY(100%); }
911
+ to { transform: translateY(0); }
912
+ }
913
+
914
+ .modal-header {
915
+ display: flex;
916
+ align-items: center;
917
+ justify-content: space-between;
918
+ margin-bottom: 16px;
919
+ flex-shrink: 0;
920
+ border-bottom: 2px solid var(--border);
921
+ padding-bottom: 12px;
922
+ }
923
+ .modal-title {
924
+ font-size: clamp(16px, 2vw, 20px);
925
+ font-weight: 800;
926
+ color: var(--accent);
927
+ }
928
+ .modal-close {
929
+ display: flex;
930
+ align-items: center;
931
+ justify-content: center;
932
+ background: none;
933
+ border: 2px solid var(--border);
934
+ color: var(--text-secondary);
935
+ cursor: pointer;
936
+ width: var(--touch-min);
937
+ height: var(--touch-min);
938
+ font-size: 18px;
939
+ transition: all 0.15s;
940
+ touch-action: manipulation;
941
+ font-weight: 700;
942
+ }
943
+ .modal-close:hover { background: var(--accent-dark); color: #fff; border-color: var(--accent-dark); }
944
+
945
+ .modal-body {
946
+ flex: 1;
947
+ overflow-y: auto;
948
+ -webkit-overflow-scrolling: touch;
949
+ }
950
+
951
+ .session-item {
952
+ display: flex;
953
+ align-items: center;
954
+ justify-content: space-between;
955
+ gap: 8px;
956
+ padding: 12px;
957
+ min-height: 56px;
958
+ cursor: pointer;
959
+ transition: background 0.15s;
960
+ border: 2px solid transparent;
961
+ margin-bottom: 4px;
962
+ touch-action: manipulation;
963
+ }
964
+ .session-item:hover { background: var(--bg-primary); }
965
+ .session-item:active { background: var(--bg-tertiary); }
966
+ .session-item.current { border-color: var(--green-bright); background: var(--green-soft); }
967
+ .session-item-info { flex: 1; min-width: 0; }
968
+ .session-item-summary {
969
+ font-size: clamp(13px, 1.3vw, 15px);
970
+ font-weight: 600;
971
+ overflow: hidden;
972
+ text-overflow: ellipsis;
973
+ white-space: nowrap;
974
+ }
975
+ .session-item-meta {
976
+ font-size: clamp(11px, 1vw, 13px);
977
+ color: var(--text-dim);
978
+ margin-top: 2px;
979
+ }
980
+ .session-item-badge {
981
+ font-size: 10px;
982
+ color: var(--green-bright);
983
+ flex-shrink: 0;
984
+ font-weight: 700;
985
+ }
986
+ .session-load-btn {
987
+ background: var(--bg-primary);
988
+ border: 2px solid var(--border);
989
+ color: var(--text-primary);
990
+ padding: 6px 12px;
991
+ min-height: var(--touch-min);
992
+ cursor: pointer;
993
+ font-size: clamp(11px, 1.1vw, 13px);
994
+ flex-shrink: 0;
995
+ white-space: nowrap;
996
+ transition: all 0.15s;
997
+ touch-action: manipulation;
998
+ font-family: var(--font-mono);
999
+ font-weight: 700;
1000
+ box-shadow: 2px 2px 0px 0px var(--shadow-color);
1001
+ }
1002
+ .session-load-btn:hover { background: var(--bg-tertiary); border-color: var(--accent); }
1003
+ .session-load-btn:active {
1004
+ transform: translate(1px, 1px);
1005
+ box-shadow: 1px 1px 0px 0px var(--shadow-color);
1006
+ }
1007
+ .session-del-btn {
1008
+ display: flex;
1009
+ align-items: center;
1010
+ justify-content: center;
1011
+ background: transparent;
1012
+ border: 1px solid transparent;
1013
+ color: var(--text-dim);
1014
+ width: var(--touch-min);
1015
+ height: var(--touch-min);
1016
+ cursor: pointer;
1017
+ font-size: 16px;
1018
+ flex-shrink: 0;
1019
+ transition: all 0.15s;
1020
+ touch-action: manipulation;
1021
+ }
1022
+ .session-del-btn:hover { color: var(--red); border-color: var(--red); }
1023
+
1024
+ .no-sessions {
1025
+ text-align: center;
1026
+ color: var(--text-dim);
1027
+ padding: 40px 20px;
1028
+ font-size: clamp(13px, 1.3vw, 15px);
1029
+ }
1030
+
1031
+ .modal-drag-handle {
1032
+ display: none;
1033
+ width: 36px;
1034
+ height: 4px;
1035
+ background: var(--text-dim);
1036
+ margin: 0 auto 12px;
1037
+ flex-shrink: 0;
1038
+ }
1039
+
1040
+ /* ═══════════════════════════════════════════════════════
1041
+ 响应式断点
1042
+ ═══════════════════════════════════════════════════════ */
1043
+
1044
+ @media (min-width: 769px) {
1045
+ .session-label { display: inline; }
1046
+ .modal-overlay { align-items: center; }
1047
+ .modal { border: 4px solid var(--bg-tertiary); border-radius: 0; max-height: 80vh; }
1048
+ .modal-drag-handle { display: none !important; }
1049
+ .topbar { padding: 10px 24px; min-height: 56px; }
1050
+ .messages-container { padding: 16px 24px; }
1051
+ .input-area { padding: 12px 24px 20px; }
1052
+ .logo-full { display: inline; }
1053
+ .logo-short { display: none; }
1054
+ }
1055
+
1056
+ @media (max-width: 768px) {
1057
+ .topbar-btn .btn-label { display: none; }
1058
+ .topbar-btn { padding: 6px 8px; min-width: 40px; }
1059
+ .modal-drag-handle { display: block; }
1060
+ .modal { padding: 12px 16px calc(16px + var(--safe-bottom)); border-left: none; border-right: none; }
1061
+ }
1062
+
1063
+ @media (max-width: 420px) {
1064
+ .topbar { padding: 6px 10px; padding-top: calc(6px + var(--safe-top)); gap: 4px; }
1065
+ .topbar-right { gap: 4px; }
1066
+ .topbar-btn { padding: 4px 6px; min-width: 36px; min-height: 36px; font-size: 11px; }
1067
+ .model-badge { max-width: 80px; }
1068
+ .msg { gap: 8px; }
1069
+ .input-area { padding: 8px 10px calc(10px + var(--safe-bottom)); }
1070
+ .input-wrapper { padding: 0; }
1071
+ .send-btn { width: 40px; min-width: 40px; height: 40px; font-size: 18px; }
1072
+ .backtrack-btn { font-size: 9px; padding: 1px 4px; min-height: 18px; }
1073
+ .auth-box { padding: 24px 20px; }
1074
+ }
1075
+
1076
+ @media (prefers-reduced-motion: reduce) {
1077
+ *, *::before, *::after {
1078
+ animation-duration: 0.01ms !important;
1079
+ animation-iteration-count: 1 !important;
1080
+ transition-duration: 0.01ms !important;
1081
+ }
1082
+ }
1083
+
1084
+ @media print {
1085
+ .topbar, .input-area { display: none; }
1086
+ .messages-container { overflow: visible; height: auto; }
1087
+ }
1088
+ </style>
1089
+ </head>
1090
+ <body class="crt-flicker">
1091
+
1092
+ <!-- ═══ 装饰层 ═══ -->
1093
+ <div class="noise-bg"></div>
1094
+ <div class="rust-overlay"></div>
1095
+ <div class="scanlines"></div>
1096
+
1097
+ <!-- ═══ 登录遮罩层 ═══ -->
1098
+ <div class="auth-overlay" id="authOverlay" role="dialog" aria-label="登录验证">
1099
+ <div class="auth-box">
1100
+ <div class="logo-icon">
1101
+ <img src="/static/favicon.png" width="52" height="54" alt="Five Pebbles 图标" style="vertical-align:middle">
1102
+ </div>
1103
+ <h1>Five Pebbles</h1>
1104
+ <p>请输入启动 Token 以连接</p>
1105
+ <div class="token-hint">
1106
+ ⚠ Token 在服务器启动时打印在终端中<br>
1107
+ 或查看项目根目录的 <code style="color:var(--yellow);background:transparent;padding:0">.webui_token</code> 文件
1108
+ </div>
1109
+ <input type="password" class="auth-input" id="authInput"
1110
+ placeholder="ENTER_ACCESS_TOKEN..."
1111
+ autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
1112
+ aria-label="Token 输入框"
1113
+ onkeydown="if(event.key==='Enter') doLogin()">
1114
+ <button class="auth-btn" id="authBtn" onclick="doLogin()" aria-label="连接">
1115
+ 🔓 连接
1116
+ </button>
1117
+ <div class="auth-error" id="authError" role="alert">⛔ Token 无效,请重试</div>
1118
+ </div>
1119
+ </div>
1120
+
1121
+ <!-- ═══ 主应用 ═══ -->
1122
+ <div class="app-container">
1123
+
1124
+ <!-- ─── 顶栏 ─── -->
1125
+ <header class="topbar" role="banner">
1126
+ <div class="topbar-left">
1127
+ <span class="logo-icon" aria-hidden="true">
1128
+ <img src="/static/favicon.png" width="24" height="25" alt="" style="vertical-align:middle;margin-top:-3px">
1129
+ </span>
1130
+ <span class="logo">
1131
+ <span class="logo-full"><span>Five</span> Pebbles</span>
1132
+ <span class="logo-short"><span>F</span>P</span>
1133
+ </span>
1134
+ <span class="model-badge" id="modelBadge" aria-label="当前模型">—</span>
1135
+ </div>
1136
+ <div class="topbar-right">
1137
+ <span class="session-label" id="sessionLabel" title="当前会话" aria-label="当前会话">—</span>
1138
+ <span class="status-dot" id="statusDot" aria-label="连接状态"></span>
1139
+ <button class="topbar-btn" onclick="openSessions()" title="历史会话" aria-label="打开历史会话列表">
1140
+ <span aria-hidden="true">📋</span><span class="btn-label"> 会话</span>
1141
+ </button>
1142
+ <button class="topbar-btn" onclick="newAgent()" title="新建 Agent(shutdown 旧实例,创建全新实例和会话)" aria-label="新建 Agent">
1143
+ <span aria-hidden="true">🆕</span><span class="btn-label"> 新建</span>
1144
+ </button>
1145
+ <button class="topbar-btn" onclick="reloadAgent()" title="热重载 Agent(不重启服务器,加载修改后的代码)" aria-label="热重载 Agent">
1146
+ <span aria-hidden="true">🔄</span><span class="btn-label"> 重载</span>
1147
+ </button>
1148
+ <button class="topbar-btn danger" onclick="clearSession()" title="清空当前会话" aria-label="清空当前会话">
1149
+ <span aria-hidden="true">🗑️</span><span class="btn-label"> 清空</span>
1150
+ </button>
1151
+ </div>
1152
+ </header>
1153
+
1154
+ <!-- ─── 消息列表 ─── -->
1155
+ <main class="messages-container" id="messagesContainer" role="log" aria-label="聊天消息" aria-live="polite"></main>
1156
+
1157
+ <!-- ─── 输入区 ─── -->
1158
+ <div class="input-area" role="form" aria-label="消息输入">
1159
+ <div class="input-wrapper">
1160
+ <textarea id="inputField" rows="1"
1161
+ placeholder="输入指令..."
1162
+ autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
1163
+ aria-label="输入消息"
1164
+ onkeydown="handleKeyDown(event)"
1165
+ oninput="autoResize(this)"
1166
+ onfocus="handleInputFocus()"></textarea>
1167
+ <button class="send-btn" id="sendBtn" onclick="sendMessage()" title="发送 (Enter)" aria-label="发送消息">
1168
+
1169
+ </button>
1170
+ </div>
1171
+ <div class="input-hint">
1172
+ <span><kbd>Enter</kbd> 发送 · <kbd>Shift+Enter</kbd> 换行</span>
1173
+ <span id="connectionStatus" aria-live="polite"><span style="color:var(--green-bright)">🟢</span> 已连接</span>
1174
+ </div>
1175
+ </div>
1176
+ </div>
1177
+
1178
+ <!-- ═══ 会话列表浮层 ═══ -->
1179
+ <div class="modal-overlay" id="sessionsModal" role="dialog" aria-label="历史会话列表">
1180
+ <div class="modal">
1181
+ <div class="modal-drag-handle" aria-hidden="true"></div>
1182
+ <div class="modal-header">
1183
+ <span class="modal-title">📋 历史会话</span>
1184
+ <button class="modal-close" onclick="closeSessions()" aria-label="关闭会话列表">✕</button>
1185
+ </div>
1186
+ <div class="modal-body" id="sessionsList">
1187
+ <div class="no-sessions">加载中...</div>
1188
+ </div>
1189
+ </div>
1190
+ </div>
1191
+
1192
+ <script>
1193
+ /* ═══════════════════════════════════════════════════════════
1194
+ Five Pebbles WebUI — 前端逻辑
1195
+ ═══════════════════════════════════════════════════════════ */
1196
+
1197
+ // ── 状态 ──
1198
+ let ws = null;
1199
+ let sessionId = null;
1200
+ let processing = false;
1201
+ let reconnectTimer = null;
1202
+ let lastGroupEl = null;
1203
+ let thinkingEl = null;
1204
+ let progressEl = null;
1205
+
1206
+ // ── DOM 引用 ──
1207
+ const messagesEl = document.getElementById('messagesContainer');
1208
+ const inputEl = document.getElementById('inputField');
1209
+ const sendBtn = document.getElementById('sendBtn');
1210
+ const sessionLabel = document.getElementById('sessionLabel');
1211
+ const modelBadge = document.getElementById('modelBadge');
1212
+ const statusEl = document.getElementById('connectionStatus');
1213
+
1214
+ // ── 认证 ──
1215
+ const AUTH_KEY = 'webui_token';
1216
+ function getToken() { return sessionStorage.getItem(AUTH_KEY) || ''; }
1217
+ function setToken(t) { sessionStorage.setItem(AUTH_KEY, t); }
1218
+ function clearToken() { sessionStorage.removeItem(AUTH_KEY); }
1219
+ function isAuthed() { return !!getToken(); }
1220
+
1221
+ function withAuth(opts) {
1222
+ opts = opts || {};
1223
+ opts.headers = opts.headers || {};
1224
+ opts.headers['Authorization'] = 'Bearer ' + getToken();
1225
+ return opts;
1226
+ }
1227
+
1228
+ async function authFetch(url, opts) {
1229
+ var res = await fetch(url, withAuth(opts));
1230
+ if (res.status === 401) {
1231
+ clearToken();
1232
+ showLogin();
1233
+ throw new Error('未授权');
1234
+ }
1235
+ return res;
1236
+ }
1237
+
1238
+ // ── 登录 ──
1239
+ function showLogin() {
1240
+ document.getElementById('authOverlay').style.display = 'flex';
1241
+ document.getElementById('authInput').value = '';
1242
+ document.getElementById('authError').classList.remove('show');
1243
+ document.getElementById('authBtn').disabled = false;
1244
+ setTimeout(function() { document.getElementById('authInput').focus(); }, 100);
1245
+ }
1246
+
1247
+ function hideLogin() {
1248
+ document.getElementById('authOverlay').style.display = 'none';
1249
+ }
1250
+
1251
+ async function doLogin() {
1252
+ var token = document.getElementById('authInput').value.trim();
1253
+ if (!token) return;
1254
+ var btn = document.getElementById('authBtn');
1255
+ var err = document.getElementById('authError');
1256
+ btn.disabled = true;
1257
+ err.classList.remove('show');
1258
+ try {
1259
+ var res = await fetch('/api/auth', {
1260
+ method: 'POST',
1261
+ headers: { 'Content-Type': 'application/json' },
1262
+ body: JSON.stringify({ token: token })
1263
+ });
1264
+ if (res.ok) {
1265
+ setToken(token);
1266
+ hideLogin();
1267
+ initApp();
1268
+ } else {
1269
+ err.textContent = '⛔ Token 无效,请重试';
1270
+ err.classList.add('show');
1271
+ }
1272
+ } catch (e) {
1273
+ err.textContent = '❌ 服务器连接失败';
1274
+ err.classList.add('show');
1275
+ }
1276
+ btn.disabled = false;
1277
+ }
1278
+
1279
+ // ── 工具函数 ──
1280
+ function escapeHtml(text) {
1281
+ var div = document.createElement('div');
1282
+ div.textContent = text;
1283
+ return div.innerHTML;
1284
+ }
1285
+
1286
+ function formatTime(ts) {
1287
+ var d = new Date(ts * 1000);
1288
+ return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
1289
+ }
1290
+
1291
+ function scrollToBottom() {
1292
+ requestAnimationFrame(function() {
1293
+ messagesEl.scrollTop = messagesEl.scrollHeight;
1294
+ });
1295
+ }
1296
+
1297
+ function autoResize(el) {
1298
+ el.style.height = 'auto';
1299
+ el.style.height = Math.min(el.scrollHeight, 160) + 'px';
1300
+ }
1301
+
1302
+ // iOS 输入框 focus 时不自动缩放(已在 meta 中 user-scalable=no)
1303
+ function handleInputFocus() {
1304
+ // 某些 Android 浏览器需要额外处理
1305
+ }
1306
+
1307
+ // ── 消息渲染 ──
1308
+
1309
+ function renderMarkdown(content) {
1310
+ if (!content) return '';
1311
+ if (typeof marked === 'undefined') {
1312
+ return escapeHtml(content).replace(/\n/g, '<br>');
1313
+ }
1314
+ try {
1315
+ var html = marked.parse(content, { breaks: true, gfm: true });
1316
+ // DOMPurify 清洗 HTML,移除恶意标签/属性
1317
+ if (typeof DOMPurify !== 'undefined') {
1318
+ html = DOMPurify.sanitize(html);
1319
+ } else {
1320
+ // DOMPurify 不可用时,降级到 escapeHtml(牺牲格式保安全)
1321
+ return escapeHtml(content).replace(/\n/g, '<br>');
1322
+ }
1323
+ // 包裹所有 <table> 使其可横向滚动
1324
+ html = html.replace(/<table>/g, '<div class="table-wrapper"><table>');
1325
+ html = html.replace(/<\/table>/g, '</table></div>');
1326
+ return html;
1327
+ } catch (e) {
1328
+ return escapeHtml(content);
1329
+ }
1330
+ }
1331
+
1332
+ function renderMath(root) {
1333
+ if (typeof renderMathInElement === 'undefined') return;
1334
+ try {
1335
+ renderMathInElement(root || document, {
1336
+ delimiters: [
1337
+ { left: '$$', right: '$$', display: true },
1338
+ { left: '$', right: '$', display: false }
1339
+ ],
1340
+ throwOnError: false,
1341
+ macros: { '\\R': '\\mathbb{R}', '\\N': '\\mathbb{N}', '\\Z': '\\mathbb{Z}' }
1342
+ });
1343
+ } catch (e) {}
1344
+ }
1345
+
1346
+ function getOrCreateMsgGroup() {
1347
+ if (!lastGroupEl || !document.body.contains(lastGroupEl)) {
1348
+ lastGroupEl = document.createElement('div');
1349
+ lastGroupEl.className = 'msg-group';
1350
+ messagesEl.appendChild(lastGroupEl);
1351
+ }
1352
+ return lastGroupEl;
1353
+ }
1354
+
1355
+ // 点击折叠/展开 tool 消息
1356
+ function toggleToolMsg(event) {
1357
+ if (event.target.closest('.backtrack-btn') || event.target.closest('.msg-avatar')) return;
1358
+ var msg = event.currentTarget;
1359
+ if (!msg.classList.contains('tool-msg')) return;
1360
+ msg.classList.toggle('collapsed');
1361
+ }
1362
+
1363
+ function addUserMessage(content) {
1364
+ var group = getOrCreateMsgGroup();
1365
+ liveMsgIndex++;
1366
+ var idx = liveMsgIndex;
1367
+
1368
+ var msg = document.createElement('div');
1369
+ msg.className = 'msg';
1370
+ msg.innerHTML =
1371
+ '<div class="msg-avatar user" aria-hidden="true">👤</div>' +
1372
+ '<div class="msg-body">' +
1373
+ '<div class="msg-header">' +
1374
+ '<span class="msg-author user">OPERATOR</span>' +
1375
+ '<span class="msg-time">' + formatTime(Date.now()/1000) + '</span>' +
1376
+ '<button class="backtrack-btn" title="回溯到此位置" data-index="' + idx + '" aria-label="回溯到位置 ' + idx + '">↩ 回溯</button>' +
1377
+ '</div>' +
1378
+ '<div class="msg-content">' + escapeHtml(content) + '</div>' +
1379
+ '</div>';
1380
+ group.appendChild(msg);
1381
+ scrollToBottom();
1382
+ }
1383
+
1384
+ function addAssistantMessage(content) {
1385
+ var group = getOrCreateMsgGroup();
1386
+ liveMsgIndex++;
1387
+ var idx = liveMsgIndex;
1388
+
1389
+ var msg = document.createElement('div');
1390
+ msg.className = 'msg';
1391
+ msg.innerHTML =
1392
+ '<div class="msg-avatar assistant" aria-hidden="true">' +
1393
+ '<img src="/static/favicon.png" width="18" height="19" alt="" style="vertical-align:middle">' +
1394
+ '</div>' +
1395
+ '<div class="msg-body">' +
1396
+ '<div class="msg-header">' +
1397
+ '<span class="msg-author assistant">FIVE PEBBLES</span>' +
1398
+ '<span class="msg-time">' + formatTime(Date.now()/1000) + '</span>' +
1399
+ '<button class="backtrack-btn" title="回溯到此位置" data-index="' + idx + '" aria-label="回溯到位置 ' + idx + '">↩ 回溯</button>' +
1400
+ '</div>' +
1401
+ '<div class="msg-content rendered"></div>' +
1402
+ '</div>';
1403
+ group.appendChild(msg);
1404
+
1405
+ var contentEl = msg.querySelector('.msg-content');
1406
+ if (content) {
1407
+ contentEl.innerHTML = renderMarkdown(content);
1408
+ renderMath();
1409
+ }
1410
+
1411
+ scrollToBottom();
1412
+ return contentEl;
1413
+ }
1414
+
1415
+ function addToolCall(name, args) {
1416
+ removeThinking();
1417
+ var group = getOrCreateMsgGroup();
1418
+
1419
+ // ⚠️ 关键修复:tool 消息在后端文件中也占一个索引位置,
1420
+ // 所以 liveMsgIndex 必须递增,否则后续 user/assistant 消息的
1421
+ // 回溯按钮 data-index 会与后端 /back 索引错位(偏差 = tool 消息数)。
1422
+ liveMsgIndex++;
1423
+ var idx = liveMsgIndex;
1424
+
1425
+ var prettyArgs = '';
1426
+ try {
1427
+ var parsed = typeof args === 'string' ? JSON.parse(args) : args;
1428
+ prettyArgs = JSON.stringify(parsed, null, 2);
1429
+ } catch(e) {
1430
+ prettyArgs = args || '{}';
1431
+ }
1432
+
1433
+ var msg = document.createElement('div');
1434
+ msg.className = 'msg tool-msg collapsed';
1435
+ msg.dataset.toolName = name;
1436
+ msg.dataset.msgIndex = idx;
1437
+ msg.onclick = toggleToolMsg;
1438
+
1439
+ msg.innerHTML =
1440
+ '<div class="msg-avatar tool" aria-hidden="true">🔧</div>' +
1441
+ '<div class="msg-body">' +
1442
+ '<div class="msg-header">' +
1443
+ '<span class="msg-author tool">Tool</span>' +
1444
+ '<span class="msg-time">#' + idx + '</span>' +
1445
+ '<button class="backtrack-btn" title="回溯到此位置" data-index="' + idx + '" aria-label="回溯到位置 ' + idx + '">↩ 回溯</button>' +
1446
+ '</div>' +
1447
+ '<div class="msg-content rendered">' +
1448
+ '<div class="tool-msg-toggle">' +
1449
+ '<span class="tool-msg-icon">▶</span>' +
1450
+ '<strong>🛠️ ' + escapeHtml(name) + '</strong>' +
1451
+ '<span class="tool-msg-status">⏳ 运行中...</span>' +
1452
+ '</div>' +
1453
+ '<div class="tool-msg-body">' +
1454
+ '<pre style="font-size:0.85em;opacity:0.6;margin:0;white-space:pre-wrap;word-break:break-all">' + escapeHtml(prettyArgs) + '</pre>' +
1455
+ '</div>' +
1456
+ '</div>' +
1457
+ '</div>';
1458
+
1459
+ group.appendChild(msg);
1460
+ scrollToBottom();
1461
+ }
1462
+
1463
+ function addToolResult(name, result, isError) {
1464
+ var msgs = document.querySelectorAll('.msg[data-tool-name]');
1465
+ var target = null;
1466
+ for (var i = msgs.length - 1; i >= 0; i--) {
1467
+ if (msgs[i].dataset.toolName === name) {
1468
+ target = msgs[i];
1469
+ break;
1470
+ }
1471
+ }
1472
+
1473
+ if (!target) {
1474
+ var group = getOrCreateMsgGroup();
1475
+ target = document.createElement('div');
1476
+ target.className = 'msg tool-msg collapsed';
1477
+ target.onclick = toggleToolMsg;
1478
+ target.innerHTML =
1479
+ '<div class="msg-avatar tool" aria-hidden="true">🔧</div>' +
1480
+ '<div class="msg-body">' +
1481
+ '<div class="msg-header">' +
1482
+ '<span class="msg-author tool">Tool</span>' +
1483
+ '<span class="msg-time">' + formatTime(Date.now()/1000) + '</span>' +
1484
+ '</div>' +
1485
+ '<div class="msg-content rendered">' +
1486
+ '<div class="tool-msg-toggle">' +
1487
+ '<span class="tool-msg-icon">▶</span>' +
1488
+ '<strong>🛠️ ' + escapeHtml(name) + '</strong>' +
1489
+ '</div>' +
1490
+ '<div class="tool-msg-body"></div>' +
1491
+ '</div>' +
1492
+ '</div>';
1493
+ group.appendChild(target);
1494
+ }
1495
+
1496
+ // 更新运行状态为完成
1497
+ var statusEl = target.querySelector('.tool-msg-status');
1498
+ if (statusEl) {
1499
+ statusEl.textContent = isError ? '❌ 失败' : '✅ 完成';
1500
+ }
1501
+
1502
+ var bodyEl = target.querySelector('.tool-msg-body');
1503
+ if (bodyEl) {
1504
+ bodyEl.innerHTML = escapeHtml(result);
1505
+ }
1506
+
1507
+ scrollToBottom();
1508
+ }
1509
+
1510
+ function showThinking() {
1511
+ removeThinking();
1512
+ var group = getOrCreateMsgGroup();
1513
+
1514
+ progressEl = document.createElement('div');
1515
+ progressEl.className = 'thinking-progress';
1516
+ progressEl.innerHTML = '<div class="thinking-progress-bar"></div>';
1517
+ group.appendChild(progressEl);
1518
+
1519
+ thinkingEl = document.createElement('div');
1520
+ thinkingEl.className = 'thinking-indicator';
1521
+ thinkingEl.innerHTML =
1522
+ '<span>⏳ 思考中</span>' +
1523
+ '<span class="thinking-dots" aria-label="思考中">' +
1524
+ '<span></span><span></span><span></span>' +
1525
+ '</span>';
1526
+ group.appendChild(thinkingEl);
1527
+
1528
+ scrollToBottom();
1529
+ }
1530
+
1531
+ function removeThinking() {
1532
+ if (thinkingEl && document.body.contains(thinkingEl)) { thinkingEl.remove(); thinkingEl = null; }
1533
+ if (progressEl && document.body.contains(progressEl)) { progressEl.remove(); progressEl = null; }
1534
+ }
1535
+
1536
+ function showError(msg) {
1537
+ var group = getOrCreateMsgGroup();
1538
+ var err = document.createElement('div');
1539
+ err.className = 'error-msg';
1540
+ err.setAttribute('role', 'alert');
1541
+ err.textContent = '❌ ' + msg;
1542
+ group.appendChild(err);
1543
+ scrollToBottom();
1544
+ }
1545
+
1546
+ function toggleToolBody(headerEl) {
1547
+ var icon = headerEl.querySelector('.icon');
1548
+ var body = headerEl.nextElementSibling;
1549
+ if (icon && body) {
1550
+ var expanded = icon.classList.toggle('collapsed');
1551
+ body.classList.toggle('hidden');
1552
+ headerEl.setAttribute('aria-expanded', !expanded);
1553
+ }
1554
+ }
1555
+
1556
+ function setProcessing(state) {
1557
+ processing = state;
1558
+ inputEl.disabled = state;
1559
+
1560
+ if (state) {
1561
+ // 处理中 → 按钮变为中断按钮 ⏹️
1562
+ sendBtn.disabled = false;
1563
+ sendBtn.textContent = '⏹️';
1564
+ sendBtn.style.background = '#8b3a3a';
1565
+ sendBtn.style.borderLeftColor = '#5a2a2a';
1566
+ sendBtn.onclick = cancelMessage;
1567
+ sendBtn.title = '中断 (Ctrl+Shift+C)';
1568
+ sendBtn.setAttribute('aria-label', '中断');
1569
+ } else {
1570
+ // 空闲 → 恢复为发送按钮 ➤
1571
+ sendBtn.disabled = false;
1572
+ sendBtn.textContent = '➤';
1573
+ sendBtn.style.background = '';
1574
+ sendBtn.style.borderLeftColor = '';
1575
+ sendBtn.onclick = sendMessage;
1576
+ sendBtn.title = '发送 (Enter)';
1577
+ sendBtn.setAttribute('aria-label', '发送消息');
1578
+ inputEl.focus();
1579
+ }
1580
+ }
1581
+
1582
+ function cancelMessage() {
1583
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
1584
+ if (!processing) return;
1585
+
1586
+ // 发送取消请求 → 后端 task.cancel() 注入 CancelledError
1587
+ ws.send(JSON.stringify({ type: 'cancel' }));
1588
+
1589
+ // 按钮临时禁用,等待后端 cancelled 事件返回后再恢复
1590
+ sendBtn.disabled = true;
1591
+ sendBtn.textContent = '⏳';
1592
+ sendBtn.style.background = '#555';
1593
+ sendBtn.style.borderLeftColor = '#555';
1594
+
1595
+ // 安全网:5 秒后如果后端没响应,强制恢复
1596
+ var cancelTimeout = setTimeout(function() {
1597
+ if (processing) setProcessing(false);
1598
+ }, 5000);
1599
+ // 存储 timeout id 以便 cancelled 事件到达时清理
1600
+ window._cancelTimeout = cancelTimeout;
1601
+ }
1602
+
1603
+ function clearUI() {
1604
+ messagesEl.innerHTML = '';
1605
+ lastGroupEl = null;
1606
+ liveMsgIndex = 0; // 重置索引计数器,后续由 fetchSessionHistory 重新设置
1607
+ removeThinking();
1608
+ }
1609
+
1610
+ // ── WebSocket ──
1611
+
1612
+ function connectWebSocket() {
1613
+ if (ws && ws.readyState === WebSocket.OPEN) return;
1614
+
1615
+ var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
1616
+ var host = window.location.host;
1617
+ var token = getToken();
1618
+ var wsUrl = protocol + '//' + host + '/ws/chat?token=' + encodeURIComponent(token);
1619
+
1620
+ ws = new WebSocket(wsUrl);
1621
+
1622
+ ws.onopen = function() {
1623
+ statusEl.textContent = '🟢 已连接';
1624
+ setProcessing(false);
1625
+ if (reconnectTimer) {
1626
+ clearTimeout(reconnectTimer);
1627
+ reconnectTimer = null;
1628
+ }
1629
+ loadSessionsList();
1630
+ };
1631
+
1632
+ ws.onclose = function() {
1633
+ statusEl.textContent = '🔴 已断开';
1634
+ setProcessing(false);
1635
+ if (!reconnectTimer) {
1636
+ reconnectTimer = setTimeout(function() {
1637
+ reconnectTimer = null;
1638
+ connectWebSocket();
1639
+ }, 3000);
1640
+ }
1641
+ };
1642
+
1643
+ ws.onerror = function() {
1644
+ statusEl.textContent = '🔴 连接错误';
1645
+ };
1646
+
1647
+ ws.onmessage = function(event) {
1648
+ try {
1649
+ var data = JSON.parse(event.data);
1650
+ handleEvent(data);
1651
+ } catch (e) {
1652
+ console.error('[WS] 解析失败:', e);
1653
+ }
1654
+ };
1655
+ }
1656
+
1657
+ function handleEvent(data) {
1658
+ var type = data.type;
1659
+
1660
+ switch (type) {
1661
+ case 'connected':
1662
+ sessionId = data.sub_id;
1663
+ statusEl.textContent = '🟢 已连接';
1664
+ fetch('/api/health')
1665
+ .then(function(r) { return r.json(); })
1666
+ .then(function(h) {
1667
+ modelBadge.textContent = h.agent || '—';
1668
+ sessionLabel.textContent = '📄 ' + (h.session || '—');
1669
+ })
1670
+ .catch(function() {});
1671
+ break;
1672
+
1673
+ case 'ping':
1674
+ try { ws.send(JSON.stringify({ type: 'ping' })); } catch(e) {}
1675
+ break;
1676
+
1677
+ case 'pong':
1678
+ break;
1679
+
1680
+ case 'llm_start':
1681
+ showThinking();
1682
+ break;
1683
+
1684
+ case 'llm_end':
1685
+ removeThinking();
1686
+ if (data.content && data.has_tool_calls) {
1687
+ addAssistantMessage(data.content);
1688
+ }
1689
+ break;
1690
+
1691
+ case 'tool_select':
1692
+ removeThinking();
1693
+ if (data.tools && data.tools.length > 0) {
1694
+ var toolNames = data.tools.join(', ');
1695
+ var group = getOrCreateMsgGroup();
1696
+ var info = document.createElement('div');
1697
+ info.className = 'tool-card';
1698
+ info.style.borderLeft = '3px solid var(--accent)';
1699
+ info.textContent = '🔧 计划调用: ' + toolNames;
1700
+ group.appendChild(info);
1701
+ scrollToBottom();
1702
+ }
1703
+ break;
1704
+
1705
+ case 'tool_call':
1706
+ removeThinking();
1707
+ addToolCall(data.name, data.args);
1708
+ break;
1709
+
1710
+ case 'tool_result':
1711
+ addToolResult(data.name, data.result, false);
1712
+ break;
1713
+
1714
+ case 'error':
1715
+ removeThinking();
1716
+ showError(data.error);
1717
+ setProcessing(false);
1718
+ break;
1719
+
1720
+ case 'response':
1721
+ removeThinking();
1722
+ addAssistantMessage(data.content);
1723
+ break;
1724
+
1725
+ case 'done':
1726
+ removeThinking();
1727
+ setProcessing(false);
1728
+ // ── 从后端获取权威索引计数,消除前端自增漂移 ──
1729
+ // 每次 done 事件都校准 liveMsgIndex,之后新的消息从正确起点继续累加。
1730
+ // 这样即使某条消息在保存过程中被跳过/压缩,索引也不会错位。
1731
+ if (data.non_system_count != null) {
1732
+ liveMsgIndex = data.non_system_count;
1733
+ }
1734
+ if (data.session_id) {
1735
+ sessionId = data.session_id;
1736
+ sessionLabel.textContent = '📄 ' + data.session_id;
1737
+ }
1738
+ if (_pendingBacktrack) {
1739
+ _pendingBacktrack = false;
1740
+ var curSid = data.session_id || sessionId;
1741
+ if (curSid) {
1742
+ setTimeout(function() { fetchSessionHistory(curSid); }, 100);
1743
+ }
1744
+ }
1745
+ break;
1746
+
1747
+ case 'cancelled':
1748
+ removeThinking();
1749
+ // 取消 5 秒安全网定时器
1750
+ if (window._cancelTimeout) { clearTimeout(window._cancelTimeout); window._cancelTimeout = null; }
1751
+ setProcessing(false);
1752
+ addSystemMessage('⏹️ 已中断');
1753
+ break;
1754
+
1755
+ case 'shutdown':
1756
+ statusEl.textContent = '🟠 服务器关闭中';
1757
+ setProcessing(false);
1758
+ break;
1759
+
1760
+ case 'reload':
1761
+ statusEl.textContent = '🟠 Agent 重载中...';
1762
+ setProcessing(false);
1763
+ addSystemMessage('🔄 Agent 正在重载,连接即将断开...');
1764
+ break;
1765
+
1766
+ case 'reload_done':
1767
+ statusEl.textContent = '🟢 已连接(已重载)';
1768
+ setProcessing(false);
1769
+ if (data.session_id) {
1770
+ sessionId = data.session_id;
1771
+ sessionLabel.textContent = '📄 ' + data.session_id;
1772
+ }
1773
+ if (data.model) { modelBadge.textContent = data.model; }
1774
+ addSystemMessage('🔄 Agent 重载完成,新代码已生效');
1775
+ break;
1776
+
1777
+ case 'ask':
1778
+ removeThinking();
1779
+ addAssistantMessage('🔍 ' + data.prompt);
1780
+ setProcessing(false);
1781
+ inputEl.placeholder = '输入回复... (Enter 发送)';
1782
+ inputEl.focus();
1783
+ break;
1784
+
1785
+ case 'info':
1786
+ addAssistantMessage('ℹ️ ' + data.content);
1787
+ break;
1788
+
1789
+ case 'hint':
1790
+ var hintGroup = getOrCreateMsgGroup();
1791
+ var hintEl = document.createElement('div');
1792
+ hintEl.style.cssText = 'font-size:12px;color:var(--text-dim);padding:3px 0 3px calc(clamp(28px, 3vw, 36px) + clamp(8px, 1.5vw, 14px));font-style:italic;';
1793
+ hintEl.textContent = '💡 ' + data.content;
1794
+ hintGroup.appendChild(hintEl);
1795
+ scrollToBottom();
1796
+ break;
1797
+
1798
+ case 'item':
1799
+ var itemGroup = getOrCreateMsgGroup();
1800
+ var itemEl = document.createElement('div');
1801
+ itemEl.style.cssText = 'font-size:13px;color:var(--text-secondary);padding:2px 0 2px calc(clamp(28px, 3vw, 36px) + clamp(8px, 1.5vw, 14px));font-family:var(--font-mono);';
1802
+ itemEl.textContent = data.content;
1803
+ itemGroup.appendChild(itemEl);
1804
+ scrollToBottom();
1805
+ break;
1806
+
1807
+ case 'say':
1808
+ addAssistantMessage(data.content);
1809
+ break;
1810
+ }
1811
+ }
1812
+
1813
+ // ── 发送消息 ──
1814
+
1815
+ // 回溯按钮事件委托
1816
+ messagesEl.addEventListener('click', function(e) {
1817
+ var btn = e.target.closest('.backtrack-btn');
1818
+ if (!btn) return;
1819
+ var idx = parseInt(btn.dataset.index, 10);
1820
+ if (isNaN(idx)) return;
1821
+ var sid = sessionId;
1822
+ if (!sid) { showError('未指定当前会话'); return; }
1823
+ _pendingBacktrack = true;
1824
+ clearUI();
1825
+ setProcessing(true);
1826
+ try { ws.send(JSON.stringify({ type: 'message', content: '/back ' + idx + ' 2' })); } catch(e) { showError('发送失败'); }
1827
+ });
1828
+
1829
+ function sendMessage() {
1830
+ var text = inputEl.value.trim();
1831
+ if (!text || processing || !ws || ws.readyState !== WebSocket.OPEN) return;
1832
+
1833
+ var exitCmds = ['/exit', '/exit!', '/quit'];
1834
+ var trimmed = text.trim().toLowerCase();
1835
+ if (exitCmds.some(function(cmd) { return trimmed === cmd.trim(); })) {
1836
+ inputEl.value = '';
1837
+ autoResize(inputEl);
1838
+ addUserMessage(text);
1839
+ addAssistantMessage('🔄 检测到 `' + text.trim() + '`,自动新建会话…');
1840
+ authFetch('/api/sessions', { method: 'POST' }).then(function(r) {
1841
+ if (r.ok) {
1842
+ clearUI();
1843
+ r.json().then(function(data) {
1844
+ sessionLabel.textContent = '📄 ' + (data.session_id || '—');
1845
+ loadSessionsList();
1846
+ });
1847
+ }
1848
+ }).catch(function() {});
1849
+ return;
1850
+ }
1851
+
1852
+ inputEl.value = '';
1853
+ autoResize(inputEl);
1854
+ addUserMessage(text);
1855
+ lastGroupEl = null;
1856
+ setProcessing(true);
1857
+ try { ws.send(JSON.stringify({ type: 'message', content: text })); } catch(e) { showError('发送失败'); setProcessing(false); }
1858
+ }
1859
+
1860
+ function handleKeyDown(event) {
1861
+ // Enter 发送
1862
+ if (event.key === 'Enter' && !event.shiftKey) {
1863
+ event.preventDefault();
1864
+ sendMessage();
1865
+ }
1866
+ // Ctrl+Shift+C 中断(浏览器中 Ctrl+C 默认是复制,不覆盖)
1867
+ if (event.ctrlKey && event.shiftKey && (event.key === 'C' || event.key === 'c')) {
1868
+ event.preventDefault();
1869
+ if (processing) cancelMessage();
1870
+ }
1871
+ }
1872
+
1873
+ // ── 会话管理 ──
1874
+
1875
+ function openSessions() {
1876
+ var modal = document.getElementById('sessionsModal');
1877
+ modal.classList.add('active');
1878
+ document.body.style.overflow = 'hidden';
1879
+ loadSessionsList();
1880
+ }
1881
+
1882
+ function closeSessions() {
1883
+ document.getElementById('sessionsModal').classList.remove('active');
1884
+ document.body.style.overflow = '';
1885
+ }
1886
+
1887
+ function loadSessionsList() {
1888
+ var list = document.getElementById('sessionsList');
1889
+ authFetch('/api/sessions')
1890
+ .then(function(r) { return r.json(); })
1891
+ .then(function(data) {
1892
+ var sessions = data.sessions || [];
1893
+ if (sessions.length === 0) {
1894
+ list.innerHTML = '<div class="no-sessions">暂无历史会话</div>';
1895
+ return;
1896
+ }
1897
+ var html = '';
1898
+ sessions.forEach(function(s) {
1899
+ var isCurrent = s.is_current;
1900
+ var summary = s.summary || s.id.slice(0, 16) + '...';
1901
+ var created = s.created || '—';
1902
+ var msgCount = s.message_count || 0;
1903
+ html +=
1904
+ '<div class="session-item' + (isCurrent ? ' current' : '') + '" onclick="switchSession(\'' + s.id + '\')">' +
1905
+ '<div class="session-item-info">' +
1906
+ '<div class="session-item-summary">' + escapeHtml(summary) + '</div>' +
1907
+ '<div class="session-item-meta">' +
1908
+ escapeHtml(created) + ' · ' + msgCount + ' 条消息' +
1909
+ '</div>' +
1910
+ '</div>' +
1911
+ (isCurrent ? '<span class="session-item-badge">当前</span>' : '') +
1912
+ '<button class="session-load-btn" onclick="event.stopPropagation(); switchSession(\'' + s.id + '\')">' +
1913
+ (isCurrent ? '✓ 当前' : '切换到') +
1914
+ '</button>' +
1915
+ (!isCurrent ? '<button class="session-del-btn" onclick="event.stopPropagation(); deleteSession(\'' + s.id + '\', this)" title="删除会话" aria-label="删除会话">🗑</button>' : '') +
1916
+ '</div>';
1917
+ });
1918
+ list.innerHTML = html;
1919
+ })
1920
+ .catch(function(err) {
1921
+ list.innerHTML = '<div class="no-sessions">加载失败: ' + err.message + '</div>';
1922
+ });
1923
+ }
1924
+
1925
+ function switchSession(sid) {
1926
+ authFetch('/api/sessions/' + sid + '/switch', { method: 'POST' })
1927
+ .then(function(r) { return r.json(); })
1928
+ .then(function(data) {
1929
+ if (data.status === 'switched') {
1930
+ clearUI();
1931
+ sessionLabel.textContent = '📄 ' + sid;
1932
+ closeSessions();
1933
+ loadSessionsList();
1934
+ fetchSessionHistory(sid);
1935
+ var msg = document.createElement('div');
1936
+ msg.className = 'msg-group';
1937
+ msg.style.borderBottom = 'none';
1938
+ msg.innerHTML = '<div class="msg" style="justify-content:center;padding:8px 0"><span style="color:var(--text-dim);font-size:12px">📂 已切换到 ' + sid.slice(0, 8) + '... 可发送消息继续对话</span></div>';
1939
+ document.getElementById('messagesContainer').appendChild(msg);
1940
+ scrollToBottom();
1941
+ }
1942
+ })
1943
+ .catch(function(err) { showError('切换会话失败: ' + err.message); });
1944
+ }
1945
+
1946
+ function deleteSession(sid, btnEl) {
1947
+ if (!confirm('确定删除会话 ' + sid.slice(0, 12) + '... 吗?此操作不可恢复。')) return;
1948
+ btnEl.disabled = true;
1949
+ btnEl.style.opacity = '0.3';
1950
+ authFetch('/api/sessions/' + sid, { method: 'DELETE' })
1951
+ .then(function(r) { return r.json(); })
1952
+ .then(function(data) {
1953
+ if (data.status === 'deleted') {
1954
+ var item = btnEl.closest('.session-item');
1955
+ if (item) { item.style.transition = 'opacity 0.3s'; item.style.opacity = '0'; }
1956
+ setTimeout(function() {
1957
+ if (item) item.remove();
1958
+ var list = document.getElementById('sessionsList');
1959
+ if (list && list.children.length === 0) {
1960
+ list.innerHTML = '<div class="no-sessions">暂无历史会话</div>';
1961
+ }
1962
+ }, 300);
1963
+ } else {
1964
+ btnEl.disabled = false;
1965
+ btnEl.style.opacity = '';
1966
+ showError('删除失败: ' + (data.detail || '未知错误'));
1967
+ }
1968
+ })
1969
+ .catch(function(err) {
1970
+ btnEl.disabled = false;
1971
+ btnEl.style.opacity = '';
1972
+ showError('删除会话失败: ' + err.message);
1973
+ });
1974
+ }
1975
+
1976
+ // ── 重载 Agent ──
1977
+
1978
+ function reloadAgent() {
1979
+ if (processing) { showError('正在处理中,请等待完成'); return; }
1980
+
1981
+ var btn = document.querySelector('.topbar-btn[onclick*="reloadAgent"]');
1982
+ var originalText = btn.textContent;
1983
+ btn.textContent = '⏳...';
1984
+ btn.disabled = true;
1985
+
1986
+ authFetch('/api/reload', { method: 'POST' })
1987
+ .then(function(r) {
1988
+ if (!r.ok) return r.json().then(function(e) { throw new Error(e.detail || 'HTTP ' + r.status); });
1989
+ return r.json();
1990
+ })
1991
+ .then(function(data) {
1992
+ if (data.status === 'ok') {
1993
+ modelBadge.textContent = data.model || '—';
1994
+ sessionLabel.textContent = '📄 ' + (data.session_id || '—');
1995
+ fetch('/api/health')
1996
+ .then(function(r) { return r.json(); })
1997
+ .then(function(h) { modelBadge.textContent = h.agent || '—'; })
1998
+ .catch(function() {});
1999
+ loadSessionsList();
2000
+ addSystemMessage('🔄 Agent 重载完成');
2001
+ }
2002
+ })
2003
+ .catch(function(err) { showError('重载失败: ' + err.message); })
2004
+ .finally(function() { btn.textContent = originalText; btn.disabled = false; });
2005
+ }
2006
+
2007
+ function addSystemMessage(text) {
2008
+ var group = getOrCreateMsgGroup();
2009
+ var el = document.createElement('div');
2010
+ el.className = 'sys-msg';
2011
+ el.textContent = text;
2012
+ group.appendChild(el);
2013
+ scrollToBottom();
2014
+ }
2015
+
2016
+ function clearSession() {
2017
+ if (!confirm('确定要清空当前会话吗?')) return;
2018
+ authFetch('/api/sessions/clear', { method: 'POST' })
2019
+ .then(function(r) { return r.json(); })
2020
+ .then(function(data) {
2021
+ if (data.status === 'cleared') {
2022
+ clearUI();
2023
+ sessionLabel.textContent = '📄 新会话';
2024
+ scrollToBottom();
2025
+ }
2026
+ })
2027
+ .catch(function(err) { showError('清空失败: ' + err.message); });
2028
+ }
2029
+
2030
+ function newAgent() {
2031
+ if (processing) { showError('正在处理中,请等待完成'); return; }
2032
+
2033
+ var btn = document.querySelector('.topbar-btn[onclick*="newAgent"]');
2034
+ var originalText = btn.textContent;
2035
+ btn.textContent = '⏳...';
2036
+ btn.disabled = true;
2037
+
2038
+ authFetch('/api/agent/new', { method: 'POST' })
2039
+ .then(function(r) {
2040
+ if (!r.ok) return r.json().then(function(e) { throw new Error(e.detail || 'HTTP ' + r.status); });
2041
+ return r.json();
2042
+ })
2043
+ .then(function(data) {
2044
+ if (data.status === 'ok') {
2045
+ clearUI();
2046
+ sessionLabel.textContent = '📄 ' + (data.session_id || '—');
2047
+ modelBadge.textContent = data.model || '—';
2048
+ loadSessionsList();
2049
+ var msg = document.createElement('div');
2050
+ msg.className = 'msg-group';
2051
+ msg.innerHTML = '<div class="msg" style="justify-content:center;padding:16px 0"><span style="color:var(--text-dim);font-size:13px">🆕 已创建新 Agent(' + (data.model || '') + ')</span></div>';
2052
+ messagesEl.appendChild(msg);
2053
+ scrollToBottom();
2054
+ }
2055
+ })
2056
+ .catch(function(err) { showError('新建 Agent 失败: ' + err.message); })
2057
+ .finally(function() { btn.textContent = originalText; btn.disabled = false; });
2058
+ }
2059
+
2060
+ // ── 会话历史 ──
2061
+
2062
+ var historyIndexMap = {};
2063
+ var _pendingBacktrack = false;
2064
+ var liveMsgIndex = 0;
2065
+
2066
+ function renderHistoryMessages(sid, messages) {
2067
+ var container = document.getElementById('messagesContainer');
2068
+ var sep = document.createElement('div');
2069
+ sep.className = 'history-separator';
2070
+ sep.textContent = '📜 历史记录';
2071
+ container.appendChild(sep);
2072
+
2073
+ var allRendered = [];
2074
+
2075
+ // 第一遍:从 assistant 消息构建 tool_call_id → tool_name 映射
2076
+ var toolNameMap = {};
2077
+ messages.forEach(function(msg) {
2078
+ if (msg.tool_calls && msg.tool_calls.length > 0) {
2079
+ msg.tool_calls.forEach(function(tc) {
2080
+ if (tc.id && tc.function && tc.function.name) {
2081
+ toolNameMap[tc.id] = tc.function.name;
2082
+ }
2083
+ });
2084
+ }
2085
+ });
2086
+
2087
+ messages.forEach(function(msg) {
2088
+ var group = document.createElement('div');
2089
+ group.className = 'msg-group';
2090
+ group.style.borderBottom = 'none';
2091
+ group.style.padding = '2px 0';
2092
+
2093
+ var msgDiv = document.createElement('div');
2094
+
2095
+ // ── system 消息(如 compact 摘要):特殊渲染,不显示回溯按钮 ──
2096
+ if (msg.role === 'system') {
2097
+ msgDiv.className = 'msg';
2098
+ msgDiv.innerHTML =
2099
+ '<div class="msg-avatar tool" aria-hidden="true">📋</div>' +
2100
+ '<div class="msg-body">' +
2101
+ '<div class="msg-header">' +
2102
+ '<span class="msg-author tool">System</span>' +
2103
+ '</div>' +
2104
+ '<div class="msg-content" style="font-size:0.85em;color:var(--text-dim);font-style:italic;white-space:pre-wrap">' +
2105
+ escapeHtml(msg.content || '') +
2106
+ '</div>' +
2107
+ '</div>';
2108
+ group.appendChild(msgDiv);
2109
+ container.appendChild(group);
2110
+ return;
2111
+ }
2112
+
2113
+ if (msg.role === 'tool') {
2114
+ // ── tool 消息:可折叠,显示回溯按钮 ──
2115
+ msgDiv.className = 'msg tool-msg collapsed';
2116
+ msgDiv.onclick = toggleToolMsg;
2117
+
2118
+ var toolName = toolNameMap[msg.tool_call_id] || 'Tool';
2119
+ var statusText = msg.content ? '✅ 完成' : '';
2120
+
2121
+ msgDiv.innerHTML =
2122
+ '<div class="msg-avatar tool" aria-hidden="true">🔧</div>' +
2123
+ '<div class="msg-body">' +
2124
+ '<div class="msg-header">' +
2125
+ '<span class="msg-author tool">Tool</span>' +
2126
+ '<span class="msg-time">#' + (msg.index || '?') + '</span>' +
2127
+ (msg.index ? '<button class="backtrack-btn" title="回溯到此位置" data-index="' + msg.index + '" aria-label="回溯到位置 ' + msg.index + '">↩ 回溯</button>' : '') +
2128
+ '</div>' +
2129
+ '<div class="msg-content">' +
2130
+ '<div class="tool-msg-toggle">' +
2131
+ '<span class="tool-msg-icon">▶</span>' +
2132
+ '<strong>🛠️ ' + escapeHtml(toolName) + '</strong>' +
2133
+ (statusText ? '<span class="tool-msg-status">' + statusText + '</span>' : '') +
2134
+ '</div>' +
2135
+ '<div class="tool-msg-body"></div>' +
2136
+ '</div>' +
2137
+ '</div>';
2138
+
2139
+ group.appendChild(msgDiv);
2140
+
2141
+ // 填充结果内容(原样显示,不渲染 Markdown)
2142
+ var bodyEl = msgDiv.querySelector('.tool-msg-body');
2143
+ if (bodyEl && msg.content) {
2144
+ bodyEl.textContent = msg.content;
2145
+ }
2146
+
2147
+ } else {
2148
+ // ── 普通消息(user / assistant)──
2149
+ msgDiv.className = 'msg';
2150
+
2151
+ var roleConfig;
2152
+ if (msg.role === 'user') {
2153
+ roleConfig = { avatar: '👤', author: 'OPERATOR', cls: 'user' };
2154
+ } else {
2155
+ roleConfig = { avatar: '<img src=\"/static/favicon.png\" width=\"18\" height=\"19\" alt=\"\" style=\"vertical-align:middle\">', author: 'FIVE PEBBLES', cls: 'assistant' };
2156
+ }
2157
+
2158
+ var avatarHtml = '<div class="msg-avatar ' + roleConfig.cls + '" aria-hidden="true">' + roleConfig.avatar + '</div>';
2159
+
2160
+ msgDiv.innerHTML =
2161
+ avatarHtml +
2162
+ '<div class="msg-body">' +
2163
+ '<div class="msg-header">' +
2164
+ '<span class="msg-author ' + roleConfig.cls + '">' + roleConfig.author + '</span>' +
2165
+ '<span class="msg-time">#' + (msg.index || '?') + '</span>' +
2166
+ (msg.index ? '<button class="backtrack-btn" title="回溯到此位置" data-index="' + msg.index + '" aria-label="回溯到位置 ' + msg.index + '">↩ 回溯</button>' : '') +
2167
+ '</div>' +
2168
+ '<div class="msg-content rendered"></div>' +
2169
+ '</div>';
2170
+
2171
+ group.appendChild(msgDiv);
2172
+
2173
+ // 渲染内容
2174
+ var contentEl = msgDiv.querySelector('.msg-content');
2175
+ if (msg.content) {
2176
+ contentEl.innerHTML = renderMarkdown(msg.content);
2177
+ allRendered.push(contentEl);
2178
+ }
2179
+ }
2180
+
2181
+ container.appendChild(group);
2182
+ });
2183
+
2184
+ // 批量渲染公式
2185
+ if (allRendered.length > 0) {
2186
+ renderMath();
2187
+ }
2188
+
2189
+ scrollToBottom();
2190
+ }
2191
+
2192
+ function fetchSessionHistory(sid) {
2193
+ authFetch('/api/sessions/' + sid + '/messages')
2194
+ .then(function(r) {
2195
+ if (!r.ok) throw new Error('HTTP ' + r.status);
2196
+ return r.json();
2197
+ })
2198
+ .then(function(data) {
2199
+ var msgs = data.messages || [];
2200
+ historyIndexMap[sid] = msgs;
2201
+ if (msgs.length > 0) {
2202
+ // 从尾部找最后一个有效 index(跳过 system 消息的 null index)
2203
+ liveMsgIndex = 0;
2204
+ for (var i = msgs.length - 1; i >= 0; i--) {
2205
+ if (msgs[i].index != null) {
2206
+ liveMsgIndex = msgs[i].index;
2207
+ break;
2208
+ }
2209
+ }
2210
+ }
2211
+ if (msgs.length > 0) {
2212
+ renderHistoryMessages(sid, msgs);
2213
+ }
2214
+ })
2215
+ .catch(function(err) { console.warn('[History] 加载历史失败:', err.message); });
2216
+ }
2217
+
2218
+ function backtrackTo(sid, index) {
2219
+ if (!ws || ws.readyState !== WebSocket.OPEN) { showError('WebSocket 未连接'); return; }
2220
+ if (processing) { showError('正在处理中,请等待完成'); return; }
2221
+ _pendingBacktrack = true;
2222
+ clearUI();
2223
+ setProcessing(true);
2224
+ try { ws.send(JSON.stringify({ type: 'message', content: '/back ' + index + ' 2' })); } catch(e) { showError('发送失败'); }
2225
+ }
2226
+
2227
+ // ── 重写 switchSession 以加载历史 ──
2228
+ var _origSwitchSession = switchSession;
2229
+ switchSession = function(sid) {
2230
+ authFetch('/api/sessions/' + sid + '/switch', { method: 'POST' })
2231
+ .then(function(r) { return r.json(); })
2232
+ .then(function(data) {
2233
+ if (data.status === 'switched') {
2234
+ clearUI();
2235
+ sessionLabel.textContent = '📄 ' + sid;
2236
+ closeSessions();
2237
+ loadSessionsList();
2238
+ fetchSessionHistory(sid);
2239
+ var msg = document.createElement('div');
2240
+ msg.className = 'msg-group';
2241
+ msg.style.borderBottom = 'none';
2242
+ msg.innerHTML = '<div class="msg" style="justify-content:center;padding:8px 0"><span style="color:var(--text-dim);font-size:12px">📂 已切换到 ' + sid.slice(0, 8) + '... 可发送消息继续对话</span></div>';
2243
+ document.getElementById('messagesContainer').appendChild(msg);
2244
+ scrollToBottom();
2245
+ }
2246
+ })
2247
+ .catch(function(err) { showError('切换会话失败: ' + err.message); });
2248
+ };
2249
+
2250
+ // ── 初始化与登录流程 ──
2251
+
2252
+ function initApp() {
2253
+ connectWebSocket();
2254
+ inputEl.focus();
2255
+ }
2256
+
2257
+ document.addEventListener('DOMContentLoaded', function() {
2258
+ if (isAuthed()) {
2259
+ initApp();
2260
+ } else {
2261
+ showLogin();
2262
+ }
2263
+
2264
+ // 模态框外部点击关闭
2265
+ document.getElementById('sessionsModal').addEventListener('click', function(e) {
2266
+ if (e.target === e.currentTarget) closeSessions();
2267
+ });
2268
+
2269
+ // ESC 关闭模态框
2270
+ document.addEventListener('keydown', function(e) {
2271
+ if (e.key === 'Escape') closeSessions();
2272
+ });
2273
+
2274
+ // 窗口 resize 时保持滚动在底部(键盘弹出场景)
2275
+ var resizeTimer = null;
2276
+ window.addEventListener('resize', function() {
2277
+ if (resizeTimer) clearTimeout(resizeTimer);
2278
+ resizeTimer = setTimeout(function() {
2279
+ if (!processing) scrollToBottom();
2280
+ }, 150);
2281
+ });
2282
+
2283
+ // 阻止 iOS 橡皮筋滚动导致页面整体上移
2284
+ document.body.addEventListener('touchmove', function(e) {
2285
+ if (e.target.closest('.messages-container') || e.target.closest('.modal-body')) return;
2286
+ // 允许这些容器内部滚动
2287
+ }, { passive: true });
2288
+ });
2289
+ </script>
2290
+ </body>
2291
+ </html>