Files
writing-agent/web/stats.js
chenmoranzhiandCursor b59ccf976c 启动时对照开放路由价目,并在统计页看清缓存费用。
最近用过的模型变价时可以在页上直接核对。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 08:29:46 +08:00

688 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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]) =>
`<div class="stats-metric"${emphasis ? ` data-emphasis="${emphasis}"` : ""}>
<span class="stats-metric-label">${label}</span>
<strong class="stats-metric-value">${value}</strong>
${note ? `<span class="stats-metric-note">${escapeHtml(note)}</span>` : ""}
</div>`,
)
.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
? `<span class="stats-unpriced">无报价</span>`
: formatMoney(r.cost);
const unit = r.unmatched
? `<span class="stats-unpriced">—</span>`
: escapeHtml(formatUnitPrice(r.unitPrice));
const source = r.unmatched
? "—"
: escapeHtml(r.sourceName || "官方文档");
return `<tr>
<td class="col-model" title="${escapeHtml(r.model || "")}">${escapeHtml(r.model || "—")}</td>
<td class="num">${formatNum(r.calls ?? 0)}</td>
<td class="num">${formatNum(r.promptTokens ?? 0)}</td>
<td class="num">${formatNum(r.completionTokens ?? 0)}</td>
<td class="num">${formatNum(r.cachedTokens ?? 0)}</td>
<td class="num col-unit" title="${unit}">${unit}</td>
<td class="num col-total">${cost}</td>
<td class="col-caller" title="${source}">${source}</td>
</tr>`;
})
.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
? `<span class="stats-unpriced">—</span>`
: 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 `<tr class="stats-call" data-call="${escapeHtml(r.id)}">
<td class="col-time">${formatAt(r.at)}</td>
<td>${escapeHtml(r.step || "—")}</td>
<td class="col-caller" title="${escapeHtml(r.caller || "")}">${escapeHtml(r.caller || "—")}</td>
<td class="col-model" title="${escapeHtml(r.model || "")}">${escapeHtml(r.model || "—")}</td>
<td class="num">${formatNum(r.promptTokens ?? 0)}</td>
<td class="num">${uncachedTokens(r) == null ? "—" : formatNum(uncachedTokens(r))}</td>
<td class="num">${r.cachedTokens == null ? "—" : formatNum(r.cachedTokens)}</td>
<td class="col-break" title="相对本页更早的同会话请求">${escapeHtml(breakLabel(r.cacheBreak))}</td>
<td class="num">${cost}</td>
</tr>
<tr class="stats-detail" hidden>
<td colspan="9">
<p class="stats-system-note">系统信息:断点、分段标签和哈希只给你对照,不会发给模型。「工具」是 tools 字段。</p>
<p>${escapeHtml(breakNote(r))} 写出 ${formatNum(r.completionTokens ?? 0)}。</p>
${charLine ? `<p>${charLine}</p>` : ""}
${tags ? `<p>${tags}</p>` : ""}
<pre class="stats-segments">${segmentLines(r) || "(无分段)"}</pre>
</td>
</tr>`;
})
.join("");
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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) =>
`<button type="button" class="stats-suggest" data-url="${escapeHtml(s.url)}">${escapeHtml(s.name)}</button>`,
)
.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 `<li class="stats-pricing-item" data-id="${escapeHtml(s.id)}">
<div class="stats-pricing-meta">
<div class="stats-pricing-name">${escapeHtml(s.name)}</div>
<a class="stats-pricing-url" href="${escapeHtml(s.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(s.url)}</a>
<p class="stats-pricing-sub" data-state="${state}">${escapeHtml(sub)}</p>
</div>
<div class="stats-pricing-actions">
<button type="button" class="btn-secondary" data-action="refresh">更新</button>
<button type="button" class="btn-danger" data-action="delete">移除</button>
</div>
</li>`;
})
.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 = `<tr><td class="stats-compare-none" colspan="5">没有名称包含「${escapeHtml(query)}」的型号</td></tr>`;
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
? `<div class="stats-compare-estimate">
<div class="stats-compare-track" aria-hidden="true"><span class="stats-compare-bar" style="width:${barWidth(amount, maxAmount)}%"></span></div>
<span class="stats-compare-money">${escapeHtml(formatMoney(row.projected))}</span>
<span class="stats-compare-multiple">${escapeHtml(multiple)}</span>
</div>`
: "—";
return `<tr>
<td class="stats-compare-name" title="${escapeHtml(row.model)}">${escapeHtml(shortModelName(row.model))}</td>
<td class="num">${escapeHtml(formatMoney({ amount: unit.input, currency }))}</td>
<td class="num">${escapeHtml(cached)}</td>
<td class="num">${escapeHtml(formatMoney({ amount: unit.output, currency }))}</td>
<td>${estimate}</td>
</tr>`;
})
.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();
})();