zen-gitsync 2.18.26 → 2.19.1

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 (74) hide show
  1. package/package.json +6 -4
  2. package/src/aiCommit.js +61 -54
  3. package/src/cli/ai/tools.js +71 -8
  4. package/src/cli/ai/webSearch.js +108 -21
  5. package/src/cli/peer.js +272 -0
  6. package/src/config.js +14 -4
  7. package/src/gitCommit.js +34 -1
  8. package/src/paths.js +6 -0
  9. package/src/peer/client.js +165 -0
  10. package/src/peer/localAsk.js +130 -0
  11. package/src/peer/server.js +183 -0
  12. package/src/peer/store.js +292 -0
  13. package/src/ui/public/assets/AgentView-BtnAYtte.js +42 -0
  14. package/src/ui/public/assets/{AgentView-Bslr4Bc8.css → AgentView-ByQ0iJFU.css} +1 -1
  15. package/src/ui/public/assets/AppVersionBadge-BcXo-yuu.js +2 -0
  16. package/src/ui/public/assets/{BranchSelector-D4Igm7sR.js → BranchSelector-5t-T3FB6.js} +1 -1
  17. package/src/ui/public/assets/{CommitForm-CXBA-E-6.js → CommitForm-BMfp3Eqh.js} +3 -3
  18. package/src/ui/public/assets/{CommonDialog-DrbQC1fm.js → CommonDialog-BiF2fMgr.js} +1 -1
  19. package/src/ui/public/assets/{EditorView-CXj7-8DC.js → EditorView-CtmZ7RZp.js} +1 -1
  20. package/src/ui/public/assets/{FlowExecutionViewer-CjL1DfmI.js → FlowExecutionViewer-D2lchSo7.js} +1 -1
  21. package/src/ui/public/assets/{FlowOrchestrationWorkspace-p7_NcaY-.js → FlowOrchestrationWorkspace-BY2zLwWB.js} +1 -1
  22. package/src/ui/public/assets/{LogList-SDV2f7R6.js → LogList-DTfAGnlt.js} +4 -4
  23. package/src/ui/public/assets/MindmapView-DArneR4u.js +2 -0
  24. package/src/ui/public/assets/MindmapView-PoMd2cab.css +1 -0
  25. package/src/ui/public/assets/{MonitorView-G1MQ7M0F.js → MonitorView-BQvMyjEj.js} +1 -1
  26. package/src/ui/public/assets/ProjectManagerView-BPnAWNBJ.css +1 -0
  27. package/src/ui/public/assets/{ProjectManagerView-Cg3GLLxQ.js → ProjectManagerView-BRUyrHfg.js} +1 -1
  28. package/src/ui/public/assets/{ProjectStartupButton-DMlDI4Xn.js → ProjectStartupButton-D0f8OAb-.js} +1 -1
  29. package/src/ui/public/assets/{RemoteManagerDialog-BZLYsW8E.js → RemoteManagerDialog-DXDXCfyD.js} +1 -1
  30. package/src/ui/public/assets/{RemoteRepoCard-BqHQho7O.js → RemoteRepoCard-BRnjC6vm.js} +1 -1
  31. package/src/ui/public/assets/{SourceMapView-CHl1yfAe.js → SourceMapView-B6xgG5mi.js} +2 -2
  32. package/src/ui/public/assets/{SvgIcon-EoI0db7X.js → SvgIcon-GLj_3KcS.js} +1 -1
  33. package/src/ui/public/assets/{UserInputNode-CbwrGTgh.js → UserInputNode-C6Z6Kys_.js} +1 -1
  34. package/src/ui/public/assets/{WorkbenchView-hSDyngLo.js → WorkbenchView-B2EUXleS.js} +98 -97
  35. package/src/ui/public/assets/WorkbenchView-DCL6zHAP.css +1 -0
  36. package/src/ui/public/assets/_plugin-vue_export-helper-DaO48JOV.js +19 -0
  37. package/src/ui/public/assets/configStore-B_2ifbl_.js +1 -0
  38. package/src/ui/public/assets/{index-CUUfiQYd.js → index-DUzH5036.js} +100 -98
  39. package/src/ui/public/assets/index-zdGFM_7W.css +1 -0
  40. package/src/ui/public/assets/{relativeTime-257j87Fm.js → relativeTime-DzYQUHQo.js} +1 -1
  41. package/src/ui/public/assets/taskExecutionExport-C3vFixdJ.js +15 -0
  42. package/src/ui/public/assets/{useWorkbenchProjects-Cj3XIq94.js → useWorkbenchProjects-0fWZsDRX.js} +1 -1
  43. package/src/ui/public/index.html +6 -7
  44. package/src/ui/server/index.js +130 -10
  45. package/src/ui/server/routes/codeAnalysis.js +39 -16
  46. package/src/ui/server/routes/config.js +1396 -1375
  47. package/src/ui/server/routes/git/aiDiffSummary.js +26 -11
  48. package/src/ui/server/routes/peerSettings.js +164 -0
  49. package/src/ui/server/routes/projectStartupAi.js +21 -11
  50. package/src/ui/server/routes/recentDirectoriesAiSummary.js +26 -13
  51. package/src/ui/server/routes/workbench/agentChat.js +811 -811
  52. package/src/ui/server/routes/workbench/agentRoutes.js +9 -0
  53. package/src/ui/server/routes/workbench/imageDescribe.js +52 -16
  54. package/src/ui/server/utils/ensureHub.js +101 -0
  55. package/src/ui/server/utils/instanceRegistry.js +8 -3
  56. package/src/ui/server/utils/peerRuntime.js +77 -0
  57. package/src/ui/server/utils/startServerOnAvailablePort.js +6 -0
  58. package/src/utils/aiModelFallback.js +180 -0
  59. package/src/utils/aiModelFallback.test.js +166 -0
  60. package/src/ui/public/assets/AgentView-BgsIfH1V.js +0 -27
  61. package/src/ui/public/assets/AppVersionBadge-vKeEhoxI.js +0 -2
  62. package/src/ui/public/assets/ChatSilenceBadge-D-lbhnwH.js +0 -16
  63. package/src/ui/public/assets/ChatSilenceBadge-aok2usEh.css +0 -1
  64. package/src/ui/public/assets/MindmapView-BWXQHyV7.js +0 -35
  65. package/src/ui/public/assets/MindmapView-CoUbyadF.css +0 -1
  66. package/src/ui/public/assets/ProjectManagerView-DhC-Y5PR.css +0 -1
  67. package/src/ui/public/assets/TaskExecutorPicker-cEgHnI-W.js +0 -1
  68. package/src/ui/public/assets/WorkbenchView-BVTi_0Mn.css +0 -1
  69. package/src/ui/public/assets/_plugin-vue_export-helper-Birg7-Pq.js +0 -19
  70. package/src/ui/public/assets/configStore-DPzIt2AS.js +0 -1
  71. package/src/ui/public/assets/index-BJJSr6Sb.css +0 -1
  72. package/src/ui/public/assets/taskExecutionExport-CplN8eIn.js +0 -15
  73. package/src/ui/public/assets/toolsStore-BjG-9k17.js +0 -1
  74. /package/src/ui/public/assets/{TaskExecutorPicker-CdRhuBxH.css → taskExecutionExport-CdRhuBxH.css} +0 -0
@@ -1,1375 +1,1396 @@
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 fs from 'fs/promises';
19
- import open from 'open';
20
- import logger from '../utils/logger.js';
21
- import { CONFIG_FILE } from '../../../paths.js';
22
- // AI 请求的地址/请求头统一走这里:OpenCode 网关要求自报 User-Agent 并带
23
- // x-opencode-session,否则直接 4xx(见 src/utils/aiEndpoint.js 顶部说明)
24
- import { buildAiChatRequest } from '../../../utils/aiEndpoint.js';
25
- // 请求预算的解析公式住在 cli/ai/context.js —— 前端拿"解析后的三参数"做超长输入
26
- // 预警时,服务端下发同一份解析结果,不在前端重算(口径只有一处)。
27
- import { resolveRequestBudget } from '../../../cli/ai/context.js';
28
-
29
- // 跳过的产物/资源/lock 文件(用 stat 一行带过,不打 patch)
30
- const SKIP_FILE_PATTERNS = [
31
- /(^|[\\/])dist[\\/]/,
32
- /(^|[\\/])build[\\/]/,
33
- /(^|[\\/])\.next[\\/]/,
34
- /(^|[\\/])coverage[\\/]/,
35
- /(^|[\\/])node_modules[\\/]/,
36
- /\.lock$/,
37
- /package-lock\.json$/,
38
- /yarn\.lock$/,
39
- /pnpm-lock\.yaml$/,
40
- /min\.js$/,
41
- /min\.css$/,
42
- /\.bundle\.js$/,
43
- /\.map$/,
44
- /\.(png|jpe?g|gif|webp|svg|ico|bmp|tiff)$/,
45
- /\.(mp4|mov|avi|mkv|webm)$/,
46
- /\.(mp3|wav|ogg|flac)$/,
47
- /\.(woff2?|ttf|otf|eot)$/,
48
- /\.(pdf|zip|tar|gz|7z|rar)$/
49
- ]
50
-
51
- // 文件优先级:数字越大越重要
52
- function filePriority(p) {
53
- if (/\.(test|spec)\.(js|ts|jsx|tsx|vue)$/i.test(p)) return 1 // 测试文件
54
- if (/^docs?\//i.test(p) || /\.md$/i.test(p)) return 2 // 文档
55
- if (/\.(json|ya?ml|toml|env|ini|cfg|conf)$/i.test(p)) return 3 // 配置
56
- if (/\.(css|scss|sass|less|html|vue|jsx|tsx)$/i.test(p)) return 4 // 前端
57
- if (/\.(ts|js|mjs|cjs)$/i.test(p)) return 5 // 后端/脚本
58
- return 3 // 其它(资源、未知)
59
- }
60
-
61
- function isSkippedFile(path) {
62
- return SKIP_FILE_PATTERNS.some(re => re.test(path))
63
- }
64
-
65
- // 从 git diff 文本解析出 per-file 块
66
- // git diff 输出格式: "diff --git a/path b/path\n...一系列 header...\n--- a/path\n+++ b/path\n@@ ...\n<patch>"
67
- function parseDiffByFile(diffText) {
68
- if (!diffText) return []
69
- const files = []
70
- // 按 "diff --git " 切分,首段可能为空(文本以这个开头则第一段空)
71
- const parts = diffText.split(/^diff --git /m).filter(Boolean)
72
- for (const part of parts) {
73
- const headerEnd = part.indexOf('\n')
74
- const headerLine = (headerEnd >= 0 ? part.slice(0, headerEnd) : part).trim()
75
- // 从 header 里抓路径: "a/path b/path" -> 优先 b
76
- const m = headerLine.match(/^a\/(.+?)\s+b\/(.+)$/)
77
- if (!m) continue
78
- const bPath = m[2]
79
- const patch = part.slice(headerEnd + 1)
80
- // 统计 +/- 行数
81
- let added = 0, removed = 0
82
- for (const line of patch.split('\n')) {
83
- if (line.startsWith('+') && !line.startsWith('+++')) added++
84
- else if (line.startsWith('-') && !line.startsWith('---')) removed++
85
- }
86
- files.push({ path: bPath, patch, added, removed })
87
- }
88
- return files
89
- }
90
-
91
- // 收集 AI 生成提交信息所需的 diff 和文件列表
92
- // 关键: untracked 文件默认不会出现在 git diff --staged 输出里,
93
- // 所以先对所有 untracked 文件做 git add -N(intent to add),
94
- // 这样它们会以 "new file" 形式出现在 diff 里
95
- // selectedPaths: 若给定且非空,则只收集这些路径的 diff,且 git add -N 也仅作用于所选 untracked,
96
- // 避免污染整个 index
97
- async function collectDiffForAi({ execGitCommand, getCurrentProjectPath, selectedPaths }) {
98
- if (typeof execGitCommand !== 'function') {
99
- return { diff: '', fileList: [] }
100
- }
101
- const projectPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : ''
102
- const cwdOpts = projectPath ? { cwd: projectPath, log: false } : { log: false }
103
-
104
- const selectedSet = Array.isArray(selectedPaths) && selectedPaths.length > 0
105
- ? new Set(selectedPaths.map(p => String(p).replace(/\\/g, '/')))
106
- : null
107
-
108
- let fileList = []
109
- try {
110
- // 1. 拿到工作区状态,识别 untracked 文件
111
- const { stdout: statusOut } = await execGitCommand(['status', '--porcelain=1', '--untracked-files=all'], cwdOpts)
112
- const untracked = []
113
- const trackedChanges = []
114
- for (const line of (statusOut || '').split('\n')) {
115
- if (!line || line.length < 3) continue
116
- // porcelain=1 格式: "XY path" X=index状态, Y=worktree状态
117
- const x = line[0]
118
- const y = line[1]
119
- const path = line.slice(3)
120
- // 选择模式: 只保留所选路径
121
- if (selectedSet && !selectedSet.has(path.replace(/\\/g, '/'))) continue
122
- if (x === '?' && y === '?') {
123
- untracked.push(path)
124
- } else if (x !== ' ' || y !== ' ') {
125
- // 有改动(暂存或工作区)
126
- trackedChanges.push(`${x !== ' ' ? x : ' '} ${path}`.trim())
127
- }
128
- }
129
- fileList = [...trackedChanges, ...untracked.map(p => `? ${p}`)]
130
-
131
- // 2. 对 untracked 文件做 intent to add(只在内存中,不会真的暂存内容)
132
- // 选择模式下 untracked 已被裁剪,只会对所选的做 -N,不污染整个 index
133
- if (untracked.length > 0) {
134
- // 加上 --force 以防某些文件已经在 index 中
135
- // 分批处理,避免命令行过长
136
- const batchSize = 20
137
- for (let i = 0; i < untracked.length; i += batchSize) {
138
- const batch = untracked.slice(i, i + batchSize)
139
- try {
140
- await execGitCommand(['add', '-N', '--force', ...batch], cwdOpts)
141
- } catch (e) {
142
- // 单批失败不影响整体
143
- logger.warn('[generate-commit] git add -N failed for batch:', e?.message)
144
- }
145
- }
146
- }
147
-
148
- // 3. 合并 staged + unstaged diff
149
- // 用 --no-color 避免 ANSI 干扰, --no-ext-diff 避免外接 diff 工具
150
- // 选择模式下用 `-- <paths>` 限定,避免拉取无关 staged 改动
151
- const pathArgs = selectedSet ? [...selectedSet] : []
152
- const diffBase = ['diff', '--no-color', '--no-ext-diff']
153
- const stagedArgs = ['diff', '--staged', '--no-color', '--no-ext-diff', ...(pathArgs.length ? ['--', ...pathArgs] : [])]
154
- const unstagedArgs = [...diffBase, ...(pathArgs.length ? ['--', ...pathArgs] : [])]
155
- const [stagedRes, unstagedRes] = await Promise.all([
156
- execGitCommand(stagedArgs, cwdOpts).catch(() => ({ stdout: '' })),
157
- execGitCommand(unstagedArgs, cwdOpts).catch(() => ({ stdout: '' }))
158
- ])
159
- let combined = ''
160
- if (stagedRes?.stdout) combined += stagedRes.stdout.trim() + '\n'
161
- if (unstagedRes?.stdout) combined += unstagedRes.stdout.trim() + '\n'
162
-
163
- return { diff: combined.trim(), fileList }
164
- } catch (error) {
165
- logger.error('[generate-commit] collectDiffForAi error:', error?.message)
166
- return { diff: '', fileList }
167
- }
168
- }
169
-
170
- // 把 diff 压缩成给 LLM 的紧凑文本
171
- // 策略: 跳过产物/资源文件(用一行 stat 带过),源码按优先级排序,每个文件 patch 限 1500 字,总预算 6000 字
172
- function prepareDiffForPrompt(diffText, fileList) {
173
- const safeFileList = Array.isArray(fileList) ? fileList : []
174
- let files = parseDiffByFile(diffText)
175
-
176
- // 如果客户端明确指定了文件列表,则只保留与所选文件匹配的 diff 块
177
- if (safeFileList.length > 0 && files.length > 0) {
178
- const selectedPaths = new Set(
179
- safeFileList
180
- .map(s => {
181
- if (typeof s !== 'string') return ''
182
- // fileList 形如 "M src/foo.ts" 或 "? new-file.ts",取最后的路径部分
183
- const m = s.match(/^[A-Z?\s]+\s+(.+)$/)
184
- return (m ? m[1] : s).replace(/\\/g, '/')
185
- })
186
- .filter(Boolean)
187
- )
188
- if (selectedPaths.size > 0) {
189
- files = files.filter(f => selectedPaths.has(f.path.replace(/\\/g, '/')))
190
- }
191
- }
192
-
193
- // 如果 parse 出来是空的(diff 可能是空或非标准格式),退回到 fileList 推断
194
- if (files.length === 0) {
195
- const list = safeFileList.slice(0, 30).map(s => {
196
- // fileList 形如 "M src/foo.ts" 或 "? new-file.ts"
197
- const m = s.match(/^[A-Z?\s]+\s+(.+)$/)
198
- return m ? m[1] : s
199
- })
200
- if (list.length === 0) return ''
201
- return list.map(p => {
202
- if (isSkippedFile(p)) return `${p} [产物/资源,已跳过]`
203
- return p
204
- }).join('\n')
205
- }
206
-
207
- // 拆分: 跳过的 vs 保留的
208
- const skipped = []
209
- const kept = []
210
- for (const f of files) {
211
- if (isSkippedFile(f.path)) {
212
- skipped.push(f)
213
- } else {
214
- kept.push(f)
215
- }
216
- }
217
-
218
- // 保留的文件按优先级降序,同优先级按 +/- 总数降序(改得多的优先)
219
- kept.sort((a, b) => {
220
- const dp = filePriority(b.path) - filePriority(a.path)
221
- if (dp !== 0) return dp
222
- return (b.added + b.removed) - (a.added + a.removed)
223
- })
224
-
225
- const TOTAL_BUDGET = 6000
226
- const PER_FILE_LIMIT = 1500
227
- const lines = []
228
- let budget = TOTAL_BUDGET
229
-
230
- // 先把跳过的文件用一行 stat 总结
231
- for (const f of skipped) {
232
- if (budget < 80) break
233
- lines.push(`${f.path} [+${f.added}/-${f.removed}, 已跳过产物/资源]`)
234
- budget -= 80
235
- }
236
-
237
- // 再装源码,带预算控制
238
- for (const f of kept) {
239
- if (budget <= 50) break
240
- let patch = f.patch
241
- let truncated = false
242
- if (patch.length > PER_FILE_LIMIT) {
243
- patch = patch.slice(0, PER_FILE_LIMIT) + '\n... (diff 已截断)'
244
- truncated = true
245
- }
246
- const block = `--- ${f.path} (+${f.added}/-${f.removed})${truncated ? ' [截断]' : ''}\n${patch}`
247
- if (block.length > budget) {
248
- // 单个文件塞不下了,截到能塞下为止
249
- const sliceLen = Math.max(0, budget - 80)
250
- if (sliceLen < 100) break
251
- lines.push(`--- ${f.path} (+${f.added}/-${f.removed}) [预算耗尽,已截断]\n${patch.slice(0, sliceLen)}`)
252
- budget = 0
253
- break
254
- }
255
- lines.push(block)
256
- budget -= block.length
257
- }
258
-
259
- if (lines.length === 0) return ''
260
- return lines.join('\n\n')
261
- }
262
-
263
- export { prepareDiffForPrompt, parseDiffByFile, isSkippedFile, filePriority, collectDiffForAi }
264
-
265
- export function registerConfigRoutes({
266
- app,
267
- configManager,
268
- execGitCommand,
269
- getCurrentProjectPath
270
- }) {
271
- // 保存最近访问的目录
272
- app.post('/api/save_recent_directory', asyncRoute(async (req, res) => {
273
- try {
274
- const { path } = req.body;
275
-
276
- if (!path) {
277
- return res.status(400).json({
278
- success: false,
279
- error: '目录路径不能为空'
280
- });
281
- }
282
-
283
- // 保存到配置
284
- await configManager.saveRecentDirectory(path);
285
-
286
- res.json({
287
- success: true
288
- });
289
- } catch (error) {
290
- res.status(500).json({
291
- success: false,
292
- error: error.message
293
- });
294
- }
295
- }));
296
-
297
- // 删除最近访问的目录
298
- app.post('/api/remove_recent_directory', asyncRoute(async (req, res) => {
299
- try {
300
- const { path: dirPath } = req.body;
301
- if (!dirPath) {
302
- throw new HttpError(400, '目录路径不能为空');
303
- }
304
- const list = await configManager.removeRecentDirectory(dirPath);
305
- res.json({ success: true, directories: list });
306
- } catch (error) {
307
- res.status(500).json({ success: false, error: error.message });
308
- }
309
- }));
310
-
311
- // 获取配置
312
- app.get('/api/config/getConfig', asyncRoute(async (req, res) => {
313
- try {
314
- const config = await configManager.loadConfig()
315
-
316
- // 兼容旧数据:补齐自定义命令 id,避免前端编辑/删除/编排引用异常
317
- if (Array.isArray(config.customCommands)) {
318
- let changed = false
319
- config.customCommands = config.customCommands.map((cmd) => {
320
- if (cmd && typeof cmd === 'object' && !cmd.id) {
321
- changed = true
322
- return {
323
- ...cmd,
324
- id: `cmd_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
325
- }
326
- }
327
- return cmd
328
- })
329
- if (changed) {
330
- await configManager.saveConfig(config)
331
- }
332
- }
333
-
334
- // 初始化命令模板(首次安装/旧配置兼容)
335
- if (!Array.isArray(config.commandTemplates)) {
336
- config.commandTemplates = []
337
- }
338
-
339
- if (config.commandTemplates.length === 0) {
340
- config.commandTemplates = [
341
- 'echo "{{cmd}}"',
342
- 'npm run dev',
343
- 'npm run build',
344
- 'git status',
345
- 'git add .',
346
- 'git commit -m "{{message}}" --no-verify',
347
- 'git push',
348
- ]
349
- await configManager.saveConfig(config)
350
- }
351
-
352
- // 解析后的请求预算(超长输入预警的数据源):与 CLI/Web 实际裁剪用的是
353
- // 同一个公式(resolveRequestBudget),前端只读结果、不重算。
354
- // 刻意放在上面所有 saveConfig 之后 —— 这个字段是**派生值**,不许跟着
355
- // saveConfig 的 ...projectConfig 泄漏进项目级配置。
356
- config.aiRequestBudget = resolveRequestBudget(config.aiMaxRequestTokens)
357
- res.json(config)
358
- } catch (error) {
359
- const configPath = CONFIG_FILE
360
- res.status(500).json({
361
- success: false,
362
- code: 'CONFIG_LOAD_FAILED',
363
- error: error?.message || String(error),
364
- configPath
365
- })
366
- }
367
- }));
368
-
369
- // 保存默认提交信息
370
- app.post('/api/config/saveDefaultMessage', express.json(), async (req, res) => {
371
- try {
372
- const { defaultCommitMessage } = req.body
373
-
374
- if (!defaultCommitMessage) {
375
- return res.status(400).json({ success: false, error: '缺少必要参数' })
376
- }
377
-
378
- const config = await configManager.loadConfig()
379
-
380
- // 更新默认提交信息
381
- config.defaultCommitMessage = defaultCommitMessage
382
- await configManager.saveConfig(config)
383
-
384
- res.json({ success: true })
385
- } catch (error) {
386
- res.status(500).json({ success: false, error: error.message })
387
- }
388
- })
389
-
390
- // 保存所有配置
391
- app.post('/api/config/saveAll', express.json(), async (req, res) => {
392
- try {
393
- const { config } = req.body
394
-
395
- if (!config) {
396
- return res.status(400).json({ success: false, error: '缺少必要参数' })
397
- }
398
-
399
- await configManager.saveConfig(config)
400
-
401
- res.json({ success: true })
402
- } catch (error) {
403
- res.status(500).json({ success: false, error: error.message })
404
- }
405
- })
406
-
407
- // 检查系统配置文件格式
408
- app.get('/api/config/check-file-format', asyncRoute(async (req, res) => {
409
- try {
410
- const configPath = CONFIG_FILE;
411
-
412
- try {
413
- const data = await fs.readFile(configPath, 'utf-8');
414
- try {
415
- JSON.parse(data);
416
- res.json({ success: true, isValidJson: true, exists: true });
417
- } catch (parseError) {
418
- res.json({
419
- success: true,
420
- isValidJson: false,
421
- exists: true,
422
- error: `JSON解析失败: ${parseError.message}`
423
- });
424
- }
425
- } catch (fileError) {
426
- if (fileError.code === 'ENOENT') {
427
- res.json({ success: true, isValidJson: true, exists: false });
428
- } else {
429
- res.json({
430
- success: true,
431
- isValidJson: false,
432
- exists: true,
433
- error: `文件读取失败: ${fileError.message}`
434
- });
435
- }
436
- }
437
- } catch (error) {
438
- res.status(500).json({ success: false, error: error.message });
439
- }
440
- }));
441
-
442
- // 使用系统默认程序打开配置文件(~/.zen-gitsync/config.json)
443
- app.post('/api/config/open-file', asyncRoute(async (req, res) => {
444
- try {
445
- const filePath = CONFIG_FILE;
446
- try {
447
- // 检查文件是否存在,不存在也尝试让系统创建(可能会打开空文件)
448
- await fs.access(filePath);
449
- } catch (_) {
450
- // 如果文件不存在,先创建一个最小结构,避免某些系统无法打开不存在的路径。
451
- // 注意:新路径是嵌套目录,首次打开时数据目录可能还没建(正常启动流程里由
452
- // dataDirMigration 建好,这里是独立于迁移的兜底)。
453
- try {
454
- await fs.mkdir(path.dirname(filePath), { recursive: true });
455
- await fs.writeFile(filePath, '{}', 'utf-8');
456
- } catch (e) {
457
- // 创建失败不阻断打开尝试
458
- logger.warn('创建配置文件失败(可忽略):', e?.message || e);
459
- }
460
- }
461
-
462
- await open(filePath, { wait: false });
463
- res.json({ success: true })
464
- } catch (error) {
465
- res.status(400).json({ success: false, error: `无法打开配置文件: ${error.message}` })
466
- }
467
- }));
468
-
469
- // 保存模板
470
- app.post('/api/config/save-template', express.json(), async (req, res) => {
471
- try {
472
- const { template, type } = req.body
473
-
474
- if (!template || !type) {
475
- return res.status(400).json({ success: false, error: '缺少必要参数' })
476
- }
477
-
478
- const config = await configManager.loadConfig()
479
-
480
- if (type === 'description') {
481
- // 确保描述模板数组存在
482
- if (!config.descriptionTemplates) {
483
- config.descriptionTemplates = []
484
- }
485
-
486
- // 检查是否已存在相同模板
487
- if (!config.descriptionTemplates.includes(template)) {
488
- config.descriptionTemplates.push(template)
489
- await configManager.saveConfig(config)
490
- }
491
- } else if (type === 'scope') {
492
- // 确保作用域模板数组存在
493
- if (!config.scopeTemplates) {
494
- config.scopeTemplates = []
495
- }
496
-
497
- // 检查是否已存在相同模板
498
- if (!config.scopeTemplates.includes(template)) {
499
- config.scopeTemplates.push(template)
500
- await configManager.saveConfig(config)
501
- }
502
- } else if (type === 'message') {
503
- // 确保提交信息模板数组存在
504
- if (!config.messageTemplates) {
505
- config.messageTemplates = []
506
- }
507
-
508
- // 检查是否已存在相同模板
509
- if (!config.messageTemplates.includes(template)) {
510
- config.messageTemplates.push(template)
511
- await configManager.saveConfig(config)
512
- }
513
- } else if (type === 'command') {
514
- if (!config.commandTemplates) {
515
- config.commandTemplates = []
516
- }
517
- if (!config.commandTemplates.includes(template)) {
518
- config.commandTemplates.push(template)
519
- await configManager.saveConfig(config)
520
- }
521
- } else {
522
- return res.status(400).json({ success: false, error: '不支持的模板类型' })
523
- }
524
-
525
- res.json({ success: true })
526
- } catch (error) {
527
- res.status(500).json({ success: false, error: error.message })
528
- }
529
- })
530
-
531
- // 删除模板
532
- app.post('/api/config/delete-template', express.json(), async (req, res) => {
533
- try {
534
- const { template, type } = req.body
535
-
536
- if (!template || !type) {
537
- return res.status(400).json({ success: false, error: '缺少必要参数' })
538
- }
539
-
540
- const config = await configManager.loadConfig()
541
-
542
- if (type === 'description') {
543
- // 确保描述模板数组存在
544
- if (config.descriptionTemplates) {
545
- const index = config.descriptionTemplates.indexOf(template)
546
- if (index !== -1) {
547
- config.descriptionTemplates.splice(index, 1)
548
- await configManager.saveConfig(config)
549
- }
550
- }
551
- } else if (type === 'scope') {
552
- // 确保作用域模板数组存在
553
- if (config.scopeTemplates) {
554
- const index = config.scopeTemplates.indexOf(template)
555
- if (index !== -1) {
556
- config.scopeTemplates.splice(index, 1)
557
- await configManager.saveConfig(config)
558
- }
559
- }
560
- } else if (type === 'message') {
561
- // 确保提交信息模板数组存在
562
- if (config.messageTemplates) {
563
- const index = config.messageTemplates.indexOf(template)
564
- if (index !== -1) {
565
- config.messageTemplates.splice(index, 1)
566
- await configManager.saveConfig(config)
567
- }
568
- }
569
- } else if (type === 'command') {
570
- if (config.commandTemplates) {
571
- const index = config.commandTemplates.indexOf(template)
572
- if (index !== -1) {
573
- config.commandTemplates.splice(index, 1)
574
- await configManager.saveConfig(config)
575
- }
576
- }
577
- } else {
578
- return res.status(400).json({ success: false, error: '不支持的模板类型' })
579
- }
580
-
581
- res.json({ success: true })
582
- } catch (error) {
583
- res.status(500).json({ success: false, error: error.message })
584
- }
585
- })
586
-
587
- // 更新模板
588
- app.post('/api/config/update-template', express.json(), async (req, res) => {
589
- try {
590
- const { oldTemplate, newTemplate, type } = req.body
591
-
592
- if (!oldTemplate || !newTemplate || !type) {
593
- return res.status(400).json({ success: false, error: '缺少必要参数' })
594
- }
595
-
596
- const config = await configManager.loadConfig()
597
-
598
- if (type === 'description') {
599
- // 确保描述模板数组存在
600
- if (config.descriptionTemplates) {
601
- const index = config.descriptionTemplates.indexOf(oldTemplate)
602
- if (index !== -1) {
603
- config.descriptionTemplates[index] = newTemplate
604
- await configManager.saveConfig(config)
605
- } else {
606
- return res.status(404).json({ success: false, error: '未找到原模板' })
607
- }
608
- } else {
609
- return res.status(404).json({ success: false, error: '模板列表不存在' })
610
- }
611
- } else if (type === 'scope') {
612
- // 确保作用域模板数组存在
613
- if (config.scopeTemplates) {
614
- const index = config.scopeTemplates.indexOf(oldTemplate)
615
- if (index !== -1) {
616
- config.scopeTemplates[index] = newTemplate
617
- await configManager.saveConfig(config)
618
- } else {
619
- return res.status(404).json({ success: false, error: '未找到原模板' })
620
- }
621
- } else {
622
- return res.status(404).json({ success: false, error: '模板列表不存在' })
623
- }
624
- } else if (type === 'message') {
625
- // 确保提交信息模板数组存在
626
- if (config.messageTemplates) {
627
- const index = config.messageTemplates.indexOf(oldTemplate)
628
- if (index !== -1) {
629
- config.messageTemplates[index] = newTemplate
630
- await configManager.saveConfig(config)
631
- } else {
632
- return res.status(404).json({ success: false, error: '未找到原模板' })
633
- }
634
- } else {
635
- return res.status(404).json({ success: false, error: '模板列表不存在' })
636
- }
637
- } else if (type === 'command') {
638
- if (config.commandTemplates) {
639
- const index = config.commandTemplates.indexOf(oldTemplate)
640
- if (index !== -1) {
641
- config.commandTemplates[index] = newTemplate
642
- await configManager.saveConfig(config)
643
- } else {
644
- return res.status(404).json({ success: false, error: '未找到原模板' })
645
- }
646
- } else {
647
- return res.status(404).json({ success: false, error: '模板列表不存在' })
648
- }
649
- } else {
650
- return res.status(400).json({ success: false, error: '不支持的模板类型' })
651
- }
652
-
653
- res.json({ success: true })
654
- } catch (error) {
655
- res.status(500).json({ success: false, error: error.message })
656
- }
657
- })
658
-
659
- // 置顶模板
660
- app.post('/api/config/pin-template', express.json(), async (req, res) => {
661
- try {
662
- const { template, type } = req.body
663
-
664
- if (!template || !type) {
665
- return res.status(400).json({ success: false, error: '缺少必要参数' })
666
- }
667
-
668
- const config = await configManager.loadConfig()
669
-
670
- if (type === 'description') {
671
- if (config.descriptionTemplates) {
672
- // 删除原位置的模板
673
- config.descriptionTemplates = config.descriptionTemplates.filter(t => t !== template)
674
- // 添加到第一位
675
- config.descriptionTemplates.unshift(template)
676
- await configManager.saveConfig(config)
677
- } else {
678
- return res.status(404).json({ success: false, error: '模板列表不存在' })
679
- }
680
- } else if (type === 'scope') {
681
- if (config.scopeTemplates) {
682
- config.scopeTemplates = config.scopeTemplates.filter(t => t !== template)
683
- config.scopeTemplates.unshift(template)
684
- await configManager.saveConfig(config)
685
- } else {
686
- return res.status(404).json({ success: false, error: '模板列表不存在' })
687
- }
688
- } else if (type === 'message') {
689
- if (config.messageTemplates) {
690
- config.messageTemplates = config.messageTemplates.filter(t => t !== template)
691
- config.messageTemplates.unshift(template)
692
- await configManager.saveConfig(config)
693
- } else {
694
- return res.status(404).json({ success: false, error: '模板列表不存在' })
695
- }
696
- } else if (type === 'command') {
697
- if (config.commandTemplates) {
698
- config.commandTemplates = config.commandTemplates.filter(t => t !== template)
699
- config.commandTemplates.unshift(template)
700
- await configManager.saveConfig(config)
701
- } else {
702
- return res.status(404).json({ success: false, error: '模板列表不存在' })
703
- }
704
- } else {
705
- return res.status(400).json({ success: false, error: '不支持的模板类型' })
706
- }
707
-
708
- res.json({ success: true })
709
- } catch (error) {
710
- res.status(500).json({ success: false, error: error.message })
711
- }
712
- })
713
-
714
- // 保存自定义命令
715
- app.post('/api/config/save-custom-command', express.json(), async (req, res) => {
716
- try {
717
- const { command } = req.body
718
-
719
- if (!command || !command.name || !command.command) {
720
- return res.status(400).json({ success: false, error: '缺少必要参数' })
721
- }
722
-
723
- const config = await configManager.loadConfig()
724
-
725
- // 确保自定义命令数组存在
726
- if (!Array.isArray(config.customCommands)) {
727
- config.customCommands = []
728
- }
729
-
730
- // 生成唯一ID
731
- const id = `cmd_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
732
- const newCommand = {
733
- id,
734
- name: command.name,
735
- description: command.description || '',
736
- directory: command.directory || '',
737
- command: command.command,
738
- params: Array.isArray(command.params) ? command.params : []
739
- }
740
-
741
- config.customCommands.push(newCommand)
742
- await configManager.saveConfig(config)
743
-
744
- res.json({ success: true, command: newCommand })
745
- } catch (error) {
746
- res.status(500).json({ success: false, error: error.message })
747
- }
748
- })
749
-
750
- // 删除自定义命令
751
- app.post('/api/config/delete-custom-command', express.json(), async (req, res) => {
752
- try {
753
- const { id } = req.body
754
-
755
- if (!id) {
756
- return res.status(400).json({ success: false, error: '缺少命令ID参数' })
757
- }
758
-
759
- const config = await configManager.loadConfig()
760
-
761
- if (Array.isArray(config.customCommands)) {
762
- const index = config.customCommands.findIndex(cmd => cmd.id === id)
763
- if (index !== -1) {
764
- config.customCommands.splice(index, 1)
765
- await configManager.saveConfig(config)
766
- }
767
- }
768
-
769
- res.json({ success: true })
770
- } catch (error) {
771
- res.status(500).json({ success: false, error: error.message })
772
- }
773
- })
774
-
775
- // 置顶自定义命令(移到数组首位)
776
- app.post('/api/config/pin-custom-command', express.json(), async (req, res) => {
777
- try {
778
- const { id } = req.body
779
-
780
- if (!id) {
781
- return res.status(400).json({ success: false, error: '缺少命令ID参数' })
782
- }
783
-
784
- const config = await configManager.loadConfig()
785
-
786
- if (Array.isArray(config.customCommands)) {
787
- const index = config.customCommands.findIndex(cmd => cmd.id === id)
788
- if (index > 0) {
789
- const [cmd] = config.customCommands.splice(index, 1)
790
- config.customCommands.unshift(cmd)
791
- await configManager.saveConfig(config)
792
- }
793
- }
794
-
795
- res.json({ success: true })
796
- } catch (error) {
797
- res.status(500).json({ success: false, error: error.message })
798
- }
799
- })
800
-
801
- // 更新自定义命令
802
- app.post('/api/config/update-custom-command', express.json(), async (req, res) => {
803
- try {
804
- const { id, command } = req.body
805
-
806
- if (!id || !command || !command.name || !command.command) {
807
- return res.status(400).json({ success: false, error: '缺少必要参数' })
808
- }
809
-
810
- const config = await configManager.loadConfig()
811
-
812
- if (Array.isArray(config.customCommands)) {
813
- const index = config.customCommands.findIndex(cmd => cmd.id === id)
814
- if (index !== -1) {
815
- config.customCommands[index] = {
816
- id,
817
- name: command.name,
818
- description: command.description || '',
819
- directory: command.directory || '',
820
- command: command.command,
821
- params: Array.isArray(command.params) ? command.params : []
822
- }
823
- await configManager.saveConfig(config)
824
- } else {
825
- return res.status(404).json({ success: false, error: '未找到指定命令' })
826
- }
827
- } else {
828
- return res.status(404).json({ success: false, error: '命令列表不存在' })
829
- }
830
-
831
- res.json({ success: true })
832
- } catch (error) {
833
- res.status(500).json({ success: false, error: error.message })
834
- }
835
- })
836
-
837
- // 保存指令编排
838
- app.post('/api/config/save-orchestration', express.json(), async (req, res) => {
839
- try {
840
- const { orchestration } = req.body
841
-
842
- if (!orchestration || !orchestration.name) {
843
- return res.status(400).json({ success: false, error: '缺少必要参数' })
844
- }
845
-
846
- const config = await configManager.loadConfig()
847
-
848
- // 确保编排数组存在
849
- if (!Array.isArray(config.orchestrations)) {
850
- config.orchestrations = []
851
- }
852
-
853
- // 生成唯一ID
854
- const id = `orch_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
855
- const newOrchestration = {
856
- id,
857
- name: orchestration.name,
858
- description: orchestration.description || '',
859
- flowData: orchestration.flowData || null
860
- }
861
-
862
- config.orchestrations.push(newOrchestration)
863
- await configManager.saveConfig(config)
864
-
865
- res.json({ success: true, orchestration: newOrchestration })
866
- } catch (error) {
867
- res.status(500).json({ success: false, error: error.message })
868
- }
869
- })
870
-
871
- // 删除指令编排
872
- app.post('/api/config/delete-orchestration', express.json(), async (req, res) => {
873
- try {
874
- const { id } = req.body
875
-
876
- if (!id) {
877
- return res.status(400).json({ success: false, error: '缺少编排ID参数' })
878
- }
879
-
880
- const config = await configManager.loadConfig()
881
-
882
- if (Array.isArray(config.orchestrations)) {
883
- const index = config.orchestrations.findIndex(orch => orch.id === id)
884
- if (index !== -1) {
885
- config.orchestrations.splice(index, 1)
886
- await configManager.saveConfig(config)
887
- }
888
- }
889
-
890
- res.json({ success: true })
891
- } catch (error) {
892
- res.status(500).json({ success: false, error: error.message })
893
- }
894
- })
895
-
896
- // 更新指令编排
897
- app.post('/api/config/update-orchestration', express.json(), async (req, res) => {
898
- try {
899
- const { id, orchestration } = req.body
900
-
901
- if (!id || !orchestration || !orchestration.name) {
902
- return res.status(400).json({ success: false, error: '缺少必要参数' })
903
- }
904
-
905
- const config = await configManager.loadConfig()
906
-
907
- if (Array.isArray(config.orchestrations)) {
908
- const index = config.orchestrations.findIndex(orch => orch.id === id)
909
- if (index !== -1) {
910
- config.orchestrations[index] = {
911
- id,
912
- name: orchestration.name,
913
- description: orchestration.description || '',
914
- flowData: orchestration.flowData || null
915
- }
916
- await configManager.saveConfig(config)
917
- } else {
918
- return res.status(404).json({ success: false, error: '未找到指定编排' })
919
- }
920
- } else {
921
- return res.status(404).json({ success: false, error: '编排列表不存在' })
922
- }
923
-
924
- res.json({ success: true })
925
- } catch (error) {
926
- res.status(500).json({ success: false, error: error.message })
927
- }
928
- })
929
-
930
- // 保存项目启动项
931
- app.post('/api/config/save-startup-items', express.json(), async (req, res) => {
932
- try {
933
- const { startupItems, startupAutoRun } = req.body
934
-
935
- if (!Array.isArray(startupItems)) {
936
- return res.status(400).json({ success: false, error: '启动项必须是数组' })
937
- }
938
-
939
- const config = await configManager.loadConfig()
940
- config.startupItems = startupItems
941
- if (typeof startupAutoRun === 'boolean') {
942
- config.startupAutoRun = startupAutoRun
943
- }
944
- await configManager.saveConfig(config)
945
-
946
- res.json({ success: true })
947
- } catch (error) {
948
- res.status(500).json({ success: false, error: error.message })
949
- }
950
- })
951
-
952
- // 保存提交设置
953
- app.post('/api/config/save-commit-settings', express.json(), async (req, res) => {
954
- try {
955
- const { isStandardCommit, skipHooks, autoQuickPushOnEnter, autoSetDefaultMessage, autoClosePushModal, pullBeforePush } = req.body
956
- const config = await configManager.loadConfig()
957
- if (isStandardCommit !== undefined) config.isStandardCommit = Boolean(isStandardCommit)
958
- if (skipHooks !== undefined) config.skipHooks = Boolean(skipHooks)
959
- if (autoQuickPushOnEnter !== undefined) config.autoQuickPushOnEnter = Boolean(autoQuickPushOnEnter)
960
- if (autoSetDefaultMessage !== undefined) config.autoSetDefaultMessage = Boolean(autoSetDefaultMessage)
961
- if (autoClosePushModal !== undefined) config.autoClosePushModal = Boolean(autoClosePushModal)
962
- if (pullBeforePush !== undefined) config.pullBeforePush = Boolean(pullBeforePush)
963
- await configManager.saveConfig(config)
964
- res.json({ success: true })
965
- } catch (error) {
966
- res.status(500).json({ success: false, error: error.message })
967
- }
968
- })
969
-
970
- // 保存"一键推送成功后启动项"
971
- app.post('/api/config/save-after-quick-push-action', express.json(), async (req, res) => {
972
- try {
973
- const { afterQuickPushAction } = req.body
974
-
975
- if (!afterQuickPushAction || typeof afterQuickPushAction !== 'object') {
976
- return res.status(400).json({ success: false, error: '缺少必要参数' })
977
- }
978
-
979
- const enabled = Boolean(afterQuickPushAction.enabled)
980
- const type = afterQuickPushAction.type === 'workflow' ? 'workflow' : 'command'
981
- const refId = String(afterQuickPushAction.refId || '').trim()
982
-
983
- const config = await configManager.loadConfig()
984
- config.afterQuickPushAction = {
985
- enabled,
986
- type,
987
- refId
988
- }
989
- await configManager.saveConfig(config)
990
-
991
- res.json({ success: true })
992
- } catch (error) {
993
- res.status(500).json({ success: false, error: error.message })
994
- }
995
- })
996
-
997
- // 保存通用设置(主题、语言、工作台任务执行器、任务完成提示)
998
- app.post('/api/config/save-general-settings', express.json(), async (req, res) => {
999
- try {
1000
- const { theme, locale, taskExecutor, notifyPageOnTaskDone, notifyBrowserOnTaskDone, notifySoundOnTaskDone } = req.body
1001
-
1002
- // 读取原始配置以保留项目设置
1003
- const rawConfig = await configManager.readRawConfigFile()
1004
-
1005
- // 更新全局设置
1006
- if (theme && ['light', 'dark', 'auto'].includes(theme)) {
1007
- rawConfig.theme = theme
1008
- }
1009
- if (locale && ['zh-CN', 'en-US'].includes(locale)) {
1010
- rawConfig.locale = locale
1011
- }
1012
- // 任务执行器是全局配置(跨项目共享);白名单外的值静默忽略,不落盘
1013
- const normalizedExecutor = configManager.normalizeTaskExecutor(taskExecutor)
1014
- if (normalizedExecutor) {
1015
- rawConfig.taskExecutor = normalizedExecutor
1016
- }
1017
-
1018
- // 任务/对话结束提示的三个通道开关(全局):页面提示 / 浏览器通知 / 提示音。
1019
- // 三者**平级且互相独立** —— 写其中一个绝不顺带改动另外两个(老用户升级时
1020
- // 由 config.js 的 resolveNotifySwitches 从旧总开关迁移,迁移只发生在读取侧)。
1021
- // 只接受布尔值,其它类型静默忽略,避免前端误传字符串 'false' 被当成真值落盘后永久打开。
1022
- // ⚠️ 规范化函数对非法值返回的是 **null**(与 normalizeTaskExecutor 同语义),
1023
- // 不是 undefined —— 判 `!== undefined` 会把非法值原样写进去变成 `null`。
1024
- const normalizedNotifies = {}
1025
- for (const key of ['notifyPageOnTaskDone', 'notifyBrowserOnTaskDone', 'notifySoundOnTaskDone']) {
1026
- const normalized = configManager.normalizeNotifySwitch(req.body[key])
1027
- if (normalized !== null) {
1028
- rawConfig[key] = normalized
1029
- normalizedNotifies[key] = normalized
1030
- }
1031
- }
1032
-
1033
- // 直接写入原始配置,避免覆盖项目设置
1034
- await configManager.writeRawConfigFile(rawConfig)
1035
- res.json({
1036
- success: true,
1037
- taskExecutor: normalizedExecutor || undefined,
1038
- ...normalizedNotifies
1039
- })
1040
- } catch (error) {
1041
- res.status(500).json({ success: false, error: error.message })
1042
- }
1043
- })
1044
-
1045
- // 保存 UI 状态(视图模式/分割比例/控制台状态/布局比例/编辑器自动保存等)
1046
- // 接受 partial body,浅合并到顶层 ui 对象。例:{ layout: {...} } / { commandConsole: {...} } / { fileListViewMode: 'tree' }
1047
- // 例外:按项目存储的 map 在多个实例并行修改时不能浅替换,否则会丢失其它项目条目。
1048
- app.post('/api/config/save-ui-settings', express.json(), async (req, res) => {
1049
- try {
1050
- const partial = req.body && typeof req.body === 'object' ? req.body : {}
1051
-
1052
- const rawConfig = await configManager.readRawConfigFile()
1053
-
1054
- // 确保 ui 容器存在
1055
- if (!rawConfig.ui || typeof rawConfig.ui !== 'object' || Array.isArray(rawConfig.ui)) {
1056
- rawConfig.ui = {}
1057
- }
1058
- if (!rawConfig.ui.layoutsByProject || typeof rawConfig.ui.layoutsByProject !== 'object' || Array.isArray(rawConfig.ui.layoutsByProject)) {
1059
- rawConfig.ui.layoutsByProject = {}
1060
- }
1061
- if (!rawConfig.ui.aiDiffSummaryByProject || typeof rawConfig.ui.aiDiffSummaryByProject !== 'object' || Array.isArray(rawConfig.ui.aiDiffSummaryByProject)) {
1062
- rawConfig.ui.aiDiffSummaryByProject = {}
1063
- }
1064
- if (!rawConfig.ui.editorWorkspaceByProject || typeof rawConfig.ui.editorWorkspaceByProject !== 'object' || Array.isArray(rawConfig.ui.editorWorkspaceByProject)) {
1065
- rawConfig.ui.editorWorkspaceByProject = {}
1066
- }
1067
-
1068
- // 浅合并顶层 ui 字段(支持嵌套对象整体替换,如 commandConsole)
1069
- for (const key of Object.keys(partial)) {
1070
- if (key === 'layoutsByProject' || key === 'aiDiffSummaryByProject' || key === 'editorWorkspaceByProject') {
1071
- // 深合并 map:保留其它项目条目
1072
- const incoming = partial[key]
1073
- if (incoming && typeof incoming === 'object' && !Array.isArray(incoming)) {
1074
- rawConfig.ui[key] = {
1075
- ...rawConfig.ui[key],
1076
- ...incoming,
1077
- }
1078
- }
1079
- continue
1080
- }
1081
- rawConfig.ui[key] = partial[key]
1082
- }
1083
-
1084
- await configManager.writeRawConfigFile(rawConfig)
1085
- res.json({ success: true })
1086
- } catch (error) {
1087
- logger.error('[save-ui-settings] failed:', error)
1088
- res.status(500).json({ success: false, error: error.message })
1089
- }
1090
- })
1091
-
1092
- // 保存 AI 智能体运行时设置(两个键都是全局配置,存配置文件顶层,跨项目共享):
1093
- // · aiMaxToolIterations —— 单轮最大工具调用次数
1094
- // · aiMaxRequestTokens —— 单轮请求的上下文预算(**token**;2026-10-07 从字符口径
1095
- // 改过来,它决定"一条粘贴最多能装多少、工具结果会不会被挤掉",见 cli/ai/context.js)
1096
- // 旧字段 aiMaxRequestChars 仍接受:config.js 的 resolveRequestTokens 会迁移,
1097
- // 但**新字段一旦写入就以它为准**,所以这里两个键都存、互不覆盖。
1098
- // 两个键各自可选:没传的那个不动(旧客户端只传一个键时不许把它顺带清掉)。
1099
- app.post('/api/config/save-ai-settings', express.json(), async (req, res) => {
1100
- try {
1101
- const body = req.body || {}
1102
- const updates = {}
1103
- if (body.aiMaxToolIterations !== undefined) {
1104
- // 越界值在这里夹取到 [MIN, MAX],而不是拒绝 —— 与 loadConfig 的规范化保持同一套语义
1105
- const normalized = configManager.normalizeAiMaxToolIterations(body.aiMaxToolIterations)
1106
- if (normalized === null) {
1107
- return res.status(400).json({
1108
- success: false,
1109
- error: `aiMaxToolIterations 非法: ${body.aiMaxToolIterations}`
1110
- })
1111
- }
1112
- updates.aiMaxToolIterations = normalized
1113
- }
1114
- // 新字段优先;老客户端仍发旧字段也接(迁移期),但同一请求里两个都传时
1115
- // **只认新的** —— 否则老字段会覆盖掉用户刚设的新值。
1116
- const incomingTokens = body.aiMaxRequestTokens ?? body.aiMaxRequestChars
1117
- if (incomingTokens !== undefined) {
1118
- const normalized = configManager.normalizeAiRequestTokens(incomingTokens)
1119
- if (normalized === null) {
1120
- return res.status(400).json({
1121
- success: false,
1122
- error: `aiMaxRequestTokens 非法: ${incomingTokens}`
1123
- })
1124
- }
1125
- updates.aiMaxRequestTokens = normalized
1126
- }
1127
- // 智能体视图「预设提示词」(欢迎页快捷卡片):{id?, label, prompt} 数组。
1128
- // 空数组是**合法值**(= 恢复内置默认),所以判空用 !== undefined 而不是 length。
1129
- // 结构坏的值整体拒绝(400),不清洗 —— 与 normalizeAgentPresetPrompts 语义一致;
1130
- // 响应把归一化后的最终值带回(含补 id/截断),前端以回执为准。
1131
- if (body.agentPresetPrompts !== undefined) {
1132
- const normalized = configManager.normalizeAgentPresetPrompts(body.agentPresetPrompts)
1133
- if (normalized === null) {
1134
- return res.status(400).json({
1135
- success: false,
1136
- error: 'agentPresetPrompts 非法: 需要 [{label, prompt}] 数组(每项都不能为空)'
1137
- })
1138
- }
1139
- updates.agentPresetPrompts = normalized
1140
- }
1141
- // 按板块的预设提示词:{ [viewId]: [{id?, label, prompt}] }。
1142
- // 空对象合法(= 所有板块都不单独配置,全部继承「通用」那套);
1143
- // 结构坏的值整体拒绝(400),与 normalizeAgentPresetPromptsByView 语义一致。
1144
- if (body.agentPresetPromptsByView !== undefined) {
1145
- const normalized = configManager.normalizeAgentPresetPromptsByView(body.agentPresetPromptsByView)
1146
- if (normalized === null) {
1147
- return res.status(400).json({
1148
- success: false,
1149
- error: 'agentPresetPromptsByView 非法: 需要 { [板块]: [{label, prompt}] } 对象(每项都不能为空)'
1150
- })
1151
- }
1152
- updates.agentPresetPromptsByView = normalized
1153
- }
1154
- // g ai 智能体的默认提示词(全局配置):和 g ai 对话时每轮拼进系统提示词。
1155
- // **空串是合法值**(= 清除),所以判空用 !== undefined;非字符串整体拒绝(400),
1156
- // 与 normalizeAgentDefaultPrompt 的语义一致。
1157
- if (body.agentDefaultPrompt !== undefined) {
1158
- const normalized = configManager.normalizeAgentDefaultPrompt(body.agentDefaultPrompt)
1159
- if (normalized === null) {
1160
- return res.status(400).json({
1161
- success: false,
1162
- error: 'agentDefaultPrompt 非法: 需要字符串'
1163
- })
1164
- }
1165
- updates.agentDefaultPrompt = normalized
1166
- }
1167
- // 联网搜索配置(全局):{ enabled, provider, apiKeys: {provider: key}, proxy }。
1168
- // 结构坏(非普通对象)整体拒绝(400),与 normalizeWebSearch 语义一致;
1169
- // 单个字段坏由 normalizeWebSearch 内部各自回落,不整组拒绝。
1170
- // 回执里 updates.webSearch 是归一化后的最终值,前端以回执为准。
1171
- if (body.webSearch !== undefined) {
1172
- const normalized = configManager.normalizeWebSearch(body.webSearch)
1173
- if (normalized === null) {
1174
- return res.status(400).json({
1175
- success: false,
1176
- error: 'webSearch 非法: 需要 { enabled, provider, apiKeys, proxy } 对象'
1177
- })
1178
- }
1179
- updates.webSearch = normalized
1180
- }
1181
- if (Object.keys(updates).length === 0) {
1182
- return res.status(400).json({ success: false, error: '缺少可保存的参数' })
1183
- }
1184
- const rawConfig = await configManager.readRawConfigFile()
1185
- Object.assign(rawConfig, updates)
1186
- await configManager.writeRawConfigFile(rawConfig)
1187
- // 预算按**最终落盘值**解析(含本次夹取)回给前端 —— 前端拿它做超长输入预警,
1188
- // 不在前端重算公式(口径只有 resolveRequestBudget 一处)
1189
- const finalTokens = updates.aiMaxRequestTokens
1190
- ?? configManager.normalizeAiRequestTokens(rawConfig.aiMaxRequestTokens)
1191
- ?? configManager.normalizeAiRequestTokens(rawConfig.aiMaxRequestChars)
1192
- res.json({ success: true, ...updates, aiRequestBudget: resolveRequestBudget(finalTokens) })
1193
- } catch (error) {
1194
- logger.error('[save-ai-settings] failed:', error)
1195
- res.status(500).json({ success: false, error: error.message })
1196
- }
1197
- })
1198
-
1199
- // 保存 AI 模型配置(models 是全局配置,存在配置文件顶层,跨项目共享)
1200
- app.post('/api/config/save-models', express.json(), async (req, res) => {
1201
- try {
1202
- const { models } = req.body
1203
- if (!Array.isArray(models)) {
1204
- return res.status(400).json({ success: false, error: '缺少 models 参数' })
1205
- }
1206
- const rawConfig = await configManager.readRawConfigFile()
1207
- rawConfig.models = models
1208
- await configManager.writeRawConfigFile(rawConfig)
1209
- res.json({ success: true })
1210
- } catch (error) {
1211
- res.status(500).json({ success: false, error: error.message })
1212
- }
1213
- })
1214
-
1215
- // 测试 AI 模型是否可用
1216
- app.post('/api/config/test-model', express.json(), async (req, res) => {
1217
- const { baseURL, model, apiKey } = req.body || {}
1218
- if (!baseURL || !model) {
1219
- return res.status(400).json({ success: false, error: '缺少 baseURL 或 model 参数' })
1220
- }
1221
- try {
1222
- const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }))
1223
- // 连通性探测同样要带客户端身份/会话头,否则 OpenCode 网关这条请求就被风控挡了
1224
- const { url, headers } = buildAiChatRequest({ baseURL, model, apiKey })
1225
- const body = JSON.stringify({
1226
- model,
1227
- messages: [{ role: 'user', content: 'Hello, reply with just "ok".' }],
1228
- max_tokens: 16,
1229
- stream: false
1230
- })
1231
- const controller = new AbortController()
1232
- const timer = setTimeout(() => controller.abort(), 15000)
1233
- let response
1234
- try {
1235
- response = await fetch(url, { method: 'POST', headers, body, signal: controller.signal })
1236
- } finally {
1237
- clearTimeout(timer)
1238
- }
1239
- const data = await response.json().catch(() => ({}))
1240
- if (!response.ok) {
1241
- const msg = data?.error?.message || data?.message || `HTTP ${response.status}`
1242
- return res.json({ success: false, error: msg })
1243
- }
1244
- const reply = data?.choices?.[0]?.message?.content || data?.choices?.[0]?.text || '✅'
1245
- res.json({ success: true, reply: reply.trim().slice(0, 100) })
1246
- } catch (error) {
1247
- const msg = error.name === 'AbortError' ? '请求超时(15s)' : error.message
1248
- res.json({ success: false, error: msg })
1249
- }
1250
- })
1251
-
1252
- // AI 生成提交信息
1253
- app.post('/api/config/generate-commit-message', express.json(), async (req, res) => {
1254
- const { selectedPaths, locale: reqLocale } = req.body || {}
1255
- try {
1256
- const rawConfig = await configManager.readRawConfigFile()
1257
- const userLocale = reqLocale || rawConfig.locale || 'zh-CN'
1258
- const models = Array.isArray(rawConfig.models) ? rawConfig.models : []
1259
- const defaultModel = models.find(m => m.isDefault) || models[0]
1260
- if (!defaultModel) {
1261
- return res.json({ success: false, error: '未配置 AI 模型,请先在通用设置中添加模型', code: 'NO_MODEL' })
1262
- }
1263
-
1264
- // 后端自己收集 diff,确保 untracked 文件也能进 prompt
1265
- // 选择模式下传入 selectedPaths,后端在 git 层面就过滤,避免污染 index
1266
- const { diff: rawDiff, fileList: serverFileList } = await collectDiffForAi({
1267
- execGitCommand,
1268
- getCurrentProjectPath,
1269
- selectedPaths
1270
- })
1271
- const diffText = prepareDiffForPrompt(rawDiff, serverFileList)
1272
- const filesText = serverFileList.slice(0, 30).join('\n')
1273
-
1274
- const promptZh = `你是一个 Git 提交信息生成助手。根据以下 git diff 信息,生成一条符合 Conventional Commits 规范的提交信息。
1275
-
1276
- 要求:
1277
- 1. type 只能是:feat/fix/docs/style/refactor/test/chore 之一
1278
- 2. scope 可选,表示影响范围,简短英文或中文,如果改动范围明确就填
1279
- 3. description 用中文简短描述本次变更(不超过50字)
1280
- 4. 只返回 JSON,格式:{"type":"feat","scope":"","description":"xxx"}
1281
-
1282
- 变更文件:
1283
- ${filesText}
1284
-
1285
- git diff --staged:
1286
- ${diffText || '(无 staged 内容,请根据文件列表推断)'}`
1287
-
1288
- 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.
1289
-
1290
- Requirements:
1291
- 1. type must be one of: feat/fix/docs/style/refactor/test/chore
1292
- 2. scope is optional; use a short English word or short noun phrase to indicate the affected area. Leave empty if unclear.
1293
- 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").
1294
- 4. Return ONLY a JSON object in the format: {"type":"feat","scope":"","description":"xxx"}
1295
-
1296
- Changed files:
1297
- ${filesText}
1298
-
1299
- git diff --staged:
1300
- ${diffText || '(no staged content, please infer from the file list)'}`
1301
-
1302
- const prompt = userLocale.startsWith('en') ? promptEn : promptZh
1303
-
1304
- // AI prompt 默认不打印,只有 DEBUG=1 / DEBUG=logger 时才落地(防 Token 泄露)
1305
- logger.debug('[generate-commit] locale:', userLocale, '| prompt length:', prompt.length)
1306
- logger.debug('[generate-commit] prompt:\n' + prompt)
1307
- const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }))
1308
- const { url, headers } = buildAiChatRequest({
1309
- baseURL: defaultModel.baseURL,
1310
- model: defaultModel.model,
1311
- apiKey: defaultModel.apiKey,
1312
- })
1313
- const body = JSON.stringify({
1314
- model: defaultModel.model,
1315
- messages: [{ role: 'user', content: prompt }],
1316
- max_tokens: 1024,
1317
- temperature: 0.3,
1318
- stream: false
1319
- })
1320
- const controller = new AbortController()
1321
- const timer = setTimeout(() => controller.abort(), 60000)
1322
- let response
1323
- try {
1324
- response = await fetch(url, { method: 'POST', headers, body, signal: controller.signal })
1325
- } finally {
1326
- clearTimeout(timer)
1327
- }
1328
- const data = await response.json().catch(() => ({}))
1329
- if (!response.ok) {
1330
- const msg = data?.error?.message || data?.message || `HTTP ${response.status}`
1331
- return res.json({ success: false, error: msg, code: 'HTTP_ERR' })
1332
- }
1333
- const content = data?.choices?.[0]?.message?.content || ''
1334
- // 响应内容只走 debug(默认关闭),前 600 字截断防 token 泄露
1335
- logger.debug('[generate-commit] raw content length:', content.length, JSON.stringify(content).slice(0, 600))
1336
-
1337
- const codeBlockMatch = content.match(/```(?:json)?\s*(\{[^`]*?\})\s*```/)
1338
- const jsonMatch = codeBlockMatch
1339
- ? [codeBlockMatch[1]]
1340
- : [...content.matchAll(/\{[^{}]*\}/g)].at(-1)
1341
-
1342
- if (!jsonMatch) {
1343
- logger.error('[generate-commit] no JSON found, full content:', content)
1344
- return res.json({ success: false, error: 'model returned no valid JSON', code: 'NO_JSON' })
1345
- }
1346
- let parsed
1347
- try {
1348
- parsed = JSON.parse(jsonMatch[0])
1349
- } catch {
1350
- const typeM = jsonMatch[0].match(/"type"\s*:\s*"([^"]+)"/)
1351
- const scopeM = jsonMatch[0].match(/"scope"\s*:\s*"([^"]*)"/)
1352
- const descM = jsonMatch[0].match(/"description"\s*:\s*"([^"]+)"/)
1353
- if (typeM || descM) {
1354
- return res.json({
1355
- success: true,
1356
- type: (typeM?.[1] || 'feat').trim(),
1357
- scope: (scopeM?.[1] || '').trim(),
1358
- description: (descM?.[1] || '').trim()
1359
- })
1360
- }
1361
- return res.json({ success: false, error: 'JSON parse failed', code: 'PARSE_FAILED' })
1362
- }
1363
- res.json({
1364
- success: true,
1365
- type: String(parsed.type || 'feat').trim(),
1366
- scope: String(parsed.scope || '').trim(),
1367
- description: String(parsed.description || '').trim()
1368
- })
1369
- } catch (error) {
1370
- const isTimeout = error.name === 'AbortError'
1371
- const msg = isTimeout ? 'AI 请求超时(60s),请重试或检查模型响应速度' : error.message
1372
- res.json({ success: false, error: msg, code: isTimeout ? 'TIMEOUT' : 'GENERATE_FAILED' })
1373
- }
1374
- })
1375
- }
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 fs from 'fs/promises';
19
+ import open from 'open';
20
+ import logger from '../utils/logger.js';
21
+ import { CONFIG_FILE } from '../../../paths.js';
22
+ // AI 请求的地址/请求头统一走这里:OpenCode 网关要求自报 User-Agent 并带
23
+ // x-opencode-session,否则直接 4xx(见 src/utils/aiEndpoint.js 顶部说明)
24
+ import { buildAiChatRequest } from '../../../utils/aiEndpoint.js';
25
+ import { buildModelFallbackChain, modelLabel, runWithModelFallback } from '../../../utils/aiModelFallback.js';
26
+ // 请求预算的解析公式住在 cli/ai/context.js —— 前端拿"解析后的三参数"做超长输入
27
+ // 预警时,服务端下发同一份解析结果,不在前端重算(口径只有一处)。
28
+ import { resolveRequestBudget } from '../../../cli/ai/context.js';
29
+
30
+ // 跳过的产物/资源/lock 文件(用 stat 一行带过,不打 patch)
31
+ const SKIP_FILE_PATTERNS = [
32
+ /(^|[\\/])dist[\\/]/,
33
+ /(^|[\\/])build[\\/]/,
34
+ /(^|[\\/])\.next[\\/]/,
35
+ /(^|[\\/])coverage[\\/]/,
36
+ /(^|[\\/])node_modules[\\/]/,
37
+ /\.lock$/,
38
+ /package-lock\.json$/,
39
+ /yarn\.lock$/,
40
+ /pnpm-lock\.yaml$/,
41
+ /min\.js$/,
42
+ /min\.css$/,
43
+ /\.bundle\.js$/,
44
+ /\.map$/,
45
+ /\.(png|jpe?g|gif|webp|svg|ico|bmp|tiff)$/,
46
+ /\.(mp4|mov|avi|mkv|webm)$/,
47
+ /\.(mp3|wav|ogg|flac)$/,
48
+ /\.(woff2?|ttf|otf|eot)$/,
49
+ /\.(pdf|zip|tar|gz|7z|rar)$/
50
+ ]
51
+
52
+ // 文件优先级:数字越大越重要
53
+ function filePriority(p) {
54
+ if (/\.(test|spec)\.(js|ts|jsx|tsx|vue)$/i.test(p)) return 1 // 测试文件
55
+ if (/^docs?\//i.test(p) || /\.md$/i.test(p)) return 2 // 文档
56
+ if (/\.(json|ya?ml|toml|env|ini|cfg|conf)$/i.test(p)) return 3 // 配置
57
+ if (/\.(css|scss|sass|less|html|vue|jsx|tsx)$/i.test(p)) return 4 // 前端
58
+ if (/\.(ts|js|mjs|cjs)$/i.test(p)) return 5 // 后端/脚本
59
+ return 3 // 其它(资源、未知)
60
+ }
61
+
62
+ function isSkippedFile(path) {
63
+ return SKIP_FILE_PATTERNS.some(re => re.test(path))
64
+ }
65
+
66
+ // 从 git diff 文本解析出 per-file 块
67
+ // git diff 输出格式: "diff --git a/path b/path\n...一系列 header...\n--- a/path\n+++ b/path\n@@ ...\n<patch>"
68
+ function parseDiffByFile(diffText) {
69
+ if (!diffText) return []
70
+ const files = []
71
+ // 按 "diff --git " 切分,首段可能为空(文本以这个开头则第一段空)
72
+ const parts = diffText.split(/^diff --git /m).filter(Boolean)
73
+ for (const part of parts) {
74
+ const headerEnd = part.indexOf('\n')
75
+ const headerLine = (headerEnd >= 0 ? part.slice(0, headerEnd) : part).trim()
76
+ // 从 header 里抓路径: "a/path b/path" -> 优先 b
77
+ const m = headerLine.match(/^a\/(.+?)\s+b\/(.+)$/)
78
+ if (!m) continue
79
+ const bPath = m[2]
80
+ const patch = part.slice(headerEnd + 1)
81
+ // 统计 +/- 行数
82
+ let added = 0, removed = 0
83
+ for (const line of patch.split('\n')) {
84
+ if (line.startsWith('+') && !line.startsWith('+++')) added++
85
+ else if (line.startsWith('-') && !line.startsWith('---')) removed++
86
+ }
87
+ files.push({ path: bPath, patch, added, removed })
88
+ }
89
+ return files
90
+ }
91
+
92
+ // 收集 AI 生成提交信息所需的 diff 和文件列表
93
+ // 关键: untracked 文件默认不会出现在 git diff --staged 输出里,
94
+ // 所以先对所有 untracked 文件做 git add -N(intent to add),
95
+ // 这样它们会以 "new file" 形式出现在 diff 里
96
+ // selectedPaths: 若给定且非空,则只收集这些路径的 diff,且 git add -N 也仅作用于所选 untracked,
97
+ // 避免污染整个 index
98
+ async function collectDiffForAi({ execGitCommand, getCurrentProjectPath, selectedPaths }) {
99
+ if (typeof execGitCommand !== 'function') {
100
+ return { diff: '', fileList: [] }
101
+ }
102
+ const projectPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : ''
103
+ const cwdOpts = projectPath ? { cwd: projectPath, log: false } : { log: false }
104
+
105
+ const selectedSet = Array.isArray(selectedPaths) && selectedPaths.length > 0
106
+ ? new Set(selectedPaths.map(p => String(p).replace(/\\/g, '/')))
107
+ : null
108
+
109
+ let fileList = []
110
+ try {
111
+ // 1. 拿到工作区状态,识别 untracked 文件
112
+ const { stdout: statusOut } = await execGitCommand(['status', '--porcelain=1', '--untracked-files=all'], cwdOpts)
113
+ const untracked = []
114
+ const trackedChanges = []
115
+ for (const line of (statusOut || '').split('\n')) {
116
+ if (!line || line.length < 3) continue
117
+ // porcelain=1 格式: "XY path" X=index状态, Y=worktree状态
118
+ const x = line[0]
119
+ const y = line[1]
120
+ const path = line.slice(3)
121
+ // 选择模式: 只保留所选路径
122
+ if (selectedSet && !selectedSet.has(path.replace(/\\/g, '/'))) continue
123
+ if (x === '?' && y === '?') {
124
+ untracked.push(path)
125
+ } else if (x !== ' ' || y !== ' ') {
126
+ // 有改动(暂存或工作区)
127
+ trackedChanges.push(`${x !== ' ' ? x : ' '} ${path}`.trim())
128
+ }
129
+ }
130
+ fileList = [...trackedChanges, ...untracked.map(p => `? ${p}`)]
131
+
132
+ // 2. 对 untracked 文件做 intent to add(只在内存中,不会真的暂存内容)
133
+ // 选择模式下 untracked 已被裁剪,只会对所选的做 -N,不污染整个 index
134
+ if (untracked.length > 0) {
135
+ // 加上 --force 以防某些文件已经在 index 中
136
+ // 分批处理,避免命令行过长
137
+ const batchSize = 20
138
+ for (let i = 0; i < untracked.length; i += batchSize) {
139
+ const batch = untracked.slice(i, i + batchSize)
140
+ try {
141
+ await execGitCommand(['add', '-N', '--force', ...batch], cwdOpts)
142
+ } catch (e) {
143
+ // 单批失败不影响整体
144
+ logger.warn('[generate-commit] git add -N failed for batch:', e?.message)
145
+ }
146
+ }
147
+ }
148
+
149
+ // 3. 合并 staged + unstaged diff
150
+ // 用 --no-color 避免 ANSI 干扰, --no-ext-diff 避免外接 diff 工具
151
+ // 选择模式下用 `-- <paths>` 限定,避免拉取无关 staged 改动
152
+ const pathArgs = selectedSet ? [...selectedSet] : []
153
+ const diffBase = ['diff', '--no-color', '--no-ext-diff']
154
+ const stagedArgs = ['diff', '--staged', '--no-color', '--no-ext-diff', ...(pathArgs.length ? ['--', ...pathArgs] : [])]
155
+ const unstagedArgs = [...diffBase, ...(pathArgs.length ? ['--', ...pathArgs] : [])]
156
+ const [stagedRes, unstagedRes] = await Promise.all([
157
+ execGitCommand(stagedArgs, cwdOpts).catch(() => ({ stdout: '' })),
158
+ execGitCommand(unstagedArgs, cwdOpts).catch(() => ({ stdout: '' }))
159
+ ])
160
+ let combined = ''
161
+ if (stagedRes?.stdout) combined += stagedRes.stdout.trim() + '\n'
162
+ if (unstagedRes?.stdout) combined += unstagedRes.stdout.trim() + '\n'
163
+
164
+ return { diff: combined.trim(), fileList }
165
+ } catch (error) {
166
+ logger.error('[generate-commit] collectDiffForAi error:', error?.message)
167
+ return { diff: '', fileList }
168
+ }
169
+ }
170
+
171
+ // 把 diff 压缩成给 LLM 的紧凑文本
172
+ // 策略: 跳过产物/资源文件(用一行 stat 带过),源码按优先级排序,每个文件 patch 限 1500 字,总预算 6000 字
173
+ function prepareDiffForPrompt(diffText, fileList) {
174
+ const safeFileList = Array.isArray(fileList) ? fileList : []
175
+ let files = parseDiffByFile(diffText)
176
+
177
+ // 如果客户端明确指定了文件列表,则只保留与所选文件匹配的 diff 块
178
+ if (safeFileList.length > 0 && files.length > 0) {
179
+ const selectedPaths = new Set(
180
+ safeFileList
181
+ .map(s => {
182
+ if (typeof s !== 'string') return ''
183
+ // fileList 形如 "M src/foo.ts" 或 "? new-file.ts",取最后的路径部分
184
+ const m = s.match(/^[A-Z?\s]+\s+(.+)$/)
185
+ return (m ? m[1] : s).replace(/\\/g, '/')
186
+ })
187
+ .filter(Boolean)
188
+ )
189
+ if (selectedPaths.size > 0) {
190
+ files = files.filter(f => selectedPaths.has(f.path.replace(/\\/g, '/')))
191
+ }
192
+ }
193
+
194
+ // 如果 parse 出来是空的(diff 可能是空或非标准格式),退回到 fileList 推断
195
+ if (files.length === 0) {
196
+ const list = safeFileList.slice(0, 30).map(s => {
197
+ // fileList 形如 "M src/foo.ts" 或 "? new-file.ts"
198
+ const m = s.match(/^[A-Z?\s]+\s+(.+)$/)
199
+ return m ? m[1] : s
200
+ })
201
+ if (list.length === 0) return ''
202
+ return list.map(p => {
203
+ if (isSkippedFile(p)) return `${p} [产物/资源,已跳过]`
204
+ return p
205
+ }).join('\n')
206
+ }
207
+
208
+ // 拆分: 跳过的 vs 保留的
209
+ const skipped = []
210
+ const kept = []
211
+ for (const f of files) {
212
+ if (isSkippedFile(f.path)) {
213
+ skipped.push(f)
214
+ } else {
215
+ kept.push(f)
216
+ }
217
+ }
218
+
219
+ // 保留的文件按优先级降序,同优先级按 +/- 总数降序(改得多的优先)
220
+ kept.sort((a, b) => {
221
+ const dp = filePriority(b.path) - filePriority(a.path)
222
+ if (dp !== 0) return dp
223
+ return (b.added + b.removed) - (a.added + a.removed)
224
+ })
225
+
226
+ const TOTAL_BUDGET = 6000
227
+ const PER_FILE_LIMIT = 1500
228
+ const lines = []
229
+ let budget = TOTAL_BUDGET
230
+
231
+ // 先把跳过的文件用一行 stat 总结
232
+ for (const f of skipped) {
233
+ if (budget < 80) break
234
+ lines.push(`${f.path} [+${f.added}/-${f.removed}, 已跳过产物/资源]`)
235
+ budget -= 80
236
+ }
237
+
238
+ // 再装源码,带预算控制
239
+ for (const f of kept) {
240
+ if (budget <= 50) break
241
+ let patch = f.patch
242
+ let truncated = false
243
+ if (patch.length > PER_FILE_LIMIT) {
244
+ patch = patch.slice(0, PER_FILE_LIMIT) + '\n... (diff 已截断)'
245
+ truncated = true
246
+ }
247
+ const block = `--- ${f.path} (+${f.added}/-${f.removed})${truncated ? ' [截断]' : ''}\n${patch}`
248
+ if (block.length > budget) {
249
+ // 单个文件塞不下了,截到能塞下为止
250
+ const sliceLen = Math.max(0, budget - 80)
251
+ if (sliceLen < 100) break
252
+ lines.push(`--- ${f.path} (+${f.added}/-${f.removed}) [预算耗尽,已截断]\n${patch.slice(0, sliceLen)}`)
253
+ budget = 0
254
+ break
255
+ }
256
+ lines.push(block)
257
+ budget -= block.length
258
+ }
259
+
260
+ if (lines.length === 0) return ''
261
+ return lines.join('\n\n')
262
+ }
263
+
264
+ export { prepareDiffForPrompt, parseDiffByFile, isSkippedFile, filePriority, collectDiffForAi }
265
+
266
+ export function registerConfigRoutes({
267
+ app,
268
+ configManager,
269
+ execGitCommand,
270
+ getCurrentProjectPath
271
+ }) {
272
+ // 保存最近访问的目录
273
+ app.post('/api/save_recent_directory', asyncRoute(async (req, res) => {
274
+ try {
275
+ const { path } = req.body;
276
+
277
+ if (!path) {
278
+ return res.status(400).json({
279
+ success: false,
280
+ error: '目录路径不能为空'
281
+ });
282
+ }
283
+
284
+ // 保存到配置
285
+ await configManager.saveRecentDirectory(path);
286
+
287
+ res.json({
288
+ success: true
289
+ });
290
+ } catch (error) {
291
+ res.status(500).json({
292
+ success: false,
293
+ error: error.message
294
+ });
295
+ }
296
+ }));
297
+
298
+ // 删除最近访问的目录
299
+ app.post('/api/remove_recent_directory', asyncRoute(async (req, res) => {
300
+ try {
301
+ const { path: dirPath } = req.body;
302
+ if (!dirPath) {
303
+ throw new HttpError(400, '目录路径不能为空');
304
+ }
305
+ const list = await configManager.removeRecentDirectory(dirPath);
306
+ res.json({ success: true, directories: list });
307
+ } catch (error) {
308
+ res.status(500).json({ success: false, error: error.message });
309
+ }
310
+ }));
311
+
312
+ // 获取配置
313
+ app.get('/api/config/getConfig', asyncRoute(async (req, res) => {
314
+ try {
315
+ const config = await configManager.loadConfig()
316
+
317
+ // 兼容旧数据:补齐自定义命令 id,避免前端编辑/删除/编排引用异常
318
+ if (Array.isArray(config.customCommands)) {
319
+ let changed = false
320
+ config.customCommands = config.customCommands.map((cmd) => {
321
+ if (cmd && typeof cmd === 'object' && !cmd.id) {
322
+ changed = true
323
+ return {
324
+ ...cmd,
325
+ id: `cmd_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
326
+ }
327
+ }
328
+ return cmd
329
+ })
330
+ if (changed) {
331
+ await configManager.saveConfig(config)
332
+ }
333
+ }
334
+
335
+ // 初始化命令模板(首次安装/旧配置兼容)
336
+ if (!Array.isArray(config.commandTemplates)) {
337
+ config.commandTemplates = []
338
+ }
339
+
340
+ if (config.commandTemplates.length === 0) {
341
+ config.commandTemplates = [
342
+ 'echo "{{cmd}}"',
343
+ 'npm run dev',
344
+ 'npm run build',
345
+ 'git status',
346
+ 'git add .',
347
+ 'git commit -m "{{message}}" --no-verify',
348
+ 'git push',
349
+ ]
350
+ await configManager.saveConfig(config)
351
+ }
352
+
353
+ // 解析后的请求预算(超长输入预警的数据源):与 CLI/Web 实际裁剪用的是
354
+ // 同一个公式(resolveRequestBudget),前端只读结果、不重算。
355
+ // 刻意放在上面所有 saveConfig 之后 —— 这个字段是**派生值**,不许跟着
356
+ // saveConfig 的 ...projectConfig 泄漏进项目级配置。
357
+ config.aiRequestBudget = resolveRequestBudget(config.aiMaxRequestTokens)
358
+ res.json(config)
359
+ } catch (error) {
360
+ const configPath = CONFIG_FILE
361
+ res.status(500).json({
362
+ success: false,
363
+ code: 'CONFIG_LOAD_FAILED',
364
+ error: error?.message || String(error),
365
+ configPath
366
+ })
367
+ }
368
+ }));
369
+
370
+ // 保存默认提交信息
371
+ app.post('/api/config/saveDefaultMessage', express.json(), async (req, res) => {
372
+ try {
373
+ const { defaultCommitMessage } = req.body
374
+
375
+ if (!defaultCommitMessage) {
376
+ return res.status(400).json({ success: false, error: '缺少必要参数' })
377
+ }
378
+
379
+ const config = await configManager.loadConfig()
380
+
381
+ // 更新默认提交信息
382
+ config.defaultCommitMessage = defaultCommitMessage
383
+ await configManager.saveConfig(config)
384
+
385
+ res.json({ success: true })
386
+ } catch (error) {
387
+ res.status(500).json({ success: false, error: error.message })
388
+ }
389
+ })
390
+
391
+ // 保存所有配置
392
+ app.post('/api/config/saveAll', express.json(), async (req, res) => {
393
+ try {
394
+ const { config } = req.body
395
+
396
+ if (!config) {
397
+ return res.status(400).json({ success: false, error: '缺少必要参数' })
398
+ }
399
+
400
+ await configManager.saveConfig(config)
401
+
402
+ res.json({ success: true })
403
+ } catch (error) {
404
+ res.status(500).json({ success: false, error: error.message })
405
+ }
406
+ })
407
+
408
+ // 检查系统配置文件格式
409
+ app.get('/api/config/check-file-format', asyncRoute(async (req, res) => {
410
+ try {
411
+ const configPath = CONFIG_FILE;
412
+
413
+ try {
414
+ const data = await fs.readFile(configPath, 'utf-8');
415
+ try {
416
+ JSON.parse(data);
417
+ res.json({ success: true, isValidJson: true, exists: true });
418
+ } catch (parseError) {
419
+ res.json({
420
+ success: true,
421
+ isValidJson: false,
422
+ exists: true,
423
+ error: `JSON解析失败: ${parseError.message}`
424
+ });
425
+ }
426
+ } catch (fileError) {
427
+ if (fileError.code === 'ENOENT') {
428
+ res.json({ success: true, isValidJson: true, exists: false });
429
+ } else {
430
+ res.json({
431
+ success: true,
432
+ isValidJson: false,
433
+ exists: true,
434
+ error: `文件读取失败: ${fileError.message}`
435
+ });
436
+ }
437
+ }
438
+ } catch (error) {
439
+ res.status(500).json({ success: false, error: error.message });
440
+ }
441
+ }));
442
+
443
+ // 使用系统默认程序打开配置文件(~/.zen-gitsync/config.json)
444
+ app.post('/api/config/open-file', asyncRoute(async (req, res) => {
445
+ try {
446
+ const filePath = CONFIG_FILE;
447
+ try {
448
+ // 检查文件是否存在,不存在也尝试让系统创建(可能会打开空文件)
449
+ await fs.access(filePath);
450
+ } catch (_) {
451
+ // 如果文件不存在,先创建一个最小结构,避免某些系统无法打开不存在的路径。
452
+ // 注意:新路径是嵌套目录,首次打开时数据目录可能还没建(正常启动流程里由
453
+ // dataDirMigration 建好,这里是独立于迁移的兜底)。
454
+ try {
455
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
456
+ await fs.writeFile(filePath, '{}', 'utf-8');
457
+ } catch (e) {
458
+ // 创建失败不阻断打开尝试
459
+ logger.warn('创建配置文件失败(可忽略):', e?.message || e);
460
+ }
461
+ }
462
+
463
+ await open(filePath, { wait: false });
464
+ res.json({ success: true })
465
+ } catch (error) {
466
+ res.status(400).json({ success: false, error: `无法打开配置文件: ${error.message}` })
467
+ }
468
+ }));
469
+
470
+ // 保存模板
471
+ app.post('/api/config/save-template', express.json(), async (req, res) => {
472
+ try {
473
+ const { template, type } = req.body
474
+
475
+ if (!template || !type) {
476
+ return res.status(400).json({ success: false, error: '缺少必要参数' })
477
+ }
478
+
479
+ const config = await configManager.loadConfig()
480
+
481
+ if (type === 'description') {
482
+ // 确保描述模板数组存在
483
+ if (!config.descriptionTemplates) {
484
+ config.descriptionTemplates = []
485
+ }
486
+
487
+ // 检查是否已存在相同模板
488
+ if (!config.descriptionTemplates.includes(template)) {
489
+ config.descriptionTemplates.push(template)
490
+ await configManager.saveConfig(config)
491
+ }
492
+ } else if (type === 'scope') {
493
+ // 确保作用域模板数组存在
494
+ if (!config.scopeTemplates) {
495
+ config.scopeTemplates = []
496
+ }
497
+
498
+ // 检查是否已存在相同模板
499
+ if (!config.scopeTemplates.includes(template)) {
500
+ config.scopeTemplates.push(template)
501
+ await configManager.saveConfig(config)
502
+ }
503
+ } else if (type === 'message') {
504
+ // 确保提交信息模板数组存在
505
+ if (!config.messageTemplates) {
506
+ config.messageTemplates = []
507
+ }
508
+
509
+ // 检查是否已存在相同模板
510
+ if (!config.messageTemplates.includes(template)) {
511
+ config.messageTemplates.push(template)
512
+ await configManager.saveConfig(config)
513
+ }
514
+ } else if (type === 'command') {
515
+ if (!config.commandTemplates) {
516
+ config.commandTemplates = []
517
+ }
518
+ if (!config.commandTemplates.includes(template)) {
519
+ config.commandTemplates.push(template)
520
+ await configManager.saveConfig(config)
521
+ }
522
+ } else {
523
+ return res.status(400).json({ success: false, error: '不支持的模板类型' })
524
+ }
525
+
526
+ res.json({ success: true })
527
+ } catch (error) {
528
+ res.status(500).json({ success: false, error: error.message })
529
+ }
530
+ })
531
+
532
+ // 删除模板
533
+ app.post('/api/config/delete-template', express.json(), async (req, res) => {
534
+ try {
535
+ const { template, type } = req.body
536
+
537
+ if (!template || !type) {
538
+ return res.status(400).json({ success: false, error: '缺少必要参数' })
539
+ }
540
+
541
+ const config = await configManager.loadConfig()
542
+
543
+ if (type === 'description') {
544
+ // 确保描述模板数组存在
545
+ if (config.descriptionTemplates) {
546
+ const index = config.descriptionTemplates.indexOf(template)
547
+ if (index !== -1) {
548
+ config.descriptionTemplates.splice(index, 1)
549
+ await configManager.saveConfig(config)
550
+ }
551
+ }
552
+ } else if (type === 'scope') {
553
+ // 确保作用域模板数组存在
554
+ if (config.scopeTemplates) {
555
+ const index = config.scopeTemplates.indexOf(template)
556
+ if (index !== -1) {
557
+ config.scopeTemplates.splice(index, 1)
558
+ await configManager.saveConfig(config)
559
+ }
560
+ }
561
+ } else if (type === 'message') {
562
+ // 确保提交信息模板数组存在
563
+ if (config.messageTemplates) {
564
+ const index = config.messageTemplates.indexOf(template)
565
+ if (index !== -1) {
566
+ config.messageTemplates.splice(index, 1)
567
+ await configManager.saveConfig(config)
568
+ }
569
+ }
570
+ } else if (type === 'command') {
571
+ if (config.commandTemplates) {
572
+ const index = config.commandTemplates.indexOf(template)
573
+ if (index !== -1) {
574
+ config.commandTemplates.splice(index, 1)
575
+ await configManager.saveConfig(config)
576
+ }
577
+ }
578
+ } else {
579
+ return res.status(400).json({ success: false, error: '不支持的模板类型' })
580
+ }
581
+
582
+ res.json({ success: true })
583
+ } catch (error) {
584
+ res.status(500).json({ success: false, error: error.message })
585
+ }
586
+ })
587
+
588
+ // 更新模板
589
+ app.post('/api/config/update-template', express.json(), async (req, res) => {
590
+ try {
591
+ const { oldTemplate, newTemplate, type } = req.body
592
+
593
+ if (!oldTemplate || !newTemplate || !type) {
594
+ return res.status(400).json({ success: false, error: '缺少必要参数' })
595
+ }
596
+
597
+ const config = await configManager.loadConfig()
598
+
599
+ if (type === 'description') {
600
+ // 确保描述模板数组存在
601
+ if (config.descriptionTemplates) {
602
+ const index = config.descriptionTemplates.indexOf(oldTemplate)
603
+ if (index !== -1) {
604
+ config.descriptionTemplates[index] = newTemplate
605
+ await configManager.saveConfig(config)
606
+ } else {
607
+ return res.status(404).json({ success: false, error: '未找到原模板' })
608
+ }
609
+ } else {
610
+ return res.status(404).json({ success: false, error: '模板列表不存在' })
611
+ }
612
+ } else if (type === 'scope') {
613
+ // 确保作用域模板数组存在
614
+ if (config.scopeTemplates) {
615
+ const index = config.scopeTemplates.indexOf(oldTemplate)
616
+ if (index !== -1) {
617
+ config.scopeTemplates[index] = newTemplate
618
+ await configManager.saveConfig(config)
619
+ } else {
620
+ return res.status(404).json({ success: false, error: '未找到原模板' })
621
+ }
622
+ } else {
623
+ return res.status(404).json({ success: false, error: '模板列表不存在' })
624
+ }
625
+ } else if (type === 'message') {
626
+ // 确保提交信息模板数组存在
627
+ if (config.messageTemplates) {
628
+ const index = config.messageTemplates.indexOf(oldTemplate)
629
+ if (index !== -1) {
630
+ config.messageTemplates[index] = newTemplate
631
+ await configManager.saveConfig(config)
632
+ } else {
633
+ return res.status(404).json({ success: false, error: '未找到原模板' })
634
+ }
635
+ } else {
636
+ return res.status(404).json({ success: false, error: '模板列表不存在' })
637
+ }
638
+ } else if (type === 'command') {
639
+ if (config.commandTemplates) {
640
+ const index = config.commandTemplates.indexOf(oldTemplate)
641
+ if (index !== -1) {
642
+ config.commandTemplates[index] = newTemplate
643
+ await configManager.saveConfig(config)
644
+ } else {
645
+ return res.status(404).json({ success: false, error: '未找到原模板' })
646
+ }
647
+ } else {
648
+ return res.status(404).json({ success: false, error: '模板列表不存在' })
649
+ }
650
+ } else {
651
+ return res.status(400).json({ success: false, error: '不支持的模板类型' })
652
+ }
653
+
654
+ res.json({ success: true })
655
+ } catch (error) {
656
+ res.status(500).json({ success: false, error: error.message })
657
+ }
658
+ })
659
+
660
+ // 置顶模板
661
+ app.post('/api/config/pin-template', express.json(), async (req, res) => {
662
+ try {
663
+ const { template, type } = req.body
664
+
665
+ if (!template || !type) {
666
+ return res.status(400).json({ success: false, error: '缺少必要参数' })
667
+ }
668
+
669
+ const config = await configManager.loadConfig()
670
+
671
+ if (type === 'description') {
672
+ if (config.descriptionTemplates) {
673
+ // 删除原位置的模板
674
+ config.descriptionTemplates = config.descriptionTemplates.filter(t => t !== template)
675
+ // 添加到第一位
676
+ config.descriptionTemplates.unshift(template)
677
+ await configManager.saveConfig(config)
678
+ } else {
679
+ return res.status(404).json({ success: false, error: '模板列表不存在' })
680
+ }
681
+ } else if (type === 'scope') {
682
+ if (config.scopeTemplates) {
683
+ config.scopeTemplates = config.scopeTemplates.filter(t => t !== template)
684
+ config.scopeTemplates.unshift(template)
685
+ await configManager.saveConfig(config)
686
+ } else {
687
+ return res.status(404).json({ success: false, error: '模板列表不存在' })
688
+ }
689
+ } else if (type === 'message') {
690
+ if (config.messageTemplates) {
691
+ config.messageTemplates = config.messageTemplates.filter(t => t !== template)
692
+ config.messageTemplates.unshift(template)
693
+ await configManager.saveConfig(config)
694
+ } else {
695
+ return res.status(404).json({ success: false, error: '模板列表不存在' })
696
+ }
697
+ } else if (type === 'command') {
698
+ if (config.commandTemplates) {
699
+ config.commandTemplates = config.commandTemplates.filter(t => t !== template)
700
+ config.commandTemplates.unshift(template)
701
+ await configManager.saveConfig(config)
702
+ } else {
703
+ return res.status(404).json({ success: false, error: '模板列表不存在' })
704
+ }
705
+ } else {
706
+ return res.status(400).json({ success: false, error: '不支持的模板类型' })
707
+ }
708
+
709
+ res.json({ success: true })
710
+ } catch (error) {
711
+ res.status(500).json({ success: false, error: error.message })
712
+ }
713
+ })
714
+
715
+ // 保存自定义命令
716
+ app.post('/api/config/save-custom-command', express.json(), async (req, res) => {
717
+ try {
718
+ const { command } = req.body
719
+
720
+ if (!command || !command.name || !command.command) {
721
+ return res.status(400).json({ success: false, error: '缺少必要参数' })
722
+ }
723
+
724
+ const config = await configManager.loadConfig()
725
+
726
+ // 确保自定义命令数组存在
727
+ if (!Array.isArray(config.customCommands)) {
728
+ config.customCommands = []
729
+ }
730
+
731
+ // 生成唯一ID
732
+ const id = `cmd_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
733
+ const newCommand = {
734
+ id,
735
+ name: command.name,
736
+ description: command.description || '',
737
+ directory: command.directory || '',
738
+ command: command.command,
739
+ params: Array.isArray(command.params) ? command.params : []
740
+ }
741
+
742
+ config.customCommands.push(newCommand)
743
+ await configManager.saveConfig(config)
744
+
745
+ res.json({ success: true, command: newCommand })
746
+ } catch (error) {
747
+ res.status(500).json({ success: false, error: error.message })
748
+ }
749
+ })
750
+
751
+ // 删除自定义命令
752
+ app.post('/api/config/delete-custom-command', express.json(), async (req, res) => {
753
+ try {
754
+ const { id } = req.body
755
+
756
+ if (!id) {
757
+ return res.status(400).json({ success: false, error: '缺少命令ID参数' })
758
+ }
759
+
760
+ const config = await configManager.loadConfig()
761
+
762
+ if (Array.isArray(config.customCommands)) {
763
+ const index = config.customCommands.findIndex(cmd => cmd.id === id)
764
+ if (index !== -1) {
765
+ config.customCommands.splice(index, 1)
766
+ await configManager.saveConfig(config)
767
+ }
768
+ }
769
+
770
+ res.json({ success: true })
771
+ } catch (error) {
772
+ res.status(500).json({ success: false, error: error.message })
773
+ }
774
+ })
775
+
776
+ // 置顶自定义命令(移到数组首位)
777
+ app.post('/api/config/pin-custom-command', express.json(), async (req, res) => {
778
+ try {
779
+ const { id } = req.body
780
+
781
+ if (!id) {
782
+ return res.status(400).json({ success: false, error: '缺少命令ID参数' })
783
+ }
784
+
785
+ const config = await configManager.loadConfig()
786
+
787
+ if (Array.isArray(config.customCommands)) {
788
+ const index = config.customCommands.findIndex(cmd => cmd.id === id)
789
+ if (index > 0) {
790
+ const [cmd] = config.customCommands.splice(index, 1)
791
+ config.customCommands.unshift(cmd)
792
+ await configManager.saveConfig(config)
793
+ }
794
+ }
795
+
796
+ res.json({ success: true })
797
+ } catch (error) {
798
+ res.status(500).json({ success: false, error: error.message })
799
+ }
800
+ })
801
+
802
+ // 更新自定义命令
803
+ app.post('/api/config/update-custom-command', express.json(), async (req, res) => {
804
+ try {
805
+ const { id, command } = req.body
806
+
807
+ if (!id || !command || !command.name || !command.command) {
808
+ return res.status(400).json({ success: false, error: '缺少必要参数' })
809
+ }
810
+
811
+ const config = await configManager.loadConfig()
812
+
813
+ if (Array.isArray(config.customCommands)) {
814
+ const index = config.customCommands.findIndex(cmd => cmd.id === id)
815
+ if (index !== -1) {
816
+ config.customCommands[index] = {
817
+ id,
818
+ name: command.name,
819
+ description: command.description || '',
820
+ directory: command.directory || '',
821
+ command: command.command,
822
+ params: Array.isArray(command.params) ? command.params : []
823
+ }
824
+ await configManager.saveConfig(config)
825
+ } else {
826
+ return res.status(404).json({ success: false, error: '未找到指定命令' })
827
+ }
828
+ } else {
829
+ return res.status(404).json({ success: false, error: '命令列表不存在' })
830
+ }
831
+
832
+ res.json({ success: true })
833
+ } catch (error) {
834
+ res.status(500).json({ success: false, error: error.message })
835
+ }
836
+ })
837
+
838
+ // 保存指令编排
839
+ app.post('/api/config/save-orchestration', express.json(), async (req, res) => {
840
+ try {
841
+ const { orchestration } = req.body
842
+
843
+ if (!orchestration || !orchestration.name) {
844
+ return res.status(400).json({ success: false, error: '缺少必要参数' })
845
+ }
846
+
847
+ const config = await configManager.loadConfig()
848
+
849
+ // 确保编排数组存在
850
+ if (!Array.isArray(config.orchestrations)) {
851
+ config.orchestrations = []
852
+ }
853
+
854
+ // 生成唯一ID
855
+ const id = `orch_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
856
+ const newOrchestration = {
857
+ id,
858
+ name: orchestration.name,
859
+ description: orchestration.description || '',
860
+ flowData: orchestration.flowData || null
861
+ }
862
+
863
+ config.orchestrations.push(newOrchestration)
864
+ await configManager.saveConfig(config)
865
+
866
+ res.json({ success: true, orchestration: newOrchestration })
867
+ } catch (error) {
868
+ res.status(500).json({ success: false, error: error.message })
869
+ }
870
+ })
871
+
872
+ // 删除指令编排
873
+ app.post('/api/config/delete-orchestration', express.json(), async (req, res) => {
874
+ try {
875
+ const { id } = req.body
876
+
877
+ if (!id) {
878
+ return res.status(400).json({ success: false, error: '缺少编排ID参数' })
879
+ }
880
+
881
+ const config = await configManager.loadConfig()
882
+
883
+ if (Array.isArray(config.orchestrations)) {
884
+ const index = config.orchestrations.findIndex(orch => orch.id === id)
885
+ if (index !== -1) {
886
+ config.orchestrations.splice(index, 1)
887
+ await configManager.saveConfig(config)
888
+ }
889
+ }
890
+
891
+ res.json({ success: true })
892
+ } catch (error) {
893
+ res.status(500).json({ success: false, error: error.message })
894
+ }
895
+ })
896
+
897
+ // 更新指令编排
898
+ app.post('/api/config/update-orchestration', express.json(), async (req, res) => {
899
+ try {
900
+ const { id, orchestration } = req.body
901
+
902
+ if (!id || !orchestration || !orchestration.name) {
903
+ return res.status(400).json({ success: false, error: '缺少必要参数' })
904
+ }
905
+
906
+ const config = await configManager.loadConfig()
907
+
908
+ if (Array.isArray(config.orchestrations)) {
909
+ const index = config.orchestrations.findIndex(orch => orch.id === id)
910
+ if (index !== -1) {
911
+ config.orchestrations[index] = {
912
+ id,
913
+ name: orchestration.name,
914
+ description: orchestration.description || '',
915
+ flowData: orchestration.flowData || null
916
+ }
917
+ await configManager.saveConfig(config)
918
+ } else {
919
+ return res.status(404).json({ success: false, error: '未找到指定编排' })
920
+ }
921
+ } else {
922
+ return res.status(404).json({ success: false, error: '编排列表不存在' })
923
+ }
924
+
925
+ res.json({ success: true })
926
+ } catch (error) {
927
+ res.status(500).json({ success: false, error: error.message })
928
+ }
929
+ })
930
+
931
+ // 保存项目启动项
932
+ app.post('/api/config/save-startup-items', express.json(), async (req, res) => {
933
+ try {
934
+ const { startupItems, startupAutoRun } = req.body
935
+
936
+ if (!Array.isArray(startupItems)) {
937
+ return res.status(400).json({ success: false, error: '启动项必须是数组' })
938
+ }
939
+
940
+ const config = await configManager.loadConfig()
941
+ config.startupItems = startupItems
942
+ if (typeof startupAutoRun === 'boolean') {
943
+ config.startupAutoRun = startupAutoRun
944
+ }
945
+ await configManager.saveConfig(config)
946
+
947
+ res.json({ success: true })
948
+ } catch (error) {
949
+ res.status(500).json({ success: false, error: error.message })
950
+ }
951
+ })
952
+
953
+ // 保存提交设置
954
+ app.post('/api/config/save-commit-settings', express.json(), async (req, res) => {
955
+ try {
956
+ const { isStandardCommit, skipHooks, autoQuickPushOnEnter, autoSetDefaultMessage, autoClosePushModal, pullBeforePush } = req.body
957
+ const config = await configManager.loadConfig()
958
+ if (isStandardCommit !== undefined) config.isStandardCommit = Boolean(isStandardCommit)
959
+ if (skipHooks !== undefined) config.skipHooks = Boolean(skipHooks)
960
+ if (autoQuickPushOnEnter !== undefined) config.autoQuickPushOnEnter = Boolean(autoQuickPushOnEnter)
961
+ if (autoSetDefaultMessage !== undefined) config.autoSetDefaultMessage = Boolean(autoSetDefaultMessage)
962
+ if (autoClosePushModal !== undefined) config.autoClosePushModal = Boolean(autoClosePushModal)
963
+ if (pullBeforePush !== undefined) config.pullBeforePush = Boolean(pullBeforePush)
964
+ await configManager.saveConfig(config)
965
+ res.json({ success: true })
966
+ } catch (error) {
967
+ res.status(500).json({ success: false, error: error.message })
968
+ }
969
+ })
970
+
971
+ // 保存"一键推送成功后启动项"
972
+ app.post('/api/config/save-after-quick-push-action', express.json(), async (req, res) => {
973
+ try {
974
+ const { afterQuickPushAction } = req.body
975
+
976
+ if (!afterQuickPushAction || typeof afterQuickPushAction !== 'object') {
977
+ return res.status(400).json({ success: false, error: '缺少必要参数' })
978
+ }
979
+
980
+ const enabled = Boolean(afterQuickPushAction.enabled)
981
+ const type = afterQuickPushAction.type === 'workflow' ? 'workflow' : 'command'
982
+ const refId = String(afterQuickPushAction.refId || '').trim()
983
+
984
+ const config = await configManager.loadConfig()
985
+ config.afterQuickPushAction = {
986
+ enabled,
987
+ type,
988
+ refId
989
+ }
990
+ await configManager.saveConfig(config)
991
+
992
+ res.json({ success: true })
993
+ } catch (error) {
994
+ res.status(500).json({ success: false, error: error.message })
995
+ }
996
+ })
997
+
998
+ // 保存通用设置(主题、语言、工作台任务执行器、任务完成提示)
999
+ app.post('/api/config/save-general-settings', express.json(), async (req, res) => {
1000
+ try {
1001
+ const { theme, locale, taskExecutor, notifyPageOnTaskDone, notifyBrowserOnTaskDone, notifySoundOnTaskDone } = req.body
1002
+
1003
+ // 读取原始配置以保留项目设置
1004
+ const rawConfig = await configManager.readRawConfigFile()
1005
+
1006
+ // 更新全局设置
1007
+ if (theme && ['light', 'dark', 'auto'].includes(theme)) {
1008
+ rawConfig.theme = theme
1009
+ }
1010
+ if (locale && ['zh-CN', 'en-US'].includes(locale)) {
1011
+ rawConfig.locale = locale
1012
+ }
1013
+ // 任务执行器是全局配置(跨项目共享);白名单外的值静默忽略,不落盘
1014
+ const normalizedExecutor = configManager.normalizeTaskExecutor(taskExecutor)
1015
+ if (normalizedExecutor) {
1016
+ rawConfig.taskExecutor = normalizedExecutor
1017
+ }
1018
+
1019
+ // 任务/对话结束提示的三个通道开关(全局):页面提示 / 浏览器通知 / 提示音。
1020
+ // 三者**平级且互相独立** —— 写其中一个绝不顺带改动另外两个(老用户升级时
1021
+ // 由 config.js 的 resolveNotifySwitches 从旧总开关迁移,迁移只发生在读取侧)。
1022
+ // 只接受布尔值,其它类型静默忽略,避免前端误传字符串 'false' 被当成真值落盘后永久打开。
1023
+ // ⚠️ 规范化函数对非法值返回的是 **null**(与 normalizeTaskExecutor 同语义),
1024
+ // 不是 undefined —— 判 `!== undefined` 会把非法值原样写进去变成 `null`。
1025
+ const normalizedNotifies = {}
1026
+ for (const key of ['notifyPageOnTaskDone', 'notifyBrowserOnTaskDone', 'notifySoundOnTaskDone']) {
1027
+ const normalized = configManager.normalizeNotifySwitch(req.body[key])
1028
+ if (normalized !== null) {
1029
+ rawConfig[key] = normalized
1030
+ normalizedNotifies[key] = normalized
1031
+ }
1032
+ }
1033
+
1034
+ // 直接写入原始配置,避免覆盖项目设置
1035
+ await configManager.writeRawConfigFile(rawConfig)
1036
+ res.json({
1037
+ success: true,
1038
+ taskExecutor: normalizedExecutor || undefined,
1039
+ ...normalizedNotifies
1040
+ })
1041
+ } catch (error) {
1042
+ res.status(500).json({ success: false, error: error.message })
1043
+ }
1044
+ })
1045
+
1046
+ // 保存 UI 状态(视图模式/分割比例/控制台状态/布局比例/编辑器自动保存等)
1047
+ // 接受 partial body,浅合并到顶层 ui 对象。例:{ layout: {...} } / { commandConsole: {...} } / { fileListViewMode: 'tree' }
1048
+ // 例外:按项目存储的 map 在多个实例并行修改时不能浅替换,否则会丢失其它项目条目。
1049
+ app.post('/api/config/save-ui-settings', express.json(), async (req, res) => {
1050
+ try {
1051
+ const partial = req.body && typeof req.body === 'object' ? req.body : {}
1052
+
1053
+ const rawConfig = await configManager.readRawConfigFile()
1054
+
1055
+ // 确保 ui 容器存在
1056
+ if (!rawConfig.ui || typeof rawConfig.ui !== 'object' || Array.isArray(rawConfig.ui)) {
1057
+ rawConfig.ui = {}
1058
+ }
1059
+ if (!rawConfig.ui.layoutsByProject || typeof rawConfig.ui.layoutsByProject !== 'object' || Array.isArray(rawConfig.ui.layoutsByProject)) {
1060
+ rawConfig.ui.layoutsByProject = {}
1061
+ }
1062
+ if (!rawConfig.ui.aiDiffSummaryByProject || typeof rawConfig.ui.aiDiffSummaryByProject !== 'object' || Array.isArray(rawConfig.ui.aiDiffSummaryByProject)) {
1063
+ rawConfig.ui.aiDiffSummaryByProject = {}
1064
+ }
1065
+ if (!rawConfig.ui.editorWorkspaceByProject || typeof rawConfig.ui.editorWorkspaceByProject !== 'object' || Array.isArray(rawConfig.ui.editorWorkspaceByProject)) {
1066
+ rawConfig.ui.editorWorkspaceByProject = {}
1067
+ }
1068
+
1069
+ // 浅合并顶层 ui 字段(支持嵌套对象整体替换,如 commandConsole)
1070
+ for (const key of Object.keys(partial)) {
1071
+ if (key === 'layoutsByProject' || key === 'aiDiffSummaryByProject' || key === 'editorWorkspaceByProject') {
1072
+ // 深合并 map:保留其它项目条目
1073
+ const incoming = partial[key]
1074
+ if (incoming && typeof incoming === 'object' && !Array.isArray(incoming)) {
1075
+ rawConfig.ui[key] = {
1076
+ ...rawConfig.ui[key],
1077
+ ...incoming,
1078
+ }
1079
+ }
1080
+ continue
1081
+ }
1082
+ rawConfig.ui[key] = partial[key]
1083
+ }
1084
+
1085
+ await configManager.writeRawConfigFile(rawConfig)
1086
+ res.json({ success: true })
1087
+ } catch (error) {
1088
+ logger.error('[save-ui-settings] failed:', error)
1089
+ res.status(500).json({ success: false, error: error.message })
1090
+ }
1091
+ })
1092
+
1093
+ // 保存 AI 智能体运行时设置(两个键都是全局配置,存配置文件顶层,跨项目共享):
1094
+ // · aiMaxToolIterations —— 单轮最大工具调用次数
1095
+ // · aiMaxRequestTokens —— 单轮请求的上下文预算(**token**;2026-10-07 从字符口径
1096
+ // 改过来,它决定"一条粘贴最多能装多少、工具结果会不会被挤掉",见 cli/ai/context.js)
1097
+ // 旧字段 aiMaxRequestChars 仍接受:config.js 的 resolveRequestTokens 会迁移,
1098
+ // 但**新字段一旦写入就以它为准**,所以这里两个键都存、互不覆盖。
1099
+ // 两个键各自可选:没传的那个不动(旧客户端只传一个键时不许把它顺带清掉)。
1100
+ app.post('/api/config/save-ai-settings', express.json(), async (req, res) => {
1101
+ try {
1102
+ const body = req.body || {}
1103
+ const updates = {}
1104
+ if (body.aiMaxToolIterations !== undefined) {
1105
+ // 越界值在这里夹取到 [MIN, MAX],而不是拒绝 —— 与 loadConfig 的规范化保持同一套语义
1106
+ const normalized = configManager.normalizeAiMaxToolIterations(body.aiMaxToolIterations)
1107
+ if (normalized === null) {
1108
+ return res.status(400).json({
1109
+ success: false,
1110
+ error: `aiMaxToolIterations 非法: ${body.aiMaxToolIterations}`
1111
+ })
1112
+ }
1113
+ updates.aiMaxToolIterations = normalized
1114
+ }
1115
+ // 新字段优先;老客户端仍发旧字段也接(迁移期),但同一请求里两个都传时
1116
+ // **只认新的** —— 否则老字段会覆盖掉用户刚设的新值。
1117
+ const incomingTokens = body.aiMaxRequestTokens ?? body.aiMaxRequestChars
1118
+ if (incomingTokens !== undefined) {
1119
+ const normalized = configManager.normalizeAiRequestTokens(incomingTokens)
1120
+ if (normalized === null) {
1121
+ return res.status(400).json({
1122
+ success: false,
1123
+ error: `aiMaxRequestTokens 非法: ${incomingTokens}`
1124
+ })
1125
+ }
1126
+ updates.aiMaxRequestTokens = normalized
1127
+ }
1128
+ // 智能体视图「预设提示词」(欢迎页快捷卡片):{id?, label, prompt} 数组。
1129
+ // 空数组是**合法值**(= 恢复内置默认),所以判空用 !== undefined 而不是 length。
1130
+ // 结构坏的值整体拒绝(400),不清洗 —— 与 normalizeAgentPresetPrompts 语义一致;
1131
+ // 响应把归一化后的最终值带回(含补 id/截断),前端以回执为准。
1132
+ if (body.agentPresetPrompts !== undefined) {
1133
+ const normalized = configManager.normalizeAgentPresetPrompts(body.agentPresetPrompts)
1134
+ if (normalized === null) {
1135
+ return res.status(400).json({
1136
+ success: false,
1137
+ error: 'agentPresetPrompts 非法: 需要 [{label, prompt}] 数组(每项都不能为空)'
1138
+ })
1139
+ }
1140
+ updates.agentPresetPrompts = normalized
1141
+ }
1142
+ // 按板块的预设提示词:{ [viewId]: [{id?, label, prompt}] }。
1143
+ // 空对象合法(= 所有板块都不单独配置,全部继承「通用」那套);
1144
+ // 结构坏的值整体拒绝(400),与 normalizeAgentPresetPromptsByView 语义一致。
1145
+ if (body.agentPresetPromptsByView !== undefined) {
1146
+ const normalized = configManager.normalizeAgentPresetPromptsByView(body.agentPresetPromptsByView)
1147
+ if (normalized === null) {
1148
+ return res.status(400).json({
1149
+ success: false,
1150
+ error: 'agentPresetPromptsByView 非法: 需要 { [板块]: [{label, prompt}] } 对象(每项都不能为空)'
1151
+ })
1152
+ }
1153
+ updates.agentPresetPromptsByView = normalized
1154
+ }
1155
+ // g ai 智能体的默认提示词(全局配置):和 g ai 对话时每轮拼进系统提示词。
1156
+ // **空串是合法值**(= 清除),所以判空用 !== undefined;非字符串整体拒绝(400),
1157
+ // 与 normalizeAgentDefaultPrompt 的语义一致。
1158
+ if (body.agentDefaultPrompt !== undefined) {
1159
+ const normalized = configManager.normalizeAgentDefaultPrompt(body.agentDefaultPrompt)
1160
+ if (normalized === null) {
1161
+ return res.status(400).json({
1162
+ success: false,
1163
+ error: 'agentDefaultPrompt 非法: 需要字符串'
1164
+ })
1165
+ }
1166
+ updates.agentDefaultPrompt = normalized
1167
+ }
1168
+ // 联网搜索配置(全局):{ enabled, provider, apiKeys: {provider: key},
1169
+ // baseUrls: {provider: url}, proxy }。
1170
+ // 结构坏(非普通对象)整体拒绝(400),与 normalizeWebSearch 语义一致;
1171
+ // 单个字段坏由 normalizeWebSearch 内部各自回落,不整组拒绝。
1172
+ // 回执里 updates.webSearch 是归一化后的最终值,前端以回执为准。
1173
+ if (body.webSearch !== undefined) {
1174
+ const normalized = configManager.normalizeWebSearch(body.webSearch)
1175
+ if (normalized === null) {
1176
+ return res.status(400).json({
1177
+ success: false,
1178
+ error: 'webSearch 非法: 需要 { enabled, provider, apiKeys, baseUrls, proxy } 对象'
1179
+ })
1180
+ }
1181
+ updates.webSearch = normalized
1182
+ }
1183
+ if (Object.keys(updates).length === 0) {
1184
+ return res.status(400).json({ success: false, error: '缺少可保存的参数' })
1185
+ }
1186
+ const rawConfig = await configManager.readRawConfigFile()
1187
+ Object.assign(rawConfig, updates)
1188
+ await configManager.writeRawConfigFile(rawConfig)
1189
+ // 预算按**最终落盘值**解析(含本次夹取)回给前端 —— 前端拿它做超长输入预警,
1190
+ // 不在前端重算公式(口径只有 resolveRequestBudget 一处)
1191
+ const finalTokens = updates.aiMaxRequestTokens
1192
+ ?? configManager.normalizeAiRequestTokens(rawConfig.aiMaxRequestTokens)
1193
+ ?? configManager.normalizeAiRequestTokens(rawConfig.aiMaxRequestChars)
1194
+ res.json({ success: true, ...updates, aiRequestBudget: resolveRequestBudget(finalTokens) })
1195
+ } catch (error) {
1196
+ logger.error('[save-ai-settings] failed:', error)
1197
+ res.status(500).json({ success: false, error: error.message })
1198
+ }
1199
+ })
1200
+
1201
+ // 保存 AI 模型配置(models 是全局配置,存在配置文件顶层,跨项目共享)
1202
+ app.post('/api/config/save-models', express.json(), async (req, res) => {
1203
+ try {
1204
+ const { models } = req.body
1205
+ if (!Array.isArray(models)) {
1206
+ return res.status(400).json({ success: false, error: '缺少 models 参数' })
1207
+ }
1208
+ const rawConfig = await configManager.readRawConfigFile()
1209
+ rawConfig.models = models
1210
+ await configManager.writeRawConfigFile(rawConfig)
1211
+ res.json({ success: true })
1212
+ } catch (error) {
1213
+ res.status(500).json({ success: false, error: error.message })
1214
+ }
1215
+ })
1216
+
1217
+ // 测试 AI 模型是否可用
1218
+ app.post('/api/config/test-model', express.json(), async (req, res) => {
1219
+ const { baseURL, model, apiKey } = req.body || {}
1220
+ if (!baseURL || !model) {
1221
+ return res.status(400).json({ success: false, error: '缺少 baseURL 或 model 参数' })
1222
+ }
1223
+ try {
1224
+ const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }))
1225
+ // 连通性探测同样要带客户端身份/会话头,否则 OpenCode 网关这条请求就被风控挡了
1226
+ const { url, headers } = buildAiChatRequest({ baseURL, model, apiKey })
1227
+ const body = JSON.stringify({
1228
+ model,
1229
+ messages: [{ role: 'user', content: 'Hello, reply with just "ok".' }],
1230
+ max_tokens: 16,
1231
+ stream: false
1232
+ })
1233
+ const controller = new AbortController()
1234
+ const timer = setTimeout(() => controller.abort(), 15000)
1235
+ let response
1236
+ try {
1237
+ response = await fetch(url, { method: 'POST', headers, body, signal: controller.signal })
1238
+ } finally {
1239
+ clearTimeout(timer)
1240
+ }
1241
+ const data = await response.json().catch(() => ({}))
1242
+ if (!response.ok) {
1243
+ const msg = data?.error?.message || data?.message || `HTTP ${response.status}`
1244
+ return res.json({ success: false, error: msg })
1245
+ }
1246
+ const reply = data?.choices?.[0]?.message?.content || data?.choices?.[0]?.text || '✅'
1247
+ res.json({ success: true, reply: reply.trim().slice(0, 100) })
1248
+ } catch (error) {
1249
+ const msg = error.name === 'AbortError' ? '请求超时(15s)' : error.message
1250
+ res.json({ success: false, error: msg })
1251
+ }
1252
+ })
1253
+
1254
+ // AI 生成提交信息
1255
+ app.post('/api/config/generate-commit-message', express.json(), async (req, res) => {
1256
+ const { selectedPaths, locale: reqLocale } = req.body || {}
1257
+ try {
1258
+ const rawConfig = await configManager.readRawConfigFile()
1259
+ const userLocale = reqLocale || rawConfig.locale || 'zh-CN'
1260
+ const models = Array.isArray(rawConfig.models) ? rawConfig.models : []
1261
+ // 候选链 = 默认模型打头 + 其余已配置模型:默认模型额度用完 / 限流时自动换下一个,
1262
+ // 而不是让「AI 生成提交信息」直接报错(这几个功能是最常按的按钮)
1263
+ const chain = buildModelFallbackChain(models)
1264
+ if (chain.length === 0) {
1265
+ return res.json({ success: false, error: '未配置 AI 模型,请先在通用设置中添加模型', code: 'NO_MODEL' })
1266
+ }
1267
+
1268
+ // 后端自己收集 diff,确保 untracked 文件也能进 prompt
1269
+ // 选择模式下传入 selectedPaths,后端在 git 层面就过滤,避免污染 index
1270
+ const { diff: rawDiff, fileList: serverFileList } = await collectDiffForAi({
1271
+ execGitCommand,
1272
+ getCurrentProjectPath,
1273
+ selectedPaths
1274
+ })
1275
+ const diffText = prepareDiffForPrompt(rawDiff, serverFileList)
1276
+ const filesText = serverFileList.slice(0, 30).join('\n')
1277
+
1278
+ const promptZh = `你是一个 Git 提交信息生成助手。根据以下 git diff 信息,生成一条符合 Conventional Commits 规范的提交信息。
1279
+
1280
+ 要求:
1281
+ 1. type 只能是:feat/fix/docs/style/refactor/test/chore 之一
1282
+ 2. scope 可选,表示影响范围,简短英文或中文,如果改动范围明确就填
1283
+ 3. description 用中文简短描述本次变更(不超过50字)
1284
+ 4. 只返回 JSON,格式:{"type":"feat","scope":"","description":"xxx"}
1285
+
1286
+ 变更文件:
1287
+ ${filesText}
1288
+
1289
+ git diff --staged:
1290
+ ${diffText || '(无 staged 内容,请根据文件列表推断)'}`
1291
+
1292
+ 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.
1293
+
1294
+ Requirements:
1295
+ 1. type must be one of: feat/fix/docs/style/refactor/test/chore
1296
+ 2. scope is optional; use a short English word or short noun phrase to indicate the affected area. Leave empty if unclear.
1297
+ 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").
1298
+ 4. Return ONLY a JSON object in the format: {"type":"feat","scope":"","description":"xxx"}
1299
+
1300
+ Changed files:
1301
+ ${filesText}
1302
+
1303
+ git diff --staged:
1304
+ ${diffText || '(no staged content, please infer from the file list)'}`
1305
+
1306
+ const prompt = userLocale.startsWith('en') ? promptEn : promptZh
1307
+
1308
+ // AI prompt 默认不打印,只有 DEBUG=1 / DEBUG=logger 时才落地(防 Token 泄露)
1309
+ logger.debug('[generate-commit] locale:', userLocale, '| prompt length:', prompt.length)
1310
+ logger.debug('[generate-commit] prompt:\n' + prompt)
1311
+ // 单次尝试:换模型重试的每一步都走这里。失败一律 throw —— 由 runWithModelFallback
1312
+ // 决定换下一个模型还是收手(错误码靠 err.code 带出去,保持原有 NO_JSON / PARSE_FAILED 口径)
1313
+ const attemptCommit = async (candidate) => {
1314
+ const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }))
1315
+ const { url, headers } = buildAiChatRequest({
1316
+ baseURL: candidate.baseURL,
1317
+ model: candidate.model,
1318
+ apiKey: candidate.apiKey,
1319
+ })
1320
+ const body = JSON.stringify({
1321
+ model: candidate.model,
1322
+ messages: [{ role: 'user', content: prompt }],
1323
+ max_tokens: 1024,
1324
+ temperature: 0.3,
1325
+ stream: false
1326
+ })
1327
+ const controller = new AbortController()
1328
+ const timer = setTimeout(() => controller.abort(), 60000)
1329
+ let response
1330
+ try {
1331
+ response = await fetch(url, { method: 'POST', headers, body, signal: controller.signal })
1332
+ } finally {
1333
+ clearTimeout(timer)
1334
+ }
1335
+ const data = await response.json().catch(() => ({}))
1336
+ if (!response.ok) {
1337
+ const msg = data?.error?.message || data?.message || `HTTP ${response.status}`
1338
+ const err = new Error(msg)
1339
+ err.code = 'HTTP_ERR'
1340
+ throw err
1341
+ }
1342
+ const content = data?.choices?.[0]?.message?.content || ''
1343
+ // 响应内容只走 debug(默认关闭),前 600 字截断防 token 泄露
1344
+ logger.debug('[generate-commit] raw content length:', content.length, JSON.stringify(content).slice(0, 600))
1345
+
1346
+ const codeBlockMatch = content.match(/```(?:json)?\s*(\{[^`]*?\})\s*```/)
1347
+ const jsonMatch = codeBlockMatch
1348
+ ? [codeBlockMatch[1]]
1349
+ : [...content.matchAll(/\{[^{}]*\}/g)].at(-1)
1350
+
1351
+ if (!jsonMatch) {
1352
+ logger.error('[generate-commit] no JSON found, full content:', content)
1353
+ const err = new Error('model returned no valid JSON')
1354
+ err.code = 'NO_JSON'
1355
+ throw err
1356
+ }
1357
+ let parsed
1358
+ try {
1359
+ parsed = JSON.parse(jsonMatch[0])
1360
+ } catch {
1361
+ // JSON.parse 失败时用正则兜底提取字段(与 CLI 口径一致)
1362
+ const typeM = jsonMatch[0].match(/"type"\s*:\s*"([^"]+)"/)
1363
+ const scopeM = jsonMatch[0].match(/"scope"\s*:\s*"([^"]*)"/)
1364
+ const descM = jsonMatch[0].match(/"description"\s*:\s*"([^"]+)"/)
1365
+ if (!typeM && !descM) {
1366
+ const err = new Error('JSON parse failed')
1367
+ err.code = 'PARSE_FAILED'
1368
+ throw err
1369
+ }
1370
+ parsed = {
1371
+ type: (typeM?.[1] || 'feat').trim(),
1372
+ scope: (scopeM?.[1] || '').trim(),
1373
+ description: (descM?.[1] || '').trim()
1374
+ }
1375
+ }
1376
+ return {
1377
+ type: String(parsed.type || 'feat').trim(),
1378
+ scope: String(parsed.scope || '').trim(),
1379
+ description: String(parsed.description || '').trim()
1380
+ }
1381
+ }
1382
+
1383
+ // 默认模型失败(额度用完 / 限流 / 5xx / 返回不了 JSON)就换下一个已配置模型
1384
+ const { result: commit } = await runWithModelFallback(chain, attemptCommit, {
1385
+ onFallback: ({ from, to, error }) => logger.warn(
1386
+ `[generate-commit] 模型 ${modelLabel(from)} 调用失败(${error?.message || error}),改用 ${modelLabel(to)}`
1387
+ )
1388
+ })
1389
+ res.json({ success: true, ...commit })
1390
+ } catch (error) {
1391
+ const isTimeout = error.name === 'AbortError'
1392
+ const msg = isTimeout ? 'AI 请求超时(60s),请重试或检查模型响应速度' : error.message
1393
+ res.json({ success: false, error: msg, code: isTimeout ? 'TIMEOUT' : 'GENERATE_FAILED' })
1394
+ }
1395
+ })
1396
+ }