@adversity/coding-tool-x 3.1.1 → 3.1.3

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.
Files changed (69) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/web/assets/Analytics-BIqc8Rin.css +1 -0
  3. package/dist/web/assets/Analytics-D2V09DHH.js +39 -0
  4. package/dist/web/assets/{ConfigTemplates-ZrK_s7ma.js → ConfigTemplates-Bf_11LhH.js} +1 -1
  5. package/dist/web/assets/Home-BRnW4FTS.js +1 -0
  6. package/dist/web/assets/Home-CyCIx4BA.css +1 -0
  7. package/dist/web/assets/{PluginManager-BD7QUZbU.js → PluginManager-B9J32GhW.js} +1 -1
  8. package/dist/web/assets/{ProjectList-DRb1DuHV.js → ProjectList-5a19MWJk.js} +1 -1
  9. package/dist/web/assets/SessionList-CXUr6S7w.css +1 -0
  10. package/dist/web/assets/SessionList-Cxg5bAdT.js +1 -0
  11. package/dist/web/assets/{SkillManager-C1xG5B4Q.js → SkillManager-CVBr0CLi.js} +1 -1
  12. package/dist/web/assets/{Terminal-DksBo_lM.js → Terminal-D2Xe_Q0H.js} +1 -1
  13. package/dist/web/assets/{WorkspaceManager-Burx7XOo.js → WorkspaceManager-C7dwV94C.js} +1 -1
  14. package/dist/web/assets/icons-BxcwoY5F.js +1 -0
  15. package/dist/web/assets/index-BS9RA6SN.js +2 -0
  16. package/dist/web/assets/index-DUNAVDGb.css +1 -0
  17. package/dist/web/assets/naive-ui-BIXcURHZ.js +1 -0
  18. package/dist/web/assets/{vendors-CO3Upi1d.js → vendors-i5CBGnlm.js} +1 -1
  19. package/dist/web/assets/{vue-vendor-DqyWIXEb.js → vue-vendor-PKd8utv_.js} +1 -1
  20. package/dist/web/index.html +6 -6
  21. package/package.json +1 -1
  22. package/src/config/default.js +7 -27
  23. package/src/config/loader.js +6 -3
  24. package/src/config/model-metadata.js +167 -0
  25. package/src/config/model-metadata.json +125 -0
  26. package/src/config/model-pricing.js +23 -93
  27. package/src/server/api/channels.js +16 -39
  28. package/src/server/api/codex-channels.js +15 -43
  29. package/src/server/api/commands.js +0 -77
  30. package/src/server/api/config.js +4 -1
  31. package/src/server/api/gemini-channels.js +16 -40
  32. package/src/server/api/opencode-channels.js +108 -56
  33. package/src/server/api/opencode-proxy.js +42 -33
  34. package/src/server/api/opencode-sessions.js +4 -69
  35. package/src/server/api/sessions.js +11 -68
  36. package/src/server/api/settings.js +138 -0
  37. package/src/server/api/skills.js +0 -44
  38. package/src/server/api/statistics.js +115 -1
  39. package/src/server/codex-proxy-server.js +32 -59
  40. package/src/server/gemini-proxy-server.js +21 -18
  41. package/src/server/index.js +13 -7
  42. package/src/server/opencode-proxy-server.js +1232 -197
  43. package/src/server/proxy-server.js +8 -8
  44. package/src/server/services/codex-sessions.js +105 -6
  45. package/src/server/services/commands-service.js +0 -29
  46. package/src/server/services/config-templates-service.js +38 -28
  47. package/src/server/services/env-checker.js +97 -9
  48. package/src/server/services/env-manager.js +29 -1
  49. package/src/server/services/opencode-channels.js +3 -1
  50. package/src/server/services/opencode-sessions.js +486 -218
  51. package/src/server/services/opencode-settings-manager.js +172 -36
  52. package/src/server/services/plugins-service.js +37 -28
  53. package/src/server/services/pty-manager.js +22 -18
  54. package/src/server/services/response-decoder.js +21 -0
  55. package/src/server/services/skill-service.js +1 -49
  56. package/src/server/services/speed-test.js +40 -3
  57. package/src/server/services/statistics-service.js +238 -1
  58. package/src/server/utils/pricing.js +51 -60
  59. package/src/server/websocket-server.js +24 -5
  60. package/dist/web/assets/Home-B8YfhZ3c.js +0 -1
  61. package/dist/web/assets/Home-Di2qsylF.css +0 -1
  62. package/dist/web/assets/SessionList-BGJWyneI.css +0 -1
  63. package/dist/web/assets/SessionList-lZ0LKzfT.js +0 -1
  64. package/dist/web/assets/icons-kcfLIMBB.js +0 -1
  65. package/dist/web/assets/index-Ufv5rCa5.css +0 -1
  66. package/dist/web/assets/index-lAkrRC3h.js +0 -2
  67. package/dist/web/assets/naive-ui-CSrLusZZ.js +0 -1
  68. package/src/server/api/convert.js +0 -260
  69. package/src/server/services/session-converter.js +0 -577
@@ -4,6 +4,7 @@ const {
4
4
  getProjects,
5
5
  getSessionsByProject,
6
6
  getSessionById,
7
+ getSessionMessages,
7
8
  getRecentSessions,
8
9
  searchSessions,
9
10
  deleteSession,
@@ -14,8 +15,6 @@ const {
14
15
  const { loadAliases } = require('../services/alias');
15
16
  const { getTerminalLaunchCommand } = require('../services/terminal-config');
16
17
  const { broadcastLog } = require('../websocket-server');
17
- const fs = require('fs');
18
- const path = require('path');
19
18
  const os = require('os');
20
19
 
21
20
  function isNotFoundError(error) {
@@ -156,7 +155,6 @@ module.exports = (config) => {
156
155
  /**
157
156
  * GET /api/opencode/sessions/:projectName/:sessionId/messages
158
157
  * 获取会话的消息列表
159
- * Note: OpenCode 的消息存储在单独的 message 目录,暂时返回基本信息
160
158
  */
161
159
  router.get('/:projectName/:sessionId/messages', (req, res) => {
162
160
  try {
@@ -167,66 +165,10 @@ module.exports = (config) => {
167
165
  const { sessionId } = req.params;
168
166
  const { page = 1, limit = 20, order = 'desc' } = req.query;
169
167
  const session = getSessionById(sessionId);
170
-
171
- // 读取消息文件
172
- const messagesDir = path.join(
173
- os.homedir(), '.local', 'share', 'opencode', 'storage', 'message', sessionId
174
- );
175
-
176
- const convertedMessages = [];
177
-
178
- if (fs.existsSync(messagesDir)) {
179
- const files = fs.readdirSync(messagesDir)
180
- .filter(f => f.endsWith('.json'))
181
- .sort();
182
-
183
- for (const file of files) {
184
- try {
185
- const content = fs.readFileSync(path.join(messagesDir, file), 'utf8');
186
- const msg = JSON.parse(content);
187
-
188
- if (msg.role === 'user') {
189
- // 提取用户消息内容
190
- let textContent = '';
191
- if (Array.isArray(msg.content)) {
192
- textContent = msg.content
193
- .filter(c => c.type === 'text')
194
- .map(c => c.text || '')
195
- .join('\n');
196
- } else if (typeof msg.content === 'string') {
197
- textContent = msg.content;
198
- }
199
-
200
- convertedMessages.push({
201
- type: 'user',
202
- content: textContent || '[空消息]',
203
- timestamp: msg.time?.created ? new Date(msg.time.created).toISOString() : null,
204
- model: null
205
- });
206
- } else if (msg.role === 'assistant') {
207
- // 提取助手消息内容
208
- let textContent = '';
209
- if (Array.isArray(msg.content)) {
210
- textContent = msg.content
211
- .filter(c => c.type === 'text')
212
- .map(c => c.text || '')
213
- .join('\n');
214
- } else if (typeof msg.content === 'string') {
215
- textContent = msg.content;
216
- }
217
-
218
- convertedMessages.push({
219
- type: 'assistant',
220
- content: textContent || '[空消息]',
221
- timestamp: msg.time?.created ? new Date(msg.time.created).toISOString() : null,
222
- model: msg.model || 'opencode'
223
- });
224
- }
225
- } catch (parseErr) {
226
- // 忽略解析错误
227
- }
228
- }
168
+ if (!session) {
169
+ return res.status(404).json({ error: 'Session not found' });
229
170
  }
171
+ const convertedMessages = getSessionMessages(sessionId);
230
172
 
231
173
  // 分页处理
232
174
  const pageNum = parseInt(page);
@@ -347,13 +289,6 @@ module.exports = (config) => {
347
289
 
348
290
  const { exec } = require('child_process');
349
291
  const { projectName, sessionId } = req.params;
350
- const { targetTool } = req.body || {};
351
-
352
- if (targetTool && targetTool !== 'opencode') {
353
- return res.status(400).json({
354
- error: 'OpenCode 会话暂不支持直接切换到其他 CLI,请使用 OpenCode 启动'
355
- });
356
- }
357
292
 
358
293
  const session = getSessionById(sessionId);
359
294
  if (!session) {
@@ -391,7 +391,6 @@ module.exports = (config) => {
391
391
  router.post('/:projectName/:sessionId/launch', async (req, res) => {
392
392
  try {
393
393
  const { projectName, sessionId } = req.params;
394
- const { targetTool } = req.body; // 'claude', 'codex', 或 'gemini'
395
394
  const { exec } = require('child_process');
396
395
  const path = require('path');
397
396
  const fs = require('fs');
@@ -440,66 +439,10 @@ module.exports = (config) => {
440
439
  });
441
440
  }
442
441
 
443
- // 判断会话来源类型
444
- let sourceType = 'claude'; // 默认
445
- if (sessionFile.includes('/.codex/') || sessionFile.includes('\\.codex\\')) {
446
- sourceType = 'codex';
447
- } else if (sessionFile.includes('/.gemini/') || sessionFile.includes('\\.gemini\\')) {
448
- sourceType = 'gemini';
449
- }
450
-
451
- // 如果指定了 targetTool 且与 sourceType 不同,则需要转换
452
- let finalSessionFile = sessionFile;
453
- let finalSessionId = sessionId;
454
-
455
- if (targetTool && targetTool !== sourceType) {
456
- console.log(`跨工具启动:${sourceType} -> ${targetTool},会话 ${sessionId}`);
457
-
458
- try {
459
- const { convertSession } = require('../services/session-converter');
460
-
461
- // 执行转换
462
- const convertResult = await convertSession(
463
- sourceType,
464
- targetTool,
465
- sessionId,
466
- {
467
- sourcePath: sessionFile,
468
- preserveTimestamps: true,
469
- targetProjectPath: fullPath
470
- }
471
- );
472
-
473
- if (convertResult.success) {
474
- finalSessionFile = convertResult.targetPath;
475
- finalSessionId = convertResult.targetSessionId;
476
- console.log(`转换成功:${finalSessionFile}`);
477
-
478
- // 广播转换日志
479
- broadcastLog({
480
- type: 'action',
481
- action: 'auto_convert_session',
482
- message: `自动转换会话:${sourceType} -> ${targetTool}`,
483
- sessionId: finalSessionId,
484
- timestamp: Date.now()
485
- });
486
- } else {
487
- return res.status(500).json({
488
- error: '会话转换失败:' + (convertResult.error || '未知错误')
489
- });
490
- }
491
- } catch (convertError) {
492
- console.error('会话转换出错:', convertError);
493
- return res.status(500).json({
494
- error: '会话转换出错:' + convertError.message
495
- });
496
- }
497
- }
498
-
499
442
  // Extract working directory from session file
500
443
  let cwd = fullPath; // Default to project directory
501
444
  try {
502
- const content = fs.readFileSync(finalSessionFile, 'utf8');
445
+ const content = fs.readFileSync(sessionFile, 'utf8');
503
446
  const firstLine = content.split('\n')[0];
504
447
  if (firstLine) {
505
448
  const json = JSON.parse(firstLine);
@@ -514,15 +457,15 @@ module.exports = (config) => {
514
457
  // 确保会话文件在 cwd 的 .claude/sessions/ 目录下
515
458
  // 这样 claude -r 才能找到文件
516
459
  const cwdSessionsDir = path.join(cwd, '.claude', 'sessions');
517
- const cwdSessionFile = path.join(cwdSessionsDir, finalSessionId + '.jsonl');
460
+ const cwdSessionFile = path.join(cwdSessionsDir, sessionId + '.jsonl');
518
461
 
519
462
  // 如果会话文件不在 cwd 的 sessions 目录,复制过去
520
- if (finalSessionFile !== cwdSessionFile && !fs.existsSync(cwdSessionFile)) {
463
+ if (sessionFile !== cwdSessionFile && !fs.existsSync(cwdSessionFile)) {
521
464
  try {
522
465
  if (!fs.existsSync(cwdSessionsDir)) {
523
466
  fs.mkdirSync(cwdSessionsDir, { recursive: true });
524
467
  }
525
- fs.copyFileSync(finalSessionFile, cwdSessionFile);
468
+ fs.copyFileSync(sessionFile, cwdSessionFile);
526
469
  console.log(`[Launch] Copied session to cwd: ${cwdSessionFile}`);
527
470
  } catch (copyError) {
528
471
  console.warn('[Launch] Failed to copy session file to cwd:', copyError.message);
@@ -536,16 +479,16 @@ module.exports = (config) => {
536
479
 
537
480
  // Get alias
538
481
  const aliases = loadAliases();
539
- const alias = aliases[finalSessionId];
482
+ const alias = aliases[sessionId];
540
483
 
541
484
  // 广播行为日志
542
485
  broadcastLog({
543
486
  type: 'action',
544
487
  action: 'launch_session',
545
- message: `启动会话 ${alias || finalSessionId.substring(0, 8)} (${targetTool || sourceType})`,
546
- sessionId: finalSessionId,
488
+ message: `启动会话 ${alias || sessionId.substring(0, 8)} (claude)`,
489
+ sessionId,
547
490
  alias: alias || null,
548
- tool: targetTool || sourceType,
491
+ tool: 'claude',
549
492
  timestamp: Date.now()
550
493
  });
551
494
 
@@ -556,11 +499,11 @@ module.exports = (config) => {
556
499
  // Windows 路径需要转换为反斜杠格式
557
500
  const normalizedCwd = process.platform === 'win32' ? cwd.replace(/\//g, '\\') : cwd;
558
501
 
559
- // 获取启动命令(需要传入 targetTool)
502
+ // 获取 Claude 会话启动命令
560
503
  const { command, terminalId, terminalName } = getTerminalLaunchCommand(
561
504
  normalizedCwd,
562
- finalSessionId,
563
- targetTool || sourceType
505
+ sessionId,
506
+ 'claude'
564
507
  );
565
508
 
566
509
  console.log(`Launching terminal: ${terminalName} (${terminalId})`);
@@ -2,6 +2,13 @@ const express = require('express');
2
2
  const router = express.Router();
3
3
  const { detectAvailableTerminals } = require('../services/terminal-detector');
4
4
  const { loadTerminalConfig, saveTerminalConfig, getSelectedTerminal } = require('../services/terminal-config');
5
+ const {
6
+ MODEL_METADATA,
7
+ METADATA_LAST_UPDATED,
8
+ getDefaultSpeedTestModels,
9
+ saveDefaultSpeedTestModels
10
+ } = require('../../config/model-metadata');
11
+ const { loadConfig, saveConfig } = require('../../config/loader');
5
12
 
6
13
  // GET /api/settings/terminals - 获取可用终端列表
7
14
  router.get('/terminals', (req, res) => {
@@ -58,4 +65,135 @@ router.post('/terminal-config', (req, res) => {
58
65
  }
59
66
  });
60
67
 
68
+ function handleGetModelSettings(req, res) {
69
+ try {
70
+ const config = loadConfig();
71
+ const overrides = config.modelMetadataOverrides || {};
72
+ const defaultSpeedTestModels = getDefaultSpeedTestModels();
73
+
74
+ // Build merged table: built-in + user overrides
75
+ const merged = {};
76
+ for (const [id, meta] of Object.entries(MODEL_METADATA)) {
77
+ merged[id] = overrides[id]
78
+ ? {
79
+ limit: { ...meta.limit, ...(overrides[id].limit || {}) },
80
+ pricing: { ...meta.pricing, ...(overrides[id].pricing || {}) }
81
+ }
82
+ : meta;
83
+ }
84
+
85
+ // Also include any user-added custom models from overrides
86
+ for (const [id, meta] of Object.entries(overrides)) {
87
+ if (!merged[id]) {
88
+ merged[id] = meta;
89
+ }
90
+ }
91
+
92
+ res.json({
93
+ models: merged,
94
+ overrides,
95
+ builtinModelIds: Object.keys(MODEL_METADATA),
96
+ lastUpdated: METADATA_LAST_UPDATED,
97
+ defaultSpeedTestModels
98
+ });
99
+ } catch (error) {
100
+ console.error('Error getting model metadata:', error);
101
+ res.status(500).json({ error: error.message });
102
+ }
103
+ }
104
+
105
+ // GET /api/settings/model-settings - 获取模型设置(元数据 + 默认测速模型)
106
+ router.get('/model-settings', handleGetModelSettings);
107
+ // backward compatibility
108
+ router.get('/model-metadata', handleGetModelSettings);
109
+
110
+ function handleSaveModelSettings(req, res) {
111
+ try {
112
+ const { overrides, defaultSpeedTestModels } = req.body || {};
113
+ if (overrides !== undefined && (typeof overrides !== 'object' || overrides === null || Array.isArray(overrides))) {
114
+ return res.status(400).json({ error: 'overrides must be an object' });
115
+ }
116
+
117
+ // Validate each override entry
118
+ if (overrides && typeof overrides === 'object') {
119
+ for (const [modelId, meta] of Object.entries(overrides)) {
120
+ if (typeof modelId !== 'string' || !modelId.trim()) {
121
+ return res.status(400).json({ error: `Invalid model ID: "${modelId}"` });
122
+ }
123
+ if (meta.limit !== undefined) {
124
+ if (typeof meta.limit !== 'object') {
125
+ return res.status(400).json({ error: `${modelId}: limit must be an object` });
126
+ }
127
+ if (meta.limit.context !== undefined && (typeof meta.limit.context !== 'number' || meta.limit.context <= 0)) {
128
+ return res.status(400).json({ error: `${modelId}: limit.context must be a positive number` });
129
+ }
130
+ if (meta.limit.output !== undefined && (typeof meta.limit.output !== 'number' || meta.limit.output <= 0)) {
131
+ return res.status(400).json({ error: `${modelId}: limit.output must be a positive number` });
132
+ }
133
+ }
134
+ if (meta.pricing !== undefined) {
135
+ if (typeof meta.pricing !== 'object') {
136
+ return res.status(400).json({ error: `${modelId}: pricing must be an object` });
137
+ }
138
+ for (const field of ['input', 'output']) {
139
+ if (meta.pricing[field] !== undefined && (typeof meta.pricing[field] !== 'number' || meta.pricing[field] < 0)) {
140
+ return res.status(400).json({ error: `${modelId}: pricing.${field} must be a non-negative number` });
141
+ }
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ const config = loadConfig();
148
+ const newConfig = {
149
+ ...config,
150
+ projectsDir: config.projectsDir.replace(require('os').homedir(), '~'),
151
+ modelMetadataOverrides: overrides && typeof overrides === 'object'
152
+ ? overrides
153
+ : (config.modelMetadataOverrides || {})
154
+ };
155
+ saveConfig(newConfig);
156
+ const persistedDefaultSpeedTestModels = saveDefaultSpeedTestModels(defaultSpeedTestModels);
157
+
158
+ res.json({
159
+ success: true,
160
+ overrides: newConfig.modelMetadataOverrides,
161
+ defaultSpeedTestModels: persistedDefaultSpeedTestModels
162
+ });
163
+ } catch (error) {
164
+ console.error('Error saving model metadata:', error);
165
+ res.status(500).json({ error: error.message });
166
+ }
167
+ }
168
+
169
+ // POST /api/settings/model-settings - 保存模型设置
170
+ router.post('/model-settings', handleSaveModelSettings);
171
+ // backward compatibility
172
+ router.post('/model-metadata', handleSaveModelSettings);
173
+
174
+ // DELETE /api/settings/model-metadata/:modelId - 删除单个模型覆盖项(恢复内置默认值)
175
+ function handleDeleteModelOverride(req, res) {
176
+ try {
177
+ const modelId = decodeURIComponent(req.params.modelId);
178
+ const config = loadConfig();
179
+ const overrides = { ...(config.modelMetadataOverrides || {}) };
180
+ delete overrides[modelId];
181
+
182
+ const newConfig = {
183
+ ...config,
184
+ projectsDir: config.projectsDir.replace(require('os').homedir(), '~'),
185
+ modelMetadataOverrides: overrides
186
+ };
187
+ saveConfig(newConfig);
188
+
189
+ res.json({ success: true, modelId });
190
+ } catch (error) {
191
+ console.error('Error deleting model metadata override:', error);
192
+ res.status(500).json({ error: error.message });
193
+ }
194
+ }
195
+
196
+ router.delete('/model-settings/:modelId', handleDeleteModelOverride);
197
+ router.delete('/model-metadata/:modelId', handleDeleteModelOverride);
198
+
61
199
  module.exports = router;
@@ -567,48 +567,4 @@ router.put('/:directory/file/*', (req, res) => {
567
567
  }
568
568
  });
569
569
 
570
- // ==================== 格式转换 API ====================
571
-
572
- /**
573
- * 转换技能格式
574
- * POST /api/skills/convert
575
- * Body: { content, targetFormat }
576
- * - content: 技能内容
577
- * - targetFormat: 目标格式 ('claude' | 'codex')
578
- */
579
- router.post('/convert', (req, res) => {
580
- try {
581
- const { platform, service } = getSkillService(req);
582
- const { content, targetFormat } = req.body;
583
-
584
- if (!content) {
585
- return res.status(400).json({
586
- success: false,
587
- message: '请提供技能内容'
588
- });
589
- }
590
-
591
- if (!['claude', 'codex'].includes(targetFormat)) {
592
- return res.status(400).json({
593
- success: false,
594
- message: '目标格式必须是 claude 或 codex'
595
- });
596
- }
597
-
598
- const result = service.convertSkillFormat(content, targetFormat);
599
-
600
- res.json({
601
- success: true,
602
- platform,
603
- ...result
604
- });
605
- } catch (err) {
606
- console.error('[Skills API] Convert skill error:', err);
607
- res.status(500).json({
608
- success: false,
609
- message: err.message
610
- });
611
- }
612
- });
613
-
614
570
  module.exports = router;
@@ -1,6 +1,6 @@
1
1
  const express = require('express');
2
2
  const router = express.Router();
3
- const { getStatistics, getDailyStatistics, getTodayStatistics } = require('../services/statistics-service');
3
+ const { getStatistics, getDailyStatistics, getTodayStatistics, getTrendStatistics } = require('../services/statistics-service');
4
4
 
5
5
  /**
6
6
  * 获取总体统计数据
@@ -88,4 +88,118 @@ router.get('/recent', (req, res) => {
88
88
  }
89
89
  });
90
90
 
91
+ /**
92
+ * 获取趋势统计数据
93
+ * GET /api/statistics/trend
94
+ *
95
+ * @query {string} startDate - YYYY-MM-DD (required)
96
+ * @query {string} endDate - YYYY-MM-DD (required)
97
+ * @query {string} granularity - 'day' | 'hour' (default: 'day')
98
+ * @query {string} groupBy - 'model' | 'channel' | 'toolType' (default: 'model')
99
+ * @query {string} metric - 'tokens' | 'cost' | 'requests' (default: 'tokens')
100
+ */
101
+ router.get('/trend', async (req, res) => {
102
+ try {
103
+ const { startDate, endDate, granularity = 'day', step = 1, groupBy = 'model', metric = 'tokens' } = req.query;
104
+
105
+ if (!startDate || !endDate) {
106
+ return res.status(400).json({ error: 'startDate and endDate are required' });
107
+ }
108
+
109
+ const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
110
+ if (!dateRegex.test(startDate) || !dateRegex.test(endDate)) {
111
+ return res.status(400).json({ error: 'Invalid date format. Expected YYYY-MM-DD' });
112
+ }
113
+
114
+ const start = new Date(startDate);
115
+ const end = new Date(endDate);
116
+ const diffDays = Math.round((end - start) / (1000 * 60 * 60 * 24)) + 1;
117
+
118
+ if (diffDays < 1) {
119
+ return res.status(400).json({ error: 'endDate must be >= startDate' });
120
+ }
121
+
122
+ if (granularity === 'hour' && diffDays > 7) {
123
+ return res.status(400).json({ error: 'Hour granularity is limited to 7 days' });
124
+ }
125
+
126
+ if (diffDays > 90) {
127
+ return res.status(400).json({ error: 'Date range cannot exceed 90 days' });
128
+ }
129
+
130
+ const result = await getTrendStatistics({ startDate, endDate, granularity, step, groupBy, metric });
131
+ res.json({ success: true, ...result });
132
+ } catch (error) {
133
+ console.error('Failed to get trend statistics:', error);
134
+ res.status(500).json({ error: 'Failed to get trend statistics' });
135
+ }
136
+ });
137
+
138
+ /**
139
+ * 导出趋势统计数据
140
+ * GET /api/statistics/trend/export
141
+ *
142
+ * @query {string} startDate - YYYY-MM-DD (required)
143
+ * @query {string} endDate - YYYY-MM-DD (required)
144
+ * @query {string} granularity - 'day' | 'hour' (default: 'day')
145
+ * @query {string} groupBy - 'model' | 'channel' | 'toolType' (default: 'model')
146
+ * @query {string} metric - 'tokens' | 'cost' | 'requests' (default: 'tokens')
147
+ * @query {string} format - 'csv' | 'json' (default: 'csv')
148
+ */
149
+ router.get('/trend/export', async (req, res) => {
150
+ try {
151
+ const { startDate, endDate, granularity = 'day', step = 1, groupBy = 'model', metric = 'tokens', format = 'csv' } = req.query;
152
+
153
+ if (!startDate || !endDate) {
154
+ return res.status(400).json({ error: 'startDate and endDate are required' });
155
+ }
156
+
157
+ const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
158
+ if (!dateRegex.test(startDate) || !dateRegex.test(endDate)) {
159
+ return res.status(400).json({ error: 'Invalid date format. Expected YYYY-MM-DD' });
160
+ }
161
+
162
+ const start = new Date(startDate);
163
+ const end = new Date(endDate);
164
+ const diffDays = Math.round((end - start) / (1000 * 60 * 60 * 24)) + 1;
165
+
166
+ if (diffDays < 1) {
167
+ return res.status(400).json({ error: 'endDate must be >= startDate' });
168
+ }
169
+
170
+ if (granularity === 'hour' && diffDays > 7) {
171
+ return res.status(400).json({ error: 'Hour granularity is limited to 7 days' });
172
+ }
173
+
174
+ if (diffDays > 90) {
175
+ return res.status(400).json({ error: 'Date range cannot exceed 90 days' });
176
+ }
177
+
178
+ const result = await getTrendStatistics({ startDate, endDate, granularity, step, groupBy, metric });
179
+
180
+ const filename = `analytics-${startDate}-${endDate}-${groupBy}-${metric}.${format}`;
181
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
182
+
183
+ if (format === 'json') {
184
+ res.setHeader('Content-Type', 'application/json');
185
+ return res.json(result);
186
+ }
187
+
188
+ // CSV format
189
+ res.setHeader('Content-Type', 'text/csv; charset=utf-8');
190
+ const seriesNames = result.series.map(s => s.name);
191
+ const header = ['Date/Time', ...seriesNames, 'Total'].join(',');
192
+ const rows = result.labels.map((label, i) => {
193
+ const values = result.series.map(s => s.data[i] || 0);
194
+ const total = values.reduce((a, b) => a + b, 0);
195
+ return [label, ...values, total].join(',');
196
+ });
197
+
198
+ res.send([header, ...rows].join('\n'));
199
+ } catch (error) {
200
+ console.error('Failed to export trend statistics:', error);
201
+ res.status(500).json({ error: 'Failed to export trend statistics' });
202
+ }
203
+ });
204
+
91
205
  module.exports = router;