transcript-viewer 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- transcript_viewer/__init__.py +5 -0
- transcript_viewer/ai.py +374 -0
- transcript_viewer/cli.py +87 -0
- transcript_viewer/config.py +193 -0
- transcript_viewer/corpus.py +340 -0
- transcript_viewer/fetch.py +771 -0
- transcript_viewer/library.py +169 -0
- transcript_viewer/page.html +2248 -0
- transcript_viewer/store.py +83 -0
- transcript_viewer/viewer.py +1000 -0
- transcript_viewer-0.5.0.dist-info/METADATA +440 -0
- transcript_viewer-0.5.0.dist-info/RECORD +14 -0
- transcript_viewer-0.5.0.dist-info/WHEEL +4 -0
- transcript_viewer-0.5.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,2248 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
3
|
+
<title>Transcript Viewer</title>
|
|
4
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
5
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
6
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,400;6..72,600&family=IBM+Plex+Mono:wght@400;500&display=swap">
|
|
7
|
+
<style>
|
|
8
|
+
/* Diwan's design tokens, verbatim from www/frontend/src/index.css.
|
|
9
|
+
Three themes, not two: `paper` and `cool` are both light. */
|
|
10
|
+
:root,:root[data-theme="paper"]{
|
|
11
|
+
--bg:#f6f2ea; --surface:#fffdf8; --panel:#faf6ee; --ink:#241f18;
|
|
12
|
+
--muted:#7d7466; --line:#e5ddcd; --accent:#b0522a;
|
|
13
|
+
--soft:rgba(176,82,42,.09); --hl:rgba(233,180,76,.3); --ok:#3f7d4e;
|
|
14
|
+
--shadow:0 1px 2px rgba(60,45,20,.08), 0 8px 28px rgba(60,45,20,.1);
|
|
15
|
+
}
|
|
16
|
+
:root[data-theme="cool"]{
|
|
17
|
+
--bg:#f1f4f6; --surface:#ffffff; --panel:#f7fafb; --ink:#171c22;
|
|
18
|
+
--muted:#68737f; --line:#dbe2e9; --accent:#33689f;
|
|
19
|
+
--soft:rgba(51,104,159,.09); --hl:rgba(116,169,222,.28); --ok:#2f7a52;
|
|
20
|
+
--shadow:0 1px 2px rgba(20,40,60,.08), 0 8px 28px rgba(20,40,60,.1);
|
|
21
|
+
}
|
|
22
|
+
:root[data-theme="dark"]{
|
|
23
|
+
--bg:#18161c; --surface:#232028; --panel:#1d1b22; --ink:#eae4d8;
|
|
24
|
+
--muted:#948d80; --line:#332f3a; --accent:#d3894b;
|
|
25
|
+
--soft:rgba(211,137,75,.13); --hl:rgba(211,137,75,.25); --ok:#7fbf8f;
|
|
26
|
+
--shadow:0 1px 2px rgba(0,0,0,.4), 0 10px 30px rgba(0,0,0,.45);
|
|
27
|
+
}
|
|
28
|
+
:root{
|
|
29
|
+
--danger:oklch(0.54 0.13 30);
|
|
30
|
+
--font-ui:"Helvetica Neue",Helvetica,Arial,sans-serif;
|
|
31
|
+
--font-serif:Newsreader,Georgia,serif;
|
|
32
|
+
--font-mono:"IBM Plex Mono",ui-monospace,monospace;
|
|
33
|
+
/* Roles the trajectory view needs that Diwan has no token for. Derived from
|
|
34
|
+
its palette so they stay in family across all three themes. */
|
|
35
|
+
--dim:var(--muted); --faint:var(--muted); --sunk:var(--bg);
|
|
36
|
+
--user:var(--accent); --agent:var(--ink); --system:var(--muted); --tool:var(--accent);
|
|
37
|
+
--user-bg:var(--soft); --agent-bg:transparent; --system-bg:transparent;
|
|
38
|
+
--j-key:var(--accent); --j-str:var(--ink); --j-num:var(--muted); --j-lit:var(--muted);
|
|
39
|
+
}
|
|
40
|
+
*{box-sizing:border-box;margin:0}
|
|
41
|
+
a{color:var(--accent);text-decoration:none}
|
|
42
|
+
a:hover{text-decoration:underline;text-underline-offset:2px}
|
|
43
|
+
a.gone{color:var(--faint);text-decoration:line-through}
|
|
44
|
+
body{font:14px/1.55 var(--font-ui);background:var(--bg);color:var(--ink);display:flex;flex-direction:column;height:100vh;overflow:hidden}
|
|
45
|
+
#shell{flex:1;display:flex;min-height:0}
|
|
46
|
+
kbd,code,pre{font-family:var(--font-mono)}
|
|
47
|
+
|
|
48
|
+
/* ---- sidebar ---- */
|
|
49
|
+
#side{width:212px;flex:0 0 212px;border-right:1px solid var(--line);background:var(--panel);display:flex;flex-direction:column}
|
|
50
|
+
/* Diwan's header: 0 18px, one line, bordered below, on --panel. */
|
|
51
|
+
#top{display:flex;align-items:center;gap:18px;padding:0 18px;height:52px;
|
|
52
|
+
border-bottom:1px solid var(--line);background:var(--panel);flex:none}
|
|
53
|
+
#top .mark{font:600 18px var(--font-serif);letter-spacing:-.01em;color:var(--ink)}
|
|
54
|
+
#crumb{flex:1;min-width:0;display:flex;align-items:baseline;gap:10px;overflow:hidden;
|
|
55
|
+
font-size:13px;color:var(--muted)}
|
|
56
|
+
#crumb b{font-weight:500;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
57
|
+
.sep{font-style:normal;color:var(--faint);margin:0 7px;font-weight:400}
|
|
58
|
+
#crumb .back{display:inline}
|
|
59
|
+
#crumb .back{cursor:pointer;color:var(--accent)}
|
|
60
|
+
/* The header's controls are one control in three copies. Their box lives in a
|
|
61
|
+
single rule because keeping it in three is what let the settings button drift
|
|
62
|
+
into looking like plain text beside two pills. */
|
|
63
|
+
#theme,#gear,#open{font:500 11px var(--font-mono);letter-spacing:.05em;
|
|
64
|
+
color:var(--muted);background:var(--surface);border:1px solid var(--line);
|
|
65
|
+
border-radius:8px;padding:7px 10px;cursor:pointer;flex:none}
|
|
66
|
+
#theme:hover,#gear:hover{color:var(--ink)}
|
|
67
|
+
#brand{padding:13px 14px 9px;display:flex;align-items:center;gap:9px}
|
|
68
|
+
#brand .mark{font:600 17px var(--font-serif);letter-spacing:-.01em;color:var(--ink)}
|
|
69
|
+
#brand small{font:400 11.5px var(--font-mono);color:var(--faint)}
|
|
70
|
+
#open:hover{color:var(--accent);border-color:var(--accent)}
|
|
71
|
+
#drop{position:fixed;inset:0;z-index:100;background:color-mix(in srgb,var(--bg) 88%,transparent);
|
|
72
|
+
display:none;align-items:center;justify-content:center;font-size:16px;color:var(--accent);
|
|
73
|
+
border:3px dashed var(--accent);pointer-events:none}
|
|
74
|
+
#drop.on{display:flex}
|
|
75
|
+
.note{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:200;max-width:70ch;
|
|
76
|
+
background:var(--panel);border:1px solid var(--line);border-left:3px solid var(--tool);
|
|
77
|
+
border-radius:9px;padding:10px 14px;font-size:12.5px;white-space:pre-wrap;box-shadow:0 4px 14px rgba(0,0,0,.16)}
|
|
78
|
+
#q{margin:0 16px 10px;padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--ink);font-size:13px;width:calc(100% - 32px)}
|
|
79
|
+
#q:focus{outline:2px solid var(--accent);outline-offset:-1px}
|
|
80
|
+
#list{overflow:auto;flex:1;padding-bottom:20px}
|
|
81
|
+
.group{padding:14px 16px 5px;font-size:10.5px;letter-spacing:.13em;text-transform:uppercase;color:var(--faint);font-weight:600}
|
|
82
|
+
.item{padding:9px 16px;cursor:pointer;border-left:3px solid transparent}
|
|
83
|
+
.item:hover{background:var(--sunk)}
|
|
84
|
+
.item.on{background:var(--sunk);border-left-color:var(--accent)}
|
|
85
|
+
.item .t{font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
86
|
+
.item .m{margin-top:3px;color:var(--dim);font-size:11px;display:flex;gap:7px;align-items:center;flex-wrap:wrap}
|
|
87
|
+
.pill{border:1px solid var(--line);border-radius:999px;padding:1px 7px;font-size:10px;color:var(--dim);white-space:nowrap}
|
|
88
|
+
.pill.br{border-color:var(--accent);color:var(--accent)}
|
|
89
|
+
|
|
90
|
+
/* ---- main ---- */
|
|
91
|
+
#main{flex:1;overflow:auto;padding:28px 34px 90px;max-width:1100px}
|
|
92
|
+
|
|
93
|
+
/* Sidebar toggle. The button is fixed rather than inside #main, which is
|
|
94
|
+
replaced wholesale on every render. */
|
|
95
|
+
#toggle{position:fixed;top:62px;left:176px;z-index:20;width:28px;height:28px;padding:0;
|
|
96
|
+
border:1px solid var(--line);border-radius:7px;background:var(--panel);color:var(--dim);
|
|
97
|
+
font:inherit;font-size:15px;line-height:1;cursor:pointer;transition:left .15s ease}
|
|
98
|
+
#toggle:hover{color:var(--ink);background:var(--sunk)}
|
|
99
|
+
body.hide-side #side{display:none}
|
|
100
|
+
body.hide-side #toggle{left:10px}
|
|
101
|
+
/* Keep the heading clear of the button once the sidebar is gone. */
|
|
102
|
+
body.hide-side #main{padding-left:52px}
|
|
103
|
+
.hd h2{font-size:21px;letter-spacing:-.02em;overflow-wrap:anywhere;cursor:text}
|
|
104
|
+
.hd .sub{color:var(--dim);font-size:12.5px;margin-top:4px;overflow-wrap:anywhere}
|
|
105
|
+
.stats{display:flex;gap:26px;flex-wrap:wrap;padding:16px 0 18px;margin:16px 0 22px;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}
|
|
106
|
+
.stat b{display:block;font-size:18px;font-variant-numeric:tabular-nums;letter-spacing:-.01em}
|
|
107
|
+
.stat span{color:var(--faint);font-size:10.5px;text-transform:uppercase;letter-spacing:.09em}
|
|
108
|
+
/* ---- collection rail (Diwan's collectionTree, flattened) ---- */
|
|
109
|
+
#tagbar{padding:0 14px 10px;display:flex;gap:4px;flex-wrap:wrap}
|
|
110
|
+
.rail{display:flex;align-items:center;gap:6px;padding:5px 12px 5px 10px;cursor:pointer;
|
|
111
|
+
font-size:12.5px;border-left:2px solid transparent;color:var(--ink)}
|
|
112
|
+
.rail:hover{background:var(--bg)}
|
|
113
|
+
.rail.on{background:var(--soft);border-left-color:var(--accent);color:var(--accent)}
|
|
114
|
+
.rail .rl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
115
|
+
.racts{display:none;gap:1px;margin-left:auto}
|
|
116
|
+
.rail:hover .racts{display:inline-flex}
|
|
117
|
+
.racts button{border:none;background:none;color:var(--muted);cursor:pointer;
|
|
118
|
+
font-size:11px;padding:0 3px;line-height:1}
|
|
119
|
+
.racts button:hover{color:var(--ink)}
|
|
120
|
+
.rsrc{color:var(--muted);text-decoration:none;font-size:11px;padding:0 3px;line-height:1}
|
|
121
|
+
.rsrc:hover{color:var(--accent)}
|
|
122
|
+
.racts button.dang:hover{color:var(--danger)}
|
|
123
|
+
.acts button.dang:hover{color:var(--danger)}
|
|
124
|
+
.railtop{padding:6px 10px 8px;border-bottom:1px solid var(--line);margin-bottom:5px}
|
|
125
|
+
.railtop button{width:100%;border:1px solid var(--line);background:none;color:var(--muted);
|
|
126
|
+
border-radius:5px;padding:4px 8px;font:500 10.5px var(--font-mono);letter-spacing:.05em;
|
|
127
|
+
cursor:pointer}
|
|
128
|
+
.railtop button:hover{color:var(--danger);border-color:var(--danger)}
|
|
129
|
+
.rail .rc{font:500 10px var(--font-mono);color:var(--muted)}
|
|
130
|
+
.caret{width:10px;display:inline-flex;color:var(--muted);opacity:.75;
|
|
131
|
+
transition:transform .12s ease}
|
|
132
|
+
.caret.open{transform:rotate(90deg)}
|
|
133
|
+
.pill.tag{background:var(--soft);color:var(--accent);border:none;border-radius:20px;
|
|
134
|
+
padding:1px 8px;font-size:10.5px;cursor:pointer;display:inline-flex;gap:5px;align-items:baseline}
|
|
135
|
+
.pill.tag b{font:600 9.5px var(--font-mono);opacity:.75}
|
|
136
|
+
.pill.tag.on{background:var(--accent);color:var(--surface)}
|
|
137
|
+
.pill.origin{border:1px solid var(--line);color:var(--muted);border-radius:20px;
|
|
138
|
+
padding:1px 8px;font:500 10px var(--font-mono);cursor:pointer;background:transparent}
|
|
139
|
+
.pill.origin.on{background:var(--accent);color:var(--surface);border-color:var(--accent)}
|
|
140
|
+
|
|
141
|
+
/* ---- library table (Diwan's LibraryRow grid) ---- */
|
|
142
|
+
.lhead{display:flex;align-items:baseline;gap:12px;margin-bottom:14px}
|
|
143
|
+
.lhead h2{font:600 19px var(--font-serif);letter-spacing:-.01em}
|
|
144
|
+
.inline.hdin{font:600 19px var(--font-serif);width:min(100%,34ch);padding:1px 7px}
|
|
145
|
+
.mono{font:500 10.5px var(--font-mono);color:var(--muted)}
|
|
146
|
+
.tgrid{display:grid;
|
|
147
|
+
grid-template-columns:34px minmax(180px,2.4fr) 88px 84px 68px minmax(90px,1fr) 76px;
|
|
148
|
+
gap:10px;align-items:center}
|
|
149
|
+
.thead{padding:6px 10px;border-bottom:1px solid var(--line);
|
|
150
|
+
font:600 9.5px var(--font-mono);letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
|
|
151
|
+
.trow{padding:10px;border-bottom:1px solid var(--line);cursor:pointer}
|
|
152
|
+
.trow:hover{background:var(--panel)}
|
|
153
|
+
.trow:hover .acts{opacity:1}
|
|
154
|
+
.tcell{min-width:0;overflow:hidden}
|
|
155
|
+
.tt{font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;
|
|
156
|
+
white-space:nowrap;display:block}
|
|
157
|
+
.tags{display:flex;gap:4px;flex-wrap:nowrap;overflow:hidden}
|
|
158
|
+
.marks{display:flex;gap:4px;align-items:center;color:var(--muted)}
|
|
159
|
+
.marks .star{color:var(--accent)}
|
|
160
|
+
.inline{border:1px solid var(--accent);border-radius:5px;background:var(--surface);
|
|
161
|
+
color:var(--ink);padding:2px 6px;font:inherit;font-size:13px;width:100%}
|
|
162
|
+
.inline.tagin{border-radius:20px;font-size:11px;width:86px}
|
|
163
|
+
.acts{display:flex;gap:1px;opacity:0;transition:opacity .12s ease}
|
|
164
|
+
.acts button{background:none;border:none;color:var(--muted);cursor:pointer;
|
|
165
|
+
font-size:12px;padding:2px 4px;border-radius:5px;line-height:1}
|
|
166
|
+
.acts button:hover{color:var(--accent);background:var(--soft)}
|
|
167
|
+
|
|
168
|
+
/* Settings, over the whole page */
|
|
169
|
+
#modal,#urlmodal{position:fixed;inset:0;background:rgba(0,0,0,.34);display:flex;
|
|
170
|
+
align-items:center;justify-content:center;z-index:50;padding:24px}
|
|
171
|
+
#modal[hidden],#urlmodal[hidden]{display:none}
|
|
172
|
+
.secret{margin-bottom:15px}
|
|
173
|
+
.secret .row{margin-top:7px}
|
|
174
|
+
.way{border-top:1px solid var(--line);padding:14px 0 4px}
|
|
175
|
+
.way:first-of-type{border-top:none;padding-top:0}
|
|
176
|
+
.way h3{font:600 12px var(--font-ui);margin:0 0 3px}
|
|
177
|
+
.way .row{margin-top:9px;align-items:center}
|
|
178
|
+
.way input{margin-top:7px}
|
|
179
|
+
.blank{max-width:420px;margin:14vh auto 0;text-align:center;color:var(--muted)}
|
|
180
|
+
.blank h2{font:600 19px var(--font-serif);color:var(--ink);margin:0 0 7px}
|
|
181
|
+
.blank p{font-size:13.5px;margin:0 0 18px;line-height:1.6}
|
|
182
|
+
.blank button{border:1px solid var(--line);background:var(--surface);color:var(--ink);
|
|
183
|
+
border-radius:5px;padding:7px 16px;font:inherit;font-size:12.5px;cursor:pointer;
|
|
184
|
+
margin:0 3px}
|
|
185
|
+
.blank button.primary{background:var(--accent);border-color:var(--accent);color:var(--bg)}
|
|
186
|
+
.egs{display:flex;align-items:center;gap:7px;margin-top:8px;flex-wrap:wrap;
|
|
187
|
+
font:500 10.5px var(--font-mono);letter-spacing:.05em;color:var(--muted)}
|
|
188
|
+
.egs button{border:1px solid var(--line);background:var(--surface);color:var(--muted);
|
|
189
|
+
border-radius:20px;padding:2px 10px;font:inherit;cursor:pointer}
|
|
190
|
+
.egs button:hover{color:var(--accent);border-color:var(--accent)}
|
|
191
|
+
.pkbar{display:flex;gap:12px;padding:5px 10px 7px;border-bottom:1px solid var(--line);
|
|
192
|
+
margin-bottom:4px;font:500 10.5px var(--font-mono);letter-spacing:.05em;
|
|
193
|
+
color:var(--muted)}
|
|
194
|
+
.pkbar span{cursor:pointer}
|
|
195
|
+
.pkbar span:hover{color:var(--accent)}
|
|
196
|
+
.pkbar .pkexp{margin-left:auto}
|
|
197
|
+
#tree{max-height:38vh;overflow:auto;border:1px solid var(--line);border-radius:7px;
|
|
198
|
+
margin-top:9px;padding:5px 0;background:var(--surface)}
|
|
199
|
+
#tree[hidden]{display:none}
|
|
200
|
+
.pkrow{display:flex;align-items:center;gap:6px;padding:2px 10px;font-size:12.5px}
|
|
201
|
+
.pkrow:hover{background:var(--sunk)}
|
|
202
|
+
.pkrow input{margin:0}
|
|
203
|
+
.pkcar{width:10px;flex:none;color:var(--muted);font-size:9px;cursor:pointer;
|
|
204
|
+
transition:transform .12s;display:inline-block}
|
|
205
|
+
.pkcar.open{transform:rotate(90deg)}
|
|
206
|
+
.pkname{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
|
207
|
+
cursor:default;font:12.5px var(--font-mono);color:var(--ink)}
|
|
208
|
+
.pksize{font:500 10px var(--font-mono);color:var(--muted);flex:none}
|
|
209
|
+
#urlbar{height:4px;border-radius:2px;background:var(--sunk);overflow:hidden;margin-top:11px}
|
|
210
|
+
#urlbar[hidden]{display:none}
|
|
211
|
+
#urlfill{height:100%;width:0;background:var(--accent);border-radius:2px;
|
|
212
|
+
transition:width .18s ease-out}
|
|
213
|
+
.sheet{background:var(--panel);border:1px solid var(--line);border-radius:10px;
|
|
214
|
+
box-shadow:var(--shadow);padding:22px 24px;width:min(520px,100%)}
|
|
215
|
+
.sheet h2{font:600 15px var(--font-ui);margin:0 0 14px}
|
|
216
|
+
.sheet label{display:block;font:600 9.5px var(--font-mono);letter-spacing:.11em;
|
|
217
|
+
text-transform:uppercase;color:var(--muted);margin-bottom:5px}
|
|
218
|
+
.sheet input:not([type=checkbox]){width:100%;padding:8px 11px;border:1px solid var(--line);border-radius:5px;
|
|
219
|
+
background:var(--surface);color:var(--ink);font:13px var(--font-mono)}
|
|
220
|
+
.sheet input:not([type=checkbox]):focus{outline:none;border-color:var(--accent)}
|
|
221
|
+
.sheet input[type=checkbox]{width:13px;height:13px;padding:0;border:none;flex:none;
|
|
222
|
+
accent-color:var(--accent)}
|
|
223
|
+
.sheet input::placeholder{color:var(--muted);opacity:1}
|
|
224
|
+
.sheet .fine{font-size:11.5px;color:var(--muted);line-height:1.55;margin:8px 0 0;
|
|
225
|
+
white-space:pre-line}
|
|
226
|
+
.sheet .fine code{font:11px var(--font-mono);color:var(--ink)}
|
|
227
|
+
.sheet .fine.bad{color:var(--danger)}
|
|
228
|
+
.sheet .row{display:flex;gap:8px;margin-top:16px}
|
|
229
|
+
.sheet .row button{border:1px solid var(--line);background:var(--surface);color:var(--ink);
|
|
230
|
+
border-radius:5px;padding:6px 14px;font:inherit;font-size:12.5px;cursor:pointer}
|
|
231
|
+
.sheet .row button:hover{background:var(--sunk)}
|
|
232
|
+
.sheet .row button.primary{background:var(--accent);border-color:var(--accent);color:var(--bg)}
|
|
233
|
+
|
|
234
|
+
/* The per-transcript switch */
|
|
235
|
+
.aisw{display:inline-flex;align-items:center;gap:6px;font:500 10px var(--font-mono);
|
|
236
|
+
letter-spacing:.05em;color:var(--muted);cursor:pointer;user-select:none}
|
|
237
|
+
.aisw input{accent-color:var(--accent);margin:0}
|
|
238
|
+
|
|
239
|
+
/* Claude-backed explanations, offered only when configured */
|
|
240
|
+
.aibtn{margin-top:8px;border:1px solid var(--line);background:var(--surface);color:var(--muted);
|
|
241
|
+
border-radius:20px;padding:3px 11px;font:500 10.5px var(--font-mono);letter-spacing:.05em;
|
|
242
|
+
cursor:pointer}
|
|
243
|
+
.aibtn:hover{color:var(--accent);border-color:var(--accent)}
|
|
244
|
+
.aiout{margin-top:8px;border-left:2px solid var(--accent);background:var(--soft);
|
|
245
|
+
border-radius:0 7px 7px 0;padding:8px 12px;font-size:13px}
|
|
246
|
+
.aiout.busy{color:var(--muted);border-left-color:var(--line);background:transparent}
|
|
247
|
+
.aiout.bad{border-left-color:var(--danger);color:var(--danger);background:transparent}
|
|
248
|
+
.ail{display:block;font:600 9px var(--font-mono);letter-spacing:.11em;text-transform:uppercase;
|
|
249
|
+
color:var(--accent);margin-bottom:3px}
|
|
250
|
+
.ask{margin-bottom:16px;border:1px solid var(--line);border-radius:9px;
|
|
251
|
+
background:var(--panel);overflow:hidden}
|
|
252
|
+
.ask>summary{list-style:none;cursor:pointer;padding:8px 13px;display:flex;
|
|
253
|
+
align-items:center;gap:12px}
|
|
254
|
+
.ask>summary::-webkit-details-marker{display:none}
|
|
255
|
+
.ask>summary::before{content:"▸";color:var(--muted);font-size:10px;transition:transform .12s}
|
|
256
|
+
.ask[open]>summary::before{transform:rotate(90deg)}
|
|
257
|
+
.askt{font:600 11px var(--font-mono);letter-spacing:.06em;text-transform:uppercase;
|
|
258
|
+
color:var(--muted)}
|
|
259
|
+
.ask>summary:hover .askt{color:var(--ink)}
|
|
260
|
+
.ask .aisw{margin-left:auto}
|
|
261
|
+
.askbody{padding:0 13px 12px;display:flex;flex-direction:column;gap:9px}
|
|
262
|
+
.turns{display:flex;flex-direction:column;gap:11px}
|
|
263
|
+
.turns:empty{display:none}
|
|
264
|
+
.askq{font-weight:600;font-size:13.5px;margin-bottom:5px}
|
|
265
|
+
.askq::before{content:"›";color:var(--accent);margin-right:6px}
|
|
266
|
+
.askin{width:100%;padding:8px 12px;border:1px solid var(--line);border-radius:9px;
|
|
267
|
+
background:var(--surface);color:var(--ink);font:inherit;font-size:13.5px}
|
|
268
|
+
.askin:focus{outline:2px solid var(--accent);outline-offset:-1px}
|
|
269
|
+
.askout{border-left:2px solid var(--accent);padding:2px 0 2px 12px;font-size:13.5px}
|
|
270
|
+
.askout.bad{border-left-color:var(--danger);color:var(--danger)}
|
|
271
|
+
.askout.busy::after,.aiout.streaming::after{content:"";display:inline-block;width:6px;
|
|
272
|
+
height:13px;margin-left:2px;background:var(--accent);vertical-align:text-bottom;
|
|
273
|
+
animation:blink 1s steps(2) infinite}
|
|
274
|
+
@keyframes blink{50%{opacity:0}}
|
|
275
|
+
.askref{margin-top:7px;font:500 10px var(--font-mono);letter-spacing:.05em;color:var(--muted)}
|
|
276
|
+
.stepref{color:var(--accent);cursor:pointer;border-bottom:1px solid transparent}
|
|
277
|
+
.stepref:hover{border-bottom-color:var(--accent)}
|
|
278
|
+
|
|
279
|
+
/* tabs */
|
|
280
|
+
.tabs{display:flex;gap:2px;border-bottom:1px solid var(--line);margin:0 0 18px}
|
|
281
|
+
.tab{padding:7px 14px;font-size:13px;color:var(--dim);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px}
|
|
282
|
+
.tab:hover{color:var(--ink)}
|
|
283
|
+
.tab.on{color:var(--ink);border-bottom-color:var(--accent);font-weight:600}
|
|
284
|
+
|
|
285
|
+
/* search + lenses + provenance */
|
|
286
|
+
.runbar{display:grid;grid-template-columns:1fr;gap:12px;margin-bottom:16px}
|
|
287
|
+
.search{width:100%;padding:9px 12px;border:1px solid var(--line);border-radius:9px;
|
|
288
|
+
background:var(--panel);color:var(--ink);font:inherit;font-size:13.5px}
|
|
289
|
+
.search:focus{outline:2px solid var(--accent);outline-offset:-1px}
|
|
290
|
+
.lenses{display:flex;gap:5px;flex-wrap:wrap}
|
|
291
|
+
.lens{display:inline-flex;align-items:baseline;gap:6px;border:1px solid var(--line);
|
|
292
|
+
border-radius:999px;padding:3px 6px 3px 11px;font-size:12px;color:var(--dim);cursor:pointer;background:var(--panel)}
|
|
293
|
+
.lens:hover{color:var(--ink)}
|
|
294
|
+
.lens.on{background:var(--ink);color:var(--bg);border-color:var(--ink)}
|
|
295
|
+
.lens b{font-variant-numeric:tabular-nums;font-size:11px;background:var(--sunk);color:var(--dim);
|
|
296
|
+
border-radius:999px;padding:0 6px;font-weight:600}
|
|
297
|
+
.lens.on b{background:rgba(255,255,255,.2);color:var(--bg)}
|
|
298
|
+
.details{display:grid;grid-template-columns:auto minmax(0,1fr) auto minmax(0,1fr);
|
|
299
|
+
gap:3px 10px;align-items:baseline;border-top:1px solid var(--line);padding-top:11px}
|
|
300
|
+
.dt{font:600 9.5px var(--font-mono);letter-spacing:.09em;text-transform:uppercase;
|
|
301
|
+
color:var(--muted);white-space:nowrap;text-align:right}
|
|
302
|
+
.dd{font:400 11.5px var(--font-mono);color:var(--ink);min-width:0;overflow:hidden;
|
|
303
|
+
text-overflow:ellipsis;white-space:nowrap}
|
|
304
|
+
.dd a{color:var(--accent)}
|
|
305
|
+
@media(max-width:900px){.details{grid-template-columns:auto minmax(0,1fr)}}
|
|
306
|
+
.hits{font-size:12px;color:var(--dim);align-self:center;margin-left:4px}
|
|
307
|
+
|
|
308
|
+
/* raw + files panels */
|
|
309
|
+
.rawmeta{font-size:11.5px;color:var(--faint);margin-bottom:8px;overflow-wrap:anywhere}
|
|
310
|
+
table.files{border-collapse:collapse;width:100%;font-size:13px}
|
|
311
|
+
table.files th{text-align:left;font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;
|
|
312
|
+
color:var(--faint);font-weight:600;padding:6px 10px;border-bottom:1px solid var(--line)}
|
|
313
|
+
table.files td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:baseline}
|
|
314
|
+
table.files tr:hover td{background:var(--sunk)}
|
|
315
|
+
|
|
316
|
+
.filters{display:flex;gap:6px;margin-bottom:18px;flex-wrap:wrap;align-items:center}
|
|
317
|
+
.chip{border:1px solid var(--line);background:var(--panel);color:var(--dim);border-radius:999px;padding:3px 11px;font-size:12px;cursor:pointer}
|
|
318
|
+
.chip.on{background:var(--ink);color:var(--bg);border-color:var(--ink)}
|
|
319
|
+
/* Divider between the role filters and the display toggles. */
|
|
320
|
+
.fsep{width:1px;background:var(--line);align-self:stretch;margin:1px 6px}
|
|
321
|
+
|
|
322
|
+
/* ---- steps ---- */
|
|
323
|
+
.step{position:relative;margin:0 0 10px 46px;padding:9px 15px 11px 15px;
|
|
324
|
+
border-left:2px solid var(--line);border-radius:0 9px 9px 0;background:var(--panel)}
|
|
325
|
+
.step.user{background:var(--user-bg);border-left-color:var(--user)}
|
|
326
|
+
.step.agent{background:var(--agent-bg);border-left-color:var(--agent)}
|
|
327
|
+
.step.system{background:var(--system-bg);border-left-color:var(--system)}
|
|
328
|
+
.step:before{content:"";position:absolute;left:-6px;top:14px;width:9px;height:9px;border-radius:50%;background:var(--line);border:2px solid var(--bg)}
|
|
329
|
+
.step.user:before{background:var(--user)}.step.agent:before{background:var(--agent)}.step.system:before{background:var(--system)}
|
|
330
|
+
.role{display:flex;gap:10px;align-items:baseline;font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--faint);margin-bottom:6px}
|
|
331
|
+
.role b{font-weight:700}
|
|
332
|
+
/* The gutter is one column so the number and the star cannot drift apart:
|
|
333
|
+
positioning them separately meant two sets of offsets to keep in step, and
|
|
334
|
+
a padding nudge in a flex-end box moves content the wrong way. */
|
|
335
|
+
.gut{position:absolute;left:-52px;top:9px;width:38px;display:flex;flex-direction:column;
|
|
336
|
+
align-items:flex-end;gap:7px}
|
|
337
|
+
.sid{font-variant-numeric:tabular-nums;font-size:11.5px;font-weight:600;letter-spacing:0;
|
|
338
|
+
line-height:1.35;color:var(--faint);text-decoration:none}
|
|
339
|
+
.sid:hover{color:var(--accent)}
|
|
340
|
+
/* Always visible: an affordance revealed on hover is one nobody finds. Quiet
|
|
341
|
+
at rest, accent once it means something. */
|
|
342
|
+
.sstar{display:flex;color:var(--muted);opacity:.45;cursor:pointer;
|
|
343
|
+
transition:opacity .12s ease,color .12s ease}
|
|
344
|
+
.step:hover .sstar{opacity:.75}
|
|
345
|
+
.sstar:hover{opacity:1;color:var(--accent)}
|
|
346
|
+
.sstar.on,.step:hover .sstar.on{opacity:1;color:var(--accent)}
|
|
347
|
+
.branch .inner .gut{left:-40px;width:28px}
|
|
348
|
+
.branch .inner .sid{font-size:11px}
|
|
349
|
+
.bnav-i .sid{position:static;width:auto;text-align:left;font-size:11px;color:var(--dim)}
|
|
350
|
+
.step.user .role b{color:var(--user)}.step.agent .role b{color:var(--agent)}.step.system .role b{color:var(--system)}
|
|
351
|
+
.msg{white-space:pre-wrap;overflow-wrap:anywhere}
|
|
352
|
+
.think{margin:8px 0;padding:8px 12px;border-left:2px solid var(--line);background:var(--bg);border-radius:0 6px 6px 0;color:var(--dim);font-size:13.5px;white-space:pre-wrap;overflow-wrap:anywhere}
|
|
353
|
+
details.tool{margin-top:9px;border:1px solid var(--line);border-radius:9px;background:var(--panel);box-shadow:var(--shadow);overflow:hidden}
|
|
354
|
+
details.tool>summary{cursor:pointer;padding:8px 12px;font-size:12.5px;display:flex;gap:9px;align-items:center;list-style:none}
|
|
355
|
+
details.tool>summary::-webkit-details-marker{display:none}
|
|
356
|
+
details.tool>summary:before{content:"▸";color:var(--faint);font-size:10px}
|
|
357
|
+
details.tool[open]>summary:before{content:"▾"}
|
|
358
|
+
.tname{font-family:ui-monospace,monospace;font-weight:600;color:var(--tool)}
|
|
359
|
+
.tbody{padding:0 12px 12px}
|
|
360
|
+
pre{background:var(--sunk);border-radius:7px;padding:10px;overflow:auto;font-size:12px;max-height:320px;margin-top:8px;white-space:pre-wrap;overflow-wrap:anywhere}
|
|
361
|
+
|
|
362
|
+
/* ---- branching ---- */
|
|
363
|
+
.branch{margin-top:10px;border:1px solid var(--accent);border-radius:9px;background:var(--panel);overflow:hidden}
|
|
364
|
+
.branch>summary{cursor:pointer;padding:9px 12px;font-size:12.5px;display:flex;gap:9px;align-items:center;list-style:none;color:var(--accent)}
|
|
365
|
+
.branch>summary::-webkit-details-marker{display:none}
|
|
366
|
+
.branch>summary:before{content:"⤷";font-weight:700}
|
|
367
|
+
.branch>summary b{font-weight:700}
|
|
368
|
+
.branch>summary .desc{color:var(--dim);font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
369
|
+
.branch .inner{padding:6px 14px 4px 22px;border-top:1px solid var(--line);background:var(--sunk)}
|
|
370
|
+
.branch .inner .step{border-left-color:var(--line)}
|
|
371
|
+
.miss{padding:9px 12px;color:var(--dim);font-size:12.5px}
|
|
372
|
+
.empty{color:var(--faint);padding:60px 0;text-align:center}
|
|
373
|
+
|
|
374
|
+
/* branch navigator: branches are scattered through thousands of steps */
|
|
375
|
+
.bnav{border:1px solid var(--line);border-radius:10px;background:var(--panel);margin-bottom:18px;overflow:hidden}
|
|
376
|
+
.bnav-h{padding:9px 13px;font-size:10.5px;letter-spacing:.13em;text-transform:uppercase;color:var(--faint);font-weight:600;border-bottom:1px solid var(--line)}
|
|
377
|
+
.bnav-l{max-height:190px;overflow:auto}
|
|
378
|
+
.bnav-i{display:grid;grid-template-columns:minmax(70px,auto) 1fr auto;gap:11px;align-items:baseline;width:100%;
|
|
379
|
+
text-align:left;background:none;border:0;border-bottom:1px solid var(--line);padding:7px 13px;cursor:pointer;
|
|
380
|
+
color:var(--ink);font:inherit;font-size:12.5px}
|
|
381
|
+
.bnav-i:last-child{border-bottom:0}
|
|
382
|
+
.bnav-i:hover{background:var(--sunk)}
|
|
383
|
+
.bnav-i b{color:var(--accent);font-weight:600}
|
|
384
|
+
.bnav-i span{color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
385
|
+
.bnav-i em{color:var(--faint);font-style:normal;font-size:11px;white-space:nowrap}
|
|
386
|
+
/* rendered markdown */
|
|
387
|
+
.md{overflow-wrap:anywhere}
|
|
388
|
+
.md>*:first-child{margin-top:0}
|
|
389
|
+
.md>*:last-child{margin-bottom:0}
|
|
390
|
+
.md p{margin:.55em 0}
|
|
391
|
+
.md h1,.md h2,.md h3,.md h4{margin:1em 0 .4em;line-height:1.3;letter-spacing:-.01em}
|
|
392
|
+
.md h1{font-size:1.32em}.md h2{font-size:1.18em}.md h3{font-size:1.06em}.md h4{font-size:1em;color:var(--dim)}
|
|
393
|
+
.md ul,.md ol{margin:.5em 0;padding-left:1.4em}
|
|
394
|
+
.md li{margin:.2em 0}
|
|
395
|
+
.md code{background:var(--bg);border-radius:4px;padding:.1em .35em;font-size:.88em}
|
|
396
|
+
.md pre.code{background:var(--bg);border-radius:7px;padding:10px 12px;overflow:auto;margin:.6em 0;max-height:340px}
|
|
397
|
+
.md pre.code code{background:none;padding:0;font-size:12px;line-height:1.5}
|
|
398
|
+
.md blockquote{margin:.6em 0;padding:.1em 0 .1em .9em;border-left:2px solid var(--line);color:var(--dim)}
|
|
399
|
+
.md table{border-collapse:collapse;margin:.7em 0;font-size:.93em;display:block;overflow-x:auto}
|
|
400
|
+
.md th,.md td{border:1px solid var(--line);padding:5px 10px;text-align:left}
|
|
401
|
+
.md th{background:var(--sunk);font-weight:600}
|
|
402
|
+
.md hr{border:0;border-top:1px solid var(--line);margin:1em 0}
|
|
403
|
+
/* shell output: what changed, what failed, what passed */
|
|
404
|
+
.s-meta{color:var(--muted)}
|
|
405
|
+
.s-add{color:var(--ok)}
|
|
406
|
+
.s-del{color:var(--danger)}
|
|
407
|
+
.s-bad{color:var(--danger);font-weight:500}
|
|
408
|
+
.s-ok{color:var(--ok)}
|
|
409
|
+
|
|
410
|
+
/* JSON syntax colours */
|
|
411
|
+
.j-key{color:var(--j-key)}
|
|
412
|
+
.j-str{color:var(--j-str)}
|
|
413
|
+
.j-num{color:var(--j-num)}
|
|
414
|
+
.j-lit{color:var(--j-lit)}
|
|
415
|
+
pre.json{line-height:1.45}
|
|
416
|
+
|
|
417
|
+
/* A call and its output are different things; showing them in identical boxes
|
|
418
|
+
made a long argument list read as though it were already output. */
|
|
419
|
+
.io{position:relative;border-radius:7px;margin-top:8px;padding:6px 10px 8px}
|
|
420
|
+
.io.in{background:var(--soft)}
|
|
421
|
+
.io.out{background:var(--bg);border:1px solid var(--line)}
|
|
422
|
+
.io .iol{display:block;font:600 9px var(--font-mono);letter-spacing:.11em;
|
|
423
|
+
text-transform:uppercase;margin-bottom:2px}
|
|
424
|
+
.io.in .iol{color:var(--accent)}
|
|
425
|
+
.io.out .iol{color:var(--muted)}
|
|
426
|
+
.io pre{background:transparent;padding:0;margin-top:2px;max-height:320px}
|
|
427
|
+
.img{display:inline-block;margin:8px 8px 0 0}
|
|
428
|
+
.img img{max-width:min(420px,100%);max-height:300px;border:1px solid var(--line);border-radius:8px;display:block;background:var(--sunk)}
|
|
429
|
+
.more{padding:20px 0;text-align:center}
|
|
430
|
+
.more button{border:1px solid var(--line);background:var(--panel);color:var(--dim);border-radius:999px;
|
|
431
|
+
padding:8px 18px;font:inherit;font-size:12.5px;cursor:pointer}
|
|
432
|
+
.more button:hover{background:var(--sunk);color:var(--ink)}
|
|
433
|
+
</style>
|
|
434
|
+
|
|
435
|
+
<header id="top">
|
|
436
|
+
<span class="mark">Transcript Viewer</span>
|
|
437
|
+
<div id="crumb"></div>
|
|
438
|
+
<button id="theme" onclick="cycleTheme()" title="Change theme"></button>
|
|
439
|
+
<button id="gear" onclick="openSettings()" title="Settings">Settings</button>
|
|
440
|
+
<button id="open" onclick="openAdd()" title="Add transcripts to the library">Add…</button>
|
|
441
|
+
</header>
|
|
442
|
+
|
|
443
|
+
<div id="modal" hidden onclick="if(event.target===this)closeSettings()">
|
|
444
|
+
<div class="sheet">
|
|
445
|
+
<h2>Settings</h2>
|
|
446
|
+
<div id="secrets"></div>
|
|
447
|
+
<p class="fine">Kept in <code>~/.transcript-viewer/config.json</code>, readable only by you.
|
|
448
|
+
Each is sent to its own service and nowhere else, and none is ever read back
|
|
449
|
+
into this page. A token in your keychain or password manager is safer than one
|
|
450
|
+
in a file — set the environment variable instead and leave the field empty.</p>
|
|
451
|
+
<div class="row">
|
|
452
|
+
<button onclick="closeSettings()">Close</button>
|
|
453
|
+
</div>
|
|
454
|
+
<p class="fine bad" id="keyerr" hidden></p>
|
|
455
|
+
</div>
|
|
456
|
+
</div>
|
|
457
|
+
|
|
458
|
+
<div id="urlmodal" hidden onclick="if(event.target===this)closeUrl()">
|
|
459
|
+
<div class="sheet">
|
|
460
|
+
<h2>Add transcripts</h2>
|
|
461
|
+
|
|
462
|
+
<section class="way">
|
|
463
|
+
<h3>A file or folder</h3>
|
|
464
|
+
<p class="fine">A log, a converted trajectory, a HAR, or an archive of them.
|
|
465
|
+
Dragging onto the window does the same.</p>
|
|
466
|
+
<div class="row">
|
|
467
|
+
<button onclick="picker.click()">Choose…</button>
|
|
468
|
+
</div>
|
|
469
|
+
</section>
|
|
470
|
+
|
|
471
|
+
<section class="way">
|
|
472
|
+
<h3>From a URL</h3>
|
|
473
|
+
<p class="fine">A <b>Hugging Face</b> dataset, a <b>GitHub</b> repository —
|
|
474
|
+
whole, or one folder inside it — an <b>S3</b> bucket or prefix, or a direct
|
|
475
|
+
link to a single file.</p>
|
|
476
|
+
<p class="fine">S3 is read by running the <code>aws</code> CLI, so sign in with
|
|
477
|
+
<code>aws sso login --profile <name></code> first. The profile is worked
|
|
478
|
+
out from your setup; only a machine with several needs one chosen, in
|
|
479
|
+
Settings.</p>
|
|
480
|
+
<div class="egs" id="egs"></div>
|
|
481
|
+
<input id="urlin" spellcheck="false" autocomplete="off"
|
|
482
|
+
placeholder="https://huggingface.co/… · https://github.com/… · s3://bucket/prefix"
|
|
483
|
+
onkeydown="if(event.key==='Enter')planUrl()">
|
|
484
|
+
<div id="urlbar" hidden><div id="urlfill"></div></div>
|
|
485
|
+
<div id="tree" hidden></div>
|
|
486
|
+
<p class="fine" id="urlstate"></p>
|
|
487
|
+
<div class="row">
|
|
488
|
+
<button class="primary" id="urlgo" onclick="planUrl()">Look</button>
|
|
489
|
+
</div>
|
|
490
|
+
<p class="fine bad" id="urlerr" hidden></p>
|
|
491
|
+
</section>
|
|
492
|
+
|
|
493
|
+
<div class="row">
|
|
494
|
+
<button onclick="closeUrl()">Close</button>
|
|
495
|
+
</div>
|
|
496
|
+
</div>
|
|
497
|
+
</div>
|
|
498
|
+
|
|
499
|
+
<div id="shell">
|
|
500
|
+
<div id="side">
|
|
501
|
+
<div id="brand"><small id="count"></small></div>
|
|
502
|
+
<input id="picker" type="file" multiple hidden
|
|
503
|
+
accept=".jsonl,.json,.har,.zip,.gz,.tgz,.bz2,.xz,.tar"
|
|
504
|
+
onchange="openFiles(this.files)">
|
|
505
|
+
<div id="drop">Drop logs, trajectories or archives</div>
|
|
506
|
+
<input id="q" placeholder="Filter…" oninput="drawList();if(!cur)showLibrary()">
|
|
507
|
+
<div id="tagbar"></div>
|
|
508
|
+
<div id="list"></div>
|
|
509
|
+
</div>
|
|
510
|
+
<button id="toggle" onclick="toggleSide()" title="Show/hide sessions (\\)" aria-label="Toggle sidebar">‹</button>
|
|
511
|
+
<div id="main"><div class="empty">Loading…</div></div>
|
|
512
|
+
</div>
|
|
513
|
+
|
|
514
|
+
<script>
|
|
515
|
+
// Minimal Markdown renderer. Escapes first, then transforms, so no raw HTML
|
|
516
|
+
// from a log can ever reach the DOM.
|
|
517
|
+
// Bare URLs become links, in prose, JSON strings and tool output alike.
|
|
518
|
+
// Splitting on existing anchors, code spans and tags means this pass only ever
|
|
519
|
+
// touches plain text, never markup a caller already produced.
|
|
520
|
+
// A leading component that plausibly starts a real filesystem path. Requiring
|
|
521
|
+
// one of these keeps prose like "and/or" from being read as a path.
|
|
522
|
+
const PATH_ROOT = "~|/(?:Users|Volumes|home|opt|etc|var|tmp|private|Applications|srv|mnt)";
|
|
523
|
+
// URLs and absolute paths are found in one pass so both get identical
|
|
524
|
+
// treatment; the callback decides which kind it matched.
|
|
525
|
+
const AUTOLINK = new RegExp(
|
|
526
|
+
"(\\bhttps?://[^\\s<>()\\[\\]\"']+)" +
|
|
527
|
+
"|((?:" + PATH_ROOT + ")(?:/[^\\s<>\"'`]+)*)",
|
|
528
|
+
"g",
|
|
529
|
+
);
|
|
530
|
+
// Inside a JSON string the quotes delimit the value exactly, so a path there
|
|
531
|
+
// may contain spaces — unlike one found loose in prose.
|
|
532
|
+
const PATH_EXACT = new RegExp("^(?:" + PATH_ROOT + ")(?:/[^/].*)*$");
|
|
533
|
+
|
|
534
|
+
const anchor = (href, label) =>
|
|
535
|
+
`<a href="${href}" target="_blank" rel="noreferrer noopener">${label}</a>`;
|
|
536
|
+
// Revealing happens server-side: Chrome refuses to follow file:// from an http
|
|
537
|
+
// page, so the link calls back to the local server instead.
|
|
538
|
+
// `label` is inserted as-is, so callers pass text they have already escaped.
|
|
539
|
+
const pathAnchor = (path, label) =>
|
|
540
|
+
`<a href="/api/reveal?path=${encodeURIComponent(path)}" onclick="return reveal(this)"` +
|
|
541
|
+
` title="Reveal in Finder">${label === undefined ? path : label}</a>`;
|
|
542
|
+
const autolink = (html) =>
|
|
543
|
+
html
|
|
544
|
+
.split(/(<a\b[^>]*>[\s\S]*?<\/a>|<[^>]+>)/g)
|
|
545
|
+
.map((part, i) =>
|
|
546
|
+
i % 2
|
|
547
|
+
? part
|
|
548
|
+
: part.replace(AUTOLINK, (m, url, path) => {
|
|
549
|
+
// Trailing punctuation belongs to the sentence, not the target.
|
|
550
|
+
let value = url || path,
|
|
551
|
+
rest = "";
|
|
552
|
+
const trail = value.match(/(?:&(?:lt|gt|quot|amp);|[.,;:!?)\]}'"])+$/);
|
|
553
|
+
if (trail) { value = value.slice(0, -trail[0].length); rest = trail[0]; }
|
|
554
|
+
if (!value) return m;
|
|
555
|
+
return (url ? anchor(value, value) : pathAnchor(value)) + rest;
|
|
556
|
+
}),
|
|
557
|
+
)
|
|
558
|
+
.join("");
|
|
559
|
+
|
|
560
|
+
function md(src) {
|
|
561
|
+
if (!src) return "";
|
|
562
|
+
let s = String(src)
|
|
563
|
+
.replace(/\r\n?/g, "\n")
|
|
564
|
+
.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
|
|
565
|
+
|
|
566
|
+
// Fenced code is pulled out first so its contents are never marked up.
|
|
567
|
+
const blocks = [];
|
|
568
|
+
s = s.replace(/```([\w+-]*)\n([\s\S]*?)```/g, (m, lang, code) => {
|
|
569
|
+
blocks.push(
|
|
570
|
+
`<pre class="code"${lang ? ` data-lang="${lang}"` : ""}><code>${code.replace(/\n$/, "")}</code></pre>`,
|
|
571
|
+
);
|
|
572
|
+
return `\n<!--B${blocks.length - 1}-->\n`;
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
const inline = (t) =>
|
|
576
|
+
t
|
|
577
|
+
.replace(/`([^`\n]+)`/g, "<code>$1</code>")
|
|
578
|
+
.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>")
|
|
579
|
+
.replace(/(^|[\s(])\*([^*\n]+)\*(?=[\s.,;:)!?]|$)/g, "$1<em>$2</em>")
|
|
580
|
+
.replace(/(^|[\s(])_([^_\n]+)_(?=[\s.,;:)!?]|$)/g, "$1<em>$2</em>")
|
|
581
|
+
.replace(/~~([^~\n]+)~~/g, "<del>$1</del>")
|
|
582
|
+
// Only http(s) links become anchors; anything else stays as plain text.
|
|
583
|
+
.replace(
|
|
584
|
+
/\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)/g,
|
|
585
|
+
'<a href="$2" target="_blank" rel="noreferrer noopener">$1</a>',
|
|
586
|
+
);
|
|
587
|
+
|
|
588
|
+
const lines = s.split("\n"),
|
|
589
|
+
out = [];
|
|
590
|
+
let para = [],
|
|
591
|
+
list = null,
|
|
592
|
+
quote = [],
|
|
593
|
+
table = null;
|
|
594
|
+
|
|
595
|
+
const flushPara = () => {
|
|
596
|
+
if (para.length) {
|
|
597
|
+
out.push(`<p>${inline(para.join(" "))}</p>`);
|
|
598
|
+
para = [];
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
const flushList = () => {
|
|
602
|
+
if (list) {
|
|
603
|
+
out.push(
|
|
604
|
+
`<${list.tag}>${list.items.map((i) => `<li>${inline(i)}</li>`).join("")}</${list.tag}>`,
|
|
605
|
+
);
|
|
606
|
+
list = null;
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
const flushQuote = () => {
|
|
610
|
+
if (quote.length) {
|
|
611
|
+
out.push(`<blockquote>${inline(quote.join(" "))}</blockquote>`);
|
|
612
|
+
quote = [];
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
const flushTable = () => {
|
|
616
|
+
if (!table) return;
|
|
617
|
+
const cells = (r) =>
|
|
618
|
+
r
|
|
619
|
+
.split("|")
|
|
620
|
+
.slice(1, -1)
|
|
621
|
+
.map((c) => c.trim());
|
|
622
|
+
const head = cells(table[0])
|
|
623
|
+
.map((c) => `<th>${inline(c)}</th>`)
|
|
624
|
+
.join("");
|
|
625
|
+
const body = table
|
|
626
|
+
.slice(2)
|
|
627
|
+
.map(
|
|
628
|
+
(r) =>
|
|
629
|
+
`<tr>${cells(r)
|
|
630
|
+
.map((c) => `<td>${inline(c)}</td>`)
|
|
631
|
+
.join("")}</tr>`,
|
|
632
|
+
)
|
|
633
|
+
.join("");
|
|
634
|
+
out.push(
|
|
635
|
+
`<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`,
|
|
636
|
+
);
|
|
637
|
+
table = null;
|
|
638
|
+
};
|
|
639
|
+
const flushAll = () => {
|
|
640
|
+
flushPara();
|
|
641
|
+
flushList();
|
|
642
|
+
flushQuote();
|
|
643
|
+
flushTable();
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
for (let i = 0; i < lines.length; i++) {
|
|
647
|
+
const line = lines[i],
|
|
648
|
+
t = line.trim();
|
|
649
|
+
|
|
650
|
+
if (/^<!--B\d+-->$/.test(t)) {
|
|
651
|
+
flushAll();
|
|
652
|
+
out.push(t);
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
if (!t) {
|
|
656
|
+
flushAll();
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// A pipe table needs its delimiter row to be a table at all.
|
|
661
|
+
if (
|
|
662
|
+
!table &&
|
|
663
|
+
/^\|.*\|$/.test(t) &&
|
|
664
|
+
/^\|[\s:|-]+\|$/.test((lines[i + 1] || "").trim())
|
|
665
|
+
) {
|
|
666
|
+
flushPara();
|
|
667
|
+
flushList();
|
|
668
|
+
flushQuote();
|
|
669
|
+
table = [t, (lines[i + 1] || "").trim()];
|
|
670
|
+
i++;
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
if (table) {
|
|
674
|
+
if (/^\|.*\|$/.test(t)) {
|
|
675
|
+
table.push(t);
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
flushTable();
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
let m;
|
|
682
|
+
if ((m = t.match(/^(#{1,6})\s+(.*)$/))) {
|
|
683
|
+
flushAll();
|
|
684
|
+
out.push(`<h${m[1].length}>${inline(m[2])}</h${m[1].length}>`);
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
if (/^([-*_])\1{2,}$/.test(t)) {
|
|
688
|
+
flushAll();
|
|
689
|
+
out.push("<hr>");
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
if ((m = t.match(/^>\s?(.*)$/))) {
|
|
693
|
+
flushPara();
|
|
694
|
+
flushList();
|
|
695
|
+
flushTable();
|
|
696
|
+
quote.push(m[1]);
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
flushQuote();
|
|
700
|
+
|
|
701
|
+
if ((m = t.match(/^[-*+]\s+(.*)$/))) {
|
|
702
|
+
flushPara();
|
|
703
|
+
flushTable();
|
|
704
|
+
if (!list || list.tag !== "ul") {
|
|
705
|
+
flushList();
|
|
706
|
+
list = { tag: "ul", items: [] };
|
|
707
|
+
}
|
|
708
|
+
list.items.push(m[1]);
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
if ((m = t.match(/^\d+[.)]\s+(.*)$/))) {
|
|
712
|
+
flushPara();
|
|
713
|
+
flushTable();
|
|
714
|
+
if (!list || list.tag !== "ol") {
|
|
715
|
+
flushList();
|
|
716
|
+
list = { tag: "ol", items: [] };
|
|
717
|
+
}
|
|
718
|
+
list.items.push(m[1]);
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
// A continuation line belongs to the open list item, not a new paragraph.
|
|
722
|
+
if (list && /^\s{2,}\S/.test(line)) {
|
|
723
|
+
list.items[list.items.length - 1] += " " + t;
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
flushList();
|
|
727
|
+
para.push(t);
|
|
728
|
+
}
|
|
729
|
+
flushAll();
|
|
730
|
+
|
|
731
|
+
return autolink(
|
|
732
|
+
out.join("").replace(/<!--B(\d+)-->/g, (m, n) => blocks[+n]),
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Escape only the three characters that matter in element text. Quotes are
|
|
737
|
+
// left intact so the tokenizer below can still see JSON string delimiters.
|
|
738
|
+
const escText = (s) =>
|
|
739
|
+
String(s ?? "").replace(
|
|
740
|
+
/[&<>]/g,
|
|
741
|
+
(c) => ({ "&": "&", "<": "<", ">": ">" })[c],
|
|
742
|
+
);
|
|
743
|
+
|
|
744
|
+
// Colourise a JSON value. Runs over already-escaped text, so nothing it emits
|
|
745
|
+
// can introduce markup.
|
|
746
|
+
function hjson(value, indent = 2) {
|
|
747
|
+
let text;
|
|
748
|
+
try {
|
|
749
|
+
text = JSON.stringify(value, null, indent);
|
|
750
|
+
} catch (e) {
|
|
751
|
+
return escText(String(value));
|
|
752
|
+
}
|
|
753
|
+
if (text === undefined) return "";
|
|
754
|
+
const coloured = escText(text).replace(
|
|
755
|
+
/("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g,
|
|
756
|
+
(m, str, colon, lit) => {
|
|
757
|
+
if (str !== undefined) {
|
|
758
|
+
if (colon) return `<span class="j-key">${str}</span>${colon}`;
|
|
759
|
+
const inner = str.slice(1, -1);
|
|
760
|
+
// Quotes bound the value, so a path with spaces is unambiguous here.
|
|
761
|
+
if (PATH_EXACT.test(inner)) {
|
|
762
|
+
return `<span class="j-str">"${pathAnchor(inner)}"</span>`;
|
|
763
|
+
}
|
|
764
|
+
return `<span class="j-str">${str}</span>`;
|
|
765
|
+
}
|
|
766
|
+
if (lit !== undefined) return `<span class="j-lit">${m}</span>`;
|
|
767
|
+
return `<span class="j-num">${m}</span>`;
|
|
768
|
+
},
|
|
769
|
+
);
|
|
770
|
+
// A URL inside a JSON string value should be clickable too.
|
|
771
|
+
return autolink(coloured);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/* Shell output gets scanned for three things: what changed, what failed, what
|
|
775
|
+
passed. Measured over a real corpus those cover most of what appears — diffs
|
|
776
|
+
8%, errors 8%, test results 14% — so colour those and leave the rest alone
|
|
777
|
+
rather than tinting every line. */
|
|
778
|
+
function shell(text) {
|
|
779
|
+
return escText(text)
|
|
780
|
+
.split("\n")
|
|
781
|
+
.map((line) => {
|
|
782
|
+
const t = line.trimStart();
|
|
783
|
+
// Headers first: "---" and "+++" would otherwise read as removed/added.
|
|
784
|
+
if (/^(diff --git |index [0-9a-f]|@@ |\+\+\+ |--- )/.test(t))
|
|
785
|
+
return `<span class="s-meta">${line}</span>`;
|
|
786
|
+
if (/^\+/.test(t)) return `<span class="s-add">${line}</span>`;
|
|
787
|
+
if (/^-(?!-)/.test(t)) return `<span class="s-del">${line}</span>`;
|
|
788
|
+
if (/^(Traceback \(|FAILED\b|ERROR\b|fatal:|E\s{3})/.test(t))
|
|
789
|
+
return `<span class="s-bad">${line}</span>`;
|
|
790
|
+
if (/^(ok\b|PASSED\b)|\b\d+ passed\b|\bAll tests passed\b/.test(t))
|
|
791
|
+
return `<span class="s-ok">${line}</span>`;
|
|
792
|
+
return line;
|
|
793
|
+
})
|
|
794
|
+
.join("\n");
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Tool output is usually plain text, sometimes JSON. Colour it only when it
|
|
798
|
+
// really parses, so a log line that merely starts with "{" is left alone.
|
|
799
|
+
function pre(content) {
|
|
800
|
+
const t = String(content ?? "").trim();
|
|
801
|
+
if (
|
|
802
|
+
(t.startsWith("{") && t.endsWith("}")) ||
|
|
803
|
+
(t.startsWith("[") && t.endsWith("]"))
|
|
804
|
+
) {
|
|
805
|
+
try {
|
|
806
|
+
return `<pre class="json">${hjson(JSON.parse(t))}</pre>`;
|
|
807
|
+
} catch (e) {
|
|
808
|
+
/* not JSON after all */
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
return `<pre>${autolink(shell(content))}</pre>`;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const esc=s=>String(s??"").replace(/[&<>"]/g,c=>({"&":"&","<":"<",">":">",'"':"""}[c]));
|
|
815
|
+
const num=n=>(n??0).toLocaleString();
|
|
816
|
+
/* One size formatter. Everything used to divide by 1048576 and say MB, so a
|
|
817
|
+
900-byte log read as "0.0 MB" and a 144 GB bucket as "147456.0 MB". Each step
|
|
818
|
+
keeps three significant figures, which is as much as anyone reads. */
|
|
819
|
+
const UNITS=["B","KB","MB","GB","TB"];
|
|
820
|
+
function bytes(n){
|
|
821
|
+
n=Number(n)||0;
|
|
822
|
+
if(n<1024)return `${Math.round(n)} B`;
|
|
823
|
+
let step=0;
|
|
824
|
+
while(n>=1024&&step<UNITS.length-1){n/=1024;step++}
|
|
825
|
+
return `${n<10?n.toFixed(1):Math.round(n)} ${UNITS[step]}`;
|
|
826
|
+
}
|
|
827
|
+
const short=s=>{const p=String(s||"").split("/").filter(Boolean);return p.slice(-2).join("/")||s};
|
|
828
|
+
const PAGE_SIZE=250;
|
|
829
|
+
let INDEX=[],GROUPS=[],TAGS=[],AI={},DOWNLOADS="",CHAT=[],ASK_OPEN=false,cur=null,traj=null,
|
|
830
|
+
collection="__all",ACTIVE_TAGS={},EDITING=null,
|
|
831
|
+
EXPANDED=(()=>{try{return JSON.parse(localStorage.getItem("transcript-viewer.folders"))||{}}catch(e){return {}}})(),show={user:1,agent:1,system:1},limit=PAGE_SIZE,raw=false;
|
|
832
|
+
let tab="trajectory",query="",lens="all",extra=null,RENAMING=false,OPEN_ALL=false;
|
|
833
|
+
|
|
834
|
+
function reveal(a){
|
|
835
|
+
fetch(a.getAttribute("href")).then(r=>{
|
|
836
|
+
// A path recorded in an old log may simply not exist any more.
|
|
837
|
+
if(!r.ok)a.classList.add("gone");
|
|
838
|
+
});
|
|
839
|
+
return false;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/* Send a file to the server, which routes it through the same discovery the
|
|
843
|
+
CLI uses. One request per file; the body is the file itself, so there is no
|
|
844
|
+
multipart parsing to get wrong. */
|
|
845
|
+
async function openFiles(files){
|
|
846
|
+
if(!files||!files.length)return;
|
|
847
|
+
const names=[...files].map(f=>f.name);
|
|
848
|
+
count.textContent=`opening ${names.length} file${names.length>1?"s":""}…`;
|
|
849
|
+
const problems=[];
|
|
850
|
+
let first=null;
|
|
851
|
+
for(const file of files){
|
|
852
|
+
try{
|
|
853
|
+
const res=await fetch("/api/open",{method:"POST",
|
|
854
|
+
headers:{"X-Filename":encodeURIComponent(file.name)},body:file});
|
|
855
|
+
const data=await res.json();
|
|
856
|
+
if(!res.ok||data.error){problems.push(`${file.name}: ${data.error||res.status}`);continue}
|
|
857
|
+
if(first===null)first=(data.keys||[])[0]||null;
|
|
858
|
+
}catch(err){problems.push(`${file.name}: ${err.message}`)}
|
|
859
|
+
}
|
|
860
|
+
const fresh=await fetch("/api/index").then(r=>r.json());
|
|
861
|
+
INDEX=fresh.sessions||[];GROUPS=fresh.groups||[];TAGS=fresh.tags||[];AI=fresh.ai||{};DOWNLOADS=fresh.downloads||DOWNLOADS;
|
|
862
|
+
count.textContent=INDEX.length+" sessions";drawList();
|
|
863
|
+
if(first!==null)pick(first);
|
|
864
|
+
if(problems.length)note(problems.join("\n"));
|
|
865
|
+
picker.value="";
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/* A passing message. It swallows its own failures on purpose: this is the last
|
|
869
|
+
thing that happens after real work, and a cosmetic toast must never be the
|
|
870
|
+
reason a handler aborts. */
|
|
871
|
+
function note(message){
|
|
872
|
+
try{
|
|
873
|
+
const el=document.createElement("div");
|
|
874
|
+
el.className="note";el.textContent=message;
|
|
875
|
+
document.body.appendChild(el);
|
|
876
|
+
setTimeout(()=>el.remove(),6000);
|
|
877
|
+
}catch(e){}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Dropping a file anywhere is the same action as choosing one. dragenter and
|
|
881
|
+
// dragleave fire for every element the pointer crosses, so count them instead
|
|
882
|
+
// of trying to detect the boundary from a single event.
|
|
883
|
+
let dragDepth=0;
|
|
884
|
+
const hasFiles=e=>[...(e.dataTransfer?.types||[])].includes("Files");
|
|
885
|
+
const showDrop=on=>{dragDepth=on?dragDepth:0;drop.classList.toggle("on",on)};
|
|
886
|
+
|
|
887
|
+
addEventListener("dragenter",e=>{
|
|
888
|
+
if(!hasFiles(e))return;
|
|
889
|
+
e.preventDefault();dragDepth++;drop.classList.add("on");
|
|
890
|
+
});
|
|
891
|
+
addEventListener("dragover",e=>{if(hasFiles(e))e.preventDefault()});
|
|
892
|
+
addEventListener("dragleave",e=>{
|
|
893
|
+
if(!hasFiles(e))return;
|
|
894
|
+
dragDepth=Math.max(0,dragDepth-1);
|
|
895
|
+
if(!dragDepth)showDrop(false);
|
|
896
|
+
});
|
|
897
|
+
addEventListener("drop",e=>{
|
|
898
|
+
e.preventDefault();showDrop(false);
|
|
899
|
+
if(e.dataTransfer?.files?.length)openFiles(e.dataTransfer.files);
|
|
900
|
+
});
|
|
901
|
+
// A drag that ends outside the window never fires drop; do not strand the overlay.
|
|
902
|
+
addEventListener("dragend",()=>showDrop(false));
|
|
903
|
+
addEventListener("mouseout",e=>{if(!e.relatedTarget&&dragDepth)showDrop(false)});
|
|
904
|
+
|
|
905
|
+
const THEMES=["paper","cool","dark"];
|
|
906
|
+
const THEME_LABEL={paper:"PAPER",cool:"COOL",dark:"DARK"};
|
|
907
|
+
|
|
908
|
+
function setTheme(name){
|
|
909
|
+
document.documentElement.setAttribute("data-theme",name);
|
|
910
|
+
try{localStorage.setItem("transcript-viewer.theme",name)}catch(e){}
|
|
911
|
+
if(typeof theme!=="undefined"&&theme)theme.textContent=THEME_LABEL[name]||name;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function cycleTheme(){
|
|
915
|
+
const now=document.documentElement.getAttribute("data-theme")||"paper";
|
|
916
|
+
setTheme(THEMES[(THEMES.indexOf(now)+1)%THEMES.length]);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// Restore the chosen theme before anything paints, so there is no flash.
|
|
920
|
+
try{setTheme(localStorage.getItem("transcript-viewer.theme")||"paper")}catch(e){setTheme("paper")}
|
|
921
|
+
|
|
922
|
+
function toggleSide(){
|
|
923
|
+
const hidden=document.body.classList.toggle("hide-side");
|
|
924
|
+
toggle.innerHTML=hidden?"›":"‹";
|
|
925
|
+
try{localStorage.setItem("transcript-viewer.side",hidden?"1":"0")}catch(e){}
|
|
926
|
+
}
|
|
927
|
+
// Restore the last choice; localStorage can throw in restricted contexts.
|
|
928
|
+
try{if(localStorage.getItem("transcript-viewer.side")==="1")toggleSide()}catch(e){}
|
|
929
|
+
|
|
930
|
+
addEventListener("keydown",e=>{
|
|
931
|
+
// Ignore the shortcut while typing in the filter box.
|
|
932
|
+
if(e.key==="\\"&&e.target.tagName!=="INPUT"){e.preventDefault();toggleSide()}
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
fetch("/api/index").then(r=>r.json()).then(d=>{
|
|
936
|
+
INDEX=d.sessions||[];GROUPS=d.groups||[];TAGS=d.tags||[];AI=d.ai||{};DOWNLOADS=d.downloads||DOWNLOADS;
|
|
937
|
+
count.textContent=INDEX.length+" sessions";drawList();showLibrary();
|
|
938
|
+
});
|
|
939
|
+
q.oninput=drawList;
|
|
940
|
+
|
|
941
|
+
/* Diwan's collectionTree builds a forest carrying depth, then flattens it
|
|
942
|
+
against an expanded set. Same shape here, over "a/b/c" folder strings. */
|
|
943
|
+
function buildForest(folders){
|
|
944
|
+
const nodes=folders.map(path=>({path,depth:path.split("/").length-1,
|
|
945
|
+
name:path.split("/").pop()}));
|
|
946
|
+
return nodes.sort((a,b)=>a.path.localeCompare(b.path));
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function visibleRails(){
|
|
950
|
+
const counts={};
|
|
951
|
+
for(const e of INDEX){
|
|
952
|
+
// A session under Local/Codex/x counts toward Local/Codex and Local too,
|
|
953
|
+
// as Diwan rolls up. A row without a group is not a crash.
|
|
954
|
+
if(!e.group)continue;
|
|
955
|
+
const parts=e.group.split("/");
|
|
956
|
+
for(let i=1;i<=parts.length;i++){
|
|
957
|
+
const p=parts.slice(0,i).join("/");
|
|
958
|
+
counts[p]=(counts[p]||0)+1;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
// Every ancestor must be open, not just the one directly above: checking a
|
|
962
|
+
// single parent left a collapsed node's grandchildren on screen, because
|
|
963
|
+
// their own parent was still marked expanded from an earlier click.
|
|
964
|
+
const shown=path=>{
|
|
965
|
+
const parts=path.split("/");
|
|
966
|
+
for(let i=1;i<parts.length;i++){
|
|
967
|
+
if(!EXPANDED[parts.slice(0,i).join("/")])return false;
|
|
968
|
+
}
|
|
969
|
+
return true;
|
|
970
|
+
};
|
|
971
|
+
|
|
972
|
+
const rows=[{path:"__all",name:"All",depth:0,count:INDEX.length}];
|
|
973
|
+
// Local is shown even when empty: it is where looking on this machine lives,
|
|
974
|
+
// and a first run would otherwise have nothing to press.
|
|
975
|
+
if(!GROUPS.includes("Local")){
|
|
976
|
+
rows.push({path:"Local",name:"Local",depth:0,count:0,children:false,source:""});
|
|
977
|
+
}
|
|
978
|
+
for(const n of buildForest(GROUPS)){
|
|
979
|
+
if(!shown(n.path))continue;
|
|
980
|
+
rows.push({...n,count:counts[n.path]||0,
|
|
981
|
+
children:GROUPS.some(f=>f.startsWith(n.path+"/")),
|
|
982
|
+
source:sourceOf(n.path)});
|
|
983
|
+
}
|
|
984
|
+
return rows;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/* Where a node came from, if it mirrors a repository folder. Any session under
|
|
988
|
+
it knows; they all point at the same place. */
|
|
989
|
+
function sourceOf(path){
|
|
990
|
+
const member=INDEX.find(e=>e.source&&(e.group===path
|
|
991
|
+
||(e.group||"").startsWith(path+"/")));
|
|
992
|
+
if(!member)return "";
|
|
993
|
+
// A nested collection mirrors a nested folder, so trim back to this level.
|
|
994
|
+
const depth=(member.collection||"").split("/").length-path.split("/").length;
|
|
995
|
+
return depth>0
|
|
996
|
+
? member.source.split("/").slice(0,-depth).join("/")
|
|
997
|
+
: member.source;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function drawList(){
|
|
1001
|
+
const rows=visibleRails();
|
|
1002
|
+
list.innerHTML=(INDEX.length?`<div class="railtop">
|
|
1003
|
+
<button onclick="clearLibrary()"
|
|
1004
|
+
title="Forget every session. Files stay where they are.">Clear library</button>
|
|
1005
|
+
</div>`:"")+rows.map(r=>{
|
|
1006
|
+
const on=collection===r.path;
|
|
1007
|
+
const caret=r.children
|
|
1008
|
+
? `<span class="caret ${EXPANDED[r.path]?"open":""}"
|
|
1009
|
+
onclick="event.stopPropagation();toggleFolder('${esc(r.path)}')">
|
|
1010
|
+
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
1011
|
+
stroke-width="3" stroke-linecap="round"><path d="m9 18 6-6-6-6"/></svg></span>`
|
|
1012
|
+
: `<span class="caret"></span>`;
|
|
1013
|
+
return `<div class="rail${on?" on":""}" onclick="setCollection('${esc(r.path)}')"
|
|
1014
|
+
style="padding-left:${10+r.depth*13}px">
|
|
1015
|
+
${caret}<span class="rl">${esc(r.name)}</span>
|
|
1016
|
+
${r.path.startsWith("__")?"":`<span class="racts">
|
|
1017
|
+
${r.source&&!r.source.startsWith("s3://")?`<a class="rsrc"
|
|
1018
|
+
href="${esc(r.source)}" target="_blank" rel="noreferrer noopener"
|
|
1019
|
+
title="Open ${esc(r.source)}"
|
|
1020
|
+
onclick="event.stopPropagation()">↗</a>`:""}
|
|
1021
|
+
${r.path==="Local"?`<button title="Look on this machine again"
|
|
1022
|
+
onclick="event.stopPropagation();scanMachine()">⟳</button>`
|
|
1023
|
+
:r.source?`<button title="Fetch this folder again"
|
|
1024
|
+
onclick="event.stopPropagation();refreshGroup('${esc(r.path)}')">⟳</button>`:""}
|
|
1025
|
+
<button title="Remove these sessions from the library" class="dang"
|
|
1026
|
+
onclick="event.stopPropagation();dropGroup('${esc(r.path)}')">✕</button>
|
|
1027
|
+
</span>`}
|
|
1028
|
+
<span class="rc">${r.count}</span></div>`;
|
|
1029
|
+
}).join("");
|
|
1030
|
+
|
|
1031
|
+
tagbar.innerHTML=TAGS.map(t=>{
|
|
1032
|
+
const on=!!ACTIVE_TAGS[t.name];
|
|
1033
|
+
return `<span class="pill tag${on?" on":""}" onclick="toggleTag('${esc(t.name)}')">
|
|
1034
|
+
${esc(t.name)}<b>${t.count}</b></span>`;
|
|
1035
|
+
}).join("");
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
const setCollection=p=>{collection=p;cur=null;drawList();showLibrary()};
|
|
1039
|
+
|
|
1040
|
+
/* Forget everything. Not "delete everything": the files are mostly not the
|
|
1041
|
+
viewer's, and it says so before doing it. */
|
|
1042
|
+
async function clearLibrary(){
|
|
1043
|
+
const owned=INDEX.filter(e=>e.origin==="opened").length;
|
|
1044
|
+
if(!confirm(`Forget all ${num(INDEX.length)} session${INDEX.length===1?"":"s"}?\n\n`
|
|
1045
|
+
+`Files stay where they are`
|
|
1046
|
+
+(owned?`, except ${num(owned)} copy${owned===1?"":"ies"} the viewer made itself, `
|
|
1047
|
+
+`which will be deleted.`:".")
|
|
1048
|
+
+`\n\nTitles, tags, stars and any cached AI summaries go with them.`))return;
|
|
1049
|
+
try{
|
|
1050
|
+
const res=await fetch("/api/library?all=1",{method:"DELETE"});
|
|
1051
|
+
if(!res.ok)throw new Error("could not clear the library");
|
|
1052
|
+
cur=null;collection="__all";
|
|
1053
|
+
await refreshIndex();
|
|
1054
|
+
showLibrary();
|
|
1055
|
+
}catch(e){note(e.message)}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/* Removing says what it will and will not touch. Only a copy the viewer made
|
|
1059
|
+
itself is deleted; a session found on this machine, or downloaded to a folder
|
|
1060
|
+
of yours, keeps its file. */
|
|
1061
|
+
async function forget(key){
|
|
1062
|
+
const row=INDEX.find(e=>e.key===key)||{};
|
|
1063
|
+
const ours=row.origin==="opened";
|
|
1064
|
+
const what=titleOf(row);
|
|
1065
|
+
if(!confirm(ours
|
|
1066
|
+
? `Remove "${what}" and delete the copy the viewer made?`
|
|
1067
|
+
: `Remove "${what}" from the library? The file stays where it is.`))return;
|
|
1068
|
+
try{
|
|
1069
|
+
const res=await fetch(`/api/library?id=${encodeURIComponent(key)}`,{method:"DELETE"});
|
|
1070
|
+
if(!res.ok)throw new Error("could not remove that");
|
|
1071
|
+
if(cur===key)cur=null;
|
|
1072
|
+
await refreshIndex();
|
|
1073
|
+
showLibrary();
|
|
1074
|
+
}catch(e){note(e.message)}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/* Fetch a remote folder again. Everything needed is already recorded, so this
|
|
1078
|
+
asks no questions: same source, same destination, and anything unchanged
|
|
1079
|
+
hashes to what is already there. */
|
|
1080
|
+
async function refreshGroup(name){
|
|
1081
|
+
try{
|
|
1082
|
+
note(`Refreshing ${name}…`);
|
|
1083
|
+
let added=0;
|
|
1084
|
+
await streamJSON("/api/refetch",{node:name},frame=>{
|
|
1085
|
+
if(frame.t==="file")showProgress(frame.done,frame.total,frame.name);
|
|
1086
|
+
else if(frame.t==="added")added=frame.added;
|
|
1087
|
+
});
|
|
1088
|
+
showProgress(0,0);
|
|
1089
|
+
const before=INDEX.length;
|
|
1090
|
+
await refreshIndex();
|
|
1091
|
+
const fresh=INDEX.length-before;
|
|
1092
|
+
note(fresh?`${num(fresh)} new session${fresh===1?"":"s"} in ${name}.`
|
|
1093
|
+
:`${name} is up to date.`);
|
|
1094
|
+
}catch(e){showProgress(0,0);note(e.message)}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
async function dropGroup(name){
|
|
1098
|
+
const inside=INDEX.filter(e=>e.group===name||(e.group||"").startsWith(name+"/")).length;
|
|
1099
|
+
if(!confirm(`Remove ${inside} session${inside===1?"":"s"} under "${name}" from the `
|
|
1100
|
+
+`library? Files stay where they are, unless the viewer made the copy.`))return;
|
|
1101
|
+
try{
|
|
1102
|
+
const res=await fetch(`/api/group?name=${encodeURIComponent(name)}`,{method:"DELETE"});
|
|
1103
|
+
if(!res.ok)throw new Error("could not remove those");
|
|
1104
|
+
if(collection===name||collection.startsWith(name+"/"))collection="__all";
|
|
1105
|
+
await refreshIndex();
|
|
1106
|
+
cur=null;showLibrary();
|
|
1107
|
+
}catch(e){note(e.message)}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/* Where a session came from. Three answers now that a URL download is not the
|
|
1111
|
+
same act as opening a file off this machine. */
|
|
1112
|
+
const ORIGINS=[
|
|
1113
|
+
{key:"scanned",label:"indexed",mark:"◆",hint:"Found on this machine"},
|
|
1114
|
+
{key:"opened", label:"local", mark:"↥",hint:"Opened from a file on this machine"},
|
|
1115
|
+
{key:"fetched",label:"url", mark:"↧",hint:"Fetched from a URL"},
|
|
1116
|
+
];
|
|
1117
|
+
const originOf=key=>ORIGINS.find(o=>o.key===key)||ORIGINS[0];
|
|
1118
|
+
|
|
1119
|
+
/* A tree path runs seven levels deep, and "a/b/c/d" with nothing between the
|
|
1120
|
+
parts is a wall rather than a trail. Each part is spaced and the separator is
|
|
1121
|
+
faint, so the eye lands on the names. */
|
|
1122
|
+
const crumbPath=path=>path.split("/").filter(Boolean)
|
|
1123
|
+
.map(part=>`<span>${esc(part)}</span>`).join(`<i class="sep">›</i>`);
|
|
1124
|
+
|
|
1125
|
+
/* The header and the library heading answer the same question, and were
|
|
1126
|
+
answering it differently. */
|
|
1127
|
+
const whereYouAre=()=>collection==="__all"
|
|
1128
|
+
? `<span>All sessions</span>`
|
|
1129
|
+
: crumbPath(collection);
|
|
1130
|
+
|
|
1131
|
+
function drawCrumb(){
|
|
1132
|
+
if(!crumb)return;
|
|
1133
|
+
crumb.innerHTML=cur
|
|
1134
|
+
? `<span class="back" onclick="showLibrary()">${whereYouAre()}</span>
|
|
1135
|
+
<i class="sep">›</i><b>${esc(titleOf(INDEX.find(e=>e.key===cur)||{}))}</b>`
|
|
1136
|
+
: whereYouAre();
|
|
1137
|
+
}
|
|
1138
|
+
const toggleFolder=p=>{EXPANDED[p]=!EXPANDED[p];
|
|
1139
|
+
try{localStorage.setItem("transcript-viewer.folders",JSON.stringify(EXPANDED))}catch(e){}
|
|
1140
|
+
drawList()};
|
|
1141
|
+
const toggleTag=t=>{ACTIVE_TAGS[t]=!ACTIVE_TAGS[t];drawList();if(!cur)showLibrary()};
|
|
1142
|
+
function libraryRows(){
|
|
1143
|
+
const f=q.value.toLowerCase();
|
|
1144
|
+
const active=Object.keys(ACTIVE_TAGS).filter(t=>ACTIVE_TAGS[t]);
|
|
1145
|
+
return INDEX.filter(e=>{
|
|
1146
|
+
if(collection!=="__all"
|
|
1147
|
+
&&!(e.group===collection||e.group.startsWith(collection+"/")))return false;
|
|
1148
|
+
if(active.length&&!active.every(t=>(e.tags||[]).includes(t)))return false;
|
|
1149
|
+
if(f){
|
|
1150
|
+
const hay=(e.title||"")+" "+(e.project||"")+" "+(e.session_id||"")+" "
|
|
1151
|
+
+e.agent+" "+(e.tags||[]).join(" ");
|
|
1152
|
+
if(!hay.toLowerCase().includes(f))return false;
|
|
1153
|
+
}
|
|
1154
|
+
return true;
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/* What to call a session. What the reader named it, then what the agent named
|
|
1159
|
+
it — Claude Code writes an "ai-title" and revises it as the work turns — and
|
|
1160
|
+
only then the working directory, which is where a name used to come from and
|
|
1161
|
+
is why a session read as a mangled folder path. The tree still groups by that
|
|
1162
|
+
directory; a name is not a place. */
|
|
1163
|
+
const titleOf=e=>e.title||e.session_title||short(e.project||e.session_id||e.path);
|
|
1164
|
+
|
|
1165
|
+
/* Diwan shares one grid template between the header and every row so the
|
|
1166
|
+
columns line up; the same trick, with the columns this corpus has. */
|
|
1167
|
+
function showLibrary(){
|
|
1168
|
+
if(!INDEX.length&&!q.value){
|
|
1169
|
+
main.innerHTML=`<div class="blank">
|
|
1170
|
+
<h2>Nothing here yet</h2>
|
|
1171
|
+
<p>Look for sessions this machine has already written, or add transcripts
|
|
1172
|
+
from a file or a URL.</p>
|
|
1173
|
+
<button class="primary" onclick="scanMachine()">Look on this machine</button>
|
|
1174
|
+
<button onclick="openAdd()">Add…</button>
|
|
1175
|
+
</div>`;
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
cur=null;drawCrumb();
|
|
1179
|
+
const rows=libraryRows();
|
|
1180
|
+
main.innerHTML=`
|
|
1181
|
+
<div class="lhead">
|
|
1182
|
+
<h2>${whereYouAre()}</h2>
|
|
1183
|
+
<span class="mono">${num(rows.length)} of ${num(INDEX.length)}</span>
|
|
1184
|
+
</div>
|
|
1185
|
+
<div class="tgrid thead">
|
|
1186
|
+
<span></span><span>Title</span><span>Agent</span>
|
|
1187
|
+
<span>Modified</span><span>Size</span><span>Tags</span><span></span>
|
|
1188
|
+
</div>
|
|
1189
|
+
<div id="tbody">${rows.map(libraryRow).join("")
|
|
1190
|
+
||`<div class="empty">Nothing here yet.</div>`}</div>`;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function libraryRow(e){
|
|
1194
|
+
const editing=EDITING&&EDITING.key===e.key?EDITING.field:null;
|
|
1195
|
+
const title=editing==="title"
|
|
1196
|
+
? `<input class="inline" value="${esc(e.title||titleOf(e))}" autofocus
|
|
1197
|
+
onkeydown="titleKey(event,'${e.key}')" onblur="stopEdit()">`
|
|
1198
|
+
: `<span class="tt" onclick="titleClick(event,'${e.key}')"
|
|
1199
|
+
ondblclick="startEdit('${e.key}','title')">${esc(titleOf(e))}</span>`;
|
|
1200
|
+
return `<div class="tgrid trow" onclick="openRow(event,'${e.key}')">
|
|
1201
|
+
<span class="marks">
|
|
1202
|
+
${e.starred?`<svg class="star" width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>`:""}
|
|
1203
|
+
<span class="opened" title="${originOf(e.origin).hint}">${originOf(e.origin).mark}</span>
|
|
1204
|
+
</span>
|
|
1205
|
+
<span class="tcell">${title}</span>
|
|
1206
|
+
<span class="mono">${esc(e.agent)}</span>
|
|
1207
|
+
<span class="mono">${e.modified?e.modified.slice(0,10):"—"}</span>
|
|
1208
|
+
<span class="mono">${bytes(e.size_bytes)}</span>
|
|
1209
|
+
<span class="tcell tags">${(e.tags||[]).map(t=>`<span class="pill tag">${esc(t)}</span>`).join("")}
|
|
1210
|
+
${editing==="tags"?`<input class="inline tagin" placeholder="add tag"
|
|
1211
|
+
onkeydown="tagKey(event,'${e.key}')" onblur="stopEdit()" autofocus>`:""}</span>
|
|
1212
|
+
<span class="acts">
|
|
1213
|
+
<button title="Rename" onclick="event.stopPropagation();startEdit('${e.key}','title')">✎</button>
|
|
1214
|
+
<button title="Tags" onclick="event.stopPropagation();startEdit('${e.key}','tags')">◌</button>
|
|
1215
|
+
<button title="Star" onclick="event.stopPropagation();star('${e.key}')">★</button>
|
|
1216
|
+
<button title="Remove from the library" class="dang"
|
|
1217
|
+
onclick="event.stopPropagation();forget('${e.key}')">✕</button>
|
|
1218
|
+
</span></div>`;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function openRow(event,key){
|
|
1222
|
+
if(event.target.closest("input,button"))return;
|
|
1223
|
+
pick(key);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/* Diwan's LibraryRow pattern: a single click on the title opens after a beat,
|
|
1227
|
+
and a second click inside that beat cancels it and renames instead. Without
|
|
1228
|
+
this the title either swallowed the click or opened on the way to renaming. */
|
|
1229
|
+
let CLICK_TIMER=null;
|
|
1230
|
+
function titleClick(event,key){
|
|
1231
|
+
event.stopPropagation();
|
|
1232
|
+
if(CLICK_TIMER){clearTimeout(CLICK_TIMER);CLICK_TIMER=null;return}
|
|
1233
|
+
CLICK_TIMER=setTimeout(()=>{CLICK_TIMER=null;pick(key)},220);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// ---- annotation -------------------------------------------------------------
|
|
1237
|
+
/* AI controls appear only when a credential is configured AND this transcript
|
|
1238
|
+
has not been switched off. The server enforces the same rule. */
|
|
1239
|
+
const aiOn=()=>{
|
|
1240
|
+
if(!AI.available)return false;
|
|
1241
|
+
const rec=INDEX.find(x=>x.key===cur);
|
|
1242
|
+
return !rec||rec.ai!==false;
|
|
1243
|
+
};
|
|
1244
|
+
|
|
1245
|
+
function openSettings(){
|
|
1246
|
+
const err=document.getElementById("keyerr");
|
|
1247
|
+
if(err){err.hidden=true;err.textContent=""}
|
|
1248
|
+
drawSecrets();
|
|
1249
|
+
modal.hidden=false;
|
|
1250
|
+
const first=document.querySelector(".secret input");
|
|
1251
|
+
if(first)first.focus();
|
|
1252
|
+
}
|
|
1253
|
+
const closeSettings=()=>{modal.hidden=true};
|
|
1254
|
+
|
|
1255
|
+
/* One field per credential, built from what the server says it can hold, so a
|
|
1256
|
+
new one needs no markup here. */
|
|
1257
|
+
function drawSecrets(){
|
|
1258
|
+
const host=document.getElementById("secrets");
|
|
1259
|
+
if(!host)return;
|
|
1260
|
+
const all=AI.secrets||{};
|
|
1261
|
+
host.innerHTML=Object.keys(all).map(name=>{
|
|
1262
|
+
const s=all[name];
|
|
1263
|
+
// Dots as a placeholder, never a value: a value can be submitted, and
|
|
1264
|
+
// bullets would pass the length check and be stored as the token.
|
|
1265
|
+
const shown=s.source==="settings"?`${"•".repeat(24)} ${s.hint}`
|
|
1266
|
+
:s.source==="environment"?`Set by ${s.env}`
|
|
1267
|
+
:s.placeholder;
|
|
1268
|
+
return `<div class="secret">
|
|
1269
|
+
<label for="sec-${name}">${esc(s.label)}</label>
|
|
1270
|
+
<input id="sec-${name}" type="password" spellcheck="false" autocomplete="off"
|
|
1271
|
+
placeholder="${esc(shown)}" onkeydown="if(event.key==='Enter')saveSecret('${name}')">
|
|
1272
|
+
<div class="row">
|
|
1273
|
+
<button class="primary" onclick="saveSecret('${name}')">Save</button>
|
|
1274
|
+
<button onclick="clearSecret('${name}')">Remove</button>
|
|
1275
|
+
</div>
|
|
1276
|
+
</div>`;
|
|
1277
|
+
}).join("")+awsField();
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/* Only worth asking when there is a choice: with one profile configured there
|
|
1281
|
+
is nothing to disambiguate, and the CLI is asked for the list rather than the
|
|
1282
|
+
reader. */
|
|
1283
|
+
function awsField(){
|
|
1284
|
+
const known=AI.aws_profiles||[];
|
|
1285
|
+
const chosen=AI.aws_profile||"";
|
|
1286
|
+
const choice=known.length>1
|
|
1287
|
+
? `<select id="awsprofile" onchange="saveProfile()">
|
|
1288
|
+
<option value=""${chosen?"":" selected"}>Choose…</option>
|
|
1289
|
+
${known.map(p=>`<option value="${esc(p)}"${p===chosen?" selected":""}>${esc(p)}</option>`).join("")}
|
|
1290
|
+
</select>`
|
|
1291
|
+
: `<input id="awsprofile" spellcheck="false" autocomplete="off"
|
|
1292
|
+
placeholder="${known.length?esc(known[0])+" (the only one)":"none configured"}"
|
|
1293
|
+
value="${esc(chosen)}" onkeydown="if(event.key==='Enter')saveProfile()">`;
|
|
1294
|
+
return `<div class="secret">
|
|
1295
|
+
<label for="awsprofile">AWS profile</label>
|
|
1296
|
+
${choice}
|
|
1297
|
+
<p class="fine">A name, not a credential — S3 is read by running the aws CLI,
|
|
1298
|
+
which holds the session. Sign in with
|
|
1299
|
+
<code>aws sso login --profile <name></code> in a terminal.
|
|
1300
|
+
${known.length>1
|
|
1301
|
+
? "This machine has several profiles, so pick the one to use."
|
|
1302
|
+
: "With one profile configured there is nothing to set here."}</p>
|
|
1303
|
+
${known.length>1?"":`<div class="row">
|
|
1304
|
+
<button class="primary" onclick="saveProfile()">Save</button></div>`}
|
|
1305
|
+
</div>`;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
const saveProfile=()=>settings({
|
|
1309
|
+
aws_profile:(document.getElementById("awsprofile")||{}).value||""});
|
|
1310
|
+
|
|
1311
|
+
async function settings(body){
|
|
1312
|
+
const err=document.getElementById("keyerr");
|
|
1313
|
+
err.hidden=true;
|
|
1314
|
+
try{
|
|
1315
|
+
AI=await postJSON("/api/settings",body);
|
|
1316
|
+
drawSecrets();
|
|
1317
|
+
render();
|
|
1318
|
+
}catch(e){err.textContent=e.message;err.hidden=false}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
const saveSecret=name=>{
|
|
1322
|
+
const value=(document.getElementById("sec-"+name)||{}).value||"";
|
|
1323
|
+
if(value.trim())settings({name,token:value.trim()});
|
|
1324
|
+
};
|
|
1325
|
+
const clearSecret=name=>settings({name,clear:true});
|
|
1326
|
+
|
|
1327
|
+
/* Fetching by URL: look first, then confirm. A dataset URL can name hundreds of
|
|
1328
|
+
files, and nobody should find that out by pressing a button once. */
|
|
1329
|
+
let PLANNED=null;
|
|
1330
|
+
|
|
1331
|
+
const EXAMPLES=[
|
|
1332
|
+
{name:"SLEIGHT-Bench",
|
|
1333
|
+
url:"https://huggingface.co/datasets/sleightbench/SLEIGHT-Bench/tree/main/attacks"},
|
|
1334
|
+
{name:"Redwood agent transcripts", url:"s3://rr-agent-transcripts"},
|
|
1335
|
+
];
|
|
1336
|
+
|
|
1337
|
+
function drawExamples(){
|
|
1338
|
+
const host=document.getElementById("egs");
|
|
1339
|
+
if(!host)return;
|
|
1340
|
+
host.innerHTML=`<span>Try:</span>`+EXAMPLES.map((e,i)=>
|
|
1341
|
+
`<button onclick="useExample(${i})" title="${esc(e.url)}">${esc(e.name)}</button>`
|
|
1342
|
+
).join("");
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
function useExample(i){
|
|
1346
|
+
const box=document.getElementById("urlin");
|
|
1347
|
+
if(!box)return;
|
|
1348
|
+
box.value=EXAMPLES[i].url;
|
|
1349
|
+
PLANNED=null;
|
|
1350
|
+
planUrl();
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/* What a browsed location holds, filled in a level at a time, and what has been
|
|
1354
|
+
ticked. Paths are as the remote spells them, so they can go straight back. */
|
|
1355
|
+
let TREE={url:"",open:{},nodes:{},picked:new Set(),busy:new Set(),
|
|
1356
|
+
sized:{},sizing:0};
|
|
1357
|
+
|
|
1358
|
+
const isPicked=path=>TREE.picked.has(path)
|
|
1359
|
+
||[...TREE.picked].some(p=>path.startsWith(p+"/"));
|
|
1360
|
+
|
|
1361
|
+
async function openNode(path){
|
|
1362
|
+
if(TREE.nodes[path]||TREE.busy.has(path))return;
|
|
1363
|
+
TREE.busy.add(path);
|
|
1364
|
+
drawTree();
|
|
1365
|
+
try{
|
|
1366
|
+
const {nodes}=await postJSON("/api/browse",{url:TREE.url,path});
|
|
1367
|
+
TREE.nodes[path]=nodes;
|
|
1368
|
+
}catch(e){
|
|
1369
|
+
TREE.nodes[path]=[];
|
|
1370
|
+
note(e.message);
|
|
1371
|
+
}finally{
|
|
1372
|
+
TREE.busy.delete(path);
|
|
1373
|
+
drawTree();
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
function toggleNode(path){
|
|
1378
|
+
if(TREE.open[path]){delete TREE.open[path];drawTree();return}
|
|
1379
|
+
TREE.open[path]=true;
|
|
1380
|
+
openNode(path);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
function pickNode(path){
|
|
1384
|
+
if(TREE.picked.has(path))TREE.picked.delete(path);
|
|
1385
|
+
else{
|
|
1386
|
+
// Ticking a folder covers what is inside it, so drop the redundant ticks.
|
|
1387
|
+
for(const p of [...TREE.picked])if(p.startsWith(path+"/"))TREE.picked.delete(p);
|
|
1388
|
+
TREE.picked.add(path);
|
|
1389
|
+
}
|
|
1390
|
+
drawTree();
|
|
1391
|
+
measure();
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/* Ask the server how much the selection actually is. A tick on a folder is a
|
|
1395
|
+
promise about its contents, and "1+ files" is not an answer to "how much am I
|
|
1396
|
+
getting?" — so it is listed rather than guessed. Cached per path, since a
|
|
1397
|
+
folder's contents do not change while the dialog is open. */
|
|
1398
|
+
async function measure(){
|
|
1399
|
+
const wanted=[...TREE.picked];
|
|
1400
|
+
const missing=wanted.filter(p=>TREE.sized[p]===undefined);
|
|
1401
|
+
if(!missing.length){drawTotal();return}
|
|
1402
|
+
TREE.sizing++;
|
|
1403
|
+
drawTotal();
|
|
1404
|
+
try{
|
|
1405
|
+
for(const path of missing){
|
|
1406
|
+
const got=await postJSON("/api/measure",{url:TREE.url,paths:[path]});
|
|
1407
|
+
TREE.sized[path]=got;
|
|
1408
|
+
}
|
|
1409
|
+
}catch(e){
|
|
1410
|
+
for(const path of missing)TREE.sized[path]={files:0,bytes:0,error:true};
|
|
1411
|
+
}finally{
|
|
1412
|
+
TREE.sizing--;
|
|
1413
|
+
drawTotal();
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
function total(){
|
|
1418
|
+
let files=0,bytes=0,capped=false;
|
|
1419
|
+
for(const path of TREE.picked){
|
|
1420
|
+
const got=TREE.sized[path];
|
|
1421
|
+
if(!got)continue;
|
|
1422
|
+
files+=got.files;bytes+=got.bytes;capped=capped||!!got.capped;
|
|
1423
|
+
}
|
|
1424
|
+
return {files,bytes,capped};
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
function drawTotal(){
|
|
1428
|
+
const {files,bytes:size,capped}=total();
|
|
1429
|
+
const counting=TREE.sizing>0;
|
|
1430
|
+
const label=files?`Download ${num(files)}`:"Download";
|
|
1431
|
+
setUrlState(
|
|
1432
|
+
!TREE.picked.size?"Tick what you want."
|
|
1433
|
+
:counting?"Working out how much that is…"
|
|
1434
|
+
:`${num(files)} file${files===1?"":"s"} · ${bytes(size)}${
|
|
1435
|
+
capped?" (more than the limit — narrow it down)":""}`,
|
|
1436
|
+
label);
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/* Ticking a folder already covers everything under it — isPicked walks up the
|
|
1440
|
+
path — so these only need to reach the top level. */
|
|
1441
|
+
const pickAll=()=>{
|
|
1442
|
+
TREE.picked=new Set((TREE.nodes[""]||[]).map(n=>n.path));
|
|
1443
|
+
drawTree();measure();
|
|
1444
|
+
};
|
|
1445
|
+
const pickNone=()=>{TREE.picked=new Set();drawTree();drawTotal()};
|
|
1446
|
+
|
|
1447
|
+
/* Opening everything is one request per folder, so it is asked for rather than
|
|
1448
|
+
assumed. The measured total does not need it — that is listed server-side —
|
|
1449
|
+
but seeing the shape of a place sometimes does. */
|
|
1450
|
+
async function expandAll(){
|
|
1451
|
+
for(let depth=0;depth<6;depth++){
|
|
1452
|
+
const shut=[];
|
|
1453
|
+
const walk=path=>{
|
|
1454
|
+
for(const n of TREE.nodes[path]||[]){
|
|
1455
|
+
if(n.kind!=="folder")continue;
|
|
1456
|
+
if(TREE.nodes[n.path]===undefined)shut.push(n.path);
|
|
1457
|
+
else walk(n.path);
|
|
1458
|
+
}
|
|
1459
|
+
};
|
|
1460
|
+
walk("");
|
|
1461
|
+
if(!shut.length)break;
|
|
1462
|
+
for(const path of shut){TREE.open[path]=true;await openNode(path)}
|
|
1463
|
+
}
|
|
1464
|
+
drawTree();
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
function drawTree(){
|
|
1468
|
+
const host=document.getElementById("tree");
|
|
1469
|
+
if(!host)return;
|
|
1470
|
+
const rows=[];
|
|
1471
|
+
const walk=(path,depth)=>{
|
|
1472
|
+
for(const n of TREE.nodes[path]||[]){
|
|
1473
|
+
const open=!!TREE.open[n.path];
|
|
1474
|
+
const on=isPicked(n.path);
|
|
1475
|
+
rows.push(`<div class="pkrow" style="padding-left:${8+depth*14}px">
|
|
1476
|
+
<input type="checkbox" ${on?"checked":""}
|
|
1477
|
+
onchange="pickNode('${esc(n.path)}')">
|
|
1478
|
+
${n.kind==="folder"
|
|
1479
|
+
?`<span class="pkcar${open?" open":""}" onclick="toggleNode('${esc(n.path)}')">▸</span>`
|
|
1480
|
+
:`<span class="pkcar"></span>`}
|
|
1481
|
+
<span class="pkname"${n.kind==="folder"
|
|
1482
|
+
?` onclick="toggleNode('${esc(n.path)}')"`:""}>${esc(n.name)}</span>
|
|
1483
|
+
<span class="pksize">${n.size==null?"":bytes(n.size)}</span>
|
|
1484
|
+
</div>`);
|
|
1485
|
+
if(open)walk(n.path,depth+1);
|
|
1486
|
+
}
|
|
1487
|
+
if(TREE.busy.has(path))rows.push(
|
|
1488
|
+
`<div class="pkrow" style="padding-left:${depth*14}px"><span class="pkcar"></span>
|
|
1489
|
+
<span class="pkname fine">looking…</span></div>`);
|
|
1490
|
+
};
|
|
1491
|
+
walk("",0);
|
|
1492
|
+
host.hidden=false;
|
|
1493
|
+
const top=(TREE.nodes[""]||[]).length;
|
|
1494
|
+
host.innerHTML=(top?`<div class="pkbar">
|
|
1495
|
+
<span onclick="pickAll()">All</span>
|
|
1496
|
+
<span onclick="pickNone()">None</span>
|
|
1497
|
+
<span class="pkexp" onclick="expandAll()">Open all</span>
|
|
1498
|
+
</div>`:"")
|
|
1499
|
+
+(rows.join("")||`<div class="pkrow fine">Nothing here.</div>`);
|
|
1500
|
+
|
|
1501
|
+
drawTotal();
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
function openAdd(){
|
|
1505
|
+
PLANNED=null;
|
|
1506
|
+
const err=document.getElementById("urlerr");
|
|
1507
|
+
if(err){err.hidden=true;err.textContent=""}
|
|
1508
|
+
TREE={url:"",open:{},nodes:{},picked:new Set(),busy:new Set(),sized:{},sizing:0};
|
|
1509
|
+
const tree=document.getElementById("tree");
|
|
1510
|
+
if(tree){tree.hidden=true;tree.innerHTML=""}
|
|
1511
|
+
drawExamples();
|
|
1512
|
+
urlmodal.hidden=false;
|
|
1513
|
+
const box=document.getElementById("urlin");
|
|
1514
|
+
if(box){box.value="";box.focus()}
|
|
1515
|
+
setUrlState("Nothing downloads until you have seen what is there.","Look");
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
/* Looking on this machine is a deliberate act: it indexes every agent session
|
|
1519
|
+
here, which is not a thing to do because a library happens to be empty. It
|
|
1520
|
+
lives on the Local folder as a refresh, beside the one remote folders get. */
|
|
1521
|
+
async function scanMachine(){
|
|
1522
|
+
const before=INDEX.length;
|
|
1523
|
+
note("Looking on this machine…");
|
|
1524
|
+
try{
|
|
1525
|
+
await postJSON("/api/scan",{});
|
|
1526
|
+
await refreshIndex();
|
|
1527
|
+
if(!cur)showLibrary();
|
|
1528
|
+
const added=INDEX.length-before;
|
|
1529
|
+
note(added?`${num(added)} new session${added===1?"":"s"}.`:"Nothing new here.");
|
|
1530
|
+
}catch(e){note(e.message)}
|
|
1531
|
+
}
|
|
1532
|
+
const closeUrl=()=>{urlmodal.hidden=true;PLANNED=null};
|
|
1533
|
+
|
|
1534
|
+
function showProgress(done,total,name){
|
|
1535
|
+
const bar=document.getElementById("urlbar");
|
|
1536
|
+
const fill=document.getElementById("urlfill");
|
|
1537
|
+
if(!bar||!fill)return;
|
|
1538
|
+
if(!total){bar.hidden=true;return}
|
|
1539
|
+
bar.hidden=false;
|
|
1540
|
+
fill.style.width=`${Math.round(100*done/total)}%`;
|
|
1541
|
+
setUrlState(`${done} of ${total}${name?` · ${name}`:""}`,"Working");
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
function setUrlState(text,button){
|
|
1545
|
+
const state=document.getElementById("urlstate");
|
|
1546
|
+
if(state)state.textContent=text;
|
|
1547
|
+
const go=document.getElementById("urlgo");
|
|
1548
|
+
if(go)go.textContent=button;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
async function planUrl(){
|
|
1552
|
+
const err=document.getElementById("urlerr");
|
|
1553
|
+
err.hidden=true;
|
|
1554
|
+
const url=(document.getElementById("urlin").value||"").trim();
|
|
1555
|
+
if(!url)return;
|
|
1556
|
+
|
|
1557
|
+
if(PLANNED===url){
|
|
1558
|
+
let result=null;
|
|
1559
|
+
setUrlState("Starting…","Working");
|
|
1560
|
+
try{
|
|
1561
|
+
const paths=[...TREE.picked];
|
|
1562
|
+
await streamJSON("/api/fetch",{url,confirm:true,paths},frame=>{
|
|
1563
|
+
if(frame.t==="file")showProgress(frame.done,frame.total,frame.name);
|
|
1564
|
+
else if(frame.t==="added")result=frame;
|
|
1565
|
+
});
|
|
1566
|
+
}catch(e){
|
|
1567
|
+
err.textContent=e.message;err.hidden=false;
|
|
1568
|
+
showProgress(0,0);setUrlState("","Look");PLANNED=null;
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
// The index comes first: a failure anywhere after it must not cost the
|
|
1572
|
+
// reader the rows they just paid to download.
|
|
1573
|
+
await refreshIndex();
|
|
1574
|
+
cur=null;
|
|
1575
|
+
showLibrary();
|
|
1576
|
+
closeUrl();
|
|
1577
|
+
showProgress(0,0);
|
|
1578
|
+
if(result)note(`Added ${result.added} session${result.added===1?"":"s"} — ${result.into}`);
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
setUrlState("Looking…","Working");
|
|
1583
|
+
|
|
1584
|
+
// Browse first, not plan. Planning weighs the whole location against the
|
|
1585
|
+
// download limits, so a bucket of 118,801 objects was refused before the
|
|
1586
|
+
// picker — the thing that exists to make such a place usable — could open.
|
|
1587
|
+
try{
|
|
1588
|
+
TREE={url,open:{},nodes:{},picked:new Set(),busy:new Set(),sized:{},sizing:0};
|
|
1589
|
+
const {nodes}=await postJSON("/api/browse",{url,path:""});
|
|
1590
|
+
TREE.nodes[""]=nodes;
|
|
1591
|
+
PLANNED=url;
|
|
1592
|
+
drawTree();
|
|
1593
|
+
return;
|
|
1594
|
+
}catch(e){
|
|
1595
|
+
// Not somewhere with an inside — a link to one file. Fall through.
|
|
1596
|
+
TREE={url:"",open:{},nodes:{},picked:new Set(),busy:new Set(),sized:{},sizing:0};
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
try{
|
|
1600
|
+
const p=await postJSON("/api/fetch",{url});
|
|
1601
|
+
PLANNED=url;
|
|
1602
|
+
const size=p.bytes?` · ${bytes(p.bytes)}`:"";
|
|
1603
|
+
const sample=p.names.length?` — ${p.names.slice(0,3).join(", ")}${
|
|
1604
|
+
p.count>3?", …":""}`:"";
|
|
1605
|
+
setUrlState(`${p.count} file${p.count===1?"":"s"} in ${p.label}${size}${sample}\n`
|
|
1606
|
+
+`→ ${p.into}`, `Download ${p.count}`);
|
|
1607
|
+
}catch(e){
|
|
1608
|
+
err.textContent=e.message;err.hidden=false;
|
|
1609
|
+
setUrlState("","Look");PLANNED=null;
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
/* Nothing here runs on its own: every request below is the direct result of a
|
|
1614
|
+
click, so a transcript's contents never leave the machine unasked. */
|
|
1615
|
+
async function postJSON(url,body){
|
|
1616
|
+
const res=await fetch(url,{method:"POST",
|
|
1617
|
+
headers:{"Content-Type":"application/json"},body:JSON.stringify(body)});
|
|
1618
|
+
const data=await res.json().catch(()=>({error:"the server said nothing useful"}));
|
|
1619
|
+
if(!res.ok||data.error)throw new Error(data.error||`request failed (${res.status})`);
|
|
1620
|
+
return data;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
/* Read a streamed NDJSON response, calling back on every frame. Text arrives
|
|
1624
|
+
in pieces so an answer can be watched being written rather than waited for. */
|
|
1625
|
+
const streamClaude=(body,onFrame)=>streamJSON("/api/ai",{key:cur,...body},onFrame);
|
|
1626
|
+
|
|
1627
|
+
async function streamJSON(url,body,onFrame){
|
|
1628
|
+
const res=await fetch(url,{method:"POST",
|
|
1629
|
+
headers:{"Content-Type":"application/json"},
|
|
1630
|
+
body:JSON.stringify(body)});
|
|
1631
|
+
if(!res.ok){
|
|
1632
|
+
const data=await res.json().catch(()=>({}));
|
|
1633
|
+
throw new Error(data.error||`request failed (${res.status})`);
|
|
1634
|
+
}
|
|
1635
|
+
const reader=res.body.getReader(),decoder=new TextDecoder();
|
|
1636
|
+
let buffer="";
|
|
1637
|
+
const take=line=>{
|
|
1638
|
+
if(!line.trim())return;
|
|
1639
|
+
const frame=JSON.parse(line);
|
|
1640
|
+
if(frame.t==="error")throw new Error(frame.error);
|
|
1641
|
+
onFrame(frame);
|
|
1642
|
+
};
|
|
1643
|
+
for(;;){
|
|
1644
|
+
const {done,value}=await reader.read();
|
|
1645
|
+
if(done)break;
|
|
1646
|
+
buffer+=decoder.decode(value,{stream:true});
|
|
1647
|
+
const lines=buffer.split("\n");
|
|
1648
|
+
buffer=lines.pop();
|
|
1649
|
+
lines.forEach(take);
|
|
1650
|
+
}
|
|
1651
|
+
take(buffer);
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
const summaries = () => (INDEX.find(e=>e.key===cur)||{}).summaries||{};
|
|
1655
|
+
|
|
1656
|
+
/* A summary already paid for is simply shown — there is nothing left to
|
|
1657
|
+
explain, and making someone press a button to reveal text they already own
|
|
1658
|
+
is a click that buys nothing. Only a call without one offers the button. */
|
|
1659
|
+
function callSummary(id){
|
|
1660
|
+
const cached=(summaries()[id]||"").trim();
|
|
1661
|
+
if(!cached)return `<button class="aibtn" onclick="explainCall(event,'${id}')"
|
|
1662
|
+
title="Send this one call to Claude and explain it">explain this call</button>
|
|
1663
|
+
<div class="aiout" id="ai-${id}" hidden></div>`;
|
|
1664
|
+
return `<div class="aiout" id="ai-${id}"><span class="ail">summary</span>${md(cached)}</div>`;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
async function explainCall(event,callId){
|
|
1668
|
+
event.preventDefault();event.stopPropagation();
|
|
1669
|
+
const box=document.getElementById("ai-"+callId);
|
|
1670
|
+
if(!box)return;
|
|
1671
|
+
box.hidden=false;
|
|
1672
|
+
box.className="aiout busy";
|
|
1673
|
+
box.textContent="Reading the call and response…";
|
|
1674
|
+
let text="";
|
|
1675
|
+
try{
|
|
1676
|
+
await streamClaude({what:"call",call_id:callId},frame=>{
|
|
1677
|
+
if(frame.t!=="delta")return;
|
|
1678
|
+
text+=frame.text;
|
|
1679
|
+
box.className="aiout streaming";
|
|
1680
|
+
// Rendered on every frame so partial markdown still reads as prose.
|
|
1681
|
+
box.innerHTML=`<span class="ail">summary</span>${md(text)}`;
|
|
1682
|
+
});
|
|
1683
|
+
box.className="aiout";
|
|
1684
|
+
box.innerHTML=`<span class="ail">summary</span>${md(text)}`;
|
|
1685
|
+
// Remember it locally too, so a re-render shows it without asking again.
|
|
1686
|
+
const rec=INDEX.find(e=>e.key===cur);
|
|
1687
|
+
if(rec&&text.trim())rec.summaries={...(rec.summaries||{}),[callId]:text.trim()};
|
|
1688
|
+
}catch(err){
|
|
1689
|
+
box.className="aiout bad";
|
|
1690
|
+
box.textContent=err.message;
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
async function askSession(event){
|
|
1695
|
+
if(event.key!=="Enter")return;
|
|
1696
|
+
const box=event.target,question=box.value.trim();
|
|
1697
|
+
if(!question||CHAT.some(t=>t.busy))return;
|
|
1698
|
+
box.value="";
|
|
1699
|
+
|
|
1700
|
+
// Prior turns go up as context; the steps sent with them do not, since the
|
|
1701
|
+
// answers already carry what mattered.
|
|
1702
|
+
const history=CHAT.filter(t=>t.a&&!t.error).map(t=>({q:t.q,a:t.a}));
|
|
1703
|
+
// What the last answer read. A follow-up with nothing to match on — "why?" —
|
|
1704
|
+
// is almost always about those steps rather than an arbitrary slice.
|
|
1705
|
+
const previous=CHAT.filter(t=>t.steps&&t.steps.length).pop();
|
|
1706
|
+
const focus=previous?previous.steps:[];
|
|
1707
|
+
const turn={q:question,a:"",steps:[],busy:true,thinking:false};
|
|
1708
|
+
CHAT.push(turn);
|
|
1709
|
+
paintTurns();
|
|
1710
|
+
|
|
1711
|
+
try{
|
|
1712
|
+
await streamClaude({what:"ask",question,history,focus},frame=>{
|
|
1713
|
+
if(frame.t==="steps"){turn.steps=frame.steps;turn.total=frame.total}
|
|
1714
|
+
else if(frame.t==="thinking")turn.thinking=true;
|
|
1715
|
+
else if(frame.t==="delta"){turn.a+=frame.text;turn.thinking=false}
|
|
1716
|
+
paintTurns();
|
|
1717
|
+
});
|
|
1718
|
+
}catch(err){
|
|
1719
|
+
turn.error=err.message;
|
|
1720
|
+
}
|
|
1721
|
+
turn.busy=false;
|
|
1722
|
+
paintTurns();
|
|
1723
|
+
const again=document.querySelector(".askin");
|
|
1724
|
+
if(again)again.focus();
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
/* Repaint only the conversation. A full render() would rebuild the question
|
|
1728
|
+
field and steal focus on every token; the input deliberately sits outside
|
|
1729
|
+
the repainted region.
|
|
1730
|
+
|
|
1731
|
+
While text is streaming, only the last turn changes, so only that node is
|
|
1732
|
+
written — replacing the whole conversation would make the browser reparse
|
|
1733
|
+
every earlier answer on every token. Repaints are coalesced to one per
|
|
1734
|
+
animation frame, since tokens arrive faster than a screen refreshes. */
|
|
1735
|
+
const nextFrame=globalThis.requestAnimationFrame||(f=>setTimeout(f,16));
|
|
1736
|
+
let PAINTING=false;
|
|
1737
|
+
|
|
1738
|
+
function paintTurns(){
|
|
1739
|
+
if(PAINTING)return;
|
|
1740
|
+
PAINTING=true;
|
|
1741
|
+
nextFrame(()=>{PAINTING=false;drawTurns()});
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
function drawTurns(){
|
|
1745
|
+
const host=document.querySelector(".turns");
|
|
1746
|
+
if(!host){render();return}
|
|
1747
|
+
const nodes=host.querySelectorAll(".turn");
|
|
1748
|
+
const live=CHAT[CHAT.length-1];
|
|
1749
|
+
if(nodes.length===CHAT.length&&live){
|
|
1750
|
+
const body=nodes[nodes.length-1].querySelector(".askout");
|
|
1751
|
+
if(body){body.className=turnClass(live);body.innerHTML=turnBody(live);return}
|
|
1752
|
+
}
|
|
1753
|
+
host.innerHTML=turnsHTML();
|
|
1754
|
+
const last=host.querySelector(".turn:last-child");
|
|
1755
|
+
if(last&&CHAT.length>1)last.scrollIntoView({block:"nearest"});
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
async function annotate(key,fields){
|
|
1759
|
+
try{await postJSON("/api/library",{key,...fields})}
|
|
1760
|
+
catch(e){note("Could not save that change.");return}
|
|
1761
|
+
await refreshIndex();
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
async function refreshIndex(){
|
|
1765
|
+
const d=await fetch("/api/index").then(r=>r.json());
|
|
1766
|
+
INDEX=d.sessions||[];GROUPS=d.groups||[];TAGS=d.tags||[];AI=d.ai||{};DOWNLOADS=d.downloads||DOWNLOADS;
|
|
1767
|
+
count.textContent=INDEX.length+" sessions";
|
|
1768
|
+
drawList();
|
|
1769
|
+
if(!cur)showLibrary();
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
const startEdit=(key,field)=>{
|
|
1773
|
+
if(CLICK_TIMER){clearTimeout(CLICK_TIMER);CLICK_TIMER=null}
|
|
1774
|
+
EDITING={key,field};showLibrary();
|
|
1775
|
+
};
|
|
1776
|
+
const stopEdit=()=>{EDITING=null;showLibrary()};
|
|
1777
|
+
|
|
1778
|
+
function titleKey(event,key){
|
|
1779
|
+
if(event.key==="Escape")return stopEdit();
|
|
1780
|
+
if(event.key!=="Enter")return;
|
|
1781
|
+
const value=event.target.value.trim();
|
|
1782
|
+
EDITING=null;
|
|
1783
|
+
annotate(key,{title:value});
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
function tagKey(event,key){
|
|
1787
|
+
if(event.key==="Escape")return stopEdit();
|
|
1788
|
+
if(event.key!=="Enter")return;
|
|
1789
|
+
const entry=INDEX.find(e=>e.key===key);
|
|
1790
|
+
const value=event.target.value.trim().toLowerCase();
|
|
1791
|
+
if(!value)return stopEdit();
|
|
1792
|
+
EDITING=null;
|
|
1793
|
+
annotate(key,{tags:[...(entry.tags||[]),value]});
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
function star(key){
|
|
1797
|
+
const entry=INDEX.find(e=>e.key===key);
|
|
1798
|
+
annotate(key,{starred:!entry.starred});
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
function pick(key){
|
|
1802
|
+
cur=key;EDITING=null;drawList();drawCrumb();
|
|
1803
|
+
main.innerHTML=`<div class="empty">Converting…</div>`;
|
|
1804
|
+
fetch("/api/trajectory?id="+encodeURIComponent(key)).then(r=>r.json()).then(t=>{
|
|
1805
|
+
if(t.error){main.innerHTML=`<div class="empty">${esc(t.error)}</div>`;return}
|
|
1806
|
+
traj=t;limit=PAGE_SIZE;query="";lens="all";tab="trajectory";extra=null;RENAMING=false;render();
|
|
1807
|
+
});
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
const toggle=r=>{show[r]=!show[r];limit=PAGE_SIZE;render()};
|
|
1811
|
+
const more=()=>{limit+=PAGE_SIZE*4;render()};
|
|
1812
|
+
const toggleRaw=()=>{raw=!raw;render()};
|
|
1813
|
+
|
|
1814
|
+
/* Open or close every tool call and branch at once. Applied to the elements
|
|
1815
|
+
already on screen rather than by repainting: a repaint would drop the reader
|
|
1816
|
+
to the top, and the state is kept so a later filter change honours it. */
|
|
1817
|
+
function toggleExpand(){
|
|
1818
|
+
OPEN_ALL=!OPEN_ALL;
|
|
1819
|
+
main.querySelectorAll("details.tool,details.branch").forEach(d=>{d.open=OPEN_ALL});
|
|
1820
|
+
const chip=document.getElementById("xall");
|
|
1821
|
+
if(chip){chip.textContent=OPEN_ALL?"collapse all":"expand all";
|
|
1822
|
+
chip.classList.toggle("on",OPEN_ALL)}
|
|
1823
|
+
}
|
|
1824
|
+
const setLens=l=>{lens=l;limit=PAGE_SIZE;render()};
|
|
1825
|
+
|
|
1826
|
+
function renameKey(event){
|
|
1827
|
+
if(event.key==="Escape"){RENAMING=false;return render()}
|
|
1828
|
+
if(event.key!=="Enter")return;
|
|
1829
|
+
const value=event.target.value.trim();
|
|
1830
|
+
RENAMING=false;
|
|
1831
|
+
annotate(cur,{title:value});
|
|
1832
|
+
}
|
|
1833
|
+
const setQuery=v=>{query=v;limit=PAGE_SIZE;render()};
|
|
1834
|
+
function setTab(t){
|
|
1835
|
+
tab=t;
|
|
1836
|
+
// Raw and Files come from the server; fetch once per session, then cache.
|
|
1837
|
+
if(t!=="trajectory"&&!(extra&&extra[t])){
|
|
1838
|
+
fetch(`/api/${t}?id=${encodeURIComponent(cur)}`).then(r=>r.json()).then(d=>{extra={...extra,[t]:d};render()});
|
|
1839
|
+
}
|
|
1840
|
+
render();
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
/* What a step counts as, for the filter counts. A step can be several things
|
|
1844
|
+
at once: an agent turn that calls tools and carries reasoning. */
|
|
1845
|
+
/* Starred steps live on the session's library record, keyed the same way the
|
|
1846
|
+
anchors are — plain id at the top level, trajectory-scoped inside a subagent,
|
|
1847
|
+
whose ids restart at 1. */
|
|
1848
|
+
const stepKey = (step, prefix) => (prefix || "") + step.step_id;
|
|
1849
|
+
const starredSteps = () =>
|
|
1850
|
+
(INDEX.find((e) => e.key === cur) || {}).starred_steps || [];
|
|
1851
|
+
const isStarred = (step, prefix) => starredSteps().includes(stepKey(step, prefix));
|
|
1852
|
+
|
|
1853
|
+
async function toggleStep(event, key) {
|
|
1854
|
+
event.stopPropagation();
|
|
1855
|
+
// render() repaints the whole pane, which drops the reader back to the top
|
|
1856
|
+
// of a long transcript. Starring is a small act; hold their place.
|
|
1857
|
+
const at = main.scrollTop;
|
|
1858
|
+
const now = starredSteps();
|
|
1859
|
+
const next = now.includes(key) ? now.filter((k) => k !== key) : [...now, key];
|
|
1860
|
+
await annotate(cur, { starred_steps: next });
|
|
1861
|
+
render();
|
|
1862
|
+
main.scrollTop = at;
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
function facets(s){
|
|
1866
|
+
const f=[s.source];
|
|
1867
|
+
if(s.tool_calls?.length)f.push("tools");
|
|
1868
|
+
if(s.reasoning_content)f.push("reasoning");
|
|
1869
|
+
if(isStarred(s,""))f.push("favourited");
|
|
1870
|
+
return f;
|
|
1871
|
+
}
|
|
1872
|
+
const LENSES=[["all","All steps"],["user","User"],["agent","Agent"],["system","System"],
|
|
1873
|
+
["tools","Tools"],["reasoning","Reasoning"],["branches","Branches"],
|
|
1874
|
+
["favourited","Favourited"]];
|
|
1875
|
+
|
|
1876
|
+
/* Search the text a reader can actually see: message, reasoning, tool names and
|
|
1877
|
+
arguments, and observation content. */
|
|
1878
|
+
function haystack(s){
|
|
1879
|
+
const parts=[typeof s.message==="string"?s.message:(s.message||[]).map(p=>p.text||"").join(" "),
|
|
1880
|
+
s.reasoning_content||""];
|
|
1881
|
+
for(const c of s.tool_calls||[]){parts.push(c.function_name);parts.push(JSON.stringify(c.arguments||{}))}
|
|
1882
|
+
for(const r of s.observation?.results||[]){
|
|
1883
|
+
if(typeof r.content==="string")parts.push(r.content);
|
|
1884
|
+
}
|
|
1885
|
+
return parts.join(" ").toLowerCase();
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
/* Every step that delegates, with the trajectory it delegates to. */
|
|
1889
|
+
function branchIndex(t){
|
|
1890
|
+
const out=[];
|
|
1891
|
+
t.steps.forEach((s,i)=>{
|
|
1892
|
+
for(const c of s.tool_calls||[]){
|
|
1893
|
+
const r=(s.observation?.results||[]).find(x=>x.source_call_id===c.tool_call_id);
|
|
1894
|
+
for(const ref of r?.subagent_trajectory_ref||[]){
|
|
1895
|
+
const sub=(t.subagent_trajectories||[]).find(x=>x.trajectory_id===ref.trajectory_id);
|
|
1896
|
+
out.push({stepIndex:i,stepId:s.step_id,ref,sub});
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
});
|
|
1900
|
+
return out;
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
/* `pos` is the array position (what windowing counts); `id` is the ATIF
|
|
1904
|
+
step_id (what the anchor and the label use). They differ by one, so keep
|
|
1905
|
+
them distinct rather than letting the label drift from the link. */
|
|
1906
|
+
function jump(pos,id){
|
|
1907
|
+
// The target may be outside the current window, or hidden by a filter.
|
|
1908
|
+
if(pos>=limit){limit=pos+PAGE_SIZE;render()}
|
|
1909
|
+
const el=document.getElementById("step-"+id);
|
|
1910
|
+
if(!el)return;
|
|
1911
|
+
el.scrollIntoView({behavior:"smooth",block:"center"});
|
|
1912
|
+
el.querySelectorAll("details.branch").forEach(d=>d.open=true);
|
|
1913
|
+
el.style.transition="none";el.style.background="var(--sunk)";
|
|
1914
|
+
setTimeout(()=>{el.style.transition="background .8s";el.style.background=""},600);
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
function render(){
|
|
1918
|
+
const t=traj,e=INDEX.find(x=>x.key===cur)||{},m=t.final_metrics||{},a=t.agent||{};
|
|
1919
|
+
const branches=branchIndex(t);
|
|
1920
|
+
const branchSteps=new Set(branches.map(b=>b.stepIndex));
|
|
1921
|
+
const q=query.trim().toLowerCase();
|
|
1922
|
+
|
|
1923
|
+
// Counts describe the whole run, so they stay stable as filters change.
|
|
1924
|
+
const counts={all:t.steps.length,branches:branches.length};
|
|
1925
|
+
for(const [k] of LENSES){ if(k!=="all"&&k!=="branches")counts[k]=0 }
|
|
1926
|
+
t.steps.forEach(s=>facets(s).forEach(f=>{if(f in counts)counts[f]++}));
|
|
1927
|
+
|
|
1928
|
+
main.innerHTML=`
|
|
1929
|
+
<div class="hd">
|
|
1930
|
+
${RENAMING
|
|
1931
|
+
? `<input id="rn" class="inline hdin" value="${esc(titleOf(e))}"
|
|
1932
|
+
onkeydown="renameKey(event)" onblur="RENAMING=false;render()">`
|
|
1933
|
+
: `<h2 ondblclick="RENAMING=true;render()"
|
|
1934
|
+
title="Double-click to rename">${esc(titleOf(e))}</h2>`}
|
|
1935
|
+
<div class="sub">${esc(a.name)} ${esc(a.version||"")} · ${esc(a.model_name||"no model")} · ${esc(t.session_id||"")}</div>
|
|
1936
|
+
</div>
|
|
1937
|
+
<div class="stats">
|
|
1938
|
+
${stat(t.steps.length,"steps")}
|
|
1939
|
+
${(()=>{const d=duration(spanOf(t.steps));
|
|
1940
|
+
return d?`<div class="stat"><b>${d}</b><span>duration</span></div>`:""})()}
|
|
1941
|
+
${stat(counts.tools,"tool turns")}
|
|
1942
|
+
${stat(counts.reasoning,"reasoning")}
|
|
1943
|
+
${branches.length?stat(branches.length,"branches"):""}
|
|
1944
|
+
${stat(m.total_prompt_tokens,"prompt")}
|
|
1945
|
+
${stat(m.total_completion_tokens,"output")}
|
|
1946
|
+
${stat(m.total_cached_tokens,"cached")}
|
|
1947
|
+
</div>
|
|
1948
|
+
<div class="tabs">
|
|
1949
|
+
${[["trajectory","Trajectory"],["raw","Raw"],["files","Files"]].map(([k,l])=>
|
|
1950
|
+
`<span class="tab${tab===k?" on":""}" onclick="setTab('${k}')">${l}</span>`).join("")}
|
|
1951
|
+
</div>
|
|
1952
|
+
${tab==="trajectory"?trajectoryTab(t,e,branches,branchSteps,counts,q):panelTab()}`;
|
|
1953
|
+
|
|
1954
|
+
const box=document.getElementById("q2");
|
|
1955
|
+
if(box){ box.value=query; box.focus();
|
|
1956
|
+
box.setSelectionRange(box.value.length,box.value.length); }
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
function trajectoryTab(t,e,branches,branchSteps,counts,q){
|
|
1960
|
+
let rows=t.steps.map((s,i)=>({s,i}));
|
|
1961
|
+
if(lens==="branches")rows=rows.filter(({i})=>branchSteps.has(i));
|
|
1962
|
+
else if(lens!=="all")rows=rows.filter(({s})=>facets(s).includes(lens));
|
|
1963
|
+
else rows=rows.filter(({s})=>show[s.source]);
|
|
1964
|
+
if(q)rows=rows.filter(({s})=>haystack(s).includes(q));
|
|
1965
|
+
|
|
1966
|
+
const total=rows.length, shown=rows.slice(0,limit);
|
|
1967
|
+
return `
|
|
1968
|
+
<div class="runbar">
|
|
1969
|
+
<input id="q2" class="search" placeholder="Search this run…"
|
|
1970
|
+
oninput="setQuery(this.value)" spellcheck="false">
|
|
1971
|
+
<div class="lenses">
|
|
1972
|
+
${LENSES.filter(([k])=>k!=="branches"||branches.length)
|
|
1973
|
+
.map(([k,l])=>(k==="favourited"?`<span class="fsep"></span>`:"")
|
|
1974
|
+
+`<span class="lens${lens===k?" on":""}" onclick="setLens('${k}')">
|
|
1975
|
+
${l}<b>${num(counts[k]??0)}</b></span>`).join("")}
|
|
1976
|
+
</div>
|
|
1977
|
+
${details(t,e)}
|
|
1978
|
+
</div>
|
|
1979
|
+
${AI.available?aiStrip():""}
|
|
1980
|
+
${branches.length&&lens!=="branches"?branchNav(branches):""}
|
|
1981
|
+
<div class="filters">
|
|
1982
|
+
${lens==="all"?["user","agent","system"].map(r=>
|
|
1983
|
+
`<span class="chip${show[r]?" on":""}" onclick="toggle('${r}')">${r}</span>`).join(""):""}
|
|
1984
|
+
<span class="fsep"></span>
|
|
1985
|
+
<span class="chip${OPEN_ALL?" on":""}" id="xall" onclick="toggleExpand()">${
|
|
1986
|
+
OPEN_ALL?"collapse all":"expand all"}</span>
|
|
1987
|
+
<span class="chip${raw?" on":""}" onclick="toggleRaw()">raw text</span>
|
|
1988
|
+
${q?`<span class="hits">${num(total)} of ${num(t.steps.length)} match “${esc(query)}”</span>`:""}
|
|
1989
|
+
</div>
|
|
1990
|
+
${shown.map(({s,i})=>step(s,t,0,i,"")).join("")
|
|
1991
|
+
||`<div class="empty">Nothing matches.</div>`}
|
|
1992
|
+
${total>shown.length?`<div class="more"><button onclick="more()">Show more —
|
|
1993
|
+
${num(shown.length)} of ${num(total)} steps</button></div>`:""}`;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
/* The switch stays visible when AI is off for a transcript — otherwise there
|
|
1997
|
+
would be no way to turn it back on. Only the ask box hides. */
|
|
1998
|
+
function aiStrip(){
|
|
1999
|
+
const on=aiOn();
|
|
2000
|
+
return `<details class="ask"${ASK_OPEN&&on?" open":""} ontoggle="ASK_OPEN=this.open">
|
|
2001
|
+
<summary>
|
|
2002
|
+
<span class="askt">Ask Claude</span>
|
|
2003
|
+
<label class="aisw" onclick="event.stopPropagation()"
|
|
2004
|
+
title="Hide every AI control for this transcript">
|
|
2005
|
+
<input type="checkbox" ${on?"checked":""}
|
|
2006
|
+
onchange="annotate(cur,{ai:this.checked}).then(render)"> With AI support
|
|
2007
|
+
</label>
|
|
2008
|
+
</summary>
|
|
2009
|
+
${on?askInner():""}
|
|
2010
|
+
</details>`;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
/* Ask a question about this transcript. Nothing is sent until Enter. */
|
|
2014
|
+
function askInner(){
|
|
2015
|
+
return `<div class="askbody">
|
|
2016
|
+
<div class="turns">${turnsHTML()}</div>
|
|
2017
|
+
<input class="askin" spellcheck="false" onkeydown="askSession(event)"
|
|
2018
|
+
placeholder="${CHAT.length?"Ask a follow-up…":"Ask about this transcript…"}">
|
|
2019
|
+
</div>`;
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
/* What the answer was allowed to see. The bare count was misleading: nearly
|
|
2023
|
+
every question hits the same ceiling, so the number described the cap rather
|
|
2024
|
+
than the transcript. Against the total it says something real — whether the
|
|
2025
|
+
answer read everything or a sliver. */
|
|
2026
|
+
function coverage(turn){
|
|
2027
|
+
const seen=turn.steps.length,total=turn.total||0;
|
|
2028
|
+
if(!seen)return "";
|
|
2029
|
+
const what=total&&seen<total
|
|
2030
|
+
?`read ${num(seen)} of ${num(total)} steps`
|
|
2031
|
+
:`read all ${num(seen)} step${seen===1?"":"s"}`;
|
|
2032
|
+
return `<div class="askref">${what}</div>`;
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
/* Turn "(step 42)" in an answer into a link to that step. The model is asked to
|
|
2036
|
+
cite, so these are the references worth following; code is skipped, where a
|
|
2037
|
+
number is likelier to be data than a citation. */
|
|
2038
|
+
function linkSteps(html){
|
|
2039
|
+
return html.split(/(<pre[\s\S]*?<\/pre>|<code>[\s\S]*?<\/code>)/)
|
|
2040
|
+
.map((part,i)=>i%2?part:part.replace(
|
|
2041
|
+
/\bsteps?\s+\d+(?:\s*(?:,|and|&|–|-)\s*\d+)*/gi,
|
|
2042
|
+
m=>m.replace(/\d+/g,n=>`<span class="stepref" onclick="jumpToStep(${n})">${n}</span>`)))
|
|
2043
|
+
.join("");
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
function jumpToStep(id){
|
|
2047
|
+
const pos=((traj||{}).steps||[]).findIndex(s=>s.step_id===id);
|
|
2048
|
+
if(pos<0)return;
|
|
2049
|
+
jump(pos,id);
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
function turnBody(t){
|
|
2053
|
+
return t.error?esc(t.error)
|
|
2054
|
+
:t.thinking&&!t.a?"Thinking…"
|
|
2055
|
+
:t.busy&&!t.a?"Reading the steps…"
|
|
2056
|
+
:`${linkSteps(md(t.a))}${coverage(t)}`;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
const turnClass=t=>`askout${t.error?" bad":""}${t.busy&&!t.a?" busy":""}`;
|
|
2060
|
+
|
|
2061
|
+
function turnsHTML(){
|
|
2062
|
+
return CHAT.map(t=>`<div class="turn">
|
|
2063
|
+
<div class="askq">${esc(t.q)}</div>
|
|
2064
|
+
<div class="${turnClass(t)}">${turnBody(t)}</div>
|
|
2065
|
+
</div>`).join("");
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
/* Provenance: what this trajectory is and where it came from. */
|
|
2069
|
+
/* Provenance reads as a table, not a ragged run: flex-wrap put each label on
|
|
2070
|
+
its own line as soon as one value was long. */
|
|
2071
|
+
function details(t,e){
|
|
2072
|
+
const rows=[
|
|
2073
|
+
["Schema",esc(t.schema_version)],
|
|
2074
|
+
["Source",esc(e.format)],
|
|
2075
|
+
["Agent",esc([t.agent?.name,t.agent?.version].filter(Boolean).join(" "))],
|
|
2076
|
+
["Model",esc(t.agent?.model_name)],
|
|
2077
|
+
["Session",esc(t.session_id)],
|
|
2078
|
+
["Size",e.size_bytes?bytes(e.size_bytes):""],
|
|
2079
|
+
// The path is the one value worth acting on, so it reveals in the file
|
|
2080
|
+
// manager through the same helper the prose linkifier uses.
|
|
2081
|
+
["Path",e.path?pathAnchor(e.path,esc(e.path)):""],
|
|
2082
|
+
];
|
|
2083
|
+
return `<div class="details">${rows.filter(([,v])=>v).map(([k,v])=>
|
|
2084
|
+
`<div class="dt">${k}</div><div class="dd" title="${v.replace(/<[^>]*>/g,"")}">${v}</div>`
|
|
2085
|
+
).join("")}</div>`;
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
function panelTab(){
|
|
2089
|
+
const d=extra&&extra[tab];
|
|
2090
|
+
if(!d)return `<div class="empty">Loading…</div>`;
|
|
2091
|
+
if(tab==="raw"){
|
|
2092
|
+
return `<div class="rawmeta">${esc(d.path)} · ${bytes(d.size)}${
|
|
2093
|
+
d.truncated?` · showing the first ${bytes(RAW_KB*1024)}`:""}</div>
|
|
2094
|
+
${rawSource(d.text).html}`;
|
|
2095
|
+
}
|
|
2096
|
+
if(!d.length)return `<div class="empty">No associated files.</div>`;
|
|
2097
|
+
return `<table class="files"><thead><tr><th>File</th><th>Role</th><th>Size</th></tr></thead><tbody>${
|
|
2098
|
+
d.map(f=>`<tr><td>${pathAnchor(f.path,esc(f.name))}</td>
|
|
2099
|
+
<td><span class="pill">${esc(f.role)}</span></td>
|
|
2100
|
+
<td>${bytes(f.size)}</td></tr>`).join("")}</tbody></table>`;
|
|
2101
|
+
}
|
|
2102
|
+
const RAW_KB=512;
|
|
2103
|
+
// Pretty-printing every line of a large log would balloon the page; enough to
|
|
2104
|
+
// read the shape is the point of this panel.
|
|
2105
|
+
const RAW_LINES=300;
|
|
2106
|
+
|
|
2107
|
+
/* Colour the source with hjson — the same function that colours a tool
|
|
2108
|
+
argument — rather than growing a second way to render JSON. A log is one
|
|
2109
|
+
object per line; a trajectory or HAR is one document. */
|
|
2110
|
+
function rawSource(text){
|
|
2111
|
+
const lines=text.split("\n");
|
|
2112
|
+
const shown=lines.slice(0,RAW_LINES);
|
|
2113
|
+
const parsed=shown.map(l=>{
|
|
2114
|
+
if(!l.trim())return null;
|
|
2115
|
+
try{return {v:JSON.parse(l)}}catch(e){return undefined}
|
|
2116
|
+
});
|
|
2117
|
+
// A truncated fetch leaves a partial final line, which is expected — every
|
|
2118
|
+
// other line failing means this is not line-oriented.
|
|
2119
|
+
const solid=parsed.filter((p,i)=>i<parsed.length-1&&p!==null);
|
|
2120
|
+
if(solid.length&&solid.every(p=>p!==undefined)){
|
|
2121
|
+
const html=shown.map((l,i)=>{
|
|
2122
|
+
const p=parsed[i];
|
|
2123
|
+
return p===null?"":p===undefined?escText(l):hjson(p.v);
|
|
2124
|
+
}).filter(Boolean).join("\n\n");
|
|
2125
|
+
const rest=lines.length-shown.length;
|
|
2126
|
+
return {html,note:rest>0?` · first ${RAW_LINES} of ${num(lines.length)} records`:""};
|
|
2127
|
+
}
|
|
2128
|
+
try{return {html:hjson(JSON.parse(text)),note:""}}catch(e){}
|
|
2129
|
+
return {html:escText(text),note:""}; // not JSON at all
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
/* Elapsed time from the first step to the last. Sessions here run from seconds
|
|
2133
|
+
to weeks, so the unit follows the span rather than fixing on one. */
|
|
2134
|
+
function spanOf(steps){
|
|
2135
|
+
const times=steps.map(s=>s.timestamp).filter(Boolean).map(t=>Date.parse(t))
|
|
2136
|
+
.filter(n=>!Number.isNaN(n));
|
|
2137
|
+
if(times.length<2)return null;
|
|
2138
|
+
const ms=Math.max(...times)-Math.min(...times);
|
|
2139
|
+
return ms>0?ms:null;
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
function duration(ms){
|
|
2143
|
+
if(ms==null)return null;
|
|
2144
|
+
// A whole number reads better without the decimal: 2h, not 2.0h.
|
|
2145
|
+
const trim=n=>(n<10?n.toFixed(1).replace(/\.0$/,""):String(Math.round(n)));
|
|
2146
|
+
const s=ms/1000;
|
|
2147
|
+
if(s<90)return Math.round(s)+"s";
|
|
2148
|
+
const m=s/60;
|
|
2149
|
+
if(m<90)return Math.round(m)+"m";
|
|
2150
|
+
const h=m/60;
|
|
2151
|
+
if(h<48)return trim(h)+"h";
|
|
2152
|
+
const d=h/24;
|
|
2153
|
+
if(d<14)return trim(d)+"d";
|
|
2154
|
+
const w=d/7;
|
|
2155
|
+
return trim(w)+"w";
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
const stat=(v,l)=>v==null?"":`<div class="stat"><b>${num(v)}</b><span>${l}</span></div>`;
|
|
2159
|
+
|
|
2160
|
+
/* A jump list, because branches are scattered through thousands of steps. */
|
|
2161
|
+
function branchNav(branches){
|
|
2162
|
+
return `<div class="bnav"><div class="bnav-h">Branches</div><div class="bnav-l">`+
|
|
2163
|
+
branches.map((b,n)=>{
|
|
2164
|
+
const x=b.sub?.extra||{};
|
|
2165
|
+
const label=x.agentType||b.sub?.agent?.name||"subagent";
|
|
2166
|
+
return `<button class="bnav-i" onclick="jump(${b.stepIndex},${b.stepId})" title="${esc(x.description||"")}">
|
|
2167
|
+
<span class="sid">${b.stepId}</span>
|
|
2168
|
+
<b>${esc(label)}</b>
|
|
2169
|
+
<span>${esc(x.description||"")||"—"}</span>
|
|
2170
|
+
<em>${b.sub?b.sub.steps.length+" steps":"external"}</em>
|
|
2171
|
+
</button>`;
|
|
2172
|
+
}).join("")+`</div></div>`;
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
/* A message is either a plain string or ATIF ContentParts (text and images). */
|
|
2176
|
+
function text(v){
|
|
2177
|
+
// Agent messages are written in Markdown; rendering them as such is the
|
|
2178
|
+
// difference between a wall of asterisks and something readable.
|
|
2179
|
+
return raw?`<div class="msg">${esc(v)}</div>`:`<div class="md">${md(v)}</div>`;
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
function body(v){
|
|
2183
|
+
if(typeof v==="string")return text(v);
|
|
2184
|
+
if(!Array.isArray(v))return "";
|
|
2185
|
+
return v.map(p=>p.type==="image"&&p.source
|
|
2186
|
+
? `<a class="img" href="${esc(p.source.path)}" target="_blank" rel="noreferrer">
|
|
2187
|
+
<img loading="lazy" src="${esc(p.source.path)}" alt="${esc(p.source.media_type||"image")}"></a>`
|
|
2188
|
+
: text(p.text||"")).join("");
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
function step(s,ctx,depth,idx,prefix){
|
|
2192
|
+
// Subagent trajectories number their own steps from 1, so scope their DOM ids
|
|
2193
|
+
// by trajectory to keep every anchor on the page unique.
|
|
2194
|
+
const key=(prefix||"")+s.step_id;
|
|
2195
|
+
const on=starredSteps().includes(key);
|
|
2196
|
+
let h=`<div class="step ${s.source}" id="step-${key}">
|
|
2197
|
+
<div class="gut">
|
|
2198
|
+
<a class="sid" href="#step-${key}" title="step ${s.step_id}">${s.step_id}</a>
|
|
2199
|
+
<span class="sstar${on?" on":""}" onclick="toggleStep(event,'${key}')"
|
|
2200
|
+
title="${on?"Remove from favourites":"Add to favourites"}">
|
|
2201
|
+
<svg width="11" height="11" viewBox="0 0 24 24" fill="${on?"currentColor":"none"}"
|
|
2202
|
+
stroke="currentColor" stroke-width="2"><path d="m12 2 3.09 6.26L22 9.27l-5 4.87
|
|
2203
|
+
1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg></span>
|
|
2204
|
+
</div>
|
|
2205
|
+
<div class="role"><b>${esc(s.source)}</b>`;
|
|
2206
|
+
if(s.timestamp)h+=`<span>${esc(s.timestamp.replace("T"," ").replace(/\.\d+Z?$/,""))}</span>`;
|
|
2207
|
+
if(s.model_name&&s.source==="agent")h+=`<span>${esc(s.model_name)}</span>`;
|
|
2208
|
+
if(s.metrics?.completion_tokens)h+=`<span>${num(s.metrics.completion_tokens)} tok</span>`;
|
|
2209
|
+
h+=`</div>`;
|
|
2210
|
+
if(s.reasoning_content)h+=`<div class="think">${esc(s.reasoning_content)}</div>`;
|
|
2211
|
+
if(s.message)h+=body(s.message);
|
|
2212
|
+
for(const c of s.tool_calls||[]){
|
|
2213
|
+
const r=(s.observation?.results||[]).find(x=>x.source_call_id===c.tool_call_id);
|
|
2214
|
+
const refs=r?.subagent_trajectory_ref||[];
|
|
2215
|
+
h+=`<details class="tool"${OPEN_ALL?" open":""}><summary><span class="tname">${esc(c.function_name)}</span>
|
|
2216
|
+
${refs.length?`<span class="pill br">delegates</span>`:""}</summary>
|
|
2217
|
+
<div class="tbody">
|
|
2218
|
+
<div class="io in"><span class="iol">call</span>
|
|
2219
|
+
<pre class="json">${hjson(c.arguments)}</pre></div>
|
|
2220
|
+
${r?.content?`<div class="io out"><span class="iol">output</span>
|
|
2221
|
+
${typeof r.content==="string"?pre(r.content):body(r.content)}</div>`:""}
|
|
2222
|
+
${aiOn()?callSummary(c.tool_call_id):""}
|
|
2223
|
+
</div></details>`;
|
|
2224
|
+
for(const ref of refs)h+=branch(ref,ctx,depth);
|
|
2225
|
+
}
|
|
2226
|
+
return h+`</div>`;
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
/* A delegated subagent is its own complete trajectory. Render it collapsed, and
|
|
2230
|
+
recurse — ATIF nests arbitrarily deep. */
|
|
2231
|
+
function branch(ref,ctx,depth){
|
|
2232
|
+
const sub=(ctx.subagent_trajectories||[]).find(x=>x.trajectory_id===ref.trajectory_id);
|
|
2233
|
+
if(!sub){
|
|
2234
|
+
const where=ref.trajectory_path?`external file ${esc(ref.trajectory_path)}`:"not embedded";
|
|
2235
|
+
return `<div class="branch"><div class="miss">⤷ subagent ${esc(ref.trajectory_id||"")} — ${where}</div></div>`;
|
|
2236
|
+
}
|
|
2237
|
+
const x=sub.extra||{};
|
|
2238
|
+
const label=x.agentType||sub.agent?.name||"subagent";
|
|
2239
|
+
return `<details class="branch"${OPEN_ALL?" open":""}><summary>
|
|
2240
|
+
<b>${esc(label)}</b>
|
|
2241
|
+
<span class="desc">${esc(x.description||"")}</span>
|
|
2242
|
+
<span class="pill br">${sub.steps.length} steps</span>
|
|
2243
|
+
${depth?`<span class="pill">depth ${depth+1}</span>`:""}
|
|
2244
|
+
</summary><div class="inner">
|
|
2245
|
+
${sub.steps.map(s=>step(s,sub,depth+1,null,esc(sub.trajectory_id||"sub")+"-")).join("")}
|
|
2246
|
+
</div></details>`;
|
|
2247
|
+
}
|
|
2248
|
+
</script>
|