function toLocalInputValue(d) { const pad = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } function localInputToIso(value) { if (!value) return undefined; const d = new Date(value); return Number.isNaN(d.getTime()) ? undefined : d.toISOString(); } function formatAt(iso) { if (!iso) return "—"; const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso; return d.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, }); } function formatNum(n) { if (n == null || Number.isNaN(Number(n))) return "—"; return Number(n).toLocaleString("zh-CN"); } function formatMoney(money) { if (!money || money.amount == null || Number.isNaN(Number(money.amount))) return "—"; const amount = Number(money.amount); const currency = money.currency || "USD"; const symbol = currency === "CNY" ? "¥" : currency === "USD" ? "$" : `${currency} `; const abs = Math.abs(amount); let digits = 2; if (abs > 0 && abs < 0.01) digits = 6; else if (abs < 1) digits = 4; const body = amount.toFixed(digits).replace(/(\.\d*?[1-9])0+$/, "$1").replace(/\.0+$/, ""); const shown = body.includes(".") ? body : `${body}.00`; return `${symbol}${shown}`; } function formatCostTotal(total, byCurrency) { if (total) return formatMoney(total); const entries = Object.entries(byCurrency || {}); if (!entries.length) return "—"; return entries.map(([currency, amount]) => formatMoney({ amount, currency })).join(" + "); } function formatUnitPrice(unit) { if (!unit || unit.input == null || unit.output == null) return "—"; const currency = unit.currency || "USD"; const inPrice = formatMoney({ amount: unit.input, currency }); const outPrice = formatMoney({ amount: unit.output, currency }); const cached = unit.cached == null ? "" : ` · 缓存 ${formatMoney({ amount: unit.cached, currency })}`; const per = unit.unitTokens === 1_000_000 ? "/1M" : `/${formatNum(unit.unitTokens)}`; return `${inPrice} / ${outPrice}${per}${cached}`; } function setStatus(text, state = "idle") { const status = document.getElementById("status"); status.textContent = text; status.dataset.state = state; } function setPricingStatus(text, state = "idle") { const el = document.getElementById("pricing-status"); if (!text) { el.hidden = true; el.textContent = ""; el.dataset.state = "idle"; return; } el.hidden = false; el.textContent = text; el.dataset.state = state; } function setRangeActive(id) { for (const btn of document.querySelectorAll(".stats-range .btn-secondary")) { btn.classList.toggle("is-active", btn.id === id); } } async function api(path, options = {}) { const res = await fetch(path, { headers: { "Content-Type": "application/json" }, ...options, }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); return data; } async function loadStats() { const from = localInputToIso(document.getElementById("from").value); const to = localInputToIso(document.getElementById("to").value); const limit = Number(document.getElementById("limit").value) || 200; const qs = new URLSearchParams(); if (from) qs.set("from", from); if (to) qs.set("to", to); qs.set("limit", String(limit)); setStatus("加载中…", "loading"); try { const res = await fetch(`/api/stats/tokens?${qs}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); const records = data.records || []; renderSummary(data.summary); renderModels(data.summary?.byModel || data.cost?.byModel || []); renderRows(records); const totalCalls = data.summary?.totalCalls ?? 0; const unpriced = data.summary?.unpricedCalls ?? data.cost?.unpricedCalls ?? 0; const extra = unpriced ? ` · ${formatNum(unpriced)} 次无报价` : ""; setStatus( `共 ${formatNum(totalCalls)} 次调用 · 显示 ${formatNum(records.length)} 条${extra}`, "idle", ); loadCompare(); } catch (err) { renderSummary(null); renderModels([]); renderRows([]); setStatus( `加载失败:${err instanceof Error ? err.message : String(err)}`, "error", ); loadCompare(); } } function renderSummary(summary) { const el = document.getElementById("summary"); if (!summary) { el.innerHTML = ""; return; } const costValue = formatCostTotal(summary.estimatedCost, summary.byCurrency); const costNote = summary.unpricedCalls ? `${formatNum(summary.unpricedCalls)} 次无报价` : summary.estimatedCost ? "按官方文档估算" : "未配置价目"; const metrics = [ ["调用次数", formatNum(summary.totalCalls ?? 0), null, null], ["估算花销", costValue, "cost", costNote], ["总 token", formatNum(summary.totalTokens ?? 0), "total", null], ["prompt", formatNum(summary.promptTokens ?? 0), null, null], ["completion", formatNum(summary.completionTokens ?? 0), null, null], ["cached", formatNum(summary.totalCached ?? 0), null, null], ]; el.innerHTML = metrics .map( ([label, value, emphasis, note]) => `
${label} ${value} ${note ? `${escapeHtml(note)}` : ""}
`, ) .join(""); } function renderModels(rows) { const tbody = document.getElementById("model-rows"); const empty = document.getElementById("model-empty"); const table = document.getElementById("model-table"); if (!rows.length) { tbody.innerHTML = ""; table.hidden = true; empty.hidden = false; return; } table.hidden = false; empty.hidden = true; tbody.innerHTML = rows .map((r) => { const cost = r.unmatched || !r.cost ? `无报价` : formatMoney(r.cost); const unit = r.unmatched ? `—` : escapeHtml(formatUnitPrice(r.unitPrice)); const source = r.unmatched ? "—" : escapeHtml(r.sourceName || "官方文档"); return ` ${escapeHtml(r.model || "—")} ${formatNum(r.calls ?? 0)} ${formatNum(r.promptTokens ?? 0)} ${formatNum(r.completionTokens ?? 0)} ${formatNum(r.cachedTokens ?? 0)} ${unit} ${cost} ${source} `; }) .join(""); } function uncachedTokens(record) { if (record.cacheMissTokens != null) return record.cacheMissTokens; if (record.promptTokens != null && record.cachedTokens != null) { return Math.max(0, record.promptTokens - record.cachedTokens); } return null; } function breakLabel(info) { if (!info) return "—"; if (info.cold) return "冷启动"; if (info.breakAt < 0) return "整段接上"; return info.tag || "断开"; } function breakNote(record) { const info = record.cacheBreak; const segments = record.segments || []; if (!segments.length) return "这次没有分段标签。"; if (!info || info.cold) { return "本页没有更早的同会话请求可比。"; } if (info.breakAt < 0) { let note = "与上一条同会话请求的前缀整段相同。"; if (!record.cachedTokens) note += " 账单缓存是 0。"; return note; } const tag = segments[info.breakAt]?.tag || info.tag || "(上一条更长)"; let note = `前 ${info.matched ?? info.breakAt} 段与上一条相同,从「${tag}」起前缀不再一致。`; if (!record.cachedTokens && info.breakAt > 0) { note += " 账单缓存是 0。"; } return note; } function segmentLines(record) { const info = record.cacheBreak; const segments = record.segments || []; return segments .map((seg, i) => { let mark = " "; if (info && !info.cold) { if (info.breakAt < 0 || i < info.breakAt) mark = "已接上"; else if (i === info.breakAt) mark = "断开 "; else mark = "之后 "; } return `${String(i + 1).padStart(2, "0")} ${mark} ${escapeHtml(seg.tag)} ${formatNum(seg.chars)}字 ${escapeHtml(seg.hash)}`; }) .join("\n"); } function renderRows(records) { const tbody = document.getElementById("rows"); const empty = document.getElementById("empty"); const table = document.getElementById("call-table") || tbody.closest("table"); if (!records.length) { tbody.innerHTML = ""; if (table) table.hidden = true; empty.hidden = false; return; } if (table) table.hidden = false; empty.hidden = true; tbody.innerHTML = records .map((r) => { const cost = r.unmatchedRate || !r.estimatedCost ? `—` : formatMoney(r.estimatedCost); const chars = r.segmentChars; const charLine = chars ? `规则 ${formatNum(chars.rules)} · 更早对话 ${formatNum(chars.history)} · 产物 ${formatNum(chars.products)} · 注入 ${formatNum(chars.injected)} · 末尾 ${formatNum(chars.tail)}` : ""; const tags = [ r.turnId ? `turn ${escapeHtml(r.turnId)}` : "", r.plannedTags?.length ? `计划 ${escapeHtml(r.plannedTags.join("、"))}` : "", r.succeededTags?.length ? `已写入 ${escapeHtml(r.succeededTags.join("、"))}` : "", r.failedTags?.length ? `未完成 ${escapeHtml(r.failedTags.join("、"))}` : "", ].filter(Boolean).join(" · "); return ` ${formatAt(r.at)} ${escapeHtml(r.step || "—")} ${escapeHtml(r.caller || "—")} ${escapeHtml(r.model || "—")} ${formatNum(r.promptTokens ?? 0)} ${uncachedTokens(r) == null ? "—" : formatNum(uncachedTokens(r))} ${r.cachedTokens == null ? "—" : formatNum(r.cachedTokens)} ${escapeHtml(breakLabel(r.cacheBreak))} ${cost}

系统信息:断点、分段标签和哈希只给你对照,不会发给模型。「工具」是 tools 字段。

${escapeHtml(breakNote(r))} 写出 ${formatNum(r.completionTokens ?? 0)}。

${charLine ? `

${charLine}

` : ""} ${tags ? `

${tags}

` : ""}
${segmentLines(r) || "(无分段)"}
`; }) .join(""); } function escapeHtml(s) { return String(s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function setRange(fromDate, toDate) { document.getElementById("from").value = toLocalInputValue(fromDate); document.getElementById("to").value = toLocalInputValue(toDate); } async function loadPricing() { try { const data = await api("/api/stats/pricing"); renderSuggested(data.suggested || [], data.sources || []); renderSources(data.sources || []); } catch (err) { setPricingStatus( `价目来源加载失败:${err instanceof Error ? err.message : String(err)}`, "error", ); } } function renderSuggested(items, sources = []) { const el = document.getElementById("pricing-suggested"); const used = new Set((sources || []).map((s) => s.url)); const remaining = items.filter((s) => !used.has(s.url)); if (!remaining.length) { el.innerHTML = ""; return; } el.innerHTML = remaining .map( (s) => ``, ) .join(""); } function renderSources(sources) { const el = document.getElementById("pricing-sources"); if (!sources.length) { el.innerHTML = ""; return; } el.innerHTML = sources .map((s) => { const when = s.fetchedAt ? formatAt(s.fetchedAt) : "尚未拉取"; const sub = s.fetchError ? s.fetchError : `${formatNum(s.modelCount ?? 0)} 个型号 · ${when}`; const state = s.fetchError ? "error" : "idle"; return `
  • ${escapeHtml(s.name)}
    ${escapeHtml(s.url)}

    ${escapeHtml(sub)}

  • `; }) .join(""); } function setPricingBusy(busy) { document.getElementById("btn-add-pricing").disabled = busy; for (const btn of document.querySelectorAll(".stats-pricing-actions button, .stats-suggest")) { btn.disabled = busy; } } async function addPricingSource(url, name) { const trimmed = (url || "").trim(); if (!trimmed) { setPricingStatus("请填写官方价目文档 URL", "error"); return; } setPricingBusy(true); setPricingStatus("正在拉取并解析官方文档…", "idle"); try { const data = await api("/api/stats/pricing/sources", { method: "POST", body: JSON.stringify({ url: trimmed, name }), }); document.getElementById("pricing-url").value = ""; if (data.source?.fetchError) { setPricingStatus(data.source.fetchError, "error"); } else { setPricingStatus("已按文档更新单价", "idle"); } await loadPricing(); await loadStats(); } catch (err) { setPricingStatus(err instanceof Error ? err.message : String(err), "error"); await loadPricing(); } finally { setPricingBusy(false); } } document.getElementById("btn-load").addEventListener("click", () => { setRangeActive(""); loadStats(); }); document.getElementById("btn-today").addEventListener("click", () => { const now = new Date(); const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0); setRange(start, now); setRangeActive("btn-today"); loadStats(); }); document.getElementById("btn-7d").addEventListener("click", () => { const now = new Date(); const start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); setRange(start, now); setRangeActive("btn-7d"); loadStats(); }); document.getElementById("from").addEventListener("change", () => setRangeActive("")); document.getElementById("to").addEventListener("change", () => setRangeActive("")); document.getElementById("pricing-form").addEventListener("submit", (ev) => { ev.preventDefault(); addPricingSource(document.getElementById("pricing-url").value); }); document.getElementById("pricing-suggested").addEventListener("click", (ev) => { const btn = ev.target.closest(".stats-suggest"); if (!btn) return; addPricingSource(btn.dataset.url, btn.textContent); }); document.getElementById("pricing-sources").addEventListener("click", async (ev) => { const btn = ev.target.closest("button[data-action]"); if (!btn) return; const item = btn.closest(".stats-pricing-item"); const id = item?.dataset.id; if (!id) return; const action = btn.dataset.action; setPricingBusy(true); try { if (action === "refresh") { setPricingStatus("正在重新拉取官方文档…", "idle"); const data = await api(`/api/stats/pricing/sources/${encodeURIComponent(id)}/refresh`, { method: "POST", }); if (data.source?.fetchError) { setPricingStatus(data.source.fetchError, "error"); } else { setPricingStatus("已按文档更新单价", "idle"); } } else if (action === "delete") { await api(`/api/stats/pricing/sources/${encodeURIComponent(id)}`, { method: "DELETE", }); setPricingStatus("已移除价目来源", "idle"); } await loadPricing(); await loadStats(); } catch (err) { setPricingStatus(err instanceof Error ? err.message : String(err), "error"); await loadPricing(); } finally { setPricingBusy(false); } }); function setCompareStatus(text, state = "idle") { const el = document.getElementById("compare-status"); if (!text) { el.hidden = true; el.textContent = ""; el.dataset.state = "idle"; return; } el.hidden = false; el.textContent = text; el.dataset.state = state; } function barWidth(value, max) { if (!max || value == null || value <= 0) return 0; return Math.max(2, Math.round((value / max) * 100)); } function shortModelName(id) { const slash = id.lastIndexOf("/"); return slash >= 0 ? id.slice(slash + 1) : id; } /** 默认按近 100 次从低到高;没有调用时这一列没有数,排序退回输入单价。 */ let comparePayload = null; let compareSort = { key: "projected", dir: "asc" }; function compareSortValue(row, key, usageCalls) { const unit = row.unitPrice || {}; if (key === "model") return shortModelName(row.model || "").toLowerCase(); if (key === "input") return unit.input ?? null; if (key === "cached") return unit.cached ?? null; if (key === "output") return unit.output ?? null; if (usageCalls > 0) return row.projected?.amount ?? null; return unit.input ?? null; } function compareNullable(av, bv, dir) { const aMissing = av == null || av === ""; const bMissing = bv == null || bv === ""; if (aMissing && bMissing) return 0; if (aMissing) return 1; if (bMissing) return -1; const cmp = typeof av === "string" ? av.localeCompare(bv, "en") : av - bv; return dir === "desc" ? -cmp : cmp; } function syncCompareSortHeaders() { for (const btn of document.querySelectorAll("#compare-table .stats-sort")) { const key = btn.dataset.sort; const th = btn.closest("th"); const active = compareSort.key === key; th?.setAttribute( "aria-sort", active ? (compareSort.dir === "asc" ? "ascending" : "descending") : "none", ); const label = btn.dataset.label || ""; btn.textContent = active ? `${label} ${compareSort.dir === "asc" ? "↑" : "↓"}` : label; } } function renderCompare(data) { const list = document.getElementById("compare-list"); const table = document.getElementById("compare-table"); const rows = (data?.rows || []).filter((row) => row.found && row.unitPrice); const usageCalls = data?.usage?.calls ?? 0; syncCompareSortHeaders(); if (!rows.length) { table.hidden = true; list.innerHTML = ""; return; } table.hidden = false; const query = (document.getElementById("compare-filter")?.value || "").trim().toLowerCase(); const matched = query ? rows.filter((row) => { const id = String(row.model || "").toLowerCase(); return id.includes(query) || shortModelName(id).includes(query); }) : rows; if (!matched.length) { list.innerHTML = `没有名称包含「${escapeHtml(query)}」的型号`; return; } const ranked = [...matched].sort((a, b) => compareNullable( compareSortValue(a, compareSort.key, usageCalls), compareSortValue(b, compareSort.key, usageCalls), compareSort.dir, ), ); const maxAmount = Math.max(0, ...rows.map((row) => row.projected?.amount || 0)); const cheapest = Math.min(...rows.map((row) => row.projected?.amount || 0).filter((n) => n > 0)); list.innerHTML = ranked .map((row) => { const unit = row.unitPrice; const currency = unit.currency; const cached = unit.cached == null ? "—" : formatMoney({ amount: unit.cached, currency }); const amount = row.projected?.amount || 0; const multiple = usageCalls > 0 && cheapest > 0 && amount > 0 ? `×${(amount / cheapest).toFixed(amount / cheapest >= 10 ? 0 : 1)}` : ""; const estimate = usageCalls > 0 ? `
    ${escapeHtml(formatMoney(row.projected))} ${escapeHtml(multiple)}
    ` : "—"; return ` ${escapeHtml(shortModelName(row.model))} ${escapeHtml(formatMoney({ amount: unit.input, currency }))} ${escapeHtml(cached)} ${escapeHtml(formatMoney({ amount: unit.output, currency }))} ${estimate} `; }) .join(""); } document.querySelector("#compare-table thead")?.addEventListener("click", (ev) => { const btn = ev.target.closest(".stats-sort"); if (!btn) return; const key = btn.dataset.sort; if (!key) return; if (compareSort.key === key) { compareSort.dir = compareSort.dir === "asc" ? "desc" : "asc"; } else { compareSort = { key, dir: "asc" }; } if (comparePayload) renderCompare(comparePayload); else syncCompareSortHeaders(); }); document.getElementById("compare-filter")?.addEventListener("input", () => { if (comparePayload) renderCompare(comparePayload); }); async function loadCompare() { try { const data = await api("/api/stats/compare"); comparePayload = data; renderCompare(data); const usage = data.usage || {}; const calls = usage.calls ?? 0; if (!data.rows?.length) { setCompareStatus("尚未拉取", "idle"); return; } if (!calls) { setCompareStatus("通常单价已显示。最近没有调用,预估区为空。", "idle"); return; } const prompt = usage.promptTokens || 0; const cached = usage.cachedTokens || 0; const hit = prompt > 0 ? Math.round((cached / prompt) * 100) : 0; setCompareStatus( `最近 ${formatNum(calls)} 次:输入 ${formatNum(prompt)}(缓存命中 ${formatNum(cached)},${hit}%)· 输出 ${formatNum(usage.completionTokens || 0)}`, "idle", ); } catch (err) { comparePayload = null; document.getElementById("compare-list").innerHTML = ""; document.getElementById("compare-table").hidden = true; setCompareStatus(err instanceof Error ? err.message : String(err), "error"); } } document.getElementById("btn-pull-openrouter").addEventListener("click", async () => { const btn = document.getElementById("btn-pull-openrouter"); btn.disabled = true; setCompareStatus("正在拉取 OpenRouter 价格…", "idle"); try { const data = await api("/api/stats/pricing/openrouter", { method: "POST" }); await loadCompare(); await loadStats(); if (data.source?.fetchError) { setCompareStatus(data.source.fetchError, "error"); } } catch (err) { setCompareStatus(err instanceof Error ? err.message : String(err), "error"); } finally { btn.disabled = false; } }); document.getElementById("rows")?.addEventListener("click", (event) => { const row = event.target.closest("tr.stats-call"); if (!row) return; const detail = row.nextElementSibling; if (!detail?.classList.contains("stats-detail")) return; detail.hidden = !detail.hidden; }); (() => { const now = new Date(); const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0); setRange(start, now); setRangeActive("btn-today"); loadPricing(); loadStats(); })();