From 6c0e6263b9f7bbb17326ba7291ffc71a040f1f0f Mon Sep 17 00:00:00 2001 From: Neo2025new Date: Sun, 1 Mar 2026 01:32:49 +0800 Subject: [PATCH 1/3] feat: capture token usage from provider SSE responses Add StreamUsageEvent to the event system and implement token usage extraction across all three provider adapters: - Anthropic: parse message_start (input_tokens) + message_delta (output_tokens) - OpenAI: enable stream_options.include_usage, parse final usage chunk - Google: parse usageMetadata from streaming response Token usage is accumulated across tool-use rounds in chat-service and persisted as an optional `usage` field on ChatMessage. This lays the groundwork for usage statistics visualization (Issue #13). Co-Authored-By: Claude Opus 4.6 --- apps/electron/package.json | 2 +- apps/electron/src/main/lib/chat-service.ts | 13 ++++++- packages/core/package.json | 2 +- .../core/src/providers/anthropic-adapter.ts | 38 +++++++++++++++++-- packages/core/src/providers/google-adapter.ts | 24 ++++++++++-- packages/core/src/providers/openai-adapter.ts | 16 ++++++++ packages/core/src/providers/sse-reader.ts | 24 +++++++++++- packages/core/src/providers/types.ts | 8 ++++ packages/shared/package.json | 2 +- packages/shared/src/types/chat.ts | 10 +++++ 10 files changed, 128 insertions(+), 11 deletions(-) diff --git a/apps/electron/package.json b/apps/electron/package.json index e9d05d2e..6dd55dbf 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -1,6 +1,6 @@ { "name": "@proma/electron", - "version": "0.5.0", + "version": "0.5.1", "description": "Proma next gen ai software with general agents - Electron App", "main": "dist/main.cjs", "author": { diff --git a/apps/electron/src/main/lib/chat-service.ts b/apps/electron/src/main/lib/chat-service.ts index c71a5157..811c5079 100644 --- a/apps/electron/src/main/lib/chat-service.ts +++ b/apps/electron/src/main/lib/chat-service.ts @@ -342,6 +342,8 @@ export async function sendMessage( // 在 try 外累积流式内容,abort 时 catch 块仍可访问 let accumulatedContent = '' let accumulatedReasoning = '' + let accumulatedInputTokens = 0 + let accumulatedOutputTokens = 0 try { // 7. 获取适配器 @@ -383,7 +385,7 @@ export async function sendMessage( continuationMessages: continuationMessages.length > 0 ? continuationMessages : undefined, }) - const { content, reasoning, toolCalls, stopReason } = await streamSSE({ + const { content, reasoning, toolCalls, stopReason, usage } = await streamSSE({ request, adapter, signal: controller.signal, @@ -415,6 +417,12 @@ export async function sendMessage( }, }) + // 累积 token 用量(跨 tool use 轮次) + if (usage) { + accumulatedInputTokens += usage.inputTokens + accumulatedOutputTokens += usage.outputTokens + } + // 如果没有工具调用或不是 tool_use 停止,退出循环 if (!toolCalls || toolCalls.length === 0 || stopReason !== 'tool_use') { break @@ -461,6 +469,9 @@ export async function sendMessage( createdAt: Date.now(), model: modelId, reasoning: accumulatedReasoning || undefined, + usage: accumulatedInputTokens > 0 || accumulatedOutputTokens > 0 + ? { inputTokens: accumulatedInputTokens, outputTokens: accumulatedOutputTokens } + : undefined, } appendMessage(conversationId, assistantMsg) diff --git a/packages/core/package.json b/packages/core/package.json index 1b9b8e3b..004ebcdb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@proma/core", - "version": "0.2.2", + "version": "0.2.3", "license": "Apache-2.0", "description": "proma core types,storage and core logic with agents", "type": "module", diff --git a/packages/core/src/providers/anthropic-adapter.ts b/packages/core/src/providers/anthropic-adapter.ts index 221214a1..20865ad7 100644 --- a/packages/core/src/providers/anthropic-adapter.ts +++ b/packages/core/src/providers/anthropic-adapter.ts @@ -51,6 +51,13 @@ interface AnthropicMessage { /** Anthropic SSE 事件 */ interface AnthropicSSEEvent { type: string + /** message_start 的 message 对象 */ + message?: { + usage?: { + input_tokens?: number + output_tokens?: number + } + } /** content_block_start 的 content_block */ content_block?: { type: string @@ -68,6 +75,10 @@ interface AnthropicSSEEvent { /** message_delta 的 stop_reason */ stop_reason?: string } + /** message_delta 的 usage(包含最终 output_tokens) */ + usage?: { + output_tokens?: number + } } /** Anthropic 标题响应 */ @@ -254,6 +265,18 @@ export class AnthropicAdapter implements ProviderAdapter { const event = JSON.parse(jsonLine) as AnthropicSSEEvent const events: StreamEvent[] = [] + // message_start 携带 input_tokens + if (event.type === 'message_start' && event.message?.usage) { + const { input_tokens, output_tokens } = event.message.usage + if (input_tokens || output_tokens) { + events.push({ + type: 'usage', + inputTokens: input_tokens ?? 0, + outputTokens: output_tokens ?? 0, + }) + } + } + // 工具调用开始 if (event.type === 'content_block_start' && event.content_block?.type === 'tool_use') { events.push({ @@ -280,9 +303,18 @@ export class AnthropicAdapter implements ProviderAdapter { } } - // message_delta 携带 stop_reason - if (event.type === 'message_delta' && event.delta?.stop_reason) { - events.push({ type: 'done', stopReason: event.delta.stop_reason }) + // message_delta 携带 stop_reason 和最终 output_tokens + if (event.type === 'message_delta') { + if (event.usage?.output_tokens) { + events.push({ + type: 'usage', + inputTokens: 0, + outputTokens: event.usage.output_tokens, + }) + } + if (event.delta?.stop_reason) { + events.push({ type: 'done', stopReason: event.delta.stop_reason }) + } } return events diff --git a/packages/core/src/providers/google-adapter.ts b/packages/core/src/providers/google-adapter.ts index 75329e6b..4609a85e 100644 --- a/packages/core/src/providers/google-adapter.ts +++ b/packages/core/src/providers/google-adapter.ts @@ -59,6 +59,12 @@ interface GoogleStreamData { } finishReason?: string }> + /** Token 用量元数据 */ + usageMetadata?: { + promptTokenCount?: number + candidatesTokenCount?: number + totalTokenCount?: number + } } /** Google 标题响应 */ @@ -238,11 +244,23 @@ export class GoogleAdapter implements ProviderAdapter { parseSSELine(jsonLine: string): StreamEvent[] { try { const parsed = JSON.parse(jsonLine) as GoogleStreamData - const parts = parsed.candidates?.[0]?.content?.parts - if (!parts) return [] - const events: StreamEvent[] = [] + // 提取 token 用量(Google 每个 chunk 都可能携带) + if (parsed.usageMetadata) { + const { promptTokenCount, candidatesTokenCount } = parsed.usageMetadata + if (promptTokenCount || candidatesTokenCount) { + events.push({ + type: 'usage', + inputTokens: promptTokenCount ?? 0, + outputTokens: candidatesTokenCount ?? 0, + }) + } + } + + const parts = parsed.candidates?.[0]?.content?.parts + if (!parts) return events + // 遍历所有 parts,区分推理内容、正常文本和函数调用 for (const part of parts) { // 函数调用(Google 一次返回完整参数) diff --git a/packages/core/src/providers/openai-adapter.ts b/packages/core/src/providers/openai-adapter.ts index 1be75d95..731d4698 100644 --- a/packages/core/src/providers/openai-adapter.ts +++ b/packages/core/src/providers/openai-adapter.ts @@ -60,6 +60,12 @@ interface OpenAIChunkData { } finish_reason?: string | null }> + /** 流式末尾的 usage 信息(需开启 stream_options) */ + usage?: { + prompt_tokens?: number + completion_tokens?: number + total_tokens?: number + } } /** OpenAI 标题响应 */ @@ -192,6 +198,7 @@ export class OpenAIAdapter implements ProviderAdapter { model: input.modelId, messages, stream: true, + stream_options: { include_usage: true }, } // 工具定义 @@ -255,6 +262,15 @@ export class OpenAIAdapter implements ProviderAdapter { events.push({ type: 'done', stopReason: 'tool_use' }) } + // 流式末尾的 usage 信息(stream_options.include_usage 开启时) + if (chunk.usage) { + events.push({ + type: 'usage', + inputTokens: chunk.usage.prompt_tokens ?? 0, + outputTokens: chunk.usage.completion_tokens ?? 0, + }) + } + return events } catch { return [] diff --git a/packages/core/src/providers/sse-reader.ts b/packages/core/src/providers/sse-reader.ts index 0bbb7ac7..525a347d 100644 --- a/packages/core/src/providers/sse-reader.ts +++ b/packages/core/src/providers/sse-reader.ts @@ -28,6 +28,12 @@ export interface StreamSSEOptions { fetchFn?: typeof globalThis.fetch } +/** Token 用量信息 */ +export interface TokenUsage { + inputTokens: number + outputTokens: number +} + /** streamSSE 的返回结果 */ export interface StreamSSEResult { /** 累积的完整文本内容 */ @@ -38,6 +44,8 @@ export interface StreamSSEResult { toolCalls: ToolCall[] /** 停止原因('tool_use' 表示需要执行工具后继续) */ stopReason?: string + /** Token 用量(如果 Provider 返回了用量数据) */ + usage?: TokenUsage } /** @@ -77,6 +85,9 @@ export async function streamSSE(options: StreamSSEOptions): Promise void diff --git a/packages/shared/package.json b/packages/shared/package.json index f56d4da1..e86ada9c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@proma/shared", - "version": "0.1.13", + "version": "0.1.14", "license": "Apache-2.0", "description": "Shared types, configs and utilities for proma", "type": "module", diff --git a/packages/shared/src/types/chat.ts b/packages/shared/src/types/chat.ts index be384905..9118b800 100644 --- a/packages/shared/src/types/chat.ts +++ b/packages/shared/src/types/chat.ts @@ -59,6 +59,14 @@ export interface FileDialogResult { */ export type MessageRole = 'user' | 'assistant' | 'system' +/** Token 用量信息 */ +export interface MessageUsage { + /** 输入 token 数 */ + inputTokens: number + /** 输出 token 数 */ + outputTokens: number +} + /** * 聊天消息 */ @@ -79,6 +87,8 @@ export interface ChatMessage { stopped?: boolean /** 文件附件列表 */ attachments?: FileAttachment[] + /** Token 用量(assistant 消息,如果 Provider 返回了用量数据) */ + usage?: MessageUsage } // ===== 对话相关 ===== From 9b229a7c1bf0ac523b88121ed5988e6683b8c468 Mon Sep 17 00:00:00 2001 From: Neo2025new Date: Sun, 1 Mar 2026 01:37:55 +0800 Subject: [PATCH 2/3] feat: add usage statistics storage service and IPC channels Add the data persistence and IPC layer for token usage tracking: - New UsageRecord/UsageSummary types and USAGE_IPC_CHANNELS constants - usage-stats-service.ts: read/write ~/.proma/usage-stats.json with daily and per-model aggregation queries - Register IPC handlers for getUsageSummary and clearUsageStats - Expose APIs through preload bridge - Call recordUsage() in chat-service after message persistence Co-Authored-By: Claude Opus 4.6 --- apps/electron/src/main/ipc.ts | 23 ++- apps/electron/src/main/lib/chat-service.ts | 17 ++ apps/electron/src/main/lib/config-paths.ts | 9 + .../src/main/lib/usage-stats-service.ts | 174 ++++++++++++++++++ apps/electron/src/preload/index.ts | 20 +- packages/shared/src/types/index.ts | 3 + packages/shared/src/types/usage.ts | 94 ++++++++++ 7 files changed, 338 insertions(+), 2 deletions(-) create mode 100644 apps/electron/src/main/lib/usage-stats-service.ts create mode 100644 packages/shared/src/types/usage.ts diff --git a/apps/electron/src/main/ipc.ts b/apps/electron/src/main/ipc.ts index 1073e2fd..6f136346 100644 --- a/apps/electron/src/main/ipc.ts +++ b/apps/electron/src/main/ipc.ts @@ -5,7 +5,7 @@ */ import { ipcMain, nativeTheme, shell, dialog, BrowserWindow } from 'electron' -import { IPC_CHANNELS, CHANNEL_IPC_CHANNELS, CHAT_IPC_CHANNELS, AGENT_IPC_CHANNELS, ENVIRONMENT_IPC_CHANNELS, PROXY_IPC_CHANNELS, GITHUB_RELEASE_IPC_CHANNELS, SYSTEM_PROMPT_IPC_CHANNELS, MEMORY_IPC_CHANNELS } from '@proma/shared' +import { IPC_CHANNELS, CHANNEL_IPC_CHANNELS, CHAT_IPC_CHANNELS, AGENT_IPC_CHANNELS, ENVIRONMENT_IPC_CHANNELS, PROXY_IPC_CHANNELS, GITHUB_RELEASE_IPC_CHANNELS, SYSTEM_PROMPT_IPC_CHANNELS, MEMORY_IPC_CHANNELS, USAGE_IPC_CHANNELS } from '@proma/shared' import { USER_PROFILE_IPC_CHANNELS, SETTINGS_IPC_CHANNELS } from '../types' import type { RuntimeStatus, @@ -52,6 +52,8 @@ import type { SystemPromptCreateInput, SystemPromptUpdateInput, MemoryConfig, + UsageQueryRange, + UsageSummary, } from '@proma/shared' import type { UserProfile, AppSettings } from '../types' import { getRuntimeStatus, getGitRepoStatus } from './lib/runtime-init' @@ -117,6 +119,7 @@ import { setWorkspacePermissionMode, } from './lib/agent-workspace-manager' import { getMemoryConfig, setMemoryConfig } from './lib/memory-service' +import { getUsageSummary, clearUsageStats } from './lib/usage-stats-service' import { getSystemPromptConfig, createSystemPrompt, @@ -1022,6 +1025,24 @@ export function registerIpcHandlers(): void { } ) + // ===== 用量统计 ===== + + // 查询用量汇总 + ipcMain.handle( + USAGE_IPC_CHANNELS.GET_SUMMARY, + async (_, range?: UsageQueryRange): Promise => { + return getUsageSummary(range) + } + ) + + // 清除用量记录 + ipcMain.handle( + USAGE_IPC_CHANNELS.CLEAR, + async (): Promise => { + clearUsageStats() + } + ) + console.log('[IPC] IPC 处理器注册完成') // 注册更新 IPC 处理器 diff --git a/apps/electron/src/main/lib/chat-service.ts b/apps/electron/src/main/lib/chat-service.ts index 811c5079..903f1ba1 100644 --- a/apps/electron/src/main/lib/chat-service.ts +++ b/apps/electron/src/main/lib/chat-service.ts @@ -29,6 +29,7 @@ import { extractTextFromAttachment, isDocumentAttachment } from './document-pars import { getFetchFn } from './proxy-fetch' import { getEffectiveProxyUrl } from './proxy-settings-service' import { getMemoryConfig } from './memory-service' +import { recordUsage } from './usage-stats-service' import { searchMemory, addMemory, formatSearchResult } from './memos-client' /** 活跃的 AbortController 映射(conversationId → controller) */ @@ -481,6 +482,22 @@ export async function sendMessage( } catch { // 索引更新失败不影响主流程 } + + // 记录用量统计 + if (accumulatedInputTokens > 0 || accumulatedOutputTokens > 0) { + try { + recordUsage({ + channelId, + provider: channel.provider, + modelId, + inputTokens: accumulatedInputTokens, + outputTokens: accumulatedOutputTokens, + mode: 'chat', + }) + } catch { + // 用量统计写入失败不影响主流程 + } + } } else { console.warn(`[聊天服务] 模型返回空内容,跳过保存 (对话 ${conversationId})`) } diff --git a/apps/electron/src/main/lib/config-paths.ts b/apps/electron/src/main/lib/config-paths.ts index 157dd733..6e757252 100644 --- a/apps/electron/src/main/lib/config-paths.ts +++ b/apps/electron/src/main/lib/config-paths.ts @@ -165,6 +165,15 @@ export function getMemoryConfigPath(): string { return join(getConfigDir(), 'memory.json') } +/** + * 获取用量统计文件路径 + * + * @returns ~/.proma/usage-stats.json + */ +export function getUsageStatsPath(): string { + return join(getConfigDir(), 'usage-stats.json') +} + /** * 获取 Agent 会话索引文件路径 * diff --git a/apps/electron/src/main/lib/usage-stats-service.ts b/apps/electron/src/main/lib/usage-stats-service.ts new file mode 100644 index 00000000..47604ab3 --- /dev/null +++ b/apps/electron/src/main/lib/usage-stats-service.ts @@ -0,0 +1,174 @@ +/** + * 用量统计服务 + * + * 管理 Token 用量记录的读写和聚合查询。 + * 数据存储在 ~/.proma/usage-stats.json,JSON 格式。 + */ + +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { randomUUID } from 'node:crypto' +import type { ProviderType, UsageRecord, UsageQueryRange, UsageSummary, DailyUsage, ModelUsage } from '@proma/shared' +import { getUsageStatsPath } from './config-paths' + +// ===== 存储结构 ===== + +/** 用量统计文件结构 */ +interface UsageStatsFile { + version: number + records: UsageRecord[] +} + +/** 默认空数据 */ +const DEFAULT_STATS: UsageStatsFile = { + version: 1, + records: [], +} + +// ===== 读写操作 ===== + +/** 读取用量统计文件 */ +function readStats(): UsageStatsFile { + const filePath = getUsageStatsPath() + + if (!existsSync(filePath)) { + return { ...DEFAULT_STATS, records: [] } + } + + try { + const raw = readFileSync(filePath, 'utf-8') + const data = JSON.parse(raw) as Partial + return { + version: data.version ?? 1, + records: data.records ?? [], + } + } catch (error) { + console.warn('[用量统计] 读取失败,使用默认值:', error) + return { ...DEFAULT_STATS, records: [] } + } +} + +/** 写入用量统计文件 */ +function writeStats(stats: UsageStatsFile): void { + const filePath = getUsageStatsPath() + writeFileSync(filePath, JSON.stringify(stats, null, 2), 'utf-8') +} + +// ===== 公开 API ===== + +/** + * 记录一条 Token 用量 + */ +export function recordUsage(input: { + channelId: string + provider: ProviderType + modelId: string + inputTokens: number + outputTokens: number + mode: 'chat' | 'agent' +}): void { + const stats = readStats() + + const record: UsageRecord = { + id: randomUUID(), + timestamp: Date.now(), + channelId: input.channelId, + provider: input.provider, + modelId: input.modelId, + inputTokens: input.inputTokens, + outputTokens: input.outputTokens, + mode: input.mode, + } + + stats.records.push(record) + writeStats(stats) +} + +/** + * 查询用量汇总 + * + * @param range 时间范围(可选,默认最近 30 天) + */ +export function getUsageSummary(range?: UsageQueryRange): UsageSummary { + const stats = readStats() + + // 默认最近 30 天 + const now = Date.now() + const from = range?.from ?? now - 30 * 24 * 60 * 60 * 1000 + const to = range?.to ?? now + + // 过滤范围内的记录 + const filtered = stats.records.filter( + (r) => r.timestamp >= from && r.timestamp <= to + ) + + // 按日期聚合 + const dailyMap = new Map() + for (const r of filtered) { + const date = new Date(r.timestamp).toISOString().slice(0, 10) + const existing = dailyMap.get(date) + if (existing) { + existing.inputTokens += r.inputTokens + existing.outputTokens += r.outputTokens + existing.requestCount += 1 + } else { + dailyMap.set(date, { + date, + inputTokens: r.inputTokens, + outputTokens: r.outputTokens, + requestCount: 1, + }) + } + } + + // 按模型聚合 + const modelMap = new Map() + for (const r of filtered) { + const key = `${r.provider}:${r.modelId}` + const existing = modelMap.get(key) + if (existing) { + existing.inputTokens += r.inputTokens + existing.outputTokens += r.outputTokens + existing.requestCount += 1 + } else { + modelMap.set(key, { + modelId: r.modelId, + provider: r.provider, + inputTokens: r.inputTokens, + outputTokens: r.outputTokens, + requestCount: 1, + }) + } + } + + // 每日数据按日期排序 + const daily = [...dailyMap.values()].sort((a, b) => a.date.localeCompare(b.date)) + + // 模型数据按总 token 降序 + const byModel = [...modelMap.values()].sort( + (a, b) => (b.inputTokens + b.outputTokens) - (a.inputTokens + a.outputTokens) + ) + + // 总计 + let totalInputTokens = 0 + let totalOutputTokens = 0 + for (const r of filtered) { + totalInputTokens += r.inputTokens + totalOutputTokens += r.outputTokens + } + + return { + daily, + byModel, + totalInputTokens, + totalOutputTokens, + totalRequests: filtered.length, + } +} + +/** + * 清除所有用量记录 + */ +export function clearUsageStats(): void { + writeStats({ ...DEFAULT_STATS, records: [] }) + console.log('[用量统计] 已清除所有记录') +} diff --git a/apps/electron/src/preload/index.ts b/apps/electron/src/preload/index.ts index 20c563f1..3ca98c21 100644 --- a/apps/electron/src/preload/index.ts +++ b/apps/electron/src/preload/index.ts @@ -6,7 +6,7 @@ */ import { contextBridge, ipcRenderer } from 'electron' -import { IPC_CHANNELS, CHANNEL_IPC_CHANNELS, CHAT_IPC_CHANNELS, AGENT_IPC_CHANNELS, ENVIRONMENT_IPC_CHANNELS, PROXY_IPC_CHANNELS, GITHUB_RELEASE_IPC_CHANNELS, SYSTEM_PROMPT_IPC_CHANNELS, MEMORY_IPC_CHANNELS } from '@proma/shared' +import { IPC_CHANNELS, CHANNEL_IPC_CHANNELS, CHAT_IPC_CHANNELS, AGENT_IPC_CHANNELS, ENVIRONMENT_IPC_CHANNELS, PROXY_IPC_CHANNELS, GITHUB_RELEASE_IPC_CHANNELS, SYSTEM_PROMPT_IPC_CHANNELS, MEMORY_IPC_CHANNELS, USAGE_IPC_CHANNELS } from '@proma/shared' import { USER_PROFILE_IPC_CHANNELS, SETTINGS_IPC_CHANNELS } from '../types' import type { RuntimeStatus, @@ -62,6 +62,8 @@ import type { SystemPromptCreateInput, SystemPromptUpdateInput, MemoryConfig, + UsageQueryRange, + UsageSummary, } from '@proma/shared' import type { UserProfile, AppSettings } from '../types' @@ -418,6 +420,14 @@ export interface ElectronAPI { listReleases: (options?: GitHubReleaseListOptions) => Promise getReleaseByTag: (tag: string) => Promise + // ===== 用量统计 ===== + + /** 查询用量汇总 */ + getUsageSummary: (range?: UsageQueryRange) => Promise + + /** 清除用量记录 */ + clearUsageStats: () => Promise + // 工作区文件变化通知 onCapabilitiesChanged: (callback: () => void) => () => void onWorkspaceFilesChanged: (callback: () => void) => () => void @@ -875,6 +885,14 @@ const electronAPI: ElectronAPI = { getReleaseByTag: (tag) => { return ipcRenderer.invoke(GITHUB_RELEASE_IPC_CHANNELS.GET_RELEASE_BY_TAG, tag) }, + + // 用量统计 + getUsageSummary: (range?) => { + return ipcRenderer.invoke(USAGE_IPC_CHANNELS.GET_SUMMARY, range) + }, + clearUsageStats: () => { + return ipcRenderer.invoke(USAGE_IPC_CHANNELS.CLEAR) + }, } // 将 API 暴露到渲染进程的 window 对象上 diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 03eb1c8f..1d61bbf3 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -35,3 +35,6 @@ export * from './github' // 系统提示词相关类型 export * from './system-prompt' + +// 用量统计相关类型 +export * from './usage' diff --git a/packages/shared/src/types/usage.ts b/packages/shared/src/types/usage.ts new file mode 100644 index 00000000..48a6ac2a --- /dev/null +++ b/packages/shared/src/types/usage.ts @@ -0,0 +1,94 @@ +/** + * 用量统计相关类型定义 + * + * 包含用量记录、查询参数、汇总结果等类型, + * 以及用量统计模块的 IPC 通道常量。 + */ + +import type { ProviderType } from './channel' + +// ===== 用量记录 ===== + +/** 单次 API 调用的 Token 用量记录 */ +export interface UsageRecord { + /** 记录唯一标识 */ + id: string + /** 时间戳 */ + timestamp: number + /** 渠道 ID */ + channelId: string + /** 供应商类型 */ + provider: ProviderType + /** 模型 ID */ + modelId: string + /** 输入 token 数 */ + inputTokens: number + /** 输出 token 数 */ + outputTokens: number + /** 来源模式 */ + mode: 'chat' | 'agent' +} + +// ===== 查询参数 ===== + +/** 用量查询时间范围 */ +export interface UsageQueryRange { + /** 起始时间戳 */ + from: number + /** 结束时间戳 */ + to: number +} + +// ===== 汇总结果 ===== + +/** 每日用量汇总 */ +export interface DailyUsage { + /** 日期字符串 (YYYY-MM-DD) */ + date: string + /** 当日输入 token 总数 */ + inputTokens: number + /** 当日输出 token 总数 */ + outputTokens: number + /** 当日请求次数 */ + requestCount: number +} + +/** 按模型汇总的用量 */ +export interface ModelUsage { + /** 模型 ID */ + modelId: string + /** 供应商类型 */ + provider: ProviderType + /** 输入 token 总数 */ + inputTokens: number + /** 输出 token 总数 */ + outputTokens: number + /** 请求次数 */ + requestCount: number +} + +/** 用量统计汇总 */ +export interface UsageSummary { + /** 查询范围内的每日用量 */ + daily: DailyUsage[] + /** 按模型汇总 */ + byModel: ModelUsage[] + /** 总输入 token */ + totalInputTokens: number + /** 总输出 token */ + totalOutputTokens: number + /** 总请求次数 */ + totalRequests: number +} + +// ===== IPC 通道常量 ===== + +/** 用量统计 IPC 通道 */ +export const USAGE_IPC_CHANNELS = { + /** 记录一条用量(主进程内部调用,不暴露给渲染进程) */ + RECORD: 'usage:record', + /** 查询用量汇总 */ + GET_SUMMARY: 'usage:get-summary', + /** 清除用量记录 */ + CLEAR: 'usage:clear', +} as const From f66aaeaa7436479ae77899d1daf9e690c6629679 Mon Sep 17 00:00:00 2001 From: Neo2025new Date: Sun, 1 Mar 2026 01:41:44 +0800 Subject: [PATCH 3/3] feat: add usage statistics UI with recharts visualization Add UsageSettings component with token consumption dashboard: - Today/30-day summary cards with formatted token counts - 30-day trend AreaChart (input/output tokens) - Per-model usage distribution list - Empty state placeholder - Register usage tab in SettingsPanel navigation Co-Authored-By: Claude Opus 4.6 --- apps/electron/package.json | 1 + .../src/renderer/atoms/settings-tab.ts | 3 +- .../src/renderer/atoms/usage-atoms.ts | 14 + .../components/settings/SettingsPanel.tsx | 10 +- .../components/settings/UsageSettings.tsx | 293 ++++++++++++++++++ 5 files changed, 317 insertions(+), 4 deletions(-) create mode 100644 apps/electron/src/renderer/atoms/usage-atoms.ts create mode 100644 apps/electron/src/renderer/components/settings/UsageSettings.tsx diff --git a/apps/electron/package.json b/apps/electron/package.json index 6dd55dbf..822e37ed 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -76,6 +76,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", + "recharts": "^3.7.0", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "tailwind-merge": "^2.5.5", diff --git a/apps/electron/src/renderer/atoms/settings-tab.ts b/apps/electron/src/renderer/atoms/settings-tab.ts index ebcf90e5..a94d83ad 100644 --- a/apps/electron/src/renderer/atoms/settings-tab.ts +++ b/apps/electron/src/renderer/atoms/settings-tab.ts @@ -7,11 +7,12 @@ * - proxy: 代理配置 * - appearance: 外观设置 * - about: 关于 + * - usage: 用量统计 */ import { atom } from 'jotai' -export type SettingsTab = 'general' | 'channels' | 'proxy' | 'appearance' | 'about' | 'agent' | 'prompts' | 'memory' +export type SettingsTab = 'general' | 'channels' | 'proxy' | 'appearance' | 'about' | 'agent' | 'prompts' | 'memory' | 'usage' /** 当前设置标签页(不持久化,每次打开设置默认显示渠道) */ export const settingsTabAtom = atom('channels') diff --git a/apps/electron/src/renderer/atoms/usage-atoms.ts b/apps/electron/src/renderer/atoms/usage-atoms.ts new file mode 100644 index 00000000..85ec4a36 --- /dev/null +++ b/apps/electron/src/renderer/atoms/usage-atoms.ts @@ -0,0 +1,14 @@ +/** + * Usage Atoms - 用量统计状态 + * + * 管理用量统计数据的加载和缓存。 + */ + +import { atom } from 'jotai' +import type { UsageSummary } from '@proma/shared' + +/** 用量汇总数据 */ +export const usageSummaryAtom = atom(null) + +/** 用量数据加载状态 */ +export const usageLoadingAtom = atom(false) diff --git a/apps/electron/src/renderer/components/settings/SettingsPanel.tsx b/apps/electron/src/renderer/components/settings/SettingsPanel.tsx index 2a551ce0..396d98ac 100644 --- a/apps/electron/src/renderer/components/settings/SettingsPanel.tsx +++ b/apps/electron/src/renderer/components/settings/SettingsPanel.tsx @@ -9,7 +9,7 @@ import * as React from 'react' import { useAtom, useAtomValue } from 'jotai' import { cn } from '@/lib/utils' -import { Settings, Radio, Palette, Info, Plug, Globe, BookOpen, Brain } from 'lucide-react' +import { Settings, Radio, Palette, Info, Plug, Globe, BookOpen, Brain, BarChart3 } from 'lucide-react' import { ScrollArea } from '@/components/ui/scroll-area' import { settingsTabAtom } from '@/atoms/settings-tab' import type { SettingsTab } from '@/atoms/settings-tab' @@ -24,6 +24,7 @@ import { AboutSettings } from './AboutSettings' import { AgentSettings } from './AgentSettings' import { PromptSettings } from './PromptSettings' import { MemorySettings } from './MemorySettings' +import { UsageSettings } from './UsageSettings' /** 设置 Tab 定义 */ interface TabItem { @@ -43,6 +44,7 @@ const BASE_TABS: TabItem[] = [ /** Agent 模式专属 Tab */ const AGENT_TAB: TabItem = { id: 'agent', label: '配置', icon: } const MEMORY_TAB: TabItem = { id: 'memory', label: '记忆', icon: } +const USAGE_TAB: TabItem = { id: 'usage', label: '用量', icon: } /** 尾部 Tabs */ const TAIL_TABS: TabItem[] = [ @@ -67,6 +69,8 @@ function renderTabContent(tab: SettingsTab): React.ReactElement { return case 'appearance': return + case 'usage': + return case 'about': return } @@ -81,9 +85,9 @@ export function SettingsPanel(): React.ReactElement { // Agent 模式时在渠道后插入 Agent Tab,记忆 tab 两种模式都显示 const tabs = React.useMemo(() => { if (appMode === 'agent') { - return [...BASE_TABS, AGENT_TAB, MEMORY_TAB, ...TAIL_TABS] + return [...BASE_TABS, AGENT_TAB, MEMORY_TAB, USAGE_TAB, ...TAIL_TABS] } - return [...BASE_TABS, MEMORY_TAB, ...TAIL_TABS] + return [...BASE_TABS, MEMORY_TAB, USAGE_TAB, ...TAIL_TABS] }, [appMode]) return ( diff --git a/apps/electron/src/renderer/components/settings/UsageSettings.tsx b/apps/electron/src/renderer/components/settings/UsageSettings.tsx new file mode 100644 index 00000000..2be859ed --- /dev/null +++ b/apps/electron/src/renderer/components/settings/UsageSettings.tsx @@ -0,0 +1,293 @@ +/** + * UsageSettings - 用量统计页 + * + * 展示 Token 消耗的可视化统计数据: + * - 总览数字(今日/本月 Token 总量) + * - 最近 30 天趋势图表(AreaChart) + * - 按模型分布明细 + * + * 使用 recharts 绘制图表,遵循 SettingsSection/SettingsCard 视觉规范。 + */ + +import * as React from 'react' +import { useAtom } from 'jotai' +import { Loader2, Trash2, ArrowUpRight, ArrowDownRight } from 'lucide-react' +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from 'recharts' +import { Button } from '@/components/ui/button' +import { SettingsSection, SettingsCard } from './primitives' +import { usageSummaryAtom, usageLoadingAtom } from '@/atoms/usage-atoms' +import type { UsageSummary, DailyUsage } from '@proma/shared' + +// ===== 工具函数 ===== + +/** 格式化 token 数为可读字符串 */ +function formatTokens(count: number): string { + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M` + if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K` + return String(count) +} + +/** 格式化日期为短格式 (MM-DD) */ +function formatDate(dateStr: string): string { + return dateStr.slice(5) // "2024-03-01" → "03-01" +} + +/** 获取今日日期字符串 */ +function getToday(): string { + return new Date().toISOString().slice(0, 10) +} + +// ===== 子组件 ===== + +/** 统计数字卡片 */ +function StatItem({ label, value, sub }: { + label: string + value: string + sub?: string +}): React.ReactElement { + return ( +
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+ ) +} + +/** 自定义 Tooltip */ +function ChartTooltip({ active, payload, label }: { + active?: boolean + payload?: Array<{ value: number; dataKey: string }> + label?: string +}): React.ReactElement | null { + if (!active || !payload || payload.length === 0) return null + + return ( +
+

{label}

+ {payload.map((entry) => ( +
+ {entry.dataKey === 'inputTokens' ? ( + + ) : ( + + )} + + {formatTokens(entry.value)} + + + {entry.dataKey === 'inputTokens' ? '输入' : '输出'} + +
+ ))} +
+ ) +} + +// ===== 主组件 ===== + +export function UsageSettings(): React.ReactElement { + const [summary, setSummary] = useAtom(usageSummaryAtom) + const [loading, setLoading] = useAtom(usageLoadingAtom) + + /** 加载用量数据 */ + const loadData = React.useCallback(async () => { + setLoading(true) + try { + const data = await window.electronAPI.getUsageSummary() + setSummary(data) + } catch (error) { + console.error('[用量统计] 加载失败:', error) + } finally { + setLoading(false) + } + }, [setSummary, setLoading]) + + React.useEffect(() => { + loadData() + }, [loadData]) + + /** 清除用量记录 */ + const handleClear = React.useCallback(async () => { + try { + await window.electronAPI.clearUsageStats() + setSummary(null) + } catch (error) { + console.error('[用量统计] 清除失败:', error) + } + }, [setSummary]) + + if (loading && !summary) { + return ( +
+ + 加载中... +
+ ) + } + + // 计算今日数据 + const today = getToday() + const todayData = summary?.daily.find((d) => d.date === today) + const todayTotal = todayData ? todayData.inputTokens + todayData.outputTokens : 0 + + return ( +
+ {/* 总览 */} + 0 ? ( + + ) : undefined + } + > + +
+ + + +
+
+
+ + {/* 趋势图表 */} + {summary && summary.daily.length > 0 && ( + + +
+ + + + + + + + + + + + + + + + } /> + + + + +
+
+ + 输入 Token +
+
+ + 输出 Token +
+
+
+
+
+ )} + + {/* 模型分布 */} + {summary && summary.byModel.length > 0 && ( + + + {summary.byModel.map((model) => { + const total = model.inputTokens + model.outputTokens + return ( +
+
+

+ {model.modelId} +

+

+ {model.provider} · {model.requestCount} 次请求 +

+
+
+

+ {formatTokens(total)} +

+

+ {formatTokens(model.inputTokens)} 入 / {formatTokens(model.outputTokens)} 出 +

+
+
+ ) + })} +
+
+ )} + + {/* 空状态 */} + {summary && summary.totalRequests === 0 && ( +
+

暂无用量数据

+

开始对话后,Token 用量将自动记录在这里

+
+ )} +
+ ) +}