mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-03 03:10:50 +08:00
1644 lines
56 KiB
JavaScript
1644 lines
56 KiB
JavaScript
const DEFAULT_MODEL_PRICE_STORAGE_KEY = 'cli-proxy-model-prices-v2';
|
||
const LEGACY_MODEL_PRICE_STORAGE_KEY = 'cli-proxy-model-prices';
|
||
const TOKENS_PER_PRICE_UNIT = 1_000_000;
|
||
const DEFAULT_CHART_LINE_COUNT = 3;
|
||
const MIN_CHART_LINE_COUNT = 1;
|
||
const ALL_MODELS_VALUE = 'all';
|
||
|
||
export function maskUsageSensitiveValue(value) {
|
||
if (value === null || value === undefined) {
|
||
return '';
|
||
}
|
||
const raw = typeof value === 'string' ? value : String(value);
|
||
if (!raw) {
|
||
return '';
|
||
}
|
||
const maskFn = (this && typeof this.maskApiKey === 'function') ? this.maskApiKey : (v) => v;
|
||
let masked = raw;
|
||
|
||
const queryRegex = /([?&])(api[-_]?key|key|token|access_token|authorization)=([^&#\s]+)/ig;
|
||
masked = masked.replace(queryRegex, (full, prefix, keyName, valuePart) => `${prefix}${keyName}=${maskFn(valuePart)}`);
|
||
|
||
const headerRegex = /(api[-_]?key|key|token|access[-_]?token|authorization)\s*([:=])\s*([A-Za-z0-9._-]+)/ig;
|
||
masked = masked.replace(headerRegex, (full, keyName, separator, valuePart) => `${keyName}${separator}${maskFn(valuePart)}`);
|
||
|
||
const keyLikeRegex = /(sk-[A-Za-z0-9]{6,}|AI[a-zA-Z0-9_-]{6,}|AIza[0-9A-Za-z-_]{8,}|hf_[A-Za-z0-9]{6,}|pk_[A-Za-z0-9]{6,}|rk_[A-Za-z0-9]{6,})/g;
|
||
masked = masked.replace(keyLikeRegex, match => maskFn(match));
|
||
|
||
if (masked === raw) {
|
||
const trimmed = raw.trim();
|
||
if (trimmed && !/\s/.test(trimmed)) {
|
||
const looksLikeKey = /^sk-/i.test(trimmed)
|
||
|| /^AI/i.test(trimmed)
|
||
|| /^AIza/i.test(trimmed)
|
||
|| /^hf_/i.test(trimmed)
|
||
|| /^pk_/i.test(trimmed)
|
||
|| /^rk_/i.test(trimmed)
|
||
|| (!/[\\/]/.test(trimmed) && (/\d/.test(trimmed) || trimmed.length >= 10))
|
||
|| trimmed.length >= 24;
|
||
if (looksLikeKey) {
|
||
return maskFn(trimmed);
|
||
}
|
||
}
|
||
}
|
||
|
||
return masked;
|
||
}
|
||
|
||
// 获取API密钥的统计信息
|
||
export async function getKeyStats(usageData = null) {
|
||
try {
|
||
let usage = usageData;
|
||
if (!usage) {
|
||
const response = await this.makeRequest('/usage');
|
||
usage = response?.usage || null;
|
||
}
|
||
|
||
if (!usage) {
|
||
return { bySource: {}, byAuthIndex: {} };
|
||
}
|
||
|
||
const sourceStats = {};
|
||
const authIndexStats = {};
|
||
const ensureBucket = (bucket, key) => {
|
||
if (!bucket[key]) {
|
||
bucket[key] = { success: 0, failure: 0 };
|
||
}
|
||
return bucket[key];
|
||
};
|
||
const normalizeAuthIndex = (value) => {
|
||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||
return value.toString();
|
||
}
|
||
if (typeof value === 'string') {
|
||
const trimmed = value.trim();
|
||
return trimmed ? trimmed : null;
|
||
}
|
||
return null;
|
||
};
|
||
const apis = usage.apis || {};
|
||
|
||
Object.values(apis).forEach(apiEntry => {
|
||
const models = apiEntry.models || {};
|
||
|
||
Object.values(models).forEach(modelEntry => {
|
||
const details = modelEntry.details || [];
|
||
|
||
details.forEach(detail => {
|
||
const source = this.maskUsageSensitiveValue
|
||
? this.maskUsageSensitiveValue(detail.source)
|
||
: detail.source;
|
||
const authIndexKey = normalizeAuthIndex(detail?.auth_index);
|
||
const isFailed = detail.failed === true;
|
||
|
||
if (source) {
|
||
const bucket = ensureBucket(sourceStats, source);
|
||
if (isFailed) {
|
||
bucket.failure += 1;
|
||
} else {
|
||
bucket.success += 1;
|
||
}
|
||
}
|
||
|
||
if (authIndexKey) {
|
||
const bucket = ensureBucket(authIndexStats, authIndexKey);
|
||
if (isFailed) {
|
||
bucket.failure += 1;
|
||
} else {
|
||
bucket.success += 1;
|
||
}
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
return {
|
||
bySource: sourceStats,
|
||
byAuthIndex: authIndexStats
|
||
};
|
||
} catch (error) {
|
||
console.error('获取统计信息失败:', error);
|
||
return { bySource: {}, byAuthIndex: {} };
|
||
}
|
||
}
|
||
|
||
// 加载使用统计
|
||
export async function loadUsageStats(usageData = null) {
|
||
try {
|
||
let usage = usageData;
|
||
// 如果没有传入usage数据,则调用API获取
|
||
if (!usage) {
|
||
const response = await this.makeRequest('/usage');
|
||
usage = response?.usage || null;
|
||
}
|
||
this.currentUsageData = usage;
|
||
this.ensureModelPriceState();
|
||
|
||
if (!usage) {
|
||
throw new Error('usage payload missing');
|
||
}
|
||
|
||
// 更新概览卡片
|
||
this.updateUsageOverview(usage);
|
||
this.updateChartLineSelectors(usage);
|
||
this.renderModelPriceOptions(usage);
|
||
this.renderSavedModelPrices();
|
||
|
||
// 读取当前图表周期
|
||
const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active');
|
||
const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active');
|
||
const costHourActive = document.getElementById('cost-hour-btn')?.classList.contains('active');
|
||
const requestsPeriod = requestsHourActive ? 'hour' : 'day';
|
||
const tokensPeriod = tokensHourActive ? 'hour' : 'day';
|
||
const costPeriod = costHourActive ? 'hour' : 'day';
|
||
|
||
// 初始化图表(使用当前周期)
|
||
this.initializeRequestsChart(requestsPeriod);
|
||
this.initializeTokensChart(tokensPeriod);
|
||
this.updateCostSummaryAndChart(usage, costPeriod);
|
||
|
||
// 更新API详细统计表格
|
||
this.updateApiStatsTable(usage);
|
||
|
||
} catch (error) {
|
||
console.error('加载使用统计失败:', error);
|
||
this.currentUsageData = null;
|
||
this.updateChartLineSelectors(null);
|
||
this.ensureModelPriceState();
|
||
this.renderModelPriceOptions(null);
|
||
this.renderSavedModelPrices();
|
||
this.updateCostSummaryAndChart(null);
|
||
|
||
// 清空概览数据
|
||
['total-requests', 'success-requests', 'failed-requests', 'total-tokens', 'cached-tokens', 'reasoning-tokens', 'rpm-30m', 'tpm-30m'].forEach(id => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.textContent = '-';
|
||
});
|
||
|
||
// 清空图表
|
||
if (this.requestsChart) {
|
||
this.requestsChart.destroy();
|
||
this.requestsChart = null;
|
||
}
|
||
if (this.tokensChart) {
|
||
this.tokensChart.destroy();
|
||
this.tokensChart = null;
|
||
}
|
||
|
||
const tableElement = document.getElementById('api-stats-table');
|
||
if (tableElement) {
|
||
tableElement.innerHTML = `<div class="no-data-message">${i18n.t('usage_stats.loading_error')}: ${error.message}</div>`;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 更新使用统计概览
|
||
export function updateUsageOverview(data) {
|
||
const safeData = data || {};
|
||
document.getElementById('total-requests').textContent = safeData.total_requests ?? 0;
|
||
document.getElementById('success-requests').textContent = safeData.success_count ?? 0;
|
||
document.getElementById('failed-requests').textContent = safeData.failure_count ?? 0;
|
||
const totalTokensValue = safeData.total_tokens ?? 0;
|
||
document.getElementById('total-tokens').textContent = this.formatTokensInMillions(totalTokensValue);
|
||
|
||
const tokenBreakdown = this.calculateTokenBreakdown(safeData);
|
||
const cachedEl = document.getElementById('cached-tokens');
|
||
const reasoningEl = document.getElementById('reasoning-tokens');
|
||
if (cachedEl) {
|
||
cachedEl.textContent = this.formatTokensInMillions(tokenBreakdown.cachedTokens);
|
||
}
|
||
if (reasoningEl) {
|
||
reasoningEl.textContent = this.formatTokensInMillions(tokenBreakdown.reasoningTokens);
|
||
}
|
||
|
||
const recentRate = this.calculateRecentPerMinuteRates(30, safeData);
|
||
document.getElementById('rpm-30m').textContent = this.formatPerMinuteValue(recentRate.rpm);
|
||
document.getElementById('tpm-30m').textContent = this.formatPerMinuteValue(recentRate.tpm);
|
||
}
|
||
|
||
export function formatTokensInMillions(value) {
|
||
const num = Number(value);
|
||
if (!Number.isFinite(num)) {
|
||
return '0.00M';
|
||
}
|
||
return `${(num / 1_000_000).toFixed(2)}M`;
|
||
}
|
||
|
||
export function formatPerMinuteValue(value) {
|
||
const num = Number(value);
|
||
if (!Number.isFinite(num)) {
|
||
return '0.00';
|
||
}
|
||
const abs = Math.abs(num);
|
||
if (abs >= 1000) {
|
||
return Math.round(num).toLocaleString();
|
||
}
|
||
if (abs >= 100) {
|
||
return num.toFixed(0);
|
||
}
|
||
if (abs >= 10) {
|
||
return num.toFixed(1);
|
||
}
|
||
return num.toFixed(2);
|
||
}
|
||
|
||
export function getModelNamesFromUsage(usage) {
|
||
if (!usage) {
|
||
return [];
|
||
}
|
||
const apis = usage.apis || {};
|
||
const names = new Set();
|
||
Object.values(apis).forEach(apiEntry => {
|
||
const models = apiEntry.models || {};
|
||
Object.keys(models).forEach(modelName => {
|
||
if (modelName) {
|
||
names.add(modelName);
|
||
}
|
||
});
|
||
});
|
||
return Array.from(names).sort((a, b) => a.localeCompare(b));
|
||
}
|
||
|
||
export function getChartLineMaxCount() {
|
||
const idCount = Array.isArray(this.chartLineSelectIds) ? this.chartLineSelectIds.length : 0;
|
||
const configuredMax = Number(this.chartLineMaxCount);
|
||
const fallback = idCount || DEFAULT_CHART_LINE_COUNT;
|
||
const resolvedMax = Number.isFinite(configuredMax) ? configuredMax : fallback;
|
||
if (idCount > 0) {
|
||
return Math.max(MIN_CHART_LINE_COUNT, Math.min(resolvedMax, idCount));
|
||
}
|
||
return Math.max(MIN_CHART_LINE_COUNT, resolvedMax);
|
||
}
|
||
|
||
export function getVisibleChartLineCount() {
|
||
const maxCount = this.getChartLineMaxCount();
|
||
const stored = Number(this.chartLineVisibleCount);
|
||
const base = Number.isFinite(stored)
|
||
? stored
|
||
: (Array.isArray(this.chartLineSelections) ? this.chartLineSelections.length : DEFAULT_CHART_LINE_COUNT);
|
||
const resolved = Math.min(Math.max(base, MIN_CHART_LINE_COUNT), maxCount);
|
||
this.chartLineVisibleCount = resolved;
|
||
return resolved;
|
||
}
|
||
|
||
export function ensureChartLineSelectionLength(targetLength = null) {
|
||
const maxCount = this.getChartLineMaxCount();
|
||
const desiredLength = Math.min(
|
||
Math.max(targetLength ?? this.getVisibleChartLineCount(), MIN_CHART_LINE_COUNT),
|
||
maxCount
|
||
);
|
||
|
||
if (!Array.isArray(this.chartLineSelections)) {
|
||
this.chartLineSelections = Array(desiredLength).fill('none');
|
||
return this.chartLineSelections;
|
||
}
|
||
|
||
const trimmed = this.chartLineSelections.slice(0, maxCount);
|
||
if (trimmed.length < desiredLength) {
|
||
this.chartLineSelections = [...trimmed, ...Array(desiredLength - trimmed.length).fill('none')];
|
||
} else if (trimmed.length > desiredLength) {
|
||
this.chartLineSelections = trimmed.slice(0, desiredLength);
|
||
} else {
|
||
this.chartLineSelections = trimmed;
|
||
}
|
||
return this.chartLineSelections;
|
||
}
|
||
|
||
export function updateChartLineControlsUI() {
|
||
const maxCount = this.getChartLineMaxCount();
|
||
const visibleCount = this.getVisibleChartLineCount();
|
||
const counter = document.getElementById('chart-line-count');
|
||
if (counter) {
|
||
counter.textContent = `${visibleCount}/${maxCount}`;
|
||
}
|
||
const addBtn = document.getElementById('add-chart-line');
|
||
if (addBtn) {
|
||
addBtn.disabled = visibleCount >= maxCount;
|
||
}
|
||
const deleteButtons = document.querySelectorAll('.chart-line-delete');
|
||
if (deleteButtons.length) {
|
||
deleteButtons.forEach(button => {
|
||
const group = button.closest('.chart-line-group');
|
||
const index = Number.parseInt(button.getAttribute('data-line-index'), 10);
|
||
const isVisible = group
|
||
? !group.classList.contains('chart-line-hidden')
|
||
: (Number.isFinite(index) ? index < visibleCount : true);
|
||
button.disabled = visibleCount <= MIN_CHART_LINE_COUNT || !isVisible;
|
||
});
|
||
}
|
||
}
|
||
|
||
export function setChartLineVisibleCount(count) {
|
||
const maxCount = this.getChartLineMaxCount();
|
||
const nextCount = Math.min(Math.max(count, MIN_CHART_LINE_COUNT), maxCount);
|
||
const current = this.getVisibleChartLineCount();
|
||
if (nextCount === current) {
|
||
this.updateChartLineControlsUI();
|
||
return;
|
||
}
|
||
this.chartLineVisibleCount = nextCount;
|
||
this.ensureChartLineSelectionLength(nextCount);
|
||
this.updateChartLineSelectors(this.currentUsageData);
|
||
this.refreshChartsForSelections();
|
||
}
|
||
|
||
export function changeChartLineCount(delta = 0) {
|
||
const current = this.getVisibleChartLineCount();
|
||
this.setChartLineVisibleCount(current + delta);
|
||
}
|
||
|
||
export function removeChartLine(index) {
|
||
const visibleCount = this.getVisibleChartLineCount();
|
||
const normalizedIndex = Number.parseInt(index, 10);
|
||
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= visibleCount) {
|
||
return;
|
||
}
|
||
if (visibleCount <= MIN_CHART_LINE_COUNT) {
|
||
return;
|
||
}
|
||
const nextSelections = this.ensureChartLineSelectionLength(visibleCount).slice(0, visibleCount);
|
||
nextSelections.splice(normalizedIndex, 1);
|
||
this.chartLineSelections = nextSelections;
|
||
this.chartLineVisibleCount = Math.max(MIN_CHART_LINE_COUNT, visibleCount - 1);
|
||
this.updateChartLineSelectors(this.currentUsageData);
|
||
this.refreshChartsForSelections();
|
||
}
|
||
|
||
export function updateChartLineSelectors(usage) {
|
||
const modelNames = this.getModelNamesFromUsage(usage);
|
||
const selectors = this.chartLineSelectIds
|
||
.map(id => document.getElementById(id))
|
||
.filter(Boolean);
|
||
|
||
const availableCount = selectors.length || this.getChartLineMaxCount();
|
||
const visibleCount = Math.min(this.getVisibleChartLineCount(), availableCount);
|
||
this.chartLineVisibleCount = visibleCount;
|
||
this.ensureChartLineSelectionLength(visibleCount);
|
||
const wasInitialized = this.chartLineSelectionsInitialized === true;
|
||
|
||
if (!selectors.length) {
|
||
this.chartLineSelections = Array(visibleCount).fill('none');
|
||
this.chartLineSelectionsInitialized = false;
|
||
this.updateChartLineControlsUI();
|
||
return;
|
||
}
|
||
|
||
const optionsFragment = () => {
|
||
const fragment = document.createDocumentFragment();
|
||
const allOption = document.createElement('option');
|
||
allOption.value = ALL_MODELS_VALUE;
|
||
allOption.textContent = i18n.t('usage_stats.chart_line_all');
|
||
fragment.appendChild(allOption);
|
||
modelNames.forEach(name => {
|
||
const option = document.createElement('option');
|
||
option.value = name;
|
||
option.textContent = name;
|
||
fragment.appendChild(option);
|
||
});
|
||
return fragment;
|
||
};
|
||
|
||
const hasModels = modelNames.length > 0;
|
||
selectors.forEach((select, index) => {
|
||
const group = select.closest('.chart-line-group');
|
||
const isVisible = index < visibleCount;
|
||
if (group) {
|
||
group.classList.toggle('chart-line-hidden', !isVisible);
|
||
}
|
||
const deleteBtn = group ? group.querySelector('.chart-line-delete') : null;
|
||
select.innerHTML = '';
|
||
select.appendChild(optionsFragment());
|
||
select.disabled = !isVisible;
|
||
if (deleteBtn) {
|
||
deleteBtn.disabled = !isVisible || visibleCount <= MIN_CHART_LINE_COUNT;
|
||
}
|
||
if (!isVisible) {
|
||
select.value = ALL_MODELS_VALUE;
|
||
}
|
||
});
|
||
|
||
if (!hasModels) {
|
||
this.chartLineSelections = Array(visibleCount).fill(ALL_MODELS_VALUE);
|
||
this.chartLineSelectionsInitialized = false;
|
||
selectors.forEach((select, index) => {
|
||
const group = select.closest('.chart-line-group');
|
||
if (group) {
|
||
group.classList.toggle('chart-line-hidden', index >= visibleCount);
|
||
}
|
||
select.value = ALL_MODELS_VALUE;
|
||
});
|
||
this.updateChartLineControlsUI();
|
||
return;
|
||
}
|
||
|
||
const nextSelections = this.ensureChartLineSelectionLength(visibleCount).slice(0, visibleCount);
|
||
|
||
const validNames = new Set([...modelNames, ALL_MODELS_VALUE]);
|
||
let hasActiveSelection = false;
|
||
for (let i = 0; i < nextSelections.length; i++) {
|
||
const selection = nextSelections[i];
|
||
if (selection && selection !== 'none' && !validNames.has(selection)) {
|
||
nextSelections[i] = ALL_MODELS_VALUE;
|
||
}
|
||
if (nextSelections[i] && nextSelections[i] !== 'none') {
|
||
hasActiveSelection = true;
|
||
}
|
||
}
|
||
|
||
const allSelectionsAreAll = nextSelections.length > 0 && nextSelections.every(value => value === ALL_MODELS_VALUE);
|
||
|
||
if (!hasActiveSelection || (!wasInitialized && allSelectionsAreAll)) {
|
||
modelNames.slice(0, nextSelections.length).forEach((name, index) => {
|
||
nextSelections[index] = name;
|
||
});
|
||
}
|
||
|
||
for (let i = 0; i < nextSelections.length; i++) {
|
||
if (!nextSelections[i] || nextSelections[i] === 'none') {
|
||
nextSelections[i] = modelNames[i % Math.max(modelNames.length, 1)] || ALL_MODELS_VALUE;
|
||
}
|
||
}
|
||
|
||
this.chartLineSelections = nextSelections;
|
||
selectors.forEach((select, index) => {
|
||
const value = this.chartLineSelections[index] || ALL_MODELS_VALUE;
|
||
select.value = index < visibleCount ? value : ALL_MODELS_VALUE;
|
||
});
|
||
this.chartLineSelectionsInitialized = hasModels;
|
||
this.updateChartLineControlsUI();
|
||
}
|
||
|
||
export function handleChartLineSelectionChange(index, value) {
|
||
const visibleCount = this.getVisibleChartLineCount();
|
||
if (index < 0 || index >= visibleCount) {
|
||
return;
|
||
}
|
||
this.ensureChartLineSelectionLength(visibleCount);
|
||
const normalized = (value && value !== 'none') ? value : ALL_MODELS_VALUE;
|
||
if (this.chartLineSelections[index] === normalized) {
|
||
return;
|
||
}
|
||
this.chartLineSelections[index] = normalized;
|
||
this.refreshChartsForSelections();
|
||
}
|
||
|
||
export function refreshChartsForSelections() {
|
||
if (!this.currentUsageData) {
|
||
return;
|
||
}
|
||
const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active');
|
||
const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active');
|
||
const requestsPeriod = requestsHourActive ? 'hour' : 'day';
|
||
const tokensPeriod = tokensHourActive ? 'hour' : 'day';
|
||
|
||
if (this.requestsChart) {
|
||
this.requestsChart.data = this.getRequestsChartData(requestsPeriod);
|
||
this.requestsChart.update();
|
||
} else {
|
||
this.initializeRequestsChart(requestsPeriod);
|
||
}
|
||
|
||
if (this.tokensChart) {
|
||
this.tokensChart.data = this.getTokensChartData(tokensPeriod);
|
||
this.tokensChart.update();
|
||
} else {
|
||
this.initializeTokensChart(tokensPeriod);
|
||
}
|
||
}
|
||
|
||
export function getActiveChartLineSelections() {
|
||
const visibleCount = this.getVisibleChartLineCount();
|
||
const selections = this.ensureChartLineSelectionLength(visibleCount).slice(0, visibleCount);
|
||
return selections
|
||
.map((value, index) => ({ model: value, index }))
|
||
.filter(item => item.model && item.model !== 'none');
|
||
}
|
||
|
||
// 收集所有请求明细,供图表等复用
|
||
export function collectUsageDetailsFromUsage(usage) {
|
||
if (!usage) {
|
||
return [];
|
||
}
|
||
const apis = usage.apis || {};
|
||
const details = [];
|
||
Object.values(apis).forEach(apiEntry => {
|
||
const models = apiEntry.models || {};
|
||
Object.entries(models).forEach(([modelName, modelEntry]) => {
|
||
const modelDetails = Array.isArray(modelEntry.details) ? modelEntry.details : [];
|
||
modelDetails.forEach(detail => {
|
||
if (detail && detail.timestamp) {
|
||
details.push({
|
||
...detail,
|
||
__modelName: modelName
|
||
});
|
||
}
|
||
});
|
||
});
|
||
});
|
||
return details;
|
||
}
|
||
|
||
export function collectUsageDetails() {
|
||
return this.collectUsageDetailsFromUsage(this.currentUsageData);
|
||
}
|
||
|
||
export function migrateLegacyModelPrices() {
|
||
try {
|
||
if (typeof localStorage === 'undefined') {
|
||
return;
|
||
}
|
||
const storageKey = this.modelPriceStorageKey || DEFAULT_MODEL_PRICE_STORAGE_KEY;
|
||
const hasCurrent = localStorage.getItem(storageKey);
|
||
const legacyRaw = localStorage.getItem(LEGACY_MODEL_PRICE_STORAGE_KEY);
|
||
|
||
if (!legacyRaw || hasCurrent) {
|
||
return;
|
||
}
|
||
|
||
const parsed = JSON.parse(legacyRaw);
|
||
if (!parsed || typeof parsed !== 'object') {
|
||
return;
|
||
}
|
||
|
||
const migrated = {};
|
||
Object.entries(parsed).forEach(([model, price]) => {
|
||
if (!model) return;
|
||
const prompt = Number(price?.prompt);
|
||
const completion = Number(price?.completion);
|
||
const hasPrompt = Number.isFinite(prompt);
|
||
const hasCompletion = Number.isFinite(completion);
|
||
if (!hasPrompt && !hasCompletion) {
|
||
return;
|
||
}
|
||
migrated[model] = {
|
||
prompt: hasPrompt && prompt >= 0 ? prompt * 1000 : 0,
|
||
completion: hasCompletion && completion >= 0 ? completion * 1000 : 0
|
||
};
|
||
});
|
||
|
||
if (Object.keys(migrated).length) {
|
||
localStorage.setItem(storageKey, JSON.stringify(migrated));
|
||
}
|
||
localStorage.removeItem(LEGACY_MODEL_PRICE_STORAGE_KEY);
|
||
} catch (error) {
|
||
console.warn('迁移模型价格失败:', error);
|
||
}
|
||
}
|
||
|
||
export function ensureModelPriceState() {
|
||
if (this.modelPriceInitialized) {
|
||
return;
|
||
}
|
||
this.modelPriceStorageKey = this.modelPriceStorageKey || DEFAULT_MODEL_PRICE_STORAGE_KEY;
|
||
this.migrateLegacyModelPrices();
|
||
this.modelPrices = this.loadModelPricesFromStorage();
|
||
this.modelPriceInitialized = true;
|
||
}
|
||
|
||
export function loadModelPricesFromStorage() {
|
||
const storageKey = this.modelPriceStorageKey || DEFAULT_MODEL_PRICE_STORAGE_KEY;
|
||
try {
|
||
if (typeof localStorage === 'undefined') {
|
||
return {};
|
||
}
|
||
const raw = localStorage.getItem(storageKey);
|
||
if (!raw) {
|
||
return {};
|
||
}
|
||
const parsed = JSON.parse(raw);
|
||
if (!parsed || typeof parsed !== 'object') {
|
||
return {};
|
||
}
|
||
const normalized = {};
|
||
Object.entries(parsed).forEach(([model, price]) => {
|
||
if (!model) return;
|
||
const prompt = Number(price?.prompt);
|
||
const completion = Number(price?.completion);
|
||
if (!Number.isFinite(prompt) && !Number.isFinite(completion)) {
|
||
return;
|
||
}
|
||
normalized[model] = {
|
||
prompt: Number.isFinite(prompt) && prompt >= 0 ? prompt : 0,
|
||
completion: Number.isFinite(completion) && completion >= 0 ? completion : 0
|
||
};
|
||
});
|
||
return normalized;
|
||
} catch (error) {
|
||
console.warn('读取模型价格失败:', error);
|
||
return {};
|
||
}
|
||
}
|
||
|
||
export function persistModelPrices(prices = {}) {
|
||
const storageKey = this.modelPriceStorageKey || DEFAULT_MODEL_PRICE_STORAGE_KEY;
|
||
this.modelPrices = prices;
|
||
try {
|
||
if (typeof localStorage === 'undefined') {
|
||
return;
|
||
}
|
||
localStorage.setItem(storageKey, JSON.stringify(prices));
|
||
} catch (error) {
|
||
console.warn('保存模型价格失败:', error);
|
||
}
|
||
}
|
||
|
||
export function renderModelPriceOptions(usage = null) {
|
||
const select = document.getElementById('model-price-model-select');
|
||
if (!select) return;
|
||
const models = this.getModelNamesFromUsage(usage);
|
||
const previousValue = select.value;
|
||
select.innerHTML = '';
|
||
|
||
const placeholderOption = document.createElement('option');
|
||
placeholderOption.value = '';
|
||
placeholderOption.textContent = i18n.t('usage_stats.model_price_select_placeholder');
|
||
select.appendChild(placeholderOption);
|
||
|
||
models.forEach(name => {
|
||
const option = document.createElement('option');
|
||
option.value = name;
|
||
option.textContent = name;
|
||
select.appendChild(option);
|
||
});
|
||
|
||
select.disabled = models.length === 0;
|
||
if (models.includes(previousValue)) {
|
||
select.value = previousValue;
|
||
} else {
|
||
select.value = '';
|
||
}
|
||
this.prefillModelPriceInputs();
|
||
}
|
||
|
||
export function renderSavedModelPrices() {
|
||
const container = document.getElementById('model-price-list');
|
||
if (!container) return;
|
||
const entries = Object.entries(this.modelPrices || {});
|
||
if (!entries.length) {
|
||
container.innerHTML = `<div class="no-data-message">${i18n.t('usage_stats.model_price_empty')}</div>`;
|
||
return;
|
||
}
|
||
|
||
const rows = entries.map(([model, price]) => {
|
||
const prompt = Number(price?.prompt) || 0;
|
||
const completion = Number(price?.completion) || 0;
|
||
return `
|
||
<div class="model-price-row">
|
||
<span class="model-name">${model}</span>
|
||
<span>$${prompt.toFixed(4)} / 1M</span>
|
||
<span>$${completion.toFixed(4)} / 1M</span>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
container.innerHTML = `
|
||
<div class="model-price-table">
|
||
<div class="model-price-header">
|
||
<span>${i18n.t('usage_stats.model_price_model')}</span>
|
||
<span>${i18n.t('usage_stats.model_price_prompt')}</span>
|
||
<span>${i18n.t('usage_stats.model_price_completion')}</span>
|
||
</div>
|
||
${rows}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
export function prefillModelPriceInputs() {
|
||
const select = document.getElementById('model-price-model-select');
|
||
const promptInput = document.getElementById('model-price-prompt');
|
||
const completionInput = document.getElementById('model-price-completion');
|
||
if (!select || !promptInput || !completionInput) {
|
||
return;
|
||
}
|
||
const model = (select.value || '').trim();
|
||
const price = this.modelPrices?.[model];
|
||
if (price) {
|
||
promptInput.value = Number.isFinite(price.prompt) ? price.prompt : '';
|
||
completionInput.value = Number.isFinite(price.completion) ? price.completion : '';
|
||
} else {
|
||
promptInput.value = '';
|
||
completionInput.value = '';
|
||
}
|
||
}
|
||
|
||
export function normalizePriceValue(value) {
|
||
const parsed = Number(value);
|
||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||
return 0;
|
||
}
|
||
return Number(parsed.toFixed(6));
|
||
}
|
||
|
||
export function handleModelPriceSubmit() {
|
||
this.ensureModelPriceState();
|
||
const select = document.getElementById('model-price-model-select');
|
||
const promptInput = document.getElementById('model-price-prompt');
|
||
const completionInput = document.getElementById('model-price-completion');
|
||
if (!select || !promptInput || !completionInput) {
|
||
return;
|
||
}
|
||
|
||
const model = (select.value || '').trim();
|
||
if (!model) {
|
||
this.showNotification(i18n.t('usage_stats.model_price_model_required'), 'warning');
|
||
return;
|
||
}
|
||
const prompt = this.normalizePriceValue(promptInput.value);
|
||
const completion = this.normalizePriceValue(completionInput.value);
|
||
|
||
const next = { ...(this.modelPrices || {}) };
|
||
next[model] = { prompt, completion };
|
||
this.persistModelPrices(next);
|
||
this.renderSavedModelPrices();
|
||
this.updateCostSummaryAndChart(this.currentUsageData, this.getCostChartPeriod());
|
||
this.showNotification(i18n.t('usage_stats.model_price_saved'), 'success');
|
||
}
|
||
|
||
export function handleModelPriceReset() {
|
||
this.persistModelPrices({});
|
||
if (typeof localStorage !== 'undefined') {
|
||
const key = this.modelPriceStorageKey || DEFAULT_MODEL_PRICE_STORAGE_KEY;
|
||
try {
|
||
localStorage.removeItem(key);
|
||
localStorage.removeItem(LEGACY_MODEL_PRICE_STORAGE_KEY);
|
||
} catch (error) {
|
||
console.warn('清除模型价格失败:', error);
|
||
}
|
||
}
|
||
this.renderSavedModelPrices();
|
||
this.prefillModelPriceInputs();
|
||
this.updateCostSummaryAndChart(this.currentUsageData, this.getCostChartPeriod());
|
||
}
|
||
|
||
export function calculateTokenBreakdown(usage = null) {
|
||
const details = this.collectUsageDetailsFromUsage(usage || this.currentUsageData);
|
||
if (!details.length) {
|
||
return { cachedTokens: 0, reasoningTokens: 0 };
|
||
}
|
||
|
||
let cachedTokens = 0;
|
||
let reasoningTokens = 0;
|
||
|
||
details.forEach(detail => {
|
||
const tokens = detail?.tokens || {};
|
||
if (typeof tokens.cached_tokens === 'number') {
|
||
cachedTokens += tokens.cached_tokens;
|
||
}
|
||
if (typeof tokens.reasoning_tokens === 'number') {
|
||
reasoningTokens += tokens.reasoning_tokens;
|
||
}
|
||
});
|
||
|
||
return { cachedTokens, reasoningTokens };
|
||
}
|
||
|
||
export function calculateRecentPerMinuteRates(windowMinutes = 30, usage = null) {
|
||
const details = this.collectUsageDetailsFromUsage(usage || this.currentUsageData);
|
||
const effectiveWindow = Number.isFinite(windowMinutes) && windowMinutes > 0
|
||
? windowMinutes
|
||
: 30;
|
||
|
||
if (!details.length) {
|
||
return { rpm: 0, tpm: 0, windowMinutes: effectiveWindow, requestCount: 0, tokenCount: 0 };
|
||
}
|
||
|
||
const now = Date.now();
|
||
const windowStart = now - effectiveWindow * 60 * 1000;
|
||
let requestCount = 0;
|
||
let tokenCount = 0;
|
||
|
||
details.forEach(detail => {
|
||
const timestamp = Date.parse(detail.timestamp);
|
||
if (Number.isNaN(timestamp) || timestamp < windowStart) {
|
||
return;
|
||
}
|
||
requestCount += 1;
|
||
tokenCount += this.extractTotalTokens(detail);
|
||
});
|
||
|
||
const denominator = effectiveWindow > 0 ? effectiveWindow : 1;
|
||
return {
|
||
rpm: requestCount / denominator,
|
||
tpm: tokenCount / denominator,
|
||
windowMinutes: effectiveWindow,
|
||
requestCount,
|
||
tokenCount
|
||
};
|
||
}
|
||
|
||
export function createHourlyBucketMeta() {
|
||
const hourMs = 60 * 60 * 1000;
|
||
const now = new Date();
|
||
const currentHour = new Date(now);
|
||
currentHour.setMinutes(0, 0, 0);
|
||
|
||
const earliestBucket = new Date(currentHour);
|
||
earliestBucket.setHours(earliestBucket.getHours() - 23);
|
||
const earliestTime = earliestBucket.getTime();
|
||
const labels = [];
|
||
for (let i = 0; i < 24; i++) {
|
||
const bucketStart = earliestTime + i * hourMs;
|
||
labels.push(this.formatHourLabel(new Date(bucketStart)));
|
||
}
|
||
|
||
return {
|
||
labels,
|
||
earliestTime,
|
||
bucketSize: hourMs,
|
||
lastBucketTime: earliestTime + (labels.length - 1) * hourMs
|
||
};
|
||
}
|
||
|
||
export function buildHourlySeriesByModel(metric = 'requests') {
|
||
const meta = this.createHourlyBucketMeta();
|
||
const details = this.collectUsageDetails();
|
||
const dataByModel = new Map();
|
||
let hasData = false;
|
||
|
||
if (!details.length) {
|
||
return { labels: meta.labels, dataByModel, hasData };
|
||
}
|
||
|
||
details.forEach(detail => {
|
||
const timestamp = Date.parse(detail.timestamp);
|
||
if (Number.isNaN(timestamp)) {
|
||
return;
|
||
}
|
||
|
||
const normalized = new Date(timestamp);
|
||
normalized.setMinutes(0, 0, 0);
|
||
const bucketStart = normalized.getTime();
|
||
if (bucketStart < meta.earliestTime || bucketStart > meta.lastBucketTime) {
|
||
return;
|
||
}
|
||
|
||
const bucketIndex = Math.floor((bucketStart - meta.earliestTime) / meta.bucketSize);
|
||
if (bucketIndex < 0 || bucketIndex >= meta.labels.length) {
|
||
return;
|
||
}
|
||
|
||
const modelName = detail.__modelName || 'Unknown';
|
||
if (!dataByModel.has(modelName)) {
|
||
dataByModel.set(modelName, new Array(meta.labels.length).fill(0));
|
||
}
|
||
|
||
const bucketValues = dataByModel.get(modelName);
|
||
if (metric === 'tokens') {
|
||
bucketValues[bucketIndex] += this.extractTotalTokens(detail);
|
||
} else {
|
||
bucketValues[bucketIndex] += 1;
|
||
}
|
||
hasData = true;
|
||
});
|
||
|
||
return { labels: meta.labels, dataByModel, hasData };
|
||
}
|
||
|
||
export function buildDailySeriesByModel(metric = 'requests') {
|
||
const details = this.collectUsageDetails();
|
||
const valuesByModel = new Map();
|
||
const labelsSet = new Set();
|
||
let hasData = false;
|
||
|
||
if (!details.length) {
|
||
return { labels: [], dataByModel: new Map(), hasData };
|
||
}
|
||
|
||
details.forEach(detail => {
|
||
const timestamp = Date.parse(detail.timestamp);
|
||
if (Number.isNaN(timestamp)) {
|
||
return;
|
||
}
|
||
const dayLabel = this.formatDayLabel(new Date(timestamp));
|
||
if (!dayLabel) {
|
||
return;
|
||
}
|
||
|
||
const modelName = detail.__modelName || 'Unknown';
|
||
if (!valuesByModel.has(modelName)) {
|
||
valuesByModel.set(modelName, new Map());
|
||
}
|
||
const modelDayMap = valuesByModel.get(modelName);
|
||
const increment = metric === 'tokens' ? this.extractTotalTokens(detail) : 1;
|
||
modelDayMap.set(dayLabel, (modelDayMap.get(dayLabel) || 0) + increment);
|
||
labelsSet.add(dayLabel);
|
||
hasData = true;
|
||
});
|
||
|
||
const labels = Array.from(labelsSet).sort();
|
||
const dataByModel = new Map();
|
||
valuesByModel.forEach((dayMap, modelName) => {
|
||
const series = labels.map(label => dayMap.get(label) || 0);
|
||
dataByModel.set(modelName, series);
|
||
});
|
||
|
||
return { labels, dataByModel, hasData };
|
||
}
|
||
|
||
export function buildChartDataForMetric(period = 'day', metric = 'requests') {
|
||
const baseSeries = period === 'hour'
|
||
? this.buildHourlySeriesByModel(metric)
|
||
: this.buildDailySeriesByModel(metric);
|
||
|
||
const labels = baseSeries?.labels || [];
|
||
const dataByModel = baseSeries?.dataByModel || new Map();
|
||
const activeSelections = this.getActiveChartLineSelections();
|
||
let allSeriesCache = null;
|
||
|
||
const getAllSeries = () => {
|
||
if (allSeriesCache) {
|
||
return allSeriesCache;
|
||
}
|
||
const summed = new Array(labels.length).fill(0);
|
||
dataByModel.forEach(values => {
|
||
values.forEach((value, idx) => {
|
||
summed[idx] = (summed[idx] || 0) + value;
|
||
});
|
||
});
|
||
allSeriesCache = summed;
|
||
return summed;
|
||
};
|
||
|
||
const getSeriesForSelection = (selectionValue) => {
|
||
if (selectionValue === ALL_MODELS_VALUE) {
|
||
return getAllSeries();
|
||
}
|
||
return dataByModel.get(selectionValue) || new Array(labels.length).fill(0);
|
||
};
|
||
|
||
const datasets = activeSelections.map(selection => {
|
||
const values = getSeriesForSelection(selection.model);
|
||
const style = this.chartLineStyles[selection.index % this.chartLineStyles.length] || this.chartLineStyles[0];
|
||
const label = selection.model === ALL_MODELS_VALUE
|
||
? i18n.t('usage_stats.chart_line_all')
|
||
: selection.model;
|
||
return {
|
||
label,
|
||
data: values,
|
||
borderColor: style.borderColor,
|
||
backgroundColor: style.backgroundColor,
|
||
fill: false,
|
||
tension: 0.35,
|
||
pointBackgroundColor: style.borderColor,
|
||
pointBorderColor: '#ffffff',
|
||
pointBorderWidth: 2,
|
||
pointRadius: values.some(v => v > 0) ? 4 : 3
|
||
};
|
||
});
|
||
|
||
return { labels, datasets };
|
||
}
|
||
|
||
// 统一格式化小时标签
|
||
export function formatHourLabel(date) {
|
||
if (!(date instanceof Date)) {
|
||
return '';
|
||
}
|
||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||
const day = date.getDate().toString().padStart(2, '0');
|
||
const hour = date.getHours().toString().padStart(2, '0');
|
||
return `${month}-${day} ${hour}:00`;
|
||
}
|
||
|
||
export function formatDayLabel(date) {
|
||
if (!(date instanceof Date)) {
|
||
return '';
|
||
}
|
||
const year = date.getFullYear();
|
||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||
const day = date.getDate().toString().padStart(2, '0');
|
||
return `${year}-${month}-${day}`;
|
||
}
|
||
|
||
export function extractTotalTokens(detail) {
|
||
const tokens = detail?.tokens || {};
|
||
if (typeof tokens.total_tokens === 'number') {
|
||
return tokens.total_tokens;
|
||
}
|
||
const tokenKeys = ['input_tokens', 'output_tokens', 'reasoning_tokens', 'cached_tokens'];
|
||
return tokenKeys.reduce((sum, key) => {
|
||
const value = tokens[key];
|
||
return sum + (typeof value === 'number' ? value : 0);
|
||
}, 0);
|
||
}
|
||
|
||
export function formatUsd(value) {
|
||
const num = Number(value);
|
||
if (!Number.isFinite(num)) {
|
||
return '$0.00';
|
||
}
|
||
const fixed = num.toFixed(2);
|
||
const parts = Number(fixed).toLocaleString(undefined, {
|
||
minimumFractionDigits: 2,
|
||
maximumFractionDigits: 2
|
||
});
|
||
return `$${parts}`;
|
||
}
|
||
|
||
export function calculateCostData(prices = null, usage = null, period = 'day') {
|
||
const priceTable = prices || this.modelPrices || {};
|
||
const usagePayload = usage || this.currentUsageData;
|
||
const entries = Object.entries(priceTable || {});
|
||
const result = { totalCost: 0, labels: [], datasets: [] };
|
||
|
||
if (!entries.length || !usagePayload) {
|
||
return result;
|
||
}
|
||
|
||
const details = this.collectUsageDetailsFromUsage(usagePayload);
|
||
if (!details.length) {
|
||
return result;
|
||
}
|
||
|
||
const normalizedDetails = details.map(detail => {
|
||
const modelName = detail.__modelName || 'Unknown';
|
||
const price = priceTable[modelName];
|
||
if (!price) {
|
||
return null;
|
||
}
|
||
|
||
const tokens = detail?.tokens || {};
|
||
const promptTokens = Number(tokens.input_tokens) || 0;
|
||
const completionTokens = Number(tokens.output_tokens) || 0;
|
||
const promptCost = (promptTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.prompt) || 0);
|
||
const completionCost = (completionTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.completion) || 0);
|
||
const detailCost = promptCost + completionCost;
|
||
const parsedTimestamp = Date.parse(detail.timestamp);
|
||
|
||
if (!Number.isFinite(detailCost) || detailCost <= 0 || Number.isNaN(parsedTimestamp)) {
|
||
return null;
|
||
}
|
||
|
||
return { modelName, cost: detailCost, timestamp: parsedTimestamp };
|
||
}).filter(Boolean);
|
||
|
||
if (!normalizedDetails.length) {
|
||
return result;
|
||
}
|
||
|
||
const totalCost = normalizedDetails.reduce((sum, item) => sum + item.cost, 0);
|
||
|
||
if (period === 'hour') {
|
||
const meta = this.createHourlyBucketMeta();
|
||
const dataByModel = new Map();
|
||
|
||
normalizedDetails.forEach(({ modelName, cost, timestamp }) => {
|
||
const normalized = new Date(timestamp);
|
||
normalized.setMinutes(0, 0, 0);
|
||
const bucketStart = normalized.getTime();
|
||
if (bucketStart < meta.earliestTime || bucketStart > meta.lastBucketTime) {
|
||
return;
|
||
}
|
||
const bucketIndex = Math.floor((bucketStart - meta.earliestTime) / meta.bucketSize);
|
||
if (bucketIndex < 0 || bucketIndex >= meta.labels.length) {
|
||
return;
|
||
}
|
||
if (!dataByModel.has(modelName)) {
|
||
dataByModel.set(modelName, new Array(meta.labels.length).fill(0));
|
||
}
|
||
const bucketValues = dataByModel.get(modelName);
|
||
bucketValues[bucketIndex] += cost;
|
||
});
|
||
|
||
const datasets = [];
|
||
dataByModel.forEach((series, modelName) => {
|
||
datasets.push({ label: modelName, data: series.map(value => Number(value.toFixed(4))) });
|
||
});
|
||
|
||
return { totalCost, labels: meta.labels, datasets };
|
||
}
|
||
|
||
const labelSet = new Set();
|
||
const costByModelDay = new Map();
|
||
|
||
normalizedDetails.forEach(({ modelName, cost, timestamp }) => {
|
||
const dayLabel = this.formatDayLabel(new Date(timestamp));
|
||
if (!dayLabel) {
|
||
return;
|
||
}
|
||
|
||
if (!costByModelDay.has(modelName)) {
|
||
costByModelDay.set(modelName, new Map());
|
||
}
|
||
const dayMap = costByModelDay.get(modelName);
|
||
dayMap.set(dayLabel, (dayMap.get(dayLabel) || 0) + cost);
|
||
labelSet.add(dayLabel);
|
||
});
|
||
|
||
const labels = Array.from(labelSet).sort();
|
||
const datasets = [];
|
||
costByModelDay.forEach((dayMap, modelName) => {
|
||
const series = labels.map(label => Number((dayMap.get(label) || 0).toFixed(4)));
|
||
datasets.push({ label: modelName, data: series });
|
||
});
|
||
|
||
return { totalCost, labels, datasets };
|
||
}
|
||
|
||
export function setCostChartPlaceholder(messageKey = null) {
|
||
const placeholder = document.getElementById('cost-chart-placeholder');
|
||
const canvas = document.getElementById('cost-chart');
|
||
if (!placeholder || !canvas) {
|
||
return;
|
||
}
|
||
if (messageKey) {
|
||
placeholder.textContent = i18n.t(messageKey);
|
||
placeholder.style.display = 'flex';
|
||
canvas.style.display = 'none';
|
||
} else {
|
||
placeholder.style.display = 'none';
|
||
canvas.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
export function destroyCostChart() {
|
||
if (this.costChart) {
|
||
this.costChart.destroy();
|
||
this.costChart = null;
|
||
}
|
||
}
|
||
|
||
export function initializeCostChart(costData, period = 'day') {
|
||
const canvas = document.getElementById('cost-chart');
|
||
if (!canvas) {
|
||
return;
|
||
}
|
||
this.destroyCostChart();
|
||
|
||
const datasets = (costData.datasets || []).map((dataset, index) => {
|
||
const style = this.chartLineStyles[index % this.chartLineStyles.length] || this.chartLineStyles[0];
|
||
return {
|
||
...dataset,
|
||
borderColor: style.borderColor,
|
||
backgroundColor: style.backgroundColor || 'rgba(59, 130, 246, 0.15)',
|
||
fill: true,
|
||
tension: 0.35,
|
||
pointBackgroundColor: style.borderColor,
|
||
pointBorderColor: '#ffffff',
|
||
pointBorderWidth: 2,
|
||
pointRadius: dataset.data.some(v => v > 0) ? 4 : 3
|
||
};
|
||
});
|
||
|
||
this.costChart = new Chart(canvas, {
|
||
type: 'line',
|
||
data: {
|
||
labels: costData.labels || [],
|
||
datasets
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
interaction: {
|
||
mode: 'index',
|
||
intersect: false
|
||
},
|
||
plugins: {
|
||
legend: {
|
||
display: true,
|
||
position: 'top',
|
||
align: 'start',
|
||
labels: {
|
||
usePointStyle: true
|
||
}
|
||
},
|
||
tooltip: {
|
||
callbacks: {
|
||
label: context => {
|
||
const label = context.dataset.label || '';
|
||
const value = Number(context.parsed.y) || 0;
|
||
return `${label}: ${this.formatUsd(value)}`;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
scales: {
|
||
x: {
|
||
title: {
|
||
display: true,
|
||
text: i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day')
|
||
}
|
||
},
|
||
y: {
|
||
beginAtZero: true,
|
||
title: {
|
||
display: true,
|
||
text: i18n.t('usage_stats.cost_axis_label')
|
||
},
|
||
ticks: {
|
||
callback: (value) => this.formatUsd(value).replace('$', '')
|
||
}
|
||
}
|
||
},
|
||
elements: {
|
||
line: {
|
||
borderWidth: 2
|
||
},
|
||
point: {
|
||
borderWidth: 2
|
||
}
|
||
}
|
||
}
|
||
});
|
||
this.setCostChartPlaceholder(null);
|
||
}
|
||
|
||
export function getCostChartPeriod() {
|
||
const costHourActive = document.getElementById('cost-hour-btn')?.classList.contains('active');
|
||
return costHourActive ? 'hour' : 'day';
|
||
}
|
||
|
||
export function updateCostSummaryAndChart(usage = null, period = null) {
|
||
this.ensureModelPriceState();
|
||
const totalCostEl = document.getElementById('total-cost');
|
||
const hasPrices = Object.keys(this.modelPrices || {}).length > 0;
|
||
const usagePayload = usage || this.currentUsageData;
|
||
const resolvedPeriod = period || this.getCostChartPeriod();
|
||
|
||
if (!hasPrices) {
|
||
if (totalCostEl) {
|
||
totalCostEl.textContent = '--';
|
||
}
|
||
this.destroyCostChart();
|
||
this.setCostChartPlaceholder('usage_stats.cost_need_price');
|
||
return;
|
||
}
|
||
|
||
if (!usagePayload) {
|
||
if (totalCostEl) {
|
||
totalCostEl.textContent = '--';
|
||
}
|
||
this.destroyCostChart();
|
||
this.setCostChartPlaceholder('usage_stats.cost_need_usage');
|
||
return;
|
||
}
|
||
|
||
const costData = this.calculateCostData(this.modelPrices, usagePayload, resolvedPeriod);
|
||
if (totalCostEl) {
|
||
totalCostEl.textContent = this.formatUsd(costData.totalCost);
|
||
}
|
||
|
||
if (!costData.labels.length || !costData.datasets.length) {
|
||
this.destroyCostChart();
|
||
this.setCostChartPlaceholder('usage_stats.cost_no_data');
|
||
return;
|
||
}
|
||
|
||
this.initializeCostChart(costData, resolvedPeriod);
|
||
}
|
||
|
||
// 初始化图表
|
||
export function initializeCharts() {
|
||
const requestsHourActive = document.getElementById('requests-hour-btn')?.classList.contains('active');
|
||
const tokensHourActive = document.getElementById('tokens-hour-btn')?.classList.contains('active');
|
||
const costHourActive = document.getElementById('cost-hour-btn')?.classList.contains('active');
|
||
this.initializeRequestsChart(requestsHourActive ? 'hour' : 'day');
|
||
this.initializeTokensChart(tokensHourActive ? 'hour' : 'day');
|
||
this.updateCostSummaryAndChart(this.currentUsageData, costHourActive ? 'hour' : 'day');
|
||
}
|
||
|
||
// 初始化请求趋势图表
|
||
export function initializeRequestsChart(period = 'day') {
|
||
const ctx = document.getElementById('requests-chart');
|
||
if (!ctx) return;
|
||
|
||
// 销毁现有图表
|
||
if (this.requestsChart) {
|
||
this.requestsChart.destroy();
|
||
}
|
||
|
||
const data = this.getRequestsChartData(period);
|
||
|
||
this.requestsChart = new Chart(ctx, {
|
||
type: 'line',
|
||
data: data,
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
interaction: {
|
||
mode: 'index',
|
||
intersect: false
|
||
},
|
||
plugins: {
|
||
legend: {
|
||
display: true,
|
||
position: 'top',
|
||
align: 'start',
|
||
labels: {
|
||
usePointStyle: true
|
||
}
|
||
}
|
||
},
|
||
scales: {
|
||
x: {
|
||
title: {
|
||
display: true,
|
||
text: i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day')
|
||
}
|
||
},
|
||
y: {
|
||
beginAtZero: true,
|
||
title: {
|
||
display: true,
|
||
text: i18n.t('usage_stats.requests_count')
|
||
}
|
||
}
|
||
},
|
||
elements: {
|
||
line: {
|
||
tension: 0.35,
|
||
borderWidth: 2
|
||
},
|
||
point: {
|
||
borderWidth: 2,
|
||
radius: 4
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// 初始化Token使用趋势图表
|
||
export function initializeTokensChart(period = 'day') {
|
||
const ctx = document.getElementById('tokens-chart');
|
||
if (!ctx) return;
|
||
|
||
// 销毁现有图表
|
||
if (this.tokensChart) {
|
||
this.tokensChart.destroy();
|
||
}
|
||
|
||
const data = this.getTokensChartData(period);
|
||
|
||
this.tokensChart = new Chart(ctx, {
|
||
type: 'line',
|
||
data: data,
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
interaction: {
|
||
mode: 'index',
|
||
intersect: false
|
||
},
|
||
plugins: {
|
||
legend: {
|
||
display: true,
|
||
position: 'top',
|
||
align: 'start',
|
||
labels: {
|
||
usePointStyle: true
|
||
}
|
||
}
|
||
},
|
||
scales: {
|
||
x: {
|
||
title: {
|
||
display: true,
|
||
text: i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day')
|
||
}
|
||
},
|
||
y: {
|
||
beginAtZero: true,
|
||
title: {
|
||
display: true,
|
||
text: i18n.t('usage_stats.tokens_count')
|
||
}
|
||
}
|
||
},
|
||
elements: {
|
||
line: {
|
||
tension: 0.35,
|
||
borderWidth: 2
|
||
},
|
||
point: {
|
||
borderWidth: 2,
|
||
radius: 4
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// 获取请求图表数据
|
||
export function getRequestsChartData(period) {
|
||
if (!this.currentUsageData) {
|
||
return { labels: [], datasets: [] };
|
||
}
|
||
return this.buildChartDataForMetric(period, 'requests');
|
||
}
|
||
|
||
// 获取Token图表数据
|
||
export function getTokensChartData(period) {
|
||
if (!this.currentUsageData) {
|
||
return { labels: [], datasets: [] };
|
||
}
|
||
return this.buildChartDataForMetric(period, 'tokens');
|
||
}
|
||
|
||
// 切换请求图表时间周期
|
||
export function switchRequestsPeriod(period) {
|
||
// 更新按钮状态
|
||
document.getElementById('requests-hour-btn').classList.toggle('active', period === 'hour');
|
||
document.getElementById('requests-day-btn').classList.toggle('active', period === 'day');
|
||
|
||
// 更新图表数据
|
||
if (this.requestsChart) {
|
||
const newData = this.getRequestsChartData(period);
|
||
this.requestsChart.data = newData;
|
||
this.requestsChart.options.scales.x.title.text = i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day');
|
||
this.requestsChart.update();
|
||
}
|
||
}
|
||
|
||
// 切换Token图表时间周期
|
||
export function switchTokensPeriod(period) {
|
||
// 更新按钮状态
|
||
document.getElementById('tokens-hour-btn').classList.toggle('active', period === 'hour');
|
||
document.getElementById('tokens-day-btn').classList.toggle('active', period === 'day');
|
||
|
||
// 更新图表数据
|
||
if (this.tokensChart) {
|
||
const newData = this.getTokensChartData(period);
|
||
this.tokensChart.data = newData;
|
||
this.tokensChart.options.scales.x.title.text = i18n.t(period === 'hour' ? 'usage_stats.by_hour' : 'usage_stats.by_day');
|
||
this.tokensChart.update();
|
||
}
|
||
}
|
||
|
||
export function switchCostPeriod(period) {
|
||
if (period !== 'hour' && period !== 'day') {
|
||
return;
|
||
}
|
||
const hourBtn = document.getElementById('cost-hour-btn');
|
||
const dayBtn = document.getElementById('cost-day-btn');
|
||
if (hourBtn && dayBtn) {
|
||
hourBtn.classList.toggle('active', period === 'hour');
|
||
dayBtn.classList.toggle('active', period === 'day');
|
||
}
|
||
|
||
this.updateCostSummaryAndChart(this.currentUsageData, period);
|
||
}
|
||
|
||
// 更新API详细统计表格
|
||
export function updateApiStatsTable(data) {
|
||
const container = document.getElementById('api-stats-table');
|
||
if (!container) return;
|
||
|
||
const apis = data.apis || {};
|
||
|
||
if (Object.keys(apis).length === 0) {
|
||
container.innerHTML = `<div class="no-data-message">${i18n.t('usage_stats.no_data')}</div>`;
|
||
return;
|
||
}
|
||
|
||
const hasPrices = Object.keys(this.modelPrices || {}).length > 0;
|
||
const calculateEndpointCost = (apiData) => {
|
||
if (!hasPrices) return 0;
|
||
let cost = 0;
|
||
const models = apiData.models || {};
|
||
Object.entries(models).forEach(([modelName, modelData]) => {
|
||
const price = this.modelPrices?.[modelName];
|
||
if (!price) return;
|
||
const details = Array.isArray(modelData.details) ? modelData.details : [];
|
||
details.forEach(detail => {
|
||
const tokens = detail?.tokens || {};
|
||
const promptTokens = Number(tokens.input_tokens) || 0;
|
||
const completionTokens = Number(tokens.output_tokens) || 0;
|
||
const detailCost = (promptTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.prompt) || 0)
|
||
+ (completionTokens / TOKENS_PER_PRICE_UNIT) * (Number(price.completion) || 0);
|
||
if (Number.isFinite(detailCost) && detailCost > 0) {
|
||
cost += detailCost;
|
||
}
|
||
});
|
||
});
|
||
return Number(cost.toFixed(4));
|
||
};
|
||
|
||
let tableHtml = `
|
||
<table class="stats-table">
|
||
<thead>
|
||
<tr>
|
||
<th>${i18n.t('usage_stats.api_endpoint')}</th>
|
||
<th>${i18n.t('usage_stats.requests_count')}</th>
|
||
<th>${i18n.t('usage_stats.tokens_count')}</th>
|
||
<th>${i18n.t('usage_stats.total_cost')}</th>
|
||
<th>${i18n.t('usage_stats.models')}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
`;
|
||
|
||
Object.entries(apis).forEach(([endpoint, apiData]) => {
|
||
const totalRequests = apiData.total_requests || 0;
|
||
const endpointCost = calculateEndpointCost(apiData);
|
||
const displayEndpoint = (this.maskUsageSensitiveValue
|
||
? this.maskUsageSensitiveValue(endpoint)
|
||
: (endpoint ?? '')) || '-';
|
||
const safeEndpoint = this.escapeHtml
|
||
? this.escapeHtml(displayEndpoint)
|
||
: displayEndpoint;
|
||
|
||
// 构建模型详情
|
||
let modelsHtml = '';
|
||
if (apiData.models && Object.keys(apiData.models).length > 0) {
|
||
modelsHtml = '<div class="model-details">';
|
||
Object.entries(apiData.models).forEach(([modelName, modelData]) => {
|
||
const safeModel = this.escapeHtml ? this.escapeHtml(modelName || '') : (modelName || '');
|
||
const modelRequests = modelData.total_requests ?? 0;
|
||
const modelTokens = this.formatTokensInMillions(modelData.total_tokens ?? 0);
|
||
modelsHtml += `
|
||
<div class="model-item">
|
||
<span class="model-name">${safeModel}</span>
|
||
<span>${modelRequests} 请求 / ${modelTokens} tokens</span>
|
||
</div>
|
||
`;
|
||
});
|
||
modelsHtml += '</div>';
|
||
}
|
||
|
||
tableHtml += `
|
||
<tr>
|
||
<td>${safeEndpoint}</td>
|
||
<td>${totalRequests}</td>
|
||
<td>${this.formatTokensInMillions(apiData.total_tokens || 0)}</td>
|
||
<td>${hasPrices && endpointCost > 0 ? this.formatUsd(endpointCost) : '--'}</td>
|
||
<td>${modelsHtml || '-'}</td>
|
||
</tr>
|
||
`;
|
||
});
|
||
|
||
tableHtml += '</tbody></table>';
|
||
container.innerHTML = tableHtml;
|
||
}
|
||
|
||
export const usageModule = {
|
||
getKeyStats,
|
||
loadUsageStats,
|
||
updateUsageOverview,
|
||
maskUsageSensitiveValue,
|
||
getModelNamesFromUsage,
|
||
getChartLineMaxCount,
|
||
getVisibleChartLineCount,
|
||
ensureChartLineSelectionLength,
|
||
updateChartLineControlsUI,
|
||
setChartLineVisibleCount,
|
||
changeChartLineCount,
|
||
removeChartLine,
|
||
updateChartLineSelectors,
|
||
handleChartLineSelectionChange,
|
||
refreshChartsForSelections,
|
||
getActiveChartLineSelections,
|
||
collectUsageDetailsFromUsage,
|
||
collectUsageDetails,
|
||
migrateLegacyModelPrices,
|
||
ensureModelPriceState,
|
||
loadModelPricesFromStorage,
|
||
persistModelPrices,
|
||
renderModelPriceOptions,
|
||
renderSavedModelPrices,
|
||
prefillModelPriceInputs,
|
||
normalizePriceValue,
|
||
handleModelPriceSubmit,
|
||
handleModelPriceReset,
|
||
calculateTokenBreakdown,
|
||
calculateRecentPerMinuteRates,
|
||
createHourlyBucketMeta,
|
||
buildHourlySeriesByModel,
|
||
buildDailySeriesByModel,
|
||
buildChartDataForMetric,
|
||
formatHourLabel,
|
||
formatTokensInMillions,
|
||
formatPerMinuteValue,
|
||
formatDayLabel,
|
||
extractTotalTokens,
|
||
formatUsd,
|
||
calculateCostData,
|
||
getCostChartPeriod,
|
||
setCostChartPlaceholder,
|
||
destroyCostChart,
|
||
initializeCostChart,
|
||
updateCostSummaryAndChart,
|
||
initializeCharts,
|
||
initializeRequestsChart,
|
||
initializeTokensChart,
|
||
getRequestsChartData,
|
||
getTokensChartData,
|
||
switchRequestsPeriod,
|
||
switchTokensPeriod,
|
||
switchCostPeriod,
|
||
updateApiStatsTable,
|
||
registerUsageListeners
|
||
};
|
||
|
||
// 订阅全局事件,基于配置加载结果渲染使用统计
|
||
export function registerUsageListeners() {
|
||
if (!this.events || typeof this.events.on !== 'function') {
|
||
return;
|
||
}
|
||
this.events.on('data:config-loaded', (event) => {
|
||
const detail = event?.detail || {};
|
||
const usageData = detail.usageData || null;
|
||
this.loadUsageStats(usageData);
|
||
});
|
||
}
|