zen-gitsync 2.17.46 → 2.17.48

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 (70) hide show
  1. package/README.md +16 -2
  2. package/package.json +2 -1
  3. package/src/cli/ai/agent.js +1342 -1342
  4. package/src/cli/ai/context.js +431 -253
  5. package/src/cli/ai/context.test.js +376 -258
  6. package/src/cli/ai/mcp.js +323 -27
  7. package/src/cli/ai/mcp.test.js +202 -2
  8. package/src/cli/ai/runtime.test.js +7 -4
  9. package/src/cli/ai/turn.js +166 -166
  10. package/src/config.js +906 -871
  11. package/src/ui/public/assets/{AgentEngineSelector-D6rlSfMD.js → AgentEngineSelector-B829U5mr.js} +1 -1
  12. package/src/ui/public/assets/AgentView-r3_ClUmr.js +1 -0
  13. package/src/ui/public/assets/{AgentView-h2YnbB7J.css → AgentView-uHf-KMCH.css} +1 -1
  14. package/src/ui/public/assets/{AppVersionBadge-BnPsn1X5.js → AppVersionBadge-NtghusOI.js} +2 -2
  15. package/src/ui/public/assets/{BranchSelector-D30GJwUl.js → BranchSelector-D0claXAs.js} +1 -1
  16. package/src/ui/public/assets/{CommitForm-Dvg-RUZI.js → CommitForm-BIq6-sqk.js} +1 -1
  17. package/src/ui/public/assets/{CommonDialog-BzLau2RJ.js → CommonDialog-NqZURH2F.js} +1 -1
  18. package/src/ui/public/assets/EditorView-0T5LR5Hz.css +1 -0
  19. package/src/ui/public/assets/EditorView-Cny0lDhG.js +1 -0
  20. package/src/ui/public/assets/{FlowExecutionViewer-VL-rUElj.js → FlowExecutionViewer-DxDKzmaP.js} +1 -1
  21. package/src/ui/public/assets/{FlowOrchestrationWorkspace-BPNlHRVu.js → FlowOrchestrationWorkspace-B3CAJZ-U.js} +1 -1
  22. package/src/ui/public/assets/{LogList-COwGjQl6.js → LogList-D5sS4Il3.js} +1 -1
  23. package/src/ui/public/assets/{MindmapView-BVuqPD_H.js → MindmapView-6bc0EAJM.js} +1 -1
  24. package/src/ui/public/assets/{MonitorView-hqe-4xd0.js → MonitorView-BnUV4RaV.js} +1 -1
  25. package/src/ui/public/assets/{ProjectStartupButton-RBc-DJBR.js → ProjectStartupButton-Cbw079DB.js} +1 -1
  26. package/src/ui/public/assets/RecentDirectoriesChat-BCZsJR7n.js +1 -0
  27. package/src/ui/public/assets/RecentDirectoriesChat-THkqWEtO.css +1 -0
  28. package/src/ui/public/assets/{RemoteManagerDialog-D6Rbjchl.js → RemoteManagerDialog-BWWPEnSd.js} +1 -1
  29. package/src/ui/public/assets/{RemoteRepoCard-l0NpXgvB.js → RemoteRepoCard-BhA3-4PZ.js} +1 -1
  30. package/src/ui/public/assets/{SourceMapView-CeVystt0.js → SourceMapView-DHDmNzSh.js} +1 -1
  31. package/src/ui/public/assets/{SvgIcon-B-xDJQA1.js → SvgIcon-DcVasCdo.js} +1 -1
  32. package/src/ui/public/assets/{UserInputNode-DJglU68l.js → UserInputNode-B313MadX.js} +1 -1
  33. package/src/ui/public/assets/WorkbenchView-NCE_bk5a.js +20 -0
  34. package/src/ui/public/assets/WorkbenchView-rscs76HF.css +1 -0
  35. package/src/ui/public/assets/_plugin-vue_export-helper-Bsx6Zj3t.js +19 -0
  36. package/src/ui/public/assets/agentConversations-BAhGYmeY.js +8 -0
  37. package/src/ui/public/assets/{configStore-CMlW8sMa.js → configStore-DQSKV2_Y.js} +1 -1
  38. package/src/ui/public/assets/{dagre-DyI0XLXY.js → dagre-BmYeUlEa.js} +3 -3
  39. package/src/ui/public/assets/{element-plus-DrCk0pcV.js → element-plus-DTtm3XCd.js} +1 -1
  40. package/src/ui/public/assets/{flow-mindmap-Cx5RNYN9.js → flow-mindmap-JxOspgwx.js} +1 -1
  41. package/src/ui/public/assets/{index-u9LyUFkr.css → index-BgN6Z2X9.css} +1 -1
  42. package/src/ui/public/assets/{index-BQxIFopZ.js → index-DQuqmPuL.js} +9 -9
  43. package/src/ui/public/assets/{monaco-Dfcwm1aX.js → monaco-D3R9OM26.js} +1 -1
  44. package/src/ui/public/assets/{office-docx-C0unxEAm.js → office-docx-CO7mIaWO.js} +1 -1
  45. package/src/ui/public/assets/{office-excel-DPdyr_DJ.js → office-excel-gA4KEkK4.js} +1 -1
  46. package/src/ui/public/assets/{office-pptx-eojGAs9D.js → office-pptx-CQK4l_1Q.js} +1 -1
  47. package/src/ui/public/assets/{vendor-yntih_em.js → vendor-O17eRefg.js} +222 -222
  48. package/src/ui/public/assets/vendor-U3ZXMgeO.css +1 -0
  49. package/src/ui/public/assets/{vue-flow-De8mr_Fv.js → vue-flow-BG6ZF1It.js} +1 -1
  50. package/src/ui/public/index.html +13 -13
  51. package/src/ui/server/routes/config.js +1321 -1314
  52. package/src/ui/server/routes/workbench/agentChat.js +594 -571
  53. package/src/ui/server/routes/workbench/agentChatShared.test.js +302 -233
  54. package/src/ui/server/routes/workbench/agentMarketplace.js +65 -30
  55. package/src/ui/server/routes/workbench/agentMarketplace.test.js +28 -4
  56. package/src/ui/server/routes/workbench/agentRoutes.js +631 -631
  57. package/src/ui/server/routes/workbench/liveJobs.js +4 -0
  58. package/src/ui/server/routes/workbench/liveJobs.test.js +11 -0
  59. package/src/ui/server/routes/workbench/taskRunner.js +44 -7
  60. package/src/ui/server/routes/workbench/taskRunner.thinking.test.js +115 -0
  61. package/src/ui/public/assets/AgentView-BFRGoIVb.js +0 -1
  62. package/src/ui/public/assets/EditorView-8Rb4n-Wh.css +0 -1
  63. package/src/ui/public/assets/EditorView-D4ZQutRw.js +0 -1
  64. package/src/ui/public/assets/RecentDirectoriesChat-BxbpKrXc.css +0 -1
  65. package/src/ui/public/assets/RecentDirectoriesChat-DFljcYFH.js +0 -1
  66. package/src/ui/public/assets/WorkbenchView-BSQ87CDi.css +0 -1
  67. package/src/ui/public/assets/WorkbenchView-BpH5kNht.js +0 -20
  68. package/src/ui/public/assets/_plugin-vue_export-helper-Dz1ARW9a.js +0 -19
  69. package/src/ui/public/assets/agentConversations-B8nY45V4.js +0 -7
  70. package/src/ui/public/assets/vendor-CP-WuGZG.css +0 -1
@@ -1,1314 +1,1321 @@
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.aiMaxRequestChars)
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
- // · aiMaxRequestChars —— 单轮请求的上下文预算(字符;2026-10-07 事故后开放可调,
1095
- // 它决定"一条粘贴最多能装多少、工具结果会不会被挤掉",见 cli/ai/context.js)
1096
- // 两个键各自可选:没传的那个不动(旧客户端只传一个键时不许把它顺带清掉)。
1097
- app.post('/api/config/save-ai-settings', express.json(), async (req, res) => {
1098
- try {
1099
- const body = req.body || {}
1100
- const updates = {}
1101
- if (body.aiMaxToolIterations !== undefined) {
1102
- // 越界值在这里夹取到 [MIN, MAX],而不是拒绝 —— 与 loadConfig 的规范化保持同一套语义
1103
- const normalized = configManager.normalizeAiMaxToolIterations(body.aiMaxToolIterations)
1104
- if (normalized === null) {
1105
- return res.status(400).json({
1106
- success: false,
1107
- error: `aiMaxToolIterations 非法: ${body.aiMaxToolIterations}`
1108
- })
1109
- }
1110
- updates.aiMaxToolIterations = normalized
1111
- }
1112
- if (body.aiMaxRequestChars !== undefined) {
1113
- const normalized = configManager.normalizeAiRequestChars(body.aiMaxRequestChars)
1114
- if (normalized === null) {
1115
- return res.status(400).json({
1116
- success: false,
1117
- error: `aiMaxRequestChars 非法: ${body.aiMaxRequestChars}`
1118
- })
1119
- }
1120
- updates.aiMaxRequestChars = normalized
1121
- }
1122
- if (Object.keys(updates).length === 0) {
1123
- return res.status(400).json({ success: false, error: '缺少可保存的参数' })
1124
- }
1125
- const rawConfig = await configManager.readRawConfigFile()
1126
- Object.assign(rawConfig, updates)
1127
- await configManager.writeRawConfigFile(rawConfig)
1128
- // 预算按**最终落盘值**解析(含本次夹取)回给前端 —— 前端拿它做超长输入预警,
1129
- // 不在前端重算公式(口径只有 resolveRequestBudget 一处)
1130
- const finalChars = updates.aiMaxRequestChars ?? configManager.normalizeAiRequestChars(rawConfig.aiMaxRequestChars)
1131
- res.json({ success: true, ...updates, aiRequestBudget: resolveRequestBudget(finalChars) })
1132
- } catch (error) {
1133
- logger.error('[save-ai-settings] failed:', error)
1134
- res.status(500).json({ success: false, error: error.message })
1135
- }
1136
- })
1137
-
1138
- // 保存 AI 模型配置(models 是全局配置,存在配置文件顶层,跨项目共享)
1139
- app.post('/api/config/save-models', express.json(), async (req, res) => {
1140
- try {
1141
- const { models } = req.body
1142
- if (!Array.isArray(models)) {
1143
- return res.status(400).json({ success: false, error: '缺少 models 参数' })
1144
- }
1145
- const rawConfig = await configManager.readRawConfigFile()
1146
- rawConfig.models = models
1147
- await configManager.writeRawConfigFile(rawConfig)
1148
- res.json({ success: true })
1149
- } catch (error) {
1150
- res.status(500).json({ success: false, error: error.message })
1151
- }
1152
- })
1153
-
1154
- // 测试 AI 模型是否可用
1155
- app.post('/api/config/test-model', express.json(), async (req, res) => {
1156
- const { baseURL, model, apiKey } = req.body || {}
1157
- if (!baseURL || !model) {
1158
- return res.status(400).json({ success: false, error: '缺少 baseURL 或 model 参数' })
1159
- }
1160
- try {
1161
- const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }))
1162
- // 连通性探测同样要带客户端身份/会话头,否则 OpenCode 网关这条请求就被风控挡了
1163
- const { url, headers } = buildAiChatRequest({ baseURL, model, apiKey })
1164
- const body = JSON.stringify({
1165
- model,
1166
- messages: [{ role: 'user', content: 'Hello, reply with just "ok".' }],
1167
- max_tokens: 16,
1168
- stream: false
1169
- })
1170
- const controller = new AbortController()
1171
- const timer = setTimeout(() => controller.abort(), 15000)
1172
- let response
1173
- try {
1174
- response = await fetch(url, { method: 'POST', headers, body, signal: controller.signal })
1175
- } finally {
1176
- clearTimeout(timer)
1177
- }
1178
- const data = await response.json().catch(() => ({}))
1179
- if (!response.ok) {
1180
- const msg = data?.error?.message || data?.message || `HTTP ${response.status}`
1181
- return res.json({ success: false, error: msg })
1182
- }
1183
- const reply = data?.choices?.[0]?.message?.content || data?.choices?.[0]?.text || '✅'
1184
- res.json({ success: true, reply: reply.trim().slice(0, 100) })
1185
- } catch (error) {
1186
- const msg = error.name === 'AbortError' ? '请求超时(15s)' : error.message
1187
- res.json({ success: false, error: msg })
1188
- }
1189
- })
1190
-
1191
- // AI 生成提交信息
1192
- app.post('/api/config/generate-commit-message', express.json(), async (req, res) => {
1193
- const { selectedPaths, locale: reqLocale } = req.body || {}
1194
- try {
1195
- const rawConfig = await configManager.readRawConfigFile()
1196
- const userLocale = reqLocale || rawConfig.locale || 'zh-CN'
1197
- const models = Array.isArray(rawConfig.models) ? rawConfig.models : []
1198
- const defaultModel = models.find(m => m.isDefault) || models[0]
1199
- if (!defaultModel) {
1200
- return res.json({ success: false, error: '未配置 AI 模型,请先在通用设置中添加模型', code: 'NO_MODEL' })
1201
- }
1202
-
1203
- // 后端自己收集 diff,确保 untracked 文件也能进 prompt
1204
- // 选择模式下传入 selectedPaths,后端在 git 层面就过滤,避免污染 index
1205
- const { diff: rawDiff, fileList: serverFileList } = await collectDiffForAi({
1206
- execGitCommand,
1207
- getCurrentProjectPath,
1208
- selectedPaths
1209
- })
1210
- const diffText = prepareDiffForPrompt(rawDiff, serverFileList)
1211
- const filesText = serverFileList.slice(0, 30).join('\n')
1212
-
1213
- const promptZh = `你是一个 Git 提交信息生成助手。根据以下 git diff 信息,生成一条符合 Conventional Commits 规范的提交信息。
1214
-
1215
- 要求:
1216
- 1. type 只能是:feat/fix/docs/style/refactor/test/chore 之一
1217
- 2. scope 可选,表示影响范围,简短英文或中文,如果改动范围明确就填
1218
- 3. description 用中文简短描述本次变更(不超过50字)
1219
- 4. 只返回 JSON,格式:{"type":"feat","scope":"","description":"xxx"}
1220
-
1221
- 变更文件:
1222
- ${filesText}
1223
-
1224
- git diff --staged:
1225
- ${diffText || '(无 staged 内容,请根据文件列表推断)'}`
1226
-
1227
- 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.
1228
-
1229
- Requirements:
1230
- 1. type must be one of: feat/fix/docs/style/refactor/test/chore
1231
- 2. scope is optional; use a short English word or short noun phrase to indicate the affected area. Leave empty if unclear.
1232
- 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").
1233
- 4. Return ONLY a JSON object in the format: {"type":"feat","scope":"","description":"xxx"}
1234
-
1235
- Changed files:
1236
- ${filesText}
1237
-
1238
- git diff --staged:
1239
- ${diffText || '(no staged content, please infer from the file list)'}`
1240
-
1241
- const prompt = userLocale.startsWith('en') ? promptEn : promptZh
1242
-
1243
- // AI prompt 默认不打印,只有 DEBUG=1 / DEBUG=logger 时才落地(防 Token 泄露)
1244
- logger.debug('[generate-commit] locale:', userLocale, '| prompt length:', prompt.length)
1245
- logger.debug('[generate-commit] prompt:\n' + prompt)
1246
- const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }))
1247
- const { url, headers } = buildAiChatRequest({
1248
- baseURL: defaultModel.baseURL,
1249
- model: defaultModel.model,
1250
- apiKey: defaultModel.apiKey,
1251
- })
1252
- const body = JSON.stringify({
1253
- model: defaultModel.model,
1254
- messages: [{ role: 'user', content: prompt }],
1255
- max_tokens: 1024,
1256
- temperature: 0.3,
1257
- stream: false
1258
- })
1259
- const controller = new AbortController()
1260
- const timer = setTimeout(() => controller.abort(), 60000)
1261
- let response
1262
- try {
1263
- response = await fetch(url, { method: 'POST', headers, body, signal: controller.signal })
1264
- } finally {
1265
- clearTimeout(timer)
1266
- }
1267
- const data = await response.json().catch(() => ({}))
1268
- if (!response.ok) {
1269
- const msg = data?.error?.message || data?.message || `HTTP ${response.status}`
1270
- return res.json({ success: false, error: msg, code: 'HTTP_ERR' })
1271
- }
1272
- const content = data?.choices?.[0]?.message?.content || ''
1273
- // 响应内容只走 debug(默认关闭),前 600 字截断防 token 泄露
1274
- logger.debug('[generate-commit] raw content length:', content.length, JSON.stringify(content).slice(0, 600))
1275
-
1276
- const codeBlockMatch = content.match(/```(?:json)?\s*(\{[^`]*?\})\s*```/)
1277
- const jsonMatch = codeBlockMatch
1278
- ? [codeBlockMatch[1]]
1279
- : [...content.matchAll(/\{[^{}]*\}/g)].at(-1)
1280
-
1281
- if (!jsonMatch) {
1282
- logger.error('[generate-commit] no JSON found, full content:', content)
1283
- return res.json({ success: false, error: 'model returned no valid JSON', code: 'NO_JSON' })
1284
- }
1285
- let parsed
1286
- try {
1287
- parsed = JSON.parse(jsonMatch[0])
1288
- } catch {
1289
- const typeM = jsonMatch[0].match(/"type"\s*:\s*"([^"]+)"/)
1290
- const scopeM = jsonMatch[0].match(/"scope"\s*:\s*"([^"]*)"/)
1291
- const descM = jsonMatch[0].match(/"description"\s*:\s*"([^"]+)"/)
1292
- if (typeM || descM) {
1293
- return res.json({
1294
- success: true,
1295
- type: (typeM?.[1] || 'feat').trim(),
1296
- scope: (scopeM?.[1] || '').trim(),
1297
- description: (descM?.[1] || '').trim()
1298
- })
1299
- }
1300
- return res.json({ success: false, error: 'JSON parse failed', code: 'PARSE_FAILED' })
1301
- }
1302
- res.json({
1303
- success: true,
1304
- type: String(parsed.type || 'feat').trim(),
1305
- scope: String(parsed.scope || '').trim(),
1306
- description: String(parsed.description || '').trim()
1307
- })
1308
- } catch (error) {
1309
- const isTimeout = error.name === 'AbortError'
1310
- const msg = isTimeout ? 'AI 请求超时(60s),请重试或检查模型响应速度' : error.message
1311
- res.json({ success: false, error: msg, code: isTimeout ? 'TIMEOUT' : 'GENERATE_FAILED' })
1312
- }
1313
- })
1314
- }
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
+ if (Object.keys(updates).length === 0) {
1128
+ return res.status(400).json({ success: false, error: '缺少可保存的参数' })
1129
+ }
1130
+ const rawConfig = await configManager.readRawConfigFile()
1131
+ Object.assign(rawConfig, updates)
1132
+ await configManager.writeRawConfigFile(rawConfig)
1133
+ // 预算按**最终落盘值**解析(含本次夹取)回给前端 —— 前端拿它做超长输入预警,
1134
+ // 不在前端重算公式(口径只有 resolveRequestBudget 一处)
1135
+ const finalTokens = updates.aiMaxRequestTokens
1136
+ ?? configManager.normalizeAiRequestTokens(rawConfig.aiMaxRequestTokens)
1137
+ ?? configManager.normalizeAiRequestTokens(rawConfig.aiMaxRequestChars)
1138
+ res.json({ success: true, ...updates, aiRequestBudget: resolveRequestBudget(finalTokens) })
1139
+ } catch (error) {
1140
+ logger.error('[save-ai-settings] failed:', error)
1141
+ res.status(500).json({ success: false, error: error.message })
1142
+ }
1143
+ })
1144
+
1145
+ // 保存 AI 模型配置(models 是全局配置,存在配置文件顶层,跨项目共享)
1146
+ app.post('/api/config/save-models', express.json(), async (req, res) => {
1147
+ try {
1148
+ const { models } = req.body
1149
+ if (!Array.isArray(models)) {
1150
+ return res.status(400).json({ success: false, error: '缺少 models 参数' })
1151
+ }
1152
+ const rawConfig = await configManager.readRawConfigFile()
1153
+ rawConfig.models = models
1154
+ await configManager.writeRawConfigFile(rawConfig)
1155
+ res.json({ success: true })
1156
+ } catch (error) {
1157
+ res.status(500).json({ success: false, error: error.message })
1158
+ }
1159
+ })
1160
+
1161
+ // 测试 AI 模型是否可用
1162
+ app.post('/api/config/test-model', express.json(), async (req, res) => {
1163
+ const { baseURL, model, apiKey } = req.body || {}
1164
+ if (!baseURL || !model) {
1165
+ return res.status(400).json({ success: false, error: '缺少 baseURL 或 model 参数' })
1166
+ }
1167
+ try {
1168
+ const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }))
1169
+ // 连通性探测同样要带客户端身份/会话头,否则 OpenCode 网关这条请求就被风控挡了
1170
+ const { url, headers } = buildAiChatRequest({ baseURL, model, apiKey })
1171
+ const body = JSON.stringify({
1172
+ model,
1173
+ messages: [{ role: 'user', content: 'Hello, reply with just "ok".' }],
1174
+ max_tokens: 16,
1175
+ stream: false
1176
+ })
1177
+ const controller = new AbortController()
1178
+ const timer = setTimeout(() => controller.abort(), 15000)
1179
+ let response
1180
+ try {
1181
+ response = await fetch(url, { method: 'POST', headers, body, signal: controller.signal })
1182
+ } finally {
1183
+ clearTimeout(timer)
1184
+ }
1185
+ const data = await response.json().catch(() => ({}))
1186
+ if (!response.ok) {
1187
+ const msg = data?.error?.message || data?.message || `HTTP ${response.status}`
1188
+ return res.json({ success: false, error: msg })
1189
+ }
1190
+ const reply = data?.choices?.[0]?.message?.content || data?.choices?.[0]?.text || '✅'
1191
+ res.json({ success: true, reply: reply.trim().slice(0, 100) })
1192
+ } catch (error) {
1193
+ const msg = error.name === 'AbortError' ? '请求超时(15s)' : error.message
1194
+ res.json({ success: false, error: msg })
1195
+ }
1196
+ })
1197
+
1198
+ // AI 生成提交信息
1199
+ app.post('/api/config/generate-commit-message', express.json(), async (req, res) => {
1200
+ const { selectedPaths, locale: reqLocale } = req.body || {}
1201
+ try {
1202
+ const rawConfig = await configManager.readRawConfigFile()
1203
+ const userLocale = reqLocale || rawConfig.locale || 'zh-CN'
1204
+ const models = Array.isArray(rawConfig.models) ? rawConfig.models : []
1205
+ const defaultModel = models.find(m => m.isDefault) || models[0]
1206
+ if (!defaultModel) {
1207
+ return res.json({ success: false, error: '未配置 AI 模型,请先在通用设置中添加模型', code: 'NO_MODEL' })
1208
+ }
1209
+
1210
+ // 后端自己收集 diff,确保 untracked 文件也能进 prompt
1211
+ // 选择模式下传入 selectedPaths,后端在 git 层面就过滤,避免污染 index
1212
+ const { diff: rawDiff, fileList: serverFileList } = await collectDiffForAi({
1213
+ execGitCommand,
1214
+ getCurrentProjectPath,
1215
+ selectedPaths
1216
+ })
1217
+ const diffText = prepareDiffForPrompt(rawDiff, serverFileList)
1218
+ const filesText = serverFileList.slice(0, 30).join('\n')
1219
+
1220
+ const promptZh = `你是一个 Git 提交信息生成助手。根据以下 git diff 信息,生成一条符合 Conventional Commits 规范的提交信息。
1221
+
1222
+ 要求:
1223
+ 1. type 只能是:feat/fix/docs/style/refactor/test/chore 之一
1224
+ 2. scope 可选,表示影响范围,简短英文或中文,如果改动范围明确就填
1225
+ 3. description 用中文简短描述本次变更(不超过50字)
1226
+ 4. 只返回 JSON,格式:{"type":"feat","scope":"","description":"xxx"}
1227
+
1228
+ 变更文件:
1229
+ ${filesText}
1230
+
1231
+ git diff --staged:
1232
+ ${diffText || '(无 staged 内容,请根据文件列表推断)'}`
1233
+
1234
+ 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.
1235
+
1236
+ Requirements:
1237
+ 1. type must be one of: feat/fix/docs/style/refactor/test/chore
1238
+ 2. scope is optional; use a short English word or short noun phrase to indicate the affected area. Leave empty if unclear.
1239
+ 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").
1240
+ 4. Return ONLY a JSON object in the format: {"type":"feat","scope":"","description":"xxx"}
1241
+
1242
+ Changed files:
1243
+ ${filesText}
1244
+
1245
+ git diff --staged:
1246
+ ${diffText || '(no staged content, please infer from the file list)'}`
1247
+
1248
+ const prompt = userLocale.startsWith('en') ? promptEn : promptZh
1249
+
1250
+ // AI prompt 默认不打印,只有 DEBUG=1 / DEBUG=logger 时才落地(防 Token 泄露)
1251
+ logger.debug('[generate-commit] locale:', userLocale, '| prompt length:', prompt.length)
1252
+ logger.debug('[generate-commit] prompt:\n' + prompt)
1253
+ const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }))
1254
+ const { url, headers } = buildAiChatRequest({
1255
+ baseURL: defaultModel.baseURL,
1256
+ model: defaultModel.model,
1257
+ apiKey: defaultModel.apiKey,
1258
+ })
1259
+ const body = JSON.stringify({
1260
+ model: defaultModel.model,
1261
+ messages: [{ role: 'user', content: prompt }],
1262
+ max_tokens: 1024,
1263
+ temperature: 0.3,
1264
+ stream: false
1265
+ })
1266
+ const controller = new AbortController()
1267
+ const timer = setTimeout(() => controller.abort(), 60000)
1268
+ let response
1269
+ try {
1270
+ response = await fetch(url, { method: 'POST', headers, body, signal: controller.signal })
1271
+ } finally {
1272
+ clearTimeout(timer)
1273
+ }
1274
+ const data = await response.json().catch(() => ({}))
1275
+ if (!response.ok) {
1276
+ const msg = data?.error?.message || data?.message || `HTTP ${response.status}`
1277
+ return res.json({ success: false, error: msg, code: 'HTTP_ERR' })
1278
+ }
1279
+ const content = data?.choices?.[0]?.message?.content || ''
1280
+ // 响应内容只走 debug(默认关闭),前 600 字截断防 token 泄露
1281
+ logger.debug('[generate-commit] raw content length:', content.length, JSON.stringify(content).slice(0, 600))
1282
+
1283
+ const codeBlockMatch = content.match(/```(?:json)?\s*(\{[^`]*?\})\s*```/)
1284
+ const jsonMatch = codeBlockMatch
1285
+ ? [codeBlockMatch[1]]
1286
+ : [...content.matchAll(/\{[^{}]*\}/g)].at(-1)
1287
+
1288
+ if (!jsonMatch) {
1289
+ logger.error('[generate-commit] no JSON found, full content:', content)
1290
+ return res.json({ success: false, error: 'model returned no valid JSON', code: 'NO_JSON' })
1291
+ }
1292
+ let parsed
1293
+ try {
1294
+ parsed = JSON.parse(jsonMatch[0])
1295
+ } catch {
1296
+ const typeM = jsonMatch[0].match(/"type"\s*:\s*"([^"]+)"/)
1297
+ const scopeM = jsonMatch[0].match(/"scope"\s*:\s*"([^"]*)"/)
1298
+ const descM = jsonMatch[0].match(/"description"\s*:\s*"([^"]+)"/)
1299
+ if (typeM || descM) {
1300
+ return res.json({
1301
+ success: true,
1302
+ type: (typeM?.[1] || 'feat').trim(),
1303
+ scope: (scopeM?.[1] || '').trim(),
1304
+ description: (descM?.[1] || '').trim()
1305
+ })
1306
+ }
1307
+ return res.json({ success: false, error: 'JSON parse failed', code: 'PARSE_FAILED' })
1308
+ }
1309
+ res.json({
1310
+ success: true,
1311
+ type: String(parsed.type || 'feat').trim(),
1312
+ scope: String(parsed.scope || '').trim(),
1313
+ description: String(parsed.description || '').trim()
1314
+ })
1315
+ } catch (error) {
1316
+ const isTimeout = error.name === 'AbortError'
1317
+ const msg = isTimeout ? 'AI 请求超时(60s),请重试或检查模型响应速度' : error.message
1318
+ res.json({ success: false, error: msg, code: isTimeout ? 'TIMEOUT' : 'GENERATE_FAILED' })
1319
+ }
1320
+ })
1321
+ }