启动时对照开放路由价目,并在统计页看清缓存费用。
最近用过的模型变价时可以在页上直接核对。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user