Add a credit usage chart above CodeBuddy usage details table.
// ==UserScript==
// @name CodeBuddy Usage Credit Chart
// @namespace https://www.codebuddy.cn/
// @version 0.2.0
// @description Add a credit usage chart above CodeBuddy usage details table.
// @license MIT
// @match https://www.codebuddy.cn/profile/usage*
// @run-at document-start
// ==/UserScript==
(function () {
"use strict";
const SCRIPT_ID = "cbuc-script";
const STYLE_ID = "cbuc-style";
const ROOT_ID = "cbuc-root";
const PAYLOAD_EVENT = "cbuc:payload";
const BRIDGE_FLAG = "__CBUC_PAGE_BRIDGE__";
const SCHEDULE_DELAY = 120;
const DAY_MS = 24 * 60 * 60 * 1000;
const HOUR_MS = 60 * 60 * 1000;
const UNKNOWN_MODEL_NAME = "未知模型";
const MODEL_COLOR_PALETTE = [
"#6f90d6",
"#6fb7a7",
"#d49c74",
"#cdb37a",
"#c8849f",
"#8b92d6",
"#6ea6c8",
"#94b06d",
"#a887c8",
"#cb8f76",
];
const state = {
recordPool: new Map(),
datasets: new Map(),
hydrationJobs: new Map(),
mounted: false,
scheduleTimer: null,
usageRoot: null,
chartRoot: null,
lastSignature: "",
lastUiFingerprint: "",
lastDataFingerprint: "",
lastRenderWidth: 0,
resizeObserver: null,
mutationObserver: null,
globalObserver: null,
interactionHintUntil: 0,
tooltipCleanup: null,
splitByModel: false,
modelColors: new Map(),
nextModelColorIndex: 0,
};
init();
function init() {
injectPageBridge();
ensureStyles();
window.addEventListener(PAYLOAD_EVENT, handleBridgePayload, false);
document.addEventListener("click", handleUserInteraction, true);
document.addEventListener("change", handleUserInteraction, true);
document.addEventListener("input", handleUserInteraction, true);
observeDocument();
whenReady(() => scheduleSync("init"));
}
function injectPageBridge() {
if (document.documentElement && document.documentElement.hasAttribute(`data-${SCRIPT_ID}`)) {
return;
}
const target = document.documentElement || document.head || document.body;
if (!target) {
whenReady(injectPageBridge);
return;
}
const script = document.createElement("script");
script.setAttribute(`data-${SCRIPT_ID}`, "true");
script.textContent = `(${pageBridge.toString()})(${JSON.stringify({
eventName: PAYLOAD_EVENT,
flag: BRIDGE_FLAG,
})});`;
target.appendChild(script);
script.remove();
if (document.documentElement) {
document.documentElement.setAttribute(`data-${SCRIPT_ID}`, "true");
}
}
function pageBridge(config) {
try {
if (window[config.flag]) {
return;
}
window[config.flag] = true;
const shouldInspect = (url, contentType) => {
if (contentType && /json/i.test(contentType)) {
return true;
}
return /usage|credit|profile|detail|record|consum|bill|request/i.test(url || "");
};
const emit = (meta, payload) => {
try {
const detail = JSON.stringify({ meta, payload });
window.dispatchEvent(new CustomEvent(config.eventName, { detail }));
} catch (error) {
console.warn("[CBUC] emit failed", error);
}
};
const parseJsonText = (text) => {
if (!text || typeof text !== "string") {
return null;
}
try {
return JSON.parse(text);
} catch (error) {
return null;
}
};
const normalizeBody = (body) => {
if (!body) {
return null;
}
if (typeof body === "string") {
return body;
}
if (body instanceof URLSearchParams) {
return body.toString();
}
if (typeof FormData !== "undefined" && body instanceof FormData) {
const pairs = [];
body.forEach((value, key) => {
pairs.push([key, typeof value === "string" ? value : String(value)]);
});
return new URLSearchParams(pairs).toString();
}
return null;
};
const originalFetch = window.fetch;
if (typeof originalFetch === "function") {
window.fetch = function patchedFetch(input, init) {
const url = typeof input === "string" ? input : (input && input.url) || "";
const method =
(init && init.method) ||
(input && typeof input === "object" && input.method) ||
"GET";
const requestBody = (init && normalizeBody(init.body)) || null;
const meta = {
channel: "fetch",
url,
method,
body: requestBody,
headers: null,
timestamp: Date.now(),
};
try {
const rawHeaders =
(init && init.headers) ||
(input && typeof input === "object" && input.headers) ||
null;
meta.headers = normalizeHeaders(rawHeaders);
} catch (error) {
meta.headers = null;
}
return originalFetch.apply(this, arguments).then((response) => {
try {
const clone = response.clone();
const contentType =
(clone.headers && clone.headers.get("content-type")) || "";
if (!shouldInspect(url, contentType)) {
return response;
}
clone
.text()
.then((text) => {
const payload = parseJsonText(text);
if (payload !== null) {
emit(meta, payload);
}
})
.catch(() => {});
} catch (error) {
console.warn("[CBUC] fetch clone failed", error);
}
return response;
});
};
}
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
const originalSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.open = function patchedOpen(method, url) {
this.__cbucMeta = {
channel: "xhr",
url: typeof url === "string" ? url : "",
method: method || "GET",
body: null,
headers: {},
timestamp: Date.now(),
};
return originalOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.setRequestHeader = function patchedSetRequestHeader(name, value) {
if (this.__cbucMeta && name) {
this.__cbucMeta.headers[String(name)] = String(value);
}
return originalSetRequestHeader.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function patchedSend(body) {
if (this.__cbucMeta) {
this.__cbucMeta.body = normalizeBody(body);
}
this.addEventListener(
"load",
function onLoad() {
const meta = this.__cbucMeta;
if (!meta) {
return;
}
const contentType =
(typeof this.getResponseHeader === "function" &&
this.getResponseHeader("content-type")) ||
"";
if (!shouldInspect(meta.url, contentType)) {
return;
}
let payload = null;
if (this.responseType === "json") {
payload = this.response;
} else if (!this.responseType || this.responseType === "text") {
payload = parseJsonText(this.responseText);
}
if (payload !== null) {
emit(meta, payload);
}
},
{ once: true }
);
return originalSend.apply(this, arguments);
};
function normalizeHeaders(headers) {
if (!headers) {
return null;
}
if (typeof Headers !== "undefined" && headers instanceof Headers) {
const result = {};
headers.forEach((value, key) => {
result[key] = value;
});
return result;
}
if (Array.isArray(headers)) {
const result = {};
headers.forEach((entry) => {
if (Array.isArray(entry) && entry.length >= 2) {
result[String(entry[0])] = String(entry[1]);
}
});
return result;
}
if (typeof headers === "object") {
return Object.fromEntries(
Object.entries(headers).map(([key, value]) => [String(key), String(value)])
);
}
return null;
}
} catch (error) {
console.warn("[CBUC] bridge init failed", error);
}
}
function ensureStyles() {
if (document.getElementById(STYLE_ID)) {
return;
}
const target = document.head || document.documentElement || document.body;
if (!target) {
whenReady(ensureStyles);
return;
}
const style = document.createElement("style");
style.id = STYLE_ID;
style.textContent = `
#${ROOT_ID} {
--cbuc-bg: linear-gradient(180deg, rgba(41, 44, 61, 0.98), rgba(33, 36, 52, 0.96));
--cbuc-border: rgba(124, 131, 175, 0.24);
--cbuc-panel: rgba(75, 79, 109, 0.2);
--cbuc-grid: rgba(153, 164, 194, 0.16);
--cbuc-axis: rgba(255, 255, 255, 0.55);
--cbuc-muted: rgba(255, 255, 255, 0.62);
--cbuc-text: #ffffff;
--cbuc-accent: #6f90d6;
--cbuc-accent-strong: #8ca5de;
--cbuc-unknown-model: #7f8799;
--cbuc-shadow: 0 18px 42px rgba(0, 0, 0, 0.24);
position: relative;
margin: 0 0 24px;
padding: 28px 28px 18px;
border: 1px solid var(--cbuc-border);
border-radius: 20px;
background: var(--cbuc-bg);
box-shadow: var(--cbuc-shadow);
overflow: hidden;
}
#${ROOT_ID}::before {
content: "";
position: absolute;
inset: -80px auto auto -80px;
width: 220px;
height: 220px;
border-radius: 50%;
background: radial-gradient(circle, rgba(79, 140, 255, 0.16), rgba(79, 140, 255, 0));
pointer-events: none;
}
#${ROOT_ID} .cbuc-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
margin-bottom: 18px;
}
#${ROOT_ID} .cbuc-title {
font-size: 13px;
line-height: 18px;
letter-spacing: 0.08em;
color: var(--cbuc-axis);
text-transform: uppercase;
}
#${ROOT_ID} .cbuc-total {
margin-top: 6px;
font-size: clamp(34px, 4.6vw, 56px);
line-height: 1;
font-weight: 800;
color: var(--cbuc-text);
}
#${ROOT_ID} .cbuc-summary {
display: grid;
justify-items: end;
gap: 8px;
min-width: 190px;
}
#${ROOT_ID} .cbuc-summary-top {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
#${ROOT_ID} .cbuc-status {
padding: 6px 10px;
border-radius: 999px;
background: rgba(75, 79, 109, 0.2);
color: var(--cbuc-muted);
font-size: 12px;
line-height: 16px;
white-space: nowrap;
}
#${ROOT_ID} .cbuc-status[data-kind="warning"] {
color: #ffd38c;
background: rgba(255, 186, 82, 0.14);
}
#${ROOT_ID} .cbuc-meta {
color: var(--cbuc-muted);
font-size: 13px;
line-height: 18px;
text-align: right;
}
#${ROOT_ID} .cbuc-toggle {
appearance: none;
border: 1px solid rgba(133, 164, 255, 0.3);
border-radius: 999px;
padding: 7px 12px;
background: rgba(76, 87, 124, 0.18);
color: rgba(255, 255, 255, 0.82);
font-size: 12px;
line-height: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease,
transform 0.15s ease;
}
#${ROOT_ID} .cbuc-toggle:hover:not(:disabled) {
background: rgba(95, 151, 255, 0.18);
border-color: rgba(133, 164, 255, 0.52);
transform: translateY(-1px);
}
#${ROOT_ID} .cbuc-toggle[aria-pressed="true"] {
background: rgba(95, 151, 255, 0.24);
border-color: rgba(133, 164, 255, 0.72);
color: #ffffff;
}
#${ROOT_ID} .cbuc-toggle:disabled {
opacity: 0.46;
cursor: not-allowed;
}
#${ROOT_ID} .cbuc-canvas {
position: relative;
min-height: 360px;
border-radius: 18px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0.01));
overflow: hidden;
}
#${ROOT_ID} .cbuc-svg {
display: block;
width: 100%;
height: 360px;
}
#${ROOT_ID} .cbuc-legend {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 10px 14px;
margin-top: 14px;
color: var(--cbuc-muted);
font-size: 13px;
line-height: 18px;
}
#${ROOT_ID} .cbuc-legend-item {
display: inline-flex;
align-items: center;
gap: 8px;
}
#${ROOT_ID} .cbuc-legend-swatch,
#${ROOT_ID} .cbuc-tooltip-swatch {
width: 16px;
height: 16px;
border-radius: 4px;
background: linear-gradient(180deg, var(--cbuc-accent), var(--cbuc-accent-strong));
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
}
#${ROOT_ID} .cbuc-tooltip-swatch {
width: 10px;
height: 10px;
border-radius: 999px;
flex: 0 0 auto;
}
#${ROOT_ID} .cbuc-tooltip {
position: absolute;
z-index: 20;
min-width: 160px;
max-width: 240px;
padding: 10px 12px;
border: 1px solid rgba(131, 176, 255, 0.34);
border-radius: 12px;
background: rgba(19, 23, 37, 0.95);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.24);
color: #ffffff;
font-size: 12px;
line-height: 17px;
pointer-events: none;
opacity: 0;
transform: translateY(4px);
transition: opacity 0.14s ease, transform 0.14s ease;
}
#${ROOT_ID} .cbuc-tooltip.is-visible {
opacity: 1;
transform: translateY(0);
}
#${ROOT_ID} .cbuc-tooltip-title {
color: rgba(255, 255, 255, 0.74);
margin-bottom: 4px;
}
#${ROOT_ID} .cbuc-tooltip-value {
font-size: 15px;
line-height: 20px;
font-weight: 700;
}
#${ROOT_ID} .cbuc-tooltip-breakdown {
margin-top: 8px;
display: grid;
gap: 6px;
}
#${ROOT_ID} .cbuc-tooltip-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
align-items: center;
gap: 8px;
color: rgba(255, 255, 255, 0.86);
}
#${ROOT_ID} .cbuc-tooltip-model {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#${ROOT_ID} .cbuc-tooltip-credit {
font-variant-numeric: tabular-nums;
}
#${ROOT_ID} .cbuc-tooltip-share {
color: rgba(255, 255, 255, 0.58);
font-variant-numeric: tabular-nums;
}
#${ROOT_ID} .cbuc-cursor-line {
pointer-events: none;
stroke: rgba(255, 255, 255, 0.6);
stroke-width: 1;
stroke-dasharray: 4 4;
opacity: 0;
transition: opacity 0.15s ease;
}
#${ROOT_ID} .cbuc-cursor-line.is-visible {
opacity: 1;
}
#${ROOT_ID} rect[data-bar="true"] {
transition: filter 0.15s ease, opacity 0.15s ease;
}
#${ROOT_ID} rect[data-bar="true"].is-highlighted {
filter: url(#cbucGlow) brightness(1.08) !important;
opacity: 1 !important;
}
#${ROOT_ID} rect[data-bar="true"]:not(.is-highlighted).is-dimmed {
opacity: 0.5;
}
#${ROOT_ID} .cbuc-empty,
#${ROOT_ID} .cbuc-loading {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
color: var(--cbuc-muted);
font-size: 14px;
line-height: 22px;
text-align: center;
}
#${ROOT_ID} .cbuc-loading::before {
content: "";
width: 22px;
height: 22px;
margin: 0 auto 10px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.16);
border-top-color: var(--cbuc-accent-strong);
animation: cbuc-spin 0.8s linear infinite;
}
@keyframes cbuc-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 900px) {
#${ROOT_ID} {
padding: 22px 18px 16px;
}
#${ROOT_ID} .cbuc-header {
flex-direction: column;
align-items: stretch;
}
#${ROOT_ID} .cbuc-summary {
justify-items: start;
min-width: 0;
}
#${ROOT_ID} .cbuc-summary-top {
justify-content: flex-start;
}
#${ROOT_ID} .cbuc-meta {
text-align: left;
}
#${ROOT_ID} .cbuc-svg {
height: 320px;
}
#${ROOT_ID} .cbuc-canvas {
min-height: 320px;
}
}
`;
target.appendChild(style);
}
function observeDocument() {
if (state.globalObserver) {
return;
}
state.globalObserver = new MutationObserver(() => scheduleSync("dom"));
const startObserve = () => {
if (!document.body) {
return false;
}
state.globalObserver.observe(document.body, {
childList: true,
subtree: true,
});
return true;
};
if (!startObserve()) {
whenReady(startObserve);
}
}
function handleBridgePayload(event) {
const detail = event.detail;
if (!detail) {
return;
}
let parsed = null;
try {
parsed = typeof detail === "string" ? JSON.parse(detail) : detail;
} catch (error) {
return;
}
const payload = parsed && parsed.payload;
const meta = parsed && parsed.meta;
const extracted = extractUsageRecords(payload);
if (!extracted || !extracted.records.length) {
return;
}
const uiState = getUiState();
const signature = resolveSignature(meta, extracted, uiState);
if (!signature) {
return;
}
upsertDataset(signature, extracted, uiState, meta);
maybeHydrateDataset(signature, extracted, uiState, meta);
state.interactionHintUntil = 0;
scheduleSync("payload");
}
function handleUserInteraction(event) {
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const usageRoot = findUsageSection();
if (!usageRoot || !usageRoot.contains(target)) {
return;
}
const toggleButton = target.closest("[data-role='toggle-model-mode']");
if (toggleButton) {
if (toggleButton.disabled) {
return;
}
state.splitByModel = !state.splitByModel;
state.lastUiFingerprint = "";
scheduleSync("toggle-model-mode", 0);
return;
}
if (
target.closest(".t-radio-button") ||
target.closest(".t-pagination") ||
target.closest(".t-date-range-picker") ||
target.closest(".t-picker") ||
target.closest(".t-range-input")
) {
state.interactionHintUntil = Date.now() + 2000;
renderLoadingState("正在同步最新用量数据...");
scheduleSync("interaction", 220);
}
}
function whenReady(callback) {
if (document.readyState === "complete" || document.readyState === "interactive") {
callback();
return;
}
document.addEventListener("DOMContentLoaded", callback, { once: true });
}
function scheduleSync(reason, delay = SCHEDULE_DELAY) {
window.clearTimeout(state.scheduleTimer);
state.scheduleTimer = window.setTimeout(() => sync(reason), delay);
}
function sync(reason) {
const usageRoot = findUsageSection();
if (!usageRoot) {
cleanupMount();
return;
}
state.usageRoot = usageRoot;
mountInto(usageRoot);
const uiState = getUiState();
const currentSignature = getStateSignature(uiState);
const source = chooseBestSource(uiState, currentSignature);
const dataset = state.datasets.get(currentSignature);
const hydrationJob = state.hydrationJobs.get(currentSignature);
const cachedRecordCount = countCachedRecordsForUiState(uiState);
const expectedRecordCount = getExpectedCountForRange(dataset, uiState);
const uiFingerprint = JSON.stringify({
signature: currentSignature,
total: uiState.totalCount,
rows: uiState.tableRecords.length,
source: source ? source.kind : "none",
mode: state.splitByModel ? "model" : "total",
hydrating: Boolean(hydrationJob && hydrationJob.running),
cached: cachedRecordCount,
expected: expectedRecordCount,
loadingHint: isLoadingHintActive(),
});
if (
state.lastUiFingerprint === uiFingerprint &&
state.lastDataFingerprint === (source ? source.fingerprint : "none") &&
reason !== "payload"
) {
return;
}
state.lastUiFingerprint = uiFingerprint;
state.lastDataFingerprint = source ? source.fingerprint : "none";
state.lastSignature = currentSignature;
if (
hydrationJob &&
hydrationJob.running &&
(!source || !source.records.length)
) {
const progressText =
Number.isFinite(expectedRecordCount) && expectedRecordCount > 0
? `正在自动抓取完整用量数据(${cachedRecordCount}/${expectedRecordCount})...`
: "正在自动抓取完整用量数据...";
renderLoadingState(
progressText
);
return;
}
if (source && source.records.length) {
renderChart(uiState, source);
return;
}
if (isLoadingHintActive()) {
renderLoadingState("正在同步最新用量数据...");
return;
}
renderEmptyState("当前范围内暂无可用的 Credit 用量数据。");
}
function mountInto(usageRoot) {
const table = usageRoot.querySelector(".t-table");
if (!table) {
return;
}
let root = usageRoot.querySelector(`#${ROOT_ID}`);
if (!root) {
root = document.createElement("section");
root.id = ROOT_ID;
root.innerHTML = `
<div class="cbuc-header">
<div class="cbuc-overview">
<div class="cbuc-title">Credit Usage</div>
<div class="cbuc-total">0</div>
</div>
<div class="cbuc-summary">
<div class="cbuc-summary-top">
<button class="cbuc-toggle" data-role="toggle-model-mode" type="button" aria-pressed="false">
按模型划分
</button>
<div class="cbuc-status" data-role="status">等待数据</div>
</div>
<div class="cbuc-meta" data-role="meta">-</div>
</div>
</div>
<div class="cbuc-canvas" data-role="canvas"></div>
<div class="cbuc-legend" data-role="legend"></div>
<div class="cbuc-tooltip" data-role="tooltip">
<div class="cbuc-tooltip-title"></div>
<div class="cbuc-tooltip-value"></div>
<div class="cbuc-tooltip-breakdown"></div>
</div>
`;
usageRoot.insertBefore(root, table);
} else if (root.nextElementSibling !== table) {
usageRoot.insertBefore(root, table);
}
state.chartRoot = root;
state.mounted = true;
if (!state.resizeObserver) {
state.resizeObserver = new ResizeObserver(() => {
if (!state.chartRoot) {
return;
}
const width = Math.round(state.chartRoot.clientWidth);
if (width && width !== state.lastRenderWidth) {
scheduleSync("resize", 40);
}
});
}
state.resizeObserver.disconnect();
state.resizeObserver.observe(root);
if (!state.mutationObserver) {
state.mutationObserver = new MutationObserver(() => scheduleSync("usage-root"));
}
state.mutationObserver.disconnect();
state.mutationObserver.observe(usageRoot, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["class", "value", "checked"],
});
}
function cleanupMount() {
state.usageRoot = null;
state.chartRoot = null;
state.mounted = false;
state.lastUiFingerprint = "";
state.lastDataFingerprint = "";
state.lastSignature = "";
if (state.resizeObserver) {
state.resizeObserver.disconnect();
}
if (state.mutationObserver) {
state.mutationObserver.disconnect();
}
}
function findUsageSection() {
const titles = Array.from(document.querySelectorAll("div, h1, h2, h3, span"));
for (const element of titles) {
const ownText = getOwnText(element);
if (!ownText.startsWith("用量明细")) {
continue;
}
let current = element;
while (current && current !== document.body) {
if (current.querySelector(".t-table") && current.querySelector(".t-radio-group")) {
return current;
}
current = current.parentElement;
}
}
return null;
}
function getUiState() {
const usageRoot = state.usageRoot || findUsageSection();
const checkedInput = usageRoot
? usageRoot.querySelector(".t-radio-button.t-is-checked input")
: null;
const checkedLabel = usageRoot
? usageRoot.querySelector(".t-radio-button.t-is-checked .t-radio-button__label")
: null;
const rangeKey = ((checkedInput && checkedInput.value) || (checkedLabel && checkedLabel.textContent) || "")
.trim()
.toLowerCase();
const rangeInputs = usageRoot
? usageRoot.querySelectorAll(".t-range-input input.t-input__inner")
: [];
const startDate = rangeInputs[0] ? normalizeDateOnly(rangeInputs[0].value) : "";
const endDate = rangeInputs[1] ? normalizeDateOnly(rangeInputs[1].value) : "";
const unit = determineUnit(rangeKey, startDate, endDate);
return {
usageRoot,
rangeKey,
startDate,
endDate,
unit,
totalCount: parseTotalCount(usageRoot),
tableRecords: parseTableRecords(usageRoot),
now: Date.now(),
};
}
function parseTotalCount(usageRoot) {
const totalNode = usageRoot && usageRoot.querySelector(".t-pagination__total");
const text = totalNode ? totalNode.textContent || "" : "";
const match = text.match(/(\d+)/);
return match ? Number(match[1]) : null;
}
function parseTableRecords(usageRoot) {
const table = usageRoot && usageRoot.querySelector(".t-table table");
if (!table) {
return [];
}
const headers = Array.from(table.querySelectorAll("thead th")).map((th) =>
(th.textContent || "").trim()
);
const creditIndex = headers.findIndex((text) => text.includes("积分消耗"));
const timeIndex = headers.findIndex((text) => text.includes("时间"));
const requestIdIndex = headers.findIndex((text) => text.toLowerCase().includes("requestid"));
const modelIndex = headers.findIndex((text) => /(模型|model)/i.test(text));
if (creditIndex < 0 || timeIndex < 0) {
return [];
}
const rows = Array.from(table.querySelectorAll("tbody tr"));
return rows
.map((row, index) => {
const cells = Array.from(row.children);
const requestId = requestIdIndex >= 0 ? getCellText(cells[requestIdIndex]) : "";
const credit = parseNumber(getCellText(cells[creditIndex]));
const requestTime = getCellText(cells[timeIndex]);
if (!Number.isFinite(credit) || !parseDateTime(requestTime)) {
return null;
}
return {
requestId:
requestId ||
makeStableId(
"table",
`${index}|${requestTime}|${credit}|${getCellText(cells[creditIndex])}|${getCellText(
cells[timeIndex]
)}|${modelIndex >= 0 ? getCellText(cells[modelIndex]) : UNKNOWN_MODEL_NAME}`
),
credit,
requestTime: normalizeDateTime(requestTime),
modelName:
modelIndex >= 0 ? normalizeModelName(getCellText(cells[modelIndex])) : UNKNOWN_MODEL_NAME,
};
})
.filter(Boolean);
}
function chooseBestSource(uiState, signature) {
const dataset = state.datasets.get(signature);
const tableRecords = uiState.tableRecords;
const cachedRecords = getCachedRecordsForUiState(uiState);
const expected = getExpectedCountForRange(dataset, uiState);
const isComplete = Boolean(expected && cachedRecords.length >= expected);
const isHydrating = Boolean(dataset && dataset.hydrationStatus === "running");
const tableSource = tableRecords.length
? {
kind: "table",
records: dedupeRecords(tableRecords),
label: "当前仅基于已加载表格数据",
meta: `${tableRecords.length} 条已加载记录`,
fingerprint: `table:${fingerprintRecords(tableRecords)}`,
}
: null;
const betterThanTable = cachedRecords.length > tableRecords.length;
if (cachedRecords.length && isComplete) {
return {
kind: "cache-full",
records: cachedRecords,
label: "缓存命中(已完整)",
meta: expected ? `已复用 ${cachedRecords.length}/${expected} 条记录` : `${cachedRecords.length} 条缓存记录`,
fingerprint: `cache-full:${fingerprintRecords(cachedRecords)}`,
};
}
if (betterThanTable) {
return {
kind: "cache-partial",
records: cachedRecords,
label: isHydrating ? "缓存命中(正在补齐)" : "缓存命中(部分覆盖)",
meta: expected
? `已复用 ${cachedRecords.length}/${expected} 条记录`
: `已复用 ${cachedRecords.length} 条缓存记录`,
fingerprint: `cache-partial:${fingerprintRecords(cachedRecords)}`,
};
}
return tableSource;
}
function resolveSignature(meta, extracted, uiState) {
const hinted = extractRangeHint(meta);
const startDate = hinted.startDate || uiState.startDate || "";
const endDate = hinted.endDate || uiState.endDate || "";
const rangeKey = hinted.rangeKey || uiState.rangeKey || "";
const unit = determineUnit(rangeKey, startDate, endDate);
return buildSignature(startDate, endDate, rangeKey, unit);
}
function upsertDataset(signature, extracted, uiState, meta) {
const existing = ensureRangeDataset(signature, uiState);
const requestTemplate = parseRequestTemplate(meta);
const canTrustUiState = isUiStateForSignature(uiState, signature);
for (const record of extracted.records) {
state.recordPool.set(
record.requestId,
mergeUsageRecords(state.recordPool.get(record.requestId), record)
);
existing.recordIds.add(record.requestId);
}
if (Number.isFinite(extracted.total) && extracted.total > 0) {
existing.totalExpected = Math.max(existing.totalExpected || 0, extracted.total);
}
if (canTrustUiState && Number.isFinite(uiState.totalCount) && uiState.totalCount > 0) {
existing.totalExpected = Math.max(existing.totalExpected || 0, uiState.totalCount);
}
if (extracted.isPaginated) {
existing.sawPagination = true;
}
existing.lastSeenAt = Date.now();
existing.requestTemplate = requestTemplate || existing.requestTemplate || null;
existing.lastRequestMeta = meta && meta.url ? { url: meta.url, method: meta.method || "GET" } : existing.lastRequestMeta;
existing.metaHints.push({
url: meta && meta.url,
size: extracted.records.length,
at: Date.now(),
});
existing.metaHints = existing.metaHints.slice(-8);
existing.hydrationStatus = resolveHydrationStatus(existing, canTrustUiState ? uiState : null);
state.datasets.set(signature, existing);
}
function maybeHydrateDataset(signature, extracted, uiState, meta) {
const dataset = state.datasets.get(signature);
if (!dataset) {
return;
}
const scopedUiState = isUiStateForSignature(uiState, signature) ? uiState : null;
const totalExpected = getExpectedCountForRange(dataset, scopedUiState, extracted);
const cachedCount = getCachedRecordsForDataset(dataset).length;
dataset.hydrationStatus = resolveHydrationStatus(dataset, scopedUiState, extracted);
if (!Number.isFinite(totalExpected) || totalExpected <= cachedCount) {
dataset.hydrationStatus = "complete";
return;
}
const existingJob = state.hydrationJobs.get(signature);
if (existingJob && existingJob.running) {
return;
}
const plan = derivePaginationPlan(meta, totalExpected);
if (!plan) {
dataset.hydrationStatus = cachedCount > 0 ? "partial" : "idle";
return;
}
const job = {
running: true,
completed: false,
plan,
pages: new Set([String(plan.currentValue)]),
};
dataset.hydrationStatus = "running";
dataset.requestTemplate = plan;
dataset.lastSeenAt = Date.now();
state.hydrationJobs.set(signature, job);
void fetchRemainingPages(signature, job).finally(() => {
job.running = false;
job.completed = true;
const latestDataset = state.datasets.get(signature);
if (latestDataset) {
latestDataset.hydrationStatus = resolveHydrationStatus(latestDataset);
}
scheduleSync("hydrate");
});
}
function ensureRangeDataset(signature, uiState) {
const parsedSignature = parseSignature(signature);
const existing =
state.datasets.get(signature) ||
{
signature,
createdAt: Date.now(),
startDate: parsedSignature.startDate,
endDate: parsedSignature.endDate,
rangeKey: parsedSignature.rangeKey,
unit: parsedSignature.unit,
recordIds: new Set(),
totalExpected: null,
sawPagination: false,
lastSeenAt: 0,
hydrationStatus: "idle",
requestTemplate: null,
lastRequestMeta: null,
metaHints: [],
};
existing.startDate = parsedSignature.startDate || existing.startDate || "";
existing.endDate = parsedSignature.endDate || existing.endDate || "";
existing.rangeKey = parsedSignature.rangeKey || existing.rangeKey || "";
existing.unit = parsedSignature.unit || existing.unit || "day";
if (uiState && isUiStateForSignature(uiState, signature)) {
if (Number.isFinite(uiState.totalCount) && uiState.totalCount > 0) {
existing.totalExpected = Math.max(existing.totalExpected || 0, uiState.totalCount);
}
}
state.datasets.set(signature, existing);
return existing;
}
function getExpectedCountForRange(dataset, uiState, extracted) {
const candidates = [
dataset && dataset.totalExpected,
extracted && extracted.total,
uiState && uiState.totalCount,
].filter((value) => Number.isFinite(value) && value > 0);
return candidates.length ? Math.max(...candidates) : null;
}
function countCachedRecordsForUiState(uiState) {
return getCachedRecordsForUiState(uiState).length;
}
function getCachedRecordsForUiState(uiState) {
const bounds = getRangeBounds(uiState);
if (!bounds) {
return [];
}
return getCachedRecordsByBounds(bounds);
}
function getCachedRecordsForDataset(dataset) {
if (!dataset || !dataset.startDate || !dataset.endDate) {
return [];
}
const bounds = getRangeBounds({
startDate: dataset.startDate,
endDate: dataset.endDate,
unit: dataset.unit || "day",
});
if (!bounds) {
return [];
}
return getCachedRecordsByBounds(bounds);
}
function getCachedRecordsByBounds(bounds) {
const records = [];
for (const record of state.recordPool.values()) {
const time = parseDateTime(record.requestTime);
if (!time || time < bounds.filterStart || time > bounds.filterEnd) {
continue;
}
records.push(record);
}
return records.sort(
(left, right) => parseDateTime(left.requestTime) - parseDateTime(right.requestTime)
);
}
function resolveHydrationStatus(dataset, uiState, extracted) {
const expected = getExpectedCountForRange(dataset, uiState, extracted);
const cachedCount = dataset ? getCachedRecordsForDataset(dataset).length : 0;
const job = dataset ? state.hydrationJobs.get(dataset.signature) : null;
if (job && job.running) {
return "running";
}
if (Number.isFinite(expected) && expected > 0 && cachedCount >= expected) {
return "complete";
}
if (cachedCount > 0) {
return "partial";
}
return "idle";
}
function parseSignature(signature) {
const parts = String(signature || "").split("|");
return {
startDate: parts[0] && parts[0] !== "na" ? parts[0] : "",
endDate: parts[1] && parts[1] !== "na" ? parts[1] : "",
rangeKey: parts[2] && parts[2] !== "custom" ? parts[2] : parts[2] || "",
unit: parts[3] || "day",
};
}
function isUiStateForSignature(uiState, signature) {
return Boolean(uiState && signature && getStateSignature(uiState) === signature);
}
function derivePaginationPlan(meta, totalExpected) {
if (!meta || !meta.url) {
return null;
}
const template = parseRequestTemplate(meta);
if (!template || !Number.isFinite(totalExpected) || totalExpected <= 0) {
return null;
}
if (template.mode === "page") {
const pageSize = template.pageSize;
const totalPages = Math.ceil(totalExpected / pageSize);
if (!Number.isFinite(pageSize) || pageSize <= 0 || totalPages <= 1) {
return null;
}
const firstPage = template.currentValue === 0 ? 0 : 1;
return {
...template,
totalExpected,
totalPages,
firstPage,
};
}
if (template.mode === "offset") {
const pageSize = template.pageSize;
const totalPages = Math.ceil(totalExpected / pageSize);
if (!Number.isFinite(pageSize) || pageSize <= 0 || totalPages <= 1) {
return null;
}
return {
...template,
totalExpected,
totalPages,
};
}
return null;
}
function parseRequestTemplate(meta) {
let url = null;
try {
url = new URL(meta.url, location.origin);
} catch (error) {
return null;
}
const method = String(meta.method || "GET").toUpperCase();
const queryParams = Object.fromEntries(url.searchParams.entries());
const bodyInfo = parseBodyTemplate(meta.body);
const sources = [];
if (bodyInfo.params && Object.keys(bodyInfo.params).length) {
sources.push({
target: "body",
params: bodyInfo.params,
bodyType: bodyInfo.type,
});
}
if (Object.keys(queryParams).length) {
sources.push({
target: "query",
params: queryParams,
bodyType: bodyInfo.type,
});
}
for (const source of sources) {
const pageKey = detectNumericKey(source.params, [
"pageIndex",
"pageNum",
"page",
"current",
"currentPage",
]);
const sizeKey = detectNumericKey(source.params, [
"pageSize",
"page_size",
"size",
"limit",
"pageLimit",
], [pageKey]);
if (pageKey && sizeKey) {
return {
mode: "page",
url,
method,
headers: sanitizeHeaders(meta.headers),
target: source.target,
bodyType: bodyInfo.type,
queryParams,
bodyParams: bodyInfo.params,
pageKey,
sizeKey,
currentValue: Number(source.params[pageKey]),
pageSize: Number(source.params[sizeKey]),
};
}
const offsetKey = detectNumericKey(source.params, ["offset", "start", "skip"]);
const limitKey = detectNumericKey(source.params, ["limit", "size", "pageSize", "page_size"], [
offsetKey,
]);
if (offsetKey && limitKey) {
return {
mode: "offset",
url,
method,
headers: sanitizeHeaders(meta.headers),
target: source.target,
bodyType: bodyInfo.type,
queryParams,
bodyParams: bodyInfo.params,
offsetKey,
limitKey,
currentValue: Number(source.params[offsetKey]),
pageSize: Number(source.params[limitKey]),
};
}
}
return null;
}
function parseBodyTemplate(body) {
if (!body || typeof body !== "string") {
return { type: "none", params: null };
}
const trimmed = body.trim();
if (!trimmed) {
return { type: "none", params: null };
}
if (trimmed.startsWith("{")) {
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return { type: "json", params: parsed };
}
} catch (error) {
return { type: "raw", params: null };
}
}
try {
const params = Object.fromEntries(new URLSearchParams(trimmed).entries());
if (Object.keys(params).length) {
return { type: "form", params };
}
} catch (error) {
return { type: "raw", params: null };
}
return { type: "raw", params: null };
}
async function fetchRemainingPages(signature, job) {
const targets = [];
if (job.plan.mode === "page") {
for (let index = 0; index < job.plan.totalPages; index += 1) {
const pageValue = job.plan.firstPage + index;
if (pageValue !== job.plan.currentValue) {
targets.push(pageValue);
}
}
} else {
for (let offset = 0; offset < job.plan.totalExpected; offset += job.plan.pageSize) {
if (offset !== job.plan.currentValue) {
targets.push(offset);
}
}
}
const concurrency = 3;
let pointer = 0;
const runner = async () => {
while (pointer < targets.length) {
const currentIndex = pointer;
pointer += 1;
const targetValue = targets[currentIndex];
if (job.pages.has(String(targetValue))) {
continue;
}
job.pages.add(String(targetValue));
try {
const response = await fetchHydrationPage(job.plan, targetValue);
const extracted = extractUsageRecords(response);
if (!extracted || !extracted.records.length) {
continue;
}
upsertDataset(
signature,
extracted,
getUiState(),
{ url: job.plan.url.toString(), method: job.plan.method }
);
scheduleSync("hydrate-page");
} catch (error) {
console.warn("[CBUC] hydrate page failed", error);
}
}
};
await Promise.all(Array.from({ length: Math.min(concurrency, targets.length) }, runner));
}
async function fetchHydrationPage(plan, targetValue) {
const request = buildHydrationRequest(plan, targetValue);
const response = await fetch(request.url, request.options);
const text = await response.text();
if (!text) {
return null;
}
return JSON.parse(text);
}
function buildHydrationRequest(plan, targetValue) {
const url = new URL(plan.url.toString());
const headers = new Headers();
Object.entries(plan.headers || {}).forEach(([key, value]) => {
if (!/^(content-length|host|cookie)$/i.test(key)) {
headers.set(key, value);
}
});
const queryParams = new URLSearchParams(url.search);
Object.entries(plan.queryParams || {}).forEach(([key, value]) => {
queryParams.set(key, value);
});
let body = null;
if (plan.mode === "page") {
if (plan.target === "query") {
queryParams.set(plan.pageKey, String(targetValue));
} else {
body = buildBodyPayload(plan, plan.pageKey, targetValue, headers);
}
} else if (plan.target === "query") {
queryParams.set(plan.offsetKey, String(targetValue));
} else {
body = buildBodyPayload(plan, plan.offsetKey, targetValue, headers);
}
url.search = queryParams.toString();
const options = {
method: plan.method,
credentials: "include",
headers,
};
if (body != null && plan.method !== "GET" && plan.method !== "HEAD") {
options.body = body;
}
return { url: url.toString(), options };
}
function buildBodyPayload(plan, key, value, headers) {
if (plan.bodyType === "json") {
const payload = JSON.parse(JSON.stringify(plan.bodyParams || {}));
payload[key] = value;
if (!headers.has("content-type")) {
headers.set("content-type", "application/json;charset=UTF-8");
}
return JSON.stringify(payload);
}
const params = new URLSearchParams();
Object.entries(plan.bodyParams || {}).forEach(([name, currentValue]) => {
params.set(name, String(currentValue));
});
params.set(key, String(value));
if (!headers.has("content-type")) {
headers.set("content-type", "application/x-www-form-urlencoded;charset=UTF-8");
}
return params.toString();
}
function detectNumericKey(params, candidates, exclude = []) {
for (const candidate of candidates) {
if (exclude.includes(candidate)) {
continue;
}
if (Object.prototype.hasOwnProperty.call(params, candidate)) {
const numeric = Number(params[candidate]);
if (Number.isFinite(numeric)) {
return candidate;
}
}
}
return null;
}
function sanitizeHeaders(headers) {
if (!headers || typeof headers !== "object") {
return {};
}
const result = {};
Object.entries(headers).forEach(([key, value]) => {
if (!/^(content-length|host|cookie)$/i.test(key)) {
result[key] = String(value);
}
});
return result;
}
function extractUsageRecords(payload) {
const seen = new WeakSet();
let best = null;
const visit = (node, parentKey, parentObject) => {
if (!node || typeof node !== "object") {
return;
}
if (seen.has(node)) {
return;
}
seen.add(node);
if (Array.isArray(node)) {
const candidate = scoreCandidateArray(node, parentKey, parentObject);
if (candidate && (!best || candidate.score > best.score)) {
best = candidate;
}
for (const item of node) {
visit(item, parentKey, parentObject);
}
return;
}
for (const [key, value] of Object.entries(node)) {
visit(value, key, node);
}
};
visit(payload, "", null);
return best;
}
function scoreCandidateArray(array, key, parentObject) {
if (!array.length || typeof array[0] !== "object" || Array.isArray(array[0])) {
return null;
}
const records = [];
let fieldHits = 0;
const sample = array.slice(0, Math.min(array.length, 8));
for (const item of array) {
const normalized = normalizeUsageRecord(item);
if (normalized) {
records.push(normalized);
}
}
if (!records.length) {
return null;
}
for (const item of sample) {
const keys = Object.keys(item || {}).map((name) => name.toLowerCase());
if (keys.some((name) => name === "requestid")) {
fieldHits += 4;
}
if (keys.some((name) => name.includes("credit"))) {
fieldHits += 4;
}
if (keys.some((name) => name.includes("requesttime") || name === "time")) {
fieldHits += 4;
}
if (keys.some((name) => name.includes("model") || name.includes("engine"))) {
fieldHits += 2;
}
}
const keyBonus = /record|list|item|data|usage|detail/i.test(key || "") ? 6 : 0;
const total = extractTotal(parentObject);
const isPaginated = Boolean(
parentObject &&
Object.keys(parentObject).some((prop) =>
/page|pagesize|pageindex|pagenum|offset|limit|hasmore/i.test(prop)
)
);
return {
records: dedupeRecords(records),
total,
isPaginated,
score: fieldHits + keyBonus + Math.min(records.length, 48),
};
}
function normalizeUsageRecord(record) {
if (!record || typeof record !== "object") {
return null;
}
const requestId =
getFirstValue(record, ["requestId", "requestID", "reqId", "id"]) || null;
const credit = parseNumber(
getFirstValue(record, ["credit", "credits", "creditCost", "consumeCredit", "cost"])
);
const requestTime =
getFirstValue(record, [
"requestTime",
"request_time",
"time",
"createdAt",
"created_at",
"timestamp",
]) || null;
const modelName = extractModelName(record);
const normalizedTime = normalizeDateTime(requestTime);
if (!Number.isFinite(credit) || !normalizedTime) {
return null;
}
return {
requestId:
requestId || makeStableId("network", `${normalizedTime}|${credit}|${safeStringify(record)}`),
credit,
requestTime: normalizedTime,
modelName,
};
}
function extractTotal(parentObject) {
if (!parentObject || typeof parentObject !== "object") {
return null;
}
const candidates = [
"total",
"totalCount",
"count",
"recordCount",
"recordsTotal",
"totalRecords",
];
for (const key of candidates) {
const value = parentObject[key];
if (Number.isFinite(Number(value))) {
return Number(value);
}
}
if (parentObject.pagination && Number.isFinite(Number(parentObject.pagination.total))) {
return Number(parentObject.pagination.total);
}
return null;
}
function extractRangeHint(meta) {
const values = new URLSearchParams();
try {
const url = new URL(meta && meta.url ? meta.url : location.href, location.origin);
url.searchParams.forEach((value, key) => values.append(key, value));
} catch (error) {
// ignore
}
if (meta && typeof meta.body === "string" && meta.body) {
const body = meta.body.trim();
if (body.startsWith("{")) {
try {
const parsed = JSON.parse(body);
flattenObject(parsed).forEach(([key, value]) => values.append(key, String(value)));
} catch (error) {
// ignore
}
} else {
new URLSearchParams(body).forEach((value, key) => values.append(key, value));
}
}
let startDate = "";
let endDate = "";
let rangeKey = "";
values.forEach((value, key) => {
const normalizedKey = key.toLowerCase();
const normalizedValue = String(value).trim();
if (!normalizedValue) {
return;
}
if (!rangeKey && /\b(3d|7d|30d)\b/i.test(normalizedValue)) {
rangeKey = normalizedValue.toLowerCase().match(/\b(3d|7d|30d)\b/i)[1];
}
if (!startDate && /(start|from|begin)/i.test(normalizedKey)) {
startDate = normalizeDateOnly(normalizedValue);
}
if (!endDate && /(end|to|finish)/i.test(normalizedKey)) {
endDate = normalizeDateOnly(normalizedValue);
}
if (!rangeKey && /(range|days|period)/i.test(normalizedKey) && /\d+d/i.test(normalizedValue)) {
rangeKey = normalizedValue.toLowerCase();
}
});
return { startDate, endDate, rangeKey };
}
function flattenObject(object, prefix = "", result = []) {
if (!object || typeof object !== "object") {
return result;
}
Object.entries(object).forEach(([key, value]) => {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === "object" && !Array.isArray(value)) {
flattenObject(value, fullKey, result);
} else if (!Array.isArray(value)) {
result.push([fullKey, value]);
}
});
return result;
}
function renderChart(uiState, source) {
if (!state.chartRoot) {
return;
}
const bucketData = aggregateRecords(source.records, uiState);
if (!bucketData.buckets.length) {
renderEmptyState("当前范围内暂无可用的 Credit 用量数据。", source);
return;
}
state.lastRenderWidth = Math.round(state.chartRoot.clientWidth);
const total = bucketData.total;
const titleNode = state.chartRoot.querySelector(".cbuc-total");
const statusNode = state.chartRoot.querySelector("[data-role='status']");
const metaNode = state.chartRoot.querySelector("[data-role='meta']");
const canvasNode = state.chartRoot.querySelector("[data-role='canvas']");
const tooltipNode = state.chartRoot.querySelector("[data-role='tooltip']");
const legendNode = state.chartRoot.querySelector("[data-role='legend']");
if (!canvasNode || !titleNode || !statusNode || !metaNode || !tooltipNode || !legendNode) {
return;
}
titleNode.textContent = formatCredit(total);
statusNode.textContent = source.label;
statusNode.dataset.kind = source.kind.includes("partial") || source.kind === "table" ? "warning" : "default";
metaNode.textContent = `${formatRangeSummary(uiState)} · ${source.meta}`;
renderToggleState(true);
renderLegend(legendNode, bucketData, state.splitByModel);
canvasNode.innerHTML = "";
if (state.tooltipCleanup) {
state.tooltipCleanup();
state.tooltipCleanup = null;
}
const svg = buildSvgChart(
bucketData,
canvasNode.clientWidth || state.chartRoot.clientWidth || 800,
{ splitByModel: state.splitByModel }
);
svg.classList.add("cbuc-svg");
canvasNode.appendChild(svg);
state.tooltipCleanup = attachTooltip(svg, tooltipNode, { splitByModel: state.splitByModel });
}
function buildSvgChart(bucketData, containerWidth, options) {
const splitByModel = Boolean(options && options.splitByModel);
const width = Math.max(520, containerWidth || 800);
const height = width < 720 ? 320 : 360;
const margin = {
top: 18,
right: 18,
bottom: width < 720 ? 74 : 82,
left: width < 720 ? 52 : 60,
};
const plotWidth = width - margin.left - margin.right;
const plotHeight = height - margin.top - margin.bottom;
const scale = getNiceScale(bucketData.maxValue);
const labelStep = getLabelStep(bucketData.buckets.length, plotWidth, bucketData.unit);
const barGap = bucketData.buckets.length > 40 ? 2 : 6;
const barWidth = Math.max(3, plotWidth / bucketData.buckets.length - barGap);
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
svg.setAttribute("width", "100%");
svg.setAttribute("height", String(height));
svg.setAttribute("role", "img");
svg.setAttribute("aria-label", "CodeBuddy Credit usage chart");
const defs = document.createElementNS(svg.namespaceURI, "defs");
defs.innerHTML = `
<linearGradient id="cbucBarGradient" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#7c97d8"></stop>
<stop offset="100%" stop-color="#6784ca"></stop>
</linearGradient>
<filter id="cbucGlow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="7" stdDeviation="7" flood-color="rgba(122, 146, 204, 0.16)"></feDropShadow>
</filter>
`;
svg.appendChild(defs);
const gridGroup = document.createElementNS(svg.namespaceURI, "g");
const yAxisGroup = document.createElementNS(svg.namespaceURI, "g");
const xAxisGroup = document.createElementNS(svg.namespaceURI, "g");
const barsGroup = document.createElementNS(svg.namespaceURI, "g");
scale.ticks.forEach((tick) => {
const y = margin.top + plotHeight - (tick / scale.max) * plotHeight;
const line = document.createElementNS(svg.namespaceURI, "line");
line.setAttribute("x1", String(margin.left));
line.setAttribute("x2", String(width - margin.right));
line.setAttribute("y1", String(y));
line.setAttribute("y2", String(y));
line.setAttribute("stroke", "rgba(153, 164, 194, 0.16)");
line.setAttribute("stroke-width", tick === 0 ? "1.4" : "1");
gridGroup.appendChild(line);
const label = document.createElementNS(svg.namespaceURI, "text");
label.setAttribute("x", String(margin.left - 10));
label.setAttribute("y", String(y + 4));
label.setAttribute("text-anchor", "end");
label.setAttribute("font-size", width < 720 ? "11" : "12");
label.setAttribute("fill", "rgba(255,255,255,0.55)");
label.textContent = formatAxisValue(tick);
yAxisGroup.appendChild(label);
});
const cursorLine = document.createElementNS(svg.namespaceURI, "line");
cursorLine.classList.add("cbuc-cursor-line");
cursorLine.setAttribute("y1", String(margin.top));
cursorLine.setAttribute("y2", String(margin.top + plotHeight));
cursorLine.setAttribute("x1", "0");
cursorLine.setAttribute("x2", "0");
svg.appendChild(cursorLine);
bucketData.buckets.forEach((bucket, index) => {
const x = margin.left + index * (barWidth + barGap) + barGap / 2;
const scaledHeight = bucket.value <= 0 ? 0 : Math.max(3, (bucket.value / scale.max) * plotHeight);
const barTopY = margin.top + plotHeight - scaledHeight;
let segmentCursorY = margin.top + plotHeight;
if (splitByModel && bucket.stackSegments.length) {
let stackedHeight = 0;
bucket.stackSegments.forEach((segment, segmentIndex) => {
const segmentHeight =
segment.value <= 0
? 0
: segmentIndex === bucket.stackSegments.length - 1
? Math.max(0, scaledHeight - stackedHeight)
: (segment.value / bucket.value) * scaledHeight;
stackedHeight += segmentHeight;
segmentCursorY -= segmentHeight;
const rect = document.createElementNS(svg.namespaceURI, "rect");
rect.setAttribute("x", String(x));
rect.setAttribute("y", String(segmentCursorY));
rect.setAttribute("width", String(barWidth));
rect.setAttribute("height", String(segmentHeight));
rect.setAttribute("rx", String(segmentIndex === bucket.stackSegments.length - 1 ? Math.min(6, barWidth / 2) : 0));
rect.setAttribute("fill", segment.color);
rect.setAttribute("filter", segmentHeight ? "url(#cbucGlow)" : "");
rect.dataset.bar = "true";
rect.dataset.bucketIndex = String(index);
barsGroup.appendChild(rect);
});
} else {
const rect = document.createElementNS(svg.namespaceURI, "rect");
rect.setAttribute("x", String(x));
rect.setAttribute("y", String(barTopY));
rect.setAttribute("width", String(barWidth));
rect.setAttribute("height", String(scaledHeight));
rect.setAttribute("rx", String(Math.min(6, barWidth / 2)));
rect.setAttribute("fill", "url(#cbucBarGradient)");
rect.setAttribute("filter", scaledHeight ? "url(#cbucGlow)" : "");
rect.dataset.bar = "true";
rect.dataset.bucketIndex = String(index);
barsGroup.appendChild(rect);
}
const anchorRect = document.createElementNS(svg.namespaceURI, "rect");
anchorRect.setAttribute("x", String(x));
anchorRect.setAttribute("y", String(scaledHeight ? barTopY : margin.top + plotHeight - 3));
anchorRect.setAttribute("width", String(barWidth));
anchorRect.setAttribute("height", String(scaledHeight || 3));
anchorRect.setAttribute("fill", "transparent");
anchorRect.dataset.index = String(index);
anchorRect.dataset.tooltipTitle = bucket.tooltipTitle;
anchorRect.dataset.tooltipValue = formatCredit(bucket.value);
anchorRect.dataset.tooltipBreakdown = encodeTooltipBreakdown(bucket.tooltipModels);
barsGroup.appendChild(anchorRect);
if (index % labelStep === 0 || index === bucketData.buckets.length - 1) {
const label = document.createElementNS(svg.namespaceURI, "text");
label.setAttribute("x", String(x + barWidth / 2));
label.setAttribute("y", String(height - 26));
label.setAttribute("text-anchor", "middle");
label.setAttribute("font-size", width < 720 ? "10" : "11");
label.setAttribute("fill", "rgba(255,255,255,0.55)");
label.textContent = bucket.axisLabel;
xAxisGroup.appendChild(label);
}
});
const overlay = document.createElementNS(svg.namespaceURI, "rect");
overlay.setAttribute("x", String(margin.left));
overlay.setAttribute("y", String(margin.top));
overlay.setAttribute("width", String(plotWidth));
overlay.setAttribute("height", String(plotHeight));
overlay.setAttribute("fill", "transparent");
overlay.setAttribute("cursor", "crosshair");
overlay.dataset.role = "chart-overlay";
svg.dataset.marginLeft = String(margin.left);
svg.dataset.marginTop = String(margin.top);
svg.dataset.barWidth = String(barWidth);
svg.dataset.barGap = String(barGap);
svg.dataset.plotWidth = String(plotWidth);
svg.dataset.plotHeight = String(plotHeight);
svg.dataset.bucketCount = String(bucketData.buckets.length);
svg.appendChild(gridGroup);
svg.appendChild(yAxisGroup);
svg.appendChild(xAxisGroup);
svg.appendChild(barsGroup);
svg.appendChild(overlay);
return svg;
}
function attachTooltip(svg, tooltipNode, options) {
const anchors = Array.from(svg.querySelectorAll("rect[data-index]"));
const barRects = Array.from(svg.querySelectorAll("rect[data-bar='true']"));
if (!anchors.length) {
tooltipNode.classList.remove("is-visible");
return null;
}
const titleNode = tooltipNode.querySelector(".cbuc-tooltip-title");
const valueNode = tooltipNode.querySelector(".cbuc-tooltip-value");
const breakdownNode = tooltipNode.querySelector(".cbuc-tooltip-breakdown");
if (!titleNode || !valueNode || !breakdownNode) {
return null;
}
const overlay = svg.querySelector("rect[data-role='chart-overlay']");
const cursorLine = svg.querySelector(".cbuc-cursor-line");
if (!overlay) {
return null;
}
const marginLeft = parseFloat(svg.dataset.marginLeft);
const marginTop = parseFloat(svg.dataset.marginTop);
const barWidth = parseFloat(svg.dataset.barWidth);
const barGap = parseFloat(svg.dataset.barGap);
const plotWidth = parseFloat(svg.dataset.plotWidth);
const bucketCount = parseInt(svg.dataset.bucketCount, 10);
let currentIndex = -1;
let isVisible = false;
const getBucketIndexFromX = (clientX) => {
const canvas = state.chartRoot.querySelector("[data-role='canvas']");
if (!canvas) {
return -1;
}
const svgRect = svg.getBoundingClientRect();
const scaleX = svgRect.width / parseFloat(svg.getAttribute("viewBox").split(" ")[2]);
const relativeX = (clientX - svgRect.left) / scaleX - marginLeft;
const index = Math.floor(relativeX / (barWidth + barGap));
if (index < 0 || index >= bucketCount) {
return -1;
}
return index;
};
const show = (index) => {
if (index < 0 || index >= anchors.length) {
return;
}
const target = anchors[index];
if (!target) {
return;
}
barRects.forEach((rect) => {
rect.classList.remove("is-highlighted", "is-dimmed");
if (rect.dataset.bucketIndex !== String(index)) {
rect.classList.add("is-dimmed");
} else {
rect.classList.add("is-highlighted");
}
});
const canvas = state.chartRoot.querySelector("[data-role='canvas']");
if (!canvas) {
return;
}
const canvasRect = canvas.getBoundingClientRect();
const svgRect = svg.getBoundingClientRect();
const scaleX = svgRect.width / parseFloat(svg.getAttribute("viewBox").split(" ")[2]);
const tooltipRect = tooltipNode.getBoundingClientRect();
const barX = marginLeft + index * (barWidth + barGap) + barGap / 2 + barWidth / 2;
const barCenterX = (svgRect.left + barX * scaleX) - canvasRect.left;
let x = barCenterX + 12;
let y = (parseFloat(target.getAttribute("y")) * scaleX) - tooltipRect.height - 12;
const clampedX = Math.max(8, Math.min(x, canvasRect.width - tooltipRect.width - 8));
const nextY = y < 8 ? (parseFloat(target.getAttribute("y")) * scaleX) + parseFloat(target.getAttribute("height")) * scaleX + 12 : y;
tooltipNode.style.left = `${clampedX}px`;
tooltipNode.style.top = `${nextY}px`;
titleNode.textContent = target.dataset.tooltipTitle || "";
valueNode.textContent = `总计 ${target.dataset.tooltipValue || "0"} Credits`;
breakdownNode.innerHTML = buildTooltipBreakdownHtml(
decodeTooltipBreakdown(target.dataset.tooltipBreakdown || "")
);
tooltipNode.classList.add("is-visible");
isVisible = true;
};
const hide = () => {
tooltipNode.classList.remove("is-visible");
cursorLine.classList.remove("is-visible");
breakdownNode.innerHTML = "";
barRects.forEach((rect) => {
rect.classList.remove("is-highlighted", "is-dimmed");
});
currentIndex = -1;
isVisible = false;
};
const updateCursorLine = (clientX) => {
if (!cursorLine) {
return;
}
const svgRect = svg.getBoundingClientRect();
const scaleX = svgRect.width / parseFloat(svg.getAttribute("viewBox").split(" ")[2]);
const relativeX = (clientX - svgRect.left) / scaleX;
cursorLine.setAttribute("x1", String(relativeX));
cursorLine.setAttribute("x2", String(relativeX));
cursorLine.classList.add("is-visible");
};
const onMove = (event) => {
const index = getBucketIndexFromX(event.clientX);
if (index === -1) {
if (isVisible) {
hide();
}
return;
}
if (index !== currentIndex) {
currentIndex = index;
show(index);
}
updateCursorLine(event.clientX);
};
overlay.addEventListener("mousemove", onMove);
overlay.addEventListener("mouseleave", hide);
return () => {
overlay.removeEventListener("mousemove", onMove);
overlay.removeEventListener("mouseleave", hide);
};
}
function renderLoadingState(text) {
if (!state.chartRoot) {
return;
}
const titleNode = state.chartRoot.querySelector(".cbuc-total");
const statusNode = state.chartRoot.querySelector("[data-role='status']");
const metaNode = state.chartRoot.querySelector("[data-role='meta']");
const canvasNode = state.chartRoot.querySelector("[data-role='canvas']");
const legendNode = state.chartRoot.querySelector("[data-role='legend']");
if (!titleNode || !statusNode || !metaNode || !canvasNode || !legendNode) {
return;
}
titleNode.textContent = "...";
statusNode.textContent = "加载中";
statusNode.dataset.kind = "default";
metaNode.textContent = "等待页面返回最新数据";
if (state.tooltipCleanup) {
state.tooltipCleanup();
state.tooltipCleanup = null;
}
canvasNode.innerHTML = `<div class="cbuc-loading">${escapeHtml(text)}</div>`;
renderToggleState(false);
renderLegend(legendNode, null, false);
}
function renderEmptyState(text, source) {
if (!state.chartRoot) {
return;
}
const titleNode = state.chartRoot.querySelector(".cbuc-total");
const statusNode = state.chartRoot.querySelector("[data-role='status']");
const metaNode = state.chartRoot.querySelector("[data-role='meta']");
const canvasNode = state.chartRoot.querySelector("[data-role='canvas']");
const legendNode = state.chartRoot.querySelector("[data-role='legend']");
if (!titleNode || !statusNode || !metaNode || !canvasNode || !legendNode) {
return;
}
titleNode.textContent = "0";
statusNode.textContent = source ? source.label : "暂无数据";
statusNode.dataset.kind = source && source.kind === "network-full" ? "default" : "warning";
metaNode.textContent = source ? source.meta : "当前范围无记录";
if (state.tooltipCleanup) {
state.tooltipCleanup();
state.tooltipCleanup = null;
}
canvasNode.innerHTML = `<div class="cbuc-empty">${escapeHtml(text)}</div>`;
renderToggleState(false);
renderLegend(legendNode, null, false);
}
function aggregateRecords(records, uiState) {
const range = getRangeBounds(uiState);
if (!range) {
return { buckets: [], maxValue: 0, total: 0, unit: uiState.unit, modelTotals: [] };
}
const bucketCount =
uiState.unit === "hour"
? Math.floor((range.lastBucketStart - range.firstBucketStart) / HOUR_MS) + 1
: Math.floor((range.lastBucketStart - range.firstBucketStart) / DAY_MS) + 1;
const buckets = [];
const step = uiState.unit === "hour" ? HOUR_MS : DAY_MS;
for (let index = 0; index < bucketCount; index += 1) {
const bucketStart = range.firstBucketStart + index * step;
buckets.push({
start: bucketStart,
end: bucketStart + step - 1,
value: 0,
axisLabel: formatAxisLabel(bucketStart, uiState.unit, index),
tooltipTitle: formatTooltipLabel(bucketStart, uiState.unit),
models: new Map(),
stackSegments: [],
tooltipModels: [],
});
}
const modelTotals = new Map();
records.forEach((record) => {
const time = parseDateTime(record.requestTime);
if (!time || time < range.filterStart || time > range.filterEnd) {
return;
}
const bucketStart =
uiState.unit === "hour" ? startOfHour(time).getTime() : startOfDay(time).getTime();
const index = Math.floor((bucketStart - range.firstBucketStart) / step);
if (index >= 0 && index < buckets.length) {
const modelName = normalizeModelName(record.modelName);
buckets[index].value += record.credit;
buckets[index].models.set(modelName, (buckets[index].models.get(modelName) || 0) + record.credit);
modelTotals.set(modelName, (modelTotals.get(modelName) || 0) + record.credit);
}
});
const sortedModelTotals = Array.from(modelTotals.entries())
.filter((entry) => entry[1] > 0)
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0], "zh-CN"))
.map(([name, value]) => ({
name,
value,
color: getModelColor(name),
}));
buckets.forEach((bucket) => {
const tooltipModels = Array.from(bucket.models.entries())
.filter((entry) => entry[1] > 0)
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0], "zh-CN"))
.map(([name, value]) => ({
name,
value,
share: bucket.value > 0 ? value / bucket.value : 0,
color: getModelColor(name),
}));
const stackSegments = sortedModelTotals
.map((model) => ({
name: model.name,
value: bucket.models.get(model.name) || 0,
color: model.color,
}))
.filter((segment) => segment.value > 0);
bucket.tooltipModels = tooltipModels;
bucket.stackSegments = stackSegments;
});
const maxValue = Math.max(0, ...buckets.map((bucket) => bucket.value));
const total = buckets.reduce((sum, bucket) => sum + bucket.value, 0);
return {
buckets,
maxValue,
total,
unit: uiState.unit,
modelTotals: sortedModelTotals,
};
}
function renderToggleState(hasData) {
if (!state.chartRoot) {
return;
}
const button = state.chartRoot.querySelector("[data-role='toggle-model-mode']");
if (!(button instanceof HTMLButtonElement)) {
return;
}
button.disabled = !hasData;
button.setAttribute("aria-pressed", hasData && state.splitByModel ? "true" : "false");
}
function renderLegend(legendNode, bucketData, splitByModel) {
if (!legendNode) {
return;
}
if (splitByModel && bucketData && bucketData.modelTotals.length) {
legendNode.innerHTML = `
<span class="cbuc-legend-item">
<span>Credit 用量</span>
</span>
${bucketData.modelTotals
.map(
(model) => `
<span class="cbuc-legend-item">
<span class="cbuc-legend-swatch" style="background:${escapeHtml(model.color)}"></span>
<span>${escapeHtml(model.name)}</span>
</span>
`
)
.join("")}
`;
return;
}
legendNode.innerHTML = `
<span class="cbuc-legend-item">
<span class="cbuc-legend-swatch"></span>
<span>Credit 用量</span>
</span>
`;
}
function encodeTooltipBreakdown(items) {
try {
return encodeURIComponent(
JSON.stringify(
(items || []).map((item) => ({
name: item.name,
value: item.value,
share: item.share,
color: item.color,
}))
)
);
} catch (error) {
return "";
}
}
function decodeTooltipBreakdown(value) {
if (!value) {
return [];
}
try {
const parsed = JSON.parse(decodeURIComponent(value));
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
return [];
}
}
function buildTooltipBreakdownHtml(items) {
if (!items.length) {
return "";
}
return items
.map(
(item) => `
<div class="cbuc-tooltip-row">
<span class="cbuc-tooltip-swatch" style="background:${escapeHtml(item.color)}"></span>
<span class="cbuc-tooltip-model">${escapeHtml(item.name)}</span>
<span class="cbuc-tooltip-credit">${escapeHtml(formatCredit(item.value))}</span>
<span class="cbuc-tooltip-share">${escapeHtml(formatPercent(item.share))}</span>
</div>
`
)
.join("");
}
function getModelColor(modelName) {
const normalized = normalizeModelName(modelName);
if (normalized === UNKNOWN_MODEL_NAME) {
return "#7f8799";
}
if (state.modelColors.has(normalized)) {
return state.modelColors.get(normalized);
}
let color = "";
if (state.nextModelColorIndex < MODEL_COLOR_PALETTE.length) {
color = MODEL_COLOR_PALETTE[state.nextModelColorIndex];
state.nextModelColorIndex += 1;
} else {
const hash = hashText(normalized);
color = `hsl(${hash % 360} 32% 62%)`;
}
state.modelColors.set(normalized, color);
return color;
}
function extractModelName(record) {
const directKeys = ["model", "modelName", "model_name", "llmModel", "engine", "modelId"];
for (const key of directKeys) {
const normalized = normalizeModelValue(record[key]);
if (normalized) {
return normalized;
}
}
for (const [key, value] of Object.entries(record)) {
if (!/(model|engine)/i.test(key)) {
continue;
}
const normalized = normalizeModelValue(value);
if (normalized) {
return normalized;
}
}
return UNKNOWN_MODEL_NAME;
}
function normalizeModelValue(value) {
if (value == null || value === "") {
return "";
}
if (typeof value === "string" || typeof value === "number") {
const normalized = normalizeModelName(String(value));
return normalized === UNKNOWN_MODEL_NAME ? "" : normalized;
}
if (Array.isArray(value)) {
for (const item of value) {
const normalized = normalizeModelValue(item);
if (normalized) {
return normalized;
}
}
return "";
}
if (typeof value === "object") {
const nested = getFirstValue(value, [
"name",
"model",
"modelName",
"model_name",
"id",
"modelId",
"engine",
]);
return nested != null ? normalizeModelValue(nested) : "";
}
return "";
}
function normalizeModelName(value) {
const text = String(value == null ? "" : value)
.replace(/\s+/g, " ")
.trim();
if (!text || text === "[object Object]") {
return UNKNOWN_MODEL_NAME;
}
return text;
}
function getRangeBounds(uiState) {
if (!uiState.startDate || !uiState.endDate) {
return null;
}
const start = parseDateOnly(uiState.startDate);
const end = parseDateOnly(uiState.endDate);
if (!start || !end) {
return null;
}
if (uiState.unit === "hour") {
const filterStart = startOfDay(start).getTime();
const filterEnd = endOfDay(end).getTime();
return {
filterStart,
filterEnd,
firstBucketStart: filterStart,
lastBucketStart: startOfHour(filterEnd).getTime(),
};
}
return {
filterStart: startOfDay(start).getTime(),
filterEnd: endOfDay(end).getTime(),
firstBucketStart: startOfDay(start).getTime(),
lastBucketStart: startOfDay(end).getTime(),
};
}
function getStateSignature(uiState) {
return buildSignature(uiState.startDate, uiState.endDate, uiState.rangeKey, uiState.unit);
}
function buildSignature(startDate, endDate, rangeKey, unit) {
return [startDate || "na", endDate || "na", rangeKey || "custom", unit || "day"].join("|");
}
function determineUnit(rangeKey, startDate, endDate) {
if (rangeKey === "3d") {
return "hour";
}
if (rangeKey === "7d" || rangeKey === "30d") {
return "day";
}
const start = parseDateOnly(startDate);
const end = parseDateOnly(endDate);
if (!start || !end) {
return "day";
}
const diffDays = Math.floor((startOfDay(end).getTime() - startOfDay(start).getTime()) / DAY_MS) + 1;
return diffDays <= 3 ? "hour" : "day";
}
function getLabelStep(bucketCount, plotWidth, unit) {
const minSpacing = unit === "hour" ? 62 : 48;
return Math.max(1, Math.ceil((bucketCount * minSpacing) / Math.max(plotWidth, 1)));
}
function getNiceScale(maxValue) {
if (!maxValue || maxValue <= 0) {
return { max: 1, ticks: [0, 0.25, 0.5, 0.75, 1] };
}
const roughStep = maxValue / 4;
const magnitude = Math.pow(10, Math.floor(Math.log10(roughStep)));
const residual = roughStep / magnitude;
let niceResidual = 1;
if (residual > 5) {
niceResidual = 10;
} else if (residual > 2) {
niceResidual = 5;
} else if (residual > 1) {
niceResidual = 2;
}
const step = niceResidual * magnitude;
const max = Math.ceil(maxValue / step) * step;
const ticks = [];
for (let tick = 0; tick <= max + step * 0.001; tick += step) {
ticks.push(Number(tick.toFixed(10)));
}
return { max, ticks };
}
function formatAxisLabel(timestamp, unit, index) {
const date = new Date(timestamp);
const monthDay = `${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
if (unit === "hour") {
return date.getHours() === 0 || index === 0
? `${monthDay} ${pad(date.getHours())}:00`
: `${pad(date.getHours())}:00`;
}
return monthDay;
}
function formatTooltipLabel(timestamp, unit) {
const date = new Date(timestamp);
if (unit === "hour") {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
date.getHours()
)}:00`;
}
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
function formatAxisValue(value) {
if (value >= 1000) {
const fixed = value >= 10000 ? 0 : 1;
return `${Number((value / 1000).toFixed(fixed))}K`;
}
if (value >= 10) {
return Number(value.toFixed(0)).toString();
}
if (value >= 1) {
return Number(value.toFixed(1)).toString();
}
return Number(value.toFixed(2)).toString();
}
function formatRangeSummary(uiState) {
if (!uiState.startDate || !uiState.endDate) {
return "未识别范围";
}
return `${uiState.startDate} ~ ${uiState.endDate} · ${uiState.unit === "hour" ? "按小时" : "按天"}聚合`;
}
function isLoadingHintActive() {
return Date.now() < state.interactionHintUntil;
}
function parseDateOnly(value) {
const normalized = normalizeDateOnly(value);
if (!normalized) {
return null;
}
const [year, month, day] = normalized.split("-").map(Number);
const date = new Date(year, month - 1, day);
return Number.isNaN(date.getTime()) ? null : date;
}
function parseDateTime(value) {
if (!value && value !== 0) {
return null;
}
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value.getTime();
}
if (typeof value === "number") {
return value > 1e12 ? value : value * 1000;
}
const text = String(value).trim();
if (!text) {
return null;
}
if (/^\d{10,13}$/.test(text)) {
const numeric = Number(text);
return text.length === 13 ? numeric : numeric * 1000;
}
const normalized = text.replace(/\//g, "-").replace(" ", "T");
const date = new Date(normalized);
return Number.isNaN(date.getTime()) ? null : date.getTime();
}
function normalizeDateOnly(value) {
if (!value) {
return "";
}
const match = String(value).trim().match(/(\d{4})[-/](\d{1,2})[-/](\d{1,2})/);
if (!match) {
return "";
}
return `${match[1]}-${pad(match[2])}-${pad(match[3])}`;
}
function normalizeDateTime(value) {
const time = parseDateTime(value);
if (!time) {
return "";
}
const date = new Date(time);
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
date.getHours()
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
function formatCredit(value) {
const number = Number(value) || 0;
return new Intl.NumberFormat("zh-CN", {
minimumFractionDigits: number % 1 === 0 ? 0 : 2,
maximumFractionDigits: 2,
}).format(number);
}
function formatPercent(value) {
const number = Number(value) || 0;
return `${(number * 100).toFixed(number * 100 >= 10 ? 0 : 1)}%`;
}
function parseNumber(value) {
if (typeof value === "number") {
return Number.isFinite(value) ? value : NaN;
}
if (value == null) {
return NaN;
}
const text = String(value).replace(/[^\d.-]/g, "");
const parsed = Number(text);
return Number.isFinite(parsed) ? parsed : NaN;
}
function dedupeRecords(records) {
const unique = new Map();
records.forEach((record) => {
unique.set(record.requestId, mergeUsageRecords(unique.get(record.requestId), record));
});
return Array.from(unique.values()).sort(
(left, right) => parseDateTime(left.requestTime) - parseDateTime(right.requestTime)
);
}
function fingerprintRecords(records) {
const total = records.reduce((sum, record) => sum + record.credit, 0);
const first = records[0] ? records[0].requestTime : "na";
const last = records[records.length - 1] ? records[records.length - 1].requestTime : "na";
const modelSignature = records
.slice(0, 12)
.map((record) => `${record.requestId}:${normalizeModelName(record.modelName)}`)
.join("|");
return `${records.length}:${first}:${last}:${total.toFixed(2)}:${hashText(modelSignature)}`;
}
function mergeUsageRecords(existingRecord, incomingRecord) {
if (!existingRecord) {
return incomingRecord;
}
if (!incomingRecord) {
return existingRecord;
}
const existingModel = normalizeModelName(existingRecord.modelName);
const incomingModel = normalizeModelName(incomingRecord.modelName);
if (existingModel === UNKNOWN_MODEL_NAME && incomingModel !== UNKNOWN_MODEL_NAME) {
return { ...existingRecord, ...incomingRecord, modelName: incomingModel };
}
if (existingModel !== UNKNOWN_MODEL_NAME && incomingModel === UNKNOWN_MODEL_NAME) {
return { ...existingRecord, ...incomingRecord, modelName: existingModel };
}
return { ...existingRecord, ...incomingRecord, modelName: incomingModel || existingModel };
}
function getFirstValue(object, keys) {
for (const key of keys) {
if (object[key] != null && object[key] !== "") {
return object[key];
}
}
return null;
}
function getOwnText(element) {
const textNodes = Array.from(element.childNodes)
.filter((node) => node.nodeType === Node.TEXT_NODE)
.map((node) => node.textContent || "");
return textNodes.join("").trim();
}
function getCellText(cell) {
return cell ? (cell.textContent || "").trim() : "";
}
function startOfDay(value) {
const date = new Date(value);
date.setHours(0, 0, 0, 0);
return date;
}
function endOfDay(value) {
const date = new Date(value);
date.setHours(23, 59, 59, 999);
return date;
}
function startOfHour(value) {
const date = new Date(value);
date.setMinutes(0, 0, 0);
return date;
}
function pad(value) {
return String(value).padStart(2, "0");
}
function escapeHtml(text) {
return String(text)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function safeStringify(value) {
try {
return JSON.stringify(value) || "";
} catch (error) {
return String(value || "");
}
}
function makeStableId(prefix, raw) {
const text = `${prefix}:${raw}`;
const hash = hashText(text);
return `${prefix}-${(hash >>> 0).toString(16)}`;
}
function hashText(text) {
let hash = 2166136261;
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
})();