Spaces:
Running
Running
Download index.html from NewsBreak/trajectory-reader: direct link, hf CLI and curl.
- Browser
- Download file 35.6 kB
-
https://huggingface.co/spaces/NewsBreak/trajectory-reader/resolve/main/index.html
- Command line
-
hf download hf://spaces/NewsBreak/trajectory-reader/index.html
-
curl -L -o index.html https://huggingface.co/spaces/NewsBreak/trajectory-reader/resolve/main/index.html
35.6 kB
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Trajectory Reader</title> | |
| <!-- | |
| Trajectory Reader — render the `messages` list of any Hugging Face dataset row. | |
| Deployed as the static Space NewsBreak/trajectory-reader. No server, no bundled | |
| data, no bundled token, and no token box: viewers sign in with their own Hugging | |
| Face account (OAuth PKCE, scope read-repos, restricted to NewsBreak members) and | |
| every request goes straight from their browser to huggingface.co carrying THEIR | |
| access token. So the Hub's own permission check applies: someone without access | |
| to a private dataset gets 401/403 and sees nothing. Nobody ever types a | |
| credential into this page. | |
| Data path: datasets-server.huggingface.co /splits + /rows (paged, no download). | |
| Fallback when the dataset isn't processed by the viewer backend: stream the | |
| raw .jsonl via /resolve and split lines client-side. | |
| --> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <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"> | |
| <style> | |
| :root{ | |
| --bg:#F6F5F1; --panel:#FFFFFF; --ink:#1B1A17; --muted:#6E6A60; --line:#DDD9CF; --line-soft:#ECE9E1; | |
| --accent:#0F8A78; --accent-ink:#0B6B5D; --danger:#B4373A; | |
| --user:#3D5AD6; --assistant:#0F8A78; --tool:#B0700E; --system:#7A766C; --reason:#7A5AB8; | |
| --user-bg:#EEF1FC; --assistant-bg:#EAF5F2; --tool-bg:#FBF3E4; --system-bg:#F0EFEA; --reason-bg:#F3EFFA; | |
| --code-bg:#F1EFE9; --shadow:0 1px 2px rgba(27,26,23,.06); | |
| --sans:"IBM Plex Sans",system-ui,-apple-system,"Segoe UI",sans-serif; | |
| --mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; | |
| } | |
| @media (prefers-color-scheme: dark){ | |
| :root:not([data-theme="light"]){ | |
| --bg:#17181C; --panel:#1F2126; --ink:#ECEAE3; --muted:#9A968C; --line:#2E3038; --line-soft:#272930; | |
| --accent:#3FB8A4; --accent-ink:#6FD3C2; --danger:#E06B6E; | |
| --user:#8C9DF0; --assistant:#4FC2AD; --tool:#E0A54A; --system:#A5A197; --reason:#B39BE6; | |
| --user-bg:#20253A; --assistant-bg:#1B2E2B; --tool-bg:#302819; --system-bg:#24262B; --reason-bg:#2A2436; | |
| --code-bg:#15161A; --shadow:none; | |
| } | |
| } | |
| :root[data-theme="dark"]{ | |
| --bg:#17181C; --panel:#1F2126; --ink:#ECEAE3; --muted:#9A968C; --line:#2E3038; --line-soft:#272930; | |
| --accent:#3FB8A4; --accent-ink:#6FD3C2; --danger:#E06B6E; | |
| --user:#8C9DF0; --assistant:#4FC2AD; --tool:#E0A54A; --system:#A5A197; --reason:#B39BE6; | |
| --user-bg:#20253A; --assistant-bg:#1B2E2B; --tool-bg:#302819; --system-bg:#24262B; --reason-bg:#2A2436; | |
| --code-bg:#15161A; --shadow:none; | |
| } | |
| *{box-sizing:border-box} | |
| [hidden]{display:none} | |
| html,body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 var(--sans)} | |
| button,input,select{font:inherit;color:inherit} | |
| a{color:var(--accent-ink)} | |
| :focus-visible{outline:2px solid var(--accent);outline-offset:2px} | |
| @media (prefers-reduced-motion: reduce){*{transition:none;animation:none}} | |
| /* ---------- toolbar ---------- */ | |
| .bar{position:sticky;top:0;z-index:5;background:var(--panel);border-bottom:1px solid var(--line);padding-block:10px;padding-inline:20px} | |
| .bar-row{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;max-width:1400px;margin:0 auto} | |
| .brand{font-weight:600;letter-spacing:.01em;margin-right:6px;white-space:nowrap} | |
| .brand small{color:var(--muted);font-weight:400;margin-left:6px} | |
| .field{display:flex;align-items:center;gap:6px} | |
| .field label{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.06em;white-space:nowrap} | |
| input[type=text],input[type=password],input[type=number],select{ | |
| background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:6px 9px;min-width:0} | |
| input[type=text]{width:300px;max-width:100%} | |
| input[type=password]{width:210px} | |
| input[type=number]{width:84px;font-variant-numeric:tabular-nums} | |
| select{max-width:260px} | |
| .btn{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:6px 11px;cursor:pointer;white-space:nowrap} | |
| .btn:hover{border-color:var(--accent)} | |
| .btn.primary{background:var(--accent);border-color:var(--accent);color:#fff} | |
| .btn.primary:hover{filter:brightness(1.05)} | |
| .btn.quiet{border-color:transparent;color:var(--muted)} | |
| .btn:disabled{opacity:.45;cursor:default} | |
| a.btn{text-decoration:none;display:inline-block} | |
| .btn.hf{background:#FFD21E;border-color:#E8BE10;color:#1B1A17;font-weight:600} | |
| .btn.hf::before{content:"🤗";margin-right:6px} | |
| .who-name{color:var(--muted);font-size:13px;white-space:nowrap} | |
| .who-name b{color:var(--ink);font-weight:500} | |
| .nav{display:flex;align-items:center;gap:6px;margin-left:auto} | |
| .nav .count{color:var(--muted);font-variant-numeric:tabular-nums;white-space:nowrap} | |
| .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} | |
| .status.err{color:var(--danger)} | |
| .progress{height:3px;background:var(--line-soft);border-radius:2px;flex:1 1 160px;overflow:hidden} | |
| .progress i{display:block;height:100%;width:0;background:var(--accent);transition:width .2s} | |
| /* ---------- layout ---------- */ | |
| .wrap{max-width:1400px;margin:0 auto;padding-block:18px;padding-inline:20px;display:grid;grid-template-columns:300px minmax(0,1fr);gap:22px} | |
| @media (max-width:900px){.wrap{grid-template-columns:1fr}} | |
| .rail{display:flex;flex-direction:column;gap:14px;align-self:start;position:sticky;top:74px;max-height:calc(100vh - 90px);overflow:auto} | |
| @media (max-width:900px){.rail{position:static;max-height:none}} | |
| .card{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px 14px;box-shadow:var(--shadow)} | |
| .card h3{margin:0 0 8px;font-size:12px;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);font-weight:600} | |
| .kv{display:grid;grid-template-columns:auto minmax(0,1fr);gap:4px 10px;font-size:13px} | |
| .kv dt{color:var(--muted);white-space:nowrap} | |
| .kv dd{margin:0;overflow-wrap:anywhere} | |
| .kv dd.num{font-variant-numeric:tabular-nums} | |
| .opts{display:flex;flex-direction:column;gap:6px;font-size:13px} | |
| .opts label{display:flex;align-items:center;gap:8px;cursor:pointer} | |
| details{border-top:1px solid var(--line-soft)} | |
| details:first-of-type{border-top:0} | |
| summary{cursor:pointer;padding:5px 0;color:var(--ink);list-style:none;display:flex;gap:8px;align-items:baseline} | |
| summary::-webkit-details-marker{display:none} | |
| summary::before{content:"▸";color:var(--muted);font-size:11px;flex:none} | |
| details[open]>summary::before{content:"▾"} | |
| summary .hint{color:var(--muted);font-size:12px;margin-left:auto;white-space:nowrap} | |
| 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} | |
| .rail pre{max-height:260px} | |
| /* ---------- transcript ---------- */ | |
| .main{min-width:0;max-width:880px} | |
| .empty{color:var(--muted);padding:40px 0;text-align:center} | |
| .turn{display:grid;grid-template-columns:82px minmax(0,1fr);gap:12px;padding:12px 0;border-top:1px solid var(--line-soft)} | |
| .turn:first-child{border-top:0} | |
| .who{display:flex;flex-direction:column;gap:3px;align-items:flex-start} | |
| .pill{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;padding:2px 8px;border-radius:999px;color:#fff;background:var(--system)} | |
| .turn[data-role=user] .pill{background:var(--user)} | |
| .turn[data-role=assistant] .pill{background:var(--assistant)} | |
| .turn[data-role=tool] .pill{background:var(--tool)} | |
| .who .n{color:var(--muted);font-size:11px;font-variant-numeric:tabular-nums} | |
| .body{display:flex;flex-direction:column;gap:8px;min-width:0} | |
| .bubble{border-radius:8px;padding:10px 12px;background:var(--system-bg);border:1px solid transparent} | |
| .turn[data-role=user] .bubble.text{background:var(--user-bg)} | |
| .turn[data-role=assistant] .bubble.text{background:var(--assistant-bg)} | |
| .turn[data-role=tool] .bubble.text{background:var(--tool-bg)} | |
| .text .t{white-space:pre-wrap;overflow-wrap:anywhere} | |
| .reason{background:var(--reason-bg);border-left:3px solid var(--reason)} | |
| .reason summary{color:var(--reason);font-weight:500} | |
| .reason .t{white-space:pre-wrap;overflow-wrap:anywhere;color:var(--ink);opacity:.9;padding:4px 0 2px} | |
| .call{background:var(--tool-bg);border-left:3px solid var(--tool)} | |
| .call .head{display:flex;gap:8px;align-items:baseline;flex-wrap:wrap;font-family:var(--mono);font-size:12.5px} | |
| .call .head b{color:var(--tool);font-weight:600} | |
| .call .head .id{color:var(--muted);font-size:11px;margin-left:auto} | |
| .call pre{margin-top:6px;background:transparent;padding:0;max-height:360px} | |
| .call.bad{border-left-color:var(--danger)} | |
| .call.bad .head b{color:var(--danger)} | |
| .call .warn{margin-top:6px;font-size:12px;color:var(--danger)} | |
| .meta{font-family:var(--mono);font-size:11.5px;color:var(--muted);display:flex;gap:10px;flex-wrap:wrap} | |
| .more{margin-top:6px} | |
| .more .btn{font-size:12px;padding:3px 9px} | |
| .hidden-note{color:var(--muted);font-size:12px;padding:6px 0;border-top:1px dashed var(--line)} | |
| .section-h{font-size:12px;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);font-weight:600;margin:14px 0 4px} | |
| .rawrow pre{max-height:70vh} | |
| kbd{font:11px var(--mono);background:var(--code-bg);border:1px solid var(--line);border-bottom-width:2px;border-radius:4px;padding:0 5px} | |
| .help{color:var(--muted);font-size:12px} | |
| .help kbd{margin:0 2px} | |
| .tools-list{display:flex;flex-direction:column} | |
| .tools-list summary b{font-family:var(--mono);font-weight:500} | |
| .tools-list .d{color:var(--muted);font-size:12px;padding-bottom:6px} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="bar"> | |
| <div class="bar-row"> | |
| <div class="brand">Trajectory Reader<small>HF messages</small></div> | |
| <div class="field"><label for="repo">dataset</label><input id="repo" type="text" placeholder="org/name" spellcheck="false"></div> | |
| <div class="field" id="authOauth" hidden> | |
| <a id="signin" class="btn hf" href="#" target="_blank" rel="noopener" hidden>Sign in with Hugging Face</a> | |
| <span id="who" class="who-name" hidden></span> | |
| <button id="signout" class="btn quiet" hidden>退出</button> | |
| </div> | |
| <button id="load" class="btn primary">加载</button> | |
| <div class="field"><label for="split">split</label><select id="split" disabled><option>—</option></select></div> | |
| <div class="nav"> | |
| <button id="prev" class="btn" disabled title="上一行 (←)">‹</button> | |
| <input id="idx" type="number" min="0" value="0" disabled> | |
| <span class="count" id="count">/ —</span> | |
| <button id="next" class="btn" disabled title="下一行 (→)">›</button> | |
| <button id="rand" class="btn" disabled title="随机 (r)">随机</button> | |
| </div> | |
| </div> | |
| <div class="status" id="status"></div> | |
| </div> | |
| <div class="wrap"> | |
| <aside class="rail"> | |
| <div class="card"> | |
| <h3>行信息</h3> | |
| <dl class="kv" id="rowmeta"><dt>状态</dt><dd>尚未加载</dd></dl> | |
| </div> | |
| <div class="card"> | |
| <h3>显示</h3> | |
| <div class="opts"> | |
| <label><input type="checkbox" id="optReason" checked> 默认展开 reasoning</label> | |
| <label><input type="checkbox" id="optTools"> 隐藏 tool 返回</label> | |
| <label><input type="checkbox" id="optSystem"> 隐藏 system</label> | |
| <label><input type="checkbox" id="optRaw"> 显示整行原始 JSON</label> | |
| </div> | |
| <p class="help" style="margin:10px 0 0"><kbd>←</kbd><kbd>→</kbd> 翻行 · <kbd>r</kbd> 随机</p> | |
| </div> | |
| <div class="card" id="toolsCard" hidden> | |
| <h3>tools <span id="toolsN"></span></h3> | |
| <div class="tools-list" id="toolsList"></div> | |
| </div> | |
| <div class="card" id="colsCard" hidden> | |
| <h3>其它列</h3> | |
| <div id="cols"></div> | |
| </div> | |
| </aside> | |
| <main class="main" id="main"> | |
| <div class="empty" id="intro"></div> | |
| </main> | |
| </div> | |
| <script> | |
| (() => { | |
| ; | |
| const DS = 'https://datasets-server.huggingface.co'; | |
| const HUB = 'https://huggingface.co'; | |
| const PAGE = 20; // rows per /rows request (server max 100) | |
| const CLIP = 1800; // chars shown before "展开" on long blocks | |
| const LS_REPO = 'trajreader.repo', LS_OAUTH = 'trajreader.oauth', SS_HASH = 'trajreader.pendingHash'; | |
| const HFJS = 'https://cdn.jsdelivr.net/npm/@huggingface/hub@2.17.1/+esm'; // OAuth PKCE helper (Space mode only) | |
| const ORG_ID = '6a2a067c086133dad7d362e4'; // NewsBreak — preselects the org on the consent screen | |
| const $ = id => document.getElementById(id); | |
| const el = { | |
| repo: $('repo'), load: $('load'), split: $('split'), | |
| authOauth: $('authOauth'), signin: $('signin'), signout: $('signout'), who: $('who'), intro: $('intro'), | |
| prev: $('prev'), next: $('next'), rand: $('rand'), idx: $('idx'), count: $('count'), | |
| status: $('status'), rowmeta: $('rowmeta'), main: $('main'), cols: $('cols'), colsCard: $('colsCard'), | |
| toolsCard: $('toolsCard'), toolsList: $('toolsList'), toolsN: $('toolsN'), | |
| optReason: $('optReason'), optTools: $('optTools'), optSystem: $('optSystem'), optRaw: $('optRaw'), | |
| }; | |
| const S = { | |
| repo: '', splits: [], sel: null, total: null, idx: 0, | |
| mode: 'server', // 'server' (datasets-server /rows) | 'raw' (stream .jsonl) | |
| cache: new Map(), // row_idx -> row object | |
| rawLines: null, // raw mode: array of parsed rows | |
| files: [], // raw mode: candidate files | |
| current: null, loadingRow: null, features: [], gen: 0, | |
| }; | |
| /* ---------- storage (this browser only; never a credential typed by hand) ---------- */ | |
| const ls = { | |
| get(k){ try { return localStorage.getItem(k) || ''; } catch { return ''; } }, | |
| set(k,v){ try { v ? localStorage.setItem(k,v) : localStorage.removeItem(k); } catch {} }, | |
| }; | |
| el.repo.value = ls.get(LS_REPO) || 'NewsBreak/scout_c6_v2_norm'; | |
| /* ---------- auth: Hugging Face sign-in, and nothing else ---------- | |
| No token box on purpose: every request carries an OAuth token this page obtained | |
| for the person in front of it, so access is exactly their Hub access and no | |
| credential is ever typed into, or stored by, this page. */ | |
| const auth = { accessToken: '', user: '' }; | |
| const bearer = () => auth.accessToken; | |
| async function initAuth(){ | |
| const vars = (window.huggingface && window.huggingface.variables) || null; | |
| if (!vars || !vars.OAUTH_CLIENT_ID) { | |
| // Opened outside the Space (a local copy of the file): sign-in is impossible here. | |
| el.intro.innerHTML = '请从 <a href="https://huggingface.co/spaces/NewsBreak/trajectory-reader">Space</a> 打开'; | |
| el.load.disabled = true; | |
| return; | |
| } | |
| el.authOauth.hidden = false; | |
| el.intro.textContent = 'Login first'; | |
| let hub; | |
| try { hub = await import(HFJS); } | |
| catch (e) { setStatus('登录组件加载失败,请刷新页面重试:' + e.message, true); return; } | |
| let res = null; | |
| try { res = await hub.oauthHandleRedirectIfPresent(); } | |
| catch (e) { setStatus('登录失败:' + e.message, true); } | |
| if (res) { | |
| ls.set(LS_OAUTH, JSON.stringify(res)); | |
| let pending = ''; try { pending = sessionStorage.getItem(SS_HASH) || ''; sessionStorage.removeItem(SS_HASH); } catch {} | |
| history.replaceState(null, '', location.pathname + pending); | |
| } else { | |
| try { const saved = JSON.parse(ls.get(LS_OAUTH) || 'null'); if (saved && new Date(saved.accessTokenExpiresAt).getTime() > Date.now() + 60e3) res = saved; } catch {} | |
| } | |
| if (res) { | |
| auth.accessToken = res.accessToken; | |
| auth.user = (res.userInfo && (res.userInfo.preferred_username || res.userInfo.name)) || ''; | |
| el.signin.hidden = true; el.who.hidden = false; el.signout.hidden = false; | |
| el.intro.textContent = '点「加载」开始'; | |
| el.who.innerHTML = `已登录 <b>${esc(auth.user || 'HF user')}</b>`; | |
| } else { | |
| // Build the authorize URL NOW and hang it on a real link. Computing it inside the | |
| // click handler means awaiting a fetch first, which breaks the user-gesture chain | |
| // and gets the new tab blocked as a popup when the Space runs inside its iframe. | |
| const embedded = window.self !== window.top; | |
| try { | |
| const url = (await hub.oauthLoginUrl({ redirectUrl: location.origin + location.pathname, scopes: vars.OAUTH_SCOPES || 'openid profile read-repos' })) + '&orgIds=' + ORG_ID; | |
| el.signin.href = url; | |
| if (!embedded) el.signin.removeAttribute('target'); // top-level: navigate in place | |
| el.signin.hidden = false; | |
| el.signin.addEventListener('click', () => { | |
| try { sessionStorage.setItem(SS_HASH, location.hash); } catch {} | |
| if (embedded) setStatus('登录已在新标签页打开;登录完成后请在那个标签页里继续使用'); | |
| }); | |
| } catch (e) { | |
| setStatus('无法发起 HF 登录:' + (e.message || e), true); | |
| } | |
| } | |
| el.signout.onclick = () => { ls.set(LS_OAUTH, ''); auth.accessToken = ''; location.reload(); }; | |
| } | |
| /* ---------- helpers ---------- */ | |
| const esc = s => String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); | |
| const fmtN = n => n == null ? '—' : Number(n).toLocaleString('en-US'); | |
| 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; } }; | |
| const pretty = v => typeof v === 'string' ? v : JSON.stringify(v, null, 2); | |
| const isObj = v => v && typeof v === 'object' && !Array.isArray(v); | |
| function setStatus(msg, err=false, progress=null){ | |
| el.status.className = 'status' + (err ? ' err' : ''); | |
| el.status.innerHTML = esc(msg) + (progress != null ? `<span class="progress"><i style="width:${Math.round(progress*100)}%"></i></span>` : ''); | |
| } | |
| class HttpError extends Error { constructor(status, body, url){ super(`HTTP ${status}`); this.status=status; this.body=body; this.url=url; } } | |
| async function hfFetch(url, opts={}){ | |
| const h = Object.assign({}, opts.headers||{}); | |
| const tok = bearer(); | |
| if (tok) h.Authorization = 'Bearer ' + tok; | |
| const r = await fetch(url, Object.assign({}, opts, {headers:h})); | |
| if (!r.ok){ let b=''; try { b = await r.text(); } catch {} throw new HttpError(r.status, b, url); } | |
| return r; | |
| } | |
| function explain(e){ | |
| if (e instanceof HttpError){ | |
| let detail=''; try { detail = JSON.parse(e.body).error || ''; } catch { detail = (e.body||'').slice(0,160); } | |
| if (e.status===401) return '401:请先登录 Hugging Face(登录可能已过期,点「退出」再登录一次)。'; | |
| if (e.status===403) return '403:你的账号没有访问该数据集的权限(登录时要勾选授权 NewsBreak 组织)。'; | |
| if (e.status===404) return '404:数据集不存在,或 viewer 后端尚未处理。' + (detail?` (${detail})`:''); | |
| return `${e.status}: ${detail || e.message}`; | |
| } | |
| if (e instanceof TypeError) return '网络/CORS 错误:' + e.message; | |
| return e.message || String(e); | |
| } | |
| /* ---------- URL hash (shareable; carries no credential) ---------- */ | |
| function writeHash(){ | |
| if (!S.repo) return; | |
| const p = new URLSearchParams({repo:S.repo}); | |
| if (S.sel) { p.set('config', S.sel.config); p.set('split', S.sel.split); } | |
| if (S.mode==='raw' && S.sel) p.set('file', S.sel.file); | |
| p.set('idx', S.idx); | |
| history.replaceState(null, '', '#' + p.toString()); | |
| } | |
| function readHash(){ | |
| const p = new URLSearchParams(location.hash.slice(1)); | |
| 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 }; | |
| } | |
| /* ---------- dataset / splits ---------- */ | |
| async function loadDataset(want){ | |
| const repo = el.repo.value.trim().replace(/^datasets\//,''); | |
| if (!repo) { setStatus('请输入数据集名,如 NewsBreak/scout_c6_v2_norm', true); return; } | |
| if (!auth.accessToken) { setStatus('请先点「Sign in with Hugging Face」登录', true); return; } | |
| ls.set(LS_REPO, repo); | |
| const gen = ++S.gen; // a newer 加载 supersedes this one | |
| Object.assign(S, {repo, splits:[], sel:null, total:null, idx:0, cache:new Map(), rawLines:null, files:[], current:null}); | |
| el.split.disabled = true; el.split.innerHTML = '<option>…</option>'; | |
| setStatus(`读取 ${repo} 的 splits…`); | |
| try { | |
| const r = await hfFetch(`${DS}/splits?dataset=${encodeURIComponent(repo)}`); | |
| const j = await r.json(); | |
| S.mode = 'server'; | |
| 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})); | |
| if (!S.splits.length) throw new Error('viewer 后端返回了 0 个 split'); | |
| } catch (e) { | |
| // Fallback: list repo files and stream .jsonl directly. | |
| setStatus(`viewer 后端不可用(${explain(e)}),改为直接读取仓库文件…`); | |
| try { | |
| const r = await hfFetch(`${HUB}/api/datasets/${repo}/tree/main?recursive=true`); | |
| const files = (await r.json()).filter(f => f.type==='file' && /\.jsonl?$/i.test(f.path)); | |
| if (!files.length) throw new Error('仓库里没有 .jsonl / .json 文件'); | |
| S.mode = 'raw'; | |
| 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)`})); | |
| } catch (e2) { if (gen===S.gen) setStatus(explain(e2), true); return; } | |
| } | |
| if (gen !== S.gen) return; | |
| el.split.innerHTML = S.splits.map((s,i)=>`<option value="${i}">${esc(s.label)}</option>`).join(''); | |
| el.split.disabled = false; | |
| let pick = 0; | |
| if (want){ | |
| const i = S.splits.findIndex(s => (want.file && s.file===want.file) || (s.split===want.split && (!want.config || s.config===want.config))); | |
| if (i>=0) pick = i; | |
| } | |
| el.split.value = String(pick); | |
| setStatus(`${repo} · ${S.splits.length} 个 split · ${S.mode==='server'?'viewer API':'直接读文件'}`); | |
| await selectSplit(pick, want ? want.idx : 0); | |
| } | |
| async function selectSplit(i, idx=0){ | |
| S.sel = S.splits[i]; S.cache = new Map(); S.rawLines = null; S.total = null; S.idx = idx; | |
| el.count.textContent = '/ …'; | |
| for (const b of [el.prev, el.next, el.rand, el.idx]) b.disabled = false; | |
| if (S.mode==='raw') await streamRaw(S.sel); | |
| await showRow(idx); | |
| } | |
| /* ---------- rows: datasets-server ---------- */ | |
| async function fetchPage(offset){ | |
| const {config, split} = S.sel; | |
| const url = `${DS}/rows?dataset=${encodeURIComponent(S.repo)}&config=${encodeURIComponent(config)}&split=${encodeURIComponent(split)}&offset=${offset}&length=${PAGE}`; | |
| const j = await (await hfFetch(url)).json(); | |
| S.total = j.num_rows_total; S.features = j.features || []; | |
| for (const r of j.rows) S.cache.set(r.row_idx, {row:r.row, truncated:r.truncated_cells||[]}); | |
| } | |
| async function getRow(idx){ | |
| if (S.mode==='raw') return S.rawLines[idx] ? {row:S.rawLines[idx], truncated:[]} : null; | |
| if (!S.cache.has(idx)) await fetchPage(Math.floor(idx/PAGE)*PAGE); | |
| return S.cache.get(idx) || null; | |
| } | |
| /* ---------- rows: raw .jsonl streaming (fallback) ---------- */ | |
| async function streamRaw(sel){ | |
| const url = `${HUB}/datasets/${S.repo}/resolve/main/${sel.file}`; | |
| setStatus(`下载 ${sel.file}…`, false, 0); | |
| const r = await hfFetch(url); | |
| const reader = r.body.getReader(); const dec = new TextDecoder(); | |
| const total = sel.size || Number(r.headers.get('content-length')) || 0; | |
| let got = 0, buf = '', rows = []; | |
| S.rawLines = rows; | |
| for (;;) { | |
| const {done, value} = await reader.read(); | |
| if (done) break; | |
| got += value.length; buf += dec.decode(value, {stream:true}); | |
| let nl; | |
| while ((nl = buf.indexOf('\n')) >= 0) { | |
| const line = buf.slice(0, nl).trim(); buf = buf.slice(nl+1); | |
| if (line) { try { rows.push(JSON.parse(line)); } catch { rows.push({_parse_error:line.slice(0,200)}); } } | |
| } | |
| S.total = rows.length; el.count.textContent = `/ ${fmtN(rows.length)}…`; | |
| setStatus(`下载 ${sel.file}… ${(got/1e6).toFixed(1)} MB,${fmtN(rows.length)} 行`, false, total ? got/total : null); | |
| } | |
| const tail = buf.trim(); if (tail) { try { rows.push(JSON.parse(tail)); } catch {} } | |
| S.total = rows.length; | |
| setStatus(`${sel.file} · ${fmtN(rows.length)} 行(已全部载入浏览器内存)`); | |
| } | |
| /* ---------- conversation detection & normalisation ---------- */ | |
| const CONV_KEYS = ['messages','conversations','conversation','chat','turns','trajectory','dialog','dialogue']; | |
| const FROM_MAP = {human:'user', user:'user', gpt:'assistant', assistant:'assistant', model:'assistant', bot:'assistant', | |
| system:'system', developer:'system', tool:'tool', observation:'tool', toolresult:'tool', tool_result:'tool', function:'tool', function_call:'assistant'}; | |
| const isMsgList = v => Array.isArray(v) && v.length>0 && v.every(m => isObj(m) && (('role' in m) || ('from' in m))); | |
| function findConversation(row){ | |
| const keys = [...CONV_KEYS.filter(k=>k in row), ...Object.keys(row).filter(k=>!CONV_KEYS.includes(k))]; | |
| for (const k of keys){ const v = tryJSON(row[k]); if (isMsgList(v)) return {key:k, msgs:v}; } | |
| // prompt/completion, chosen/rejected style | |
| const sections = []; | |
| for (const k of ['prompt','completion','chosen','rejected']) { const v = tryJSON(row[k]); if (isMsgList(v)) sections.push({key:k, msgs:v}); } | |
| if (sections.length) return {key:sections.map(s=>s.key).join(' + '), sections}; | |
| return null; | |
| } | |
| function normMsg(m){ | |
| const role = (m.role || FROM_MAP[String(m.from||'').toLowerCase()] || m.from || 'unknown').toLowerCase(); | |
| let content = m.content ?? m.value ?? m.text ?? ''; | |
| let reasoning = m.reasoning ?? m.reasoning_content ?? m.reasoningContent ?? m.thinking ?? m.thought ?? ''; | |
| let calls = m.tool_calls ?? m.toolCalls ?? m.function_call ?? null; | |
| if (calls && !Array.isArray(calls)) calls = [calls]; | |
| calls = calls ? calls.slice() : []; | |
| // Anthropic-style content parts | |
| if (Array.isArray(content)){ | |
| const texts = []; | |
| for (const p of content){ | |
| if (!isObj(p)) { texts.push(String(p)); continue; } | |
| const t = p.type; | |
| if (t==='text' || t==='output_text' || t==='input_text') texts.push(p.text ?? ''); | |
| else if (t==='thinking' || t==='reasoning') reasoning = (reasoning ? reasoning+'\n\n' : '') + (p.thinking ?? p.text ?? ''); | |
| else if (t==='tool_use' || t==='toolCall' || t==='tool_call' || t==='function_call') calls.push(p); | |
| else if (t==='tool_result' || t==='toolResult') texts.push(typeof p.content==='string' ? p.content : pretty(p.content)); | |
| else if (t==='image' || t==='image_url') texts.push('[image]'); | |
| else texts.push(pretty(p)); | |
| } | |
| content = texts.join('\n'); | |
| } else if (content != null && typeof content !== 'string') content = pretty(content); | |
| 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'); | |
| // <think>…</think> embedded in content | |
| if (!reasoning && typeof content==='string'){ | |
| const mt = content.match(/^\s*<think>([\s\S]*?)<\/think>\s*/); | |
| if (mt) { reasoning = mt[1].trim(); content = content.slice(mt[0].length); } | |
| } | |
| const toolCalls = calls.map(c => { | |
| const f = c.function || {}; | |
| let args = c.arguments ?? f.arguments ?? c.args ?? c.input ?? c.parameters ?? null; | |
| args = tryJSON(args); | |
| const name = c.name || f.name || c.tool || ''; | |
| // An empty name / empty arguments means the trajectory lost the call when it was | |
| // rendered upstream — flag it instead of drawing a blank row. | |
| const empty = !name || args == null || (isObj(args) && !Object.keys(args).length); | |
| return { id: c.id ?? c.tool_call_id ?? c.call_id ?? '', name, args, type: c.type, empty }; | |
| }); | |
| return { role, content: String(content ?? ''), reasoning: String(reasoning ?? ''), toolCalls, | |
| toolCallId: m.tool_call_id ?? m.toolCallId ?? m.call_id ?? '', name: m.name ?? m.toolName ?? m.tool_name ?? '', raw: m }; | |
| } | |
| /* ---------- rendering ---------- */ | |
| function clipBlock(text, cls='t'){ | |
| const s = String(text); | |
| if (s.length <= CLIP) return `<div class="${cls}">${esc(s)}</div>`; | |
| const id = 'c' + Math.random().toString(36).slice(2,9); | |
| return `<div class="${cls}" id="${id}" data-full="${esc(s)}">${esc(s.slice(0,CLIP))}<span class="muted">…</span></div> | |
| <div class="more"><button class="btn" data-expand="${id}">展开全部 (${fmtN(s.length)} 字符)</button></div>`; | |
| } | |
| function renderTurn(m, i, opts){ | |
| const parts = []; | |
| 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>`); | |
| if (m.content.trim()) parts.push(`<div class="bubble text">${clipBlock(m.content)}</div>`); | |
| for (const c of m.toolCalls){ | |
| const nm = c.name ? esc(c.name) : '<i>(空 name)</i>'; | |
| const warn = c.empty ? `<div class="warn">↑ 这条 tool call 在数据里就是空的(缺失 name / arguments),不是渲染问题</div>` : ''; | |
| parts.push(`<div class="bubble call${c.empty?' bad':''}"><div class="head"><span>→</span><b>${nm}</b>${c.id?`<span class="id">${esc(c.id)}</span>`:''}</div>${c.args!=null ? `<pre>${esc(pretty(c.args))}</pre>` : ''}${warn}</div>`); | |
| } | |
| const meta = []; | |
| if (m.role==='tool' && (m.name||m.toolCallId)) meta.push(`${m.name?esc(m.name):''}${m.toolCallId?` ← ${esc(m.toolCallId)}`:''}`); | |
| if (!parts.length) parts.push(`<div class="bubble text"><span class="help">(空消息)</span></div>`); | |
| 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> | |
| <div class="body">${meta.length?`<div class="meta">${meta.join(' ')}</div>`:''}${parts.join('')}</div></section>`; | |
| } | |
| function renderMessages(msgs, opts){ | |
| let hidden = 0, out = []; | |
| msgs.forEach((raw, i) => { | |
| const m = normMsg(raw); | |
| if ((opts.hideTools && m.role==='tool') || (opts.hideSystem && m.role==='system')) { hidden++; return; } | |
| if (hidden) { out.push(`<div class="hidden-note">已隐藏 ${hidden} 条</div>`); hidden = 0; } | |
| out.push(renderTurn(m, i, opts)); | |
| }); | |
| if (hidden) out.push(`<div class="hidden-note">已隐藏 ${hidden} 条</div>`); | |
| return out.join(''); | |
| } | |
| function renderRail(row, convKey, truncated){ | |
| // row meta | |
| const conv = convKey ? tryJSON(row[convKey]) : null; | |
| const n = Array.isArray(conv) ? conv.length : null; | |
| const roles = Array.isArray(conv) ? conv.reduce((a,m)=>{ const r=normMsg(m).role; a[r]=(a[r]||0)+1; return a; },{}) : null; | |
| const kv = [ | |
| ['行', `<span class="num">${fmtN(S.idx)}</span> / ${fmtN(S.total)}`], | |
| ['split', esc(S.sel.label)], | |
| ['来源', S.mode==='server' ? 'viewer API(分页)' : '原始 .jsonl'], | |
| ]; | |
| if (convKey) kv.push(['messages 列', `<code>${esc(convKey)}</code>`]); | |
| if (n != null) kv.push(['轮次', `${n}` + (roles ? ` <span class="help">(${Object.entries(roles).map(([r,c])=>`${r} ${c}`).join(', ')})</span>` : '')]); | |
| if (truncated.length) kv.push(['⚠ 截断', `viewer API 截断了:${truncated.map(esc).join(', ')}`]); | |
| el.rowmeta.innerHTML = kv.map(([k,v]) => `<dt>${k}</dt><dd>${v}</dd>`).join(''); | |
| // tools | |
| const tools = tryJSON(row.tools); | |
| if (Array.isArray(tools) && tools.length){ | |
| el.toolsCard.hidden = false; el.toolsN.textContent = `(${tools.length})`; | |
| el.toolsList.innerHTML = tools.map(t => { | |
| const f = t.function || t; const name = f.name || t.name || '(unnamed)'; const d = f.description || t.description || ''; | |
| 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>`; | |
| }).join(''); | |
| } else el.toolsCard.hidden = true; | |
| // other columns | |
| const skip = new Set([convKey, 'tools']); | |
| const cols = Object.keys(row).filter(k => !skip.has(k)); | |
| el.colsCard.hidden = !cols.length; | |
| el.cols.innerHTML = cols.map(k => { | |
| const v = row[k]; const p = tryJSON(v); | |
| if (v == null) return `<details><summary>${esc(k)}<span class="hint">null</span></summary></details>`; | |
| 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>`; | |
| const s = String(v); | |
| 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>`; | |
| return `<details><summary>${esc(k)}<span class="hint">${fmtN(s.length)} 字符</span></summary><pre>${esc(s)}</pre></details>`; | |
| }).join(''); | |
| } | |
| function paint(){ | |
| const cur = S.current; if (!cur) return; | |
| const {row, truncated} = cur; | |
| const opts = { reason: el.optReason.checked, hideTools: el.optTools.checked, hideSystem: el.optSystem.checked }; | |
| const conv = findConversation(row); | |
| renderRail(row, conv && !conv.sections ? conv.key : null, truncated); | |
| let html = ''; | |
| 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>`; | |
| if (!conv) html += `<div class="empty">这一行没有可识别的 messages 列(找 role/from 的对象数组)。打开「显示整行原始 JSON」看结构。</div>`; | |
| else if (conv.sections) html += conv.sections.map(s => `<div class="section-h">${esc(s.key)}</div>${renderMessages(s.msgs, opts)}`).join(''); | |
| else html += renderMessages(conv.msgs, opts); | |
| el.main.innerHTML = html; | |
| window.scrollTo({top:0}); | |
| } | |
| async function showRow(idx){ | |
| if (S.total != null) idx = Math.max(0, Math.min(idx, S.total-1)); | |
| idx = Math.max(0, idx|0); | |
| S.idx = idx; el.idx.value = idx; S.loadingRow = idx; | |
| try { | |
| const r = await getRow(idx); | |
| if (S.loadingRow !== idx) return; // superseded by a newer navigation | |
| if (!r) { setStatus(`第 ${idx} 行不存在`, true); return; } | |
| S.current = r; el.count.textContent = `/ ${fmtN(S.total)}`; | |
| el.prev.disabled = idx<=0; el.next.disabled = S.total!=null && idx>=S.total-1; | |
| paint(); writeHash(); | |
| if (S.mode==='server') setStatus(`${S.repo} · ${S.sel.label} · 第 ${fmtN(idx)} 行`); | |
| } catch (e) { setStatus(explain(e), true); } | |
| } | |
| /* ---------- events ---------- */ | |
| el.load.onclick = () => loadDataset(null); | |
| el.repo.addEventListener('keydown', e => { if (e.key==='Enter') loadDataset(null); }); | |
| el.split.onchange = () => selectSplit(parseInt(el.split.value,10), 0); | |
| el.prev.onclick = () => showRow(S.idx-1); | |
| el.next.onclick = () => showRow(S.idx+1); | |
| el.rand.onclick = () => { if (S.total) showRow(Math.floor(Math.random()*S.total)); }; | |
| el.idx.addEventListener('change', () => showRow(parseInt(el.idx.value,10)||0)); | |
| for (const o of [el.optReason, el.optTools, el.optSystem, el.optRaw]) o.addEventListener('change', paint); | |
| el.main.addEventListener('click', e => { | |
| const b = e.target.closest('[data-expand]'); if (!b) return; | |
| const box = document.getElementById(b.dataset.expand); if (!box) return; | |
| box.textContent = box.dataset.full; b.parentElement.remove(); | |
| }); | |
| document.addEventListener('keydown', e => { | |
| if (e.target.matches('input,select,textarea') || !S.sel) return; | |
| if (e.key==='ArrowLeft') { e.preventDefault(); el.prev.click(); } | |
| else if (e.key==='ArrowRight') { e.preventDefault(); el.next.click(); } | |
| else if (e.key==='r') el.rand.click(); | |
| }); | |
| /* ---------- boot: resolve auth, then restore from hash if we can already authenticate ---------- */ | |
| (async () => { | |
| await initAuth(); | |
| const h = readHash(); | |
| if (h.repo) el.repo.value = h.repo; | |
| if (h.repo && bearer()) loadDataset(h); | |
| })(); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |