coding-tool-x 3.2.0

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 (185) hide show
  1. package/CHANGELOG.md +599 -0
  2. package/LICENSE +21 -0
  3. package/README.md +439 -0
  4. package/bin/ctx.js +8 -0
  5. package/dist/web/assets/Analytics-DN_YsnkW.js +39 -0
  6. package/dist/web/assets/Analytics-DuYvId7u.css +1 -0
  7. package/dist/web/assets/ConfigTemplates-Bidwfdf2.css +1 -0
  8. package/dist/web/assets/ConfigTemplates-DpXIMy0p.js +1 -0
  9. package/dist/web/assets/Home-38JTUlYt.js +1 -0
  10. package/dist/web/assets/Home-CjupSEWE.css +1 -0
  11. package/dist/web/assets/PluginManager-CX2tgq2H.js +1 -0
  12. package/dist/web/assets/PluginManager-ROyoZ-6m.css +1 -0
  13. package/dist/web/assets/ProjectList-C1lDcsn6.js +1 -0
  14. package/dist/web/assets/ProjectList-oJIyIRkP.css +1 -0
  15. package/dist/web/assets/SessionList-C55tjV7i.css +1 -0
  16. package/dist/web/assets/SessionList-CZ7T6rVx.js +1 -0
  17. package/dist/web/assets/SkillManager-D7pd-d_P.css +1 -0
  18. package/dist/web/assets/SkillManager-DLN9f79y.js +1 -0
  19. package/dist/web/assets/WorkspaceManager-CrwgQgmP.css +1 -0
  20. package/dist/web/assets/WorkspaceManager-DxlHZkpZ.js +1 -0
  21. package/dist/web/assets/icons-DRrXwWZi.js +1 -0
  22. package/dist/web/assets/index-CetESrXw.css +1 -0
  23. package/dist/web/assets/index-Cfvn-2Gb.js +2 -0
  24. package/dist/web/assets/markdown-BfC0goYb.css +10 -0
  25. package/dist/web/assets/markdown-C9MYpaSi.js +1 -0
  26. package/dist/web/assets/naive-ui-DlpKk-8M.js +1 -0
  27. package/dist/web/assets/vendors-DMjSfzlv.js +7 -0
  28. package/dist/web/assets/vue-vendor-DET08QYg.js +45 -0
  29. package/dist/web/favicon.ico +0 -0
  30. package/dist/web/index.html +20 -0
  31. package/dist/web/logo.png +0 -0
  32. package/docs/bannel.png +0 -0
  33. package/docs/home.png +0 -0
  34. package/docs/logo.png +0 -0
  35. package/docs/model-redirection.md +251 -0
  36. package/docs/multi-channel-load-balancing.md +249 -0
  37. package/package.json +80 -0
  38. package/src/commands/channels.js +551 -0
  39. package/src/commands/cli-type.js +101 -0
  40. package/src/commands/daemon.js +365 -0
  41. package/src/commands/doctor.js +333 -0
  42. package/src/commands/export-config.js +205 -0
  43. package/src/commands/list.js +222 -0
  44. package/src/commands/logs.js +261 -0
  45. package/src/commands/plugin.js +585 -0
  46. package/src/commands/port-config.js +135 -0
  47. package/src/commands/proxy-control.js +264 -0
  48. package/src/commands/proxy.js +152 -0
  49. package/src/commands/resume.js +137 -0
  50. package/src/commands/search.js +190 -0
  51. package/src/commands/security.js +37 -0
  52. package/src/commands/stats.js +398 -0
  53. package/src/commands/switch.js +48 -0
  54. package/src/commands/toggle-proxy.js +247 -0
  55. package/src/commands/ui.js +99 -0
  56. package/src/commands/update.js +97 -0
  57. package/src/commands/workspace.js +454 -0
  58. package/src/config/default.js +69 -0
  59. package/src/config/loader.js +149 -0
  60. package/src/config/model-metadata.js +167 -0
  61. package/src/config/model-metadata.json +125 -0
  62. package/src/config/model-pricing.js +35 -0
  63. package/src/config/paths.js +190 -0
  64. package/src/index.js +680 -0
  65. package/src/plugins/constants.js +15 -0
  66. package/src/plugins/event-bus.js +54 -0
  67. package/src/plugins/manifest-validator.js +129 -0
  68. package/src/plugins/plugin-api.js +128 -0
  69. package/src/plugins/plugin-installer.js +601 -0
  70. package/src/plugins/plugin-loader.js +229 -0
  71. package/src/plugins/plugin-manager.js +170 -0
  72. package/src/plugins/registry.js +152 -0
  73. package/src/plugins/schema/plugin-manifest.json +115 -0
  74. package/src/reset-config.js +94 -0
  75. package/src/server/api/agents.js +826 -0
  76. package/src/server/api/aliases.js +36 -0
  77. package/src/server/api/channels.js +368 -0
  78. package/src/server/api/claude-hooks.js +480 -0
  79. package/src/server/api/codex-channels.js +417 -0
  80. package/src/server/api/codex-projects.js +104 -0
  81. package/src/server/api/codex-proxy.js +195 -0
  82. package/src/server/api/codex-sessions.js +483 -0
  83. package/src/server/api/codex-statistics.js +57 -0
  84. package/src/server/api/commands.js +482 -0
  85. package/src/server/api/config-export.js +212 -0
  86. package/src/server/api/config-registry.js +357 -0
  87. package/src/server/api/config-sync.js +155 -0
  88. package/src/server/api/config-templates.js +248 -0
  89. package/src/server/api/config.js +521 -0
  90. package/src/server/api/convert.js +260 -0
  91. package/src/server/api/dashboard.js +142 -0
  92. package/src/server/api/env.js +144 -0
  93. package/src/server/api/favorites.js +77 -0
  94. package/src/server/api/gemini-channels.js +366 -0
  95. package/src/server/api/gemini-projects.js +91 -0
  96. package/src/server/api/gemini-proxy.js +173 -0
  97. package/src/server/api/gemini-sessions.js +376 -0
  98. package/src/server/api/gemini-statistics.js +57 -0
  99. package/src/server/api/health-check.js +31 -0
  100. package/src/server/api/mcp.js +399 -0
  101. package/src/server/api/opencode-channels.js +419 -0
  102. package/src/server/api/opencode-projects.js +99 -0
  103. package/src/server/api/opencode-proxy.js +207 -0
  104. package/src/server/api/opencode-sessions.js +327 -0
  105. package/src/server/api/opencode-statistics.js +57 -0
  106. package/src/server/api/plugins.js +463 -0
  107. package/src/server/api/pm2-autostart.js +269 -0
  108. package/src/server/api/projects.js +124 -0
  109. package/src/server/api/prompts.js +279 -0
  110. package/src/server/api/proxy.js +306 -0
  111. package/src/server/api/security.js +53 -0
  112. package/src/server/api/sessions.js +514 -0
  113. package/src/server/api/settings.js +142 -0
  114. package/src/server/api/skills.js +570 -0
  115. package/src/server/api/statistics.js +238 -0
  116. package/src/server/api/ui-config.js +64 -0
  117. package/src/server/api/workspaces.js +456 -0
  118. package/src/server/codex-proxy-server.js +681 -0
  119. package/src/server/dev-server.js +26 -0
  120. package/src/server/gemini-proxy-server.js +610 -0
  121. package/src/server/index.js +422 -0
  122. package/src/server/opencode-proxy-server.js +4771 -0
  123. package/src/server/proxy-server.js +669 -0
  124. package/src/server/services/agents-service.js +1137 -0
  125. package/src/server/services/alias.js +71 -0
  126. package/src/server/services/channel-health.js +234 -0
  127. package/src/server/services/channel-scheduler.js +240 -0
  128. package/src/server/services/channels.js +447 -0
  129. package/src/server/services/codex-channels.js +705 -0
  130. package/src/server/services/codex-config.js +90 -0
  131. package/src/server/services/codex-parser.js +322 -0
  132. package/src/server/services/codex-sessions.js +936 -0
  133. package/src/server/services/codex-settings-manager.js +619 -0
  134. package/src/server/services/codex-speed-test-template.json +24 -0
  135. package/src/server/services/codex-statistics-service.js +161 -0
  136. package/src/server/services/commands-service.js +574 -0
  137. package/src/server/services/config-export-service.js +1165 -0
  138. package/src/server/services/config-registry-service.js +828 -0
  139. package/src/server/services/config-sync-manager.js +941 -0
  140. package/src/server/services/config-sync-service.js +504 -0
  141. package/src/server/services/config-templates-service.js +913 -0
  142. package/src/server/services/enhanced-cache.js +196 -0
  143. package/src/server/services/env-checker.js +409 -0
  144. package/src/server/services/env-manager.js +436 -0
  145. package/src/server/services/favorites.js +165 -0
  146. package/src/server/services/format-converter.js +620 -0
  147. package/src/server/services/gemini-channels.js +459 -0
  148. package/src/server/services/gemini-config.js +73 -0
  149. package/src/server/services/gemini-sessions.js +689 -0
  150. package/src/server/services/gemini-settings-manager.js +263 -0
  151. package/src/server/services/gemini-statistics-service.js +157 -0
  152. package/src/server/services/health-check.js +85 -0
  153. package/src/server/services/mcp-client.js +790 -0
  154. package/src/server/services/mcp-service.js +1732 -0
  155. package/src/server/services/model-detector.js +1245 -0
  156. package/src/server/services/network-access.js +80 -0
  157. package/src/server/services/opencode-channels.js +366 -0
  158. package/src/server/services/opencode-gateway-adapters.js +1168 -0
  159. package/src/server/services/opencode-gateway-converter.js +639 -0
  160. package/src/server/services/opencode-sessions.js +931 -0
  161. package/src/server/services/opencode-settings-manager.js +478 -0
  162. package/src/server/services/opencode-statistics-service.js +161 -0
  163. package/src/server/services/plugins-service.js +1268 -0
  164. package/src/server/services/prompts-service.js +534 -0
  165. package/src/server/services/proxy-runtime.js +79 -0
  166. package/src/server/services/repo-scanner-base.js +708 -0
  167. package/src/server/services/request-logger.js +130 -0
  168. package/src/server/services/response-decoder.js +21 -0
  169. package/src/server/services/security-config.js +131 -0
  170. package/src/server/services/session-cache.js +127 -0
  171. package/src/server/services/session-converter.js +577 -0
  172. package/src/server/services/sessions.js +900 -0
  173. package/src/server/services/settings-manager.js +163 -0
  174. package/src/server/services/skill-service.js +1482 -0
  175. package/src/server/services/speed-test.js +1146 -0
  176. package/src/server/services/statistics-service.js +1043 -0
  177. package/src/server/services/ui-config.js +132 -0
  178. package/src/server/services/workspace-service.js +830 -0
  179. package/src/server/utils/pricing.js +73 -0
  180. package/src/server/websocket-server.js +513 -0
  181. package/src/ui/menu.js +139 -0
  182. package/src/ui/prompts.js +100 -0
  183. package/src/utils/format.js +43 -0
  184. package/src/utils/port-helper.js +108 -0
  185. package/src/utils/session.js +240 -0
@@ -0,0 +1,514 @@
1
+ const express = require('express');
2
+ const router = express.Router();
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const readline = require('readline');
7
+ const { getSessionsForProject, deleteSession, forkSession, saveSessionOrder, parseRealProjectPath, searchSessions, getRecentSessions, searchSessionsAcrossProjects, hasActualMessages } = require('../services/sessions');
8
+ const { loadAliases } = require('../services/alias');
9
+ const { broadcastLog } = require('../websocket-server');
10
+
11
+ module.exports = (config) => {
12
+ // GET /api/sessions/search/global - Search sessions across all projects
13
+ router.get('/search/global', async (req, res) => {
14
+ try {
15
+ const { keyword, context } = req.query;
16
+
17
+ if (!keyword) {
18
+ return res.status(400).json({ error: 'Keyword is required' });
19
+ }
20
+
21
+ const contextLength = context ? parseInt(context) : 35;
22
+ const results = await searchSessionsAcrossProjects(config, keyword, contextLength);
23
+
24
+ res.json({
25
+ keyword,
26
+ totalMatches: results.reduce((sum, r) => sum + r.matchCount, 0),
27
+ sessions: results
28
+ });
29
+ } catch (error) {
30
+ console.error('Error searching sessions globally:', error);
31
+ res.status(500).json({ error: error.message });
32
+ }
33
+ });
34
+
35
+ // GET /api/sessions/recent - Get recent sessions across all projects
36
+ router.get('/recent/list', async (req, res) => {
37
+ try {
38
+ const limit = parseInt(req.query.limit) || 5;
39
+ const sessions = await getRecentSessions(config, limit);
40
+ res.json({ sessions });
41
+ } catch (error) {
42
+ console.error('Error fetching recent sessions:', error);
43
+ res.status(500).json({ error: error.message });
44
+ }
45
+ });
46
+
47
+ // GET /api/sessions/:projectName - Get sessions for a project
48
+ router.get('/:projectName', async (req, res) => {
49
+ try {
50
+ const { projectName } = req.params;
51
+ const result = await getSessionsForProject(config, projectName);
52
+ const aliases = loadAliases();
53
+
54
+ // Parse project path info
55
+ const { fullPath, projectName: displayName } = parseRealProjectPath(projectName);
56
+
57
+ res.json({
58
+ sessions: result.sessions,
59
+ totalSize: result.totalSize,
60
+ aliases,
61
+ projectInfo: {
62
+ name: projectName,
63
+ displayName,
64
+ fullPath
65
+ }
66
+ });
67
+ } catch (error) {
68
+ console.error('Error fetching sessions:', error);
69
+ res.status(500).json({ error: error.message });
70
+ }
71
+ });
72
+
73
+ // DELETE /api/sessions/:projectName/:sessionId - Delete a session
74
+ router.delete('/:projectName/:sessionId', (req, res) => {
75
+ try {
76
+ const { projectName, sessionId } = req.params;
77
+ const result = deleteSession(config, projectName, sessionId);
78
+ res.json(result);
79
+ } catch (error) {
80
+ console.error('Error deleting session:', error);
81
+ res.status(500).json({ error: error.message });
82
+ }
83
+ });
84
+
85
+ // POST /api/sessions/:projectName/:sessionId/fork - Fork a session
86
+ router.post('/:projectName/:sessionId/fork', (req, res) => {
87
+ try {
88
+ const { projectName, sessionId } = req.params;
89
+ const result = forkSession(config, projectName, sessionId);
90
+ res.json(result);
91
+ } catch (error) {
92
+ console.error('Error forking session:', error);
93
+ res.status(500).json({ error: error.message });
94
+ }
95
+ });
96
+
97
+ // POST /api/sessions/:projectName/create - Create a new session
98
+ router.post('/:projectName/create', (req, res) => {
99
+ try {
100
+ const { projectName } = req.params;
101
+ const { toolType = 'claude' } = req.body; // 'claude', 'codex', 或 'gemini'
102
+ const crypto = require('crypto');
103
+
104
+ // 解析项目路径
105
+ const { fullPath } = parseRealProjectPath(projectName);
106
+
107
+ // 生成新的 session ID
108
+ const newSessionId = crypto.randomUUID();
109
+
110
+ // 根据工具类型决定会话文件路径
111
+ let sessionDir, sessionFile;
112
+
113
+ if (toolType === 'claude') {
114
+ // Claude Code: 直接创建在项目的 .claude/sessions/ 目录(与 Claude Code 默认行为一致)
115
+ sessionDir = path.join(fullPath, '.claude', 'sessions');
116
+ sessionFile = path.join(sessionDir, `${newSessionId}.jsonl`);
117
+ } else if (toolType === 'codex') {
118
+ // Codex: ~/.codex/sessions/YYYY/MM/DD/{sessionId}.jsonl
119
+ const now = new Date();
120
+ const year = now.getFullYear();
121
+ const month = String(now.getMonth() + 1).padStart(2, '0');
122
+ const day = String(now.getDate()).padStart(2, '0');
123
+ sessionDir = path.join(os.homedir(), '.codex', 'sessions', String(year), month, day);
124
+ sessionFile = path.join(sessionDir, `${newSessionId}.jsonl`);
125
+ } else if (toolType === 'gemini') {
126
+ // Gemini: ~/.gemini/tmp/{hash}/chats/{sessionId}.json
127
+ const pathHash = crypto.createHash('sha256').update(fullPath).digest('hex');
128
+ sessionDir = path.join(os.homedir(), '.gemini', 'tmp', pathHash, 'chats');
129
+ sessionFile = path.join(sessionDir, `${newSessionId}.json`);
130
+ } else {
131
+ return res.status(400).json({ error: 'Invalid toolType. Must be claude, codex, or gemini' });
132
+ }
133
+
134
+ // 确保目录存在
135
+ if (!fs.existsSync(sessionDir)) {
136
+ fs.mkdirSync(sessionDir, { recursive: true });
137
+ }
138
+
139
+ // 创建初始化会话文件
140
+ const timestamp = new Date().toISOString();
141
+ let initialContent;
142
+
143
+ if (toolType === 'gemini') {
144
+ // Gemini 使用 JSON 格式
145
+ initialContent = JSON.stringify({
146
+ id: newSessionId,
147
+ projectPath: fullPath,
148
+ createdAt: timestamp,
149
+ messages: []
150
+ }, null, 2);
151
+ } else {
152
+ // Claude 和 Codex 使用 JSONL 格式
153
+ const metadata = {
154
+ type: 'metadata',
155
+ cwd: fullPath,
156
+ gitBranch: null,
157
+ timestamp: timestamp
158
+ };
159
+ initialContent = JSON.stringify(metadata) + '\n';
160
+ }
161
+
162
+ fs.writeFileSync(sessionFile, initialContent, 'utf8');
163
+
164
+ // 广播日志
165
+ broadcastLog({
166
+ type: 'action',
167
+ action: 'create_session',
168
+ message: `创建新会话: ${newSessionId.substring(0, 8)} (${toolType})`,
169
+ sessionId: newSessionId,
170
+ tool: toolType,
171
+ timestamp: Date.now()
172
+ });
173
+
174
+ res.json({
175
+ success: true,
176
+ sessionId: newSessionId,
177
+ sessionFile,
178
+ toolType,
179
+ projectName
180
+ });
181
+ } catch (error) {
182
+ console.error('Error creating session:', error);
183
+ res.status(500).json({ error: error.message });
184
+ }
185
+ });
186
+
187
+ // POST /api/sessions/:projectName/order - Save session order
188
+ router.post('/:projectName/order', (req, res) => {
189
+ try {
190
+ const { projectName } = req.params;
191
+ const { order } = req.body;
192
+ if (!Array.isArray(order)) {
193
+ return res.status(400).json({ error: 'order must be an array' });
194
+ }
195
+ saveSessionOrder(projectName, order);
196
+ res.json({ success: true });
197
+ } catch (error) {
198
+ console.error('Error saving session order:', error);
199
+ res.status(500).json({ error: error.message });
200
+ }
201
+ });
202
+
203
+ // GET /api/sessions/:projectName/search - Search sessions content
204
+ router.get('/:projectName/search', (req, res) => {
205
+ try {
206
+ const { projectName } = req.params;
207
+ const { keyword, context } = req.query;
208
+
209
+ if (!keyword) {
210
+ return res.status(400).json({ error: 'Keyword is required' });
211
+ }
212
+
213
+ const contextLength = context ? parseInt(context) : 15;
214
+ const results = searchSessions(config, projectName, keyword, contextLength);
215
+
216
+ res.json({
217
+ keyword,
218
+ totalMatches: results.reduce((sum, r) => sum + r.matchCount, 0),
219
+ sessions: results
220
+ });
221
+ } catch (error) {
222
+ console.error('Error searching sessions:', error);
223
+ res.status(500).json({ error: error.message });
224
+ }
225
+ });
226
+
227
+ // GET /api/sessions/:projectName/:sessionId/messages - Get session messages with pagination
228
+ router.get('/:projectName/:sessionId/messages', async (req, res) => {
229
+ try {
230
+ const { projectName, sessionId } = req.params;
231
+ const { page = 1, limit = 20, order = 'desc' } = req.query;
232
+
233
+ console.log(`[Messages API] Request for ${projectName}/${sessionId}, page=${page}, limit=${limit}`);
234
+
235
+ const pageNum = parseInt(page);
236
+ const limitNum = parseInt(limit);
237
+
238
+ // Parse real project path
239
+ const { fullPath } = parseRealProjectPath(projectName);
240
+ console.log(`[Messages API] Parsed project path: ${fullPath}`);
241
+
242
+ // Try to find session file
243
+ let sessionFile = null;
244
+ const possiblePaths = [
245
+ path.join(fullPath, '.claude', 'sessions', sessionId + '.jsonl'),
246
+ path.join(os.homedir(), '.claude', 'projects', projectName, sessionId + '.jsonl')
247
+ ];
248
+
249
+ console.log(`[Messages API] Trying paths:`, possiblePaths);
250
+
251
+ for (const testPath of possiblePaths) {
252
+ if (fs.existsSync(testPath)) {
253
+ sessionFile = testPath;
254
+ console.log(`[Messages API] Found session file: ${sessionFile}`);
255
+ break;
256
+ }
257
+ }
258
+
259
+ if (!sessionFile) {
260
+ console.error(`[Messages API] Session file not found for: ${sessionId}`);
261
+ return res.status(404).json({
262
+ error: `Session file not found: ${sessionId}`,
263
+ triedPaths: possiblePaths
264
+ });
265
+ }
266
+
267
+ // Check if session has actual messages (not just file-history-snapshots)
268
+ if (!hasActualMessages(sessionFile)) {
269
+ console.warn(`[Messages API] Session ${sessionId} has no actual messages (only file-history-snapshots)`);
270
+ return res.status(404).json({
271
+ error: `Session has no conversation messages: ${sessionId}`,
272
+ reason: 'This session contains only file history snapshots, not actual conversation data'
273
+ });
274
+ }
275
+
276
+ // Read and parse session file
277
+ const allMessages = [];
278
+ const metadata = {};
279
+
280
+ const stream = fs.createReadStream(sessionFile, { encoding: 'utf8' });
281
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
282
+
283
+ try {
284
+ for await (const line of rl) {
285
+ if (!line.trim()) continue;
286
+ try {
287
+ const json = JSON.parse(line);
288
+
289
+ if (json.type === 'summary' && json.summary) {
290
+ metadata.summary = json.summary;
291
+ }
292
+ if (json.gitBranch) {
293
+ metadata.gitBranch = json.gitBranch;
294
+ }
295
+ if (json.cwd) {
296
+ metadata.cwd = json.cwd;
297
+ }
298
+
299
+ if (json.type === 'user' || json.type === 'assistant') {
300
+ const message = {
301
+ type: json.type,
302
+ content: null,
303
+ timestamp: json.timestamp || null,
304
+ model: json.model || null
305
+ };
306
+
307
+ if (json.type === 'user') {
308
+ if (typeof json.message?.content === 'string') {
309
+ message.content = json.message.content;
310
+ } else if (Array.isArray(json.message?.content)) {
311
+ const parts = [];
312
+ for (const item of json.message.content) {
313
+ if (item.type === 'text' && item.text) {
314
+ parts.push(item.text);
315
+ } else if (item.type === 'tool_result') {
316
+ const resultContent = typeof item.content === 'string'
317
+ ? item.content
318
+ : JSON.stringify(item.content, null, 2);
319
+ parts.push(`**[工具结果]**\n\`\`\`\n${resultContent}\n\`\`\``);
320
+ } else if (item.type === 'image') {
321
+ parts.push('[图片]');
322
+ }
323
+ }
324
+ message.content = parts.join('\n\n') || '[工具交互]';
325
+ }
326
+ } else if (json.type === 'assistant') {
327
+ if (Array.isArray(json.message?.content)) {
328
+ const parts = [];
329
+ for (const item of json.message.content) {
330
+ if (item.type === 'text' && item.text) {
331
+ parts.push(item.text);
332
+ } else if (item.type === 'tool_use') {
333
+ const inputStr = JSON.stringify(item.input, null, 2);
334
+ parts.push(`**[调用工具: ${item.name}]**\n\`\`\`json\n${inputStr}\n\`\`\``);
335
+ } else if (item.type === 'thinking' && item.thinking) {
336
+ parts.push(`**[思考]**\n${item.thinking}`);
337
+ }
338
+ }
339
+ message.content = parts.join('\n\n') || '[处理中...]';
340
+ } else if (typeof json.message?.content === 'string') {
341
+ message.content = json.message.content;
342
+ }
343
+ }
344
+
345
+ if (message.content && message.content !== 'Warmup') {
346
+ allMessages.push(message);
347
+ }
348
+ }
349
+ } catch (err) {
350
+ // Skip invalid lines
351
+ }
352
+ }
353
+ } finally {
354
+ rl.close();
355
+ stream.destroy();
356
+ }
357
+
358
+ // Sort messages (desc = newest first)
359
+ if (order === 'desc') {
360
+ allMessages.reverse();
361
+ }
362
+
363
+ console.log(`[Messages API] Parsed ${allMessages.length} total messages`);
364
+
365
+ // Pagination
366
+ const total = allMessages.length;
367
+ const startIndex = (pageNum - 1) * limitNum;
368
+ const endIndex = startIndex + limitNum;
369
+ const messages = allMessages.slice(startIndex, endIndex);
370
+ const hasMore = endIndex < total;
371
+
372
+ console.log(`[Messages API] Returning ${messages.length} messages (page ${pageNum}, total ${total})`);
373
+
374
+ res.json({
375
+ messages,
376
+ metadata,
377
+ pagination: {
378
+ page: pageNum,
379
+ limit: limitNum,
380
+ total,
381
+ hasMore
382
+ }
383
+ });
384
+ } catch (error) {
385
+ console.error('Error fetching session messages:', error);
386
+ res.status(500).json({ error: error.message });
387
+ }
388
+ });
389
+
390
+ // POST /api/sessions/:projectName/:sessionId/launch - Return session launch command for copy
391
+ router.post('/:projectName/:sessionId/launch', async (req, res) => {
392
+ try {
393
+ const { projectName, sessionId } = req.params;
394
+ const path = require('path');
395
+ const fs = require('fs');
396
+ const os = require('os');
397
+
398
+ // Parse real project path (important for cross-project sessions)
399
+ const { fullPath } = parseRealProjectPath(projectName);
400
+
401
+ const projectSessionsDir = path.join(fullPath, '.claude', 'sessions');
402
+ const projectSessionFile = path.join(projectSessionsDir, sessionId + '.jsonl');
403
+
404
+ // Try to find session file in multiple possible locations
405
+ let sessionFile = null;
406
+ const possiblePaths = [
407
+ projectSessionFile,
408
+ // Location 2: User's .claude/projects directory (ClaudeCode default)
409
+ path.join(os.homedir(), '.claude', 'projects', projectName, sessionId + '.jsonl')
410
+ ];
411
+
412
+ for (const testPath of possiblePaths) {
413
+ if (fs.existsSync(testPath)) {
414
+ sessionFile = testPath;
415
+ break;
416
+ }
417
+ }
418
+
419
+ // 如果会话只存在于全局目录,则复制到项目的 .claude/sessions 目录,避免 claude -r 找不到文件
420
+ if (sessionFile && sessionFile !== projectSessionFile) {
421
+ try {
422
+ if (!fs.existsSync(projectSessionsDir)) {
423
+ fs.mkdirSync(projectSessionsDir, { recursive: true });
424
+ }
425
+ fs.copyFileSync(sessionFile, projectSessionFile);
426
+ sessionFile = projectSessionFile;
427
+ } catch (copyError) {
428
+ console.warn('Failed to sync session file to project directory:', copyError.message);
429
+ }
430
+ }
431
+
432
+ if (!sessionFile) {
433
+ console.error(`Session file not found in any location for session: ${sessionId}`);
434
+ console.error('Tried paths:', possiblePaths);
435
+ return res.status(404).json({
436
+ error: `No conversation found with session ID: ${sessionId}`,
437
+ details: `Tried locations: ${possiblePaths.join(', ')}`
438
+ });
439
+ }
440
+
441
+ // Extract working directory from session file
442
+ let cwd = fullPath; // Default to project directory
443
+ try {
444
+ const content = fs.readFileSync(sessionFile, 'utf8');
445
+ const firstLine = content.split('\n')[0];
446
+ if (firstLine) {
447
+ const json = JSON.parse(firstLine);
448
+ if (json.cwd) {
449
+ cwd = json.cwd;
450
+ }
451
+ }
452
+ } catch (e) {
453
+ console.warn('Unable to extract cwd from session, using project path:', e.message);
454
+ }
455
+
456
+ // 确保会话文件在 cwd 的 .claude/sessions/ 目录下
457
+ // 这样 claude -r 才能找到文件
458
+ const cwdSessionsDir = path.join(cwd, '.claude', 'sessions');
459
+ const cwdSessionFile = path.join(cwdSessionsDir, sessionId + '.jsonl');
460
+
461
+ // 如果会话文件不在 cwd 的 sessions 目录,复制过去
462
+ if (sessionFile !== cwdSessionFile && !fs.existsSync(cwdSessionFile)) {
463
+ try {
464
+ if (!fs.existsSync(cwdSessionsDir)) {
465
+ fs.mkdirSync(cwdSessionsDir, { recursive: true });
466
+ }
467
+ fs.copyFileSync(sessionFile, cwdSessionFile);
468
+ console.log(`[Launch] Copied session to cwd: ${cwdSessionFile}`);
469
+ } catch (copyError) {
470
+ console.warn('[Launch] Failed to copy session file to cwd:', copyError.message);
471
+ // 如果复制失败,尝试更新 cwd 为项目目录
472
+ if (fs.existsSync(projectSessionsDir)) {
473
+ cwd = fullPath;
474
+ console.log(`[Launch] Fallback to project directory: ${cwd}`);
475
+ }
476
+ }
477
+ }
478
+
479
+ // Get alias
480
+ const aliases = loadAliases();
481
+ const alias = aliases[sessionId];
482
+
483
+ // 广播行为日志
484
+ broadcastLog({
485
+ type: 'action',
486
+ action: 'launch_session',
487
+ message: `复制会话启动命令 ${alias || sessionId.substring(0, 8)} (claude)`,
488
+ sessionId,
489
+ alias: alias || null,
490
+ tool: 'claude',
491
+ timestamp: Date.now()
492
+ });
493
+
494
+ const command = `claude -r ${sessionId}`;
495
+ const quotedCwd = `"${String(cwd).replace(/"/g, '\\"')}"`;
496
+ const copyCommand = `cd ${quotedCwd} && ${command}`;
497
+
498
+ res.json({
499
+ success: true,
500
+ cwd,
501
+ sessionFile,
502
+ sessionId,
503
+ tool: 'claude',
504
+ command,
505
+ copyCommand
506
+ });
507
+ } catch (error) {
508
+ console.error('Error preparing launch command:', error);
509
+ res.status(500).json({ error: error.message });
510
+ }
511
+ });
512
+
513
+ return router;
514
+ };
@@ -0,0 +1,142 @@
1
+ const express = require('express');
2
+ const router = express.Router();
3
+ const {
4
+ MODEL_METADATA,
5
+ METADATA_LAST_UPDATED,
6
+ getDefaultSpeedTestModels,
7
+ saveDefaultSpeedTestModels
8
+ } = require('../../config/model-metadata');
9
+ const { loadConfig, saveConfig } = require('../../config/loader');
10
+
11
+ function handleGetModelSettings(req, res) {
12
+ try {
13
+ const config = loadConfig();
14
+ const overrides = config.modelMetadataOverrides || {};
15
+ const defaultSpeedTestModels = getDefaultSpeedTestModels();
16
+
17
+ // Build merged table: built-in + user overrides
18
+ const merged = {};
19
+ for (const [id, meta] of Object.entries(MODEL_METADATA)) {
20
+ merged[id] = overrides[id]
21
+ ? {
22
+ limit: { ...meta.limit, ...(overrides[id].limit || {}) },
23
+ pricing: { ...meta.pricing, ...(overrides[id].pricing || {}) }
24
+ }
25
+ : meta;
26
+ }
27
+
28
+ // Also include any user-added custom models from overrides
29
+ for (const [id, meta] of Object.entries(overrides)) {
30
+ if (!merged[id]) {
31
+ merged[id] = meta;
32
+ }
33
+ }
34
+
35
+ res.json({
36
+ models: merged,
37
+ overrides,
38
+ builtinModelIds: Object.keys(MODEL_METADATA),
39
+ lastUpdated: METADATA_LAST_UPDATED,
40
+ defaultSpeedTestModels
41
+ });
42
+ } catch (error) {
43
+ console.error('Error getting model metadata:', error);
44
+ res.status(500).json({ error: error.message });
45
+ }
46
+ }
47
+
48
+ // GET /api/settings/model-settings - 获取模型设置(元数据 + 默认测速模型)
49
+ router.get('/model-settings', handleGetModelSettings);
50
+ // backward compatibility
51
+ router.get('/model-metadata', handleGetModelSettings);
52
+
53
+ function handleSaveModelSettings(req, res) {
54
+ try {
55
+ const { overrides, defaultSpeedTestModels } = req.body || {};
56
+ if (overrides !== undefined && (typeof overrides !== 'object' || overrides === null || Array.isArray(overrides))) {
57
+ return res.status(400).json({ error: 'overrides must be an object' });
58
+ }
59
+
60
+ // Validate each override entry
61
+ if (overrides && typeof overrides === 'object') {
62
+ for (const [modelId, meta] of Object.entries(overrides)) {
63
+ if (typeof modelId !== 'string' || !modelId.trim()) {
64
+ return res.status(400).json({ error: `Invalid model ID: "${modelId}"` });
65
+ }
66
+ if (meta.limit !== undefined) {
67
+ if (typeof meta.limit !== 'object') {
68
+ return res.status(400).json({ error: `${modelId}: limit must be an object` });
69
+ }
70
+ if (meta.limit.context !== undefined && (typeof meta.limit.context !== 'number' || meta.limit.context <= 0)) {
71
+ return res.status(400).json({ error: `${modelId}: limit.context must be a positive number` });
72
+ }
73
+ if (meta.limit.output !== undefined && (typeof meta.limit.output !== 'number' || meta.limit.output <= 0)) {
74
+ return res.status(400).json({ error: `${modelId}: limit.output must be a positive number` });
75
+ }
76
+ }
77
+ if (meta.pricing !== undefined) {
78
+ if (typeof meta.pricing !== 'object') {
79
+ return res.status(400).json({ error: `${modelId}: pricing must be an object` });
80
+ }
81
+ for (const field of ['input', 'output']) {
82
+ if (meta.pricing[field] !== undefined && (typeof meta.pricing[field] !== 'number' || meta.pricing[field] < 0)) {
83
+ return res.status(400).json({ error: `${modelId}: pricing.${field} must be a non-negative number` });
84
+ }
85
+ }
86
+ }
87
+ }
88
+ }
89
+
90
+ const config = loadConfig();
91
+ const newConfig = {
92
+ ...config,
93
+ projectsDir: config.projectsDir.replace(require('os').homedir(), '~'),
94
+ modelMetadataOverrides: overrides && typeof overrides === 'object'
95
+ ? overrides
96
+ : (config.modelMetadataOverrides || {})
97
+ };
98
+ saveConfig(newConfig);
99
+ const persistedDefaultSpeedTestModels = saveDefaultSpeedTestModels(defaultSpeedTestModels);
100
+
101
+ res.json({
102
+ success: true,
103
+ overrides: newConfig.modelMetadataOverrides,
104
+ defaultSpeedTestModels: persistedDefaultSpeedTestModels
105
+ });
106
+ } catch (error) {
107
+ console.error('Error saving model metadata:', error);
108
+ res.status(500).json({ error: error.message });
109
+ }
110
+ }
111
+
112
+ // POST /api/settings/model-settings - 保存模型设置
113
+ router.post('/model-settings', handleSaveModelSettings);
114
+ // backward compatibility
115
+ router.post('/model-metadata', handleSaveModelSettings);
116
+
117
+ // DELETE /api/settings/model-metadata/:modelId - 删除单个模型覆盖项(恢复内置默认值)
118
+ function handleDeleteModelOverride(req, res) {
119
+ try {
120
+ const modelId = decodeURIComponent(req.params.modelId);
121
+ const config = loadConfig();
122
+ const overrides = { ...(config.modelMetadataOverrides || {}) };
123
+ delete overrides[modelId];
124
+
125
+ const newConfig = {
126
+ ...config,
127
+ projectsDir: config.projectsDir.replace(require('os').homedir(), '~'),
128
+ modelMetadataOverrides: overrides
129
+ };
130
+ saveConfig(newConfig);
131
+
132
+ res.json({ success: true, modelId });
133
+ } catch (error) {
134
+ console.error('Error deleting model metadata override:', error);
135
+ res.status(500).json({ error: error.message });
136
+ }
137
+ }
138
+
139
+ router.delete('/model-settings/:modelId', handleDeleteModelOverride);
140
+ router.delete('/model-metadata/:modelId', handleDeleteModelOverride);
141
+
142
+ module.exports = router;