AIClient-2-API/static/model-usage-stats.html
hex2077 82a6ec2f43 feat(plugin): 新增模型用量统计插件并增强 API Potluck 的 token 统计功能
- 新增 `model-usage-stats` 插件,提供模型级别的 token 用量统计和 API 接口
- 增强 API Potluck 插件,记录并展示 prompt、completion 和 total tokens 用量
- 更新插件管理器以支持禁用插件的路由拦截和静态文件访问控制
- 在前端页面中展示 token 用量统计数据
- 升级版本号至 2.13.3
2026-04-09 16:30:02 +08:00

129 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#059669">
<title>模型用量统计</title>
<link rel="stylesheet" href="app/base.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
body{background:var(--bg-secondary);min-height:100vh}.navbar{position:sticky;top:0;z-index:100;background:var(--bg-primary);border-bottom:1px solid var(--border-color);box-shadow:var(--shadow-sm)}.navbar-inner,.main{max-width:1400px;margin:0 auto;padding:0 24px}.navbar-inner{height:64px;display:flex;align-items:center;justify-content:space-between;gap:16px}.brand{display:flex;align-items:center;gap:12px;min-width:0}.brand-icon{width:40px;height:40px;border-radius:12px;display:inline-flex;align-items:center;justify-content:center;background:linear-gradient(135deg,var(--primary-color),var(--primary-light));color:#fff;box-shadow:var(--shadow-md)}.brand-title{font-size:18px;font-weight:700;color:var(--text-primary)}.brand-sub{font-size:12px;color:var(--text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.nav-actions{display:flex;align-items:center;gap:10px}.nav-btn,.theme-toggle,.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:40px;padding:0 14px;border-radius:var(--radius-lg);border:1px solid var(--border-color);background:var(--bg-primary);color:var(--text-primary);text-decoration:none;cursor:pointer;font-size:14px;font-weight:600;transition:var(--transition)}.theme-toggle{width:40px;padding:0;color:var(--text-secondary)}.nav-btn:hover,.theme-toggle:hover,.btn:hover{border-color:var(--primary-color);box-shadow:var(--shadow-md);transform:translateY(-1px)}.theme-toggle .fa-sun{display:none}[data-theme="dark"] .theme-toggle .fa-sun{display:inline-block}[data-theme="dark"] .theme-toggle .fa-moon{display:none}.main{padding:30px 24px 48px}.panel,.card,.provider,.table-card,.empty{background:var(--bg-primary);border:1px solid var(--border-color);border-radius:var(--radius-xl);box-shadow:var(--shadow-sm)}.hero,.split,.stats{display:grid;gap:24px}.hero{grid-template-columns:minmax(0,1.2fr) minmax(320px,.8fr);margin-bottom:24px}.split{grid-template-columns:repeat(2,minmax(0,1fr));margin-bottom:24px}.stats{grid-template-columns:repeat(4,minmax(0,1fr));margin-bottom:24px}.hero-main,.auth,.panel,.table-card{padding:24px}.eyebrow,.pill,.tag{display:inline-flex;align-items:center;gap:8px;padding:8px 12px;border-radius:999px;font-size:12px;font-weight:600}.eyebrow{background:var(--bg-secondary);border:1px solid var(--border-color);color:var(--text-secondary)}.pill{background:var(--success-10);border:1px solid var(--primary-30);color:var(--success-text)}.pill.idle{background:var(--bg-secondary);border-color:var(--border-color);color:var(--text-secondary)}h1{margin:14px 0 12px;font-size:30px;line-height:1.2;color:var(--text-primary)}.copy,.sub,.help,.footer,.note{color:var(--text-secondary);font-size:14px;line-height:1.7}.meta{display:flex;gap:10px;flex-wrap:wrap;margin-top:16px}.meta .tag{background:var(--bg-secondary);border:1px solid var(--border-color);color:var(--text-secondary)}.title,.tools{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}.title{margin-bottom:12px}.section-title{margin:0 0 16px;font-size:18px;font-weight:700;color:var(--text-primary);display:flex;align-items:center;gap:10px}.label{display:block;margin-bottom:8px;font-size:13px;font-weight:600;color:var(--text-secondary)}.input,.select,.search{width:100%;min-height:42px;padding:0 14px;background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:var(--radius-lg);color:var(--text-primary);font-size:14px;transition:var(--transition)}.input{padding-top:10px;padding-bottom:10px}.input:focus,.select:focus,.search:focus{outline:none;border-color:var(--primary-color);box-shadow:0 0 0 4px var(--primary-10)}.group{margin-bottom:16px}.row,.tool-row{display:flex;gap:10px;flex-wrap:wrap}.status{display:none;align-items:center;gap:10px;padding:12px 14px;border-radius:var(--radius-lg);margin-top:16px;font-size:13px;border:1px solid transparent}.status.show{display:flex}.status.info{background:var(--info-bg-alt);border-color:var(--info-border);color:var(--info-text-dark)}.status.success{background:var(--success-bg-light);border-color:var(--primary-30);color:var(--success-text)}.status.error{background:var(--danger-bg-light);border-color:var(--danger-border);color:var(--danger-text)}.btn{padding:0 16px;border:1px solid transparent}.btn-primary{background:linear-gradient(135deg,var(--primary-color),var(--primary-light));color:#fff}.btn-secondary{background:var(--bg-secondary);border-color:var(--border-color);color:var(--text-primary)}.btn-danger{background:var(--danger-color);color:#fff}.card{padding:20px;position:relative;overflow:hidden;transition:var(--transition)}.card:hover,.provider:hover{transform:translateY(-4px);box-shadow:var(--shadow-lg);border-color:var(--primary-30)}.card:before{content:'';position:absolute;top:0;left:0;right:0;height:3px}.card.green:before{background:linear-gradient(90deg,var(--primary-color),var(--primary-light))}.card.blue:before{background:linear-gradient(90deg,var(--info-color),#22d3ee)}.card.indigo:before{background:linear-gradient(90deg,var(--indigo-500),var(--indigo-600))}.card.orange:before{background:linear-gradient(90deg,var(--warning-color),#fbbf24)}.card-label{font-size:12px;letter-spacing:.04em;text-transform:uppercase;color:var(--text-secondary)}.card-value{margin-top:10px;font-size:32px;font-weight:700;color:var(--text-primary);line-height:1.1}.card.green .card-value{color:var(--primary-color)}.card.blue .card-value{color:var(--info-color)}.card.indigo .card-value{color:var(--indigo-500)}.card.orange .card-value{color:var(--warning-color)}.note{margin-top:10px;font-size:12px;color:var(--text-tertiary)}.bars{display:flex;flex-direction:column;gap:14px}.bar{display:flex;flex-direction:column;gap:6px}.bar-head{display:flex;justify-content:space-between;gap:12px;font-size:13px}.bar-name{color:var(--text-primary);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.bar-value{color:var(--text-secondary)}.track{height:8px;background:var(--bg-tertiary);border-radius:999px;overflow:hidden}.fill{height:100%;background:linear-gradient(90deg,var(--primary-color),var(--primary-light));border-radius:inherit}.providers{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;margin-bottom:24px}.provider{padding:20px;transition:var(--transition)}.provider-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:16px}.provider-name{font-size:16px;font-weight:700;color:var(--text-primary);margin-bottom:6px}.provider-sub{font-size:12px;color:var(--text-secondary)}.provider-badge{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;background:var(--bg-secondary);border:1px solid var(--border-color);font-size:12px;font-weight:600;color:var(--text-secondary);white-space:nowrap}.mini{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.mini-box{padding:12px;border-radius:var(--radius-lg);background:var(--bg-secondary);border:1px solid var(--border-color)}.mini-label{font-size:12px;color:var(--text-secondary)}.mini-value{margin-top:6px;font-size:18px;font-weight:700;color:var(--text-primary)}.table-wrap{overflow:auto;border:1px solid var(--border-color);border-radius:var(--radius-lg)}table{width:100%;border-collapse:collapse}th,td{padding:14px 16px;text-align:left;border-bottom:1px solid var(--border-color)}th{background:var(--bg-secondary);font-size:12px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--text-secondary)}td{font-size:14px;color:var(--text-primary)}tr:last-child td{border-bottom:none}.mono{font-family:"JetBrains Mono",Consolas,monospace}.tag{background:var(--bg-secondary);border:1px solid var(--border-color);color:var(--text-secondary)}.empty{text-align:center;padding:36px 24px;color:var(--text-secondary);display:none}.empty i{font-size:32px;color:var(--text-tertiary);margin-bottom:12px}.footer{text-align:center;margin-top:20px;font-size:12px;color:var(--text-tertiary)}@media (max-width:1100px){.hero,.split,.stats{grid-template-columns:1fr}}@media (max-width:760px){.navbar-inner,.main{padding-left:16px;padding-right:16px}.brand-sub,.nav-btn span{display:none}.mini{grid-template-columns:1fr}.tool-row,.search,.select{width:100%}}
</style>
</head>
<body>
<header class="navbar">
<div class="navbar-inner">
<div class="brand">
<span class="brand-icon"><i class="fas fa-chart-line"></i></span>
<div>
<div class="brand-title">模型用量统计</div>
<div class="brand-sub">统一查看 Provider / Model 的请求次数与 Token 累计</div>
</div>
</div>
<div class="nav-actions">
<a class="nav-btn" href="/"><i class="fas fa-arrow-left"></i><span>返回控制台</span></a>
<button class="theme-toggle" id="themeToggle" type="button" aria-label="切换主题"><i class="fas fa-moon"></i><i class="fas fa-sun"></i></button>
</div>
</div>
</header>
<main class="main">
<section class="hero">
<div class="panel hero-main">
<span class="eyebrow"><i class="fas fa-chart-pie"></i> 统计面板</span>
<h1>模型用量统计面板</h1>
<div class="copy">通过 <code>/api/model-usage-stats</code> 查看系统已累计的请求数、Prompt Tokens、Completion Tokens 和 Provider / Model 维度分布。</div>
<div class="meta">
<span class="tag"><i class="fas fa-key"></i> 支持后台 Token 与 API Key</span>
<span class="tag"><i class="fas fa-database"></i> 基于插件持久化统计</span>
<span class="tag"><i class="fas fa-filter"></i> 支持搜索、排序、刷新与重置</span>
</div>
</div>
<aside class="panel auth">
<div class="title"><h2 class="section-title" style="margin:0"><i class="fas fa-shield-halved"></i>访问凭证</h2><span class="pill idle" id="authBadge">未连接</span></div>
<div class="sub">与其他管理页面一致,这里不会自动读取服务端敏感配置。输入凭证后可加载统计接口,刷新后会从本地缓存恢复。</div>
<div class="group">
<label class="label" for="credentialValue">访问凭证</label>
<input class="input" id="credentialValue" type="password" placeholder="输入 Token 或 API Key">
<div class="help" id="credentialHelp">这里直接填后台登录 Token 或服务 API Key。页面会自动同时尝试 <code>Authorization: Bearer</code><code>x-api-key</code> 两种方式。</div>
</div>
<div class="row">
<button class="btn btn-primary" id="connectBtn" type="button"><i class="fas fa-plug"></i><span>连接并加载</span></button>
<button class="btn btn-secondary" id="clearBtn" type="button"><i class="fas fa-eraser"></i><span>清空凭证</span></button>
</div>
<div class="status info show" id="status"><i class="fas fa-circle-info"></i><span>尚未加载统计数据。</span></div>
</aside>
</section>
<section class="stats">
<article class="card green"><div class="card-label">总请求数</div><div class="card-value" id="totalRequests">0</div><div class="note">累计成功落库的模型调用次数</div></article>
<article class="card blue"><div class="card-label">Prompt Tokens</div><div class="card-value" id="promptTokens">0</div><div class="note">输入 token 的累计值</div></article>
<article class="card indigo"><div class="card-label">Completion Tokens</div><div class="card-value" id="completionTokens">0</div><div class="note">输出 token 的累计值</div></article>
<article class="card orange"><div class="card-label">总 Tokens</div><div class="card-value" id="totalTokens">0</div><div class="note" id="updatedAt">等待数据</div></article>
</section>
<section class="split">
<div class="panel"><h2 class="section-title"><i class="fas fa-sitemap"></i>Provider 分布</h2><div class="bars" id="providerBars"></div></div>
<div class="panel"><h2 class="section-title"><i class="fas fa-fire"></i>Top Models</h2><div class="bars" id="topModelBars"></div></div>
</section>
<section>
<div class="tools">
<h2 class="section-title" style="margin:0"><i class="fas fa-layer-group"></i>Provider 视图</h2>
<div class="row">
<button class="btn btn-secondary" id="refreshBtn" type="button"><i class="fas fa-rotate-right"></i><span>刷新</span></button>
<button class="btn btn-danger" id="resetBtn" type="button"><i class="fas fa-trash-can"></i><span>重置统计</span></button>
</div>
</div>
<div class="providers" id="providerCards"></div>
</section>
<section class="table-card">
<div class="tools">
<h2 class="section-title" style="margin:0"><i class="fas fa-table"></i>模型明细</h2>
<div class="tool-row">
<input class="search" id="searchInput" type="search" placeholder="搜索 provider 或 model">
<select class="select" id="sortSelect">
<option value="totalTokens-desc">按总 Tokens 降序</option>
<option value="requestCount-desc">按请求数降序</option>
<option value="promptTokens-desc">按 Prompt Tokens 降序</option>
<option value="completionTokens-desc">按 Completion Tokens 降序</option>
<option value="provider-asc">按 Provider 升序</option>
<option value="model-asc">按 Model 升序</option>
</select>
</div>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Provider</th><th>Model</th><th>请求数</th><th>Prompt</th><th>Completion</th><th>Total</th><th>最近使用</th></tr></thead>
<tbody id="tableBody"></tbody>
</table>
</div>
</section>
<section class="empty" id="emptyState"><i class="fas fa-chart-pie"></i><h3>暂无统计数据</h3><p>先发起几次模型请求,再回来查看这里的可视化结果。</p></section>
<p class="footer">如果上游响应没有提供 usage调用次数仍会统计但 token 数可能保持为 0。</p>
</main>
<script type="module">
import{initThemeSwitcher,setTheme,getCurrentTheme}from'./app/theme-switcher.js';setTheme(getCurrentTheme());initThemeSwitcher('themeToggle');
</script>
<script>
const API_BASE='/api/model-usage-stats',STORAGE_KEY='model_usage_stats_auth';let rows=[];const el=id=>document.getElementById(id),fmt=v=>new Intl.NumberFormat('zh-CN').format(Number(v||0));
function rel(iso){if(!iso)return'未记录';const d=new Date(iso);if(Number.isNaN(d.getTime()))return iso;const m=Math.floor((Date.now()-d.getTime())/6e4);if(m<1)return'刚刚';if(m<60)return`${m} 分钟前`;const h=Math.floor(m/60);if(h<24)return`${h} 小时前`;const day=Math.floor(h/24);return day<30?`${day} 天前`:d.toLocaleString('zh-CN')}
function status(msg,type='info'){const icon=type==='error'?'fa-triangle-exclamation':type==='success'?'fa-circle-check':'fa-circle-info';el('status').className=`status ${type} show`;el('status').innerHTML=`<i class="fas ${icon}"></i><span>${msg}</span>`}
function badge(text,ok=false){const n=el('authBadge');n.className=ok?'pill':'pill idle';n.textContent=text}
function credential(){return{value:el('credentialValue').value.trim()}}
function saveCredential(){const c=credential();if(c.value)localStorage.setItem(STORAGE_KEY,JSON.stringify(c))}
function restoreCredential(){try{const c=JSON.parse(localStorage.getItem(STORAGE_KEY)||'null');if(!c)return;el('credentialValue').value=c.value||''}catch{}}
function clearCredential(){localStorage.removeItem(STORAGE_KEY);el('credentialValue').value='';badge('未连接',false);status('已清空本地保存的凭证。')}
function headers(){const c=credential();if(!c.value)throw new Error('请先输入访问凭证。');return{Authorization:`Bearer ${c.value}`,'x-api-key':c.value}}
async function request(url,options={}){const res=await fetch(url,{...options,headers:{...(options.headers||{}),...headers()}}),text=await res.text();let payload=null;try{payload=text?JSON.parse(text):null}catch{throw new Error(`接口返回了非 JSON 内容: ${text||'(empty)'}`)}if(!res.ok){if(payload?.error?.code==='PLUGIN_DISABLED')throw new Error('插件未启用:请先在插件管理中启用 model-usage-stats。');throw new Error(payload?.error?.message||payload?.message||`请求失败 (${res.status})`)}return payload}
function flatten(data){const list=[];for(const [provider,pd] of Object.entries(data.providers||{})){for(const [model,md] of Object.entries(pd.models||{}))list.push({provider,model,...md})}return list}
function bars(id,items,getValue,getLabel){const box=el(id);box.innerHTML='';if(!items.length){box.innerHTML='<div class="mini-box"><div class="mini-label">暂无数据</div><div class="mini-value" style="font-size:14px">等待统计写入</div></div>';return}const max=Math.max(...items.map(getValue),1);items.forEach(item=>{const v=Number(getValue(item)||0),w=Math.max(v/max*100,2),node=document.createElement('div');node.className='bar';node.innerHTML=`<div class="bar-head"><div class="bar-name" title="${getLabel(item)}">${getLabel(item)}</div><div class="bar-value">${fmt(v)}</div></div><div class="track"><div class="fill" style="width:${w}%"></div></div>`;box.appendChild(node)})}
function renderSummary(data){const s=data.summary||{};el('totalRequests').textContent=fmt(s.requestCount);el('promptTokens').textContent=fmt(s.promptTokens);el('completionTokens').textContent=fmt(s.completionTokens);el('totalTokens').textContent=fmt(s.totalTokens);el('updatedAt').textContent=data.updatedAt?`更新于 ${new Date(data.updatedAt).toLocaleString('zh-CN')}`:'尚未写入'}
function renderProviders(data){const providers=Object.entries(data.providers||{}).map(([name,p])=>({name,data:p,count:Object.keys(p.models||{}).length})).sort((a,b)=>(b.data.summary?.totalTokens||0)-(a.data.summary?.totalTokens||0));const box=el('providerCards');box.innerHTML='';if(!providers.length){box.innerHTML='<div class="provider"><div class="provider-name">暂无 Provider 统计</div><div class="provider-sub">当前还没有可展示的累计数据。</div></div>';return}providers.forEach(p=>{const s=p.data.summary||{};const node=document.createElement('article');node.className='provider';node.innerHTML=`<div class="provider-head"><div><div class="provider-name">${p.name}</div><div class="provider-sub">包含 ${fmt(p.count)} 个模型</div></div><span class="provider-badge"><i class="fas fa-bolt"></i>${fmt(s.requestCount)} 次调用</span></div><div class="mini"><div class="mini-box"><div class="mini-label">Total Tokens</div><div class="mini-value">${fmt(s.totalTokens)}</div></div><div class="mini-box"><div class="mini-label">Prompt Tokens</div><div class="mini-value">${fmt(s.promptTokens)}</div></div><div class="mini-box"><div class="mini-label">Completion Tokens</div><div class="mini-value">${fmt(s.completionTokens)}</div></div><div class="mini-box"><div class="mini-label">最近使用</div><div class="mini-value" style="font-size:14px">${rel(s.lastUsedAt)}</div></div></div>`;box.appendChild(node)})}
function filteredRows(){const keyword=el('searchInput').value.trim().toLowerCase();const [field,dir]=el('sortSelect').value.split('-');const list=keyword?rows.filter(r=>r.provider.toLowerCase().includes(keyword)||r.model.toLowerCase().includes(keyword)):rows.slice();list.sort((a,b)=>{if(field==='provider'||field==='model'){const l=String(a[field]||''),r=String(b[field]||'');return dir==='desc'?r.localeCompare(l):l.localeCompare(r)}const l=Number(a[field]||0),r=Number(b[field]||0);return dir==='desc'?r-l:l-r});return list}
function renderTable(){const tbody=el('tableBody'),list=filteredRows();tbody.innerHTML='';if(!list.length){tbody.innerHTML='<tr><td colspan="7" style="text-align:center;color:var(--text-tertiary)">没有匹配的数据</td></tr>';return}list.forEach(r=>{const tr=document.createElement('tr');tr.innerHTML=`<td><span class="tag"><i class="fas fa-server"></i>${r.provider}</span></td><td>${r.model}</td><td class="mono">${fmt(r.requestCount)}</td><td class="mono">${fmt(r.promptTokens)}</td><td class="mono">${fmt(r.completionTokens)}</td><td class="mono">${fmt(r.totalTokens)}</td><td>${rel(r.lastUsedAt)}</td>`;tbody.appendChild(tr)})}
function render(data){rows=flatten(data);el('emptyState').style.display=rows.length?'none':'block';renderSummary(data);const providers=Object.entries(data.providers||{}).map(([name,p])=>({name,totalTokens:Number(p.summary?.totalTokens||0)})).sort((a,b)=>b.totalTokens-a.totalTokens).slice(0,8);const topModels=rows.slice().sort((a,b)=>Number(b.totalTokens||0)-Number(a.totalTokens||0)).slice(0,8);bars('providerBars',providers,i=>i.totalTokens,i=>i.name);bars('topModelBars',topModels,i=>Number(i.totalTokens||0),i=>`${i.provider} / ${i.model}`);renderProviders(data);renderTable()}
async function loadData(){try{saveCredential();status('正在加载统计数据...');const payload=await request(API_BASE);render(payload.data||payload);badge('已连接',true);status(`已加载 ${fmt(rows.length)} 条模型统计。`,'success')}catch(error){console.error(error);badge('连接失败',false);status(error.message,'error')}}
async function resetData(){if(!confirm('确认重置全部模型统计吗?此操作会清空已落库的累计数据。'))return;try{status('正在重置统计数据...');const payload=await request(`${API_BASE}/reset`,{method:'POST'});render(payload.data||payload);status('统计数据已重置。','success')}catch(error){console.error(error);status(error.message,'error')}}
el('connectBtn').addEventListener('click',loadData);el('refreshBtn').addEventListener('click',loadData);el('resetBtn').addEventListener('click',resetData);el('clearBtn').addEventListener('click',clearCredential);el('searchInput').addEventListener('input',renderTable);el('sortSelect').addEventListener('change',renderTable);el('credentialValue').addEventListener('keydown',e=>{if(e.key==='Enter')loadData()});
restoreCredential();if(el('credentialValue').value.trim())loadData();
</script>
</body>
</html>