354 lines
15 KiB
JavaScript
354 lines
15 KiB
JavaScript
import { dataFetch, isManagementAuthorized, managedFetch, onAccessChange, routes } from "../core/runtime.js";
|
||
|
||
const statusNode = document.querySelector("#price-status");
|
||
const listNode = document.querySelector("#price-list");
|
||
const editorNode = document.querySelector("#price-editor-content");
|
||
const sourceNode = document.querySelector("#price-source");
|
||
const catalogSummaryNode = document.querySelector("#catalog-summary");
|
||
const catalogResultsNode = document.querySelector("#catalog-results");
|
||
|
||
let currentPrices = [];
|
||
let selectedPriceModel = "";
|
||
let currentCatalog = null;
|
||
|
||
export function initializePricing() {
|
||
document.querySelector("#long-enabled").addEventListener("change", event => setLongFields(event.target.checked));
|
||
document.querySelector("#save-price").addEventListener("click", savePrice);
|
||
document.querySelector("#clear-price").addEventListener("click", clearPriceForm);
|
||
document.querySelector("#new-price").addEventListener("click", clearPriceForm);
|
||
document.querySelector("#delete-price").addEventListener("click", () => selectedPriceModel && deletePrice(selectedPriceModel));
|
||
document.querySelector("#search-catalog").addEventListener("click", searchCatalog);
|
||
document.querySelector("#catalog-query").addEventListener("keydown", event => {
|
||
if (event.key === "Enter") {
|
||
event.preventDefault();
|
||
searchCatalog();
|
||
}
|
||
});
|
||
document.querySelector("#refresh-catalog").addEventListener("click", refreshCatalog);
|
||
onAccessChange(updateAccessState);
|
||
}
|
||
|
||
export async function loadPricing() {
|
||
try {
|
||
const payload = await dataFetch(routes.prices);
|
||
currentPrices = payload.prices || [];
|
||
renderPrices();
|
||
const selected = currentPrices.find(price => price.model === selectedPriceModel);
|
||
if (selected) fillPriceForm(selected);
|
||
else if (selectedPriceModel) clearPriceForm(false);
|
||
else showPriceEditor(false);
|
||
statusNode.textContent = "";
|
||
if (isManagementAuthorized()) await loadCatalogStatus();
|
||
else {
|
||
currentCatalog = null;
|
||
catalogSummaryNode.textContent = "";
|
||
renderCatalogResults([]);
|
||
}
|
||
} catch (error) {
|
||
statusNode.textContent = "读取价格失败: " + error.message;
|
||
}
|
||
}
|
||
|
||
function updateAccessState(authorized) {
|
||
document.querySelectorAll("#view-pricing .price-editor input, #view-pricing .price-editor select").forEach(node => {
|
||
if (node.id !== "catalog-query") node.disabled = !authorized;
|
||
});
|
||
renderPrices();
|
||
syncLongSectionVisibility();
|
||
}
|
||
|
||
function showPriceEditor(show) {
|
||
editorNode.classList.toggle("hidden", !show);
|
||
document.querySelector("#delete-price").classList.toggle("hidden", !selectedPriceModel);
|
||
}
|
||
|
||
function clearPriceForm(showEditor = true) {
|
||
selectedPriceModel = "";
|
||
["price-model", "price-input", "price-cache-read", "price-cache-write", "price-output", "long-threshold", "long-input", "long-cache-read", "long-cache-write", "long-output"].forEach(id => document.querySelector("#" + id).value = "");
|
||
document.querySelector("#long-enabled").checked = false;
|
||
document.querySelector("#long-comparison").value = "gt";
|
||
document.querySelector("#fast-pricing-enabled").checked = false;
|
||
document.querySelector("#fast-multiplier").value = "2.5";
|
||
showPriceSource({ kind: "manual" });
|
||
setLongFields(false);
|
||
syncLongSectionVisibility(false);
|
||
showPriceEditor(showEditor);
|
||
renderPrices();
|
||
}
|
||
|
||
function fillPriceForm(price) {
|
||
selectedPriceModel = price.model;
|
||
document.querySelector("#price-model").value = price.model;
|
||
document.querySelector("#price-input").value = price.base.input_per_1m;
|
||
document.querySelector("#price-cache-read").value = price.base.cache_read_per_1m;
|
||
document.querySelector("#price-cache-write").value = price.base.cache_write_per_1m;
|
||
document.querySelector("#price-output").value = price.base.output_per_1m;
|
||
const long = price.long_context;
|
||
document.querySelector("#long-enabled").checked = Boolean(long);
|
||
document.querySelector("#long-threshold").value = long?.threshold_input_tokens ?? "";
|
||
document.querySelector("#long-comparison").value = long?.comparison || "gt";
|
||
document.querySelector("#long-input").value = long?.input_per_1m ?? "";
|
||
document.querySelector("#long-cache-read").value = long?.cache_read_per_1m ?? "";
|
||
document.querySelector("#long-cache-write").value = long?.cache_write_per_1m ?? "";
|
||
document.querySelector("#long-output").value = long?.output_per_1m ?? "";
|
||
document.querySelector("#fast-pricing-enabled").checked = price.fast_pricing_enabled;
|
||
document.querySelector("#fast-multiplier").value = price.fast_multiplier || "2.5";
|
||
showPriceSource(price.source || { kind: "manual" });
|
||
setLongFields(Boolean(long));
|
||
syncLongSectionVisibility(Boolean(long));
|
||
showPriceEditor(true);
|
||
document.querySelectorAll(".price-row").forEach(row => row.classList.toggle("selected", row.dataset.model === selectedPriceModel));
|
||
}
|
||
|
||
function renderPrices() {
|
||
if (!currentPrices.length) {
|
||
listNode.replaceChildren(Object.assign(document.createElement("div"), { className: "empty", textContent: "尚未配置模型" }));
|
||
return;
|
||
}
|
||
if (!isManagementAuthorized()) {
|
||
listNode.replaceChildren(...currentPrices.map(renderPriceCard));
|
||
return;
|
||
}
|
||
listNode.replaceChildren(...currentPrices.map(price => {
|
||
const row = document.createElement("div");
|
||
row.className = "price-row" + (price.model === selectedPriceModel ? " selected" : "");
|
||
row.dataset.model = price.model;
|
||
row.tabIndex = 0;
|
||
row.setAttribute("role", "button");
|
||
const text = document.createElement("div");
|
||
const model = document.createElement("div");
|
||
model.className = "price-name";
|
||
model.textContent = price.model;
|
||
const detail = document.createElement("small");
|
||
detail.textContent = `输入 ${price.base.input_per_1m} · 输出 ${price.base.output_per_1m}${price.long_context ? " · 长上下文" : ""}${price.fast_pricing_enabled ? " · Fast ×" + price.fast_multiplier : ""} · ${priceSourceText(price.source)}`;
|
||
text.append(model, detail);
|
||
row.addEventListener("click", () => fillPriceForm(price));
|
||
row.addEventListener("keydown", event => {
|
||
if (event.key === "Enter" || event.key === " ") {
|
||
event.preventDefault();
|
||
fillPriceForm(price);
|
||
}
|
||
});
|
||
row.append(text);
|
||
return row;
|
||
}));
|
||
}
|
||
|
||
function renderPriceCard(price) {
|
||
const card = document.createElement("article");
|
||
card.className = "model-price-card";
|
||
|
||
const header = document.createElement("div");
|
||
header.className = "model-price-card-header";
|
||
const name = document.createElement("h2");
|
||
name.textContent = price.model;
|
||
const meta = document.createElement("div");
|
||
meta.className = "model-price-meta";
|
||
if (price.long_context) {
|
||
const comparison = price.long_context.comparison === "gte" ? "≥" : ">";
|
||
meta.append(priceBadge(`${comparison} ${compactTokens(price.long_context.threshold_input_tokens)}`));
|
||
}
|
||
if (price.fast_pricing_enabled) meta.append(priceBadge(`Fast ×${price.fast_multiplier || "2.5"}`));
|
||
header.append(name, meta);
|
||
|
||
const table = document.createElement("div");
|
||
table.className = "model-price-table";
|
||
["", "输入", "缓存读取", "缓存写入", "输出"].forEach(label => table.append(priceCell(label, "model-price-column")));
|
||
appendPriceRow(table, "标准", price.base);
|
||
if (price.long_context) {
|
||
appendPriceRow(table, "长上下文", price.long_context, true);
|
||
} else {
|
||
table.append(
|
||
priceCell("长上下文", "model-price-row-label model-price-last-row"),
|
||
priceCell("未配置", "model-price-unavailable model-price-last-row")
|
||
);
|
||
}
|
||
|
||
card.append(header, table);
|
||
return card;
|
||
}
|
||
|
||
function appendPriceRow(table, labelText, values, last = false) {
|
||
const lastClass = last ? " model-price-last-row" : "";
|
||
table.append(priceCell(labelText, "model-price-row-label" + lastClass));
|
||
["input_per_1m", "cache_read_per_1m", "cache_write_per_1m", "output_per_1m"].forEach(field => {
|
||
table.append(priceCell(`$${values[field] ?? "0"}`, "model-price-value" + lastClass));
|
||
});
|
||
}
|
||
|
||
function priceCell(text, className) {
|
||
const cell = document.createElement("div");
|
||
cell.className = className;
|
||
cell.textContent = text;
|
||
return cell;
|
||
}
|
||
|
||
function priceBadge(text) {
|
||
const badge = document.createElement("span");
|
||
badge.textContent = text;
|
||
return badge;
|
||
}
|
||
|
||
function compactTokens(value) {
|
||
const number = Number(value);
|
||
if (!Number.isFinite(number)) return "-";
|
||
if (number >= 1000000) return `${number / 1000000}M`;
|
||
if (number >= 1000) return `${number / 1000}K`;
|
||
return String(number);
|
||
}
|
||
|
||
async function savePrice() {
|
||
try {
|
||
const payload = pricePayload();
|
||
selectedPriceModel = payload.model;
|
||
await managedFetch(routes.prices, { method: "PUT", body: JSON.stringify(payload) });
|
||
statusNode.textContent = "价格已保存";
|
||
await loadPricing();
|
||
showPriceSource({ kind: "manual" });
|
||
} catch (error) {
|
||
statusNode.textContent = "保存失败: " + error.message;
|
||
}
|
||
}
|
||
|
||
async function deletePrice(model) {
|
||
if (!confirm(`删除 ${model} 的真实价格配置?`)) return;
|
||
try {
|
||
await managedFetch(routes.prices, { method: "DELETE", body: JSON.stringify({ model }) });
|
||
if (selectedPriceModel === model) clearPriceForm(false);
|
||
await loadPricing();
|
||
} catch (error) {
|
||
statusNode.textContent = "删除失败: " + error.message;
|
||
}
|
||
}
|
||
|
||
function pricePayload() {
|
||
const value = id => document.querySelector("#" + id).value.trim();
|
||
const payload = {
|
||
model: value("price-model"),
|
||
base: { input_per_1m: value("price-input"), cache_read_per_1m: value("price-cache-read"), cache_write_per_1m: value("price-cache-write"), output_per_1m: value("price-output") },
|
||
fast_pricing_enabled: document.querySelector("#fast-pricing-enabled").checked,
|
||
fast_multiplier: value("fast-multiplier")
|
||
};
|
||
if (document.querySelector("#long-enabled").checked) {
|
||
payload.long_context = {
|
||
threshold_input_tokens: Number(value("long-threshold")),
|
||
comparison: value("long-comparison"),
|
||
input_per_1m: value("long-input"),
|
||
cache_read_per_1m: value("long-cache-read"),
|
||
cache_write_per_1m: value("long-cache-write"),
|
||
output_per_1m: value("long-output")
|
||
};
|
||
}
|
||
return payload;
|
||
}
|
||
|
||
function renderCatalogResults(models) {
|
||
if (!models.length) {
|
||
catalogResultsNode.replaceChildren();
|
||
return;
|
||
}
|
||
catalogResultsNode.replaceChildren(...models.map(entry => {
|
||
const row = document.createElement("div");
|
||
row.className = "catalog-result";
|
||
const text = document.createElement("div");
|
||
const title = document.createElement("strong");
|
||
title.textContent = `${entry.provider_name || entry.provider} · ${entry.model_name || entry.model}`;
|
||
const detail = document.createElement("small");
|
||
detail.textContent = `${entry.id} · 输入 ${entry.base.input_per_1m} / 输出 ${entry.base.output_per_1m}${entry.long_context ? " · 长上下文" : ""}`;
|
||
text.append(title, detail);
|
||
row.append(text);
|
||
if (isManagementAuthorized()) {
|
||
const use = document.createElement("button");
|
||
use.type = "button";
|
||
use.className = "small admin-only";
|
||
use.textContent = "使用";
|
||
use.addEventListener("click", () => importCatalogPrice(entry));
|
||
row.append(use);
|
||
}
|
||
return row;
|
||
}));
|
||
}
|
||
|
||
async function searchCatalog() {
|
||
const query = document.querySelector("#catalog-query").value.trim().toLowerCase();
|
||
try {
|
||
const payload = await dataFetch(routes.catalog + "?q=" + encodeURIComponent(query));
|
||
catalogSummaryNode.textContent = catalogSummary(payload);
|
||
renderCatalogResults(payload.models || []);
|
||
} catch (error) {
|
||
catalogSummaryNode.textContent = "读取参考价格失败:" + error.message;
|
||
renderCatalogResults([]);
|
||
}
|
||
}
|
||
|
||
async function loadCatalogStatus() {
|
||
await searchCatalog();
|
||
}
|
||
|
||
async function importCatalogPrice(entry) {
|
||
const localModel = document.querySelector("#price-model").value.trim() || entry.model;
|
||
if (!localModel) {
|
||
statusNode.textContent = "请先输入本地模型名称";
|
||
return;
|
||
}
|
||
try {
|
||
const imported = await managedFetch(routes.priceImport, { method: "POST", body: JSON.stringify({ model: localModel, catalog_id: entry.id }) });
|
||
await loadPricing();
|
||
fillPriceForm(imported);
|
||
statusNode.textContent = `${localModel} 已采用 ${entry.provider_name || entry.provider} / ${entry.model} 参考价`;
|
||
} catch (error) {
|
||
statusNode.textContent = "导入参考价格失败:" + error.message;
|
||
}
|
||
}
|
||
|
||
async function refreshCatalog() {
|
||
try {
|
||
catalogSummaryNode.textContent = "正在更新 models.dev 目录";
|
||
const preview = await managedFetch(routes.catalogRefresh, { method: "POST" });
|
||
currentCatalog = preview.catalog;
|
||
const changed = (preview.changes || []).filter(change => change.status === "changed");
|
||
const missing = (preview.changes || []).filter(change => change.status === "missing");
|
||
if (!changed.length) {
|
||
catalogSummaryNode.textContent = `目录已更新 · 没有价格变化${missing.length ? ` · ${missing.length} 个来源已下架` : ""}`;
|
||
await searchCatalog();
|
||
return;
|
||
}
|
||
const lines = changed.slice(0, 8).map(change => `${change.model}: 输入 ${change.before.base.input_per_1m} → ${change.after.base.input_per_1m},输出 ${change.before.base.output_per_1m} → ${change.after.base.output_per_1m}`);
|
||
const confirmed = confirm(`发现 ${changed.length} 个已关联价格发生变化:\n\n${lines.join("\n")}${changed.length > lines.length ? "\n…" : ""}\n\n确认更新这些本地价格?`);
|
||
if (!confirmed) {
|
||
catalogSummaryNode.textContent = `目录已更新 · ${changed.length} 个变化尚未应用`;
|
||
return;
|
||
}
|
||
const applied = await managedFetch(routes.catalogApply, { method: "POST", body: JSON.stringify({ revision: preview.catalog.revision, models: changed.map(change => change.model) }) });
|
||
catalogSummaryNode.textContent = `已确认更新 ${applied.updated} 个本地价格`;
|
||
await loadPricing();
|
||
} catch (error) {
|
||
catalogSummaryNode.textContent = "更新目录失败:" + error.message;
|
||
}
|
||
}
|
||
|
||
function catalogSummary(payload) {
|
||
currentCatalog = payload?.catalog || null;
|
||
if (!payload?.loaded) return payload?.last_error ? `尚无可用目录 · ${payload.last_error}` : "尚未下载目录";
|
||
const updated = currentCatalog?.fetched_at ? new Date(currentCatalog.fetched_at).toLocaleString() : "-";
|
||
return `${currentCatalog?.models || 0} 条参考价格 · ${updated}`;
|
||
}
|
||
|
||
function priceSourceText(source) {
|
||
if (source?.kind === "models.dev") return `models.dev · ${source.provider || "-"} / ${source.model || source.catalog_id || "-"}`;
|
||
return "手动配置";
|
||
}
|
||
|
||
function showPriceSource(source) {
|
||
sourceNode.textContent = priceSourceText(source);
|
||
sourceNode.dataset.kind = source?.kind || "manual";
|
||
}
|
||
|
||
function setLongFields(enabled) {
|
||
document.querySelectorAll(".long-field").forEach(node => node.classList.toggle("hidden", !enabled));
|
||
}
|
||
|
||
function syncLongSectionVisibility(hasLong = document.querySelector("#long-enabled").checked) {
|
||
document.querySelector("#long-price-section").classList.toggle("hidden", !isManagementAuthorized() && !hasLong);
|
||
}
|