zen-gitsync 2.17.10 → 2.17.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +34 -0
  2. package/package.json +3 -1
  3. package/src/ui/public/assets/AgentView-BNH92bKu.js +5 -0
  4. package/src/ui/public/assets/AppVersionBadge-DXWTExsY.js +2 -0
  5. package/src/ui/public/assets/BranchSelector-CJI-I8JY.js +1 -0
  6. package/src/ui/public/assets/CommitForm-CTYhgpkj.js +6 -0
  7. package/src/ui/public/assets/CommonDialog-DNnN70HC.js +1 -0
  8. package/src/ui/public/assets/EditorView-C2AbXelw.js +1 -0
  9. package/src/ui/public/assets/FileDiffViewer-ZD705xXx.js +14 -0
  10. package/src/ui/public/assets/{FlowExecutionViewer-BgEt5_Hz.js → FlowExecutionViewer-BP9w_OQb.js} +1 -1
  11. package/src/ui/public/assets/FlowOrchestrationWorkspace-DeSI8Mlj.js +21 -0
  12. package/src/ui/public/assets/IconButton-BxRsG5E1.js +1 -0
  13. package/src/ui/public/assets/LogList-1sIFUx4e.js +7 -0
  14. package/src/ui/public/assets/MindmapView-6_fbvhh3.js +1 -0
  15. package/src/ui/public/assets/MonitorView-CURagJDe.js +1 -0
  16. package/src/ui/public/assets/ProjectStartupButton-COyb4Fbk.js +1 -0
  17. package/src/ui/public/assets/RemoteManagerDialog-DoS9EWQr.js +1 -0
  18. package/src/ui/public/assets/RemoteRepoCard-BwrdI5UH.js +1 -0
  19. package/src/ui/public/assets/SourceMapView-DADzBGfb.js +3 -0
  20. package/src/ui/public/assets/SvgIcon-VCh_kGGV.js +1 -0
  21. package/src/ui/public/assets/TemplateManager-C4go4RZu.js +1 -0
  22. package/src/ui/public/assets/UserInputNode-Cl5-bI42.js +1 -0
  23. package/src/ui/public/assets/WorkbenchView-B8QOp8nM.css +1 -0
  24. package/src/ui/public/assets/WorkbenchView-DtCyyW3b.js +6 -0
  25. package/src/ui/public/assets/_plugin-vue_export-helper-Dcylt9fO.js +19 -0
  26. package/src/ui/public/assets/{configStore-BLRcYJdf.js → configStore-BlqdtNUA.js} +1 -1
  27. package/src/ui/public/assets/element-plus-C1PloHQu.css +1 -0
  28. package/src/ui/public/assets/{element-plus-DcZ-TVp6.js → element-plus-IG-bwpR5.js} +6 -6
  29. package/src/ui/public/assets/flow-mindmap-C_w8GJXm.js +139 -0
  30. package/src/ui/public/assets/{gitStore-DMEDMZMk.js → gitStore-BKkZm2m3.js} +4 -4
  31. package/src/ui/public/assets/index-Bq7iCtof.css +1 -0
  32. package/src/ui/public/assets/index-DGHrUfrh.js +28 -0
  33. package/src/ui/public/assets/{office-docx-C0g5Oepv.js → office-docx-CkLOvV7H.js} +5 -5
  34. package/src/ui/public/assets/{office-excel-D-uYLKaA.js → office-excel-CDjN3Va1.js} +1 -1
  35. package/src/ui/public/assets/{office-pptx-De3fnc6M.js → office-pptx-CVu3fXsL.js} +1 -1
  36. package/src/ui/public/assets/previewSandbox-DYMhXJ91.js +0 -0
  37. package/src/ui/public/assets/toolsStore-BlRHNqXl.js +1 -0
  38. package/src/ui/public/assets/{useThemeObserver-CG8k8U18.js → useThemeObserver-DAWKXvWv.js} +1 -1
  39. package/src/ui/public/assets/{vendor-BIgZEtkx.js → vendor-DcK6TCKZ.js} +27 -27
  40. package/src/ui/public/assets/vue-flow-B3gZmkCI.js +39 -0
  41. package/src/ui/public/index.html +18 -18
  42. package/src/ui/server/index.js +8 -0
  43. package/src/ui/server/routes/recentDirectoriesAiSummary.js +288 -0
  44. package/src/ui/server/routes/recentDirectoriesAiSummary.test.js +208 -0
  45. package/src/ui/server/routes/remoteRepos.js +117 -21
  46. package/src/ui/server/routes/remoteRepos.test.js +167 -1
  47. package/src/ui/server/routes/workbench/agentChat.js +9 -3
  48. package/src/ui/server/routes/workbench/agentParity.test.js +16 -0
  49. package/src/ui/server/routes/workbench/agentSessionStore.js +2 -2
  50. package/src/ui/server/routes/workbench/envContext.test.js +8 -8
  51. package/src/ui/server/routes/workbench/index.js +32 -736
  52. package/src/ui/server/routes/workbench/instructionStore.js +3 -113
  53. package/src/ui/server/routes/workbench/jobStore.js +2 -4
  54. package/src/ui/server/routes/workbench/jobStore.test.js +1 -1
  55. package/src/ui/server/routes/workbench/jsonParse.js +6 -143
  56. package/src/ui/server/routes/workbench/orchestratorStore.js +1 -8
  57. package/src/ui/server/routes/workbench/projectRegistry.js +6 -29
  58. package/src/ui/server/routes/workbench/projectRegistry.test.js +33 -56
  59. package/src/ui/server/routes/workbench/projectTool.js +0 -1
  60. package/src/ui/server/routes/workbench/projectTool.test.js +2 -3
  61. package/src/ui/server/routes/workbench/shared.js +2 -7
  62. package/src/ui/server/routes/workbench/taskRunner.js +18 -180
  63. package/src/ui/server/routes/workbench.js +4 -5
  64. package/src/ui/public/assets/AgentView-C-AJo7KS.js +0 -5
  65. package/src/ui/public/assets/AppVersionBadge-B6R6eN2N.js +0 -2
  66. package/src/ui/public/assets/BranchSelector-Cj3kMWON.js +0 -1
  67. package/src/ui/public/assets/CommitForm-DpXuFG-F.js +0 -6
  68. package/src/ui/public/assets/CommonDialog-BLPHDsn0.js +0 -1
  69. package/src/ui/public/assets/EditorView-B1OnoXXf.js +0 -1
  70. package/src/ui/public/assets/FileDiffViewer-BQl0JcWd.js +0 -14
  71. package/src/ui/public/assets/FlowOrchestrationWorkspace-CbLmsSF8.js +0 -21
  72. package/src/ui/public/assets/IconButton-aM3vaSIN.js +0 -1
  73. package/src/ui/public/assets/LogList-UjoVFZ-H.js +0 -7
  74. package/src/ui/public/assets/MindmapView-D_04v0n4.js +0 -1
  75. package/src/ui/public/assets/MonitorView-BD1_uzq7.js +0 -1
  76. package/src/ui/public/assets/ProjectStartupButton-BTYlVvzh.js +0 -1
  77. package/src/ui/public/assets/RemoteManagerDialog-20mpQMwu.js +0 -1
  78. package/src/ui/public/assets/RemoteRepoCard-DOrm17Xd.js +0 -1
  79. package/src/ui/public/assets/SourceMapView-nUU9bYPh.js +0 -3
  80. package/src/ui/public/assets/SvgIcon-LUynX3Iq.js +0 -1
  81. package/src/ui/public/assets/TemplateManager-Bo3Q7J9Y.js +0 -1
  82. package/src/ui/public/assets/UserInputNode-D5tSXiJz.js +0 -1
  83. package/src/ui/public/assets/WorkbenchView-C_Gmzvj1.css +0 -1
  84. package/src/ui/public/assets/WorkbenchView-UGC334JL.js +0 -13
  85. package/src/ui/public/assets/_plugin-vue_export-helper-D-D9qkRz.js +0 -19
  86. package/src/ui/public/assets/element-plus-BWbXh9mk.css +0 -1
  87. package/src/ui/public/assets/flow-mindmap-D1VTkZh3.js +0 -139
  88. package/src/ui/public/assets/index-CW3xs-af.css +0 -1
  89. package/src/ui/public/assets/index-S0_gRyiO.js +0 -26
  90. package/src/ui/public/assets/previewSandbox-DXAxqDVu.js +0 -0
  91. package/src/ui/public/assets/toolsStore-CpImRUhI.js +0 -1
  92. package/src/ui/public/assets/vue-flow-CPCFgenl.js +0 -39
  93. package/src/ui/server/routes/workbench/sessionStore.js +0 -126
@@ -17,16 +17,15 @@
17
17
  // 历史背景:本文件曾是 3539 行的巨型文件,包含所有顶层工具函数 + 45 个路由端点。
18
18
  // 2026-06-29 按业务域拆分为 workbench/ 子目录的 9 个模块:
19
19
  // - shared.js 常量 + 通用工具 (nowIso, genId, readJson, writeJson, interpolate)
20
- // - jsonParse.js JSON 解析降级链 (parseSubtaskJson 等)
20
+ // - jsonParse.js 从 LLM 输出里抠 JSON 的纯函数
21
21
  // - llmClient.js LLM 客户端 (callLlmJson, callLlmStream)
22
22
  // - projectScan.js 子项目识别 (findSubProjects, detectProjectManifest)
23
23
  // - attachmentUtils.js 附件白名单 (sanitizeExt, resolveExt, MIME_TO_EXT)
24
- // - sessionStore.js AI 对话拆分会话持久化
25
24
  // - instructionStore.js AI 指令读写
26
25
  // - jobStore.js jobs Map + bus + 持久化 + retention
27
- // - taskRunner.js 任务执行引擎 (runTaskQueue, runSingleSubtask)
26
+ // - taskRunner.js 任务执行引擎 (runSingleSubtask —— 一次任务 = 一次会话)
28
27
  //
29
- // 本文件(index.js)只做 registerWorkbenchRoutes 入口聚合,按业务域分节注册 45 个路由。
28
+ // 本文件(index.js)只做 registerWorkbenchRoutes 入口聚合,按业务域分节注册路由。
30
29
  // 错误处理:
31
30
  // - 非 SSE 路由统一用 asyncRoute 包装,handler 内部不再写 try/catch
32
31
  // - 异常自动走 asyncRoute → 全局 errorHandler 中间件(server/index.js 注册)
@@ -48,16 +47,14 @@ import {
48
47
  JOBS_FILE,
49
48
  ORCHESTRATOR_FILE,
50
49
  IMAGES_DIR,
51
- SUBTASK_INSTRUCTION_FILE,
52
50
  MAX_IMAGE_BYTES,
53
- MAX_ATTACHMENTS_PER_SUBTASK,
51
+ MAX_ATTACHMENTS_PER_TASK,
54
52
  MAX_DEFAULT_PROMPT_CHARS,
55
53
  readJson,
56
54
  writeJson,
57
55
  nowIso,
58
56
  genId,
59
57
  } from './shared.js';
60
- import { parseSubtaskJson } from './jsonParse.js';
61
58
  import { callLlmJson, callLlmJsonWithRetry, callLlmStream } from './llmClient.js';
62
59
  import {
63
60
  findSubProjects,
@@ -78,15 +75,10 @@ import {
78
75
  findStagingFile,
79
76
  cleanupDispatchStaging,
80
77
  } from './attachmentUtils.js';
81
- import { sessionStore } from './sessionStore.js';
82
78
  import {
83
79
  DEFAULT_INSTRUCTION,
84
- DEFAULT_SUBTASK_INSTRUCTION_ZH,
85
- pickDefaultSubtaskInstruction,
86
80
  readInstruction,
87
81
  writeInstruction,
88
- readSubtaskInstruction,
89
- writeSubtaskInstruction,
90
82
  } from './instructionStore.js';
91
83
  import {
92
84
  jobs,
@@ -104,15 +96,8 @@ import {
104
96
  mergedJobs,
105
97
  } from './jobStore.js';
106
98
  import {
107
- launchClaudeInNewWindow,
108
99
  normalizeTaskExecutor,
109
- runTaskQueue,
110
100
  runSingleSubtask,
111
- syncSubToCancelled,
112
- persistTaskAfterRun,
113
- collectPriorOutputs,
114
- collectPriorOutputsUpTo,
115
- waitProcessExit,
116
101
  setEnvContextProvider,
117
102
  } from './taskRunner.js';
118
103
  import {
@@ -137,8 +122,6 @@ import {
137
122
  buildRunningAgents,
138
123
  } from './orchestratorStore.js';
139
124
 
140
- const { genSessionId, read: readSessionFile, write: writeSessionFile, delete: deleteSessionFile, listMeta: listSessionsMeta, enforceRetention: enforceSessionsRetention } = sessionStore;
141
-
142
125
  /**
143
126
  * 解析本次执行用哪个执行器:body.executor 显式指定 > 配置里的全局默认 > 'claude'。
144
127
  * 配置读失败一律回落 'claude' —— 执行器选不出来不该挡住任务本身能跑。
@@ -341,463 +324,7 @@ ${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summar
341
324
  }));
342
325
 
343
326
  // ════════════════════════════════════════════════════════════════════════
344
- // §3. AI 拆分子任务指令:读 / 写
345
- // ════════════════════════════════════════════════════════════════════════
346
- app.get('/api/workbench/tasks/ai-subtask-instruction', asyncRoute(async (req, res) => {
347
- const def = pickDefaultSubtaskInstruction(req);
348
- const instruction = await readSubtaskInstruction(req);
349
- res.json({ success: true, instruction, isDefault: instruction === def });
350
- }));
351
-
352
- app.put('/api/workbench/tasks/ai-subtask-instruction', asyncRoute(async (req, res) => {
353
- const text = req.body && typeof req.body.instruction === 'string'
354
- ? req.body.instruction.trim()
355
- : '';
356
- if (!text) throw new HttpError(400, '指令不能为空');
357
- if (text.length > 500000) throw new HttpError(413, '指令过长(最多 500000 字符)');
358
- // 如果保存的文本正好等于当前 locale 的默认——不写文件,保持 fallback 行为
359
- const def = pickDefaultSubtaskInstruction(req);
360
- if (text === def) {
361
- try { await fsp.unlink(SUBTASK_INSTRUCTION_FILE); } catch { /* 已不存在 */ }
362
- return res.json({ success: true, isDefault: true });
363
- }
364
- await writeSubtaskInstruction(text);
365
- res.json({ success: true, isDefault: false });
366
- }));
367
-
368
- // ════════════════════════════════════════════════════════════════════════
369
- // §4. AI 拆分子任务(SSE 流式)— 保留 try/catch,错误以 SSE 帧发出
370
- // ════════════════════════════════════════════════════════════════════════
371
- app.post('/api/workbench/tasks/ai-split-subtasks', async (req, res) => {
372
- const title = String(req.body?.title || '').trim();
373
- const desc = String(req.body?.desc || '').trim();
374
- const taskId = String(req.body?.taskId || '').trim();
375
- const promptId = String(req.body?.promptId || '').trim();
376
- const customUserBlock = typeof req.body?.customUserBlock === 'string' ? req.body.customUserBlock : '';
377
- if (!title) {
378
- return res.status(400).json({ success: false, error: '任务标题不能为空' });
379
- }
380
-
381
- // 建立 SSE
382
- res.set({
383
- 'Content-Type': 'text/event-stream',
384
- 'Cache-Control': 'no-cache, no-transform',
385
- 'Connection': 'keep-alive',
386
- 'X-Accel-Buffering': 'no'
387
- });
388
- res.flushHeaders?.();
389
- const send = (obj) => {
390
- try { res.write(`data: ${JSON.stringify(obj)}\n\n`); } catch {}
391
- };
392
-
393
- const abortController = new AbortController();
394
- let finished = false;
395
- // 客户端真实断开:监听 socket close
396
- const onSocketClose = () => {
397
- if (!finished) abortController.abort();
398
- };
399
- if (req.socket) req.socket.once('close', onSocketClose);
400
-
401
- try {
402
- let model;
403
- try {
404
- if (!configManager) throw new Error('configManager 不可用');
405
- const rawConfig = await configManager.readRawConfigFile();
406
- const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
407
- model = models.find(m => m.isDefault) || models[0];
408
- } catch (err) {
409
- send({ type: 'error', error: '读取 AI 配置失败: ' + err.message });
410
- finished = true;
411
- return res.end();
412
- }
413
- if (!model) {
414
- send({ type: 'error', error: '未配置 AI 模型,请先在通用设置中添加模型' });
415
- finished = true;
416
- return res.end();
417
- }
418
-
419
- const userInstruction = await readSubtaskInstruction(req);
420
- const projectPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
421
- const projectName = projectPath ? path.basename(projectPath) : '(未指定项目)';
422
- const manifestHint = await detectProjectManifest(projectPath);
423
-
424
- // 取绑定的预置模板(promptId)
425
- let templateBlock = '';
426
- if (promptId) {
427
- try {
428
- const promptData = await readJson(PROMPTS_FILE, { prompts: [] });
429
- const p = (promptData.prompts || []).find(x => x.id === promptId);
430
- if (p && p.content) {
431
- templateBlock = `\n\n## 子任务执行模板(每个拆出的子任务最终会被这套模板包裹后送给 claude 执行;拆分时请确保子任务能让模板里的 {{sub.title}} / {{sub.desc}} 等变量填得有意义)\n模板名:${p.name || '(未命名)'}\n---\n${p.content}\n---`;
432
- }
433
- } catch { /* 模板读取失败不影响拆分 */ }
434
- }
435
-
436
- // 取任务附件
437
- let attachmentBlock = '';
438
- const imageDataUrls = [];
439
- if (taskId) {
440
- try {
441
- const data = await readJson(TASKS_FILE, { tasks: [] });
442
- const task = (data.tasks || []).find(t => t.id === taskId);
443
- const atts = Array.isArray(task?.attachments) ? task.attachments : [];
444
- if (atts.length > 0) {
445
- const lines = [];
446
- for (let i = 0; i < atts.length; i++) {
447
- const a = atts[i];
448
- if (!a || !a.absolutePath) continue;
449
- lines.push(` ${i + 1}. [${a.mimeType || 'application/octet-stream'}] ${a.absolutePath}`);
450
- if (isImageExt(a.ext)) {
451
- try {
452
- const buf = await fsp.readFile(a.absolutePath);
453
- const mime = a.mimeType || 'image/png';
454
- imageDataUrls.push(`data:${mime};base64,${buf.toString('base64')}`);
455
- } catch { /* 文件丢失就跳过这张图 */ }
456
- }
457
- }
458
- if (lines.length > 0) {
459
- const imgNote = imageDataUrls.length > 0
460
- ? `(其中 ${imageDataUrls.length} 张图片已随消息一并发送,请直接基于图片内容拆分)`
461
- : '';
462
- attachmentBlock = `\n\n## 任务附件${imgNote}\n${lines.join('\n')}`;
463
- }
464
- }
465
- } catch { /* 没拿到附件不影响拆分 */ }
466
- }
467
-
468
- const userBlock = customUserBlock.trim() ? customUserBlock : `${userInstruction}
469
-
470
- ---
471
-
472
- ## 待拆分的任务
473
- 标题:${title}
474
- ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateBlock}
475
-
476
- ## 项目上下文(仅供参考,便于拆分时考虑项目特性)
477
- - 项目名称:${projectName}
478
- - 项目根目录:${projectPath || '(未指定)'}
479
- - 主要 manifest:${manifestHint || '(未识别到)'}
480
-
481
- 请先仔细分析(可以放在 reasoning 中或直接写出来)。仔细分析指:列出 5 个维度——任务真实目标 / 关键技术栈与边界 / 自然执行顺序 / 风险点与可能失败步骤 / 是否需要前置调研——不要简短一两句话就过;分析过后再给出 JSON。JSON 用 \`\`\`json ... \`\`\` 包裹:
482
- {
483
- "subtasks": [
484
- { "title": "子任务标题", "desc": "具体描述" }
485
- ]
486
- }
487
-
488
- **JSON 输出严格要求**(不遵守会导致解析失败、用户无法入库):
489
- 1. title 和 desc 内如需引用术语 / 页面名 / 状态名,**必须使用中文引号「」或『』**,禁止使用 ASCII 双引号、单引号或反引号,否则会破坏外层 JSON 结构。
490
- 2. JSON 中不允许尾随逗号(最后一个元素后面不能跟逗号)。
491
- 3. JSON 中不允许写注释。
492
- 4. 所有字符串字段必须用 ASCII 双引号包裹,字符串内部如有换行用 \\n 转义。`;
493
-
494
- // 先把 prompt 元信息推给前端
495
- send({ type: 'meta', prompt: { system: userInstruction, user: userBlock } });
496
-
497
- // 流式调用 LLM
498
- const { content, aborted } = await callLlmStream(
499
- model,
500
- userBlock,
501
- (delta) => {
502
- if (delta.thinking) send({ type: 'thinking', delta: delta.thinking });
503
- if (delta.content) send({ type: 'content', delta: delta.content });
504
- },
505
- { maxTokens: 32000, timeoutMs: 600000, images: imageDataUrls, signal: abortController.signal }
506
- );
507
-
508
- if (aborted) {
509
- send({ type: 'error', error: '已取消' });
510
- finished = true;
511
- return res.end();
512
- }
513
-
514
- const { parsed, parseError, parseStage } = parseSubtaskJson(content);
515
- const list = Array.isArray(parsed?.subtasks) ? parsed.subtasks : [];
516
- const subtasks = list
517
- .map(s => ({
518
- title: String(s?.title || '').trim(),
519
- desc: String(s?.desc || '').trim()
520
- }))
521
- .filter(s => s.title);
522
-
523
- send({ type: 'done', subtasks, raw: content, parseError, parseStage });
524
- finished = true;
525
- res.end();
526
- } catch (err) {
527
- send({ type: 'error', error: 'AI 拆分失败: ' + (err?.message || String(err)) });
528
- finished = true;
529
- res.end();
530
- }
531
- });
532
-
533
- // ════════════════════════════════════════════════════════════════════════
534
- // §5. AI 拆分预览(不调 LLM,只返回拼好的 prompt)
535
- // ════════════════════════════════════════════════════════════════════════
536
- app.post('/api/workbench/tasks/ai-split-preview', asyncRoute(async (req, res) => {
537
- const title = String(req.body?.title || '').trim();
538
- const desc = String(req.body?.desc || '').trim();
539
- const taskId = String(req.body?.taskId || '').trim();
540
- const promptId = String(req.body?.promptId || '').trim();
541
- if (!title) throw new HttpError(400, '任务标题不能为空');
542
-
543
- const userInstruction = await readSubtaskInstruction(req);
544
- const projectPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
545
- const projectName = projectPath ? path.basename(projectPath) : '(未指定项目)';
546
- const manifestHint = await detectProjectManifest(projectPath);
547
-
548
- let templateBlock = '';
549
- if (promptId) {
550
- try {
551
- const promptData = await readJson(PROMPTS_FILE, { prompts: [] });
552
- const p = (promptData.prompts || []).find(x => x.id === promptId);
553
- if (p && p.content) {
554
- templateBlock = `\n\n## 子任务执行模板(每个拆出的子任务最终会被这套模板包裹后送给 claude 执行;拆分时请确保子任务能让模板里的 {{sub.title}} / {{sub.desc}} 等变量填得有意义)\n模板名:${p.name || '(未命名)'}\n---\n${p.content}\n---`;
555
- }
556
- } catch { /* 模板读取失败不影响预览 */ }
557
- }
558
-
559
- // 附件:PDF 预提取全文,图片只计数(preview 阶段不需要 image_data_url)
560
- let attachmentBlock = '';
561
- let imageCount = 0;
562
- if (taskId) {
563
- try {
564
- const data = await readJson(TASKS_FILE, { tasks: [] });
565
- const task = (data.tasks || []).find(t => t.id === taskId);
566
- const atts = Array.isArray(task?.attachments) ? task.attachments : [];
567
- if (atts.length > 0) {
568
- imageCount = atts.filter(a => a && isImageExt(a.ext)).length;
569
- const result = await buildAttachmentBlock(atts, { withImageData: false });
570
- attachmentBlock = result.block;
571
- }
572
- } catch { /* 没拿到附件不影响预览 */ }
573
- }
574
-
575
- const userBlock = `${userInstruction}
576
-
577
- ---
578
-
579
- ## 待拆分的任务
580
- 标题:${title}
581
- ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateBlock}
582
-
583
- ## 项目上下文(仅供参考,便于拆分时考虑项目特性)
584
- - 项目名称:${projectName}
585
- - 项目根目录:${projectPath || '(未指定)'}
586
- - 主要 manifest:${manifestHint || '(未识别到)'}
587
-
588
- 请先仔细分析(可以放在 reasoning 中或直接写出来)。仔细分析指:列出 5 个维度——任务真实目标 / 关键技术栈与边界 / 自然执行顺序 / 风险点与可能失败步骤 / 是否需要前置调研——不要简短一两句话就过;分析过后再给出 JSON。JSON 用 \`\`\`json ... \`\`\` 包裹:
589
- {
590
- "subtasks": [
591
- { "title": "子任务标题", "desc": "具体描述" }
592
- ]
593
- }
594
-
595
- **JSON 输出严格要求**(不遵守会导致解析失败、用户无法入库):
596
- 1. title 和 desc 内如需引用术语 / 页面名 / 状态名,**必须使用中文引号「」或『』**,禁止使用 ASCII 双引号、单引号或反引号,否则会破坏外层 JSON 结构。
597
- 2. JSON 中不允许尾随逗号(最后一个元素后面不能跟逗号)。
598
- 3. JSON 中不允许写注释。
599
- 4. 所有字符串字段必须用 ASCII 双引号包裹,字符串内部如有换行用 \\n 转义。`;
600
-
601
- res.json({
602
- success: true,
603
- system: userInstruction,
604
- user: userBlock,
605
- hasImages: imageCount > 0,
606
- imageCount
607
- });
608
- }));
609
-
610
- // ════════════════════════════════════════════════════════════════════════
611
- // §6. 解析子任务 JSON(前端用户手改后调)
612
- // ════════════════════════════════════════════════════════════════════════
613
- app.post('/api/workbench/tasks/parse-subtasks', asyncRoute(async (req, res) => {
614
- const raw = String(req.body?.raw || '');
615
- const { parsed, parseError, parseStage } = parseSubtaskJson(raw);
616
- const list = Array.isArray(parsed?.subtasks) ? parsed.subtasks : [];
617
- const subtasks = list
618
- .map(s => ({
619
- title: String(s?.title || '').trim(),
620
- desc: String(s?.desc || '').trim()
621
- }))
622
- .filter(s => s.title);
623
- res.json({ success: true, subtasks, parseError, parseStage });
624
- }));
625
-
626
- // ════════════════════════════════════════════════════════════════════════
627
- // §7. AI 对话拆分(多轮 SSE)— 保留 try/catch
628
- // ════════════════════════════════════════════════════════════════════════
629
- app.post('/api/workbench/tasks/ai-chat-split', async (req, res) => {
630
- const userMessage = String(req.body?.userMessage || '').trim();
631
- const sessionIdInput = String(req.body?.sessionId || '').trim();
632
- const title = String(req.body?.title || '').trim();
633
- const desc = String(req.body?.desc || '').trim();
634
- const taskId = String(req.body?.taskId || '').trim();
635
- const promptId = String(req.body?.promptId || '').trim();
636
-
637
- // SSE 头
638
- res.set({
639
- 'Content-Type': 'text/event-stream',
640
- 'Cache-Control': 'no-cache, no-transform',
641
- 'Connection': 'keep-alive',
642
- 'X-Accel-Buffering': 'no'
643
- });
644
- res.flushHeaders?.();
645
- const send = (obj) => {
646
- try { res.write(`data: ${JSON.stringify(obj)}\n\n`); } catch {}
647
- };
648
- const abortController = new AbortController();
649
- let finished = false;
650
- if (req.socket) req.socket.once('close', () => { if (!finished) abortController.abort(); });
651
-
652
- try {
653
- // 加载或新建 session
654
- let session, isNew = false;
655
- if (sessionIdInput) {
656
- try {
657
- session = await readSessionFile(sessionIdInput);
658
- } catch (err) {
659
- if (err.statusCode === 404) {
660
- send({ type: 'error', error: '会话不存在' });
661
- return res.end();
662
- }
663
- throw err;
664
- }
665
- } else {
666
- if (!title) {
667
- send({ type: 'error', error: '新建会话时必须提供 title' });
668
- return res.end();
669
- }
670
- session = {
671
- version: 1,
672
- sessionId: genSessionId(),
673
- title, desc, taskId, promptId,
674
- createdAt: nowIso(),
675
- updatedAt: nowIso(),
676
- messages: [],
677
- latestSubtasks: [],
678
- latestRaw: '',
679
- latestParseStage: ''
680
- };
681
- isNew = true;
682
- }
683
-
684
- if (!userMessage) {
685
- send({ type: 'error', error: '消息内容不能为空' });
686
- return res.end();
687
- }
688
-
689
- // 拼 system message(只在第一轮)
690
- if (session.messages.length === 0) {
691
- const userInstruction = await readSubtaskInstruction(req);
692
- const ctxLines = [];
693
- if (title) ctxLines.push(`【任务标题】${title}`);
694
- if (desc) ctxLines.push(`【任务描述】${desc}`);
695
- const sysContent = ctxLines.length
696
- ? `${userInstruction}\n\n${ctxLines.join('\n')}`
697
- : userInstruction;
698
- session.messages.push({ role: 'system', content: sysContent });
699
- }
700
-
701
- // 追加 user message
702
- session.messages.push({ role: 'user', content: userMessage });
703
-
704
- // 推 meta + user_echo
705
- send({
706
- type: 'meta',
707
- sessionId: session.sessionId,
708
- isNew,
709
- prompt: { system: session.messages[0].content, user: userMessage }
710
- });
711
- send({ type: 'user_echo', userMessage });
712
-
713
- // 加载 model
714
- let model;
715
- try {
716
- if (!configManager) throw new Error('configManager 不可用');
717
- const rawConfig = await configManager.readRawConfigFile();
718
- const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
719
- model = models.find(m => m.isDefault) || models[0];
720
- } catch (err) {
721
- send({ type: 'error', error: '读取 AI 配置失败: ' + err.message });
722
- finished = true;
723
- return res.end();
724
- }
725
- if (!model) {
726
- send({ type: 'error', error: '未配置 AI 模型' });
727
- finished = true;
728
- return res.end();
729
- }
730
-
731
- // 流式调用(messages 模式)
732
- const { content, aborted } = await callLlmStream(
733
- model,
734
- session.messages,
735
- (delta) => {
736
- if (delta.thinking) send({ type: 'thinking', delta: delta.thinking });
737
- if (delta.content) send({ type: 'content', delta: delta.content });
738
- },
739
- { maxTokens: 32000, timeoutMs: 600000, signal: abortController.signal }
740
- );
741
-
742
- if (aborted) {
743
- session.updatedAt = nowIso();
744
- await writeSessionFile(session.sessionId, session).catch(() => {});
745
- enforceSessionsRetention().catch(() => {});
746
- send({ type: 'error', error: '已取消' });
747
- finished = true;
748
- return res.end();
749
- }
750
-
751
- // 解析 + 写盘
752
- const { parsed, parseError, parseStage } = parseSubtaskJson(content);
753
- const subtasks = Array.isArray(parsed?.subtasks)
754
- ? parsed.subtasks
755
- .map(s => ({
756
- title: String(s?.title || '').trim(),
757
- desc: String(s?.desc || '').trim()
758
- }))
759
- .filter(s => s.title)
760
- : [];
761
-
762
- session.messages.push({ role: 'assistant', content });
763
- session.latestSubtasks = subtasks;
764
- session.latestRaw = content;
765
- session.latestParseStage = parseStage;
766
- session.updatedAt = nowIso();
767
- await writeSessionFile(session.sessionId, session);
768
- enforceSessionsRetention().catch(() => {});
769
-
770
- send({ type: 'done', subtasks, raw: content, parseError, parseStage });
771
- finished = true;
772
- res.end();
773
- } catch (err) {
774
- send({ type: 'error', error: '对话拆分失败: ' + (err?.message || String(err)) });
775
- finished = true;
776
- res.end();
777
- }
778
- });
779
-
780
- // ════════════════════════════════════════════════════════════════════════
781
- // §8. AI 对话拆分会话:列表 / 详情 / 删除
782
- // ════════════════════════════════════════════════════════════════════════
783
- app.get('/api/workbench/tasks/ai-chat-sessions', asyncRoute(async (_req, res) => {
784
- const sessions = await listSessionsMeta();
785
- res.json({ success: true, sessions });
786
- }));
787
-
788
- app.get('/api/workbench/tasks/ai-chat-sessions/:sessionId', asyncRoute(async (req, res) => {
789
- // readSessionFile 抛带 statusCode 的 Error(400/404),让 asyncRoute 透传
790
- const session = await readSessionFile(req.params.sessionId);
791
- res.json({ success: true, session });
792
- }));
793
-
794
- app.delete('/api/workbench/tasks/ai-chat-sessions/:sessionId', asyncRoute(async (req, res) => {
795
- await deleteSessionFile(req.params.sessionId);
796
- res.json({ success: true });
797
- }));
798
-
799
- // ════════════════════════════════════════════════════════════════════════
800
- // §9. SSE 事件流(订阅 job/sub/task 更新)
327
+ // §3. SSE 事件流(订阅 job/sub/task 更新)
801
328
  // ════════════════════════════════════════════════════════════════════════
802
329
  app.get('/api/workbench/events', asyncRoute(async (req, res) => {
803
330
  res.set({
@@ -954,10 +481,8 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
954
481
  }));
955
482
 
956
483
  app.post('/api/workbench/tasks', asyncRoute(async (req, res) => {
957
- const { id, title, desc, promptId, subtasks, type: rawType, simpleOverride, sequential: rawSequential } = req.body || {};
484
+ const { id, title, desc, promptId, simpleOverride } = req.body || {};
958
485
  const safeTitle = typeof title === 'string' ? title.trim() : '';
959
- const taskType = rawType === 'simple' ? 'simple' : 'complex';
960
- const sequential = rawSequential === false ? false : true;
961
486
  const safeOverride = typeof simpleOverride === 'string' ? simpleOverride.slice(0, 8000) : '';
962
487
  // 显式指定的归属项目(多项目编排台传选中项目);不传则沿用当前项目。
963
488
  // 只影响**新建**,更新分支一律保留任务原有的 projectPath(否则编辑一次就会把任务挪到当前项目去)。
@@ -974,28 +499,7 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
974
499
  title: safeTitle,
975
500
  desc: desc || '',
976
501
  promptId: promptId || null,
977
- type: taskType,
978
- sequential: taskType === 'complex' ? sequential : true,
979
- simpleOverride: taskType === 'simple' ? safeOverride : '',
980
- subtasks: Array.isArray(subtasks) ? subtasks.map(s => ({
981
- id: s.id || genId(),
982
- title: s.title || '',
983
- desc: s.desc || '',
984
- status: s.status || 'todo',
985
- promptOverride: s.promptOverride || '',
986
- error: typeof s.error === 'string' ? s.error : undefined,
987
- errorAt: typeof s.errorAt === 'string' ? s.errorAt : undefined,
988
- attachments: Array.isArray(s.attachments) ? s.attachments.map(a => ({
989
- id: a.id,
990
- originalName: a.originalName,
991
- mimeType: a.mimeType,
992
- size: a.size,
993
- ext: a.ext,
994
- storedName: a.storedName,
995
- absolutePath: a.absolutePath,
996
- createdAt: a.createdAt
997
- })) : (tasks[i].subtasks.find(x => x.id === s.id)?.attachments || [])
998
- })) : tasks[i].subtasks,
502
+ simpleOverride: safeOverride,
999
503
  updatedAt: now
1000
504
  };
1001
505
  await writeJson(TASKS_FILE, { tasks });
@@ -1006,20 +510,8 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1006
510
  title: safeTitle,
1007
511
  desc: desc || '',
1008
512
  promptId: promptId || null,
1009
- type: taskType,
1010
- sequential: taskType === 'complex' ? sequential : true,
1011
- simpleOverride: taskType === 'simple' ? safeOverride : '',
513
+ simpleOverride: safeOverride,
1012
514
  projectPath: bodyProjectPath || currentProjectPath || '',
1013
- subtasks: Array.isArray(subtasks) ? subtasks.map(s => ({
1014
- id: s.id || genId(),
1015
- title: s.title || '',
1016
- desc: s.desc || '',
1017
- status: s.status || 'todo',
1018
- promptOverride: s.promptOverride || '',
1019
- error: typeof s.error === 'string' ? s.error : undefined,
1020
- errorAt: typeof s.errorAt === 'string' ? s.errorAt : undefined,
1021
- attachments: Array.isArray(s.attachments) ? s.attachments : []
1022
- })) : [],
1023
515
  status: 'todo',
1024
516
  createdAt: now,
1025
517
  updatedAt: now
@@ -1052,16 +544,6 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1052
544
  } catch (e) {
1053
545
  logger.warn(`[workbench] failed to remove task image dir for ${taskId}: ${e.message}`);
1054
546
  }
1055
- if (Array.isArray(task.subtasks)) {
1056
- for (const sub of task.subtasks) {
1057
- if (!sub || !sub.id) continue;
1058
- try {
1059
- await fsp.rm(path.join(IMAGES_DIR, sub.id), { recursive: true, force: true });
1060
- } catch (e) {
1061
- logger.warn(`[workbench] failed to remove sub image dir for ${sub.id}: ${e.message}`);
1062
- }
1063
- }
1064
- }
1065
547
 
1066
548
  const tasks = allTasks.filter(t => t.id !== taskId);
1067
549
  await writeJson(TASKS_FILE, { tasks });
@@ -1069,50 +551,17 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1069
551
  }));
1070
552
 
1071
553
  // ════════════════════════════════════════════════════════════════════════
1072
- // §12. 执行任务(整批 / 从指定 sub / 简单 / 单 sub)
554
+ // §12. 执行任务(每个任务都是一次会话)
1073
555
  // ════════════════════════════════════════════════════════════════════════
1074
556
  app.post('/api/workbench/tasks/:id/run', asyncRoute(async (req, res) => {
1075
557
  const data = await readJson(TASKS_FILE, { tasks: [] });
1076
558
  const task = (data.tasks || []).find(t => t.id === req.params.id);
1077
559
  if (!task) throw new HttpError(404, '任务不存在');
1078
- if (!task.subtasks || task.subtasks.length === 0) {
1079
- throw new HttpError(400, '任务没有子任务');
1080
- }
1081
- const repoPath = resolveTaskRepoPath(task, typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '');
1082
- const executor = await resolveExecutor(req.body?.executor);
1083
- // 异步执行,立即返回
1084
- res.json({ success: true, message: '已开始执行' });
1085
- runTaskQueue(task, repoPath, '', { executor }).catch(err => {
1086
- publish('task:error', { taskId: task.id, error: err.message });
1087
- });
1088
- }));
1089
-
1090
- app.post('/api/workbench/tasks/:id/run-from', asyncRoute(async (req, res) => {
1091
- const data = await readJson(TASKS_FILE, { tasks: [] });
1092
- const task = (data.tasks || []).find(t => t.id === req.params.id);
1093
- if (!task) throw new HttpError(404, '任务不存在');
1094
- if (!task.subtasks || task.subtasks.length === 0) {
1095
- throw new HttpError(400, '任务没有子任务');
1096
- }
1097
- const startSubIndex = Number(req.body?.startSubIndex);
1098
- if (!Number.isInteger(startSubIndex) || startSubIndex < 0 || startSubIndex >= task.subtasks.length) {
1099
- throw new HttpError(400, 'startSubIndex 越界');
1100
- }
1101
- const repoPath = resolveTaskRepoPath(task, typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '');
1102
- const executor = await resolveExecutor(req.body?.executor);
1103
- res.json({ success: true, message: `已从第 ${startSubIndex + 1} 个子任务开始执行` });
1104
- runTaskQueue(task, repoPath, '', { fromIndex: startSubIndex, executor }).catch(err => {
1105
- publish('task:error', { taskId: task.id, error: err.message });
1106
- });
1107
- }));
1108
-
1109
- app.post('/api/workbench/tasks/:id/run-simple', asyncRoute(async (req, res) => {
1110
- const data = await readJson(TASKS_FILE, { tasks: [] });
1111
- const task = (data.tasks || []).find(t => t.id === req.params.id);
1112
- if (!task) throw new HttpError(404, '任务不存在');
1113
- if (task.type !== 'simple') {
1114
- throw new HttpError(400, '该任务不是简单任务,请使用普通执行接口');
1115
- }
560
+ // 兜底:磁盘上没有 running 状态,但还有 job 在跑(可能是别的 g ui 起的),也拦一下。
561
+ // 先刷磁盘再取快照,否则另一个实例正在跑的这个任务会被重复起一份。
562
+ await refreshJobsFromDisk();
563
+ const liveJob = snapshotJobs().find(j => j.taskId === task.id && (j.status === 'running' || j.status === 'pending'));
564
+ if (liveJob) throw new HttpError(400, '该任务已有正在执行的 job');
1116
565
  const repoPath = resolveTaskRepoPath(task, typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '');
1117
566
  const executor = await resolveExecutor(req.body?.executor);
1118
567
  const virtualSub = {
@@ -1123,43 +572,12 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1123
572
  promptOverride: task.simpleOverride || '',
1124
573
  attachments: Array.isArray(task.attachments) ? task.attachments : []
1125
574
  };
1126
- res.json({ success: true, message: '已开始执行简单任务' });
1127
- runSingleSubtask(task, virtualSub, repoPath, '', [], { executor }).catch(err => {
575
+ res.json({ success: true, message: '已开始执行任务' });
576
+ runSingleSubtask(task, virtualSub, repoPath, '', { executor }).catch(err => {
1128
577
  publish('task:error', { taskId: task.id, error: err.message });
1129
578
  });
1130
579
  }));
1131
580
 
1132
- app.post('/api/workbench/subtasks/:id/run', asyncRoute(async (req, res) => {
1133
- const data = await readJson(TASKS_FILE, { tasks: [] });
1134
- const subId = req.params.id;
1135
- let foundTask = null;
1136
- let foundSub = null;
1137
- for (const t of (data.tasks || [])) {
1138
- if (!Array.isArray(t.subtasks)) continue;
1139
- const s = t.subtasks.find(x => x.id === subId);
1140
- if (s) { foundTask = t; foundSub = s; break; }
1141
- }
1142
- if (!foundTask || !foundSub) throw new HttpError(404, '子任务不存在');
1143
- if (foundSub.status === 'running') throw new HttpError(400, '该子任务正在执行中');
1144
- // 兜底:即便磁盘状态是 todo,如果还有 job 在跑(可能是别的 g ui 起的),也拦一下。
1145
- // 先刷磁盘再取快照,否则另一个实例正在跑的这个 sub 会被重复起一份。
1146
- await refreshJobsFromDisk();
1147
- const liveJob = snapshotJobs().find(j => j.subId === subId && (j.status === 'running' || j.status === 'pending'));
1148
- if (liveJob) throw new HttpError(400, '该子任务已有正在执行的 job');
1149
- const repoPath = resolveTaskRepoPath(foundTask, typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '');
1150
- const executor = await resolveExecutor(req.body?.executor);
1151
- res.json({ success: true, message: `已开始执行子任务:${foundSub.title || subId}` });
1152
- (async () => {
1153
- try {
1154
- const priorOutputs = await collectPriorOutputs(foundTask, foundSub);
1155
- await runSingleSubtask(foundTask, foundSub, repoPath, '', priorOutputs, { executor });
1156
- await persistTaskAfterRun(foundTask);
1157
- } catch (err) {
1158
- publish('task:error', { taskId: foundTask.id, error: err.message });
1159
- }
1160
- })();
1161
- }));
1162
-
1163
581
  // ════════════════════════════════════════════════════════════════════════
1164
582
  // §13. Job 查询(兜底,SSE 断了也能拉)
1165
583
  // ════════════════════════════════════════════════════════════════════════
@@ -1193,8 +611,6 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1193
611
  job.error = '用户已停止执行';
1194
612
  job.endedAt = nowIso();
1195
613
  publish('job:update', { ...job }); // 浅拷贝避免序列化 child 引用
1196
- // 同步把 sub 也置 cancelled
1197
- syncSubToCancelled(job).catch(err => logger.warn('[workbench] syncSubToCancelled failed:', err.message));
1198
614
  // 终态:fire-and-forget 同步落盘
1199
615
  flushJobsSaveNow().catch(err => logger.warn('[workbench] jobs save failed:', err.message));
1200
616
  const child = job.child;
@@ -1236,7 +652,6 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1236
652
  const data = await readJson(TASKS_FILE, { tasks: [] });
1237
653
  const task = (data.tasks || []).find(t => t.id === prevJob.taskId);
1238
654
  if (!task) throw new HttpError(404, '所属任务不存在');
1239
- if (task.type !== 'simple') throw new HttpError(400, '仅支持简单任务的续接');
1240
655
  // 计算续接轮次号
1241
656
  const subIdPrefix = `${task.id}__simple`;
1242
657
  let maxRound = 0;
@@ -1266,7 +681,7 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1266
681
  res.json({ success: true, message: '已加入续接队列' });
1267
682
  // 执行器必须沿用上一轮 job 的 agent:claude 的 --resume 和 opencode 的 --session
1268
683
  // 互不认对方的会话 id,串了执行器续接必然失败。
1269
- runSingleSubtask(task, virtualSub, repoPath, '', [], {
684
+ runSingleSubtask(task, virtualSub, repoPath, '', {
1270
685
  resumeSessionId: prevJob.claudeSessionId,
1271
686
  executor: normalizeTaskExecutor(prevJob.agent)
1272
687
  })
@@ -1295,7 +710,7 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1295
710
  if (status && j.status !== status) return false;
1296
711
  if (taskId && j.taskId !== taskId) return false;
1297
712
  if (term) {
1298
- const hay = `${j.title || ''} ${j.taskTitle || ''} ${j.subTitle || ''}`.toLowerCase();
713
+ const hay = `${j.title || ''} ${j.taskTitle || ''}`.toLowerCase();
1299
714
  if (!hay.includes(term)) return false;
1300
715
  }
1301
716
  return true;
@@ -1419,31 +834,11 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1419
834
  jobsData.jobs = jobsData.jobs.filter(j => j.taskId !== taskId);
1420
835
  if (jobsData.jobs.length !== before) await writeJson(JOBS_FILE, jobsData);
1421
836
  }
1422
- // 2) 重置 subtasks.status → todo
1423
- const tasksData = await readJson(TASKS_FILE, { tasks: [] });
1424
- const task = (tasksData.tasks || []).find(t => t.id === taskId);
1425
- if (!task) {
1426
- return res.json({ success: true, removedJobs: removedJobIds.length, resetSubs: 0, message: '任务不存在,仅清空 job' });
1427
- }
1428
- let resetSubs = 0;
1429
- if (Array.isArray(task.subtasks)) {
1430
- for (const s of task.subtasks) {
1431
- if (s.status !== 'todo') {
1432
- s.status = 'todo';
1433
- resetSubs++;
1434
- if (s.error) delete s.error;
1435
- publish('sub:update', { taskId, sub: s });
1436
- }
1437
- }
1438
- task.updatedAt = nowIso();
1439
- await writeJson(TASKS_FILE, tasksData);
1440
- publish('task:update', task);
1441
- }
837
+ // 2) 不用再改任务本体 —— 看板列完全由 job 推导,job 没了自然回到「待执行」
1442
838
  res.json({
1443
839
  success: true,
1444
840
  removedJobs: removedJobIds.length,
1445
- resetSubs,
1446
- message: `已清空 ${removedJobIds.length} 条执行记录,${resetSubs} 个子任务重置为待执行`
841
+ message: `已清空 ${removedJobIds.length} 条执行记录`
1447
842
  });
1448
843
  }));
1449
844
 
@@ -1475,7 +870,6 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1475
870
  if (!task) {
1476
871
  return res.json({ success: true, removedJobs: removedJobIds.length, message: '任务不存在,仅清空 job' });
1477
872
  }
1478
- const removedSubCount = Array.isArray(task.subtasks) ? task.subtasks.length : 0;
1479
873
  const removedAttCount = Array.isArray(task.attachments) ? task.attachments.length : 0;
1480
874
  const hadDesc = !!(task.desc && task.desc.length > 0);
1481
875
  const hadPrompt = !!task.promptId;
@@ -1485,7 +879,6 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1485
879
  const newTask = { id: task.id, title: task.title, status: preservedStatus };
1486
880
  if (preservedProjectPath) newTask.projectPath = preservedProjectPath;
1487
881
  if (preservedCreatedAt) newTask.createdAt = preservedCreatedAt;
1488
- newTask.subtasks = [];
1489
882
  newTask.attachments = [];
1490
883
  newTask.updatedAt = nowIso();
1491
884
  for (const k of Object.keys(task)) delete task[k];
@@ -1495,57 +888,13 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1495
888
  res.json({
1496
889
  success: true,
1497
890
  removedJobs: removedJobIds.length,
1498
- removedSubs: removedSubCount,
1499
891
  removedAttachments: removedAttCount,
1500
892
  clearedDesc: hadDesc,
1501
893
  clearedPrompt: hadPrompt,
1502
- message: `已清空:删除 ${removedSubCount} 个子任务${removedAttCount ? '、' + removedAttCount + ' 个附件' : ''}${hadDesc ? '、任务描述' : ''},任务标题保留`
894
+ message: `已清空:${removedAttCount ? '删除 ' + removedAttCount + ' 个附件' : '无附件'}${hadDesc ? '、任务描述' : ''},任务标题保留`
1503
895
  });
1504
896
  }));
1505
897
 
1506
- app.post('/api/workbench/tasks/:id/clear-subtasks', asyncRoute(async (req, res) => {
1507
- const taskId = req.params.id;
1508
- if (!taskId) throw new HttpError(400, '缺少 taskId');
1509
- const live = [];
1510
- for (const j of jobs.values()) {
1511
- if (j.taskId !== taskId) continue;
1512
- if (j.status === 'running' || j.status === 'pending') live.push(j.id);
1513
- }
1514
- if (live.length > 0) {
1515
- throw new HttpError(400, `有 ${live.length} 个 job 正在执行,请先停止`);
1516
- }
1517
- const removedJobIds = [];
1518
- for (const j of jobs.values()) {
1519
- if (j.taskId !== taskId) continue;
1520
- jobs.delete(j.id);
1521
- removedJobIds.push(j.id);
1522
- }
1523
- const jobsData = await readJson(JOBS_FILE, { version: 1, jobs: [] });
1524
- if (jobsData && Array.isArray(jobsData.jobs)) {
1525
- const before = jobsData.jobs.length;
1526
- jobsData.jobs = jobsData.jobs.filter(j => j.taskId !== taskId);
1527
- if (jobsData.jobs.length !== before) await writeJson(JOBS_FILE, jobsData);
1528
- }
1529
- const tasksData = await readJson(TASKS_FILE, { tasks: [] });
1530
- const task = (tasksData.tasks || []).find(t => t.id === taskId);
1531
- if (!task) {
1532
- return res.json({ success: true, removedJobs: removedJobIds.length, removedSubs: 0, message: '任务不存在,仅清空 job' });
1533
- }
1534
- const removedSubCount = Array.isArray(task.subtasks) ? task.subtasks.length : 0;
1535
- if (removedSubCount === 0) {
1536
- return res.json({ success: true, removedJobs: removedJobIds.length, removedSubs: 0, message: '没有子任务需要清空' });
1537
- }
1538
- task.subtasks = [];
1539
- task.updatedAt = nowIso();
1540
- await writeJson(TASKS_FILE, tasksData);
1541
- publish('task:update', task);
1542
- res.json({
1543
- success: true,
1544
- removedJobs: removedJobIds.length,
1545
- removedSubs: removedSubCount,
1546
- message: `已清空 ${removedSubCount} 个子任务,任务描述和附件保留`
1547
- });
1548
- }));
1549
898
 
1550
899
  // GET /api/workbench/jobs/:id (放在所有字面路径之后)
1551
900
  app.get('/api/workbench/jobs/:id', asyncRoute(async (req, res) => {
@@ -1600,19 +949,10 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1600
949
  return { owner: 'task', task: t, list, att, storageDir: path.join(IMAGES_DIR, '_task-' + t.id) };
1601
950
  }
1602
951
  }
1603
- for (const t of data.tasks || []) {
1604
- for (const s of t.subtasks || []) {
1605
- const list = Array.isArray(s.attachments) ? s.attachments : [];
1606
- const att = list.find(x => x.id === attId);
1607
- if (att) {
1608
- return { owner: 'sub', task: t, sub: s, list, att, storageDir: path.join(IMAGES_DIR, s.id) };
1609
- }
1610
- }
1611
- }
1612
952
  return null;
1613
953
  }
1614
954
 
1615
- // 共享 helper:写入新附件(参数化以支持 task / sub)
955
+ // 共享 helper:写入新附件
1616
956
  async function writeAttachmentTo({ req, target, maxCount }) {
1617
957
  if (!req.body || !(req.body instanceof Buffer) || req.body.length === 0) {
1618
958
  throw new HttpError(400, '请求体为空');
@@ -1653,54 +993,14 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1653
993
  }
1654
994
 
1655
995
  // 子任务附件
1656
- app.post('/api/workbench/subtasks/:subId/attachments', rawAttachment, asyncRoute(async (req, res) => {
1657
- const { subId } = req.params;
1658
- const data = await readJson(TASKS_FILE, { tasks: [] });
1659
- let foundSub = null;
1660
- for (const t of data.tasks || []) {
1661
- const s = (t.subtasks || []).find(x => x.id === subId);
1662
- if (s) { foundSub = s; break; }
1663
- }
1664
- if (!foundSub) throw new HttpError(404, '子任务不存在');
1665
- const target = { ...foundSub, storageDir: path.join(IMAGES_DIR, subId) };
1666
- const att = await writeAttachmentTo({ req, target, maxCount: MAX_ATTACHMENTS_PER_SUBTASK });
1667
- // target 是 spread 出来的浅拷贝,data 引用里的 foundSub 没改;显式 push 回去
1668
- foundSub.attachments = Array.isArray(foundSub.attachments) ? foundSub.attachments : [];
1669
- foundSub.attachments.push(att);
1670
- foundSub.updatedAt = nowIso();
1671
- await writeJson(TASKS_FILE, data);
1672
- res.json({ success: true, attachment: att });
1673
- }));
1674
-
1675
- app.delete('/api/workbench/subtasks/:subId/attachments/:attId', asyncRoute(async (req, res) => {
1676
- const { subId, attId } = req.params;
1677
- const data = await readJson(TASKS_FILE, { tasks: [] });
1678
- let foundSub = null;
1679
- for (const t of data.tasks || []) {
1680
- const s = (t.subtasks || []).find(x => x.id === subId);
1681
- if (s) { foundSub = s; break; }
1682
- }
1683
- if (!foundSub) throw new HttpError(404, '子任务不存在');
1684
- const list = Array.isArray(foundSub.attachments) ? foundSub.attachments : [];
1685
- const i = list.findIndex(a => a.id === attId);
1686
- if (i < 0) throw new HttpError(404, '附件不存在');
1687
- const [removed] = list.splice(i, 1);
1688
- try {
1689
- await fsp.unlink(path.join(IMAGES_DIR, subId, removed.storedName));
1690
- } catch { /* 文件可能已不存在 */ }
1691
- foundSub.updatedAt = nowIso();
1692
- await writeJson(TASKS_FILE, data);
1693
- res.json({ success: true });
1694
- }));
1695
-
1696
- // 主任务附件
996
+ // 任务附件
1697
997
  app.post('/api/workbench/tasks/:taskId/attachments', rawAttachment, asyncRoute(async (req, res) => {
1698
998
  const { taskId } = req.params;
1699
999
  const data = await readJson(TASKS_FILE, { tasks: [] });
1700
1000
  const task = (data.tasks || []).find(t => t.id === taskId);
1701
1001
  if (!task) throw new HttpError(404, '任务不存在');
1702
1002
  const target = { ...task, storageDir: path.join(IMAGES_DIR, '_task-' + taskId) };
1703
- const att = await writeAttachmentTo({ req, target, maxCount: MAX_ATTACHMENTS_PER_SUBTASK });
1003
+ const att = await writeAttachmentTo({ req, target, maxCount: MAX_ATTACHMENTS_PER_TASK });
1704
1004
  task.attachments = Array.isArray(task.attachments) ? task.attachments : [];
1705
1005
  task.attachments.push(att);
1706
1006
  task.updatedAt = nowIso();
@@ -1858,7 +1158,7 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1858
1158
  const task = (data.tasks || []).find(t => t && t.id === id);
1859
1159
  if (!task) throw new HttpError(404, '任务不存在');
1860
1160
 
1861
- // loadAllJobs 已经把「文件里归档的 + 内存里还活着的」合并过(含 taskTitle/subTitle)
1161
+ // loadAllJobs 已经把「文件里归档的 + 内存里还活着的」合并过(含 taskTitle)
1862
1162
  const allJobs = await loadAllJobs();
1863
1163
  const detail = buildTaskDetail(task, allJobs.filter(j => j && j.taskId === id));
1864
1164
  if (!detail) throw new HttpError(404, '任务不存在');
@@ -1954,8 +1254,7 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1954
1254
  * > 主 Agent 判断 > 应用当前项目)。用户不必先选项目,落点会如实记进指令流水
1955
1255
  * - autoRun 默认 true;调度暂停时只建任务不执行(指令记录里会写明原因)
1956
1256
  *
1957
- * 指令一律落成目标项目下的**简单任务**(一句话交给 claude 跑一轮),
1958
- * 而不是造一个没有子任务的复杂任务 —— 后者连执行入口都没有,只会变成看板上的死卡。
1257
+ * 一条指令落成目标项目下的一个任务:整段指令就是那次会话的 prompt。
1959
1258
  */
1960
1259
  app.post('/api/workbench/orchestrator/dispatch', asyncRoute(async (req, res) => {
1961
1260
  const text = typeof req.body?.text === 'string' ? req.body.text.trim() : '';
@@ -2035,8 +1334,8 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
2035
1334
  // 所以不存在"前端指定任意路径"这回事 —— 比"信任 absolutePath 再校验前缀"干净。
2036
1335
  // 数量与文件存在性在这里统一校验:上传时服务端是无状态的,压根不知道前端一共攒了几个。
2037
1336
  const rawAttachments = Array.isArray(req.body?.attachments) ? req.body.attachments : [];
2038
- if (rawAttachments.length > MAX_ATTACHMENTS_PER_SUBTASK) {
2039
- throw new HttpError(400, `附件最多 ${MAX_ATTACHMENTS_PER_SUBTASK} 个`);
1337
+ if (rawAttachments.length > MAX_ATTACHMENTS_PER_TASK) {
1338
+ throw new HttpError(400, `附件最多 ${MAX_ATTACHMENTS_PER_TASK} 个`);
2040
1339
  }
2041
1340
  const staged = [];
2042
1341
  for (const item of rawAttachments) {
@@ -2097,15 +1396,12 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
2097
1396
  title: text.split('\n')[0].slice(0, 120),
2098
1397
  desc: text,
2099
1398
  promptId: null,
2100
- type: 'simple',
2101
- sequential: true,
2102
1399
  // 默认提示词在派发这一刻抄进任务(这次生效的是什么,任务自己记着)。
2103
1400
  // 之后用户改设置不会回头改写它 —— 一条已存在的任务,"它当时是被怎么派出去的"
2104
1401
  // 是既成事实,不是当前配置的投影。
2105
1402
  simpleOverride: dispatchPrompt.text,
2106
1403
  projectPath: targetPath,
2107
1404
  attachments,
2108
- subtasks: [],
2109
1405
  status: 'todo',
2110
1406
  createdAt: now,
2111
1407
  updatedAt: now,
@@ -2130,21 +1426,21 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
2130
1426
  publish('task:created', { task });
2131
1427
  publish('orchestrator:instruction', record);
2132
1428
 
2133
- // 与 POST /tasks/:id/run-simple 走同一条执行路径,避免出现第二套执行入口
1429
+ // 与 POST /tasks/:id/run 走同一条执行路径,避免出现第二套执行入口
2134
1430
  if (willRun) {
2135
1431
  const virtualSub = {
2136
1432
  id: `${task.id}__simple`,
2137
1433
  title: task.title,
2138
1434
  desc: task.desc || '',
2139
1435
  status: 'todo',
2140
- // 与 run-simple 同口径:提示词取自 task.simpleOverride(派发时已把
1436
+ // 提示词取自 task.simpleOverride(派发时已把
2141
1437
  // 全局/项目默认提示词抄进去),而不是在这里再解析一次配置
2142
1438
  promptOverride: task.simpleOverride || '',
2143
1439
  attachments: [],
2144
1440
  };
2145
1441
  // 派发控制台可以显式指定执行器;不传则用配置里的全局默认(resolveExecutor 走回落链)
2146
1442
  const executor = await resolveExecutor(req.body?.executor);
2147
- runSingleSubtask(task, virtualSub, targetPath, '', [], { executor }).catch(err => {
1443
+ runSingleSubtask(task, virtualSub, targetPath, '', { executor }).catch(err => {
2148
1444
  publish('task:error', { taskId: task.id, error: err.message });
2149
1445
  });
2150
1446
  }
@@ -2163,7 +1459,7 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
2163
1459
  app.post('/api/workbench/orchestrator/attachments', rawAttachment, asyncRoute(async (req, res) => {
2164
1460
  await fsp.mkdir(DISPATCH_STAGING_DIR, { recursive: true });
2165
1461
  const target = { attachments: [], storageDir: DISPATCH_STAGING_DIR };
2166
- const att = await writeAttachmentTo({ req, target, maxCount: MAX_ATTACHMENTS_PER_SUBTASK });
1462
+ const att = await writeAttachmentTo({ req, target, maxCount: MAX_ATTACHMENTS_PER_TASK });
2167
1463
  res.json({ success: true, attachment: att });
2168
1464
  }));
2169
1465