feat: 聊天会话底部显示token消耗

This commit is contained in:
digua committed 2025-12-24 00:49:11 +08:00
1 parent f84eeb9b38
commit dc71592980
11 files changed
+171 -25

No files matched your search

+59 -10
View File
@@ -79,6 +79,15 @@ export interface AgentConfig {
abortSignal?: AbortSignal
}
/**
* Token 使用量
*/
export interface TokenUsage {
promptTokens: number
completionTokens: number
totalTokens: number
}
/**
* Agent 流式响应 chunk
*/
@@ -97,6 +106,8 @@ export interface AgentStreamChunk {
error?: string
/** 是否完成 */
isFinished?: boolean
/** Token 使用量(type=done 时返回累计值) */
usage?: TokenUsage
}
/**
@@ -109,6 +120,8 @@ export interface AgentResult {
toolsUsed: string[]
/** 工具调用轮数 */
toolRounds: number
/** 总 Token 使用量(累计所有 LLM 调用) */
totalUsage?: TokenUsage
}
// ==================== 提示词配置类型 ====================
@@ -235,6 +248,8 @@ export class Agent {
private historyMessages: ChatMessage[] = []
private chatType: 'group' | 'private' = 'group'
private promptConfig?: PromptConfig
/** 累计 Token 使用量 */
private totalUsage: TokenUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
constructor(
context: ToolContext,
@@ -261,6 +276,17 @@ export class Agent {
return this.abortSignal?.aborted ?? false
}
/**
* 累加 Token 使用量
*/
private addUsage(usage?: { promptTokens: number; completionTokens: number; totalTokens: number }): void {
if (usage) {
this.totalUsage.promptTokens += usage.promptTokens
this.totalUsage.completionTokens += usage.completionTokens
this.totalUsage.totalTokens += usage.totalTokens
}
}
/**
* 执行对话(非流式)
* @param userMessage 用户消息
@@ -270,7 +296,7 @@ export class Agent {
// 检查是否已中止
if (this.isAborted()) {
return { content: '', toolsUsed: [], toolRounds: 0 }
return { content: '', toolsUsed: [], toolRounds: 0, totalUsage: this.totalUsage }
}
// 初始化消息(包含历史记录)
@@ -294,6 +320,7 @@ export class Agent {
content: '',
toolsUsed: this.toolsUsed,
toolRounds: this.toolRounds,
totalUsage: this.totalUsage,
}
}
@@ -303,6 +330,9 @@ export class Agent {
abortSignal: this.abortSignal,
})
// 累加 Token 使用量
this.addUsage(response.usage)
let toolCallsToProcess = response.tool_calls
// 如果没有标准 tool_calls,尝试 fallback 解析
@@ -323,6 +353,7 @@ export class Agent {
content: cleanContent,
toolsUsed: this.toolsUsed,
toolRounds: this.toolRounds,
totalUsage: this.totalUsage,
}
}
} else {
@@ -332,6 +363,7 @@ export class Agent {
content: response.content,
toolsUsed: this.toolsUsed,
toolRounds: this.toolRounds,
totalUsage: this.totalUsage,
}
}
}
@@ -349,10 +381,12 @@ export class Agent {
})
const finalResponse = await chat(this.messages, this.config.llmOptions)
this.addUsage(finalResponse.usage)
return {
content: finalResponse.content,
toolsUsed: this.toolsUsed,
toolRounds: this.toolRounds,
totalUsage: this.totalUsage,
}
}
@@ -366,8 +400,8 @@ export class Agent {
// 检查是否已中止
if (this.isAborted()) {
onChunk({ type: 'done', isFinished: true })
return { content: '', toolsUsed: [], toolRounds: 0 }
onChunk({ type: 'done', isFinished: true, usage: this.totalUsage })
return { content: '', toolsUsed: [], toolRounds: 0, totalUsage: this.totalUsage }
}
// 初始化消息(包含历史记录)
@@ -387,11 +421,12 @@ export class Agent {
while (this.toolRounds < this.config.maxToolRounds!) {
// 每轮开始时检查是否中止
if (this.isAborted()) {
onChunk({ type: 'done', isFinished: true })
onChunk({ type: 'done', isFinished: true, usage: this.totalUsage })
return {
content: finalContent,
toolsUsed: this.toolsUsed,
toolRounds: this.toolRounds,
totalUsage: this.totalUsage,
}
}
@@ -408,11 +443,12 @@ export class Agent {
})) {
// 每个 chunk 时检查是否中止
if (this.isAborted()) {
onChunk({ type: 'done', isFinished: true })
onChunk({ type: 'done', isFinished: true, usage: this.totalUsage })
return {
content: finalContent + accumulatedContent,
toolsUsed: this.toolsUsed,
toolRounds: this.toolRounds,
totalUsage: this.totalUsage,
}
}
if (chunk.content) {
@@ -444,6 +480,11 @@ export class Agent {
toolCalls = chunk.tool_calls
}
// 累加 Token 使用量(流式响应在最后一个 chunk 返回 usage)
if (chunk.usage) {
this.addUsage(chunk.usage)
}
if (chunk.isFinished) {
// 如果没有标准 tool_calls,尝试 fallback 解析
if (chunk.finishReason !== 'tool_calls' || !toolCalls) {
@@ -467,22 +508,24 @@ export class Agent {
}
finalContent = cleanContent
aiLogger.info('Agent', 'AI 回复', finalContent)
onChunk({ type: 'done', isFinished: true })
onChunk({ type: 'done', isFinished: true, usage: this.totalUsage })
return {
content: finalContent,
toolsUsed: this.toolsUsed,
toolRounds: this.toolRounds,
totalUsage: this.totalUsage,
}
}
} else {
// 没有 tool_call 标签,正常完成
finalContent = extractThinkingContent(accumulatedContent).cleanContent
aiLogger.info('Agent', 'AI 回复', finalContent)
onChunk({ type: 'done', isFinished: true })
onChunk({ type: 'done', isFinished: true, usage: this.totalUsage })
return {
content: finalContent,
toolsUsed: this.toolsUsed,
toolRounds: this.toolRounds,
totalUsage: this.totalUsage,
}
}
}
@@ -532,11 +575,12 @@ export class Agent {
// 检查是否已中止
if (this.isAborted()) {
onChunk({ type: 'done', isFinished: true })
onChunk({ type: 'done', isFinished: true, usage: this.totalUsage })
return {
content: finalContent,
toolsUsed: this.toolsUsed,
toolRounds: this.toolRounds,
totalUsage: this.totalUsage,
}
}
@@ -551,15 +595,19 @@ export class Agent {
abortSignal: this.abortSignal,
})) {
if (this.isAborted()) {
onChunk({ type: 'done', isFinished: true })
onChunk({ type: 'done', isFinished: true, usage: this.totalUsage })
break
}
if (chunk.content) {
finalContent += chunk.content
onChunk({ type: 'content', content: chunk.content })
}
// 累加 Token 使用量
if (chunk.usage) {
this.addUsage(chunk.usage)
}
if (chunk.isFinished) {
onChunk({ type: 'done', isFinished: true })
onChunk({ type: 'done', isFinished: true, usage: this.totalUsage })
}
}
@@ -567,6 +615,7 @@ export class Agent {
content: finalContent,
toolsUsed: this.toolsUsed,
toolRounds: this.toolRounds,
totalUsage: this.totalUsage,
}
}
+13 -2
View File
@@ -151,6 +151,8 @@ export class DeepSeekService implements ILLMService {
temperature: options?.temperature ?? 0.7,
max_tokens: options?.maxTokens ?? 2048,
stream: true,
// 启用流式响应中的 usage 统计
stream_options: { include_usage: true },
}
if (options?.tools && options.tools.length > 0) {
@@ -265,6 +267,15 @@ export class DeepSeekService implements ILLMService {
reason = 'tool_calls'
}
// 解析 usage 信息
const usage = parsed.usage
? {
promptTokens: parsed.usage.prompt_tokens,
completionTokens: parsed.usage.completion_tokens,
totalTokens: parsed.usage.total_tokens,
}
: undefined
// 如果有 tool_calls,返回它们
if (toolCallsAccumulator.size > 0) {
const toolCalls: ToolCall[] = Array.from(toolCallsAccumulator.values()).map((tc) => ({
@@ -275,9 +286,9 @@ export class DeepSeekService implements ILLMService {
arguments: tc.arguments,
},
}))
yield { content: '', isFinished: true, finishReason: reason, tool_calls: toolCalls }
yield { content: '', isFinished: true, finishReason: reason, tool_calls: toolCalls, usage }
} else {
yield { content: '', isFinished: true, finishReason: reason }
yield { content: '', isFinished: true, finishReason: reason, usage }
}
return
}
+16 -5
View File
@@ -396,6 +396,8 @@ export class GeminiService implements ILLMService {
let buffer = ''
// 用于累积工具调用(可能跨多个 chunk)
const toolCallsAccumulator: ToolCall[] = []
// 用于追踪最新的 usage 信息
let latestUsage: { promptTokens: number; completionTokens: number; totalTokens: number } | undefined
try {
while (true) {
@@ -445,19 +447,28 @@ export class GeminiService implements ILLMService {
toolCallsAccumulator.push(...toolCalls)
}
// 更新 usage 信息
if (parsed.usageMetadata) {
latestUsage = {
promptTokens: parsed.usageMetadata.promptTokenCount || 0,
completionTokens: parsed.usageMetadata.candidatesTokenCount || 0,
totalTokens: parsed.usageMetadata.totalTokenCount || 0,
}
}
// 检查是否完成
const finishReason = candidate?.finishReason
if (finishReason) {
aiLogger.info('Gemini', '流式响应完成', { finishReason, toolCallsCount: toolCallsAccumulator.length })
aiLogger.info('Gemini', '流式响应完成', { finishReason, toolCallsCount: toolCallsAccumulator.length, usage: latestUsage })
if (toolCallsAccumulator.length > 0) {
yield { content: '', isFinished: true, finishReason: 'tool_calls', tool_calls: toolCallsAccumulator }
yield { content: '', isFinished: true, finishReason: 'tool_calls', tool_calls: toolCallsAccumulator, usage: latestUsage }
} else {
let reason: ChatStreamChunk['finishReason'] = 'stop'
if (finishReason === 'MAX_TOKENS') {
reason = 'length'
}
yield { content: '', isFinished: true, finishReason: reason }
yield { content: '', isFinished: true, finishReason: reason, usage: latestUsage }
}
return
}
@@ -470,9 +481,9 @@ export class GeminiService implements ILLMService {
// 如果循环正常结束,发送完成信号
if (toolCallsAccumulator.length > 0) {
yield { content: '', isFinished: true, finishReason: 'tool_calls', tool_calls: toolCallsAccumulator }
yield { content: '', isFinished: true, finishReason: 'tool_calls', tool_calls: toolCallsAccumulator, usage: latestUsage }
} else {
yield { content: '', isFinished: true, finishReason: 'stop' }
yield { content: '', isFinished: true, finishReason: 'stop', usage: latestUsage }
}
} catch (error) {
// 如果是中止错误,正常返回
+13 -2
View File
@@ -162,6 +162,8 @@ export class OpenAICompatibleService implements ILLMService {
temperature: options?.temperature ?? 0.7,
max_tokens: options?.maxTokens ?? 2048,
stream: true,
// 启用流式响应中的 usage 统计(OpenAI API 兼容)
stream_options: { include_usage: true },
}
if (options?.tools && options.tools.length > 0) {
@@ -290,6 +292,15 @@ export class OpenAICompatibleService implements ILLMService {
reason = 'tool_calls'
}
// 解析 usage 信息
const usage = parsed.usage
? {
promptTokens: parsed.usage.prompt_tokens,
completionTokens: parsed.usage.completion_tokens,
totalTokens: parsed.usage.total_tokens,
}
: undefined
if (toolCallsAccumulator.size > 0) {
const toolCalls: ToolCall[] = Array.from(toolCallsAccumulator.values()).map((tc) => ({
id: tc.id,
@@ -299,9 +310,9 @@ export class OpenAICompatibleService implements ILLMService {
arguments: tc.arguments,
},
}))
yield { content: '', isFinished: true, finishReason: reason, tool_calls: toolCalls }
yield { content: '', isFinished: true, finishReason: reason, tool_calls: toolCalls, usage }
} else {
yield { content: '', isFinished: true, finishReason: reason }
yield { content: '', isFinished: true, finishReason: reason, usage }
}
return
}
+13 -2
View File
@@ -149,6 +149,8 @@ export class QwenService implements ILLMService {
temperature: options?.temperature ?? 0.7,
max_tokens: options?.maxTokens ?? 2048,
stream: true,
// 启用流式响应中的 usage 统计
stream_options: { include_usage: true },
}
if (options?.tools && options.tools.length > 0) {
@@ -247,15 +249,24 @@ export class QwenService implements ILLMService {
else if (finishReason === 'length') reason = 'length'
else if (finishReason === 'tool_calls') reason = 'tool_calls'
// 解析 usage 信息
const usage = parsed.usage
? {
promptTokens: parsed.usage.prompt_tokens,
completionTokens: parsed.usage.completion_tokens,
totalTokens: parsed.usage.total_tokens,
}
: undefined
if (toolCallsAccumulator.size > 0) {
const toolCalls: ToolCall[] = Array.from(toolCallsAccumulator.values()).map((tc) => ({
id: tc.id,
type: 'function' as const,
function: { name: tc.name, arguments: tc.arguments },
}))
yield { content: '', isFinished: true, finishReason: reason, tool_calls: toolCalls }
yield { content: '', isFinished: true, finishReason: reason, tool_calls: toolCalls, usage }
} else {
yield { content: '', isFinished: true, finishReason: reason }
yield { content: '', isFinished: true, finishReason: reason, usage }
}
return
}
+6
View File
@@ -67,6 +67,12 @@ export interface ChatStreamChunk {
finishReason?: 'stop' | 'length' | 'error' | 'tool_calls'
/** 流式过程中的 tool_calls(增量) */
tool_calls?: ToolCall[]
/** Token 使用量(通常在最后一个 chunk 中返回) */
usage?: {
promptTokens: number
completionTokens: number
totalTokens: number
}
}
// ==================== Function Calling 相关类型 ====================
+2
View File
@@ -445,6 +445,7 @@ export function registerAIHandlers({ win }: IpcContext): void {
content: result.content,
toolsUsed: result.toolsUsed,
toolRounds: result.toolRounds,
totalUsage: result.totalUsage,
},
})
@@ -452,6 +453,7 @@ export function registerAIHandlers({ win }: IpcContext): void {
toolsUsed: result.toolsUsed,
toolRounds: result.toolRounds,
contentLength: result.content.length,
totalUsage: result.totalUsage,
})
} catch (error) {
// 如果是中止错误,不报告为错误
+12
View File
@@ -294,6 +294,13 @@ interface LlmApi {
) => Promise<{ success: boolean; error?: string }>
}
// Token 使用量类型
interface TokenUsage {
promptTokens: number
completionTokens: number
totalTokens: number
}
// Agent 相关类型
interface AgentStreamChunk {
type: 'content' | 'tool_start' | 'tool_result' | 'done' | 'error'
@@ -303,12 +310,16 @@ interface AgentStreamChunk {
toolResult?: unknown
error?: string
isFinished?: boolean
/** Token 使用量(type=done 时返回累计值) */
usage?: TokenUsage
}
interface AgentResult {
content: string
toolsUsed: string[]
toolRounds: number
/** 总 Token 使用量(累计所有 LLM 调用) */
totalUsage?: TokenUsage
}
interface ToolContext {
@@ -399,6 +410,7 @@ export {
AgentResult,
ToolContext,
PromptConfig,
TokenUsage,
CacheDirectoryInfo,
CacheInfo,
}
+1 -1
View File
@@ -20,7 +20,7 @@
- **严禁**用于窃取、监控或分析未经授权的他人隐私。
- **严禁**将分析结果用于骚扰、诈骗、人肉搜索或任何侵犯他人权益的行为。
- **严禁**用于规避安全措施或网络渗透测试。
- **禁止商用**:我们严禁任何个人或机构将本软件用于任何形式的商业盈利行为(如打包销售、提供有偿代分析服务等),因此产生的纠纷与损失由用户自行承担。
- **禁止商用**:严禁任何个人或机构将本软件用于任何形式的商业盈利行为(如打包销售、提供有偿代分析服务等),因此产生的纠纷与损失由用户自行承担。
## 4. 风险警告
+12 -1
View File
@@ -27,6 +27,7 @@ const {
currentConversationId,
currentToolStatus,
toolsUsedInCurrentRound,
sessionTokenUsage,
sendMessage,
loadConversation,
startNewConversation,
@@ -514,8 +515,18 @@ watch(
</template>
</UPopover>
<!-- 右侧:配置状态指示 -->
<!-- 右侧:Token 使用量 + 配置状态指示 -->
<div class="flex items-center gap-3">
<!-- Token 使用量 -->
<div
v-if="sessionTokenUsage.totalTokens > 0"
class="flex items-center gap-1.5 text-xs text-gray-400"
title="本次会话累计 Token 使用量"
>
<UIcon name="i-heroicons-chart-bar-square" class="h-3.5 w-3.5" />
<span>{{ sessionTokenUsage.totalTokens.toLocaleString() }} tokens</span>
</div>
<div
v-if="!isCheckingConfig"
class="flex items-center gap-1.5 text-xs transition-colors"
+24 -2
View File
@@ -67,6 +67,13 @@ export interface ToolStatus {
result?: unknown
}
// Token 使用量类型
export interface TokenUsage {
promptTokens: number
completionTokens: number
totalTokens: number
}
// 工具名称映射(英文 → 中文)
const TOOL_DISPLAY_NAMES: Record<string, string> = {
search_messages: '搜索聊天记录',
@@ -105,6 +112,9 @@ export function useAIChat(
const currentToolStatus = ref<ToolStatus | null>(null)
const toolsUsedInCurrentRound = ref<string[]>([])
// Token 使用量(当前会话累计)
const sessionTokenUsage = ref<TokenUsage>({ promptTokens: 0, completionTokens: 0, totalTokens: 0 })
// 中止控制
let isAborted = false
// 当前请求 ID,用于区分不同请求的响应
@@ -339,9 +349,18 @@ export function useAIChat(
break
case 'done':
// 完成
console.log('[AI] Agent 完成')
// 完成 - 更新 Token 使用量
console.log('[AI] Agent 完成', chunk.usage)
currentToolStatus.value = null
// 更新会话累计 Token(流式响应在最后一个 chunk 返回 usage)
if (chunk.usage) {
sessionTokenUsage.value = {
promptTokens: sessionTokenUsage.value.promptTokens + chunk.usage.promptTokens,
completionTokens: sessionTokenUsage.value.completionTokens + chunk.usage.completionTokens,
totalTokens: sessionTokenUsage.value.totalTokens + chunk.usage.totalTokens,
}
console.log('[AI] Token 使用量更新:', sessionTokenUsage.value)
}
break
case 'error':
@@ -506,6 +525,8 @@ export function useAIChat(
messages.value = []
sourceMessages.value = []
currentKeywords.value = []
// 重置 Token 计数
sessionTokenUsage.value = { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
if (welcomeMessage) {
messages.value.push({
@@ -573,6 +594,7 @@ export function useAIChat(
currentConversationId,
currentToolStatus,
toolsUsedInCurrentRound,
sessionTokenUsage,
// 方法
sendMessage,