diff --git a/packages/extension-usage/package.json b/packages/extension-usage/package.json index cd3d79013..9ed47658b 100644 --- a/packages/extension-usage/package.json +++ b/packages/extension-usage/package.json @@ -48,18 +48,26 @@ ], "dependencies": { "@koishijs/plugin-console": "^5.30.11", + "@satorijs/element": "^3.2.0", "echarts": "^5.5.0", "vue-echarts": "^6.6.9" }, "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.22" + "koishi-plugin-chatluna": "^1.4.0-alpha.22", + "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/src/index.ts b/packages/extension-usage/src/index.ts index 0bfc0cc3f..b3e78fc9c 100644 --- a/packages/extension-usage/src/index.ts +++ b/packages/extension-usage/src/index.ts @@ -1,12 +1,15 @@ -import { Context, 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 { Context, h, Logger, Time } from 'koishi' +import type { Session } from 'koishi' +import { DataService } from '@koishijs/plugin-console' +import { ChatLunaUsage, summary } from './utils' +import type {} from 'koishi-plugin-puppeteer' +import { renderTokenTrend } from './renderer' +import { createTokenReport, formatTokenReport } from './tokens' const logger = new Logger('chatluna-usage') -class ChatLunaUsage extends DataService { +class ChatLunaUsageService extends DataService { constructor( ctx: Context, public config: ChatLunaUsage.Config @@ -72,6 +75,24 @@ 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) => + this.sendTokens(session, options, args) + ) + if (!config.webui) return ctx.inject(['console'], (ctx) => { @@ -118,67 +139,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, @@ -258,6 +233,99 @@ class ChatLunaUsage extends DataService { ) } + async sendTokens( + session: Session, + options: ChatLunaUsage.TokenCommandOptions, + args: string[] + ) { + 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) { + 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 ChatLunaUsage.TokenRange + if (value) { + range = value + continue + } + 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, plugin) + await session.send(formatTokenReport(report)) + + const puppeteer = this.ctx.get('puppeteer') + if (!puppeteer) { + await session.send('图表渲染需要启用 puppeteer 服务。') + return + } + + const image = await renderTokenTrend( + this.ctx, + puppeteer, + report, + this.config.tokensTheme === 'auto' + ? 'light' + : 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 用量统计失败,请检查日志。' + } + } + + private async tokenReport( + range: ChatLunaUsage.TokenRange, + withPlugins = false + ) { + const end = new Date() + 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 + })) as ChatLunaUsage.Record[] + + return createTokenReport(range, start, end, rows, withPlugins) + } + private async search(input: ChatLunaUsage.Query) { const query = this.withDefaults(input) const where: Record = { @@ -270,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 && @@ -432,206 +497,24 @@ 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 - } - - 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) - }) - - 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 { ChatLunaUsageService as ChatLunaUsage } export function apply(ctx: Context, config: ChatLunaUsage.Config) { - ctx.plugin(ChatLunaUsage, config) + ctx.plugin(ChatLunaUsageService, config) } export const Config = ChatLunaUsage.Config export const inject = { required: ['chatluna', 'database'], - optional: ['console'] + optional: ['console', 'puppeteer'] } export const name = 'chatluna-usage' declare module 'koishi' { interface Context { - chatluna_usage: ChatLunaUsage + chatluna_usage: ChatLunaUsageService } interface Tables { @@ -643,7 +526,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 new file mode 100644 index 000000000..ee817aef6 --- /dev/null +++ b/packages/extension-usage/src/renderer.tsx @@ -0,0 +1,619 @@ +import type { Context } from 'koishi' +import type {} from 'koishi-plugin-puppeteer' +import { formatDate } from './tokens' +import type { ChatLunaUsage } from './utils' + +interface Coord { + x: number + y: number + point: ChatLunaUsage.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); + font-size: 0; + line-height: 0; +} +.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); + font-size: 0; + line-height: 0; +} +.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)); + font-size: 0; + line-height: 0; +} +` + +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: ChatLunaUsage.TokenPoint[]) { + if (!points.length) return
暂无用量数据
+ + 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 + const max = Math.max( + 1, + ...points.flatMap((p) => [p.tokens, p.inputTokens, p.outputTokens]) + ) + const [totalCoords, inputCoords, outputCoords] = ( + ['tokens', 'inputTokens', 'outputTokens'] as const + ).map((key) => + 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 [totalLine, inputLine, outputLine] = [ + totalCoords, + inputCoords, + outputCoords + ].map(monotonePath) + const area = totalLine + ? `${totalLine} L${totalCoords[totalCoords.length - 1].x},${baseline} L${totalCoords[0].x},${baseline} Z` + : '' + const size = points.length > 24 ? 10 : 12 + + return [ + + + + + + + + + + + + + {Array.from({ length: 5 }, (_, idx) => { + const y = top + (plotHeight * idx) / 4 + const value = Math.round(max - (max * idx) / 4) + return [ + , + + {fmt(value)} + + ] + })} + + + + + + {[ + ['input', inputCoords] as const, + ['output', outputCoords] as const, + ['total', totalCoords] as const + ].flatMap(([name, coords]) => + coords.map((c, idx) => { + const last = idx === coords.length - 1 + return ( + + ) + }) + )} + {totalCoords.map((c) => { + const parts = c.point.label.split(' ') + return ( + + {parts[1] + ? parts.map((part, idx) => ( + + {part} + + )) + : c.point.label} + + ) + })} + , +
+ + 总 token + + + 输入 token + + + 输出 token + +
+ ] +} + +function trendIcon() { + return ( + + + + + ) +} + +function pluginIcon() { + return ( + + + + + + + ) +} + +function pluginCard(plugins?: ChatLunaUsage.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 + return ( +
+
+ + {plugin.source} +
+
+ {ratio.toFixed(1)}% ·{' '} + {fmt(plugin.tokens)} token · {fmt(plugin.calls)}{' '} + 次 +
+
+
+ {' '} +
+
+
+ ) + })} +
+
+ ) +} + +function pageHtml(data: ChatLunaUsage.TokenReport, theme: RenderTheme) { + return ( + '' + + String( + + + + + Chatluna token 消耗趋势 + + + +
+
+
+
{trendIcon()}
+
+

Chatluna token 消耗趋势

+

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

+
+
+
+ {chart(data.points)} +
+
+ {pluginCard(data.plugins)} +
+ + + ) + ) +} + +export async function renderTokenTrend( + ctx: Context, + puppeteer: Context['puppeteer'], + data: ChatLunaUsage.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() + } +} diff --git a/packages/extension-usage/src/tokens.ts b/packages/extension-usage/src/tokens.ts new file mode 100644 index 000000000..3d55b2e58 --- /dev/null +++ b/packages/extension-usage/src/tokens.ts @@ -0,0 +1,123 @@ +import { Time } from 'koishi' +import type { ChatLunaUsage } from './utils' + +const RANGES = { + day: ['天', 2 * Time.hour], + week: ['周', Time.day], + month: ['月', 2 * Time.day], + all: ['全部', 0] +} as const + +const pad = (value: number) => String(value).padStart(2, '0') + +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: ChatLunaUsage.TokenReport) { + return [ + `Chatluna token 用量(${report.label})`, + `时间范围:${formatDate(report.start)} 至 ${formatDate(report.end)}`, + `累计 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') +} + +export 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 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: ChatLunaUsage.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 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 (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: RANGES[range][0], + start: from, + end, + totalTokens, + calls: sorted.length, + tpm, + rpm, + points, + plugins: withPlugins + ? [...plugins.values()].sort((a, b) => b.tokens - a.tokens) + : undefined + } +} 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'] +} 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"] }