diff --git a/apps/electron/src/main/ipc.ts b/apps/electron/src/main/ipc.ts index 27b983e..db464f5 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 } 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, USAGE_IPC_CHANNELS } from '@proma/shared' import { USER_PROFILE_IPC_CHANNELS, SETTINGS_IPC_CHANNELS } from '../types' import type { RuntimeStatus, @@ -44,6 +44,9 @@ import type { SystemProxyDetectResult, GitHubRelease, GitHubReleaseListOptions, + UsageStats, + ConversationUsage, + UsageSettings, } from '@proma/shared' import type { UserProfile, AppSettings } from '../types' import { getRuntimeStatus, getGitRepoStatus } from './lib/runtime-init' @@ -109,6 +112,12 @@ import { listReleases as listGitHubReleases, getReleaseByTag, } from './lib/github-release-service' +import { + getUsageStats, + getConversationUsage, + getUsageSettings, + updateUsageSettings, +} from './lib/usage-service' /** * 注册 IPC 处理器 @@ -860,6 +869,40 @@ export function registerIpcHandlers(): void { } ) + // ===== 使用统计相关 ===== + + // 获取使用量统计总览 + ipcMain.handle( + USAGE_IPC_CHANNELS.GET_USAGE_STATS, + async (_, days: number = 30): Promise => { + return getUsageStats(days) + } + ) + + // 获取指定对话的使用量详情 + ipcMain.handle( + USAGE_IPC_CHANNELS.GET_CONVERSATION_USAGE, + async (_, conversationId: string): Promise => { + return getConversationUsage(conversationId) + } + ) + + // 获取使用统计设置 + ipcMain.handle( + USAGE_IPC_CHANNELS.GET_USAGE_SETTINGS, + async (): Promise => { + return getUsageSettings() + } + ) + + // 更新使用统计设置 + ipcMain.handle( + USAGE_IPC_CHANNELS.UPDATE_USAGE_SETTINGS, + async (_, settings: UsageSettings): Promise => { + return updateUsageSettings(settings) + } + ) + 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 f8329ef..d179763 100644 --- a/apps/electron/src/main/lib/chat-service.ts +++ b/apps/electron/src/main/lib/chat-service.ts @@ -14,7 +14,7 @@ import { randomUUID } from 'node:crypto' import type { WebContents } from 'electron' import { CHAT_IPC_CHANNELS } from '@proma/shared' -import type { ChatSendInput, ChatMessage, GenerateTitleInput, FileAttachment } from '@proma/shared' +import type { ChatSendInput, ChatMessage, GenerateTitleInput, FileAttachment, TokenUsage } from '@proma/shared' import { getAdapter, streamSSE, @@ -238,6 +238,7 @@ export async function sendMessage( // 在 try 外累积流式内容,abort 时 catch 块仍可访问 let accumulatedContent = '' let accumulatedReasoning = '' + let usage: TokenUsage | undefined try { // 7. 获取适配器 + 构建请求 + 执行流式 SSE @@ -257,7 +258,7 @@ export async function sendMessage( const proxyUrl = await getEffectiveProxyUrl() const fetchFn = getFetchFn(proxyUrl) - const { content, reasoning } = await streamSSE({ + const { content, reasoning, usage: streamUsage } = await streamSSE({ request, adapter, signal: controller.signal, @@ -283,6 +284,9 @@ export async function sendMessage( }, }) + // 保存 usage 数据 + usage = streamUsage + // 8. 保存 assistant 消息(空内容不保存) const assistantMsgId = randomUUID() if (content.trim()) { @@ -293,6 +297,7 @@ export async function sendMessage( createdAt: Date.now(), model: modelId, reasoning: reasoning || undefined, + usage: usage || undefined, } appendMessage(conversationId, assistantMsg) @@ -327,6 +332,7 @@ export async function sendMessage( model: modelId, reasoning: accumulatedReasoning || undefined, stopped: true, + usage: usage || undefined, } appendMessage(conversationId, partialMsg) diff --git a/apps/electron/src/main/lib/usage-service.ts b/apps/electron/src/main/lib/usage-service.ts new file mode 100644 index 0000000..b24f997 --- /dev/null +++ b/apps/electron/src/main/lib/usage-service.ts @@ -0,0 +1,475 @@ +/** + * 使用量统计服务 + * + * 负责聚合和查询 Chat 和 Agent 模式的使用量数据, + * 提供统计总览、每日分布、模型分布等功能。 + */ + +import { readFileSync, writeFileSync, existsSync } from 'node:fs' +import { + getConfigDir, + getConversationsIndexPath, + getConversationMessagesPath, + getAgentSessionsIndexPath, + getAgentSessionMessagesPath, +} from './config-paths' +import type { + UsageStats, + DailyUsage, + ModelUsage, + ConversationUsage, + ModelPricing, + UsageSettings, + ChatMessage, + ConversationMeta, + AgentSessionMeta, + AgentMessage, + AgentEvent, + TokenUsage, +} from '@proma/shared' +import { listConversations, getConversationMessages } from './conversation-manager' +import { listAgentSessions, getAgentSessionMessages } from './agent-session-manager' + +/** 使用统计设置文件路径 */ +function getUsageSettingsPath(): string { + return `${getConfigDir()}/usage-settings.json` +} + +/** 内置默认定价(基于 2024 年主流供应商价格) */ +const DEFAULT_PRICING: ModelPricing[] = [ + // Anthropic Claude 模型 + { modelId: 'claude-3-5-sonnet', promptPricePer1k: 0.003, completionPricePer1k: 0.015 }, + { modelId: 'claude-3-5-haiku', promptPricePer1k: 0.0008, completionPricePer1k: 0.004 }, + { modelId: 'claude-3-opus', promptPricePer1k: 0.015, completionPricePer1k: 0.075 }, + { modelId: 'claude-3-sonnet', promptPricePer1k: 0.003, completionPricePer1k: 0.015 }, + { modelId: 'claude-3-haiku', promptPricePer1k: 0.00025, completionPricePer1k: 0.00125 }, + + // OpenAI 模型 + { modelId: 'gpt-4o', promptPricePer1k: 0.005, completionPricePer1k: 0.015 }, + { modelId: 'gpt-4o-mini', promptPricePer1k: 0.00015, completionPricePer1k: 0.0006 }, + { modelId: 'gpt-4-turbo', promptPricePer1k: 0.01, completionPricePer1k: 0.03 }, + { modelId: 'gpt-4', promptPricePer1k: 0.03, completionPricePer1k: 0.06 }, + { modelId: 'gpt-3.5-turbo', promptPricePer1k: 0.0005, completionPricePer1k: 0.0015 }, + + // Google Gemini 模型 + { modelId: 'gemini-1.5-pro', promptPricePer1k: 0.0035, completionPricePer1k: 0.0105 }, + { modelId: 'gemini-1.5-flash', promptPricePer1k: 0.00035, completionPricePer1k: 0.00105 }, + { modelId: 'gemini-2.5-pro', promptPricePer1k: 0.0035, completionPricePer1k: 0.0105 }, + { modelId: 'gemini-2.5-flash', promptPricePer1k: 0.00035, completionPricePer1k: 0.00105 }, + + // DeepSeek 模型 + { modelId: 'deepseek-chat', promptPricePer1k: 0.00027, completionPricePer1k: 0.0011 }, + { modelId: 'deepseek-reasoner', promptPricePer1k: 0.00055, completionPricePer1k: 0.00219 }, +] + +/** 获取使用统计设置 */ +export function getUsageSettings(): UsageSettings { + const filePath = getUsageSettingsPath() + + if (!existsSync(filePath)) { + return { pricing: [] } + } + + try { + const raw = readFileSync(filePath, 'utf-8') + const data = JSON.parse(raw) as Partial + return { + pricing: data.pricing || [], + } + } catch (error) { + console.error('[使用统计] 读取设置失败:', error) + return { pricing: [] } + } +} + +/** 更新使用统计设置 */ +export function updateUsageSettings(settings: UsageSettings): UsageSettings { + const filePath = getUsageSettingsPath() + + try { + writeFileSync(filePath, JSON.stringify(settings, null, 2), 'utf-8') + console.log('[使用统计] 设置已更新') + } catch (error) { + console.error('[使用统计] 写入设置失败:', error) + throw new Error('写入使用统计设置失败') + } + + return settings +} + +/** 根据模型 ID 查找定价(支持前缀匹配) */ +function findPricing(modelId: string): ModelPricing | undefined { + const settings = getUsageSettings() + + // 优先使用用户自定义定价 + const customPricing = settings.pricing.find((p) => + modelId.toLowerCase().includes(p.modelId.toLowerCase()), + ) + if (customPricing) return customPricing + + // 使用内置默认定价(前缀匹配) + return DEFAULT_PRICING.find((p) => + modelId.toLowerCase().includes(p.modelId.toLowerCase()), + ) +} + +/** 计算 Token 使用量的预估成本(USD) */ +function calculateCost(tokens: TokenUsage, modelId: string): number { + const pricing = findPricing(modelId) + if (!pricing) return 0 + + const promptCost = (tokens.promptTokens / 1000) * pricing.promptPricePer1k + const completionCost = (tokens.completionTokens / 1000) * pricing.completionPricePer1k + + return promptCost + completionCost +} + +/** 格式化日期为 YYYY-MM-DD */ +function formatDate(timestamp: number): string { + const date = new Date(timestamp) + const isoString = date.toISOString() + const datePart = isoString.split('T')[0] + return datePart ?? isoString +} + +/** 获取日期范围的起始时间戳 */ +function getStartTimestamp(days: number): number { + const now = new Date() + const start = new Date(now.getTime() - days * 24 * 60 * 60 * 1000) + start.setHours(0, 0, 0, 0) + return start.getTime() +} + +/** 从 Chat 消息中提取使用量统计 */ +function extractChatUsage(messages: ChatMessage[]): { + totalTokens: number + promptTokens: number + completionTokens: number + modelId: string +} { + let totalTokens = 0 + let promptTokens = 0 + let completionTokens = 0 + let modelId = '' + + for (const msg of messages) { + if (msg.role === 'assistant' && msg.usage) { + totalTokens += msg.usage.totalTokens + promptTokens += msg.usage.promptTokens + completionTokens += msg.usage.completionTokens + if (msg.model) { + modelId = msg.model + } + } + } + + return { totalTokens, promptTokens, completionTokens, modelId } +} + +/** 从 Agent 事件中提取使用量统计 */ +function extractAgentUsage(messages: AgentMessage[]): { + totalTokens: number + promptTokens: number + completionTokens: number + modelId: string +} { + let totalTokens = 0 + let promptTokens = 0 + let completionTokens = 0 + let modelId = '' + + for (const msg of messages) { + if (msg.role === 'assistant' && msg.events) { + for (const event of msg.events) { + if (event.type === 'complete' && event.usage) { + promptTokens += event.usage.inputTokens + completionTokens += event.usage.outputTokens || 0 + totalTokens += event.usage.inputTokens + (event.usage.outputTokens || 0) + } + if (event.type === 'usage_update') { + promptTokens += event.usage.inputTokens + totalTokens += event.usage.inputTokens + } + } + } + } + + return { totalTokens, promptTokens, completionTokens, modelId } +} + +/** 获取使用量统计总览 */ +export async function getUsageStats(days: number = 30): Promise { + const startTimestamp = getStartTimestamp(days) + + // 获取所有对话和会话 + const conversations = listConversations() + const sessions = listAgentSessions() + + + // 初始化统计数据 + let totalConversations = 0 + let totalMessages = 0 + let totalTokens = 0 + let totalPromptTokens = 0 + let totalCompletionTokens = 0 + let estimatedCost = 0 + + // 按日期聚合 + const dailyMap = new Map() + + // 按模型聚合 + const modelMap = new Map() + + // 最近对话列表 + const recentConversations: ConversationUsage[] = [] + + // 处理 Chat 对话 + let processedChatConvs = 0 + for (const conv of conversations) { + if (conv.createdAt < startTimestamp) continue + + const messages = getConversationMessages(conv.id) + const usage = extractChatUsage(messages) + + + if (usage.totalTokens === 0) continue + processedChatConvs++ + + totalConversations++ + totalMessages += messages.length + totalTokens += usage.totalTokens + totalPromptTokens += usage.promptTokens + totalCompletionTokens += usage.completionTokens + + const modelId = usage.modelId || conv.modelId || 'unknown' + const cost = calculateCost( + { + promptTokens: usage.promptTokens, + completionTokens: usage.completionTokens, + totalTokens: usage.totalTokens, + }, + modelId, + ) + estimatedCost += cost + + // 按日期聚合 + const date = formatDate(conv.createdAt) + const existing = dailyMap.get(date) + if (existing) { + existing.totalTokens += usage.totalTokens + existing.promptTokens += usage.promptTokens + existing.completionTokens += usage.completionTokens + existing.conversationCount++ + existing.messageCount += messages.length + existing.estimatedCost += cost + } else { + dailyMap.set(date, { + date, + totalTokens: usage.totalTokens, + promptTokens: usage.promptTokens, + completionTokens: usage.completionTokens, + conversationCount: 1, + messageCount: messages.length, + estimatedCost: cost, + }) + } + + // 按模型聚合 + const model = modelMap.get(modelId) + if (model) { + model.totalTokens += usage.totalTokens + model.promptTokens += usage.promptTokens + model.completionTokens += usage.completionTokens + model.conversationCount++ + model.estimatedCost += cost + } else { + modelMap.set(modelId, { + modelId, + totalTokens: usage.totalTokens, + promptTokens: usage.promptTokens, + completionTokens: usage.completionTokens, + conversationCount: 1, + estimatedCost: cost, + }) + } + + // 添加到最近对话列表 + recentConversations.push({ + conversationId: conv.id, + title: conv.title, + modelId: modelId || conv.modelId || 'unknown', + channelId: conv.channelId || '', + createdAt: conv.createdAt, + messageCount: messages.length, + totalTokens: usage.totalTokens, + promptTokens: usage.promptTokens, + completionTokens: usage.completionTokens, + mode: 'chat', + }) + } + + // 处理 Agent 会话 + for (const session of sessions) { + if (session.createdAt < startTimestamp) continue + + const messages = getAgentSessionMessages(session.id) + const usage = extractAgentUsage(messages) + + if (usage.totalTokens === 0) continue + + totalConversations++ + totalMessages += messages.length + totalTokens += usage.totalTokens + totalPromptTokens += usage.promptTokens + totalCompletionTokens += usage.completionTokens + + const modelId = usage.modelId || 'unknown' + const cost = calculateCost( + { + promptTokens: usage.promptTokens, + completionTokens: usage.completionTokens, + totalTokens: usage.totalTokens, + }, + modelId, + ) + estimatedCost += cost + + // 按日期聚合 + const date = formatDate(session.createdAt) + const existing = dailyMap.get(date) + if (existing) { + existing.totalTokens += usage.totalTokens + existing.promptTokens += usage.promptTokens + existing.completionTokens += usage.completionTokens + existing.conversationCount++ + existing.messageCount += messages.length + existing.estimatedCost += cost + } else { + dailyMap.set(date, { + date, + totalTokens: usage.totalTokens, + promptTokens: usage.promptTokens, + completionTokens: usage.completionTokens, + conversationCount: 1, + messageCount: messages.length, + estimatedCost: cost, + }) + } + + // 按模型聚合 + const model = modelMap.get(modelId) + if (model) { + model.totalTokens += usage.totalTokens + model.promptTokens += usage.promptTokens + model.completionTokens += usage.completionTokens + model.conversationCount++ + model.estimatedCost += cost + } else { + modelMap.set(modelId, { + modelId, + totalTokens: usage.totalTokens, + promptTokens: usage.promptTokens, + completionTokens: usage.completionTokens, + conversationCount: 1, + estimatedCost: cost, + }) + } + + // 添加到最近对话列表 + recentConversations.push({ + conversationId: session.id, + title: session.title, + modelId: modelId || 'unknown', + channelId: session.channelId || '', + createdAt: session.createdAt, + messageCount: messages.length, + totalTokens: usage.totalTokens, + promptTokens: usage.promptTokens, + completionTokens: usage.completionTokens, + mode: 'agent', + }) + } + + // 按日期排序(从近到远) + const dailyUsage = Array.from(dailyMap.values()).sort( + (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(), + ) + + // 按 Token 数量排序(从多到少) + const modelUsage = Array.from(modelMap.values()).sort( + (a, b) => b.totalTokens - a.totalTokens, + ) + + // 按创建时间排序(从近到远) + recentConversations.sort((a, b) => b.createdAt - a.createdAt) + + return { + totalConversations, + totalMessages, + totalTokens, + promptTokens: totalPromptTokens, + completionTokens: totalCompletionTokens, + estimatedCost, + dailyUsage, + modelUsage, + recentConversations: recentConversations.slice(0, 20), // 只返回最近 20 条 + } +} + +/** 获取指定对话的使用量详情 */ +export function getConversationUsage(conversationId: string): ConversationUsage | null { + // 先尝试从 Chat 对话中查找 + const conversations = listConversations() + const conv = conversations.find((c) => c.id === conversationId) + + if (conv) { + const messages = getConversationMessages(conv.id) + const usage = extractChatUsage(messages) + + if (usage.totalTokens === 0) return null + + return { + conversationId: conv.id, + title: conv.title, + modelId: usage.modelId || conv.modelId || 'unknown', + channelId: conv.channelId || '', + createdAt: conv.createdAt, + messageCount: messages.length, + totalTokens: usage.totalTokens, + promptTokens: usage.promptTokens, + completionTokens: usage.completionTokens, + mode: 'chat', + } + } + + // 再尝试从 Agent 会话中查找 + const sessions = listAgentSessions() + const session = sessions.find((s) => s.id === conversationId) + + if (session) { + const messages = getAgentSessionMessages(session.id) + const usage = extractAgentUsage(messages) + + if (usage.totalTokens === 0) return null + + return { + conversationId: session.id, + title: session.title, + modelId: usage.modelId || 'unknown', + channelId: session.channelId || '', + createdAt: session.createdAt, + messageCount: messages.length, + totalTokens: usage.totalTokens, + promptTokens: usage.promptTokens, + completionTokens: usage.completionTokens, + mode: 'agent', + } + } + + return null +} + +/** 获取默认定价列表 */ +export function getDefaultPricing(): ModelPricing[] { + return [...DEFAULT_PRICING] +} diff --git a/apps/electron/src/preload/index.ts b/apps/electron/src/preload/index.ts index 6cef392..8312896 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 } 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, USAGE_IPC_CHANNELS } from '@proma/shared' import { USER_PROFILE_IPC_CHANNELS, SETTINGS_IPC_CHANNELS } from '../types' import type { RuntimeStatus, @@ -50,6 +50,9 @@ import type { SystemProxyDetectResult, GitHubRelease, GitHubReleaseListOptions, + UsageStats, + ConversationUsage, + UsageSettings, } from '@proma/shared' import type { UserProfile, AppSettings } from '../types' @@ -361,6 +364,20 @@ export interface ElectronAPI { // 工作区文件变化通知 onCapabilitiesChanged: (callback: () => void) => () => void onWorkspaceFilesChanged: (callback: () => void) => () => void + + // ===== 使用统计相关 ===== + + /** 获取使用量统计总览 */ + getUsageStats: (days?: number) => Promise + + /** 获取指定对话的使用量详情 */ + getConversationUsage: (conversationId: string) => Promise + + /** 获取使用统计设置 */ + getUsageSettings: () => Promise + + /** 更新使用统计设置 */ + updateUsageSettings: (settings: UsageSettings) => Promise } /** @@ -754,6 +771,23 @@ const electronAPI: ElectronAPI = { getReleaseByTag: (tag) => { return ipcRenderer.invoke(GITHUB_RELEASE_IPC_CHANNELS.GET_RELEASE_BY_TAG, tag) }, + + // 使用统计 + getUsageStats: (days?: number) => { + return ipcRenderer.invoke(USAGE_IPC_CHANNELS.GET_USAGE_STATS, days) + }, + + getConversationUsage: (conversationId: string) => { + return ipcRenderer.invoke(USAGE_IPC_CHANNELS.GET_CONVERSATION_USAGE, conversationId) + }, + + getUsageSettings: () => { + return ipcRenderer.invoke(USAGE_IPC_CHANNELS.GET_USAGE_SETTINGS) + }, + + updateUsageSettings: (settings: UsageSettings) => { + return ipcRenderer.invoke(USAGE_IPC_CHANNELS.UPDATE_USAGE_SETTINGS, settings) + }, } // 将 API 暴露到渲染进程的 window 对象上 diff --git a/apps/electron/src/renderer/atoms/settings-tab.ts b/apps/electron/src/renderer/atoms/settings-tab.ts index 26af9ff..6fb0204 100644 --- a/apps/electron/src/renderer/atoms/settings-tab.ts +++ b/apps/electron/src/renderer/atoms/settings-tab.ts @@ -11,7 +11,7 @@ import { atom } from 'jotai' -export type SettingsTab = 'general' | 'channels' | 'proxy' | 'appearance' | 'about' | 'agent' +export type SettingsTab = 'general' | 'channels' | 'proxy' | 'appearance' | 'usage' | 'about' | 'agent' /** 当前设置标签页(不持久化,每次打开设置默认显示渠道) */ 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 0000000..3494e7a --- /dev/null +++ b/apps/electron/src/renderer/atoms/usage-atoms.ts @@ -0,0 +1,102 @@ +/** + * Usage Statistics 状态管理 + * + * 使用 Jotai 管理使用统计相关的状态。 + */ + +import { atom } from 'jotai' +import type { UsageStats, UsageSettings } from '@proma/shared' + +/** 使用量统计数据 */ +export const usageStatsAtom = atom(null) + +/** 加载状态 */ +export const usageLoadingAtom = atom(false) + +/** 错误信息 */ +export const usageErrorAtom = atom(null) + +/** 时间范围(天数) */ +export const usageTimeRangeAtom = atom(30) + +/** 使用统计设置 */ +export const usageSettingsAtom = atom({ pricing: [] }) + +/** 加载使用统计 */ +export const loadUsageStatsAtom = atom( + null, + async (get, set, days: number = 30) => { + set(usageLoadingAtom, true) + set(usageErrorAtom, null) + + try { + const stats = await window.electronAPI.getUsageStats(days) + set(usageStatsAtom, stats) + } catch (error) { + const message = error instanceof Error ? error.message : '加载使用统计失败' + set(usageErrorAtom, message) + console.error('[使用统计] 加载失败:', error) + } finally { + set(usageLoadingAtom, false) + } + }, +) + +/** 加载使用统计设置 */ +export const loadUsageSettingsAtom = atom( + null, + async (_get, set) => { + try { + const settings = await window.electronAPI.getUsageSettings() + set(usageSettingsAtom, settings) + } catch (error) { + console.error('[使用统计] 加载设置失败:', error) + } + }, +) + +/** 更新使用统计设置 */ +export const updateUsageSettingsAtom = atom( + null, + async (_get, set, settings: UsageSettings) => { + try { + const updated = await window.electronAPI.updateUsageSettings(settings) + set(usageSettingsAtom, updated) + return updated + } catch (error) { + console.error('[使用统计] 更新设置失败:', error) + throw error + } + }, +) + +/** 格式化 Token 数量(转换为 K/M) */ +export function formatTokens(tokens: number): string { + if (tokens >= 1_000_000) { + return `${(tokens / 1_000_000).toFixed(2)}M` + } + if (tokens >= 1_000) { + return `${(tokens / 1_000).toFixed(1)}K` + } + return tokens.toString() +} + +/** 格式化成本(USD) */ +export function formatCost(cost: number): string { + if (cost >= 1) { + return `$${cost.toFixed(2)}` + } + if (cost >= 0.01) { + return `$${cost.toFixed(3)}` + } + return `$${cost.toFixed(4)}` +} + +/** 格式化日期 */ +export function formatDate(dateStr: string): string { + const date = new Date(dateStr) + return date.toLocaleDateString('zh-CN', { + month: 'short', + day: 'numeric', + }) +} diff --git a/apps/electron/src/renderer/components/settings/SettingsPanel.tsx b/apps/electron/src/renderer/components/settings/SettingsPanel.tsx index 1db9d7b..3a8710a 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 } from 'lucide-react' +import { Settings, Radio, Palette, Info, Plug, Globe, BarChart3 } from 'lucide-react' import { ScrollArea } from '@/components/ui/scroll-area' import { settingsTabAtom } from '@/atoms/settings-tab' import type { SettingsTab } from '@/atoms/settings-tab' @@ -22,6 +22,7 @@ import { ProxySettings } from './ProxySettings' import { AppearanceSettings } from './AppearanceSettings' import { AboutSettings } from './AboutSettings' import { AgentSettings } from './AgentSettings' +import { UsageSettings } from './UsageSettings' /** 设置 Tab 定义 */ interface TabItem { @@ -43,6 +44,7 @@ const AGENT_TAB: TabItem = { id: 'agent', label: '配置', icon: }, + { id: 'usage', label: '统计', icon: }, { id: 'about', label: '关于', icon: }, ] @@ -59,6 +61,8 @@ function renderTabContent(tab: SettingsTab): React.ReactElement { return case 'appearance': return + case 'usage': + return case 'about': 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 0000000..ef9b76f --- /dev/null +++ b/apps/electron/src/renderer/components/settings/UsageSettings.tsx @@ -0,0 +1,689 @@ +/** + * UsageSettings - 使用统计设置页 + * + * 展示使用量统计总览、每日趋势图、模型分布图、成本估算等。 + * 采用科技蓝调设计风格,深浅主题自适应。 + */ + +import * as React from 'react' +import { useAtom, useSetAtom } from 'jotai' +import { + BarChart3, + Calendar, + Coins, + MessageSquare, + RefreshCw, + TrendingUp, + Clock, + Bot, + ChevronRight, + Zap, + ArrowUpRight, + ArrowDownRight, +} from 'lucide-react' +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + PieChart, + Pie, + Cell, + BarChart, + Bar, +} from 'recharts' +import { + SettingsSection, + SettingsCard, + SettingsRow, +} from './primitives' +import { Button } from '@/components/ui/button' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Spinner } from '@/components/ui/spinner' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { + usageStatsAtom, + usageLoadingAtom, + usageErrorAtom, + usageTimeRangeAtom, + loadUsageStatsAtom, + formatTokens, + formatCost, + formatDate, +} from '@/atoms/usage-atoms' +import { cn } from '@/lib/utils' +import type { DailyUsage, ModelUsage, ConversationUsage } from '@proma/shared' + +/** 统计卡片组件 - 科技蓝调风格 */ +function StatCard({ + title, + value, + icon: Icon, + description, + trend, + trendUp, + accent = 'blue', +}: { + title: string + value: string + icon: React.ElementType + description?: string + trend?: string + trendUp?: boolean + accent?: 'blue' | 'cyan' | 'violet' | 'emerald' +}): React.ReactElement { + const accentColors = { + blue: { + bg: 'bg-blue-500/10 dark:bg-blue-400/10', + text: 'text-blue-600 dark:text-blue-400', + glow: 'shadow-blue-500/20 dark:shadow-blue-400/20', + border: 'border-blue-500/20 dark:border-blue-400/20', + }, + cyan: { + bg: 'bg-cyan-500/10 dark:bg-cyan-400/10', + text: 'text-cyan-600 dark:text-cyan-400', + glow: 'shadow-cyan-500/20 dark:shadow-cyan-400/20', + border: 'border-cyan-500/20 dark:border-cyan-400/20', + }, + violet: { + bg: 'bg-violet-500/10 dark:bg-violet-400/10', + text: 'text-violet-600 dark:text-violet-400', + glow: 'shadow-violet-500/20 dark:shadow-violet-400/20', + border: 'border-violet-500/20 dark:border-violet-400/20', + }, + emerald: { + bg: 'bg-emerald-500/10 dark:bg-emerald-400/10', + text: 'text-emerald-600 dark:text-emerald-400', + glow: 'shadow-emerald-500/20 dark:shadow-emerald-400/20', + border: 'border-emerald-500/20 dark:border-emerald-400/20', + }, + } + + const colors = accentColors[accent] + const TrendIcon = trendUp ? ArrowUpRight : ArrowDownRight + + return ( +
+ {/* 背景装饰渐变 */} +
+ +
+
+ +
+ +
+

{title}

+
+

{value}

+ {trend && ( + + + {trend} + + )} +
+ {description && ( +

+ {description} +

+ )} +
+
+
+ ) +} + +/** 图表颜色配置 - 科技蓝调色板 */ +const CHART_COLORS = { + // 深色模式配色 + dark: [ + '#60a5fa', // blue-400 + '#22d3ee', // cyan-400 + '#a78bfa', // violet-400 + '#34d399', // emerald-400 + '#fbbf24', // amber-400 + ], + // 浅色模式配色 + light: [ + '#2563eb', // blue-600 + '#0891b2', // cyan-600 + '#7c3aed', // violet-600 + '#059669', // emerald-600 + '#d97706', // amber-600 + ], +} + +/** 对话列表项 - 现代化样式 */ +function ConversationItem({ + conversation, + onClick, +}: { + conversation: ConversationUsage + onClick: () => void +}): React.ReactElement { + const isAgent = conversation.mode === 'agent' + + return ( + + ) +} + +/** 自定义 Tooltip 内容 - 科技感样式 */ +function ChartTooltip({ + active, + payload, + label, + valueFormatter, + labelFormatter, +}: { + active?: boolean + payload?: Array<{ value: number; name: string; dataKey?: string }> + label?: string + valueFormatter?: (value: number) => string + labelFormatter?: (label: string) => string +}): React.ReactElement | null { + if (!active || !payload || !payload.length) return null + + return ( +
+ {label && labelFormatter && ( +

+ {labelFormatter(label)} +

+ )} +
+ {payload.map((entry, index) => ( +
+ {entry.name} + + {valueFormatter ? valueFormatter(Number(entry.value)) : entry.value} + +
+ ))} +
+
+ ) +} + +export function UsageSettings(): React.ReactElement { + const [stats] = useAtom(usageStatsAtom) + const [loading] = useAtom(usageLoadingAtom) + const [error] = useAtom(usageErrorAtom) + const [timeRange, setTimeRange] = useAtom(usageTimeRangeAtom) + const loadStats = useSetAtom(loadUsageStatsAtom) + + // 检测深色模式 + const [isDark, setIsDark] = React.useState(false) + React.useEffect(() => { + const checkDark = () => { + setIsDark(document.documentElement.classList.contains('dark')) + } + checkDark() + const observer = new MutationObserver(checkDark) + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) + return () => observer.disconnect() + }, []) + + // 获取当前配色方案 + const chartColors = isDark ? CHART_COLORS.dark : CHART_COLORS.light + const primaryColor = chartColors[0] + + // 初始加载 + React.useEffect(() => { + loadStats(timeRange) + }, [loadStats, timeRange]) + + // 刷新数据 + const handleRefresh = () => { + loadStats(timeRange) + } + + // 时间范围选项 + const timeRangeOptions = [ + { value: 7, label: '7天' }, + { value: 30, label: '30天' }, + { value: 90, label: '90天' }, + ] + + // 准备图表数据 + const dailyData = React.useMemo(() => { + if (!stats?.dailyUsage) return [] + return [...stats.dailyUsage].reverse() + }, [stats?.dailyUsage]) + + const modelData = React.useMemo(() => { + if (!stats?.modelUsage) return [] + return stats.modelUsage.slice(0, 5) + }, [stats?.modelUsage]) + + // 加载中状态 + if (loading && !stats) { + return ( +
+
+
+ +
+

正在加载统计数据...

+
+ ) + } + + // 错误状态 + if (error) { + return ( +
+
+ +
+

加载失败

+

{error}

+ +
+ ) + } + + // 空状态 + if (!stats || stats.totalConversations === 0) { + return ( +
+
+
+
+ +
+
+

暂无使用数据

+

+ 开始对话后,这里会展示你的使用量统计和分析 +

+
+ ) + } + + return ( + +
+ {/* 头部工具栏 */} +
+ + + +
+ + {/* 统计卡片 */} +
+ + + + +
+ + {/* 每日趋势图 */} + + +
+
+ + + + + + + + + + + + + } + /> + + + +
+
+
+
+ + {/* 模型分布图 */} + {modelData.length > 0 && ( + +
+ {/* 饼图 */} + +
+
+ + + + {modelData.map((entry, index) => ( + + ))} + + String(l)} + /> + } + /> + + +
+ {/* 图例 */} +
+ {modelData.map((model, index) => ( +
+
+ + {model.modelId.length > 15 + ? model.modelId.slice(0, 12) + '...' + : model.modelId} + +
+ ))} +
+
+ + + {/* 条形图 */} + +
+
+ + + + + + value.length > 18 ? value.slice(0, 15) + '...' : value + } + tickLine={false} + axisLine={false} + /> + String(l)} + /> + } + /> + + + +
+
+
+
+ + )} + + {/* 最近对话列表 */} + {stats.recentConversations.length > 0 && ( + + +
+ {stats.recentConversations.map((conv, index) => ( + { + // TODO: 跳转到对应对话 + console.log('点击对话:', conv.conversationId) + }} + /> + ))} +
+
+
+ )} + + {/* 定价信息 */} + + +
+

+ 成本估算基于主流供应商公开定价,实际费用可能因供应商调整而变化。 + 常用模型参考价格: +

+
+ {[ + { name: 'Claude 3.5 Sonnet', price: '$3/M 输入, $15/M 输出', color: 'bg-amber-500/10 text-amber-600 dark:text-amber-400' }, + { name: 'GPT-4o', price: '$5/M 输入, $15/M 输出', color: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' }, + { name: 'GPT-4o-mini', price: '$0.15/M 输入, $0.60/M 输出', color: 'bg-cyan-500/10 text-cyan-600 dark:text-cyan-400' }, + { name: 'Gemini 1.5 Pro', price: '$3.5/M 输入, $10.5/M 输出', color: 'bg-violet-500/10 text-violet-600 dark:text-violet-400' }, + ].map((model) => ( +
+ {model.name} + + {model.price} + +
+ ))} +
+
+
+
+
+ + ) +} diff --git a/apps/electron/src/renderer/components/settings/index.ts b/apps/electron/src/renderer/components/settings/index.ts index f18eb29..1bcdb6a 100644 --- a/apps/electron/src/renderer/components/settings/index.ts +++ b/apps/electron/src/renderer/components/settings/index.ts @@ -7,5 +7,6 @@ export * from './ChannelSettings' export * from './ChannelForm' export * from './GeneralSettings' export * from './AppearanceSettings' +export * from './UsageSettings' export * from './AboutSettings' -export * from './primitives' +export * from './primitives/index' diff --git a/apps/electron/src/renderer/components/settings/primitives/SettingsUIConstants.ts b/apps/electron/src/renderer/components/settings/primitives/SettingsUIConstants.ts index 97bb625..b77174f 100644 --- a/apps/electron/src/renderer/components/settings/primitives/SettingsUIConstants.ts +++ b/apps/electron/src/renderer/components/settings/primitives/SettingsUIConstants.ts @@ -17,8 +17,8 @@ export const SECTION_TITLE_CLASS = 'text-base font-semibold text-foreground' /** 区块描述样式 */ export const SECTION_DESCRIPTION_CLASS = 'text-sm text-muted-foreground mt-1' -/** 卡片容器样式 */ -export const CARD_CLASS = 'rounded-xl bg-card shadow-minimal overflow-hidden' +/** 卡片容器样式 - 科技蓝调风格 */ +export const CARD_CLASS = 'rounded-2xl bg-card border border-border/50 dark:border-border/30 shadow-sm dark:shadow-none overflow-hidden transition-all duration-200 hover:border-border/80 dark:hover:border-border/50' /** 卡片内行样式 */ export const ROW_CLASS = 'flex items-center justify-between px-4 py-3' diff --git a/bun.lock b/bun.lock index f3f9271..574e9f2 100644 --- a/bun.lock +++ b/bun.lock @@ -1,10 +1,12 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "proma", "dependencies": { "jotai": "^2.17.1", + "recharts": "^3.7.0", }, "devDependencies": { "@types/bun": "latest", @@ -19,7 +21,7 @@ }, "apps/electron": { "name": "@proma/electron", - "version": "0.4.20", + "version": "0.4.21", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.44", "@emoji-mart/data": "^1.2.1", @@ -421,6 +423,8 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="], + "@remirror/core-constants": ["@remirror/core-constants@3.0.0", "", {}, "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], @@ -491,6 +495,10 @@ "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], + "@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="], "@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="], @@ -571,6 +579,24 @@ "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -843,8 +869,32 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + "debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], "decompress": ["decompress@4.2.1", "", { "dependencies": { "decompress-tar": "^4.0.0", "decompress-tarbz2": "^4.0.0", "decompress-targz": "^4.0.0", "decompress-unzip": "^4.0.1", "graceful-fs": "^4.1.10", "make-dir": "^1.0.0", "pify": "^2.3.0", "strip-dirs": "^2.0.0" } }, "sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ=="], @@ -943,6 +993,8 @@ "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + "es-toolkit": ["es-toolkit@1.44.0", "", {}, "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg=="], + "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], "esbuild": ["esbuild@0.24.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.24.2", "@esbuild/android-arm": "0.24.2", "@esbuild/android-arm64": "0.24.2", "@esbuild/android-x64": "0.24.2", "@esbuild/darwin-arm64": "0.24.2", "@esbuild/darwin-x64": "0.24.2", "@esbuild/freebsd-arm64": "0.24.2", "@esbuild/freebsd-x64": "0.24.2", "@esbuild/linux-arm": "0.24.2", "@esbuild/linux-arm64": "0.24.2", "@esbuild/linux-ia32": "0.24.2", "@esbuild/linux-loong64": "0.24.2", "@esbuild/linux-mips64el": "0.24.2", "@esbuild/linux-ppc64": "0.24.2", "@esbuild/linux-riscv64": "0.24.2", "@esbuild/linux-s390x": "0.24.2", "@esbuild/linux-x64": "0.24.2", "@esbuild/netbsd-arm64": "0.24.2", "@esbuild/netbsd-x64": "0.24.2", "@esbuild/openbsd-arm64": "0.24.2", "@esbuild/openbsd-x64": "0.24.2", "@esbuild/sunos-x64": "0.24.2", "@esbuild/win32-arm64": "0.24.2", "@esbuild/win32-ia32": "0.24.2", "@esbuild/win32-x64": "0.24.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA=="], @@ -959,6 +1011,8 @@ "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], @@ -1099,6 +1153,8 @@ "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], + "import-from": ["import-from@3.0.0", "", { "dependencies": { "resolve-from": "^5.0.0" } }, "sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ=="], "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], @@ -1113,6 +1169,8 @@ "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -1559,8 +1617,12 @@ "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], + "react-is": ["react-is@19.2.4", "", {}, "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA=="], + "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], + "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], @@ -1581,6 +1643,12 @@ "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + "recharts": ["recharts@3.7.0", "", { "dependencies": { "@reduxjs/toolkit": "1.x.x || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew=="], + + "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], + + "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], @@ -1603,6 +1671,8 @@ "resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="], + "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], + "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], "resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="], @@ -1753,6 +1823,8 @@ "through": ["through@2.3.8", "", {}, "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tiny-typed-emitter": ["tiny-typed-emitter@2.1.0", "", {}, "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], @@ -1847,6 +1919,8 @@ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], + "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], @@ -1965,6 +2039,8 @@ "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], + "@types/cacheable-request/@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], "@types/fs-extra/@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], diff --git a/package.json b/package.json index 49bcf62..49a9955 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "typescript": "^5" }, "dependencies": { - "jotai": "^2.17.1" + "jotai": "^2.17.1", + "recharts": "^3.7.0" } } diff --git a/packages/core/src/providers/anthropic-adapter.ts b/packages/core/src/providers/anthropic-adapter.ts index 2e79b14..ee3ead5 100644 --- a/packages/core/src/providers/anthropic-adapter.ts +++ b/packages/core/src/providers/anthropic-adapter.ts @@ -47,6 +47,19 @@ interface AnthropicDeltaEvent { text?: string /** 思考内容增量 (thinking_delta) */ thinking?: string + /** 输出 token 数量 (message_delta) */ + output_tokens?: number + } + /** 消息开始时的 usage 信息 */ + usage?: { + input_tokens: number + output_tokens?: number + } + message?: { + usage?: { + input_tokens: number + output_tokens?: number + } } } @@ -188,6 +201,28 @@ export class AnthropicAdapter implements ProviderAdapter { } } + // 处理 message_start 事件中的 usage(输入 token) + if (event.type === 'message_start' && event.message?.usage) { + const usage = event.message.usage + events.push({ + type: 'usage', + promptTokens: usage.input_tokens, + completionTokens: 0, + totalTokens: usage.input_tokens, + }) + } + + // 处理 message_delta 事件中的 usage(输出 token) + if (event.type === 'message_delta' && event.usage) { + const outputTokens = event.usage.output_tokens ?? event.delta?.output_tokens ?? 0 + events.push({ + type: 'usage', + promptTokens: 0, + completionTokens: outputTokens, + totalTokens: outputTokens, + }) + } + return events } catch { return [] diff --git a/packages/core/src/providers/google-adapter.ts b/packages/core/src/providers/google-adapter.ts index 516c85c..19bf108 100644 --- a/packages/core/src/providers/google-adapter.ts +++ b/packages/core/src/providers/google-adapter.ts @@ -49,6 +49,12 @@ interface GoogleStreamData { } finishReason?: string }> + /** 使用量统计(在最后一个 chunk 中返回) */ + usageMetadata?: { + promptTokenCount?: number + candidatesTokenCount?: number + totalTokenCount?: number + } } /** Google 标题响应 */ @@ -169,24 +175,35 @@ 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[] = [] - // 遍历所有 parts,区分推理内容和正常文本 - for (const part of parts) { - if (!part.text) continue - - if (part.thought) { - // Gemini 2.5/3 思考过程 - events.push({ type: 'reasoning', delta: part.text }) - } else { - // 正常回复内容 - events.push({ type: 'chunk', delta: part.text }) + // 处理内容部分 + const parts = parsed.candidates?.[0]?.content?.parts + if (parts) { + // 遍历所有 parts,区分推理内容和正常文本 + for (const part of parts) { + if (!part.text) continue + + if (part.thought) { + // Gemini 2.5/3 思考过程 + events.push({ type: 'reasoning', delta: part.text }) + } else { + // 正常回复内容 + events.push({ type: 'chunk', delta: part.text }) + } } } + // 处理 usage(在最后一个 chunk 中返回) + if (parsed.usageMetadata) { + events.push({ + type: 'usage', + promptTokens: parsed.usageMetadata.promptTokenCount ?? 0, + completionTokens: parsed.usageMetadata.candidatesTokenCount ?? 0, + totalTokens: parsed.usageMetadata.totalTokenCount ?? 0, + }) + } + return events } catch { return [] diff --git a/packages/core/src/providers/openai-adapter.ts b/packages/core/src/providers/openai-adapter.ts index 627d926..e163c87 100644 --- a/packages/core/src/providers/openai-adapter.ts +++ b/packages/core/src/providers/openai-adapter.ts @@ -35,12 +35,23 @@ interface OpenAIMessage { content: string | OpenAIContentBlock[] } +/** OpenAI Usage 数据 */ +interface OpenAIUsage { + prompt_tokens: number + completion_tokens: number + total_tokens: number + prompt_tokens_details?: { + cached_tokens?: number + } +} + /** OpenAI SSE 数据块 */ interface OpenAIChunkData { choices?: Array<{ delta?: { content?: string; reasoning_content?: string } finish_reason?: string | null }> + usage?: OpenAIUsage } /** OpenAI 标题响应 */ @@ -135,6 +146,7 @@ export class OpenAIAdapter implements ProviderAdapter { model: input.modelId, messages, stream: true, + stream_options: { include_usage: true }, }), } } @@ -142,9 +154,10 @@ export class OpenAIAdapter implements ProviderAdapter { parseSSELine(jsonLine: string): StreamEvent[] { try { const chunk = JSON.parse(jsonLine) as OpenAIChunkData - const delta = chunk.choices?.[0]?.delta const events: StreamEvent[] = [] + // 处理内容增量 + const delta = chunk.choices?.[0]?.delta if (delta?.content) { events.push({ type: 'chunk', delta: delta.content }) } @@ -154,6 +167,17 @@ export class OpenAIAdapter implements ProviderAdapter { events.push({ type: 'reasoning', delta: delta.reasoning_content }) } + // 处理 usage(OpenAI 在最后一个 chunk 中返回,此时 choices 为空) + if (chunk.usage) { + events.push({ + type: 'usage', + promptTokens: chunk.usage.prompt_tokens, + completionTokens: chunk.usage.completion_tokens, + totalTokens: chunk.usage.total_tokens, + cacheReadTokens: chunk.usage.prompt_tokens_details?.cached_tokens, + }) + } + return events } catch { return [] diff --git a/packages/core/src/providers/sse-reader.ts b/packages/core/src/providers/sse-reader.ts index 48dd11f..a10122c 100644 --- a/packages/core/src/providers/sse-reader.ts +++ b/packages/core/src/providers/sse-reader.ts @@ -10,6 +10,7 @@ */ import type { ProviderAdapter, ProviderRequest, StreamEventCallback } from './types.ts' +import type { TokenUsage } from '@proma/shared' // ===== 流式请求 ===== @@ -33,6 +34,8 @@ export interface StreamSSEResult { content: string /** 累积的推理内容 */ reasoning: string + /** Token 使用量统计 */ + usage?: TokenUsage } /** @@ -71,6 +74,7 @@ export async function streamSSE(options: StreamSSEOptions): Promise void +// ===== Re-export TokenUsage for convenience ===== +export type { TokenUsage } from '@proma/shared' + // ===== HTTP 请求 ===== /** 构建好的 HTTP 请求配置(用于 fetch) */ diff --git a/packages/shared/src/types/chat.ts b/packages/shared/src/types/chat.ts index 8d9e198..ca00587 100644 --- a/packages/shared/src/types/chat.ts +++ b/packages/shared/src/types/chat.ts @@ -59,6 +59,22 @@ export interface FileDialogResult { */ export type MessageRole = 'user' | 'assistant' | 'system' +/** + * Token 使用量统计 + */ +export interface TokenUsage { + /** 输入 Token 数量 */ + promptTokens: number + /** 输出 Token 数量 */ + completionTokens: number + /** 总 Token 数量 */ + totalTokens: number + /** 缓存读取 Token 数量(Anthropic 等供应商支持) */ + cacheReadTokens?: number + /** 缓存创建 Token 数量(Anthropic 等供应商支持) */ + cacheCreationTokens?: number +} + /** * 聊天消息 */ @@ -79,6 +95,8 @@ export interface ChatMessage { stopped?: boolean /** 文件附件列表 */ attachments?: FileAttachment[] + /** Token 使用量统计(assistant 消息) */ + usage?: TokenUsage } // ===== 对话相关 ===== diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index abbc4f1..0e030d1 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -29,3 +29,6 @@ export * from './environment' // GitHub Release 相关类型 export * from './github' + +// 使用统计相关类型 +export * from './usage' diff --git a/packages/shared/src/types/usage.ts b/packages/shared/src/types/usage.ts new file mode 100644 index 0000000..302fd00 --- /dev/null +++ b/packages/shared/src/types/usage.ts @@ -0,0 +1,133 @@ +/** + * Usage Statistics 相关类型定义 + * + * 包含 Token 使用量统计、成本估算、统计聚合等类型。 + */ + +// ===== Token 使用量 ===== + +// ===== 使用量统计 ===== + +/** + * 单条对话的使用量统计 + */ +export interface ConversationUsage { + /** 对话 ID */ + conversationId: string + /** 对话标题 */ + title: string + /** 使用的模型 ID */ + modelId: string + /** 使用的渠道 ID */ + channelId: string + /** 对话创建时间 */ + createdAt: number + /** 消息数量 */ + messageCount: number + /** 总 Token 数量 */ + totalTokens: number + /** 输入 Token 数量 */ + promptTokens: number + /** 输出 Token 数量 */ + completionTokens: number + /** 应用模式:chat 或 agent */ + mode: 'chat' | 'agent' +} + +/** + * 每日使用量统计 + */ +export interface DailyUsage { + /** 日期 (YYYY-MM-DD) */ + date: string + /** 总 Token 数量 */ + totalTokens: number + /** 输入 Token 数量 */ + promptTokens: number + /** 输出 Token 数量 */ + completionTokens: number + /** 对话数量 */ + conversationCount: number + /** 消息数量 */ + messageCount: number + /** 预估成本 (USD) */ + estimatedCost: number +} + +/** + * 模型使用量统计 + */ +export interface ModelUsage { + /** 模型 ID */ + modelId: string + /** 总 Token 数量 */ + totalTokens: number + /** 输入 Token 数量 */ + promptTokens: number + /** 输出 Token 数量 */ + completionTokens: number + /** 对话数量 */ + conversationCount: number + /** 预估成本 (USD) */ + estimatedCost: number +} + +/** + * 使用量统计总览 + */ +export interface UsageStats { + /** 总对话数量 */ + totalConversations: number + /** 总消息数量 */ + totalMessages: number + /** 总 Token 数量 */ + totalTokens: number + /** 输入 Token 数量 */ + promptTokens: number + /** 输出 Token 数量 */ + completionTokens: number + /** 预估总成本 (USD) */ + estimatedCost: number + /** 每日使用量分布 */ + dailyUsage: DailyUsage[] + /** 模型使用量分布 */ + modelUsage: ModelUsage[] + /** 最近对话列表(含使用量) */ + recentConversations: ConversationUsage[] +} + +// ===== 成本计算 ===== + +/** + * 模型定价配置 + */ +export interface ModelPricing { + /** 模型 ID 或匹配模式(如 'claude-3-5-sonnet*') */ + modelId: string + /** 每 1K 输入 Token 的价格 (USD) */ + promptPricePer1k: number + /** 每 1K 输出 Token 的价格 (USD) */ + completionPricePer1k: number +} + +/** + * 使用统计设置 + */ +export interface UsageSettings { + /** 用户自定义定价 */ + pricing: ModelPricing[] +} + +/** + * 使用统计 IPC 通道常量 + */ +export const USAGE_IPC_CHANNELS = { + /** 获取使用量统计 */ + GET_USAGE_STATS: 'usage:getStats', + /** 获取对话使用量详情 */ + GET_CONVERSATION_USAGE: 'usage:getConversationUsage', + /** 获取使用统计设置 */ + GET_USAGE_SETTINGS: 'usage:getSettings', + /** 更新使用统计设置 */ + UPDATE_USAGE_SETTINGS: 'usage:updateSettings', +} as const