codex-snapshots 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +101 -6
  2. package/bin/codex-snapshot.mjs +1 -6326
  3. package/deploy/aliyun/README.md +311 -0
  4. package/deploy/aliyun/backup-share-data.sh +109 -0
  5. package/deploy/aliyun/check-ecs-status.sh +149 -0
  6. package/deploy/aliyun/codex-snapshot-share.env.example +29 -0
  7. package/deploy/aliyun/codex-snapshot-share.service +26 -0
  8. package/deploy/aliyun/configure-github-pages-api.sh +141 -0
  9. package/deploy/aliyun/configure-local-publisher.sh +197 -0
  10. package/deploy/aliyun/deploy-to-ecs.sh +669 -0
  11. package/deploy/aliyun/deploy.env.example +52 -0
  12. package/deploy/aliyun/doctor.mjs +398 -0
  13. package/deploy/aliyun/install-share-api.sh +252 -0
  14. package/deploy/aliyun/install-system-deps.sh +84 -0
  15. package/deploy/aliyun/nginx-codex-snapshots.bootstrap.conf +34 -0
  16. package/deploy/aliyun/nginx-codex-snapshots.conf +52 -0
  17. package/deploy/aliyun/preflight.mjs +321 -0
  18. package/deploy/aliyun/restore-share-data.sh +141 -0
  19. package/deploy/aliyun/verify-public-share.mjs +404 -0
  20. package/dist/cli/codex-snapshot.mjs +2654 -0
  21. package/dist/core/privacy.js +81 -0
  22. package/dist/core/snapshot.js +1 -0
  23. package/dist/renderers/markdown.mjs +81 -0
  24. package/dist/renderers/transcript.js +195 -0
  25. package/dist/server/http.js +10 -0
  26. package/dist/server/local-security.js +66 -0
  27. package/dist/server/local-viewer-app.mjs +1670 -0
  28. package/dist/server/local-viewer.mjs +210 -0
  29. package/dist/server/share-api.mjs +1149 -0
  30. package/dist/server/share-store.js +136 -0
  31. package/dist/shared/sanitize.js +126 -0
  32. package/dist/shared/transcript.js +1 -0
  33. package/dist/sources/index.mjs +2 -0
  34. package/dist/sources/local-history.mjs +2221 -0
  35. package/package.json +42 -14
  36. package/scripts/build-site.mjs +71 -0
  37. package/scripts/launch-agent.mjs +19 -227
  38. package/scripts/serve-site.mjs +2 -2
  39. package/scripts/test-aliyun-deploy-config.sh +230 -0
  40. package/scripts/test-share-api.mjs +967 -0
  41. package/scripts/test-site-config.mjs +100 -0
  42. package/scripts/test-static-site.mjs +403 -0
  43. package/scripts/write-site-config.mjs +161 -0
  44. package/server/share-api.mjs +1 -771
  45. package/site/assets/config.js +3 -0
  46. package/site/assets/share.js +43 -106
  47. package/site/assets/site.css +3 -605
  48. package/site/assets/site.js +15 -92
  49. package/site/favicon.svg +7 -0
  50. package/site/index.html +3 -83
  51. package/site/share/index.html +3 -8
@@ -0,0 +1,1670 @@
1
+ // @ts-nocheck
2
+ import { MUTATION_CSRF_HEADER } from "./local-security.js";
3
+ export function renderServerApp(csrfToken, shareConfig = {}) {
4
+ return `<!doctype html>
5
+ <html lang="zh-CN">
6
+ <head>
7
+ <meta charset="utf-8">
8
+ <meta name="viewport" content="width=device-width, initial-scale=1">
9
+ <title>Codex Snapshots</title>
10
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
11
+ <style>${serverCss()}</style>
12
+ </head>
13
+ <body>
14
+ <main class="app">
15
+ <aside class="sidebar">
16
+ <div class="sidebar-top">
17
+ <header>
18
+ <p class="eyebrow">Codex Snapshots</p>
19
+ <h1>会话</h1>
20
+ </header>
21
+ <div class="toolbar">
22
+ <input id="filter" type="search" placeholder="搜索来源、项目或对话">
23
+ <button id="reload" type="button" title="刷新会话列表">刷新</button>
24
+ </div>
25
+ </div>
26
+ <div id="sessions" class="sessions"></div>
27
+ </aside>
28
+ <div id="splitter" class="splitter" role="separator" aria-label="调整项目列表宽度" aria-orientation="vertical" aria-valuemin="280" aria-valuemax="680" aria-valuenow="0" tabindex="0"></div>
29
+ <section class="viewer">
30
+ <div class="viewer-top">
31
+ <div>
32
+ <p class="eyebrow">Read-only review</p>
33
+ <h2 id="title">选择一个会话</h2>
34
+ </div>
35
+ <div class="switches">
36
+ <label title="显示工具调用"><input id="includeTools" type="checkbox"> 工具</label>
37
+ <label title="显示工具输出"><input id="includeToolOutput" type="checkbox"> 输出</label>
38
+ <label title="自动脱敏常见敏感内容"><input id="redact" type="checkbox" checked> 脱敏</label>
39
+ </div>
40
+ </div>
41
+ <div id="meta" class="meta empty">还没有选择会话。</div>
42
+ <div id="risks" class="risks"></div>
43
+ <div id="exports" class="exports"></div>
44
+ <div id="turns" class="turns"></div>
45
+ </section>
46
+ </main>
47
+ <script>window.CODEX_SNAPSHOT_SHARE_CONFIG=${inlineJson(shareConfig || {})}; window.CODEX_SNAPSHOT_CSRF_TOKEN=${inlineJson(csrfToken)};</script>
48
+ <script>${serverJs()}</script>
49
+ </body>
50
+ </html>`;
51
+ }
52
+ function inlineJson(value) {
53
+ return JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, (char) => {
54
+ if (char === "<")
55
+ return "\\u003c";
56
+ if (char === ">")
57
+ return "\\u003e";
58
+ if (char === "&")
59
+ return "\\u0026";
60
+ if (char === "\u2028")
61
+ return "\\u2028";
62
+ return "\\u2029";
63
+ });
64
+ }
65
+ function serverCss() {
66
+ return `
67
+ :root {
68
+ --ink: #17202a;
69
+ --muted: #617181;
70
+ --soft: #8492a3;
71
+ --line: #d9e2e8;
72
+ --paper: #f6f8f5;
73
+ --panel: #ffffff;
74
+ --panel-soft: #f8fbf9;
75
+ --panel-wash: rgba(255, 255, 255, 0.82);
76
+ --sidebar-width: clamp(340px, 28vw, 470px);
77
+ --splitter-width: 14px;
78
+ --blue: #2f6fbb;
79
+ --teal: #0f766e;
80
+ --green: #15803d;
81
+ --red: #b43b45;
82
+ --amber: #b7791f;
83
+ --focus: #2f6fbb;
84
+ --shadow-soft: 0 24px 70px -54px rgba(23, 32, 42, 0.42);
85
+ --shadow-panel: 0 26px 80px -62px rgba(23, 32, 42, 0.55);
86
+ --grid-strong: rgba(23, 32, 42, 0.058);
87
+ --grid-soft: rgba(23, 32, 42, 0.034);
88
+ }
89
+ * { box-sizing: border-box; }
90
+ html {
91
+ height: 100%;
92
+ overflow: hidden;
93
+ }
94
+ body {
95
+ height: 100%;
96
+ margin: 0;
97
+ overflow: hidden;
98
+ color: var(--ink);
99
+ background: var(--paper);
100
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
101
+ -webkit-font-smoothing: antialiased;
102
+ text-rendering: optimizeLegibility;
103
+ }
104
+ .app {
105
+ display: grid;
106
+ grid-template-columns: var(--sidebar-width) var(--splitter-width) minmax(0, 1fr);
107
+ grid-template-rows: minmax(0, 1fr);
108
+ height: 100dvh;
109
+ min-height: 0;
110
+ overflow: hidden;
111
+ }
112
+ .app.resizing,
113
+ .app.resizing * {
114
+ cursor: col-resize;
115
+ user-select: none;
116
+ }
117
+ .sidebar {
118
+ min-height: 0;
119
+ background: var(--panel-soft);
120
+ padding: 12px 14px 24px;
121
+ overflow-x: hidden;
122
+ overflow-y: auto;
123
+ overscroll-behavior: contain;
124
+ scrollbar-gutter: stable;
125
+ border-right: 1px solid rgba(23, 32, 42, 0.1);
126
+ box-shadow: inset -16px 0 36px -34px rgba(23, 32, 42, 0.46);
127
+ }
128
+ .splitter {
129
+ position: relative;
130
+ min-width: var(--splitter-width);
131
+ min-height: 0;
132
+ border: 0;
133
+ background: transparent;
134
+ cursor: col-resize;
135
+ touch-action: none;
136
+ z-index: 8;
137
+ }
138
+ .splitter::before {
139
+ position: absolute;
140
+ inset: 0 auto 0 50%;
141
+ width: 2px;
142
+ background: rgba(23, 32, 42, 0.16);
143
+ content: "";
144
+ transform: translateX(-50%);
145
+ }
146
+ .splitter::after {
147
+ position: absolute;
148
+ top: 50%;
149
+ left: 50%;
150
+ width: 7px;
151
+ height: 76px;
152
+ border: 1px solid rgba(23, 32, 42, 0.18);
153
+ border-radius: 999px;
154
+ background: rgba(255, 255, 255, 0.72);
155
+ content: "";
156
+ opacity: 0;
157
+ transform: translate(-50%, -50%);
158
+ transition: opacity 120ms ease, background 120ms ease, border-color 120ms ease;
159
+ }
160
+ .splitter:hover::after,
161
+ .splitter:focus-visible::after,
162
+ .app.resizing .splitter::after {
163
+ border-color: rgba(23, 32, 42, 0.42);
164
+ background: rgba(255, 255, 255, 0.96);
165
+ opacity: 1;
166
+ }
167
+ .splitter:focus-visible {
168
+ outline: 3px solid rgba(47, 111, 187, 0.22);
169
+ outline-offset: -3px;
170
+ }
171
+ .viewer {
172
+ min-width: 0;
173
+ min-height: 0;
174
+ padding: 14px clamp(18px, 2vw, 34px) 34px;
175
+ overflow-x: hidden;
176
+ overflow-y: auto;
177
+ overscroll-behavior: contain;
178
+ scrollbar-gutter: stable;
179
+ }
180
+ .sidebar-top {
181
+ position: sticky;
182
+ top: -1px;
183
+ z-index: 6;
184
+ margin: -12px -14px 0;
185
+ padding: 14px 14px 12px;
186
+ background: var(--panel-soft);
187
+ box-shadow: 0 16px 30px -30px rgba(23, 32, 42, 0.78);
188
+ }
189
+ .eyebrow {
190
+ margin: 0 0 4px;
191
+ color: var(--blue);
192
+ font: 800 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
193
+ text-transform: uppercase;
194
+ }
195
+ h1, h2 { margin: 0; letter-spacing: 0; }
196
+ h1 { font-size: 36px; line-height: 1; }
197
+ .sidebar h1 {
198
+ color: var(--ink);
199
+ font: 900 26px/1.02 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
200
+ }
201
+ h2 { font-size: 28px; line-height: 1.12; overflow-wrap: anywhere; }
202
+ .toolbar { display: grid; grid-template-columns: 1fr auto; gap: 8px; margin-top: 12px; }
203
+ input[type="search"] {
204
+ min-width: 0;
205
+ height: 40px;
206
+ border: 1px solid var(--line);
207
+ border-radius: 8px;
208
+ background: var(--panel);
209
+ padding: 0 14px;
210
+ color: var(--ink);
211
+ font: 700 14px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
212
+ outline: 0;
213
+ }
214
+ input[type="search"]:focus {
215
+ border-color: var(--focus);
216
+ box-shadow: 0 0 0 3px rgba(47, 111, 187, 0.13);
217
+ }
218
+ button, .exports a {
219
+ min-height: 40px;
220
+ border: 1px solid var(--ink);
221
+ border-radius: 8px;
222
+ background: var(--ink);
223
+ color: white;
224
+ padding: 0 14px;
225
+ font: 800 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
226
+ text-decoration: none;
227
+ cursor: pointer;
228
+ transition: background 120ms ease, color 120ms ease, border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease;
229
+ }
230
+ button:hover, .exports a:hover {
231
+ transform: translateY(-1px);
232
+ box-shadow: 0 12px 26px -20px rgba(23, 32, 42, 0.82);
233
+ }
234
+ button:focus-visible,
235
+ .exports a:focus-visible,
236
+ .source-tab:focus-visible,
237
+ .session:focus-visible,
238
+ .project-more:focus-visible,
239
+ .sessions-load-more:focus-visible {
240
+ outline: 3px solid rgba(47, 111, 187, 0.22);
241
+ outline-offset: 2px;
242
+ }
243
+ button:disabled {
244
+ cursor: wait;
245
+ opacity: 0.62;
246
+ transform: none;
247
+ box-shadow: none;
248
+ }
249
+ .loading-state {
250
+ display: flex;
251
+ align-items: center;
252
+ gap: 10px;
253
+ min-height: 42px;
254
+ border: 1px solid var(--line);
255
+ border-radius: 8px;
256
+ background: rgba(255, 255, 255, 0.86);
257
+ color: var(--muted);
258
+ padding: 12px;
259
+ font: 800 12px/1.25 ui-monospace, SFMono-Regular, Menlo, monospace;
260
+ box-shadow: var(--shadow-soft);
261
+ }
262
+ .turns > .loading-state {
263
+ justify-self: center;
264
+ justify-content: center;
265
+ width: min(460px, 100%);
266
+ min-height: 86px;
267
+ }
268
+ .loading-spinner {
269
+ width: 16px;
270
+ height: 16px;
271
+ flex: 0 0 auto;
272
+ border: 2px solid rgba(23, 32, 42, 0.16);
273
+ border-top-color: var(--ink);
274
+ border-radius: 999px;
275
+ animation: snapshot-spin 0.8s linear infinite;
276
+ }
277
+ @keyframes snapshot-spin {
278
+ to { transform: rotate(360deg); }
279
+ }
280
+ .sessions {
281
+ display: grid;
282
+ gap: 14px;
283
+ margin-top: 16px;
284
+ }
285
+ .source-switcher {
286
+ position: sticky;
287
+ top: 108px;
288
+ z-index: 5;
289
+ display: grid;
290
+ grid-template-columns: repeat(3, minmax(0, 1fr));
291
+ gap: 5px;
292
+ border: 1px solid rgba(23, 32, 42, 0.12);
293
+ border-radius: 8px;
294
+ background: rgba(255, 255, 255, 0.86);
295
+ padding: 5px;
296
+ box-shadow: 0 14px 28px -30px rgba(23, 32, 42, 0.72);
297
+ }
298
+ .source-tab {
299
+ display: grid;
300
+ grid-template-columns: minmax(0, 1fr) auto;
301
+ gap: 8px;
302
+ align-items: center;
303
+ min-width: 0;
304
+ min-height: 36px;
305
+ border: 1px solid transparent;
306
+ border-radius: 6px;
307
+ background: transparent;
308
+ color: rgba(23, 32, 42, 0.64);
309
+ padding: 0 9px;
310
+ text-align: left;
311
+ font: 900 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
312
+ text-transform: uppercase;
313
+ }
314
+ .source-tab:hover {
315
+ border-color: rgba(23, 32, 42, 0.16);
316
+ background: rgba(23, 32, 42, 0.06);
317
+ color: var(--ink);
318
+ }
319
+ .source-tab.active {
320
+ border-color: var(--teal);
321
+ background: var(--teal);
322
+ color: #fff;
323
+ box-shadow: none;
324
+ }
325
+ .source-tab span {
326
+ min-width: 0;
327
+ overflow: hidden;
328
+ text-overflow: ellipsis;
329
+ white-space: nowrap;
330
+ }
331
+ .source-tab b {
332
+ color: inherit;
333
+ font: inherit;
334
+ }
335
+ .source-total {
336
+ color: var(--muted);
337
+ font: 800 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
338
+ }
339
+ .source-empty {
340
+ margin-left: 34px;
341
+ color: rgba(23, 32, 42, 0.48);
342
+ font: 700 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
343
+ }
344
+ .project-group {
345
+ display: grid;
346
+ gap: 8px;
347
+ border-top: 1px solid rgba(23, 32, 42, 0.08);
348
+ padding-top: 12px;
349
+ }
350
+ .project-header {
351
+ display: grid;
352
+ grid-template-columns: 24px minmax(0, 1fr) auto;
353
+ gap: 11px;
354
+ align-items: center;
355
+ width: 100%;
356
+ min-height: 32px;
357
+ border: 0;
358
+ border-radius: 8px;
359
+ background: transparent;
360
+ color: rgba(23, 32, 42, 0.78);
361
+ padding: 0;
362
+ text-align: left;
363
+ font: 900 19px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace;
364
+ box-shadow: none;
365
+ cursor: pointer;
366
+ }
367
+ .project-header:hover {
368
+ background: rgba(23, 32, 42, 0.06);
369
+ color: var(--ink);
370
+ transform: none;
371
+ box-shadow: none;
372
+ }
373
+ .project-header:focus-visible {
374
+ outline: 3px solid rgba(15, 118, 110, 0.28);
375
+ outline-offset: 2px;
376
+ }
377
+ .project-group.collapsed .project-header {
378
+ color: rgba(23, 32, 42, 0.62);
379
+ }
380
+ .project-title {
381
+ min-width: 0;
382
+ overflow: hidden;
383
+ text-overflow: ellipsis;
384
+ white-space: nowrap;
385
+ }
386
+ .project-count {
387
+ color: var(--muted);
388
+ font: 900 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
389
+ }
390
+ .project-icon {
391
+ position: relative;
392
+ display: inline-block;
393
+ width: 21px;
394
+ height: 15px;
395
+ border: 2px solid currentColor;
396
+ border-radius: 3px;
397
+ }
398
+ .project-icon::before {
399
+ position: absolute;
400
+ top: -7px;
401
+ left: 1px;
402
+ width: 10px;
403
+ height: 6px;
404
+ border: 2px solid currentColor;
405
+ border-bottom: 0;
406
+ border-radius: 3px 3px 0 0;
407
+ content: "";
408
+ }
409
+ .session-list {
410
+ display: grid;
411
+ gap: 4px;
412
+ margin-left: 34px;
413
+ }
414
+ .session {
415
+ position: relative;
416
+ display: grid;
417
+ grid-template-columns: minmax(0, 1fr) auto auto;
418
+ gap: 12px;
419
+ align-items: center;
420
+ width: 100%;
421
+ min-height: 38px;
422
+ border: 0;
423
+ border-radius: 8px;
424
+ background: transparent;
425
+ color: var(--ink);
426
+ padding: 8px 11px;
427
+ text-align: left;
428
+ box-shadow: none;
429
+ }
430
+ .session::before {
431
+ position: absolute;
432
+ inset: 9px auto 9px 0;
433
+ width: 3px;
434
+ border-radius: 99px;
435
+ background: transparent;
436
+ content: "";
437
+ }
438
+ .session:hover, .session.active {
439
+ background: rgba(15, 118, 110, 0.1);
440
+ transform: none;
441
+ box-shadow: none;
442
+ }
443
+ .session.active::before { background: var(--teal); }
444
+ .session strong {
445
+ min-width: 0;
446
+ overflow: hidden;
447
+ text-overflow: ellipsis;
448
+ white-space: nowrap;
449
+ font-size: 15px;
450
+ line-height: 1.25;
451
+ }
452
+ .session-time {
453
+ color: var(--muted);
454
+ font: 900 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
455
+ white-space: nowrap;
456
+ }
457
+ .session-badge {
458
+ border: 1px solid rgba(183, 121, 31, 0.32);
459
+ background: rgba(255, 248, 232, 0.86);
460
+ color: var(--amber);
461
+ padding: 3px 5px;
462
+ font: 800 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
463
+ text-transform: uppercase;
464
+ white-space: nowrap;
465
+ }
466
+ .project-more {
467
+ justify-self: start;
468
+ min-height: 30px;
469
+ margin-left: 34px;
470
+ border: 0;
471
+ border-radius: 8px;
472
+ background: transparent;
473
+ color: rgba(23, 32, 42, 0.5);
474
+ padding: 4px 10px;
475
+ font: 800 13px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
476
+ box-shadow: none;
477
+ }
478
+ .project-more:hover {
479
+ color: var(--ink);
480
+ background: rgba(23, 32, 42, 0.06);
481
+ transform: none;
482
+ box-shadow: none;
483
+ }
484
+ .project-note {
485
+ margin-left: 44px;
486
+ color: rgba(23, 32, 42, 0.52);
487
+ font: 700 11px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace;
488
+ }
489
+ .load-more-row {
490
+ display: grid;
491
+ grid-template-columns: minmax(0, 1fr);
492
+ gap: 8px;
493
+ margin-left: 34px;
494
+ }
495
+ .sessions-load-more {
496
+ width: 100%;
497
+ min-height: 42px;
498
+ border: 1px solid rgba(23, 32, 42, 0.16);
499
+ border-radius: 8px;
500
+ background: rgba(255, 255, 255, 0.9);
501
+ color: var(--ink);
502
+ box-shadow: none;
503
+ }
504
+ .sessions-load-more:hover {
505
+ background: rgba(23, 32, 42, 0.07);
506
+ transform: none;
507
+ box-shadow: none;
508
+ }
509
+ .sessions-load-more:disabled {
510
+ background: rgba(23, 32, 42, 0.05);
511
+ }
512
+ .load-more-meta {
513
+ color: rgba(23, 32, 42, 0.48);
514
+ font: 700 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
515
+ }
516
+ .load-more-error {
517
+ color: var(--red);
518
+ }
519
+ .project-group.no-project .project-header {
520
+ grid-template-columns: minmax(0, 1fr) auto;
521
+ margin-left: 34px;
522
+ width: calc(100% - 34px);
523
+ }
524
+ .project-group.no-project .project-icon {
525
+ display: none;
526
+ }
527
+ .viewer-top {
528
+ position: sticky;
529
+ top: 0;
530
+ z-index: 4;
531
+ display: grid;
532
+ grid-template-columns: minmax(0, 1fr) auto;
533
+ gap: 12px;
534
+ align-items: center;
535
+ border-bottom: 1px solid rgba(23, 32, 42, 0.12);
536
+ margin: -14px clamp(-34px, -2vw, -18px) 14px;
537
+ padding: 12px clamp(18px, 2vw, 34px);
538
+ background: var(--paper);
539
+ box-shadow: 0 18px 42px -38px rgba(23, 32, 42, 0.72);
540
+ isolation: isolate;
541
+ }
542
+ .viewer-top::before {
543
+ position: absolute;
544
+ right: 0;
545
+ bottom: 100%;
546
+ left: 0;
547
+ height: 36px;
548
+ background: var(--paper);
549
+ content: "";
550
+ pointer-events: none;
551
+ }
552
+ .switches { display: flex; flex-wrap: wrap; gap: 8px; justify-content: end; }
553
+ .switches label {
554
+ display: inline-flex;
555
+ align-items: center;
556
+ gap: 6px;
557
+ min-height: 32px;
558
+ border: 1px solid var(--line);
559
+ border-radius: 8px;
560
+ background: rgba(255, 255, 255, 0.88);
561
+ padding: 0 10px;
562
+ font: 800 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
563
+ user-select: none;
564
+ }
565
+ .switches input {
566
+ accent-color: var(--teal);
567
+ }
568
+ .meta, .risks, .exports { margin-top: 10px; }
569
+ .risks:empty { display: none; }
570
+ .meta {
571
+ border: 1px solid var(--line);
572
+ border-radius: 8px;
573
+ background: var(--panel-wash);
574
+ padding: 10px;
575
+ color: var(--muted);
576
+ font: 800 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
577
+ overflow-wrap: anywhere;
578
+ box-shadow: var(--shadow-soft);
579
+ }
580
+ .meta.loading {
581
+ border: 0;
582
+ background: transparent;
583
+ padding: 0;
584
+ box-shadow: none;
585
+ }
586
+ .meta.loading .loading-state {
587
+ width: 100%;
588
+ }
589
+ .meta-pills {
590
+ display: flex;
591
+ flex-wrap: wrap;
592
+ gap: 8px;
593
+ }
594
+ .meta-pill {
595
+ display: inline-flex;
596
+ min-width: 0;
597
+ align-items: center;
598
+ gap: 8px;
599
+ border: 1px solid rgba(23, 32, 42, 0.09);
600
+ border-radius: 8px;
601
+ background: rgba(255, 255, 255, 0.7);
602
+ padding: 8px 10px;
603
+ color: var(--ink);
604
+ }
605
+ .meta-pill b {
606
+ color: var(--soft);
607
+ font: 900 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
608
+ text-transform: uppercase;
609
+ white-space: nowrap;
610
+ }
611
+ .meta-pill span {
612
+ min-width: 0;
613
+ overflow-wrap: anywhere;
614
+ }
615
+ .meta-goal {
616
+ display: grid;
617
+ grid-template-columns: 42px minmax(0, 1fr);
618
+ gap: 10px;
619
+ margin-top: 8px;
620
+ border: 1px solid rgba(23, 32, 42, 0.09);
621
+ border-radius: 8px;
622
+ background: rgba(255, 255, 255, 0.7);
623
+ padding: 10px;
624
+ color: var(--ink);
625
+ }
626
+ .meta-goal b {
627
+ color: var(--soft);
628
+ font: 900 10px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace;
629
+ text-transform: uppercase;
630
+ }
631
+ .meta-goal span {
632
+ min-width: 0;
633
+ overflow-wrap: anywhere;
634
+ white-space: pre-wrap;
635
+ }
636
+ .risks { display: grid; gap: 8px; }
637
+ .notice {
638
+ display: grid;
639
+ grid-template-columns: 76px minmax(0, 1fr);
640
+ gap: 10px;
641
+ align-items: center;
642
+ border-left: 5px solid var(--amber);
643
+ border-radius: 8px;
644
+ background: rgba(255, 248, 232, 0.9);
645
+ padding: 10px 12px;
646
+ }
647
+ .notice b {
648
+ color: var(--amber);
649
+ font: 800 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
650
+ text-transform: uppercase;
651
+ }
652
+ .notice span {
653
+ overflow-wrap: anywhere;
654
+ }
655
+ .risk {
656
+ display: grid;
657
+ grid-template-columns: 76px minmax(160px, 0.65fr) minmax(0, 1.35fr);
658
+ gap: 10px;
659
+ align-items: start;
660
+ border-left: 5px solid var(--green);
661
+ border-radius: 8px;
662
+ background: rgba(245, 251, 247, 0.9);
663
+ padding: 11px 12px;
664
+ }
665
+ .risk.high { border-color: var(--red); background: rgba(255, 241, 238, 0.92); }
666
+ .risk.medium { border-color: var(--amber); background: rgba(255, 248, 232, 0.9); }
667
+ .risk b, .risk span, .risk em { min-width: 0; }
668
+ .risk b { font: 800 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: uppercase; }
669
+ .risk span { line-height: 1.35; overflow-wrap: normal; }
670
+ .risk em { color: var(--muted); font-style: normal; font-size: 13px; line-height: 1.35; overflow-wrap: anywhere; }
671
+ .exports { display: flex; flex-wrap: wrap; gap: 8px; }
672
+ .exports a,
673
+ .exports button {
674
+ display: inline-flex;
675
+ min-height: 34px;
676
+ align-items: center;
677
+ border-radius: 7px;
678
+ padding: 0 11px;
679
+ font-size: 11px;
680
+ }
681
+ .exports button[data-publish-cloud] {
682
+ border-color: var(--teal);
683
+ background: var(--teal);
684
+ }
685
+ .publish-status {
686
+ display: inline-flex;
687
+ align-items: center;
688
+ min-height: 34px;
689
+ max-width: min(680px, 100%);
690
+ overflow-wrap: anywhere;
691
+ color: var(--muted);
692
+ font: 800 12px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace;
693
+ }
694
+ .publish-status a {
695
+ color: var(--blue);
696
+ text-decoration: underline;
697
+ text-underline-offset: 3px;
698
+ }
699
+ .publish-status.error {
700
+ color: var(--red);
701
+ }
702
+ .turns {
703
+ display: grid;
704
+ gap: 32px;
705
+ width: min(1600px, 100%);
706
+ margin: 24px auto 0;
707
+ }
708
+ .turn {
709
+ display: flex;
710
+ min-width: 0;
711
+ }
712
+ .user { justify-content: flex-end; }
713
+ .assistant, .tool { justify-content: flex-start; }
714
+ .process { justify-content: flex-start; }
715
+ .message-card {
716
+ min-width: 0;
717
+ max-width: min(1160px, 74%);
718
+ border: 0;
719
+ background: transparent;
720
+ padding: 0;
721
+ box-shadow: none;
722
+ }
723
+ .user .message-card {
724
+ max-width: min(1220px, 76%);
725
+ border: 1px solid rgba(15, 118, 110, 0.18);
726
+ border-radius: 8px;
727
+ background: #eef9f6;
728
+ padding: 12px 18px;
729
+ box-shadow: 0 26px 64px -56px rgba(23, 32, 42, 0.48);
730
+ }
731
+ .assistant .message-card {
732
+ max-width: min(1120px, 74%);
733
+ }
734
+ .tool .message-card {
735
+ max-width: min(1160px, 80%);
736
+ border: 1px solid rgba(183, 121, 31, 0.26);
737
+ border-radius: 8px;
738
+ background: #fff8df;
739
+ padding: 16px 18px;
740
+ }
741
+ .turn-meta {
742
+ margin-bottom: 10px;
743
+ color: var(--muted);
744
+ font: 900 11px/1.25 ui-monospace, SFMono-Regular, Menlo, monospace;
745
+ text-transform: uppercase;
746
+ }
747
+ .turn-meta span { font-weight: 700; }
748
+ .process-details {
749
+ width: min(1120px, 74%);
750
+ border-top: 1px solid rgba(23, 32, 42, 0.1);
751
+ color: rgba(23, 32, 42, 0.62);
752
+ }
753
+ .process-summary {
754
+ display: inline-flex;
755
+ align-items: center;
756
+ gap: 9px;
757
+ min-height: 42px;
758
+ cursor: pointer;
759
+ list-style: none;
760
+ user-select: none;
761
+ font: 800 17px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace;
762
+ }
763
+ .process-summary::-webkit-details-marker {
764
+ display: none;
765
+ }
766
+ .process-summary::after {
767
+ width: 8px;
768
+ height: 8px;
769
+ border-right: 2px solid currentColor;
770
+ border-bottom: 2px solid currentColor;
771
+ content: "";
772
+ transform: translateY(-2px) rotate(45deg);
773
+ transition: transform 0.16s ease;
774
+ }
775
+ .process-details[open] .process-summary::after {
776
+ transform: translateY(2px) rotate(225deg);
777
+ }
778
+ .process-body {
779
+ display: grid;
780
+ gap: 24px;
781
+ padding: 6px 0 8px;
782
+ }
783
+ .process-entry {
784
+ min-width: 0;
785
+ }
786
+ .process-entry .body {
787
+ color: var(--ink);
788
+ font-size: 17px;
789
+ }
790
+ .process-tool {
791
+ max-width: min(980px, 100%);
792
+ border-left: 3px solid rgba(183, 121, 31, 0.32);
793
+ padding-left: 12px;
794
+ }
795
+ .body {
796
+ min-width: 0;
797
+ max-width: 78ch;
798
+ font-size: 18px;
799
+ line-height: 1.7;
800
+ }
801
+ .body > * { margin: 0; }
802
+ .body > * + * { margin-top: 18px; }
803
+ .body p, .body li { overflow-wrap: anywhere; }
804
+ .body strong { font-weight: 800; }
805
+ .body em { font-style: italic; }
806
+ .body a { color: var(--blue); text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 3px; }
807
+ .body code {
808
+ border: 1px solid rgba(23, 32, 42, 0.12);
809
+ border-radius: 6px;
810
+ background: rgba(23, 32, 42, 0.06);
811
+ padding: 0.08rem 0.34rem;
812
+ font-size: 0.9em;
813
+ }
814
+ .body pre {
815
+ position: relative;
816
+ max-width: 100%;
817
+ overflow: auto;
818
+ border: 1px solid #253043;
819
+ border-radius: 8px;
820
+ background: #111722;
821
+ color: #edf4ff;
822
+ padding: 38px 16px 16px;
823
+ font: 13px/1.58 ui-monospace, SFMono-Regular, Menlo, monospace;
824
+ white-space: pre;
825
+ box-shadow: 0 26px 64px -52px rgba(23, 32, 42, 0.8);
826
+ }
827
+ .body pre[data-language]::before {
828
+ position: absolute;
829
+ top: 10px;
830
+ right: 12px;
831
+ max-width: calc(100% - 24px);
832
+ overflow: hidden;
833
+ color: #aeb8c8;
834
+ content: attr(data-language);
835
+ font: 900 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
836
+ text-overflow: ellipsis;
837
+ text-transform: uppercase;
838
+ white-space: nowrap;
839
+ }
840
+ .body pre code {
841
+ display: block;
842
+ min-width: max-content;
843
+ border: 0;
844
+ background: transparent;
845
+ padding: 0;
846
+ color: inherit;
847
+ }
848
+ .body .hljs-keyword,
849
+ .body .hljs-selector-tag,
850
+ .body .hljs-built_in { color: #8ab4f8; }
851
+ .body .hljs-title,
852
+ .body .hljs-title.class_,
853
+ .body .hljs-title.function_ { color: #f2cc60; }
854
+ .body .hljs-string,
855
+ .body .hljs-attr,
856
+ .body .hljs-symbol { color: #9ccc65; }
857
+ .body .hljs-number,
858
+ .body .hljs-literal { color: #f8a978; }
859
+ .body .hljs-comment { color: #7d8796; font-style: italic; }
860
+ .body .hljs-type,
861
+ .body .hljs-params,
862
+ .body .hljs-variable,
863
+ .body .hljs-property { color: #c4b5fd; }
864
+ .body ul, .body ol { padding-left: 1.35rem; }
865
+ .body li + li { margin-top: 0.25rem; }
866
+ .body blockquote {
867
+ border-left: 3px solid #ccd5df;
868
+ margin-left: 0;
869
+ padding-left: 14px;
870
+ color: #4b5563;
871
+ }
872
+ .body h1, .body h2, .body h3 {
873
+ line-height: 1.25;
874
+ font-size: 1.08em;
875
+ }
876
+ .attachment-grid {
877
+ display: grid;
878
+ gap: 18px;
879
+ margin-top: 24px;
880
+ }
881
+ .body > .attachment-grid { margin-top: 24px; }
882
+ .image-attachment {
883
+ margin: 0;
884
+ min-width: 0;
885
+ }
886
+ .image-attachment img {
887
+ display: block;
888
+ max-width: 100%;
889
+ max-height: 520px;
890
+ border: 1px solid rgba(23, 32, 42, 0.18);
891
+ border-radius: 8px;
892
+ background: #fff;
893
+ object-fit: contain;
894
+ box-shadow: 0 24px 54px -50px rgba(23, 32, 42, 0.6);
895
+ }
896
+ .image-unavailable {
897
+ border: 1px dashed var(--line);
898
+ border-radius: 8px;
899
+ padding: 16px;
900
+ color: var(--muted);
901
+ }
902
+ pre {
903
+ overflow: auto;
904
+ max-height: 460px;
905
+ margin: 0;
906
+ border: 1px solid #252c39;
907
+ background: #111722;
908
+ color: #edf4ff;
909
+ padding: 12px;
910
+ font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace;
911
+ white-space: pre-wrap;
912
+ }
913
+ .empty { color: var(--muted); }
914
+ @media (max-width: 900px) {
915
+ .app {
916
+ grid-template-columns: 1fr;
917
+ grid-template-rows: minmax(220px, 38dvh) minmax(0, 1fr);
918
+ }
919
+ .viewer {
920
+ padding: 22px 18px 34px;
921
+ }
922
+ .viewer-top {
923
+ grid-template-columns: 1fr;
924
+ margin: -22px -18px 22px;
925
+ padding: 12px 18px 14px;
926
+ }
927
+ .sidebar { border-bottom: 2px solid var(--ink); }
928
+ .splitter { display: none; }
929
+ .sidebar-top { position: static; }
930
+ .source-switcher { position: static; }
931
+ .switches { justify-content: start; }
932
+ .risk { grid-template-columns: 1fr; }
933
+ .turns { gap: 36px; }
934
+ .message-card, .user .message-card { max-width: 94%; }
935
+ .assistant .message-card { max-width: 100%; }
936
+ .user .message-card { padding: 12px 16px; }
937
+ .body { font-size: 18px; }
938
+ }
939
+ @media (prefers-reduced-motion: reduce) {
940
+ .loading-spinner { animation: none; }
941
+ }
942
+ `;
943
+ }
944
+ function serverJs() {
945
+ return `
946
+ const state = { sessions: [], selected: "", activeSource: "codex", requestToken: 0, expandedProjects: new Set(), collapsedProjects: new Set(), hasMoreSessions: false, loadingMoreSessions: false, sessionListError: "" };
947
+ const SOURCE_MODULES = [
948
+ { key: "codex", label: "Codex" },
949
+ { key: "claude", label: "Claude Code" },
950
+ { key: "trae", label: "Trae" },
951
+ ];
952
+ const SESSION_BATCH_LIMIT = 200;
953
+ const SAFETY_CHECKS_ENABLED = false;
954
+ const SIDEBAR_WIDTH_KEY = "codex-snapshot.sidebar-width";
955
+ const SIDEBAR_MIN = 280;
956
+ const SIDEBAR_MAX = 680;
957
+ const $ = (id) => document.getElementById(id);
958
+ const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[char]));
959
+ const shareConfig = window.CODEX_SNAPSHOT_SHARE_CONFIG || {};
960
+ const csrfToken = String(window.CODEX_SNAPSHOT_CSRF_TOKEN || "");
961
+
962
+ function renderLoading(message) {
963
+ return "<div class='loading-state' role='status' aria-live='polite' aria-busy='true'>" +
964
+ "<span class='loading-spinner' aria-hidden='true'></span>" +
965
+ "<span>" + esc(message) + "</span>" +
966
+ "</div>";
967
+ }
968
+
969
+ function activeOptions() {
970
+ if ($("includeToolOutput").checked) {
971
+ $("includeTools").checked = true;
972
+ }
973
+ return new URLSearchParams({
974
+ id: state.selected,
975
+ includeTools: $("includeTools").checked ? "1" : "0",
976
+ includeToolOutput: $("includeToolOutput").checked ? "1" : "0",
977
+ redact: $("redact").checked ? "1" : "0",
978
+ safety: SAFETY_CHECKS_ENABLED ? "1" : "0",
979
+ });
980
+ }
981
+
982
+ function clampNumber(value, min, max) {
983
+ return Math.min(max, Math.max(min, value));
984
+ }
985
+
986
+ function sidebarMaxWidth() {
987
+ return Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, window.innerWidth - 520));
988
+ }
989
+
990
+ function currentSidebarWidth() {
991
+ const sidebar = document.querySelector(".sidebar");
992
+ return sidebar ? sidebar.getBoundingClientRect().width : 360;
993
+ }
994
+
995
+ function setSidebarWidth(value, persist) {
996
+ if (window.matchMedia("(max-width: 900px)").matches) {
997
+ return;
998
+ }
999
+ const width = Math.round(clampNumber(Number(value) || currentSidebarWidth(), SIDEBAR_MIN, sidebarMaxWidth()));
1000
+ document.documentElement.style.setProperty("--sidebar-width", width + "px");
1001
+ const splitter = $("splitter");
1002
+ if (splitter) {
1003
+ splitter.setAttribute("aria-valuenow", String(width));
1004
+ splitter.setAttribute("aria-valuetext", width + "px");
1005
+ }
1006
+ if (persist) {
1007
+ localStorage.setItem(SIDEBAR_WIDTH_KEY, String(width));
1008
+ }
1009
+ }
1010
+
1011
+ function initSplitter() {
1012
+ const splitter = $("splitter");
1013
+ const app = document.querySelector(".app");
1014
+ if (!splitter || !app) {
1015
+ return;
1016
+ }
1017
+ const saved = Number(localStorage.getItem(SIDEBAR_WIDTH_KEY));
1018
+ setSidebarWidth(Number.isFinite(saved) ? saved : currentSidebarWidth(), false);
1019
+
1020
+ const widthFromPointer = (event) => event.clientX - app.getBoundingClientRect().left;
1021
+ const stopResize = (event) => {
1022
+ app.classList.remove("resizing");
1023
+ try {
1024
+ splitter.releasePointerCapture(event.pointerId);
1025
+ } catch (_error) {
1026
+ // Pointer capture may already be released when the pointer leaves the window.
1027
+ }
1028
+ window.removeEventListener("pointermove", onPointerMove);
1029
+ window.removeEventListener("pointerup", stopResize);
1030
+ window.removeEventListener("pointercancel", stopResize);
1031
+ };
1032
+ const onPointerMove = (event) => {
1033
+ event.preventDefault();
1034
+ setSidebarWidth(widthFromPointer(event), true);
1035
+ };
1036
+
1037
+ splitter.addEventListener("pointerdown", (event) => {
1038
+ if (event.button !== 0) {
1039
+ return;
1040
+ }
1041
+ event.preventDefault();
1042
+ app.classList.add("resizing");
1043
+ splitter.setPointerCapture(event.pointerId);
1044
+ setSidebarWidth(widthFromPointer(event), true);
1045
+ window.addEventListener("pointermove", onPointerMove);
1046
+ window.addEventListener("pointerup", stopResize);
1047
+ window.addEventListener("pointercancel", stopResize);
1048
+ });
1049
+
1050
+ splitter.addEventListener("keydown", (event) => {
1051
+ const current = Number(splitter.getAttribute("aria-valuenow")) || currentSidebarWidth();
1052
+ const step = event.shiftKey ? 40 : 16;
1053
+ let next = current;
1054
+ if (event.key === "ArrowLeft") next = current - step;
1055
+ else if (event.key === "ArrowRight") next = current + step;
1056
+ else if (event.key === "Home") next = SIDEBAR_MIN;
1057
+ else if (event.key === "End") next = sidebarMaxWidth();
1058
+ else return;
1059
+ event.preventDefault();
1060
+ setSidebarWidth(next, true);
1061
+ });
1062
+
1063
+ window.addEventListener("resize", () => setSidebarWidth(currentSidebarWidth(), true));
1064
+ }
1065
+
1066
+ async function loadSessions() {
1067
+ setViewerLoading("正在加载会话...");
1068
+ $("sessions").innerHTML = renderLoading("正在加载会话...");
1069
+ $("sessions").setAttribute("aria-busy", "true");
1070
+ $("reload").disabled = true;
1071
+ state.sessions = [];
1072
+ state.hasMoreSessions = false;
1073
+ state.loadingMoreSessions = false;
1074
+ state.sessionListError = "";
1075
+ try {
1076
+ const sessions = await fetchSessionPage(0);
1077
+ state.sessions = sessions;
1078
+ state.hasMoreSessions = sessions.length === SESSION_BATCH_LIMIT;
1079
+ if (!sourceSessions(state.activeSource).length) {
1080
+ const firstSourceWithSessions = SOURCE_MODULES.find((source) => sourceSessions(source.key).length);
1081
+ if (firstSourceWithSessions) {
1082
+ state.activeSource = firstSourceWithSessions.key;
1083
+ }
1084
+ }
1085
+ await selectFirstSessionForActiveSource();
1086
+ } catch (error) {
1087
+ state.sessionListError = error instanceof Error ? error.message : String(error);
1088
+ renderSessions();
1089
+ clearViewer("会话列表加载失败。");
1090
+ } finally {
1091
+ $("sessions").removeAttribute("aria-busy");
1092
+ $("reload").disabled = false;
1093
+ }
1094
+ }
1095
+
1096
+ async function fetchSessionPage(offset) {
1097
+ const query = new URLSearchParams({
1098
+ source: "all",
1099
+ limit: String(SESSION_BATCH_LIMIT),
1100
+ offset: String(Math.max(0, Number(offset) || 0)),
1101
+ });
1102
+ const response = await fetch("/api/sessions?" + query.toString());
1103
+ const result = await response.json();
1104
+ if (!response.ok) {
1105
+ throw new Error(result.error || "Failed to load sessions");
1106
+ }
1107
+ return Array.isArray(result) ? result : [];
1108
+ }
1109
+
1110
+ function appendSessions(sessions) {
1111
+ const seen = new Set(state.sessions.map(sessionRef));
1112
+ const nextSessions = [];
1113
+ for (const session of sessions) {
1114
+ const ref = sessionRef(session);
1115
+ if (!seen.has(ref)) {
1116
+ seen.add(ref);
1117
+ nextSessions.push(session);
1118
+ }
1119
+ }
1120
+ state.sessions = state.sessions.concat(nextSessions);
1121
+ }
1122
+
1123
+ async function loadMoreSessions() {
1124
+ if (state.loadingMoreSessions || !state.hasMoreSessions) {
1125
+ return;
1126
+ }
1127
+ state.loadingMoreSessions = true;
1128
+ state.sessionListError = "";
1129
+ renderSessions();
1130
+ try {
1131
+ const sessions = await fetchSessionPage(state.sessions.length);
1132
+ appendSessions(sessions);
1133
+ state.hasMoreSessions = sessions.length === SESSION_BATCH_LIMIT;
1134
+ if (!state.selected && sourceSessions(state.activeSource).length) {
1135
+ await selectFirstSessionForActiveSource();
1136
+ return;
1137
+ }
1138
+ } catch (error) {
1139
+ state.sessionListError = error instanceof Error ? error.message : String(error);
1140
+ } finally {
1141
+ state.loadingMoreSessions = false;
1142
+ renderSessions();
1143
+ }
1144
+ }
1145
+
1146
+ function renderSessions() {
1147
+ const filter = $("filter").value.trim().toLowerCase();
1148
+ const source = sourceByKey(state.activeSource);
1149
+ const sessions = sourceSessions(source.key);
1150
+ const sourceMatches = (source.label + " " + source.key).toLowerCase().includes(filter);
1151
+ const groups = groupSessions(sessions, sourceMatches ? "" : filter);
1152
+ const body = groups.length
1153
+ ? groups.map(renderProjectGroup).join("")
1154
+ : "<div class='source-empty'>" + (filter ? "没有匹配的会话" : "暂无会话") + "</div>";
1155
+ $("sessions").innerHTML = renderSourceSwitcher() + body + renderLoadMore();
1156
+ }
1157
+
1158
+ function renderSourceSwitcher() {
1159
+ return "<div class='source-switcher' role='tablist' aria-label='Session source'>" +
1160
+ SOURCE_MODULES.map((source) => {
1161
+ const count = sourceSessions(source.key).length;
1162
+ const active = source.key === state.activeSource;
1163
+ return "<button class='source-tab" + (active ? " active" : "") + "' type='button' role='tab' aria-selected='" + (active ? "true" : "false") + "' data-source='" + esc(source.key) + "'>" +
1164
+ "<span>" + esc(source.label) + "</span>" +
1165
+ "<b>" + esc(count) + "</b>" +
1166
+ "</button>";
1167
+ }).join("") +
1168
+ "</div>";
1169
+ }
1170
+
1171
+ function renderLoadMore() {
1172
+ if (!state.hasMoreSessions && !state.loadingMoreSessions && !state.sessionListError) {
1173
+ return "";
1174
+ }
1175
+ const button = state.hasMoreSessions || state.loadingMoreSessions
1176
+ ? "<button class='sessions-load-more' type='button' data-load-more='1'" + (state.loadingMoreSessions ? " disabled aria-busy='true'" : "") + ">" + (state.loadingMoreSessions ? "正在加载..." : "加载更多") + "</button>"
1177
+ : "";
1178
+ const status = state.sessionListError
1179
+ ? "<span class='load-more-meta load-more-error'>" + esc(state.sessionListError) + "</span>"
1180
+ : "<span class='load-more-meta'>已加载 " + esc(state.sessions.length) + " 条</span>";
1181
+ return "<div class='load-more-row'>" + button + status + "</div>";
1182
+ }
1183
+
1184
+ function sourceByKey(key) {
1185
+ return SOURCE_MODULES.find((source) => source.key === key) || SOURCE_MODULES[0];
1186
+ }
1187
+
1188
+ function sourceSessions(key) {
1189
+ return state.sessions.filter((session) => sessionEngine(session) === key);
1190
+ }
1191
+
1192
+ async function selectFirstSessionForActiveSource() {
1193
+ const sessions = sourceSessions(state.activeSource);
1194
+ if (!sessions.length) {
1195
+ state.selected = "";
1196
+ renderSessions();
1197
+ clearViewer(sourceByKey(state.activeSource).label + " 暂无可审阅会话。");
1198
+ return;
1199
+ }
1200
+ const selected = sessions.find((session) => sessionRef(session) === state.selected);
1201
+ await selectSession(sessionRef(selected || sessions[0]));
1202
+ }
1203
+
1204
+ function setViewerLoading(message) {
1205
+ state.requestToken += 1;
1206
+ $("title").textContent = "正在加载会话";
1207
+ $("meta").classList.add("empty", "loading");
1208
+ $("meta").innerHTML = renderLoading(message || "正在加载...");
1209
+ $("risks").innerHTML = "";
1210
+ $("exports").innerHTML = "";
1211
+ $("turns").innerHTML = "";
1212
+ }
1213
+
1214
+ function clearViewer(message) {
1215
+ state.requestToken += 1;
1216
+ $("title").textContent = "选择一个会话";
1217
+ $("meta").textContent = message || "还没有选择会话。";
1218
+ $("meta").classList.add("empty");
1219
+ $("meta").classList.remove("loading");
1220
+ $("risks").innerHTML = "";
1221
+ $("exports").innerHTML = "";
1222
+ $("turns").innerHTML = "";
1223
+ }
1224
+
1225
+ function sessionEngine(session) {
1226
+ return session.engine || "codex";
1227
+ }
1228
+
1229
+ function sessionRef(session) {
1230
+ return session.ref || (sessionEngine(session) + ":" + session.id);
1231
+ }
1232
+
1233
+ function groupSessions(sessions, filter) {
1234
+ const groupMap = new Map();
1235
+ for (const session of sessions) {
1236
+ const key = projectKey(session);
1237
+ const isNoProject = isNoProjectSession(session);
1238
+ if (!groupMap.has(key)) {
1239
+ groupMap.set(key, {
1240
+ key,
1241
+ label: projectLabel(session),
1242
+ displayPath: projectDisplayPath(session),
1243
+ isNoProject,
1244
+ newestMs: 0,
1245
+ sessions: [],
1246
+ });
1247
+ }
1248
+ const group = groupMap.get(key);
1249
+ group.sessions.push(session);
1250
+ const mtime = new Date(session.mtime).getTime();
1251
+ if (Number.isFinite(mtime)) {
1252
+ group.newestMs = Math.max(group.newestMs, mtime);
1253
+ }
1254
+ }
1255
+ const groups = sortProjectGroups(Array.from(groupMap.values()));
1256
+ if (!filter) {
1257
+ return groups;
1258
+ }
1259
+ return sortProjectGroups(groups.map((group) => {
1260
+ const projectHaystack = (group.label + " " + group.displayPath + " " + group.key).toLowerCase();
1261
+ const projectMatches = projectHaystack.includes(filter);
1262
+ const filteredSessions = projectMatches
1263
+ ? group.sessions
1264
+ : group.sessions.filter((session) => sessionHaystack(session, group).includes(filter));
1265
+ return { ...group, sessions: filteredSessions };
1266
+ }).filter((group) => group.sessions.length));
1267
+ }
1268
+
1269
+ function projectKey(session) {
1270
+ if (isNoProjectSession(session)) {
1271
+ return sessionEngine(session) + "::no-project";
1272
+ }
1273
+ return sessionEngine(session) + "::" + projectPath(session);
1274
+ }
1275
+
1276
+ function isNoProjectSession(session) {
1277
+ if (session.projectKind === "none" || session.projectKind === "conversation") {
1278
+ return true;
1279
+ }
1280
+ const cwd = projectPath(session);
1281
+ return !cwd || cwd === "/" || cwd === "No project" || isCodexStandaloneConversationPath(session);
1282
+ }
1283
+
1284
+ function isCodexStandaloneConversationPath(session) {
1285
+ if (sessionEngine(session) !== "codex") {
1286
+ return false;
1287
+ }
1288
+ return [session.cwd, session.displayCwd].some(isStandaloneConversationPath);
1289
+ }
1290
+
1291
+ function isStandaloneConversationPath(value) {
1292
+ const parts = normalizeProjectPath(value).split("/").filter(Boolean);
1293
+ const codexIndex = parts.findIndex((part, index) => part === "Codex" && parts[index - 1] === "Documents");
1294
+ if (codexIndex < 0 || codexIndex + 3 !== parts.length) {
1295
+ return false;
1296
+ }
1297
+ return /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(parts[codexIndex + 1]) && Boolean(parts[codexIndex + 2]);
1298
+ }
1299
+
1300
+ function projectDisplayPath(session) {
1301
+ return isNoProjectSession(session) ? "普通会话" : projectPath(session);
1302
+ }
1303
+
1304
+ function projectPath(session) {
1305
+ return String(session.cwd || session.displayCwd || "").trim();
1306
+ }
1307
+
1308
+ function normalizeProjectPath(value) {
1309
+ return String(value || "").trim().replace(/\\\\/g, "/").replace(/\\/+$/, "");
1310
+ }
1311
+
1312
+ function sortProjectGroups(groups) {
1313
+ return groups.slice().sort((a, b) => {
1314
+ if (a.isNoProject !== b.isNoProject) {
1315
+ return a.isNoProject ? 1 : -1;
1316
+ }
1317
+ return (b.newestMs || 0) - (a.newestMs || 0) || a.label.localeCompare(b.label);
1318
+ });
1319
+ }
1320
+
1321
+ function projectLabel(session) {
1322
+ if (isNoProjectSession(session)) {
1323
+ return "普通会话";
1324
+ }
1325
+ const value = String(session.displayCwd || session.cwd || "No project").replace(/[\\\\/]+$/, "");
1326
+ const parts = value.split(/[\\\\/]/).filter(Boolean);
1327
+ return parts[parts.length - 1] || value || "No project";
1328
+ }
1329
+
1330
+ function sessionHaystack(session, group) {
1331
+ return [
1332
+ session.engineLabel,
1333
+ session.engine,
1334
+ session.title,
1335
+ session.cwd,
1336
+ session.displayCwd,
1337
+ session.id,
1338
+ session.ref,
1339
+ group.label,
1340
+ group.displayPath,
1341
+ ].filter(Boolean).join(" ").toLowerCase();
1342
+ }
1343
+
1344
+ function renderProjectGroup(group) {
1345
+ const collapsedLimit = 5;
1346
+ const noisyExpandedLimit = 25;
1347
+ const expanded = state.expandedProjects.has(group.key);
1348
+ const collapsed = state.collapsedProjects.has(group.key);
1349
+ const activeIndex = group.sessions.findIndex((session) => sessionRef(session) === state.selected);
1350
+ const expandedLimit = group.isNoProject ? Math.min(noisyExpandedLimit, group.sessions.length) : group.sessions.length;
1351
+ const visibleLimit = expanded ? expandedLimit : Math.min(collapsedLimit, group.sessions.length);
1352
+ let visible = group.sessions.slice(0, visibleLimit);
1353
+ if (!collapsed && activeIndex >= visibleLimit) {
1354
+ visible = visible.slice(0, Math.max(0, visibleLimit - 1)).concat(group.sessions[activeIndex]);
1355
+ }
1356
+ const showToggle = !collapsed && group.sessions.length > collapsedLimit;
1357
+ const toggleLabel = expanded ? "收起" : group.isNoProject ? "显示最近 " + Math.min(noisyExpandedLimit, group.sessions.length) : "展开显示";
1358
+ const toggle = showToggle
1359
+ ? "<button class='project-more' type='button' data-project-toggle='" + esc(group.key) + "'>" + toggleLabel + "</button>"
1360
+ : "";
1361
+ const note = !collapsed && group.isNoProject && expanded && group.sessions.length > noisyExpandedLimit
1362
+ ? "<div class='project-note'>仅显示最近 " + noisyExpandedLimit + " / " + esc(group.sessions.length) + ",可搜索标题定位更多</div>"
1363
+ : "";
1364
+ const sessionList = collapsed ? "" : "<div class='session-list'>" + visible.map(renderSessionRow).join("") + "</div>";
1365
+ const sectionClass = "project-group" + (group.isNoProject ? " no-project" : "") + (collapsed ? " collapsed" : "");
1366
+ return "<section class='" + sectionClass + "'>" +
1367
+ "<button class='project-header' type='button' data-project-collapse='" + esc(group.key) + "' aria-expanded='" + (collapsed ? "false" : "true") + "' title='" + esc(group.displayPath) + "'>" +
1368
+ "<span class='project-icon' aria-hidden='true'></span>" +
1369
+ "<span class='project-title'>" + esc(group.label) + "</span>" +
1370
+ "<span class='project-count'>" + esc(group.sessions.length) + "</span>" +
1371
+ "</button>" +
1372
+ sessionList +
1373
+ note +
1374
+ toggle +
1375
+ "</section>";
1376
+ }
1377
+
1378
+ function renderSessionRow(session) {
1379
+ const ref = sessionRef(session);
1380
+ const active = ref === state.selected ? " active" : "";
1381
+ const badge = session.historyOnly ? "<span class='session-badge'>history</span>" : "";
1382
+ return "<button class='session" + active + "' data-id='" + esc(ref) + "' title='" + esc(session.title) + "'>" +
1383
+ "<strong>" + esc(session.title) + "</strong>" +
1384
+ badge +
1385
+ "<span class='session-time'>" + esc(relativeTime(session.mtime)) + "</span>" +
1386
+ "</button>";
1387
+ }
1388
+
1389
+ function relativeTime(value) {
1390
+ const time = new Date(value).getTime();
1391
+ if (!Number.isFinite(time)) {
1392
+ return "";
1393
+ }
1394
+ const diff = Math.max(0, Date.now() - time);
1395
+ const minute = 60 * 1000;
1396
+ const hour = 60 * minute;
1397
+ const day = 24 * hour;
1398
+ if (diff < minute) {
1399
+ return "刚刚";
1400
+ }
1401
+ if (diff < hour) {
1402
+ return Math.max(1, Math.floor(diff / minute)) + " 分钟";
1403
+ }
1404
+ if (diff < day) {
1405
+ return Math.max(1, Math.floor(diff / hour)) + " 小时";
1406
+ }
1407
+ if (diff < 7 * day) {
1408
+ return Math.max(1, Math.floor(diff / day)) + " 天";
1409
+ }
1410
+ return new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric" }).format(new Date(time));
1411
+ }
1412
+
1413
+ async function selectSession(id) {
1414
+ const requestToken = state.requestToken + 1;
1415
+ state.requestToken = requestToken;
1416
+ state.selected = id;
1417
+ renderSessions();
1418
+ $("turns").innerHTML = renderLoading("正在加载会话内容...");
1419
+ $("turns").setAttribute("aria-busy", "true");
1420
+ const response = await fetch("/api/snapshot?" + activeOptions().toString());
1421
+ const snapshot = await response.json();
1422
+ if (requestToken !== state.requestToken || id !== state.selected) {
1423
+ return;
1424
+ }
1425
+ if (snapshot.error) {
1426
+ $("turns").innerHTML = "<div class='meta'>" + esc(snapshot.error) + "</div>";
1427
+ $("turns").removeAttribute("aria-busy");
1428
+ return;
1429
+ }
1430
+ renderSnapshot(snapshot);
1431
+ }
1432
+
1433
+ function renderSnapshot(snapshot) {
1434
+ $("turns").removeAttribute("aria-busy");
1435
+ $("title").textContent = snapshot.title;
1436
+ $("meta").classList.remove("empty", "loading");
1437
+ $("meta").innerHTML = renderSnapshotMeta(snapshot);
1438
+ const notices = (snapshot.notices || []).map((notice) => {
1439
+ return "<div class='notice " + esc(notice.severity || "medium") + "'><b>NOTE</b><span><strong>" + esc(notice.label || "Notice") + ".</strong> " + esc(notice.text || "") + "</span></div>";
1440
+ }).join("");
1441
+ const risks = snapshot.risks.length ? snapshot.risks.map((risk) => {
1442
+ return "<div class='risk " + esc(risk.severity) + "'><b>" + esc(risk.severity) + "</b><span>" + esc(risk.label) + "</span><em>" + esc(formatRiskTurns(risk)) + "</em></div>";
1443
+ }).join("") : "<div class='risk'><b>OK</b><span>未发现常见高风险模式</span><em>分享前仍建议快速复核。</em></div>";
1444
+ $("risks").innerHTML = snapshot.safetyChecks === false ? "" : notices + risks;
1445
+ const options = activeOptions();
1446
+ $("exports").innerHTML = "<a href='/export?" + options.toString() + "&format=html' target='_blank' rel='noopener noreferrer'>导出 HTML</a><a href='/export?" + options.toString() + "&format=md' target='_blank' rel='noopener noreferrer'>导出 Markdown</a><button type='button' data-publish-cloud='1'>发布分享</button><span id='publishStatus' class='publish-status'></span>";
1447
+ $("turns").innerHTML = snapshot.transcriptHtml || "<div class='meta'>没有找到可分享的用户或助手消息。</div>";
1448
+ openContentLinksInNewTabs($("turns"));
1449
+ postSnapshotState(snapshot);
1450
+ }
1451
+
1452
+ function renderSnapshotMeta(snapshot) {
1453
+ const items = [
1454
+ ["来源", (snapshot.engineLabel || "Codex") + (snapshot.sourceDetail ? " / " + snapshot.sourceDetail : "")],
1455
+ ["会话", snapshot.id || "unknown"],
1456
+ ["项目", projectDisplayPath(snapshot) || "no cwd"],
1457
+ ["记录", String((snapshot.turns || []).length) + " 条"],
1458
+ ["脱敏", snapshot.redacted ? "已开启" : "未开启"],
1459
+ ];
1460
+ const pills = "<div class='meta-pills'>" + items.map(([label, value]) => {
1461
+ return "<span class='meta-pill'><b>" + esc(label) + "</b><span>" + esc(value) + "</span></span>";
1462
+ }).join("") + "</div>";
1463
+ const goal = snapshot.goalObjective
1464
+ ? "<div class='meta-goal'><b>目标</b><span>" + esc(snapshot.goalObjective) + "</span></div>"
1465
+ : "";
1466
+ return pills + goal;
1467
+ }
1468
+
1469
+ function postSnapshotState(snapshot) {
1470
+ if (!window.parent || window.parent === window) {
1471
+ return;
1472
+ }
1473
+ const options = activeOptions();
1474
+ window.parent.postMessage({
1475
+ type: "codex-snapshot:state",
1476
+ version: 1,
1477
+ selected: state.selected,
1478
+ title: snapshot.title || state.selected,
1479
+ engineLabel: snapshot.engineLabel || "Codex",
1480
+ redacted: Boolean(snapshot.redacted),
1481
+ options: Object.fromEntries(options.entries()),
1482
+ }, "*");
1483
+ }
1484
+
1485
+ function openContentLinksInNewTabs(root) {
1486
+ for (const link of root.querySelectorAll("a[href]")) {
1487
+ link.target = "_blank";
1488
+ link.rel = mergeLinkRel(link.rel);
1489
+ }
1490
+ }
1491
+
1492
+ function openInNewTab(url) {
1493
+ const opened = window.open(url, "_blank");
1494
+ if (opened) {
1495
+ opened.opener = null;
1496
+ opened.focus?.();
1497
+ return;
1498
+ }
1499
+ window.location.href = url;
1500
+ }
1501
+
1502
+ function mergeLinkRel(value) {
1503
+ const rel = new Set(String(value || "").split(/\\s+/).filter(Boolean));
1504
+ rel.add("noopener");
1505
+ rel.add("noreferrer");
1506
+ return Array.from(rel).join(" ");
1507
+ }
1508
+
1509
+ function shareApiBaseUrl() {
1510
+ return String(shareConfig.apiUrl || "").replace(/\\/+$/, "");
1511
+ }
1512
+
1513
+ async function fetchShareAuth(apiUrl) {
1514
+ const response = await fetch(apiUrl + "/api/auth/me?returnTo=" + encodeURIComponent(window.location.href), {
1515
+ cache: "no-store",
1516
+ credentials: "include",
1517
+ });
1518
+ const payload = await response.json().catch(() => ({}));
1519
+ if (!response.ok) {
1520
+ throw new Error(payload.error || "GitHub login check failed");
1521
+ }
1522
+ return payload;
1523
+ }
1524
+
1525
+ function redirectToShareLogin(apiUrl, auth) {
1526
+ const loginUrl = auth?.loginUrl || apiUrl + "/api/auth/github/start?returnTo=" + encodeURIComponent(window.location.href);
1527
+ window.location.href = loginUrl;
1528
+ }
1529
+
1530
+ async function publishSelectedSession() {
1531
+ if (!state.selected) {
1532
+ return;
1533
+ }
1534
+ const status = $("publishStatus");
1535
+ const button = document.querySelector("[data-publish-cloud]");
1536
+ if (button) button.disabled = true;
1537
+ if (status) {
1538
+ status.textContent = shareConfig.apiUrl ? "正在检查 GitHub 登录..." : "正在发布...";
1539
+ status.classList.remove("error");
1540
+ }
1541
+ try {
1542
+ const apiUrl = shareApiBaseUrl();
1543
+ if (!apiUrl) {
1544
+ throw new Error("分享 API 尚未配置。");
1545
+ }
1546
+ const auth = await fetchShareAuth(apiUrl);
1547
+ if (!auth.configured) {
1548
+ throw new Error("分享 API 尚未配置 GitHub 登录。");
1549
+ }
1550
+ if (!auth.user) {
1551
+ if (status) {
1552
+ status.textContent = "请先登录 GitHub,登录后会回到这里继续发布。";
1553
+ }
1554
+ redirectToShareLogin(apiUrl, auth);
1555
+ return;
1556
+ }
1557
+ if (status) {
1558
+ status.textContent = "正在发布到 " + apiUrl + "...";
1559
+ }
1560
+ const options = activeOptions();
1561
+ options.set("redact", "1");
1562
+ const payloadResponse = await fetch("/api/share-payload?" + options.toString(), {
1563
+ method: "POST",
1564
+ headers: { "${MUTATION_CSRF_HEADER}": csrfToken },
1565
+ });
1566
+ const payload = await payloadResponse.json();
1567
+ if (!payloadResponse.ok) {
1568
+ throw new Error(payload.error || "Publish failed");
1569
+ }
1570
+ const response = await fetch(String(payload.apiUrl || apiUrl).replace(/\\/+$/, "") + "/api/snapshots", {
1571
+ method: "POST",
1572
+ credentials: "include",
1573
+ headers: { "content-type": "application/json" },
1574
+ body: JSON.stringify(payload.body || {}),
1575
+ });
1576
+ const result = await response.json().catch(() => ({}));
1577
+ if (!response.ok) {
1578
+ if (response.status === 401) {
1579
+ redirectToShareLogin(apiUrl, auth);
1580
+ return;
1581
+ }
1582
+ throw new Error(result.error || "Publish failed");
1583
+ }
1584
+ if (status) {
1585
+ status.innerHTML = "<a href='" + esc(result.url) + "' target='_blank' rel='noopener noreferrer'>" + esc(result.url) + "</a>";
1586
+ }
1587
+ await navigator.clipboard?.writeText(result.url).catch(() => undefined);
1588
+ } catch (error) {
1589
+ if (status) {
1590
+ status.textContent = error instanceof Error ? error.message : String(error);
1591
+ status.classList.add("error");
1592
+ }
1593
+ } finally {
1594
+ if (button) button.disabled = false;
1595
+ }
1596
+ }
1597
+
1598
+ function formatRiskTurns(risk) {
1599
+ const turns = Array.isArray(risk.turns) ? risk.turns : [];
1600
+ const visibleTurns = turns.slice(0, 18).join(", ");
1601
+ const hiddenCount = Math.max(0, turns.length - 18);
1602
+ const suffix = hiddenCount ? ", +" + hiddenCount + " more" : "";
1603
+ return risk.count + " match(es)" + (turns.length ? ", turns " + visibleTurns + suffix : "");
1604
+ }
1605
+
1606
+ $("sessions").addEventListener("click", async (event) => {
1607
+ const sourceButton = event.target.closest("[data-source]");
1608
+ if (sourceButton) {
1609
+ const nextSource = sourceButton.dataset.source;
1610
+ if (nextSource && nextSource !== state.activeSource) {
1611
+ state.activeSource = nextSource;
1612
+ await selectFirstSessionForActiveSource();
1613
+ }
1614
+ return;
1615
+ }
1616
+ const loadMoreButton = event.target.closest("[data-load-more]");
1617
+ if (loadMoreButton) {
1618
+ await loadMoreSessions();
1619
+ return;
1620
+ }
1621
+ const toggle = event.target.closest("[data-project-toggle]");
1622
+ if (toggle) {
1623
+ const key = toggle.dataset.projectToggle;
1624
+ if (state.expandedProjects.has(key)) {
1625
+ state.expandedProjects.delete(key);
1626
+ } else {
1627
+ state.expandedProjects.add(key);
1628
+ }
1629
+ renderSessions();
1630
+ return;
1631
+ }
1632
+ const projectHeader = event.target.closest("[data-project-collapse]");
1633
+ if (projectHeader) {
1634
+ const key = projectHeader.dataset.projectCollapse;
1635
+ if (state.collapsedProjects.has(key)) {
1636
+ state.collapsedProjects.delete(key);
1637
+ } else {
1638
+ state.collapsedProjects.add(key);
1639
+ }
1640
+ renderSessions();
1641
+ return;
1642
+ }
1643
+ const button = event.target.closest("[data-id]");
1644
+ if (button) selectSession(button.dataset.id);
1645
+ });
1646
+ $("filter").addEventListener("input", renderSessions);
1647
+ $("reload").addEventListener("click", loadSessions);
1648
+ $("exports").addEventListener("click", (event) => {
1649
+ if (event.target.closest("[data-publish-cloud]")) {
1650
+ publishSelectedSession();
1651
+ }
1652
+ });
1653
+ $("turns").addEventListener("click", (event) => {
1654
+ const link = event.target.closest?.("a[href]");
1655
+ if (!link) {
1656
+ return;
1657
+ }
1658
+ event.preventDefault();
1659
+ openInNewTab(link.href);
1660
+ });
1661
+ for (const id of ["includeTools", "includeToolOutput", "redact"]) {
1662
+ $(id).addEventListener("change", () => state.selected && selectSession(state.selected));
1663
+ }
1664
+ initSplitter();
1665
+ loadSessions().catch((error) => {
1666
+ $("sessions").innerHTML = "<div class='meta'>" + esc(error.message) + "</div>";
1667
+ clearViewer(error.message || "Failed to load sessions.");
1668
+ });
1669
+ `;
1670
+ }