zen-gitsync 2.15.2 → 2.15.4

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 (33) hide show
  1. package/package.json +2 -1
  2. package/src/aiCommit.js +349 -0
  3. package/src/gitCommit.js +8 -0
  4. package/src/ui/public/assets/{AppVersionBadge-9TpgyH6h.js → AppVersionBadge-DMKhbObL.js} +2 -2
  5. package/src/ui/public/assets/{BranchSelector-C0xJ5O5y.js → BranchSelector-CxPacVCh.js} +1 -1
  6. package/src/ui/public/assets/{CommitForm-DRCkTbcQ.js → CommitForm-CBa1PA_C.js} +1 -1
  7. package/src/ui/public/assets/{ConsoleView-Bi1yhOrF.js → ConsoleView-B7de9rHN.js} +2 -2
  8. package/src/ui/public/assets/CustomCommandManager-1UV6ZLCz.js +1 -0
  9. package/src/ui/public/assets/{EditorView-DNDHHVxO.js → EditorView-P4m33J8s.js} +0 -0
  10. package/src/ui/public/assets/{FlowExecutionViewer-Bd5rDsy-.js → FlowExecutionViewer-DByZ_uHj.js} +1 -1
  11. package/src/ui/public/assets/{FlowOrchestrationWorkspace-BnxWvF7c.js → FlowOrchestrationWorkspace-DdpJ2kNR.js} +1 -1
  12. package/src/ui/public/assets/{LogList-D6GVPxGk.js → LogList-BNCeMiv3.js} +1 -1
  13. package/src/ui/public/assets/{MindmapView-BzVqyW95.js → MindmapView-DO7Vx_mW.js} +1 -1
  14. package/src/ui/public/assets/{MonitorView-BynVSuRY.js → MonitorView-C25GR4cJ.js} +1 -1
  15. package/src/ui/public/assets/{ProjectStartupButton-CMonyvWZ.js → ProjectStartupButton-weoEHkYd.js} +1 -1
  16. package/src/ui/public/assets/{RemoteRepoCard-BJQgSYGu.js → RemoteRepoCard-BVXdefAD.js} +1 -1
  17. package/src/ui/public/assets/{SourceMapView-CNw8GHm_.js → SourceMapView-D1OsjwRH.js} +1 -1
  18. package/src/ui/public/assets/{SvgIcon-CVSYDtNO.js → SvgIcon-B6tujJqO.js} +1 -1
  19. package/src/ui/public/assets/{TemplateManager-D8f0RivS.js → TemplateManager-B_kW5b84.js} +1 -1
  20. package/src/ui/public/assets/{UserInputNode-BpIFJ8Kq.js → UserInputNode-Ct8P-cuc.js} +1 -1
  21. package/src/ui/public/assets/{WorkbenchView-7uXNRGkb.js → WorkbenchView-B7aUVdUR.js} +1 -1
  22. package/src/ui/public/assets/{_plugin-vue_export-helper-DCUswitV.js → _plugin-vue_export-helper-DIn89cdZ.js} +2 -2
  23. package/src/ui/public/assets/configStore-CN0dYgax.js +1 -0
  24. package/src/ui/public/assets/index-2hEmjwdr.js +23 -0
  25. package/src/ui/public/assets/index-DHCO3dbI.css +1 -0
  26. package/src/ui/public/index.html +5 -5
  27. package/src/ui/server/routes/config.js +1224 -1208
  28. package/src/ui/server/routes/fs.js +5 -0
  29. package/src/utils/index.js +6 -2
  30. package/src/ui/public/assets/CustomCommandManager-CCPQ4oG8.js +0 -1
  31. package/src/ui/public/assets/configStore-BEUBCuQ4.js +0 -1
  32. package/src/ui/public/assets/index-BUUVsQTV.js +0 -23
  33. package/src/ui/public/assets/index-BiyC8HRp.css +0 -1
@@ -1,1208 +1,1224 @@
1
- // Copyright 2026 xz333221
2
- //
3
- // Licensed under the Apache License, Version 2.0 (the "License");
4
- // you may not use this file except in compliance with the License.
5
- // You may obtain a copy of the License at
6
- //
7
- // http://www.apache.org/licenses/LICENSE-2.0
8
- //
9
- // Unless required by applicable law or agreed to in writing, software
10
- // distributed under the License is distributed on an "AS IS" BASIS,
11
- // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- // See the License for the specific language governing permissions and
13
- // limitations under the License.
14
- //
15
- import express from 'express';
16
- import { asyncRoute, HttpError } from '../utils/asyncRoute.js';
17
- import path from 'path';
18
- import os from 'os';
19
- import fs from 'fs/promises';
20
- import open from 'open';
21
- import logger from '../utils/logger.js';
22
-
23
- // 跳过的产物/资源/lock 文件(用 stat 一行带过,不打 patch)
24
- const SKIP_FILE_PATTERNS = [
25
- /(^|[\\/])dist[\\/]/,
26
- /(^|[\\/])build[\\/]/,
27
- /(^|[\\/])\.next[\\/]/,
28
- /(^|[\\/])coverage[\\/]/,
29
- /(^|[\\/])node_modules[\\/]/,
30
- /\.lock$/,
31
- /package-lock\.json$/,
32
- /yarn\.lock$/,
33
- /pnpm-lock\.yaml$/,
34
- /min\.js$/,
35
- /min\.css$/,
36
- /\.bundle\.js$/,
37
- /\.map$/,
38
- /\.(png|jpe?g|gif|webp|svg|ico|bmp|tiff)$/,
39
- /\.(mp4|mov|avi|mkv|webm)$/,
40
- /\.(mp3|wav|ogg|flac)$/,
41
- /\.(woff2?|ttf|otf|eot)$/,
42
- /\.(pdf|zip|tar|gz|7z|rar)$/
43
- ]
44
-
45
- // 文件优先级:数字越大越重要
46
- function filePriority(p) {
47
- if (/\.(test|spec)\.(js|ts|jsx|tsx|vue)$/i.test(p)) return 1 // 测试文件
48
- if (/^docs?\//i.test(p) || /\.md$/i.test(p)) return 2 // 文档
49
- if (/\.(json|ya?ml|toml|env|ini|cfg|conf)$/i.test(p)) return 3 // 配置
50
- if (/\.(css|scss|sass|less|html|vue|jsx|tsx)$/i.test(p)) return 4 // 前端
51
- if (/\.(ts|js|mjs|cjs)$/i.test(p)) return 5 // 后端/脚本
52
- return 3 // 其它(资源、未知)
53
- }
54
-
55
- function isSkippedFile(path) {
56
- return SKIP_FILE_PATTERNS.some(re => re.test(path))
57
- }
58
-
59
- // 从 git diff 文本解析出 per-file 块
60
- // git diff 输出格式: "diff --git a/path b/path\n...一系列 header...\n--- a/path\n+++ b/path\n@@ ...\n<patch>"
61
- function parseDiffByFile(diffText) {
62
- if (!diffText) return []
63
- const files = []
64
- // 按 "diff --git " 切分,首段可能为空(文本以这个开头则第一段空)
65
- const parts = diffText.split(/^diff --git /m).filter(Boolean)
66
- for (const part of parts) {
67
- const headerEnd = part.indexOf('\n')
68
- const headerLine = (headerEnd >= 0 ? part.slice(0, headerEnd) : part).trim()
69
- // 从 header 里抓路径: "a/path b/path" -> 优先 b
70
- const m = headerLine.match(/^a\/(.+?)\s+b\/(.+)$/)
71
- if (!m) continue
72
- const bPath = m[2]
73
- const patch = part.slice(headerEnd + 1)
74
- // 统计 +/- 行数
75
- let added = 0, removed = 0
76
- for (const line of patch.split('\n')) {
77
- if (line.startsWith('+') && !line.startsWith('+++')) added++
78
- else if (line.startsWith('-') && !line.startsWith('---')) removed++
79
- }
80
- files.push({ path: bPath, patch, added, removed })
81
- }
82
- return files
83
- }
84
-
85
- // 收集 AI 生成提交信息所需的 diff 和文件列表
86
- // 关键: untracked 文件默认不会出现在 git diff --staged 输出里,
87
- // 所以先对所有 untracked 文件做 git add -N(intent to add),
88
- // 这样它们会以 "new file" 形式出现在 diff 里
89
- // selectedPaths: 若给定且非空,则只收集这些路径的 diff,且 git add -N 也仅作用于所选 untracked,
90
- // 避免污染整个 index
91
- async function collectDiffForAi({ execGitCommand, getCurrentProjectPath, selectedPaths }) {
92
- if (typeof execGitCommand !== 'function') {
93
- return { diff: '', fileList: [] }
94
- }
95
- const projectPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : ''
96
- const cwdOpts = projectPath ? { cwd: projectPath, log: false } : { log: false }
97
-
98
- const selectedSet = Array.isArray(selectedPaths) && selectedPaths.length > 0
99
- ? new Set(selectedPaths.map(p => String(p).replace(/\\/g, '/')))
100
- : null
101
-
102
- let fileList = []
103
- try {
104
- // 1. 拿到工作区状态,识别 untracked 文件
105
- const { stdout: statusOut } = await execGitCommand(['status', '--porcelain=1', '--untracked-files=all'], cwdOpts)
106
- const untracked = []
107
- const trackedChanges = []
108
- for (const line of (statusOut || '').split('\n')) {
109
- if (!line || line.length < 3) continue
110
- // porcelain=1 格式: "XY path" X=index状态, Y=worktree状态
111
- const x = line[0]
112
- const y = line[1]
113
- const path = line.slice(3)
114
- // 选择模式: 只保留所选路径
115
- if (selectedSet && !selectedSet.has(path.replace(/\\/g, '/'))) continue
116
- if (x === '?' && y === '?') {
117
- untracked.push(path)
118
- } else if (x !== ' ' || y !== ' ') {
119
- // 有改动(暂存或工作区)
120
- trackedChanges.push(`${x !== ' ' ? x : ' '} ${path}`.trim())
121
- }
122
- }
123
- fileList = [...trackedChanges, ...untracked.map(p => `? ${p}`)]
124
-
125
- // 2. 对 untracked 文件做 intent to add(只在内存中,不会真的暂存内容)
126
- // 选择模式下 untracked 已被裁剪,只会对所选的做 -N,不污染整个 index
127
- if (untracked.length > 0) {
128
- // 加上 --force 以防某些文件已经在 index 中
129
- // 分批处理,避免命令行过长
130
- const batchSize = 20
131
- for (let i = 0; i < untracked.length; i += batchSize) {
132
- const batch = untracked.slice(i, i + batchSize)
133
- try {
134
- await execGitCommand(['add', '-N', '--force', ...batch], cwdOpts)
135
- } catch (e) {
136
- // 单批失败不影响整体
137
- logger.warn('[generate-commit] git add -N failed for batch:', e?.message)
138
- }
139
- }
140
- }
141
-
142
- // 3. 合并 staged + unstaged diff
143
- // 用 --no-color 避免 ANSI 干扰, --no-ext-diff 避免外接 diff 工具
144
- // 选择模式下用 `-- <paths>` 限定,避免拉取无关 staged 改动
145
- const pathArgs = selectedSet ? [...selectedSet] : []
146
- const diffBase = ['diff', '--no-color', '--no-ext-diff']
147
- const stagedArgs = ['diff', '--staged', '--no-color', '--no-ext-diff', ...(pathArgs.length ? ['--', ...pathArgs] : [])]
148
- const unstagedArgs = [...diffBase, ...(pathArgs.length ? ['--', ...pathArgs] : [])]
149
- const [stagedRes, unstagedRes] = await Promise.all([
150
- execGitCommand(stagedArgs, cwdOpts).catch(() => ({ stdout: '' })),
151
- execGitCommand(unstagedArgs, cwdOpts).catch(() => ({ stdout: '' }))
152
- ])
153
- let combined = ''
154
- if (stagedRes?.stdout) combined += stagedRes.stdout.trim() + '\n'
155
- if (unstagedRes?.stdout) combined += unstagedRes.stdout.trim() + '\n'
156
-
157
- return { diff: combined.trim(), fileList }
158
- } catch (error) {
159
- logger.error('[generate-commit] collectDiffForAi error:', error?.message)
160
- return { diff: '', fileList }
161
- }
162
- }
163
-
164
- // 把 diff 压缩成给 LLM 的紧凑文本
165
- // 策略: 跳过产物/资源文件(用一行 stat 带过),源码按优先级排序,每个文件 patch 限 1500 字,总预算 6000 字
166
- function prepareDiffForPrompt(diffText, fileList) {
167
- const safeFileList = Array.isArray(fileList) ? fileList : []
168
- let files = parseDiffByFile(diffText)
169
-
170
- // 如果客户端明确指定了文件列表,则只保留与所选文件匹配的 diff 块
171
- if (safeFileList.length > 0 && files.length > 0) {
172
- const selectedPaths = new Set(
173
- safeFileList
174
- .map(s => {
175
- if (typeof s !== 'string') return ''
176
- // fileList 形如 "M src/foo.ts" 或 "? new-file.ts",取最后的路径部分
177
- const m = s.match(/^[A-Z?\s]+\s+(.+)$/)
178
- return (m ? m[1] : s).replace(/\\/g, '/')
179
- })
180
- .filter(Boolean)
181
- )
182
- if (selectedPaths.size > 0) {
183
- files = files.filter(f => selectedPaths.has(f.path.replace(/\\/g, '/')))
184
- }
185
- }
186
-
187
- // 如果 parse 出来是空的(diff 可能是空或非标准格式),退回到 fileList 推断
188
- if (files.length === 0) {
189
- const list = safeFileList.slice(0, 30).map(s => {
190
- // fileList 形如 "M src/foo.ts" 或 "? new-file.ts"
191
- const m = s.match(/^[A-Z?\s]+\s+(.+)$/)
192
- return m ? m[1] : s
193
- })
194
- if (list.length === 0) return ''
195
- return list.map(p => {
196
- if (isSkippedFile(p)) return `${p} [产物/资源,已跳过]`
197
- return p
198
- }).join('\n')
199
- }
200
-
201
- // 拆分: 跳过的 vs 保留的
202
- const skipped = []
203
- const kept = []
204
- for (const f of files) {
205
- if (isSkippedFile(f.path)) {
206
- skipped.push(f)
207
- } else {
208
- kept.push(f)
209
- }
210
- }
211
-
212
- // 保留的文件按优先级降序,同优先级按 +/- 总数降序(改得多的优先)
213
- kept.sort((a, b) => {
214
- const dp = filePriority(b.path) - filePriority(a.path)
215
- if (dp !== 0) return dp
216
- return (b.added + b.removed) - (a.added + a.removed)
217
- })
218
-
219
- const TOTAL_BUDGET = 6000
220
- const PER_FILE_LIMIT = 1500
221
- const lines = []
222
- let budget = TOTAL_BUDGET
223
-
224
- // 先把跳过的文件用一行 stat 总结
225
- for (const f of skipped) {
226
- if (budget < 80) break
227
- lines.push(`${f.path} [+${f.added}/-${f.removed}, 已跳过产物/资源]`)
228
- budget -= 80
229
- }
230
-
231
- // 再装源码,带预算控制
232
- for (const f of kept) {
233
- if (budget <= 50) break
234
- let patch = f.patch
235
- let truncated = false
236
- if (patch.length > PER_FILE_LIMIT) {
237
- patch = patch.slice(0, PER_FILE_LIMIT) + '\n... (diff 已截断)'
238
- truncated = true
239
- }
240
- const block = `--- ${f.path} (+${f.added}/-${f.removed})${truncated ? ' [截断]' : ''}\n${patch}`
241
- if (block.length > budget) {
242
- // 单个文件塞不下了,截到能塞下为止
243
- const sliceLen = Math.max(0, budget - 80)
244
- if (sliceLen < 100) break
245
- lines.push(`--- ${f.path} (+${f.added}/-${f.removed}) [预算耗尽,已截断]\n${patch.slice(0, sliceLen)}`)
246
- budget = 0
247
- break
248
- }
249
- lines.push(block)
250
- budget -= block.length
251
- }
252
-
253
- if (lines.length === 0) return ''
254
- return lines.join('\n\n')
255
- }
256
-
257
- export { prepareDiffForPrompt, parseDiffByFile, isSkippedFile, filePriority, collectDiffForAi }
258
-
259
- export function registerConfigRoutes({
260
- app,
261
- configManager,
262
- execGitCommand,
263
- getCurrentProjectPath
264
- }) {
265
- // 保存最近访问的目录
266
- app.post('/api/save_recent_directory', asyncRoute(async (req, res) => {
267
- try {
268
- const { path } = req.body;
269
-
270
- if (!path) {
271
- return res.status(400).json({
272
- success: false,
273
- error: '目录路径不能为空'
274
- });
275
- }
276
-
277
- // 保存到配置
278
- await configManager.saveRecentDirectory(path);
279
-
280
- res.json({
281
- success: true
282
- });
283
- } catch (error) {
284
- res.status(500).json({
285
- success: false,
286
- error: error.message
287
- });
288
- }
289
- }));
290
-
291
- // 删除最近访问的目录
292
- app.post('/api/remove_recent_directory', asyncRoute(async (req, res) => {
293
- try {
294
- const { path: dirPath } = req.body;
295
- if (!dirPath) {
296
- throw new HttpError(400, '目录路径不能为空');
297
- }
298
- const list = await configManager.removeRecentDirectory(dirPath);
299
- res.json({ success: true, directories: list });
300
- } catch (error) {
301
- res.status(500).json({ success: false, error: error.message });
302
- }
303
- }));
304
-
305
- // 获取配置
306
- app.get('/api/config/getConfig', asyncRoute(async (req, res) => {
307
- try {
308
- const config = await configManager.loadConfig()
309
-
310
- // 兼容旧数据:补齐自定义命令 id,避免前端编辑/删除/编排引用异常
311
- if (Array.isArray(config.customCommands)) {
312
- let changed = false
313
- config.customCommands = config.customCommands.map((cmd) => {
314
- if (cmd && typeof cmd === 'object' && !cmd.id) {
315
- changed = true
316
- return {
317
- ...cmd,
318
- id: `cmd_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
319
- }
320
- }
321
- return cmd
322
- })
323
- if (changed) {
324
- await configManager.saveConfig(config)
325
- }
326
- }
327
-
328
- // 初始化命令模板(首次安装/旧配置兼容)
329
- if (!Array.isArray(config.commandTemplates)) {
330
- config.commandTemplates = []
331
- }
332
-
333
- if (config.commandTemplates.length === 0) {
334
- config.commandTemplates = [
335
- 'echo "{{cmd}}"',
336
- 'npm run dev',
337
- 'npm run build',
338
- 'git status',
339
- 'git add .',
340
- 'git commit -m "{{message}}" --no-verify',
341
- 'git push',
342
- ]
343
- await configManager.saveConfig(config)
344
- }
345
-
346
- res.json(config)
347
- } catch (error) {
348
- const configPath = path.join(os.homedir(), '.git-commit-tool.json')
349
- res.status(500).json({
350
- success: false,
351
- code: 'CONFIG_LOAD_FAILED',
352
- error: error?.message || String(error),
353
- configPath
354
- })
355
- }
356
- }));
357
-
358
- // 保存默认提交信息
359
- app.post('/api/config/saveDefaultMessage', express.json(), async (req, res) => {
360
- try {
361
- const { defaultCommitMessage } = req.body
362
-
363
- if (!defaultCommitMessage) {
364
- return res.status(400).json({ success: false, error: '缺少必要参数' })
365
- }
366
-
367
- const config = await configManager.loadConfig()
368
-
369
- // 更新默认提交信息
370
- config.defaultCommitMessage = defaultCommitMessage
371
- await configManager.saveConfig(config)
372
-
373
- res.json({ success: true })
374
- } catch (error) {
375
- res.status(500).json({ success: false, error: error.message })
376
- }
377
- })
378
-
379
- // 保存所有配置
380
- app.post('/api/config/saveAll', express.json(), async (req, res) => {
381
- try {
382
- const { config } = req.body
383
-
384
- if (!config) {
385
- return res.status(400).json({ success: false, error: '缺少必要参数' })
386
- }
387
-
388
- await configManager.saveConfig(config)
389
-
390
- res.json({ success: true })
391
- } catch (error) {
392
- res.status(500).json({ success: false, error: error.message })
393
- }
394
- })
395
-
396
- // 检查系统配置文件格式
397
- app.get('/api/config/check-file-format', asyncRoute(async (req, res) => {
398
- try {
399
- const configPath = path.join(os.homedir(), '.git-commit-tool.json');
400
-
401
- try {
402
- const data = await fs.readFile(configPath, 'utf-8');
403
- try {
404
- JSON.parse(data);
405
- res.json({ success: true, isValidJson: true, exists: true });
406
- } catch (parseError) {
407
- res.json({
408
- success: true,
409
- isValidJson: false,
410
- exists: true,
411
- error: `JSON解析失败: ${parseError.message}`
412
- });
413
- }
414
- } catch (fileError) {
415
- if (fileError.code === 'ENOENT') {
416
- res.json({ success: true, isValidJson: true, exists: false });
417
- } else {
418
- res.json({
419
- success: true,
420
- isValidJson: false,
421
- exists: true,
422
- error: `文件读取失败: ${fileError.message}`
423
- });
424
- }
425
- }
426
- } catch (error) {
427
- res.status(500).json({ success: false, error: error.message });
428
- }
429
- }));
430
-
431
- // 使用系统默认程序打开配置文件 ~/.git-commit-tool.json
432
- app.post('/api/config/open-file', asyncRoute(async (req, res) => {
433
- try {
434
- const filePath = path.join(os.homedir(), '.git-commit-tool.json');
435
- try {
436
- // 检查文件是否存在,不存在也尝试让系统创建(可能会打开空文件)
437
- await fs.access(filePath);
438
- } catch (_) {
439
- // 如果文件不存在,先创建一个最小结构,避免某些系统无法打开不存在的路径
440
- try {
441
- await fs.writeFile(filePath, '{}', 'utf-8');
442
- } catch (e) {
443
- // 创建失败不阻断打开尝试
444
- logger.warn('创建配置文件失败(可忽略):', e?.message || e);
445
- }
446
- }
447
-
448
- await open(filePath, { wait: false });
449
- res.json({ success: true })
450
- } catch (error) {
451
- res.status(400).json({ success: false, error: `无法打开配置文件: ${error.message}` })
452
- }
453
- }));
454
-
455
- // 保存模板
456
- app.post('/api/config/save-template', express.json(), async (req, res) => {
457
- try {
458
- const { template, type } = req.body
459
-
460
- if (!template || !type) {
461
- return res.status(400).json({ success: false, error: '缺少必要参数' })
462
- }
463
-
464
- const config = await configManager.loadConfig()
465
-
466
- if (type === 'description') {
467
- // 确保描述模板数组存在
468
- if (!config.descriptionTemplates) {
469
- config.descriptionTemplates = []
470
- }
471
-
472
- // 检查是否已存在相同模板
473
- if (!config.descriptionTemplates.includes(template)) {
474
- config.descriptionTemplates.push(template)
475
- await configManager.saveConfig(config)
476
- }
477
- } else if (type === 'scope') {
478
- // 确保作用域模板数组存在
479
- if (!config.scopeTemplates) {
480
- config.scopeTemplates = []
481
- }
482
-
483
- // 检查是否已存在相同模板
484
- if (!config.scopeTemplates.includes(template)) {
485
- config.scopeTemplates.push(template)
486
- await configManager.saveConfig(config)
487
- }
488
- } else if (type === 'message') {
489
- // 确保提交信息模板数组存在
490
- if (!config.messageTemplates) {
491
- config.messageTemplates = []
492
- }
493
-
494
- // 检查是否已存在相同模板
495
- if (!config.messageTemplates.includes(template)) {
496
- config.messageTemplates.push(template)
497
- await configManager.saveConfig(config)
498
- }
499
- } else if (type === 'command') {
500
- if (!config.commandTemplates) {
501
- config.commandTemplates = []
502
- }
503
- if (!config.commandTemplates.includes(template)) {
504
- config.commandTemplates.push(template)
505
- await configManager.saveConfig(config)
506
- }
507
- } else {
508
- return res.status(400).json({ success: false, error: '不支持的模板类型' })
509
- }
510
-
511
- res.json({ success: true })
512
- } catch (error) {
513
- res.status(500).json({ success: false, error: error.message })
514
- }
515
- })
516
-
517
- // 删除模板
518
- app.post('/api/config/delete-template', express.json(), async (req, res) => {
519
- try {
520
- const { template, type } = req.body
521
-
522
- if (!template || !type) {
523
- return res.status(400).json({ success: false, error: '缺少必要参数' })
524
- }
525
-
526
- const config = await configManager.loadConfig()
527
-
528
- if (type === 'description') {
529
- // 确保描述模板数组存在
530
- if (config.descriptionTemplates) {
531
- const index = config.descriptionTemplates.indexOf(template)
532
- if (index !== -1) {
533
- config.descriptionTemplates.splice(index, 1)
534
- await configManager.saveConfig(config)
535
- }
536
- }
537
- } else if (type === 'scope') {
538
- // 确保作用域模板数组存在
539
- if (config.scopeTemplates) {
540
- const index = config.scopeTemplates.indexOf(template)
541
- if (index !== -1) {
542
- config.scopeTemplates.splice(index, 1)
543
- await configManager.saveConfig(config)
544
- }
545
- }
546
- } else if (type === 'message') {
547
- // 确保提交信息模板数组存在
548
- if (config.messageTemplates) {
549
- const index = config.messageTemplates.indexOf(template)
550
- if (index !== -1) {
551
- config.messageTemplates.splice(index, 1)
552
- await configManager.saveConfig(config)
553
- }
554
- }
555
- } else if (type === 'command') {
556
- if (config.commandTemplates) {
557
- const index = config.commandTemplates.indexOf(template)
558
- if (index !== -1) {
559
- config.commandTemplates.splice(index, 1)
560
- await configManager.saveConfig(config)
561
- }
562
- }
563
- } else {
564
- return res.status(400).json({ success: false, error: '不支持的模板类型' })
565
- }
566
-
567
- res.json({ success: true })
568
- } catch (error) {
569
- res.status(500).json({ success: false, error: error.message })
570
- }
571
- })
572
-
573
- // 更新模板
574
- app.post('/api/config/update-template', express.json(), async (req, res) => {
575
- try {
576
- const { oldTemplate, newTemplate, type } = req.body
577
-
578
- if (!oldTemplate || !newTemplate || !type) {
579
- return res.status(400).json({ success: false, error: '缺少必要参数' })
580
- }
581
-
582
- const config = await configManager.loadConfig()
583
-
584
- if (type === 'description') {
585
- // 确保描述模板数组存在
586
- if (config.descriptionTemplates) {
587
- const index = config.descriptionTemplates.indexOf(oldTemplate)
588
- if (index !== -1) {
589
- config.descriptionTemplates[index] = newTemplate
590
- await configManager.saveConfig(config)
591
- } else {
592
- return res.status(404).json({ success: false, error: '未找到原模板' })
593
- }
594
- } else {
595
- return res.status(404).json({ success: false, error: '模板列表不存在' })
596
- }
597
- } else if (type === 'scope') {
598
- // 确保作用域模板数组存在
599
- if (config.scopeTemplates) {
600
- const index = config.scopeTemplates.indexOf(oldTemplate)
601
- if (index !== -1) {
602
- config.scopeTemplates[index] = newTemplate
603
- await configManager.saveConfig(config)
604
- } else {
605
- return res.status(404).json({ success: false, error: '未找到原模板' })
606
- }
607
- } else {
608
- return res.status(404).json({ success: false, error: '模板列表不存在' })
609
- }
610
- } else if (type === 'message') {
611
- // 确保提交信息模板数组存在
612
- if (config.messageTemplates) {
613
- const index = config.messageTemplates.indexOf(oldTemplate)
614
- if (index !== -1) {
615
- config.messageTemplates[index] = newTemplate
616
- await configManager.saveConfig(config)
617
- } else {
618
- return res.status(404).json({ success: false, error: '未找到原模板' })
619
- }
620
- } else {
621
- return res.status(404).json({ success: false, error: '模板列表不存在' })
622
- }
623
- } else if (type === 'command') {
624
- if (config.commandTemplates) {
625
- const index = config.commandTemplates.indexOf(oldTemplate)
626
- if (index !== -1) {
627
- config.commandTemplates[index] = newTemplate
628
- await configManager.saveConfig(config)
629
- } else {
630
- return res.status(404).json({ success: false, error: '未找到原模板' })
631
- }
632
- } else {
633
- return res.status(404).json({ success: false, error: '模板列表不存在' })
634
- }
635
- } else {
636
- return res.status(400).json({ success: false, error: '不支持的模板类型' })
637
- }
638
-
639
- res.json({ success: true })
640
- } catch (error) {
641
- res.status(500).json({ success: false, error: error.message })
642
- }
643
- })
644
-
645
- // 置顶模板
646
- app.post('/api/config/pin-template', express.json(), async (req, res) => {
647
- try {
648
- const { template, type } = req.body
649
-
650
- if (!template || !type) {
651
- return res.status(400).json({ success: false, error: '缺少必要参数' })
652
- }
653
-
654
- const config = await configManager.loadConfig()
655
-
656
- if (type === 'description') {
657
- if (config.descriptionTemplates) {
658
- // 删除原位置的模板
659
- config.descriptionTemplates = config.descriptionTemplates.filter(t => t !== template)
660
- // 添加到第一位
661
- config.descriptionTemplates.unshift(template)
662
- await configManager.saveConfig(config)
663
- } else {
664
- return res.status(404).json({ success: false, error: '模板列表不存在' })
665
- }
666
- } else if (type === 'scope') {
667
- if (config.scopeTemplates) {
668
- config.scopeTemplates = config.scopeTemplates.filter(t => t !== template)
669
- config.scopeTemplates.unshift(template)
670
- await configManager.saveConfig(config)
671
- } else {
672
- return res.status(404).json({ success: false, error: '模板列表不存在' })
673
- }
674
- } else if (type === 'message') {
675
- if (config.messageTemplates) {
676
- config.messageTemplates = config.messageTemplates.filter(t => t !== template)
677
- config.messageTemplates.unshift(template)
678
- await configManager.saveConfig(config)
679
- } else {
680
- return res.status(404).json({ success: false, error: '模板列表不存在' })
681
- }
682
- } else if (type === 'command') {
683
- if (config.commandTemplates) {
684
- config.commandTemplates = config.commandTemplates.filter(t => t !== template)
685
- config.commandTemplates.unshift(template)
686
- await configManager.saveConfig(config)
687
- } else {
688
- return res.status(404).json({ success: false, error: '模板列表不存在' })
689
- }
690
- } else {
691
- return res.status(400).json({ success: false, error: '不支持的模板类型' })
692
- }
693
-
694
- res.json({ success: true })
695
- } catch (error) {
696
- res.status(500).json({ success: false, error: error.message })
697
- }
698
- })
699
-
700
- // 保存自定义命令
701
- app.post('/api/config/save-custom-command', express.json(), async (req, res) => {
702
- try {
703
- const { command } = req.body
704
-
705
- if (!command || !command.name || !command.command) {
706
- return res.status(400).json({ success: false, error: '缺少必要参数' })
707
- }
708
-
709
- const config = await configManager.loadConfig()
710
-
711
- // 确保自定义命令数组存在
712
- if (!Array.isArray(config.customCommands)) {
713
- config.customCommands = []
714
- }
715
-
716
- // 生成唯一ID
717
- const id = `cmd_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
718
- const newCommand = {
719
- id,
720
- name: command.name,
721
- description: command.description || '',
722
- directory: command.directory || '',
723
- command: command.command,
724
- params: Array.isArray(command.params) ? command.params : []
725
- }
726
-
727
- config.customCommands.push(newCommand)
728
- await configManager.saveConfig(config)
729
-
730
- res.json({ success: true, command: newCommand })
731
- } catch (error) {
732
- res.status(500).json({ success: false, error: error.message })
733
- }
734
- })
735
-
736
- // 删除自定义命令
737
- app.post('/api/config/delete-custom-command', express.json(), async (req, res) => {
738
- try {
739
- const { id } = req.body
740
-
741
- if (!id) {
742
- return res.status(400).json({ success: false, error: '缺少命令ID参数' })
743
- }
744
-
745
- const config = await configManager.loadConfig()
746
-
747
- if (Array.isArray(config.customCommands)) {
748
- const index = config.customCommands.findIndex(cmd => cmd.id === id)
749
- if (index !== -1) {
750
- config.customCommands.splice(index, 1)
751
- await configManager.saveConfig(config)
752
- }
753
- }
754
-
755
- res.json({ success: true })
756
- } catch (error) {
757
- res.status(500).json({ success: false, error: error.message })
758
- }
759
- })
760
-
761
- // 置顶自定义命令(移到数组首位)
762
- app.post('/api/config/pin-custom-command', express.json(), async (req, res) => {
763
- try {
764
- const { id } = req.body
765
-
766
- if (!id) {
767
- return res.status(400).json({ success: false, error: '缺少命令ID参数' })
768
- }
769
-
770
- const config = await configManager.loadConfig()
771
-
772
- if (Array.isArray(config.customCommands)) {
773
- const index = config.customCommands.findIndex(cmd => cmd.id === id)
774
- if (index > 0) {
775
- const [cmd] = config.customCommands.splice(index, 1)
776
- config.customCommands.unshift(cmd)
777
- await configManager.saveConfig(config)
778
- }
779
- }
780
-
781
- res.json({ success: true })
782
- } catch (error) {
783
- res.status(500).json({ success: false, error: error.message })
784
- }
785
- })
786
-
787
- // 更新自定义命令
788
- app.post('/api/config/update-custom-command', express.json(), async (req, res) => {
789
- try {
790
- const { id, command } = req.body
791
-
792
- if (!id || !command || !command.name || !command.command) {
793
- return res.status(400).json({ success: false, error: '缺少必要参数' })
794
- }
795
-
796
- const config = await configManager.loadConfig()
797
-
798
- if (Array.isArray(config.customCommands)) {
799
- const index = config.customCommands.findIndex(cmd => cmd.id === id)
800
- if (index !== -1) {
801
- config.customCommands[index] = {
802
- id,
803
- name: command.name,
804
- description: command.description || '',
805
- directory: command.directory || '',
806
- command: command.command,
807
- params: Array.isArray(command.params) ? command.params : []
808
- }
809
- await configManager.saveConfig(config)
810
- } else {
811
- return res.status(404).json({ success: false, error: '未找到指定命令' })
812
- }
813
- } else {
814
- return res.status(404).json({ success: false, error: '命令列表不存在' })
815
- }
816
-
817
- res.json({ success: true })
818
- } catch (error) {
819
- res.status(500).json({ success: false, error: error.message })
820
- }
821
- })
822
-
823
- // 保存指令编排
824
- app.post('/api/config/save-orchestration', express.json(), async (req, res) => {
825
- try {
826
- const { orchestration } = req.body
827
-
828
- if (!orchestration || !orchestration.name) {
829
- return res.status(400).json({ success: false, error: '缺少必要参数' })
830
- }
831
-
832
- const config = await configManager.loadConfig()
833
-
834
- // 确保编排数组存在
835
- if (!Array.isArray(config.orchestrations)) {
836
- config.orchestrations = []
837
- }
838
-
839
- // 生成唯一ID
840
- const id = `orch_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
841
- const newOrchestration = {
842
- id,
843
- name: orchestration.name,
844
- description: orchestration.description || '',
845
- flowData: orchestration.flowData || null
846
- }
847
-
848
- config.orchestrations.push(newOrchestration)
849
- await configManager.saveConfig(config)
850
-
851
- res.json({ success: true, orchestration: newOrchestration })
852
- } catch (error) {
853
- res.status(500).json({ success: false, error: error.message })
854
- }
855
- })
856
-
857
- // 删除指令编排
858
- app.post('/api/config/delete-orchestration', express.json(), async (req, res) => {
859
- try {
860
- const { id } = req.body
861
-
862
- if (!id) {
863
- return res.status(400).json({ success: false, error: '缺少编排ID参数' })
864
- }
865
-
866
- const config = await configManager.loadConfig()
867
-
868
- if (Array.isArray(config.orchestrations)) {
869
- const index = config.orchestrations.findIndex(orch => orch.id === id)
870
- if (index !== -1) {
871
- config.orchestrations.splice(index, 1)
872
- await configManager.saveConfig(config)
873
- }
874
- }
875
-
876
- res.json({ success: true })
877
- } catch (error) {
878
- res.status(500).json({ success: false, error: error.message })
879
- }
880
- })
881
-
882
- // 更新指令编排
883
- app.post('/api/config/update-orchestration', express.json(), async (req, res) => {
884
- try {
885
- const { id, orchestration } = req.body
886
-
887
- if (!id || !orchestration || !orchestration.name) {
888
- return res.status(400).json({ success: false, error: '缺少必要参数' })
889
- }
890
-
891
- const config = await configManager.loadConfig()
892
-
893
- if (Array.isArray(config.orchestrations)) {
894
- const index = config.orchestrations.findIndex(orch => orch.id === id)
895
- if (index !== -1) {
896
- config.orchestrations[index] = {
897
- id,
898
- name: orchestration.name,
899
- description: orchestration.description || '',
900
- flowData: orchestration.flowData || null
901
- }
902
- await configManager.saveConfig(config)
903
- } else {
904
- return res.status(404).json({ success: false, error: '未找到指定编排' })
905
- }
906
- } else {
907
- return res.status(404).json({ success: false, error: '编排列表不存在' })
908
- }
909
-
910
- res.json({ success: true })
911
- } catch (error) {
912
- res.status(500).json({ success: false, error: error.message })
913
- }
914
- })
915
-
916
- // 保存项目启动项
917
- app.post('/api/config/save-startup-items', express.json(), async (req, res) => {
918
- try {
919
- const { startupItems, startupAutoRun } = req.body
920
-
921
- if (!Array.isArray(startupItems)) {
922
- return res.status(400).json({ success: false, error: '启动项必须是数组' })
923
- }
924
-
925
- const config = await configManager.loadConfig()
926
- config.startupItems = startupItems
927
- if (typeof startupAutoRun === 'boolean') {
928
- config.startupAutoRun = startupAutoRun
929
- }
930
- await configManager.saveConfig(config)
931
-
932
- res.json({ success: true })
933
- } catch (error) {
934
- res.status(500).json({ success: false, error: error.message })
935
- }
936
- })
937
-
938
- // 保存提交设置
939
- app.post('/api/config/save-commit-settings', express.json(), async (req, res) => {
940
- try {
941
- const { isStandardCommit, skipHooks, autoQuickPushOnEnter, autoSetDefaultMessage, autoClosePushModal, pullBeforePush } = req.body
942
- const config = await configManager.loadConfig()
943
- if (isStandardCommit !== undefined) config.isStandardCommit = Boolean(isStandardCommit)
944
- if (skipHooks !== undefined) config.skipHooks = Boolean(skipHooks)
945
- if (autoQuickPushOnEnter !== undefined) config.autoQuickPushOnEnter = Boolean(autoQuickPushOnEnter)
946
- if (autoSetDefaultMessage !== undefined) config.autoSetDefaultMessage = Boolean(autoSetDefaultMessage)
947
- if (autoClosePushModal !== undefined) config.autoClosePushModal = Boolean(autoClosePushModal)
948
- if (pullBeforePush !== undefined) config.pullBeforePush = Boolean(pullBeforePush)
949
- await configManager.saveConfig(config)
950
- res.json({ success: true })
951
- } catch (error) {
952
- res.status(500).json({ success: false, error: error.message })
953
- }
954
- })
955
-
956
- // 保存"一键推送成功后启动项"
957
- app.post('/api/config/save-after-quick-push-action', express.json(), async (req, res) => {
958
- try {
959
- const { afterQuickPushAction } = req.body
960
-
961
- if (!afterQuickPushAction || typeof afterQuickPushAction !== 'object') {
962
- return res.status(400).json({ success: false, error: '缺少必要参数' })
963
- }
964
-
965
- const enabled = Boolean(afterQuickPushAction.enabled)
966
- const type = afterQuickPushAction.type === 'workflow' ? 'workflow' : 'command'
967
- const refId = String(afterQuickPushAction.refId || '').trim()
968
-
969
- const config = await configManager.loadConfig()
970
- config.afterQuickPushAction = {
971
- enabled,
972
- type,
973
- refId
974
- }
975
- await configManager.saveConfig(config)
976
-
977
- res.json({ success: true })
978
- } catch (error) {
979
- res.status(500).json({ success: false, error: error.message })
980
- }
981
- })
982
-
983
- // 保存通用设置(主题和语言)
984
- app.post('/api/config/save-general-settings', express.json(), async (req, res) => {
985
- try {
986
- const { theme, locale } = req.body
987
-
988
- // 读取原始配置以保留项目设置
989
- const rawConfig = await configManager.readRawConfigFile()
990
-
991
- // 更新全局设置
992
- if (theme && ['light', 'dark', 'auto'].includes(theme)) {
993
- rawConfig.theme = theme
994
- }
995
- if (locale && ['zh-CN', 'en-US'].includes(locale)) {
996
- rawConfig.locale = locale
997
- }
998
-
999
- // 直接写入原始配置,避免覆盖项目设置
1000
- await configManager.writeRawConfigFile(rawConfig)
1001
- res.json({ success: true })
1002
- } catch (error) {
1003
- res.status(500).json({ success: false, error: error.message })
1004
- }
1005
- })
1006
-
1007
- // 保存 UI 状态(视图模式/分割比例/控制台状态/布局比例/编辑器自动保存等)
1008
- // 接受 partial body,浅合并到顶层 ui 对象。例:{ layout: {...} } / { commandConsole: {...} } / { fileListViewMode: 'tree' }
1009
- app.post('/api/config/save-ui-settings', express.json(), async (req, res) => {
1010
- try {
1011
- const partial = req.body && typeof req.body === 'object' ? req.body : {}
1012
-
1013
- const rawConfig = await configManager.readRawConfigFile()
1014
-
1015
- // 确保 ui 容器存在
1016
- if (!rawConfig.ui || typeof rawConfig.ui !== 'object' || Array.isArray(rawConfig.ui)) {
1017
- rawConfig.ui = {}
1018
- }
1019
-
1020
- // 浅合并顶层 ui 字段(支持嵌套对象整体替换,如 commandConsole)
1021
- for (const key of Object.keys(partial)) {
1022
- rawConfig.ui[key] = partial[key]
1023
- }
1024
-
1025
- await configManager.writeRawConfigFile(rawConfig)
1026
- res.json({ success: true })
1027
- } catch (error) {
1028
- logger.error('[save-ui-settings] failed:', error)
1029
- res.status(500).json({ success: false, error: error.message })
1030
- }
1031
- })
1032
-
1033
- // 保存 AI 模型配置(models 是全局配置,存在配置文件顶层,跨项目共享)
1034
- app.post('/api/config/save-models', express.json(), async (req, res) => {
1035
- try {
1036
- const { models } = req.body
1037
- if (!Array.isArray(models)) {
1038
- return res.status(400).json({ success: false, error: '缺少 models 参数' })
1039
- }
1040
- const rawConfig = await configManager.readRawConfigFile()
1041
- rawConfig.models = models
1042
- await configManager.writeRawConfigFile(rawConfig)
1043
- res.json({ success: true })
1044
- } catch (error) {
1045
- res.status(500).json({ success: false, error: error.message })
1046
- }
1047
- })
1048
-
1049
- // 测试 AI 模型是否可用
1050
- app.post('/api/config/test-model', express.json(), async (req, res) => {
1051
- const { baseURL, model, apiKey } = req.body || {}
1052
- if (!baseURL || !model) {
1053
- return res.status(400).json({ success: false, error: '缺少 baseURL 或 model 参数' })
1054
- }
1055
- try {
1056
- const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }))
1057
- const url = `${baseURL.replace(/\/$/, '')}/chat/completions`
1058
- const headers = { 'Content-Type': 'application/json' }
1059
- if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
1060
- const body = JSON.stringify({
1061
- model,
1062
- messages: [{ role: 'user', content: 'Hello, reply with just "ok".' }],
1063
- max_tokens: 16,
1064
- stream: false
1065
- })
1066
- const controller = new AbortController()
1067
- const timer = setTimeout(() => controller.abort(), 15000)
1068
- let response
1069
- try {
1070
- response = await fetch(url, { method: 'POST', headers, body, signal: controller.signal })
1071
- } finally {
1072
- clearTimeout(timer)
1073
- }
1074
- const data = await response.json().catch(() => ({}))
1075
- if (!response.ok) {
1076
- const msg = data?.error?.message || data?.message || `HTTP ${response.status}`
1077
- return res.json({ success: false, error: msg })
1078
- }
1079
- const reply = data?.choices?.[0]?.message?.content || data?.choices?.[0]?.text || '✅'
1080
- res.json({ success: true, reply: reply.trim().slice(0, 100) })
1081
- } catch (error) {
1082
- const msg = error.name === 'AbortError' ? '请求超时(15s)' : error.message
1083
- res.json({ success: false, error: msg })
1084
- }
1085
- })
1086
-
1087
- // AI 生成提交信息
1088
- app.post('/api/config/generate-commit-message', express.json(), async (req, res) => {
1089
- const { selectedPaths, locale: reqLocale } = req.body || {}
1090
- try {
1091
- const rawConfig = await configManager.readRawConfigFile()
1092
- const userLocale = reqLocale || rawConfig.locale || 'zh-CN'
1093
- const models = Array.isArray(rawConfig.models) ? rawConfig.models : []
1094
- const defaultModel = models.find(m => m.isDefault) || models[0]
1095
- if (!defaultModel) {
1096
- return res.json({ success: false, error: '未配置 AI 模型,请先在通用设置中添加模型', code: 'NO_MODEL' })
1097
- }
1098
-
1099
- // 后端自己收集 diff,确保 untracked 文件也能进 prompt
1100
- // 选择模式下传入 selectedPaths,后端在 git 层面就过滤,避免污染 index
1101
- const { diff: rawDiff, fileList: serverFileList } = await collectDiffForAi({
1102
- execGitCommand,
1103
- getCurrentProjectPath,
1104
- selectedPaths
1105
- })
1106
- const diffText = prepareDiffForPrompt(rawDiff, serverFileList)
1107
- const filesText = serverFileList.slice(0, 30).join('\n')
1108
-
1109
- const promptZh = `你是一个 Git 提交信息生成助手。根据以下 git diff 信息,生成一条符合 Conventional Commits 规范的提交信息。
1110
-
1111
- 要求:
1112
- 1. type 只能是:feat/fix/docs/style/refactor/test/chore 之一
1113
- 2. scope 可选,表示影响范围,简短英文或中文,如果改动范围明确就填
1114
- 3. description 用中文简短描述本次变更(不超过50字)
1115
- 4. 只返回 JSON,格式:{"type":"feat","scope":"","description":"xxx"}
1116
-
1117
- 变更文件:
1118
- ${filesText}
1119
-
1120
- git diff --staged:
1121
- ${diffText || '(无 staged 内容,请根据文件列表推断)'}`
1122
-
1123
- const promptEn = `You are a Git commit message generation assistant. Based on the following git diff, generate a commit message that follows the Conventional Commits specification.
1124
-
1125
- Requirements:
1126
- 1. type must be one of: feat/fix/docs/style/refactor/test/chore
1127
- 2. scope is optional; use a short English word or short noun phrase to indicate the affected area. Leave empty if unclear.
1128
- 3. description must be a concise English summary of the change (no more than 50 characters). Use the imperative mood (e.g. "add login button", not "added" or "adding").
1129
- 4. Return ONLY a JSON object in the format: {"type":"feat","scope":"","description":"xxx"}
1130
-
1131
- Changed files:
1132
- ${filesText}
1133
-
1134
- git diff --staged:
1135
- ${diffText || '(no staged content, please infer from the file list)'}`
1136
-
1137
- const prompt = userLocale.startsWith('en') ? promptEn : promptZh
1138
-
1139
- // AI prompt 默认不打印,只有 DEBUG=1 / DEBUG=logger 时才落地(防 Token 泄露)
1140
- logger.debug('[generate-commit] locale:', userLocale, '| prompt length:', prompt.length)
1141
- logger.debug('[generate-commit] prompt:\n' + prompt)
1142
- const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }))
1143
- const url = `${defaultModel.baseURL.replace(/\/$/, '')}/chat/completions`
1144
- const headers = { 'Content-Type': 'application/json' }
1145
- if (defaultModel.apiKey) headers['Authorization'] = `Bearer ${defaultModel.apiKey}`
1146
- const body = JSON.stringify({
1147
- model: defaultModel.model,
1148
- messages: [{ role: 'user', content: prompt }],
1149
- max_tokens: 1024,
1150
- temperature: 0.3,
1151
- stream: false
1152
- })
1153
- const controller = new AbortController()
1154
- const timer = setTimeout(() => controller.abort(), 60000)
1155
- let response
1156
- try {
1157
- response = await fetch(url, { method: 'POST', headers, body, signal: controller.signal })
1158
- } finally {
1159
- clearTimeout(timer)
1160
- }
1161
- const data = await response.json().catch(() => ({}))
1162
- if (!response.ok) {
1163
- const msg = data?.error?.message || data?.message || `HTTP ${response.status}`
1164
- return res.json({ success: false, error: msg, code: 'HTTP_ERR' })
1165
- }
1166
- const content = data?.choices?.[0]?.message?.content || ''
1167
- // 响应内容只走 debug(默认关闭),前 600 字截断防 token 泄露
1168
- logger.debug('[generate-commit] raw content length:', content.length, JSON.stringify(content).slice(0, 600))
1169
-
1170
- const codeBlockMatch = content.match(/```(?:json)?\s*(\{[^`]*?\})\s*```/)
1171
- const jsonMatch = codeBlockMatch
1172
- ? [codeBlockMatch[1]]
1173
- : [...content.matchAll(/\{[^{}]*\}/g)].at(-1)
1174
-
1175
- if (!jsonMatch) {
1176
- logger.error('[generate-commit] no JSON found, full content:', content)
1177
- return res.json({ success: false, error: 'model returned no valid JSON', code: 'NO_JSON' })
1178
- }
1179
- let parsed
1180
- try {
1181
- parsed = JSON.parse(jsonMatch[0])
1182
- } catch {
1183
- const typeM = jsonMatch[0].match(/"type"\s*:\s*"([^"]+)"/)
1184
- const scopeM = jsonMatch[0].match(/"scope"\s*:\s*"([^"]*)"/)
1185
- const descM = jsonMatch[0].match(/"description"\s*:\s*"([^"]+)"/)
1186
- if (typeM || descM) {
1187
- return res.json({
1188
- success: true,
1189
- type: (typeM?.[1] || 'feat').trim(),
1190
- scope: (scopeM?.[1] || '').trim(),
1191
- description: (descM?.[1] || '').trim()
1192
- })
1193
- }
1194
- return res.json({ success: false, error: 'JSON parse failed', code: 'PARSE_FAILED' })
1195
- }
1196
- res.json({
1197
- success: true,
1198
- type: String(parsed.type || 'feat').trim(),
1199
- scope: String(parsed.scope || '').trim(),
1200
- description: String(parsed.description || '').trim()
1201
- })
1202
- } catch (error) {
1203
- const isTimeout = error.name === 'AbortError'
1204
- const msg = isTimeout ? 'AI 请求超时(60s),请重试或检查模型响应速度' : error.message
1205
- res.json({ success: false, error: msg, code: isTimeout ? 'TIMEOUT' : 'GENERATE_FAILED' })
1206
- }
1207
- })
1208
- }
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+ import express from 'express';
16
+ import { asyncRoute, HttpError } from '../utils/asyncRoute.js';
17
+ import path from 'path';
18
+ import os from 'os';
19
+ import fs from 'fs/promises';
20
+ import open from 'open';
21
+ import logger from '../utils/logger.js';
22
+
23
+ // 跳过的产物/资源/lock 文件(用 stat 一行带过,不打 patch)
24
+ const SKIP_FILE_PATTERNS = [
25
+ /(^|[\\/])dist[\\/]/,
26
+ /(^|[\\/])build[\\/]/,
27
+ /(^|[\\/])\.next[\\/]/,
28
+ /(^|[\\/])coverage[\\/]/,
29
+ /(^|[\\/])node_modules[\\/]/,
30
+ /\.lock$/,
31
+ /package-lock\.json$/,
32
+ /yarn\.lock$/,
33
+ /pnpm-lock\.yaml$/,
34
+ /min\.js$/,
35
+ /min\.css$/,
36
+ /\.bundle\.js$/,
37
+ /\.map$/,
38
+ /\.(png|jpe?g|gif|webp|svg|ico|bmp|tiff)$/,
39
+ /\.(mp4|mov|avi|mkv|webm)$/,
40
+ /\.(mp3|wav|ogg|flac)$/,
41
+ /\.(woff2?|ttf|otf|eot)$/,
42
+ /\.(pdf|zip|tar|gz|7z|rar)$/
43
+ ]
44
+
45
+ // 文件优先级:数字越大越重要
46
+ function filePriority(p) {
47
+ if (/\.(test|spec)\.(js|ts|jsx|tsx|vue)$/i.test(p)) return 1 // 测试文件
48
+ if (/^docs?\//i.test(p) || /\.md$/i.test(p)) return 2 // 文档
49
+ if (/\.(json|ya?ml|toml|env|ini|cfg|conf)$/i.test(p)) return 3 // 配置
50
+ if (/\.(css|scss|sass|less|html|vue|jsx|tsx)$/i.test(p)) return 4 // 前端
51
+ if (/\.(ts|js|mjs|cjs)$/i.test(p)) return 5 // 后端/脚本
52
+ return 3 // 其它(资源、未知)
53
+ }
54
+
55
+ function isSkippedFile(path) {
56
+ return SKIP_FILE_PATTERNS.some(re => re.test(path))
57
+ }
58
+
59
+ // 从 git diff 文本解析出 per-file 块
60
+ // git diff 输出格式: "diff --git a/path b/path\n...一系列 header...\n--- a/path\n+++ b/path\n@@ ...\n<patch>"
61
+ function parseDiffByFile(diffText) {
62
+ if (!diffText) return []
63
+ const files = []
64
+ // 按 "diff --git " 切分,首段可能为空(文本以这个开头则第一段空)
65
+ const parts = diffText.split(/^diff --git /m).filter(Boolean)
66
+ for (const part of parts) {
67
+ const headerEnd = part.indexOf('\n')
68
+ const headerLine = (headerEnd >= 0 ? part.slice(0, headerEnd) : part).trim()
69
+ // 从 header 里抓路径: "a/path b/path" -> 优先 b
70
+ const m = headerLine.match(/^a\/(.+?)\s+b\/(.+)$/)
71
+ if (!m) continue
72
+ const bPath = m[2]
73
+ const patch = part.slice(headerEnd + 1)
74
+ // 统计 +/- 行数
75
+ let added = 0, removed = 0
76
+ for (const line of patch.split('\n')) {
77
+ if (line.startsWith('+') && !line.startsWith('+++')) added++
78
+ else if (line.startsWith('-') && !line.startsWith('---')) removed++
79
+ }
80
+ files.push({ path: bPath, patch, added, removed })
81
+ }
82
+ return files
83
+ }
84
+
85
+ // 收集 AI 生成提交信息所需的 diff 和文件列表
86
+ // 关键: untracked 文件默认不会出现在 git diff --staged 输出里,
87
+ // 所以先对所有 untracked 文件做 git add -N(intent to add),
88
+ // 这样它们会以 "new file" 形式出现在 diff 里
89
+ // selectedPaths: 若给定且非空,则只收集这些路径的 diff,且 git add -N 也仅作用于所选 untracked,
90
+ // 避免污染整个 index
91
+ async function collectDiffForAi({ execGitCommand, getCurrentProjectPath, selectedPaths }) {
92
+ if (typeof execGitCommand !== 'function') {
93
+ return { diff: '', fileList: [] }
94
+ }
95
+ const projectPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : ''
96
+ const cwdOpts = projectPath ? { cwd: projectPath, log: false } : { log: false }
97
+
98
+ const selectedSet = Array.isArray(selectedPaths) && selectedPaths.length > 0
99
+ ? new Set(selectedPaths.map(p => String(p).replace(/\\/g, '/')))
100
+ : null
101
+
102
+ let fileList = []
103
+ try {
104
+ // 1. 拿到工作区状态,识别 untracked 文件
105
+ const { stdout: statusOut } = await execGitCommand(['status', '--porcelain=1', '--untracked-files=all'], cwdOpts)
106
+ const untracked = []
107
+ const trackedChanges = []
108
+ for (const line of (statusOut || '').split('\n')) {
109
+ if (!line || line.length < 3) continue
110
+ // porcelain=1 格式: "XY path" X=index状态, Y=worktree状态
111
+ const x = line[0]
112
+ const y = line[1]
113
+ const path = line.slice(3)
114
+ // 选择模式: 只保留所选路径
115
+ if (selectedSet && !selectedSet.has(path.replace(/\\/g, '/'))) continue
116
+ if (x === '?' && y === '?') {
117
+ untracked.push(path)
118
+ } else if (x !== ' ' || y !== ' ') {
119
+ // 有改动(暂存或工作区)
120
+ trackedChanges.push(`${x !== ' ' ? x : ' '} ${path}`.trim())
121
+ }
122
+ }
123
+ fileList = [...trackedChanges, ...untracked.map(p => `? ${p}`)]
124
+
125
+ // 2. 对 untracked 文件做 intent to add(只在内存中,不会真的暂存内容)
126
+ // 选择模式下 untracked 已被裁剪,只会对所选的做 -N,不污染整个 index
127
+ if (untracked.length > 0) {
128
+ // 加上 --force 以防某些文件已经在 index 中
129
+ // 分批处理,避免命令行过长
130
+ const batchSize = 20
131
+ for (let i = 0; i < untracked.length; i += batchSize) {
132
+ const batch = untracked.slice(i, i + batchSize)
133
+ try {
134
+ await execGitCommand(['add', '-N', '--force', ...batch], cwdOpts)
135
+ } catch (e) {
136
+ // 单批失败不影响整体
137
+ logger.warn('[generate-commit] git add -N failed for batch:', e?.message)
138
+ }
139
+ }
140
+ }
141
+
142
+ // 3. 合并 staged + unstaged diff
143
+ // 用 --no-color 避免 ANSI 干扰, --no-ext-diff 避免外接 diff 工具
144
+ // 选择模式下用 `-- <paths>` 限定,避免拉取无关 staged 改动
145
+ const pathArgs = selectedSet ? [...selectedSet] : []
146
+ const diffBase = ['diff', '--no-color', '--no-ext-diff']
147
+ const stagedArgs = ['diff', '--staged', '--no-color', '--no-ext-diff', ...(pathArgs.length ? ['--', ...pathArgs] : [])]
148
+ const unstagedArgs = [...diffBase, ...(pathArgs.length ? ['--', ...pathArgs] : [])]
149
+ const [stagedRes, unstagedRes] = await Promise.all([
150
+ execGitCommand(stagedArgs, cwdOpts).catch(() => ({ stdout: '' })),
151
+ execGitCommand(unstagedArgs, cwdOpts).catch(() => ({ stdout: '' }))
152
+ ])
153
+ let combined = ''
154
+ if (stagedRes?.stdout) combined += stagedRes.stdout.trim() + '\n'
155
+ if (unstagedRes?.stdout) combined += unstagedRes.stdout.trim() + '\n'
156
+
157
+ return { diff: combined.trim(), fileList }
158
+ } catch (error) {
159
+ logger.error('[generate-commit] collectDiffForAi error:', error?.message)
160
+ return { diff: '', fileList }
161
+ }
162
+ }
163
+
164
+ // 把 diff 压缩成给 LLM 的紧凑文本
165
+ // 策略: 跳过产物/资源文件(用一行 stat 带过),源码按优先级排序,每个文件 patch 限 1500 字,总预算 6000 字
166
+ function prepareDiffForPrompt(diffText, fileList) {
167
+ const safeFileList = Array.isArray(fileList) ? fileList : []
168
+ let files = parseDiffByFile(diffText)
169
+
170
+ // 如果客户端明确指定了文件列表,则只保留与所选文件匹配的 diff 块
171
+ if (safeFileList.length > 0 && files.length > 0) {
172
+ const selectedPaths = new Set(
173
+ safeFileList
174
+ .map(s => {
175
+ if (typeof s !== 'string') return ''
176
+ // fileList 形如 "M src/foo.ts" 或 "? new-file.ts",取最后的路径部分
177
+ const m = s.match(/^[A-Z?\s]+\s+(.+)$/)
178
+ return (m ? m[1] : s).replace(/\\/g, '/')
179
+ })
180
+ .filter(Boolean)
181
+ )
182
+ if (selectedPaths.size > 0) {
183
+ files = files.filter(f => selectedPaths.has(f.path.replace(/\\/g, '/')))
184
+ }
185
+ }
186
+
187
+ // 如果 parse 出来是空的(diff 可能是空或非标准格式),退回到 fileList 推断
188
+ if (files.length === 0) {
189
+ const list = safeFileList.slice(0, 30).map(s => {
190
+ // fileList 形如 "M src/foo.ts" 或 "? new-file.ts"
191
+ const m = s.match(/^[A-Z?\s]+\s+(.+)$/)
192
+ return m ? m[1] : s
193
+ })
194
+ if (list.length === 0) return ''
195
+ return list.map(p => {
196
+ if (isSkippedFile(p)) return `${p} [产物/资源,已跳过]`
197
+ return p
198
+ }).join('\n')
199
+ }
200
+
201
+ // 拆分: 跳过的 vs 保留的
202
+ const skipped = []
203
+ const kept = []
204
+ for (const f of files) {
205
+ if (isSkippedFile(f.path)) {
206
+ skipped.push(f)
207
+ } else {
208
+ kept.push(f)
209
+ }
210
+ }
211
+
212
+ // 保留的文件按优先级降序,同优先级按 +/- 总数降序(改得多的优先)
213
+ kept.sort((a, b) => {
214
+ const dp = filePriority(b.path) - filePriority(a.path)
215
+ if (dp !== 0) return dp
216
+ return (b.added + b.removed) - (a.added + a.removed)
217
+ })
218
+
219
+ const TOTAL_BUDGET = 6000
220
+ const PER_FILE_LIMIT = 1500
221
+ const lines = []
222
+ let budget = TOTAL_BUDGET
223
+
224
+ // 先把跳过的文件用一行 stat 总结
225
+ for (const f of skipped) {
226
+ if (budget < 80) break
227
+ lines.push(`${f.path} [+${f.added}/-${f.removed}, 已跳过产物/资源]`)
228
+ budget -= 80
229
+ }
230
+
231
+ // 再装源码,带预算控制
232
+ for (const f of kept) {
233
+ if (budget <= 50) break
234
+ let patch = f.patch
235
+ let truncated = false
236
+ if (patch.length > PER_FILE_LIMIT) {
237
+ patch = patch.slice(0, PER_FILE_LIMIT) + '\n... (diff 已截断)'
238
+ truncated = true
239
+ }
240
+ const block = `--- ${f.path} (+${f.added}/-${f.removed})${truncated ? ' [截断]' : ''}\n${patch}`
241
+ if (block.length > budget) {
242
+ // 单个文件塞不下了,截到能塞下为止
243
+ const sliceLen = Math.max(0, budget - 80)
244
+ if (sliceLen < 100) break
245
+ lines.push(`--- ${f.path} (+${f.added}/-${f.removed}) [预算耗尽,已截断]\n${patch.slice(0, sliceLen)}`)
246
+ budget = 0
247
+ break
248
+ }
249
+ lines.push(block)
250
+ budget -= block.length
251
+ }
252
+
253
+ if (lines.length === 0) return ''
254
+ return lines.join('\n\n')
255
+ }
256
+
257
+ export { prepareDiffForPrompt, parseDiffByFile, isSkippedFile, filePriority, collectDiffForAi }
258
+
259
+ export function registerConfigRoutes({
260
+ app,
261
+ configManager,
262
+ execGitCommand,
263
+ getCurrentProjectPath
264
+ }) {
265
+ // 保存最近访问的目录
266
+ app.post('/api/save_recent_directory', asyncRoute(async (req, res) => {
267
+ try {
268
+ const { path } = req.body;
269
+
270
+ if (!path) {
271
+ return res.status(400).json({
272
+ success: false,
273
+ error: '目录路径不能为空'
274
+ });
275
+ }
276
+
277
+ // 保存到配置
278
+ await configManager.saveRecentDirectory(path);
279
+
280
+ res.json({
281
+ success: true
282
+ });
283
+ } catch (error) {
284
+ res.status(500).json({
285
+ success: false,
286
+ error: error.message
287
+ });
288
+ }
289
+ }));
290
+
291
+ // 删除最近访问的目录
292
+ app.post('/api/remove_recent_directory', asyncRoute(async (req, res) => {
293
+ try {
294
+ const { path: dirPath } = req.body;
295
+ if (!dirPath) {
296
+ throw new HttpError(400, '目录路径不能为空');
297
+ }
298
+ const list = await configManager.removeRecentDirectory(dirPath);
299
+ res.json({ success: true, directories: list });
300
+ } catch (error) {
301
+ res.status(500).json({ success: false, error: error.message });
302
+ }
303
+ }));
304
+
305
+ // 获取配置
306
+ app.get('/api/config/getConfig', asyncRoute(async (req, res) => {
307
+ try {
308
+ const config = await configManager.loadConfig()
309
+
310
+ // 兼容旧数据:补齐自定义命令 id,避免前端编辑/删除/编排引用异常
311
+ if (Array.isArray(config.customCommands)) {
312
+ let changed = false
313
+ config.customCommands = config.customCommands.map((cmd) => {
314
+ if (cmd && typeof cmd === 'object' && !cmd.id) {
315
+ changed = true
316
+ return {
317
+ ...cmd,
318
+ id: `cmd_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
319
+ }
320
+ }
321
+ return cmd
322
+ })
323
+ if (changed) {
324
+ await configManager.saveConfig(config)
325
+ }
326
+ }
327
+
328
+ // 初始化命令模板(首次安装/旧配置兼容)
329
+ if (!Array.isArray(config.commandTemplates)) {
330
+ config.commandTemplates = []
331
+ }
332
+
333
+ if (config.commandTemplates.length === 0) {
334
+ config.commandTemplates = [
335
+ 'echo "{{cmd}}"',
336
+ 'npm run dev',
337
+ 'npm run build',
338
+ 'git status',
339
+ 'git add .',
340
+ 'git commit -m "{{message}}" --no-verify',
341
+ 'git push',
342
+ ]
343
+ await configManager.saveConfig(config)
344
+ }
345
+
346
+ res.json(config)
347
+ } catch (error) {
348
+ const configPath = path.join(os.homedir(), '.git-commit-tool.json')
349
+ res.status(500).json({
350
+ success: false,
351
+ code: 'CONFIG_LOAD_FAILED',
352
+ error: error?.message || String(error),
353
+ configPath
354
+ })
355
+ }
356
+ }));
357
+
358
+ // 保存默认提交信息
359
+ app.post('/api/config/saveDefaultMessage', express.json(), async (req, res) => {
360
+ try {
361
+ const { defaultCommitMessage } = req.body
362
+
363
+ if (!defaultCommitMessage) {
364
+ return res.status(400).json({ success: false, error: '缺少必要参数' })
365
+ }
366
+
367
+ const config = await configManager.loadConfig()
368
+
369
+ // 更新默认提交信息
370
+ config.defaultCommitMessage = defaultCommitMessage
371
+ await configManager.saveConfig(config)
372
+
373
+ res.json({ success: true })
374
+ } catch (error) {
375
+ res.status(500).json({ success: false, error: error.message })
376
+ }
377
+ })
378
+
379
+ // 保存所有配置
380
+ app.post('/api/config/saveAll', express.json(), async (req, res) => {
381
+ try {
382
+ const { config } = req.body
383
+
384
+ if (!config) {
385
+ return res.status(400).json({ success: false, error: '缺少必要参数' })
386
+ }
387
+
388
+ await configManager.saveConfig(config)
389
+
390
+ res.json({ success: true })
391
+ } catch (error) {
392
+ res.status(500).json({ success: false, error: error.message })
393
+ }
394
+ })
395
+
396
+ // 检查系统配置文件格式
397
+ app.get('/api/config/check-file-format', asyncRoute(async (req, res) => {
398
+ try {
399
+ const configPath = path.join(os.homedir(), '.git-commit-tool.json');
400
+
401
+ try {
402
+ const data = await fs.readFile(configPath, 'utf-8');
403
+ try {
404
+ JSON.parse(data);
405
+ res.json({ success: true, isValidJson: true, exists: true });
406
+ } catch (parseError) {
407
+ res.json({
408
+ success: true,
409
+ isValidJson: false,
410
+ exists: true,
411
+ error: `JSON解析失败: ${parseError.message}`
412
+ });
413
+ }
414
+ } catch (fileError) {
415
+ if (fileError.code === 'ENOENT') {
416
+ res.json({ success: true, isValidJson: true, exists: false });
417
+ } else {
418
+ res.json({
419
+ success: true,
420
+ isValidJson: false,
421
+ exists: true,
422
+ error: `文件读取失败: ${fileError.message}`
423
+ });
424
+ }
425
+ }
426
+ } catch (error) {
427
+ res.status(500).json({ success: false, error: error.message });
428
+ }
429
+ }));
430
+
431
+ // 使用系统默认程序打开配置文件 ~/.git-commit-tool.json
432
+ app.post('/api/config/open-file', asyncRoute(async (req, res) => {
433
+ try {
434
+ const filePath = path.join(os.homedir(), '.git-commit-tool.json');
435
+ try {
436
+ // 检查文件是否存在,不存在也尝试让系统创建(可能会打开空文件)
437
+ await fs.access(filePath);
438
+ } catch (_) {
439
+ // 如果文件不存在,先创建一个最小结构,避免某些系统无法打开不存在的路径
440
+ try {
441
+ await fs.writeFile(filePath, '{}', 'utf-8');
442
+ } catch (e) {
443
+ // 创建失败不阻断打开尝试
444
+ logger.warn('创建配置文件失败(可忽略):', e?.message || e);
445
+ }
446
+ }
447
+
448
+ await open(filePath, { wait: false });
449
+ res.json({ success: true })
450
+ } catch (error) {
451
+ res.status(400).json({ success: false, error: `无法打开配置文件: ${error.message}` })
452
+ }
453
+ }));
454
+
455
+ // 保存模板
456
+ app.post('/api/config/save-template', express.json(), async (req, res) => {
457
+ try {
458
+ const { template, type } = req.body
459
+
460
+ if (!template || !type) {
461
+ return res.status(400).json({ success: false, error: '缺少必要参数' })
462
+ }
463
+
464
+ const config = await configManager.loadConfig()
465
+
466
+ if (type === 'description') {
467
+ // 确保描述模板数组存在
468
+ if (!config.descriptionTemplates) {
469
+ config.descriptionTemplates = []
470
+ }
471
+
472
+ // 检查是否已存在相同模板
473
+ if (!config.descriptionTemplates.includes(template)) {
474
+ config.descriptionTemplates.push(template)
475
+ await configManager.saveConfig(config)
476
+ }
477
+ } else if (type === 'scope') {
478
+ // 确保作用域模板数组存在
479
+ if (!config.scopeTemplates) {
480
+ config.scopeTemplates = []
481
+ }
482
+
483
+ // 检查是否已存在相同模板
484
+ if (!config.scopeTemplates.includes(template)) {
485
+ config.scopeTemplates.push(template)
486
+ await configManager.saveConfig(config)
487
+ }
488
+ } else if (type === 'message') {
489
+ // 确保提交信息模板数组存在
490
+ if (!config.messageTemplates) {
491
+ config.messageTemplates = []
492
+ }
493
+
494
+ // 检查是否已存在相同模板
495
+ if (!config.messageTemplates.includes(template)) {
496
+ config.messageTemplates.push(template)
497
+ await configManager.saveConfig(config)
498
+ }
499
+ } else if (type === 'command') {
500
+ if (!config.commandTemplates) {
501
+ config.commandTemplates = []
502
+ }
503
+ if (!config.commandTemplates.includes(template)) {
504
+ config.commandTemplates.push(template)
505
+ await configManager.saveConfig(config)
506
+ }
507
+ } else {
508
+ return res.status(400).json({ success: false, error: '不支持的模板类型' })
509
+ }
510
+
511
+ res.json({ success: true })
512
+ } catch (error) {
513
+ res.status(500).json({ success: false, error: error.message })
514
+ }
515
+ })
516
+
517
+ // 删除模板
518
+ app.post('/api/config/delete-template', express.json(), async (req, res) => {
519
+ try {
520
+ const { template, type } = req.body
521
+
522
+ if (!template || !type) {
523
+ return res.status(400).json({ success: false, error: '缺少必要参数' })
524
+ }
525
+
526
+ const config = await configManager.loadConfig()
527
+
528
+ if (type === 'description') {
529
+ // 确保描述模板数组存在
530
+ if (config.descriptionTemplates) {
531
+ const index = config.descriptionTemplates.indexOf(template)
532
+ if (index !== -1) {
533
+ config.descriptionTemplates.splice(index, 1)
534
+ await configManager.saveConfig(config)
535
+ }
536
+ }
537
+ } else if (type === 'scope') {
538
+ // 确保作用域模板数组存在
539
+ if (config.scopeTemplates) {
540
+ const index = config.scopeTemplates.indexOf(template)
541
+ if (index !== -1) {
542
+ config.scopeTemplates.splice(index, 1)
543
+ await configManager.saveConfig(config)
544
+ }
545
+ }
546
+ } else if (type === 'message') {
547
+ // 确保提交信息模板数组存在
548
+ if (config.messageTemplates) {
549
+ const index = config.messageTemplates.indexOf(template)
550
+ if (index !== -1) {
551
+ config.messageTemplates.splice(index, 1)
552
+ await configManager.saveConfig(config)
553
+ }
554
+ }
555
+ } else if (type === 'command') {
556
+ if (config.commandTemplates) {
557
+ const index = config.commandTemplates.indexOf(template)
558
+ if (index !== -1) {
559
+ config.commandTemplates.splice(index, 1)
560
+ await configManager.saveConfig(config)
561
+ }
562
+ }
563
+ } else {
564
+ return res.status(400).json({ success: false, error: '不支持的模板类型' })
565
+ }
566
+
567
+ res.json({ success: true })
568
+ } catch (error) {
569
+ res.status(500).json({ success: false, error: error.message })
570
+ }
571
+ })
572
+
573
+ // 更新模板
574
+ app.post('/api/config/update-template', express.json(), async (req, res) => {
575
+ try {
576
+ const { oldTemplate, newTemplate, type } = req.body
577
+
578
+ if (!oldTemplate || !newTemplate || !type) {
579
+ return res.status(400).json({ success: false, error: '缺少必要参数' })
580
+ }
581
+
582
+ const config = await configManager.loadConfig()
583
+
584
+ if (type === 'description') {
585
+ // 确保描述模板数组存在
586
+ if (config.descriptionTemplates) {
587
+ const index = config.descriptionTemplates.indexOf(oldTemplate)
588
+ if (index !== -1) {
589
+ config.descriptionTemplates[index] = newTemplate
590
+ await configManager.saveConfig(config)
591
+ } else {
592
+ return res.status(404).json({ success: false, error: '未找到原模板' })
593
+ }
594
+ } else {
595
+ return res.status(404).json({ success: false, error: '模板列表不存在' })
596
+ }
597
+ } else if (type === 'scope') {
598
+ // 确保作用域模板数组存在
599
+ if (config.scopeTemplates) {
600
+ const index = config.scopeTemplates.indexOf(oldTemplate)
601
+ if (index !== -1) {
602
+ config.scopeTemplates[index] = newTemplate
603
+ await configManager.saveConfig(config)
604
+ } else {
605
+ return res.status(404).json({ success: false, error: '未找到原模板' })
606
+ }
607
+ } else {
608
+ return res.status(404).json({ success: false, error: '模板列表不存在' })
609
+ }
610
+ } else if (type === 'message') {
611
+ // 确保提交信息模板数组存在
612
+ if (config.messageTemplates) {
613
+ const index = config.messageTemplates.indexOf(oldTemplate)
614
+ if (index !== -1) {
615
+ config.messageTemplates[index] = newTemplate
616
+ await configManager.saveConfig(config)
617
+ } else {
618
+ return res.status(404).json({ success: false, error: '未找到原模板' })
619
+ }
620
+ } else {
621
+ return res.status(404).json({ success: false, error: '模板列表不存在' })
622
+ }
623
+ } else if (type === 'command') {
624
+ if (config.commandTemplates) {
625
+ const index = config.commandTemplates.indexOf(oldTemplate)
626
+ if (index !== -1) {
627
+ config.commandTemplates[index] = newTemplate
628
+ await configManager.saveConfig(config)
629
+ } else {
630
+ return res.status(404).json({ success: false, error: '未找到原模板' })
631
+ }
632
+ } else {
633
+ return res.status(404).json({ success: false, error: '模板列表不存在' })
634
+ }
635
+ } else {
636
+ return res.status(400).json({ success: false, error: '不支持的模板类型' })
637
+ }
638
+
639
+ res.json({ success: true })
640
+ } catch (error) {
641
+ res.status(500).json({ success: false, error: error.message })
642
+ }
643
+ })
644
+
645
+ // 置顶模板
646
+ app.post('/api/config/pin-template', express.json(), async (req, res) => {
647
+ try {
648
+ const { template, type } = req.body
649
+
650
+ if (!template || !type) {
651
+ return res.status(400).json({ success: false, error: '缺少必要参数' })
652
+ }
653
+
654
+ const config = await configManager.loadConfig()
655
+
656
+ if (type === 'description') {
657
+ if (config.descriptionTemplates) {
658
+ // 删除原位置的模板
659
+ config.descriptionTemplates = config.descriptionTemplates.filter(t => t !== template)
660
+ // 添加到第一位
661
+ config.descriptionTemplates.unshift(template)
662
+ await configManager.saveConfig(config)
663
+ } else {
664
+ return res.status(404).json({ success: false, error: '模板列表不存在' })
665
+ }
666
+ } else if (type === 'scope') {
667
+ if (config.scopeTemplates) {
668
+ config.scopeTemplates = config.scopeTemplates.filter(t => t !== template)
669
+ config.scopeTemplates.unshift(template)
670
+ await configManager.saveConfig(config)
671
+ } else {
672
+ return res.status(404).json({ success: false, error: '模板列表不存在' })
673
+ }
674
+ } else if (type === 'message') {
675
+ if (config.messageTemplates) {
676
+ config.messageTemplates = config.messageTemplates.filter(t => t !== template)
677
+ config.messageTemplates.unshift(template)
678
+ await configManager.saveConfig(config)
679
+ } else {
680
+ return res.status(404).json({ success: false, error: '模板列表不存在' })
681
+ }
682
+ } else if (type === 'command') {
683
+ if (config.commandTemplates) {
684
+ config.commandTemplates = config.commandTemplates.filter(t => t !== template)
685
+ config.commandTemplates.unshift(template)
686
+ await configManager.saveConfig(config)
687
+ } else {
688
+ return res.status(404).json({ success: false, error: '模板列表不存在' })
689
+ }
690
+ } else {
691
+ return res.status(400).json({ success: false, error: '不支持的模板类型' })
692
+ }
693
+
694
+ res.json({ success: true })
695
+ } catch (error) {
696
+ res.status(500).json({ success: false, error: error.message })
697
+ }
698
+ })
699
+
700
+ // 保存自定义命令
701
+ app.post('/api/config/save-custom-command', express.json(), async (req, res) => {
702
+ try {
703
+ const { command } = req.body
704
+
705
+ if (!command || !command.name || !command.command) {
706
+ return res.status(400).json({ success: false, error: '缺少必要参数' })
707
+ }
708
+
709
+ const config = await configManager.loadConfig()
710
+
711
+ // 确保自定义命令数组存在
712
+ if (!Array.isArray(config.customCommands)) {
713
+ config.customCommands = []
714
+ }
715
+
716
+ // 生成唯一ID
717
+ const id = `cmd_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
718
+ const newCommand = {
719
+ id,
720
+ name: command.name,
721
+ description: command.description || '',
722
+ directory: command.directory || '',
723
+ command: command.command,
724
+ params: Array.isArray(command.params) ? command.params : []
725
+ }
726
+
727
+ config.customCommands.push(newCommand)
728
+ await configManager.saveConfig(config)
729
+
730
+ res.json({ success: true, command: newCommand })
731
+ } catch (error) {
732
+ res.status(500).json({ success: false, error: error.message })
733
+ }
734
+ })
735
+
736
+ // 删除自定义命令
737
+ app.post('/api/config/delete-custom-command', express.json(), async (req, res) => {
738
+ try {
739
+ const { id } = req.body
740
+
741
+ if (!id) {
742
+ return res.status(400).json({ success: false, error: '缺少命令ID参数' })
743
+ }
744
+
745
+ const config = await configManager.loadConfig()
746
+
747
+ if (Array.isArray(config.customCommands)) {
748
+ const index = config.customCommands.findIndex(cmd => cmd.id === id)
749
+ if (index !== -1) {
750
+ config.customCommands.splice(index, 1)
751
+ await configManager.saveConfig(config)
752
+ }
753
+ }
754
+
755
+ res.json({ success: true })
756
+ } catch (error) {
757
+ res.status(500).json({ success: false, error: error.message })
758
+ }
759
+ })
760
+
761
+ // 置顶自定义命令(移到数组首位)
762
+ app.post('/api/config/pin-custom-command', express.json(), async (req, res) => {
763
+ try {
764
+ const { id } = req.body
765
+
766
+ if (!id) {
767
+ return res.status(400).json({ success: false, error: '缺少命令ID参数' })
768
+ }
769
+
770
+ const config = await configManager.loadConfig()
771
+
772
+ if (Array.isArray(config.customCommands)) {
773
+ const index = config.customCommands.findIndex(cmd => cmd.id === id)
774
+ if (index > 0) {
775
+ const [cmd] = config.customCommands.splice(index, 1)
776
+ config.customCommands.unshift(cmd)
777
+ await configManager.saveConfig(config)
778
+ }
779
+ }
780
+
781
+ res.json({ success: true })
782
+ } catch (error) {
783
+ res.status(500).json({ success: false, error: error.message })
784
+ }
785
+ })
786
+
787
+ // 更新自定义命令
788
+ app.post('/api/config/update-custom-command', express.json(), async (req, res) => {
789
+ try {
790
+ const { id, command } = req.body
791
+
792
+ if (!id || !command || !command.name || !command.command) {
793
+ return res.status(400).json({ success: false, error: '缺少必要参数' })
794
+ }
795
+
796
+ const config = await configManager.loadConfig()
797
+
798
+ if (Array.isArray(config.customCommands)) {
799
+ const index = config.customCommands.findIndex(cmd => cmd.id === id)
800
+ if (index !== -1) {
801
+ config.customCommands[index] = {
802
+ id,
803
+ name: command.name,
804
+ description: command.description || '',
805
+ directory: command.directory || '',
806
+ command: command.command,
807
+ params: Array.isArray(command.params) ? command.params : []
808
+ }
809
+ await configManager.saveConfig(config)
810
+ } else {
811
+ return res.status(404).json({ success: false, error: '未找到指定命令' })
812
+ }
813
+ } else {
814
+ return res.status(404).json({ success: false, error: '命令列表不存在' })
815
+ }
816
+
817
+ res.json({ success: true })
818
+ } catch (error) {
819
+ res.status(500).json({ success: false, error: error.message })
820
+ }
821
+ })
822
+
823
+ // 保存指令编排
824
+ app.post('/api/config/save-orchestration', express.json(), async (req, res) => {
825
+ try {
826
+ const { orchestration } = req.body
827
+
828
+ if (!orchestration || !orchestration.name) {
829
+ return res.status(400).json({ success: false, error: '缺少必要参数' })
830
+ }
831
+
832
+ const config = await configManager.loadConfig()
833
+
834
+ // 确保编排数组存在
835
+ if (!Array.isArray(config.orchestrations)) {
836
+ config.orchestrations = []
837
+ }
838
+
839
+ // 生成唯一ID
840
+ const id = `orch_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
841
+ const newOrchestration = {
842
+ id,
843
+ name: orchestration.name,
844
+ description: orchestration.description || '',
845
+ flowData: orchestration.flowData || null
846
+ }
847
+
848
+ config.orchestrations.push(newOrchestration)
849
+ await configManager.saveConfig(config)
850
+
851
+ res.json({ success: true, orchestration: newOrchestration })
852
+ } catch (error) {
853
+ res.status(500).json({ success: false, error: error.message })
854
+ }
855
+ })
856
+
857
+ // 删除指令编排
858
+ app.post('/api/config/delete-orchestration', express.json(), async (req, res) => {
859
+ try {
860
+ const { id } = req.body
861
+
862
+ if (!id) {
863
+ return res.status(400).json({ success: false, error: '缺少编排ID参数' })
864
+ }
865
+
866
+ const config = await configManager.loadConfig()
867
+
868
+ if (Array.isArray(config.orchestrations)) {
869
+ const index = config.orchestrations.findIndex(orch => orch.id === id)
870
+ if (index !== -1) {
871
+ config.orchestrations.splice(index, 1)
872
+ await configManager.saveConfig(config)
873
+ }
874
+ }
875
+
876
+ res.json({ success: true })
877
+ } catch (error) {
878
+ res.status(500).json({ success: false, error: error.message })
879
+ }
880
+ })
881
+
882
+ // 更新指令编排
883
+ app.post('/api/config/update-orchestration', express.json(), async (req, res) => {
884
+ try {
885
+ const { id, orchestration } = req.body
886
+
887
+ if (!id || !orchestration || !orchestration.name) {
888
+ return res.status(400).json({ success: false, error: '缺少必要参数' })
889
+ }
890
+
891
+ const config = await configManager.loadConfig()
892
+
893
+ if (Array.isArray(config.orchestrations)) {
894
+ const index = config.orchestrations.findIndex(orch => orch.id === id)
895
+ if (index !== -1) {
896
+ config.orchestrations[index] = {
897
+ id,
898
+ name: orchestration.name,
899
+ description: orchestration.description || '',
900
+ flowData: orchestration.flowData || null
901
+ }
902
+ await configManager.saveConfig(config)
903
+ } else {
904
+ return res.status(404).json({ success: false, error: '未找到指定编排' })
905
+ }
906
+ } else {
907
+ return res.status(404).json({ success: false, error: '编排列表不存在' })
908
+ }
909
+
910
+ res.json({ success: true })
911
+ } catch (error) {
912
+ res.status(500).json({ success: false, error: error.message })
913
+ }
914
+ })
915
+
916
+ // 保存项目启动项
917
+ app.post('/api/config/save-startup-items', express.json(), async (req, res) => {
918
+ try {
919
+ const { startupItems, startupAutoRun } = req.body
920
+
921
+ if (!Array.isArray(startupItems)) {
922
+ return res.status(400).json({ success: false, error: '启动项必须是数组' })
923
+ }
924
+
925
+ const config = await configManager.loadConfig()
926
+ config.startupItems = startupItems
927
+ if (typeof startupAutoRun === 'boolean') {
928
+ config.startupAutoRun = startupAutoRun
929
+ }
930
+ await configManager.saveConfig(config)
931
+
932
+ res.json({ success: true })
933
+ } catch (error) {
934
+ res.status(500).json({ success: false, error: error.message })
935
+ }
936
+ })
937
+
938
+ // 保存提交设置
939
+ app.post('/api/config/save-commit-settings', express.json(), async (req, res) => {
940
+ try {
941
+ const { isStandardCommit, skipHooks, autoQuickPushOnEnter, autoSetDefaultMessage, autoClosePushModal, pullBeforePush } = req.body
942
+ const config = await configManager.loadConfig()
943
+ if (isStandardCommit !== undefined) config.isStandardCommit = Boolean(isStandardCommit)
944
+ if (skipHooks !== undefined) config.skipHooks = Boolean(skipHooks)
945
+ if (autoQuickPushOnEnter !== undefined) config.autoQuickPushOnEnter = Boolean(autoQuickPushOnEnter)
946
+ if (autoSetDefaultMessage !== undefined) config.autoSetDefaultMessage = Boolean(autoSetDefaultMessage)
947
+ if (autoClosePushModal !== undefined) config.autoClosePushModal = Boolean(autoClosePushModal)
948
+ if (pullBeforePush !== undefined) config.pullBeforePush = Boolean(pullBeforePush)
949
+ await configManager.saveConfig(config)
950
+ res.json({ success: true })
951
+ } catch (error) {
952
+ res.status(500).json({ success: false, error: error.message })
953
+ }
954
+ })
955
+
956
+ // 保存"一键推送成功后启动项"
957
+ app.post('/api/config/save-after-quick-push-action', express.json(), async (req, res) => {
958
+ try {
959
+ const { afterQuickPushAction } = req.body
960
+
961
+ if (!afterQuickPushAction || typeof afterQuickPushAction !== 'object') {
962
+ return res.status(400).json({ success: false, error: '缺少必要参数' })
963
+ }
964
+
965
+ const enabled = Boolean(afterQuickPushAction.enabled)
966
+ const type = afterQuickPushAction.type === 'workflow' ? 'workflow' : 'command'
967
+ const refId = String(afterQuickPushAction.refId || '').trim()
968
+
969
+ const config = await configManager.loadConfig()
970
+ config.afterQuickPushAction = {
971
+ enabled,
972
+ type,
973
+ refId
974
+ }
975
+ await configManager.saveConfig(config)
976
+
977
+ res.json({ success: true })
978
+ } catch (error) {
979
+ res.status(500).json({ success: false, error: error.message })
980
+ }
981
+ })
982
+
983
+ // 保存通用设置(主题和语言)
984
+ app.post('/api/config/save-general-settings', express.json(), async (req, res) => {
985
+ try {
986
+ const { theme, locale } = req.body
987
+
988
+ // 读取原始配置以保留项目设置
989
+ const rawConfig = await configManager.readRawConfigFile()
990
+
991
+ // 更新全局设置
992
+ if (theme && ['light', 'dark', 'auto'].includes(theme)) {
993
+ rawConfig.theme = theme
994
+ }
995
+ if (locale && ['zh-CN', 'en-US'].includes(locale)) {
996
+ rawConfig.locale = locale
997
+ }
998
+
999
+ // 直接写入原始配置,避免覆盖项目设置
1000
+ await configManager.writeRawConfigFile(rawConfig)
1001
+ res.json({ success: true })
1002
+ } catch (error) {
1003
+ res.status(500).json({ success: false, error: error.message })
1004
+ }
1005
+ })
1006
+
1007
+ // 保存 UI 状态(视图模式/分割比例/控制台状态/布局比例/编辑器自动保存等)
1008
+ // 接受 partial body,浅合并到顶层 ui 对象。例:{ layout: {...} } / { commandConsole: {...} } / { fileListViewMode: 'tree' }
1009
+ // 例外:layoutsByProject 是"按项目 cwd layout"的 map;多个实例并行修改不同项目时
1010
+ // 浅替换会丢失其它项目条目,因此对这个 key 做 per-project 合并。
1011
+ app.post('/api/config/save-ui-settings', express.json(), async (req, res) => {
1012
+ try {
1013
+ const partial = req.body && typeof req.body === 'object' ? req.body : {}
1014
+
1015
+ const rawConfig = await configManager.readRawConfigFile()
1016
+
1017
+ // 确保 ui 容器存在
1018
+ if (!rawConfig.ui || typeof rawConfig.ui !== 'object' || Array.isArray(rawConfig.ui)) {
1019
+ rawConfig.ui = {}
1020
+ }
1021
+ if (!rawConfig.ui.layoutsByProject || typeof rawConfig.ui.layoutsByProject !== 'object' || Array.isArray(rawConfig.ui.layoutsByProject)) {
1022
+ rawConfig.ui.layoutsByProject = {}
1023
+ }
1024
+
1025
+ // 浅合并顶层 ui 字段(支持嵌套对象整体替换,如 commandConsole)
1026
+ for (const key of Object.keys(partial)) {
1027
+ if (key === 'layoutsByProject') {
1028
+ // 深合并 map<string, layout>:保留其它项目条目
1029
+ const incoming = partial[key]
1030
+ if (incoming && typeof incoming === 'object' && !Array.isArray(incoming)) {
1031
+ rawConfig.ui.layoutsByProject = {
1032
+ ...rawConfig.ui.layoutsByProject,
1033
+ ...incoming,
1034
+ }
1035
+ }
1036
+ continue
1037
+ }
1038
+ rawConfig.ui[key] = partial[key]
1039
+ }
1040
+
1041
+ await configManager.writeRawConfigFile(rawConfig)
1042
+ res.json({ success: true })
1043
+ } catch (error) {
1044
+ logger.error('[save-ui-settings] failed:', error)
1045
+ res.status(500).json({ success: false, error: error.message })
1046
+ }
1047
+ })
1048
+
1049
+ // 保存 AI 模型配置(models 是全局配置,存在配置文件顶层,跨项目共享)
1050
+ app.post('/api/config/save-models', express.json(), async (req, res) => {
1051
+ try {
1052
+ const { models } = req.body
1053
+ if (!Array.isArray(models)) {
1054
+ return res.status(400).json({ success: false, error: '缺少 models 参数' })
1055
+ }
1056
+ const rawConfig = await configManager.readRawConfigFile()
1057
+ rawConfig.models = models
1058
+ await configManager.writeRawConfigFile(rawConfig)
1059
+ res.json({ success: true })
1060
+ } catch (error) {
1061
+ res.status(500).json({ success: false, error: error.message })
1062
+ }
1063
+ })
1064
+
1065
+ // 测试 AI 模型是否可用
1066
+ app.post('/api/config/test-model', express.json(), async (req, res) => {
1067
+ const { baseURL, model, apiKey } = req.body || {}
1068
+ if (!baseURL || !model) {
1069
+ return res.status(400).json({ success: false, error: '缺少 baseURL 或 model 参数' })
1070
+ }
1071
+ try {
1072
+ const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }))
1073
+ const url = `${baseURL.replace(/\/$/, '')}/chat/completions`
1074
+ const headers = { 'Content-Type': 'application/json' }
1075
+ if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
1076
+ const body = JSON.stringify({
1077
+ model,
1078
+ messages: [{ role: 'user', content: 'Hello, reply with just "ok".' }],
1079
+ max_tokens: 16,
1080
+ stream: false
1081
+ })
1082
+ const controller = new AbortController()
1083
+ const timer = setTimeout(() => controller.abort(), 15000)
1084
+ let response
1085
+ try {
1086
+ response = await fetch(url, { method: 'POST', headers, body, signal: controller.signal })
1087
+ } finally {
1088
+ clearTimeout(timer)
1089
+ }
1090
+ const data = await response.json().catch(() => ({}))
1091
+ if (!response.ok) {
1092
+ const msg = data?.error?.message || data?.message || `HTTP ${response.status}`
1093
+ return res.json({ success: false, error: msg })
1094
+ }
1095
+ const reply = data?.choices?.[0]?.message?.content || data?.choices?.[0]?.text || '✅'
1096
+ res.json({ success: true, reply: reply.trim().slice(0, 100) })
1097
+ } catch (error) {
1098
+ const msg = error.name === 'AbortError' ? '请求超时(15s)' : error.message
1099
+ res.json({ success: false, error: msg })
1100
+ }
1101
+ })
1102
+
1103
+ // AI 生成提交信息
1104
+ app.post('/api/config/generate-commit-message', express.json(), async (req, res) => {
1105
+ const { selectedPaths, locale: reqLocale } = req.body || {}
1106
+ try {
1107
+ const rawConfig = await configManager.readRawConfigFile()
1108
+ const userLocale = reqLocale || rawConfig.locale || 'zh-CN'
1109
+ const models = Array.isArray(rawConfig.models) ? rawConfig.models : []
1110
+ const defaultModel = models.find(m => m.isDefault) || models[0]
1111
+ if (!defaultModel) {
1112
+ return res.json({ success: false, error: '未配置 AI 模型,请先在通用设置中添加模型', code: 'NO_MODEL' })
1113
+ }
1114
+
1115
+ // 后端自己收集 diff,确保 untracked 文件也能进 prompt
1116
+ // 选择模式下传入 selectedPaths,后端在 git 层面就过滤,避免污染 index
1117
+ const { diff: rawDiff, fileList: serverFileList } = await collectDiffForAi({
1118
+ execGitCommand,
1119
+ getCurrentProjectPath,
1120
+ selectedPaths
1121
+ })
1122
+ const diffText = prepareDiffForPrompt(rawDiff, serverFileList)
1123
+ const filesText = serverFileList.slice(0, 30).join('\n')
1124
+
1125
+ const promptZh = `你是一个 Git 提交信息生成助手。根据以下 git diff 信息,生成一条符合 Conventional Commits 规范的提交信息。
1126
+
1127
+ 要求:
1128
+ 1. type 只能是:feat/fix/docs/style/refactor/test/chore 之一
1129
+ 2. scope 可选,表示影响范围,简短英文或中文,如果改动范围明确就填
1130
+ 3. description 用中文简短描述本次变更(不超过50字)
1131
+ 4. 只返回 JSON,格式:{"type":"feat","scope":"","description":"xxx"}
1132
+
1133
+ 变更文件:
1134
+ ${filesText}
1135
+
1136
+ git diff --staged:
1137
+ ${diffText || '(无 staged 内容,请根据文件列表推断)'}`
1138
+
1139
+ const promptEn = `You are a Git commit message generation assistant. Based on the following git diff, generate a commit message that follows the Conventional Commits specification.
1140
+
1141
+ Requirements:
1142
+ 1. type must be one of: feat/fix/docs/style/refactor/test/chore
1143
+ 2. scope is optional; use a short English word or short noun phrase to indicate the affected area. Leave empty if unclear.
1144
+ 3. description must be a concise English summary of the change (no more than 50 characters). Use the imperative mood (e.g. "add login button", not "added" or "adding").
1145
+ 4. Return ONLY a JSON object in the format: {"type":"feat","scope":"","description":"xxx"}
1146
+
1147
+ Changed files:
1148
+ ${filesText}
1149
+
1150
+ git diff --staged:
1151
+ ${diffText || '(no staged content, please infer from the file list)'}`
1152
+
1153
+ const prompt = userLocale.startsWith('en') ? promptEn : promptZh
1154
+
1155
+ // AI prompt 默认不打印,只有 DEBUG=1 / DEBUG=logger 时才落地(防 Token 泄露)
1156
+ logger.debug('[generate-commit] locale:', userLocale, '| prompt length:', prompt.length)
1157
+ logger.debug('[generate-commit] prompt:\n' + prompt)
1158
+ const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }))
1159
+ const url = `${defaultModel.baseURL.replace(/\/$/, '')}/chat/completions`
1160
+ const headers = { 'Content-Type': 'application/json' }
1161
+ if (defaultModel.apiKey) headers['Authorization'] = `Bearer ${defaultModel.apiKey}`
1162
+ const body = JSON.stringify({
1163
+ model: defaultModel.model,
1164
+ messages: [{ role: 'user', content: prompt }],
1165
+ max_tokens: 1024,
1166
+ temperature: 0.3,
1167
+ stream: false
1168
+ })
1169
+ const controller = new AbortController()
1170
+ const timer = setTimeout(() => controller.abort(), 60000)
1171
+ let response
1172
+ try {
1173
+ response = await fetch(url, { method: 'POST', headers, body, signal: controller.signal })
1174
+ } finally {
1175
+ clearTimeout(timer)
1176
+ }
1177
+ const data = await response.json().catch(() => ({}))
1178
+ if (!response.ok) {
1179
+ const msg = data?.error?.message || data?.message || `HTTP ${response.status}`
1180
+ return res.json({ success: false, error: msg, code: 'HTTP_ERR' })
1181
+ }
1182
+ const content = data?.choices?.[0]?.message?.content || ''
1183
+ // 响应内容只走 debug(默认关闭),前 600 字截断防 token 泄露
1184
+ logger.debug('[generate-commit] raw content length:', content.length, JSON.stringify(content).slice(0, 600))
1185
+
1186
+ const codeBlockMatch = content.match(/```(?:json)?\s*(\{[^`]*?\})\s*```/)
1187
+ const jsonMatch = codeBlockMatch
1188
+ ? [codeBlockMatch[1]]
1189
+ : [...content.matchAll(/\{[^{}]*\}/g)].at(-1)
1190
+
1191
+ if (!jsonMatch) {
1192
+ logger.error('[generate-commit] no JSON found, full content:', content)
1193
+ return res.json({ success: false, error: 'model returned no valid JSON', code: 'NO_JSON' })
1194
+ }
1195
+ let parsed
1196
+ try {
1197
+ parsed = JSON.parse(jsonMatch[0])
1198
+ } catch {
1199
+ const typeM = jsonMatch[0].match(/"type"\s*:\s*"([^"]+)"/)
1200
+ const scopeM = jsonMatch[0].match(/"scope"\s*:\s*"([^"]*)"/)
1201
+ const descM = jsonMatch[0].match(/"description"\s*:\s*"([^"]+)"/)
1202
+ if (typeM || descM) {
1203
+ return res.json({
1204
+ success: true,
1205
+ type: (typeM?.[1] || 'feat').trim(),
1206
+ scope: (scopeM?.[1] || '').trim(),
1207
+ description: (descM?.[1] || '').trim()
1208
+ })
1209
+ }
1210
+ return res.json({ success: false, error: 'JSON parse failed', code: 'PARSE_FAILED' })
1211
+ }
1212
+ res.json({
1213
+ success: true,
1214
+ type: String(parsed.type || 'feat').trim(),
1215
+ scope: String(parsed.scope || '').trim(),
1216
+ description: String(parsed.description || '').trim()
1217
+ })
1218
+ } catch (error) {
1219
+ const isTimeout = error.name === 'AbortError'
1220
+ const msg = isTimeout ? 'AI 请求超时(60s),请重试或检查模型响应速度' : error.message
1221
+ res.json({ success: false, error: msg, code: isTimeout ? 'TIMEOUT' : 'GENERATE_FAILED' })
1222
+ }
1223
+ })
1224
+ }