diff --git a/.gitignore b/.gitignore index 994530a..befe77c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ **/node_modules/** /.idea/ -trae-usage-monitor-*.vsix +*.vsix out/ trae-usage-server/ .vscode-test/ diff --git a/.vscode/settings.json b/.vscode/settings.json index 57c3a17..ed7077c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,39 @@ -{ - "idf.pythonInstallPath": "D:\\tools\\Espressif\\tools\\idf-python\\3.11.2\\python.exe" -} \ No newline at end of file +{ + "idf.pythonInstallPath": "D:\\tools\\Espressif\\tools\\idf-python\\3.11.2\\python.exe", + "projectColors.mainColor": "#f40b93", + "projectColors.name": "TraeUsage", + "projectColors.isActivityBarColored": false, + "projectColors.isTitleBarColored": false, + "projectColors.isStatusBarColored": true, + "projectColors.isProjectNameColored": true, + "projectColors.isActiveItemsColored": true, + "projectColors.setWindowTitle": true, + "window.title": "TraeUsage", + "workbench.colorCustomizations": { + "statusBarItem.warningBackground": "#13d862", + "statusBarItem.warningForeground": "#000000", + "statusBarItem.warningHoverBackground": "#13d862", + "statusBarItem.warningHoverForeground": "#00000090", + "statusBarItem.remoteBackground": "#13d862", + "statusBarItem.remoteForeground": "#000000", + "statusBarItem.remoteHoverBackground": "#13d862", + "statusBarItem.remoteHoverForeground": "#00000090", + "focusBorder": "#13d86299", + "progressBar.background": "#13d862", + "textLink.foreground": "#53ffa2", + "textLink.activeForeground": "#60ffaf", + "selection.background": "#06cb55", + "list.highlightForeground": "#13d862", + "list.focusAndSelectionOutline": "#13d86299", + "button.background": "#13d862", + "button.foreground": "#000000", + "button.hoverBackground": "#20e56f", + "tab.activeBorderTop": "#20e56f", + "pickerGroup.foreground": "#20e56f", + "list.activeSelectionBackground": "#13d8624d", + "panelTitle.activeBorder": "#20e56f", + "activityBar.activeBorder": "#13d862", + "activityBarBadge.foreground": "#000000", + "activityBarBadge.background": "#13d862" + } +} diff --git a/README.md b/README.md index 9af32b2..733d0d6 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,18 @@ # Trae Usage Monitor -[English](README.en.md) +由于原项目出现本人无法使用的问题,并发现有其他用户也遇到了相同问题,因此本人 fork 了原项目,本意是为了帮助优先,但是反馈问题和提交 pr,等待好久无人回应,所以单开一个版本。 -一个VSCode扩展,用于实时监控Trae AI的使用量统计。 +一个 VSCode 扩展,用于实时监控 Trae AI 的使用量统计。 + +## 版本更新 + +### v1.4.0 (2026-04-21) + +- **计费方式更新**:适配官方从次数计费改为 Token 量计费的变更 +- **状态栏显示优化**:智能显示订阅计划的基础额度和奖励额度使用情况 +- **进度条改进**:区分基础额度(▒)和奖励额度(█)的进度条符号 +- **使用详情收集**:修复使用详情收集功能,支持新的 API 接口 +- **时间范围计算**:优化时间范围计算逻辑,确保能正确收集历史使用数据 ### 状态栏 @@ -23,42 +33,47 @@ 功能截图 -### 2. 获取Session ID +### 2. 获取 Session ID **使用浏览器扩展** -**Chrome浏览器:** -1. 安装Chrome扩展:[Trae Usage Token Extractor](https://chromewebstore.google.com/detail/edkpaodbjadikhahggapfilgmfijjhei?utm_source=item-share-cb) +**Chrome 浏览器:** + +1. 安装 Chrome 扩展:[Trae Usage Token Extractor](https://chromewebstore.google.com/detail/edkpaodbjadikhahggapfilgmfijjhei?utm_source=item-share-cb) -**Edge浏览器:** -1. 安装Edge扩展:[Trae Usage Token Extractor](https://microsoftedge.microsoft.com/addons/detail/trae-usage-token-extracto/leopdblngeedggognlgokdlfpiojalji) +**Edge 浏览器:** + +1. 安装 Edge 扩展:[Trae Usage Token Extractor](https://microsoftedge.microsoft.com/addons/detail/trae-usage-token-extracto/leopdblngeedggognlgokdlfpiojalji) **使用步骤:** -1. 安装后通过通知或TraeUsage窗口设置跳转安装Chrome扩展 -2. 安装后在浏览器点击Chrome扩展图标,点击跳转按钮到Usage页面 -3. 登录并浏览usage页面,自动获取Session ID -4. 点击Chrome扩展图标,自动复制Session ID至粘贴板 -5. 返回Trae,Trae Usage扩展会自动识别粘贴板并配置Session ID -6. Ctrl+Shift+P 打开命令面板,输入TraeUsage: Collect Usage Details + +1. 安装后通过通知或 TraeUsage 窗口设置跳转安装 Chrome 扩展 +2. 安装后在浏览器点击 Chrome 扩展图标,点击跳转按钮到 Usage 页面 +3. 登录并浏览 usage 页面,自动获取 Session ID +4. 点击 Chrome 扩展图标,自动复制 Session ID 至粘贴板 +5. 返回 Trae,Trae Usage 扩展会自动识别粘贴板并配置 Session ID +6. Ctrl+Shift+P 打开命令面板,输入 TraeUsage: Collect Usage Details + +注意:如果原作者提供的浏览器插件不管用,请自行使用F12工具,在请求中复制请求头Cookie里面的X-Cloudide-Session。例如:X-Cloudide-Session=HfM3FZusMzX2we20bjiXPpvwqcUgr\_zIdpy5-zKFGY=.18a409a3e99e195e; 然后返回Trae ### 3. 查看使用量 -配置完成后,在VSCode左侧的资源管理器面板中会出现 "Trae Usage" 视图,显示: +配置完成后,在 VSCode 左侧的资源管理器面板中会出现 "Trae Usage" 视图,显示: - ⚡ Premium Fast Request:快速请求的使用量和剩余配额 -- 🐌 Premium Slow Request:慢速请求的使用量和剩余配额 +- 🐌 Premium Slow Request:慢速请求的使用量和剩余配额 - 🔧 Auto Completion:自动补全的使用量和剩余配额 - 🚀 Advanced Model:高级模型的使用量和剩余配额 - ## 反馈与支持 -如果您在使用过程中遇到问题或有功能建议,欢迎访问我们的GitHub项目页面: +如果您在使用过程中遇到问题或有功能建议,欢迎访问我们的 GitHub 项目页面: -🔗 **项目地址**:[https://github.com/whyuds/TraeUsage](https://github.com/whyuds/TraeUsage) +🔗 **原项目地址**: +🔗 **当前项目地址**: -💬 **问题反馈**:如有问题请在GitHub上提交[Issues](https://github.com/whyuds/TraeUsage/issues) +💬 **问题反馈**:如有问题请在 GitHub 上提交[Issues](https://github.com/mtpupil/TraeUsage/issues) ## 许可证 -MIT License \ No newline at end of file +MIT License diff --git a/package-lock.json b/package-lock.json index 2606887..f3bc43e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "trae-usage-monitor", - "version": "1.3.1-SNAPSHOT", + "version": "1.3.4-SNAPSHOT", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "trae-usage-monitor", - "version": "1.3.1-SNAPSHOT", + "version": "1.3.4-SNAPSHOT", "license": "MIT", "dependencies": { "axios": "^1.6.0" diff --git a/package.json b/package.json index 5653864..098d8f0 100644 --- a/package.json +++ b/package.json @@ -1,110 +1,154 @@ -{ - "name": "trae-usage-monitor", - "displayName": "Trae Usage", - "description": "Monitor Trae AI usage statistics in real-time", - "version": "1.3.3", - "publisher": "whyuds", - "repository": { - "type": "git", - "url": "https://github.com/whyuds/TraeUsage.git" - }, - "license": "MIT", - "engines": { - "vscode": "^1.74.0" - }, - "categories": [ - "Other" - ], - "icon": "img/logo_256.png", - "activationEvents": [ - "onStartupFinished" - ], - "main": "./out/extension.js", - "contributes": { - "commands": [ - { - "command": "traeUsage.refresh", - "title": "%commands.refresh.title%", - "icon": "$(refresh)" - }, - { - "command": "traeUsage.updateSession", - "title": "%commands.updateSession.title%", - "icon": "$(settings-gear)" - }, - { - "command": "traeUsage.collectUsageDetails", - "title": "%commands.collectUsageDetails.title%", - "icon": "$(database)" - }, - { - "command": "traeUsage.showUsageDashboard", - "title": "%commands.showUsageDashboard.title%", - "icon": "$(graph)" - }, - { - "command": "traeUsage.showOutput", - "title": "%commands.showOutput.title%", - "icon": "$(output)" - } - ], - - "configuration": { - "title": "%configuration.title%", - "properties": { - "traeUsage.sessionId": { - "type": "string", - "default": "", - "description": "%configuration.sessionId.description%" - }, - "traeUsage.refreshInterval": { - "type": "number", - "default": 300, - "description": "%configuration.refreshInterval.description%" - }, - "traeUsage.language": { - "type": "string", - "enum": [ - "auto", - "zh-CN", - "en" - ], - "enumDescriptions": [ - "%configuration.language.auto%", - "%configuration.language.zhCN%", - "%configuration.language.en%" - ], - "default": "auto", - "description": "%configuration.language.description%" - } - } - } - }, - "scripts": { - "vscode:prepublish": "npm run package", - "compile": "tsc -p ./", - "watch": "tsc -watch -p ./", - "pretest": "npm run compile && npm run lint", - "lint": "eslint src --ext ts", - "test": "node ./out/test/runTest.js", - "test:unit": "npm run compile && node ./src/test/buildDetailedTooltip.test.js", - "package": "node esbuild.js" - }, - "devDependencies": { - "@types/node": "16.x", - "@types/vscode": "^1.74.0", - "@typescript-eslint/eslint-plugin": "^5.45.0", - "@typescript-eslint/parser": "^5.45.0", - "esbuild": "^0.19.0", - "eslint": "^8.28.0", - "typescript": "^4.9.4", - "@types/mocha": "^10.0.0", - "@types/glob": "^8.0.0", - "mocha": "^10.0.0", - "glob": "^8.0.0", - "@vscode/test-electron": "^2.2.0" - }, - "dependencies": { - "axios": "^1.6.0" - } -} \ No newline at end of file +{ + "name": "trae-usage-reborn", + "displayName": "Trae Usage Reborn", + "description": "Monitor Trae AI usage statistics in real-time", + "version": "1.4.0", + "publisher": "MTpupil", + "repository": { + "type": "git", + "url": "https://github.com/mtpupil/TraeUsage.git" + }, + "license": "MIT", + "engines": { + "vscode": "^1.74.0" + }, + "categories": [ + "Other" + ], + "keywords": [ + "Trae", + "trae", + "Trae AI", + "AI", + "ai", + "人工智能", + "Usage", + "usage", + "使用量", + "用量", + "用量统计", + "配额", + "额度", + "Monitor", + "monitor", + "监控", + "统计", + "仪表板", + "看板", + "Token", + "token", + "令牌", + "JWT", + "Session", + "session", + "Session ID", + "会话", + "Cloud-IDE", + "Cloud IDE", + "云IDE", + "VSCode Extension", + "extension", + "扩展", + "插件", + "Usage Details", + "使用详情", + "Entitlement", + "权益", + "订阅", + "套餐", + "木瞳", + "mtpupil", + "MTpupil" + ], + "icon": "img/logo_256.png", + "activationEvents": [ + "onStartupFinished" + ], + "main": "./out/extension.js", + "contributes": { + "commands": [ + { + "command": "traeUsage.refresh", + "title": "%commands.refresh.title%", + "icon": "$(refresh)" + }, + { + "command": "traeUsage.updateSession", + "title": "%commands.updateSession.title%", + "icon": "$(settings-gear)" + }, + { + "command": "traeUsage.collectUsageDetails", + "title": "%commands.collectUsageDetails.title%", + "icon": "$(database)" + }, + { + "command": "traeUsage.showUsageDashboard", + "title": "%commands.showUsageDashboard.title%", + "icon": "$(graph)" + }, + { + "command": "traeUsage.showOutput", + "title": "%commands.showOutput.title%", + "icon": "$(output)" + } + ], + "configuration": { + "title": "%configuration.title%", + "properties": { + "traeUsage.sessionId": { + "type": "string", + "default": "", + "description": "%configuration.sessionId.description%" + }, + "traeUsage.refreshInterval": { + "type": "number", + "default": 300, + "description": "%configuration.refreshInterval.description%" + }, + "traeUsage.language": { + "type": "string", + "enum": [ + "auto", + "zh-CN", + "en" + ], + "enumDescriptions": [ + "%configuration.language.auto%", + "%configuration.language.zhCN%", + "%configuration.language.en%" + ], + "default": "auto", + "description": "%configuration.language.description%" + } + } + } + }, + "scripts": { + "vscode:prepublish": "npm run package", + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./", + "pretest": "npm run compile && npm run lint", + "lint": "eslint src --ext ts", + "test": "node ./out/test/runTest.js", + "test:unit": "npm run compile && node ./src/test/buildDetailedTooltip.test.js", + "package": "node esbuild.js" + }, + "devDependencies": { + "@types/node": "16.x", + "@types/vscode": "^1.74.0", + "@typescript-eslint/eslint-plugin": "^5.45.0", + "@typescript-eslint/parser": "^5.45.0", + "esbuild": "^0.19.0", + "eslint": "^8.28.0", + "typescript": "^4.9.4", + "@types/mocha": "^10.0.0", + "@types/glob": "^8.0.0", + "mocha": "^10.0.0", + "glob": "^8.0.0", + "@vscode/test-electron": "^2.2.0" + }, + "dependencies": { + "axios": "^1.6.0" + } +} diff --git a/src/apiService.ts b/src/apiService.ts index c2dda72..5487340 100644 --- a/src/apiService.ts +++ b/src/apiService.ts @@ -1,418 +1,472 @@ -import * as vscode from 'vscode'; -import axios from 'axios'; -import { logWithTime } from './utils'; -import { TokenResponse } from './extension'; -import { t } from './i18n'; -import { UsageDetailResponse } from './types'; - -// 常量定义 -const DEFAULT_HOST = 'https://api-sg-central.trae.ai'; -const FALLBACK_HOST = 'https://api-us-east.trae.ai'; -const API_TIMEOUT = 3000; -const MAX_RETRY_COUNT = 5; -const RETRY_DELAY = 1000; -const TOKEN_ERROR_CODE = '20310'; - -/** - * 统一的API服务类,管理GetUserToken接口调用 - */ -export class ApiService { - private static instance: ApiService; - private cachedToken: string | null = null; - private cachedSessionId: string | null = null; - private hasSwitchedHost: boolean = false; - private currentHost: string = DEFAULT_HOST; - - private constructor() {} - - public static getInstance(): ApiService { - if (!ApiService.instance) { - ApiService.instance = new ApiService(); - } - return ApiService.instance; - } - - /** - * 获取用户Token(带缓存功能) - * @param sessionId 会话ID - * @param retryCount 重试次数 - * @param isManualRefresh 是否为手动刷新 - * @returns Promise - */ - public async getTokenFromSession(sessionId: string, retryCount = 0, isManualRefresh = false): Promise { - // 检查缓存 - if (this.cachedToken && this.cachedSessionId === sessionId) { - return this.cachedToken; - } - - const currentHost = this.getHost(); - - try { - const response = await axios.post( - `${currentHost}/cloudide/api/v3/common/GetUserToken`, - {}, - { - headers: { - 'Cookie': `X-Cloudide-Session=${sessionId}`, - 'Host': new URL(currentHost).hostname, - 'Content-Type': 'application/json' - }, - timeout: API_TIMEOUT - } - ); - - logWithTime('更新Token'); - this.cachedToken = response.data.Result.Token; - this.cachedSessionId = sessionId; - return this.cachedToken; - } catch (error) { - return this.handleTokenError(error, sessionId, retryCount, currentHost, isManualRefresh); - } - } - - /** - * 获取用户Token(带重试机制,无缓存) - * @param sessionId 会话ID - * @param maxRetries 最大重试次数 - * @returns Promise - */ - public async getTokenWithRetry(sessionId: string, maxRetries: number = MAX_RETRY_COUNT): Promise { - return this.apiRequestWithRetry(async () => { - const currentHost = this.getHost(); - const response = await axios.post( - `${currentHost}/cloudide/api/v3/common/GetUserToken`, - {}, - { - headers: { - 'Cookie': `X-Cloudide-Session=${sessionId}`, - 'Host': new URL(currentHost).hostname, - 'Content-Type': 'application/json' - }, - timeout: API_TIMEOUT - } - ); - - return response.data.Result.Token; - }, '获取认证Token', maxRetries); - } - - /** - * 清除缓存的Token - */ - public clearCache(): void { - this.cachedToken = null; - this.cachedSessionId = null; - this.hasSwitchedHost = false; - this.currentHost = DEFAULT_HOST; - } - - /** - * 处理Token获取错误 - */ - private async handleTokenError( - error: any, - sessionId: string, - retryCount: number, - currentHost: string, - isManualRefresh: boolean = false - ): Promise { - logWithTime(`获取Token失败 (尝试 ${retryCount + 1}/${MAX_RETRY_COUNT}): ${error.code}, ${error.message}`); - - // 处理401认证失败情况 - if (error.response?.status === 401) { - logWithTime('检测到401认证失败,可能是sessionId无效或已过期'); - if (isManualRefresh) { - vscode.window.showErrorMessage( - '认证失败:Session ID可能无效或已过期,请更新Session ID', - '更新Session ID' - ).then(selection => { - if (selection === '更新Session ID') { - vscode.commands.executeCommand('traeUsage.updateSession'); - } - }); - } else { - // 自动刷新时显示错误提示,但不阻塞流程 - vscode.window.showErrorMessage('Trae Usage: 认证失败,请手动更新Session ID'); - } - return null; - } - - // 核心修改:处理Token错误(支持双向切换主机) - if (this.isTokenError(error)) { - if (!this.hasSwitchedHost) { - // 未切换过主机:切换到另一个主机(默认 ↔ 备用互切) - const otherHost = currentHost === DEFAULT_HOST ? FALLBACK_HOST : DEFAULT_HOST; - logWithTime(`检测到错误代码${TOKEN_ERROR_CODE},尝试切换到备用主机: ${otherHost}`); - await this.setHost(otherHost); // 切换到另一个主机 - this.hasSwitchedHost = true; // 标记为已切换 - return this.getTokenFromSession(sessionId, 0); // 重置重试次数,重试获取Token - } else { - // 已切换过主机仍失败:通知用户无法获取Token - if (isManualRefresh) { - vscode.window.showErrorMessage(t('messages.cannotGetToken')); - } else { - vscode.window.showErrorMessage('Trae Usage: 无法获取认证Token,请检查网络连接或手动更新Session ID'); - } - return null; - } - } - - // 原有可重试错误逻辑(不变) - if (this.isRetryableError(error) && retryCount < MAX_RETRY_COUNT) { - logWithTime(`Token获取失败,将在1秒后进行第${retryCount + 1}次重试`); - await new Promise(resolve => setTimeout(resolve, RETRY_DELAY)); - return this.getTokenFromSession(sessionId, retryCount + 1); - } - - // 重试后网络仍有问题(不变) - if (this.isRetryableError(error) && retryCount >= MAX_RETRY_COUNT) { - if (isManualRefresh) { - vscode.window.showErrorMessage(t('messages.networkUnstable')); - } else { - vscode.window.showErrorMessage('Trae Usage: 网络不稳定,请稍后重试'); - } - } - - return null; - } - - - - /** - * 带重试机制的通用API请求函数 - */ - private async apiRequestWithRetry( - requestFn: () => Promise, - operationName: string, - maxRetries: number = MAX_RETRY_COUNT - ): Promise { - let lastError: any; - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - const result = await requestFn(); - if (attempt > 1) { - logWithTime(`${operationName} 在第${attempt}次尝试后成功`); - } - return result; - } catch (error) { - lastError = error; - logWithTime(`${operationName} 第${attempt}次尝试失败: ${String(error)}`); - - if (attempt < maxRetries) { - const delay = RETRY_DELAY * attempt; // 递增延迟 - logWithTime(`等待${delay}ms后进行第${attempt + 1}次重试`); - await new Promise(resolve => setTimeout(resolve, delay)); - } - } - } - - throw new Error(`${operationName} 在${maxRetries}次重试后仍然失败: ${String(lastError)}`); - } - - /** - * 检查是否是Token错误 - */ - private isTokenError(error: any): boolean { - return error?.response?.data?.ResponseMetadata?.Error?.Code === TOKEN_ERROR_CODE; - } - - /** - * 检查是否是可重试的错误 - */ - public isRetryableError(error: any): boolean { - return error && ( - error.code === 'ECONNABORTED' || - error.message?.includes('timeout') || - error.code === 'ENOTFOUND' || - error.code === 'ECONNRESET' || - error.message?.includes('Failed to establish a socket connection to proxies') || - error.message?.includes('proxy') - ); - } - - /** - * 统一的错误处理方法 - * @param error 错误对象 - * @param operationName 操作名称 - * @param showUserMessage 是否显示用户消息 - */ - public handleApiError(error: any, operationName: string, showUserMessage: boolean = false): void { - const errorMessage = `${operationName}失败: ${String(error)}`; - logWithTime(errorMessage); - - if (showUserMessage) { - if (this.isRetryableError(error)) { - vscode.window.showErrorMessage(t('messages.networkUnstable')); - } else if (this.isTokenError(error)) { - vscode.window.showErrorMessage(t('messages.cannotGetToken')); - } else { - vscode.window.showErrorMessage(`${operationName}失败,请稍后重试`); - } - } - } - - /** - * 检查API响应是否成功 - * @param response API响应 - * @returns boolean - */ - public isApiResponseSuccess(response: any): boolean { - return response && (!response.code || response.code === 0); - } - - /** - * 处理API响应错误 - * @param response API响应 - * @param operationName 操作名称 - */ - public handleApiResponseError(response: any, operationName: string): void { - if (response?.code === 1001) { - logWithTime(`${operationName}: Token已失效(code: 1001)`); - this.clearCache(); - vscode.window.showErrorMessage(t('messages.tokenExpired')); - } else if (response?.code) { - logWithTime(`${operationName}: API返回错误码 ${response.code}, 消息: ${response.message || 'Unknown error'}`); - vscode.window.showErrorMessage(`${operationName}失败: ${response.message || 'Unknown error'}`); - } - } - - /** - * 获取当前主机地址 - */ - private getHost(): string { - return this.currentHost; - } - - /** - * 设置主机地址 - */ - public async setHost(host: string): Promise { - this.currentHost = host; - logWithTime(`主机地址已更新为: ${host}`); - } - - /** - * 重置为默认主机地址 - */ - public async resetToDefaultHost(): Promise { - this.hasSwitchedHost = false; - await this.setHost(DEFAULT_HOST); - } - - /** - * 获取用户当前权益列表 - * @param authToken 认证Token - * @returns Promise - */ - public async getUserEntitlementList(authToken: string): Promise { - try { - const result = await this.apiRequestWithRetry(async () => { - const currentHost = this.getHost(); - const response = await axios.post( - `${currentHost}/trae/api/v1/pay/user_current_entitlement_list`, - {}, - { - headers: { - 'authorization': `Cloud-IDE-JWT ${authToken}`, - 'Host': new URL(currentHost).hostname, - 'Content-Type': 'application/json' - }, - timeout: API_TIMEOUT - } - ); - - return response.data; - }, '获取用户权益列表'); - - return result; - } catch (error) { - logWithTime(`获取用户权益列表失败: ${error}`); - throw error; - } - } - - /** - * 获取订阅时间范围 - * @param authToken 认证Token - * @returns Promise<{ start_time: number; end_time: number } | null> - */ - public async getSubscriptionTimeRange(authToken: string): Promise<{ start_time: number; end_time: number } | null> { - try { - const result = await this.getUserEntitlementList(authToken); - - if (result?.user_entitlement_pack_list?.length > 0) { - const pack = result.user_entitlement_pack_list[0]; - return { - start_time: pack.entitlement_base_info.start_time, - end_time: pack.entitlement_base_info.end_time - }; - } - - throw new Error('No entitlement pack found'); - } catch (error) { - logWithTime(`获取订阅时间范围失败: ${error}`); - return null; - } - } - - /** - * 查询用户使用详情(按会话分组) - * @param authToken 认证Token - * @param start_time 开始时间 - * @param end_time 结束时间 - * @param pageNum 页码 - * @param pageSize 页大小 - * @returns Promise - */ - public async queryUserUsageGroupBySession( - authToken: string, - start_time: number, - end_time: number, - pageNum: number, - pageSize: number - ): Promise { - try { - const result = await this.apiRequestWithRetry(async () => { - const currentHost = this.getHost(); - const url = `${currentHost}/trae/api/v1/pay/query_user_usage_group_by_session`; - const requestBody = { - start_time, - end_time, - page_size: pageSize, - page_num: pageNum - }; - const headers = { - 'authorization': `Cloud-IDE-JWT ${authToken}`, - 'Host': new URL(currentHost).hostname, - 'Content-Type': 'application/json' - }; - - logWithTime(`请求第${pageNum}页使用详情数据: ${url}`); - - const response = await axios.post( - url, - requestBody, - { - headers, - timeout: 10000 - } - ); - - return response.data; - }, `获取第${pageNum}页使用详情`); - - return result; - } catch (error: any) { - logWithTime(`获取第${pageNum}页使用详情失败: ${error}`); - return null; - } - } -} - -/** - * 获取API服务实例的便捷函数 - */ -export function getApiService(): ApiService { - return ApiService.getInstance(); -} \ No newline at end of file +import * as vscode from "vscode"; +import axios from "axios"; +import { logWithTime } from "./utils"; +import { TokenResponse } from "./extension"; +import { t } from "./i18n"; +import { UsageDetailResponse } from "./types"; + +// 常量定义 +const DEFAULT_HOST = "https://api-us-east.trae.ai"; +const FALLBACK_HOST = "https://api-sg-central.trae.ai"; +const API_TIMEOUT = 3000; +const MAX_RETRY_COUNT = 5; +const RETRY_DELAY = 1000; +const TOKEN_ERROR_CODE = "20310"; + +/** + * 统一的API服务类,管理GetUserToken接口调用 + */ +export class ApiService { + private static instance: ApiService; + private cachedToken: string | null = null; + private cachedSessionId: string | null = null; + private hasSwitchedHost: boolean = false; + private currentHost: string = DEFAULT_HOST; + + private constructor() {} + + public static getInstance(): ApiService { + if (!ApiService.instance) { + ApiService.instance = new ApiService(); + } + return ApiService.instance; + } + + /** + * 获取用户Token(带缓存功能) + * @param sessionId 会话ID + * @param retryCount 重试次数 + * @param isManualRefresh 是否为手动刷新 + * @returns Promise + */ + public async getTokenFromSession( + sessionId: string, + retryCount = 0, + isManualRefresh = false + ): Promise { + // 检查缓存 + if (this.cachedToken && this.cachedSessionId === sessionId) { + return this.cachedToken; + } + + const currentHost = this.getHost(); + + try { + const response = await axios.post( + `${currentHost}/cloudide/api/v3/common/GetUserToken`, + {}, + { + headers: { + Cookie: `X-Cloudide-Session=${sessionId}`, + Host: new URL(currentHost).hostname, + "Content-Type": "application/json", + }, + timeout: API_TIMEOUT, + } + ); + + logWithTime("更新Token"); + this.cachedToken = response.data.Result.Token; + this.cachedSessionId = sessionId; + return this.cachedToken; + } catch (error) { + return this.handleTokenError( + error, + sessionId, + retryCount, + currentHost, + isManualRefresh + ); + } + } + + /** + * 获取用户Token(带重试机制,无缓存) + * @param sessionId 会话ID + * @param maxRetries 最大重试次数 + * @returns Promise + */ + public async getTokenWithRetry( + sessionId: string, + maxRetries: number = MAX_RETRY_COUNT + ): Promise { + return this.apiRequestWithRetry( + async () => { + const currentHost = this.getHost(); + const response = await axios.post( + `${currentHost}/cloudide/api/v3/common/GetUserToken`, + {}, + { + headers: { + Cookie: `X-Cloudide-Session=${sessionId}`, + Host: new URL(currentHost).hostname, + "Content-Type": "application/json", + }, + timeout: API_TIMEOUT, + } + ); + + return response.data.Result.Token; + }, + "获取认证Token", + maxRetries + ); + } + + /** + * 清除缓存的Token + */ + public clearCache(): void { + this.cachedToken = null; + this.cachedSessionId = null; + this.hasSwitchedHost = false; + this.currentHost = DEFAULT_HOST; + } + + /** + * 处理Token获取错误 + */ + private async handleTokenError( + error: any, + sessionId: string, + retryCount: number, + currentHost: string, + isManualRefresh: boolean = false + ): Promise { + logWithTime( + `获取Token失败 (尝试 ${retryCount + 1}/${MAX_RETRY_COUNT}): ${ + error.code + }, ${error.message}` + ); + + // 处理401认证失败情况 + if (error.response?.status === 401) { + logWithTime("检测到401认证失败,可能是sessionId无效或已过期"); + if (isManualRefresh) { + vscode.window + .showErrorMessage( + "认证失败:Session ID可能无效或已过期,请更新Session ID", + "更新Session ID" + ) + .then((selection) => { + if (selection === "更新Session ID") { + vscode.commands.executeCommand("traeUsage.updateSession"); + } + }); + } else { + // 自动刷新时显示错误提示,但不阻塞流程 + vscode.window.showErrorMessage( + "Trae Usage: 认证失败,请手动更新Session ID" + ); + } + return null; + } + + // 核心修改:处理Token错误(支持双向切换主机) + if (this.isTokenError(error)) { + if (!this.hasSwitchedHost) { + // 未切换过主机:切换到另一个主机(默认 ↔ 备用互切) + const otherHost = + currentHost === DEFAULT_HOST ? FALLBACK_HOST : DEFAULT_HOST; + logWithTime( + `检测到错误代码${TOKEN_ERROR_CODE},尝试切换到备用主机: ${otherHost}` + ); + await this.setHost(otherHost); // 切换到另一个主机 + this.hasSwitchedHost = true; // 标记为已切换 + return this.getTokenFromSession(sessionId, 0); // 重置重试次数,重试获取Token + } else { + // 已切换过主机仍失败:通知用户无法获取Token + if (isManualRefresh) { + vscode.window.showErrorMessage(t("messages.cannotGetToken")); + } else { + vscode.window.showErrorMessage( + "Trae Usage: 无法获取认证Token,请检查网络连接或手动更新Session ID" + ); + } + return null; + } + } + + // 原有可重试错误逻辑(不变) + if (this.isRetryableError(error) && retryCount < MAX_RETRY_COUNT) { + logWithTime(`Token获取失败,将在1秒后进行第${retryCount + 1}次重试`); + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY)); + return this.getTokenFromSession(sessionId, retryCount + 1); + } + + // 重试后网络仍有问题(不变) + if (this.isRetryableError(error) && retryCount >= MAX_RETRY_COUNT) { + if (isManualRefresh) { + vscode.window.showErrorMessage(t("messages.networkUnstable")); + } else { + vscode.window.showErrorMessage("Trae Usage: 网络不稳定,请稍后重试"); + } + } + + return null; + } + + /** + * 带重试机制的通用API请求函数 + */ + private async apiRequestWithRetry( + requestFn: () => Promise, + operationName: string, + maxRetries: number = MAX_RETRY_COUNT + ): Promise { + let lastError: any; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const result = await requestFn(); + if (attempt > 1) { + logWithTime(`${operationName} 在第${attempt}次尝试后成功`); + } + return result; + } catch (error) { + lastError = error; + logWithTime( + `${operationName} 第${attempt}次尝试失败: ${String(error)}` + ); + + if (attempt < maxRetries) { + const delay = RETRY_DELAY * attempt; // 递增延迟 + logWithTime(`等待${delay}ms后进行第${attempt + 1}次重试`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + } + + throw new Error( + `${operationName} 在${maxRetries}次重试后仍然失败: ${String(lastError)}` + ); + } + + /** + * 检查是否是Token错误 + */ + private isTokenError(error: any): boolean { + return ( + error?.response?.data?.ResponseMetadata?.Error?.Code === TOKEN_ERROR_CODE + ); + } + + /** + * 检查是否是可重试的错误 + */ + public isRetryableError(error: any): boolean { + return ( + error && + (error.code === "ECONNABORTED" || + error.message?.includes("timeout") || + error.code === "ENOTFOUND" || + error.code === "ECONNRESET" || + error.message?.includes( + "Failed to establish a socket connection to proxies" + ) || + error.message?.includes("proxy")) + ); + } + + /** + * 统一的错误处理方法 + * @param error 错误对象 + * @param operationName 操作名称 + * @param showUserMessage 是否显示用户消息 + */ + public handleApiError( + error: any, + operationName: string, + showUserMessage: boolean = false + ): void { + const errorMessage = `${operationName}失败: ${String(error)}`; + logWithTime(errorMessage); + + if (showUserMessage) { + if (this.isRetryableError(error)) { + vscode.window.showErrorMessage(t("messages.networkUnstable")); + } else if (this.isTokenError(error)) { + vscode.window.showErrorMessage(t("messages.cannotGetToken")); + } else { + vscode.window.showErrorMessage(`${operationName}失败,请稍后重试`); + } + } + } + + /** + * 检查API响应是否成功 + * @param response API响应 + * @returns boolean + */ + public isApiResponseSuccess(response: any): boolean { + return response && (!response.code || response.code === 0); + } + + /** + * 处理API响应错误 + * @param response API响应 + * @param operationName 操作名称 + */ + public handleApiResponseError(response: any, operationName: string): void { + if (response?.code === 1001) { + logWithTime(`${operationName}: Token已失效(code: 1001)`); + this.clearCache(); + vscode.window.showErrorMessage(t("messages.tokenExpired")); + } else if (response?.code) { + logWithTime( + `${operationName}: API返回错误码 ${response.code}, 消息: ${ + response.message || "Unknown error" + }` + ); + vscode.window.showErrorMessage( + `${operationName}失败: ${response.message || "Unknown error"}` + ); + } + } + + /** + * 获取当前主机地址 + */ + private getHost(): string { + return this.currentHost; + } + + /** + * 设置主机地址 + */ + public async setHost(host: string): Promise { + this.currentHost = host; + logWithTime(`主机地址已更新为: ${host}`); + } + + /** + * 重置为默认主机地址 + */ + public async resetToDefaultHost(): Promise { + this.hasSwitchedHost = false; + await this.setHost(DEFAULT_HOST); + } + + /** + * 获取用户当前权益列表 + * @param authToken 认证Token + * @returns Promise + */ + public async getUserEntitlementList(authToken: string): Promise { + try { + const result = await this.apiRequestWithRetry(async () => { + const currentHost = this.getHost(); + const response = await axios.post( + `${currentHost}/trae/api/v1/pay/user_current_entitlement_list`, + {}, + { + headers: { + authorization: `Cloud-IDE-JWT ${authToken}`, + Host: new URL(currentHost).hostname, + "Content-Type": "application/json", + }, + timeout: API_TIMEOUT, + } + ); + + return response.data; + }, "获取用户权益列表"); + + return result; + } catch (error) { + logWithTime(`获取用户权益列表失败: ${error}`); + throw error; + } + } + + /** + * 获取订阅时间范围 + * @param authToken 认证Token + * @returns Promise<{ start_time: number; end_time: number } | null> + */ + public async getSubscriptionTimeRange( + authToken: string + ): Promise<{ start_time: number; end_time: number } | null> { + try { + const result = await this.getUserEntitlementList(authToken); + + if (result?.user_entitlement_pack_list?.length > 0) { + const pack = result.user_entitlement_pack_list[0]; + return { + start_time: pack.entitlement_base_info.start_time, + end_time: pack.entitlement_base_info.end_time, + }; + } + + throw new Error("No entitlement pack found"); + } catch (error) { + logWithTime(`获取订阅时间范围失败: ${error}`); + return null; + } + } + + /** + * 查询用户使用详情(按会话分组) + * @param authToken 认证Token + * @param start_time 开始时间 + * @param end_time 结束时间 + * @param pageNum 页码 + * @param pageSize 页大小 + * @returns Promise + */ + public async queryUserUsageGroupBySession( + authToken: string, + start_time: number, + end_time: number, + pageNum: number, + pageSize: number + ): Promise { + try { + const result = await this.apiRequestWithRetry(async () => { + const currentHost = this.getHost(); + const url = `${currentHost}/trae/api/v1/pay/query_user_usage_group_by_session`; + const requestBody = { + start_time, + end_time, + page_size: pageSize, + page_num: pageNum, + usage_type: [5, 6], // 添加使用类型参数 + }; + const headers = { + authorization: `Cloud-IDE-JWT ${authToken}`, + Host: new URL(currentHost).hostname, + "Content-Type": "application/json", + }; + + logWithTime(`请求第${pageNum}页使用详情数据: ${url}`); + + const response = await axios.post( + url, + requestBody, + { + headers, + timeout: 10000, + } + ); + + // 调试:显示API返回的完整数据 + logWithTime(`API返回数据: ${JSON.stringify(response.data)}`); + logWithTime(`请求参数: start_time=${start_time}, end_time=${end_time}, page_num=${pageNum}, page_size=${pageSize}`); + + return response.data; + }, `获取第${pageNum}页使用详情`); + + return result; + } catch (error: any) { + logWithTime(`获取第${pageNum}页使用详情失败: ${error}`); + return null; + } + } +} + +/** + * 获取API服务实例的便捷函数 + */ +export function getApiService(): ApiService { + return ApiService.getInstance(); +} diff --git a/src/dashboardGenerator.ts b/src/dashboardGenerator.ts index e62566b..7f4877c 100644 --- a/src/dashboardGenerator.ts +++ b/src/dashboardGenerator.ts @@ -1,9 +1,16 @@ -import * as vscode from 'vscode'; -import * as os from 'os'; -import { StoredUsageData, UsageDetailItem, UsageSummary, ModelStats, ModeStats, DailyStats } from './types'; -import { logWithTime, formatTimestamp } from './utils'; - -const USAGE_DATA_FILE = 'usage_data.json'; +import * as vscode from "vscode"; +import * as os from "os"; +import { + StoredUsageData, + UsageDetailItem, + UsageSummary, + ModelStats, + ModeStats, + DailyStats, +} from "./types"; +import { logWithTime, formatTimestamp } from "./utils"; + +const USAGE_DATA_FILE = "usage_data.json"; export class UsageDashboardGenerator { private context: vscode.ExtensionContext; @@ -18,42 +25,53 @@ export class UsageDashboardGenerator { const rawData = await this.loadUsageData(); if (!rawData || Object.keys(rawData.usage_details).length === 0) { const choice = await vscode.window.showWarningMessage( - 'No usage data found, please collect data first', - 'Collect Now' + "未找到使用数据,请先收集数据", + "立即收集" ); - if (choice === 'Collect Now') { - vscode.commands.executeCommand('traeUsage.collectUsageDetails'); + if (choice === "立即收集") { + vscode.commands.executeCommand("traeUsage.collectUsageDetails"); } return; } await this.generateAndShowDashboard(rawData); } catch (error) { - logWithTime(`Failed to display dashboard: ${error}`); - vscode.window.showErrorMessage(`Dashboard error: ${error?.toString() || 'Unknown error'}`); + logWithTime(`显示看板失败: ${error}`); + vscode.window.showErrorMessage( + `看板错误: ${error?.toString() || "Unknown error"}` + ); } } private async loadUsageData(): Promise { - const dataPath = vscode.Uri.joinPath(this.context.globalStorageUri, USAGE_DATA_FILE); - + const dataPath = vscode.Uri.joinPath( + this.context.globalStorageUri, + USAGE_DATA_FILE + ); + try { const fileContent = await vscode.workspace.fs.readFile(dataPath); const jsonData = JSON.parse(fileContent.toString()); return jsonData as StoredUsageData; } catch (error) { - logWithTime(`Failed to read usage data file: ${error}`); + logWithTime(`读取使用数据文件失败: ${error}`); return null; } } - private filterUsageDetails(usageDetails: UsageDetailItem[], startDate?: string, endDate?: string): UsageDetailItem[] { + private filterUsageDetails( + usageDetails: UsageDetailItem[], + startDate?: string, + endDate?: string + ): UsageDetailItem[] { if (!startDate && !endDate) { return usageDetails; } - return usageDetails.filter(item => { - const itemDate = new Date(item.usage_time * 1000).toISOString().split('T')[0]; + return usageDetails.filter((item) => { + const itemDate = new Date(item.usage_time * 1000) + .toISOString() + .split("T")[0]; if (startDate && itemDate < startDate) return false; if (endDate && itemDate > endDate) return false; return true; @@ -67,10 +85,10 @@ export class UsageDashboardGenerator { total_sessions: usageDetails.length, model_stats: {}, mode_stats: {}, - daily_stats: {} + daily_stats: {}, }; - usageDetails.forEach(item => { + usageDetails.forEach((item) => { summary.total_amount += item.amount_float; summary.total_cost += item.cost_money_float; @@ -84,7 +102,7 @@ export class UsageDashboardGenerator { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, - cache_write_tokens: 0 + cache_write_tokens: 0, }; } const modelStats = summary.model_stats[modelName]; @@ -97,7 +115,7 @@ export class UsageDashboardGenerator { modelStats.cache_write_tokens += item.extra_info.cache_write_token; // Mode statistics - const mode = item.use_max_mode ? 'Max' : 'Normal'; + const mode = item.use_max_mode ? "Max" : "Normal"; if (!summary.mode_stats[mode]) { summary.mode_stats[mode] = { count: 0, amount: 0, cost: 0 }; } @@ -106,9 +124,14 @@ export class UsageDashboardGenerator { summary.mode_stats[mode].cost += item.cost_money_float; // Daily statistics - const date = new Date(item.usage_time * 1000).toISOString().split('T')[0]; + const date = new Date(item.usage_time * 1000).toISOString().split("T")[0]; if (!summary.daily_stats[date]) { - summary.daily_stats[date] = { count: 0, amount: 0, cost: 0, models: [] }; + summary.daily_stats[date] = { + count: 0, + amount: 0, + cost: 0, + models: [], + }; } summary.daily_stats[date].count++; summary.daily_stats[date].amount += item.amount_float; @@ -121,106 +144,151 @@ export class UsageDashboardGenerator { return summary; } - private async generateAndShowDashboard(rawData: StoredUsageData): Promise { + private async generateAndShowDashboard( + rawData: StoredUsageData + ): Promise { const allUsageDetails = Object.values(rawData.usage_details); const initialSummary = this.generateSummary(allUsageDetails); - + if (this.panel) { this.panel.dispose(); } - + this.panel = vscode.window.createWebviewPanel( - 'traeUsageDashboard', - 'Trae Usage Statistics', + "traeUsageDashboard", + "Trae 使用统计看板", vscode.ViewColumn.One, { enableScripts: true, - retainContextWhenHidden: true + retainContextWhenHidden: true, } ); // Listen for messages from webview this.panel.webview.onDidReceiveMessage(async (message) => { switch (message.command) { - case 'filter': - const filteredDetails = this.filterUsageDetails(allUsageDetails, message.startDate, message.endDate); + case "filter": + const filteredDetails = this.filterUsageDetails( + allUsageDetails, + message.startDate, + message.endDate + ); const filteredSummary = this.generateSummary(filteredDetails); - + this.panel?.webview.postMessage({ - command: 'updateData', + command: "updateData", summary: filteredSummary, - details: filteredDetails + details: filteredDetails, }); break; - - case 'export': - const exportDetails = this.filterUsageDetails(allUsageDetails, message.startDate, message.endDate); - await this.exportData(exportDetails, message.startDate, message.endDate); + + case "export": + const exportDetails = this.filterUsageDetails( + allUsageDetails, + message.startDate, + message.endDate + ); + await this.exportData( + exportDetails, + message.startDate, + message.endDate + ); break; } }); - this.panel.webview.html = this.generateDashboardHTML(rawData, initialSummary, allUsageDetails); + this.panel.webview.html = this.generateDashboardHTML( + rawData, + initialSummary, + allUsageDetails + ); } - private async exportData(filteredDetails: UsageDetailItem[], startDate?: string, endDate?: string): Promise { + private async exportData( + filteredDetails: UsageDetailItem[], + startDate?: string, + endDate?: string + ): Promise { try { const csvContent = this.generateCSV(filteredDetails); - const dateRange = startDate && endDate ? `_${startDate}_to_${endDate}` : ''; - const fileName = `trae_usage_export${dateRange}_${new Date().toISOString().split('T')[0]}.csv`; - + const dateRange = + startDate && endDate ? `_${startDate}_to_${endDate}` : ""; + const fileName = `trae_usage_export${dateRange}_${ + new Date().toISOString().split("T")[0] + }.csv`; + const uri = await vscode.window.showSaveDialog({ defaultUri: vscode.Uri.file(fileName), filters: { - 'CSV Files': ['csv'] - } + "CSV 文件": ["csv"], + }, }); if (uri) { - await vscode.workspace.fs.writeFile(uri, Buffer.from(csvContent, 'utf8')); - vscode.window.showInformationMessage(`Data exported to: ${uri.fsPath}`); + await vscode.workspace.fs.writeFile( + uri, + Buffer.from(csvContent, "utf8") + ); + vscode.window.showInformationMessage(`数据已导出: ${uri.fsPath}`); } } catch (error) { - vscode.window.showErrorMessage(`Export failed: ${error}`); + vscode.window.showErrorMessage(`导出失败: ${error}`); } } private generateCSV(details: UsageDetailItem[]): string { const headers = [ - 'Time', 'Model', 'Mode', 'Usage', 'Cost', 'Input Tokens', 'Output Tokens', 'Cache Read Tokens', 'Cache Write Tokens', 'Session ID' + "Time", + "Model", + "Mode", + "Usage", + "Cost", + "Input Tokens", + "Output Tokens", + "Cache Read Tokens", + "Cache Write Tokens", + "Session ID", ]; - - const rows = details.map(item => [ - new Date((item.usage_time || 0) * 1000).toLocaleString('en-US'), - item.model_name || '', - item.use_max_mode ? 'Max' : 'Normal', + + const rows = details.map((item) => [ + new Date((item.usage_time || 0) * 1000).toLocaleString("en-US"), + item.model_name || "", + item.use_max_mode ? "Max" : "Normal", (item.amount_float || 0).toString(), (item.cost_money_float || 0).toString(), (item.extra_info?.input_token || 0).toString(), (item.extra_info?.output_token || 0).toString(), (item.extra_info?.cache_read_token || 0).toString(), (item.extra_info?.cache_write_token || 0).toString(), - item.session_id || '' + item.session_id || "", ]); - return [headers, ...rows].map(row => row.join(',')).join('\n'); + return [headers, ...rows].map((row) => row.join(",")).join("\n"); } - private generateDashboardHTML(rawData: StoredUsageData, summary: UsageSummary, allUsageDetails: UsageDetailItem[]): string { - const timeRange = `${formatTimestamp(rawData.start_time)} - ${formatTimestamp(rawData.end_time)}`; - + private generateDashboardHTML( + rawData: StoredUsageData, + summary: UsageSummary, + allUsageDetails: UsageDetailItem[] + ): string { + const timeRange = `${formatTimestamp( + rawData.start_time + )} - ${formatTimestamp(rawData.end_time)}`; + // Get date range for filter - const dates = allUsageDetails.map(item => new Date(item.usage_time * 1000).toISOString().split('T')[0]); - const minDate = Math.min(...dates.map(d => new Date(d).getTime())); - const maxDate = Math.max(...dates.map(d => new Date(d).getTime())); - + const dates = allUsageDetails.map( + (item) => new Date(item.usage_time * 1000).toISOString().split("T")[0] + ); + const minDate = Math.min(...dates.map((d) => new Date(d).getTime())); + const maxDate = Math.max(...dates.map((d) => new Date(d).getTime())); + return ` - Trae Usage Statistics + Trae 使用统计看板