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..8f676f6ed 100644
--- a/packages/extension-usage/src/index.ts
+++ b/packages/extension-usage/src/index.ts
@@ -1,11 +1,164 @@
-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
+ 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 {
constructor(
ctx: Context,
@@ -71,6 +224,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 +470,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 +694,7 @@ namespace ChatLunaUsage {
}
export type Period = 'day' | 'month' | 'year'
+ export type TokenRange = 'day' | 'week' | 'month' | 'all'
export type GroupBy =
| 'source'
| 'model'
@@ -546,6 +786,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 +842,7 @@ namespace ChatLunaUsage {
recentDays: number
pageSize: number
webui: boolean
+ tokensTheme: 'light' | 'dark'
}
export interface ActionResult {
@@ -591,7 +858,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 +892,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..f8284f988
--- /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 } 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}`
+}