Download admin.html from Xunjunaa/pp01: direct link, hf CLI and curl.
- Browser
- Download file 21.3 kB
-
https://huggingface.co/spaces/Xunjunaa/pp01/resolve/main/admin.html
- Command line
-
hf download hf://spaces/Xunjunaa/pp01/admin.html
-
curl -L -o admin.html https://huggingface.co/spaces/Xunjunaa/pp01/resolve/main/admin.html
21.3 kB
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>控制台</title> | |
| <style> | |
| :root { | |
| --bg: #0d1117; --panel: #161b22; --border: #30363d; | |
| --fg: #e6edf3; --dim: #8b949e; --accent: #58a6ff; | |
| --ok: #3fb950; --warn: #d29922; --err: #f85149; | |
| } | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { background: var(--bg); color: var(--fg); | |
| font: 14px/1.5 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; | |
| padding: 16px; max-width: 1280px; margin: 0 auto; } | |
| h1 { font-size: 18px; display: flex; align-items: center; gap: 10px; } | |
| h2 { font-size: 14px; color: var(--dim); margin-bottom: 10px; | |
| text-transform: uppercase; letter-spacing: .05em; } | |
| .sub { color: var(--dim); font-size: 12px; font-weight: normal; } | |
| header { display: flex; align-items: center; justify-content: space-between; | |
| flex-wrap: wrap; gap: 10px; margin-bottom: 16px; } | |
| .pill { display: inline-block; padding: 2px 10px; border-radius: 12px; | |
| font-size: 12px; font-weight: 600; } | |
| .pill.ok { background: #1a3a24; color: var(--ok); } | |
| .pill.bad { background: #3d1d1d; color: var(--err); } | |
| .card { background: var(--panel); border: 1px solid var(--border); | |
| border-radius: 8px; padding: 14px; } | |
| .grid { display: grid; gap: 12px; margin-bottom: 12px; } | |
| .grid.c4 { grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); } | |
| .grid.c2 { grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); } | |
| .big { font-size: 26px; font-weight: 700; } | |
| .kv { color: var(--dim); font-size: 12px; margin-top: 4px; } | |
| table { width: 100%; border-collapse: collapse; font-size: 13px; } | |
| th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); } | |
| th { color: var(--dim); font-weight: 500; } | |
| input, button, select { background: #0d1117; color: var(--fg); | |
| border: 1px solid var(--border); border-radius: 6px; padding: 6px 10px; | |
| font: inherit; } | |
| button { cursor: pointer; background: #21262d; } | |
| button:hover { border-color: var(--accent); } | |
| button.primary { background: #1f6feb; border-color: #1f6feb; color: #fff; } | |
| .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } | |
| .chip { display: inline-block; background: #21262d; border: 1px solid var(--border); | |
| border-radius: 12px; padding: 1px 9px; margin: 2px; font-size: 12px; | |
| font-family: ui-monospace, Consolas, monospace; } | |
| .chip.denied { border-color: var(--err); color: var(--err); } | |
| .scroll { max-height: 260px; overflow-y: auto; } | |
| .bars { display: flex; align-items: flex-end; gap: 2px; height: 26px; } | |
| .bar { width: 5px; background: var(--accent); border-radius: 1px 1px 0 0; | |
| min-height: 1px; opacity: .85; } | |
| .st200 { color: var(--ok); } .st4xx { color: var(--warn); } .st5xx { color: var(--err); } | |
| .mono { font-family: ui-monospace, Consolas, monospace; } | |
| pre { white-space: pre-wrap; word-break: break-all; background: #0d1117; | |
| border: 1px solid var(--border); border-radius: 6px; padding: 10px; | |
| font-size: 12px; max-height: 300px; overflow-y: auto; } | |
| #login { position: fixed; inset: 0; display: flex; align-items: center; | |
| justify-content: center; background: rgba(13,17,23,.96); z-index: 9; } | |
| #login .card { width: 360px; } | |
| .hidden { display: none ; } | |
| #refreshNote { color: var(--dim); font-size: 12px; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="login"> | |
| <div class="card"> | |
| <h2 style="margin-bottom:12px">🔑 登录控制台</h2> | |
| <div class="row" style="margin-bottom:8px"> | |
| <input id="keyInput" type="password" placeholder="管理密钥" style="flex:1"> | |
| <button class="primary" onclick="saveKeys()">进入</button> | |
| </div> | |
| <div class="kv" id="loginErr" style="color:var(--err)"></div> | |
| <div class="kv" style="margin-top:6px">单一密钥, 读/写操作通用。</div> | |
| </div> | |
| </div> | |
| <header> | |
| <h1>🎛 控制台 <span id="statusPill" class="pill bad">…</span> | |
| <span class="sub" id="uptime"></span></h1> | |
| <div class="row"> | |
| <span id="refreshNote">每 2s 自动刷新</span> | |
| <button onclick="manualRefresh()">刷新</button> | |
| </div> | |
| </header> | |
| <div class="grid c4"> | |
| <div class="card"><h2>货架现货</h2> | |
| <div class="big"><span id="stock">-</span><span class="sub"> / <span id="cap">-</span></span></div> | |
| <div class="kv" id="shelfMeta">在签 -, 最老现货 -</div></div> | |
| <div class="card"><h2>签发统计</h2> | |
| <div class="big" id="signed">-</div> | |
| <div class="kv">失败 <span id="signFailed">-</span> · 平均耗时 <span id="avgLat">-</span>s · 超产丢弃 <span id="surplus">-</span></div></div> | |
| <div class="card"><h2>取货消耗</h2> | |
| <div class="big" id="served">-</div> | |
| <div class="kv">过期丢弃 <span id="expired">-</span></div></div> | |
| <div class="card"><h2>工人页</h2> | |
| <div class="big"><span id="workerCount">-</span><span class="sub"> / 期望 <span id="workerDesired">-</span></span></div> | |
| <div class="kv">浏览器通道 <span id="channel">-</span></div></div> | |
| </div> | |
| <div class="grid c2"> | |
| <!-- 左: 工人明细 + 控制 --> | |
| <div style="display:grid; gap:12px; align-content:start"> | |
| <div class="card"> | |
| <h2>工人页明细</h2> | |
| <table> | |
| <thead><tr><th>#</th><th>状态</th><th>已签</th><th>失败</th><th>最近耗时</th><th>签发曲线</th></tr></thead> | |
| <tbody id="workerRows"></tbody> | |
| </table> | |
| </div> | |
| <div class="card"> | |
| <h2>控制</h2> | |
| <div class="row" style="margin-bottom:10px"> | |
| 工人页数: <select id="scaleSel"></select> | |
| <button class="primary" onclick="scale()">应用</button> | |
| <span class="kv" id="scaleMsg"></span> | |
| </div> | |
| <div class="row" style="margin-bottom:10px"> | |
| 工厂模式: <select id="modeSel"> | |
| <option value="intercept">intercept · 空壳页/内存拦截(推荐)</option> | |
| <option value="mock">mock · 空壳页/本地HTTPS</option> | |
| <option value="real">real · 真实工厂页(保底)</option> | |
| </select> | |
| <button onclick="switchMode()">切换</button> | |
| <span class="kv" id="modeMsg"></span> | |
| </div> | |
| <div class="row" style="margin-bottom:10px"> | |
| 工厂页: <select id="factorySel" style="max-width:340px"></select> | |
| <button onclick="switchFactory()">切换</button> | |
| <span class="kv" id="factoryMsg"></span> | |
| </div> | |
| <div class="kv" style="margin-top:8px"> | |
| 切换工厂页会退役全部工人页并按新页重建(货架现货不受影响, token 通用)。 | |
| intercept/mock 模式下工厂页仅决定空壳页 origin(host), 不真实加载; | |
| real 模式才真实访问页面。上游模型之外的完整 URL 也可手动填入: | |
| <input id="factoryCustom" class="mono" placeholder="https://… 或 /owner/model" style="width:100%;margin-top:6px"> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h2>测试对话(走中转全链路)</h2> | |
| <div class="row" style="margin-bottom:8px"> | |
| <input id="testModel" list="routableList" placeholder="模型" style="flex:2; min-width:220px"> | |
| <datalist id="routableList"></datalist> | |
| </div> | |
| <div class="row" style="margin-bottom:8px"> | |
| <input id="testPrompt" placeholder="prompt" value="回一个 pong" style="flex:2"> | |
| <button class="primary" onclick="testChat()" id="testBtn">运行</button> | |
| </div> | |
| <div class="kv" id="testMeta"></div> | |
| <pre id="testOut" class="hidden"></pre> | |
| </div> | |
| </div> | |
| <!-- 右: 上游模型 + 请求日志 --> | |
| <div style="display:grid; gap:12px; align-content:start"> | |
| <div class="card"> | |
| <h2>上游模型 (<span id="routableN">-</span>) <span class="sub">/v1/models 原样, 能否调用以上游实时回答为准</span></h2> | |
| <div id="routableChips" class="scroll mono"><span class="sub">加载中…</span></div> | |
| </div> | |
| <div class="card"> | |
| <h2>最近请求</h2> | |
| <div class="scroll" style="max-height:340px"> | |
| <table> | |
| <thead><tr><th>时间</th><th>模型</th><th>流式</th><th>状态</th><th>耗时</th><th>备注</th></tr></thead> | |
| <tbody id="reqRows"></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h2>参数控制台(热改, 立即生效)</h2> | |
| <div id="cfgMsg" class="kv" style="margin-bottom:8px"></div> | |
| <table> | |
| <thead><tr><th>参数</th><th>当前</th><th>新值</th><th></th></tr></thead> | |
| <tbody id="cfgRows"></tbody> | |
| </table> | |
| <div class="row" style="margin-top:8px"> | |
| <button class="primary" onclick="applyConfigAll()">全部写入</button> | |
| <span class="kv">当前工厂页: <span id="factoryPage" class="mono"></span> · 模式: <span id="factoryModeNow" class="mono">—</span></span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| let KEY = localStorage.getItem('console_key') || ''; // 单一管理密钥, 读写通用 | |
| function saveKeys() { | |
| KEY = document.getElementById('keyInput').value.trim(); | |
| localStorage.setItem('console_key', KEY); | |
| refresh(true); | |
| loadModels(); | |
| } | |
| document.getElementById('keyInput').addEventListener('keydown', e => { | |
| if (e.key === 'Enter') saveKeys(); | |
| }); | |
| async function api(path, opts) { | |
| const r = await fetch(path, Object.assign({ | |
| method: opts && opts.method || 'GET', | |
| headers: { 'Authorization': 'Bearer ' + KEY, | |
| 'Content-Type': 'application/json' } | |
| }, opts)); | |
| if (r.status === 401) { | |
| document.getElementById('login').classList.remove('hidden'); | |
| document.getElementById('loginErr').textContent = | |
| KEY ? '密钥无效, 请重试' : ''; | |
| throw new Error('401'); | |
| } | |
| return r; | |
| } | |
| const $ = id => document.getElementById(id); | |
| const fmt = (v, u='') => v === null || v === undefined ? '—' : v + u; | |
| function stClass(s) { return s === 200 ? 'st200' : s >= 500 ? 'st5xx' : 'st4xx'; } | |
| function bars(arr, max) { | |
| if (!arr || !arr.length) return '<span class="sub">—</span>'; | |
| const m = Math.max(...arr, 0.1); | |
| return '<div class="bars">' + arr.map(v => | |
| `<div class="bar" style="height:${Math.max(1, v / m * 24)}px" title="${v}s"></div>` | |
| ).join('') + '</div>'; | |
| } | |
| // ---- 上游模型列表(/v1/models 原样, 每会话拉一次) ---- | |
| let UPSTREAM_MODELS = []; | |
| async function loadModels() { | |
| try { | |
| const d = await (await api('/v1/models')).json(); | |
| UPSTREAM_MODELS = (d.data || []).map(m => m.id).filter(Boolean).sort(); | |
| $('routableN').textContent = UPSTREAM_MODELS.length; | |
| $('routableChips').innerHTML = UPSTREAM_MODELS.map(m => `<span class="chip">${m}</span>`).join('') | |
| || '<span class="sub">空</span>'; | |
| if (!$('routableList').options.length) | |
| $('routableList').innerHTML = UPSTREAM_MODELS.map(m => `<option value="${m}">`).join(''); | |
| buildFactorySel(); | |
| } catch (e) { | |
| $('routableChips').innerHTML = '<span class="sub">模型列表拉取失败</span>'; | |
| } | |
| } | |
| function buildFactorySel() { | |
| if (!UPSTREAM_MODELS.length) return; | |
| const cur = $('factoryPage').textContent; | |
| const curPath = '/' + cur.replace(/^https?:\/\/[^/]+\//, '').replace(/\/$/, ''); | |
| $('factorySel').innerHTML = | |
| `<option value="">— 选择工厂页 —</option>` + | |
| UPSTREAM_MODELS.map(m => { | |
| const u = 'https://deepinfra.com/' + m; | |
| return `<option value="${u}" ${u === cur || m === curPath ? 'selected' : ''}>${m}</option>`; | |
| }).join(''); | |
| } | |
| async function refresh(first) { | |
| try { | |
| const s = await (await api('/api/stats')).json(); | |
| document.getElementById('login').classList.add('hidden'); | |
| // 状态 | |
| const pill = $('statusPill'); | |
| pill.textContent = s.ready ? 'READY' : 'WARMING'; | |
| pill.className = 'pill ' + (s.ready ? 'ok' : 'bad'); | |
| $('uptime').textContent = `运行 ${Math.floor(s.uptime_s/60)}m${s.uptime_s%60}s`; | |
| // 卡片 | |
| $('stock').textContent = s.shelf.stock; | |
| $('cap').textContent = s.shelf.capacity; | |
| $('shelfMeta').textContent = | |
| `在签 ${s.shelf.inflight_signs}, 最老现货 ${fmt(s.shelf.oldest_age_s,'s')} / 寿限 ${s.config.token_max_age}s`; | |
| $('signed').textContent = s.signing.signed_total; | |
| $('signFailed').textContent = s.signing.failed_total; | |
| $('avgLat').textContent = fmt(s.signing.avg_latency); | |
| $('surplus').textContent = s.shelf.surplus_dropped; | |
| $('served').textContent = s.shelf.served_total; | |
| $('expired').textContent = s.shelf.expired_dropped; | |
| $('workerCount').textContent = s.workers.length; | |
| $('workerDesired').textContent = s.workers_desired; | |
| $('channel').textContent = s.browser_channel || '—'; | |
| // 工人页数下拉: 1..max, 当前值选中 | |
| if (first || document.activeElement !== $('scaleSel')) { | |
| $('scaleSel').innerHTML = Array.from({length: s.workers_max}, | |
| (_, i) => `<option value="${i+1}" ${i+1 === s.workers_desired ? 'selected' : ''}>${i+1}</option>`).join(''); | |
| } | |
| // 工人表 | |
| const stateTxt = {ready:'🟢 空闲', busy:'🔵 签发中', starting:'🟡 启动中', retiring:'⚪ 退役中'}; | |
| $('workerRows').innerHTML = s.workers.map(w => | |
| `<tr><td>${w.id}</td><td>${stateTxt[w.state]||w.state}</td> | |
| <td>${w.signed}</td><td>${w.sign_failed}</td> | |
| <td>${fmt(w.last_sign_latency,'s')}</td> | |
| <td>${bars(w.latencies)}</td></tr>`).join('') || | |
| '<tr><td colspan="6" class="sub">无</td></tr>'; | |
| // 上游模型/否定缓存区块已移除; 模型列表由 loadModels() 每会话拉一次 | |
| // 工厂页下拉: 由 loadModels() 填充; 这里只回显当前工厂页 | |
| $('factoryPage').textContent = s.config.factory_page; | |
| // 工厂模式回显(不抢占用户正在操作的下拉) | |
| $('factoryModeNow').textContent = s.config.factory_mode || '—'; | |
| if (document.activeElement !== $('modeSel')) { | |
| $('modeSel').value = s.config.factory_mode || 'intercept'; | |
| } | |
| // 配置(可热改参数的输入框 + 当前值); type: num=数字, text=字符串 | |
| const c = s.config; | |
| const CFIELDS = [ | |
| ['shelf_size', '货架容量(上限库存)', 'num', 100000], | |
| ['worker_pages', '工人页数(签发并发)', 'num', s.workers_max], | |
| ['max_worker_pages', '工人页数硬上限', 'num', 256], | |
| ['token_max_age', 'token寿命(s, ≤300)', 'num', 300], | |
| ['idle_pause', '闲置静默(s)', 'num', 86400], | |
| ['max_retry', '上游错误重试次数(0=不重试, 不封顶)', 'num', null], | |
| ['retry_statuses', '重试状态码(逗号分隔)', 'text', null], | |
| ]; | |
| $('cfgRows').innerHTML = CFIELDS.map(([k, label, type, mx]) => { | |
| const cur = c[k]; | |
| const input = type === 'num' | |
| ? `<input id="in_${k}" type="number" min="0" ${mx != null ? `max="${mx}"` : ''} style="width:90px" data-k="${k}">` | |
| : `<input id="in_${k}" type="text" class="mono" style="width:150px" data-k="${k}">`; | |
| return `<tr><td class="mono">${label}<div class="sub" style="font-size:11px">${k}</div></td> | |
| <td class="mono" id="cur_${k}">${cur}</td> | |
| <td>${input}</td> | |
| <td><button onclick="applyConfigOne('${k}')">写入</button></td></tr>`; | |
| }).join(''); | |
| $('in_worker_pages').max = s.workers_max; | |
| // 请求日志 | |
| const rq = await (await api('/api/requests')).json(); | |
| $('reqRows').innerHTML = rq.requests.map(r => | |
| `<tr><td class="mono">${r.ts}</td><td class="mono">${r.model||'—'}</td> | |
| <td>${r.stream?'':'非'}流式</td> | |
| <td class="${stClass(r.status)}">${r.status}</td> | |
| <td class="mono">${fmt(r.latency_ms,'ms')}</td> | |
| <td class="sub" title="${(r.err||'').replace(/"/g,'')}">${(r.err||'').slice(0,40)||''}</td></tr>` | |
| ).join('') || '<tr><td colspan="6" class="sub">暂无</td></tr>'; | |
| } catch (e) { /* 401 已处理; 其他忽略等下一轮 */ } | |
| } | |
| async function scale() { | |
| try { | |
| const r = await (await api('/api/pool/scale', { | |
| method: 'POST', body: JSON.stringify({ pages: +$('scaleSel').value }) | |
| })).json(); | |
| $('scaleMsg').textContent = `已提交, 期望 ${r.desired}, 当前 ${r.active}`; | |
| refresh(); | |
| } catch (e) {} | |
| } | |
| async function switchMode() { | |
| const mode = $('modeSel').value; | |
| const desc = {intercept: 'route 拦截, 空壳页纯内存承载(不访问目标站)', | |
| mock: '域名映射 + 本地 HTTPS 空壳页(将回收重建浏览器)', | |
| real: '真实加载工厂页(旧行为)'}[mode]; | |
| if (!confirm(`切换工厂模式到 ${mode} ?\n${desc}\n现有工人页将退役并按新模式重建, 货架现货保留。`)) return; | |
| try { | |
| const r = await (await api('/api/config', { | |
| method: 'PUT', body: JSON.stringify({ factory_mode: mode }) | |
| })).json(); | |
| if (r.applied && r.applied.factory_mode) { | |
| $('modeMsg').textContent = `✅ 已切换 -> ${r.applied.factory_mode}`; | |
| } else { | |
| $('modeMsg').textContent = `⚠️ ${JSON.stringify(r.skipped||{})}`; | |
| } | |
| refresh(); | |
| } catch (e) { $('modeMsg').textContent = '切换失败'; } | |
| } | |
| async function switchFactory() { | |
| const custom = $('factoryCustom').value.trim(); | |
| const url = custom || $('factorySel').value; | |
| if (!url) { $('factoryMsg').textContent = '请先选择模型或填写 URL'; return; } | |
| if (!confirm(`切换工厂页到 ${url} ?\n现有工人页将退役并按新页重建, 货架现货保留。`)) return; | |
| try { | |
| const r = await (await api('/api/config', { | |
| method: 'PUT', body: JSON.stringify({ factory_page: url }) | |
| })).json(); | |
| if (r.applied && r.applied.factory_page) { | |
| $('factoryMsg').textContent = `✅ 已切换 -> ${r.applied.factory_page}, 工人页重建中`; | |
| } else { | |
| $('factoryMsg').textContent = `⚠️ ${JSON.stringify(r.skipped||{})}`; | |
| } | |
| $('factoryCustom').value = ''; | |
| refresh(); | |
| } catch (e) { $('factoryMsg').textContent = '切换失败'; } | |
| } | |
| // ---- 参数热改 ---- | |
| function cfgBodyFromInput(k) { | |
| const el = $('in_' + k); | |
| if (!el || el.value === '') return null; | |
| const isNum = el.type === 'number'; | |
| if (isNum && isNaN(+el.value)) return { err: '请填数字' }; | |
| return { val: isNum ? +el.value : el.value.trim() }; | |
| } | |
| async function applyConfigOne(k) { | |
| const parsed = cfgBodyFromInput(k); | |
| if (!parsed) { $('cfgMsg').textContent = '请填新值'; return; } | |
| if (parsed.err) { $('cfgMsg').textContent = parsed.err; return; } | |
| if (k === 'worker_pages') { // worker_pages 单独走 scale 路径 | |
| await scaleTo(+parsed.val); return; | |
| } | |
| const body = {}; body[k] = parsed.val; | |
| try { | |
| const r = await (await api('/api/config', { | |
| method: 'PUT', body: JSON.stringify(body) | |
| })).json(); | |
| showCfgMsg(r); | |
| refresh(); | |
| } catch (e) { $('cfgMsg').textContent = '写入失败'; } | |
| } | |
| async function applyConfigAll() { | |
| const body = {}; | |
| let bad = ''; | |
| ['shelf_size','worker_pages','max_worker_pages','token_max_age', | |
| 'idle_pause','max_retry','retry_statuses'].forEach(k => { | |
| const parsed = cfgBodyFromInput(k); | |
| if (!parsed) return; | |
| if (parsed.err) { bad = `${k}: ${parsed.err}`; return; } | |
| body[k] = parsed.val; | |
| }); | |
| if (bad) { $('cfgMsg').textContent = bad; return; } | |
| if (Object.keys(body).length === 0) { $('cfgMsg').textContent = '无新值'; return; } | |
| try { | |
| const r = await (await api('/api/config', { | |
| method: 'PUT', body: JSON.stringify(body) | |
| })).json(); | |
| if (body.worker_pages != null) await scaleTo(body.worker_pages); | |
| showCfgMsg(r); | |
| refresh(); | |
| } catch (e) { $('cfgMsg').textContent = '写入失败'; } | |
| } | |
| async function scaleTo(n) { | |
| try { | |
| await api('/api/pool/scale', { method: 'POST', body: JSON.stringify({ pages: n }) }); | |
| $('cfgMsg').textContent = `worker_pages 已提交: 期望 ${n}`; | |
| } catch (e) {} | |
| } | |
| function showCfgMsg(r) { | |
| $('cfgMsg').innerHTML = | |
| (r.applied && Object.keys(r.applied).length | |
| ? `✅ 已应用: ${Object.entries(r.applied).map(([k,v])=>k+'='+v).join(', ')}` : '') | |
| + (r.skipped && Object.keys(r.skipped).length | |
| ? ` ⚠️ 跳过: ${Object.entries(r.skipped).map(([k,v])=>k+'('+v+')').join(', ')}` : ''); | |
| } | |
| async function testChat() { | |
| const btn = $('testBtn'); btn.disabled = true; btn.textContent = '运行中…'; | |
| $('testMeta').textContent = ''; $('testOut').classList.add('hidden'); | |
| try { | |
| const r = await (await api('/api/test/chat', { | |
| method: 'POST', | |
| body: JSON.stringify({ model: $('testModel').value, prompt: $('testPrompt').value }) | |
| })).json(); | |
| if (r.ok) { | |
| $('testMeta').innerHTML = | |
| `✅ <span class="st200">成功</span> · 等token ${r.t_token_s}s · 上游 ${r.t_upstream_s}s · 共 ${r.t_total_s}s` + | |
| (r.usage ? ` · tokens ${r.usage.total_tokens||'?'}` : ''); | |
| $('testOut').textContent = r.content; | |
| } else { | |
| $('testMeta').innerHTML = | |
| `❌ <span class="st5xx">失败</span> @ ${r.stage}` + | |
| (r.status ? ` HTTP ${r.status}` : '') + | |
| (r.t_token_s != null ? ` · 等token ${r.t_token_s}s` : ''); | |
| $('testOut').textContent = r.error || ''; | |
| } | |
| $('testOut').classList.remove('hidden'); | |
| } catch (e) {} | |
| btn.disabled = false; btn.textContent = '运行'; | |
| refresh(); | |
| } | |
| function manualRefresh() { refresh(); } | |
| refresh(true); | |
| loadModels(); | |
| setInterval(refresh, 2000); | |
| </script> | |
| </body> | |
| </html> | |