@wendongfly/myhi 1.3.130 → 1.3.138

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/dist/index.html CHANGED
@@ -1,1294 +1,1501 @@
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, viewport-fit=cover">
6
- <meta name="theme-color" content="#0d1117">
7
- <meta name="apple-mobile-web-app-capable" content="yes">
8
- <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
9
- <meta name="apple-mobile-web-app-title" content="myhi">
10
- <link rel="manifest" href="/manifest.json">
11
- <link rel="apple-touch-icon" href="/icon.png">
12
- <title>myhi</title>
13
- <script src="/sio-client/socket.io.min.js"></script>
14
- <script>
15
- (function(){try{var t=localStorage.getItem('myhi_theme')||'dark',f=localStorage.getItem('myhi_font')||'medium',r=document.documentElement;if(t&&t!=='dark')r.setAttribute('data-theme',t);r.setAttribute('data-font',f);}catch(e){}})();
16
- </script>
17
- <style>
18
- :root {
19
- --bg:#0d1117; --surface:#161b22; --border:#21262d; --border2:#30363d;
20
- --text:#e6edf3; --text2:#b1bac4; --muted:#8b949e; --dim:#6e7681;
21
- --accent:#7c3aed; --accent2:#9d5cf5; --red:#f85149; --redbg:#3d1515;
22
- --green:#3fb950; --yellow:#d29922; --orange:#f0883e;
23
- }
24
- :root[data-theme="light"] { --bg:#ffffff; --surface:#f6f8fa; --border:#d0d7de; --border2:#d0d7de;
25
- --text:#1f2328; --text2:#57606a; --muted:#656d76; --dim:#818b98;
26
- --accent:#8250df; --accent2:#8250df; --red:#cf222e; --redbg:#ffebe9;
27
- --green:#1a7f37; --yellow:#9a6700; --orange:#bc4c00; }
28
- :root[data-theme="black"] { --bg:#000000; --surface:#0a0a0a; --border:#161616; --border2:#262626; }
29
- @media (prefers-color-scheme: light) { :root[data-theme="system"] { --bg:#ffffff; --surface:#f6f8fa; --border:#d0d7de; --border2:#d0d7de;
30
- --text:#1f2328; --text2:#57606a; --muted:#656d76; --dim:#818b98;
31
- --accent:#8250df; --accent2:#8250df; --red:#cf222e; --redbg:#ffebe9;
32
- --green:#1a7f37; --yellow:#9a6700; --orange:#bc4c00; } }
33
- html { font-size: 16px; }
34
- html[data-font="small"]{font-size:15px;} html[data-font="medium"]{font-size:17px;} html[data-font="large"]{font-size:19.5px;}
35
- * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
36
-
37
- html, body {
38
- height: 100%;
39
- background: var(--bg);
40
- color: var(--text);
41
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
42
- overscroll-behavior: none;
43
- }
44
-
45
- /* ── Layout ── */
46
- #app {
47
- display: flex;
48
- flex-direction: column;
49
- height: 100%;
50
- max-width: 640px;
51
- margin: 0 auto;
52
- }
53
-
54
- /* ── Header ── */
55
- #header {
56
- display: flex;
57
- align-items: center;
58
- justify-content: space-between;
59
- padding: 0 1rem;
60
- padding-top: max(1rem, env(safe-area-inset-top));
61
- padding-bottom: 0.75rem;
62
- background: var(--bg);
63
- position: sticky;
64
- top: 0;
65
- z-index: 10;
66
- border-bottom: 1px solid var(--border);
67
- }
68
- .logo {
69
- font-size: 1.35rem;
70
- font-weight: 700;
71
- letter-spacing: -0.5px;
72
- }
73
- .logo span { color: var(--accent); }
74
- #conn-dot {
75
- width: 8px; height: 8px;
76
- border-radius: 50%;
77
- background: var(--muted);
78
- transition: background 0.3s;
79
- flex-shrink: 0;
80
- }
81
- #conn-dot.on { background: var(--green); box-shadow: 0 0 6px var(--green); }
82
- #conn-dot.off { background: var(--red); }
83
-
84
- /* ── Session list ── */
85
- #list {
86
- flex: 1;
87
- overflow-y: auto;
88
- -webkit-overflow-scrolling: touch;
89
- padding: 0.75rem 0.75rem 6rem;
90
- }
91
-
92
- .empty-state {
93
- display: flex;
94
- flex-direction: column;
95
- align-items: center;
96
- justify-content: center;
97
- gap: 0.75rem;
98
- height: 60%;
99
- color: var(--muted);
100
- }
101
- .empty-state svg { opacity: 0.3; }
102
- .empty-state p { font-size: 0.95rem; }
103
-
104
- /* ── Session card ── */
105
- .card {
106
- display: flex;
107
- align-items: center;
108
- gap: 0.85rem;
109
- padding: 0.85rem 0.9rem;
110
- background: var(--surface);
111
- border: 1px solid var(--border);
112
- border-radius: 12px;
113
- margin-bottom: 0.6rem;
114
- cursor: pointer;
115
- transition: border-color 0.15s, background 0.15s;
116
- user-select: none;
117
- -webkit-user-select: none;
118
- }
119
- .card:active { background: #1c2230; }
120
-
121
- .avatar {
122
- width: 44px; height: 44px;
123
- border-radius: 10px;
124
- display: flex; align-items: center; justify-content: center;
125
- font-size: 1.3rem;
126
- font-weight: 700;
127
- flex-shrink: 0;
128
- font-family: 'SF Mono', Consolas, monospace;
129
- color: #fff;
130
- }
131
-
132
- .card-body {
133
- flex: 1;
134
- min-width: 0;
135
- }
136
- .card-title {
137
- font-size: 0.95rem;
138
- font-weight: 600;
139
- white-space: nowrap;
140
- overflow: hidden;
141
- text-overflow: ellipsis;
142
- }
143
- .card-sub {
144
- font-size: 0.75rem;
145
- color: var(--muted);
146
- margin-top: 0.2rem;
147
- white-space: nowrap;
148
- overflow: hidden;
149
- text-overflow: ellipsis;
150
- }
151
- .tag-chip {
152
- display: inline-block;
153
- font-size: 0.66rem;
154
- color: var(--text2);
155
- background: rgba(124,58,237,0.14);
156
- border: 1px solid rgba(124,58,237,0.35);
157
- border-radius: 999px;
158
- padding: 0.05rem 0.45rem;
159
- line-height: 1.5;
160
- }
161
-
162
- .card-right {
163
- display: flex;
164
- flex-direction: column;
165
- align-items: flex-end;
166
- gap: 0.35rem;
167
- flex-shrink: 0;
168
- }
169
- .status-dot {
170
- width: 8px; height: 8px;
171
- border-radius: 50%;
172
- }
173
- .status-dot.alive { background: var(--green); box-shadow: 0 0 5px var(--green); }
174
- .status-dot.dead { background: var(--red); }
175
- .viewers {
176
- font-size: 0.7rem;
177
- color: var(--muted);
178
- }
179
-
180
- /* ── FAB ── */
181
- #fab {
182
- position: fixed;
183
- bottom: max(1.5rem, calc(env(safe-area-inset-bottom) + 1rem));
184
- right: max(1.5rem, calc(env(safe-area-inset-right) + 1rem));
185
- width: 56px; height: 56px;
186
- border-radius: 28px;
187
- background: var(--accent);
188
- color: #fff;
189
- border: none;
190
- font-size: 1.6rem;
191
- line-height: 1;
192
- cursor: pointer;
193
- box-shadow: 0 4px 20px rgba(124,58,237,0.5);
194
- display: flex; align-items: center; justify-content: center;
195
- transition: background 0.15s, transform 0.15s;
196
- z-index: 20;
197
- }
198
- #fab:active { background: var(--accent2); transform: scale(0.93); }
199
-
200
- /* ── Bottom sheet ── */
201
- #sheet-backdrop {
202
- display: none;
203
- position: fixed; inset: 0;
204
- background: rgba(0,0,0,0.55);
205
- z-index: 30;
206
- }
207
- #sheet-backdrop.open { display: block; }
208
-
209
- #sheet {
210
- position: fixed;
211
- bottom: 0; left: 0; right: 0;
212
- background: var(--surface);
213
- border-radius: 20px 20px 0 0;
214
- padding: 0 1.25rem;
215
- padding-bottom: max(1.5rem, env(safe-area-inset-bottom));
216
- z-index: 31;
217
- transform: translateY(100%);
218
- transition: transform 0.28s cubic-bezier(0.32, 0.72, 0, 1);
219
- max-width: 640px;
220
- margin: 0 auto;
221
- }
222
- #sheet.open { transform: translateY(0); }
223
-
224
- .sheet-handle {
225
- width: 36px; height: 4px;
226
- background: var(--border);
227
- border-radius: 2px;
228
- margin: 0.75rem auto 1.25rem;
229
- }
230
- .sheet-title {
231
- font-size: 1rem;
232
- font-weight: 600;
233
- margin-bottom: 1rem;
234
- }
235
-
236
- /* ── Preset chips ── */
237
- .presets {
238
- display: flex;
239
- gap: 0.5rem;
240
- overflow-x: auto;
241
- padding-bottom: 0.75rem;
242
- margin-bottom: 0.75rem;
243
- border-bottom: 1px solid var(--border);
244
- scrollbar-width: none;
245
- -webkit-overflow-scrolling: touch;
246
- }
247
- .presets::-webkit-scrollbar { display: none; }
248
- .chip {
249
- display: flex;
250
- flex-direction: column;
251
- align-items: center;
252
- gap: 0.3rem;
253
- flex-shrink: 0;
254
- width: 76px;
255
- padding: 0.6rem 0.4rem;
256
- background: var(--bg);
257
- border: 1.5px solid var(--border);
258
- border-radius: 12px;
259
- cursor: pointer;
260
- transition: border-color 0.15s, background 0.15s;
261
- user-select: none;
262
- -webkit-user-select: none;
263
- }
264
- .chip:active, .chip.selected {
265
- border-color: var(--accent);
266
- background: rgba(124,58,237,0.12);
267
- }
268
- .chip-icon { font-size: 1.4rem; line-height: 1; }
269
- .chip-label {
270
- font-size: 0.65rem;
271
- color: var(--muted);
272
- text-align: center;
273
- white-space: nowrap;
274
- overflow: hidden;
275
- text-overflow: ellipsis;
276
- width: 100%;
277
- }
278
- .chip.selected .chip-label { color: var(--accent2); }
279
-
280
- .field { margin-bottom: 0.9rem; }
281
- .field label {
282
- display: block;
283
- font-size: 0.75rem;
284
- color: var(--muted);
285
- margin-bottom: 0.35rem;
286
- font-weight: 500;
287
- text-transform: uppercase;
288
- letter-spacing: 0.04em;
289
- }
290
- .field input {
291
- width: 100%;
292
- background: var(--bg);
293
- border: 1px solid var(--border);
294
- border-radius: 8px;
295
- padding: 0.65rem 0.8rem;
296
- color: var(--text);
297
- font-size: 0.95rem;
298
- font-family: inherit;
299
- outline: none;
300
- transition: border-color 0.15s;
301
- }
302
- .field input:focus { border-color: var(--accent); }
303
- .field input::placeholder { color: var(--dim); }
304
-
305
- #btn-create {
306
- width: 100%;
307
- background: var(--accent);
308
- color: #fff;
309
- border: none;
310
- border-radius: 10px;
311
- padding: 0.8rem;
312
- font-size: 0.95rem;
313
- font-weight: 600;
314
- cursor: pointer;
315
- margin-top: 0.25rem;
316
- transition: background 0.15s;
317
- }
318
- #btn-create:active { background: var(--accent2); }
319
-
320
- /* ── Dir picker ── */
321
- #dir-picker {
322
- display: none; position: fixed; inset: 0; z-index: 50;
323
- align-items: flex-end; justify-content: center;
324
- }
325
- #dir-picker.open { display: flex; }
326
- #dir-backdrop2 { position: absolute; inset: 0; background: rgba(0,0,0,0.6); }
327
- #dir-box {
328
- position: relative; z-index: 1;
329
- background: var(--surface);
330
- border-radius: 20px 20px 0 0;
331
- width: 100%; max-width: 640px;
332
- padding: 0 1.25rem;
333
- padding-bottom: max(1.5rem, env(safe-area-inset-bottom));
334
- max-height: 70vh; display: flex; flex-direction: column;
335
- }
336
- #dir-current {
337
- font-size: 0.72rem; color: var(--muted);
338
- font-family: 'SF Mono', Consolas, monospace;
339
- padding: 0.9rem 0 0.5rem;
340
- border-bottom: 1px solid var(--border);
341
- word-break: break-all;
342
- }
343
- #dir-list {
344
- flex: 1; overflow-y: auto; padding: 0.4rem 0;
345
- -webkit-overflow-scrolling: touch;
346
- }
347
- .dir-item {
348
- display: flex; align-items: center; gap: 0.6rem;
349
- padding: 0.65rem 0.2rem;
350
- border-bottom: 1px solid var(--border);
351
- cursor: pointer; font-size: 0.9rem;
352
- }
353
- .dir-item:active { background: var(--border); border-radius: 6px; }
354
- .dir-item .di { font-size: 1rem; }
355
- .dir-item .dn { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
356
- #dir-actions {
357
- display: flex; gap: 0.6rem; padding: 0.75rem 0;
358
- border-top: 1px solid var(--border);
359
- }
360
- .dir-btn {
361
- flex: 1; padding: 0.7rem; border-radius: 10px;
362
- border: none; font-size: 0.9rem; font-weight: 600; cursor: pointer;
363
- }
364
- .dir-btn.primary { background: var(--accent); color: #fff; }
365
- .dir-btn.cancel { background: var(--bg); color: var(--text); }
366
-
367
- /* ── Kill confirm ── */
368
- #kill-menu {
369
- display: none;
370
- position: fixed; inset: 0;
371
- z-index: 40;
372
- align-items: flex-end;
373
- justify-content: center;
374
- }
375
- #kill-menu.open { display: flex; }
376
- #kill-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,0.55); }
377
- #kill-box {
378
- position: relative;
379
- background: var(--surface);
380
- border-radius: 16px 16px 0 0;
381
- width: 100%;
382
- max-width: 640px;
383
- padding: 1.25rem;
384
- padding-bottom: max(1.5rem, env(safe-area-inset-bottom));
385
- z-index: 1;
386
- }
387
- #kill-box h3 { font-size: 0.95rem; margin-bottom: 0.3rem; }
388
- #kill-box p { font-size: 0.8rem; color: var(--muted); margin-bottom: 1rem; }
389
- .kill-btn {
390
- display: block; width: 100%;
391
- padding: 0.75rem; border-radius: 10px;
392
- border: none; font-size: 0.95rem; font-weight: 600;
393
- cursor: pointer; margin-bottom: 0.5rem;
394
- }
395
- .kill-btn.danger { background: var(--redbg); color: var(--red); }
396
- .kill-btn.cancel { background: var(--bg); color: var(--text); }
397
-
398
- /* ── 导入会话 ── */
399
- #import-menu {
400
- display: none; position: fixed; inset: 0; z-index: 40;
401
- align-items: flex-end; justify-content: center;
402
- }
403
- #import-menu.open { display: flex; }
404
- .import-item {
405
- display: flex; align-items: center; gap: 0.6rem;
406
- padding: 0.7rem 0.2rem; border-bottom: 1px solid var(--border);
407
- cursor: pointer; font-size: 0.85rem;
408
- }
409
- .import-item:active { background: var(--border); border-radius: 6px; }
410
- .import-item .ii-icon { font-size: 1.1rem; }
411
- .import-item .ii-body { flex: 1; min-width: 0; }
412
- .import-item .ii-id { font-size: 0.82rem; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
413
- .import-item .ii-info { font-size: 0.72rem; color: var(--muted); }
414
-
415
- /* ── 盘符选择栏 ── */
416
- #drive-bar {
417
- display: flex; gap: 0.5rem; padding: 0.6rem 0; border-bottom: 1px solid var(--border);
418
- overflow-x: auto; scrollbar-width: none;
419
- }
420
- #drive-bar::-webkit-scrollbar { display: none; }
421
- .drive-btn {
422
- padding: 0.5rem 1rem; border-radius: 8px; font-size: 1rem; font-weight: 600;
423
- background: var(--bg); border: 1.5px solid var(--border); color: var(--text);
424
- cursor: pointer; white-space: nowrap; flex-shrink: 0;
425
- min-width: 3rem; text-align: center;
426
- }
427
- .drive-btn:active, .drive-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(124,58,237,0.12); }
428
- #update-btn { background: var(--yellow); color: #000; border: none; border-radius: 6px; padding: 0.35rem 0.75rem; font-size: 0.75rem; font-weight: 600; cursor: pointer; white-space: nowrap; }
429
- #update-btn:active { opacity: 0.7; }
430
- #update-btn:disabled { opacity: 0.5; cursor: not-allowed; }
431
- /* 分屏是 PC 大屏功能,手机端(触屏)隐藏入口 */
432
- @media (pointer: coarse) { #grid-btn { display: none !important; } }
433
- </style>
434
- </head>
435
- <body>
436
- <div id="app">
437
- <div id="header">
438
- <div class="logo">my<span>hi</span></div>
439
- <div style="display:flex;align-items:center;gap:0.6rem">
440
- <span id="user-name" style="font-size:0.8rem;color:var(--muted)"></span>
441
- <button id="grid-btn" onclick="location.href='/grid'" title="分屏工作台(多任务并排)" style="background:none;border:1px solid var(--border);color:var(--muted);font-size:0.75rem;padding:0.3rem 0.6rem;border-radius:6px;cursor:pointer;width:auto">▦ 分屏</button>
442
- <button id="appearance-btn" onclick="openAppearance()" title="外观" style="background:none;border:1px solid var(--border);color:var(--muted);font-size:0.75rem;padding:0.3rem 0.55rem;border-radius:6px;cursor:pointer;width:auto">🎨</button>
443
- <button id="doctor-btn" onclick="openDoctor()" style="background:none;border:1px solid var(--border);color:var(--muted);font-size:0.75rem;padding:0.3rem 0.6rem;border-radius:6px;cursor:pointer;width:auto">🩺 体检</button>
444
- <button id="usage-btn" onclick="showUsage()" style="background:none;border:1px solid var(--border);color:var(--muted);font-size:0.75rem;padding:0.3rem 0.6rem;border-radius:6px;cursor:pointer;width:auto">用量</button>
445
- <button id="logout-btn" onclick="doLogout()" style="display:none;background:none;border:1px solid var(--border);color:var(--muted);font-size:0.75rem;padding:0.3rem 0.6rem;border-radius:6px;cursor:pointer;width:auto">退出</button>
446
- <div id="conn-dot"></div>
447
- </div>
448
- </div>
449
-
450
- <!-- 升级管理已移至 /admin -->
451
-
452
- <div id="view-controls" style="display:flex;flex-direction:column;gap:0.5rem;margin-bottom:0.75rem">
453
- <div id="group-seg" style="display:inline-flex;align-self:flex-start;background:var(--bg,var(--bg));border:1px solid var(--border,var(--border));border-radius:8px;overflow:hidden">
454
- <button class="grp-btn" data-group="time" style="background:none;border:none;color:var(--muted,var(--muted));font-size:0.78rem;padding:0.35rem 0.8rem;cursor:pointer">最近</button>
455
- <button class="grp-btn" data-group="project" style="background:none;border:none;color:var(--muted,var(--muted));font-size:0.78rem;padding:0.35rem 0.8rem;cursor:pointer;border-left:1px solid var(--border,var(--border))">项目</button>
456
- <button class="grp-btn" data-group="tag" style="background:none;border:none;color:var(--muted,var(--muted));font-size:0.78rem;padding:0.35rem 0.8rem;cursor:pointer;border-left:1px solid var(--border,var(--border))">标签</button>
457
- </div>
458
- <div id="tag-filter" style="display:none;flex-wrap:wrap;gap:0.35rem"></div>
459
- </div>
460
-
461
- <div id="list">
462
- <div class="empty-state">
463
- <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
464
- <rect x="2" y="4" width="20" height="16" rx="2"/>
465
- <path d="M8 9l4 3-4 3M13 15h3"/>
466
- </svg>
467
- <p>还没有终端会话</p>
468
- </div>
469
- </div>
470
- </div>
471
-
472
- <!-- FAB -->
473
- <button id="fab" onclick="openSheet()">+</button>
474
-
475
- <!-- Create sheet -->
476
- <div id="sheet-backdrop" onclick="closeSheet()"></div>
477
- <div id="sheet">
478
- <div class="sheet-handle"></div>
479
- <div class="sheet-title">新建终端会话</div>
480
-
481
- <!-- Preset chips -->
482
- <div class="presets" id="presets"></div>
483
-
484
- <div class="field">
485
- <label>名称 <span style="font-weight:400;text-transform:none;color:var(--red,var(--red))">必填</span></label>
486
- <input id="inp-title" type="text" placeholder="请输入任务名称" autocomplete="off" spellcheck="false">
487
- </div>
488
- <div class="field">
489
- <label>启动命令 <span style="font-weight:400;text-transform:none">(可选)</span></label>
490
- <input id="inp-cmd" type="text" placeholder="留空仅打开 shell" autocomplete="off" spellcheck="false" style="font-family:'SF Mono',Consolas,monospace;font-size:0.85rem">
491
- </div>
492
- <div class="field">
493
- <label>工作目录 <span style="font-weight:400;text-transform:none">(可选)</span></label>
494
- <div style="display:flex;gap:0.5rem">
495
- <input id="inp-cwd" type="text" placeholder="留空使用默认目录" autocomplete="off" spellcheck="false" style="flex:1">
496
- <button onclick="openDirPicker()" style="flex-shrink:0;background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:0 0.75rem;color:var(--text);font-size:1.1rem;cursor:pointer">📁</button>
497
- </div>
498
- <div id="recent-dirs" style="display:flex;flex-wrap:wrap;gap:0.35rem;margin-top:0.4rem"></div>
499
- </div>
500
- <button id="btn-create" onclick="createSession()">创建</button>
501
- <button id="btn-import" onclick="openImportSheet()" style="width:100%;background:var(--bg);color:var(--accent);border:1.5px solid var(--accent);border-radius:10px;padding:0.7rem;font-size:0.85rem;font-weight:600;cursor:pointer;margin-top:0.5rem">导入已有 Claude 会话</button>
502
- </div>
503
-
504
- <!-- Dir picker -->
505
- <div id="dir-picker">
506
- <div id="dir-backdrop2" onclick="closeDirPicker()"></div>
507
- <div id="dir-box">
508
- <div class="sheet-handle"></div>
509
- <div id="drive-bar"></div>
510
- <div id="dir-current"></div>
511
- <div id="dir-list"></div>
512
- <div id="dir-actions">
513
- <button class="dir-btn cancel" onclick="closeDirPicker()">取消</button>
514
- <button class="dir-btn primary" onclick="selectDir()">选择此目录</button>
515
- </div>
516
- </div>
517
- </div>
518
-
519
- <!-- Kill confirm sheet -->
520
- <div id="kill-menu">
521
- <div id="kill-backdrop" onclick="closeKill()"></div>
522
- <div id="kill-box">
523
- <h3 id="kill-title"></h3>
524
- <p>终止后会话将无法恢复</p>
525
- <button class="kill-btn danger" onclick="confirmKill()">终止会话</button>
526
- <button class="kill-btn cancel" onclick="closeKill()">取消</button>
527
- </div>
528
- </div>
529
-
530
- <!-- 会话体检 sheet -->
531
- <div id="doctor-menu" style="position:fixed;inset:0;z-index:60;display:none">
532
- <div onclick="closeDoctor()" style="position:absolute;inset:0;background:rgba(0,0,0,0.55)"></div>
533
- <div style="position:absolute;left:0;right:0;bottom:0;background:var(--surface);border-radius:20px 20px 0 0;width:100%;max-width:640px;margin:0 auto;padding:0 1.25rem 1.25rem;max-height:80vh;display:flex;flex-direction:column">
534
- <div class="sheet-handle"></div>
535
- <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.5rem">
536
- <div class="sheet-title" style="margin:0">🩺 会话体检</div>
537
- <button onclick="refreshDoctor()" style="background:none;border:1px solid var(--border);color:var(--muted);font-size:0.72rem;padding:0.25rem 0.55rem;border-radius:6px;cursor:pointer">刷新</button>
538
- </div>
539
- <div style="font-size:0.72rem;color:var(--muted);margin-bottom:0.5rem">「中毒」= 该会话恢复后实际非 bypass,写/删会被拦且无法运行时提权,需重开。</div>
540
- <div id="doctor-list" style="flex:1;overflow-y:auto;scrollbar-width:thin"></div>
541
- </div>
542
- </div>
543
-
544
- <!-- 导入已有会话 -->
545
- <div id="import-menu">
546
- <div id="import-backdrop" onclick="closeImportSheet()" style="position:absolute;inset:0;background:rgba(0,0,0,0.55)"></div>
547
- <div id="import-box" style="position:relative;background:var(--surface);border-radius:20px 20px 0 0;width:100%;max-width:640px;padding:0 1.25rem;padding-bottom:max(1.5rem,env(safe-area-inset-bottom));max-height:70vh;display:flex;flex-direction:column;z-index:1">
548
- <div class="sheet-handle"></div>
549
- <div class="sheet-title">导入已有 Claude 会话</div>
550
- <div id="import-list" style="flex:1;overflow-y:auto;padding:0.4rem 0;-webkit-overflow-scrolling:touch"></div>
551
- <div style="padding:0.75rem 0;border-top:1px solid var(--border)">
552
- <button class="dir-btn cancel" onclick="closeImportSheet()" style="width:100%">取消</button>
553
- </div>
554
- </div>
555
- </div>
556
-
557
- <script>
558
- const socket = io({
559
- transports: ['websocket', 'polling'],
560
- reconnection: true,
561
- reconnectionAttempts: Infinity,
562
- reconnectionDelay: 1000,
563
- reconnectionDelayMax: 10000,
564
- timeout: 30000,
565
- });
566
-
567
- const connDot = document.getElementById('conn-dot');
568
- const list = document.getElementById('list');
569
-
570
- // 加载当前用户信息(独占模式显示用户名、退出按钮、锁定目录)
571
- let _userDir = null;
572
- fetch('/api/me').then(r => r.json()).then(data => {
573
- if ((data.exclusive || data.hasUsers) && data.name) {
574
- document.getElementById('user-name').textContent = data.name;
575
- document.getElementById('logout-btn').style.display = '';
576
- }
577
- if (data.dir) {
578
- _userDir = data.dir;
579
- // 默认填入绑定目录,隐藏盘符栏,保留最近目录
580
- document.getElementById('inp-cwd').value = data.dir;
581
- document.getElementById('drive-bar').style.display = 'none';
582
- renderRecentDirs();
583
- }
584
- }).catch(() => {});
585
-
586
- async function showUsage() {
587
- try {
588
- const res = await fetch('/api/usage');
589
- const data = await res.json();
590
- const C = 'style="margin-bottom:1rem;padding:0.8rem 1rem;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.1);border-radius:10px"';
591
- const H = 'style="font-size:0.9rem;font-weight:700;margin-bottom:0.5rem;color:var(--text)"';
592
- const T = 'style="font-size:0.82rem;color:var(--text2);line-height:1.6"';
593
- let html = '<div style="padding:1.5rem">';
594
- html += '<h3 style="margin:0 0 1.2rem;font-size:1.1rem;color:#fff;text-align:center">Claude 用量</h3>';
595
- if (data.rateLimit) {
596
- const rl = data.rateLimit;
597
- const resetTime = new Date(rl.resetsAt * 1000);
598
- const remaining = Math.max(0, Math.ceil((resetTime - Date.now()) / 60000));
599
- const hours = Math.floor(remaining / 60);
600
- const mins = remaining % 60;
601
- const timeStr = hours > 0 ? `${hours}小时${mins}分钟` : `${mins}分钟`;
602
- const statusColor = rl.status === 'allowed' ? 'var(--green)' : 'var(--red)';
603
- const statusText = rl.status === 'allowed' ? '正常可用' : '已达上限';
604
- html += `<div ${C}>`;
605
- html += `<div ${H}>5 小时配额窗口</div>`;
606
- html += `<div ${T}><span style="color:${statusColor};font-weight:700;font-size:0.95rem">● ${statusText}</span></div>`;
607
- html += `<div ${T}>窗口重置: <b style="color:var(--text)">${timeStr}后</b>(${resetTime.toLocaleTimeString('zh-CN', {hour:'2-digit',minute:'2-digit'})})</div>`;
608
- html += '</div>';
609
- } else {
610
- html += `<div ${C}><div ${H}>5 小时配额窗口</div><div ${T}>发送一条 Agent 消息后可查看</div></div>`;
611
- }
612
- if (data.activeSessions?.length) {
613
- html += `<div ${C}><div ${H}>当前会话</div>`;
614
- for (const s of data.activeSessions) {
615
- if (!s.usage) continue;
616
- const u = s.usage;
617
- html += `<div ${T}><b style="color:var(--text)">${s.title}</b> (${s.owner || '管理员'})</div>`;
618
- html += `<div style="font-size:0.78rem;color:var(--muted);margin:0.2rem 0 0.3rem;line-height:1.5">查询 <b style="color:var(--text2)">${u.queryCount}</b>次 输入 <b style="color:var(--text2)">${((u.totalInputTokens+u.totalCacheReadTokens)/1000).toFixed(0)}K</b> 输出 <b style="color:var(--text2)">${(u.totalOutputTokens/1000).toFixed(0)}K</b> 费用 <b style="color:var(--text2)">$${u.totalCostUSD.toFixed(4)}</b></div>`;
619
- if (u.modelUsage) { for (const [m, mu] of Object.entries(u.modelUsage)) { html += `<div style="font-size:0.72rem;color:var(--dim);line-height:1.4"> ${m.replace(/claude-/,'').replace(/-\d{8}$/,'').replace(/\[.*\]/,'')} ${(mu.inputTokens/1000).toFixed(0)}K/${(mu.outputTokens/1000).toFixed(0)}K $${mu.costUSD.toFixed(4)}</div>`; } }
620
- }
621
- html += '</div>';
622
- }
623
- html += '<button onclick="this.closest(\'[data-usage-overlay]\').remove()" style="margin-top:0.8rem;width:100%;padding:0.7rem;background:var(--accent);color:#fff;border:none;border-radius:10px;cursor:pointer;font-size:0.9rem;font-weight:600">关闭</button></div>';
624
- const overlay = document.createElement('div');
625
- overlay.setAttribute('data-usage-overlay', '');
626
- overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:999;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(4px)';
627
- overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
628
- const box = document.createElement('div');
629
- box.style.cssText = 'background:var(--surface);border:1px solid rgba(255,255,255,0.12);border-radius:16px;max-height:80vh;overflow-y:auto;width:90%;max-width:420px;box-shadow:0 16px 48px rgba(0,0,0,0.4)';
630
- box.innerHTML = html;
631
- overlay.appendChild(box);
632
- document.body.appendChild(overlay);
633
- } catch (err) {
634
- alert('获取用量失败: ' + err.message);
635
- }
636
- }
637
-
638
- function doLogout() {
639
- if (!confirm('确定退出登录?')) return;
640
- fetch('/logout', { method: 'POST' }).finally(() => {
641
- socket.disconnect();
642
- location.href = '/login';
643
- });
644
- }
645
-
646
- // ── 外观:主题 & 字号(与会话页共用 localStorage myhi_theme / myhi_font)──
647
- const THEME_META = { dark: '#0d1117', light: '#ffffff', black: '#000000', system: '#0d1117' };
648
- function applyAppearance() {
649
- const t = localStorage.getItem('myhi_theme') || 'dark';
650
- const f = localStorage.getItem('myhi_font') || 'medium';
651
- const r = document.documentElement;
652
- if (!t || t === 'dark') r.removeAttribute('data-theme'); else r.setAttribute('data-theme', t);
653
- r.setAttribute('data-font', f);
654
- const meta = document.querySelector('meta[name="theme-color"]');
655
- if (meta) meta.setAttribute('content', THEME_META[t] || '#0d1117');
656
- }
657
- window.openAppearance = function() {
658
- const t = localStorage.getItem('myhi_theme') || 'dark';
659
- const f = localStorage.getItem('myhi_font') || 'medium';
660
- const overlay = document.createElement('div');
661
- overlay.style.cssText = 'position:fixed;inset:0;z-index:80;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center';
662
- const seg = (group, val, cur, label) => `<button data-g="${group}" data-v="${val}" style="flex:1;min-width:3rem;background:${val===cur?'var(--accent)':'var(--bg)'};color:${val===cur?'#fff':'var(--muted)'};border:1px solid ${val===cur?'var(--accent)':'var(--border)'};border-radius:8px;padding:0.5rem 0.3rem;font-size:0.82rem;cursor:pointer;white-space:nowrap">${label}</button>`;
663
- const box = document.createElement('div');
664
- box.style.cssText = 'background:var(--surface);border-radius:18px 18px 0 0;width:100%;max-width:640px;padding:0.5rem 1.1rem max(1rem,env(safe-area-inset-bottom))';
665
- box.innerHTML = `<div class="sheet-handle"></div>
666
- <div style="font-size:0.95rem;font-weight:600;margin-bottom:0.6rem">外观</div>
667
- <div style="font-size:0.8rem;color:var(--text2);margin-bottom:0.35rem">主题</div>
668
- <div style="display:flex;gap:0.35rem;flex-wrap:wrap">${seg('theme','system',t,'跟随系统')}${seg('theme','dark',t,'深色')}${seg('theme','light',t,'浅色')}${seg('theme','black',t,'纯黑')}</div>
669
- <div style="font-size:0.8rem;color:var(--text2);margin:0.7rem 0 0.35rem">字号</div>
670
- <div style="display:flex;gap:0.35rem">${seg('font','small',f,'小')}${seg('font','medium',f,'中')}${seg('font','large',f,'大')}</div>
671
- <button id="ap-close" style="margin-top:0.9rem;width:100%;background:var(--bg);border:1px solid var(--border);color:var(--muted);font-size:0.85rem;padding:0.6rem;border-radius:10px;cursor:pointer">完成</button>`;
672
- overlay.appendChild(box);
673
- const close = () => overlay.remove();
674
- overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
675
- box.querySelector('#ap-close').onclick = close;
676
- box.querySelectorAll('button[data-g]').forEach(b => b.onclick = () => {
677
- localStorage.setItem(b.dataset.g === 'theme' ? 'myhi_theme' : 'myhi_font', b.dataset.v);
678
- applyAppearance();
679
- close(); openAppearance();
680
- });
681
- document.body.appendChild(overlay);
682
- };
683
-
684
- // HTML 转义,防止 XSS
685
- function esc(s) {
686
- const d = document.createElement('div');
687
- d.textContent = s;
688
- return d.innerHTML;
689
- }
690
-
691
- // ── Connection ──────────────────────────────────────────────
692
- socket.on('connect', () => {
693
- connDot.className = 'on';
694
- socket.emit('list');
695
- });
696
- socket.on('disconnect', () => { connDot.className = 'off'; });
697
- socket.on('sessions', renderSessions);
698
-
699
- // Refresh periodically
700
- setInterval(() => { if (socket.connected) socket.emit('list'); }, 5000);
701
-
702
- // ── 移动端连接保活 ──────────────────────────────
703
- document.addEventListener('visibilitychange', () => {
704
- if (!document.hidden) {
705
- if (!socket.connected) socket.connect();
706
- else socket.emit('list');
707
- }
708
- });
709
- window.addEventListener('online', () => { if (!socket.connected) socket.connect(); });
710
- setInterval(() => { if (!document.hidden && !socket.connected) socket.connect(); }, 15000);
711
-
712
- // ── Render ──────────────────────────────────────────────────
713
- const PALETTE = ['#7c3aed','#2563eb','#059669','#b45309','#db2777','#0891b2'];
714
-
715
- function avatarColor(title) {
716
- let h = 0;
717
- for (const c of (title || '?')) h = (h * 31 + c.charCodeAt(0)) & 0xff;
718
- return PALETTE[h % PALETTE.length];
719
- }
720
-
721
- function fmtCwd(cwd) {
722
- if (!cwd) return '';
723
- // Show last 2 path segments
724
- const parts = cwd.replace(/\\/g, '/').split('/').filter(Boolean);
725
- return parts.slice(-2).join('/');
726
- }
727
-
728
- function fmtTime(iso) {
729
- const d = new Date(iso);
730
- return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
731
- }
732
-
733
- // 会话的「最后任务时间」:优先最后活动时间,回退创建时间。ms 数或 ISO 皆可。
734
- function sessionTime(s) {
735
- const v = s.lastActivityAt || s.createdAt || 0;
736
- const ms = typeof v === 'number' ? v : new Date(v).getTime();
737
- return isNaN(ms) ? 0 : ms;
738
- }
739
-
740
- // 相对时间:刚刚 / N分钟前 / N小时前 / 昨天 HH:MM / M月D日
741
- function fmtRelTime(ms) {
742
- if (!ms) return '';
743
- const now = Date.now();
744
- const diff = now - ms;
745
- if (diff < 60_000) return '刚刚';
746
- if (diff < 3_600_000) return Math.floor(diff / 60_000) + '分钟前';
747
- if (diff < 86_400_000) return Math.floor(diff / 3_600_000) + '小时前';
748
- const d = new Date(ms);
749
- const hm = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
750
- if (diff < 172_800_000) return '昨天 ' + hm;
751
- return (d.getMonth() + 1) + '月' + d.getDate() + '日';
752
- }
753
-
754
- // 项目名 = cwd 最后一段
755
- function projectName(cwd) {
756
- if (!cwd) return '(默认目录)';
757
- const parts = cwd.replace(/\\/g, '/').split('/').filter(Boolean);
758
- return parts[parts.length - 1] || '(根)';
759
- }
760
-
761
- // 视图状态:分组方式 + 标签筛选
762
- let _allSessions = [];
763
- let _groupMode = localStorage.getItem('myhi_group_mode') || 'time'; // time | project | tag
764
- let _tagFilter = null; // 仅 tag 模式下:只看某标签
765
-
766
- const EMPTY_HTML = `<div class="empty-state">
767
- <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
768
- <rect x="2" y="4" width="20" height="16" rx="2"/>
769
- <path d="M8 9l4 3-4 3M13 15h3"/>
770
- </svg>
771
- <p>还没有终端会话</p>
772
- </div>`;
773
-
774
- function cardHTML(s) {
775
- const safeTitle = esc(s.title || '');
776
- const safeId = esc(s.id);
777
- const color = avatarColor(s.title);
778
- const letter = esc((s.title || '?')[0].toUpperCase());
779
- const sub = esc([fmtCwd(s.cwd), fmtRelTime(sessionTime(s)), s.viewers > 0 ? `${s.viewers} 在线` : '', s.controlHolderName ? `${s.controlHolderName} 控制中` : ''].filter(Boolean).join(' · '));
780
- const tags = (s.tags || []).map(t => `<span class="tag-chip">${esc(t)}</span>`).join('');
781
- return `
782
- <div class="card" data-id="${safeId}" data-title="${safeTitle}">
783
- <div class="avatar" style="background:${color}">${letter}</div>
784
- <div class="card-body">
785
- <div class="card-title">${safeTitle}</div>
786
- <div class="card-sub">${sub}</div>
787
- ${tags ? `<div class="card-tags" style="display:flex;flex-wrap:wrap;gap:0.3rem;margin-top:0.3rem">${tags}</div>` : ''}
788
- </div>
789
- <div class="card-right">
790
- <div class="status-dot ${s.alive ? 'alive' : 'dead'}"></div>
791
- ${s.viewers > 1 ? `<div class="viewers">👁 ${s.viewers}</div>` : ''}
792
- </div>
793
- </div>`;
794
- }
795
-
796
- function groupHeaderHTML(label, count) {
797
- return `<div class="group-header" style="display:flex;align-items:center;gap:0.5rem;margin:0.85rem 0.2rem 0.4rem;color:var(--muted,var(--muted));font-size:0.75rem;font-weight:600">
798
- <span>${esc(label)}</span>
799
- <span style="color:var(--border,var(--border2))">·</span>
800
- <span style="font-weight:400">${count}</span>
801
- </div>`;
802
- }
803
-
804
- function renderSessions(sessions) {
805
- if (Array.isArray(sessions)) _allSessions = sessions;
806
- renderTagFilter();
807
- updateGroupSeg();
808
-
809
- const items = [..._allSessions].sort((a, b) => sessionTime(b) - sessionTime(a)); // 最新在前
810
- if (!items.length) { list.innerHTML = EMPTY_HTML; return; }
811
-
812
- let html = '';
813
- if (_groupMode === 'project') {
814
- const groups = new Map();
815
- for (const s of items) {
816
- const k = projectName(s.cwd);
817
- if (!groups.has(k)) groups.set(k, []);
818
- groups.get(k).push(s);
819
- }
820
- // 组按组内最新会话时间排序
821
- const ordered = [...groups.entries()].sort((a, b) => sessionTime(b[1][0]) - sessionTime(a[1][0]));
822
- for (const [name, arr] of ordered) {
823
- html += groupHeaderHTML('📁 ' + name, arr.length);
824
- html += arr.map(cardHTML).join('');
825
- }
826
- } else if (_groupMode === 'tag') {
827
- let shown = items;
828
- if (_tagFilter) shown = items.filter(s => (s.tags || []).includes(_tagFilter));
829
- const tagged = new Map();
830
- const untagged = [];
831
- for (const s of shown) {
832
- const tags = s.tags || [];
833
- if (!tags.length) { untagged.push(s); continue; }
834
- for (const t of tags) {
835
- if (_tagFilter && t !== _tagFilter) continue;
836
- if (!tagged.has(t)) tagged.set(t, []);
837
- tagged.get(t).push(s);
838
- }
839
- }
840
- const ordered = [...tagged.entries()].sort((a, b) => sessionTime(b[1][0]) - sessionTime(a[1][0]));
841
- for (const [name, arr] of ordered) {
842
- html += groupHeaderHTML('🏷 ' + name, arr.length);
843
- html += arr.map(cardHTML).join('');
844
- }
845
- if (!_tagFilter && untagged.length) {
846
- html += groupHeaderHTML('未分类', untagged.length);
847
- html += untagged.map(cardHTML).join('');
848
- }
849
- } else {
850
- html = items.map(cardHTML).join('');
851
- }
852
- list.innerHTML = html;
853
-
854
- // 事件委托:点击打开、右键/长按出菜单
855
- list.querySelectorAll('.card').forEach(card => {
856
- const id = card.dataset.id;
857
- const title = card.dataset.title;
858
- card.addEventListener('click', () => openSession(id));
859
- card.addEventListener('contextmenu', (e) => showActions(e, id, title));
860
- // 移动端长按
861
- let lpTimer = null;
862
- card.addEventListener('touchstart', (e) => { lpTimer = setTimeout(() => showActions(e, id, title), 550); }, { passive: true });
863
- card.addEventListener('touchend', () => clearTimeout(lpTimer));
864
- card.addEventListener('touchmove', () => clearTimeout(lpTimer));
865
- });
866
- }
867
-
868
- function renderTagFilter() {
869
- const bar = document.getElementById('tag-filter');
870
- if (_groupMode !== 'tag') { bar.style.display = 'none'; return; }
871
- const allTags = [...new Set(_allSessions.flatMap(s => s.tags || []))].sort();
872
- if (!allTags.length) { bar.style.display = 'none'; return; }
873
- bar.style.display = 'flex';
874
- const mk = (label, val, active) =>
875
- `<button class="tagf-btn" data-tag="${val === null ? '' : esc(val)}" style="background:${active ? 'var(--accent,var(--accent))' : 'var(--bg,var(--bg))'};color:${active ? '#fff' : 'var(--muted,var(--muted))'};border:1px solid ${active ? 'var(--accent,var(--accent))' : 'var(--border,var(--border))'};border-radius:999px;font-size:0.72rem;padding:0.2rem 0.6rem;cursor:pointer">${esc(label)}</button>`;
876
- bar.innerHTML = mk('全部', null, !_tagFilter) + allTags.map(t => mk(t, t, _tagFilter === t)).join('');
877
- bar.querySelectorAll('.tagf-btn').forEach(b => b.addEventListener('click', () => {
878
- _tagFilter = b.dataset.tag || null;
879
- renderSessions();
880
- }));
881
- }
882
-
883
- function updateGroupSeg() {
884
- document.querySelectorAll('.grp-btn').forEach(b => {
885
- const on = b.dataset.group === _groupMode;
886
- b.style.background = on ? 'var(--accent,var(--accent))' : 'none';
887
- b.style.color = on ? '#fff' : 'var(--muted,var(--muted))';
888
- });
889
- }
890
-
891
- document.querySelectorAll('.grp-btn').forEach(b => b.addEventListener('click', () => {
892
- _groupMode = b.dataset.group;
893
- _tagFilter = null;
894
- localStorage.setItem('myhi_group_mode', _groupMode);
895
- renderSessions();
896
- }));
897
-
898
- // ── Navigation ──────────────────────────────────────────────
899
- function openSession(id) {
900
- window.location.href = `/terminal/${id}`;
901
- }
902
-
903
- // ── 会话体检 ────────────────────────────────────────────────
904
- window.openDoctor = function() {
905
- document.getElementById('doctor-menu').style.display = 'block';
906
- refreshDoctor();
907
- };
908
- window.closeDoctor = function() {
909
- document.getElementById('doctor-menu').style.display = 'none';
910
- };
911
- window.refreshDoctor = async function() {
912
- const box = document.getElementById('doctor-list');
913
- box.innerHTML = '<div style="color:var(--muted);font-size:0.8rem;padding:1rem 0.2rem">加载中…</div>';
914
- try {
915
- const res = await fetch('/api/sessions/health', { credentials: 'same-origin' });
916
- const data = await res.json();
917
- renderDoctor(data.sessions || []);
918
- } catch (e) {
919
- box.innerHTML = '<div style="color:var(--red);font-size:0.8rem;padding:1rem 0.2rem">加载失败:' + esc(e.message) + '</div>';
920
- }
921
- };
922
- function pill(text, color, bg) {
923
- return `<span style="font-size:0.65rem;padding:0.1rem 0.4rem;border-radius:5px;color:${color};background:${bg};white-space:nowrap">${text}</span>`;
924
- }
925
- function renderDoctor(sessions) {
926
- const box = document.getElementById('doctor-list');
927
- if (!sessions.length) {
928
- box.innerHTML = '<div style="color:var(--muted);font-size:0.8rem;padding:1rem 0.2rem">没有 agent 会话</div>';
929
- return;
930
- }
931
- // 中毒的排前面
932
- sessions.sort((a, b) => (b.health?.poisoned ? 1 : 0) - (a.health?.poisoned ? 1 : 0));
933
- box.innerHTML = sessions.map(s => {
934
- const h = s.health || {};
935
- const poisoned = h.poisoned;
936
- const badges = [];
937
- if (poisoned) badges.push(pill('⚠ 中毒 · ' + esc(h.persistedMode || '非bypass'), '#fff', '#a11'));
938
- else if (h.persistedMode) badges.push(pill(esc(h.persistedMode), 'var(--muted)', 'var(--bg)'));
939
- badges.push(pill(s.alive ? '运行中' : '已停', s.alive ? 'var(--green)' : 'var(--muted)', 'var(--bg)'));
940
- if (s.busy) badges.push(pill('忙', 'var(--orange)', 'var(--bg)'));
941
- if (h.transcriptKB) badges.push(pill(h.transcriptKB + 'KB', 'var(--muted)', 'var(--bg)'));
942
- if (h.imageCount) badges.push(pill('🖼 ' + h.imageCount, 'var(--muted)', 'var(--bg)'));
943
- const border = poisoned ? '#a11' : 'var(--border)';
944
- return `
945
- <div style="border:1px solid ${border};border-radius:10px;padding:0.6rem 0.7rem;margin-bottom:0.5rem">
946
- <div style="display:flex;align-items:center;gap:0.4rem;flex-wrap:wrap;margin-bottom:0.35rem">
947
- <span style="font-weight:600;font-size:0.85rem">${esc(s.title || '(无标题)')}</span>
948
- ${badges.join('')}
949
- </div>
950
- <div style="font-size:0.68rem;color:var(--muted);margin-bottom:0.5rem;word-break:break-all">${esc(s.cwd || '')}</div>
951
- <div style="display:flex;gap:0.4rem">
952
- <button onclick="openSession('${esc(s.id)}')" style="flex:1;background:var(--bg);border:1px solid var(--border);color:var(--text);font-size:0.72rem;padding:0.35rem;border-radius:6px;cursor:pointer">打开</button>
953
- <button onclick="doctorReset('${esc(s.id)}','${esc(s.title || '')}')" style="flex:1;background:${poisoned ? '#1c3a2b' : 'var(--bg)'};border:1px solid ${poisoned ? 'var(--green)' : 'var(--border)'};color:${poisoned ? 'var(--green)' : 'var(--text)'};font-size:0.72rem;padding:0.35rem;border-radius:6px;cursor:pointer">重开为干净会话</button>
954
- <button onclick="doctorDelete('${esc(s.id)}','${esc(s.title || '')}')" style="background:var(--redbg);border:none;color:var(--red);font-size:0.72rem;padding:0.35rem 0.6rem;border-radius:6px;cursor:pointer">删除</button>
955
- </div>
956
- </div>`;
957
- }).join('');
958
- }
959
- window.doctorReset = async function(id, title) {
960
- if (!confirm(`重开「${title}」为干净会话?\n会断开旧对话上下文(磁盘上的文件不受影响),下次提问开启全新会话。`)) return;
961
- try {
962
- const res = await fetch(`/api/sessions/${id}/reset`, { method: 'POST', credentials: 'same-origin' });
963
- if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || res.status);
964
- refreshDoctor();
965
- } catch (e) { alert('重开失败:' + e.message); }
966
- };
967
- window.doctorDelete = async function(id, title) {
968
- if (!confirm(`删除会话「${title}」?此操作不可恢复。`)) return;
969
- try {
970
- const res = await fetch(`/api/sessions/${id}`, { method: 'DELETE', credentials: 'same-origin' });
971
- if (!res.ok && res.status !== 204) throw new Error(res.status);
972
- refreshDoctor();
973
- } catch (e) { alert('删除失败:' + e.message); }
974
- };
975
-
976
- // ── Presets ───────────────────────────────────────────────────
977
- const PRESETS = [
978
- // Agent 模式
979
- { icon: '🤖', label: 'Claude', title: 'claude', type: 'agent', cmd: 'claude' },
980
- // PTY 模式(终端)
981
- { icon: '✨', label: 'Gemini', title: 'gemini', type: 'pty', cmd: 'gemini' },
982
- { icon: '🐚', label: 'Shell', title: 'shell', type: 'pty', cmd: '' },
983
- ];
984
-
985
- let selectedPreset = null;
986
-
987
- function renderPresets() {
988
- const el = document.getElementById('presets');
989
- el.innerHTML = PRESETS.map((p, i) => `
990
- <div class="chip" id="chip-${i}" onclick="selectPreset(${i})">
991
- <span class="chip-icon">${p.icon}</span>
992
- <span class="chip-label">${p.label}</span>
993
- </div>`).join('');
994
- }
995
- renderPresets();
996
-
997
- function selectPreset(i) {
998
- selectedPreset = i;
999
- document.querySelectorAll('.chip').forEach((c, j) =>
1000
- c.classList.toggle('selected', j === i));
1001
- const p = PRESETS[i];
1002
- // 不再自动把名称填成 claude/gemini/shell —— 名称必填、留给用户输入任务名
1003
- document.getElementById('inp-cmd').value = p.cmd || '';
1004
- }
1005
-
1006
- // ── Create sheet ─────────────────────────────────────────────
1007
- function openSheet() {
1008
- document.getElementById('sheet-backdrop').classList.add('open');
1009
- document.getElementById('sheet').classList.add('open');
1010
- // Default to first preset
1011
- selectPreset(0);
1012
- setTimeout(() => document.getElementById('inp-title').focus(), 300);
1013
- }
1014
- function closeSheet() {
1015
- document.getElementById('sheet-backdrop').classList.remove('open');
1016
- document.getElementById('sheet').classList.remove('open');
1017
- selectedPreset = null;
1018
- document.querySelectorAll('.chip').forEach(c => c.classList.remove('selected'));
1019
- }
1020
-
1021
- document.getElementById('inp-title').addEventListener('keydown', e => {
1022
- if (e.key === 'Enter') createSession();
1023
- });
1024
-
1025
- function createSession() {
1026
- const titleInput = document.getElementById('inp-title');
1027
- const title = titleInput.value.trim();
1028
- if (!title) {
1029
- alert('请输入任务名称');
1030
- titleInput.focus();
1031
- return;
1032
- }
1033
- const initCmd = document.getElementById('inp-cmd').value.trim() || undefined;
1034
- const cwd = document.getElementById('inp-cwd').value.trim() || undefined;
1035
- if (cwd) recordDir(cwd);
1036
- const preset = selectedPreset != null ? PRESETS[selectedPreset] : null;
1037
- closeSheet();
1038
- document.getElementById('inp-title').value = '';
1039
- document.getElementById('inp-cmd').value = '';
1040
- document.getElementById('inp-cwd').value = '';
1041
-
1042
- if (preset?.type === 'agent') {
1043
- // Agent 模式:使用 Claude SDK
1044
- socket.emit('create-agent', {
1045
- title,
1046
- cwd,
1047
- }, (res) => {
1048
- if (res?.ok) openSession(res.session.id);
1049
- else alert('创建失败: ' + (res?.error || '未知错误'));
1050
- });
1051
- } else {
1052
- // PTY 模式:创建终端会话
1053
- socket.emit('create', { title, initCmd, cwd }, (res) => {
1054
- if (res?.ok) openSession(res.session.id);
1055
- else alert('创建失败: ' + (res?.error || '未知错误'));
1056
- });
1057
- }
1058
- }
1059
-
1060
- // ── Dir picker ───────────────────────────────────────────────
1061
- let _dirCurrent = '';
1062
- const RECENT_DIRS_KEY = 'myhi_recent_dirs'; // { path: count } 频次记录
1063
- const RECENT_DIRS_MAX = 5;
1064
-
1065
- function getRecentDirs() {
1066
- try { return JSON.parse(localStorage.getItem(RECENT_DIRS_KEY)) || {}; } catch { return {}; }
1067
- }
1068
- function recordDir(dir) {
1069
- if (!dir) return;
1070
- const dirs = getRecentDirs();
1071
- dirs[dir] = (dirs[dir] || 0) + 1;
1072
- // 只保留使用最多的若干条
1073
- const sorted = Object.entries(dirs).sort((a, b) => b[1] - a[1]);
1074
- const trimmed = Object.fromEntries(sorted.slice(0, RECENT_DIRS_MAX));
1075
- localStorage.setItem(RECENT_DIRS_KEY, JSON.stringify(trimmed));
1076
- renderRecentDirs();
1077
- }
1078
- function renderRecentDirs() {
1079
- const container = document.getElementById('recent-dirs');
1080
- container.innerHTML = '';
1081
- const dirs = getRecentDirs();
1082
- const sorted = Object.entries(dirs).sort((a, b) => b[1] - a[1]);
1083
- const filtered = _userDir ? sorted.filter(([path]) => path.startsWith(_userDir)) : sorted;
1084
- if (!filtered.length) return;
1085
- filtered.forEach(([path]) => {
1086
- const btn = document.createElement('button');
1087
- // 只显示最后一级目录名
1088
- const short = path.replace(/[\\/]$/, '').split(/[\\/]/).pop() || path;
1089
- btn.textContent = short;
1090
- btn.title = path;
1091
- btn.style.cssText = 'background:var(--bg);color:var(--accent);border:1px solid var(--border);border-radius:6px;padding:0.2rem 0.5rem;font-size:0.72rem;cursor:pointer;font-family:"SF Mono",Consolas,monospace;max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap';
1092
- btn.onclick = () => { document.getElementById('inp-cwd').value = path; };
1093
- container.appendChild(btn);
1094
- });
1095
- }
1096
- renderRecentDirs();
1097
-
1098
- function openDirPicker() {
1099
- document.getElementById('dir-picker').classList.add('open');
1100
- const dirs = getRecentDirs();
1101
- const topDir = Object.entries(dirs).sort((a, b) => b[1] - a[1])[0]?.[0] || '';
1102
- const initial = document.getElementById('inp-cwd').value.trim() || topDir || '';
1103
- loadDirs(initial);
1104
- loadDrives();
1105
- }
1106
- function closeDirPicker() {
1107
- document.getElementById('dir-picker').classList.remove('open');
1108
- }
1109
- function selectDir() {
1110
- document.getElementById('inp-cwd').value = _dirCurrent;
1111
- recordDir(_dirCurrent);
1112
- closeDirPicker();
1113
- }
1114
-
1115
- // Windows 盘符检测和切换
1116
- function loadDrives() {
1117
- const bar = document.getElementById('drive-bar');
1118
- if (!bar) return;
1119
- // 通过尝试访问常见盘符来检测
1120
- const letters = ['C', 'D', 'E', 'F', 'G', 'H'];
1121
- bar.innerHTML = '';
1122
- let found = 0;
1123
- for (const letter of letters) {
1124
- const path = letter + ':\\';
1125
- socket.emit('dirs', path, (data) => {
1126
- if (data.ok) {
1127
- found++;
1128
- const btn = document.createElement('button');
1129
- btn.className = 'drive-btn';
1130
- btn.textContent = letter + ':';
1131
- btn.onclick = () => {
1132
- loadDirs(path);
1133
- bar.querySelectorAll('.drive-btn').forEach(b => b.classList.remove('active'));
1134
- btn.classList.add('active');
1135
- };
1136
- // 高亮当前盘符
1137
- if (_dirCurrent && _dirCurrent.toUpperCase().startsWith(letter + ':')) {
1138
- btn.classList.add('active');
1139
- }
1140
- bar.appendChild(btn);
1141
- }
1142
- });
1143
- }
1144
- }
1145
-
1146
- function loadDirs(path) {
1147
- document.getElementById('dir-list').innerHTML = '<div style="padding:1rem;color:var(--muted);font-size:0.85rem">加载中...</div>';
1148
- socket.emit('dirs', path || '', (data) => {
1149
- if (!data.ok) {
1150
- document.getElementById('dir-list').innerHTML = `<div style="padding:1rem;color:var(--red);font-size:0.85rem">${escH(data.error)}</div>`;
1151
- return;
1152
- }
1153
- _dirCurrent = data.current;
1154
- document.getElementById('dir-current').textContent = data.current;
1155
- // 更新盘符高亮
1156
- document.querySelectorAll('.drive-btn').forEach(btn => {
1157
- btn.classList.toggle('active', _dirCurrent.toUpperCase().startsWith(btn.textContent.toUpperCase()));
1158
- });
1159
- const dirList = document.getElementById('dir-list');
1160
- dirList.innerHTML = '';
1161
- if (data.parent) {
1162
- const el = document.createElement('div');
1163
- el.className = 'dir-item';
1164
- el.dataset.path = data.parent;
1165
- el.innerHTML = '<span class="di">↩</span><span class="dn">..</span>';
1166
- dirList.appendChild(el);
1167
- }
1168
- for (const d of data.dirs) {
1169
- const el = document.createElement('div');
1170
- el.className = 'dir-item';
1171
- el.dataset.path = d.path;
1172
- el.innerHTML = `<span class="di">📁</span><span class="dn">${escH(d.name)}</span>`;
1173
- dirList.appendChild(el);
1174
- }
1175
- if (!dirList.children.length) {
1176
- dirList.innerHTML = '<div style="padding:1rem;color:var(--muted);font-size:0.85rem">无子目录</div>';
1177
- }
1178
- });
1179
- }
1180
- document.getElementById('dir-list').addEventListener('click', e => {
1181
- const item = e.target.closest('.dir-item');
1182
- if (item?.dataset.path) loadDirs(item.dataset.path);
1183
- });
1184
- function escH(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
1185
-
1186
- // ── 导入已有 Claude 会话 ───────────────────────────────────
1187
- function openImportSheet() {
1188
- closeSheet(); // 关闭创建面板
1189
- document.getElementById('import-menu').classList.add('open');
1190
- document.getElementById('import-list').innerHTML = '<div style="padding:1rem;color:var(--muted);font-size:0.85rem">加载中...</div>';
1191
- // 通过 REST API 获取本地 Claude 会话列表
1192
- fetch('/api/claude-sessions')
1193
- .then(r => r.json())
1194
- .then(sessions => {
1195
- const el = document.getElementById('import-list');
1196
- if (!sessions.length) {
1197
- el.innerHTML = '<div style="padding:1rem;color:var(--muted);font-size:0.85rem">未找到本地 Claude 会话<br><span style="font-size:0.72rem">会话数据存储在 ~/.claude/projects/ 目录</span></div>';
1198
- return;
1199
- }
1200
- el.innerHTML = '';
1201
- for (const s of sessions) {
1202
- const item = document.createElement('div');
1203
- item.className = 'import-item';
1204
- item.onclick = () => importSession(s);
1205
- const time = s.updatedAt ? new Date(s.updatedAt).toLocaleString('zh-CN', { month:'numeric', day:'numeric', hour:'2-digit', minute:'2-digit' }) : '';
1206
- const proj = s.projectDir ? decodeURIComponent(s.projectDir).split(/[\\/]/).pop() : '';
1207
- const name = s.summary || s.sessionId.slice(0, 12);
1208
- item.innerHTML = `
1209
- <span class="ii-icon">🤖</span>
1210
- <div class="ii-body">
1211
- <div class="ii-id">${escH(name)}</div>
1212
- <div class="ii-info">${time ? time + ' · ' : ''}${s.messageCount} 条消息${proj ? ' · ' + escH(proj) : ''}</div>
1213
- </div>`;
1214
- el.appendChild(item);
1215
- }
1216
- })
1217
- .catch(err => {
1218
- document.getElementById('import-list').innerHTML = `<div style="padding:1rem;color:var(--red);font-size:0.85rem">加载失败: ${escH(err.message)}</div>`;
1219
- });
1220
- }
1221
-
1222
- function closeImportSheet() {
1223
- document.getElementById('import-menu').classList.remove('open');
1224
- }
1225
-
1226
- function importSession(session) {
1227
- closeImportSheet();
1228
- const cwd = session.projectPath || document.getElementById('inp-cwd').value.trim() || undefined;
1229
- const title = session.summary ? session.summary.slice(0, 30) : 'claude (导入)';
1230
- socket.emit('create-agent', {
1231
- title,
1232
- cwd,
1233
- resumeSessionId: session.sessionId,
1234
- }, (res) => {
1235
- if (res?.ok) openSession(res.session.id);
1236
- else alert('导入失败: ' + (res?.error || '未知错误'));
1237
- });
1238
- }
1239
-
1240
- // ── Kill ─────────────────────────────────────────────────────
1241
- let _killId = null;
1242
- // 会话操作菜单:打开 / 重命名 / 设置标签 / 终止
1243
- function showActions(e, id, title) {
1244
- e.preventDefault();
1245
- const s = _allSessions.find(x => x.id === id);
1246
- const curTags = (s?.tags || []).join(', ');
1247
- const overlay = document.createElement('div');
1248
- overlay.style.cssText = 'position:fixed;inset:0;z-index:80;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center';
1249
- const box = document.createElement('div');
1250
- box.style.cssText = 'background:var(--surface,var(--surface));border-radius:18px 18px 0 0;width:100%;max-width:640px;padding:0.5rem 0 max(1rem,env(safe-area-inset-bottom))';
1251
- const mkBtn = (label, color) => `<button style="display:block;width:100%;text-align:left;background:none;border:none;border-top:1px solid var(--border,var(--border));color:${color || 'var(--text,var(--text))'};font-size:0.95rem;padding:0.9rem 1.25rem;cursor:pointer">${label}</button>`;
1252
- box.innerHTML =
1253
- `<div style="padding:0.6rem 1.25rem 0.4rem;color:var(--muted,var(--muted));font-size:0.8rem;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(title)}</div>` +
1254
- mkBtn('打开') + mkBtn('重命名') + mkBtn('设置标签') + mkBtn('终止会话', 'var(--red,var(--red))') +
1255
- `<button style="display:block;width:100%;background:none;border:none;border-top:6px solid var(--bg,var(--bg));color:var(--muted,var(--muted));font-size:0.9rem;padding:0.85rem;cursor:pointer">取消</button>`;
1256
- overlay.appendChild(box);
1257
- const close = () => overlay.remove();
1258
- overlay.addEventListener('click', (ev) => { if (ev.target === overlay) close(); });
1259
- const btns = box.querySelectorAll('button');
1260
- btns[0].onclick = () => { close(); openSession(id); };
1261
- btns[1].onclick = () => {
1262
- close();
1263
- const nv = prompt('重命名会话', title);
1264
- if (nv && nv.trim()) socket.emit('rename', { sessionId: id, title: nv.trim() });
1265
- };
1266
- btns[2].onclick = () => {
1267
- close();
1268
- const nv = prompt('设置标签(多个用逗号分隔,留空清除)', curTags);
1269
- if (nv !== null) socket.emit('set-tags', { sessionId: id, tags: nv.split(/[,,]/).map(t => t.trim()).filter(Boolean) });
1270
- };
1271
- btns[3].onclick = () => { close(); showKill(e, id, title); };
1272
- btns[4].onclick = close;
1273
- document.body.appendChild(overlay);
1274
- }
1275
-
1276
- function showKill(e, id, title) {
1277
- if (e && e.preventDefault) e.preventDefault();
1278
- _killId = id;
1279
- document.getElementById('kill-title').textContent = title;
1280
- document.getElementById('kill-menu').classList.add('open');
1281
- }
1282
- function closeKill() {
1283
- document.getElementById('kill-menu').classList.remove('open');
1284
- _killId = null;
1285
- }
1286
- function confirmKill() {
1287
- if (_killId) socket.emit('kill', _killId);
1288
- closeKill();
1289
- }
1290
-
1291
- // ── 版本更新 ──────────────────────────────────────────────
1292
- </script>
1293
- </body>
1294
- </html>
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover">
6
+ <meta name="theme-color" content="#0d1117">
7
+ <meta name="apple-mobile-web-app-capable" content="yes">
8
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
9
+ <meta name="apple-mobile-web-app-title" content="myhi">
10
+ <link rel="manifest" href="/manifest.json">
11
+ <link rel="apple-touch-icon" href="/icon.png">
12
+ <title>myhi</title>
13
+ <script src="/sio-client/socket.io.min.js"></script>
14
+ <script>
15
+ (function(){try{var t=localStorage.getItem('myhi_theme')||'dark',f=localStorage.getItem('myhi_font')||'medium',r=document.documentElement;if(t&&t!=='dark')r.setAttribute('data-theme',t);r.setAttribute('data-font',f);}catch(e){}})();
16
+ </script>
17
+ <style>
18
+ :root {
19
+ --bg:#0d1117; --surface:#161b22; --border:#21262d; --border2:#30363d;
20
+ --text:#e6edf3; --text2:#b1bac4; --muted:#8b949e; --dim:#6e7681;
21
+ --accent:#7c3aed; --accent2:#9d5cf5; --red:#f85149; --redbg:#3d1515;
22
+ --green:#3fb950; --yellow:#d29922; --orange:#f0883e;
23
+ }
24
+ :root[data-theme="light"] { --bg:#ffffff; --surface:#f6f8fa; --border:#d0d7de; --border2:#d0d7de;
25
+ --text:#1f2328; --text2:#57606a; --muted:#656d76; --dim:#818b98;
26
+ --accent:#8250df; --accent2:#8250df; --red:#cf222e; --redbg:#ffebe9;
27
+ --green:#1a7f37; --yellow:#9a6700; --orange:#bc4c00; }
28
+ :root[data-theme="black"] { --bg:#000000; --surface:#0a0a0a; --border:#161616; --border2:#262626; }
29
+ @media (prefers-color-scheme: light) { :root[data-theme="system"] { --bg:#ffffff; --surface:#f6f8fa; --border:#d0d7de; --border2:#d0d7de;
30
+ --text:#1f2328; --text2:#57606a; --muted:#656d76; --dim:#818b98;
31
+ --accent:#8250df; --accent2:#8250df; --red:#cf222e; --redbg:#ffebe9;
32
+ --green:#1a7f37; --yellow:#9a6700; --orange:#bc4c00; } }
33
+ html { font-size: 16px; }
34
+ html[data-font="small"]{font-size:15px;} html[data-font="medium"]{font-size:17px;} html[data-font="large"]{font-size:19.5px;}
35
+ * { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
36
+
37
+ html, body {
38
+ height: 100%;
39
+ background: var(--bg);
40
+ color: var(--text);
41
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
42
+ overscroll-behavior: none;
43
+ }
44
+
45
+ /* ── Layout ── */
46
+ #app {
47
+ display: flex;
48
+ flex-direction: column;
49
+ height: 100%;
50
+ max-width: 640px;
51
+ margin: 0 auto;
52
+ }
53
+
54
+ /* ── Header ── */
55
+ #header {
56
+ display: flex;
57
+ align-items: center;
58
+ justify-content: space-between;
59
+ padding: 0 1rem;
60
+ padding-top: max(1rem, env(safe-area-inset-top));
61
+ padding-bottom: 0.75rem;
62
+ background: var(--bg);
63
+ position: sticky;
64
+ top: 0;
65
+ z-index: 10;
66
+ border-bottom: 1px solid var(--border);
67
+ }
68
+ .logo {
69
+ font-size: 1.35rem;
70
+ font-weight: 700;
71
+ letter-spacing: -0.5px;
72
+ }
73
+ .logo span { color: var(--accent); }
74
+ #conn-dot {
75
+ width: 8px; height: 8px;
76
+ border-radius: 50%;
77
+ background: var(--muted);
78
+ transition: background 0.3s;
79
+ flex-shrink: 0;
80
+ }
81
+ #conn-dot.on { background: var(--green); box-shadow: 0 0 6px var(--green); }
82
+ #conn-dot.off { background: var(--red); }
83
+
84
+ /* ── Session list ── */
85
+ #list {
86
+ flex: 1;
87
+ overflow-y: auto;
88
+ -webkit-overflow-scrolling: touch;
89
+ padding: 0.75rem 0.75rem 6rem;
90
+ }
91
+
92
+ .empty-state {
93
+ display: flex;
94
+ flex-direction: column;
95
+ align-items: center;
96
+ justify-content: center;
97
+ gap: 0.75rem;
98
+ height: 60%;
99
+ color: var(--muted);
100
+ }
101
+ .empty-state svg { opacity: 0.3; }
102
+ .empty-state p { font-size: 0.95rem; }
103
+
104
+ /* ── Session card ── */
105
+ .card {
106
+ display: flex;
107
+ align-items: center;
108
+ gap: 0.85rem;
109
+ padding: 0.85rem 0.9rem;
110
+ background: var(--surface);
111
+ border: 1px solid var(--border);
112
+ border-radius: 12px;
113
+ margin-bottom: 0.6rem;
114
+ cursor: pointer;
115
+ transition: border-color 0.15s, background 0.15s;
116
+ user-select: none;
117
+ -webkit-user-select: none;
118
+ }
119
+ .card:active { background: #1c2230; }
120
+
121
+ .avatar {
122
+ width: 44px; height: 44px;
123
+ border-radius: 10px;
124
+ display: flex; align-items: center; justify-content: center;
125
+ font-size: 1.3rem;
126
+ font-weight: 700;
127
+ flex-shrink: 0;
128
+ font-family: 'SF Mono', Consolas, monospace;
129
+ color: #fff;
130
+ }
131
+
132
+ .card-body {
133
+ flex: 1;
134
+ min-width: 0;
135
+ }
136
+ .card-title {
137
+ font-size: 0.95rem;
138
+ font-weight: 600;
139
+ white-space: nowrap;
140
+ overflow: hidden;
141
+ text-overflow: ellipsis;
142
+ }
143
+ .card-sub {
144
+ font-size: 0.75rem;
145
+ color: var(--muted);
146
+ margin-top: 0.2rem;
147
+ white-space: nowrap;
148
+ overflow: hidden;
149
+ text-overflow: ellipsis;
150
+ }
151
+ .tag-chip {
152
+ display: inline-block;
153
+ font-size: 0.66rem;
154
+ color: var(--text2);
155
+ background: rgba(124,58,237,0.14);
156
+ border: 1px solid rgba(124,58,237,0.35);
157
+ border-radius: 999px;
158
+ padding: 0.05rem 0.45rem;
159
+ line-height: 1.5;
160
+ }
161
+
162
+ .card-right {
163
+ display: flex;
164
+ flex-direction: column;
165
+ align-items: flex-end;
166
+ gap: 0.35rem;
167
+ flex-shrink: 0;
168
+ }
169
+ .status-dot {
170
+ width: 8px; height: 8px;
171
+ border-radius: 50%;
172
+ }
173
+ .status-dot.alive { background: var(--green); box-shadow: 0 0 5px var(--green); }
174
+ .status-dot.dead { background: var(--red); }
175
+ .viewers {
176
+ font-size: 0.7rem;
177
+ color: var(--muted);
178
+ }
179
+
180
+ /* ── FAB ── */
181
+ #fab {
182
+ position: fixed;
183
+ bottom: max(1.5rem, calc(env(safe-area-inset-bottom) + 1rem));
184
+ right: max(1.5rem, calc(env(safe-area-inset-right) + 1rem));
185
+ width: 56px; height: 56px;
186
+ border-radius: 28px;
187
+ background: var(--accent);
188
+ color: #fff;
189
+ border: none;
190
+ font-size: 1.6rem;
191
+ line-height: 1;
192
+ cursor: pointer;
193
+ box-shadow: 0 4px 20px rgba(124,58,237,0.5);
194
+ display: flex; align-items: center; justify-content: center;
195
+ transition: background 0.15s, transform 0.15s;
196
+ z-index: 20;
197
+ }
198
+ #fab:active { background: var(--accent2); transform: scale(0.93); }
199
+
200
+ /* 随手记 FAB:灵感常常在你不在任何会话页的时候冒出来,首页也得能记 */
201
+ #fab-note {
202
+ position: fixed;
203
+ bottom: max(5.3rem, calc(env(safe-area-inset-bottom) + 4.8rem));
204
+ right: max(1.75rem, calc(env(safe-area-inset-right) + 1.25rem));
205
+ width: 46px; height: 46px;
206
+ border-radius: 23px;
207
+ background: #d29922;
208
+ color: #1a1a1a;
209
+ border: none;
210
+ font-size: 1.15rem;
211
+ line-height: 1;
212
+ cursor: pointer;
213
+ box-shadow: 0 4px 16px rgba(210,153,34,0.45);
214
+ display: flex; align-items: center; justify-content: center;
215
+ transition: background 0.15s, transform 0.15s;
216
+ z-index: 20;
217
+ }
218
+ #fab-note:active { transform: scale(0.93); }
219
+
220
+ /* ── Bottom sheet ── */
221
+ #sheet-backdrop {
222
+ display: none;
223
+ position: fixed; inset: 0;
224
+ background: rgba(0,0,0,0.55);
225
+ z-index: 30;
226
+ }
227
+ #sheet-backdrop.open { display: block; }
228
+
229
+ #sheet {
230
+ position: fixed;
231
+ bottom: 0; left: 0; right: 0;
232
+ background: var(--surface);
233
+ border-radius: 20px 20px 0 0;
234
+ padding: 0 1.25rem;
235
+ padding-bottom: max(1.5rem, env(safe-area-inset-bottom));
236
+ z-index: 31;
237
+ transform: translateY(100%);
238
+ transition: transform 0.28s cubic-bezier(0.32, 0.72, 0, 1);
239
+ max-width: 640px;
240
+ margin: 0 auto;
241
+ }
242
+ #sheet.open { transform: translateY(0); }
243
+
244
+ .sheet-handle {
245
+ width: 36px; height: 4px;
246
+ background: var(--border);
247
+ border-radius: 2px;
248
+ margin: 0.75rem auto 1.25rem;
249
+ }
250
+ .sheet-title {
251
+ font-size: 1rem;
252
+ font-weight: 600;
253
+ margin-bottom: 1rem;
254
+ }
255
+
256
+ /* ── Preset chips ── */
257
+ .presets {
258
+ display: flex;
259
+ gap: 0.5rem;
260
+ overflow-x: auto;
261
+ padding-bottom: 0.75rem;
262
+ margin-bottom: 0.75rem;
263
+ border-bottom: 1px solid var(--border);
264
+ scrollbar-width: none;
265
+ -webkit-overflow-scrolling: touch;
266
+ }
267
+ .presets::-webkit-scrollbar { display: none; }
268
+ .chip {
269
+ display: flex;
270
+ flex-direction: column;
271
+ align-items: center;
272
+ gap: 0.3rem;
273
+ flex-shrink: 0;
274
+ width: 76px;
275
+ padding: 0.6rem 0.4rem;
276
+ background: var(--bg);
277
+ border: 1.5px solid var(--border);
278
+ border-radius: 12px;
279
+ cursor: pointer;
280
+ transition: border-color 0.15s, background 0.15s;
281
+ user-select: none;
282
+ -webkit-user-select: none;
283
+ }
284
+ .chip:active, .chip.selected {
285
+ border-color: var(--accent);
286
+ background: rgba(124,58,237,0.12);
287
+ }
288
+ .chip-icon { font-size: 1.4rem; line-height: 1; }
289
+ .chip-label {
290
+ font-size: 0.65rem;
291
+ color: var(--muted);
292
+ text-align: center;
293
+ white-space: nowrap;
294
+ overflow: hidden;
295
+ text-overflow: ellipsis;
296
+ width: 100%;
297
+ }
298
+ .chip.selected .chip-label { color: var(--accent2); }
299
+
300
+ .field { margin-bottom: 0.9rem; }
301
+ .field label {
302
+ display: block;
303
+ font-size: 0.75rem;
304
+ color: var(--muted);
305
+ margin-bottom: 0.35rem;
306
+ font-weight: 500;
307
+ text-transform: uppercase;
308
+ letter-spacing: 0.04em;
309
+ }
310
+ .field input {
311
+ width: 100%;
312
+ background: var(--bg);
313
+ border: 1px solid var(--border);
314
+ border-radius: 8px;
315
+ padding: 0.65rem 0.8rem;
316
+ color: var(--text);
317
+ font-size: 0.95rem;
318
+ font-family: inherit;
319
+ outline: none;
320
+ transition: border-color 0.15s;
321
+ }
322
+ .field input:focus { border-color: var(--accent); }
323
+ .field input::placeholder { color: var(--dim); }
324
+
325
+ #btn-create {
326
+ width: 100%;
327
+ background: var(--accent);
328
+ color: #fff;
329
+ border: none;
330
+ border-radius: 10px;
331
+ padding: 0.8rem;
332
+ font-size: 0.95rem;
333
+ font-weight: 600;
334
+ cursor: pointer;
335
+ margin-top: 0.25rem;
336
+ transition: background 0.15s;
337
+ }
338
+ #btn-create:active { background: var(--accent2); }
339
+
340
+ /* ── Dir picker ── */
341
+ #dir-picker {
342
+ display: none; position: fixed; inset: 0; z-index: 50;
343
+ align-items: flex-end; justify-content: center;
344
+ }
345
+ #dir-picker.open { display: flex; }
346
+ #dir-backdrop2 { position: absolute; inset: 0; background: rgba(0,0,0,0.6); }
347
+ #dir-box {
348
+ position: relative; z-index: 1;
349
+ background: var(--surface);
350
+ border-radius: 20px 20px 0 0;
351
+ width: 100%; max-width: 640px;
352
+ padding: 0 1.25rem;
353
+ padding-bottom: max(1.5rem, env(safe-area-inset-bottom));
354
+ max-height: 70vh; display: flex; flex-direction: column;
355
+ }
356
+ #dir-current {
357
+ font-size: 0.72rem; color: var(--muted);
358
+ font-family: 'SF Mono', Consolas, monospace;
359
+ padding: 0.9rem 0 0.5rem;
360
+ border-bottom: 1px solid var(--border);
361
+ word-break: break-all;
362
+ }
363
+ #dir-list {
364
+ flex: 1; overflow-y: auto; padding: 0.4rem 0;
365
+ -webkit-overflow-scrolling: touch;
366
+ }
367
+ .dir-item {
368
+ display: flex; align-items: center; gap: 0.6rem;
369
+ padding: 0.65rem 0.2rem;
370
+ border-bottom: 1px solid var(--border);
371
+ cursor: pointer; font-size: 0.9rem;
372
+ }
373
+ .dir-item:active { background: var(--border); border-radius: 6px; }
374
+ .dir-item .di { font-size: 1rem; }
375
+ .dir-item .dn { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
376
+ #dir-actions {
377
+ display: flex; gap: 0.6rem; padding: 0.75rem 0;
378
+ border-top: 1px solid var(--border);
379
+ }
380
+ .dir-btn {
381
+ flex: 1; padding: 0.7rem; border-radius: 10px;
382
+ border: none; font-size: 0.9rem; font-weight: 600; cursor: pointer;
383
+ }
384
+ .dir-btn.primary { background: var(--accent); color: #fff; }
385
+ .dir-btn.cancel { background: var(--bg); color: var(--text); }
386
+
387
+ /* ── Kill confirm ── */
388
+ #kill-menu {
389
+ display: none;
390
+ position: fixed; inset: 0;
391
+ z-index: 40;
392
+ align-items: flex-end;
393
+ justify-content: center;
394
+ }
395
+ #kill-menu.open { display: flex; }
396
+ #kill-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,0.55); }
397
+ #kill-box {
398
+ position: relative;
399
+ background: var(--surface);
400
+ border-radius: 16px 16px 0 0;
401
+ width: 100%;
402
+ max-width: 640px;
403
+ padding: 1.25rem;
404
+ padding-bottom: max(1.5rem, env(safe-area-inset-bottom));
405
+ z-index: 1;
406
+ }
407
+ #kill-box h3 { font-size: 0.95rem; margin-bottom: 0.3rem; }
408
+ #kill-box p { font-size: 0.8rem; color: var(--muted); margin-bottom: 1rem; }
409
+ .kill-btn {
410
+ display: block; width: 100%;
411
+ padding: 0.75rem; border-radius: 10px;
412
+ border: none; font-size: 0.95rem; font-weight: 600;
413
+ cursor: pointer; margin-bottom: 0.5rem;
414
+ }
415
+ .kill-btn.danger { background: var(--redbg); color: var(--red); }
416
+ .kill-btn.cancel { background: var(--bg); color: var(--text); }
417
+
418
+ /* ── 导入会话 ── */
419
+ #import-menu {
420
+ display: none; position: fixed; inset: 0; z-index: 40;
421
+ align-items: flex-end; justify-content: center;
422
+ }
423
+ #import-menu.open { display: flex; }
424
+ .import-item {
425
+ display: flex; align-items: center; gap: 0.6rem;
426
+ padding: 0.7rem 0.2rem; border-bottom: 1px solid var(--border);
427
+ cursor: pointer; font-size: 0.85rem;
428
+ }
429
+ .import-item:active { background: var(--border); border-radius: 6px; }
430
+ .import-item .ii-icon { font-size: 1.1rem; }
431
+ .import-item .ii-body { flex: 1; min-width: 0; }
432
+ .import-item .ii-id { font-size: 0.82rem; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
433
+ .import-item .ii-info { font-size: 0.72rem; color: var(--muted); }
434
+
435
+ /* ── 盘符选择栏 ── */
436
+ #drive-bar {
437
+ display: flex; gap: 0.5rem; padding: 0.6rem 0; border-bottom: 1px solid var(--border);
438
+ overflow-x: auto; scrollbar-width: none;
439
+ }
440
+ #drive-bar::-webkit-scrollbar { display: none; }
441
+ .drive-btn {
442
+ padding: 0.5rem 1rem; border-radius: 8px; font-size: 1rem; font-weight: 600;
443
+ background: var(--bg); border: 1.5px solid var(--border); color: var(--text);
444
+ cursor: pointer; white-space: nowrap; flex-shrink: 0;
445
+ min-width: 3rem; text-align: center;
446
+ }
447
+ .drive-btn:active, .drive-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(124,58,237,0.12); }
448
+ #update-btn { background: var(--yellow); color: #000; border: none; border-radius: 6px; padding: 0.35rem 0.75rem; font-size: 0.75rem; font-weight: 600; cursor: pointer; white-space: nowrap; }
449
+ #update-btn:active { opacity: 0.7; }
450
+ #update-btn:disabled { opacity: 0.5; cursor: not-allowed; }
451
+ /* 分屏是 PC 大屏功能,手机端(触屏)隐藏入口 */
452
+ @media (pointer: coarse) { #grid-btn { display: none !important; } }
453
+ </style>
454
+ </head>
455
+ <body>
456
+ <div id="app">
457
+ <div id="header">
458
+ <div class="logo">my<span>hi</span></div>
459
+ <div style="display:flex;align-items:center;gap:0.6rem">
460
+ <span id="user-name" style="font-size:0.8rem;color:var(--muted)"></span>
461
+ <button id="grid-btn" onclick="location.href='/grid'" title="分屏工作台(多任务并排)" style="background:none;border:1px solid var(--border);color:var(--muted);font-size:0.75rem;padding:0.3rem 0.6rem;border-radius:6px;cursor:pointer;width:auto">▦ 分屏</button>
462
+ <button id="appearance-btn" onclick="openAppearance()" title="外观" style="background:none;border:1px solid var(--border);color:var(--muted);font-size:0.75rem;padding:0.3rem 0.55rem;border-radius:6px;cursor:pointer;width:auto">🎨</button>
463
+ <button id="doctor-btn" onclick="openDoctor()" style="background:none;border:1px solid var(--border);color:var(--muted);font-size:0.75rem;padding:0.3rem 0.6rem;border-radius:6px;cursor:pointer;width:auto">🩺 体检</button>
464
+ <button id="usage-btn" onclick="showUsage()" style="background:none;border:1px solid var(--border);color:var(--muted);font-size:0.75rem;padding:0.3rem 0.6rem;border-radius:6px;cursor:pointer;width:auto">用量</button>
465
+ <button id="logout-btn" onclick="doLogout()" style="display:none;background:none;border:1px solid var(--border);color:var(--muted);font-size:0.75rem;padding:0.3rem 0.6rem;border-radius:6px;cursor:pointer;width:auto">退出</button>
466
+ <div id="conn-dot"></div>
467
+ </div>
468
+ </div>
469
+
470
+ <!-- 升级管理已移至 /admin -->
471
+
472
+ <div id="view-controls" style="display:flex;flex-direction:column;gap:0.5rem;margin-bottom:0.75rem">
473
+ <!-- 会话搜索:任务多了光看标题认不出,连首条消息/目录/标签一起搜 -->
474
+ <div style="display:flex;align-items:center;gap:0.4rem">
475
+ <input id="sess-search" type="search" placeholder="搜任务:标题 / 首条需求 / 目录 / 标签"
476
+ autocomplete="off" spellcheck="false"
477
+ style="flex:1;background:var(--bg,var(--bg));border:1px solid var(--border,var(--border));border-radius:8px;color:var(--text,var(--text));font-size:0.8rem;padding:0.4rem 0.7rem;outline:none">
478
+ <span id="search-count" style="font-size:0.72rem;color:var(--muted,var(--muted));white-space:nowrap"></span>
479
+ </div>
480
+ <div id="group-seg" style="display:inline-flex;align-self:flex-start;background:var(--bg,var(--bg));border:1px solid var(--border,var(--border));border-radius:8px;overflow:hidden">
481
+ <button class="grp-btn" data-group="time" style="background:none;border:none;color:var(--muted,var(--muted));font-size:0.78rem;padding:0.35rem 0.8rem;cursor:pointer">最近</button>
482
+ <button class="grp-btn" data-group="project" style="background:none;border:none;color:var(--muted,var(--muted));font-size:0.78rem;padding:0.35rem 0.8rem;cursor:pointer;border-left:1px solid var(--border,var(--border))">项目</button>
483
+ <button class="grp-btn" data-group="tag" style="background:none;border:none;color:var(--muted,var(--muted));font-size:0.78rem;padding:0.35rem 0.8rem;cursor:pointer;border-left:1px solid var(--border,var(--border))">标签</button>
484
+ </div>
485
+ <div id="tag-filter" style="display:none;flex-wrap:wrap;gap:0.35rem"></div>
486
+ </div>
487
+
488
+ <div id="list">
489
+ <div class="empty-state">
490
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
491
+ <rect x="2" y="4" width="20" height="16" rx="2"/>
492
+ <path d="M8 9l4 3-4 3M13 15h3"/>
493
+ </svg>
494
+ <p>还没有终端会话</p>
495
+ </div>
496
+ </div>
497
+ </div>
498
+
499
+ <!-- FAB -->
500
+ <button id="fab" onclick="openSheet()">+</button>
501
+ <button id="fab-note" onclick="openNoteSheet()" title="随手记:把想法先记进某个任务的草稿箱">📥</button>
502
+
503
+ <!-- 随手记:不进会话也能记一条草稿,但必须指定归属任务 -->
504
+ <div id="note-backdrop" onclick="closeNoteSheet()" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:30"></div>
505
+ <div id="note-sheet" style="display:none;position:fixed;left:0;right:0;bottom:0;z-index:31;background:var(--surface,var(--surface));border-radius:16px 16px 0 0;padding:1rem 1rem calc(1rem + env(safe-area-inset-bottom));max-height:88vh;overflow-y:auto">
506
+ <div class="sheet-handle"></div>
507
+ <div class="sheet-title">📥 随手记</div>
508
+ <div style="font-size:0.7rem;color:var(--muted,var(--muted));line-height:1.55;margin-bottom:0.6rem">
509
+ 临时冒出的需求先记下来,等那个任务干完再发。<b>必须选归属任务</b>——不记归属的话,过几天翻到这条也想不起是哪个项目的事。
510
+ </div>
511
+ <div class="field">
512
+ <label>归属任务 <span style="font-weight:400;text-transform:none;color:var(--red,var(--red))">必填</span></label>
513
+ <select id="note-session" style="width:100%;background:var(--bg,var(--bg));border:1px solid var(--border,var(--border));border-radius:8px;color:var(--text,var(--text));padding:0.6rem;font-size:0.85rem"></select>
514
+ </div>
515
+ <div class="field">
516
+ <label>想法 / 需求</label>
517
+ <textarea id="note-text" rows="4" placeholder="例如:导出接口改成异步,顺便把超时时间提到 60s" spellcheck="false"
518
+ style="width:100%;box-sizing:border-box;background:var(--bg,var(--bg));border:1px solid var(--border,var(--border));border-radius:8px;color:var(--text,var(--text));padding:0.6rem;font-size:0.85rem;resize:vertical"></textarea>
519
+ </div>
520
+ <div class="field">
521
+ <label>图片 <span style="font-weight:400;text-transform:none">(可选)</span></label>
522
+ <input type="file" id="note-image" accept="image/*" style="font-size:0.78rem;color:var(--muted,var(--muted))">
523
+ <div id="note-image-status" style="font-size:0.7rem;color:var(--muted,var(--muted));margin-top:0.25rem"></div>
524
+ </div>
525
+ <label style="display:flex;align-items:center;gap:0.45rem;font-size:0.8rem;color:var(--text,var(--text));margin:0.3rem 0 0.7rem;cursor:pointer">
526
+ <input type="checkbox" id="note-autosend" style="width:1.05rem;height:1.05rem">
527
+ 自动接力:那个任务一干完就自动发出(默认关)
528
+ </label>
529
+ <button onclick="saveNote()" style="width:100%;background:#d29922;color:#1a1a1a;border:none;border-radius:10px;padding:0.75rem;font-size:0.9rem;font-weight:700;cursor:pointer">存进草稿箱</button>
530
+ <button onclick="closeNoteSheet()" style="width:100%;background:var(--bg,var(--bg));color:var(--muted,var(--muted));border:1px solid var(--border,var(--border));border-radius:10px;padding:0.65rem;font-size:0.85rem;cursor:pointer;margin-top:0.5rem">取消</button>
531
+ </div>
532
+
533
+ <!-- Create sheet -->
534
+ <div id="sheet-backdrop" onclick="closeSheet()"></div>
535
+ <div id="sheet">
536
+ <div class="sheet-handle"></div>
537
+ <div class="sheet-title">新建终端会话</div>
538
+
539
+ <!-- Preset chips -->
540
+ <div class="presets" id="presets"></div>
541
+
542
+ <div class="field">
543
+ <label>名称 <span style="font-weight:400;text-transform:none;color:var(--red,var(--red))">必填</span></label>
544
+ <input id="inp-title" type="text" placeholder="请输入任务名称" autocomplete="off" spellcheck="false">
545
+ </div>
546
+ <div class="field">
547
+ <label>启动命令 <span style="font-weight:400;text-transform:none">(可选)</span></label>
548
+ <input id="inp-cmd" type="text" placeholder="留空仅打开 shell" autocomplete="off" spellcheck="false" style="font-family:'SF Mono',Consolas,monospace;font-size:0.85rem">
549
+ </div>
550
+ <div class="field">
551
+ <label>工作目录 <span style="font-weight:400;text-transform:none">(可选)</span></label>
552
+ <div style="display:flex;gap:0.5rem">
553
+ <input id="inp-cwd" type="text" placeholder="留空使用默认目录" autocomplete="off" spellcheck="false" style="flex:1">
554
+ <button onclick="openDirPicker()" style="flex-shrink:0;background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:0 0.75rem;color:var(--text);font-size:1.1rem;cursor:pointer">📁</button>
555
+ </div>
556
+ <div id="recent-dirs" style="display:flex;flex-wrap:wrap;gap:0.35rem;margin-top:0.4rem"></div>
557
+ </div>
558
+ <button id="btn-create" onclick="createSession()">创建</button>
559
+ <button id="btn-import" onclick="openImportSheet()" style="width:100%;background:var(--bg);color:var(--accent);border:1.5px solid var(--accent);border-radius:10px;padding:0.7rem;font-size:0.85rem;font-weight:600;cursor:pointer;margin-top:0.5rem">导入已有 Claude 会话</button>
560
+ </div>
561
+
562
+ <!-- Dir picker -->
563
+ <div id="dir-picker">
564
+ <div id="dir-backdrop2" onclick="closeDirPicker()"></div>
565
+ <div id="dir-box">
566
+ <div class="sheet-handle"></div>
567
+ <div id="drive-bar"></div>
568
+ <div id="dir-current"></div>
569
+ <div id="dir-list"></div>
570
+ <div id="dir-actions">
571
+ <button class="dir-btn cancel" onclick="closeDirPicker()">取消</button>
572
+ <button class="dir-btn primary" onclick="selectDir()">选择此目录</button>
573
+ </div>
574
+ </div>
575
+ </div>
576
+
577
+ <!-- Kill confirm sheet -->
578
+ <div id="kill-menu">
579
+ <div id="kill-backdrop" onclick="closeKill()"></div>
580
+ <div id="kill-box">
581
+ <h3 id="kill-title"></h3>
582
+ <p>终止后会话将无法恢复</p>
583
+ <button class="kill-btn danger" onclick="confirmKill()">终止会话</button>
584
+ <button class="kill-btn cancel" onclick="closeKill()">取消</button>
585
+ </div>
586
+ </div>
587
+
588
+ <!-- 会话体检 sheet -->
589
+ <div id="doctor-menu" style="position:fixed;inset:0;z-index:60;display:none">
590
+ <div onclick="closeDoctor()" style="position:absolute;inset:0;background:rgba(0,0,0,0.55)"></div>
591
+ <div style="position:absolute;left:0;right:0;bottom:0;background:var(--surface);border-radius:20px 20px 0 0;width:100%;max-width:640px;margin:0 auto;padding:0 1.25rem 1.25rem;max-height:80vh;display:flex;flex-direction:column">
592
+ <div class="sheet-handle"></div>
593
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.5rem">
594
+ <div class="sheet-title" style="margin:0">🩺 会话体检</div>
595
+ <button onclick="refreshDoctor()" style="background:none;border:1px solid var(--border);color:var(--muted);font-size:0.72rem;padding:0.25rem 0.55rem;border-radius:6px;cursor:pointer">刷新</button>
596
+ </div>
597
+ <div style="font-size:0.72rem;color:var(--muted);margin-bottom:0.5rem">「中毒」= 该会话恢复后实际非 bypass,写/删会被拦且无法运行时提权,需重开。</div>
598
+ <div id="doctor-list" style="flex:1;overflow-y:auto;scrollbar-width:thin"></div>
599
+ </div>
600
+ </div>
601
+
602
+ <!-- 导入已有会话 -->
603
+ <div id="import-menu">
604
+ <div id="import-backdrop" onclick="closeImportSheet()" style="position:absolute;inset:0;background:rgba(0,0,0,0.55)"></div>
605
+ <div id="import-box" style="position:relative;background:var(--surface);border-radius:20px 20px 0 0;width:100%;max-width:640px;padding:0 1.25rem;padding-bottom:max(1.5rem,env(safe-area-inset-bottom));max-height:70vh;display:flex;flex-direction:column;z-index:1">
606
+ <div class="sheet-handle"></div>
607
+ <div class="sheet-title">导入已有 Claude 会话</div>
608
+ <div id="import-list" style="flex:1;overflow-y:auto;padding:0.4rem 0;-webkit-overflow-scrolling:touch"></div>
609
+ <div style="padding:0.75rem 0;border-top:1px solid var(--border)">
610
+ <button class="dir-btn cancel" onclick="closeImportSheet()" style="width:100%">取消</button>
611
+ </div>
612
+ </div>
613
+ </div>
614
+
615
+ <script>
616
+ const socket = io({
617
+ transports: ['websocket', 'polling'],
618
+ reconnection: true,
619
+ reconnectionAttempts: Infinity,
620
+ reconnectionDelay: 1000,
621
+ reconnectionDelayMax: 10000,
622
+ timeout: 30000,
623
+ });
624
+
625
+ const connDot = document.getElementById('conn-dot');
626
+ const list = document.getElementById('list');
627
+
628
+ // 加载当前用户信息(独占模式显示用户名、退出按钮、锁定目录)
629
+ let _userDir = null;
630
+ fetch('/api/me').then(r => r.json()).then(data => {
631
+ if ((data.exclusive || data.hasUsers) && data.name) {
632
+ document.getElementById('user-name').textContent = data.name;
633
+ document.getElementById('logout-btn').style.display = '';
634
+ }
635
+ if (data.dir) {
636
+ _userDir = data.dir;
637
+ // 默认填入绑定目录,隐藏盘符栏,保留最近目录
638
+ document.getElementById('inp-cwd').value = data.dir;
639
+ document.getElementById('drive-bar').style.display = 'none';
640
+ renderRecentDirs();
641
+ }
642
+ }).catch(() => {});
643
+
644
+ async function showUsage() {
645
+ try {
646
+ const res = await fetch('/api/usage');
647
+ const data = await res.json();
648
+ const C = 'style="margin-bottom:1rem;padding:0.8rem 1rem;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.1);border-radius:10px"';
649
+ const H = 'style="font-size:0.9rem;font-weight:700;margin-bottom:0.5rem;color:var(--text)"';
650
+ const T = 'style="font-size:0.82rem;color:var(--text2);line-height:1.6"';
651
+ let html = '<div style="padding:1.5rem">';
652
+ html += '<h3 style="margin:0 0 1.2rem;font-size:1.1rem;color:#fff;text-align:center">Claude 用量</h3>';
653
+ if (data.rateLimit) {
654
+ const rl = data.rateLimit;
655
+ const resetTime = new Date(rl.resetsAt * 1000);
656
+ const remaining = Math.max(0, Math.ceil((resetTime - Date.now()) / 60000));
657
+ const hours = Math.floor(remaining / 60);
658
+ const mins = remaining % 60;
659
+ const timeStr = hours > 0 ? `${hours}小时${mins}分钟` : `${mins}分钟`;
660
+ const statusColor = rl.status === 'allowed' ? 'var(--green)' : 'var(--red)';
661
+ const statusText = rl.status === 'allowed' ? '正常可用' : '已达上限';
662
+ html += `<div ${C}>`;
663
+ html += `<div ${H}>5 小时配额窗口</div>`;
664
+ html += `<div ${T}><span style="color:${statusColor};font-weight:700;font-size:0.95rem">● ${statusText}</span></div>`;
665
+ html += `<div ${T}>窗口重置: <b style="color:var(--text)">${timeStr}后</b>(${resetTime.toLocaleTimeString('zh-CN', {hour:'2-digit',minute:'2-digit'})})</div>`;
666
+ html += '</div>';
667
+ } else {
668
+ html += `<div ${C}><div ${H}>5 小时配额窗口</div><div ${T}>发送一条 Agent 消息后可查看</div></div>`;
669
+ }
670
+ if (data.activeSessions?.length) {
671
+ html += `<div ${C}><div ${H}>当前会话</div>`;
672
+ for (const s of data.activeSessions) {
673
+ if (!s.usage) continue;
674
+ const u = s.usage;
675
+ html += `<div ${T}><b style="color:var(--text)">${s.title}</b> (${s.owner || '管理员'})</div>`;
676
+ html += `<div style="font-size:0.78rem;color:var(--muted);margin:0.2rem 0 0.3rem;line-height:1.5">查询 <b style="color:var(--text2)">${u.queryCount}</b>次 输入 <b style="color:var(--text2)">${((u.totalInputTokens+u.totalCacheReadTokens)/1000).toFixed(0)}K</b> 输出 <b style="color:var(--text2)">${(u.totalOutputTokens/1000).toFixed(0)}K</b> 费用 <b style="color:var(--text2)">$${u.totalCostUSD.toFixed(4)}</b></div>`;
677
+ if (u.modelUsage) { for (const [m, mu] of Object.entries(u.modelUsage)) { html += `<div style="font-size:0.72rem;color:var(--dim);line-height:1.4"> ${m.replace(/claude-/,'').replace(/-\d{8}$/,'').replace(/\[.*\]/,'')} ${(mu.inputTokens/1000).toFixed(0)}K/${(mu.outputTokens/1000).toFixed(0)}K $${mu.costUSD.toFixed(4)}</div>`; } }
678
+ }
679
+ html += '</div>';
680
+ }
681
+ html += '<button onclick="this.closest(\'[data-usage-overlay]\').remove()" style="margin-top:0.8rem;width:100%;padding:0.7rem;background:var(--accent);color:#fff;border:none;border-radius:10px;cursor:pointer;font-size:0.9rem;font-weight:600">关闭</button></div>';
682
+ const overlay = document.createElement('div');
683
+ overlay.setAttribute('data-usage-overlay', '');
684
+ overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:999;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(4px)';
685
+ overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
686
+ const box = document.createElement('div');
687
+ box.style.cssText = 'background:var(--surface);border:1px solid rgba(255,255,255,0.12);border-radius:16px;max-height:80vh;overflow-y:auto;width:90%;max-width:420px;box-shadow:0 16px 48px rgba(0,0,0,0.4)';
688
+ box.innerHTML = html;
689
+ overlay.appendChild(box);
690
+ document.body.appendChild(overlay);
691
+ } catch (err) {
692
+ alert('获取用量失败: ' + err.message);
693
+ }
694
+ }
695
+
696
+ function doLogout() {
697
+ if (!confirm('确定退出登录?')) return;
698
+ fetch('/logout', { method: 'POST' }).finally(() => {
699
+ socket.disconnect();
700
+ location.href = '/login';
701
+ });
702
+ }
703
+
704
+ // ── 外观:主题 & 字号(与会话页共用 localStorage myhi_theme / myhi_font)──
705
+ const THEME_META = { dark: '#0d1117', light: '#ffffff', black: '#000000', system: '#0d1117' };
706
+ function applyAppearance() {
707
+ const t = localStorage.getItem('myhi_theme') || 'dark';
708
+ const f = localStorage.getItem('myhi_font') || 'medium';
709
+ const r = document.documentElement;
710
+ if (!t || t === 'dark') r.removeAttribute('data-theme'); else r.setAttribute('data-theme', t);
711
+ r.setAttribute('data-font', f);
712
+ const meta = document.querySelector('meta[name="theme-color"]');
713
+ if (meta) meta.setAttribute('content', THEME_META[t] || '#0d1117');
714
+ }
715
+ window.openAppearance = function() {
716
+ const t = localStorage.getItem('myhi_theme') || 'dark';
717
+ const f = localStorage.getItem('myhi_font') || 'medium';
718
+ const overlay = document.createElement('div');
719
+ overlay.style.cssText = 'position:fixed;inset:0;z-index:80;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center';
720
+ const seg = (group, val, cur, label) => `<button data-g="${group}" data-v="${val}" style="flex:1;min-width:3rem;background:${val===cur?'var(--accent)':'var(--bg)'};color:${val===cur?'#fff':'var(--muted)'};border:1px solid ${val===cur?'var(--accent)':'var(--border)'};border-radius:8px;padding:0.5rem 0.3rem;font-size:0.82rem;cursor:pointer;white-space:nowrap">${label}</button>`;
721
+ const box = document.createElement('div');
722
+ box.style.cssText = 'background:var(--surface);border-radius:18px 18px 0 0;width:100%;max-width:640px;padding:0.5rem 1.1rem max(1rem,env(safe-area-inset-bottom))';
723
+ box.innerHTML = `<div class="sheet-handle"></div>
724
+ <div style="font-size:0.95rem;font-weight:600;margin-bottom:0.6rem">外观</div>
725
+ <div style="font-size:0.8rem;color:var(--text2);margin-bottom:0.35rem">主题</div>
726
+ <div style="display:flex;gap:0.35rem;flex-wrap:wrap">${seg('theme','system',t,'跟随系统')}${seg('theme','dark',t,'深色')}${seg('theme','light',t,'浅色')}${seg('theme','black',t,'纯黑')}</div>
727
+ <div style="font-size:0.8rem;color:var(--text2);margin:0.7rem 0 0.35rem">字号</div>
728
+ <div style="display:flex;gap:0.35rem">${seg('font','small',f,'小')}${seg('font','medium',f,'中')}${seg('font','large',f,'大')}</div>
729
+ <button id="ap-close" style="margin-top:0.9rem;width:100%;background:var(--bg);border:1px solid var(--border);color:var(--muted);font-size:0.85rem;padding:0.6rem;border-radius:10px;cursor:pointer">完成</button>`;
730
+ overlay.appendChild(box);
731
+ const close = () => overlay.remove();
732
+ overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
733
+ box.querySelector('#ap-close').onclick = close;
734
+ box.querySelectorAll('button[data-g]').forEach(b => b.onclick = () => {
735
+ localStorage.setItem(b.dataset.g === 'theme' ? 'myhi_theme' : 'myhi_font', b.dataset.v);
736
+ applyAppearance();
737
+ close(); openAppearance();
738
+ });
739
+ document.body.appendChild(overlay);
740
+ };
741
+
742
+ // HTML 转义,防止 XSS
743
+ function esc(s) {
744
+ const d = document.createElement('div');
745
+ d.textContent = s;
746
+ return d.innerHTML;
747
+ }
748
+
749
+ // ── Connection ──────────────────────────────────────────────
750
+ socket.on('connect', () => {
751
+ connDot.className = 'on';
752
+ socket.emit('list');
753
+ });
754
+ socket.on('disconnect', () => { connDot.className = 'off'; });
755
+ socket.on('sessions', renderSessions);
756
+
757
+ // Refresh periodically
758
+ setInterval(() => { if (socket.connected) socket.emit('list'); }, 5000);
759
+
760
+ // ── 移动端连接保活 ──────────────────────────────
761
+ document.addEventListener('visibilitychange', () => {
762
+ if (!document.hidden) {
763
+ if (!socket.connected) socket.connect();
764
+ else socket.emit('list');
765
+ }
766
+ });
767
+ window.addEventListener('online', () => { if (!socket.connected) socket.connect(); });
768
+ setInterval(() => { if (!document.hidden && !socket.connected) socket.connect(); }, 15000);
769
+
770
+ // ── Render ──────────────────────────────────────────────────
771
+ const PALETTE = ['#7c3aed','#2563eb','#059669','#b45309','#db2777','#0891b2'];
772
+
773
+ function avatarColor(title) {
774
+ let h = 0;
775
+ for (const c of (title || '?')) h = (h * 31 + c.charCodeAt(0)) & 0xff;
776
+ return PALETTE[h % PALETTE.length];
777
+ }
778
+
779
+ function fmtCwd(cwd) {
780
+ if (!cwd) return '';
781
+ // Show last 2 path segments
782
+ const parts = cwd.replace(/\\/g, '/').split('/').filter(Boolean);
783
+ return parts.slice(-2).join('/');
784
+ }
785
+
786
+ function fmtTime(iso) {
787
+ const d = new Date(iso);
788
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
789
+ }
790
+
791
+ // 会话的「最后任务时间」:优先最后活动时间,回退创建时间。ms 数或 ISO 皆可。
792
+ function sessionTime(s) {
793
+ const v = s.lastActivityAt || s.createdAt || 0;
794
+ const ms = typeof v === 'number' ? v : new Date(v).getTime();
795
+ return isNaN(ms) ? 0 : ms;
796
+ }
797
+
798
+ // 相对时间:刚刚 / N分钟前 / N小时前 / 昨天 HH:MM / M月D日
799
+ function fmtRelTime(ms) {
800
+ if (!ms) return '';
801
+ const now = Date.now();
802
+ const diff = now - ms;
803
+ if (diff < 60_000) return '刚刚';
804
+ if (diff < 3_600_000) return Math.floor(diff / 60_000) + '分钟前';
805
+ if (diff < 86_400_000) return Math.floor(diff / 3_600_000) + '小时前';
806
+ const d = new Date(ms);
807
+ const hm = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
808
+ if (diff < 172_800_000) return '昨天 ' + hm;
809
+ return (d.getMonth() + 1) + '月' + d.getDate() + '日';
810
+ }
811
+
812
+ // 项目名 = cwd 最后一段
813
+ function projectName(cwd) {
814
+ if (!cwd) return '(默认目录)';
815
+ const parts = cwd.replace(/\\/g, '/').split('/').filter(Boolean);
816
+ return parts[parts.length - 1] || '(根)';
817
+ }
818
+
819
+ // 视图状态:分组方式 + 标签筛选
820
+ let _allSessions = [];
821
+ let _groupMode = localStorage.getItem('myhi_group_mode') || 'time'; // time | project | tag
822
+ let _tagFilter = null; // 仅 tag 模式下:只看某标签
823
+ let _search = ''; // 会话搜索关键词(标题/首条需求/目录/标签)
824
+ let _draftCounts = {}; // { sessionId: 待发草稿数 },给卡片打角标
825
+
826
+ // 关键词匹配:任务多了要靠「当初说过什么」找回来,所以首条消息也进搜索范围
827
+ function matchSession(s, q) {
828
+ if (!q) return true;
829
+ const hay = [s.title, s.firstUserText, s.cwd, ...(s.tags || [])]
830
+ .filter(Boolean).join(' ').toLowerCase();
831
+ return q.split(/\s+/).filter(Boolean).every(w => hay.includes(w));
832
+ }
833
+
834
+ async function refreshDraftCounts() {
835
+ try {
836
+ const r = await fetch('/api/inbox');
837
+ const d = await r.json();
838
+ _draftCounts = d.counts || {};
839
+ renderSessions();
840
+ } catch { /* 收件箱不可用不影响列表 */ }
841
+ }
842
+
843
+ const EMPTY_HTML = `<div class="empty-state">
844
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
845
+ <rect x="2" y="4" width="20" height="16" rx="2"/>
846
+ <path d="M8 9l4 3-4 3M13 15h3"/>
847
+ </svg>
848
+ <p>还没有终端会话</p>
849
+ </div>`;
850
+
851
+ function cardHTML(s) {
852
+ const safeTitle = esc(s.title || '');
853
+ const safeId = esc(s.id);
854
+ const color = avatarColor(s.title);
855
+ const letter = esc((s.title || '?')[0].toUpperCase());
856
+ const sub = esc([fmtCwd(s.cwd), fmtRelTime(sessionTime(s)), s.viewers > 0 ? `${s.viewers} 在线` : '', s.controlHolderName ? `${s.controlHolderName} 控制中` : ''].filter(Boolean).join(' · '));
857
+ const tags = (s.tags || []).map(t => `<span class="tag-chip">${esc(t)}</span>`).join('');
858
+ // 会话名片:一堆会话都叫 claude,靠第一条真人消息认出「这个任务当初是干啥的」
859
+ const gist = s.firstUserText
860
+ ? `<div class="card-gist" style="font-size:0.72rem;color:var(--muted,var(--muted));margin-top:0.18rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;opacity:0.85">💬 ${esc(s.firstUserText)}</div>`
861
+ : '';
862
+ const nDraft = _draftCounts[s.id] || 0;
863
+ const draftChip = nDraft
864
+ ? `<div style="font-size:0.66rem;color:#d29922;margin-top:0.2rem">📥 ${nDraft} 条待发草稿</div>`
865
+ : '';
866
+ return `
867
+ <div class="card" data-id="${safeId}" data-title="${safeTitle}">
868
+ <div class="avatar" style="background:${color}">${letter}</div>
869
+ <div class="card-body">
870
+ <div class="card-title">${safeTitle}</div>
871
+ <div class="card-sub">${sub}</div>
872
+ ${gist}
873
+ ${draftChip}
874
+ ${tags ? `<div class="card-tags" style="display:flex;flex-wrap:wrap;gap:0.3rem;margin-top:0.3rem">${tags}</div>` : ''}
875
+ </div>
876
+ <div class="card-right">
877
+ <div class="status-dot ${s.alive ? 'alive' : 'dead'}"></div>
878
+ ${s.viewers > 1 ? `<div class="viewers">👁 ${s.viewers}</div>` : ''}
879
+ </div>
880
+ </div>`;
881
+ }
882
+
883
+ // 分组折叠状态(项目多了列表很长,收起不关心的项目)
884
+ let _collapsed = new Set(JSON.parse(localStorage.getItem('myhi_collapsed_groups') || '[]'));
885
+ function saveCollapsed() {
886
+ localStorage.setItem('myhi_collapsed_groups', JSON.stringify([..._collapsed]));
887
+ }
888
+ function isCollapsed(key) { return _collapsed.has(key); }
889
+
890
+ function groupHeaderHTML(label, count, key) {
891
+ const foldable = key !== undefined;
892
+ const off = foldable && isCollapsed(key);
893
+ const attrs = foldable ? ` data-gkey="${esc(key)}"` : '';
894
+ const style = 'display:flex;align-items:center;gap:0.5rem;margin:0.85rem 0.2rem 0.4rem;'
895
+ + 'color:var(--muted,var(--muted));font-size:0.75rem;font-weight:600;user-select:none'
896
+ + (foldable ? ';cursor:pointer' : '');
897
+ return `<div class="group-header"${attrs} style="${style}">
898
+ ${foldable ? `<span style="display:inline-block;width:0.7rem">${off ? '▸' : '▾'}</span>` : ''}
899
+ <span>${esc(label)}</span>
900
+ <span style="color:var(--border,var(--border2))">·</span>
901
+ <span style="font-weight:400">${count}</span>
902
+ </div>`;
903
+ }
904
+
905
+ function renderSessions(sessions) {
906
+ if (Array.isArray(sessions)) _allSessions = sessions;
907
+ renderTagFilter();
908
+ updateGroupSeg();
909
+
910
+ const q = _search.trim().toLowerCase();
911
+ const items = [..._allSessions]
912
+ .filter(s => matchSession(s, q))
913
+ .sort((a, b) => sessionTime(b) - sessionTime(a)); // 最新在前
914
+
915
+ const cntEl = document.getElementById('search-count');
916
+ if (cntEl) cntEl.textContent = q ? `${items.length}/${_allSessions.length}` : '';
917
+
918
+ if (!items.length) {
919
+ list.innerHTML = q
920
+ ? `<div class="empty-state"><p>没有匹配「${esc(_search)}」的任务</p></div>`
921
+ : EMPTY_HTML;
922
+ return;
923
+ }
924
+
925
+ let html = '';
926
+ if (_groupMode === 'project') {
927
+ const groups = new Map();
928
+ for (const s of items) {
929
+ const k = projectName(s.cwd);
930
+ if (!groups.has(k)) groups.set(k, []);
931
+ groups.get(k).push(s);
932
+ }
933
+ // 组按组内最新会话时间排序
934
+ const ordered = [...groups.entries()].sort((a, b) => sessionTime(b[1][0]) - sessionTime(a[1][0]));
935
+ for (const [name, arr] of ordered) {
936
+ const key = 'p:' + name;
937
+ html += groupHeaderHTML('📁 ' + name, arr.length, key);
938
+ // 搜索时强制展开,否则会出现「明明搜到了却看不见」
939
+ if (!isCollapsed(key) || q) html += arr.map(cardHTML).join('');
940
+ }
941
+ } else if (_groupMode === 'tag') {
942
+ let shown = items;
943
+ if (_tagFilter) shown = items.filter(s => (s.tags || []).includes(_tagFilter));
944
+ const tagged = new Map();
945
+ const untagged = [];
946
+ for (const s of shown) {
947
+ const tags = s.tags || [];
948
+ if (!tags.length) { untagged.push(s); continue; }
949
+ for (const t of tags) {
950
+ if (_tagFilter && t !== _tagFilter) continue;
951
+ if (!tagged.has(t)) tagged.set(t, []);
952
+ tagged.get(t).push(s);
953
+ }
954
+ }
955
+ const ordered = [...tagged.entries()].sort((a, b) => sessionTime(b[1][0]) - sessionTime(a[1][0]));
956
+ for (const [name, arr] of ordered) {
957
+ const key = 't:' + name;
958
+ html += groupHeaderHTML('🏷 ' + name, arr.length, key);
959
+ if (!isCollapsed(key) || q) html += arr.map(cardHTML).join('');
960
+ }
961
+ if (!_tagFilter && untagged.length) {
962
+ html += groupHeaderHTML('未分类', untagged.length, 't:__none__');
963
+ if (!isCollapsed('t:__none__') || q) html += untagged.map(cardHTML).join('');
964
+ }
965
+ } else {
966
+ html = items.map(cardHTML).join('');
967
+ }
968
+ list.innerHTML = html;
969
+
970
+ // 组头点击折叠/展开(项目多了列表很长)
971
+ list.querySelectorAll('.group-header[data-gkey]').forEach(h => {
972
+ h.addEventListener('click', () => {
973
+ const k = h.dataset.gkey;
974
+ if (_collapsed.has(k)) _collapsed.delete(k); else _collapsed.add(k);
975
+ saveCollapsed();
976
+ renderSessions();
977
+ });
978
+ });
979
+
980
+ // 事件委托:点击打开、右键/长按出菜单
981
+ list.querySelectorAll('.card').forEach(card => {
982
+ const id = card.dataset.id;
983
+ const title = card.dataset.title;
984
+ card.addEventListener('click', () => openSession(id));
985
+ card.addEventListener('contextmenu', (e) => showActions(e, id, title));
986
+ // 移动端长按
987
+ let lpTimer = null;
988
+ card.addEventListener('touchstart', (e) => { lpTimer = setTimeout(() => showActions(e, id, title), 550); }, { passive: true });
989
+ card.addEventListener('touchend', () => clearTimeout(lpTimer));
990
+ card.addEventListener('touchmove', () => clearTimeout(lpTimer));
991
+ });
992
+ }
993
+
994
+ function renderTagFilter() {
995
+ const bar = document.getElementById('tag-filter');
996
+ if (_groupMode !== 'tag') { bar.style.display = 'none'; return; }
997
+ const allTags = [...new Set(_allSessions.flatMap(s => s.tags || []))].sort();
998
+ if (!allTags.length) { bar.style.display = 'none'; return; }
999
+ bar.style.display = 'flex';
1000
+ const mk = (label, val, active) =>
1001
+ `<button class="tagf-btn" data-tag="${val === null ? '' : esc(val)}" style="background:${active ? 'var(--accent,var(--accent))' : 'var(--bg,var(--bg))'};color:${active ? '#fff' : 'var(--muted,var(--muted))'};border:1px solid ${active ? 'var(--accent,var(--accent))' : 'var(--border,var(--border))'};border-radius:999px;font-size:0.72rem;padding:0.2rem 0.6rem;cursor:pointer">${esc(label)}</button>`;
1002
+ bar.innerHTML = mk('全部', null, !_tagFilter) + allTags.map(t => mk(t, t, _tagFilter === t)).join('');
1003
+ bar.querySelectorAll('.tagf-btn').forEach(b => b.addEventListener('click', () => {
1004
+ _tagFilter = b.dataset.tag || null;
1005
+ renderSessions();
1006
+ }));
1007
+ }
1008
+
1009
+ function updateGroupSeg() {
1010
+ document.querySelectorAll('.grp-btn').forEach(b => {
1011
+ const on = b.dataset.group === _groupMode;
1012
+ b.style.background = on ? 'var(--accent,var(--accent))' : 'none';
1013
+ b.style.color = on ? '#fff' : 'var(--muted,var(--muted))';
1014
+ });
1015
+ }
1016
+
1017
+ document.querySelectorAll('.grp-btn').forEach(b => b.addEventListener('click', () => {
1018
+ _groupMode = b.dataset.group;
1019
+ _tagFilter = null;
1020
+ localStorage.setItem('myhi_group_mode', _groupMode);
1021
+ renderSessions();
1022
+ }));
1023
+
1024
+ // 搜索:边打边过滤
1025
+ (() => {
1026
+ const box = document.getElementById('sess-search');
1027
+ if (!box) return;
1028
+ box.addEventListener('input', () => { _search = box.value; renderSessions(); });
1029
+ box.addEventListener('keydown', (e) => {
1030
+ if (e.key === 'Escape') { box.value = ''; _search = ''; renderSessions(); box.blur(); }
1031
+ });
1032
+ })();
1033
+
1034
+ // 草稿角标:进页面拉一次,之后跟着会话列表推送刷新
1035
+ refreshDraftCounts();
1036
+ setInterval(refreshDraftCounts, 60_000);
1037
+
1038
+ // ── 随手记:不在会话页也能记一条草稿 ──────────────────────────
1039
+ let _notePendingImage = null; // { path }
1040
+
1041
+ window.openNoteSheet = function() {
1042
+ const sel = document.getElementById('note-session');
1043
+ // AI 会话排前面(草稿最终是发给 AI 的),同项目的按最近活跃排
1044
+ const items = [..._allSessions].sort((a, b) =>
1045
+ (b.mode === 'agent') - (a.mode === 'agent') || sessionTime(b) - sessionTime(a));
1046
+ if (!items.length) { alert('还没有任何会话——草稿必须归属一个任务,先建一个吧'); return; }
1047
+ sel.innerHTML = items.map(s =>
1048
+ `<option value="${esc(s.id)}">${esc(s.title)} · ${esc(projectName(s.cwd))}${s.mode === 'agent' ? '' : '(终端)'}</option>`
1049
+ ).join('');
1050
+ document.getElementById('note-text').value = '';
1051
+ document.getElementById('note-autosend').checked = false;
1052
+ document.getElementById('note-image').value = '';
1053
+ document.getElementById('note-image-status').textContent = '';
1054
+ _notePendingImage = null;
1055
+ document.getElementById('note-backdrop').style.display = 'block';
1056
+ document.getElementById('note-sheet').style.display = 'block';
1057
+ };
1058
+ window.closeNoteSheet = function() {
1059
+ document.getElementById('note-backdrop').style.display = 'none';
1060
+ document.getElementById('note-sheet').style.display = 'none';
1061
+ };
1062
+
1063
+ document.getElementById('note-image')?.addEventListener('change', async (e) => {
1064
+ const file = e.target.files?.[0];
1065
+ const status = document.getElementById('note-image-status');
1066
+ if (!file) { _notePendingImage = null; status.textContent = ''; return; }
1067
+ const sid = document.getElementById('note-session').value;
1068
+ status.textContent = '上传中…';
1069
+ try {
1070
+ const form = new FormData();
1071
+ form.append('image', file, file.name);
1072
+ const r = await fetch(`/upload?sessionId=${encodeURIComponent(sid)}`, { method: 'POST', body: form });
1073
+ const d = await r.json();
1074
+ if (d.path) { _notePendingImage = { path: d.path }; status.textContent = '✅ 已上传:' + file.name; }
1075
+ else { _notePendingImage = null; status.textContent = '上传失败:' + (d.error || '未知'); }
1076
+ } catch (err) {
1077
+ _notePendingImage = null;
1078
+ status.textContent = '上传出错:' + err.message;
1079
+ }
1080
+ });
1081
+
1082
+ window.saveNote = async function() {
1083
+ const sid = document.getElementById('note-session').value;
1084
+ const text = document.getElementById('note-text').value.trim();
1085
+ if (!sid) { alert('请选择归属任务'); return; }
1086
+ if (!text && !_notePendingImage) { alert('写点什么或传张图'); return; }
1087
+ try {
1088
+ const r = await fetch('/api/inbox', {
1089
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
1090
+ body: JSON.stringify({
1091
+ text, sessionId: sid, source: 'home',
1092
+ autoSend: document.getElementById('note-autosend').checked,
1093
+ images: _notePendingImage ? [{ path: _notePendingImage.path, name: '图片' }] : [],
1094
+ }),
1095
+ });
1096
+ const d = await r.json();
1097
+ if (d.error) { alert('存草稿失败:' + d.error); return; }
1098
+ closeNoteSheet();
1099
+ refreshDraftCounts();
1100
+ } catch (e) {
1101
+ alert('存草稿失败:' + e.message);
1102
+ }
1103
+ };
1104
+
1105
+ // ── Navigation ──────────────────────────────────────────────
1106
+ function openSession(id) {
1107
+ window.location.href = `/terminal/${id}`;
1108
+ }
1109
+
1110
+ // ── 会话体检 ────────────────────────────────────────────────
1111
+ window.openDoctor = function() {
1112
+ document.getElementById('doctor-menu').style.display = 'block';
1113
+ refreshDoctor();
1114
+ };
1115
+ window.closeDoctor = function() {
1116
+ document.getElementById('doctor-menu').style.display = 'none';
1117
+ };
1118
+ window.refreshDoctor = async function() {
1119
+ const box = document.getElementById('doctor-list');
1120
+ box.innerHTML = '<div style="color:var(--muted);font-size:0.8rem;padding:1rem 0.2rem">加载中…</div>';
1121
+ try {
1122
+ const res = await fetch('/api/sessions/health', { credentials: 'same-origin' });
1123
+ const data = await res.json();
1124
+ renderDoctor(data.sessions || []);
1125
+ } catch (e) {
1126
+ box.innerHTML = '<div style="color:var(--red);font-size:0.8rem;padding:1rem 0.2rem">加载失败:' + esc(e.message) + '</div>';
1127
+ }
1128
+ };
1129
+ function pill(text, color, bg) {
1130
+ return `<span style="font-size:0.65rem;padding:0.1rem 0.4rem;border-radius:5px;color:${color};background:${bg};white-space:nowrap">${text}</span>`;
1131
+ }
1132
+ function renderDoctor(sessions) {
1133
+ const box = document.getElementById('doctor-list');
1134
+ if (!sessions.length) {
1135
+ box.innerHTML = '<div style="color:var(--muted);font-size:0.8rem;padding:1rem 0.2rem">没有 agent 会话</div>';
1136
+ return;
1137
+ }
1138
+ // 中毒的排前面
1139
+ sessions.sort((a, b) => (b.health?.poisoned ? 1 : 0) - (a.health?.poisoned ? 1 : 0));
1140
+ box.innerHTML = sessions.map(s => {
1141
+ const h = s.health || {};
1142
+ const poisoned = h.poisoned;
1143
+ const badges = [];
1144
+ if (poisoned) badges.push(pill('⚠ 中毒 · ' + esc(h.persistedMode || '非bypass'), '#fff', '#a11'));
1145
+ else if (h.persistedMode) badges.push(pill(esc(h.persistedMode), 'var(--muted)', 'var(--bg)'));
1146
+ badges.push(pill(s.alive ? '运行中' : '已停', s.alive ? 'var(--green)' : 'var(--muted)', 'var(--bg)'));
1147
+ if (s.busy) badges.push(pill('忙', 'var(--orange)', 'var(--bg)'));
1148
+ if (h.transcriptKB) badges.push(pill(h.transcriptKB + 'KB', 'var(--muted)', 'var(--bg)'));
1149
+ if (h.imageCount) badges.push(pill('🖼 ' + h.imageCount, 'var(--muted)', 'var(--bg)'));
1150
+ const border = poisoned ? '#a11' : 'var(--border)';
1151
+ return `
1152
+ <div style="border:1px solid ${border};border-radius:10px;padding:0.6rem 0.7rem;margin-bottom:0.5rem">
1153
+ <div style="display:flex;align-items:center;gap:0.4rem;flex-wrap:wrap;margin-bottom:0.35rem">
1154
+ <span style="font-weight:600;font-size:0.85rem">${esc(s.title || '(无标题)')}</span>
1155
+ ${badges.join('')}
1156
+ </div>
1157
+ <div style="font-size:0.68rem;color:var(--muted);margin-bottom:0.5rem;word-break:break-all">${esc(s.cwd || '')}</div>
1158
+ <div style="display:flex;gap:0.4rem">
1159
+ <button onclick="openSession('${esc(s.id)}')" style="flex:1;background:var(--bg);border:1px solid var(--border);color:var(--text);font-size:0.72rem;padding:0.35rem;border-radius:6px;cursor:pointer">打开</button>
1160
+ <button onclick="doctorReset('${esc(s.id)}','${esc(s.title || '')}')" style="flex:1;background:${poisoned ? '#1c3a2b' : 'var(--bg)'};border:1px solid ${poisoned ? 'var(--green)' : 'var(--border)'};color:${poisoned ? 'var(--green)' : 'var(--text)'};font-size:0.72rem;padding:0.35rem;border-radius:6px;cursor:pointer">重开为干净会话</button>
1161
+ <button onclick="doctorDelete('${esc(s.id)}','${esc(s.title || '')}')" style="background:var(--redbg);border:none;color:var(--red);font-size:0.72rem;padding:0.35rem 0.6rem;border-radius:6px;cursor:pointer">删除</button>
1162
+ </div>
1163
+ </div>`;
1164
+ }).join('');
1165
+ }
1166
+ window.doctorReset = async function(id, title) {
1167
+ if (!confirm(`重开「${title}」为干净会话?\n会断开旧对话上下文(磁盘上的文件不受影响),下次提问开启全新会话。`)) return;
1168
+ try {
1169
+ const res = await fetch(`/api/sessions/${id}/reset`, { method: 'POST', credentials: 'same-origin' });
1170
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || res.status);
1171
+ refreshDoctor();
1172
+ } catch (e) { alert('重开失败:' + e.message); }
1173
+ };
1174
+ window.doctorDelete = async function(id, title) {
1175
+ if (!confirm(`删除会话「${title}」?此操作不可恢复。`)) return;
1176
+ try {
1177
+ const res = await fetch(`/api/sessions/${id}`, { method: 'DELETE', credentials: 'same-origin' });
1178
+ if (!res.ok && res.status !== 204) throw new Error(res.status);
1179
+ refreshDoctor();
1180
+ } catch (e) { alert('删除失败:' + e.message); }
1181
+ };
1182
+
1183
+ // ── Presets ───────────────────────────────────────────────────
1184
+ const PRESETS = [
1185
+ // Agent 模式
1186
+ { icon: '🤖', label: 'Claude', title: 'claude', type: 'agent', cmd: 'claude' },
1187
+ // PTY 模式(终端)
1188
+ { icon: '✨', label: 'Gemini', title: 'gemini', type: 'pty', cmd: 'gemini' },
1189
+ { icon: '🐚', label: 'Shell', title: 'shell', type: 'pty', cmd: '' },
1190
+ ];
1191
+
1192
+ let selectedPreset = null;
1193
+
1194
+ function renderPresets() {
1195
+ const el = document.getElementById('presets');
1196
+ el.innerHTML = PRESETS.map((p, i) => `
1197
+ <div class="chip" id="chip-${i}" onclick="selectPreset(${i})">
1198
+ <span class="chip-icon">${p.icon}</span>
1199
+ <span class="chip-label">${p.label}</span>
1200
+ </div>`).join('');
1201
+ }
1202
+ renderPresets();
1203
+
1204
+ function selectPreset(i) {
1205
+ selectedPreset = i;
1206
+ document.querySelectorAll('.chip').forEach((c, j) =>
1207
+ c.classList.toggle('selected', j === i));
1208
+ const p = PRESETS[i];
1209
+ // 不再自动把名称填成 claude/gemini/shell —— 名称必填、留给用户输入任务名
1210
+ document.getElementById('inp-cmd').value = p.cmd || '';
1211
+ }
1212
+
1213
+ // ── Create sheet ─────────────────────────────────────────────
1214
+ function openSheet() {
1215
+ document.getElementById('sheet-backdrop').classList.add('open');
1216
+ document.getElementById('sheet').classList.add('open');
1217
+ // Default to first preset
1218
+ selectPreset(0);
1219
+ setTimeout(() => document.getElementById('inp-title').focus(), 300);
1220
+ }
1221
+ function closeSheet() {
1222
+ document.getElementById('sheet-backdrop').classList.remove('open');
1223
+ document.getElementById('sheet').classList.remove('open');
1224
+ selectedPreset = null;
1225
+ document.querySelectorAll('.chip').forEach(c => c.classList.remove('selected'));
1226
+ }
1227
+
1228
+ document.getElementById('inp-title').addEventListener('keydown', e => {
1229
+ if (e.key === 'Enter') createSession();
1230
+ });
1231
+
1232
+ function createSession() {
1233
+ const titleInput = document.getElementById('inp-title');
1234
+ const title = titleInput.value.trim();
1235
+ if (!title) {
1236
+ alert('请输入任务名称');
1237
+ titleInput.focus();
1238
+ return;
1239
+ }
1240
+ const initCmd = document.getElementById('inp-cmd').value.trim() || undefined;
1241
+ const cwd = document.getElementById('inp-cwd').value.trim() || undefined;
1242
+ if (cwd) recordDir(cwd);
1243
+ const preset = selectedPreset != null ? PRESETS[selectedPreset] : null;
1244
+ closeSheet();
1245
+ document.getElementById('inp-title').value = '';
1246
+ document.getElementById('inp-cmd').value = '';
1247
+ document.getElementById('inp-cwd').value = '';
1248
+
1249
+ if (preset?.type === 'agent') {
1250
+ // Agent 模式:使用 Claude SDK
1251
+ socket.emit('create-agent', {
1252
+ title,
1253
+ cwd,
1254
+ }, (res) => {
1255
+ if (res?.ok) openSession(res.session.id);
1256
+ else alert('创建失败: ' + (res?.error || '未知错误'));
1257
+ });
1258
+ } else {
1259
+ // PTY 模式:创建终端会话
1260
+ socket.emit('create', { title, initCmd, cwd }, (res) => {
1261
+ if (res?.ok) openSession(res.session.id);
1262
+ else alert('创建失败: ' + (res?.error || '未知错误'));
1263
+ });
1264
+ }
1265
+ }
1266
+
1267
+ // ── Dir picker ───────────────────────────────────────────────
1268
+ let _dirCurrent = '';
1269
+ const RECENT_DIRS_KEY = 'myhi_recent_dirs'; // { path: count } 频次记录
1270
+ const RECENT_DIRS_MAX = 5;
1271
+
1272
+ function getRecentDirs() {
1273
+ try { return JSON.parse(localStorage.getItem(RECENT_DIRS_KEY)) || {}; } catch { return {}; }
1274
+ }
1275
+ function recordDir(dir) {
1276
+ if (!dir) return;
1277
+ const dirs = getRecentDirs();
1278
+ dirs[dir] = (dirs[dir] || 0) + 1;
1279
+ // 只保留使用最多的若干条
1280
+ const sorted = Object.entries(dirs).sort((a, b) => b[1] - a[1]);
1281
+ const trimmed = Object.fromEntries(sorted.slice(0, RECENT_DIRS_MAX));
1282
+ localStorage.setItem(RECENT_DIRS_KEY, JSON.stringify(trimmed));
1283
+ renderRecentDirs();
1284
+ }
1285
+ function renderRecentDirs() {
1286
+ const container = document.getElementById('recent-dirs');
1287
+ container.innerHTML = '';
1288
+ const dirs = getRecentDirs();
1289
+ const sorted = Object.entries(dirs).sort((a, b) => b[1] - a[1]);
1290
+ const filtered = _userDir ? sorted.filter(([path]) => path.startsWith(_userDir)) : sorted;
1291
+ if (!filtered.length) return;
1292
+ filtered.forEach(([path]) => {
1293
+ const btn = document.createElement('button');
1294
+ // 只显示最后一级目录名
1295
+ const short = path.replace(/[\\/]$/, '').split(/[\\/]/).pop() || path;
1296
+ btn.textContent = short;
1297
+ btn.title = path;
1298
+ btn.style.cssText = 'background:var(--bg);color:var(--accent);border:1px solid var(--border);border-radius:6px;padding:0.2rem 0.5rem;font-size:0.72rem;cursor:pointer;font-family:"SF Mono",Consolas,monospace;max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap';
1299
+ btn.onclick = () => { document.getElementById('inp-cwd').value = path; };
1300
+ container.appendChild(btn);
1301
+ });
1302
+ }
1303
+ renderRecentDirs();
1304
+
1305
+ function openDirPicker() {
1306
+ document.getElementById('dir-picker').classList.add('open');
1307
+ const dirs = getRecentDirs();
1308
+ const topDir = Object.entries(dirs).sort((a, b) => b[1] - a[1])[0]?.[0] || '';
1309
+ const initial = document.getElementById('inp-cwd').value.trim() || topDir || '';
1310
+ loadDirs(initial);
1311
+ loadDrives();
1312
+ }
1313
+ function closeDirPicker() {
1314
+ document.getElementById('dir-picker').classList.remove('open');
1315
+ }
1316
+ function selectDir() {
1317
+ document.getElementById('inp-cwd').value = _dirCurrent;
1318
+ recordDir(_dirCurrent);
1319
+ closeDirPicker();
1320
+ }
1321
+
1322
+ // Windows 盘符检测和切换
1323
+ function loadDrives() {
1324
+ const bar = document.getElementById('drive-bar');
1325
+ if (!bar) return;
1326
+ // 通过尝试访问常见盘符来检测
1327
+ const letters = ['C', 'D', 'E', 'F', 'G', 'H'];
1328
+ bar.innerHTML = '';
1329
+ let found = 0;
1330
+ for (const letter of letters) {
1331
+ const path = letter + ':\\';
1332
+ socket.emit('dirs', path, (data) => {
1333
+ if (data.ok) {
1334
+ found++;
1335
+ const btn = document.createElement('button');
1336
+ btn.className = 'drive-btn';
1337
+ btn.textContent = letter + ':';
1338
+ btn.onclick = () => {
1339
+ loadDirs(path);
1340
+ bar.querySelectorAll('.drive-btn').forEach(b => b.classList.remove('active'));
1341
+ btn.classList.add('active');
1342
+ };
1343
+ // 高亮当前盘符
1344
+ if (_dirCurrent && _dirCurrent.toUpperCase().startsWith(letter + ':')) {
1345
+ btn.classList.add('active');
1346
+ }
1347
+ bar.appendChild(btn);
1348
+ }
1349
+ });
1350
+ }
1351
+ }
1352
+
1353
+ function loadDirs(path) {
1354
+ document.getElementById('dir-list').innerHTML = '<div style="padding:1rem;color:var(--muted);font-size:0.85rem">加载中...</div>';
1355
+ socket.emit('dirs', path || '', (data) => {
1356
+ if (!data.ok) {
1357
+ document.getElementById('dir-list').innerHTML = `<div style="padding:1rem;color:var(--red);font-size:0.85rem">${escH(data.error)}</div>`;
1358
+ return;
1359
+ }
1360
+ _dirCurrent = data.current;
1361
+ document.getElementById('dir-current').textContent = data.current;
1362
+ // 更新盘符高亮
1363
+ document.querySelectorAll('.drive-btn').forEach(btn => {
1364
+ btn.classList.toggle('active', _dirCurrent.toUpperCase().startsWith(btn.textContent.toUpperCase()));
1365
+ });
1366
+ const dirList = document.getElementById('dir-list');
1367
+ dirList.innerHTML = '';
1368
+ if (data.parent) {
1369
+ const el = document.createElement('div');
1370
+ el.className = 'dir-item';
1371
+ el.dataset.path = data.parent;
1372
+ el.innerHTML = '<span class="di">↩</span><span class="dn">..</span>';
1373
+ dirList.appendChild(el);
1374
+ }
1375
+ for (const d of data.dirs) {
1376
+ const el = document.createElement('div');
1377
+ el.className = 'dir-item';
1378
+ el.dataset.path = d.path;
1379
+ el.innerHTML = `<span class="di">📁</span><span class="dn">${escH(d.name)}</span>`;
1380
+ dirList.appendChild(el);
1381
+ }
1382
+ if (!dirList.children.length) {
1383
+ dirList.innerHTML = '<div style="padding:1rem;color:var(--muted);font-size:0.85rem">无子目录</div>';
1384
+ }
1385
+ });
1386
+ }
1387
+ document.getElementById('dir-list').addEventListener('click', e => {
1388
+ const item = e.target.closest('.dir-item');
1389
+ if (item?.dataset.path) loadDirs(item.dataset.path);
1390
+ });
1391
+ function escH(s) { return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
1392
+
1393
+ // ── 导入已有 Claude 会话 ───────────────────────────────────
1394
+ function openImportSheet() {
1395
+ closeSheet(); // 关闭创建面板
1396
+ document.getElementById('import-menu').classList.add('open');
1397
+ document.getElementById('import-list').innerHTML = '<div style="padding:1rem;color:var(--muted);font-size:0.85rem">加载中...</div>';
1398
+ // 通过 REST API 获取本地 Claude 会话列表
1399
+ fetch('/api/claude-sessions')
1400
+ .then(r => r.json())
1401
+ .then(sessions => {
1402
+ const el = document.getElementById('import-list');
1403
+ if (!sessions.length) {
1404
+ el.innerHTML = '<div style="padding:1rem;color:var(--muted);font-size:0.85rem">未找到本地 Claude 会话<br><span style="font-size:0.72rem">会话数据存储在 ~/.claude/projects/ 目录</span></div>';
1405
+ return;
1406
+ }
1407
+ el.innerHTML = '';
1408
+ for (const s of sessions) {
1409
+ const item = document.createElement('div');
1410
+ item.className = 'import-item';
1411
+ item.onclick = () => importSession(s);
1412
+ const time = s.updatedAt ? new Date(s.updatedAt).toLocaleString('zh-CN', { month:'numeric', day:'numeric', hour:'2-digit', minute:'2-digit' }) : '';
1413
+ const proj = s.projectDir ? decodeURIComponent(s.projectDir).split(/[\\/]/).pop() : '';
1414
+ const name = s.summary || s.sessionId.slice(0, 12);
1415
+ item.innerHTML = `
1416
+ <span class="ii-icon">🤖</span>
1417
+ <div class="ii-body">
1418
+ <div class="ii-id">${escH(name)}</div>
1419
+ <div class="ii-info">${time ? time + ' · ' : ''}${s.messageCount} 条消息${proj ? ' · ' + escH(proj) : ''}</div>
1420
+ </div>`;
1421
+ el.appendChild(item);
1422
+ }
1423
+ })
1424
+ .catch(err => {
1425
+ document.getElementById('import-list').innerHTML = `<div style="padding:1rem;color:var(--red);font-size:0.85rem">加载失败: ${escH(err.message)}</div>`;
1426
+ });
1427
+ }
1428
+
1429
+ function closeImportSheet() {
1430
+ document.getElementById('import-menu').classList.remove('open');
1431
+ }
1432
+
1433
+ function importSession(session) {
1434
+ closeImportSheet();
1435
+ const cwd = session.projectPath || document.getElementById('inp-cwd').value.trim() || undefined;
1436
+ const title = session.summary ? session.summary.slice(0, 30) : 'claude (导入)';
1437
+ socket.emit('create-agent', {
1438
+ title,
1439
+ cwd,
1440
+ resumeSessionId: session.sessionId,
1441
+ }, (res) => {
1442
+ if (res?.ok) openSession(res.session.id);
1443
+ else alert('导入失败: ' + (res?.error || '未知错误'));
1444
+ });
1445
+ }
1446
+
1447
+ // ── Kill ─────────────────────────────────────────────────────
1448
+ let _killId = null;
1449
+ // 会话操作菜单:打开 / 重命名 / 设置标签 / 终止
1450
+ function showActions(e, id, title) {
1451
+ e.preventDefault();
1452
+ const s = _allSessions.find(x => x.id === id);
1453
+ const curTags = (s?.tags || []).join(', ');
1454
+ const overlay = document.createElement('div');
1455
+ overlay.style.cssText = 'position:fixed;inset:0;z-index:80;background:rgba(0,0,0,0.55);display:flex;align-items:flex-end;justify-content:center';
1456
+ const box = document.createElement('div');
1457
+ box.style.cssText = 'background:var(--surface,var(--surface));border-radius:18px 18px 0 0;width:100%;max-width:640px;padding:0.5rem 0 max(1rem,env(safe-area-inset-bottom))';
1458
+ const mkBtn = (label, color) => `<button style="display:block;width:100%;text-align:left;background:none;border:none;border-top:1px solid var(--border,var(--border));color:${color || 'var(--text,var(--text))'};font-size:0.95rem;padding:0.9rem 1.25rem;cursor:pointer">${label}</button>`;
1459
+ box.innerHTML =
1460
+ `<div style="padding:0.6rem 1.25rem 0.4rem;color:var(--muted,var(--muted));font-size:0.8rem;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(title)}</div>` +
1461
+ mkBtn('打开') + mkBtn('重命名') + mkBtn('设置标签') + mkBtn('终止会话', 'var(--red,var(--red))') +
1462
+ `<button style="display:block;width:100%;background:none;border:none;border-top:6px solid var(--bg,var(--bg));color:var(--muted,var(--muted));font-size:0.9rem;padding:0.85rem;cursor:pointer">取消</button>`;
1463
+ overlay.appendChild(box);
1464
+ const close = () => overlay.remove();
1465
+ overlay.addEventListener('click', (ev) => { if (ev.target === overlay) close(); });
1466
+ const btns = box.querySelectorAll('button');
1467
+ btns[0].onclick = () => { close(); openSession(id); };
1468
+ btns[1].onclick = () => {
1469
+ close();
1470
+ const nv = prompt('重命名会话', title);
1471
+ if (nv && nv.trim()) socket.emit('rename', { sessionId: id, title: nv.trim() });
1472
+ };
1473
+ btns[2].onclick = () => {
1474
+ close();
1475
+ const nv = prompt('设置标签(多个用逗号分隔,留空清除)', curTags);
1476
+ if (nv !== null) socket.emit('set-tags', { sessionId: id, tags: nv.split(/[,,]/).map(t => t.trim()).filter(Boolean) });
1477
+ };
1478
+ btns[3].onclick = () => { close(); showKill(e, id, title); };
1479
+ btns[4].onclick = close;
1480
+ document.body.appendChild(overlay);
1481
+ }
1482
+
1483
+ function showKill(e, id, title) {
1484
+ if (e && e.preventDefault) e.preventDefault();
1485
+ _killId = id;
1486
+ document.getElementById('kill-title').textContent = title;
1487
+ document.getElementById('kill-menu').classList.add('open');
1488
+ }
1489
+ function closeKill() {
1490
+ document.getElementById('kill-menu').classList.remove('open');
1491
+ _killId = null;
1492
+ }
1493
+ function confirmKill() {
1494
+ if (_killId) socket.emit('kill', _killId);
1495
+ closeKill();
1496
+ }
1497
+
1498
+ // ── 版本更新 ──────────────────────────────────────────────
1499
+ </script>
1500
+ </body>
1501
+ </html>