MagicCaster commited on
Commit
cafe946
·
verified ·
1 Parent(s): afd8e3d

trajectory reader: page

Browse files
Files changed (1) hide show
  1. index.html +609 -17
index.html CHANGED
@@ -1,19 +1,611 @@
1
  <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
  <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Trajectory Reader</title>
7
+ <!--
8
+ Trajectory Reader — render the `messages` list of any Hugging Face dataset row.
9
+
10
+ A single static file. No server, no bundled data, no bundled token.
11
+ Open it in a browser (double-click, or `python3 -m http.server`), paste YOUR
12
+ HF token, pick a dataset + split, and page through rows. Every request goes
13
+ straight from your browser to huggingface.co with your token, so the Hub's own
14
+ permission check applies: someone without access to a private dataset gets
15
+ 401/403 and sees nothing. The token is kept only in this browser's
16
+ localStorage ("忘记 token" clears it). Share the file freely; never share a
17
+ token.
18
+
19
+ Data path: datasets-server.huggingface.co /splits + /rows (paged, no download).
20
+ Fallback when the dataset isn't processed by the viewer backend: stream the
21
+ raw .jsonl via /resolve and split lines client-side.
22
+ -->
23
+ <link rel="preconnect" href="https://fonts.googleapis.com">
24
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
25
+ <style>
26
+ :root{
27
+ --bg:#F6F5F1; --panel:#FFFFFF; --ink:#1B1A17; --muted:#6E6A60; --line:#DDD9CF; --line-soft:#ECE9E1;
28
+ --accent:#0F8A78; --accent-ink:#0B6B5D; --danger:#B4373A;
29
+ --user:#3D5AD6; --assistant:#0F8A78; --tool:#B0700E; --system:#7A766C; --reason:#7A5AB8;
30
+ --user-bg:#EEF1FC; --assistant-bg:#EAF5F2; --tool-bg:#FBF3E4; --system-bg:#F0EFEA; --reason-bg:#F3EFFA;
31
+ --code-bg:#F1EFE9; --shadow:0 1px 2px rgba(27,26,23,.06);
32
+ --sans:"IBM Plex Sans",system-ui,-apple-system,"Segoe UI",sans-serif;
33
+ --mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
34
+ }
35
+ @media (prefers-color-scheme: dark){
36
+ :root:not([data-theme="light"]){
37
+ --bg:#17181C; --panel:#1F2126; --ink:#ECEAE3; --muted:#9A968C; --line:#2E3038; --line-soft:#272930;
38
+ --accent:#3FB8A4; --accent-ink:#6FD3C2; --danger:#E06B6E;
39
+ --user:#8C9DF0; --assistant:#4FC2AD; --tool:#E0A54A; --system:#A5A197; --reason:#B39BE6;
40
+ --user-bg:#20253A; --assistant-bg:#1B2E2B; --tool-bg:#302819; --system-bg:#24262B; --reason-bg:#2A2436;
41
+ --code-bg:#15161A; --shadow:none;
42
+ }
43
+ }
44
+ :root[data-theme="dark"]{
45
+ --bg:#17181C; --panel:#1F2126; --ink:#ECEAE3; --muted:#9A968C; --line:#2E3038; --line-soft:#272930;
46
+ --accent:#3FB8A4; --accent-ink:#6FD3C2; --danger:#E06B6E;
47
+ --user:#8C9DF0; --assistant:#4FC2AD; --tool:#E0A54A; --system:#A5A197; --reason:#B39BE6;
48
+ --user-bg:#20253A; --assistant-bg:#1B2E2B; --tool-bg:#302819; --system-bg:#24262B; --reason-bg:#2A2436;
49
+ --code-bg:#15161A; --shadow:none;
50
+ }
51
+ *{box-sizing:border-box}
52
+ [hidden]{display:none!important}
53
+ html,body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 var(--sans)}
54
+ button,input,select{font:inherit;color:inherit}
55
+ a{color:var(--accent-ink)}
56
+ :focus-visible{outline:2px solid var(--accent);outline-offset:2px}
57
+ @media (prefers-reduced-motion: reduce){*{transition:none!important;animation:none!important}}
58
+
59
+ /* ---------- toolbar ---------- */
60
+ .bar{position:sticky;top:0;z-index:5;background:var(--panel);border-bottom:1px solid var(--line);padding-block:10px;padding-inline:20px}
61
+ .bar-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;max-width:1400px;margin:0 auto}
62
+ .brand{font-weight:600;letter-spacing:.01em;margin-right:6px;white-space:nowrap}
63
+ .brand small{color:var(--muted);font-weight:400;margin-left:6px}
64
+ .field{display:flex;align-items:center;gap:6px}
65
+ .field label{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.06em;white-space:nowrap}
66
+ input[type=text],input[type=password],input[type=number],select{
67
+ background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:6px 9px;min-width:0}
68
+ input[type=text]{width:300px;max-width:100%}
69
+ input[type=password]{width:210px}
70
+ input[type=number]{width:84px;font-variant-numeric:tabular-nums}
71
+ select{max-width:260px}
72
+ .btn{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:6px 11px;cursor:pointer;white-space:nowrap}
73
+ .btn:hover{border-color:var(--accent)}
74
+ .btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}
75
+ .btn.primary:hover{filter:brightness(1.05)}
76
+ .btn.quiet{border-color:transparent;color:var(--muted)}
77
+ .btn:disabled{opacity:.45;cursor:default}
78
+ .btn.hf{background:#FFD21E;border-color:#E8BE10;color:#1B1A17;font-weight:600}
79
+ .btn.hf::before{content:"🤗";margin-right:6px}
80
+ .who-name{color:var(--muted);font-size:13px;white-space:nowrap}
81
+ .who-name b{color:var(--ink);font-weight:500}
82
+ .nav{display:flex;align-items:center;gap:6px;margin-left:auto}
83
+ .nav .count{color:var(--muted);font-variant-numeric:tabular-nums;white-space:nowrap}
84
+ .status{max-width:1400px;margin:8px auto 0;font-size:12.5px;color:var(--muted);min-height:1.2em;display:flex;gap:10px;align-items:center;flex-wrap:wrap}
85
+ .status.err{color:var(--danger)}
86
+ .progress{height:3px;background:var(--line-soft);border-radius:2px;flex:1 1 160px;overflow:hidden}
87
+ .progress i{display:block;height:100%;width:0;background:var(--accent);transition:width .2s}
88
+
89
+ /* ---------- layout ---------- */
90
+ .wrap{max-width:1400px;margin:0 auto;padding-block:18px;padding-inline:20px;display:grid;grid-template-columns:300px minmax(0,1fr);gap:22px}
91
+ @media (max-width:900px){.wrap{grid-template-columns:1fr}}
92
+ .rail{display:flex;flex-direction:column;gap:14px;align-self:start;position:sticky;top:74px;max-height:calc(100vh - 90px);overflow:auto}
93
+ @media (max-width:900px){.rail{position:static;max-height:none}}
94
+ .card{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px 14px;box-shadow:var(--shadow)}
95
+ .card h3{margin:0 0 8px;font-size:12px;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);font-weight:600}
96
+ .kv{display:grid;grid-template-columns:auto minmax(0,1fr);gap:4px 10px;font-size:13px}
97
+ .kv dt{color:var(--muted);white-space:nowrap}
98
+ .kv dd{margin:0;overflow-wrap:anywhere}
99
+ .kv dd.num{font-variant-numeric:tabular-nums}
100
+ .opts{display:flex;flex-direction:column;gap:6px;font-size:13px}
101
+ .opts label{display:flex;align-items:center;gap:8px;cursor:pointer}
102
+ details{border-top:1px solid var(--line-soft)}
103
+ details:first-of-type{border-top:0}
104
+ summary{cursor:pointer;padding:5px 0;color:var(--ink);list-style:none;display:flex;gap:8px;align-items:baseline}
105
+ summary::-webkit-details-marker{display:none}
106
+ summary::before{content:"▸";color:var(--muted);font-size:11px;flex:none}
107
+ details[open]>summary::before{content:"▾"}
108
+ summary .hint{color:var(--muted);font-size:12px;margin-left:auto;white-space:nowrap}
109
+ pre{margin:0;font:12.5px/1.5 var(--mono);white-space:pre-wrap;overflow-wrap:anywhere;background:var(--code-bg);border-radius:6px;padding:8px 10px;max-height:420px;overflow:auto}
110
+ .rail pre{max-height:260px}
111
+
112
+ /* ---------- transcript ---------- */
113
+ .main{min-width:0;max-width:880px}
114
+ .empty{color:var(--muted);padding:40px 0;text-align:center}
115
+ .turn{display:grid;grid-template-columns:82px minmax(0,1fr);gap:12px;padding:12px 0;border-top:1px solid var(--line-soft)}
116
+ .turn:first-child{border-top:0}
117
+ .who{display:flex;flex-direction:column;gap:3px;align-items:flex-start}
118
+ .pill{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;padding:2px 8px;border-radius:999px;color:#fff;background:var(--system)}
119
+ .turn[data-role=user] .pill{background:var(--user)}
120
+ .turn[data-role=assistant] .pill{background:var(--assistant)}
121
+ .turn[data-role=tool] .pill{background:var(--tool)}
122
+ .who .n{color:var(--muted);font-size:11px;font-variant-numeric:tabular-nums}
123
+ .body{display:flex;flex-direction:column;gap:8px;min-width:0}
124
+ .bubble{border-radius:8px;padding:10px 12px;background:var(--system-bg);border:1px solid transparent}
125
+ .turn[data-role=user] .bubble.text{background:var(--user-bg)}
126
+ .turn[data-role=assistant] .bubble.text{background:var(--assistant-bg)}
127
+ .turn[data-role=tool] .bubble.text{background:var(--tool-bg)}
128
+ .text .t{white-space:pre-wrap;overflow-wrap:anywhere}
129
+ .reason{background:var(--reason-bg);border-left:3px solid var(--reason)}
130
+ .reason summary{color:var(--reason);font-weight:500}
131
+ .reason .t{white-space:pre-wrap;overflow-wrap:anywhere;color:var(--ink);opacity:.9;padding:4px 0 2px}
132
+ .call{background:var(--tool-bg);border-left:3px solid var(--tool)}
133
+ .call .head{display:flex;gap:8px;align-items:baseline;flex-wrap:wrap;font-family:var(--mono);font-size:12.5px}
134
+ .call .head b{color:var(--tool);font-weight:600}
135
+ .call .head .id{color:var(--muted);font-size:11px;margin-left:auto}
136
+ .call pre{margin-top:6px;background:transparent;padding:0;max-height:360px}
137
+ .meta{font-family:var(--mono);font-size:11.5px;color:var(--muted);display:flex;gap:10px;flex-wrap:wrap}
138
+ .more{margin-top:6px}
139
+ .more .btn{font-size:12px;padding:3px 9px}
140
+ .hidden-note{color:var(--muted);font-size:12px;padding:6px 0;border-top:1px dashed var(--line)}
141
+ .section-h{font-size:12px;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);font-weight:600;margin:14px 0 4px}
142
+ .rawrow pre{max-height:70vh}
143
+ kbd{font:11px var(--mono);background:var(--code-bg);border:1px solid var(--line);border-bottom-width:2px;border-radius:4px;padding:0 5px}
144
+ .help{color:var(--muted);font-size:12px}
145
+ .help kbd{margin:0 2px}
146
+ .tools-list{display:flex;flex-direction:column}
147
+ .tools-list summary b{font-family:var(--mono);font-weight:500}
148
+ .tools-list .d{color:var(--muted);font-size:12px;padding-bottom:6px}
149
+ </style>
150
+ </head>
151
+ <body>
152
+
153
+ <div class="bar">
154
+ <div class="bar-row">
155
+ <div class="brand">Trajectory Reader<small>HF messages</small></div>
156
+ <div class="field"><label for="repo">dataset</label><input id="repo" type="text" placeholder="org/name" spellcheck="false"></div>
157
+ <div class="field" id="authOauth" hidden>
158
+ <button id="signin" class="btn hf">Sign in with Hugging Face</button>
159
+ <span id="who" class="who-name" hidden></span>
160
+ <button id="signout" class="btn quiet" hidden>退出</button>
161
+ </div>
162
+ <div class="field" id="authToken"><label for="token">token</label><input id="token" type="password" placeholder="hf_… (只存本地)" autocomplete="off"><button id="forget" class="btn quiet" title="从这个浏览器删除 token">忘记</button></div>
163
+ <button id="load" class="btn primary">加载</button>
164
+ <div class="field"><label for="split">split</label><select id="split" disabled><option>—</option></select></div>
165
+ <div class="nav">
166
+ <button id="prev" class="btn" disabled title="上一行 (←)">‹</button>
167
+ <input id="idx" type="number" min="0" value="0" disabled>
168
+ <span class="count" id="count">/ —</span>
169
+ <button id="next" class="btn" disabled title="下一行 (→)">›</button>
170
+ <button id="rand" class="btn" disabled title="随机 (r)">随机</button>
171
+ </div>
172
+ </div>
173
+ <div class="status" id="status"></div>
174
+ </div>
175
+
176
+ <div class="wrap">
177
+ <aside class="rail">
178
+ <div class="card">
179
+ <h3>行信息</h3>
180
+ <dl class="kv" id="rowmeta"><dt>状态</dt><dd>尚未加载</dd></dl>
181
+ </div>
182
+ <div class="card">
183
+ <h3>显示</h3>
184
+ <div class="opts">
185
+ <label><input type="checkbox" id="optReason" checked> 默认展开 reasoning</label>
186
+ <label><input type="checkbox" id="optTools"> 隐藏 tool 返回</label>
187
+ <label><input type="checkbox" id="optSystem"> 隐藏 system</label>
188
+ <label><input type="checkbox" id="optRaw"> 显示整行原始 JSON</label>
189
+ </div>
190
+ <p class="help" style="margin:10px 0 0"><kbd>←</kbd><kbd>→</kbd> 翻行 · <kbd>r</kbd> 随机</p>
191
+ </div>
192
+ <div class="card" id="toolsCard" hidden>
193
+ <h3>tools <span id="toolsN"></span></h3>
194
+ <div class="tools-list" id="toolsList"></div>
195
+ </div>
196
+ <div class="card" id="colsCard" hidden>
197
+ <h3>其它列</h3>
198
+ <div id="cols"></div>
199
+ </div>
200
+ </aside>
201
+
202
+ <main class="main" id="main">
203
+ <div class="empty" id="intro">用 Hugging Face 账号登录(或粘贴你的 HF token),输入数据集名,点「加载」。<br>页面不带任何数据,所有请求从你的浏览器直连 huggingface.co,能看到什么由你的 HF 权限决定。</div>
204
+ </main>
205
+ </div>
206
+
207
+ <script>
208
+ (() => {
209
+ 'use strict';
210
+ const DS = 'https://datasets-server.huggingface.co';
211
+ const HUB = 'https://huggingface.co';
212
+ const PAGE = 20; // rows per /rows request (server max 100)
213
+ const CLIP = 1800; // chars shown before "展开" on long blocks
214
+ const LS_TOKEN = 'trajreader.token', LS_REPO = 'trajreader.repo', LS_OAUTH = 'trajreader.oauth', SS_HASH = 'trajreader.pendingHash';
215
+ const HFJS = 'https://cdn.jsdelivr.net/npm/@huggingface/hub@2.17.1/+esm'; // OAuth PKCE helper (Space mode only)
216
+ const ORG_ID = '6a2a067c086133dad7d362e4'; // NewsBreak — preselects the org on the consent screen
217
+
218
+ const $ = id => document.getElementById(id);
219
+ const el = {
220
+ repo: $('repo'), token: $('token'), forget: $('forget'), load: $('load'), split: $('split'),
221
+ authOauth: $('authOauth'), authToken: $('authToken'), signin: $('signin'), signout: $('signout'), who: $('who'),
222
+ prev: $('prev'), next: $('next'), rand: $('rand'), idx: $('idx'), count: $('count'),
223
+ status: $('status'), rowmeta: $('rowmeta'), main: $('main'), cols: $('cols'), colsCard: $('colsCard'),
224
+ toolsCard: $('toolsCard'), toolsList: $('toolsList'), toolsN: $('toolsN'),
225
+ optReason: $('optReason'), optTools: $('optTools'), optSystem: $('optSystem'), optRaw: $('optRaw'),
226
+ };
227
+
228
+ const S = {
229
+ repo: '', splits: [], sel: null, total: null, idx: 0,
230
+ mode: 'server', // 'server' (datasets-server /rows) | 'raw' (stream .jsonl)
231
+ cache: new Map(), // row_idx -> row object
232
+ rawLines: null, // raw mode: array of parsed rows
233
+ files: [], // raw mode: candidate files
234
+ current: null, loadingRow: null, features: [], gen: 0,
235
+ };
236
+
237
+ /* ---------- storage (token stays in this browser only) ---------- */
238
+ const ls = {
239
+ get(k){ try { return localStorage.getItem(k) || ''; } catch { return ''; } },
240
+ set(k,v){ try { v ? localStorage.setItem(k,v) : localStorage.removeItem(k); } catch {} },
241
+ };
242
+ el.token.value = ls.get(LS_TOKEN);
243
+ el.repo.value = ls.get(LS_REPO) || 'NewsBreak/local-trajectories';
244
+ el.forget.onclick = () => { el.token.value=''; ls.set(LS_TOKEN,''); setStatus('已从这个浏览器删除 token'); };
245
+
246
+ /* ---------- auth: OAuth (inside a static Space) or manual token (local file) ---------- */
247
+ const auth = { mode: 'token', accessToken: '', user: '' };
248
+ const bearer = () => auth.mode === 'oauth' && auth.accessToken ? auth.accessToken : el.token.value.trim();
249
+
250
+ async function initAuth(){
251
+ const vars = (window.huggingface && window.huggingface.variables) || null;
252
+ if (!vars || !vars.OAUTH_CLIENT_ID) return; // not in a Space: manual token
253
+ auth.mode = 'oauth'; el.authOauth.hidden = false; el.authToken.hidden = true;
254
+ let hub;
255
+ try { hub = await import(HFJS); }
256
+ catch (e) { auth.mode = 'token'; el.authOauth.hidden = true; el.authToken.hidden = false; setStatus('OAuth 组件加载失败,改用手动 token:' + e.message, true); return; }
257
+
258
+ let res = null;
259
+ try { res = await hub.oauthHandleRedirectIfPresent(); }
260
+ catch (e) { setStatus('登录失败:' + e.message, true); }
261
+ if (res) {
262
+ ls.set(LS_OAUTH, JSON.stringify(res));
263
+ let pending = ''; try { pending = sessionStorage.getItem(SS_HASH) || ''; sessionStorage.removeItem(SS_HASH); } catch {}
264
+ history.replaceState(null, '', location.pathname + pending);
265
+ } else {
266
+ try { const saved = JSON.parse(ls.get(LS_OAUTH) || 'null'); if (saved && new Date(saved.accessTokenExpiresAt).getTime() > Date.now() + 60e3) res = saved; } catch {}
267
+ }
268
+ if (res) {
269
+ auth.accessToken = res.accessToken;
270
+ auth.user = (res.userInfo && (res.userInfo.preferred_username || res.userInfo.name)) || '';
271
+ el.signin.hidden = true; el.who.hidden = false; el.signout.hidden = false;
272
+ el.who.innerHTML = `已登录 <b>${esc(auth.user || 'HF user')}</b>`;
273
+ } else {
274
+ el.signin.hidden = false;
275
+ }
276
+ el.signin.onclick = async () => {
277
+ try {
278
+ try { sessionStorage.setItem(SS_HASH, location.hash); } catch {}
279
+ setStatus('跳转到 Hugging Face 登录…');
280
+ const url = (await hub.oauthLoginUrl({ redirectUrl: location.origin + location.pathname, scopes: vars.OAUTH_SCOPES || 'openid profile read-repos' })) + '&orgIds=' + ORG_ID;
281
+ if (window.self !== window.top) { window.open(url, '_blank'); setStatus('已在新标签页打开登录,登录后请在新标签页里继续使用'); }
282
+ else location.href = url;
283
+ } catch (e) { setStatus('无法发起登录:' + (e.message || e), true); }
284
+ };
285
+ el.signout.onclick = () => { ls.set(LS_OAUTH, ''); auth.accessToken = ''; location.reload(); };
286
+ }
287
+
288
+ /* ---------- helpers ---------- */
289
+ const esc = s => String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
290
+ const fmtN = n => n == null ? '—' : Number(n).toLocaleString('en-US');
291
+ const tryJSON = v => { if (typeof v !== 'string') return v; const t=v.trim(); if (!(t.startsWith('{')||t.startsWith('['))) return v; try { return JSON.parse(t); } catch { return v; } };
292
+ const pretty = v => typeof v === 'string' ? v : JSON.stringify(v, null, 2);
293
+ const isObj = v => v && typeof v === 'object' && !Array.isArray(v);
294
+
295
+ function setStatus(msg, err=false, progress=null){
296
+ el.status.className = 'status' + (err ? ' err' : '');
297
+ el.status.innerHTML = esc(msg) + (progress != null ? `<span class="progress"><i style="width:${Math.round(progress*100)}%"></i></span>` : '');
298
+ }
299
+ class HttpError extends Error { constructor(status, body, url){ super(`HTTP ${status}`); this.status=status; this.body=body; this.url=url; } }
300
+ async function hfFetch(url, opts={}){
301
+ const h = Object.assign({}, opts.headers||{});
302
+ const tok = bearer();
303
+ if (tok) h.Authorization = 'Bearer ' + tok;
304
+ const r = await fetch(url, Object.assign({}, opts, {headers:h}));
305
+ if (!r.ok){ let b=''; try { b = await r.text(); } catch {} throw new HttpError(r.status, b, url); }
306
+ return r;
307
+ }
308
+ function explain(e){
309
+ if (e instanceof HttpError){
310
+ let detail=''; try { detail = JSON.parse(e.body).error || ''; } catch { detail = (e.body||'').slice(0,160); }
311
+ if (e.status===401) return auth.mode==='oauth' ? '401:请先登录 Hugging Face(登录可能已过期)。' : '401:需要 token(或 token 无效)。这个页面不带 token,请粘贴你自己的。';
312
+ if (e.status===403) return '403:你的账号没有访问该数据集的权限' + (auth.mode==='oauth' ? '(登录时要勾选授权 NewsBreak 组织)。' : '。');
313
+ if (e.status===404) return '404:数据集不存在,或 viewer 后端尚未处理。' + (detail?` (${detail})`:'');
314
+ return `${e.status}: ${detail || e.message}`;
315
+ }
316
+ if (e instanceof TypeError) return '网络/CORS 错误:' + e.message;
317
+ return e.message || String(e);
318
+ }
319
+
320
+ /* ---------- URL hash (shareable, never carries the token) ---------- */
321
+ function writeHash(){
322
+ if (!S.repo) return;
323
+ const p = new URLSearchParams({repo:S.repo});
324
+ if (S.sel) { p.set('config', S.sel.config); p.set('split', S.sel.split); }
325
+ if (S.mode==='raw' && S.sel) p.set('file', S.sel.file);
326
+ p.set('idx', S.idx);
327
+ history.replaceState(null, '', '#' + p.toString());
328
+ }
329
+ function readHash(){
330
+ const p = new URLSearchParams(location.hash.slice(1));
331
+ return { repo:p.get('repo'), config:p.get('config'), split:p.get('split'), file:p.get('file'), idx:parseInt(p.get('idx')||'0',10)||0 };
332
+ }
333
+
334
+ /* ---------- dataset / splits ---------- */
335
+ async function loadDataset(want){
336
+ const repo = el.repo.value.trim().replace(/^datasets\//,'');
337
+ if (!repo) { setStatus('请输入数据集名,如 NewsBreak/local-trajectories', true); return; }
338
+ if (auth.mode==='oauth' && !auth.accessToken) { setStatus('请先点「Sign in with Hugging Face」登录', true); return; }
339
+ if (auth.mode==='token') ls.set(LS_TOKEN, el.token.value.trim());
340
+ ls.set(LS_REPO, repo);
341
+ const gen = ++S.gen; // a newer 加载 supersedes this one
342
+ Object.assign(S, {repo, splits:[], sel:null, total:null, idx:0, cache:new Map(), rawLines:null, files:[], current:null});
343
+ el.split.disabled = true; el.split.innerHTML = '<option>…</option>';
344
+ setStatus(`读取 ${repo} 的 splits…`);
345
+ try {
346
+ const r = await hfFetch(`${DS}/splits?dataset=${encodeURIComponent(repo)}`);
347
+ const j = await r.json();
348
+ S.mode = 'server';
349
+ S.splits = (j.splits||[]).map(s => ({config:s.config, split:s.split, label: (j.splits.some(o=>o.config!==s.config) ? `${s.config} / ` : '') + s.split}));
350
+ if (!S.splits.length) throw new Error('viewer 后端返回了 0 个 split');
351
+ } catch (e) {
352
+ // Fallback: list repo files and stream .jsonl directly.
353
+ setStatus(`viewer 后端不可用(${explain(e)}),改为直接读取仓库文件…`);
354
+ try {
355
+ const r = await hfFetch(`${HUB}/api/datasets/${repo}/tree/main?recursive=true`);
356
+ const files = (await r.json()).filter(f => f.type==='file' && /\.jsonl?$/i.test(f.path));
357
+ if (!files.length) throw new Error('仓库里没有 .jsonl / .json 文件');
358
+ S.mode = 'raw';
359
+ S.splits = files.map(f => ({config:'raw', split:f.path.replace(/\.jsonl?$/i,''), file:f.path, size:f.size, label:`${f.path} (${(f.size/1e6).toFixed(1)} MB)`}));
360
+ } catch (e2) { if (gen===S.gen) setStatus(explain(e2), true); return; }
361
+ }
362
+ if (gen !== S.gen) return;
363
+ el.split.innerHTML = S.splits.map((s,i)=>`<option value="${i}">${esc(s.label)}</option>`).join('');
364
+ el.split.disabled = false;
365
+ let pick = 0;
366
+ if (want){
367
+ const i = S.splits.findIndex(s => (want.file && s.file===want.file) || (s.split===want.split && (!want.config || s.config===want.config)));
368
+ if (i>=0) pick = i;
369
+ }
370
+ el.split.value = String(pick);
371
+ setStatus(`${repo} · ${S.splits.length} 个 split · ${S.mode==='server'?'viewer API':'直接读文件'}`);
372
+ await selectSplit(pick, want ? want.idx : 0);
373
+ }
374
+
375
+ async function selectSplit(i, idx=0){
376
+ S.sel = S.splits[i]; S.cache = new Map(); S.rawLines = null; S.total = null; S.idx = idx;
377
+ el.count.textContent = '/ …';
378
+ for (const b of [el.prev, el.next, el.rand, el.idx]) b.disabled = false;
379
+ if (S.mode==='raw') await streamRaw(S.sel);
380
+ await showRow(idx);
381
+ }
382
+
383
+ /* ---------- rows: datasets-server ---------- */
384
+ async function fetchPage(offset){
385
+ const {config, split} = S.sel;
386
+ const url = `${DS}/rows?dataset=${encodeURIComponent(S.repo)}&config=${encodeURIComponent(config)}&split=${encodeURIComponent(split)}&offset=${offset}&length=${PAGE}`;
387
+ const j = await (await hfFetch(url)).json();
388
+ S.total = j.num_rows_total; S.features = j.features || [];
389
+ for (const r of j.rows) S.cache.set(r.row_idx, {row:r.row, truncated:r.truncated_cells||[]});
390
+ }
391
+ async function getRow(idx){
392
+ if (S.mode==='raw') return S.rawLines[idx] ? {row:S.rawLines[idx], truncated:[]} : null;
393
+ if (!S.cache.has(idx)) await fetchPage(Math.floor(idx/PAGE)*PAGE);
394
+ return S.cache.get(idx) || null;
395
+ }
396
+
397
+ /* ---------- rows: raw .jsonl streaming (fallback) ---------- */
398
+ async function streamRaw(sel){
399
+ const url = `${HUB}/datasets/${S.repo}/resolve/main/${sel.file}`;
400
+ setStatus(`下载 ${sel.file}…`, false, 0);
401
+ const r = await hfFetch(url);
402
+ const reader = r.body.getReader(); const dec = new TextDecoder();
403
+ const total = sel.size || Number(r.headers.get('content-length')) || 0;
404
+ let got = 0, buf = '', rows = [];
405
+ S.rawLines = rows;
406
+ for (;;) {
407
+ const {done, value} = await reader.read();
408
+ if (done) break;
409
+ got += value.length; buf += dec.decode(value, {stream:true});
410
+ let nl;
411
+ while ((nl = buf.indexOf('\n')) >= 0) {
412
+ const line = buf.slice(0, nl).trim(); buf = buf.slice(nl+1);
413
+ if (line) { try { rows.push(JSON.parse(line)); } catch { rows.push({_parse_error:line.slice(0,200)}); } }
414
+ }
415
+ S.total = rows.length; el.count.textContent = `/ ${fmtN(rows.length)}…`;
416
+ setStatus(`下载 ${sel.file}… ${(got/1e6).toFixed(1)} MB,${fmtN(rows.length)} 行`, false, total ? got/total : null);
417
+ }
418
+ const tail = buf.trim(); if (tail) { try { rows.push(JSON.parse(tail)); } catch {} }
419
+ S.total = rows.length;
420
+ setStatus(`${sel.file} · ${fmtN(rows.length)} 行(已全部载入浏览器内存)`);
421
+ }
422
+
423
+ /* ---------- conversation detection & normalisation ---------- */
424
+ const CONV_KEYS = ['messages','conversations','conversation','chat','turns','trajectory','dialog','dialogue'];
425
+ const FROM_MAP = {human:'user', user:'user', gpt:'assistant', assistant:'assistant', model:'assistant', bot:'assistant',
426
+ system:'system', developer:'system', tool:'tool', observation:'tool', toolresult:'tool', tool_result:'tool', function:'tool', function_call:'assistant'};
427
+ const isMsgList = v => Array.isArray(v) && v.length>0 && v.every(m => isObj(m) && (('role' in m) || ('from' in m)));
428
+
429
+ function findConversation(row){
430
+ const keys = [...CONV_KEYS.filter(k=>k in row), ...Object.keys(row).filter(k=>!CONV_KEYS.includes(k))];
431
+ for (const k of keys){ const v = tryJSON(row[k]); if (isMsgList(v)) return {key:k, msgs:v}; }
432
+ // prompt/completion, chosen/rejected style
433
+ const sections = [];
434
+ for (const k of ['prompt','completion','chosen','rejected']) { const v = tryJSON(row[k]); if (isMsgList(v)) sections.push({key:k, msgs:v}); }
435
+ if (sections.length) return {key:sections.map(s=>s.key).join(' + '), sections};
436
+ return null;
437
+ }
438
+
439
+ function normMsg(m){
440
+ const role = (m.role || FROM_MAP[String(m.from||'').toLowerCase()] || m.from || 'unknown').toLowerCase();
441
+ let content = m.content ?? m.value ?? m.text ?? '';
442
+ let reasoning = m.reasoning ?? m.reasoning_content ?? m.reasoningContent ?? m.thinking ?? m.thought ?? '';
443
+ let calls = m.tool_calls ?? m.toolCalls ?? m.function_call ?? null;
444
+ if (calls && !Array.isArray(calls)) calls = [calls];
445
+ calls = calls ? calls.slice() : [];
446
+ // Anthropic-style content parts
447
+ if (Array.isArray(content)){
448
+ const texts = [];
449
+ for (const p of content){
450
+ if (!isObj(p)) { texts.push(String(p)); continue; }
451
+ const t = p.type;
452
+ if (t==='text' || t==='output_text' || t==='input_text') texts.push(p.text ?? '');
453
+ else if (t==='thinking' || t==='reasoning') reasoning = (reasoning ? reasoning+'\n\n' : '') + (p.thinking ?? p.text ?? '');
454
+ else if (t==='tool_use' || t==='toolCall' || t==='tool_call' || t==='function_call') calls.push(p);
455
+ else if (t==='tool_result' || t==='toolResult') texts.push(typeof p.content==='string' ? p.content : pretty(p.content));
456
+ else if (t==='image' || t==='image_url') texts.push('[image]');
457
+ else texts.push(pretty(p));
458
+ }
459
+ content = texts.join('\n');
460
+ } else if (content != null && typeof content !== 'string') content = pretty(content);
461
+ if (!reasoning && m.thinking_blocks) reasoning = (Array.isArray(m.thinking_blocks)?m.thinking_blocks:[m.thinking_blocks]).map(b => isObj(b) ? (b.thinking ?? b.text ?? '') : String(b)).filter(Boolean).join('\n\n');
462
+ // <think>…</think> embedded in content
463
+ if (!reasoning && typeof content==='string'){
464
+ const mt = content.match(/^\s*<think>([\s\S]*?)<\/think>\s*/);
465
+ if (mt) { reasoning = mt[1].trim(); content = content.slice(mt[0].length); }
466
+ }
467
+ const toolCalls = calls.map(c => {
468
+ const f = c.function || {};
469
+ let args = c.arguments ?? f.arguments ?? c.args ?? c.input ?? c.parameters ?? null;
470
+ args = tryJSON(args);
471
+ return { id: c.id ?? c.tool_call_id ?? c.call_id ?? '', name: c.name ?? f.name ?? c.tool ?? '(unnamed)', args, type: c.type };
472
+ });
473
+ return { role, content: String(content ?? ''), reasoning: String(reasoning ?? ''), toolCalls,
474
+ toolCallId: m.tool_call_id ?? m.toolCallId ?? m.call_id ?? '', name: m.name ?? m.toolName ?? m.tool_name ?? '', raw: m };
475
+ }
476
+
477
+ /* ---------- rendering ---------- */
478
+ function clipBlock(text, cls='t'){
479
+ const s = String(text);
480
+ if (s.length <= CLIP) return `<div class="${cls}">${esc(s)}</div>`;
481
+ const id = 'c' + Math.random().toString(36).slice(2,9);
482
+ return `<div class="${cls}" id="${id}" data-full="${esc(s)}">${esc(s.slice(0,CLIP))}<span class="muted">…</span></div>
483
+ <div class="more"><button class="btn" data-expand="${id}">展开全部 (${fmtN(s.length)} 字符)</button></div>`;
484
+ }
485
+ function renderTurn(m, i, opts){
486
+ const parts = [];
487
+ if (m.reasoning) parts.push(`<details class="bubble reason" ${opts.reason?'open':''}><summary>reasoning<span class="hint">${fmtN(m.reasoning.length)} 字符</span></summary>${clipBlock(m.reasoning)}</details>`);
488
+ if (m.content.trim()) parts.push(`<div class="bubble text">${clipBlock(m.content)}</div>`);
489
+ for (const c of m.toolCalls){
490
+ parts.push(`<div class="bubble call"><div class="head"><span>→</span><b>${esc(c.name)}</b>${c.id?`<span class="id">${esc(c.id)}</span>`:''}</div>${c.args!=null ? `<pre>${esc(pretty(c.args))}</pre>` : ''}</div>`);
491
+ }
492
+ const meta = [];
493
+ if (m.role==='tool' && (m.name||m.toolCallId)) meta.push(`${m.name?esc(m.name):''}${m.toolCallId?` ← ${esc(m.toolCallId)}`:''}`);
494
+ if (!parts.length) parts.push(`<div class="bubble text"><span class="help">(空消息)</span></div>`);
495
+ return `<section class="turn" data-role="${esc(m.role)}"><div class="who"><span class="pill">${esc(m.role)}</span><span class="n">#${i}</span></div>
496
+ <div class="body">${meta.length?`<div class="meta">${meta.join(' ')}</div>`:''}${parts.join('')}</div></section>`;
497
+ }
498
+ function renderMessages(msgs, opts){
499
+ let hidden = 0, out = [];
500
+ msgs.forEach((raw, i) => {
501
+ const m = normMsg(raw);
502
+ if ((opts.hideTools && m.role==='tool') || (opts.hideSystem && m.role==='system')) { hidden++; return; }
503
+ if (hidden) { out.push(`<div class="hidden-note">已隐藏 ${hidden} 条</div>`); hidden = 0; }
504
+ out.push(renderTurn(m, i, opts));
505
+ });
506
+ if (hidden) out.push(`<div class="hidden-note">已隐藏 ${hidden} 条</div>`);
507
+ return out.join('');
508
+ }
509
+
510
+ function renderRail(row, convKey, truncated){
511
+ // row meta
512
+ const conv = convKey ? tryJSON(row[convKey]) : null;
513
+ const n = Array.isArray(conv) ? conv.length : null;
514
+ const roles = Array.isArray(conv) ? conv.reduce((a,m)=>{ const r=normMsg(m).role; a[r]=(a[r]||0)+1; return a; },{}) : null;
515
+ const kv = [
516
+ ['行', `<span class="num">${fmtN(S.idx)}</span> / ${fmtN(S.total)}`],
517
+ ['split', esc(S.sel.label)],
518
+ ['来源', S.mode==='server' ? 'viewer API(分页)' : '原始 .jsonl'],
519
+ ];
520
+ if (convKey) kv.push(['messages 列', `<code>${esc(convKey)}</code>`]);
521
+ if (n != null) kv.push(['轮次', `${n}` + (roles ? ` <span class="help">(${Object.entries(roles).map(([r,c])=>`${r} ${c}`).join(', ')})</span>` : '')]);
522
+ if (truncated.length) kv.push(['⚠ 截断', `viewer API 截断了:${truncated.map(esc).join(', ')}`]);
523
+ el.rowmeta.innerHTML = kv.map(([k,v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('');
524
+
525
+ // tools
526
+ const tools = tryJSON(row.tools);
527
+ if (Array.isArray(tools) && tools.length){
528
+ el.toolsCard.hidden = false; el.toolsN.textContent = `(${tools.length})`;
529
+ el.toolsList.innerHTML = tools.map(t => {
530
+ const f = t.function || t; const name = f.name || t.name || '(unnamed)'; const d = f.description || t.description || '';
531
+ return `<details><summary><b>${esc(name)}</b></summary>${d?`<div class="d">${esc(d)}</div>`:''}<pre>${esc(pretty(f.parameters || f.input_schema || t))}</pre></details>`;
532
+ }).join('');
533
+ } else el.toolsCard.hidden = true;
534
+
535
+ // other columns
536
+ const skip = new Set([convKey, 'tools']);
537
+ const cols = Object.keys(row).filter(k => !skip.has(k));
538
+ el.colsCard.hidden = !cols.length;
539
+ el.cols.innerHTML = cols.map(k => {
540
+ const v = row[k]; const p = tryJSON(v);
541
+ if (v == null) return `<details><summary>${esc(k)}<span class="hint">null</span></summary></details>`;
542
+ if (typeof p === 'object') return `<details><summary>${esc(k)}<span class="hint">${Array.isArray(p)?`array[${p.length}]`:'object'}</span></summary><pre>${esc(pretty(p))}</pre></details>`;
543
+ const s = String(v);
544
+ if (s.length <= 80 && !s.includes('\n')) return `<details><summary>${esc(k)}<span class="hint" style="max-width:55%;overflow:hidden;text-overflow:ellipsis">${esc(s)}</span></summary></details>`;
545
+ return `<details><summary>${esc(k)}<span class="hint">${fmtN(s.length)} 字符</span></summary><pre>${esc(s)}</pre></details>`;
546
+ }).join('');
547
+ }
548
+
549
+ function paint(){
550
+ const cur = S.current; if (!cur) return;
551
+ const {row, truncated} = cur;
552
+ const opts = { reason: el.optReason.checked, hideTools: el.optTools.checked, hideSystem: el.optSystem.checked };
553
+ const conv = findConversation(row);
554
+ renderRail(row, conv && !conv.sections ? conv.key : null, truncated);
555
+ let html = '';
556
+ if (el.optRaw.checked) html += `<div class="rawrow"><div class="section-h">原始行 JSON</div><pre>${esc(JSON.stringify(row, (k,v)=>tryJSON(v), 2))}</pre></div>`;
557
+ if (!conv) html += `<div class="empty">这一行没有可识别的 messages 列(找 role/from 的对象数组)。打开「显示整行原始 JSON」看结构。</div>`;
558
+ else if (conv.sections) html += conv.sections.map(s => `<div class="section-h">${esc(s.key)}</div>${renderMessages(s.msgs, opts)}`).join('');
559
+ else html += renderMessages(conv.msgs, opts);
560
+ el.main.innerHTML = html;
561
+ window.scrollTo({top:0});
562
+ }
563
+
564
+ async function showRow(idx){
565
+ if (S.total != null) idx = Math.max(0, Math.min(idx, S.total-1));
566
+ idx = Math.max(0, idx|0);
567
+ S.idx = idx; el.idx.value = idx; S.loadingRow = idx;
568
+ try {
569
+ const r = await getRow(idx);
570
+ if (S.loadingRow !== idx) return; // superseded by a newer navigation
571
+ if (!r) { setStatus(`第 ${idx} 行不存在`, true); return; }
572
+ S.current = r; el.count.textContent = `/ ${fmtN(S.total)}`;
573
+ el.prev.disabled = idx<=0; el.next.disabled = S.total!=null && idx>=S.total-1;
574
+ paint(); writeHash();
575
+ if (S.mode==='server') setStatus(`${S.repo} · ${S.sel.label} · 第 ${fmtN(idx)} 行`);
576
+ } catch (e) { setStatus(explain(e), true); }
577
+ }
578
+
579
+ /* ---------- events ---------- */
580
+ el.load.onclick = () => loadDataset(null);
581
+ el.repo.addEventListener('keydown', e => { if (e.key==='Enter') loadDataset(null); });
582
+ el.token.addEventListener('keydown', e => { if (e.key==='Enter') loadDataset(null); });
583
+ el.split.onchange = () => selectSplit(parseInt(el.split.value,10), 0);
584
+ el.prev.onclick = () => showRow(S.idx-1);
585
+ el.next.onclick = () => showRow(S.idx+1);
586
+ el.rand.onclick = () => { if (S.total) showRow(Math.floor(Math.random()*S.total)); };
587
+ el.idx.addEventListener('change', () => showRow(parseInt(el.idx.value,10)||0));
588
+ for (const o of [el.optReason, el.optTools, el.optSystem, el.optRaw]) o.addEventListener('change', paint);
589
+ el.main.addEventListener('click', e => {
590
+ const b = e.target.closest('[data-expand]'); if (!b) return;
591
+ const box = document.getElementById(b.dataset.expand); if (!box) return;
592
+ box.textContent = box.dataset.full; b.parentElement.remove();
593
+ });
594
+ document.addEventListener('keydown', e => {
595
+ if (e.target.matches('input,select,textarea') || !S.sel) return;
596
+ if (e.key==='ArrowLeft') { e.preventDefault(); el.prev.click(); }
597
+ else if (e.key==='ArrowRight') { e.preventDefault(); el.next.click(); }
598
+ else if (e.key==='r') el.rand.click();
599
+ });
600
+
601
+ /* ---------- boot: resolve auth, then restore from hash if we can already authenticate ---------- */
602
+ (async () => {
603
+ await initAuth();
604
+ const h = readHash();
605
+ if (h.repo) el.repo.value = h.repo;
606
+ if (h.repo && bearer()) loadDataset(h);
607
+ })();
608
+ })();
609
+ </script>
610
+ </body>
611
  </html>