diff --git a/packages/extension-usage/package.json b/packages/extension-usage/package.json index 7e3fe72d3..a01412565 100644 --- a/packages/extension-usage/package.json +++ b/packages/extension-usage/package.json @@ -7,7 +7,8 @@ "typings": "lib/index.d.ts", "files": [ "lib", - "dist" + "dist", + "resources" ], "exports": { ".": { @@ -54,12 +55,19 @@ "devDependencies": { "@koishijs/client": "^5.30.11", "atsc": "^2.1.0", - "koishi": "^4.18.9" + "koishi": "^4.18.9", + "koishi-plugin-puppeteer": "^3.9.0" }, "peerDependencies": { "@koishijs/plugin-console": "^5.30.11", "koishi": "^4.18.9", - "koishi-plugin-chatluna": "^1.4.0-alpha.20" + "koishi-plugin-chatluna": "^1.4.0-alpha.20", + "koishi-plugin-puppeteer": "^3.9.0" + }, + "peerDependenciesMeta": { + "koishi-plugin-puppeteer": { + "optional": true + } }, "koishi": { "description": { @@ -72,7 +80,8 @@ "database" ], "optional": [ - "console" + "console", + "puppeteer" ] } } diff --git a/packages/extension-usage/resources/token-trend/template.html b/packages/extension-usage/resources/token-trend/template.html new file mode 100644 index 000000000..ccb6c950f --- /dev/null +++ b/packages/extension-usage/resources/token-trend/template.html @@ -0,0 +1,114 @@ + + + + + + ${title} + + + +
+
+
+
+

${title}

${range}

+
+
${chart}
+
+ ${pluginCard} +
+ + diff --git a/packages/extension-usage/src/index.ts b/packages/extension-usage/src/index.ts index ca3e5bbc5..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 ` + + + + + + + + + + + + ${grid} + + + + + ${dots} + ${labels} + +
+ 总 token + 输入 token + 输出 token +
+ ` +} + +const PLUGIN_COLORS: [string, string][] = [ + ['#6366f1', '#8b5cf6'], + ['#0ea5e9', '#22d3ee'], + ['#f43f5e', '#fb7185'], + ['#f59e0b', '#fbbf24'], + ['#10b981', '#34d399'], + ['#a855f7', '#d946ef'] +] + +function pluginCard(plugins?: ChatLunaUsage.PluginUsage[]) { + if (!plugins?.length) return '' + + const total = plugins.reduce((sum, p) => sum + p.tokens, 0) || 1 + const rows = plugins + .map((plugin, idx) => { + const [accent, accent2] = PLUGIN_COLORS[idx % PLUGIN_COLORS.length] + const ratio = (plugin.tokens / total) * 100 + const width = Math.max(2, Math.min(100, ratio)) + const pct = ratio.toFixed(1) + const style = `--accent:${accent};--accent-2:${accent2}` + return ` +
+
${escapeHtml(plugin.source)}
+
${pct}% · ${fmt(plugin.tokens)} token · ${fmt(plugin.calls)} 次
+
+
+ ` + }) + .join('') + + const icon = + '' + + '' + + '' + + '' + + '' + + '' + + return ` +
+
+
${icon}
+

各插件用量明细

按 token 占比排序

+
+
${rows}
+
+ ` +} + +export async function renderTokenTrend( + ctx: Context, + data: ChatLunaUsage.TokenReport, + theme: 'light' | 'dark' = 'light' +) { + const dirname = + typeof __dirname !== 'undefined' + ? __dirname + : path.dirname(fileURLToPath(import.meta.url)) + const templatePath = path.resolve( + dirname, + '../resources/token-trend/template.html' + ) + const outDir = path.resolve(ctx.baseDir, 'data/chatluna/usage') + const file = `${Math.random().toString(36).slice(2)}.html` + const out = path.resolve(outDir, file) + + await fs.mkdir(outDir, { recursive: true }) + await fs.writeFile( + out, + renderTemplate(await fs.readFile(templatePath, 'utf-8'), { + title: 'Chatluna token 消耗趋势', + range: `时间范围:${formatDate(data.start)} 至 ${formatDate(data.end)}`, + chart: chart(data.points), + pluginCard: pluginCard(data.plugins), + themeClass: theme === 'dark' ? 'theme-dark' : 'theme-light' + }) + ) + + let page: Awaited> | undefined + try { + page = await ctx.puppeteer.page() + await page.goto('file://' + out, { waitUntil: 'domcontentloaded' }) + await page.evaluate(() => document.fonts.ready) + const el = await page.$('.stage') + if (!el) { + return '图表渲染失败:未找到图表容器。' + } + + return await el.screenshot() + } catch (err) { + ctx.logger.error(err) + return '图表渲染失败,请检查日志。' + } finally { + await page?.close().catch((err) => ctx.logger.warn(err)) + await fs.unlink(out).catch((err) => ctx.logger.warn(err)) + } +} + +function formatDate(date: Date) { + const y = date.getFullYear() + const m = String(date.getMonth() + 1).padStart(2, '0') + const d = String(date.getDate()).padStart(2, '0') + const h = String(date.getHours()).padStart(2, '0') + const min = String(date.getMinutes()).padStart(2, '0') + return `${y}-${m}-${d} ${h}:${min}` +}