From fb893917cc6b6f0a9b212b8741fa97f62a5ec0f3 Mon Sep 17 00:00:00 2001 From: ProcyonNAN <3189960265@qq.com> Date: Mon, 1 Jun 2026 19:01:31 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat(packages):=20=E4=B8=BA=20usage=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=BE=93=E5=87=BA=20token=20=E8=B6=8B?= =?UTF-8?q?=E5=8A=BF=E6=8C=87=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 chatluna-usage 中新增 tokens 指令,支持 day、week、month、all 统计范围及插件明细参数。 - 基于 chatluna_usage 表聚合 total、input、output token 与请求峰值,按不同范围生成分桶趋势数据。 - 新增 puppeteer HTML 图表渲染模板,支持浅色和深色主题以及颜色图例。 - 将 puppeteer 声明为可选依赖服务,未启用时保留文字统计输出。 --- packages/extension-usage/package.json | 17 +- .../resources/token-trend/template.html | 114 +++++++ packages/extension-usage/src/index.ts | 278 ++++++++++++++++- packages/extension-usage/src/renderer.ts | 291 ++++++++++++++++++ 4 files changed, 692 insertions(+), 8 deletions(-) create mode 100644 packages/extension-usage/resources/token-trend/template.html create mode 100644 packages/extension-usage/src/renderer.ts diff --git a/packages/extension-usage/package.json b/packages/extension-usage/package.json index 7e3fe72d3..a01412565 100644 --- a/packages/extension-usage/package.json +++ b/packages/extension-usage/package.json @@ -7,7 +7,8 @@ "typings": "lib/index.d.ts", "files": [ "lib", - "dist" + "dist", + "resources" ], "exports": { ".": { @@ -54,12 +55,19 @@ "devDependencies": { "@koishijs/client": "^5.30.11", "atsc": "^2.1.0", - "koishi": "^4.18.9" + "koishi": "^4.18.9", + "koishi-plugin-puppeteer": "^3.9.0" }, "peerDependencies": { "@koishijs/plugin-console": "^5.30.11", "koishi": "^4.18.9", - "koishi-plugin-chatluna": "^1.4.0-alpha.20" + "koishi-plugin-chatluna": "^1.4.0-alpha.20", + "koishi-plugin-puppeteer": "^3.9.0" + }, + "peerDependenciesMeta": { + "koishi-plugin-puppeteer": { + "optional": true + } }, "koishi": { "description": { @@ -72,7 +80,8 @@ "database" ], "optional": [ - "console" + "console", + "puppeteer" ] } } diff --git a/packages/extension-usage/resources/token-trend/template.html b/packages/extension-usage/resources/token-trend/template.html new file mode 100644 index 000000000..ccb6c950f --- /dev/null +++ b/packages/extension-usage/resources/token-trend/template.html @@ -0,0 +1,114 @@ + + + + + + ${title} + + + +
+
+
+
+

${title}

${range}

+
+
${chart}
+
+ ${pluginCard} +
+ + diff --git a/packages/extension-usage/src/index.ts b/packages/extension-usage/src/index.ts index ca3e5bbc5..04ac7b83b 100644 --- a/packages/extension-usage/src/index.ts +++ b/packages/extension-usage/src/index.ts @@ -1,11 +1,160 @@ -import { Context, Logger, Schema, Time } from 'koishi' +import { resolve } from 'path' +import { Context, h, Logger, Schema, Time } from 'koishi' import { DataService } from '@koishijs/plugin-console' import type { UsageMetadata } from '@langchain/core/messages' -import { resolve } from 'path' import type { ModelUsageCallType } from 'koishi-plugin-chatluna/llm-core/platform/usage' +import type {} from 'koishi-plugin-puppeteer' +import { renderTokenTrend } from './renderer' const logger = new Logger('chatluna-usage') +const RANGE_ALIASES: Record = { + d: 'day', + day: 'day', + w: 'week', + week: 'week', + m: 'month', + month: 'month', + a: 'all', + all: 'all' +} + +// Normalize a bare token like "d" / "day" / "-d" into a TokenRange. +function toTokenRange(value: string): ChatLunaUsage.TokenRange | undefined { + return RANGE_ALIASES[value.replace(/^-+/, '').trim().toLowerCase()] +} + +function label(range: ChatLunaUsage.TokenRange) { + if (range === 'day') return '天' + if (range === 'week') return '周' + if (range === 'month') return '月' + return '全部' +} + +function formatNumber(value: number) { + return value.toLocaleString('en-US') +} + +function formatDate(date: Date) { + const y = date.getFullYear() + const m = String(date.getMonth() + 1).padStart(2, '0') + const d = String(date.getDate()).padStart(2, '0') + const h = String(date.getHours()).padStart(2, '0') + const min = String(date.getMinutes()).padStart(2, '0') + return `${y}-${m}-${d} ${h}:${min}` +} + +function formatTokenReport(report: ChatLunaUsage.TokenReport) { + return [ + `Chatluna token 用量(${report.label})`, + `时间范围:${formatDate(report.start)} 至 ${formatDate(report.end)}`, + `累计 token:${formatNumber(report.totalTokens)}`, + `累计请求:${formatNumber(report.calls)}次`, + `TPM:${formatNumber(report.tpm)}`, + `RPM:${formatNumber(report.rpm)}次` + ].join('\n') +} + +function createTokenReport( + range: ChatLunaUsage.TokenRange, + start: Date, + end: Date, + rows: ChatLunaUsage.Record[], + withPlugins = false +): ChatLunaUsage.TokenReport { + const sorted = rows.slice().sort((a, b) => +a.createdAt - +b.createdAt) + const from = range === 'all' ? sorted[0]?.createdAt ?? end : start + const minutes = new Map() + let totalTokens = 0 + + for (const row of sorted) { + const tokens = row.usageMetadata.total_tokens + const key = Math.floor(+row.createdAt / Time.minute) * Time.minute + const item = minutes.get(key) ?? { tokens: 0, calls: 0 } + item.tokens += tokens + item.calls += 1 + totalTokens += tokens + minutes.set(key, item) + } + + return { + range, + label: label(range), + start: from, + end, + totalTokens, + calls: sorted.length, + tpm: Math.max(0, ...[...minutes.values()].map((item) => item.tokens)), + rpm: Math.max(0, ...[...minutes.values()].map((item) => item.calls)), + points: tokenPoints(range, from, end, sorted), + plugins: withPlugins ? pluginUsage(sorted) : undefined + } +} + +function pluginUsage( + rows: ChatLunaUsage.Record[] +): ChatLunaUsage.PluginUsage[] { + const map = new Map() + + for (const row of rows) { + const source = row.source || 'unknown' + const item = map.get(source) ?? { source, tokens: 0, calls: 0 } + item.tokens += row.usageMetadata.total_tokens + item.calls += 1 + map.set(source, item) + } + + return [...map.values()].sort((a, b) => b.tokens - a.tokens) +} + +function tokenPoints( + range: ChatLunaUsage.TokenRange, + start: Date, + end: Date, + rows: ChatLunaUsage.Record[] +) { + if (!rows.length) return [] + + // d buckets by 2 hours; w by day; m by 2 days; a by dynamic days. + const hourly = range === 'day' + const step = + range === 'day' + ? 2 * Time.hour + : range === 'month' + ? 2 * Time.day + : range === 'all' + ? Math.max( + Time.day, + Math.ceil((+end - +start) / Time.day / 15) * Time.day + ) + : Time.day + const result: ChatLunaUsage.TokenPoint[] = [] + + for (let at = +start; at < +end; at += step) { + const date = new Date(at) + const m = String(date.getMonth() + 1).padStart(2, '0') + const d = String(date.getDate()).padStart(2, '0') + const h = String(date.getHours()).padStart(2, '0') + result.push({ + label: hourly ? `${m}-${d} ${h}:00` : `${m}-${d}`, + tokens: 0, + inputTokens: 0, + outputTokens: 0 + }) + } + + for (const row of rows) { + const idx = Math.floor((+row.createdAt - +start) / step) + if (result[idx]) { + result[idx].tokens += row.usageMetadata.total_tokens + result[idx].inputTokens += row.usageMetadata.input_tokens + result[idx].outputTokens += row.usageMetadata.output_tokens + } + } + + return result +} + class ChatLunaUsage extends DataService { constructor( ctx: Context, @@ -71,6 +220,71 @@ class ChatLunaUsage extends DataService { } }) + ctx.command( + 'tokens [...args:string]', + '查看 ChatLuna 整体 token 消耗趋势', + { authority: 1 } + ) + .alias('/tokens') + .option('day', '-d 按天统计') + .option('week', '-w 按一周统计') + .option('month', '-m 按一月统计') + .option('all', '-a 统计全部') + .option('plugin', '-p 附带各插件用量明细') + .usage( + '示例:/tokens / /tokens day / /tokens -d / /tokens d,附带插件明细 /tokens -p' + ) + .action(async ({ session, options }, ...args) => { + let range: ChatLunaUsage.TokenRange | undefined + if (options.all) range = 'all' + else if (options.month) range = 'month' + else if (options.week) range = 'week' + else if (options.day) range = 'day' + + let plugin = Boolean(options.plugin) + + for (const arg of args) { + const resolved = toTokenRange(arg) + if (resolved) { + range = resolved + continue + } + const keyword = arg.replace(/^-+/, '').trim().toLowerCase() + if (keyword === 'p' || keyword === 'plugin') { + plugin = true + continue + } + return '参数只能是 day、week、month、all(或简写 d/w/m/a),以及 plugin(或 p)。' + } + + try { + const report = await this.tokenReport( + range ?? 'day', + plugin + ) + await session.send(formatTokenReport(report)) + + if (!ctx.puppeteer) { + await session.send('图表渲染需要启用 puppeteer 服务。') + return + } + + const image = await renderTokenTrend( + ctx, + report, + this.config.tokensTheme + ) + await session.send( + typeof image === 'string' + ? h.text(image) + : h.image(image, 'image/png') + ) + } catch (e) { + logger.error(e) + return 'ChatLuna token 用量统计失败,请检查日志。' + } + }) + if (!config.webui) return ctx.inject(['console'], (ctx) => { @@ -252,6 +466,27 @@ class ChatLunaUsage extends DataService { ) } + private async tokenReport( + range: ChatLunaUsage.TokenRange, + withPlugins = false + ) { + const end = new Date() + const start = + range === 'day' + ? new Date(+end - Time.day) + : range === 'week' + ? new Date(+end - 7 * Time.day) + : range === 'month' + ? new Date(+end - 30 * Time.day) + : end + const time = range === 'all' ? { $lt: end } : { $gte: start, $lt: end } + const rows = (await this.ctx.database.get('chatluna_usage', { + createdAt: time + })) as ChatLunaUsage.Record[] + + return createTokenReport(range, start, end, rows, withPlugins) + } + private async search(input: ChatLunaUsage.Query) { const query = this.withDefaults(input) const where: Record = { @@ -455,6 +690,7 @@ namespace ChatLunaUsage { } export type Period = 'day' | 'month' | 'year' + export type TokenRange = 'day' | 'week' | 'month' | 'all' export type GroupBy = | 'source' | 'model' @@ -546,6 +782,32 @@ namespace ChatLunaUsage { rows: ListRow[] } + export interface TokenPoint { + label: string + tokens: number + inputTokens: number + outputTokens: number + } + + export interface PluginUsage { + source: string + tokens: number + calls: number + } + + export interface TokenReport { + range: TokenRange + label: string + start: Date + end: Date + totalTokens: number + calls: number + tpm: number + rpm: number + points: TokenPoint[] + plugins?: PluginUsage[] + } + export interface Payload { query: Required< Pick< @@ -576,6 +838,7 @@ namespace ChatLunaUsage { recentDays: number pageSize: number webui: boolean + tokensTheme: 'light' | 'dark' } export interface ActionResult { @@ -591,7 +854,14 @@ namespace ChatLunaUsage { .default(50), webui: Schema.boolean() .description('启用 Web UI 控制台用量面板。') - .default(true) + .default(true), + tokensTheme: Schema.union([ + Schema.const('light').description('浅色主题'), + Schema.const('dark').description('深色主题') + ]) + .description('tokens命令渲染主题。') + .default('light') + .role('select') }) export const inject = ['chatluna', 'database'] @@ -618,7 +888,7 @@ export const Config = ChatLunaUsage.Config export const inject = { required: ['chatluna', 'database'], - optional: ['console'] + optional: ['console', 'puppeteer'] } export const name = 'chatluna-usage' diff --git a/packages/extension-usage/src/renderer.ts b/packages/extension-usage/src/renderer.ts new file mode 100644 index 000000000..d7be27b39 --- /dev/null +++ b/packages/extension-usage/src/renderer.ts @@ -0,0 +1,291 @@ +import { promises as fs } from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' +import { Context, Time } from 'koishi' +import type {} from 'koishi-plugin-puppeteer' +import type { ChatLunaUsage } from './index' + +function renderTemplate(template: string, data: Record) { + return template.replace(/\$\{(.*?)}/g, (_, key) => data[key] || '') +} + +function escapeHtml(value: string) { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +function fmt(value: number) { + return value.toLocaleString('en-US') +} + +interface Coord { + x: number + y: number + point: ChatLunaUsage.TokenPoint +} + +// Monotone cubic (Fritsch-Carlson) so the curve never overshoots below the +// baseline on flat-then-spike data. +function monotonePath(pts: Coord[]) { + const n = pts.length + if (n < 2) return '' + if (n === 2) { + return `M${pts[0].x},${pts[0].y} L${pts[1].x},${pts[1].y}` + } + + const dx: number[] = [] + const slope: number[] = [] + for (let i = 0; i < n - 1; i++) { + dx[i] = pts[i + 1].x - pts[i].x + slope[i] = (pts[i + 1].y - pts[i].y) / dx[i] + } + + const t: number[] = [slope[0]] + for (let i = 1; i < n - 1; i++) { + t[i] = slope[i - 1] * slope[i] <= 0 ? 0 : (slope[i - 1] + slope[i]) / 2 + } + t[n - 1] = slope[n - 2] + + for (let i = 0; i < n - 1; i++) { + if (slope[i] === 0) { + t[i] = 0 + t[i + 1] = 0 + continue + } + const a = t[i] / slope[i] + const b = t[i + 1] / slope[i] + const h = Math.hypot(a, b) + if (h > 3) { + const k = 3 / h + t[i] = k * a * slope[i] + t[i + 1] = k * b * slope[i] + } + } + + let d = `M${pts[0].x},${pts[0].y}` + for (let i = 0; i < n - 1; i++) { + const c1x = pts[i].x + dx[i] / 3 + const c1y = pts[i].y + (t[i] * dx[i]) / 3 + const c2x = pts[i + 1].x - dx[i] / 3 + const c2y = pts[i + 1].y - (t[i + 1] * dx[i]) / 3 + d += ` C${c1x},${c1y} ${c2x},${c2y} ${pts[i + 1].x},${pts[i + 1].y}` + } + return d +} + +function chart(points: ChatLunaUsage.TokenPoint[]) { + if (!points.length) return '
暂无用量数据
' + + const width = 968 + const height = 360 + const left = 78 + const right = 26 + const top = 30 + const bottom = 56 + const plotWidth = width - left - right + const plotHeight = height - top - bottom + const baseline = top + plotHeight + const max = Math.max( + 1, + ...points.flatMap((p) => [p.tokens, p.inputTokens, p.outputTokens]) + ) + const makeCoords = ( + key: 'tokens' | 'inputTokens' | 'outputTokens' + ): Coord[] => + points.map((point, idx) => ({ + x: + points.length === 1 + ? left + plotWidth / 2 + : left + (plotWidth * idx) / (points.length - 1), + y: baseline - (point[key] / max) * plotHeight, + point + })) + + const totalCoords = makeCoords('tokens') + const inputCoords = makeCoords('inputTokens') + const outputCoords = makeCoords('outputTokens') + const totalLine = monotonePath(totalCoords) + const inputLine = monotonePath(inputCoords) + const outputLine = monotonePath(outputCoords) + const area = totalLine + ? `${totalLine} L${totalCoords[totalCoords.length - 1].x},${baseline} L${totalCoords[0].x},${baseline} Z` + : '' + + const grid = Array.from({ length: 5 }, (_, idx) => { + const y = top + (plotHeight * idx) / 4 + const value = Math.round(max - (max * idx) / 4) + return ( + `` + + `${fmt(value)}` + ) + }).join('') + + const size = points.length > 24 ? 10 : 12 + const labels = totalCoords + .map((c) => { + const label = c.point.label.split(' ') + if (label.length < 2) { + return `${escapeHtml(c.point.label)}` + } + return ( + `` + + `${escapeHtml(label[0])}` + + `${escapeHtml(label[1])}` + + '' + ) + }) + .join('') + + const series: [string, Coord[]][] = [ + ['input', inputCoords], + ['output', outputCoords], + ['total', totalCoords] + ] + const dots = series + .flatMap(([name, coords]) => { + return coords.map((c, _, arr) => { + const isLast = c === arr[arr.length - 1] + const cls = isLast ? `dot-${name} dot-last` : `dot-${name}` + return `` + }) + }) + .join('') + + return ` + + + + + + + + + + + + ${grid} + + + + + ${dots} + ${labels} + +
+ 总 token + 输入 token + 输出 token +
+ ` +} + +const PLUGIN_COLORS: [string, string][] = [ + ['#6366f1', '#8b5cf6'], + ['#0ea5e9', '#22d3ee'], + ['#f43f5e', '#fb7185'], + ['#f59e0b', '#fbbf24'], + ['#10b981', '#34d399'], + ['#a855f7', '#d946ef'] +] + +function pluginCard(plugins?: ChatLunaUsage.PluginUsage[]) { + if (!plugins?.length) return '' + + const total = plugins.reduce((sum, p) => sum + p.tokens, 0) || 1 + const rows = plugins + .map((plugin, idx) => { + const [accent, accent2] = PLUGIN_COLORS[idx % PLUGIN_COLORS.length] + const ratio = (plugin.tokens / total) * 100 + const width = Math.max(2, Math.min(100, ratio)) + const pct = ratio.toFixed(1) + const style = `--accent:${accent};--accent-2:${accent2}` + return ` +
+
${escapeHtml(plugin.source)}
+
${pct}% · ${fmt(plugin.tokens)} token · ${fmt(plugin.calls)} 次
+
+
+ ` + }) + .join('') + + const icon = + '' + + '' + + '' + + '' + + '' + + '' + + return ` +
+
+
${icon}
+

各插件用量明细

按 token 占比排序

+
+
${rows}
+
+ ` +} + +export async function renderTokenTrend( + ctx: Context, + data: ChatLunaUsage.TokenReport, + theme: 'light' | 'dark' = 'light' +) { + const dirname = + __dirname?.length > 0 ? __dirname : fileURLToPath(import.meta.url) + const templatePath = path.resolve( + dirname, + '../resources/token-trend/template.html' + ) + const outDir = path.resolve(ctx.baseDir, 'data/chatluna/usage') + const file = `${Math.random().toString(36).slice(2)}.html` + const out = path.resolve(outDir, file) + + await fs.mkdir(outDir, { recursive: true }) + await fs.writeFile( + out, + renderTemplate(await fs.readFile(templatePath, 'utf-8'), { + title: 'Chatluna token 消耗趋势', + range: `时间范围:${formatDate(data.start)} 至 ${formatDate(data.end)}`, + chart: chart(data.points), + pluginCard: pluginCard(data.plugins), + themeClass: theme === 'dark' ? 'theme-dark' : 'theme-light' + }) + ) + + let page: Awaited> | undefined + try { + page = await ctx.puppeteer.page() + await page.goto('file://' + out, { waitUntil: 'domcontentloaded' }) + await page.evaluate(() => document.fonts.ready) + const el = await page.$('.stage') + if (!el) { + return '图表渲染失败:未找到图表容器。' + } + + return await el.screenshot() + } catch (err) { + ctx.logger.error(err) + return '图表渲染失败,请检查日志。' + } finally { + await page?.close().catch((err) => ctx.logger.warn(err)) + ctx.setTimeout(() => { + fs.unlink(out).catch((err) => ctx.logger.warn(err)) + }, 3 * Time.minute) + } +} + +function formatDate(date: Date) { + const y = date.getFullYear() + const m = String(date.getMonth() + 1).padStart(2, '0') + const d = String(date.getDate()).padStart(2, '0') + const h = String(date.getHours()).padStart(2, '0') + const min = String(date.getMinutes()).padStart(2, '0') + return `${y}-${m}-${d} ${h}:${min}` +} From 60f01e2171f799780371954da47987fa71bc761a Mon Sep 17 00:00:00 2001 From: ProcyonNAN <3189960265@qq.com> Date: Mon, 1 Jun 2026 19:25:49 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix(packages):=20=E4=BF=AE=E5=A4=8D=20usage?= =?UTF-8?q?=20token=20=E8=B6=8B=E5=8A=BF=E5=AE=A1=E6=9F=A5=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 调整 chatluna-usage tokens 报表中 TPM 与 RPM 的计算方式,在统计循环内维护单分钟峰值,避免大量分钟桶通过参数展开传入 Math.max。 - 修复 token 趋势图渲染模块在 ESM 环境下的目录解析逻辑,使用 import.meta.url 时转换为所在目录。 - 调整 puppeteer 渲染临时 HTML 的清理时机,在渲染流程结束后立即删除临时文件,避免延迟定时器残留。 --- packages/extension-usage/src/index.ts | 10 +++++++--- packages/extension-usage/src/renderer.ts | 10 +++++----- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/extension-usage/src/index.ts b/packages/extension-usage/src/index.ts index 04ac7b83b..8f676f6ed 100644 --- a/packages/extension-usage/src/index.ts +++ b/packages/extension-usage/src/index.ts @@ -63,9 +63,11 @@ function createTokenReport( withPlugins = false ): ChatLunaUsage.TokenReport { const sorted = rows.slice().sort((a, b) => +a.createdAt - +b.createdAt) - const from = range === 'all' ? sorted[0]?.createdAt ?? end : start + const from = range === 'all' ? (sorted[0]?.createdAt ?? end) : start const minutes = new Map() let totalTokens = 0 + let tpm = 0 + let rpm = 0 for (const row of sorted) { const tokens = row.usageMetadata.total_tokens @@ -74,6 +76,8 @@ function createTokenReport( item.tokens += tokens item.calls += 1 totalTokens += tokens + if (item.tokens > tpm) tpm = item.tokens + if (item.calls > rpm) rpm = item.calls minutes.set(key, item) } @@ -84,8 +88,8 @@ function createTokenReport( end, totalTokens, calls: sorted.length, - tpm: Math.max(0, ...[...minutes.values()].map((item) => item.tokens)), - rpm: Math.max(0, ...[...minutes.values()].map((item) => item.calls)), + tpm, + rpm, points: tokenPoints(range, from, end, sorted), plugins: withPlugins ? pluginUsage(sorted) : undefined } diff --git a/packages/extension-usage/src/renderer.ts b/packages/extension-usage/src/renderer.ts index d7be27b39..f8284f988 100644 --- a/packages/extension-usage/src/renderer.ts +++ b/packages/extension-usage/src/renderer.ts @@ -1,7 +1,7 @@ import { promises as fs } from 'fs' import path from 'path' import { fileURLToPath } from 'url' -import { Context, Time } from 'koishi' +import { Context } from 'koishi' import type {} from 'koishi-plugin-puppeteer' import type { ChatLunaUsage } from './index' @@ -238,7 +238,9 @@ export async function renderTokenTrend( theme: 'light' | 'dark' = 'light' ) { const dirname = - __dirname?.length > 0 ? __dirname : fileURLToPath(import.meta.url) + typeof __dirname !== 'undefined' + ? __dirname + : path.dirname(fileURLToPath(import.meta.url)) const templatePath = path.resolve( dirname, '../resources/token-trend/template.html' @@ -275,9 +277,7 @@ export async function renderTokenTrend( return '图表渲染失败,请检查日志。' } finally { await page?.close().catch((err) => ctx.logger.warn(err)) - ctx.setTimeout(() => { - fs.unlink(out).catch((err) => ctx.logger.warn(err)) - }, 3 * Time.minute) + await fs.unlink(out).catch((err) => ctx.logger.warn(err)) } } From ec3d65d891cbfe2f9488065adc10aa824534dd71 Mon Sep 17 00:00:00 2001 From: ProcyonNAN <3189960265@qq.com> Date: Sun, 7 Jun 2026 19:04:11 +0800 Subject: [PATCH 3/8] =?UTF-8?q?fix(packages):=20=E4=BE=9D=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E4=BF=AE=E5=A4=8D=20usage=20=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E7=9A=84=20tokens=20=E6=8C=87=E4=BB=A4=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 tokens 指令参数解析、报表聚合、格式化和趋势分桶逻辑移动到独立 tokens 模块,减少 index.ts 复杂度。 - 将 tokens 指令 action 下沉到 ChatLunaUsage.sendTokens,并保留 tokenReport 负责数据库查询。 - 将趋势图渲染器改为 TSX 和包内 HTML JSX runtime,改用 page.setContent 直接渲染截图,移除资源模板依赖。 - 增加 auto/light/dark tokensTheme;auto 通过控制台客户端同步的色彩模式解析,light/dark 保持强制主题。 - 保留原有 range 简写、plugin 明细、先发送文本和未启用 puppeteer 时提示的行为。 --- packages/extension-usage/client/index.ts | 15 +- packages/extension-usage/package.json | 3 +- .../resources/token-trend/template.html | 114 ---- .../extension-usage/src/html/jsx-runtime.ts | 65 ++ packages/extension-usage/src/index.ts | 345 +++------- packages/extension-usage/src/renderer.ts | 291 --------- packages/extension-usage/src/renderer.tsx | 612 ++++++++++++++++++ packages/extension-usage/src/tokens.ts | 187 ++++++ 8 files changed, 984 insertions(+), 648 deletions(-) delete mode 100644 packages/extension-usage/resources/token-trend/template.html create mode 100644 packages/extension-usage/src/html/jsx-runtime.ts delete mode 100644 packages/extension-usage/src/renderer.ts create mode 100644 packages/extension-usage/src/renderer.tsx create mode 100644 packages/extension-usage/src/tokens.ts diff --git a/packages/extension-usage/client/index.ts b/packages/extension-usage/client/index.ts index 3257719f8..2a9d74ffa 100644 --- a/packages/extension-usage/client/index.ts +++ b/packages/extension-usage/client/index.ts @@ -1,9 +1,22 @@ -import { Context } from '@koishijs/client' +import { Context, send, socket, useColorMode } from '@koishijs/client' +import { watch } from 'vue' import type {} from 'koishi-plugin-chatluna-usage' import charts from './charts' import home from './home.vue' export default (ctx: Context) => { + const mode = useColorMode() + + ctx.effect(() => + watch( + [mode, socket], + () => { + if (socket.value) send('chatluna-usage/theme', mode.value) + }, + { immediate: true } + ) + ) + ctx.plugin(charts) ctx.slot({ diff --git a/packages/extension-usage/package.json b/packages/extension-usage/package.json index a01412565..9d51a71f2 100644 --- a/packages/extension-usage/package.json +++ b/packages/extension-usage/package.json @@ -7,8 +7,7 @@ "typings": "lib/index.d.ts", "files": [ "lib", - "dist", - "resources" + "dist" ], "exports": { ".": { diff --git a/packages/extension-usage/resources/token-trend/template.html b/packages/extension-usage/resources/token-trend/template.html deleted file mode 100644 index ccb6c950f..000000000 --- a/packages/extension-usage/resources/token-trend/template.html +++ /dev/null @@ -1,114 +0,0 @@ - - - - - - ${title} - - - -
-
-
-
-

${title}

${range}

-
-
${chart}
-
- ${pluginCard} -
- - diff --git a/packages/extension-usage/src/html/jsx-runtime.ts b/packages/extension-usage/src/html/jsx-runtime.ts new file mode 100644 index 000000000..89a0be0e6 --- /dev/null +++ b/packages/extension-usage/src/html/jsx-runtime.ts @@ -0,0 +1,65 @@ +type Child = RawHtml | string | number | boolean | null | undefined | Child[] +type Props = Record & { children?: Child } +type NodeType = string | typeof Fragment + +export interface RawHtml { + __html: string +} + +export const Fragment = Symbol('Fragment') + +export function raw(value: string): RawHtml { + return { __html: value } +} + +export function renderHtml(value: Child) { + return render(value) +} + +export function jsx(type: NodeType, props: Props = {}, _key?: string): RawHtml { + if (type === Fragment) return raw(render(props.children)) + + const attrs = Object.entries(props) + .filter(([key]) => key !== 'children') + .map(([key, value]) => attr(key, value)) + .join('') + + return raw(`<${type}${attrs}>${render(props.children)}`) +} + +export const jsxs = jsx + +function attr(key: string, value: unknown) { + if (value == null || value === false) return '' + if (value === true) return ` ${key}` + return ` ${key}="${escapeHtml(String(value))}"` +} + +function render(value: Child): string { + if (Array.isArray(value)) return value.map((item) => render(item)).join('') + if (value == null || value === false || value === true) return '' + if (isRaw(value)) return value.__html + return escapeHtml(String(value)) +} + +function isRaw(value: unknown): value is RawHtml { + return typeof value === 'object' && value !== null && '__html' in value +} + +function escapeHtml(value: string) { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace JSX { + export type Element = RawHtml + + export interface IntrinsicElements { + [name: string]: Record + } +} diff --git a/packages/extension-usage/src/index.ts b/packages/extension-usage/src/index.ts index 8f676f6ed..555e857e9 100644 --- a/packages/extension-usage/src/index.ts +++ b/packages/extension-usage/src/index.ts @@ -1,165 +1,30 @@ import { resolve } from 'path' import { Context, h, Logger, Schema, Time } from 'koishi' +import type { Session } from 'koishi' import { DataService } from '@koishijs/plugin-console' import type { UsageMetadata } from '@langchain/core/messages' import type { ModelUsageCallType } from 'koishi-plugin-chatluna/llm-core/platform/usage' import type {} from 'koishi-plugin-puppeteer' import { renderTokenTrend } from './renderer' +import { + createTokenReport, + formatTokenReport, + tokenRange, + tokenStart +} from './tokens' +import type { + PluginUsage as UsagePluginUsage, + TokenPoint as UsageTokenPoint, + TokenRange as UsageTokenRange, + TokenReport as UsageTokenReport, + TokenTheme as UsageTokenTheme +} from './tokens' const logger = new Logger('chatluna-usage') -const RANGE_ALIASES: Record = { - d: 'day', - day: 'day', - w: 'week', - week: 'week', - m: 'month', - month: 'month', - a: 'all', - all: 'all' -} - -// Normalize a bare token like "d" / "day" / "-d" into a TokenRange. -function toTokenRange(value: string): ChatLunaUsage.TokenRange | undefined { - return RANGE_ALIASES[value.replace(/^-+/, '').trim().toLowerCase()] -} - -function label(range: ChatLunaUsage.TokenRange) { - if (range === 'day') return '天' - if (range === 'week') return '周' - if (range === 'month') return '月' - return '全部' -} - -function formatNumber(value: number) { - return value.toLocaleString('en-US') -} - -function formatDate(date: Date) { - const y = date.getFullYear() - const m = String(date.getMonth() + 1).padStart(2, '0') - const d = String(date.getDate()).padStart(2, '0') - const h = String(date.getHours()).padStart(2, '0') - const min = String(date.getMinutes()).padStart(2, '0') - return `${y}-${m}-${d} ${h}:${min}` -} - -function formatTokenReport(report: ChatLunaUsage.TokenReport) { - return [ - `Chatluna token 用量(${report.label})`, - `时间范围:${formatDate(report.start)} 至 ${formatDate(report.end)}`, - `累计 token:${formatNumber(report.totalTokens)}`, - `累计请求:${formatNumber(report.calls)}次`, - `TPM:${formatNumber(report.tpm)}`, - `RPM:${formatNumber(report.rpm)}次` - ].join('\n') -} - -function createTokenReport( - range: ChatLunaUsage.TokenRange, - start: Date, - end: Date, - rows: ChatLunaUsage.Record[], - withPlugins = false -): ChatLunaUsage.TokenReport { - const sorted = rows.slice().sort((a, b) => +a.createdAt - +b.createdAt) - const from = range === 'all' ? (sorted[0]?.createdAt ?? end) : start - const minutes = new Map() - let totalTokens = 0 - let tpm = 0 - let rpm = 0 - - for (const row of sorted) { - const tokens = row.usageMetadata.total_tokens - const key = Math.floor(+row.createdAt / Time.minute) * Time.minute - const item = minutes.get(key) ?? { tokens: 0, calls: 0 } - item.tokens += tokens - item.calls += 1 - totalTokens += tokens - if (item.tokens > tpm) tpm = item.tokens - if (item.calls > rpm) rpm = item.calls - minutes.set(key, item) - } - - return { - range, - label: label(range), - start: from, - end, - totalTokens, - calls: sorted.length, - tpm, - rpm, - points: tokenPoints(range, from, end, sorted), - plugins: withPlugins ? pluginUsage(sorted) : undefined - } -} - -function pluginUsage( - rows: ChatLunaUsage.Record[] -): ChatLunaUsage.PluginUsage[] { - const map = new Map() - - for (const row of rows) { - const source = row.source || 'unknown' - const item = map.get(source) ?? { source, tokens: 0, calls: 0 } - item.tokens += row.usageMetadata.total_tokens - item.calls += 1 - map.set(source, item) - } - - return [...map.values()].sort((a, b) => b.tokens - a.tokens) -} - -function tokenPoints( - range: ChatLunaUsage.TokenRange, - start: Date, - end: Date, - rows: ChatLunaUsage.Record[] -) { - if (!rows.length) return [] - - // d buckets by 2 hours; w by day; m by 2 days; a by dynamic days. - const hourly = range === 'day' - const step = - range === 'day' - ? 2 * Time.hour - : range === 'month' - ? 2 * Time.day - : range === 'all' - ? Math.max( - Time.day, - Math.ceil((+end - +start) / Time.day / 15) * Time.day - ) - : Time.day - const result: ChatLunaUsage.TokenPoint[] = [] - - for (let at = +start; at < +end; at += step) { - const date = new Date(at) - const m = String(date.getMonth() + 1).padStart(2, '0') - const d = String(date.getDate()).padStart(2, '0') - const h = String(date.getHours()).padStart(2, '0') - result.push({ - label: hourly ? `${m}-${d} ${h}:00` : `${m}-${d}`, - tokens: 0, - inputTokens: 0, - outputTokens: 0 - }) - } - - for (const row of rows) { - const idx = Math.floor((+row.createdAt - +start) / step) - if (result[idx]) { - result[idx].tokens += row.usageMetadata.total_tokens - result[idx].inputTokens += row.usageMetadata.input_tokens - result[idx].outputTokens += row.usageMetadata.output_tokens - } - } - - return result -} - class ChatLunaUsage extends DataService { + private _theme: 'light' | 'dark' = 'light' + constructor( ctx: Context, public config: ChatLunaUsage.Config @@ -238,56 +103,9 @@ class ChatLunaUsage extends DataService { .usage( '示例:/tokens / /tokens day / /tokens -d / /tokens d,附带插件明细 /tokens -p' ) - .action(async ({ session, options }, ...args) => { - let range: ChatLunaUsage.TokenRange | undefined - if (options.all) range = 'all' - else if (options.month) range = 'month' - else if (options.week) range = 'week' - else if (options.day) range = 'day' - - let plugin = Boolean(options.plugin) - - for (const arg of args) { - const resolved = toTokenRange(arg) - if (resolved) { - range = resolved - continue - } - const keyword = arg.replace(/^-+/, '').trim().toLowerCase() - if (keyword === 'p' || keyword === 'plugin') { - plugin = true - continue - } - return '参数只能是 day、week、month、all(或简写 d/w/m/a),以及 plugin(或 p)。' - } - - try { - const report = await this.tokenReport( - range ?? 'day', - plugin - ) - await session.send(formatTokenReport(report)) - - if (!ctx.puppeteer) { - await session.send('图表渲染需要启用 puppeteer 服务。') - return - } - - const image = await renderTokenTrend( - ctx, - report, - this.config.tokensTheme - ) - await session.send( - typeof image === 'string' - ? h.text(image) - : h.image(image, 'image/png') - ) - } catch (e) { - logger.error(e) - return 'ChatLuna token 用量统计失败,请检查日志。' - } - }) + .action(async ({ session, options }, ...args) => + this.sendTokens(session, options, args) + ) if (!config.webui) return @@ -300,6 +118,11 @@ class ChatLunaUsage extends DataService { this.list(input) ) + ctx.console.addListener('chatluna-usage/theme', async (theme) => { + if (theme === 'light' || theme === 'dark') this._theme = theme + return { success: true } + }) + ctx.console.addListener( 'chatluna-usage/cleanup', async (before) => { @@ -470,19 +293,70 @@ class ChatLunaUsage extends DataService { ) } + async sendTokens( + session: Session, + options: ChatLunaUsage.TokenCommandOptions, + args: string[] + ) { + let range: ChatLunaUsage.TokenRange | undefined + if (options.all) range = 'all' + else if (options.month) range = 'month' + else if (options.week) range = 'week' + else if (options.day) range = 'day' + + let plugin = Boolean(options.plugin) + + for (const arg of args) { + const value = tokenRange(arg) + if (value) { + range = value + continue + } + const keyword = arg.replace(/^-+/, '').trim().toLowerCase() + if (keyword === 'p' || keyword === 'plugin') { + plugin = true + continue + } + return '参数只能是 day、week、month、all(或简写 d/w/m/a),以及 plugin(或 p)。' + } + + try { + const report = await this.tokenReport(range ?? 'day', plugin) + await session.send(formatTokenReport(report)) + + const puppeteer = this.ctx.get('puppeteer') + if (!puppeteer) { + await session.send('图表渲染需要启用 puppeteer 服务。') + return + } + + const theme = + this.config.tokensTheme === 'auto' + ? this._theme + : this.config.tokensTheme + const image = await renderTokenTrend( + this.ctx, + puppeteer, + report, + theme + ) + await session.send( + typeof image === 'string' + ? h.text(image) + : h.image(image, 'image/png') + ) + } catch (e) { + logger.error(e) + return 'ChatLuna token 用量统计失败,请检查日志。' + } + } + private async tokenReport( range: ChatLunaUsage.TokenRange, withPlugins = false ) { const end = new Date() - const start = - range === 'day' - ? new Date(+end - Time.day) - : range === 'week' - ? new Date(+end - 7 * Time.day) - : range === 'month' - ? new Date(+end - 30 * Time.day) - : end + const start = tokenStart(range, end) const time = range === 'all' ? { $lt: end } : { $gte: start, $lt: end } const rows = (await this.ctx.database.get('chatluna_usage', { createdAt: time @@ -694,7 +568,8 @@ namespace ChatLunaUsage { } export type Period = 'day' | 'month' | 'year' - export type TokenRange = 'day' | 'week' | 'month' | 'all' + export type TokenRange = UsageTokenRange + export type TokenTheme = UsageTokenTheme export type GroupBy = | 'source' | 'model' @@ -786,31 +661,9 @@ namespace ChatLunaUsage { rows: ListRow[] } - export interface TokenPoint { - label: string - tokens: number - inputTokens: number - outputTokens: number - } - - export interface PluginUsage { - source: string - tokens: number - calls: number - } - - export interface TokenReport { - range: TokenRange - label: string - start: Date - end: Date - totalTokens: number - calls: number - tpm: number - rpm: number - points: TokenPoint[] - plugins?: PluginUsage[] - } + export type TokenPoint = UsageTokenPoint + export type PluginUsage = UsagePluginUsage + export type TokenReport = UsageTokenReport export interface Payload { query: Required< @@ -842,7 +695,15 @@ namespace ChatLunaUsage { recentDays: number pageSize: number webui: boolean - tokensTheme: 'light' | 'dark' + tokensTheme: TokenTheme + } + + export interface TokenCommandOptions { + day?: boolean + week?: boolean + month?: boolean + all?: boolean + plugin?: boolean } export interface ActionResult { @@ -860,11 +721,12 @@ namespace ChatLunaUsage { .description('启用 Web UI 控制台用量面板。') .default(true), tokensTheme: Schema.union([ - Schema.const('light').description('浅色主题'), - Schema.const('dark').description('深色主题') + Schema.const('auto').description('自动'), + Schema.const('light').description('浅色模式'), + Schema.const('dark').description('深色模式') ]) - .description('tokens命令渲染主题。') - .default('light') + .description('tokens命令渲染出的图表颜色主题') + .default('auto') .role('select') }) @@ -922,6 +784,9 @@ declare module '@koishijs/plugin-console' { 'chatluna-usage/list': ( input?: ChatLunaUsage.Query ) => Promise + 'chatluna-usage/theme': ( + theme: 'light' | 'dark' + ) => Promise 'chatluna-usage/cleanup': ( before?: string ) => Promise diff --git a/packages/extension-usage/src/renderer.ts b/packages/extension-usage/src/renderer.ts deleted file mode 100644 index f8284f988..000000000 --- a/packages/extension-usage/src/renderer.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { promises as fs } from 'fs' -import path from 'path' -import { fileURLToPath } from 'url' -import { Context } from 'koishi' -import type {} from 'koishi-plugin-puppeteer' -import type { ChatLunaUsage } from './index' - -function renderTemplate(template: string, data: Record) { - return template.replace(/\$\{(.*?)}/g, (_, key) => data[key] || '') -} - -function escapeHtml(value: string) { - return value - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", ''') -} - -function fmt(value: number) { - return value.toLocaleString('en-US') -} - -interface Coord { - x: number - y: number - point: ChatLunaUsage.TokenPoint -} - -// Monotone cubic (Fritsch-Carlson) so the curve never overshoots below the -// baseline on flat-then-spike data. -function monotonePath(pts: Coord[]) { - const n = pts.length - if (n < 2) return '' - if (n === 2) { - return `M${pts[0].x},${pts[0].y} L${pts[1].x},${pts[1].y}` - } - - const dx: number[] = [] - const slope: number[] = [] - for (let i = 0; i < n - 1; i++) { - dx[i] = pts[i + 1].x - pts[i].x - slope[i] = (pts[i + 1].y - pts[i].y) / dx[i] - } - - const t: number[] = [slope[0]] - for (let i = 1; i < n - 1; i++) { - t[i] = slope[i - 1] * slope[i] <= 0 ? 0 : (slope[i - 1] + slope[i]) / 2 - } - t[n - 1] = slope[n - 2] - - for (let i = 0; i < n - 1; i++) { - if (slope[i] === 0) { - t[i] = 0 - t[i + 1] = 0 - continue - } - const a = t[i] / slope[i] - const b = t[i + 1] / slope[i] - const h = Math.hypot(a, b) - if (h > 3) { - const k = 3 / h - t[i] = k * a * slope[i] - t[i + 1] = k * b * slope[i] - } - } - - let d = `M${pts[0].x},${pts[0].y}` - for (let i = 0; i < n - 1; i++) { - const c1x = pts[i].x + dx[i] / 3 - const c1y = pts[i].y + (t[i] * dx[i]) / 3 - const c2x = pts[i + 1].x - dx[i] / 3 - const c2y = pts[i + 1].y - (t[i + 1] * dx[i]) / 3 - d += ` C${c1x},${c1y} ${c2x},${c2y} ${pts[i + 1].x},${pts[i + 1].y}` - } - return d -} - -function chart(points: ChatLunaUsage.TokenPoint[]) { - if (!points.length) return '
暂无用量数据
' - - const width = 968 - const height = 360 - const left = 78 - const right = 26 - const top = 30 - const bottom = 56 - const plotWidth = width - left - right - const plotHeight = height - top - bottom - const baseline = top + plotHeight - const max = Math.max( - 1, - ...points.flatMap((p) => [p.tokens, p.inputTokens, p.outputTokens]) - ) - const makeCoords = ( - key: 'tokens' | 'inputTokens' | 'outputTokens' - ): Coord[] => - points.map((point, idx) => ({ - x: - points.length === 1 - ? left + plotWidth / 2 - : left + (plotWidth * idx) / (points.length - 1), - y: baseline - (point[key] / max) * plotHeight, - point - })) - - const totalCoords = makeCoords('tokens') - const inputCoords = makeCoords('inputTokens') - const outputCoords = makeCoords('outputTokens') - const totalLine = monotonePath(totalCoords) - const inputLine = monotonePath(inputCoords) - const outputLine = monotonePath(outputCoords) - const area = totalLine - ? `${totalLine} L${totalCoords[totalCoords.length - 1].x},${baseline} L${totalCoords[0].x},${baseline} Z` - : '' - - const grid = Array.from({ length: 5 }, (_, idx) => { - const y = top + (plotHeight * idx) / 4 - const value = Math.round(max - (max * idx) / 4) - return ( - `` + - `${fmt(value)}` - ) - }).join('') - - const size = points.length > 24 ? 10 : 12 - const labels = totalCoords - .map((c) => { - const label = c.point.label.split(' ') - if (label.length < 2) { - return `${escapeHtml(c.point.label)}` - } - return ( - `` + - `${escapeHtml(label[0])}` + - `${escapeHtml(label[1])}` + - '' - ) - }) - .join('') - - const series: [string, Coord[]][] = [ - ['input', inputCoords], - ['output', outputCoords], - ['total', totalCoords] - ] - const dots = series - .flatMap(([name, coords]) => { - return coords.map((c, _, arr) => { - const isLast = c === arr[arr.length - 1] - const cls = isLast ? `dot-${name} dot-last` : `dot-${name}` - return `` - }) - }) - .join('') - - return ` - - - - - - - - - - - - ${grid} - - - - - ${dots} - ${labels} - -
- 总 token - 输入 token - 输出 token -
- ` -} - -const PLUGIN_COLORS: [string, string][] = [ - ['#6366f1', '#8b5cf6'], - ['#0ea5e9', '#22d3ee'], - ['#f43f5e', '#fb7185'], - ['#f59e0b', '#fbbf24'], - ['#10b981', '#34d399'], - ['#a855f7', '#d946ef'] -] - -function pluginCard(plugins?: ChatLunaUsage.PluginUsage[]) { - if (!plugins?.length) return '' - - const total = plugins.reduce((sum, p) => sum + p.tokens, 0) || 1 - const rows = plugins - .map((plugin, idx) => { - const [accent, accent2] = PLUGIN_COLORS[idx % PLUGIN_COLORS.length] - const ratio = (plugin.tokens / total) * 100 - const width = Math.max(2, Math.min(100, ratio)) - const pct = ratio.toFixed(1) - const style = `--accent:${accent};--accent-2:${accent2}` - return ` -
-
${escapeHtml(plugin.source)}
-
${pct}% · ${fmt(plugin.tokens)} token · ${fmt(plugin.calls)} 次
-
-
- ` - }) - .join('') - - const icon = - '' + - '' + - '' + - '' + - '' + - '' - - return ` -
-
-
${icon}
-

各插件用量明细

按 token 占比排序

-
-
${rows}
-
- ` -} - -export async function renderTokenTrend( - ctx: Context, - data: ChatLunaUsage.TokenReport, - theme: 'light' | 'dark' = 'light' -) { - const dirname = - typeof __dirname !== 'undefined' - ? __dirname - : path.dirname(fileURLToPath(import.meta.url)) - const templatePath = path.resolve( - dirname, - '../resources/token-trend/template.html' - ) - const outDir = path.resolve(ctx.baseDir, 'data/chatluna/usage') - const file = `${Math.random().toString(36).slice(2)}.html` - const out = path.resolve(outDir, file) - - await fs.mkdir(outDir, { recursive: true }) - await fs.writeFile( - out, - renderTemplate(await fs.readFile(templatePath, 'utf-8'), { - title: 'Chatluna token 消耗趋势', - range: `时间范围:${formatDate(data.start)} 至 ${formatDate(data.end)}`, - chart: chart(data.points), - pluginCard: pluginCard(data.plugins), - themeClass: theme === 'dark' ? 'theme-dark' : 'theme-light' - }) - ) - - let page: Awaited> | undefined - try { - page = await ctx.puppeteer.page() - await page.goto('file://' + out, { waitUntil: 'domcontentloaded' }) - await page.evaluate(() => document.fonts.ready) - const el = await page.$('.stage') - if (!el) { - return '图表渲染失败:未找到图表容器。' - } - - return await el.screenshot() - } catch (err) { - ctx.logger.error(err) - return '图表渲染失败,请检查日志。' - } finally { - await page?.close().catch((err) => ctx.logger.warn(err)) - await fs.unlink(out).catch((err) => ctx.logger.warn(err)) - } -} - -function formatDate(date: Date) { - const y = date.getFullYear() - const m = String(date.getMonth() + 1).padStart(2, '0') - const d = String(date.getDate()).padStart(2, '0') - const h = String(date.getHours()).padStart(2, '0') - const min = String(date.getMinutes()).padStart(2, '0') - return `${y}-${m}-${d} ${h}:${min}` -} diff --git a/packages/extension-usage/src/renderer.tsx b/packages/extension-usage/src/renderer.tsx new file mode 100644 index 000000000..f87b8f6c0 --- /dev/null +++ b/packages/extension-usage/src/renderer.tsx @@ -0,0 +1,612 @@ +/** @jsxImportSource ./html */ +import type { Context } from 'koishi' +import type {} from 'koishi-plugin-puppeteer' +import { raw, renderHtml } from './html/jsx-runtime' +import { formatDate } from './tokens' +import type { TokenPoint, TokenReport, TokenTheme } from './tokens' + +interface Coord { + x: number + y: number + point: TokenPoint +} + +type RenderTheme = Exclude + +const CSS = ` +:root { + color-scheme: light; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +.stage { + --card: #ffffff; + --text: #0f172a; + --muted: #64748b; + --faint: #94a3b8; + --brand: #6366f1; + --brand-2: #8b5cf6; + --border: #eef0f6; + color-scheme: light; + display: inline-flex; + flex-direction: column; + gap: 24px; + padding: 56px; + background: + radial-gradient(1100px 560px at 10% -10%, #e7e9ff 0%, transparent 55%), + radial-gradient(900px 480px at 110% 0%, #f5e9ff 0%, transparent 50%), + linear-gradient(180deg, #eef1ff, #f7f8fc); + font-family: Inter, "Noto Sans SC", "Microsoft YaHei", system-ui, sans-serif; + -webkit-font-smoothing: antialiased; +} +.stage.theme-dark { + --card: #111827; + --text: #e5e7eb; + --muted: #94a3b8; + --faint: #64748b; + --border: #253044; + color-scheme: dark; + background: + radial-gradient(1100px 560px at 10% -10%, rgba(79, 70, 229, 0.28) 0%, transparent 55%), + radial-gradient(900px 480px at 110% 0%, rgba(14, 165, 233, 0.18) 0%, transparent 50%), + linear-gradient(180deg, #0b1020, #111827); +} +.token-trend-card { + position: relative; + width: 1040px; + padding: 38px 40px 28px; + background: var(--card); + border: 1px solid var(--border); + border-radius: 24px; + box-shadow: + 0 24px 60px -20px rgba(49, 46, 129, 0.30), + 0 8px 24px -12px rgba(15, 23, 42, 0.12); + overflow: hidden; + color: var(--text); +} +.token-trend-card::before { + content: ""; + position: absolute; + inset: 0 0 auto; + height: 5px; + background: linear-gradient(90deg, var(--brand), var(--brand-2)); +} +.stage.theme-dark .token-trend-card { + box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.65), 0 8px 24px -12px rgba(0, 0, 0, 0.55); +} +.head { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 26px; +} +.mark { + display: grid; + place-items: center; + width: 48px; + height: 48px; + border-radius: 14px; + background: linear-gradient(140deg, var(--brand), var(--brand-2)); + box-shadow: 0 10px 22px -8px rgba(99, 102, 241, 0.65); +} +h1 { + margin: 0; + font-size: 26px; + font-weight: 700; + letter-spacing: 0; + line-height: 1.25; +} +.range { + margin: 5px 0 0; + color: var(--muted); + font-size: 14px; +} +.chart-wrap { + padding: 18px 16px 12px; + border: 1px solid var(--border); + border-radius: 18px; + background: radial-gradient(620px 220px at 82% -10%, rgba(139, 92, 246, 0.07), transparent 60%), linear-gradient(180deg, #ffffff, #fbfcff); +} +.stage.theme-dark .chart-wrap { + background: radial-gradient(620px 220px at 82% -10%, rgba(14, 165, 233, 0.10), transparent 60%), linear-gradient(180deg, #111827, #0f172a); +} +.trend-chart { + display: block; + width: 100%; + height: auto; +} +.grid line { + stroke: #eef1f6; + stroke-width: 1; +} +.stage.theme-dark .grid line { + stroke: #253044; +} +.grid text { + fill: var(--faint); + font-size: 12px; + text-anchor: end; + font-variant-numeric: tabular-nums; +} +.axis-x { + fill: var(--faint); + font-size: 12px; + text-anchor: middle; +} +.line { + fill: none; + stroke-width: 3.5; + stroke-linecap: round; + stroke-linejoin: round; +} +.line-total { + stroke: url(#totalGrad); +} +.line-input { + stroke: #0ea5e9; +} +.line-output { + stroke: #f59e0b; +} +.dot-total, +.dot-input, +.dot-output { + fill: var(--card); + stroke-width: 3; +} +.dot-total { + stroke: #6366f1; +} +.dot-input { + stroke: #0ea5e9; +} +.dot-output { + stroke: #f59e0b; +} +.dot-last.dot-total { + fill: #6366f1; + stroke: #fff; +} +.dot-last.dot-input { + fill: #0ea5e9; + stroke: #fff; +} +.dot-last.dot-output { + fill: #f59e0b; + stroke: #fff; +} +.chart-legend { + display: flex; + justify-content: center; + gap: 22px; + margin: 12px 0 0; + color: var(--muted); + font-size: 13px; +} +.legend-item { + display: inline-flex; + align-items: center; + gap: 7px; + font-variant-numeric: tabular-nums; +} +.legend-item i { + width: 26px; + height: 3px; + border-radius: 999px; + background: var(--legend-color); + box-shadow: 0 0 0 3px rgba(15, 23, 42, 0.03); +} +.empty-chart { + display: grid; + height: 360px; + place-items: center; + color: var(--faint); + font-size: 16px; +} +.plugin-list { + display: flex; + flex-direction: column; + gap: 18px; +} +.plugin-row { + display: grid; + grid-template-columns: 1fr auto; + gap: 6px 12px; + align-items: baseline; +} +.plugin-name { + display: flex; + align-items: center; + gap: 9px; + font-size: 15px; + font-weight: 600; + color: var(--text); +} +.plugin-name i { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--accent); +} +.plugin-meta { + font-size: 13px; + color: var(--muted); + font-variant-numeric: tabular-nums; +} +.plugin-meta b { + color: var(--text); + font-weight: 700; + font-size: 15px; +} +.plugin-track { + grid-column: 1 / -1; + height: 10px; + border-radius: 6px; + background: #f1f3f9; + overflow: hidden; +} +.stage.theme-dark .plugin-track { + background: #1f2937; +} +.plugin-fill { + height: 100%; + border-radius: 6px; + background: linear-gradient(90deg, var(--accent), var(--accent-2)); +} +` + +const PLUGIN_COLORS: [string, string][] = [ + ['#6366f1', '#8b5cf6'], + ['#0ea5e9', '#22d3ee'], + ['#f43f5e', '#fb7185'], + ['#f59e0b', '#fbbf24'], + ['#10b981', '#34d399'], + ['#a855f7', '#d946ef'] +] + +function fmt(value: number) { + return value.toLocaleString('en-US') +} + +function monotonePath(pts: Coord[]) { + const n = pts.length + if (n < 2) return '' + if (n === 2) { + return `M${pts[0].x},${pts[0].y} L${pts[1].x},${pts[1].y}` + } + + const dx: number[] = [] + const slope: number[] = [] + for (let i = 0; i < n - 1; i++) { + dx[i] = pts[i + 1].x - pts[i].x + slope[i] = (pts[i + 1].y - pts[i].y) / dx[i] + } + + const t: number[] = [slope[0]] + for (let i = 1; i < n - 1; i++) { + t[i] = slope[i - 1] * slope[i] <= 0 ? 0 : (slope[i - 1] + slope[i]) / 2 + } + t[n - 1] = slope[n - 2] + + for (let i = 0; i < n - 1; i++) { + if (slope[i] === 0) { + t[i] = 0 + t[i + 1] = 0 + continue + } + const a = t[i] / slope[i] + const b = t[i + 1] / slope[i] + const h = Math.hypot(a, b) + if (h > 3) { + const k = 3 / h + t[i] = k * a * slope[i] + t[i + 1] = k * b * slope[i] + } + } + + let d = `M${pts[0].x},${pts[0].y}` + for (let i = 0; i < n - 1; i++) { + const c1x = pts[i].x + dx[i] / 3 + const c1y = pts[i].y + (t[i] * dx[i]) / 3 + const c2x = pts[i + 1].x - dx[i] / 3 + const c2y = pts[i + 1].y - (t[i + 1] * dx[i]) / 3 + d += ` C${c1x},${c1y} ${c2x},${c2y} ${pts[i + 1].x},${pts[i + 1].y}` + } + return d +} + +function chart(points: TokenPoint[]) { + if (!points.length) return
暂无用量数据
+ + const width = 968 + const height = 360 + const left = 78 + const right = 26 + const top = 30 + const bottom = 56 + const plotWidth = width - left - right + const plotHeight = height - top - bottom + const baseline = top + plotHeight + const max = Math.max( + 1, + ...points.flatMap((p) => [p.tokens, p.inputTokens, p.outputTokens]) + ) + const makeCoords = ( + key: 'tokens' | 'inputTokens' | 'outputTokens' + ): Coord[] => + points.map((point, idx) => ({ + x: + points.length === 1 + ? left + plotWidth / 2 + : left + (plotWidth * idx) / (points.length - 1), + y: baseline - (point[key] / max) * plotHeight, + point + })) + + const totalCoords = makeCoords('tokens') + const inputCoords = makeCoords('inputTokens') + const outputCoords = makeCoords('outputTokens') + const totalLine = monotonePath(totalCoords) + const inputLine = monotonePath(inputCoords) + const outputLine = monotonePath(outputCoords) + const area = totalLine + ? `${totalLine} L${totalCoords[totalCoords.length - 1].x},${baseline} L${totalCoords[0].x},${baseline} Z` + : '' + const size = points.length > 24 ? 10 : 12 + + return ( + <> + + + + + + + + + + + + + {Array.from({ length: 5 }, (_, idx) => { + const y = top + (plotHeight * idx) / 4 + const value = Math.round(max - (max * idx) / 4) + return ( + <> + + + {fmt(value)} + + + ) + })} + + + + + + {[ + ['input', inputCoords] as const, + ['output', outputCoords] as const, + ['total', totalCoords] as const + ].flatMap(([name, coords]) => + coords.map((c, _, arr) => ( + + )) + )} + {totalCoords.map((c) => { + const parts = c.point.label.split(' ') + if (parts.length < 2) { + return ( + + {c.point.label} + + ) + } + return ( + + + {parts[0]} + + + {parts[1]} + + + ) + })} + +
+ + 总 token + + + 输入 token + + + 输出 token + +
+ + ) +} + +function trendIcon() { + return ( + + + + + ) +} + +function pluginIcon() { + return ( + + + + + + + ) +} + +function pluginCard(plugins?: TokenReport['plugins']) { + if (!plugins?.length) return '' + + const total = plugins.reduce((sum, p) => sum + p.tokens, 0) || 1 + + return ( +
+
+
{pluginIcon()}
+
+

各插件用量明细

+

按 token 占比排序

+
+
+
+ {plugins.map((plugin, idx) => { + const [accent, accent2] = + PLUGIN_COLORS[idx % PLUGIN_COLORS.length] + const ratio = (plugin.tokens / total) * 100 + const width = Math.max(2, Math.min(100, ratio)) + return ( +
+
+ + {plugin.source} +
+
+ {ratio.toFixed(1)}% · {fmt(plugin.tokens)} token ·{' '} + {fmt(plugin.calls)} 次 +
+
+
+
+
+ ) + })} +
+
+ ) +} + +function pageHtml(data: TokenReport, theme: RenderTheme) { + return ( + '' + + renderHtml( + + + + + Chatluna token 消耗趋势 + + + +
+
+
+
{trendIcon()}
+
+

Chatluna token 消耗趋势

+

+ 时间范围:{formatDate(data.start)} 至{' '} + {formatDate(data.end)} +

+
+
+
{chart(data.points)}
+
+ {pluginCard(data.plugins)} +
+ + + ) + ) +} + +export async function renderTokenTrend( + ctx: Context, + puppeteer: Context['puppeteer'], + data: TokenReport, + theme: RenderTheme = 'light' +) { + let page: Awaited> | undefined + try { + page = await puppeteer.page() + await page.setContent(pageHtml(data, theme), { + waitUntil: 'domcontentloaded' + }) + await page.evaluate(() => document.fonts.ready) + const el = await page.$('.stage') + if (!el) { + return '图表渲染失败:未找到图表容器。' + } + + return await el.screenshot() + } catch (err) { + ctx.logger.error(err) + return '图表渲染失败,请检查日志。' + } finally { + await page?.close().catch((err) => ctx.logger.warn(err)) + } +} diff --git a/packages/extension-usage/src/tokens.ts b/packages/extension-usage/src/tokens.ts new file mode 100644 index 000000000..04035c74f --- /dev/null +++ b/packages/extension-usage/src/tokens.ts @@ -0,0 +1,187 @@ +import { Time } from 'koishi' +import type { ChatLunaUsage } from './index' + +export type TokenRange = 'day' | 'week' | 'month' | 'all' +export type TokenTheme = 'auto' | 'light' | 'dark' + +export interface TokenPoint { + label: string + tokens: number + inputTokens: number + outputTokens: number +} + +export interface PluginUsage { + source: string + tokens: number + calls: number +} + +export interface TokenReport { + range: TokenRange + label: string + start: Date + end: Date + totalTokens: number + calls: number + tpm: number + rpm: number + points: TokenPoint[] + plugins?: PluginUsage[] +} + +const RANGES: Record = { + d: 'day', + day: 'day', + w: 'week', + week: 'week', + m: 'month', + month: 'month', + a: 'all', + all: 'all' +} + +export function tokenRange(value: string): TokenRange | undefined { + return RANGES[value.replace(/^-+/, '').trim().toLowerCase()] +} + +export function tokenStart(range: TokenRange, end: Date) { + if (range === 'day') return new Date(+end - Time.day) + if (range === 'week') return new Date(+end - 7 * Time.day) + if (range === 'month') return new Date(+end - 30 * Time.day) + return end +} + +export function formatDate(date: Date) { + const y = date.getFullYear() + const m = String(date.getMonth() + 1).padStart(2, '0') + const d = String(date.getDate()).padStart(2, '0') + const h = String(date.getHours()).padStart(2, '0') + const min = String(date.getMinutes()).padStart(2, '0') + return `${y}-${m}-${d} ${h}:${min}` +} + +export function formatTokenReport(report: TokenReport) { + return [ + `Chatluna token 用量(${report.label})`, + `时间范围:${formatDate(report.start)} 至 ${formatDate(report.end)}`, + `累计 token:${formatNumber(report.totalTokens)}`, + `累计请求:${formatNumber(report.calls)}次`, + `TPM:${formatNumber(report.tpm)}`, + `RPM:${formatNumber(report.rpm)}次` + ].join('\n') +} + +export function createTokenReport( + range: TokenRange, + start: Date, + end: Date, + rows: ChatLunaUsage.Record[], + withPlugins = false +): TokenReport { + const sorted = rows.slice().sort((a, b) => +a.createdAt - +b.createdAt) + const from = range === 'all' ? (sorted[0]?.createdAt ?? end) : start + const minutes = new Map() + let totalTokens = 0 + let tpm = 0 + let rpm = 0 + + for (const row of sorted) { + const tokens = row.usageMetadata.total_tokens + const key = Math.floor(+row.createdAt / Time.minute) * Time.minute + const item = minutes.get(key) ?? { tokens: 0, calls: 0 } + item.tokens += tokens + item.calls += 1 + totalTokens += tokens + if (item.tokens > tpm) tpm = item.tokens + if (item.calls > rpm) rpm = item.calls + minutes.set(key, item) + } + + return { + range, + label: rangeLabel(range), + start: from, + end, + totalTokens, + calls: sorted.length, + tpm, + rpm, + points: tokenPoints(range, from, end, sorted), + plugins: withPlugins ? pluginUsage(sorted) : undefined + } +} + +function rangeLabel(range: TokenRange) { + if (range === 'day') return '天' + if (range === 'week') return '周' + if (range === 'month') return '月' + return '全部' +} + +function formatNumber(value: number) { + return value.toLocaleString('en-US') +} + +function pluginUsage(rows: ChatLunaUsage.Record[]): PluginUsage[] { + const map = new Map() + + for (const row of rows) { + const source = row.source || 'unknown' + const item = map.get(source) ?? { source, tokens: 0, calls: 0 } + item.tokens += row.usageMetadata.total_tokens + item.calls += 1 + map.set(source, item) + } + + return [...map.values()].sort((a, b) => b.tokens - a.tokens) +} + +function tokenPoints( + range: TokenRange, + start: Date, + end: Date, + rows: ChatLunaUsage.Record[] +) { + if (!rows.length) return [] + + const hourly = range === 'day' + const step = tokenStep(range, start, end) + const result: TokenPoint[] = [] + + for (let at = +start; at < +end; at += step) { + const date = new Date(at) + const m = String(date.getMonth() + 1).padStart(2, '0') + const d = String(date.getDate()).padStart(2, '0') + const h = String(date.getHours()).padStart(2, '0') + result.push({ + label: hourly ? `${m}-${d} ${h}:00` : `${m}-${d}`, + tokens: 0, + inputTokens: 0, + outputTokens: 0 + }) + } + + for (const row of rows) { + const idx = Math.floor((+row.createdAt - +start) / step) + if (result[idx]) { + result[idx].tokens += row.usageMetadata.total_tokens + result[idx].inputTokens += row.usageMetadata.input_tokens + result[idx].outputTokens += row.usageMetadata.output_tokens + } + } + + return result +} + +function tokenStep(range: TokenRange, start: Date, end: Date) { + if (range === 'day') return 2 * Time.hour + if (range === 'month') return 2 * Time.day + if (range === 'all') { + return Math.max( + Time.day, + Math.ceil((+end - +start) / Time.day / 15) * Time.day + ) + } + return Time.day +} From be6f3f0ef55de7c9a86fd63fd4fd068ee4b72c46 Mon Sep 17 00:00:00 2001 From: ProcyonNAN <3189960265@qq.com> Date: Mon, 8 Jun 2026 23:14:12 +0800 Subject: [PATCH 4/8] =?UTF-8?q?fix(packages):=20=E4=BF=AE=E5=A4=8D=20usage?= =?UTF-8?q?=20tokens=20=E6=B8=B2=E6=9F=93=E5=AE=A1=E6=9F=A5=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除 usage 控制台向后端同步主题的逻辑,并删除服务端进程级主题状态,避免 tokens 命令在 auto 主题下受其他控制台连接影响。 调整 tokens 趋势分桶的起始时间对齐逻辑,使分桶基准与图表标签保持一致,避免整点或整日标签与实际桶范围错位。 --- packages/extension-usage/client/index.ts | 15 +-------------- packages/extension-usage/src/index.ts | 13 ++----------- packages/extension-usage/src/tokens.ts | 12 ++++++++++-- 3 files changed, 13 insertions(+), 27 deletions(-) diff --git a/packages/extension-usage/client/index.ts b/packages/extension-usage/client/index.ts index 2a9d74ffa..3257719f8 100644 --- a/packages/extension-usage/client/index.ts +++ b/packages/extension-usage/client/index.ts @@ -1,22 +1,9 @@ -import { Context, send, socket, useColorMode } from '@koishijs/client' -import { watch } from 'vue' +import { Context } from '@koishijs/client' import type {} from 'koishi-plugin-chatluna-usage' import charts from './charts' import home from './home.vue' export default (ctx: Context) => { - const mode = useColorMode() - - ctx.effect(() => - watch( - [mode, socket], - () => { - if (socket.value) send('chatluna-usage/theme', mode.value) - }, - { immediate: true } - ) - ) - ctx.plugin(charts) ctx.slot({ diff --git a/packages/extension-usage/src/index.ts b/packages/extension-usage/src/index.ts index 555e857e9..b5668da8a 100644 --- a/packages/extension-usage/src/index.ts +++ b/packages/extension-usage/src/index.ts @@ -21,10 +21,9 @@ import type { } from './tokens' const logger = new Logger('chatluna-usage') +const DEFAULT_TOKEN_THEME: Exclude = 'light' class ChatLunaUsage extends DataService { - private _theme: 'light' | 'dark' = 'light' - constructor( ctx: Context, public config: ChatLunaUsage.Config @@ -118,11 +117,6 @@ class ChatLunaUsage extends DataService { this.list(input) ) - ctx.console.addListener('chatluna-usage/theme', async (theme) => { - if (theme === 'light' || theme === 'dark') this._theme = theme - return { success: true } - }) - ctx.console.addListener( 'chatluna-usage/cleanup', async (before) => { @@ -332,7 +326,7 @@ class ChatLunaUsage extends DataService { const theme = this.config.tokensTheme === 'auto' - ? this._theme + ? DEFAULT_TOKEN_THEME : this.config.tokensTheme const image = await renderTokenTrend( this.ctx, @@ -784,9 +778,6 @@ declare module '@koishijs/plugin-console' { 'chatluna-usage/list': ( input?: ChatLunaUsage.Query ) => Promise - 'chatluna-usage/theme': ( - theme: 'light' | 'dark' - ) => Promise 'chatluna-usage/cleanup': ( before?: string ) => Promise diff --git a/packages/extension-usage/src/tokens.ts b/packages/extension-usage/src/tokens.ts index 04035c74f..3cf1ce2bf 100644 --- a/packages/extension-usage/src/tokens.ts +++ b/packages/extension-usage/src/tokens.ts @@ -147,9 +147,10 @@ function tokenPoints( const hourly = range === 'day' const step = tokenStep(range, start, end) + const alignedStart = tokenAlignedStart(range, start) const result: TokenPoint[] = [] - for (let at = +start; at < +end; at += step) { + for (let at = +alignedStart; at < +end; at += step) { const date = new Date(at) const m = String(date.getMonth() + 1).padStart(2, '0') const d = String(date.getDate()).padStart(2, '0') @@ -163,7 +164,7 @@ function tokenPoints( } for (const row of rows) { - const idx = Math.floor((+row.createdAt - +start) / step) + const idx = Math.floor((+row.createdAt - +alignedStart) / step) if (result[idx]) { result[idx].tokens += row.usageMetadata.total_tokens result[idx].inputTokens += row.usageMetadata.input_tokens @@ -174,6 +175,13 @@ function tokenPoints( return result } +function tokenAlignedStart(range: TokenRange, start: Date) { + const result = new Date(start) + if (range === 'day') result.setMinutes(0, 0, 0) + else result.setHours(0, 0, 0, 0) + return result +} + function tokenStep(range: TokenRange, start: Date, end: Date) { if (range === 'day') return 2 * Time.hour if (range === 'month') return 2 * Time.day From 15bd147f3463fbb67f357cb8fe6a4a35702018ce Mon Sep 17 00:00:00 2001 From: dingyi Date: Tue, 9 Jun 2026 02:27:20 +0800 Subject: [PATCH 5/8] fix(extension-usage): simplify token report rendering --- .../extension-usage/src/html/jsx-runtime.ts | 65 ----- packages/extension-usage/src/index.ts | 165 +++++-------- packages/extension-usage/src/renderer.tsx | 233 +++++++++--------- packages/extension-usage/src/tokens.ts | 201 ++++++--------- 4 files changed, 263 insertions(+), 401 deletions(-) delete mode 100644 packages/extension-usage/src/html/jsx-runtime.ts diff --git a/packages/extension-usage/src/html/jsx-runtime.ts b/packages/extension-usage/src/html/jsx-runtime.ts deleted file mode 100644 index 89a0be0e6..000000000 --- a/packages/extension-usage/src/html/jsx-runtime.ts +++ /dev/null @@ -1,65 +0,0 @@ -type Child = RawHtml | string | number | boolean | null | undefined | Child[] -type Props = Record & { children?: Child } -type NodeType = string | typeof Fragment - -export interface RawHtml { - __html: string -} - -export const Fragment = Symbol('Fragment') - -export function raw(value: string): RawHtml { - return { __html: value } -} - -export function renderHtml(value: Child) { - return render(value) -} - -export function jsx(type: NodeType, props: Props = {}, _key?: string): RawHtml { - if (type === Fragment) return raw(render(props.children)) - - const attrs = Object.entries(props) - .filter(([key]) => key !== 'children') - .map(([key, value]) => attr(key, value)) - .join('') - - return raw(`<${type}${attrs}>${render(props.children)}`) -} - -export const jsxs = jsx - -function attr(key: string, value: unknown) { - if (value == null || value === false) return '' - if (value === true) return ` ${key}` - return ` ${key}="${escapeHtml(String(value))}"` -} - -function render(value: Child): string { - if (Array.isArray(value)) return value.map((item) => render(item)).join('') - if (value == null || value === false || value === true) return '' - if (isRaw(value)) return value.__html - return escapeHtml(String(value)) -} - -function isRaw(value: unknown): value is RawHtml { - return typeof value === 'object' && value !== null && '__html' in value -} - -function escapeHtml(value: string) { - return value - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", ''') -} - -// eslint-disable-next-line @typescript-eslint/no-namespace -export namespace JSX { - export type Element = RawHtml - - export interface IntrinsicElements { - [name: string]: Record - } -} diff --git a/packages/extension-usage/src/index.ts b/packages/extension-usage/src/index.ts index 716654da5..a983eae67 100644 --- a/packages/extension-usage/src/index.ts +++ b/packages/extension-usage/src/index.ts @@ -6,22 +6,32 @@ import type { UsageMetadata } from '@langchain/core/messages' import type { ModelUsageCallType } from 'koishi-plugin-chatluna/llm-core/platform/usage' import type {} from 'koishi-plugin-puppeteer' import { renderTokenTrend } from './renderer' -import { - createTokenReport, - formatTokenReport, - tokenRange, - tokenStart -} from './tokens' -import type { - PluginUsage as UsagePluginUsage, - TokenPoint as UsageTokenPoint, - TokenRange as UsageTokenRange, - TokenReport as UsageTokenReport, - TokenTheme as UsageTokenTheme -} from './tokens' +import { createTokenReport, formatTokenReport } from './tokens' +import type { TokenRange, TokenTheme } from './tokens' const logger = new Logger('chatluna-usage') -const DEFAULT_TOKEN_THEME: Exclude = 'light' + +function summary( + key: string, + label = key, + platform?: string +): ChatLunaUsage.Summary { + return { + key, + label, + platform, + calls: 0, + successfulCalls: 0, + failedCalls: 0, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + estimatedTokens: 0, + cachedTokens: 0, + reasoningTokens: 0, + successRate: 0 + } +} class ChatLunaUsage extends DataService { constructor( @@ -153,67 +163,21 @@ class ChatLunaUsage extends DataService { const sources = new Map() const timeline = new Map() const modelTimeline = new Map>() - const totals: ChatLunaUsage.Summary = { - key: 'total', - label: '全部用量', - calls: 0, - successfulCalls: 0, - failedCalls: 0, - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - estimatedTokens: 0, - cachedTokens: 0, - reasoningTokens: 0, - successRate: 0 - } + const totals = summary('total', '全部用量') for (const row of rows) { const key = this.groupKey(row, groupBy) - const item = groups.get(key) ?? { - key, - label: this.groupLabel(key, groupBy), - platform: groupBy === 'model' ? row.platform : undefined, - calls: 0, - successfulCalls: 0, - failedCalls: 0, - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - estimatedTokens: 0, - cachedTokens: 0, - reasoningTokens: 0, - successRate: 0 - } - const model = models.get(row.model) ?? { - key: row.model, - label: row.model, - platform: row.platform, - calls: 0, - successfulCalls: 0, - failedCalls: 0, - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - estimatedTokens: 0, - cachedTokens: 0, - reasoningTokens: 0, - successRate: 0 - } - const source = sources.get(row.source) ?? { - key: row.source, - label: row.source, - calls: 0, - successfulCalls: 0, - failedCalls: 0, - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - estimatedTokens: 0, - cachedTokens: 0, - reasoningTokens: 0, - successRate: 0 - } + const item = + groups.get(key) ?? + summary( + key, + this.groupLabel(key, groupBy), + groupBy === 'model' ? row.platform : undefined + ) + const model = + models.get(row.model) ?? + summary(row.model, row.model, row.platform) + const source = sources.get(row.source) ?? summary(row.source) const date = this.dateKey(row.createdAt, input.period ?? 'day') const point = timeline.get(date) ?? { date, @@ -298,21 +262,31 @@ class ChatLunaUsage extends DataService { options: ChatLunaUsage.TokenCommandOptions, args: string[] ) { - let range: ChatLunaUsage.TokenRange | undefined - if (options.all) range = 'all' - else if (options.month) range = 'month' - else if (options.week) range = 'week' - else if (options.day) range = 'day' - + let range: TokenRange = options.all + ? 'all' + : options.month + ? 'month' + : options.week + ? 'week' + : 'day' let plugin = Boolean(options.plugin) for (const arg of args) { - const value = tokenRange(arg) + const keyword = arg.replace(/^-+/, '').trim().toLowerCase() + const value = { + d: 'day', + day: 'day', + w: 'week', + week: 'week', + m: 'month', + month: 'month', + a: 'all', + all: 'all' + }[keyword] as TokenRange if (value) { range = value continue } - const keyword = arg.replace(/^-+/, '').trim().toLowerCase() if (keyword === 'p' || keyword === 'plugin') { plugin = true continue @@ -321,7 +295,7 @@ class ChatLunaUsage extends DataService { } try { - const report = await this.tokenReport(range ?? 'day', plugin) + const report = await this.tokenReport(range, plugin) await session.send(formatTokenReport(report)) const puppeteer = this.ctx.get('puppeteer') @@ -330,15 +304,13 @@ class ChatLunaUsage extends DataService { return } - const theme = - this.config.tokensTheme === 'auto' - ? DEFAULT_TOKEN_THEME - : this.config.tokensTheme const image = await renderTokenTrend( this.ctx, puppeteer, report, - theme + this.config.tokensTheme === 'auto' + ? 'light' + : this.config.tokensTheme ) await session.send( typeof image === 'string' @@ -351,12 +323,17 @@ class ChatLunaUsage extends DataService { } } - private async tokenReport( - range: ChatLunaUsage.TokenRange, - withPlugins = false - ) { + private async tokenReport(range: TokenRange, withPlugins = false) { const end = new Date() - const start = tokenStart(range, end) + const start = new Date( + +end - + { + day: Time.day, + week: 7 * Time.day, + month: 30 * Time.day, + all: 0 + }[range] + ) const time = range === 'all' ? { $lt: end } : { $gte: start, $lt: end } const rows = (await this.ctx.database.get('chatluna_usage', { createdAt: time @@ -568,8 +545,6 @@ namespace ChatLunaUsage { } export type Period = 'day' | 'month' | 'year' - export type TokenRange = UsageTokenRange - export type TokenTheme = UsageTokenTheme export type GroupBy = | 'source' | 'model' @@ -661,10 +636,6 @@ namespace ChatLunaUsage { rows: ListRow[] } - export type TokenPoint = UsageTokenPoint - export type PluginUsage = UsagePluginUsage - export type TokenReport = UsageTokenReport - export interface Payload { query: Required< Pick< diff --git a/packages/extension-usage/src/renderer.tsx b/packages/extension-usage/src/renderer.tsx index f87b8f6c0..bfa88937f 100644 --- a/packages/extension-usage/src/renderer.tsx +++ b/packages/extension-usage/src/renderer.tsx @@ -1,7 +1,5 @@ -/** @jsxImportSource ./html */ import type { Context } from 'koishi' import type {} from 'koishi-plugin-puppeteer' -import { raw, renderHtml } from './html/jsx-runtime' import { formatDate } from './tokens' import type { TokenPoint, TokenReport, TokenTheme } from './tokens' @@ -322,12 +320,7 @@ function monotonePath(pts: Coord[]) { function chart(points: TokenPoint[]) { if (!points.length) return
暂无用量数据
- const width = 968 - const height = 360 - const left = 78 - const right = 26 - const top = 30 - const bottom = 56 + const [width, height, left, right, top, bottom] = [968, 360, 78, 26, 30, 56] const plotWidth = width - left - right const plotHeight = height - top - bottom const baseline = top + plotHeight @@ -335,9 +328,9 @@ function chart(points: TokenPoint[]) { 1, ...points.flatMap((p) => [p.tokens, p.inputTokens, p.outputTokens]) ) - const makeCoords = ( - key: 'tokens' | 'inputTokens' | 'outputTokens' - ): Coord[] => + const [totalCoords, inputCoords, outputCoords] = ( + ['tokens', 'inputTokens', 'outputTokens'] as const + ).map((key) => points.map((point, idx) => ({ x: points.length === 1 @@ -346,116 +339,101 @@ function chart(points: TokenPoint[]) { y: baseline - (point[key] / max) * plotHeight, point })) - - const totalCoords = makeCoords('tokens') - const inputCoords = makeCoords('inputTokens') - const outputCoords = makeCoords('outputTokens') - const totalLine = monotonePath(totalCoords) - const inputLine = monotonePath(inputCoords) - const outputLine = monotonePath(outputCoords) + ) + const [totalLine, inputLine, outputLine] = [ + totalCoords, + inputCoords, + outputCoords + ].map(monotonePath) const area = totalLine ? `${totalLine} L${totalCoords[totalCoords.length - 1].x},${baseline} L${totalCoords[0].x},${baseline} Z` : '' const size = points.length > 24 ? 10 : 12 - return ( - <> - - - - - - - - - - - - - {Array.from({ length: 5 }, (_, idx) => { - const y = top + (plotHeight * idx) / 4 - const value = Math.round(max - (max * idx) / 4) - return ( - <> - - - {fmt(value)} - - - ) - })} - - - - - - {[ - ['input', inputCoords] as const, - ['output', outputCoords] as const, - ['total', totalCoords] as const - ].flatMap(([name, coords]) => - coords.map((c, _, arr) => ( + return [ + + + + + + + + + + + + + {Array.from({ length: 5 }, (_, idx) => { + const y = top + (plotHeight * idx) / 4 + const value = Math.round(max - (max * idx) / 4) + return [ + , + + {fmt(value)} + + ] + })} + + + + + + {[ + ['input', inputCoords] as const, + ['output', outputCoords] as const, + ['total', totalCoords] as const + ].flatMap(([name, coords]) => + coords.map((c, idx) => { + const last = idx === coords.length - 1 + return ( - )) - )} - {totalCoords.map((c) => { - const parts = c.point.label.split(' ') - if (parts.length < 2) { - return ( - - {c.point.label} - - ) - } - return ( - - - {parts[0]} - - - {parts[1]} - - ) - })} - -
- - 总 token - - - 输入 token - - - 输出 token - -
- - ) + }) + )} + {totalCoords.map((c) => { + const parts = c.point.label.split(' ') + return ( + + {parts[1] + ? parts.map((part, idx) => ( + + {part} + + )) + : c.point.label} + + ) + })} + , +
+ + 总 token + + + 输入 token + + + 输出 token + +
+ ] } function trendIcon() { return ( - + - + + - + ) } @@ -522,7 +516,6 @@ function pluginCard(plugins?: TokenReport['plugins']) { const [accent, accent2] = PLUGIN_COLORS[idx % PLUGIN_COLORS.length] const ratio = (plugin.tokens / total) * 100 - const width = Math.max(2, Math.min(100, ratio)) return (
- {ratio.toFixed(1)}% · {fmt(plugin.tokens)} token ·{' '} - {fmt(plugin.calls)} 次 + {ratio.toFixed(1)}% ·{' '} + {fmt(plugin.tokens)} token · {fmt(plugin.calls)}{' '} + 次
@@ -553,13 +547,16 @@ function pluginCard(plugins?: TokenReport['plugins']) { function pageHtml(data: TokenReport, theme: RenderTheme) { return ( '' + - renderHtml( + String( - + Chatluna token 消耗趋势 - +
@@ -574,7 +571,9 @@ function pageHtml(data: TokenReport, theme: RenderTheme) {

-
{chart(data.points)}
+
+ {chart(data.points)} +
{pluginCard(data.plugins)} @@ -607,6 +606,6 @@ export async function renderTokenTrend( ctx.logger.error(err) return '图表渲染失败,请检查日志。' } finally { - await page?.close().catch((err) => ctx.logger.warn(err)) + await page?.close() } } diff --git a/packages/extension-usage/src/tokens.ts b/packages/extension-usage/src/tokens.ts index 3cf1ce2bf..43fa680a4 100644 --- a/packages/extension-usage/src/tokens.ts +++ b/packages/extension-usage/src/tokens.ts @@ -30,45 +30,27 @@ export interface TokenReport { plugins?: PluginUsage[] } -const RANGES: Record = { - d: 'day', - day: 'day', - w: 'week', - week: 'week', - m: 'month', - month: 'month', - a: 'all', - all: 'all' -} +const RANGES = { + day: ['天', 2 * Time.hour], + week: ['周', Time.day], + month: ['月', 2 * Time.day], + all: ['全部', 0] +} as const -export function tokenRange(value: string): TokenRange | undefined { - return RANGES[value.replace(/^-+/, '').trim().toLowerCase()] -} - -export function tokenStart(range: TokenRange, end: Date) { - if (range === 'day') return new Date(+end - Time.day) - if (range === 'week') return new Date(+end - 7 * Time.day) - if (range === 'month') return new Date(+end - 30 * Time.day) - return end -} +const pad = (value: number) => String(value).padStart(2, '0') export function formatDate(date: Date) { - const y = date.getFullYear() - const m = String(date.getMonth() + 1).padStart(2, '0') - const d = String(date.getDate()).padStart(2, '0') - const h = String(date.getHours()).padStart(2, '0') - const min = String(date.getMinutes()).padStart(2, '0') - return `${y}-${m}-${d} ${h}:${min}` + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` } export function formatTokenReport(report: TokenReport) { return [ `Chatluna token 用量(${report.label})`, `时间范围:${formatDate(report.start)} 至 ${formatDate(report.end)}`, - `累计 token:${formatNumber(report.totalTokens)}`, - `累计请求:${formatNumber(report.calls)}次`, - `TPM:${formatNumber(report.tpm)}`, - `RPM:${formatNumber(report.rpm)}次` + `累计 token:${report.totalTokens.toLocaleString('en-US')}`, + `累计请求:${report.calls.toLocaleString('en-US')}次`, + `TPM:${report.tpm.toLocaleString('en-US')}`, + `RPM:${report.rpm.toLocaleString('en-US')}次` ].join('\n') } @@ -81,115 +63,90 @@ export function createTokenReport( ): TokenReport { const sorted = rows.slice().sort((a, b) => +a.createdAt - +b.createdAt) const from = range === 'all' ? (sorted[0]?.createdAt ?? end) : start - const minutes = new Map() + const step = + range === 'all' + ? Math.max( + Time.day, + Math.ceil((+end - +from) / Time.day / 15) * Time.day + ) + : RANGES[range][1] + const aligned = new Date(from) + const plugins = new Map() let totalTokens = 0 let tpm = 0 let rpm = 0 + let minute = -1 + let minuteTokens = 0 + let minuteCalls = 0 + + if (range === 'day') aligned.setMinutes(0, 0, 0) + else aligned.setHours(0, 0, 0, 0) + + const points: TokenPoint[] = sorted.length + ? Array.from( + { length: Math.ceil((+end - +aligned) / step) }, + (_, i) => { + const date = new Date(+aligned + i * step) + const label = `${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + return { + label: + range === 'day' + ? `${label} ${pad(date.getHours())}:00` + : label, + tokens: 0, + inputTokens: 0, + outputTokens: 0 + } + } + ) + : [] for (const row of sorted) { const tokens = row.usageMetadata.total_tokens const key = Math.floor(+row.createdAt / Time.minute) * Time.minute - const item = minutes.get(key) ?? { tokens: 0, calls: 0 } - item.tokens += tokens - item.calls += 1 + const point = points[Math.floor((+row.createdAt - +aligned) / step)] + if (key === minute) { + minuteTokens += tokens + minuteCalls += 1 + } else { + tpm = Math.max(tpm, minuteTokens) + rpm = Math.max(rpm, minuteCalls) + minute = key + minuteTokens = tokens + minuteCalls = 1 + } totalTokens += tokens - if (item.tokens > tpm) tpm = item.tokens - if (item.calls > rpm) rpm = item.calls - minutes.set(key, item) + if (point) { + point.tokens += tokens + point.inputTokens += row.usageMetadata.input_tokens + point.outputTokens += row.usageMetadata.output_tokens + } + if (withPlugins) { + const plugin = plugins.get(row.source) ?? { + source: row.source, + tokens: 0, + calls: 0 + } + plugin.tokens += tokens + plugin.calls += 1 + plugins.set(row.source, plugin) + } } + tpm = Math.max(tpm, minuteTokens) + rpm = Math.max(rpm, minuteCalls) return { range, - label: rangeLabel(range), + label: RANGES[range][0], start: from, end, totalTokens, calls: sorted.length, tpm, rpm, - points: tokenPoints(range, from, end, sorted), - plugins: withPlugins ? pluginUsage(sorted) : undefined - } -} - -function rangeLabel(range: TokenRange) { - if (range === 'day') return '天' - if (range === 'week') return '周' - if (range === 'month') return '月' - return '全部' -} - -function formatNumber(value: number) { - return value.toLocaleString('en-US') -} - -function pluginUsage(rows: ChatLunaUsage.Record[]): PluginUsage[] { - const map = new Map() - - for (const row of rows) { - const source = row.source || 'unknown' - const item = map.get(source) ?? { source, tokens: 0, calls: 0 } - item.tokens += row.usageMetadata.total_tokens - item.calls += 1 - map.set(source, item) - } - - return [...map.values()].sort((a, b) => b.tokens - a.tokens) -} - -function tokenPoints( - range: TokenRange, - start: Date, - end: Date, - rows: ChatLunaUsage.Record[] -) { - if (!rows.length) return [] - - const hourly = range === 'day' - const step = tokenStep(range, start, end) - const alignedStart = tokenAlignedStart(range, start) - const result: TokenPoint[] = [] - - for (let at = +alignedStart; at < +end; at += step) { - const date = new Date(at) - const m = String(date.getMonth() + 1).padStart(2, '0') - const d = String(date.getDate()).padStart(2, '0') - const h = String(date.getHours()).padStart(2, '0') - result.push({ - label: hourly ? `${m}-${d} ${h}:00` : `${m}-${d}`, - tokens: 0, - inputTokens: 0, - outputTokens: 0 - }) - } - - for (const row of rows) { - const idx = Math.floor((+row.createdAt - +alignedStart) / step) - if (result[idx]) { - result[idx].tokens += row.usageMetadata.total_tokens - result[idx].inputTokens += row.usageMetadata.input_tokens - result[idx].outputTokens += row.usageMetadata.output_tokens - } - } - - return result -} - -function tokenAlignedStart(range: TokenRange, start: Date) { - const result = new Date(start) - if (range === 'day') result.setMinutes(0, 0, 0) - else result.setHours(0, 0, 0, 0) - return result -} - -function tokenStep(range: TokenRange, start: Date, end: Date) { - if (range === 'day') return 2 * Time.hour - if (range === 'month') return 2 * Time.day - if (range === 'all') { - return Math.max( - Time.day, - Math.ceil((+end - +start) / Time.day / 15) * Time.day - ) + points, + plugins: withPlugins + ? [...plugins.values()].sort((a, b) => b.tokens - a.tokens) + : undefined } - return Time.day } From 6982e2e752bb502aed8297bab02b701a9be26523 Mon Sep 17 00:00:00 2001 From: dingyi Date: Wed, 10 Jun 2026 00:47:32 +0800 Subject: [PATCH 6/8] fix(extension-usage): extract usage utility types --- packages/extension-usage/src/index.ts | 269 +++------------------- packages/extension-usage/src/renderer.tsx | 14 +- packages/extension-usage/src/tokens.ts | 41 +--- packages/extension-usage/src/utils.ts | 252 ++++++++++++++++++++ 4 files changed, 291 insertions(+), 285 deletions(-) create mode 100644 packages/extension-usage/src/utils.ts diff --git a/packages/extension-usage/src/index.ts b/packages/extension-usage/src/index.ts index a983eae67..53e1eb748 100644 --- a/packages/extension-usage/src/index.ts +++ b/packages/extension-usage/src/index.ts @@ -1,39 +1,15 @@ import { resolve } from 'path' -import { Context, h, Logger, Schema, Time } from 'koishi' +import { Context, h, Logger, Time } from 'koishi' import type { Session } from 'koishi' import { DataService } from '@koishijs/plugin-console' -import type { UsageMetadata } from '@langchain/core/messages' -import type { ModelUsageCallType } from 'koishi-plugin-chatluna/llm-core/platform/usage' +import { ChatLunaUsage, summary } from './utils' import type {} from 'koishi-plugin-puppeteer' import { renderTokenTrend } from './renderer' import { createTokenReport, formatTokenReport } from './tokens' -import type { TokenRange, TokenTheme } from './tokens' const logger = new Logger('chatluna-usage') -function summary( - key: string, - label = key, - platform?: string -): ChatLunaUsage.Summary { - return { - key, - label, - platform, - calls: 0, - successfulCalls: 0, - failedCalls: 0, - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - estimatedTokens: 0, - cachedTokens: 0, - reasoningTokens: 0, - successRate: 0 - } -} - -class ChatLunaUsage extends DataService { +class ChatLunaUsageService extends DataService { constructor( ctx: Context, public config: ChatLunaUsage.Config @@ -262,13 +238,18 @@ class ChatLunaUsage extends DataService { options: ChatLunaUsage.TokenCommandOptions, args: string[] ) { - let range: TokenRange = options.all - ? 'all' - : options.month - ? 'month' - : options.week - ? 'week' - : 'day' + let range: ChatLunaUsage.TokenRange + + if (options.all) { + range = 'all' + } else if (options.month) { + range = 'month' + } else if (options.week) { + range = 'week' + } else { + range = 'day' + } + let plugin = Boolean(options.plugin) for (const arg of args) { @@ -282,7 +263,7 @@ class ChatLunaUsage extends DataService { month: 'month', a: 'all', all: 'all' - }[keyword] as TokenRange + }[keyword] as ChatLunaUsage.TokenRange if (value) { range = value continue @@ -323,7 +304,10 @@ class ChatLunaUsage extends DataService { } } - private async tokenReport(range: TokenRange, withPlugins = false) { + private async tokenReport( + range: ChatLunaUsage.TokenRange, + withPlugins = false + ) { const end = new Date() const start = new Date( +end - @@ -354,10 +338,7 @@ class ChatLunaUsage extends DataService { if (query.success != null) where.success = query.success if (query.estimated != null) where.estimated = query.estimated - const rows = (await this.ctx.database.get( - 'chatluna_usage', - where - )) as ChatLunaUsage.Record[] + const rows = await this.ctx.database.get('chatluna_usage', where) if ( !query.chatPlatform && @@ -516,209 +497,11 @@ class ChatLunaUsage extends DataService { } } -// eslint-disable-next-line @typescript-eslint/no-namespace -namespace ChatLunaUsage { - export interface Record { - id?: number - source: string - callType: ModelUsageCallType - platform: string - chatPlatform?: string | null - model: string - usageMetadata: UsageMetadata - estimated: boolean - success: boolean - createdAt: Date - conversationId?: string | null - requestId?: string | null - userId?: string | null - guildId?: string | null - } - - export interface ListRow extends Record { - inputTokens: number - outputTokens: number - totalTokens: number - estimated: boolean - cachedTokens: number - reasoningTokens: number - } - - export type Period = 'day' | 'month' | 'year' - export type GroupBy = - | 'source' - | 'model' - | 'guild' - | 'platform' - | 'chatPlatform' - | 'callType' - export type SortBy = - | 'calls' - | 'successfulCalls' - | 'failedCalls' - | 'inputTokens' - | 'outputTokens' - | 'totalTokens' - | 'estimatedTokens' - | 'cachedTokens' - | 'reasoningTokens' - | 'successRate' - export type ListSortBy = - | 'createdAt' - | 'inputTokens' - | 'outputTokens' - | 'totalTokens' - | 'cachedTokens' - | 'reasoningTokens' - - export interface Query { - period?: Period - start?: string | Date - end?: string | Date - groupBy?: GroupBy - sortBy?: SortBy - desc?: boolean - page?: number - pageSize?: number - listSortBy?: ListSortBy - listDesc?: boolean - source?: string - model?: string - platform?: string - chatPlatform?: string - callType?: ModelUsageCallType - guildId?: string - userId?: string - success?: boolean - estimated?: boolean - keyword?: string - } - - export interface Summary { - key: string - label: string - platform?: string - calls: number - successfulCalls: number - failedCalls: number - inputTokens: number - outputTokens: number - totalTokens: number - estimatedTokens: number - cachedTokens: number - reasoningTokens: number - successRate: number - lastSeen?: Date - } - - export interface Timeline { - date: string - calls: number - inputTokens: number - outputTokens: number - totalTokens: number - cachedTokens: number - reasoningTokens: number - } - - export interface ModelTimeline { - model: string - points: { - date: string - calls: number - }[] - } - - export interface List { - total: number - page: number - pageSize: number - rows: ListRow[] - } - - export interface Payload { - query: Required< - Pick< - Query, - | 'period' - | 'groupBy' - | 'sortBy' - | 'desc' - | 'page' - | 'pageSize' - | 'listSortBy' - | 'listDesc' - > - > & { - start: Date - end: Date - } & Query - totals: Summary - groups: Summary[] - models: Summary[] - sources: Summary[] - timeline: Timeline[] - modelTimeline: ModelTimeline[] - list: List - } - - export interface Config { - recentDays: number - pageSize: number - webui: boolean - tokensTheme: TokenTheme - } - - export interface TokenCommandOptions { - day?: boolean - week?: boolean - month?: boolean - all?: boolean - plugin?: boolean - } - - export interface ActionResult { - success: boolean - } - - export const Config: Schema = Schema.object({ - recentDays: Schema.natural() - .description('默认统计最近几天的数据。') - .default(30), - pageSize: Schema.natural() - .description('调用明细分页大小。') - .default(50), - webui: Schema.boolean() - .description('启用 Web UI 控制台用量面板。') - .default(true), - tokensTheme: Schema.union([ - Schema.const('auto').description('自动'), - Schema.const('light').description('浅色模式'), - Schema.const('dark').description('深色模式') - ]) - .description('tokens命令渲染出的图表颜色主题') - .default('auto') - .role('select') - }) - - export const inject = ['chatluna', 'database'] -} - -export default ChatLunaUsage -export { ChatLunaUsage } - -export async function queryUsage(ctx: Context, source?: string) { - const result = await ctx.chatluna_usage.query({ groupBy: 'source' }) - if (!source) return result.groups - return result.groups.filter((row) => row.key === source) -} - -export async function cleanupUsage(ctx: Context, before?: Date) { - await ctx.chatluna_usage.cleanup(before) -} +export default ChatLunaUsageService +export { ChatLunaUsageService as ChatLunaUsage } export function apply(ctx: Context, config: ChatLunaUsage.Config) { - ctx.plugin(ChatLunaUsage, config) + ctx.plugin(ChatLunaUsageService, config) } export const Config = ChatLunaUsage.Config @@ -732,7 +515,7 @@ export const name = 'chatluna-usage' declare module 'koishi' { interface Context { - chatluna_usage: ChatLunaUsage + chatluna_usage: ChatLunaUsageService } interface Tables { @@ -744,7 +527,7 @@ declare module '@koishijs/plugin-console' { // eslint-disable-next-line @typescript-eslint/no-namespace namespace Console { interface Services { - chatluna_usage: ChatLunaUsage + chatluna_usage: ChatLunaUsageService } } diff --git a/packages/extension-usage/src/renderer.tsx b/packages/extension-usage/src/renderer.tsx index bfa88937f..05331ca65 100644 --- a/packages/extension-usage/src/renderer.tsx +++ b/packages/extension-usage/src/renderer.tsx @@ -1,15 +1,15 @@ import type { Context } from 'koishi' import type {} from 'koishi-plugin-puppeteer' import { formatDate } from './tokens' -import type { TokenPoint, TokenReport, TokenTheme } from './tokens' +import type { ChatLunaUsage } from './utils' interface Coord { x: number y: number - point: TokenPoint + point: ChatLunaUsage.TokenPoint } -type RenderTheme = Exclude +type RenderTheme = Exclude const CSS = ` :root { @@ -317,7 +317,7 @@ function monotonePath(pts: Coord[]) { return d } -function chart(points: TokenPoint[]) { +function chart(points: ChatLunaUsage.TokenPoint[]) { if (!points.length) return
暂无用量数据
const [width, height, left, right, top, bottom] = [968, 360, 78, 26, 30, 56] @@ -497,7 +497,7 @@ function pluginIcon() { ) } -function pluginCard(plugins?: TokenReport['plugins']) { +function pluginCard(plugins?: ChatLunaUsage.TokenReport['plugins']) { if (!plugins?.length) return '' const total = plugins.reduce((sum, p) => sum + p.tokens, 0) || 1 @@ -544,7 +544,7 @@ function pluginCard(plugins?: TokenReport['plugins']) { ) } -function pageHtml(data: TokenReport, theme: RenderTheme) { +function pageHtml(data: ChatLunaUsage.TokenReport, theme: RenderTheme) { return ( '' + String( @@ -586,7 +586,7 @@ function pageHtml(data: TokenReport, theme: RenderTheme) { export async function renderTokenTrend( ctx: Context, puppeteer: Context['puppeteer'], - data: TokenReport, + data: ChatLunaUsage.TokenReport, theme: RenderTheme = 'light' ) { let page: Awaited> | undefined diff --git a/packages/extension-usage/src/tokens.ts b/packages/extension-usage/src/tokens.ts index 43fa680a4..3d55b2e58 100644 --- a/packages/extension-usage/src/tokens.ts +++ b/packages/extension-usage/src/tokens.ts @@ -1,34 +1,5 @@ import { Time } from 'koishi' -import type { ChatLunaUsage } from './index' - -export type TokenRange = 'day' | 'week' | 'month' | 'all' -export type TokenTheme = 'auto' | 'light' | 'dark' - -export interface TokenPoint { - label: string - tokens: number - inputTokens: number - outputTokens: number -} - -export interface PluginUsage { - source: string - tokens: number - calls: number -} - -export interface TokenReport { - range: TokenRange - label: string - start: Date - end: Date - totalTokens: number - calls: number - tpm: number - rpm: number - points: TokenPoint[] - plugins?: PluginUsage[] -} +import type { ChatLunaUsage } from './utils' const RANGES = { day: ['天', 2 * Time.hour], @@ -43,7 +14,7 @@ export function formatDate(date: Date) { return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` } -export function formatTokenReport(report: TokenReport) { +export function formatTokenReport(report: ChatLunaUsage.TokenReport) { return [ `Chatluna token 用量(${report.label})`, `时间范围:${formatDate(report.start)} 至 ${formatDate(report.end)}`, @@ -55,12 +26,12 @@ export function formatTokenReport(report: TokenReport) { } export function createTokenReport( - range: TokenRange, + range: ChatLunaUsage.TokenRange, start: Date, end: Date, rows: ChatLunaUsage.Record[], withPlugins = false -): TokenReport { +): ChatLunaUsage.TokenReport { const sorted = rows.slice().sort((a, b) => +a.createdAt - +b.createdAt) const from = range === 'all' ? (sorted[0]?.createdAt ?? end) : start const step = @@ -71,7 +42,7 @@ export function createTokenReport( ) : RANGES[range][1] const aligned = new Date(from) - const plugins = new Map() + const plugins = new Map() let totalTokens = 0 let tpm = 0 let rpm = 0 @@ -82,7 +53,7 @@ export function createTokenReport( if (range === 'day') aligned.setMinutes(0, 0, 0) else aligned.setHours(0, 0, 0, 0) - const points: TokenPoint[] = sorted.length + const points: ChatLunaUsage.TokenPoint[] = sorted.length ? Array.from( { length: Math.ceil((+end - +aligned) / step) }, (_, i) => { diff --git a/packages/extension-usage/src/utils.ts b/packages/extension-usage/src/utils.ts new file mode 100644 index 000000000..31127e178 --- /dev/null +++ b/packages/extension-usage/src/utils.ts @@ -0,0 +1,252 @@ +import { Context, Schema } from 'koishi' +import type { UsageMetadata } from '@langchain/core/messages' +import type { ModelUsageCallType } from 'koishi-plugin-chatluna/llm-core/platform/usage' + +export function summary( + key: string, + label = key, + platform?: string +): ChatLunaUsage.Summary { + return { + key, + label, + platform, + calls: 0, + successfulCalls: 0, + failedCalls: 0, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + estimatedTokens: 0, + cachedTokens: 0, + reasoningTokens: 0, + successRate: 0 + } +} + +export async function queryUsage(ctx: Context, source?: string) { + const result = await ctx.chatluna_usage.query({ groupBy: 'source' }) + if (!source) return result.groups + return result.groups.filter((row) => row.key === source) +} + +export async function cleanupUsage(ctx: Context, before?: Date) { + await ctx.chatluna_usage.cleanup(before) +} + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace ChatLunaUsage { + export interface Record { + id?: number + source: string + callType: ModelUsageCallType + platform: string + chatPlatform?: string | null + model: string + usageMetadata: UsageMetadata + estimated: boolean + success: boolean + createdAt: Date + conversationId?: string | null + requestId?: string | null + userId?: string | null + guildId?: string | null + } + + export interface ListRow extends Record { + inputTokens: number + outputTokens: number + totalTokens: number + estimated: boolean + cachedTokens: number + reasoningTokens: number + } + + export type Period = 'day' | 'month' | 'year' + export type GroupBy = + | 'source' + | 'model' + | 'guild' + | 'platform' + | 'chatPlatform' + | 'callType' + export type SortBy = + | 'calls' + | 'successfulCalls' + | 'failedCalls' + | 'inputTokens' + | 'outputTokens' + | 'totalTokens' + | 'estimatedTokens' + | 'cachedTokens' + | 'reasoningTokens' + | 'successRate' + export type ListSortBy = + | 'createdAt' + | 'inputTokens' + | 'outputTokens' + | 'totalTokens' + | 'cachedTokens' + | 'reasoningTokens' + + export interface Query { + period?: Period + start?: string | Date + end?: string | Date + groupBy?: GroupBy + sortBy?: SortBy + desc?: boolean + page?: number + pageSize?: number + listSortBy?: ListSortBy + listDesc?: boolean + source?: string + model?: string + platform?: string + chatPlatform?: string + callType?: ModelUsageCallType + guildId?: string + userId?: string + success?: boolean + estimated?: boolean + keyword?: string + } + + export interface Summary { + key: string + label: string + platform?: string + calls: number + successfulCalls: number + failedCalls: number + inputTokens: number + outputTokens: number + totalTokens: number + estimatedTokens: number + cachedTokens: number + reasoningTokens: number + successRate: number + lastSeen?: Date + } + + export interface Timeline { + date: string + calls: number + inputTokens: number + outputTokens: number + totalTokens: number + cachedTokens: number + reasoningTokens: number + } + + export interface ModelTimeline { + model: string + points: { + date: string + calls: number + }[] + } + + export interface List { + total: number + page: number + pageSize: number + rows: ListRow[] + } + + export type TokenRange = 'day' | 'week' | 'month' | 'all' + export type TokenTheme = 'auto' | 'light' | 'dark' + + export interface TokenPoint { + label: string + tokens: number + inputTokens: number + outputTokens: number + } + + export interface PluginUsage { + source: string + tokens: number + calls: number + } + + export interface TokenReport { + range: TokenRange + label: string + start: Date + end: Date + totalTokens: number + calls: number + tpm: number + rpm: number + points: TokenPoint[] + plugins?: PluginUsage[] + } + + export interface Payload { + query: Required< + Pick< + Query, + | 'period' + | 'groupBy' + | 'sortBy' + | 'desc' + | 'page' + | 'pageSize' + | 'listSortBy' + | 'listDesc' + > + > & { + start: Date + end: Date + } & Query + totals: Summary + groups: Summary[] + models: Summary[] + sources: Summary[] + timeline: Timeline[] + modelTimeline: ModelTimeline[] + list: List + } + + export interface Config { + recentDays: number + pageSize: number + webui: boolean + tokensTheme: TokenTheme + } + + export interface TokenCommandOptions { + day?: boolean + week?: boolean + month?: boolean + all?: boolean + plugin?: boolean + } + + export interface ActionResult { + success: boolean + } + + export const Config: Schema = Schema.object({ + recentDays: Schema.natural() + .description('默认统计最近几天的数据。') + .default(30), + pageSize: Schema.natural() + .description('调用明细分页大小。') + .default(50), + webui: Schema.boolean() + .description('启用 Web UI 控制台用量面板。') + .default(true), + tokensTheme: Schema.union([ + Schema.const('auto').description('自动'), + Schema.const('light').description('浅色模式'), + Schema.const('dark').description('深色模式') + ]) + .description('tokens命令渲染出的图表颜色主题') + .default('auto') + .role('select') + }) + + export const inject = ['chatluna', 'database'] +} From d33492e6af6cce342c148d0c5e177b221cb267af Mon Sep 17 00:00:00 2001 From: ProcyonNAN <3189960265@qq.com> Date: Wed, 10 Jun 2026 12:31:10 +0800 Subject: [PATCH 7/8] =?UTF-8?q?fix(extension-usage):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=E9=87=8F=E6=8F=92=E4=BB=B6=E9=85=8D=E7=BD=AE=E5=A3=B0?= =?UTF-8?q?=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 extension-usage 插件入口导出方式,移除默认导出以避免 Koishi loader 优先解析默认导出时丢失模块级 Config。 为 extension-usage 声明 @satorijs/element 运行时依赖,并在包级 tsconfig 中显式加载其 JSX 类型,确保 renderer.tsx 使用的 Satori JSX 属性能够被正确识别。 影响文件包括 packages/extension-usage/package.json、packages/extension-usage/src/index.ts 和 packages/extension-usage/tsconfig.json。 --- packages/extension-usage/package.json | 1 + packages/extension-usage/src/index.ts | 1 - packages/extension-usage/tsconfig.json | 3 ++- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/extension-usage/package.json b/packages/extension-usage/package.json index 9f4b36be1..9ed47658b 100644 --- a/packages/extension-usage/package.json +++ b/packages/extension-usage/package.json @@ -48,6 +48,7 @@ ], "dependencies": { "@koishijs/plugin-console": "^5.30.11", + "@satorijs/element": "^3.2.0", "echarts": "^5.5.0", "vue-echarts": "^6.6.9" }, diff --git a/packages/extension-usage/src/index.ts b/packages/extension-usage/src/index.ts index 53e1eb748..b3e78fc9c 100644 --- a/packages/extension-usage/src/index.ts +++ b/packages/extension-usage/src/index.ts @@ -497,7 +497,6 @@ class ChatLunaUsageService extends DataService { } } -export default ChatLunaUsageService export { ChatLunaUsageService as ChatLunaUsage } export function apply(ctx: Context, config: ChatLunaUsage.Config) { diff --git a/packages/extension-usage/tsconfig.json b/packages/extension-usage/tsconfig.json index d51f803d5..0969d0620 100644 --- a/packages/extension-usage/tsconfig.json +++ b/packages/extension-usage/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib", + "types": ["node", "@satorijs/element"] }, "include": ["src"] } From 1a9bd8e20690611c1ebb8530801fc6c2d2a2fe27 Mon Sep 17 00:00:00 2001 From: ProcyonNAN <3189960265@qq.com> Date: Wed, 10 Jun 2026 17:33:10 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix(extension-usage):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20tokens=20=E5=9B=BE=E8=A1=A8=E6=B8=B2=E6=9F=93=E9=94=99?= =?UTF-8?q?=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 usage 插件 tokens 图表渲染中的装饰元素闭合问题。 修正 packages/extension-usage/src/renderer.tsx: - 为图例、插件色点和进度条填充元素加入显式文本子节点,避免普通 HTML 元素被序列化为自闭合标签。 - 为装饰元素设置零字号和零行高,保持原有视觉尺寸。 - 修复浏览器解析自闭合普通 HTML 标签时导致的图例换行、插件名称覆盖和进度条裁切问题。 --- packages/extension-usage/src/renderer.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/extension-usage/src/renderer.tsx b/packages/extension-usage/src/renderer.tsx index 05331ca65..ee817aef6 100644 --- a/packages/extension-usage/src/renderer.tsx +++ b/packages/extension-usage/src/renderer.tsx @@ -197,6 +197,8 @@ h1 { border-radius: 999px; background: var(--legend-color); box-shadow: 0 0 0 3px rgba(15, 23, 42, 0.03); + font-size: 0; + line-height: 0; } .empty-chart { display: grid; @@ -229,6 +231,8 @@ h1 { height: 10px; border-radius: 50%; background: var(--accent); + font-size: 0; + line-height: 0; } .plugin-meta { font-size: 13px; @@ -254,6 +258,8 @@ h1 { height: 100%; border-radius: 6px; background: linear-gradient(90deg, var(--accent), var(--accent-2)); + font-size: 0; + line-height: 0; } ` @@ -419,13 +425,13 @@ function chart(points: ChatLunaUsage.TokenPoint[]) { ,
- 总 token + 总 token - 输入 token + 输入 token - 输出 token + 输出 token
] @@ -522,7 +528,7 @@ function pluginCard(plugins?: ChatLunaUsage.TokenReport['plugins']) { style={`--accent:${accent};--accent-2:${accent2}`} >
- + {plugin.source}
@@ -534,7 +540,9 @@ function pluginCard(plugins?: ChatLunaUsage.TokenReport['plugins']) {
+ > + {' '} +
)