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}
+
+
+
+
+
+
+
+
+ ${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 `
+
+
+ 总 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 `
+
+ `
+}
+
+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}
-
-
-
-
-
-
-
-
- ${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)}${type}>`)
+}
+
+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 `
-
-
- 总 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 `
-
- `
-}
-
-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 (
+ <>
+
+
+
+ 总 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 消耗趋势
+
+
+
+
+
+
+
+
+ {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)}${type}>`)
-}
-
-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 (
- <>
- ,
+
+
+ 总 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) {
-
+
{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}
)