// ==UserScript== // @name MiMo 平台用量增强统计 // @namespace http://tampermonkey.net/ // @version 1.0 // @description 在 xiaomimimo 用量统计页面增加 Token/Credits 消耗、费用、缓存命中率等指标 // @author Hermes // @match https://platform.xiaomimimo.com/console/plan-manage* // @grant none // @run-at document-start // ==/UserScript== (function () { 'use strict'; // ============ 配置 ============ const MODEL_RATES = { 'mimo-v2.5': { hit: 2, miss: 100, output: 200 }, 'mimo-v2.5-pro': { hit: 2.5, miss: 300, output: 600 }, 'mimo-v2-pro': { hit: 140, miss: 700, output: 2100 }, 'mimo-v2-omni': { hit: 56, miss: 280, output: 1400 }, }; const CREDITS_PER_YUAN = 100_000_000; const MODEL_COLORS = ['#6366f1','#f59e0b','#10b981','#ef4444','#8b5cf6','#06b6d4','#f97316','#ec4899']; // ============ 主题 ============ let isDark = true; function theme() { return isDark ? { // 暗色 = v5.3 原版配色 bg: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)', cardBg: 'rgba(255,255,255,0.04)', cardBorder: 'rgba(255,255,255,0.06)', modelBg: 'rgba(255,255,255,0.03)', modelBorder: 'rgba(255,255,255,0.06)', text: '#e0e0e0', textDim: '#888', textFaint: '#666', titleColor: '#fff', shadow: '0 4px 20px rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.08)', barText: '#aaa', barDate: '#666', chartBorder: 'rgba(255,255,255,0.08)', } : { // 亮色 bg: 'linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%)', cardBg: 'rgba(0,0,0,0.06)', cardBorder: 'rgba(0,0,0,0.08)', modelBg: 'rgba(0,0,0,0.04)', modelBorder: 'rgba(0,0,0,0.1)', text: '#1e293b', textDim: '#475569', textFaint: '#94a3b8', titleColor: '#0f172a', shadow: '0 4px 20px rgba(0,0,0,0.1)', border: '1px solid rgba(0,0,0,0.1)', barText: '#334155', barDate: '#94a3b8', chartBorder: 'rgba(0,0,0,0.1)', }; } function fmt(n) { if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B'; if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'; if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'; return n.toLocaleString(); } function pct(v) { return (v * 100).toFixed(1) + '%'; } function pct2(v) { return (v * 100).toFixed(2) + '%'; } function yuan(v) { return '¥' + v.toFixed(2); } function calcCredits(model, hit, miss, output) { const r = MODEL_RATES[model] || MODEL_RATES['mimo-v2.5']; return hit * r.hit + miss * r.miss + output * r.output; } let cachedUsage = null; let cachedList = null; let refreshInterval = 300000; // 5分钟 let refreshTimer = null; // ============ 真实数据刷新 ============ const API_BASE = 'https://platform.xiaomimimo.com'; // 捕获页面自身请求的完整URL(含api-platform_ph等参数),用于刷新时复用 let capturedUsageUrl = null; let capturedListUrl = null; async function fetchData() { // 优先用捕获的URL(含页面自带的认证参数) const usageUrl = capturedUsageUrl || (API_BASE + '/api/v1/tokenPlan/usage'); const listUrl = capturedListUrl; if (!listUrl) { console.warn('[MiMo Stats] 未捕获到 list 请求URL,等待页面自身请求...'); return; } const now = new Date(); const body = JSON.stringify({ year: now.getFullYear(), month: now.getMonth() + 1 }); try { const [usageRes, listRes] = await Promise.all([ origFetch(usageUrl, { credentials: 'include' }), origFetch(listUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, credentials: 'include', }), ]); const usageJson = await usageRes.json(); const listJson = await listRes.json(); if (usageJson.code === 0) cachedUsage = usageJson.data; if (listJson.code === 0) cachedList = listJson.data; } catch (e) { console.warn('[MiMo Stats] fetchData error:', e); } } // ============ 拦截 fetch ============ const origFetch = window.fetch; window.fetch = function (...args) { const result = origFetch.apply(this, args); const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || ''; if (url.includes('/api/v1/tokenPlan/usage')) { if (!capturedUsageUrl) capturedUsageUrl = url; // 捕获完整URL result.then(r => r.clone().json().then(j => { if (j.code === 0) { cachedUsage = j.data; tryInject(); } })).catch(() => {}); } if (url.includes('/api/v1/usage/token-plan/list')) { if (!capturedListUrl) { capturedListUrl = url; // 捕获完整URL console.log('[MiMo Stats] 捕获 list URL:', url); } result.then(r => r.clone().json().then(j => { if (j.code === 0) { cachedList = j.data; tryInject(); } })).catch(() => {}); } return result; }; // ============ 拦截 XMLHttpRequest ============ const origOpen = XMLHttpRequest.prototype.open; const origSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function (method, url, ...rest) { this._mimoUrl = url; return origOpen.call(this, method, url, ...rest); }; XMLHttpRequest.prototype.send = function (...args) { this.addEventListener('load', function () { try { const data = JSON.parse(this.responseText); if (this._mimoUrl?.includes('/api/v1/tokenPlan/usage') && data.code === 0) { if (!capturedUsageUrl) capturedUsageUrl = this._mimoUrl; cachedUsage = data.data; tryInject(); } if (this._mimoUrl?.includes('/api/v1/usage/token-plan/list') && data.code === 0) { if (!capturedListUrl) capturedListUrl = this._mimoUrl; cachedList = data.data; tryInject(); } } catch (e) { } }); return origSend.apply(this, args); }; // ============ 计算指标 ============ function computeMetrics(usageData, listData) { const m = {}; const planItem = usageData?.usage?.items?.find(i => i.name === 'plan_total_token'); const monthItem = usageData?.monthUsage?.items?.[0]; m.planLimit = planItem?.limit || monthItem?.limit || 0; m.planUsed = monthItem?.used || 0; m.planRemaining = m.planLimit - m.planUsed; const modelMap = {}; for (const d of listData) { if (!modelMap[d.model]) modelMap[d.model] = { hit: 0, miss: 0, output: 0, total: 0, reqs: 0 }; const m2 = modelMap[d.model]; m2.hit += d.inputHitToken; m2.miss += d.inputMissToken; m2.output += d.outputToken; m2.total += d.totalToken; m2.reqs += d.requestCount; } m.models = Object.entries(modelMap) .map(([name, data]) => ({ name, ...data, credits: calcCredits(name, data.hit, data.miss, data.output), hitRate: (data.hit + data.miss) > 0 ? data.hit / (data.hit + data.miss) : 0, })) .sort((a, b) => b.total - a.total); const today = new Date().toISOString().slice(0, 10); const todayItems = listData.filter(d => d.date === today); m.todayTotal = todayItems.reduce((s, d) => s + d.totalToken, 0); m.todayHit = todayItems.reduce((s, d) => s + d.inputHitToken, 0); m.todayMiss = todayItems.reduce((s, d) => s + d.inputMissToken, 0); m.todayOutput = todayItems.reduce((s, d) => s + d.outputToken, 0); m.todayReqs = todayItems.reduce((s, d) => s + d.requestCount, 0); m.todayCredits = todayItems.reduce((s, d) => s + calcCredits(d.model, d.inputHitToken, d.inputMissToken, d.outputToken), 0); m.todayHitRate = (m.todayHit + m.todayMiss) > 0 ? m.todayHit / (m.todayHit + m.todayMiss) : 0; m.todayYuan = m.todayCredits / CREDITS_PER_YUAN; m.todayPercent = m.planLimit > 0 ? m.todayCredits / m.planLimit : null; m.monthCredits = m.models.reduce((s, d) => s + d.credits, 0); m.monthYuan = m.monthCredits / CREDITS_PER_YUAN; m.monthRemainingYuan = m.planRemaining / CREDITS_PER_YUAN; const dailyMap = {}; for (const d of listData) { if (!dailyMap[d.date]) dailyMap[d.date] = {}; const dm = dailyMap[d.date]; if (!dm[d.model]) dm[d.model] = { hit: 0, miss: 0, output: 0, total: 0, reqs: 0 }; dm[d.model].hit += d.inputHitToken; dm[d.model].miss += d.inputMissToken; dm[d.model].output += d.outputToken; dm[d.model].total += d.totalToken; dm[d.model].reqs += d.requestCount; } m.daily = Object.entries(dailyMap) .sort(([a], [b]) => a.localeCompare(b)) .map(([date, models]) => { const dayTotal = Object.values(models).reduce((s, v) => s + v.total, 0); const dayCredits = Object.entries(models).reduce((s, [name, v]) => s + calcCredits(name, v.hit, v.miss, v.output), 0); const dayHit = Object.values(models).reduce((s, v) => s + v.hit, 0); const dayMiss = Object.values(models).reduce((s, v) => s + v.miss, 0); return { date, models, total: dayTotal, credits: dayCredits, yuan: dayCredits / CREDITS_PER_YUAN, percent: m.planLimit > 0 ? dayCredits / m.planLimit : 0, hitRate: (dayHit + dayMiss) > 0 ? dayHit / (dayHit + dayMiss) : 0, }; }); const currentDay = new Date().getUTCDate(); const avgDailyCredits = m.monthCredits / Math.max(currentDay, 1); m.daysUntilExhaust = avgDailyCredits > 0 ? Math.floor(m.planRemaining / avgDailyCredits) : 999; return m; } // ============ 渲染 ============ function metricBox(label, value, color, sub) { const t = theme(); return `