启动时对照开放路由价目,并在统计页看清缓存费用。

最近用过的模型变价时可以在页上直接核对。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-14 08:29:46 +08:00
co-authored by Cursor
parent b864591afa
commit b59ccf976c
10 changed files with 1271 additions and 18 deletions
+75
View File
@@ -0,0 +1,75 @@
const DISMISS_KEY = "wa-price-watch-dismissed";
function money(amount, currency) {
if (amount == null || Number.isNaN(Number(amount))) return "—";
const symbol = currency === "CNY" ? "¥" : "$";
const value = Number(amount);
const digits = value >= 1 ? 2 : 4;
const body = value.toFixed(digits).replace(/(\.\d*?[1-9])0+$/, "$1").replace(/\.0+$/, "");
return `${symbol}${body.includes(".") ? body : `${body}.00`}`;
}
function itemLine(item) {
if (item.status === "changed" && item.previous && item.current) {
const currency = item.current.currency || "USD";
return `${item.model} 变了:输入 ${money(item.previous.input, currency)} → ${money(item.current.input, currency)},输出 ${money(item.previous.output, currency)} → ${money(item.current.output, currency)} / 百万 token`;
}
if (item.status === "unchanged" && item.current) {
const currency = item.current.currency || "USD";
return `${item.model} 没变(输入 ${money(item.current.input, currency)},输出 ${money(item.current.output, currency)})`;
}
if (item.status === "baseline") return `${item.model} 第一次记下单价,下次启动再比`;
return `${item.model} 这次价目里没有`;
}
function showNotice(notice) {
const host = document.getElementById("price-watch");
if (!host || !notice) return;
try {
if (sessionStorage.getItem(DISMISS_KEY) === notice.at) return;
} catch {
/* ignore */
}
if (!notice.error && !notice.items?.length) return;
const changed = notice.items?.some((item) => item.status === "changed");
host.hidden = false;
host.dataset.state = notice.error ? "error" : changed ? "changed" : "idle";
const text = notice.error
? `OpenRouter 价格没拉下来:${notice.error}。上次的价目还在。`
: notice.items.map((item) => itemLine(item)).join("\n");
host.innerHTML = `<p>${escapeHtml(text).replace(/\n/g, "<br>")}</p><button type="button">知道了</button>`;
host.querySelector("button")?.addEventListener("click", () => {
host.hidden = true;
try {
sessionStorage.setItem(DISMISS_KEY, notice.at);
} catch {
/* ignore */
}
});
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
async function poll(tries = 0) {
try {
const res = await fetch("/api/stats/pricing/notice");
if (!res.ok) return;
const data = await res.json();
if (data.status === "pulling" || data.status === "idle") {
if (tries < 12) {
window.setTimeout(() => poll(tries + 1), 1500);
return;
}
}
showNotice(data.notice);
} catch {
/* 页面离线时不打扰 */
}
}
poll();
+204
View File
@@ -258,6 +258,196 @@
color: var(--st-danger);
}
.stats-compare {
margin: 0 24px 16px;
padding: 12px 14px;
background: var(--st-surface);
border: 1px solid var(--st-border);
border-radius: var(--st-radius);
}
.stats-compare .stats-section-label {
margin: 0;
}
.stats-compare-head {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 10px 16px;
margin-bottom: 12px;
}
.stats-compare-head .btn-primary:focus-visible,
.stats-compare-head .btn-primary:disabled {
outline: none;
}
.stats-compare-head .btn-primary:focus-visible {
box-shadow: 0 0 0 3px var(--st-accent-soft);
}
.stats-compare-head .btn-primary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.stats-compare .stats-table-wrap {
margin: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.stats-compare-table thead th {
vertical-align: middle;
}
.stats-compare-model-col {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.stats-sort {
appearance: none;
margin: 0;
padding: 2px 0;
border: 0;
background: transparent;
font: inherit;
letter-spacing: inherit;
text-transform: inherit;
color: inherit;
cursor: pointer;
white-space: nowrap;
}
.stats-table th.num .stats-sort {
width: 100%;
text-align: right;
}
.stats-sort:hover {
color: var(--st-text);
}
.stats-sort:focus-visible,
.stats-compare-filter:focus-visible {
outline: none;
border-radius: 4px;
box-shadow: 0 0 0 3px var(--st-accent-soft);
}
.stats-compare-table thead th[aria-sort="ascending"] .stats-sort,
.stats-compare-table thead th[aria-sort="descending"] .stats-sort {
color: var(--st-text);
}
.stats-compare-filter {
width: 9.5rem;
max-width: 36vw;
height: 26px;
padding: 0 8px;
border: 1px solid var(--st-border-strong);
border-radius: 6px;
background: var(--st-bg);
color: var(--st-text);
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
font-size: 12px;
font-weight: 400;
letter-spacing: 0;
text-transform: none;
}
.stats-compare-filter::placeholder {
color: var(--st-muted);
font-family: "Noto Sans SC", sans-serif;
}
.stats-compare-filter:focus-visible {
border-color: var(--st-accent);
}
.stats-compare-none {
padding: 16px 12px;
color: var(--st-muted);
font-size: 13px;
text-transform: none;
letter-spacing: 0;
}
.stats-compare-table .stats-compare-name {
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
font-size: 12px;
white-space: nowrap;
}
.stats-compare-estimate {
display: grid;
grid-template-columns: minmax(4rem, 1fr) auto auto;
gap: 8px;
align-items: center;
min-width: 11rem;
}
.stats-compare-multiple {
min-width: 2.5rem;
font-size: 12px;
color: var(--st-muted);
font-variant-numeric: tabular-nums;
}
.stats-compare-track {
height: 8px;
border-radius: 4px;
background: color-mix(in srgb, var(--st-border) 65%, transparent);
overflow: hidden;
}
.stats-compare-bar {
display: block;
height: 100%;
border-radius: 4px;
background: var(--st-accent);
min-width: 2px;
}
.stats-compare-money {
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.stats-compare-meta {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
margin-top: -4px;
font-size: 12px;
color: var(--st-muted);
}
.stats-compare-status {
margin: 8px 0 0;
font-size: 12px;
color: var(--st-muted);
}
.stats-compare-status[data-state="error"] {
color: var(--st-danger);
}
.stats-compare-empty {
margin: 0;
font-size: 13px;
color: var(--st-muted);
}
.stats-section-label {
margin: 0 24px 8px;
font-size: 13px;
@@ -476,6 +666,7 @@
.stats-main .st-main-head,
.stats-toolbar,
.stats-pricing,
.stats-compare,
.stats-summary,
.stats-status,
.stats-section-label,
@@ -512,4 +703,17 @@
width: 100%;
justify-content: space-between;
}
.stats-compare-estimate {
min-width: 9rem;
}
.stats-compare-model-col {
flex-wrap: wrap;
}
.stats-compare-filter {
width: 100%;
max-width: none;
}
}
+54 -4
View File
@@ -19,6 +19,7 @@
<a class="st-brand" href="/">Writing Agent</a>
<nav class="st-nav" aria-label="主导航">
<a href="/">对话</a>
<a href="/chat.html">直聊</a>
<a href="/modules.html">技能</a>
<a href="/libraries.html">库</a>
<a href="/stats.html" aria-current="page">Token</a>
@@ -31,7 +32,7 @@
<header class="st-main-head">
<div>
<h1>Token 统计</h1>
<p class="st-sub">按时间筛选本机调用;花销按你指定的官方价目文档估算(<code>stats/*.jsonl</code>)</p>
<p class="st-sub">按时间筛选本机调用。OpenRouter 价格在下面手动拉取,只保留 DeepSeek、Claude、GLM、Gemini、GPT、Grok 的当前档。</p>
</div>
</header>
@@ -63,7 +64,7 @@
<div class="stats-pricing-head">
<p class="stats-section-label">价格来源</p>
<p class="stats-pricing-hint">
粘贴厂商官方价目页,解析型号单价后按调用时间估算(含缓存命中与高峰/闲时,若文档有写)。
厂商官方价目(含高峰/闲时)仍可粘贴文档。OpenRouter 用下方按钮拉取。
</p>
</div>
<form class="stats-pricing-add" id="pricing-form">
@@ -83,6 +84,54 @@
<p class="stats-pricing-status" id="pricing-status" hidden aria-live="polite"></p>
</section>
<section class="stats-compare" aria-label="模型价格对比">
<div class="stats-compare-head">
<div>
<p class="stats-section-label">价格对比</p>
<p class="stats-pricing-hint">
一行一个型号。前三列是每百万 token 的通常单价,最后一列是按最近 100 次调用重估的花费。
</p>
</div>
<button type="button" class="btn-primary" id="btn-pull-openrouter">拉取 OpenRouter 价格</button>
</div>
<div class="stats-table-wrap stats-compare-wrap">
<table class="stats-table stats-compare-table" id="compare-table">
<thead>
<tr>
<th scope="col" aria-sort="none">
<div class="stats-compare-model-col">
<button type="button" class="stats-sort" data-sort="model" data-label="型号" title="按型号排序">型号</button>
<input
type="search"
id="compare-filter"
class="stats-compare-filter"
placeholder="筛选型号"
aria-label="按型号筛选"
autocomplete="off"
spellcheck="false"
/>
</div>
</th>
<th scope="col" class="num" aria-sort="none">
<button type="button" class="stats-sort" data-sort="input" data-label="输入" title="按输入单价排序">输入</button>
</th>
<th scope="col" class="num" aria-sort="none">
<button type="button" class="stats-sort" data-sort="cached" data-label="缓存" title="按缓存单价排序">缓存</button>
</th>
<th scope="col" class="num" aria-sort="none">
<button type="button" class="stats-sort" data-sort="output" data-label="输出" title="按输出单价排序">输出</button>
</th>
<th scope="col" aria-sort="ascending">
<button type="button" class="stats-sort" data-sort="projected" data-label="近 100 次" title="按近 100 次花费排序">近 100 次 ↑</button>
</th>
</tr>
</thead>
<tbody id="compare-list"></tbody>
</table>
</div>
<p class="stats-compare-status" id="compare-status" hidden aria-live="polite"></p>
</section>
<div class="stats-summary" id="summary" aria-live="polite"></div>
<p class="stats-status" id="status" data-state="idle" aria-live="polite"></p>
@@ -97,15 +146,16 @@
<th scope="col" class="num">prompt</th>
<th scope="col" class="num">completion</th>
<th scope="col" class="num">cached</th>
<th scope="col" class="num">当前单价</th>
<th scope="col" class="num">估算花销</th>
<th scope="col">单价</th>
<th scope="col">来源</th>
</tr>
</thead>
<tbody id="model-rows"></tbody>
</table>
<div class="stats-empty" id="model-empty" hidden>
<strong>没有可汇总的型号</strong>
先查出调用记录,并添加官方价目文档。
先查出调用记录;OpenRouter 会自动同步单价,其他渠道可手动添加价目。
</div>
</div>
</section>
+205 -1
View File
@@ -49,6 +49,17 @@ function formatCostTotal(total, byCurrency) {
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;
@@ -111,6 +122,7 @@ async function loadStats() {
`共 ${formatNum(totalCalls)} 次调用 · 显示 ${formatNum(records.length)} 条${extra}`,
"idle",
);
loadCompare();
} catch (err) {
renderSummary(null);
renderModels([]);
@@ -119,6 +131,7 @@ async function loadStats() {
`加载失败:${err instanceof Error ? err.message : String(err)}`,
"error",
);
loadCompare();
}
}
@@ -171,6 +184,9 @@ function renderModels(rows) {
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 || "官方文档");
@@ -180,6 +196,7 @@ function renderModels(rows) {
<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>`;
@@ -325,7 +342,7 @@ function renderSuggested(items, sources = []) {
el.innerHTML = "";
return;
}
el.innerHTML = items
el.innerHTML = remaining
.map(
(s) =>
`<button type="button" class="stats-suggest" data-url="${escapeHtml(s.url)}">${escapeHtml(s.name)}</button>`,
@@ -465,6 +482,193 @@ document.getElementById("pricing-sources").addEventListener("click", async (ev)
}
});
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;