zen-gitsync 2.13.3 → 2.13.5
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 +4 -2
- package/package.json +1 -1
- package/src/ui/public/assets/EditorView-ClwvgXDr.js +0 -0
- package/src/ui/public/assets/SourceMapView-JtcpliBT.js +3 -0
- package/src/ui/public/assets/WorkbenchView-DbuvMS0c.css +1 -0
- package/src/ui/public/assets/WorkbenchView-N_UzEo17.js +6 -0
- package/src/ui/public/assets/{_plugin-vue_export-helper-K0RsuraF.js → _plugin-vue_export-helper-Bjo1gczS.js} +3 -3
- package/src/ui/public/assets/{index-DEEcURiV.css → index-CV4VteDa.css} +1 -1
- package/src/ui/public/assets/index-DYzZstrt.js +66 -0
- package/src/ui/public/assets/{vendor-DpYOE9sy.css → vendor-Bq2rS2vY.css} +1 -1
- package/src/ui/public/assets/{vendor-vsQ98rZk.js → vendor-C0_tGl5f.js} +242 -242
- package/src/ui/public/index.html +5 -5
- package/src/ui/server/routes/workbench.js +2056 -1702
- package/src/ui/public/assets/EditorView-9Q0gSlr6.js +0 -0
- package/src/ui/public/assets/SourceMapView-Dxmg8KPB.js +0 -3
- package/src/ui/public/assets/WorkbenchView-CYC_l23l.js +0 -2
- package/src/ui/public/assets/WorkbenchView-DauLIzog.css +0 -1
- package/src/ui/public/assets/index-CGnU7zDA.js +0 -66
|
@@ -1,1702 +1,2056 @@
|
|
|
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
|
-
// 工作台后端:管理预置提示词 / 任务 / 子任务,
|
|
16
|
-
// 并以 bypassPermissions 模式依次 spawn claude CLI 执行子任务(每次新窗口)。
|
|
17
|
-
// 数据存到用户主目录 ~/.zen-gitsync/,跨项目共享。
|
|
18
|
-
|
|
19
|
-
import fs from 'fs';
|
|
20
|
-
import fsp from 'fs/promises';
|
|
21
|
-
import path from 'path';
|
|
22
|
-
import os from 'os';
|
|
23
|
-
import { spawn, execFileSync, execFile } from 'child_process';
|
|
24
|
-
import { EventEmitter } from 'events';
|
|
25
|
-
import express from 'express';
|
|
26
|
-
|
|
27
|
-
const DATA_DIR = path.join(os.homedir(), '.zen-gitsync');
|
|
28
|
-
const PROMPTS_FILE = path.join(DATA_DIR, 'prompts.json');
|
|
29
|
-
const TASKS_FILE = path.join(DATA_DIR, 'tasks.json');
|
|
30
|
-
const IMAGES_DIR = path.join(DATA_DIR, 'workbench-images');
|
|
31
|
-
const INSTRUCTION_FILE = path.join(DATA_DIR, 'ai-instruction.json');
|
|
32
|
-
const SUBTASK_INSTRUCTION_FILE = path.join(DATA_DIR, 'ai-subtask-instruction.json');
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
const
|
|
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
|
-
function
|
|
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
|
-
const
|
|
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
|
-
const
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
//
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
-
const
|
|
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
|
-
}
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
//
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
{
|
|
976
|
-
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
//
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
: '';
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
await
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
const
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
const
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
{
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
'
|
|
1290
|
-
'
|
|
1291
|
-
'
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
}
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
try {
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
const
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
}
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
//
|
|
1471
|
-
app.
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
}
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
}
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
//
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
}
|
|
1532
|
-
}
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
}
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
}
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
}
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
const
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
if (
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
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
|
+
// 工作台后端:管理预置提示词 / 任务 / 子任务,
|
|
16
|
+
// 并以 bypassPermissions 模式依次 spawn claude CLI 执行子任务(每次新窗口)。
|
|
17
|
+
// 数据存到用户主目录 ~/.zen-gitsync/,跨项目共享。
|
|
18
|
+
|
|
19
|
+
import fs from 'fs';
|
|
20
|
+
import fsp from 'fs/promises';
|
|
21
|
+
import path from 'path';
|
|
22
|
+
import os from 'os';
|
|
23
|
+
import { spawn, execFileSync, execFile } from 'child_process';
|
|
24
|
+
import { EventEmitter } from 'events';
|
|
25
|
+
import express from 'express';
|
|
26
|
+
|
|
27
|
+
const DATA_DIR = path.join(os.homedir(), '.zen-gitsync');
|
|
28
|
+
const PROMPTS_FILE = path.join(DATA_DIR, 'prompts.json');
|
|
29
|
+
const TASKS_FILE = path.join(DATA_DIR, 'tasks.json');
|
|
30
|
+
const IMAGES_DIR = path.join(DATA_DIR, 'workbench-images');
|
|
31
|
+
const INSTRUCTION_FILE = path.join(DATA_DIR, 'ai-instruction.json');
|
|
32
|
+
const SUBTASK_INSTRUCTION_FILE = path.join(DATA_DIR, 'ai-subtask-instruction.json');
|
|
33
|
+
// 执行日志持久化:jobs.json 是历史档案,jobs-config.json 是保留策略。
|
|
34
|
+
// jobs Map 仍只承载当前进程产出的活跃 job;管理页直接读 jobs.json。
|
|
35
|
+
const JOBS_FILE = path.join(DATA_DIR, 'jobs.json');
|
|
36
|
+
const JOBS_CONFIG_FILE = path.join(DATA_DIR, 'jobs-config.json');
|
|
37
|
+
// 流式 chunk 写盘会撑爆 IO;用 1.5s debounce 把高频写入折叠成一次。
|
|
38
|
+
// 终态时由 flushJobsSaveNow() 强制立即落盘。
|
|
39
|
+
const JOBS_SAVE_DEBOUNCE_MS = 1500;
|
|
40
|
+
let jobsSaveTimer = null;
|
|
41
|
+
const DEFAULT_JOBS_CONFIG = { maxCount: 500, maxSizeMB: 256 };
|
|
42
|
+
|
|
43
|
+
// 子项目识别 / 文件扫描时需要跳过的目录
|
|
44
|
+
const SKIP_DIRS = new Set([
|
|
45
|
+
'node_modules', 'dist', 'build', '.next', '.nuxt', '__pycache__',
|
|
46
|
+
'target', 'out', 'coverage', 'vendor', '.git', '.svn', '.hg',
|
|
47
|
+
'.idea', '.vscode', '.gradle', '.terraform', '.cache', '.parcel-cache',
|
|
48
|
+
'.turbo', '.svelte-kit', 'storybook-static'
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
// 默认生成指令:用户首次使用时作为可编辑指令的初始值
|
|
52
|
+
const DEFAULT_INSTRUCTION = `你是一名资深软件架构师。
|
|
53
|
+
|
|
54
|
+
【探索步骤】
|
|
55
|
+
1. 先识别项目结构:扫描根目录是否包含 .git 目录,以及 package.json / pyproject.toml / go.mod / Cargo.toml / pom.xml / build.gradle{,.kts} / composer.json / Gemfile / pubspec.yaml 这 9 种 manifest。
|
|
56
|
+
2. 如果根目录含 manifest,就把整个根目录视为一个子项目。
|
|
57
|
+
3. 如果根目录不含 manifest、但子目录(含一层 .git 或上述 manifest)形成多个子项目,对每个子项目分别探索。
|
|
58
|
+
4. 对每个子项目,重点读取:
|
|
59
|
+
- 所有识别到的 manifest(限制单文件 20KB)
|
|
60
|
+
- README.md(限制 8KB)
|
|
61
|
+
- 入口文件:package.json 的 main / scripts / workspaces 字段;pyproject.toml 的 [project.scripts];go.mod 的 module;Cargo.toml 的 [[bin]];pom.xml 的 <modules>
|
|
62
|
+
- 2 层目录树(最多 200 行)
|
|
63
|
+
|
|
64
|
+
【输出要求】
|
|
65
|
+
1. 给出一段 400-800 字的中文「项目架构说明」,覆盖:项目整体定位、技术栈、模块划分、核心流程、关键设计决策。
|
|
66
|
+
2. 必须引用子项目里实际存在的文件路径、目录名、依赖名,不要编造。
|
|
67
|
+
3. 多个子项目时:先逐个说明,最后输出一段「整体架构」总结它们之间的关系。
|
|
68
|
+
4. 语气专业、具体、面向接手这个项目的开发者。
|
|
69
|
+
5. 只返回 JSON:{ "name": "项目名(10-20字)", "summary": "架构说明正文" }。`;
|
|
70
|
+
|
|
71
|
+
// 单个附件最大 5MB;与 Anthropic Messages API 文档约束一致
|
|
72
|
+
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
73
|
+
// 一个子任务最多挂 9 个附件
|
|
74
|
+
const MAX_ATTACHMENTS_PER_SUBTASK = 9;
|
|
75
|
+
|
|
76
|
+
// AI 拆分子任务:默认系统指令(用户在 GUI 可编辑覆盖)。
|
|
77
|
+
// 国际化:根据请求 Accept-Language 在 zh / en 之间选默认。
|
|
78
|
+
// 中英两份都内置——用户保存到 ~/.zen-gitsync/ai-subtask-instruction.json 后覆盖。
|
|
79
|
+
const DEFAULT_SUBTASK_INSTRUCTION_ZH = `你是一名任务拆分助手。
|
|
80
|
+
|
|
81
|
+
【思考过程】
|
|
82
|
+
在给出 JSON 之前,请先在内部仔细思考(如果模型支持,把思考放在 reasoning 中;否则可以先输出一段简短分析,再输出 JSON):
|
|
83
|
+
- 任务的真实目标是什么?用户提供的描述/图片/上下文里有哪些关键信息?
|
|
84
|
+
- 涉及哪些技术栈、模块、文件、约束?是否有易被忽略的边界条件?
|
|
85
|
+
- 自然的执行顺序是什么?哪些步骤是前置依赖?哪些可以并行?
|
|
86
|
+
- 哪些步骤可能失败、需要单独验证?
|
|
87
|
+
|
|
88
|
+
【拆分原则】
|
|
89
|
+
1. 单一职责:每个子任务只做一件事,避免"做 A 和 B"。
|
|
90
|
+
2. 粒度适中:单个子任务应当能在一次会话里完成(既不要"实现整个登录功能"这么大,也不要"打印 hello"这么琐碎)。
|
|
91
|
+
3. 顺序合理:子任务按依赖关系和执行顺序排列(先准备、后实现、最后验证)。
|
|
92
|
+
4. 可验证:每个子任务都有明确的完成标志("输出文件 xxx"、"通过测试 yyy"、"控制台打印 zzz")。
|
|
93
|
+
5. 数量:拆成 3-6 个子任务为宜。任务很简单时 2-3 个;复杂时 5-6 个,不要超过 8 个。
|
|
94
|
+
6. 描述具体:desc 字段要写清楚"要做什么、参考什么、产出什么",不要只是把 title 改写一遍。
|
|
95
|
+
7. 如果任务里附带了图片,必须基于图片的实际内容拆分(例如指出图片中的哪个区域、哪个元素需要改),而不是泛泛而谈。
|
|
96
|
+
|
|
97
|
+
【输出要求】
|
|
98
|
+
最后必须输出 JSON,结构:
|
|
99
|
+
{
|
|
100
|
+
"subtasks": [
|
|
101
|
+
{ "title": "子任务标题(10-20字)", "desc": "子任务的具体描述,包含要做什么、输入是什么、输出/验证标志是什么" }
|
|
102
|
+
]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
JSON 要用 \`\`\`json ... \`\`\` 代码块包裹,前面可以有分析文字,但 JSON 必须完整、合法、可解析。`;
|
|
106
|
+
|
|
107
|
+
const DEFAULT_SUBTASK_INSTRUCTION_EN = `You are a task breakdown assistant.
|
|
108
|
+
|
|
109
|
+
[Thinking process]
|
|
110
|
+
Before producing JSON, think carefully (put your thoughts in reasoning if the model supports it; otherwise output a short analysis first, then JSON):
|
|
111
|
+
- What is the real goal? What key information is in the description / images / context?
|
|
112
|
+
- Which stack, modules, files, constraints are involved? Any easily missed edge cases?
|
|
113
|
+
- What is the natural execution order? What blocks what? What can run in parallel?
|
|
114
|
+
- Which steps may fail and need separate verification?
|
|
115
|
+
|
|
116
|
+
[Breakdown principles]
|
|
117
|
+
1. Single responsibility: each subtask does only one thing, avoid bundling "do A and B".
|
|
118
|
+
2. Right granularity: a subtask should finish in one Claude session (not as big as "implement the whole login flow", not as trivial as "print hello").
|
|
119
|
+
3. Sensible order: arrange subtasks by dependency / execution order (prepare first, implement, then verify).
|
|
120
|
+
4. Verifiable: every subtask has a clear completion signal (e.g. "produce file xxx", "pass test yyy", "log zzz to console").
|
|
121
|
+
5. Quantity: prefer 3-6 subtasks. Very simple tasks 2-3, complex ones 5-6, never exceed 8.
|
|
122
|
+
6. Concrete desc: write what to do, what to reference, what to produce — don't just paraphrase the title.
|
|
123
|
+
7. If the task includes images, the breakdown must reference the actual image content (which region, which element to change), not just generic talk.
|
|
124
|
+
|
|
125
|
+
[Output requirements]
|
|
126
|
+
End with JSON, structure:
|
|
127
|
+
{
|
|
128
|
+
"subtasks": [
|
|
129
|
+
{ "title": "subtask title (10-20 chars)", "desc": "concrete description: what to do, what the input is, what the output / verification signal is" }
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
Wrap the JSON in \`\`\`json ... \`\`\`. Analysis text before it is allowed, but the JSON must be complete and parseable.`;
|
|
134
|
+
|
|
135
|
+
// 兼容旧引用
|
|
136
|
+
const DEFAULT_SUBTASK_INSTRUCTION = DEFAULT_SUBTASK_INSTRUCTION_ZH
|
|
137
|
+
|
|
138
|
+
// 根据请求 Accept-Language 选默认(zh / en)
|
|
139
|
+
function pickDefaultSubtaskInstruction(req) {
|
|
140
|
+
const al = String(req?.headers?.['accept-language'] || '').toLowerCase()
|
|
141
|
+
if (al.startsWith('en')) return DEFAULT_SUBTASK_INSTRUCTION_EN
|
|
142
|
+
return DEFAULT_SUBTASK_INSTRUCTION_ZH
|
|
143
|
+
}
|
|
144
|
+
// 白名单后缀:图片 + 常见文档(PDF / 纯文本 / Markdown)
|
|
145
|
+
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
|
|
146
|
+
const DOC_EXTS = new Set(['pdf', 'txt', 'md', 'markdown', 'csv', 'json', 'log']);
|
|
147
|
+
const ALLOWED_EXTS = new Set([...IMAGE_EXTS, ...DOC_EXTS]);
|
|
148
|
+
|
|
149
|
+
// mime → 文件后缀;与前端 el-upload accept 对齐
|
|
150
|
+
const MIME_TO_EXT = {
|
|
151
|
+
'image/png': 'png',
|
|
152
|
+
'image/jpeg': 'jpg',
|
|
153
|
+
'image/jpg': 'jpg',
|
|
154
|
+
'image/gif': 'gif',
|
|
155
|
+
'image/webp': 'webp',
|
|
156
|
+
'image/bmp': 'bmp',
|
|
157
|
+
'image/svg+xml': 'svg',
|
|
158
|
+
'image/x-icon': 'ico',
|
|
159
|
+
'image/vnd.microsoft.icon': 'ico',
|
|
160
|
+
'application/pdf': 'pdf',
|
|
161
|
+
'text/plain': 'txt',
|
|
162
|
+
'text/markdown': 'md',
|
|
163
|
+
'text/x-markdown': 'md',
|
|
164
|
+
'text/csv': 'csv',
|
|
165
|
+
'application/json': 'json',
|
|
166
|
+
'text/json': 'json',
|
|
167
|
+
'text/x-log': 'log',
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
function sanitizeExt(name, fallback = 'bin') {
|
|
171
|
+
if (typeof name !== 'string') return fallback;
|
|
172
|
+
const m = name.toLowerCase().match(/\.([a-z0-9]+)$/);
|
|
173
|
+
if (!m) return fallback;
|
|
174
|
+
return ALLOWED_EXTS.has(m[1]) ? m[1] : fallback;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isImageExt(ext) {
|
|
178
|
+
return IMAGE_EXTS.has(String(ext || '').toLowerCase());
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function ensureImagesDir() {
|
|
182
|
+
await fsp.mkdir(IMAGES_DIR, { recursive: true });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 把 mime 或文件名规范成统一后缀;遇到不在白名单的情况返回 null
|
|
186
|
+
function resolveExt({ originalName, mime }) {
|
|
187
|
+
if (mime && MIME_TO_EXT[mime.toLowerCase()]) {
|
|
188
|
+
return MIME_TO_EXT[mime.toLowerCase()];
|
|
189
|
+
}
|
|
190
|
+
const fromName = sanitizeExt(originalName, '');
|
|
191
|
+
if (fromName) return fromName;
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 解析 manifest 文件名(按优先级)
|
|
196
|
+
const MANIFEST_FILES = [
|
|
197
|
+
'package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml',
|
|
198
|
+
'pom.xml', 'build.gradle', 'build.gradle.kts', 'composer.json',
|
|
199
|
+
'Gemfile', 'pubspec.yaml'
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
// 轻量级:仅告诉 LLM 项目的主 manifest 是什么(用于 AI 拆分时让 LLM 知道项目类型)。
|
|
203
|
+
// 不读内容,只 stat 存在性——拆分子任务不需要细节。
|
|
204
|
+
async function detectProjectManifest(projectPath) {
|
|
205
|
+
if (!projectPath) return ''
|
|
206
|
+
for (const f of MANIFEST_FILES) {
|
|
207
|
+
try {
|
|
208
|
+
const stat = await fsp.stat(path.join(projectPath, f))
|
|
209
|
+
if (stat.isFile()) return f
|
|
210
|
+
} catch { /* 不存在,继续 */ }
|
|
211
|
+
}
|
|
212
|
+
return ''
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function readProjectManifest(projectPath) {
|
|
216
|
+
const out = {};
|
|
217
|
+
for (const f of MANIFEST_FILES) {
|
|
218
|
+
const p = path.join(projectPath, f);
|
|
219
|
+
try {
|
|
220
|
+
const stat = await fsp.stat(p);
|
|
221
|
+
if (!stat.isFile()) continue;
|
|
222
|
+
// 限制大小,避免巨型 pom.xml 把上下文打爆
|
|
223
|
+
const content = stat.size > 20000
|
|
224
|
+
? (await safeReadFile(p, 20000))
|
|
225
|
+
: (await fsp.readFile(p, 'utf8'));
|
|
226
|
+
out[f] = content;
|
|
227
|
+
} catch { /* 不存在就跳过 */ }
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function safeReadFile(filePath, maxBytes = 200000) {
|
|
233
|
+
try {
|
|
234
|
+
const stat = await fsp.stat(filePath);
|
|
235
|
+
if (stat.size > maxBytes) {
|
|
236
|
+
const buf = Buffer.alloc(maxBytes);
|
|
237
|
+
const fd = await fsp.open(filePath, 'r');
|
|
238
|
+
await fd.read(buf, 0, maxBytes, 0);
|
|
239
|
+
await fd.close();
|
|
240
|
+
return buf.toString('utf8').slice(0, maxBytes);
|
|
241
|
+
}
|
|
242
|
+
return await fsp.readFile(filePath, 'utf8');
|
|
243
|
+
} catch {
|
|
244
|
+
return '';
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function listDirTree(projectPath, maxDepth = 2, maxEntries = 400) {
|
|
249
|
+
const lines = [];
|
|
250
|
+
async function walk(dir, depth) {
|
|
251
|
+
if (depth > maxDepth || lines.length >= maxEntries) return;
|
|
252
|
+
let entries;
|
|
253
|
+
try { entries = await fsp.readdir(dir, { withFileTypes: true }); }
|
|
254
|
+
catch { return; }
|
|
255
|
+
const filtered = entries.filter(e => {
|
|
256
|
+
if (e.name.startsWith('.')) return false;
|
|
257
|
+
if (['node_modules', 'dist', 'build', '.next', '.nuxt', '__pycache__', 'target', 'out', 'coverage', 'vendor'].includes(e.name)) return false;
|
|
258
|
+
return true;
|
|
259
|
+
});
|
|
260
|
+
const indent = ' '.repeat(depth);
|
|
261
|
+
for (const e of filtered) {
|
|
262
|
+
if (lines.length >= maxEntries) return;
|
|
263
|
+
if (e.isDirectory()) {
|
|
264
|
+
lines.push(`${indent}${e.name}/`);
|
|
265
|
+
await walk(path.join(dir, e.name), depth + 1);
|
|
266
|
+
} else if (e.isFile()) {
|
|
267
|
+
lines.push(`${indent}${e.name}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
await walk(projectPath, 0);
|
|
272
|
+
return lines.join('\n');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function callLlmJson(model, prompt, opts = {}) {
|
|
276
|
+
const { maxTokens = 1500, timeoutMs = 60000, images = [] } = opts;
|
|
277
|
+
const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }));
|
|
278
|
+
const url = `${String(model.baseURL || '').replace(/\/$/, '')}/chat/completions`;
|
|
279
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
280
|
+
if (model.apiKey) headers['Authorization'] = `Bearer ${model.apiKey}`;
|
|
281
|
+
|
|
282
|
+
// 有图片时改用 OpenAI multimodal content 数组(text + image_url)。
|
|
283
|
+
// 非多模态模型遇到 image_url 会忽略图片块,相当于退化成纯文本,不会报错。
|
|
284
|
+
let userContent;
|
|
285
|
+
if (Array.isArray(images) && images.length > 0) {
|
|
286
|
+
userContent = [
|
|
287
|
+
{ type: 'text', text: prompt },
|
|
288
|
+
...images.map(img => ({ type: 'image_url', image_url: { url: img } }))
|
|
289
|
+
];
|
|
290
|
+
} else {
|
|
291
|
+
userContent = prompt;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const body = JSON.stringify({
|
|
295
|
+
model: model.model,
|
|
296
|
+
messages: [{ role: 'user', content: userContent }],
|
|
297
|
+
max_tokens: maxTokens,
|
|
298
|
+
temperature: 0.4,
|
|
299
|
+
response_format: { type: 'json_object' },
|
|
300
|
+
stream: false,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const controller = new AbortController();
|
|
304
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
305
|
+
try {
|
|
306
|
+
const resp = await fetch(url, { method: 'POST', headers, body, signal: controller.signal });
|
|
307
|
+
const data = await resp.json().catch(() => ({}));
|
|
308
|
+
if (!resp.ok) throw new Error(data?.error?.message || `HTTP ${resp.status}`);
|
|
309
|
+
const content = data?.choices?.[0]?.message?.content || '{}';
|
|
310
|
+
try {
|
|
311
|
+
const m = content.match(/```json\s*([\s\S]*?)```/) || content.match(/({[\s\S]*})/);
|
|
312
|
+
return JSON.parse(m ? m[1] : content);
|
|
313
|
+
} catch {
|
|
314
|
+
return {};
|
|
315
|
+
}
|
|
316
|
+
} finally {
|
|
317
|
+
clearTimeout(timer);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* 流式调用 OpenAI 兼容 LLM。每收到一个 chunk 调 onDelta 回调。
|
|
323
|
+
* onDelta 接收 { thinking?: string, content?: string },二选一。
|
|
324
|
+
* - reasoning_content / reasoning:部分模型(如 deepseek)放在 delta.reasoning_content
|
|
325
|
+
* - reasoning / reasoning_text:openai o1 风格
|
|
326
|
+
* - content:普通输出
|
|
327
|
+
* 返回完整 content 字符串。
|
|
328
|
+
*/
|
|
329
|
+
async function callLlmStream(model, prompt, onDelta, opts = {}) {
|
|
330
|
+
const { maxTokens = 2000, timeoutMs = 600000, signal, images = [] } = opts
|
|
331
|
+
const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }))
|
|
332
|
+
const url = `${String(model.baseURL || '').replace(/\/$/, '')}/chat/completions`
|
|
333
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
334
|
+
if (model.apiKey) headers['Authorization'] = `Bearer ${model.apiKey}`
|
|
335
|
+
|
|
336
|
+
let userContent
|
|
337
|
+
if (Array.isArray(images) && images.length > 0) {
|
|
338
|
+
userContent = [
|
|
339
|
+
{ type: 'text', text: prompt },
|
|
340
|
+
...images.map(img => ({ type: 'image_url', image_url: { url: img } }))
|
|
341
|
+
]
|
|
342
|
+
} else {
|
|
343
|
+
userContent = prompt
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const body = JSON.stringify({
|
|
347
|
+
model: model.model,
|
|
348
|
+
messages: [{ role: 'user', content: userContent }],
|
|
349
|
+
max_tokens: maxTokens,
|
|
350
|
+
temperature: 0.4,
|
|
351
|
+
// 注意:stream: true 模式下不能同时使用 response_format:{type:'json_object'},
|
|
352
|
+
// 部分 provider 会在收到两个一起时报错/静默卡住。改在 prompt 里约束 JSON 输出即可。
|
|
353
|
+
stream: true,
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
const controller = new AbortController()
|
|
357
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
358
|
+
// 允许外部 signal 触发取消(用户在 GUI 点停止)
|
|
359
|
+
const onAbort = () => controller.abort()
|
|
360
|
+
if (signal) {
|
|
361
|
+
if (signal.aborted) controller.abort()
|
|
362
|
+
else signal.addEventListener('abort', onAbort)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let fullContent = ''
|
|
366
|
+
let aborted = false
|
|
367
|
+
try {
|
|
368
|
+
const resp = await fetch(url, { method: 'POST', headers, body, signal: controller.signal })
|
|
369
|
+
if (!resp.ok || !resp.body) {
|
|
370
|
+
const errText = await resp.text().catch(() => '')
|
|
371
|
+
throw new Error(errText || `HTTP ${resp.status}`)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// SSE 格式:每行 "data: {...}",最后 "data: [DONE]"
|
|
375
|
+
const decoder = new TextDecoder('utf-8')
|
|
376
|
+
let buf = ''
|
|
377
|
+
for await (const chunk of resp.body) {
|
|
378
|
+
buf += decoder.decode(chunk, { stream: true })
|
|
379
|
+
const lines = buf.split('\n')
|
|
380
|
+
buf = lines.pop() ?? ''
|
|
381
|
+
for (const line of lines) {
|
|
382
|
+
const trimmed = line.trim()
|
|
383
|
+
if (!trimmed || !trimmed.startsWith('data:')) continue
|
|
384
|
+
const payload = trimmed.slice(5).trim()
|
|
385
|
+
if (payload === '[DONE]') continue
|
|
386
|
+
try {
|
|
387
|
+
const evt = JSON.parse(payload)
|
|
388
|
+
const delta = evt.choices?.[0]?.delta || {}
|
|
389
|
+
// thinking 在不同 provider 字段名不同,全部尝试
|
|
390
|
+
const thinkingChunk = delta.reasoning_content
|
|
391
|
+
|| delta.reasoning
|
|
392
|
+
|| delta.reasoning_text
|
|
393
|
+
|| ''
|
|
394
|
+
const contentChunk = delta.content || ''
|
|
395
|
+
if (thinkingChunk) onDelta({ thinking: thinkingChunk })
|
|
396
|
+
if (contentChunk) {
|
|
397
|
+
fullContent += contentChunk
|
|
398
|
+
onDelta({ content: contentChunk })
|
|
399
|
+
}
|
|
400
|
+
} catch { /* 跳过无法解析的行 */ }
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
} catch (err) {
|
|
404
|
+
if (err?.name === 'AbortError' || controller.signal.aborted) {
|
|
405
|
+
aborted = true
|
|
406
|
+
// 中断不算错误——上层会决定怎么处理
|
|
407
|
+
} else {
|
|
408
|
+
throw err
|
|
409
|
+
}
|
|
410
|
+
} finally {
|
|
411
|
+
clearTimeout(timer)
|
|
412
|
+
if (signal) signal.removeEventListener('abort', onAbort)
|
|
413
|
+
}
|
|
414
|
+
return { content: fullContent, aborted }
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function nowIso() {
|
|
418
|
+
return new Date().toISOString();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function genId() {
|
|
422
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function ensureDataDir() {
|
|
426
|
+
await fsp.mkdir(DATA_DIR, { recursive: true });
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function readJson(file, fallback) {
|
|
430
|
+
try {
|
|
431
|
+
const buf = await fsp.readFile(file, 'utf-8');
|
|
432
|
+
return JSON.parse(buf);
|
|
433
|
+
} catch (err) {
|
|
434
|
+
if (err && err.code === 'ENOENT') return fallback;
|
|
435
|
+
throw err;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function writeJson(file, data) {
|
|
440
|
+
await ensureDataDir();
|
|
441
|
+
const tmp = `${file}.tmp`;
|
|
442
|
+
await fsp.writeFile(tmp, JSON.stringify(data, null, 2), 'utf-8');
|
|
443
|
+
await fsp.rename(tmp, file);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ── 执行日志持久化 ────────────────────────────────────────────
|
|
447
|
+
// 设计要点:
|
|
448
|
+
// - 写盘在流式 chunk 阶段走 1.5s debounce;终态时(finally / cancel)强制 flush。
|
|
449
|
+
// - 永不持久化 child 引用(参考 cancel 路由的浅拷贝模式)。
|
|
450
|
+
// - hydrate 时把 running/pending 降级为 error:原 child 进程已不存在。
|
|
451
|
+
// - enforceRetention 在每次落盘后跑,按 endedAt desc FIFO 裁剪。
|
|
452
|
+
|
|
453
|
+
function serializeJob(j, taskMap) {
|
|
454
|
+
// child 是 ChildProcess 引用,序列化会爆;剥离后 size 用三字段累加预计算
|
|
455
|
+
const { child, ...rest } = j
|
|
456
|
+
const t = taskMap ? taskMap.get(rest.taskId) : null
|
|
457
|
+
const sub = t && Array.isArray(t.subtasks) ? t.subtasks.find(s => s.id === rest.subId) : null
|
|
458
|
+
const size = ((rest.prompt || '').length
|
|
459
|
+
+ (rest.output || '').length
|
|
460
|
+
+ (rest.thinking || '').length)
|
|
461
|
+
return {
|
|
462
|
+
...rest,
|
|
463
|
+
taskTitle: t ? t.title : '',
|
|
464
|
+
subTitle: sub ? sub.title : '',
|
|
465
|
+
size
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function scheduleJobsSave() {
|
|
470
|
+
if (jobsSaveTimer) clearTimeout(jobsSaveTimer)
|
|
471
|
+
jobsSaveTimer = setTimeout(() => {
|
|
472
|
+
jobsSaveTimer = null
|
|
473
|
+
flushJobsSaveNow().catch(err => console.warn('[workbench] jobs save failed:', err.message))
|
|
474
|
+
}, JOBS_SAVE_DEBOUNCE_MS)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function flushJobsSaveNow() {
|
|
478
|
+
if (jobsSaveTimer) { clearTimeout(jobsSaveTimer); jobsSaveTimer = null }
|
|
479
|
+
// 读 tasks.json 给落盘 job 反范式 taskTitle/subTitle——父任务被删后管理页仍可读
|
|
480
|
+
const tasksData = await readJson(TASKS_FILE, { tasks: [] })
|
|
481
|
+
const taskMap = new Map((tasksData.tasks || []).map(t => [t.id, t]))
|
|
482
|
+
const payload = {
|
|
483
|
+
version: 1,
|
|
484
|
+
jobs: Array.from(jobs.values()).map(j => serializeJob(j, taskMap))
|
|
485
|
+
}
|
|
486
|
+
await writeJson(JOBS_FILE, payload)
|
|
487
|
+
await enforceRetention()
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function readJobsConfig() {
|
|
491
|
+
const cfg = await readJson(JOBS_CONFIG_FILE, null)
|
|
492
|
+
if (!cfg || typeof cfg !== 'object') return { ...DEFAULT_JOBS_CONFIG }
|
|
493
|
+
return {
|
|
494
|
+
maxCount: Number.isFinite(cfg.maxCount) ? Math.max(0, Math.floor(cfg.maxCount)) : DEFAULT_JOBS_CONFIG.maxCount,
|
|
495
|
+
maxSizeMB: Number.isFinite(cfg.maxSizeMB) ? Math.max(0, Math.floor(cfg.maxSizeMB)) : DEFAULT_JOBS_CONFIG.maxSizeMB
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function writeJobsConfig(cfg) {
|
|
500
|
+
// 校验:非负整数;硬上限防误填爆盘
|
|
501
|
+
const out = {}
|
|
502
|
+
if (cfg.maxCount !== undefined) {
|
|
503
|
+
const n = Math.floor(Number(cfg.maxCount))
|
|
504
|
+
if (!Number.isFinite(n) || n < 0 || n > 10000) throw new Error('maxCount 必须在 0-10000 之间')
|
|
505
|
+
out.maxCount = n
|
|
506
|
+
}
|
|
507
|
+
if (cfg.maxSizeMB !== undefined) {
|
|
508
|
+
const n = Math.floor(Number(cfg.maxSizeMB))
|
|
509
|
+
if (!Number.isFinite(n) || n < 0 || n > 10240) throw new Error('maxSizeMB 必须在 0-10240 之间')
|
|
510
|
+
out.maxSizeMB = n
|
|
511
|
+
}
|
|
512
|
+
const merged = { ...(await readJobsConfig()), ...out }
|
|
513
|
+
await writeJson(JOBS_CONFIG_FILE, merged)
|
|
514
|
+
return merged
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// 进程启动时把磁盘上的历史拉回内存 Map;陈旧的 running/pending 强制降级。
|
|
518
|
+
// 陈旧 job 的 child 进程已退出,标记为 error 方便用户识别。
|
|
519
|
+
async function hydrateJobs() {
|
|
520
|
+
let data
|
|
521
|
+
try {
|
|
522
|
+
data = await readJson(JOBS_FILE, null)
|
|
523
|
+
} catch (err) {
|
|
524
|
+
// 损坏文件:改名备份避免下次 flush 静默覆盖用户数据
|
|
525
|
+
console.warn('[workbench] jobs.json 解析失败,备份原文件后重置:', err.message)
|
|
526
|
+
try { await fsp.rename(JOBS_FILE, `${JOBS_FILE}.bak-${Date.now()}`) } catch { /* 文件可能已不在 */ }
|
|
527
|
+
return
|
|
528
|
+
}
|
|
529
|
+
if (!data || !Array.isArray(data.jobs)) return
|
|
530
|
+
for (const j of data.jobs) {
|
|
531
|
+
if (j.status === 'running' || j.status === 'pending') {
|
|
532
|
+
j.status = 'error'
|
|
533
|
+
j.error = (j.error || '') + ' [重启后回收:原进程已退出]'
|
|
534
|
+
j.endedAt = j.endedAt || nowIso()
|
|
535
|
+
j.exitCode = typeof j.exitCode === 'number' ? j.exitCode : 1
|
|
536
|
+
}
|
|
537
|
+
// 旧版本可能没 size 字段;补齐以兼容历史文件
|
|
538
|
+
if (typeof j.size !== 'number') {
|
|
539
|
+
j.size = ((j.prompt || '').length + (j.output || '').length + (j.thinking || '').length)
|
|
540
|
+
}
|
|
541
|
+
jobs.set(j.id, j)
|
|
542
|
+
}
|
|
543
|
+
// 启动后也跑一遍保留策略,让历史文件立刻缩到当前配置
|
|
544
|
+
try { await enforceRetention() } catch (err) { console.warn('[workbench] 启动时 enforceRetention 失败:', err.message) }
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// 保留策略:按 endedAt desc(fallback startedAt / id)排序,先按 maxCount 截,
|
|
548
|
+
// 再按 maxSizeMB 累计裁,淘汰同步从内存 Map 删除。
|
|
549
|
+
async function enforceRetention() {
|
|
550
|
+
const cfg = await readJobsConfig()
|
|
551
|
+
const data = await readJson(JOBS_FILE, { version: 1, jobs: [] })
|
|
552
|
+
if (!data || !Array.isArray(data.jobs) || data.jobs.length === 0) return
|
|
553
|
+
const sortKey = (j) => j.endedAt || j.startedAt || j.id || ''
|
|
554
|
+
data.jobs.sort((a, b) => sortKey(b).localeCompare(sortKey(a)))
|
|
555
|
+
if (cfg.maxCount > 0) data.jobs = data.jobs.slice(0, cfg.maxCount)
|
|
556
|
+
if (cfg.maxSizeMB > 0) {
|
|
557
|
+
const cap = cfg.maxSizeMB * 1024 * 1024
|
|
558
|
+
let total = data.jobs.reduce((s, j) => s + (j.size || 0), 0)
|
|
559
|
+
while (total > cap && data.jobs.length > 1) {
|
|
560
|
+
const dropped = data.jobs.pop()
|
|
561
|
+
total -= (dropped && dropped.size) || 0
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
await writeJson(JOBS_FILE, data)
|
|
565
|
+
const keepIds = new Set(data.jobs.map(j => j.id))
|
|
566
|
+
for (const id of Array.from(jobs.keys())) {
|
|
567
|
+
if (!keepIds.has(id)) jobs.delete(id)
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// 简单的 Mustache 风格变量插值:{{task.title}} / {{task.desc}} / {{repo.path}} / {{branch}}
|
|
572
|
+
function interpolate(template, ctx) {
|
|
573
|
+
if (typeof template !== 'string') return template;
|
|
574
|
+
return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, key) => {
|
|
575
|
+
const parts = key.split('.');
|
|
576
|
+
let cur = ctx;
|
|
577
|
+
for (const p of parts) {
|
|
578
|
+
if (cur == null) return '';
|
|
579
|
+
cur = cur[p];
|
|
580
|
+
}
|
|
581
|
+
return cur == null ? '' : String(cur);
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ── 进程表:记录每个子任务的运行状态 ──────────────────────────────────────
|
|
586
|
+
const bus = new EventEmitter();
|
|
587
|
+
const jobs = new Map(); // jobId -> { id, taskId, subId, status, pid, startedAt, endedAt, exitCode, error, prompt }
|
|
588
|
+
// 被用户主动取消的 jobId 集合——runTaskQueue 在 waitProcessExit 之后检查这个集合
|
|
589
|
+
// 来决定把 job 标为 'cancelled' 还是 'done'。
|
|
590
|
+
// 用 Set 而不是 job.cancelled 标志,是为了在 SIGTERM 发出后到 child 真正退出之间
|
|
591
|
+
// 有一个简洁的"待回收"窗口。
|
|
592
|
+
const cancelledJobs = new Set();
|
|
593
|
+
// 启动时从磁盘拉回历史 job(陈旧 running/pending 自动降级 error)
|
|
594
|
+
hydrateJobs().catch(err => console.warn('[workbench] hydrate jobs failed:', err.message))
|
|
595
|
+
|
|
596
|
+
// ── 生成指令持久化(~/.zen-gitsync/ai-instruction.json) ────────────────────
|
|
597
|
+
async function readInstruction() {
|
|
598
|
+
try {
|
|
599
|
+
const buf = await fsp.readFile(INSTRUCTION_FILE, 'utf-8');
|
|
600
|
+
const obj = JSON.parse(buf);
|
|
601
|
+
if (obj && typeof obj.instruction === 'string' && obj.instruction.trim()) {
|
|
602
|
+
return obj.instruction;
|
|
603
|
+
}
|
|
604
|
+
} catch { /* 文件不存在或解析失败 */ }
|
|
605
|
+
return DEFAULT_INSTRUCTION;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async function writeInstruction(instruction) {
|
|
609
|
+
await ensureDataDir();
|
|
610
|
+
const text = String(instruction || '').trim() || DEFAULT_INSTRUCTION;
|
|
611
|
+
const tmp = `${INSTRUCTION_FILE}.tmp`;
|
|
612
|
+
await fsp.writeFile(tmp, JSON.stringify({ instruction: text, updatedAt: nowIso() }, null, 2), 'utf-8');
|
|
613
|
+
await fsp.rename(tmp, INSTRUCTION_FILE);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// AI 拆分子任务的指令:与生成项目架构说明的指令分开持久化,
|
|
617
|
+
// 因为它面向的输出形态(subtask 列表)和任务粒度完全不同。
|
|
618
|
+
// 支持传入 req:根据 Accept-Language 选默认(zh/en)
|
|
619
|
+
async function readSubtaskInstruction(req) {
|
|
620
|
+
try {
|
|
621
|
+
const buf = await fsp.readFile(SUBTASK_INSTRUCTION_FILE, 'utf-8');
|
|
622
|
+
const obj = JSON.parse(buf);
|
|
623
|
+
if (obj && typeof obj.instruction === 'string' && obj.instruction.trim()) {
|
|
624
|
+
return obj.instruction;
|
|
625
|
+
}
|
|
626
|
+
} catch { /* 文件不存在或解析失败 */ }
|
|
627
|
+
return pickDefaultSubtaskInstruction(req);
|
|
628
|
+
}
|
|
629
|
+
async function writeSubtaskInstruction(instruction) {
|
|
630
|
+
await ensureDataDir();
|
|
631
|
+
// 写入时如果与当前 locale 默认一致,不写文件——这样前端"isDefault"判定永远准确
|
|
632
|
+
const text = String(instruction || '').trim() || DEFAULT_SUBTASK_INSTRUCTION_ZH;
|
|
633
|
+
const tmp = `${SUBTASK_INSTRUCTION_FILE}.tmp`;
|
|
634
|
+
await fsp.writeFile(tmp, JSON.stringify({ instruction: text, updatedAt: nowIso() }, null, 2), 'utf-8');
|
|
635
|
+
await fsp.rename(tmp, SUBTASK_INSTRUCTION_FILE);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// ── 子项目识别:递归找 .git / manifest;A 是 B 的祖先时只保留 B ─────────────
|
|
639
|
+
async function findSubProjects(projectPath, opts = {}) {
|
|
640
|
+
const { maxDepth = 4 } = opts;
|
|
641
|
+
const candidates = [];
|
|
642
|
+
|
|
643
|
+
async function walk(dir, depth) {
|
|
644
|
+
if (depth > maxDepth) return;
|
|
645
|
+
let entries;
|
|
646
|
+
try { entries = await fsp.readdir(dir, { withFileTypes: true }); }
|
|
647
|
+
catch { return; }
|
|
648
|
+
|
|
649
|
+
let hasManifest = false;
|
|
650
|
+
let hasGit = false;
|
|
651
|
+
const subDirs = [];
|
|
652
|
+
for (const e of entries) {
|
|
653
|
+
if (!e.isDirectory() && !e.isFile()) continue;
|
|
654
|
+
if (e.name.startsWith('.')) {
|
|
655
|
+
if (e.name === '.git' && e.isDirectory()) hasGit = true;
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
if (e.isDirectory()) {
|
|
659
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
660
|
+
subDirs.push(path.join(dir, e.name));
|
|
661
|
+
} else if (e.isFile() && MANIFEST_FILES.includes(e.name)) {
|
|
662
|
+
hasManifest = true;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
if (hasManifest || hasGit) {
|
|
666
|
+
candidates.push(dir);
|
|
667
|
+
return; // 子目录里若还有 manifest,会被自己发现;这里不再下钻避免冗余
|
|
668
|
+
}
|
|
669
|
+
if (depth >= maxDepth) return;
|
|
670
|
+
for (const sub of subDirs) {
|
|
671
|
+
await walk(sub, depth + 1);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
await walk(projectPath, 0);
|
|
676
|
+
|
|
677
|
+
// 去重:若 candidates 里 A 是 B 的祖先,只保留更深一级的 B
|
|
678
|
+
candidates.sort((a, b) => a.length - b.length);
|
|
679
|
+
const kept = [];
|
|
680
|
+
for (const c of candidates) {
|
|
681
|
+
let dominated = false;
|
|
682
|
+
for (const k of kept) {
|
|
683
|
+
if (c === k || c.startsWith(k + path.sep)) { dominated = true; break; }
|
|
684
|
+
}
|
|
685
|
+
if (!dominated) kept.push(c);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// 收集每个子项目的关键文件
|
|
689
|
+
const result = [];
|
|
690
|
+
for (const root of kept) {
|
|
691
|
+
const manifests = {};
|
|
692
|
+
for (const m of MANIFEST_FILES) {
|
|
693
|
+
const p = path.join(root, m);
|
|
694
|
+
try {
|
|
695
|
+
const stat = await fsp.stat(p);
|
|
696
|
+
if (stat.isFile()) {
|
|
697
|
+
manifests[m] = stat.size > 20000
|
|
698
|
+
? await safeReadFile(p, 20000)
|
|
699
|
+
: await fsp.readFile(p, 'utf8');
|
|
700
|
+
}
|
|
701
|
+
} catch { /* 不存在就跳过 */ }
|
|
702
|
+
}
|
|
703
|
+
let readme = '';
|
|
704
|
+
try {
|
|
705
|
+
const stat = await fsp.stat(path.join(root, 'README.md'));
|
|
706
|
+
if (stat.isFile()) readme = await safeReadFile(path.join(root, 'README.md'), 8000);
|
|
707
|
+
} catch { /* 不存在就跳过 */ }
|
|
708
|
+
const dirTree = await listDirTree(root, 2, 200);
|
|
709
|
+
result.push({
|
|
710
|
+
root,
|
|
711
|
+
name: path.basename(root) || path.basename(projectPath),
|
|
712
|
+
manifests,
|
|
713
|
+
readme,
|
|
714
|
+
dirTree
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
return result;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function publish(event, payload) {
|
|
721
|
+
bus.emit('event', { event, payload, ts: nowIso() });
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function snapshotJobs() {
|
|
725
|
+
return Array.from(jobs.values()).map(j => ({
|
|
726
|
+
id: j.id,
|
|
727
|
+
taskId: j.taskId,
|
|
728
|
+
subId: j.subId,
|
|
729
|
+
title: j.title,
|
|
730
|
+
status: j.status,
|
|
731
|
+
prompt: j.prompt || '',
|
|
732
|
+
output: j.output || '',
|
|
733
|
+
pid: j.pid || null,
|
|
734
|
+
startedAt: j.startedAt || null,
|
|
735
|
+
endedAt: j.endedAt || null,
|
|
736
|
+
exitCode: typeof j.exitCode === 'number' ? j.exitCode : null,
|
|
737
|
+
error: j.error || null
|
|
738
|
+
}));
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// 用 detached 进程跑 claude;进程退出时回填状态。
|
|
742
|
+
// 返回 { pid, child }:调用方可以监听 child.stdout/stderr 实时收集输出。
|
|
743
|
+
// 不再走 cmd /k 弹窗——claude -p 是非交互模式,输出通过 stdout pipe 实时回传
|
|
744
|
+
// 到前端面板展示。
|
|
745
|
+
function launchClaudeInNewWindow(cwd, promptText) {
|
|
746
|
+
return new Promise((resolve, reject) => {
|
|
747
|
+
const args = [
|
|
748
|
+
'-p', promptText,
|
|
749
|
+
'--input-format', 'text',
|
|
750
|
+
'--output-format', 'stream-json',
|
|
751
|
+
'--verbose',
|
|
752
|
+
'--permission-mode', 'bypassPermissions',
|
|
753
|
+
'--dangerously-skip-permissions'
|
|
754
|
+
];
|
|
755
|
+
let child;
|
|
756
|
+
let spawnedExe = 'claude';
|
|
757
|
+
if (process.platform === 'win32') {
|
|
758
|
+
// 直接 spawn claude.exe(npm 全局 @anthropic-ai/claude-code 里的真实二进制),
|
|
759
|
+
// 避开两件事:
|
|
760
|
+
// 1. Node 23 在 Windows 上拒绝 spawn .cmd/.bat(EINVAL)
|
|
761
|
+
// 2. shell:true 会把 argv 拼成命令行交给 cmd 解释,prompt 里的 \n 被切成多段
|
|
762
|
+
// 用 `where claude` 找到 claude.cmd,再从 cmd 内容推断对应 .exe 路径。
|
|
763
|
+
let claudeExe = 'claude.exe';
|
|
764
|
+
try {
|
|
765
|
+
const cmdShim = execFileSync('where', ['claude'], { encoding: 'utf8' })
|
|
766
|
+
.split(/\r?\n/).map(s => s.trim()).find(s => /\.cmd$/i.test(s));
|
|
767
|
+
if (cmdShim) {
|
|
768
|
+
const txt = fs.readFileSync(cmdShim, 'utf8');
|
|
769
|
+
if (/%dp0%\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude\.exe/i.test(txt)) {
|
|
770
|
+
claudeExe = path.join(path.dirname(cmdShim), 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe');
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
} catch { /* fallback */ }
|
|
774
|
+
spawnedExe = claudeExe;
|
|
775
|
+
child = spawn(claudeExe, args, {
|
|
776
|
+
cwd,
|
|
777
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
778
|
+
windowsHide: false,
|
|
779
|
+
env: { ...process.env, LANG: 'zh_CN.UTF-8' }
|
|
780
|
+
});
|
|
781
|
+
} else {
|
|
782
|
+
// macOS / Linux:直接 spawn claude(Node spawn 不走 shell,
|
|
783
|
+
// prompt 中的引号 / 反斜杠无需手动 escape)
|
|
784
|
+
child = spawn('claude', args, {
|
|
785
|
+
cwd,
|
|
786
|
+
detached: true,
|
|
787
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
788
|
+
env: { ...process.env, LANG: 'zh_CN.UTF-8' }
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
child.on('error', reject);
|
|
792
|
+
child.on('spawn', () => {
|
|
793
|
+
// unref 让 claude 独立于父进程事件循环;返回 child 引用让调用方继续读 stdout。
|
|
794
|
+
child.unref();
|
|
795
|
+
resolve({ pid: child.pid, child });
|
|
796
|
+
});
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// 顺序执行一个任务下所有子任务;上一个结束再启动下一个
|
|
801
|
+
async function runTaskQueue(task, repoPath, branch) {
|
|
802
|
+
// 前序上下文:跑完一个 sub 后把它"完成态"摘要存到这里,下一个 sub 启动时
|
|
803
|
+
// 拼到 prompt 头部,让 Claude 知道前面做了什么、产出了什么。
|
|
804
|
+
// 故意不用 raw output 全文——LLM 已经习惯"摘要 + 关键结论"的格式,且不会
|
|
805
|
+
// 一次塞几 MB 进 prompt 烧 token。truncate 到每条 MAX_PREV_OUTPUT_CHARS。
|
|
806
|
+
const MAX_PREV_OUTPUT_CHARS = 2000
|
|
807
|
+
const priorOutputs = []
|
|
808
|
+
for (const sub of task.subtasks) {
|
|
809
|
+
if (sub.status === 'done') continue;
|
|
810
|
+
const promptTemplate = sub.promptOverride || (task.promptId
|
|
811
|
+
? (await readJson(PROMPTS_FILE, { prompts: [] })).prompts.find(p => p.id === task.promptId)?.content
|
|
812
|
+
: null) || '';
|
|
813
|
+
const ctx = {
|
|
814
|
+
task: { title: task.title, desc: task.desc || '' },
|
|
815
|
+
sub: { title: sub.title, desc: sub.desc || '' },
|
|
816
|
+
repo: { path: repoPath || '' },
|
|
817
|
+
branch: branch || ''
|
|
818
|
+
};
|
|
819
|
+
const interpolated = interpolate(promptTemplate, ctx);
|
|
820
|
+
const parts = [interpolated, sub.title, sub.desc].filter(s => s && s.trim());
|
|
821
|
+
let prompt = parts.join('\n\n');
|
|
822
|
+
|
|
823
|
+
// ── 前序上下文:把前几个 done 子任务的输出摘要拼到 prompt 头部 ──
|
|
824
|
+
if (priorOutputs.length > 0) {
|
|
825
|
+
const prevBlock = priorOutputs.map((p, i) => {
|
|
826
|
+
const text = (p.output || '').slice(0, MAX_PREV_OUTPUT_CHARS)
|
|
827
|
+
const truncated = (p.output || '').length > MAX_PREV_OUTPUT_CHARS ? '\n…(前文已截断)' : ''
|
|
828
|
+
return `### [${i + 1}] ${p.title}\n${text}${truncated}`
|
|
829
|
+
}).join('\n\n')
|
|
830
|
+
prompt = `以下是同一任务下已经完成的前序子任务输出(仅作上下文参考,请基于这些结论继续当前子任务,无需重复执行它们):
|
|
831
|
+
|
|
832
|
+
${prevBlock}
|
|
833
|
+
|
|
834
|
+
---
|
|
835
|
+
|
|
836
|
+
${prompt}`
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// ── 附件:合并 sub.attachments + task.attachments 后拼到 prompt 末尾 ──
|
|
840
|
+
// claude -p 字符串模式会扫描 prompt 中出现的本地文件路径并自动
|
|
841
|
+
// 识别为附件(图片 / PDF / 文本均可)。
|
|
842
|
+
// 主任务附件对所有 sub 都可见;子任务自己的附件只对该 sub 可见。
|
|
843
|
+
const allAttachments = [
|
|
844
|
+
...(Array.isArray(task.attachments) ? task.attachments : []),
|
|
845
|
+
...(Array.isArray(sub.attachments) ? sub.attachments : [])
|
|
846
|
+
];
|
|
847
|
+
if (allAttachments.length > 0) {
|
|
848
|
+
const lines = allAttachments
|
|
849
|
+
.filter(a => a && a.absolutePath)
|
|
850
|
+
.map((a, i) => ` ${i + 1}. [${a.mimeType || 'application/octet-stream'}] ${a.absolutePath}`);
|
|
851
|
+
if (lines.length > 0) {
|
|
852
|
+
prompt += `\n\n---\n本任务包含 ${lines.length} 个附件(请按文件路径读取,不要让用户重新提供):\n${lines.join('\n')}\n---`;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const jobId = genId();
|
|
857
|
+
const job = {
|
|
858
|
+
id: jobId,
|
|
859
|
+
taskId: task.id,
|
|
860
|
+
subId: sub.id,
|
|
861
|
+
title: `${task.title} / ${sub.title}`,
|
|
862
|
+
status: 'pending',
|
|
863
|
+
prompt
|
|
864
|
+
};
|
|
865
|
+
jobs.set(jobId, job);
|
|
866
|
+
sub.status = 'running';
|
|
867
|
+
publish('sub:update', { taskId: task.id, sub });
|
|
868
|
+
publish('job:update', job);
|
|
869
|
+
|
|
870
|
+
try {
|
|
871
|
+
const { pid, child } = await launchClaudeInNewWindow(repoPath || process.cwd(), prompt);
|
|
872
|
+
job.pid = pid;
|
|
873
|
+
// 保存 child 引用,供 cancel 接口调用 kill
|
|
874
|
+
job.child = child;
|
|
875
|
+
job.startedAt = nowIso();
|
|
876
|
+
job.status = 'running';
|
|
877
|
+
publish('job:update', job);
|
|
878
|
+
|
|
879
|
+
// 流式 NDJSON 解析:把 stdout 当作 stream-json 协议处理
|
|
880
|
+
// assistant.text → job.output (用户主要关心的内容)
|
|
881
|
+
// assistant.thinking → job.thinking (折叠展示,让用户知道 Claude 在想)
|
|
882
|
+
// 其他事件(init / tool_use / result 等)忽略,避免噪声
|
|
883
|
+
// 解析失败的行原样进 output,便于排查协议异常。
|
|
884
|
+
// thinking 几乎不做服务端截断:Claude reasoning 一般在 KB~几十 MB 之间,
|
|
885
|
+
// 100MB 兜底只是为了防止内存爆炸。流式 publish 时改推增量 delta(仅新拼接的
|
|
886
|
+
// 那部分),终态或重连时才随 job:update 全量同步,避免每帧重复广播整个累积文本。
|
|
887
|
+
const MAX_OUTPUT = 100 * 1024 * 1024;
|
|
888
|
+
const MAX_THINKING = 100 * 1024 * 1024;
|
|
889
|
+
job.output = '';
|
|
890
|
+
job.thinking = '';
|
|
891
|
+
const lineBuf = { stdout: '', stderr: '' };
|
|
892
|
+
|
|
893
|
+
const parseLines = (channel, buf) => {
|
|
894
|
+
const chunk = buf.toString('utf8');
|
|
895
|
+
lineBuf[channel] += chunk;
|
|
896
|
+
const lines = lineBuf[channel].split('\n');
|
|
897
|
+
lineBuf[channel] = lines.pop() ?? ''; // 最后一段可能不完整,留给下次
|
|
898
|
+
let pendingThinkingDelta = '';
|
|
899
|
+
for (const line of lines) {
|
|
900
|
+
const trimmed = line.trim();
|
|
901
|
+
if (!trimmed) continue;
|
|
902
|
+
if (channel === 'stderr' || !trimmed.startsWith('{')) {
|
|
903
|
+
// 非 stream-json 行:原样塞进 output(兼容老版本 claude / 错误信息)
|
|
904
|
+
const prevLen = job.output.length;
|
|
905
|
+
job.output = (job.output + trimmed + '\n').slice(-MAX_OUTPUT);
|
|
906
|
+
// output 也用 delta 推送,前端按"以 length 为锚追加"语义合并
|
|
907
|
+
const delta = job.output.slice(prevLen);
|
|
908
|
+
if (delta) publish('job:output-delta', { id: job.id, delta });
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
let evt;
|
|
912
|
+
try { evt = JSON.parse(trimmed) } catch { continue }
|
|
913
|
+
if (evt.type !== 'assistant') continue;
|
|
914
|
+
const blocks = evt.message?.content;
|
|
915
|
+
if (!Array.isArray(blocks)) continue;
|
|
916
|
+
for (const b of blocks) {
|
|
917
|
+
if (b.type === 'text' && typeof b.text === 'string') {
|
|
918
|
+
const prevLen = job.output.length;
|
|
919
|
+
job.output = (job.output + b.text).slice(-MAX_OUTPUT);
|
|
920
|
+
const delta = job.output.slice(prevLen);
|
|
921
|
+
if (delta) publish('job:output-delta', { id: job.id, delta });
|
|
922
|
+
} else if (b.type === 'thinking' && typeof b.thinking === 'string') {
|
|
923
|
+
const prevLen = job.thinking.length;
|
|
924
|
+
job.thinking = (job.thinking + b.thinking).slice(-MAX_THINKING);
|
|
925
|
+
const delta = job.thinking.slice(prevLen);
|
|
926
|
+
if (delta) pendingThinkingDelta += delta;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
// 一批 NDJSON 处理完后统一发一次 thinking delta,避免高频小块 socket 占用
|
|
931
|
+
if (pendingThinkingDelta) {
|
|
932
|
+
publish('job:thinking-delta', { id: job.id, delta: pendingThinkingDelta });
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
if (child.stdout) child.stdout.on('data', (buf) => parseLines('stdout', buf));
|
|
936
|
+
if (child.stderr) child.stderr.on('data', (buf) => parseLines('stderr', buf));
|
|
937
|
+
|
|
938
|
+
// 等待进程退出(detached 不阻塞主进程,用 polling /proc 兜底)
|
|
939
|
+
await waitProcessExit(pid);
|
|
940
|
+
const wasCancelled = cancelledJobs.has(jobId)
|
|
941
|
+
if (wasCancelled) cancelledJobs.delete(jobId)
|
|
942
|
+
// 进程退出时 stdout 可能残留最后一段未换行的 NDJSON,flush 一次
|
|
943
|
+
// flush 内部也按 delta 推送,保持与流式阶段一致
|
|
944
|
+
if (lineBuf.stdout.trim()) {
|
|
945
|
+
const outPrev = job.output.length;
|
|
946
|
+
const thinkPrev = job.thinking.length;
|
|
947
|
+
try {
|
|
948
|
+
const evt = JSON.parse(lineBuf.stdout.trim())
|
|
949
|
+
if (evt.type === 'assistant' && Array.isArray(evt.message?.content)) {
|
|
950
|
+
for (const b of evt.message.content) {
|
|
951
|
+
if (b.type === 'text' && typeof b.text === 'string') {
|
|
952
|
+
job.output = (job.output + b.text).slice(-MAX_OUTPUT)
|
|
953
|
+
} else if (b.type === 'thinking' && typeof b.thinking === 'string') {
|
|
954
|
+
job.thinking = (job.thinking + b.thinking).slice(-MAX_THINKING)
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
} catch { /* 不是 JSON,忽略 */ }
|
|
959
|
+
const outDelta = job.output.slice(outPrev);
|
|
960
|
+
if (outDelta) publish('job:output-delta', { id: job.id, delta: outDelta });
|
|
961
|
+
const thinkDelta = job.thinking.slice(thinkPrev);
|
|
962
|
+
if (thinkDelta) publish('job:thinking-delta', { id: job.id, delta: thinkDelta });
|
|
963
|
+
}
|
|
964
|
+
job.endedAt = nowIso();
|
|
965
|
+
if (wasCancelled) {
|
|
966
|
+
job.exitCode = 130; // 128 + SIGINT(2),约定俗成的"用户取消"退出码
|
|
967
|
+
job.status = 'cancelled';
|
|
968
|
+
job.error = '用户已停止执行';
|
|
969
|
+
// sub 不改状态——cancelled 是 job 维度,同 task 后续 sub 仍可继续执行
|
|
970
|
+
} else {
|
|
971
|
+
job.exitCode = 0;
|
|
972
|
+
job.status = 'done';
|
|
973
|
+
sub.status = 'done';
|
|
974
|
+
// 把这个 sub 的输出累积到前序上下文,喂给下一个 sub
|
|
975
|
+
priorOutputs.push({ title: sub.title, output: job.output || '' })
|
|
976
|
+
}
|
|
977
|
+
} catch (err) {
|
|
978
|
+
job.error = err && err.message ? err.message : String(err);
|
|
979
|
+
job.status = 'error';
|
|
980
|
+
sub.status = 'error';
|
|
981
|
+
} finally {
|
|
982
|
+
// 移除 child 引用——避免后续被 SSE 序列化到前端
|
|
983
|
+
delete job.child
|
|
984
|
+
publish('job:update', job);
|
|
985
|
+
publish('sub:update', { taskId: task.id, sub });
|
|
986
|
+
// 终态:fire-and-forget 同步落盘,确保 done/cancelled/error 都立即归档
|
|
987
|
+
flushJobsSaveNow().catch(err => console.warn('[workbench] jobs save failed:', err.message))
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
// 写回 tasks.json
|
|
991
|
+
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
992
|
+
const t = data.tasks.find(x => x.id === task.id);
|
|
993
|
+
if (t) {
|
|
994
|
+
t.subtasks = task.subtasks;
|
|
995
|
+
t.updatedAt = nowIso();
|
|
996
|
+
await writeJson(TASKS_FILE, data);
|
|
997
|
+
publish('task:update', t);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function waitProcessExit(pid) {
|
|
1002
|
+
return new Promise(resolve => {
|
|
1003
|
+
let exited = false;
|
|
1004
|
+
const tryCheck = () => {
|
|
1005
|
+
if (exited) return;
|
|
1006
|
+
try {
|
|
1007
|
+
process.kill(pid, 0); // 信号 0 = 探测存活
|
|
1008
|
+
} catch (err) {
|
|
1009
|
+
// 只在进程真的消失(ESRCH / EPERM)时才 resolve;
|
|
1010
|
+
// 其他错误(比如参数类型)保留 polling 状态,由超时兜底。
|
|
1011
|
+
if (err && (err.code === 'ESRCH' || err.code === 'EPERM')) {
|
|
1012
|
+
exited = true;
|
|
1013
|
+
resolve();
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
setTimeout(tryCheck, 1500);
|
|
1018
|
+
};
|
|
1019
|
+
tryCheck();
|
|
1020
|
+
// 兜底:30 分钟超时自动结束
|
|
1021
|
+
setTimeout(() => { if (!exited) { exited = true; resolve(); } }, 30 * 60 * 1000);
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
export function registerWorkbenchRoutes({ app, getCurrentProjectPath, getProjectRoomId, io, configManager }) {
|
|
1026
|
+
// ── AI 生成提示词(基于当前项目) ─────────────────────────────────────
|
|
1027
|
+
app.post('/api/workbench/prompts/ai-generate', async (req, res) => {
|
|
1028
|
+
try {
|
|
1029
|
+
const projectPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
|
|
1030
|
+
if (!projectPath) {
|
|
1031
|
+
return res.status(400).json({ success: false, error: '未选中项目' });
|
|
1032
|
+
}
|
|
1033
|
+
let stat;
|
|
1034
|
+
try { stat = await fsp.stat(projectPath); }
|
|
1035
|
+
catch { return res.status(400).json({ success: false, error: '项目路径不存在' }); }
|
|
1036
|
+
if (!stat.isDirectory()) {
|
|
1037
|
+
return res.status(400).json({ success: false, error: '项目路径不是目录' });
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// 取模型
|
|
1041
|
+
let model;
|
|
1042
|
+
try {
|
|
1043
|
+
if (!configManager) throw new Error('configManager 不可用');
|
|
1044
|
+
const rawConfig = await configManager.readRawConfigFile();
|
|
1045
|
+
const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
|
|
1046
|
+
model = models.find(m => m.isDefault) || models[0];
|
|
1047
|
+
} catch (err) {
|
|
1048
|
+
return res.status(500).json({ success: false, error: '读取 AI 配置失败: ' + err.message });
|
|
1049
|
+
}
|
|
1050
|
+
if (!model) {
|
|
1051
|
+
return res.status(400).json({ success: false, error: '未配置 AI 模型,请先在通用设置中添加模型' });
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// 读取用户可编辑的生成指令;没存就用默认
|
|
1055
|
+
const userInstruction = await readInstruction();
|
|
1056
|
+
|
|
1057
|
+
// 递归识别多子项目
|
|
1058
|
+
const subProjects = await findSubProjects(projectPath);
|
|
1059
|
+
if (subProjects.length === 0) {
|
|
1060
|
+
// 没识别到任何子项目:回退到根目录本身
|
|
1061
|
+
const fallbackTree = await listDirTree(projectPath, 2, 400);
|
|
1062
|
+
const fallbackManifest = await readProjectManifest(projectPath);
|
|
1063
|
+
const fallbackReadme = await safeReadFile(path.join(projectPath, 'README.md'), 8000);
|
|
1064
|
+
subProjects.push({
|
|
1065
|
+
root: projectPath,
|
|
1066
|
+
name: path.basename(projectPath),
|
|
1067
|
+
manifests: fallbackManifest,
|
|
1068
|
+
readme: fallbackReadme,
|
|
1069
|
+
dirTree: fallbackTree
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const projectName = path.basename(projectPath);
|
|
1074
|
+
const LLM_OPTS = { maxTokens: 4000, timeoutMs: 1200000 };
|
|
1075
|
+
|
|
1076
|
+
// ── 第一阶段:基于可编辑指令 + 根目录概览,生成「可复用的提示词模板」 ──
|
|
1077
|
+
const overviewBlock = subProjects.map(sp =>
|
|
1078
|
+
`### 子项目 ${sp.name} (${sp.root})\n目录:\n${sp.dirTree || '(无)'}`
|
|
1079
|
+
).join('\n\n');
|
|
1080
|
+
|
|
1081
|
+
const firstPrompt = `${userInstruction}
|
|
1082
|
+
|
|
1083
|
+
---
|
|
1084
|
+
|
|
1085
|
+
以下是你需要分析的项目(请先生成「可复用的提示词模板」,不要直接给总结):
|
|
1086
|
+
|
|
1087
|
+
项目根目录:${projectPath}
|
|
1088
|
+
项目名称:${projectName}
|
|
1089
|
+
子项目数:${subProjects.length}
|
|
1090
|
+
|
|
1091
|
+
## 子项目概览
|
|
1092
|
+
${overviewBlock || '(无)'}
|
|
1093
|
+
|
|
1094
|
+
## 各子项目 manifest 与 README
|
|
1095
|
+
${subProjects.map(sp => {
|
|
1096
|
+
const manifestBlock = Object.entries(sp.manifests)
|
|
1097
|
+
.map(([n, c]) => `\n--- ${n} ---\n${c}`)
|
|
1098
|
+
.join('\n');
|
|
1099
|
+
return `\n### ${sp.name}\n${manifestBlock || '(无 manifest)'}\n\nREADME(前 8KB):\n${sp.readme || '(无)'}`;
|
|
1100
|
+
}).join('\n')}
|
|
1101
|
+
|
|
1102
|
+
只返回 JSON:
|
|
1103
|
+
{
|
|
1104
|
+
"name": "项目名(10-20字)",
|
|
1105
|
+
"template": "可复用的提示词模板(300-600字),应明确使用 {{task.title}} / {{task.desc}} / {{sub.title}} / {{sub.desc}} / {{repo.path}} / {{branch}} 这 6 个变量"
|
|
1106
|
+
}`;
|
|
1107
|
+
|
|
1108
|
+
const first = await callLlmJson(model, firstPrompt, LLM_OPTS);
|
|
1109
|
+
const templateName = String(first.name || '').trim() || projectName || '项目架构说明';
|
|
1110
|
+
const template = String(first.template || '').trim();
|
|
1111
|
+
|
|
1112
|
+
// ── 第二阶段:为每个子项目分别生成总结(单子项目 = 现在的行为) ──
|
|
1113
|
+
async function summarizeOneSub(sp) {
|
|
1114
|
+
const manifestBlock = Object.entries(sp.manifests)
|
|
1115
|
+
.map(([n, c]) => `\n--- ${n} ---\n${c}`)
|
|
1116
|
+
.join('\n');
|
|
1117
|
+
const subPrompt = `${template}
|
|
1118
|
+
|
|
1119
|
+
---
|
|
1120
|
+
|
|
1121
|
+
以下是你需要分析的一个子项目(请直接基于这些数据输出该子项目的架构说明):
|
|
1122
|
+
|
|
1123
|
+
子项目根目录:${sp.root}
|
|
1124
|
+
子项目名称:${sp.name}
|
|
1125
|
+
|
|
1126
|
+
## 目录结构(前 2 层)
|
|
1127
|
+
${sp.dirTree || '(无)'}
|
|
1128
|
+
|
|
1129
|
+
## manifest
|
|
1130
|
+
${manifestBlock || '(无)'}
|
|
1131
|
+
|
|
1132
|
+
## README
|
|
1133
|
+
${sp.readme || '(无)'}
|
|
1134
|
+
|
|
1135
|
+
只返回 JSON:
|
|
1136
|
+
{
|
|
1137
|
+
"summary": "该子项目的架构说明(300-600字)"
|
|
1138
|
+
}`;
|
|
1139
|
+
const r = await callLlmJson(model, subPrompt, LLM_OPTS);
|
|
1140
|
+
return { name: sp.name, root: sp.root, summary: String(r.summary || '').trim() };
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
const subSummaries = await Promise.all(subProjects.map(summarizeOneSub));
|
|
1144
|
+
|
|
1145
|
+
// ── 第三阶段:仅多子项目时合并(单子项目直接拿它的 summary) ──
|
|
1146
|
+
let finalSummary = '';
|
|
1147
|
+
let finalName = templateName;
|
|
1148
|
+
|
|
1149
|
+
if (subSummaries.length === 1) {
|
|
1150
|
+
finalSummary = subSummaries[0].summary;
|
|
1151
|
+
} else {
|
|
1152
|
+
const mergePrompt = `你是项目架构师。下列是同一仓库下 N 个子项目的架构说明,请合并输出**单一**的「项目架构说明」(800-1500字),覆盖:项目整体定位、技术栈、模块划分、子项目间关系、核心流程、关键设计决策。
|
|
1153
|
+
子项目之间用清晰的小标题或编号分隔。最后输出一段「整体架构」总结它们如何协同。
|
|
1154
|
+
只引用实际出现的子项目名 / 文件路径 / 依赖名,不要编造。只返回 JSON:
|
|
1155
|
+
|
|
1156
|
+
{
|
|
1157
|
+
"name": "项目名(10-20字)",
|
|
1158
|
+
"summary": "合并后的架构说明"
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
## 子项目说明
|
|
1162
|
+
${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summary || '(空)'}`).join('\n')}`;
|
|
1163
|
+
|
|
1164
|
+
const merged = await callLlmJson(model, mergePrompt, LLM_OPTS);
|
|
1165
|
+
finalSummary = String(merged.summary || '').trim()
|
|
1166
|
+
|| subSummaries.map(s => `### ${s.name}\n${s.summary}`).join('\n\n');
|
|
1167
|
+
finalName = String(merged.name || '').trim() || templateName;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
if (!finalSummary) {
|
|
1171
|
+
// 兜底:仅返回模板
|
|
1172
|
+
return res.json({
|
|
1173
|
+
success: true,
|
|
1174
|
+
name: finalName,
|
|
1175
|
+
template,
|
|
1176
|
+
result: '',
|
|
1177
|
+
content: template
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// 顶层 request 已经自带 20 分钟(1200s)超时;
|
|
1182
|
+
// 这里在 express 处理器内部不再额外加整体超时。
|
|
1183
|
+
res.json({
|
|
1184
|
+
success: true,
|
|
1185
|
+
name: finalName,
|
|
1186
|
+
template,
|
|
1187
|
+
result: finalSummary,
|
|
1188
|
+
content: finalSummary
|
|
1189
|
+
});
|
|
1190
|
+
} catch (err) {
|
|
1191
|
+
res.status(500).json({ success: false, error: err.message });
|
|
1192
|
+
}
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
// ── 生成指令:读 / 写(用户可在弹窗里自定义) ───────────────────────
|
|
1196
|
+
app.get('/api/workbench/prompts/ai-instruction', async (_req, res) => {
|
|
1197
|
+
try {
|
|
1198
|
+
const instruction = await readInstruction();
|
|
1199
|
+
res.json({ success: true, instruction, isDefault: instruction === DEFAULT_INSTRUCTION });
|
|
1200
|
+
} catch (err) {
|
|
1201
|
+
res.status(500).json({ success: false, error: err.message });
|
|
1202
|
+
}
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
app.put('/api/workbench/prompts/ai-instruction', async (req, res) => {
|
|
1206
|
+
try {
|
|
1207
|
+
const text = req.body && typeof req.body.instruction === 'string'
|
|
1208
|
+
? req.body.instruction.trim()
|
|
1209
|
+
: '';
|
|
1210
|
+
if (!text) {
|
|
1211
|
+
return res.status(400).json({ success: false, error: '指令不能为空' });
|
|
1212
|
+
}
|
|
1213
|
+
if (text.length > 50000) {
|
|
1214
|
+
return res.status(413).json({ success: false, error: '指令过长(最多 50000 字符)' });
|
|
1215
|
+
}
|
|
1216
|
+
await writeInstruction(text);
|
|
1217
|
+
res.json({ success: true });
|
|
1218
|
+
} catch (err) {
|
|
1219
|
+
res.status(500).json({ success: false, error: err.message });
|
|
1220
|
+
}
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
// ── AI 拆分子任务:独立的指令文件、独立的端点 ───────────────────────
|
|
1224
|
+
// GET /api/workbench/tasks/ai-subtask-instruction
|
|
1225
|
+
// → { success, instruction, isDefault }
|
|
1226
|
+
// PUT /api/workbench/tasks/ai-subtask-instruction
|
|
1227
|
+
// body: { instruction: string }
|
|
1228
|
+
// → { success }
|
|
1229
|
+
app.get('/api/workbench/tasks/ai-subtask-instruction', async (req, res) => {
|
|
1230
|
+
try {
|
|
1231
|
+
const def = pickDefaultSubtaskInstruction(req);
|
|
1232
|
+
const instruction = await readSubtaskInstruction(req);
|
|
1233
|
+
// isDefault:当前 instruction 和 locale 默认完全一致
|
|
1234
|
+
res.json({ success: true, instruction, isDefault: instruction === def });
|
|
1235
|
+
} catch (err) {
|
|
1236
|
+
res.status(500).json({ success: false, error: err.message });
|
|
1237
|
+
}
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
app.put('/api/workbench/tasks/ai-subtask-instruction', async (req, res) => {
|
|
1241
|
+
try {
|
|
1242
|
+
const text = req.body && typeof req.body.instruction === 'string'
|
|
1243
|
+
? req.body.instruction.trim()
|
|
1244
|
+
: '';
|
|
1245
|
+
if (!text) {
|
|
1246
|
+
return res.status(400).json({ success: false, error: '指令不能为空' });
|
|
1247
|
+
}
|
|
1248
|
+
if (text.length > 50000) {
|
|
1249
|
+
return res.status(413).json({ success: false, error: '指令过长(最多 50000 字符)' });
|
|
1250
|
+
}
|
|
1251
|
+
// 如果保存的文本正好等于当前 locale 的默认——不写文件,保持 fallback 行为
|
|
1252
|
+
const def = pickDefaultSubtaskInstruction(req);
|
|
1253
|
+
if (text === def) {
|
|
1254
|
+
// 删除已存在的自定义文件
|
|
1255
|
+
try { await fsp.unlink(SUBTASK_INSTRUCTION_FILE) } catch {}
|
|
1256
|
+
return res.json({ success: true, isDefault: true });
|
|
1257
|
+
}
|
|
1258
|
+
await writeSubtaskInstruction(text);
|
|
1259
|
+
res.json({ success: true, isDefault: false });
|
|
1260
|
+
} catch (err) {
|
|
1261
|
+
res.status(500).json({ success: false, error: err.message });
|
|
1262
|
+
}
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
// POST /api/workbench/tasks/ai-split-subtasks
|
|
1266
|
+
// body: { title, desc, taskId? }
|
|
1267
|
+
// → SSE 流:
|
|
1268
|
+
// data:{"type":"meta","prompt":{system,user}}\n\n
|
|
1269
|
+
// data:{"type":"thinking","delta":"..."}\n\n (多次)
|
|
1270
|
+
// data:{"type":"content","delta":"..."}\n\n (多次)
|
|
1271
|
+
// data:{"type":"done","subtasks":[...],"raw":"..."}\n\n
|
|
1272
|
+
// data:{"type":"error","error":"..."}\n\n (失败时)
|
|
1273
|
+
//
|
|
1274
|
+
// 走流式是为了让用户看到模型真实的 reasoning_content(如果模型支持),
|
|
1275
|
+
// 而不是前端用 setInterval 假装"打字机"——拆分质量也会因为给了模型
|
|
1276
|
+
// 充分的思考空间而显著提升。
|
|
1277
|
+
app.post('/api/workbench/tasks/ai-split-subtasks', async (req, res) => {
|
|
1278
|
+
const title = String(req.body?.title || '').trim();
|
|
1279
|
+
const desc = String(req.body?.desc || '').trim();
|
|
1280
|
+
const taskId = String(req.body?.taskId || '').trim();
|
|
1281
|
+
const promptId = String(req.body?.promptId || '').trim();
|
|
1282
|
+
if (!title) {
|
|
1283
|
+
return res.status(400).json({ success: false, error: '任务标题不能为空' });
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// 建立 SSE
|
|
1287
|
+
res.set({
|
|
1288
|
+
'Content-Type': 'text/event-stream',
|
|
1289
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
1290
|
+
'Connection': 'keep-alive',
|
|
1291
|
+
'X-Accel-Buffering': 'no'
|
|
1292
|
+
});
|
|
1293
|
+
res.flushHeaders?.();
|
|
1294
|
+
const send = (obj) => {
|
|
1295
|
+
try { res.write(`data: ${JSON.stringify(obj)}\n\n`); } catch {}
|
|
1296
|
+
};
|
|
1297
|
+
|
|
1298
|
+
const abortController = new AbortController();
|
|
1299
|
+
let finished = false; // 标记响应是否已正常结束
|
|
1300
|
+
// 客户端真实断开:监听 socket close,而不是 req.close。
|
|
1301
|
+
// Node 22+ 的 req 'close' 事件会在 HTTP keep-alive socket 池回收时过早触发,
|
|
1302
|
+
// 导致正常请求中途被 abort。这里改用 socket 真实断开事件,
|
|
1303
|
+
// 并只在响应还没 end 时才取消上游 LLM。
|
|
1304
|
+
const onSocketClose = () => {
|
|
1305
|
+
if (!finished) abortController.abort()
|
|
1306
|
+
};
|
|
1307
|
+
if (req.socket) {
|
|
1308
|
+
req.socket.once('close', onSocketClose);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
try {
|
|
1312
|
+
let model;
|
|
1313
|
+
try {
|
|
1314
|
+
if (!configManager) throw new Error('configManager 不可用');
|
|
1315
|
+
const rawConfig = await configManager.readRawConfigFile();
|
|
1316
|
+
const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
|
|
1317
|
+
model = models.find(m => m.isDefault) || models[0];
|
|
1318
|
+
} catch (err) {
|
|
1319
|
+
send({ type: 'error', error: '读取 AI 配置失败: ' + err.message });
|
|
1320
|
+
finished = true;
|
|
1321
|
+
return res.end();
|
|
1322
|
+
}
|
|
1323
|
+
if (!model) {
|
|
1324
|
+
send({ type: 'error', error: '未配置 AI 模型,请先在通用设置中添加模型' });
|
|
1325
|
+
finished = true;
|
|
1326
|
+
return res.end();
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
const userInstruction = await readSubtaskInstruction(req);
|
|
1330
|
+
const projectPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
|
|
1331
|
+
const projectName = projectPath ? path.basename(projectPath) : '(未指定项目)';
|
|
1332
|
+
const manifestHint = await detectProjectManifest(projectPath);
|
|
1333
|
+
|
|
1334
|
+
// 取绑定的预置模板(promptId):模板内容作为"执行模板"提示
|
|
1335
|
+
// 让 LLM 拆分时知道:每个 sub 最终都会被这套模板包裹后送进 claude
|
|
1336
|
+
let templateBlock = '';
|
|
1337
|
+
if (promptId) {
|
|
1338
|
+
try {
|
|
1339
|
+
const promptData = await readJson(PROMPTS_FILE, { prompts: [] });
|
|
1340
|
+
const p = (promptData.prompts || []).find(x => x.id === promptId);
|
|
1341
|
+
if (p && p.content) {
|
|
1342
|
+
templateBlock = `\n\n## 子任务执行模板(每个拆出的子任务最终会被这套模板包裹后送给 claude 执行;拆分时请确保子任务能让模板里的 {{sub.title}} / {{sub.desc}} 等变量填得有意义)\n模板名:${p.name || '(未命名)'}\n---\n${p.content}\n---`;
|
|
1343
|
+
}
|
|
1344
|
+
} catch { /* 模板读取失败不影响拆分 */ }
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// 取任务附件
|
|
1348
|
+
let attachmentBlock = '';
|
|
1349
|
+
const imageDataUrls = [];
|
|
1350
|
+
if (taskId) {
|
|
1351
|
+
try {
|
|
1352
|
+
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
1353
|
+
const task = (data.tasks || []).find(t => t.id === taskId);
|
|
1354
|
+
const atts = Array.isArray(task?.attachments) ? task.attachments : [];
|
|
1355
|
+
if (atts.length > 0) {
|
|
1356
|
+
const lines = [];
|
|
1357
|
+
for (let i = 0; i < atts.length; i++) {
|
|
1358
|
+
const a = atts[i];
|
|
1359
|
+
if (!a || !a.absolutePath) continue;
|
|
1360
|
+
lines.push(` ${i + 1}. [${a.mimeType || 'application/octet-stream'}] ${a.absolutePath}`);
|
|
1361
|
+
if (isImageExt(a.ext)) {
|
|
1362
|
+
try {
|
|
1363
|
+
const buf = await fsp.readFile(a.absolutePath);
|
|
1364
|
+
const mime = a.mimeType || 'image/png';
|
|
1365
|
+
imageDataUrls.push(`data:${mime};base64,${buf.toString('base64')}`);
|
|
1366
|
+
} catch { /* 文件丢失就跳过这张图 */ }
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
if (lines.length > 0) {
|
|
1370
|
+
const imgNote = imageDataUrls.length > 0
|
|
1371
|
+
? `(其中 ${imageDataUrls.length} 张图片已随消息一并发送,请直接基于图片内容拆分)`
|
|
1372
|
+
: '';
|
|
1373
|
+
attachmentBlock = `\n\n## 任务附件${imgNote}\n${lines.join('\n')}`;
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
} catch { /* 没拿到附件不影响拆分 */ }
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
const userBlock = `${userInstruction}
|
|
1380
|
+
|
|
1381
|
+
---
|
|
1382
|
+
|
|
1383
|
+
## 待拆分的任务
|
|
1384
|
+
标题:${title}
|
|
1385
|
+
${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateBlock}
|
|
1386
|
+
|
|
1387
|
+
## 项目上下文(仅供参考,便于拆分时考虑项目特性)
|
|
1388
|
+
- 项目名称:${projectName}
|
|
1389
|
+
- 项目根目录:${projectPath || '(未指定)'}
|
|
1390
|
+
- 主要 manifest:${manifestHint || '(未识别到)'}
|
|
1391
|
+
|
|
1392
|
+
请先简要分析(可以放在 reasoning 中或直接写出来),然后给出 JSON。JSON 用 \`\`\`json ... \`\`\` 包裹:
|
|
1393
|
+
{
|
|
1394
|
+
"subtasks": [
|
|
1395
|
+
{ "title": "子任务标题(10-20字)", "desc": "具体描述" }
|
|
1396
|
+
]
|
|
1397
|
+
}`;
|
|
1398
|
+
|
|
1399
|
+
// 先把 prompt 元信息推给前端
|
|
1400
|
+
send({ type: 'meta', prompt: { system: userInstruction, user: userBlock } });
|
|
1401
|
+
|
|
1402
|
+
// 流式调用 LLM,把 thinking / content 实时回传
|
|
1403
|
+
const { content, aborted } = await callLlmStream(
|
|
1404
|
+
model,
|
|
1405
|
+
userBlock,
|
|
1406
|
+
(delta) => {
|
|
1407
|
+
if (delta.thinking) send({ type: 'thinking', delta: delta.thinking });
|
|
1408
|
+
if (delta.content) send({ type: 'content', delta: delta.content });
|
|
1409
|
+
},
|
|
1410
|
+
{ maxTokens: 4000, timeoutMs: 600000, images: imageDataUrls, signal: abortController.signal }
|
|
1411
|
+
);
|
|
1412
|
+
|
|
1413
|
+
if (aborted) {
|
|
1414
|
+
send({ type: 'error', error: '已取消' });
|
|
1415
|
+
finished = true;
|
|
1416
|
+
return res.end();
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// 解析 JSON:兼容 ```json ... ``` 代码块或裸 JSON
|
|
1420
|
+
let parsed = {};
|
|
1421
|
+
try {
|
|
1422
|
+
const m = content.match(/```json\s*([\s\S]*?)```/i)
|
|
1423
|
+
|| content.match(/```\s*([\s\S]*?)```/)
|
|
1424
|
+
|| content.match(/(\{[\s\S]*\})/);
|
|
1425
|
+
parsed = JSON.parse(m ? m[1] : content);
|
|
1426
|
+
} catch { parsed = {}; }
|
|
1427
|
+
|
|
1428
|
+
const list = Array.isArray(parsed?.subtasks) ? parsed.subtasks : [];
|
|
1429
|
+
const subtasks = list
|
|
1430
|
+
.map(s => ({
|
|
1431
|
+
title: String(s?.title || '').trim().slice(0, 80),
|
|
1432
|
+
desc: String(s?.desc || '').trim().slice(0, 500)
|
|
1433
|
+
}))
|
|
1434
|
+
.filter(s => s.title)
|
|
1435
|
+
.slice(0, 8);
|
|
1436
|
+
|
|
1437
|
+
send({ type: 'done', subtasks, raw: content });
|
|
1438
|
+
finished = true;
|
|
1439
|
+
res.end();
|
|
1440
|
+
} catch (err) {
|
|
1441
|
+
send({ type: 'error', error: 'AI 拆分失败: ' + (err?.message || String(err)) });
|
|
1442
|
+
finished = true;
|
|
1443
|
+
res.end();
|
|
1444
|
+
}
|
|
1445
|
+
});
|
|
1446
|
+
|
|
1447
|
+
// SSE 事件流
|
|
1448
|
+
app.get('/api/workbench/events', (req, res) => {
|
|
1449
|
+
res.set({
|
|
1450
|
+
'Content-Type': 'text/event-stream',
|
|
1451
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
1452
|
+
'Connection': 'keep-alive',
|
|
1453
|
+
'X-Accel-Buffering': 'no'
|
|
1454
|
+
});
|
|
1455
|
+
res.flushHeaders?.();
|
|
1456
|
+
const send = (data) => {
|
|
1457
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
1458
|
+
};
|
|
1459
|
+
// 初始快照
|
|
1460
|
+
send({ event: 'hello', payload: { jobs: snapshotJobs() }, ts: nowIso() });
|
|
1461
|
+
const handler = (evt) => send(evt);
|
|
1462
|
+
bus.on('event', handler);
|
|
1463
|
+
const ka = setInterval(() => res.write(`: keep-alive\n\n`), 15000);
|
|
1464
|
+
req.on('close', () => {
|
|
1465
|
+
clearInterval(ka);
|
|
1466
|
+
bus.off('event', handler);
|
|
1467
|
+
});
|
|
1468
|
+
});
|
|
1469
|
+
|
|
1470
|
+
// ── 提示词 CRUD ─────────────────────────────────────────────────────
|
|
1471
|
+
app.get('/api/workbench/prompts', async (_req, res) => {
|
|
1472
|
+
try {
|
|
1473
|
+
const data = await readJson(PROMPTS_FILE, { prompts: [] });
|
|
1474
|
+
res.json({ success: true, prompts: data.prompts || [] });
|
|
1475
|
+
} catch (err) {
|
|
1476
|
+
res.status(500).json({ success: false, error: err.message });
|
|
1477
|
+
}
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
app.post('/api/workbench/prompts', async (req, res) => {
|
|
1481
|
+
try {
|
|
1482
|
+
const { id, name, content } = req.body || {};
|
|
1483
|
+
if (!name || typeof content !== 'string') {
|
|
1484
|
+
return res.status(400).json({ success: false, error: 'name 和 content 必填' });
|
|
1485
|
+
}
|
|
1486
|
+
const data = await readJson(PROMPTS_FILE, { prompts: [] });
|
|
1487
|
+
const prompts = data.prompts || [];
|
|
1488
|
+
const now = nowIso();
|
|
1489
|
+
if (id) {
|
|
1490
|
+
const i = prompts.findIndex(p => p.id === id);
|
|
1491
|
+
if (i < 0) return res.status(404).json({ success: false, error: '提示词不存在' });
|
|
1492
|
+
prompts[i] = { ...prompts[i], name, content, updatedAt: now };
|
|
1493
|
+
await writeJson(PROMPTS_FILE, { prompts });
|
|
1494
|
+
return res.json({ success: true, prompt: prompts[i] });
|
|
1495
|
+
}
|
|
1496
|
+
const prompt = { id: genId(), name, content, createdAt: now, updatedAt: now };
|
|
1497
|
+
prompts.push(prompt);
|
|
1498
|
+
await writeJson(PROMPTS_FILE, { prompts });
|
|
1499
|
+
res.json({ success: true, prompt });
|
|
1500
|
+
} catch (err) {
|
|
1501
|
+
res.status(500).json({ success: false, error: err.message });
|
|
1502
|
+
}
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
app.delete('/api/workbench/prompts/:id', async (req, res) => {
|
|
1506
|
+
try {
|
|
1507
|
+
const data = await readJson(PROMPTS_FILE, { prompts: [] });
|
|
1508
|
+
const prompts = (data.prompts || []).filter(p => p.id !== req.params.id);
|
|
1509
|
+
await writeJson(PROMPTS_FILE, { prompts });
|
|
1510
|
+
res.json({ success: true });
|
|
1511
|
+
} catch (err) {
|
|
1512
|
+
res.status(500).json({ success: false, error: err.message });
|
|
1513
|
+
}
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
// ── 任务 CRUD ───────────────────────────────────────────────────────
|
|
1517
|
+
app.get('/api/workbench/tasks', async (_req, res) => {
|
|
1518
|
+
try {
|
|
1519
|
+
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
1520
|
+
res.json({ success: true, tasks: data.tasks || [] });
|
|
1521
|
+
} catch (err) {
|
|
1522
|
+
res.status(500).json({ success: false, error: err.message });
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
// 当前选中的项目路径(侧边栏按项目分组时要用)
|
|
1527
|
+
app.get('/api/workbench/current-project', async (_req, res) => {
|
|
1528
|
+
try {
|
|
1529
|
+
const projectPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
|
|
1530
|
+
const projectName = projectPath ? projectPath.split(/[\\/]/).filter(Boolean).pop() : '';
|
|
1531
|
+
res.json({ success: true, projectPath, projectName });
|
|
1532
|
+
} catch (err) {
|
|
1533
|
+
res.status(500).json({ success: false, error: err.message });
|
|
1534
|
+
}
|
|
1535
|
+
});
|
|
1536
|
+
|
|
1537
|
+
app.post('/api/workbench/tasks', async (req, res) => {
|
|
1538
|
+
try {
|
|
1539
|
+
const { id, title, desc, promptId, subtasks } = req.body || {};
|
|
1540
|
+
if (!title) return res.status(400).json({ success: false, error: 'title 必填' });
|
|
1541
|
+
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
1542
|
+
const tasks = data.tasks || [];
|
|
1543
|
+
const now = nowIso();
|
|
1544
|
+
// 创建任务时记录当时所属项目;编辑已有任务不覆盖(避免切换项目后老任务被改归属)
|
|
1545
|
+
const currentProjectPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
|
|
1546
|
+
if (id) {
|
|
1547
|
+
const i = tasks.findIndex(t => t.id === id);
|
|
1548
|
+
if (i < 0) return res.status(404).json({ success: false, error: '任务不存在' });
|
|
1549
|
+
tasks[i] = {
|
|
1550
|
+
...tasks[i],
|
|
1551
|
+
title,
|
|
1552
|
+
desc: desc || '',
|
|
1553
|
+
promptId: promptId || null,
|
|
1554
|
+
subtasks: Array.isArray(subtasks) ? subtasks.map(s => ({
|
|
1555
|
+
id: s.id || genId(),
|
|
1556
|
+
title: s.title || '',
|
|
1557
|
+
desc: s.desc || '',
|
|
1558
|
+
status: s.status || 'todo',
|
|
1559
|
+
promptOverride: s.promptOverride || '',
|
|
1560
|
+
// 保留附件元数据(仅保留基础字段,丢弃客户端临时字段)
|
|
1561
|
+
attachments: Array.isArray(s.attachments) ? s.attachments.map(a => ({
|
|
1562
|
+
id: a.id,
|
|
1563
|
+
originalName: a.originalName,
|
|
1564
|
+
mimeType: a.mimeType,
|
|
1565
|
+
size: a.size,
|
|
1566
|
+
ext: a.ext,
|
|
1567
|
+
storedName: a.storedName,
|
|
1568
|
+
absolutePath: a.absolutePath,
|
|
1569
|
+
createdAt: a.createdAt
|
|
1570
|
+
})) : (tasks[i].subtasks.find(x => x.id === s.id)?.attachments || [])
|
|
1571
|
+
})) : tasks[i].subtasks,
|
|
1572
|
+
updatedAt: now
|
|
1573
|
+
};
|
|
1574
|
+
await writeJson(TASKS_FILE, { tasks });
|
|
1575
|
+
return res.json({ success: true, task: tasks[i] });
|
|
1576
|
+
}
|
|
1577
|
+
const task = {
|
|
1578
|
+
id: genId(),
|
|
1579
|
+
title,
|
|
1580
|
+
desc: desc || '',
|
|
1581
|
+
promptId: promptId || null,
|
|
1582
|
+
projectPath: currentProjectPath || '',
|
|
1583
|
+
subtasks: Array.isArray(subtasks) ? subtasks.map(s => ({
|
|
1584
|
+
id: s.id || genId(),
|
|
1585
|
+
title: s.title || '',
|
|
1586
|
+
desc: s.desc || '',
|
|
1587
|
+
status: s.status || 'todo',
|
|
1588
|
+
promptOverride: s.promptOverride || '',
|
|
1589
|
+
attachments: Array.isArray(s.attachments) ? s.attachments : []
|
|
1590
|
+
})) : [],
|
|
1591
|
+
status: 'todo',
|
|
1592
|
+
createdAt: now,
|
|
1593
|
+
updatedAt: now
|
|
1594
|
+
};
|
|
1595
|
+
tasks.push(task);
|
|
1596
|
+
await writeJson(TASKS_FILE, { tasks });
|
|
1597
|
+
res.json({ success: true, task });
|
|
1598
|
+
} catch (err) {
|
|
1599
|
+
res.status(500).json({ success: false, error: err.message });
|
|
1600
|
+
}
|
|
1601
|
+
});
|
|
1602
|
+
|
|
1603
|
+
app.delete('/api/workbench/tasks/:id', async (req, res) => {
|
|
1604
|
+
try {
|
|
1605
|
+
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
1606
|
+
const tasks = (data.tasks || []).filter(t => t.id !== req.params.id);
|
|
1607
|
+
await writeJson(TASKS_FILE, { tasks });
|
|
1608
|
+
res.json({ success: true });
|
|
1609
|
+
} catch (err) {
|
|
1610
|
+
res.status(500).json({ success: false, error: err.message });
|
|
1611
|
+
}
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
// ── 执行任务 ────────────────────────────────────────────────────────
|
|
1615
|
+
app.post('/api/workbench/tasks/:id/run', async (req, res) => {
|
|
1616
|
+
try {
|
|
1617
|
+
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
1618
|
+
const task = (data.tasks || []).find(t => t.id === req.params.id);
|
|
1619
|
+
if (!task) return res.status(404).json({ success: false, error: '任务不存在' });
|
|
1620
|
+
if (!task.subtasks || task.subtasks.length === 0) {
|
|
1621
|
+
return res.status(400).json({ success: false, error: '任务没有子任务' });
|
|
1622
|
+
}
|
|
1623
|
+
const repoPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
|
|
1624
|
+
// 异步执行,立即返回
|
|
1625
|
+
res.json({ success: true, message: '已开始执行' });
|
|
1626
|
+
runTaskQueue(task, repoPath, '').catch(err => {
|
|
1627
|
+
publish('task:error', { taskId: task.id, error: err.message });
|
|
1628
|
+
});
|
|
1629
|
+
} catch (err) {
|
|
1630
|
+
res.status(500).json({ success: false, error: err.message });
|
|
1631
|
+
}
|
|
1632
|
+
});
|
|
1633
|
+
|
|
1634
|
+
// ── 进程状态查询(兜底,SSE 断了也能拉) ────────────────────────────
|
|
1635
|
+
app.get('/api/workbench/jobs', (_req, res) => {
|
|
1636
|
+
res.json({ success: true, jobs: snapshotJobs() });
|
|
1637
|
+
});
|
|
1638
|
+
|
|
1639
|
+
// ── 取消正在执行的 job ───────────────────────────────────────────
|
|
1640
|
+
// POST /api/workbench/jobs/:id/cancel
|
|
1641
|
+
// 行为:
|
|
1642
|
+
// - 找到正在运行的 job,调 child.kill() 终止 claude 进程
|
|
1643
|
+
// - Windows 下用 taskkill /T /F 杀进程树(claude 进程可能 fork 出子进程)
|
|
1644
|
+
// - 加入 cancelledJobs 集合,runTaskQueue 退出循环后会把 job 标为 'cancelled'
|
|
1645
|
+
// - 只影响这一个 sub;同 task 后续 sub 仍按队列顺序继续执行
|
|
1646
|
+
app.post('/api/workbench/jobs/:id/cancel', (req, res) => {
|
|
1647
|
+
const job = jobs.get(req.params.id)
|
|
1648
|
+
if (!job) {
|
|
1649
|
+
return res.status(404).json({ success: false, error: 'job 不存在' })
|
|
1650
|
+
}
|
|
1651
|
+
if (job.status !== 'running' && job.status !== 'pending') {
|
|
1652
|
+
return res.status(400).json({ success: false, error: `当前状态 ${job.status} 不可取消` })
|
|
1653
|
+
}
|
|
1654
|
+
cancelledJobs.add(job.id)
|
|
1655
|
+
// 立即给前端一个状态反馈(不等 child 真正退出)
|
|
1656
|
+
job.status = 'cancelled'
|
|
1657
|
+
job.error = '用户已停止执行'
|
|
1658
|
+
job.endedAt = nowIso()
|
|
1659
|
+
publish('job:update', { ...job }) // 用浅拷贝避免序列化 child 引用
|
|
1660
|
+
// 终态:fire-and-forget 同步落盘,cancel 是显式操作,要保证不丢
|
|
1661
|
+
flushJobsSaveNow().catch(err => console.warn('[workbench] jobs save failed:', err.message))
|
|
1662
|
+
const child = job.child
|
|
1663
|
+
if (!child) {
|
|
1664
|
+
return res.json({ success: true, message: '已标记取消,进程将尽快结束' })
|
|
1665
|
+
}
|
|
1666
|
+
try {
|
|
1667
|
+
if (process.platform === 'win32') {
|
|
1668
|
+
// Windows: child.kill(SIGTERM) 经常无效,用 taskkill 杀进程树
|
|
1669
|
+
execFile('taskkill', ['/PID', String(child.pid), '/T', '/F'], (err) => {
|
|
1670
|
+
if (err) {
|
|
1671
|
+
console.warn(`[workbench] taskkill ${child.pid} 失败:`, err.message)
|
|
1672
|
+
}
|
|
1673
|
+
})
|
|
1674
|
+
} else {
|
|
1675
|
+
child.kill('SIGTERM')
|
|
1676
|
+
}
|
|
1677
|
+
res.json({ success: true, message: '已发送停止信号' })
|
|
1678
|
+
} catch (err) {
|
|
1679
|
+
cancelledJobs.delete(job.id)
|
|
1680
|
+
res.status(500).json({ success: false, error: '发送停止信号失败: ' + err.message })
|
|
1681
|
+
}
|
|
1682
|
+
});
|
|
1683
|
+
|
|
1684
|
+
// ── 执行日志管理 API(持久化 + 清理) ────────────────────────────
|
|
1685
|
+
// 路径都在 /api/workbench/jobs/* 下;/list、/config、/batch-delete、/clear
|
|
1686
|
+
// 是字面路径,必须排在 :id 路由之前注册,避免被 :id 匹配吞掉。
|
|
1687
|
+
//
|
|
1688
|
+
// 数据来源:
|
|
1689
|
+
// - 文件 jobs.json:已落盘的历史 job(含反范式 taskTitle/subTitle)
|
|
1690
|
+
// - 内存 jobs Map:当前进程刚创建还没刷盘的(尤其是 running/pending)
|
|
1691
|
+
// 合并后再过滤分页,保证管理页能看到"刚启动还没结束"的任务。
|
|
1692
|
+
|
|
1693
|
+
async function loadAllJobs() {
|
|
1694
|
+
// 读 tasks.json 一次,给内存里没落盘的 job 反范式补 title
|
|
1695
|
+
const tasksData = await readJson(TASKS_FILE, { tasks: [] })
|
|
1696
|
+
const taskMap = new Map((tasksData.tasks || []).map(t => [t.id, t]))
|
|
1697
|
+
const data = await readJson(JOBS_FILE, { version: 1, jobs: [] })
|
|
1698
|
+
const fileJobs = (data && Array.isArray(data.jobs)) ? data.jobs : []
|
|
1699
|
+
const fileIds = new Set(fileJobs.map(j => j.id))
|
|
1700
|
+
// 内存里有但文件里没有:running/pending 或最近刚起还没刷盘的
|
|
1701
|
+
const liveOnly = Array.from(jobs.values())
|
|
1702
|
+
.filter(j => !fileIds.has(j.id))
|
|
1703
|
+
.map(j => serializeJob(j, taskMap))
|
|
1704
|
+
return [...fileJobs, ...liveOnly]
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
function applyJobsFilter(list, q) {
|
|
1708
|
+
const status = (q.status || '').trim()
|
|
1709
|
+
const taskId = (q.taskId || '').trim()
|
|
1710
|
+
const term = (q.q || '').trim().toLowerCase()
|
|
1711
|
+
return list.filter(j => {
|
|
1712
|
+
if (status && j.status !== status) return false
|
|
1713
|
+
if (taskId && j.taskId !== taskId) return false
|
|
1714
|
+
if (term) {
|
|
1715
|
+
const hay = `${j.title || ''} ${j.taskTitle || ''} ${j.subTitle || ''}`.toLowerCase()
|
|
1716
|
+
if (!hay.includes(term)) return false
|
|
1717
|
+
}
|
|
1718
|
+
return true
|
|
1719
|
+
})
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
// GET /api/workbench/jobs/list?status=&q=&taskId=&limit=&offset=
|
|
1723
|
+
app.get('/api/workbench/jobs/list', async (req, res) => {
|
|
1724
|
+
try {
|
|
1725
|
+
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50))
|
|
1726
|
+
const offset = Math.max(0, parseInt(req.query.offset, 10) || 0)
|
|
1727
|
+
const all = await loadAllJobs()
|
|
1728
|
+
const filtered = applyJobsFilter(all, req.query)
|
|
1729
|
+
// 按 endedAt desc;没有就 startedAt desc;都没有就 id desc(id 时间戳前缀可比)
|
|
1730
|
+
const sortKey = (j) => j.endedAt || j.startedAt || j.id || ''
|
|
1731
|
+
filtered.sort((a, b) => sortKey(b).localeCompare(sortKey(a)))
|
|
1732
|
+
const total = filtered.length
|
|
1733
|
+
const page = filtered.slice(offset, offset + limit)
|
|
1734
|
+
// 统计:按 status 分组 + 总 size(基于全集,给顶部条用)
|
|
1735
|
+
const byStatus = {}
|
|
1736
|
+
let totalSize = 0
|
|
1737
|
+
for (const j of all) {
|
|
1738
|
+
byStatus[j.status] = (byStatus[j.status] || 0) + 1
|
|
1739
|
+
totalSize += j.size || 0
|
|
1740
|
+
}
|
|
1741
|
+
res.json({
|
|
1742
|
+
success: true,
|
|
1743
|
+
jobs: page,
|
|
1744
|
+
total,
|
|
1745
|
+
stats: { count: all.length, sizeMB: +(totalSize / 1024 / 1024).toFixed(2), byStatus }
|
|
1746
|
+
})
|
|
1747
|
+
} catch (err) {
|
|
1748
|
+
res.status(500).json({ success: false, error: 'list jobs 失败: ' + (err.message || String(err)) })
|
|
1749
|
+
}
|
|
1750
|
+
})
|
|
1751
|
+
|
|
1752
|
+
// GET /api/workbench/jobs/config
|
|
1753
|
+
app.get('/api/workbench/jobs/config', async (_req, res) => {
|
|
1754
|
+
try {
|
|
1755
|
+
res.json({ success: true, config: await readJobsConfig() })
|
|
1756
|
+
} catch (err) {
|
|
1757
|
+
res.status(500).json({ success: false, error: '读取配置失败: ' + err.message })
|
|
1758
|
+
}
|
|
1759
|
+
})
|
|
1760
|
+
|
|
1761
|
+
// PUT /api/workbench/jobs/config
|
|
1762
|
+
app.put('/api/workbench/jobs/config', async (req, res) => {
|
|
1763
|
+
try {
|
|
1764
|
+
const cfg = await writeJobsConfig(req.body || {})
|
|
1765
|
+
// 配置变更后立刻 enforce,让已落盘的多余记录立刻被裁掉
|
|
1766
|
+
await enforceRetention()
|
|
1767
|
+
res.json({ success: true, config: cfg })
|
|
1768
|
+
} catch (err) {
|
|
1769
|
+
res.status(400).json({ success: false, error: err.message || String(err) })
|
|
1770
|
+
}
|
|
1771
|
+
})
|
|
1772
|
+
|
|
1773
|
+
// POST /api/workbench/jobs/batch-delete
|
|
1774
|
+
app.post('/api/workbench/jobs/batch-delete', async (req, res) => {
|
|
1775
|
+
try {
|
|
1776
|
+
const ids = Array.isArray(req.body?.ids) ? req.body.ids.filter(s => typeof s === 'string') : []
|
|
1777
|
+
if (ids.length === 0) return res.json({ success: true, removed: 0 })
|
|
1778
|
+
// 1) 删内存 Map
|
|
1779
|
+
let removed = 0
|
|
1780
|
+
for (const id of ids) {
|
|
1781
|
+
if (jobs.delete(id)) removed++
|
|
1782
|
+
}
|
|
1783
|
+
// 2) 改写文件
|
|
1784
|
+
const data = await readJson(JOBS_FILE, { version: 1, jobs: [] })
|
|
1785
|
+
if (data && Array.isArray(data.jobs)) {
|
|
1786
|
+
const set = new Set(ids)
|
|
1787
|
+
const before = data.jobs.length
|
|
1788
|
+
data.jobs = data.jobs.filter(j => !set.has(j.id))
|
|
1789
|
+
removed += before - data.jobs.length
|
|
1790
|
+
await writeJson(JOBS_FILE, data)
|
|
1791
|
+
}
|
|
1792
|
+
res.json({ success: true, removed })
|
|
1793
|
+
} catch (err) {
|
|
1794
|
+
res.status(500).json({ success: false, error: '批量删除失败: ' + (err.message || String(err)) })
|
|
1795
|
+
}
|
|
1796
|
+
})
|
|
1797
|
+
|
|
1798
|
+
// POST /api/workbench/jobs/clear
|
|
1799
|
+
app.post('/api/workbench/jobs/clear', async (req, res) => {
|
|
1800
|
+
try {
|
|
1801
|
+
if (req.body?.confirm !== true) {
|
|
1802
|
+
return res.status(400).json({ success: false, error: '需要 confirm: true' })
|
|
1803
|
+
}
|
|
1804
|
+
let removed = 0
|
|
1805
|
+
for (const j of jobs.values()) {
|
|
1806
|
+
// 不清当前还在跑/排队的(防止误清活跃 job;用户应逐个取消或等结束)
|
|
1807
|
+
if (j.status === 'running' || j.status === 'pending') continue
|
|
1808
|
+
jobs.delete(j.id)
|
|
1809
|
+
removed++
|
|
1810
|
+
}
|
|
1811
|
+
// 写一个空 jobs.json
|
|
1812
|
+
await writeJson(JOBS_FILE, { version: 1, jobs: [] })
|
|
1813
|
+
res.json({ success: true, removed })
|
|
1814
|
+
} catch (err) {
|
|
1815
|
+
res.status(500).json({ success: false, error: '清空失败: ' + (err.message || String(err)) })
|
|
1816
|
+
}
|
|
1817
|
+
})
|
|
1818
|
+
|
|
1819
|
+
// GET /api/workbench/jobs/:id
|
|
1820
|
+
app.get('/api/workbench/jobs/:id', async (req, res) => {
|
|
1821
|
+
try {
|
|
1822
|
+
// 优先查内存(含活跃)
|
|
1823
|
+
const live = jobs.get(req.params.id)
|
|
1824
|
+
if (live) {
|
|
1825
|
+
const tasksData = await readJson(TASKS_FILE, { tasks: [] })
|
|
1826
|
+
const taskMap = new Map((tasksData.tasks || []).map(t => [t.id, t]))
|
|
1827
|
+
return res.json({ success: true, job: serializeJob(live, taskMap) })
|
|
1828
|
+
}
|
|
1829
|
+
// 退回文件
|
|
1830
|
+
const data = await readJson(JOBS_FILE, { version: 1, jobs: [] })
|
|
1831
|
+
const j = (data.jobs || []).find(x => x.id === req.params.id)
|
|
1832
|
+
if (!j) return res.status(404).json({ success: false, error: 'job 不存在' })
|
|
1833
|
+
res.json({ success: true, job: j })
|
|
1834
|
+
} catch (err) {
|
|
1835
|
+
res.status(500).json({ success: false, error: '查询失败: ' + (err.message || String(err)) })
|
|
1836
|
+
}
|
|
1837
|
+
})
|
|
1838
|
+
|
|
1839
|
+
// DELETE /api/workbench/jobs/:id
|
|
1840
|
+
app.delete('/api/workbench/jobs/:id', async (req, res) => {
|
|
1841
|
+
try {
|
|
1842
|
+
const id = req.params.id
|
|
1843
|
+
let removed = false
|
|
1844
|
+
if (jobs.delete(id)) removed = true
|
|
1845
|
+
const data = await readJson(JOBS_FILE, { version: 1, jobs: [] })
|
|
1846
|
+
if (data && Array.isArray(data.jobs)) {
|
|
1847
|
+
const before = data.jobs.length
|
|
1848
|
+
data.jobs = data.jobs.filter(j => j.id !== id)
|
|
1849
|
+
if (data.jobs.length !== before) {
|
|
1850
|
+
removed = true
|
|
1851
|
+
await writeJson(JOBS_FILE, data)
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
if (!removed) return res.status(404).json({ success: false, error: 'job 不存在' })
|
|
1855
|
+
res.json({ success: true, removed: 1 })
|
|
1856
|
+
} catch (err) {
|
|
1857
|
+
res.status(500).json({ success: false, error: '删除失败: ' + (err.message || String(err)) })
|
|
1858
|
+
}
|
|
1859
|
+
})
|
|
1860
|
+
|
|
1861
|
+
// ── 子任务附件:上传 / 删除 / 列表 ───────────────────────────────
|
|
1862
|
+
// 上传:POST /api/workbench/subtasks/:subId/attachments
|
|
1863
|
+
// header: X-Original-Name, X-Mime-Type
|
|
1864
|
+
// body: raw binary
|
|
1865
|
+
// 删除:DELETE /api/workbench/subtasks/:subId/attachments/:attId
|
|
1866
|
+
// 列表:GET /api/workbench/subtasks/:subId/attachments
|
|
1867
|
+
//
|
|
1868
|
+
// 文件存到 ~/.zen-gitsync/workbench-images/{subId}/{attId}.{ext}
|
|
1869
|
+
// 元数据(id / originalName / mime / size / storedName)通过 sub.attachments
|
|
1870
|
+
// 跟随 tasks.json 一起持久化。
|
|
1871
|
+
const rawAttachment = express.raw({
|
|
1872
|
+
type: '*/*',
|
|
1873
|
+
limit: MAX_IMAGE_BYTES * 4 // 整体路由上限 20MB;单文件大小由业务再卡
|
|
1874
|
+
});
|
|
1875
|
+
|
|
1876
|
+
// 共享 helper:找到一个 attachment 所在的位置(task 主附件 或 sub 附件)
|
|
1877
|
+
// 返回 { owner, task, sub?, list, att, storageDir } 或 null
|
|
1878
|
+
async function findAttachmentLocation(attId) {
|
|
1879
|
+
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
1880
|
+
for (const t of data.tasks || []) {
|
|
1881
|
+
const list = Array.isArray(t.attachments) ? t.attachments : [];
|
|
1882
|
+
const att = list.find(x => x.id === attId);
|
|
1883
|
+
if (att) {
|
|
1884
|
+
return { owner: 'task', task: t, list, att, storageDir: path.join(IMAGES_DIR, '_task-' + t.id) };
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
for (const t of data.tasks || []) {
|
|
1888
|
+
for (const s of t.subtasks || []) {
|
|
1889
|
+
const list = Array.isArray(s.attachments) ? s.attachments : [];
|
|
1890
|
+
const att = list.find(x => x.id === attId);
|
|
1891
|
+
if (att) {
|
|
1892
|
+
return { owner: 'sub', task: t, sub: s, list, att, storageDir: path.join(IMAGES_DIR, s.id) };
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
return null;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// 共享 helper:写入新附件(参数化以支持 task / sub)
|
|
1900
|
+
async function writeAttachmentTo({ req, target, maxCount }) {
|
|
1901
|
+
if (!req.body || !(req.body instanceof Buffer) || req.body.length === 0) {
|
|
1902
|
+
return { error: '请求体为空', status: 400 };
|
|
1903
|
+
}
|
|
1904
|
+
if (req.body.length > MAX_IMAGE_BYTES) {
|
|
1905
|
+
return { error: `单文件不得超过 ${MAX_IMAGE_BYTES / 1024 / 1024}MB`, status: 413 };
|
|
1906
|
+
}
|
|
1907
|
+
const originalName = String(req.get('X-Original-Name') || 'attachment').slice(0, 200);
|
|
1908
|
+
const mimeType = String(req.get('X-Mime-Type') || 'application/octet-stream').slice(0, 120);
|
|
1909
|
+
const ext = resolveExt({ originalName, mime: mimeType });
|
|
1910
|
+
if (!ext) {
|
|
1911
|
+
return { error: `不支持的文件类型(仅允许 ${[...ALLOWED_EXTS].join(', ')})`, status: 400 };
|
|
1912
|
+
}
|
|
1913
|
+
if (!Array.isArray(target.attachments)) target.attachments = [];
|
|
1914
|
+
if (target.attachments.length >= maxCount) {
|
|
1915
|
+
return { error: `附件已达上限 ${maxCount} 个`, status: 400 };
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
const attId = genId();
|
|
1919
|
+
await fsp.mkdir(target.storageDir, { recursive: true });
|
|
1920
|
+
const storedName = `${attId}.${ext}`;
|
|
1921
|
+
const storedPath = path.join(target.storageDir, storedName);
|
|
1922
|
+
await fsp.writeFile(storedPath, req.body);
|
|
1923
|
+
|
|
1924
|
+
const attachment = {
|
|
1925
|
+
id: attId,
|
|
1926
|
+
originalName,
|
|
1927
|
+
mimeType,
|
|
1928
|
+
size: req.body.length,
|
|
1929
|
+
ext,
|
|
1930
|
+
storedName,
|
|
1931
|
+
absolutePath: storedPath,
|
|
1932
|
+
createdAt: nowIso()
|
|
1933
|
+
};
|
|
1934
|
+
target.attachments.push(attachment);
|
|
1935
|
+
target.updatedAt = nowIso();
|
|
1936
|
+
return { attachment };
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
// 子任务附件
|
|
1940
|
+
app.post('/api/workbench/subtasks/:subId/attachments', rawAttachment, async (req, res) => {
|
|
1941
|
+
try {
|
|
1942
|
+
const { subId } = req.params;
|
|
1943
|
+
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
1944
|
+
let foundSub = null;
|
|
1945
|
+
for (const t of data.tasks || []) {
|
|
1946
|
+
const s = (t.subtasks || []).find(x => x.id === subId);
|
|
1947
|
+
if (s) { foundSub = s; break; }
|
|
1948
|
+
}
|
|
1949
|
+
if (!foundSub) {
|
|
1950
|
+
return res.status(404).json({ success: false, error: '子任务不存在' });
|
|
1951
|
+
}
|
|
1952
|
+
const target = { ...foundSub, storageDir: path.join(IMAGES_DIR, subId) };
|
|
1953
|
+
const result = await writeAttachmentTo({ req, target, maxCount: MAX_ATTACHMENTS_PER_SUBTASK });
|
|
1954
|
+
if (result.error) return res.status(result.status).json({ success: false, error: result.error });
|
|
1955
|
+
// target 是 spread 出来的浅拷贝,data 引用里的 foundSub 没改;显式 push 回去
|
|
1956
|
+
const att = result.attachment;
|
|
1957
|
+
foundSub.attachments = Array.isArray(foundSub.attachments) ? foundSub.attachments : [];
|
|
1958
|
+
foundSub.attachments.push(att);
|
|
1959
|
+
foundSub.updatedAt = nowIso();
|
|
1960
|
+
await writeJson(TASKS_FILE, data);
|
|
1961
|
+
res.json({ success: true, attachment: att });
|
|
1962
|
+
} catch (err) {
|
|
1963
|
+
res.status(500).json({ success: false, error: err.message });
|
|
1964
|
+
}
|
|
1965
|
+
});
|
|
1966
|
+
|
|
1967
|
+
app.delete('/api/workbench/subtasks/:subId/attachments/:attId', async (req, res) => {
|
|
1968
|
+
try {
|
|
1969
|
+
const { subId, attId } = req.params;
|
|
1970
|
+
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
1971
|
+
let foundSub = null;
|
|
1972
|
+
for (const t of data.tasks || []) {
|
|
1973
|
+
const s = (t.subtasks || []).find(x => x.id === subId);
|
|
1974
|
+
if (s) { foundSub = s; break; }
|
|
1975
|
+
}
|
|
1976
|
+
if (!foundSub) return res.status(404).json({ success: false, error: '子任务不存在' });
|
|
1977
|
+
const list = Array.isArray(foundSub.attachments) ? foundSub.attachments : [];
|
|
1978
|
+
const i = list.findIndex(a => a.id === attId);
|
|
1979
|
+
if (i < 0) return res.status(404).json({ success: false, error: '附件不存在' });
|
|
1980
|
+
const [removed] = list.splice(i, 1);
|
|
1981
|
+
try {
|
|
1982
|
+
await fsp.unlink(path.join(IMAGES_DIR, subId, removed.storedName));
|
|
1983
|
+
} catch { /* 文件可能已不存在 */ }
|
|
1984
|
+
foundSub.updatedAt = nowIso();
|
|
1985
|
+
await writeJson(TASKS_FILE, data);
|
|
1986
|
+
res.json({ success: true });
|
|
1987
|
+
} catch (err) {
|
|
1988
|
+
res.status(500).json({ success: false, error: err.message });
|
|
1989
|
+
}
|
|
1990
|
+
});
|
|
1991
|
+
|
|
1992
|
+
// 主任务附件
|
|
1993
|
+
app.post('/api/workbench/tasks/:taskId/attachments', rawAttachment, async (req, res) => {
|
|
1994
|
+
try {
|
|
1995
|
+
const { taskId } = req.params;
|
|
1996
|
+
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
1997
|
+
const task = (data.tasks || []).find(t => t.id === taskId);
|
|
1998
|
+
if (!task) return res.status(404).json({ success: false, error: '任务不存在' });
|
|
1999
|
+
const target = { ...task, storageDir: path.join(IMAGES_DIR, '_task-' + taskId) };
|
|
2000
|
+
const result = await writeAttachmentTo({ req, target, maxCount: MAX_ATTACHMENTS_PER_SUBTASK });
|
|
2001
|
+
if (result.error) return res.status(result.status).json({ success: false, error: result.error });
|
|
2002
|
+
const att = result.attachment;
|
|
2003
|
+
task.attachments = Array.isArray(task.attachments) ? task.attachments : [];
|
|
2004
|
+
task.attachments.push(att);
|
|
2005
|
+
task.updatedAt = nowIso();
|
|
2006
|
+
await writeJson(TASKS_FILE, data);
|
|
2007
|
+
res.json({ success: true, attachment: att });
|
|
2008
|
+
} catch (err) {
|
|
2009
|
+
res.status(500).json({ success: false, error: err.message });
|
|
2010
|
+
}
|
|
2011
|
+
});
|
|
2012
|
+
|
|
2013
|
+
app.delete('/api/workbench/tasks/:taskId/attachments/:attId', async (req, res) => {
|
|
2014
|
+
try {
|
|
2015
|
+
const { taskId, attId } = req.params;
|
|
2016
|
+
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
2017
|
+
const task = (data.tasks || []).find(t => t.id === taskId);
|
|
2018
|
+
if (!task) return res.status(404).json({ success: false, error: '任务不存在' });
|
|
2019
|
+
const list = Array.isArray(task.attachments) ? task.attachments : [];
|
|
2020
|
+
const i = list.findIndex(a => a.id === attId);
|
|
2021
|
+
if (i < 0) return res.status(404).json({ success: false, error: '附件不存在' });
|
|
2022
|
+
const [removed] = list.splice(i, 1);
|
|
2023
|
+
try {
|
|
2024
|
+
await fsp.unlink(path.join(IMAGES_DIR, '_task-' + taskId, removed.storedName));
|
|
2025
|
+
} catch { /* 文件可能已不存在 */ }
|
|
2026
|
+
task.updatedAt = nowIso();
|
|
2027
|
+
await writeJson(TASKS_FILE, data);
|
|
2028
|
+
res.json({ success: true });
|
|
2029
|
+
} catch (err) {
|
|
2030
|
+
res.status(500).json({ success: false, error: err.message });
|
|
2031
|
+
}
|
|
2032
|
+
});
|
|
2033
|
+
|
|
2034
|
+
// 附件原文件读取(前端 <img> 缩略图用)—— 支持 task 和 sub 两种归属
|
|
2035
|
+
app.get('/api/workbench/attachments/:attId/raw', async (req, res) => {
|
|
2036
|
+
try {
|
|
2037
|
+
const { attId } = req.params;
|
|
2038
|
+
const loc = await findAttachmentLocation(attId);
|
|
2039
|
+
if (!loc) return res.status(404).json({ success: false, error: '附件不存在' });
|
|
2040
|
+
const filePath = path.join(loc.storageDir, loc.att.storedName);
|
|
2041
|
+
try {
|
|
2042
|
+
const stat = await fsp.stat(filePath);
|
|
2043
|
+
res.set('Content-Type', loc.att.mimeType || 'application/octet-stream');
|
|
2044
|
+
res.set('Content-Length', String(stat.size));
|
|
2045
|
+
res.set('Cache-Control', 'private, max-age=3600');
|
|
2046
|
+
const stream = (await import('fs')).createReadStream(filePath);
|
|
2047
|
+
stream.on('error', () => res.end());
|
|
2048
|
+
stream.pipe(res);
|
|
2049
|
+
} catch {
|
|
2050
|
+
res.status(404).json({ success: false, error: '文件已丢失' });
|
|
2051
|
+
}
|
|
2052
|
+
} catch (err) {
|
|
2053
|
+
res.status(500).json({ success: false, error: err.message });
|
|
2054
|
+
}
|
|
2055
|
+
});
|
|
2056
|
+
}
|