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.
- package/README.md +16 -2
- package/package.json +2 -1
- package/src/cli/ai/agent.js +1342 -1342
- package/src/cli/ai/context.js +431 -253
- package/src/cli/ai/context.test.js +376 -258
- package/src/cli/ai/mcp.js +323 -27
- package/src/cli/ai/mcp.test.js +202 -2
- package/src/cli/ai/runtime.test.js +7 -4
- package/src/cli/ai/turn.js +166 -166
- package/src/config.js +906 -871
- package/src/ui/public/assets/{AgentEngineSelector-D6rlSfMD.js → AgentEngineSelector-B829U5mr.js} +1 -1
- package/src/ui/public/assets/AgentView-r3_ClUmr.js +1 -0
- package/src/ui/public/assets/{AgentView-h2YnbB7J.css → AgentView-uHf-KMCH.css} +1 -1
- package/src/ui/public/assets/{AppVersionBadge-BnPsn1X5.js → AppVersionBadge-NtghusOI.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-D30GJwUl.js → BranchSelector-D0claXAs.js} +1 -1
- package/src/ui/public/assets/{CommitForm-Dvg-RUZI.js → CommitForm-BIq6-sqk.js} +1 -1
- package/src/ui/public/assets/{CommonDialog-BzLau2RJ.js → CommonDialog-NqZURH2F.js} +1 -1
- package/src/ui/public/assets/EditorView-0T5LR5Hz.css +1 -0
- package/src/ui/public/assets/EditorView-Cny0lDhG.js +1 -0
- package/src/ui/public/assets/{FlowExecutionViewer-VL-rUElj.js → FlowExecutionViewer-DxDKzmaP.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-BPNlHRVu.js → FlowOrchestrationWorkspace-B3CAJZ-U.js} +1 -1
- package/src/ui/public/assets/{LogList-COwGjQl6.js → LogList-D5sS4Il3.js} +1 -1
- package/src/ui/public/assets/{MindmapView-BVuqPD_H.js → MindmapView-6bc0EAJM.js} +1 -1
- package/src/ui/public/assets/{MonitorView-hqe-4xd0.js → MonitorView-BnUV4RaV.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-RBc-DJBR.js → ProjectStartupButton-Cbw079DB.js} +1 -1
- package/src/ui/public/assets/RecentDirectoriesChat-BCZsJR7n.js +1 -0
- package/src/ui/public/assets/RecentDirectoriesChat-THkqWEtO.css +1 -0
- package/src/ui/public/assets/{RemoteManagerDialog-D6Rbjchl.js → RemoteManagerDialog-BWWPEnSd.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-l0NpXgvB.js → RemoteRepoCard-BhA3-4PZ.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-CeVystt0.js → SourceMapView-DHDmNzSh.js} +1 -1
- package/src/ui/public/assets/{SvgIcon-B-xDJQA1.js → SvgIcon-DcVasCdo.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-DJglU68l.js → UserInputNode-B313MadX.js} +1 -1
- package/src/ui/public/assets/WorkbenchView-NCE_bk5a.js +20 -0
- package/src/ui/public/assets/WorkbenchView-rscs76HF.css +1 -0
- package/src/ui/public/assets/_plugin-vue_export-helper-Bsx6Zj3t.js +19 -0
- package/src/ui/public/assets/agentConversations-BAhGYmeY.js +8 -0
- package/src/ui/public/assets/{configStore-CMlW8sMa.js → configStore-DQSKV2_Y.js} +1 -1
- package/src/ui/public/assets/{dagre-DyI0XLXY.js → dagre-BmYeUlEa.js} +3 -3
- package/src/ui/public/assets/{element-plus-DrCk0pcV.js → element-plus-DTtm3XCd.js} +1 -1
- package/src/ui/public/assets/{flow-mindmap-Cx5RNYN9.js → flow-mindmap-JxOspgwx.js} +1 -1
- package/src/ui/public/assets/{index-u9LyUFkr.css → index-BgN6Z2X9.css} +1 -1
- package/src/ui/public/assets/{index-BQxIFopZ.js → index-DQuqmPuL.js} +9 -9
- package/src/ui/public/assets/{monaco-Dfcwm1aX.js → monaco-D3R9OM26.js} +1 -1
- package/src/ui/public/assets/{office-docx-C0unxEAm.js → office-docx-CO7mIaWO.js} +1 -1
- package/src/ui/public/assets/{office-excel-DPdyr_DJ.js → office-excel-gA4KEkK4.js} +1 -1
- package/src/ui/public/assets/{office-pptx-eojGAs9D.js → office-pptx-CQK4l_1Q.js} +1 -1
- package/src/ui/public/assets/{vendor-yntih_em.js → vendor-O17eRefg.js} +222 -222
- package/src/ui/public/assets/vendor-U3ZXMgeO.css +1 -0
- package/src/ui/public/assets/{vue-flow-De8mr_Fv.js → vue-flow-BG6ZF1It.js} +1 -1
- package/src/ui/public/index.html +13 -13
- package/src/ui/server/routes/config.js +1321 -1314
- package/src/ui/server/routes/workbench/agentChat.js +594 -571
- package/src/ui/server/routes/workbench/agentChatShared.test.js +302 -233
- package/src/ui/server/routes/workbench/agentMarketplace.js +65 -30
- package/src/ui/server/routes/workbench/agentMarketplace.test.js +28 -4
- package/src/ui/server/routes/workbench/agentRoutes.js +631 -631
- package/src/ui/server/routes/workbench/liveJobs.js +4 -0
- package/src/ui/server/routes/workbench/liveJobs.test.js +11 -0
- package/src/ui/server/routes/workbench/taskRunner.js +44 -7
- package/src/ui/server/routes/workbench/taskRunner.thinking.test.js +115 -0
- package/src/ui/public/assets/AgentView-BFRGoIVb.js +0 -1
- package/src/ui/public/assets/EditorView-8Rb4n-Wh.css +0 -1
- package/src/ui/public/assets/EditorView-D4ZQutRw.js +0 -1
- package/src/ui/public/assets/RecentDirectoriesChat-BxbpKrXc.css +0 -1
- package/src/ui/public/assets/RecentDirectoriesChat-DFljcYFH.js +0 -1
- package/src/ui/public/assets/WorkbenchView-BSQ87CDi.css +0 -1
- package/src/ui/public/assets/WorkbenchView-BpH5kNht.js +0 -20
- package/src/ui/public/assets/_plugin-vue_export-helper-Dz1ARW9a.js +0 -19
- package/src/ui/public/assets/agentConversations-B8nY45V4.js +0 -7
- package/src/ui/public/assets/vendor-CP-WuGZG.css +0 -1
package/src/config.js
CHANGED
|
@@ -1,871 +1,906 @@
|
|
|
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 { promises as fs } from 'fs';
|
|
16
|
-
import path from 'path';
|
|
17
|
-
import chalk from 'chalk';
|
|
18
|
-
import { execSync } from 'child_process';
|
|
19
|
-
import { CONFIG_FILE } from './paths.js';
|
|
20
|
-
import { migrateDataDir } from './dataDirMigration.js';
|
|
21
|
-
import { atomicWriteText } from './fsAtomic.js';
|
|
22
|
-
// 请求预算的口径(默认值 / 规范化 / 解析公式)住在 cli/ai/context.js —— 那里是
|
|
23
|
-
// "每轮请求怎么裁剪"的唯一实现,config.js 只负责把它接到配置项上,不复制公式。
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
ensureSplitStore,
|
|
27
|
-
readSplitProjects,
|
|
28
|
-
writeSplitStore,
|
|
29
|
-
deleteProjectConfig,
|
|
30
|
-
isSplitActive,
|
|
31
|
-
splitMarkerExists,
|
|
32
|
-
} from './configSplit.js';
|
|
33
|
-
|
|
34
|
-
// 当前生效的配置文件路径。默认是统一数据目录下的 ~/.zen-gitsync/config.json;
|
|
35
|
-
// 只有历史文件搬迁失败(被占用/无权限)时才回退到旧的 ~/.git-commit-tool.json,
|
|
36
|
-
// 让应用至少还能以只读方式跑起来,下次启动再试迁移。
|
|
37
|
-
let configPath = CONFIG_FILE;
|
|
38
|
-
let _configPathReady = null;
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* 确保配置文件已就位(必要时执行一次性数据目录迁移),返回实际使用的路径。
|
|
42
|
-
* 惰性执行且只跑一次;迁移内部不抛错,这里的 catch 只是最后一道保险。
|
|
43
|
-
*/
|
|
44
|
-
function resolveConfigPath() {
|
|
45
|
-
if (!_configPathReady) {
|
|
46
|
-
_configPathReady = migrateDataDir()
|
|
47
|
-
.then((report) => {
|
|
48
|
-
if (report?.configPath) configPath = report.configPath;
|
|
49
|
-
return configPath;
|
|
50
|
-
})
|
|
51
|
-
.catch(() => configPath);
|
|
52
|
-
}
|
|
53
|
-
return _configPathReady;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// AI 智能体单轮工具调用上限的可调范围。
|
|
57
|
-
// 下限 1 是为了保留"防失控"语义 —— 0 会让智能体一动手就停,没人是这个意图;
|
|
58
|
-
// 上限 10000 只防"手改配置写成天文数字"这种明显笔误,不再当成额度保护 ——
|
|
59
|
-
// 真要烧光额度,单轮本来就该由用户自己在设置里选的大小负责(2026-10-03: 2000 → 10000)。
|
|
60
|
-
const AI_MAX_TOOL_ITERATIONS_MIN = 1;
|
|
61
|
-
const AI_MAX_TOOL_ITERATIONS_MAX = 10000;
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* 规范化单轮最大工具调用次数。
|
|
65
|
-
*
|
|
66
|
-
* 非法值(非数字 / NaN / 越界)一律**夹取**到合法区间而不是回退默认值 ——
|
|
67
|
-
* 用户手改成 50000 的意图明显是"想更大",夹到上限比悄悄退回默认小值更贴近意图。
|
|
68
|
-
* 完全无法解析(undefined / 'abc')才返回 null,交给调用方取默认值。
|
|
69
|
-
*/
|
|
70
|
-
function normalizeAiMaxToolIterations(value) {
|
|
71
|
-
if (value === undefined || value === null || value === '') return null;
|
|
72
|
-
const n = Number(value);
|
|
73
|
-
if (!Number.isFinite(n)) return null;
|
|
74
|
-
const int = Math.floor(n);
|
|
75
|
-
if (int < AI_MAX_TOOL_ITERATIONS_MIN) return AI_MAX_TOOL_ITERATIONS_MIN;
|
|
76
|
-
if (int > AI_MAX_TOOL_ITERATIONS_MAX) return AI_MAX_TOOL_ITERATIONS_MAX;
|
|
77
|
-
return int;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// 工作台任务执行器白名单。'claude' 是历史默认;'opencode' / 'codex' 为可选执行器
|
|
81
|
-
// (opencode run --format json / codex exec --json,模型都跟随各自 CLI 的配置)。
|
|
82
|
-
export const TASK_EXECUTORS = ['claude', 'opencode', 'codex'];
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* 规范化任务执行器。非法值返回 null(交给调用方取默认),不抛错 ——
|
|
86
|
-
* 与 normalizeAiMaxToolIterations 同一套"夹取/兜底"语义。
|
|
87
|
-
*/
|
|
88
|
-
export function normalizeTaskExecutor(value) {
|
|
89
|
-
if (typeof value !== 'string') return null;
|
|
90
|
-
const v = value.trim().toLowerCase();
|
|
91
|
-
return TASK_EXECUTORS.includes(v) ? v : null;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
//
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
//
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
//
|
|
386
|
-
// 两次
|
|
387
|
-
//
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
return
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
)
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
:
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
//
|
|
569
|
-
//
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
//
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
//
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
const
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
config
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
const
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
const
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
const
|
|
748
|
-
if (
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
list
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
return
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
//
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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 { promises as fs } from 'fs';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import chalk from 'chalk';
|
|
18
|
+
import { execSync } from 'child_process';
|
|
19
|
+
import { CONFIG_FILE } from './paths.js';
|
|
20
|
+
import { migrateDataDir } from './dataDirMigration.js';
|
|
21
|
+
import { atomicWriteText } from './fsAtomic.js';
|
|
22
|
+
// 请求预算的口径(默认值 / 规范化 / 解析公式)住在 cli/ai/context.js —— 那里是
|
|
23
|
+
// "每轮请求怎么裁剪"的唯一实现,config.js 只负责把它接到配置项上,不复制公式。
|
|
24
|
+
import { normalizeAiRequestTokens, migrateLegacyCharsToTokens, REQUEST_DEFAULT_MAX_TOKENS } from './cli/ai/context.js';
|
|
25
|
+
import {
|
|
26
|
+
ensureSplitStore,
|
|
27
|
+
readSplitProjects,
|
|
28
|
+
writeSplitStore,
|
|
29
|
+
deleteProjectConfig,
|
|
30
|
+
isSplitActive,
|
|
31
|
+
splitMarkerExists,
|
|
32
|
+
} from './configSplit.js';
|
|
33
|
+
|
|
34
|
+
// 当前生效的配置文件路径。默认是统一数据目录下的 ~/.zen-gitsync/config.json;
|
|
35
|
+
// 只有历史文件搬迁失败(被占用/无权限)时才回退到旧的 ~/.git-commit-tool.json,
|
|
36
|
+
// 让应用至少还能以只读方式跑起来,下次启动再试迁移。
|
|
37
|
+
let configPath = CONFIG_FILE;
|
|
38
|
+
let _configPathReady = null;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 确保配置文件已就位(必要时执行一次性数据目录迁移),返回实际使用的路径。
|
|
42
|
+
* 惰性执行且只跑一次;迁移内部不抛错,这里的 catch 只是最后一道保险。
|
|
43
|
+
*/
|
|
44
|
+
function resolveConfigPath() {
|
|
45
|
+
if (!_configPathReady) {
|
|
46
|
+
_configPathReady = migrateDataDir()
|
|
47
|
+
.then((report) => {
|
|
48
|
+
if (report?.configPath) configPath = report.configPath;
|
|
49
|
+
return configPath;
|
|
50
|
+
})
|
|
51
|
+
.catch(() => configPath);
|
|
52
|
+
}
|
|
53
|
+
return _configPathReady;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// AI 智能体单轮工具调用上限的可调范围。
|
|
57
|
+
// 下限 1 是为了保留"防失控"语义 —— 0 会让智能体一动手就停,没人是这个意图;
|
|
58
|
+
// 上限 10000 只防"手改配置写成天文数字"这种明显笔误,不再当成额度保护 ——
|
|
59
|
+
// 真要烧光额度,单轮本来就该由用户自己在设置里选的大小负责(2026-10-03: 2000 → 10000)。
|
|
60
|
+
const AI_MAX_TOOL_ITERATIONS_MIN = 1;
|
|
61
|
+
const AI_MAX_TOOL_ITERATIONS_MAX = 10000;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 规范化单轮最大工具调用次数。
|
|
65
|
+
*
|
|
66
|
+
* 非法值(非数字 / NaN / 越界)一律**夹取**到合法区间而不是回退默认值 ——
|
|
67
|
+
* 用户手改成 50000 的意图明显是"想更大",夹到上限比悄悄退回默认小值更贴近意图。
|
|
68
|
+
* 完全无法解析(undefined / 'abc')才返回 null,交给调用方取默认值。
|
|
69
|
+
*/
|
|
70
|
+
function normalizeAiMaxToolIterations(value) {
|
|
71
|
+
if (value === undefined || value === null || value === '') return null;
|
|
72
|
+
const n = Number(value);
|
|
73
|
+
if (!Number.isFinite(n)) return null;
|
|
74
|
+
const int = Math.floor(n);
|
|
75
|
+
if (int < AI_MAX_TOOL_ITERATIONS_MIN) return AI_MAX_TOOL_ITERATIONS_MIN;
|
|
76
|
+
if (int > AI_MAX_TOOL_ITERATIONS_MAX) return AI_MAX_TOOL_ITERATIONS_MAX;
|
|
77
|
+
return int;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 工作台任务执行器白名单。'claude' 是历史默认;'opencode' / 'codex' 为可选执行器
|
|
81
|
+
// (opencode run --format json / codex exec --json,模型都跟随各自 CLI 的配置)。
|
|
82
|
+
export const TASK_EXECUTORS = ['claude', 'opencode', 'codex'];
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 规范化任务执行器。非法值返回 null(交给调用方取默认),不抛错 ——
|
|
86
|
+
* 与 normalizeAiMaxToolIterations 同一套"夹取/兜底"语义。
|
|
87
|
+
*/
|
|
88
|
+
export function normalizeTaskExecutor(value) {
|
|
89
|
+
if (typeof value !== 'string') return null;
|
|
90
|
+
const v = value.trim().toLowerCase();
|
|
91
|
+
return TASK_EXECUTORS.includes(v) ? v : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 解析请求上下文预算的 token 值(2026-10-07 字符口径 → token 口径)。
|
|
96
|
+
*
|
|
97
|
+
* 新字段 `aiMaxRequestTokens` 优先;读不到才从**旧字段** `aiMaxRequestChars`
|
|
98
|
+
* 迁移。为什么必须迁移而不是直接回落默认:用户磁盘上的 config.json 已经存着
|
|
99
|
+
* 旧值(本机是 80,000 字符),改字段名后它读不到 → 静默变成 1,000,000 token,
|
|
100
|
+
* 等于把用户的设置清了一轮。迁移是有损的(估算换算),但只发生一次,
|
|
101
|
+
* 且落在用户原意附近。
|
|
102
|
+
*
|
|
103
|
+
* ⚠️ 旧字段**故意不清除**:清了就等于给不出迁移(用户下次读配置时新字段还没写,
|
|
104
|
+
* 旧值已经没了)。代价是"删掉 aiMaxRequestTokens 想回默认"的人会拿到迁移值
|
|
105
|
+
* 而不是默认值 —— 这是**一次性**的错位(保存一次全局设置就会把新字段写上),
|
|
106
|
+
* 比静默清空用户设置好得多。
|
|
107
|
+
*/
|
|
108
|
+
function resolveRequestTokens(raw) {
|
|
109
|
+
const direct = normalizeAiRequestTokens(raw?.aiMaxRequestTokens);
|
|
110
|
+
if (direct !== null) return direct;
|
|
111
|
+
const migrated = migrateLegacyCharsToTokens(raw?.aiMaxRequestChars);
|
|
112
|
+
if (migrated !== null) return normalizeAiRequestTokens(migrated);
|
|
113
|
+
return defaultConfig.aiMaxRequestTokens;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 规范化「任务/对话结束提示」的通道开关(页面提示 / 浏览器通知 / 提示音共用)。
|
|
118
|
+
* 只接受布尔值,其它类型返回 null(交给调用方取默认),不抛错 —— 与
|
|
119
|
+
* normalizeTaskExecutor 同一套语义。
|
|
120
|
+
*
|
|
121
|
+
* 为什么不做 `!!value`:这个值控制是否弹提示条、是否弹浏览器系统通知,
|
|
122
|
+
* `'false'` 这种字符串被强转成 true 之后用户会在设置里明明关着却照样被弹,
|
|
123
|
+
* 且落盘后一直错下去。
|
|
124
|
+
*/
|
|
125
|
+
export function normalizeNotifySwitch(value) {
|
|
126
|
+
return typeof value === 'boolean' ? value : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 三个提示通道(页面提示 / 浏览器通知 / 提示音)的最终取值。
|
|
131
|
+
*
|
|
132
|
+
* 2026-10-05 之前只有「总开关 notifyOnTaskDone + 从属的提示音 notifySoundOnTaskDone」,
|
|
133
|
+
* 浏览器通知没有自己的开关 —— 前台弹 toast、后台发系统通知是自动二选一,而且页面内
|
|
134
|
+
* 首次点击会自动申请通知权限。用户反馈"每次开 GUI 都弹授权框"(默认开 + 自动申请 =
|
|
135
|
+
* 一个从没要过系统通知的人被反复打扰),且想要的三件事本来就该分开配。
|
|
136
|
+
*
|
|
137
|
+
* 现在三个键平级、各自独立,默认值:页面提示开、提示音开、**浏览器通知关**。
|
|
138
|
+
* 权限只在用户把浏览器通知拨开的那一刻申请(见设置页的 onBrowserNotifyToggleChange)。
|
|
139
|
+
*
|
|
140
|
+
* 旧键 notifyOnTaskDone 只作为**迁移输入**读一次:磁盘上显式 false 且新键都没写过
|
|
141
|
+
* → 三个通道全关(尊重"我以前就是关掉的",别让老用户升级后被突然弹一脸)。
|
|
142
|
+
* 它不再被写入,也不再是任何东西的前置条件 —— 三通道之间没有从属关系。
|
|
143
|
+
*/
|
|
144
|
+
function resolveNotifySwitches(raw) {
|
|
145
|
+
const page = normalizeNotifySwitch(raw?.notifyPageOnTaskDone);
|
|
146
|
+
const browser = normalizeNotifySwitch(raw?.notifyBrowserOnTaskDone);
|
|
147
|
+
const sound = normalizeNotifySwitch(raw?.notifySoundOnTaskDone);
|
|
148
|
+
const legacyAllOff = raw?.notifyOnTaskDone === false;
|
|
149
|
+
return {
|
|
150
|
+
notifyPageOnTaskDone: page ?? (legacyAllOff ? false : defaultConfig.notifyPageOnTaskDone),
|
|
151
|
+
notifyBrowserOnTaskDone: browser ?? (legacyAllOff ? false : defaultConfig.notifyBrowserOnTaskDone),
|
|
152
|
+
notifySoundOnTaskDone: sound ?? (legacyAllOff ? false : defaultConfig.notifySoundOnTaskDone),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 默认配置
|
|
157
|
+
const defaultConfig = {
|
|
158
|
+
defaultCommitMessage: "submit",
|
|
159
|
+
descriptionTemplates: [], // 添加描述模板数组
|
|
160
|
+
scopeTemplates: [],
|
|
161
|
+
messageTemplates: [],
|
|
162
|
+
commandTemplates: [
|
|
163
|
+
'echo "{{cmd}}"',
|
|
164
|
+
'npm run dev',
|
|
165
|
+
'npm run build',
|
|
166
|
+
'git status',
|
|
167
|
+
'git add .',
|
|
168
|
+
'git commit -m "{{message}}" --no-verify',
|
|
169
|
+
'git push',
|
|
170
|
+
],
|
|
171
|
+
lockedFiles: [], // 添加锁定文件数组
|
|
172
|
+
customCommands: [], // 添加自定义命令数组
|
|
173
|
+
orchestrations: [],
|
|
174
|
+
startupItems: [], // 添加项目启动项数组
|
|
175
|
+
startupAutoRun: false, // 添加启动项自动执行开关
|
|
176
|
+
afterQuickPushAction: {
|
|
177
|
+
enabled: false,
|
|
178
|
+
type: 'command',
|
|
179
|
+
refId: ''
|
|
180
|
+
},
|
|
181
|
+
currentDirectory: '',
|
|
182
|
+
// 提交设置
|
|
183
|
+
isStandardCommit: true,
|
|
184
|
+
skipHooks: false,
|
|
185
|
+
autoQuickPushOnEnter: false,
|
|
186
|
+
autoSetDefaultMessage: false,
|
|
187
|
+
// 默认开启:推送成功后进度弹窗自动关闭,2 秒后消失。
|
|
188
|
+
// 用户可在 设置 → 提交设置 → "Push 完成自动关闭" 单独关闭。
|
|
189
|
+
autoClosePushModal: true,
|
|
190
|
+
pullBeforePush: true,
|
|
191
|
+
// 通用设置
|
|
192
|
+
theme: 'light', // 主题: light | dark | auto
|
|
193
|
+
locale: 'zh-CN', // 语言: zh-CN | en-US
|
|
194
|
+
// AI 模型配置
|
|
195
|
+
models: [],
|
|
196
|
+
// AI 智能体单轮最大工具调用次数(防失控)。CLI `g ai` 与 Web 智能体共用,全局生效。
|
|
197
|
+
// 2026-09-20: 40 → 200。40 轮在"读多个文件 → 逐个验证 → 再改"这类任务里很容易触顶,
|
|
198
|
+
// 触顶后本轮被强制结束,用户必须再发一条消息才能接着跑,体感像被截断。
|
|
199
|
+
// 2026-10-03: 200 → 1000。实测"边探边改"的长任务(读多个文件 + 逐个验证 + 提交)200 轮
|
|
200
|
+
// 依然会被顶掉,而顶掉后重发消息要重新把上下文喂一遍,远比多跑几轮贵。
|
|
201
|
+
// 想调小/调大改这个值即可(GUI: 设置 → AI 模型配置)。
|
|
202
|
+
aiMaxToolIterations: 1000,
|
|
203
|
+
// AI 智能体单轮请求的上下文预算(**token**,全局配置,CLI `g ai` 与 Web 智能体共用)。
|
|
204
|
+
//
|
|
205
|
+
// 2026-10-07 一天内调了三次:80,000 字符 → 400,000 字符 → 1,000,000 token。
|
|
206
|
+
// 最终定在 token 口径 + 1M 的理由(详见 cli/ai/context.js 的预算一节):
|
|
207
|
+
// · 用户的模型都是 1M 档 —— MiniMax-M3 官方写"up to 1M tokens",
|
|
208
|
+
// DeepSeek V4 Flash 官方 API 文档 CONTEXT LENGTH 1M 且默认就是 1M;
|
|
209
|
+
// · provider 只认 token,用字符当闸门在纯中文内容上会超窗口;
|
|
210
|
+
// · 成本不是障碍:DeepSeek V4 Flash 输入 ¥1/1M token,**缓存命中只 ¥0.02/1M**
|
|
211
|
+
// (50 倍差价),而工具循环每轮重发的前缀完全相同,缓存命中率极高。
|
|
212
|
+
//
|
|
213
|
+
// 越界值夹取到 [20,000, 1,000,000]。解析公式见 cli/ai/context.js 的 resolveRequestBudget。
|
|
214
|
+
// 旧字段 aiMaxRequestChars(字符口径)由 resolveRequestTokens 迁移读取。
|
|
215
|
+
aiMaxRequestTokens: REQUEST_DEFAULT_MAX_TOKENS,
|
|
216
|
+
// 工作台任务执行器(claude | opencode | codex)。全局配置,跨项目共享。
|
|
217
|
+
// 决定「执行任务 / 执行子任务 / 从此处开始 / 简单任务续聊」这条链路
|
|
218
|
+
// 默认 spawn 哪个本地 CLI;执行入口可以按次覆盖(见 workbench 执行路由)。
|
|
219
|
+
taskExecutor: 'claude',
|
|
220
|
+
// 任务 / 对话执行结束提示的三个通道(全局)。三者**平级且互相独立**,
|
|
221
|
+
// 见 resolveNotifySwitches 的注释(2026-10-05 从"总开关 + 提示音子开关"拆过来)。
|
|
222
|
+
//
|
|
223
|
+
// 页面提示(默认开):结束时在页面内弹一条 ElMessage。
|
|
224
|
+
// 浏览器通知(默认关):页面在后台/别的窗口时发系统通知。默认关是因为它要申请
|
|
225
|
+
// 浏览器通知权限 —— 默认开 + 自动申请 = 用户每次开 GUI 都被弹一个授权框。
|
|
226
|
+
// 想用的人自己去设置里拨开,那一刻才申请。
|
|
227
|
+
// 提示音(默认开):跑完 / 出错误各响一声,主动停止不响。
|
|
228
|
+
// 音源是 CC0 资源(src/ui/client/public/sounds/),见同目录 CREDITS.txt。
|
|
229
|
+
notifyPageOnTaskDone: true,
|
|
230
|
+
notifyBrowserOnTaskDone: false,
|
|
231
|
+
notifySoundOnTaskDone: true,
|
|
232
|
+
// UI 状态(跨项目共享,存到顶层 ui 对象)
|
|
233
|
+
// 之前散落在 localStorage,因随机端口启动而失效,迁到文件持久化
|
|
234
|
+
ui: {
|
|
235
|
+
layout: { leftRatio: 0.25, midRatio: 0.375, rightRatio: 0.375, topRatio: 0.5 },
|
|
236
|
+
fileListViewMode: 'list', // 'list' | 'tree'
|
|
237
|
+
fileDiffSplitPercent: 35, // 15-85
|
|
238
|
+
commandConsole: {
|
|
239
|
+
expanded: true,
|
|
240
|
+
useTerminal: true,
|
|
241
|
+
showTerminalSessions: true,
|
|
242
|
+
splitPercent: 25, // 15-85
|
|
243
|
+
},
|
|
244
|
+
editorAutoSave: false,
|
|
245
|
+
// 文件空间的文件树是否定时静默刷新(捕获编辑器/外部工具产生的改动)。
|
|
246
|
+
// 关掉后只能手动点「刷新」按钮,适合大目录/网络盘(见 EditorView 的轮询注释)。
|
|
247
|
+
fileTreeAutoRefresh: true,
|
|
248
|
+
// 顶栏工具图标(VSCode / Claude Code / Codex / …)中**隐藏**的那几个。
|
|
249
|
+
// 白名单式的默认空数组:以后新增工具默认就固定显示,不需要回头改默认值。
|
|
250
|
+
// 未勾选(= 落在本数组里)的工具连同未安装的一起收进顶栏右侧「更多」菜单。
|
|
251
|
+
headerToolsHidden: [],
|
|
252
|
+
// 工作台任务执行器「上次用过/选过的那个」(claude | opencode | codex),null = 还没选过,
|
|
253
|
+
// 回落顶层 taskExecutor(设置里配的默认值)。两者**故意分开**:后者是"配置的默认",
|
|
254
|
+
// 前者是"上次用的",语义不同,合并就再也分不清哪个是用户主动配的。
|
|
255
|
+
// 2026-09-30: 原先记在 localStorage(键 zen-gitsync-task-executor),而 GUI 每次启动都换一个
|
|
256
|
+
// 随机端口(见 utils/startServerOnAvailablePort.js —— 端口被占就往后找),
|
|
257
|
+
// 浏览器按 origin(协议+主机+**端口**)隔离 localStorage,所以每开一次就是一个新桶,
|
|
258
|
+
// "记住上次"从来没生效过。跟本对象其它字段一样落文件。
|
|
259
|
+
lastTaskExecutor: null,
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// 规范化项目路径作为配置键
|
|
264
|
+
function normalizeProjectPath(p) {
|
|
265
|
+
const resolved = path.resolve(p);
|
|
266
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 缓存当前项目的唯一键。git 根目录在一个进程生命周期内通常不变
|
|
270
|
+
// (用户极少在 CLI 运行中 cd 出去),缓存命中后省掉一次同步 git 子进程
|
|
271
|
+
// (Windows 上 ~30-100ms,是 CLI 冷启动最大的单点延迟来源)。
|
|
272
|
+
// invalidateCurrentProjectKey() 在 process.chdir / fs.js 的 /api/change_directory
|
|
273
|
+
// 之类"cwd 改变"的场景下被调用,保证缓存不变成 stale。
|
|
274
|
+
let _currentProjectKeyCache = null;
|
|
275
|
+
let _currentProjectKeyCwd = null;
|
|
276
|
+
|
|
277
|
+
function getCurrentProjectKey() {
|
|
278
|
+
const cwd = process.cwd();
|
|
279
|
+
// cwd 没变 + 已有缓存:直接返回
|
|
280
|
+
if (_currentProjectKeyCache && _currentProjectKeyCwd === cwd) {
|
|
281
|
+
return _currentProjectKeyCache;
|
|
282
|
+
}
|
|
283
|
+
let key = null;
|
|
284
|
+
try {
|
|
285
|
+
const gitRoot = execSync('git rev-parse --show-toplevel', {
|
|
286
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, windowsHide: true,
|
|
287
|
+
}).trim();
|
|
288
|
+
if (gitRoot) key = normalizeProjectPath(gitRoot);
|
|
289
|
+
} catch (_) {
|
|
290
|
+
// 非 Git 项目或 git 不可用,降级到 CWD
|
|
291
|
+
}
|
|
292
|
+
if (!key) key = normalizeProjectPath(cwd);
|
|
293
|
+
_currentProjectKeyCache = key;
|
|
294
|
+
_currentProjectKeyCwd = cwd;
|
|
295
|
+
return key;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 主动让项目键缓存失效。fs.js /api/change_directory 等修改 process.cwd
|
|
300
|
+
* 的代码必须在 chdir 之后调一次,否则下一次 loadConfig/saveConfig 还会
|
|
301
|
+
* 命中旧 key,写到错误项目的配置容器里。
|
|
302
|
+
*/
|
|
303
|
+
function invalidateCurrentProjectKey() {
|
|
304
|
+
_currentProjectKeyCache = null;
|
|
305
|
+
_currentProjectKeyCwd = null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// 注意:Node.js 的 process 对象**不会**因为 process.chdir(path) 自动派发
|
|
309
|
+
// 'chdir' 事件(只有 cwd 包装器才会,API 文档明确标注不可靠)。所以这里
|
|
310
|
+
// 不挂 process.on('chdir', ...) —— 任何修改 cwd 的代码路径
|
|
311
|
+
// (fs.js 的 /api/change_directory 等)必须显式 import 并调用
|
|
312
|
+
// invalidateCurrentProjectKey(),否则下一次 loadConfig/saveConfig 还会
|
|
313
|
+
// 命中旧 key,写到错误项目的配置容器里。
|
|
314
|
+
|
|
315
|
+
// 从磁盘读取原始配置对象
|
|
316
|
+
//
|
|
317
|
+
// 缓存策略:单进程内 safeLoadRaw 的结果缓存,所有 loadConfig / saveConfig /
|
|
318
|
+
// saveRecentDirectory 等路径共享,避免每次都重新打开文件 + JSON.parse。
|
|
319
|
+
// 写盘( writeRawConfigFile )时主动失效缓存,保证下一次读看到最新值。
|
|
320
|
+
//
|
|
321
|
+
// 多进程一致性(2026-08-07):之前缓存不感知外部进程修改 —— 一个 g ui 实例
|
|
322
|
+
// 改了模型配置,另一个实例要重启才能看到。现在缓存命中时先 fs.stat 比对
|
|
323
|
+
// mtimeMs + size(一次 stat 系统调用,远低于 open+read+parse 的开销),
|
|
324
|
+
// 签名不一致则自动重读,等价于"懒加载版 fs.watch",且无 watcher 生命周期 /
|
|
325
|
+
// CLI 进程退出阻塞的问题。
|
|
326
|
+
let _rawConfigCache = null; // { value, existed, mtimeMs, size } | null(null = 失效/未缓存)
|
|
327
|
+
|
|
328
|
+
// 取配置文件签名(mtimeMs + size);文件不存在返回 null,stat 异常返回 undefined(表示"未知")
|
|
329
|
+
async function statConfigSignature() {
|
|
330
|
+
try {
|
|
331
|
+
const st = await fs.stat(configPath);
|
|
332
|
+
return { mtimeMs: st.mtimeMs, size: st.size };
|
|
333
|
+
} catch (err) {
|
|
334
|
+
if (err && err.code === 'ENOENT') return null;
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// 判断缓存是否仍新鲜。
|
|
340
|
+
//
|
|
341
|
+
// ⚠️ 必须通过参数接收快照,不能在函数内直接闭包引用模块级 _rawConfigCache:
|
|
342
|
+
// 本函数第一个 await(fs.stat)会让出事件循环,并发路径(writeRawConfigFile 尾部、
|
|
343
|
+
// fs.js 的 chdir)可能在这个窗口内调用 invalidateRawConfigCache() 把它置 null,
|
|
344
|
+
// 恢复执行后再解引用就抛 "Cannot read properties of null (reading 'existed')"。
|
|
345
|
+
// 该 await 在 safeLoadRaw 的 try 之外,错误会一路冒到 loadConfig 并被包成
|
|
346
|
+
// "系统配置文件JSON格式错误" —— 报错信息完全误导(磁盘上的文件其实是合法 JSON)。
|
|
347
|
+
async function isRawConfigCacheFresh(cache) {
|
|
348
|
+
if (!cache) return false;
|
|
349
|
+
const sig = await statConfigSignature();
|
|
350
|
+
if (sig === undefined) {
|
|
351
|
+
// stat 异常(权限等):保守沿用缓存,避免把本来可读的状态打成错误
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
if (sig === null) {
|
|
355
|
+
// 文件当前不存在:缓存也说"不存在"才算新鲜;缓存有值但文件被外部删了 → 需重走读取路径
|
|
356
|
+
return !cache.existed;
|
|
357
|
+
}
|
|
358
|
+
if (!cache.existed) return false; // 外部进程新建了配置文件 → 需重读
|
|
359
|
+
return sig.mtimeMs === cache.mtimeMs && sig.size === cache.size;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function readRawConfigFile() {
|
|
363
|
+
// 缓存的新鲜性校验统一收敛在 safeLoadRaw,这里不另开快速通道,
|
|
364
|
+
// 否则外部进程写盘会被这条捷径挡住。
|
|
365
|
+
const state = await safeLoadRaw();
|
|
366
|
+
if (!state.ok) {
|
|
367
|
+
throw state.error;
|
|
368
|
+
}
|
|
369
|
+
return state.obj;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* 主动让 raw config 缓存失效。测试 / 跨进程场景可手动调。
|
|
374
|
+
*/
|
|
375
|
+
function invalidateRawConfigCache() {
|
|
376
|
+
_rawConfigCache = null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// 将原始配置对象写回磁盘
|
|
380
|
+
//
|
|
381
|
+
// 串行化(2026-09-18):同一进程内多个请求(锁定文件、布局比例、主题、最近目录、
|
|
382
|
+
// 模型配置…)都可能并发触发写盘。旧实现的 tmpPath 只带 `pid.毫秒时间戳` ——
|
|
383
|
+
// 同一毫秒内的两次写会**共用同一个 tmp 文件**:
|
|
384
|
+
// ① 先完成的 rename 把 tmp 搬走,后者的 rename 拿到 ENOENT/EPERM;
|
|
385
|
+
// ② 于是降级成"直接覆盖写",原子性丢失;
|
|
386
|
+
// ③ 两次 writeFile 交错写同一个 tmp 时,读者能 parse 到半截 JSON
|
|
387
|
+
// (实测报 "Unexpected non-whitespace character after JSON at position N")。
|
|
388
|
+
// 现在:进程内写队列串行 + tmp 名加自增序号(序号在 fsAtomic 里,多进程间 pid 天然不同)。
|
|
389
|
+
let _writeQueue = Promise.resolve();
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* 串行写入口。返回的 Promise 与本次写入的真实结果一致(失败也如实抛出),
|
|
393
|
+
* 但队列本身会吞掉失败继续跑 —— 一次写失败不能把后续写全部卡死。
|
|
394
|
+
*/
|
|
395
|
+
function writeRawConfigFile(obj) {
|
|
396
|
+
const run = () => writeRawConfigFileInner(obj);
|
|
397
|
+
// then(run, run):前一个写 reject 时也要继续执行本次写
|
|
398
|
+
const p = _writeQueue.then(run, run);
|
|
399
|
+
_writeQueue = p.then(() => {}, () => {});
|
|
400
|
+
return p;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// 当前该按哪种模式落盘。原子写与"占用类错误重试"都收敛在 src/fsAtomic.js ——
|
|
404
|
+
// 分文件后写入点变成 1 + N + M 个,重试逻辑必须有唯一实现,否则迟早有一边漏掉
|
|
405
|
+
// EPERM/EACCES/EBUSY(那正是"并发读写偶发 500"的成因)。
|
|
406
|
+
//
|
|
407
|
+
// 模式判定优先用读路径已经定好的结论;_splitActive 还是 null(本进程还没读过
|
|
408
|
+
// 任何配置)时退化成看标记文件 —— 标记存在 = 拆分成功过 = 按分文件写。
|
|
409
|
+
async function resolveWriteMode() {
|
|
410
|
+
const active = isSplitActive();
|
|
411
|
+
if (active !== null) return active;
|
|
412
|
+
return splitMarkerExists();
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// 用 tmp + rename 原子写:避免拖拽高频触发 + 防抖期间 beforeunload 同时写入时
|
|
416
|
+
// 两次 fs.writeFile 直接覆盖产生的"先 truncate 再写"的中间态空文件,
|
|
417
|
+
// 触发 readRawConfigFile 在 race 时 JSON.parse 失败 → 500。
|
|
418
|
+
async function writeRawConfigFileInner(obj) {
|
|
419
|
+
await resolveConfigPath();
|
|
420
|
+
if (await resolveWriteMode()) {
|
|
421
|
+
// 分文件模式:项目/画布各自成文件,只有变更过的才重写;config.json 只留全局
|
|
422
|
+
// 设置(但每次都会写,它是别的实例判断"磁盘变了没"的唯一签名来源)。
|
|
423
|
+
const res = await writeSplitStore(obj);
|
|
424
|
+
if (res.errors.length) {
|
|
425
|
+
console.warn(chalk.yellow(
|
|
426
|
+
`[config] 分文件写盘有 ${res.errors.length} 个错误: ${res.errors.slice(0, 3).join('; ')}`
|
|
427
|
+
));
|
|
428
|
+
}
|
|
429
|
+
invalidateRawConfigCache();
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
await atomicWriteText(configPath, JSON.stringify(obj, null, 2));
|
|
433
|
+
// 写盘成功后让缓存失效 — 下次 readRawConfigFile 走实盘,保证拿到最新值
|
|
434
|
+
invalidateRawConfigCache();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function backupConfigFileIfExists() {
|
|
438
|
+
try {
|
|
439
|
+
await fs.access(configPath);
|
|
440
|
+
} catch (_) {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
await fs.copyFile(configPath, `${configPath}.bak`);
|
|
446
|
+
} catch (_) {
|
|
447
|
+
// 备份失败不阻断主流程
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// 把 config.json 读出来的"内联对象"转成对外使用的 raw。
|
|
452
|
+
//
|
|
453
|
+
// 分文件模式(configSplit)下 config.json 只留全局设置,projects 是这里从
|
|
454
|
+
// projects/ + orchestration/ 组装回来的 —— 于是 loadConfig / readRawConfigFile /
|
|
455
|
+
// saveConfig 看到的形状与拆分前**完全一致**,调用点一行都不用改。
|
|
456
|
+
//
|
|
457
|
+
// ⚠️ 返回值的 `projects` 必须是对象,**哪怕是空的**。loadConfig 靠 `raw.projects`
|
|
458
|
+
// 是否存在来区分新旧两种结构(见下面的兼容分支),给出 undefined 会让它掉进
|
|
459
|
+
// "旧扁平格式"分支、把项目配置当顶层字段读 —— 表现为所有项目设置静默失效。
|
|
460
|
+
async function materializeRaw(inline) {
|
|
461
|
+
try {
|
|
462
|
+
const mode = await ensureSplitStore(inline, { configPath });
|
|
463
|
+
if (!mode.active) return mode.raw ?? inline;
|
|
464
|
+
const read = await readSplitProjects();
|
|
465
|
+
if (read.errors.length) {
|
|
466
|
+
// 单个项目/画布文件坏掉不阻断:其余照常可用,如实报告让用户知道少了什么
|
|
467
|
+
console.warn(chalk.yellow(
|
|
468
|
+
`[config] 读取分文件配置有 ${read.errors.length} 个问题(已跳过): ${read.errors.slice(0, 3).join('; ')}`
|
|
469
|
+
));
|
|
470
|
+
}
|
|
471
|
+
return { ...inline, projects: read.ok ? read.projects : {} };
|
|
472
|
+
} catch (err) {
|
|
473
|
+
console.warn(chalk.yellow(
|
|
474
|
+
`[config] 分文件存储不可用,按内联模式处理: ${err?.code || err?.message}`
|
|
475
|
+
));
|
|
476
|
+
return inline;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// 更安全的读取,区分“文件不存在”和“解析失败”
|
|
481
|
+
async function safeLoadRaw() {
|
|
482
|
+
await resolveConfigPath();
|
|
483
|
+
// 命中缓存且签名未变(无外部进程写入)才直接返回,跳过 readFile + JSON.parse
|
|
484
|
+
//
|
|
485
|
+
// 先取快照再判新鲜:判新鲜期间会让出事件循环,缓存可能被并发路径失效。
|
|
486
|
+
// 收尾再比对一次引用 —— 引用变了说明期间确实发生了写盘,此时不能返回旧快照
|
|
487
|
+
// (会读到"写之前的旧值"),直接落到下面重读实盘。
|
|
488
|
+
const cached = _rawConfigCache;
|
|
489
|
+
if (cached && await isRawConfigCacheFresh(cached) && _rawConfigCache === cached) {
|
|
490
|
+
return { ok: true, obj: cached.value, existed: cached.existed };
|
|
491
|
+
}
|
|
492
|
+
try {
|
|
493
|
+
const data = await fs.readFile(configPath, 'utf-8');
|
|
494
|
+
const inline = JSON.parse(data);
|
|
495
|
+
// 组装(必要时含一次性拆分)必须在取签名之前 —— 拆分本身会重写 config.json,
|
|
496
|
+
// 先取签名会缓存到一个已经失效的 mtime,导致下一次读误判为"外部没改"。
|
|
497
|
+
const obj = await materializeRaw(inline);
|
|
498
|
+
// 读完后立刻取签名。取不到(stat 异常)则签名字段置 undefined ——
|
|
499
|
+
// 之后若 stat 恢复可用,签名比对必然不命中 → 自动重读兜底;
|
|
500
|
+
// 若 stat 持续异常,isRawConfigCacheFresh 会保守沿用缓存。
|
|
501
|
+
const sig = await statConfigSignature();
|
|
502
|
+
_rawConfigCache = {
|
|
503
|
+
value: obj,
|
|
504
|
+
existed: true,
|
|
505
|
+
mtimeMs: sig ? sig.mtimeMs : undefined,
|
|
506
|
+
size: sig ? sig.size : undefined
|
|
507
|
+
};
|
|
508
|
+
return { ok: true, obj, existed: true };
|
|
509
|
+
} catch (err) {
|
|
510
|
+
if (err && err.code === 'ENOENT') {
|
|
511
|
+
// 文件不存在:当作空对象,但可继续写入。
|
|
512
|
+
// 缓存签名 mtimeMs:null —— 命中缓存时仍会 stat 一次以感知外部新建,
|
|
513
|
+
// 比旧的"每次都 readFile 抛 ENOENT"便宜,比"永久信任缓存"及时。
|
|
514
|
+
//
|
|
515
|
+
// 仍然走一次 materializeRaw:config.json 没了不代表数据没了 ——
|
|
516
|
+
// 分文件模式下项目配置在 projects/ 里,直接返回 `{}` 会让用户看到
|
|
517
|
+
// "项目全消失了",而磁盘上其实一份不少。
|
|
518
|
+
const empty = await materializeRaw({});
|
|
519
|
+
_rawConfigCache = { value: empty, existed: false, mtimeMs: null, size: null };
|
|
520
|
+
return { ok: true, obj: empty, existed: false };
|
|
521
|
+
}
|
|
522
|
+
// 其他错误(例如解析失败):不写入,提示用户
|
|
523
|
+
// 不缓存错误状态 — 修复后下次读还能成功
|
|
524
|
+
return { ok: false, error: err };
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* 判断错误是否**真的**是 JSON 解析失败。
|
|
530
|
+
*
|
|
531
|
+
* 非解析类错误(IO 异常、内部竞态)如果也套上"JSON 格式错误"的帽子,会把排查
|
|
532
|
+
* 方向彻底带偏 —— 2026-09-18 那次缓存竞态就是这样伪装成
|
|
533
|
+
* "系统配置文件JSON格式错误…原因: Cannot read properties of null",而磁盘上的
|
|
534
|
+
* 文件其实是合法 JSON,用户照着提示去修文件只能白忙。
|
|
535
|
+
*/
|
|
536
|
+
function isJsonParseError(err) {
|
|
537
|
+
return err instanceof SyntaxError || err?.name === 'SyntaxError';
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// 异步读取配置文件
|
|
541
|
+
async function loadConfig() {
|
|
542
|
+
const key = getCurrentProjectKey();
|
|
543
|
+
let raw = null;
|
|
544
|
+
try {
|
|
545
|
+
raw = await readRawConfigFile();
|
|
546
|
+
} catch (err) {
|
|
547
|
+
const msg = err?.message ? String(err.message) : String(err);
|
|
548
|
+
const head = isJsonParseError(err)
|
|
549
|
+
? '系统配置文件JSON格式错误,请修复后重试。'
|
|
550
|
+
: '读取系统配置文件失败。';
|
|
551
|
+
throw new Error(`${head}\n文件: ${configPath}\n原因: ${msg}`);
|
|
552
|
+
}
|
|
553
|
+
// 兼容旧版(全局扁平结构)
|
|
554
|
+
if (raw && !raw.projects) {
|
|
555
|
+
return {
|
|
556
|
+
...defaultConfig,
|
|
557
|
+
...raw,
|
|
558
|
+
aiMaxToolIterations: normalizeAiMaxToolIterations(raw.aiMaxToolIterations)
|
|
559
|
+
?? defaultConfig.aiMaxToolIterations,
|
|
560
|
+
aiMaxRequestTokens: resolveRequestTokens(raw),
|
|
561
|
+
taskExecutor: normalizeTaskExecutor(raw.taskExecutor) ?? defaultConfig.taskExecutor,
|
|
562
|
+
...resolveNotifySwitches(raw)
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// 新版结构:{ projects: { [key]: projectConfig }, theme?, locale?, ui?, recentDirectories? }
|
|
567
|
+
const projectConfig = raw?.projects?.[key];
|
|
568
|
+
// 合并:默认配置 + 项目配置 + 全局通用设置(theme, locale, models, ui)
|
|
569
|
+
// models / ui 是全局配置(跨项目共享),始终取顶层,不使用项目级的(防止旧数据覆盖)
|
|
570
|
+
return {
|
|
571
|
+
...defaultConfig,
|
|
572
|
+
...(projectConfig || {}),
|
|
573
|
+
theme: raw?.theme ?? defaultConfig.theme,
|
|
574
|
+
locale: raw?.locale ?? defaultConfig.locale,
|
|
575
|
+
models: raw?.models ?? defaultConfig.models,
|
|
576
|
+
ui: raw?.ui ?? defaultConfig.ui,
|
|
577
|
+
// 同 models:全局配置,始终取顶层,防止被项目配置里的旧值覆盖
|
|
578
|
+
aiMaxToolIterations: normalizeAiMaxToolIterations(raw?.aiMaxToolIterations)
|
|
579
|
+
?? defaultConfig.aiMaxToolIterations,
|
|
580
|
+
aiMaxRequestTokens: resolveRequestTokens(raw),
|
|
581
|
+
taskExecutor: normalizeTaskExecutor(raw?.taskExecutor) ?? defaultConfig.taskExecutor,
|
|
582
|
+
// 同 taskExecutor:全局配置,始终取顶层,防止被项目配置里的旧值覆盖
|
|
583
|
+
...resolveNotifySwitches(raw)
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// 配置写入失败的错误类型,让上层 catch 后能区分是参数错误还是 IO 错误
|
|
588
|
+
class ConfigWriteError extends Error {
|
|
589
|
+
constructor(message, cause) {
|
|
590
|
+
super(message);
|
|
591
|
+
this.name = 'ConfigWriteError';
|
|
592
|
+
if (cause) this.cause = cause;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// 异步保存配置
|
|
597
|
+
// 契约变更(2026-06-26):之前在错误分支仅 console.warn + return undefined,
|
|
598
|
+
// 调用方按成功处理 → 用户看到 ✓ / 200 OK 但其实没写盘(MAINT-4)。
|
|
599
|
+
// 现在改为:参数非法 → 抛 ConfigWriteError;IO/解析失败 → 抛 ConfigWriteError(wrapped)。
|
|
600
|
+
// 成功路径仍然返回 undefined,保持与大多数 fs.writeFile 风格一致。
|
|
601
|
+
async function saveConfig(config) {
|
|
602
|
+
if(!config || typeof config !== 'object' || Array.isArray(config)){
|
|
603
|
+
throw new ConfigWriteError(`saveConfig: 入参必须是普通对象,实际收到 ${Array.isArray(config) ? 'array' : typeof config}`);
|
|
604
|
+
}
|
|
605
|
+
if (Object.keys(config).length === 0) {
|
|
606
|
+
throw new ConfigWriteError('saveConfig: 配置对象为空,已取消写入以避免覆盖');
|
|
607
|
+
}
|
|
608
|
+
const key = getCurrentProjectKey();
|
|
609
|
+
const state = await safeLoadRaw();
|
|
610
|
+
if (!state.ok) {
|
|
611
|
+
const cause = state.error;
|
|
612
|
+
const detail = cause?.message ? String(cause.message) : String(cause);
|
|
613
|
+
const head = isJsonParseError(cause)
|
|
614
|
+
? '解析配置文件失败,已取消写入以避免覆盖。'
|
|
615
|
+
: '读取配置文件失败,已取消写入以避免覆盖。';
|
|
616
|
+
throw new ConfigWriteError(
|
|
617
|
+
`${head}\n文件: ${configPath}\n原因: ${detail}`,
|
|
618
|
+
cause
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const raw = state.obj; // 保留现有顶层键
|
|
623
|
+
|
|
624
|
+
// 确保 projects 容器存在
|
|
625
|
+
if (!raw.projects || typeof raw.projects !== 'object') {
|
|
626
|
+
raw.projects = {};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// 分离全局设置和项目设置
|
|
630
|
+
// models / ui 也是全局配置(跨项目共享),和 theme/locale 一样存到顶层
|
|
631
|
+
// ⚠️ notifyOnTaskDone 是**旧键**(2026-10-05 拆成下面三个通道键):这里仍要显式
|
|
632
|
+
// 解构出来,否则它会跟着 ...projectConfig 被写进**项目配置**里 —— 全局设置漏进
|
|
633
|
+
// 项目级是这一族键最容易踩的坑。解构出来本身不写回顶层(它已不再被任何地方读取)。
|
|
634
|
+
const {
|
|
635
|
+
theme, locale, models, ui, aiMaxToolIterations, aiMaxRequestTokens, taskExecutor,
|
|
636
|
+
notifyOnTaskDone: legacyNotifyOnTaskDone,
|
|
637
|
+
notifyPageOnTaskDone, notifyBrowserOnTaskDone, notifySoundOnTaskDone,
|
|
638
|
+
...projectConfig
|
|
639
|
+
} = config;
|
|
640
|
+
void legacyNotifyOnTaskDone;
|
|
641
|
+
|
|
642
|
+
// 保存全局设置到根级别
|
|
643
|
+
if (theme !== undefined) {
|
|
644
|
+
raw.theme = theme;
|
|
645
|
+
}
|
|
646
|
+
if (locale !== undefined) {
|
|
647
|
+
raw.locale = locale;
|
|
648
|
+
}
|
|
649
|
+
if (models !== undefined) {
|
|
650
|
+
raw.models = models;
|
|
651
|
+
}
|
|
652
|
+
if (ui !== undefined) {
|
|
653
|
+
raw.ui = ui;
|
|
654
|
+
}
|
|
655
|
+
// 工具调用上限同属全局设置:非法值不落盘(保留磁盘上的旧值),避免把手改坏的值固化
|
|
656
|
+
const normalizedIterations = normalizeAiMaxToolIterations(aiMaxToolIterations);
|
|
657
|
+
if (normalizedIterations !== null) {
|
|
658
|
+
raw.aiMaxToolIterations = normalizedIterations;
|
|
659
|
+
}
|
|
660
|
+
// 请求上下文预算同属全局设置:同一套夹取语义
|
|
661
|
+
const normalizedTokens = normalizeAiRequestTokens(aiMaxRequestTokens);
|
|
662
|
+
if (normalizedTokens !== null) {
|
|
663
|
+
raw.aiMaxRequestTokens = normalizedTokens;
|
|
664
|
+
}
|
|
665
|
+
// 任务执行器同属全局设置:白名单外的值不落盘
|
|
666
|
+
const normalizedExecutor = normalizeTaskExecutor(taskExecutor);
|
|
667
|
+
if (normalizedExecutor !== null) {
|
|
668
|
+
raw.taskExecutor = normalizedExecutor;
|
|
669
|
+
}
|
|
670
|
+
// 三个提示通道开关同属全局设置:非布尔值不落盘(保留磁盘旧值)。
|
|
671
|
+
// 各自独立 —— 写其中一个绝不顺带改动另外两个。
|
|
672
|
+
const notifySwitches = {
|
|
673
|
+
notifyPageOnTaskDone,
|
|
674
|
+
notifyBrowserOnTaskDone,
|
|
675
|
+
notifySoundOnTaskDone,
|
|
676
|
+
};
|
|
677
|
+
for (const [key, value] of Object.entries(notifySwitches)) {
|
|
678
|
+
const normalized = normalizeNotifySwitch(value);
|
|
679
|
+
if (normalized !== null) {
|
|
680
|
+
raw[key] = normalized;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// 写入当前项目配置(在 defaultConfig 基础上合并,但不清空顶层其它键)
|
|
685
|
+
const existingProjectConfig = (raw.projects[key] && typeof raw.projects[key] === 'object') ? raw.projects[key] : {};
|
|
686
|
+
raw.projects[key] = { ...defaultConfig, ...existingProjectConfig, ...projectConfig };
|
|
687
|
+
await backupConfigFileIfExists();
|
|
688
|
+
await writeRawConfigFile(raw);
|
|
689
|
+
// writeRawConfigFile 内部已 invalidateRawConfigCache,这里不再重复
|
|
690
|
+
}
|
|
691
|
+
// 文件锁定管理函数
|
|
692
|
+
async function lockFile(filePath) {
|
|
693
|
+
const config = await loadConfig();
|
|
694
|
+
const normalizedPath = path.normalize(filePath);
|
|
695
|
+
|
|
696
|
+
if (!config.lockedFiles.includes(normalizedPath)) {
|
|
697
|
+
config.lockedFiles.push(normalizedPath);
|
|
698
|
+
try {
|
|
699
|
+
await saveConfig(config);
|
|
700
|
+
} catch (err) {
|
|
701
|
+
// 写入失败时回滚内存变更,避免"以为锁了实际没锁"
|
|
702
|
+
const idx = config.lockedFiles.lastIndexOf(normalizedPath);
|
|
703
|
+
if (idx > -1) config.lockedFiles.splice(idx, 1);
|
|
704
|
+
console.error(chalk.red(`❌ 锁定失败: "${normalizedPath}"`));
|
|
705
|
+
console.error(chalk.gray(err.message));
|
|
706
|
+
throw err;
|
|
707
|
+
}
|
|
708
|
+
console.log(chalk.green(`✓ 文件已锁定: "${normalizedPath}"`));
|
|
709
|
+
return true;
|
|
710
|
+
} else {
|
|
711
|
+
console.log(chalk.yellow(`⚠️ 文件已经被锁定: "${normalizedPath}"`));
|
|
712
|
+
return false;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
async function unlockFile(filePath) {
|
|
717
|
+
const config = await loadConfig();
|
|
718
|
+
const normalizedPath = path.normalize(filePath);
|
|
719
|
+
const index = config.lockedFiles.indexOf(normalizedPath);
|
|
720
|
+
|
|
721
|
+
if (index > -1) {
|
|
722
|
+
config.lockedFiles.splice(index, 1);
|
|
723
|
+
try {
|
|
724
|
+
await saveConfig(config);
|
|
725
|
+
} catch (err) {
|
|
726
|
+
// 写入失败时回滚(把刚 splice 掉的项加回去)
|
|
727
|
+
config.lockedFiles.splice(index, 0, normalizedPath);
|
|
728
|
+
console.error(chalk.red(`❌ 解锁失败: "${normalizedPath}"`));
|
|
729
|
+
console.error(chalk.gray(err.message));
|
|
730
|
+
throw err;
|
|
731
|
+
}
|
|
732
|
+
console.log(chalk.green(`✓ 文件已解锁: "${normalizedPath}"`));
|
|
733
|
+
return true;
|
|
734
|
+
} else {
|
|
735
|
+
console.log(chalk.yellow(`⚠️ 文件未被锁定: "${normalizedPath}"`));
|
|
736
|
+
return false;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async function isFileLocked(filePath) {
|
|
741
|
+
const config = await loadConfig();
|
|
742
|
+
const normalizedPath = path.normalize(filePath);
|
|
743
|
+
return config.lockedFiles.includes(normalizedPath);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
async function listLockedFiles() {
|
|
747
|
+
const config = await loadConfig();
|
|
748
|
+
if (config.lockedFiles.length === 0) {
|
|
749
|
+
console.log(chalk.blue('📝 当前没有锁定的文件'));
|
|
750
|
+
} else {
|
|
751
|
+
console.log(chalk.blue('🔒 已锁定的文件:'));
|
|
752
|
+
config.lockedFiles.forEach((file, index) => {
|
|
753
|
+
console.log(chalk.gray(` ${index + 1}. ${file}`));
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
return config.lockedFiles;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
async function getLockedFiles() {
|
|
760
|
+
const config = await loadConfig();
|
|
761
|
+
return config.lockedFiles || [];
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// 全局“最近访问的目录”管理(保存在原始配置的顶层)
|
|
765
|
+
const MAX_RECENT_DIRS = 50;
|
|
766
|
+
|
|
767
|
+
async function getRecentDirectories() {
|
|
768
|
+
const raw = await readRawConfigFile();
|
|
769
|
+
const list = raw?.recentDirectories;
|
|
770
|
+
return Array.isArray(list) ? list : [];
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
async function saveRecentDirectory(dirPath) {
|
|
774
|
+
if (!dirPath || typeof dirPath !== 'string') return;
|
|
775
|
+
const state = await safeLoadRaw();
|
|
776
|
+
if (!state.ok) {
|
|
777
|
+
const cause = state.error;
|
|
778
|
+
const detail = cause?.message ? String(cause.message) : String(cause);
|
|
779
|
+
const head = isJsonParseError(cause)
|
|
780
|
+
? '解析配置文件失败,已取消写入最近目录以避免覆盖。'
|
|
781
|
+
: '读取配置文件失败,已取消写入最近目录以避免覆盖。';
|
|
782
|
+
throw new ConfigWriteError(
|
|
783
|
+
`${head}\n文件: ${configPath}\n原因: ${detail}`,
|
|
784
|
+
cause
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
const raw = state.obj; // 保留现有顶层键
|
|
788
|
+
let list = Array.isArray(raw.recentDirectories) ? raw.recentDirectories.slice() : [];
|
|
789
|
+
|
|
790
|
+
// 规范化:Windows 下统一为小写,去掉重复,保持最新在前
|
|
791
|
+
// 修复(MAINT-5):unshift 用原始大小写,Windows 下 C:\Project 与 C:\PROJECT
|
|
792
|
+
// 会产生两条"看起来一样"的项;现在 unshift 前先 normalize,dup 检测自然合并。
|
|
793
|
+
// 注意:存的字符串是 normalized 形式,在 Windows 下显示为小写路径,
|
|
794
|
+
// 这是有意为之 — 用 OS 标准大小写形式消除歧义,GUI 层可选择单独保留原始形式。
|
|
795
|
+
const normalized = normalizeProjectPath(dirPath);
|
|
796
|
+
list = list.filter(p => normalizeProjectPath(p) !== normalized);
|
|
797
|
+
list.unshift(normalized);
|
|
798
|
+
if (list.length > MAX_RECENT_DIRS) list = list.slice(0, MAX_RECENT_DIRS);
|
|
799
|
+
|
|
800
|
+
raw.recentDirectories = list;
|
|
801
|
+
await writeRawConfigFile(raw);
|
|
802
|
+
return list;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
async function removeRecentDirectory(dirPath) {
|
|
806
|
+
if (!dirPath || typeof dirPath !== 'string') return;
|
|
807
|
+
const state = await safeLoadRaw();
|
|
808
|
+
if (!state.ok) return [];
|
|
809
|
+
const raw = state.obj;
|
|
810
|
+
let list = Array.isArray(raw.recentDirectories) ? raw.recentDirectories.slice() : [];
|
|
811
|
+
const normalized = normalizeProjectPath(dirPath);
|
|
812
|
+
list = list.filter(p => normalizeProjectPath(p) !== normalized);
|
|
813
|
+
raw.recentDirectories = list;
|
|
814
|
+
await writeRawConfigFile(raw);
|
|
815
|
+
return list;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// 添加配置管理函数
|
|
819
|
+
async function handleConfigCommands() {
|
|
820
|
+
if (process.argv.includes('get-config')) {
|
|
821
|
+
const currentConfig = await loadConfig();
|
|
822
|
+
console.log('Current configuration:');
|
|
823
|
+
console.log(currentConfig);
|
|
824
|
+
process.exit();
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const setMsgArg = process.argv.find(arg => arg.startsWith('--set-default-message='));
|
|
828
|
+
if (setMsgArg) {
|
|
829
|
+
const newMessage = setMsgArg.split('=')[1];
|
|
830
|
+
const currentConfig = await loadConfig();
|
|
831
|
+
currentConfig.defaultCommitMessage = newMessage;
|
|
832
|
+
try {
|
|
833
|
+
await saveConfig(currentConfig);
|
|
834
|
+
} catch (err) {
|
|
835
|
+
console.error(chalk.red('❌ 默认提交信息写入失败'));
|
|
836
|
+
console.error(chalk.gray(err.message));
|
|
837
|
+
process.exit(1);
|
|
838
|
+
}
|
|
839
|
+
console.log(chalk.green(`✓ 默认提交信息已设置为: "${newMessage}"`));
|
|
840
|
+
process.exit();
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* 显式删除某个项目的全部分文件。
|
|
845
|
+
*
|
|
846
|
+
* ⚠️ 必须顺带失效读缓存 —— 删的是 projects/ 下的文件,config.json 本身没动,
|
|
847
|
+
* 而缓存新鲜度只看 config.json 的 mtime+size。不失效的话删完再读会把刚删掉的项目
|
|
848
|
+
* 从旧快照里"读回来",用户看到"删了没反应"。
|
|
849
|
+
*/
|
|
850
|
+
async function deleteProjectConfigAndInvalidate(key) {
|
|
851
|
+
const res = await deleteProjectConfig(key);
|
|
852
|
+
invalidateRawConfigCache();
|
|
853
|
+
return res;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
export default {
|
|
857
|
+
loadConfig,
|
|
858
|
+
saveConfig,
|
|
859
|
+
handleConfigCommands,
|
|
860
|
+
lockFile,
|
|
861
|
+
unlockFile,
|
|
862
|
+
isFileLocked,
|
|
863
|
+
listLockedFiles,
|
|
864
|
+
getLockedFiles,
|
|
865
|
+
getRecentDirectories,
|
|
866
|
+
saveRecentDirectory,
|
|
867
|
+
removeRecentDirectory,
|
|
868
|
+
readRawConfigFile,
|
|
869
|
+
writeRawConfigFile,
|
|
870
|
+
// 显式删除某个项目的全部分文件。写路径刻意不做删除(防"只加载了一个项目就写回"
|
|
871
|
+
// 连带清空其它项目),清理失效项目只能走这里。
|
|
872
|
+
deleteProjectConfig: deleteProjectConfigAndInvalidate,
|
|
873
|
+
// AI 智能体单轮工具调用上限的规范化/区间(GUI 保存前也要用,见 /api/config/save-ai-settings)
|
|
874
|
+
normalizeAiMaxToolIterations,
|
|
875
|
+
// 请求上下文预算的规范化/区间(同一路由保存时用;公式本体在 cli/ai/context.js)
|
|
876
|
+
normalizeAiRequestTokens,
|
|
877
|
+
migrateLegacyCharsToTokens,
|
|
878
|
+
AI_MAX_TOOL_ITERATIONS_MIN,
|
|
879
|
+
AI_MAX_TOOL_ITERATIONS_MAX,
|
|
880
|
+
// 工作台任务执行器规范化(GUI 保存前也要用,见 /api/config/save-general-settings)
|
|
881
|
+
normalizeTaskExecutor,
|
|
882
|
+
TASK_EXECUTORS,
|
|
883
|
+
// 任务/对话结束提示三个通道的开关规范化(同上)。
|
|
884
|
+
// ⚠️ 新增规范化函数务必同步加进这个对象字面量 —— server 侧
|
|
885
|
+
// `import config from '../../config.js'` 拿的是**这个默认导出对象**,
|
|
886
|
+
// 只在 `export function` 里命名导出是不够的:漏加会变成 undefined 调用 → 路由 500,
|
|
887
|
+
// 而且因为有 try/catch 兜底,前端只会看到"保存失败",不知道是哪个函数缺了。
|
|
888
|
+
normalizeNotifySwitch,
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
// 命名导出 — 用于测试与外部复用
|
|
892
|
+
export {
|
|
893
|
+
ConfigWriteError,
|
|
894
|
+
normalizeProjectPath,
|
|
895
|
+
invalidateCurrentProjectKey,
|
|
896
|
+
invalidateRawConfigCache,
|
|
897
|
+
normalizeAiMaxToolIterations,
|
|
898
|
+
// 请求上下文预算的规范化(2026-10-07 字符口径 → token 口径)。
|
|
899
|
+
// 命名导出与 configManager 上的方法并存:路由层两种都要用 ——
|
|
900
|
+
// save-ai-settings 走 configManager.*,而 context.js 之外的地方按需import。
|
|
901
|
+
normalizeAiRequestTokens,
|
|
902
|
+
migrateLegacyCharsToTokens,
|
|
903
|
+
REQUEST_DEFAULT_MAX_TOKENS,
|
|
904
|
+
AI_MAX_TOOL_ITERATIONS_MIN,
|
|
905
|
+
AI_MAX_TOOL_ITERATIONS_MAX,
|
|
906
|
+
};
|