Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/electron/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -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",
Expand Down
23 changes: 22 additions & 1 deletion apps/electron/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1022,6 +1025,24 @@ export function registerIpcHandlers(): void {
}
)

// ===== 用量统计 =====

// 查询用量汇总
ipcMain.handle(
USAGE_IPC_CHANNELS.GET_SUMMARY,
async (_, range?: UsageQueryRange): Promise<UsageSummary> => {
return getUsageSummary(range)
}
)

// 清除用量记录
ipcMain.handle(
USAGE_IPC_CHANNELS.CLEAR,
async (): Promise<void> => {
clearUsageStats()
}
)

console.log('[IPC] IPC 处理器注册完成')

// 注册更新 IPC 处理器
Expand Down
30 changes: 29 additions & 1 deletion apps/electron/src/main/lib/chat-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down Expand Up @@ -342,6 +343,8 @@ export async function sendMessage(
// 在 try 外累积流式内容,abort 时 catch 块仍可访问
let accumulatedContent = ''
let accumulatedReasoning = ''
let accumulatedInputTokens = 0
let accumulatedOutputTokens = 0

try {
// 7. 获取适配器
Expand Down Expand Up @@ -383,7 +386,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,
Expand Down Expand Up @@ -415,6 +418,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
Expand Down Expand Up @@ -461,6 +470,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)

Expand All @@ -470,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})`)
}
Expand Down
9 changes: 9 additions & 0 deletions apps/electron/src/main/lib/config-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 会话索引文件路径
*
Expand Down
174 changes: 174 additions & 0 deletions apps/electron/src/main/lib/usage-stats-service.ts
Original file line number Diff line number Diff line change
@@ -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<UsageStatsFile>
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<string, DailyUsage>()
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<string, ModelUsage>()
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('[用量统计] 已清除所有记录')
}
20 changes: 19 additions & 1 deletion apps/electron/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -62,6 +62,8 @@ import type {
SystemPromptCreateInput,
SystemPromptUpdateInput,
MemoryConfig,
UsageQueryRange,
UsageSummary,
} from '@proma/shared'
import type { UserProfile, AppSettings } from '../types'

Expand Down Expand Up @@ -418,6 +420,14 @@ export interface ElectronAPI {
listReleases: (options?: GitHubReleaseListOptions) => Promise<GitHubRelease[]>
getReleaseByTag: (tag: string) => Promise<GitHubRelease | null>

// ===== 用量统计 =====

/** 查询用量汇总 */
getUsageSummary: (range?: UsageQueryRange) => Promise<UsageSummary>

/** 清除用量记录 */
clearUsageStats: () => Promise<void>

// 工作区文件变化通知
onCapabilitiesChanged: (callback: () => void) => () => void
onWorkspaceFilesChanged: (callback: () => void) => () => void
Expand Down Expand Up @@ -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 对象上
Expand Down
3 changes: 2 additions & 1 deletion apps/electron/src/renderer/atoms/settings-tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SettingsTab>('channels')
Loading