/* dsfree2api web console */ (function () { "use strict"; const $ = (s, r) => (r || document).querySelector(s); const $$ = (s, r) => Array.from((r || document).querySelectorAll(s)); const view = $("#view"); const TITLES = { dashboard: "仪表盘", models: "模型与站点", keys: "API Key", proxy: "代理线路", turnstile: "Turnstile", playground: "对话调试", logs: "实时日志", settings: "设置" }; let overviewTimer = null; let logStop = null; let lastOverview = null; let pxMode = "basic"; // 代理线路页签:basic=基础线路 pool=代理池,重渲染后保留 /* ── helpers ─────────────────────────────────────────────── */ function toast(msg, kind) { const t = $("#toast"); t.textContent = msg; t.className = "toast " + (kind || ""); clearTimeout(t._h); t._h = setTimeout(() => t.classList.add("hidden"), 3200); } async function api(path, opts) { opts = opts || {}; const init = { method: opts.method || (opts.body !== undefined ? "POST" : "GET"), headers: {} }; if (opts.signal) init.signal = opts.signal; if (opts.body !== undefined) { init.headers["Content-Type"] = "application/json"; init.body = typeof opts.body === "string" ? opts.body : JSON.stringify(opts.body); } const res = await fetch(path, init); let data = null; try { data = await res.json(); } catch (e) { data = null; } if (res.status === 401 && !path.endsWith("/login")) { showLogin(); throw new Error("unauthorized"); } if (!res.ok) { const msg = (data && (data.error || data.detail)) || res.statusText; throw new Error(typeof msg === "string" ? msg : JSON.stringify(msg)); } return data; } function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } const fmtInt = n => (n || 0).toLocaleString("en-US"); const fmtPct = (a, b) => b ? (a * 100 / b).toFixed(1) + "%" : "0%"; const fmtMs = ms => ms >= 1000 ? (ms / 1000).toFixed(2) + "s" : Math.round(ms) + "ms"; const fmtBytes = n => n > 1048576 ? (n / 1048576).toFixed(1) + "MB" : n > 1024 ? (n / 1024).toFixed(1) + "KB" : n + "B"; const timeStr = t => new Date(t).toLocaleTimeString("zh-CN", { hour12: false }); function switchHTML(id, checked) { // id 可能含模型名/站点码等配置里的任意字符,必须转义,否则闭合属性后 // 会把后面的 checked/span 拆成攻击者可控的标记。 return ``; } /* ── auth ────────────────────────────────────────────────── */ async function ensureSession() { try { const s = await api("/api/session"); if (s.authenticated) { $("#login").classList.add("hidden"); return true; } } catch (e) { /* ignore */ } showLogin(); return false; } function showLogin() { $("#login").classList.remove("hidden"); setTimeout(() => $("#login-password").focus(), 50); } async function doLogin() { const pw = $("#login-password").value; if (!pw) return; try { await api("/api/login", { method: "POST", body: { password: pw } }); $("#login-error").textContent = ""; $("#login").classList.add("hidden"); $("#login-password").value = ""; route(); } catch (e) { $("#login-error").textContent = e.message === "invalid password" ? "密码错误" : e.message; } } /* ── router ──────────────────────────────────────────────── */ function route() { if (overviewTimer) { clearInterval(overviewTimer); overviewTimer = null; } if (logStop) { logStop(); logStop = null; } const hash = location.hash.replace(/^#\/?/, "") || "dashboard"; const page = (hash.split("?")[0] || "dashboard"); $$("#nav a").forEach(a => a.classList.toggle("active", a.dataset.page === page)); $("#page-title").textContent = TITLES[page] || page; view.innerHTML = '
加载中…
'; const fn = PAGES[page] || PAGES.dashboard; fn().catch(e => { view.innerHTML = `
加载失败:${esc(e.message)}
`; }); } /* ── overview poll ───────────────────────────────────────── */ async function fetchOverview() { const ov = await api("/api/overview"); lastOverview = ov; renderBadges(ov); return ov; } function renderBadges(ov) { const st = (ov.turnstile.status || []).filter(s => s.valid).length; const total = (ov.sites || []).filter(s => s.enabled).length; $("#sys-version").textContent = "v" + (ov.system.version || "?"); $("#sys-badges").innerHTML = ` 运行 ${Math.floor(ov.system.uptime_s / 3600)}h${Math.floor(ov.system.uptime_s % 3600 / 60)}m ${esc(ov.system.go)} · ${esc(ov.system.os)}/${esc(ov.system.arch)} Turnstile ${st}/${total} 在线 ${fmtInt(ov.metrics.in_flight)}`; } /* ── pages ───────────────────────────────────────────────── */ const PAGES = {}; PAGES.dashboard = async () => { view.innerHTML = `
总请求
–
错误 / 空响应
–
Token(估算)
–
延迟 P50 / P95
–

模型用量

模型请求错误Token平均耗时
暂无数据

站点状态

最近请求

时间模型站点线路类型耗时Token状态
暂无请求
`; $("#btn-invalidate").onclick = async () => { await api("/api/actions", { body: { action: "invalidate" } }); toast("已清除 nonce 与 cookie 缓存", "ok"); }; $("#btn-reset").onclick = async () => { await api("/api/actions", { body: { action: "reset_metrics" } }); toast("统计已清空", "ok"); refresh(); }; async function refresh() { let ov; try { ov = await fetchOverview(); } catch (e) { return; } const m = ov.metrics; $("#s-req").textContent = fmtInt(m.requests); $("#s-rps").textContent = `${m.rps.toFixed(2)} req/s · 流式 ${fmtInt(m.streams)} · 运行中 ${fmtInt(m.in_flight)}`; $("#s-err").textContent = fmtInt(m.errors); $("#s-err").className = "stat-value " + (m.errors ? "err" : "ok"); $("#s-errpct").textContent = `错误率 ${fmtPct(m.errors, m.requests)} · 空响应 ${fmtInt(m.empty)} · 拒绝 ${fmtInt(m.unauthorized)} · 限流 ${fmtInt(m.rate_limited)}`; $("#s-tok").textContent = fmtInt(m.prompt_tokens + m.completion_tokens); $("#s-toksub").textContent = `输入 ${fmtInt(m.prompt_tokens)} · 输出 ${fmtInt(m.completion_tokens)}`; $("#s-lat").textContent = m.latency.count ? `${fmtMs(m.latency.p50)} / ${fmtMs(m.latency.p95)}` : "–"; $("#s-latsub").textContent = m.latency.count ? `样本 ${m.latency.count} · 最大 ${fmtMs(m.latency.max)}` : "尚无样本"; const rows = Object.entries(m.models || {}); $("#tb-models").innerHTML = rows.length ? rows.map(([id, v]) => ` ${esc(id)} ${fmtInt(v.requests)} ${fmtInt(v.errors)} ${fmtInt(v.prompt_tokens + v.completion_tokens)} ${v.requests ? fmtMs(v.sum_ms / v.requests) : "–"}`).join("") : '暂无数据'; $("#site-cards").innerHTML = (ov.sites || []).map(s => { const ts = (ov.turnstile.status || []).find(x => x.site === s.code) || {}; const ms = m.sites && m.sites[s.code]; const pct = ts.remaining_s > 0 ? Math.max(0, Math.min(100, ts.remaining_s / (ov.turnstile.config.cookie_ttl_seconds || 10800) * 100)) : 0; return `
${esc(s.code.toUpperCase())} ${s.enabled ? "启用" : "停用"}
${esc(s.base_url)}
请求 ${fmtInt(ms ? ms.requests : 0)} · 错误 ${fmtInt(ms ? ms.errors : 0)} · Cookie ${ts.valid ? "有效 " + Math.round(ts.remaining_s / 60) + " 分钟" : "未建立"}
`; }).join("") || '
无站点
'; $$("[data-probe]").forEach(b => b.onclick = async () => { b.disabled = true; b.textContent = "…"; try { const r = await api("/api/actions", { body: { action: "probe_site", site: b.dataset.probe } }); toast(r.ok ? `${b.dataset.probe}: HTTP ${r.status} · ${r.ms}ms · 对话容器 ${r.chat_container ? "有" : "无"}` : `${b.dataset.probe}: ${r.error || "失败"}`, r.ok ? "ok" : "err"); } catch (e) { toast(e.message, "err"); } b.disabled = false; b.textContent = "探测"; }); $$("[data-quota]").forEach(b => b.onclick = async () => { b.disabled = true; b.textContent = "…"; try { const r = await api("/api/actions", { body: { action: "site_balance", site: b.dataset.quota } }); const period = r.reset_period === "daily" ? "每日" : (r.reset_period || ""); toast(r.ok ? `${b.dataset.quota}: 剩余 ${fmtInt(r.remaining)} / ${fmtInt(r.limit)} · 已用 ${fmtInt(r.used)} · ${period}重置` : `${b.dataset.quota}: ${r.error || "失败"}`, r.ok ? "ok" : "err"); } catch (e) { toast(e.message, "err"); } b.disabled = false; b.textContent = "额度"; }); $$("[data-rotate]").forEach(b => b.onclick = async () => { b.disabled = true; b.textContent = "…"; try { const r = await api("/api/actions", { body: { action: "rotate_guest", site: b.dataset.rotate } }); toast(r.ok ? `${b.dataset.rotate}: 已换新访客身份,剩余 ${fmtInt(r.remaining)} / ${fmtInt(r.limit)}` : `${b.dataset.rotate}: ${r.error || "失败"}`, r.ok ? "ok" : "err"); } catch (e) { toast(e.message, "err"); } b.disabled = false; b.textContent = "重置"; }); const rec = (m.recent || []).slice(0, 30); $("#tb-recent").innerHTML = rec.length ? rec.map(r => ` ${timeStr(r.at)} ${esc(r.served_by || r.model)} ${esc(r.site)}${esc(r.route || "–")} ${r.stream ? "流式" : "同步"} ${fmtMs(r.duration_ms)} ${fmtInt((r.prompt_tokens || 0) + (r.completion_tokens || 0))} ${esc(r.status)}${r.error ? ` ${esc(r.error.slice(0, 60))}` : ""}`).join("") : '暂无请求'; } await refresh(); overviewTimer = setInterval(refresh, 5000); }; PAGES.models = async () => { const ov = await fetchOverview(); view.innerHTML = `

模型

bot_id / post_id 修改后会自动清除该模型的 nonce 缓存
ID标签站点上游模型路径botpost启用

站点

代码Base URLAJAX URL语言启用
`; $("#tb").innerHTML = ov.models.map(m => ` ${esc(m.id)} ${esc(m.site)}${esc(m.upstream_id)} ${switchHTML("sw-" + m.id, m.enabled)} `).join(""); $("#tb-sites").innerHTML = ov.sites.map(s => ` ${esc(s.code.toUpperCase())} ${switchHTML("sws-" + s.code, s.enabled)}
`).join(""); $$("[data-save]").forEach(b => b.onclick = async () => { const tr = b.closest("tr"), id = b.dataset.save; const g = f => $(`[data-f="${f}"]`, tr).value; try { await api("/api/actions", { body: { action: "update_model", id, label: g("label"), page_path: g("page_path"), bot_id: +g("bot_id"), post_id: +g("post_id") } }); toast("模型已保存", "ok"); } catch (e) { toast(e.message, "err"); } }); $$("[data-savesite]").forEach(b => b.onclick = async () => { const tr = b.closest("tr"), code = b.dataset.savesite; const g = f => $(`[data-f="${f}"]`, tr).value; try { await api("/api/actions", { body: { action: "update_site", code, base_url: g("base_url"), ajax_url: g("ajax_url"), language: g("language") } }); toast("站点已保存", "ok"); } catch (e) { toast(e.message, "err"); } }); ov.models.forEach(m => { $("#sw-" + CSS.escape(m.id)).onchange = async ev => { try { await api("/api/actions", { body: { action: "toggle_model", id: m.id, enabled: ev.target.checked } }); toast("已更新", "ok"); } catch (e) { toast(e.message, "err"); ev.target.checked = !ev.target.checked; } }; }); ov.sites.forEach(s => { $("#sws-" + CSS.escape(s.code)).onchange = async ev => { try { await api("/api/actions", { body: { action: "toggle_site", id: s.code, enabled: ev.target.checked } }); toast("已更新", "ok"); } catch (e) { toast(e.message, "err"); ev.target.checked = !ev.target.checked; } }; }); }; PAGES.keys = async () => { const ov = await fetchOverview(); view.innerHTML = `

下游 API Key

留空表示不鉴权;请求时使用 Authorization: Bearer <key> 或 X-Api-Key
Key状态
修改会立即生效并写入 config.toml(自动备份为 .bak)
`; const render = keys => { $("#tb").innerHTML = keys.length ? keys.map(k => ` ${esc(mask(k))}启用 `).join("") : '未配置任何 Key —— 当前不鉴权'; $$("[data-copy]").forEach(b => b.onclick = () => { navigator.clipboard && navigator.clipboard.writeText(b.dataset.copy); toast("已复制", "ok"); }); $$("[data-del]").forEach(b => b.onclick = async () => { if (!confirm("删除该 Key?")) return; try { await api("/api/actions", { body: { action: "delete_key", key: b.dataset.del } }); toast("已删除", "ok"); PAGES.keys(); } catch (e) { toast(e.message, "err"); } }); }; render(ov.keys || []); $("#btn-gen").onclick = async () => { try { const r = await api("/api/actions", { body: { action: "create_key" } }); if (r.ok) { toast("已生成", "ok"); PAGES.keys(); } } catch (e) { toast(e.message, "err"); } }; }; const mask = k => k.length > 16 ? k.slice(0, 7) + "…" + k.slice(-6) : k; PAGES.proxy = async () => { const ov = await fetchOverview(); const p = ov.proxy; const pool = ov.proxypool || { xray: {}, entries: [], subscriptions: [], sticky: {} }; view.innerHTML = `

出站线路

主线路

支持 http:// · https:// · socks5://

限流与故障切换

${switchHTML("px-cross", p.cross_site_failover)}
主线路在 slow-start 内无首事件,或输出内容前失败时,自动切换到备用线路。

备用线路

#地址
当前生效顺序:${ov.cache.routes.map(r => `${esc(r.name)} → ${esc(r.proxy)}`).join(" ")}
`; const renderFb = list => { $("#tb-fb").innerHTML = list.length ? list.map((u, i) => ` ${i + 1}${esc(u)} `).join("") : '无备用线路'; $$("[data-rm]").forEach(b => b.onclick = async () => { try { await api("/api/actions", { body: { action: "remove_fallback", url: b.dataset.rm } }); PAGES.proxy(); } catch (e) { toast(e.message, "err"); } }); }; renderFb(p.fallbacks || []); const renderPool = () => { const entries = pool.entries || []; $("#tb-pool").innerHTML = entries.length ? entries.map(e => { const health = e.parse_error ? `解析失败` : e.checked ? (e.healthy ? '健康' : `不可用`) : '未探测'; return `${esc(e.name)}${e.source ? ` ← ${esc(e.source)}` : ""} ${esc(e.kind === "url" ? e.scheme : (e.scheme || "xray"))} ${esc(e.target || "")} ${health}${e.latency_ms ? e.latency_ms + "ms" : "–"} ${e.source ? "" : ``}`; }).join("") : '尚无节点 —— 添加分享链接或 ip:port 端点'; $$("[data-ptoggle]").forEach(b => b.onclick = async () => { try { await api("/api/actions", { body: { action: "pool_toggle_entry", name: b.dataset.ptoggle, enabled: b.dataset.pen === "1" } }); PAGES.proxy(); } catch (e) { toast(e.message, "err"); } }); $$("[data-pdel]").forEach(b => b.onclick = async () => { if (!confirm("删除节点 " + b.dataset.pdel + "?")) return; try { await api("/api/actions", { body: { action: "pool_delete_entry", name: b.dataset.pdel } }); PAGES.proxy(); } catch (e) { toast(e.message, "err"); } }); const subs = pool.subscriptions || []; $("#tb-subs").innerHTML = subs.length ? subs.map(s => ` ${esc(s.name)}${esc(s.url)} ${s.interval_minutes || 60}m${s.nodes || 0} ${s.fetched_at ? timeStr(s.fetched_at * 1000) : "未拉取"}${s.last_error ? ` ${esc(String(s.last_error).slice(0, 60))}` : ""} `).join("") : '尚无订阅'; $$("[data-stoggle]").forEach(b => b.onclick = async () => { try { await api("/api/actions", { body: { action: "pool_toggle_sub", name: b.dataset.stoggle, enabled: b.dataset.sen === "1" } }); PAGES.proxy(); } catch (e) { toast(e.message, "err"); } }); $$("[data-sdel]").forEach(b => b.onclick = async () => { if (!confirm("删除订阅 " + b.dataset.sdel + "?")) return; try { await api("/api/actions", { body: { action: "pool_delete_sub", name: b.dataset.sdel } }); PAGES.proxy(); } catch (e) { toast(e.message, "err"); } }); const sticky = pool.sticky || {}; $("#tb-bind").innerHTML = (ov.sites || []).map(s => { const cur = sticky[s.code]; const curEntry = entries.find(e => e.name === cur); return `${esc(s.code.toUpperCase())} ${cur ? `${esc(cur)} → ${esc(curEntry ? curEntry.target : "?")}` : "全局线路"} `; }).join(""); $$("[data-bsave]").forEach(b => b.onclick = async () => { const input = $(`[data-bind="${CSS.escape(b.dataset.bsave)}"]`); const list = input.value.split(",").map(x => x.trim()).filter(Boolean); try { await api("/api/actions", { body: { action: "pool_set_site", site: b.dataset.bsave, proxies: list } }); toast("已保存", "ok"); PAGES.proxy(); } catch (e) { toast(e.message, "err"); } }); }; renderPool(); // 「基础线路 / 代理池」两个按钮组各自带保存,共用同一提交逻辑 $$("[data-pool-save]").forEach(b => b.onclick = async () => { try { await api("/api/actions", { body: { action: "pool_set_config", enabled: $("#pool-en").checked, check_interval_seconds: +$("#pool-check-int").value || 120, check_timeout_seconds: +$("#pool-check-to").value || 10, check_url: $("#pool-check-url").value.trim(), default_scheme: $("#pool-scheme").value.trim() || "http", xray_path: $("#pool-xray-path").value.trim(), xray_version: $("#pool-xray-ver").value.trim(), xray_auto_download: $("#pool-xray-auto").checked } }); toast("池配置已保存", "ok"); PAGES.proxy(); } catch (e) { toast(e.message, "err"); } }); $("#pool-refresh").onclick = async () => { const b = $("#pool-refresh"); b.disabled = true; b.textContent = "刷新中…"; try { await api("/api/actions", { body: { action: "pool_refresh" } }); toast("已在后台拉取订阅并检查健康,稍后重进本页查看", "ok"); PAGES.proxy(); } catch (e) { toast(e.message, "err"); b.disabled = false; b.textContent = "刷新节点与健康检查"; } }; $("#pool-add").onclick = async () => { try { await api("/api/actions", { body: { action: "pool_add_entry", name: $("#pool-name").value.trim(), link: $("#pool-link").value.trim() } }); toast("已添加", "ok"); PAGES.proxy(); } catch (e) { toast(e.message, "err"); } }; $("#sub-add").onclick = async () => { try { await api("/api/actions", { body: { action: "pool_add_sub", name: $("#sub-name").value.trim(), url: $("#sub-url").value.trim(), interval_minutes: +$("#sub-int").value || 60 } }); toast("已添加", "ok"); PAGES.proxy(); } catch (e) { toast(e.message, "err"); } }; $("#px-save").onclick = async () => { try { await api("/api/config", { method: "PUT", body: await merged(c => { c.proxy.url = $("#px-primary").value.trim(); }) }); toast("主线路已保存", "ok"); PAGES.proxy(); } catch (e) { toast(e.message, "err"); } }; $("#px-test").onclick = async () => { const b = $("#px-test"); b.disabled = true; b.textContent = "测试中…"; try { const r = await api("/api/actions", { body: { action: "test_proxy", url: $("#px-primary").value.trim() } }); toast(r.ok ? `连通 · HTTP ${r.status} · ${r.ms}ms` : (r.error || "失败"), r.ok ? "ok" : "err"); } catch (e) { toast(e.message, "err"); } b.disabled = false; b.textContent = "连通性测试"; }; $("#px-savelim").onclick = async () => { try { await api("/api/actions", { body: { action: "set_limits", slow_start: +$("#px-slow").value, max_concurrent: +$("#px-conc").value, rate_per_minute: +$("#px-rate").value, cross_site_failover: $("#px-cross").checked } }); toast("已保存", "ok"); } catch (e) { toast(e.message, "err"); } }; $("#px-add").onclick = async () => { const v = $("#px-fb").value.trim(); if (!v) return; try { await api("/api/actions", { body: { action: "add_fallback", url: v } }); $("#px-fb").value = ""; PAGES.proxy(); } catch (e) { toast(e.message, "err"); } }; // 顶部页签切换:基础线路 / 代理池 两组卡片三选一显隐 const syncPx = () => { $$("#px-seg button").forEach(b => b.classList.toggle("on", b.dataset.m === pxMode)); $$(".px-mode").forEach(el => { el.style.display = el.dataset.mode === pxMode ? "" : "none"; }); $("#px-hint").textContent = pxMode === "basic" ? "上游请求的出口:主线路优先,失败或超时按备用线路顺序降级;限流与故障切换作用于全局。" : "聚合分享链接与订阅节点,按站点粘性绑定出口;未绑定或全部不健康时回退到基础线路。"; }; $$("#px-seg button").forEach(b => b.onclick = () => { pxMode = b.dataset.m; syncPx(); }); syncPx(); }; async function merged(mutate) { const cfg = await api("/api/config"); mutate(cfg); return cfg; } PAGES.turnstile = async () => { if (overviewTimer) { clearInterval(overviewTimer); overviewTimer = null; } const ov = await fetchOverview(); const t = ov.turnstile.config; view.innerHTML = `

Token 获取方式

Sitekey / Action 是全局兜底:站点未单独配置时,方式 2 的浏览器求解也用它们。

求解超时与重试

只作用于自动求解(方式 1 / 2),手动导入不走这条路径。

Cookie 缓存(通用)

三种方式共用:自动求解出的与手动导入的 Cookie 都按此时长缓存,到期后自动重新获取。

已验证 Cookie

缓存 TTL ${t.cookie_ttl_seconds}s · 求解超时 ${t.timeout_seconds || 90}s · 重试 ${t.retries} 次

Cookie 池

${switchHTML("ts-warm", !!t.warm_enabled)}

求解历史

时间站点线路耗时结果
`; $("#ts-status").innerHTML = (ov.turnstile.status || []).map(s => `
${esc(s.site.toUpperCase())} ${s.valid ? "有效" : "未建立"}
${s.valid ? "剩余 " + Math.round(s.remaining_s / 60) + " 分钟 · " + s.cookie_count + " 个 Cookie" : "下次请求时自动求解"}
`).join(""); const hist = ov.turnstile.history || []; $("#ts-hist").innerHTML = hist.length ? hist.slice().reverse().slice(0, 40).map(h => ` ${timeStr(h.at)}${esc(h.site)}${esc(h.proxy)} ${fmtMs(h.elapsed_ms)} ${h.ok ? "成功" : "失败"}${h.error ? ` ${esc(h.error.slice(0, 70))}` : ""}`).join("") : '尚无记录'; const renderPool = (o) => { const pool = (o.turnstile && o.turnstile.pool) || { entries: [] }; const label = $("#ts-pool-state"); if (label) { label.textContent = `状态:${pool.enabled ? (pool.running ? "运行中" : "已启用,等待下一轮巡检") : "未开启"} · 阈值 剩余 ${Math.round((pool.ratio || 0.3) * 100)}% · 每 ${pool.check_seconds || 60}s 巡检一次`; } const box = $("#ts-pool"); if (!box) return; const entries = pool.entries || []; box.innerHTML = entries.length ? entries.map(e => { const state = e.warming ? '预热中' : e.valid ? '就绪' : e.last_error ? '求解失败' : '未建立'; const left = e.valid ? `剩余 ${Math.max(0, Math.round(e.remaining_s / 60))} 分钟` : "等待预热"; const next = e.next_warm_at ? `下次 ${timeStr(e.next_warm_at * 1000)}` : "—"; const last = e.last_at ? `上次 ${timeStr(e.last_at * 1000)} · ${fmtMs(e.last_ms)}` : "尚未求解"; return `
${esc(e.site.toUpperCase())} ${esc(e.route)}
${state}
${left} · ${next}
${last}${e.last_error ? `
${esc(String(e.last_error).slice(0, 90))}` : ""}
`; }).join("") : '
暂无池内记录 — 开启预热并等待下一轮巡检
'; }; renderPool(ov); $$("[data-rf]").forEach(b => b.onclick = async () => { b.disabled = true; try { await api("/api/actions", { body: { action: "refresh_cookies", site: b.dataset.rf } }); toast(t.warm_enabled ? "已作废,Cookie 池将在下一轮巡检时重新预热" : "已作废,将在下次请求时重新求解", "ok"); PAGES.turnstile(); } catch (e) { toast(e.message, "err"); } b.disabled = false; }); // 所选方式存在局部变量里,seg 按钮只负责切换,不依赖隐藏的 select。 let provider = t.provider || "api"; $("#ts-style").value = t.api_style === "ezsolver" ? "ezsolver" : "sync"; const TS_HINTS = { api: "调用求解服务 API 获取 Turnstile Token", browser: "用下方配置的本地浏览器自动求解 Token", manual: "不自动求解,导入已通过验证的 Cookie 即可(TTL 内免求解)", }; // 组件级显隐:方式 N 的字段只在选中时出现;超时重试 / 右侧池与历史 // 只在方式 1/2 出现;缓存卡与已验证 Cookie 恒显。求解恒开启,无开关。 const syncProvider = () => { const solving = provider === "api" || provider === "browser"; $$("#ts-seg button").forEach(b => b.classList.toggle("on", b.dataset.p === provider)); $$(".ts-mode").forEach(el => { el.style.display = el.dataset.mode === provider ? "" : "none"; }); $$(".ts-solve-only").forEach(el => { el.style.display = solving ? "" : "none"; }); $("#ts-refresh").style.display = provider === "manual" ? "none" : ""; // Cookie 池卡片隐藏时连轮询一起停,manual 方式不再每 5s 拉一次 overview。 if (solving) { if (!overviewTimer) overviewTimer = setInterval(() => { fetchOverview().then(renderPool).catch(() => {}); }, 5000); } else if (overviewTimer) { clearInterval(overviewTimer); overviewTimer = null; } $("#ts-hint").textContent = TS_HINTS[provider] || TS_HINTS.api; }; $$("#ts-seg button").forEach(b => b.onclick = () => { provider = b.dataset.p; syncProvider(); }); syncProvider(); $("#ts-save").onclick = async () => { try { await api("/api/actions", { body: { // 求解恒开启:控制台不再提供关闭入口,保存时强制 enabled=true, // 避免误关后所有站点悄悄退化成直连上游。 action: "set_turnstile", enabled: true, provider, api_url: $("#ts-url").value.trim(), api_key: $("#ts-key").value.trim(), sitekey: $("#ts-sitekey").value.trim(), challenge_action: $("#ts-action").value.trim(), cookie_ttl_seconds: +$("#ts-ttl").value, api_style: $("#ts-style").value, timeout_seconds: Math.round(+$("#ts-timeout").value || 90), retries: Math.round(+$("#ts-retries").value || 5), retry_backoff_seconds: Math.max(0, +$("#ts-backoff").value || 0), warm_enabled: $("#ts-warm").checked, warm_ratio: Math.min(95, Math.max(5, Math.round(+$("#ts-ratio").value || 30))) / 100, warm_check_seconds: Math.round(+$("#ts-check").value || 60), browser_path: $("#ts-bpath").value.trim(), browser_headless: $("#ts-bhead").checked, browser_user_data_dir: $("#ts-bprof").value.trim(), browser_timezone: $("#ts-btz").value.trim(), browser_locale: $("#ts-bloc").value.trim() } }); toast("已保存", "ok"); PAGES.turnstile(); } catch (e) { toast(e.message, "err"); } }; $("#ts-refresh").onclick = async () => { try { await api("/api/actions", { body: { action: "invalidate" } }); toast("已作废全部 cookie", "ok"); PAGES.turnstile(); } catch (e) { toast(e.message, "err"); } }; $("#ts-clear").onclick = async () => { try { await api("/api/actions", { body: { action: "clear_cookies" } }); toast("已清空 Cookie", "ok"); PAGES.turnstile(); } catch (e) { toast(e.message, "err"); } }; $("#im-save").onclick = async () => { try { const r = await api("/api/actions", { body: { action: "import_cookies", site: $("#im-site").value, cookies: $("#im-cookies").value } }); toast(`已导入 ${r.cookie_count} 个 Cookie,${timeStr(r.expires_at * 1000)} 到期`, "ok"); PAGES.turnstile(); } catch (e) { toast(e.message, "err"); } }; }; PAGES.playground = async () => { const ov = await fetchOverview(); const enabled = ov.models.filter(m => m.enabled); view.innerHTML = `

请求

${switchHTML("pg-stream", true)}

响应

尚未发送请求
`; let ac = null, timer = null; const stopTimer = () => { if (timer) { clearInterval(timer); timer = null; } }; $("#pg-clear").onclick = () => { stopTimer(); ac = null; $("#pg-out").innerHTML = '尚未发送请求'; $("#pg-meta").textContent = ""; }; $("#pg-cancel").onclick = () => { if (ac) ac.abort(); }; $("#pg-send").onclick = async () => { const input = $("#pg-input").value.trim(); if (!input) { toast("请输入内容", "err"); return; } let tools = null; const raw = $("#pg-tools").value.trim(); if (raw) { try { tools = JSON.parse(raw); } catch (e) { toast("Tools JSON 解析失败", "err"); return; } } const messages = [{ role: "system", content: $("#pg-system").value }, { role: "user", content: input }]; const model = $("#pg-model").value; const stream = $("#pg-stream").checked; const out = $("#pg-out"); out.innerHTML = ""; const status = document.createElement("span"); status.className = "dim"; status.textContent = "连接中… 0.0s"; const caret = document.createElement("span"); caret.className = "caret"; out.appendChild(status); out.appendChild(caret); ac = new AbortController(); const btn = $("#pg-send"), cancel = $("#pg-cancel"); btn.disabled = true; btn.textContent = "请求中…"; cancel.disabled = false; $("#pg-meta").textContent = ""; const t0 = Date.now(); const tick = () => { status.textContent = `连接中… ${((Date.now() - t0) / 1000).toFixed(1)}s`; }; stopTimer(); timer = setInterval(tick, 100); const finish = () => { stopTimer(); ac = null; btn.disabled = false; btn.textContent = "发送"; cancel.disabled = true; if (!$("#pg-meta").textContent) $("#pg-meta").textContent = `${fmtMs(Date.now() - t0)}`; }; const firstText = () => { if (status.parentNode) status.remove(); }; try { if (stream) { const res = await fetch("/api/playground", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model, messages, stream: true, tools }), signal: ac.signal }); if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.error || res.statusText); } out.appendChild(caret); const reader = res.body.getReader(), dec = new TextDecoder(); let buf = "", first = null; for (;;) { const { done, value } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); let idx; while ((idx = buf.indexOf("\n\n")) >= 0) { const frame = buf.slice(0, idx); buf = buf.slice(idx + 2); if (!frame.startsWith("data: ")) continue; const payload = frame.slice(6); if (payload === "[DONE]") continue; let j; try { j = JSON.parse(payload); } catch (e) { continue; } if (j.error) { firstText(); out.insertBefore(document.createTextNode("\n[错误] " + j.error), caret); continue; } if (j.delta) { if (!first) first = Date.now(); firstText(); out.insertBefore(document.createTextNode(j.delta), caret); } if (j.done) { $("#pg-meta").textContent = `${j.ms}ms · ${j.site} · ${j.route}`; } } } caret.remove(); } else { const j = await api("/api/playground", { body: { model, messages, stream: false, tools }, signal: ac.signal }); out.textContent = j.text; $("#pg-meta").textContent = `${j.ms}ms · ${j.site} · ${j.route} · prompt ${j.prompt_chars} 字符`; } } catch (e) { caret.remove(); if (e && e.name === "AbortError") { out.innerHTML = '已取消'; } else { out.innerHTML = `请求失败:${esc(e.message)}`; } } finish(); }; }; PAGES.logs = async () => { view.innerHTML = `
实时连接中…
`; const box = $("#lg-box"); let paused = false, es = null, gen = 0; const minVal = { DEBUG: -4, INFO: 0, WARN: 4, ERROR: 8 }; function append(e) { if (paused) return; const sel = $("#lg-level"); if (sel && minVal[e.level] < minVal[sel.value]) return; const line = document.createElement("div"); line.className = "log-line"; const attrs = e.attrs ? Object.entries(e.attrs).map(([k, v]) => `${k}=${v}`).join(" ") : ""; line.innerHTML = `${timeStr(e.ts)} ${e.level} ${esc(e.msg)}${attrs ? ' ' + esc(attrs) + "" : ""}`; box.appendChild(line); while (box.children.length > 600) box.removeChild(box.firstChild); box.scrollTop = box.scrollHeight; } function stopES() { if (es) { es.close(); es = null; } } // (Re)load history and reopen the SSE stream with the currently selected // minimum level. Never re-render the page here: rebuilding the select would // reset it to INFO before the value is read, and would leak the old stream. // History comes solely from the server replay on connect (and on every // automatic reconnect), so the box is cleared in onopen instead of fetching // the JSON endpoint separately — otherwise entries show up twice. async function load() { const my = ++gen; const level = $("#lg-level").value; stopES(); box.innerHTML = ""; es = new EventSource("/api/logs?stream=1&level=" + level); es.onmessage = ev => { if (my !== gen) return; try { append(JSON.parse(ev.data)); } catch (e) {} }; es.onerror = () => { if (my !== gen) return; const st = $("#lg-state"); if (st) st.textContent = "连接中断,自动重连中…"; }; es.onopen = () => { if (my !== gen) return; box.innerHTML = ""; const st = $("#lg-state"); if (st) st.textContent = "实时连接中…"; }; logStop = () => { gen++; stopES(); }; } $("#lg-toggle").onclick = e => { paused = !paused; e.target.textContent = paused ? "继续" : "暂停"; }; $("#lg-clear").onclick = () => { box.innerHTML = ""; }; $("#lg-level").onchange = load; await load(); }; PAGES.settings = async () => { const cfg = await api("/api/config"); view.innerHTML = `

服务

上游

${switchHTML("st-ar", cfg.upstream.auto_refresh)}

危险操作

配置保存会自动备份原文件为 config.toml.bak;修改端口/密码需重启服务生效。
`; const put = async mutate => { try { const c = await api("/api/config"); mutate(c); await api("/api/config", { method: "PUT", body: c }); toast("已保存并写入 config.toml", "ok"); return true; } catch (e) { toast(e.message, "err"); return false; } }; $("#st-save").onclick = () => put(c => { c.server.host = $("#st-host").value; c.server.port = +$("#st-port").value; c.server.log_level = $("#st-log").value; c.admin.host = $("#st-ah").value; c.admin.port = +$("#st-ap").value; if ($("#st-pw").value) c.admin.password = $("#st-pw").value; }); $("#st-save2").onclick = () => put(c => { c.upstream.timeout = +$("#st-to").value; c.upstream.stream_timeout = +$("#st-sto").value; c.upstream.config_ttl_seconds = +$("#st-ttl").value; c.upstream.refresh_retries = +$("#st-retry").value; c.upstream.retry_backoff_seconds = +$("#st-back").value; c.upstream.auto_refresh = $("#st-ar").checked; c.upstream.user_agent = $("#st-ua").value; c.runtime.data_dir = $("#st-dir").value; }); $("#st-reset").onclick = async () => { try { await api("/api/actions", { body: { action: "reset_metrics" } }); toast("已清空", "ok"); } catch (e) { toast(e.message, "err"); } }; $("#st-inv").onclick = async () => { try { await api("/api/actions", { body: { action: "invalidate" } }); toast("已作废", "ok"); } catch (e) { toast(e.message, "err"); } }; }; /* ── boot ────────────────────────────────────────────────── */ window.addEventListener("hashchange", route); $("#login-btn").onclick = doLogin; $("#login-password").addEventListener("keydown", e => { if (e.key === "Enter") doLogin(); }); $("#logout-btn").onclick = async () => { try { await api("/api/logout", { method: "POST" }); } catch (e) {} location.hash = "#/dashboard"; showLogin(); }; (async function init() { if (await ensureSession()) route(); })(); })();