Files

354 lines
15 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.
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);
}