/** * 使用情况页面 — 对接 OpenClaw Gateway sessions.usage API * 展示 Token 用量、费用、Top Models/Providers/Tools/Agents 等分析数据 */ import { wsClient } from '../lib/ws-client.js' import { toast } from '../components/toast.js' import { icon } from '../lib/icons.js' import { t } from '../lib/i18n.js' let _page = null, _unsubReady = null export async function render() { const page = document.createElement('div') page.className = 'page' _page = page page.innerHTML = `
` page.querySelectorAll('[data-days]').forEach(btn => { btn.onclick = () => { _days = parseInt(btn.dataset.days) page.querySelectorAll('[data-days]').forEach(b => { b.classList.remove('btn-primary'); b.classList.add('btn-secondary') }) btn.classList.remove('btn-secondary'); btn.classList.add('btn-primary') loadUsage(page) } }) page.querySelector('#btn-usage-refresh')?.addEventListener('click', () => loadUsage(page)) loadUsage(page) return page } export function cleanup() { _page = null if (_unsubReady) { _unsubReady(); _unsubReady = null } } let _days = 7 async function loadUsage(page) { const el = page.querySelector('#usage-content') el.innerHTML = `
` if (!wsClient.connected) { el.innerHTML = `
${t('usage.gwConnecting')}
${t('usage.gwWait')}
` // 自动等待连接就绪后重试 if (_unsubReady) _unsubReady() _unsubReady = wsClient.onReady(() => { if (_unsubReady) { _unsubReady(); _unsubReady = null } if (_page) loadUsage(_page) }) return } try { const now = new Date() const end = now.toISOString().slice(0, 10) const start = new Date(now.getTime() - (_days - 1) * 86400000).toISOString().slice(0, 10) const data = await wsClient.request('sessions.usage', { startDate: start, endDate: end, limit: 20 }) renderUsage(el, data) } catch (e) { el.innerHTML = `
${t('usage.loadFailed')}: ${esc(e?.message || e)}
${t('usage.loadFailedHint')}
` } } function renderUsage(el, data) { if (!data) { el.innerHTML = `
${t('usage.noData')}
`; return } const totals = data.totals || {} const a = data.aggregates || {} const msgs = a.messages || {} const tools = a.tools || {} const fmtTokens = (n) => { if (n == null || n === 0) return '0' if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M' if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k' return String(n) } const fmtCost = (n) => n != null && n > 0 ? '$' + n.toFixed(4) : '$0' const fmtRate = (errors, total) => { if (!total) return '—' const pct = (errors / total * 100).toFixed(1) return pct + '%' } // ── 概览卡片 ── const overviewHtml = `
${t('usage.messages')}
${msgs.total || 0}
${msgs.user || 0} ${t('usage.userMsgs')} · ${msgs.assistant || 0} ${t('usage.assistantMsgs')}
${t('usage.toolCalls')}
${tools.totalCalls || 0}
${t('usage.toolKinds', { count: tools.uniqueTools || 0 })}
${t('usage.errors')}
${msgs.errors || 0}
${t('usage.errorRate')} ${fmtRate(msgs.errors, msgs.total)}
${t('usage.totalTokens')}
${fmtTokens(totals.totalTokens)}
${fmtTokens(totals.input)} ${t('usage.input')} · ${fmtTokens(totals.output)} ${t('usage.output')}
${t('usage.cost')}
${fmtCost(totals.totalCost)}
${fmtCost(totals.inputCost)} ${t('usage.input')} · ${fmtCost(totals.outputCost)} ${t('usage.output')}
${t('usage.sessions')}
${(data.sessions || []).length}
${data.startDate || ''} ~ ${data.endDate || ''}
` // ── Top 排行 ── const renderTop = (title, items, keyFn, valueFn) => { if (!items || !items.length) return '' const rows = items.slice(0, 5).map(item => `
${esc(keyFn(item))} ${valueFn(item)}
`).join('') return `
${title}
${rows}
` } const topModels = renderTop(t('usage.topModels'), a.byModel, m => m.model || t('usage.unknownModel'), m => fmtCost(m.totals?.totalCost) + ' · ' + fmtTokens(m.totals?.totalTokens)) const topProviders = renderTop(t('usage.topProviders'), a.byProvider, p => p.provider || t('usage.unknownProvider'), p => fmtCost(p.totals?.totalCost) + ' · ' + t('usage.times', { count: p.count })) const topTools = renderTop(t('usage.topTools'), (tools.tools || []), item => item.name, item => t('usage.timesCall', { count: item.count })) const topAgents = renderTop(t('usage.topAgents'), a.byAgent, item => item.agentId || 'main', item => fmtCost(item.totals?.totalCost)) const topChannels = renderTop(t('usage.topChannels'), a.byChannel, c => c.channel || 'webchat', c => fmtCost(c.totals?.totalCost)) const topsHtml = `
${topModels}${topProviders}${topTools}${topAgents}${topChannels}
` // ── Token 分类 ── const tokenBreakdownHtml = `
${t('usage.tokenBreakdown')}
${t('usage.outputTokens')} ${fmtTokens(totals.output)}
${t('usage.inputTokens')} ${fmtTokens(totals.input)}
${t('usage.cacheRead')} ${fmtTokens(totals.cacheRead)}
${t('usage.cacheWrite')} ${fmtTokens(totals.cacheWrite)}
` // ── 每日用量 ── const daily = a.daily || [] let dailyHtml = '' if (daily.length > 0) { const maxTokens = Math.max(...daily.map(d => d.tokens || 0), 1) const bars = daily.map(d => { const pct = Math.max(1, Math.round((d.tokens || 0) / maxTokens * 100)) const date = (d.date || '').slice(5) // MM-DD return `
${date}
` }).join('') dailyHtml = `
${t('usage.dailyUsage')}
${bars}
` } // ── 会话列表 ── const sessions = (data.sessions || []).slice(0, 10) let sessionsHtml = '' if (sessions.length > 0) { const rows = sessions.map(s => { const u = s.usage || {} const key = esc(s.key || '').replace(/^agent:main:/, '') const model = s.model || u.modelUsage?.[0]?.model || '' const provider = u.modelUsage?.[0]?.provider || s.modelProvider || '' return `
${key || s.sessionId?.slice(0, 12) || '—'} ${s.agentId ? `${esc(s.agentId)}` : ''} ${model ? `${esc(model)}` : ''} ${provider ? `${esc(provider)}` : ''}
${fmtTokens(u.totalTokens)} tokens · ${fmtCost(u.totalCost)} · ${(u.messageCounts?.total || 0)} msgs${u.messageCounts?.errors ? ' · ' + u.messageCounts.errors + ' err' : ''}
` }).join('') sessionsHtml = `
${t('usage.sessionDetail')} ${t('usage.recentN', { count: sessions.length })}
${rows}
` } el.innerHTML = overviewHtml + topsHtml + tokenBreakdownHtml + dailyHtml + sessionsHtml } function esc(str) { return (str || '').replace(/&/g, '&').replace(//g, '>') }