codebuddy-stats 1.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1 -2
- package/dist/lib/data-loader.js +0 -1
- package/dist/lib/paths.js +0 -1
- package/dist/lib/pricing.js +0 -1
- package/dist/lib/utils.js +0 -1
- package/dist/lib/workspace-resolver.js +0 -1
- package/package.json +19 -4
- package/.github/workflows/publish.yml +0 -41
- package/dist/index.js.map +0 -1
- package/dist/lib/data-loader.js.map +0 -1
- package/dist/lib/paths.js.map +0 -1
- package/dist/lib/pricing.js.map +0 -1
- package/dist/lib/utils.js.map +0 -1
- package/dist/lib/workspace-resolver.js.map +0 -1
- package/src/index.ts +0 -615
- package/src/lib/data-loader.ts +0 -581
- package/src/lib/paths.ts +0 -70
- package/src/lib/pricing.ts +0 -128
- package/src/lib/utils.ts +0 -61
- package/src/lib/workspace-resolver.ts +0 -235
- package/tsconfig.json +0 -25
package/src/index.ts
DELETED
|
@@ -1,615 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import fs from 'node:fs'
|
|
4
|
-
import path from 'node:path'
|
|
5
|
-
import { fileURLToPath } from 'node:url'
|
|
6
|
-
import blessed from 'blessed'
|
|
7
|
-
|
|
8
|
-
import { loadUsageData } from './lib/data-loader.js'
|
|
9
|
-
import type { AnalysisData } from './lib/data-loader.js'
|
|
10
|
-
import { resolveProjectName } from './lib/workspace-resolver.js'
|
|
11
|
-
import { formatCost, formatNumber, formatPercent, formatTokens, truncate } from './lib/utils.js'
|
|
12
|
-
|
|
13
|
-
// 读取 package.json 获取版本号
|
|
14
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
15
|
-
const pkgPath = path.resolve(__dirname, '../package.json')
|
|
16
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { version: string }
|
|
17
|
-
const VERSION = pkg.version
|
|
18
|
-
|
|
19
|
-
type CliOptions = {
|
|
20
|
-
days: number | null
|
|
21
|
-
noTui: boolean
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// 解析命令行参数
|
|
25
|
-
function parseArgs(): CliOptions {
|
|
26
|
-
const args = process.argv.slice(2)
|
|
27
|
-
const options: CliOptions = { days: null, noTui: false }
|
|
28
|
-
|
|
29
|
-
for (let i = 0; i < args.length; i++) {
|
|
30
|
-
if (args[i] === '--days' && args[i + 1]) {
|
|
31
|
-
const parsed = Number.parseInt(args[i + 1]!, 10)
|
|
32
|
-
options.days = Number.isFinite(parsed) ? parsed : null
|
|
33
|
-
i++
|
|
34
|
-
} else if (args[i] === '--no-tui') {
|
|
35
|
-
options.noTui = true
|
|
36
|
-
} else if (args[i] === '--help' || args[i] === '-h') {
|
|
37
|
-
console.log(`
|
|
38
|
-
CodeBuddy Cost Analyzer
|
|
39
|
-
|
|
40
|
-
Usage: cost-analyzer [options]
|
|
41
|
-
|
|
42
|
-
Options:
|
|
43
|
-
--days <n> 只显示最近 n 天的数据
|
|
44
|
-
--no-tui 使用纯文本输出(不启用交互式界面)
|
|
45
|
-
--help, -h 显示帮助信息
|
|
46
|
-
`)
|
|
47
|
-
process.exit(0)
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return options
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
type HeatmapData = {
|
|
54
|
-
dates: string[]
|
|
55
|
-
costs: number[]
|
|
56
|
-
maxCost: number
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// 生成热力图数据
|
|
60
|
-
function generateHeatmapData(dailySummary: AnalysisData['dailySummary']): HeatmapData {
|
|
61
|
-
const sortedDates = Object.keys(dailySummary).sort()
|
|
62
|
-
if (sortedDates.length === 0) return { dates: [], costs: [], maxCost: 0 }
|
|
63
|
-
|
|
64
|
-
const costs = sortedDates.map(d => dailySummary[d]?.cost ?? 0)
|
|
65
|
-
const maxCost = Math.max(...costs)
|
|
66
|
-
|
|
67
|
-
return {
|
|
68
|
-
dates: sortedDates,
|
|
69
|
-
costs,
|
|
70
|
-
maxCost,
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// 获取热力图字符
|
|
75
|
-
function getHeatChar(cost: number, maxCost: number): string {
|
|
76
|
-
if (cost === 0) return '·'
|
|
77
|
-
const ratio = cost / maxCost
|
|
78
|
-
if (ratio < 0.25) return '░'
|
|
79
|
-
if (ratio < 0.5) return '▒'
|
|
80
|
-
if (ratio < 0.75) return '▓'
|
|
81
|
-
return '█'
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// 渲染 Overview 视图
|
|
85
|
-
function renderOverview(box: any, data: AnalysisData, width: number, note: string): void {
|
|
86
|
-
const { dailySummary, grandTotal, topModel, topProject, cacheHitRate, activeDays } = data
|
|
87
|
-
const heatmap = generateHeatmapData(dailySummary)
|
|
88
|
-
|
|
89
|
-
// 根据宽度计算热力图周数
|
|
90
|
-
const availableWidth = width - 10
|
|
91
|
-
const maxWeeks = Math.min(Math.floor(availableWidth / 2), 26) // 最多 26 周 (半年)
|
|
92
|
-
|
|
93
|
-
let content = '{bold}Cost Heatmap{/bold}\n\n'
|
|
94
|
-
|
|
95
|
-
// 生成正确的日期网格 - 从今天往前推算
|
|
96
|
-
const today = new Date()
|
|
97
|
-
const todayStr = today.toISOString().split('T')[0]!
|
|
98
|
-
|
|
99
|
-
// 找到最近的周六作为结束点(或今天)
|
|
100
|
-
const endDate = new Date(today)
|
|
101
|
-
|
|
102
|
-
// 往前推 maxWeeks 周
|
|
103
|
-
const startDate = new Date(endDate)
|
|
104
|
-
startDate.setDate(startDate.getDate() - maxWeeks * 7 + 1)
|
|
105
|
-
// 调整到周日开始
|
|
106
|
-
startDate.setDate(startDate.getDate() - startDate.getDay())
|
|
107
|
-
|
|
108
|
-
// 构建周数组,每周从周日到周六
|
|
109
|
-
const weeks: string[][] = []
|
|
110
|
-
const currentDate = new Date(startDate)
|
|
111
|
-
while (currentDate <= endDate) {
|
|
112
|
-
const week: string[] = []
|
|
113
|
-
for (let d = 0; d < 7; d++) {
|
|
114
|
-
const dateStr = currentDate.toISOString().split('T')[0]!
|
|
115
|
-
week.push(dateStr)
|
|
116
|
-
currentDate.setDate(currentDate.getDate() + 1)
|
|
117
|
-
}
|
|
118
|
-
weeks.push(week)
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const maxCost = heatmap.maxCost || 1
|
|
122
|
-
const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
|
123
|
-
|
|
124
|
-
for (let dayOfWeek = 0; dayOfWeek < 7; dayOfWeek++) {
|
|
125
|
-
let row = dayLabels[dayOfWeek]!.padEnd(4)
|
|
126
|
-
for (const week of weeks) {
|
|
127
|
-
const date = week[dayOfWeek]
|
|
128
|
-
if (date && date <= todayStr && dailySummary[date]) {
|
|
129
|
-
row += getHeatChar(dailySummary[date]!.cost, maxCost) + ' '
|
|
130
|
-
} else if (date && date <= todayStr) {
|
|
131
|
-
row += '· ' // 有日期但无数据
|
|
132
|
-
} else {
|
|
133
|
-
row += ' ' // 未来日期
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
content += row + '\n'
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
content += ' Less {gray-fg}·░▒▓{/gray-fg}{white-fg}█{/white-fg} More\n\n'
|
|
140
|
-
|
|
141
|
-
// 汇总指标 - 根据宽度决定布局
|
|
142
|
-
const avgDailyCost = activeDays > 0 ? grandTotal.cost / activeDays : 0
|
|
143
|
-
const summaryWidth = Math.min(width - 6, 70)
|
|
144
|
-
|
|
145
|
-
content += '{bold}Summary{/bold}\n'
|
|
146
|
-
content += '─'.repeat(summaryWidth) + '\n'
|
|
147
|
-
|
|
148
|
-
if (width >= 80) {
|
|
149
|
-
// 双列布局
|
|
150
|
-
content += `{green-fg}Total cost:{/green-fg} ${formatCost(grandTotal.cost).padStart(12)} `
|
|
151
|
-
content += `{green-fg}Active days:{/green-fg} ${String(activeDays).padStart(8)}\n`
|
|
152
|
-
content += `{green-fg}Total tokens:{/green-fg} ${formatTokens(grandTotal.tokens).padStart(12)} `
|
|
153
|
-
content += `{green-fg}Total requests:{/green-fg} ${formatNumber(grandTotal.requests).padStart(8)}\n`
|
|
154
|
-
content += `{green-fg}Cache hit rate:{/green-fg} ${formatPercent(cacheHitRate).padStart(12)} `
|
|
155
|
-
content += `{green-fg}Avg daily cost:{/green-fg} ${formatCost(avgDailyCost).padStart(8)}\n\n`
|
|
156
|
-
} else {
|
|
157
|
-
// 单列布局
|
|
158
|
-
content += `{green-fg}Total cost:{/green-fg} ${formatCost(grandTotal.cost)}\n`
|
|
159
|
-
content += `{green-fg}Total tokens:{/green-fg} ${formatTokens(grandTotal.tokens)}\n`
|
|
160
|
-
content += `{green-fg}Total requests:{/green-fg} ${formatNumber(grandTotal.requests)}\n`
|
|
161
|
-
content += `{green-fg}Active days:{/green-fg} ${activeDays}\n`
|
|
162
|
-
content += `{green-fg}Cache hit rate:{/green-fg} ${formatPercent(cacheHitRate)}\n`
|
|
163
|
-
content += `{green-fg}Avg daily cost:{/green-fg} ${formatCost(avgDailyCost)}\n\n`
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
if (topModel) {
|
|
167
|
-
content += `{cyan-fg}Top model:{/cyan-fg} ${topModel.id} (${formatCost(topModel.cost)})\n`
|
|
168
|
-
}
|
|
169
|
-
if (topProject) {
|
|
170
|
-
const projectMaxLen = width >= 100 ? 60 : 35
|
|
171
|
-
const shortName = resolveProjectName(topProject.name, data.workspaceMappings)
|
|
172
|
-
content += `{cyan-fg}Top project:{/cyan-fg} ${truncate(shortName, projectMaxLen)} (${formatCost(topProject.cost)})\n`
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
if (note) {
|
|
176
|
-
content += `\n{gray-fg}备注:${note}{/gray-fg}\n`
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
box.setContent(content)
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// 渲染 By Model 视图
|
|
183
|
-
function renderByModel(box: any, data: AnalysisData, width: number, note: string): void {
|
|
184
|
-
const { modelTotals, grandTotal } = data
|
|
185
|
-
const sorted = Object.entries(modelTotals).sort((a, b) => b[1].cost - a[1].cost)
|
|
186
|
-
|
|
187
|
-
// 根据宽度计算列宽
|
|
188
|
-
const availableWidth = width - 6 // padding
|
|
189
|
-
const fixedCols = 12 + 12 + 12 + 10 // Cost + Requests + Tokens + Avg/Req
|
|
190
|
-
const modelCol = Math.max(20, Math.min(40, availableWidth - fixedCols))
|
|
191
|
-
const totalWidth = modelCol + fixedCols
|
|
192
|
-
|
|
193
|
-
let content = '{bold}Cost by Model{/bold}\n\n'
|
|
194
|
-
content +=
|
|
195
|
-
'{underline}' +
|
|
196
|
-
'Model'.padEnd(modelCol) +
|
|
197
|
-
'Cost'.padStart(12) +
|
|
198
|
-
'Requests'.padStart(12) +
|
|
199
|
-
'Tokens'.padStart(12) +
|
|
200
|
-
'Avg/Req'.padStart(10) +
|
|
201
|
-
'{/underline}\n'
|
|
202
|
-
|
|
203
|
-
for (const [modelId, stats] of sorted) {
|
|
204
|
-
const avgPerReq = stats.requests > 0 ? stats.cost / stats.requests : 0
|
|
205
|
-
content +=
|
|
206
|
-
truncate(modelId, modelCol - 1).padEnd(modelCol) +
|
|
207
|
-
formatCost(stats.cost).padStart(12) +
|
|
208
|
-
formatNumber(stats.requests).padStart(12) +
|
|
209
|
-
formatTokens(stats.tokens).padStart(12) +
|
|
210
|
-
formatCost(avgPerReq).padStart(10) +
|
|
211
|
-
'\n'
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
content += '─'.repeat(totalWidth) + '\n'
|
|
215
|
-
content +=
|
|
216
|
-
'{bold}' +
|
|
217
|
-
'Total'.padEnd(modelCol) +
|
|
218
|
-
formatCost(grandTotal.cost).padStart(12) +
|
|
219
|
-
formatNumber(grandTotal.requests).padStart(12) +
|
|
220
|
-
formatTokens(grandTotal.tokens).padStart(12) +
|
|
221
|
-
'{/bold}\n'
|
|
222
|
-
|
|
223
|
-
if (note) {
|
|
224
|
-
content += `\n{gray-fg}备注:${note}{/gray-fg}\n`
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
box.setContent(content)
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// 渲染 By Project 视图
|
|
231
|
-
function renderByProject(box: any, data: AnalysisData, width: number, note: string): void {
|
|
232
|
-
const { projectTotals, grandTotal } = data
|
|
233
|
-
const sorted = Object.entries(projectTotals).sort((a, b) => b[1].cost - a[1].cost)
|
|
234
|
-
|
|
235
|
-
// 根据宽度计算列宽
|
|
236
|
-
const availableWidth = width - 6 // padding
|
|
237
|
-
const fixedCols = 12 + 12 + 12 // Cost + Requests + Tokens
|
|
238
|
-
const projectCol = Math.max(25, availableWidth - fixedCols)
|
|
239
|
-
const totalWidth = projectCol + fixedCols
|
|
240
|
-
|
|
241
|
-
let content = '{bold}Cost by Project{/bold}\n\n'
|
|
242
|
-
content +=
|
|
243
|
-
'{underline}' +
|
|
244
|
-
'Project'.padEnd(projectCol) +
|
|
245
|
-
'Cost'.padStart(12) +
|
|
246
|
-
'Requests'.padStart(12) +
|
|
247
|
-
'Tokens'.padStart(12) +
|
|
248
|
-
'{/underline}\n'
|
|
249
|
-
|
|
250
|
-
for (const [projectName, stats] of sorted) {
|
|
251
|
-
// 简化项目名
|
|
252
|
-
const shortName = resolveProjectName(projectName, data.workspaceMappings)
|
|
253
|
-
content +=
|
|
254
|
-
truncate(shortName, projectCol - 1).padEnd(projectCol) +
|
|
255
|
-
formatCost(stats.cost).padStart(12) +
|
|
256
|
-
formatNumber(stats.requests).padStart(12) +
|
|
257
|
-
formatTokens(stats.tokens).padStart(12) +
|
|
258
|
-
'\n'
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
content += '─'.repeat(totalWidth) + '\n'
|
|
262
|
-
content +=
|
|
263
|
-
'{bold}' +
|
|
264
|
-
`Total (${sorted.length} projects)`.padEnd(projectCol) +
|
|
265
|
-
formatCost(grandTotal.cost).padStart(12) +
|
|
266
|
-
formatNumber(grandTotal.requests).padStart(12) +
|
|
267
|
-
formatTokens(grandTotal.tokens).padStart(12) +
|
|
268
|
-
'{/bold}\n'
|
|
269
|
-
|
|
270
|
-
if (note) {
|
|
271
|
-
content += `\n{gray-fg}备注:${note}{/gray-fg}\n`
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
box.setContent(content)
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// 渲染 Daily 视图
|
|
278
|
-
function renderDaily(box: any, data: AnalysisData, scrollOffset = 0, width: number, note: string): void {
|
|
279
|
-
const { dailySummary, dailyData } = data
|
|
280
|
-
const sortedDates = Object.keys(dailySummary).sort().reverse()
|
|
281
|
-
|
|
282
|
-
// 根据宽度计算列宽
|
|
283
|
-
const availableWidth = width - 6 // padding
|
|
284
|
-
const dateCol = 12
|
|
285
|
-
const costCol = 12
|
|
286
|
-
const reqCol = 10
|
|
287
|
-
const fixedCols = dateCol + costCol + reqCol
|
|
288
|
-
const remainingWidth = availableWidth - fixedCols
|
|
289
|
-
const modelCol = Math.max(15, Math.min(25, Math.floor(remainingWidth * 0.4)))
|
|
290
|
-
const projectCol = Math.max(20, remainingWidth - modelCol)
|
|
291
|
-
|
|
292
|
-
let content = '{bold}Daily Cost Details{/bold}\n\n'
|
|
293
|
-
content +=
|
|
294
|
-
'{underline}' +
|
|
295
|
-
'Date'.padEnd(dateCol) +
|
|
296
|
-
'Cost'.padStart(costCol) +
|
|
297
|
-
'Requests'.padStart(reqCol) +
|
|
298
|
-
'Top Model'.padStart(modelCol) +
|
|
299
|
-
'Top Project'.padStart(projectCol) +
|
|
300
|
-
'{/underline}\n'
|
|
301
|
-
|
|
302
|
-
const visibleDates = sortedDates.slice(scrollOffset, scrollOffset + 20)
|
|
303
|
-
|
|
304
|
-
for (const date of visibleDates) {
|
|
305
|
-
const daySummary = dailySummary[date]
|
|
306
|
-
const dayData = dailyData[date]
|
|
307
|
-
if (!daySummary || !dayData) continue
|
|
308
|
-
|
|
309
|
-
// 找出当天 top model 和 project
|
|
310
|
-
let topModel: { id: string; cost: number } = { id: '-', cost: 0 }
|
|
311
|
-
let topProject: { name: string; cost: number } = { name: '-', cost: 0 }
|
|
312
|
-
|
|
313
|
-
for (const [project, models] of Object.entries(dayData)) {
|
|
314
|
-
let projectCost = 0
|
|
315
|
-
for (const [model, stats] of Object.entries(models)) {
|
|
316
|
-
const modelStats = stats as any
|
|
317
|
-
projectCost += Number(modelStats.cost ?? 0)
|
|
318
|
-
if (Number(modelStats.cost ?? 0) > topModel.cost) {
|
|
319
|
-
topModel = { id: model, cost: Number(modelStats.cost ?? 0) }
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
if (projectCost > topProject.cost) {
|
|
323
|
-
topProject = { name: project, cost: projectCost }
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
const shortProject = resolveProjectName(topProject.name, data.workspaceMappings)
|
|
328
|
-
|
|
329
|
-
content +=
|
|
330
|
-
date.padEnd(dateCol) +
|
|
331
|
-
formatCost(daySummary.cost).padStart(costCol) +
|
|
332
|
-
formatNumber(daySummary.requests).padStart(reqCol) +
|
|
333
|
-
truncate(topModel.id, modelCol - 1).padStart(modelCol) +
|
|
334
|
-
truncate(shortProject, projectCol - 1).padStart(projectCol) +
|
|
335
|
-
'\n'
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
if (sortedDates.length > 20) {
|
|
339
|
-
content += `\n{gray-fg}Showing ${scrollOffset + 1}-${Math.min(scrollOffset + 20, sortedDates.length)} of ${sortedDates.length} days (↑↓ to scroll){/gray-fg}`
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
if (note) {
|
|
343
|
-
content += `\n\n{gray-fg}备注:${note}{/gray-fg}\n`
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
box.setContent(content)
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// 纯文本输出模式
|
|
350
|
-
function printTextReport(data: AnalysisData): void {
|
|
351
|
-
const { modelTotals, projectTotals, grandTotal, topModel, topProject, cacheHitRate, activeDays } = data
|
|
352
|
-
|
|
353
|
-
console.log('\n🤖 CodeBuddy Cost Analysis Report')
|
|
354
|
-
console.log('='.repeat(50))
|
|
355
|
-
|
|
356
|
-
console.log(`\nTotal cost: ${formatCost(grandTotal.cost)}`)
|
|
357
|
-
console.log(`Total tokens: ${formatTokens(grandTotal.tokens)}`)
|
|
358
|
-
console.log(`Total requests: ${formatNumber(grandTotal.requests)}`)
|
|
359
|
-
console.log(`Active days: ${activeDays}`)
|
|
360
|
-
console.log(`Cache hit rate: ${formatPercent(cacheHitRate)}`)
|
|
361
|
-
|
|
362
|
-
if (topModel) {
|
|
363
|
-
console.log(`\nTop model: ${topModel.id} (${formatCost(topModel.cost)})`)
|
|
364
|
-
}
|
|
365
|
-
if (topProject) {
|
|
366
|
-
const shortName = resolveProjectName(topProject.name, data.workspaceMappings)
|
|
367
|
-
console.log(`Top project: ${shortName}`)
|
|
368
|
-
console.log(` (${formatCost(topProject.cost)})`)
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
console.log('\n' + '-'.repeat(50))
|
|
372
|
-
console.log('By Model:')
|
|
373
|
-
for (const [model, stats] of Object.entries(modelTotals).sort((a, b) => b[1].cost - a[1].cost)) {
|
|
374
|
-
console.log(` ${model}: ${formatCost(stats.cost)} (${formatNumber(stats.requests)} req)`) // eslint-disable-line no-console
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
console.log('\n' + '-'.repeat(50))
|
|
378
|
-
console.log('By Project:')
|
|
379
|
-
for (const [project, stats] of Object.entries(projectTotals)
|
|
380
|
-
.sort((a, b) => b[1].cost - a[1].cost)
|
|
381
|
-
.slice(0, 10)) {
|
|
382
|
-
const shortName = resolveProjectName(project, data.workspaceMappings)
|
|
383
|
-
console.log(` ${truncate(shortName, 40)}: ${formatCost(stats.cost)}`) // eslint-disable-line no-console
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
console.log('\n' + '='.repeat(50) + '\n')
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// 主程序
|
|
390
|
-
async function main(): Promise<void> {
|
|
391
|
-
const options = parseArgs()
|
|
392
|
-
|
|
393
|
-
console.log('Loading data...')
|
|
394
|
-
let currentSource: 'code' | 'ide' = 'code'
|
|
395
|
-
let data = await loadUsageData({ days: options.days, source: currentSource })
|
|
396
|
-
|
|
397
|
-
if (options.noTui) {
|
|
398
|
-
printTextReport(data)
|
|
399
|
-
return
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// 创建 TUI
|
|
403
|
-
const screen = blessed.screen({
|
|
404
|
-
smartCSR: true,
|
|
405
|
-
title: 'CodeBuddy Cost Analyzer',
|
|
406
|
-
forceUnicode: true,
|
|
407
|
-
fullUnicode: true,
|
|
408
|
-
})
|
|
409
|
-
|
|
410
|
-
// Tab 状态
|
|
411
|
-
const tabs = ['Overview', 'By Model', 'By Project', 'Daily']
|
|
412
|
-
let currentTab = 0
|
|
413
|
-
let dailyScrollOffset = 0
|
|
414
|
-
|
|
415
|
-
// Tab 栏
|
|
416
|
-
const tabBar = blessed.box({
|
|
417
|
-
top: 0,
|
|
418
|
-
left: 0,
|
|
419
|
-
width: '100%',
|
|
420
|
-
height: 3,
|
|
421
|
-
tags: true,
|
|
422
|
-
style: {
|
|
423
|
-
fg: 'white',
|
|
424
|
-
bg: 'black',
|
|
425
|
-
},
|
|
426
|
-
})
|
|
427
|
-
|
|
428
|
-
// 内容区域
|
|
429
|
-
const contentBox = blessed.box({
|
|
430
|
-
top: 3,
|
|
431
|
-
left: 0,
|
|
432
|
-
width: '100%',
|
|
433
|
-
height: '100%-5',
|
|
434
|
-
tags: true,
|
|
435
|
-
scrollable: true,
|
|
436
|
-
alwaysScroll: true,
|
|
437
|
-
keys: true,
|
|
438
|
-
vi: true,
|
|
439
|
-
style: {
|
|
440
|
-
fg: 'white',
|
|
441
|
-
bg: 'black',
|
|
442
|
-
},
|
|
443
|
-
padding: {
|
|
444
|
-
left: 2,
|
|
445
|
-
right: 2,
|
|
446
|
-
top: 1,
|
|
447
|
-
},
|
|
448
|
-
})
|
|
449
|
-
|
|
450
|
-
// 底部状态栏
|
|
451
|
-
const statusBar = blessed.box({
|
|
452
|
-
bottom: 0,
|
|
453
|
-
left: 0,
|
|
454
|
-
width: '100%',
|
|
455
|
-
height: 1,
|
|
456
|
-
tags: true,
|
|
457
|
-
style: {
|
|
458
|
-
fg: 'black',
|
|
459
|
-
bg: 'green',
|
|
460
|
-
},
|
|
461
|
-
})
|
|
462
|
-
|
|
463
|
-
screen.append(tabBar)
|
|
464
|
-
screen.append(contentBox)
|
|
465
|
-
screen.append(statusBar)
|
|
466
|
-
|
|
467
|
-
// 更新 Tab 栏
|
|
468
|
-
function updateTabBar(): void {
|
|
469
|
-
let content = ' Cost Analysis '
|
|
470
|
-
|
|
471
|
-
content += '{gray-fg}Source:{/gray-fg} '
|
|
472
|
-
if (currentSource === 'code') {
|
|
473
|
-
content += '{black-fg}{green-bg} Code {/green-bg}{/black-fg} '
|
|
474
|
-
content += '{gray-fg}IDE{/gray-fg} '
|
|
475
|
-
} else {
|
|
476
|
-
content += '{gray-fg}Code{/gray-fg} '
|
|
477
|
-
content += '{black-fg}{green-bg} IDE {/green-bg}{/black-fg} '
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
content += '{gray-fg}Views:{/gray-fg} '
|
|
481
|
-
|
|
482
|
-
for (let i = 0; i < tabs.length; i++) {
|
|
483
|
-
if (i === currentTab) {
|
|
484
|
-
content += `{black-fg}{green-bg} ${tabs[i]} {/green-bg}{/black-fg} `
|
|
485
|
-
} else {
|
|
486
|
-
content += `{gray-fg}${tabs[i]}{/gray-fg} `
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
content += ' {gray-fg}(Tab view, s source){/gray-fg}'
|
|
490
|
-
tabBar.setContent(content)
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
// 更新内容
|
|
494
|
-
function updateContent(): void {
|
|
495
|
-
const width = Number(screen.width) || 80
|
|
496
|
-
|
|
497
|
-
const note =
|
|
498
|
-
currentSource === 'code'
|
|
499
|
-
? `针对 CodeBuddy Code ≤ 2.20.0 版本产生的数据,由于没有请求级别的 model ID,用量是基于当前 CodeBuddy Code 设置的 model ID(${data.defaultModelId})计算价格的`
|
|
500
|
-
: 'IDE 的 usage 不包含缓存命中/写入 tokens,无法计算缓存相关价格与命中率;成本按 input/output tokens 估算'
|
|
501
|
-
|
|
502
|
-
switch (currentTab) {
|
|
503
|
-
case 0:
|
|
504
|
-
renderOverview(contentBox, data, width, note)
|
|
505
|
-
break
|
|
506
|
-
case 1:
|
|
507
|
-
renderByModel(contentBox, data, width, note)
|
|
508
|
-
break
|
|
509
|
-
case 2:
|
|
510
|
-
renderByProject(contentBox, data, width, note)
|
|
511
|
-
break
|
|
512
|
-
case 3:
|
|
513
|
-
renderDaily(contentBox, data, dailyScrollOffset, width, note)
|
|
514
|
-
break
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
// 更新状态栏
|
|
519
|
-
function updateStatusBar(): void {
|
|
520
|
-
const daysInfo = options.days ? `Last ${options.days} days` : 'All time'
|
|
521
|
-
const sourceInfo = currentSource === 'code' ? 'Code' : 'IDE'
|
|
522
|
-
const leftContent = ` ${daysInfo} | Source: ${sourceInfo} | Total: ${formatCost(data.grandTotal.cost)} | q quit, Tab view, s source, r refresh`
|
|
523
|
-
const rightContent = `v${VERSION} `
|
|
524
|
-
const width = Number(screen.width) || 80
|
|
525
|
-
const padding = Math.max(0, width - leftContent.length - rightContent.length)
|
|
526
|
-
statusBar.setContent(leftContent + ' '.repeat(padding) + rightContent)
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
// 键盘事件
|
|
530
|
-
screen.key(['tab'], () => {
|
|
531
|
-
currentTab = (currentTab + 1) % tabs.length
|
|
532
|
-
dailyScrollOffset = 0
|
|
533
|
-
updateTabBar()
|
|
534
|
-
updateContent()
|
|
535
|
-
screen.render()
|
|
536
|
-
})
|
|
537
|
-
|
|
538
|
-
screen.key(['S-tab'], () => {
|
|
539
|
-
currentTab = (currentTab - 1 + tabs.length) % tabs.length
|
|
540
|
-
dailyScrollOffset = 0
|
|
541
|
-
updateTabBar()
|
|
542
|
-
updateContent()
|
|
543
|
-
screen.render()
|
|
544
|
-
})
|
|
545
|
-
|
|
546
|
-
screen.key(['up', 'k'], () => {
|
|
547
|
-
if (currentTab === 3) {
|
|
548
|
-
dailyScrollOffset = Math.max(0, dailyScrollOffset - 1)
|
|
549
|
-
updateContent()
|
|
550
|
-
screen.render()
|
|
551
|
-
}
|
|
552
|
-
})
|
|
553
|
-
|
|
554
|
-
screen.key(['down', 'j'], () => {
|
|
555
|
-
if (currentTab === 3) {
|
|
556
|
-
const maxOffset = Math.max(0, Object.keys(data.dailySummary).length - 20)
|
|
557
|
-
dailyScrollOffset = Math.min(maxOffset, dailyScrollOffset + 1)
|
|
558
|
-
updateContent()
|
|
559
|
-
screen.render()
|
|
560
|
-
}
|
|
561
|
-
})
|
|
562
|
-
|
|
563
|
-
screen.key(['q', 'C-c'], () => {
|
|
564
|
-
screen.destroy()
|
|
565
|
-
process.exit(0)
|
|
566
|
-
})
|
|
567
|
-
|
|
568
|
-
screen.key(['r'], async () => {
|
|
569
|
-
statusBar.setContent(' {yellow-fg}Reloading...{/yellow-fg}')
|
|
570
|
-
screen.render()
|
|
571
|
-
try {
|
|
572
|
-
data = await loadUsageData({ days: options.days, source: currentSource })
|
|
573
|
-
dailyScrollOffset = 0
|
|
574
|
-
updateTabBar()
|
|
575
|
-
updateContent()
|
|
576
|
-
updateStatusBar()
|
|
577
|
-
} catch (err) {
|
|
578
|
-
statusBar.setContent(` {red-fg}Reload failed: ${String(err)}{/red-fg}`)
|
|
579
|
-
}
|
|
580
|
-
screen.render()
|
|
581
|
-
})
|
|
582
|
-
|
|
583
|
-
screen.key(['s'], async () => {
|
|
584
|
-
statusBar.setContent(' {yellow-fg}Switching source...{/yellow-fg}')
|
|
585
|
-
screen.render()
|
|
586
|
-
try {
|
|
587
|
-
currentSource = currentSource === 'code' ? 'ide' : 'code'
|
|
588
|
-
data = await loadUsageData({ days: options.days, source: currentSource })
|
|
589
|
-
dailyScrollOffset = 0
|
|
590
|
-
updateTabBar()
|
|
591
|
-
updateContent()
|
|
592
|
-
updateStatusBar()
|
|
593
|
-
} catch (err) {
|
|
594
|
-
statusBar.setContent(` {red-fg}Switch source failed: ${String(err)}{/red-fg}`)
|
|
595
|
-
}
|
|
596
|
-
screen.render()
|
|
597
|
-
})
|
|
598
|
-
|
|
599
|
-
// 监听窗口大小变化
|
|
600
|
-
screen.on('resize', () => {
|
|
601
|
-
updateContent()
|
|
602
|
-
screen.render()
|
|
603
|
-
})
|
|
604
|
-
|
|
605
|
-
// 初始渲染
|
|
606
|
-
updateTabBar()
|
|
607
|
-
updateContent()
|
|
608
|
-
updateStatusBar()
|
|
609
|
-
screen.render()
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
main().catch(err => {
|
|
613
|
-
console.error('Error:', err)
|
|
614
|
-
process.exit(1)
|
|
615
|
-
})
|