zen-gitsync 2.14.2 → 2.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/package.json +1 -1
  2. package/src/ui/public/assets/{AppVersionBadge-M9ydXpd9.js → AppVersionBadge-BoAhI0Kt.js} +2 -2
  3. package/src/ui/public/assets/BranchSelector--8Qfb75A.js +1 -0
  4. package/src/ui/public/assets/CommitForm-CobjwXah.js +6 -0
  5. package/src/ui/public/assets/ConsoleView-B2WZRKsP.css +1 -0
  6. package/src/ui/public/assets/ConsoleView-HcfiYLnM.js +17 -0
  7. package/src/ui/public/assets/CustomCommandManager-DQG4wRU7.js +1 -0
  8. package/src/ui/public/assets/{EditorView-rGLnccH3.js → EditorView-CAjuz7yY.js} +0 -0
  9. package/src/ui/public/assets/{FlowExecutionViewer-B4BADP8e.js → FlowExecutionViewer-C8H9uoZL.js} +1 -1
  10. package/src/ui/public/assets/FlowOrchestrationWorkspace-C2RMFB-p.js +21 -0
  11. package/src/ui/public/assets/{LogList-7agk5Obq.js → LogList-8qSNrgT7.js} +4 -4
  12. package/src/ui/public/assets/{MindmapView-mOFG9TmR.js → MindmapView-C5pLBOs_.js} +1 -1
  13. package/src/ui/public/assets/{MonitorView-Dlpgy8Bb.js → MonitorView-Bijvi8pi.js} +1 -1
  14. package/src/ui/public/assets/ProjectStartupButton-Cb-RAZqA.js +1 -0
  15. package/src/ui/public/assets/{RemoteRepoCard-CFKl6CB9.js → RemoteRepoCard-DjfI188H.js} +1 -1
  16. package/src/ui/public/assets/{SourceMapView-Dsc_a3t7.js → SourceMapView-DbdbojUR.js} +2 -2
  17. package/src/ui/public/assets/{SvgIcon-Cw2mL_zx.js → SvgIcon-BVBpj6nK.js} +1 -1
  18. package/src/ui/public/assets/TemplateManager-C0rm8xfC.css +1 -0
  19. package/src/ui/public/assets/TemplateManager-CSI1wQZY.js +1 -0
  20. package/src/ui/public/assets/{UserInputNode-yBT2005h.js → UserInputNode-8ocSzj-6.js} +1 -1
  21. package/src/ui/public/assets/WorkbenchView-CvHPPs8Q.css +1 -0
  22. package/src/ui/public/assets/{WorkbenchView-CiG-qMl7.js → WorkbenchView-DAXAzJTK.js} +4 -4
  23. package/src/ui/public/assets/_plugin-vue_export-helper-Uwc4Esmp.js +15 -0
  24. package/src/ui/public/assets/commandParser-wwLxoJ92.js +1 -0
  25. package/src/ui/public/assets/{configStore-Cq6Qwn35.js → configStore-BHFJlgWp.js} +1 -1
  26. package/src/ui/public/assets/element-plus-R1nMzM9j.css +1 -0
  27. package/src/ui/public/assets/{flow-mindmap-CAkGY2ir.js → flow-mindmap-C0IUPqjN.js} +1 -1
  28. package/src/ui/public/assets/index-BZk-ldXr.css +1 -0
  29. package/src/ui/public/assets/index-Bl8fyL3X.js +23 -0
  30. package/src/ui/public/assets/{vendor-BuiZXcjj.js → vendor-0hkrGFDb.js} +1 -1
  31. package/src/ui/public/assets/{vue-flow-C6RXt-U3.js → vue-flow-CkwNeuGO.js} +1 -1
  32. package/src/ui/public/index.html +9 -11
  33. package/src/ui/server/index.js +632 -625
  34. package/src/ui/server/middleware/errorHandler.js +84 -0
  35. package/src/ui/server/middleware/errorHandler.test.js +114 -0
  36. package/src/ui/server/routes/branchStatus.test.js +37 -0
  37. package/src/ui/server/routes/exec.js +0 -21
  38. package/src/ui/server/routes/gitOps.js +62 -0
  39. package/src/ui/server/routes/monitor.js +42 -26
  40. package/src/ui/server/routes/npm.js +1387 -1327
  41. package/src/ui/server/routes/terminal.js +318 -320
  42. package/src/ui/server/routes/workbench/attachmentUtils.js +76 -0
  43. package/src/ui/server/routes/workbench/index.js +1680 -0
  44. package/src/ui/server/routes/workbench/instructionStore.js +175 -0
  45. package/src/ui/server/routes/workbench/jobStore.js +225 -0
  46. package/src/ui/server/routes/workbench/jsonParse.js +219 -0
  47. package/src/ui/server/routes/workbench/llmClient.js +214 -0
  48. package/src/ui/server/routes/workbench/projectScan.js +191 -0
  49. package/src/ui/server/routes/workbench/sessionStore.js +126 -0
  50. package/src/ui/server/routes/workbench/shared.js +115 -0
  51. package/src/ui/server/routes/workbench/taskRunner.js +493 -0
  52. package/src/ui/server/routes/workbench.js +16 -3523
  53. package/src/utils/index.js +10 -3
  54. package/src/ui/public/assets/BranchSelector-scIk6OeM.js +0 -1
  55. package/src/ui/public/assets/CommandConsole-BikQuarw.js +0 -17
  56. package/src/ui/public/assets/CommandConsole-Da3j09oc.css +0 -1
  57. package/src/ui/public/assets/CommitForm-DuvNxUdP.js +0 -6
  58. package/src/ui/public/assets/CommonDialog-Cyh3xu0p.css +0 -1
  59. package/src/ui/public/assets/CommonDialog-G17HYMln.js +0 -1
  60. package/src/ui/public/assets/FlowOrchestrationWorkspace-C3l3Jco7.js +0 -21
  61. package/src/ui/public/assets/ProjectStartupButton-CgS14qyg.js +0 -1
  62. package/src/ui/public/assets/WorkbenchView-OmJ1eM5g.css +0 -1
  63. package/src/ui/public/assets/_plugin-vue_export-helper-BMXWW0fG.js +0 -15
  64. package/src/ui/public/assets/element-plus-D06ZgZjR.css +0 -1
  65. package/src/ui/public/assets/index-CPqKvZBK.js +0 -23
  66. package/src/ui/public/assets/index-gbRjS0sL.css +0 -1
  67. /package/src/ui/public/assets/{element-plus-B6Owr5jY.js → element-plus-tbZnDtyd.js} +0 -0
@@ -0,0 +1,1680 @@
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
+ //
17
+ // 历史背景:本文件曾是 3539 行的巨型文件,包含所有顶层工具函数 + 45 个路由端点。
18
+ // 2026-06-29 按业务域拆分为 workbench/ 子目录的 9 个模块:
19
+ // - shared.js 常量 + 通用工具 (nowIso, genId, readJson, writeJson, interpolate)
20
+ // - jsonParse.js JSON 解析降级链 (parseSubtaskJson 等)
21
+ // - llmClient.js LLM 客户端 (callLlmJson, callLlmStream)
22
+ // - projectScan.js 子项目识别 (findSubProjects, detectProjectManifest)
23
+ // - attachmentUtils.js 附件白名单 (sanitizeExt, resolveExt, MIME_TO_EXT)
24
+ // - sessionStore.js AI 对话拆分会话持久化
25
+ // - instructionStore.js AI 指令读写
26
+ // - jobStore.js jobs Map + bus + 持久化 + retention
27
+ // - taskRunner.js 任务执行引擎 (runTaskQueue, runSingleSubtask)
28
+ //
29
+ // 本文件(index.js)只做 registerWorkbenchRoutes 入口聚合,按业务域分节注册 45 个路由。
30
+ // 错误处理:
31
+ // - 非 SSE 路由统一用 asyncRoute 包装,handler 内部不再写 try/catch
32
+ // - 异常自动走 asyncRoute → 全局 errorHandler 中间件(server/index.js 注册)
33
+ // - SSE 路由保留 try/catch:错误必须以 SSE data 帧 format 发出,不能转 JSON
34
+ // - handler 抛 HttpError(statusCode, msg) 可定制状态码
35
+
36
+ import path from 'path';
37
+ import { execFile } from 'child_process';
38
+ import express from 'express';
39
+ import { asyncRoute, HttpError } from '../../utils/asyncRoute.js';
40
+
41
+ import {
42
+ logger,
43
+ fs,
44
+ fsp,
45
+ PROMPTS_FILE,
46
+ TASKS_FILE,
47
+ JOBS_FILE,
48
+ IMAGES_DIR,
49
+ SUBTASK_INSTRUCTION_FILE,
50
+ MAX_IMAGE_BYTES,
51
+ MAX_ATTACHMENTS_PER_SUBTASK,
52
+ readJson,
53
+ writeJson,
54
+ nowIso,
55
+ genId,
56
+ } from './shared.js';
57
+ import { parseSubtaskJson } from './jsonParse.js';
58
+ import { callLlmJson, callLlmJsonWithRetry, callLlmStream } from './llmClient.js';
59
+ import {
60
+ findSubProjects,
61
+ detectProjectManifest,
62
+ readProjectManifest,
63
+ listDirTree,
64
+ safeReadFile,
65
+ } from './projectScan.js';
66
+ import { isImageExt, resolveExt, ALLOWED_EXTS, ensureImagesDir } from './attachmentUtils.js';
67
+ import { sessionStore } from './sessionStore.js';
68
+ import {
69
+ DEFAULT_INSTRUCTION,
70
+ DEFAULT_SUBTASK_INSTRUCTION_ZH,
71
+ pickDefaultSubtaskInstruction,
72
+ readInstruction,
73
+ writeInstruction,
74
+ readSubtaskInstruction,
75
+ writeSubtaskInstruction,
76
+ } from './instructionStore.js';
77
+ import {
78
+ jobs,
79
+ bus,
80
+ cancelledJobs,
81
+ serializeJob,
82
+ scheduleJobsSave,
83
+ flushJobsSaveNow,
84
+ readJobsConfig,
85
+ writeJobsConfig,
86
+ enforceRetention,
87
+ publish,
88
+ snapshotJobs,
89
+ } from './jobStore.js';
90
+ import {
91
+ launchClaudeInNewWindow,
92
+ runTaskQueue,
93
+ runSingleSubtask,
94
+ syncSubToCancelled,
95
+ persistTaskAfterRun,
96
+ collectPriorOutputs,
97
+ collectPriorOutputsUpTo,
98
+ waitProcessExit,
99
+ } from './taskRunner.js';
100
+
101
+ const { genSessionId, read: readSessionFile, write: writeSessionFile, delete: deleteSessionFile, listMeta: listSessionsMeta, enforceRetention: enforceSessionsRetention } = sessionStore;
102
+
103
+ /**
104
+ * 注册所有 workbench 路由(共 45 个端点)。
105
+ * @param {Object} deps
106
+ * @param {import('express').Express} deps.app
107
+ * @param {() => string} deps.getCurrentProjectPath
108
+ * @param {() => string} deps.getProjectRoomId
109
+ * @param {import('socket.io').Server} deps.io
110
+ * @param {Object} deps.configManager
111
+ */
112
+ export function registerWorkbenchRoutes({ app, getCurrentProjectPath, getProjectRoomId, io, configManager }) {
113
+ // ════════════════════════════════════════════════════════════════════════
114
+ // §1. AI 生成提示词(基于当前项目)
115
+ // ════════════════════════════════════════════════════════════════════════
116
+ app.post('/api/workbench/prompts/ai-generate', asyncRoute(async (req, res) => {
117
+ const projectPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
118
+ if (!projectPath) throw new HttpError(400, '未选中项目');
119
+ let stat;
120
+ try { stat = await fsp.stat(projectPath); }
121
+ catch { throw new HttpError(400, '项目路径不存在'); }
122
+ if (!stat.isDirectory()) throw new HttpError(400, '项目路径不是目录');
123
+
124
+ // 取模型
125
+ let model;
126
+ try {
127
+ if (!configManager) throw new Error('configManager 不可用');
128
+ const rawConfig = await configManager.readRawConfigFile();
129
+ const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
130
+ model = models.find(m => m.isDefault) || models[0];
131
+ } catch (err) {
132
+ throw new HttpError(500, '读取 AI 配置失败: ' + err.message);
133
+ }
134
+ if (!model) throw new HttpError(400, '未配置 AI 模型,请先在通用设置中添加模型');
135
+
136
+ const userInstruction = await readInstruction();
137
+
138
+ // 递归识别多子项目
139
+ const subProjects = await findSubProjects(projectPath);
140
+ if (subProjects.length === 0) {
141
+ subProjects.push({
142
+ root: projectPath,
143
+ name: path.basename(projectPath),
144
+ manifests: await readProjectManifest(projectPath),
145
+ readme: await safeReadFile(path.join(projectPath, 'README.md'), 8000),
146
+ dirTree: await listDirTree(projectPath, 2, 400)
147
+ });
148
+ }
149
+
150
+ const projectName = path.basename(projectPath);
151
+ const LLM_OPTS = { timeoutMs: 1200000 };
152
+
153
+ // 第一阶段:生成「可复用的提示词模板」
154
+ const overviewBlock = subProjects.map(sp =>
155
+ `### 子项目 ${sp.name} (${sp.root})\n目录:\n${sp.dirTree || '(无)'}`
156
+ ).join('\n\n');
157
+
158
+ const firstPrompt = `${userInstruction}
159
+
160
+ ---
161
+
162
+ 以下是你需要分析的项目(请先生成「可复用的提示词模板」,不要直接给总结):
163
+
164
+ 项目根目录:${projectPath}
165
+ 项目名称:${projectName}
166
+ 子项目数:${subProjects.length}
167
+
168
+ ## 子项目概览
169
+ ${overviewBlock || '(无)'}
170
+
171
+ ## 各子项目 manifest 与 README
172
+ ${subProjects.map(sp => {
173
+ const manifestBlock = Object.entries(sp.manifests)
174
+ .map(([n, c]) => `\n--- ${n} ---\n${c}`)
175
+ .join('\n');
176
+ return `\n### ${sp.name}\n${manifestBlock || '(无 manifest)'}\n\nREADME(前 8KB):\n${sp.readme || '(无)'}`;
177
+ }).join('\n')}
178
+
179
+ 只返回 JSON:
180
+ {
181
+ "name": "项目名(建议:项目名+架构说明,可根据实际项目特征调整)",
182
+ "template": "可复用的提示词模板,长度不限,请充分覆盖 {{task.title}} / {{task.desc}} / {{sub.title}} / {{sub.desc}} / {{repo.path}} / {{branch}} 这 6 个变量的用法与上下文"
183
+ }`;
184
+
185
+ const first = await callLlmJsonWithRetry(model, firstPrompt, LLM_OPTS);
186
+ const templateName = String(first.name || '').trim() || `${projectName}架构说明`;
187
+ const template = String(first.template || '').trim();
188
+
189
+ // 第二阶段:为每个子项目分别生成总结
190
+ async function summarizeOneSub(sp) {
191
+ const manifestBlock = Object.entries(sp.manifests)
192
+ .map(([n, c]) => `\n--- ${n} ---\n${c}`)
193
+ .join('\n');
194
+ const subPrompt = `${template}
195
+
196
+ ---
197
+
198
+ 以下是你需要分析的一个子项目(请直接基于这些数据输出该子项目的架构说明):
199
+
200
+ 子项目根目录:${sp.root}
201
+ 子项目名称:${sp.name}
202
+
203
+ ## 目录结构(前 2 层)
204
+ ${sp.dirTree || '(无)'}
205
+
206
+ ## manifest
207
+ ${manifestBlock || '(无)'}
208
+
209
+ ## README
210
+ ${sp.readme || '(无)'}
211
+
212
+ 只返回 JSON:
213
+ {
214
+ "summary": "该子项目的架构说明,长度不限,模型自行决定篇幅与详尽程度,能写多详细就多详细"
215
+ }`;
216
+ const r = await callLlmJsonWithRetry(model, subPrompt, LLM_OPTS);
217
+ return { name: sp.name, root: sp.root, summary: String(r.summary || '').trim() };
218
+ }
219
+
220
+ // 串行执行,避免并发触发 provider 限流(429)
221
+ const subSummaries = [];
222
+ for (const sp of subProjects) {
223
+ subSummaries.push(await summarizeOneSub(sp));
224
+ }
225
+
226
+ // 第三阶段:仅多子项目时合并
227
+ let finalSummary = '';
228
+ let finalName = templateName;
229
+
230
+ if (subSummaries.length === 1) {
231
+ finalSummary = subSummaries[0].summary;
232
+ } else {
233
+ const mergePrompt = `你是项目架构师。下列是同一仓库下 N 个子项目的架构说明,请合并输出**单一**的「项目架构说明」,长度不限,模型自行决定篇幅与详尽程度。覆盖:项目整体定位、技术栈、模块划分、子项目间关系、核心流程、关键设计决策。
234
+ 子项目之间用清晰的小标题或编号分隔。最后输出一段「整体架构」总结它们如何协同。
235
+ 只引用实际出现的子项目名 / 文件路径 / 依赖名,不要编造。只返回 JSON:
236
+
237
+ {
238
+ "name": "项目名(建议:项目名+架构说明)",
239
+ "summary": "合并后的架构说明"
240
+ }
241
+
242
+ ## 子项目说明
243
+ ${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summary || '(空)'}`).join('\n')}`;
244
+
245
+ const merged = await callLlmJsonWithRetry(model, mergePrompt, LLM_OPTS);
246
+ finalSummary = String(merged.summary || '').trim()
247
+ || subSummaries.map(s => `### ${s.name}\n${s.summary}`).join('\n\n');
248
+ finalName = String(merged.name || '').trim() || templateName;
249
+ }
250
+
251
+ // 名称始终固定为「<项目名>架构说明」,不信任模型返回的 name 字段
252
+ finalName = `${projectName}架构说明`;
253
+
254
+ if (!finalSummary) {
255
+ throw new HttpError(502, '模型返回为空,请稍后重试');
256
+ }
257
+
258
+ res.json({
259
+ success: true,
260
+ name: finalName,
261
+ template,
262
+ result: finalSummary,
263
+ content: finalSummary
264
+ });
265
+ }));
266
+
267
+ // ════════════════════════════════════════════════════════════════════════
268
+ // §2. 生成指令:读 / 写
269
+ // ════════════════════════════════════════════════════════════════════════
270
+ app.get('/api/workbench/prompts/ai-instruction', asyncRoute(async (_req, res) => {
271
+ const instruction = await readInstruction();
272
+ res.json({ success: true, instruction, isDefault: instruction === DEFAULT_INSTRUCTION });
273
+ }));
274
+
275
+ app.put('/api/workbench/prompts/ai-instruction', asyncRoute(async (req, res) => {
276
+ const text = req.body && typeof req.body.instruction === 'string'
277
+ ? req.body.instruction.trim()
278
+ : '';
279
+ if (!text) throw new HttpError(400, '指令不能为空');
280
+ if (text.length > 500000) throw new HttpError(413, '指令过长(最多 500000 字符)');
281
+ await writeInstruction(text);
282
+ res.json({ success: true });
283
+ }));
284
+
285
+ // ════════════════════════════════════════════════════════════════════════
286
+ // §3. AI 拆分子任务指令:读 / 写
287
+ // ════════════════════════════════════════════════════════════════════════
288
+ app.get('/api/workbench/tasks/ai-subtask-instruction', asyncRoute(async (req, res) => {
289
+ const def = pickDefaultSubtaskInstruction(req);
290
+ const instruction = await readSubtaskInstruction(req);
291
+ res.json({ success: true, instruction, isDefault: instruction === def });
292
+ }));
293
+
294
+ app.put('/api/workbench/tasks/ai-subtask-instruction', asyncRoute(async (req, res) => {
295
+ const text = req.body && typeof req.body.instruction === 'string'
296
+ ? req.body.instruction.trim()
297
+ : '';
298
+ if (!text) throw new HttpError(400, '指令不能为空');
299
+ if (text.length > 500000) throw new HttpError(413, '指令过长(最多 500000 字符)');
300
+ // 如果保存的文本正好等于当前 locale 的默认——不写文件,保持 fallback 行为
301
+ const def = pickDefaultSubtaskInstruction(req);
302
+ if (text === def) {
303
+ try { await fsp.unlink(SUBTASK_INSTRUCTION_FILE); } catch { /* 已不存在 */ }
304
+ return res.json({ success: true, isDefault: true });
305
+ }
306
+ await writeSubtaskInstruction(text);
307
+ res.json({ success: true, isDefault: false });
308
+ }));
309
+
310
+ // ════════════════════════════════════════════════════════════════════════
311
+ // §4. AI 拆分子任务(SSE 流式)— 保留 try/catch,错误以 SSE 帧发出
312
+ // ════════════════════════════════════════════════════════════════════════
313
+ app.post('/api/workbench/tasks/ai-split-subtasks', async (req, res) => {
314
+ const title = String(req.body?.title || '').trim();
315
+ const desc = String(req.body?.desc || '').trim();
316
+ const taskId = String(req.body?.taskId || '').trim();
317
+ const promptId = String(req.body?.promptId || '').trim();
318
+ const customUserBlock = typeof req.body?.customUserBlock === 'string' ? req.body.customUserBlock : '';
319
+ if (!title) {
320
+ return res.status(400).json({ success: false, error: '任务标题不能为空' });
321
+ }
322
+
323
+ // 建立 SSE
324
+ res.set({
325
+ 'Content-Type': 'text/event-stream',
326
+ 'Cache-Control': 'no-cache, no-transform',
327
+ 'Connection': 'keep-alive',
328
+ 'X-Accel-Buffering': 'no'
329
+ });
330
+ res.flushHeaders?.();
331
+ const send = (obj) => {
332
+ try { res.write(`data: ${JSON.stringify(obj)}\n\n`); } catch {}
333
+ };
334
+
335
+ const abortController = new AbortController();
336
+ let finished = false;
337
+ // 客户端真实断开:监听 socket close
338
+ const onSocketClose = () => {
339
+ if (!finished) abortController.abort();
340
+ };
341
+ if (req.socket) req.socket.once('close', onSocketClose);
342
+
343
+ try {
344
+ let model;
345
+ try {
346
+ if (!configManager) throw new Error('configManager 不可用');
347
+ const rawConfig = await configManager.readRawConfigFile();
348
+ const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
349
+ model = models.find(m => m.isDefault) || models[0];
350
+ } catch (err) {
351
+ send({ type: 'error', error: '读取 AI 配置失败: ' + err.message });
352
+ finished = true;
353
+ return res.end();
354
+ }
355
+ if (!model) {
356
+ send({ type: 'error', error: '未配置 AI 模型,请先在通用设置中添加模型' });
357
+ finished = true;
358
+ return res.end();
359
+ }
360
+
361
+ const userInstruction = await readSubtaskInstruction(req);
362
+ const projectPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
363
+ const projectName = projectPath ? path.basename(projectPath) : '(未指定项目)';
364
+ const manifestHint = await detectProjectManifest(projectPath);
365
+
366
+ // 取绑定的预置模板(promptId)
367
+ let templateBlock = '';
368
+ if (promptId) {
369
+ try {
370
+ const promptData = await readJson(PROMPTS_FILE, { prompts: [] });
371
+ const p = (promptData.prompts || []).find(x => x.id === promptId);
372
+ if (p && p.content) {
373
+ templateBlock = `\n\n## 子任务执行模板(每个拆出的子任务最终会被这套模板包裹后送给 claude 执行;拆分时请确保子任务能让模板里的 {{sub.title}} / {{sub.desc}} 等变量填得有意义)\n模板名:${p.name || '(未命名)'}\n---\n${p.content}\n---`;
374
+ }
375
+ } catch { /* 模板读取失败不影响拆分 */ }
376
+ }
377
+
378
+ // 取任务附件
379
+ let attachmentBlock = '';
380
+ const imageDataUrls = [];
381
+ if (taskId) {
382
+ try {
383
+ const data = await readJson(TASKS_FILE, { tasks: [] });
384
+ const task = (data.tasks || []).find(t => t.id === taskId);
385
+ const atts = Array.isArray(task?.attachments) ? task.attachments : [];
386
+ if (atts.length > 0) {
387
+ const lines = [];
388
+ for (let i = 0; i < atts.length; i++) {
389
+ const a = atts[i];
390
+ if (!a || !a.absolutePath) continue;
391
+ lines.push(` ${i + 1}. [${a.mimeType || 'application/octet-stream'}] ${a.absolutePath}`);
392
+ if (isImageExt(a.ext)) {
393
+ try {
394
+ const buf = await fsp.readFile(a.absolutePath);
395
+ const mime = a.mimeType || 'image/png';
396
+ imageDataUrls.push(`data:${mime};base64,${buf.toString('base64')}`);
397
+ } catch { /* 文件丢失就跳过这张图 */ }
398
+ }
399
+ }
400
+ if (lines.length > 0) {
401
+ const imgNote = imageDataUrls.length > 0
402
+ ? `(其中 ${imageDataUrls.length} 张图片已随消息一并发送,请直接基于图片内容拆分)`
403
+ : '';
404
+ attachmentBlock = `\n\n## 任务附件${imgNote}\n${lines.join('\n')}`;
405
+ }
406
+ }
407
+ } catch { /* 没拿到附件不影响拆分 */ }
408
+ }
409
+
410
+ const userBlock = customUserBlock.trim() ? customUserBlock : `${userInstruction}
411
+
412
+ ---
413
+
414
+ ## 待拆分的任务
415
+ 标题:${title}
416
+ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateBlock}
417
+
418
+ ## 项目上下文(仅供参考,便于拆分时考虑项目特性)
419
+ - 项目名称:${projectName}
420
+ - 项目根目录:${projectPath || '(未指定)'}
421
+ - 主要 manifest:${manifestHint || '(未识别到)'}
422
+
423
+ 请先仔细分析(可以放在 reasoning 中或直接写出来)。仔细分析指:列出 5 个维度——任务真实目标 / 关键技术栈与边界 / 自然执行顺序 / 风险点与可能失败步骤 / 是否需要前置调研——不要简短一两句话就过;分析过后再给出 JSON。JSON 用 \`\`\`json ... \`\`\` 包裹:
424
+ {
425
+ "subtasks": [
426
+ { "title": "子任务标题", "desc": "具体描述" }
427
+ ]
428
+ }
429
+
430
+ **JSON 输出严格要求**(不遵守会导致解析失败、用户无法入库):
431
+ 1. title 和 desc 内如需引用术语 / 页面名 / 状态名,**必须使用中文引号「」或『』**,禁止使用 ASCII 双引号、单引号或反引号,否则会破坏外层 JSON 结构。
432
+ 2. JSON 中不允许尾随逗号(最后一个元素后面不能跟逗号)。
433
+ 3. JSON 中不允许写注释。
434
+ 4. 所有字符串字段必须用 ASCII 双引号包裹,字符串内部如有换行用 \\n 转义。`;
435
+
436
+ // 先把 prompt 元信息推给前端
437
+ send({ type: 'meta', prompt: { system: userInstruction, user: userBlock } });
438
+
439
+ // 流式调用 LLM
440
+ const { content, aborted } = await callLlmStream(
441
+ model,
442
+ userBlock,
443
+ (delta) => {
444
+ if (delta.thinking) send({ type: 'thinking', delta: delta.thinking });
445
+ if (delta.content) send({ type: 'content', delta: delta.content });
446
+ },
447
+ { maxTokens: 32000, timeoutMs: 600000, images: imageDataUrls, signal: abortController.signal }
448
+ );
449
+
450
+ if (aborted) {
451
+ send({ type: 'error', error: '已取消' });
452
+ finished = true;
453
+ return res.end();
454
+ }
455
+
456
+ const { parsed, parseError, parseStage } = parseSubtaskJson(content);
457
+ const list = Array.isArray(parsed?.subtasks) ? parsed.subtasks : [];
458
+ const subtasks = list
459
+ .map(s => ({
460
+ title: String(s?.title || '').trim(),
461
+ desc: String(s?.desc || '').trim()
462
+ }))
463
+ .filter(s => s.title);
464
+
465
+ send({ type: 'done', subtasks, raw: content, parseError, parseStage });
466
+ finished = true;
467
+ res.end();
468
+ } catch (err) {
469
+ send({ type: 'error', error: 'AI 拆分失败: ' + (err?.message || String(err)) });
470
+ finished = true;
471
+ res.end();
472
+ }
473
+ });
474
+
475
+ // ════════════════════════════════════════════════════════════════════════
476
+ // §5. AI 拆分预览(不调 LLM,只返回拼好的 prompt)
477
+ // ════════════════════════════════════════════════════════════════════════
478
+ app.post('/api/workbench/tasks/ai-split-preview', asyncRoute(async (req, res) => {
479
+ const title = String(req.body?.title || '').trim();
480
+ const desc = String(req.body?.desc || '').trim();
481
+ const taskId = String(req.body?.taskId || '').trim();
482
+ const promptId = String(req.body?.promptId || '').trim();
483
+ if (!title) throw new HttpError(400, '任务标题不能为空');
484
+
485
+ const userInstruction = await readSubtaskInstruction(req);
486
+ const projectPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
487
+ const projectName = projectPath ? path.basename(projectPath) : '(未指定项目)';
488
+ const manifestHint = await detectProjectManifest(projectPath);
489
+
490
+ let templateBlock = '';
491
+ if (promptId) {
492
+ try {
493
+ const promptData = await readJson(PROMPTS_FILE, { prompts: [] });
494
+ const p = (promptData.prompts || []).find(x => x.id === promptId);
495
+ if (p && p.content) {
496
+ templateBlock = `\n\n## 子任务执行模板(每个拆出的子任务最终会被这套模板包裹后送给 claude 执行;拆分时请确保子任务能让模板里的 {{sub.title}} / {{sub.desc}} 等变量填得有意义)\n模板名:${p.name || '(未命名)'}\n---\n${p.content}\n---`;
497
+ }
498
+ } catch { /* 模板读取失败不影响预览 */ }
499
+ }
500
+
501
+ // 附件:只列条目,不读图片二进制(preview 阶段不需要 image_data_url)
502
+ let attachmentBlock = '';
503
+ let imageCount = 0;
504
+ if (taskId) {
505
+ try {
506
+ const data = await readJson(TASKS_FILE, { tasks: [] });
507
+ const task = (data.tasks || []).find(t => t.id === taskId);
508
+ const atts = Array.isArray(task?.attachments) ? task.attachments : [];
509
+ if (atts.length > 0) {
510
+ const lines = [];
511
+ for (let i = 0; i < atts.length; i++) {
512
+ const a = atts[i];
513
+ if (!a || !a.absolutePath) continue;
514
+ lines.push(` ${i + 1}. [${a.mimeType || 'application/octet-stream'}] ${a.absolutePath}`);
515
+ if (isImageExt(a.ext)) imageCount++;
516
+ }
517
+ if (lines.length > 0) {
518
+ const imgNote = imageCount > 0
519
+ ? `(其中 ${imageCount} 张图片已随消息一并发送,请直接基于图片内容拆分)`
520
+ : '';
521
+ attachmentBlock = `\n\n## 任务附件${imgNote}\n${lines.join('\n')}`;
522
+ }
523
+ }
524
+ } catch { /* 没拿到附件不影响预览 */ }
525
+ }
526
+
527
+ const userBlock = `${userInstruction}
528
+
529
+ ---
530
+
531
+ ## 待拆分的任务
532
+ 标题:${title}
533
+ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateBlock}
534
+
535
+ ## 项目上下文(仅供参考,便于拆分时考虑项目特性)
536
+ - 项目名称:${projectName}
537
+ - 项目根目录:${projectPath || '(未指定)'}
538
+ - 主要 manifest:${manifestHint || '(未识别到)'}
539
+
540
+ 请先仔细分析(可以放在 reasoning 中或直接写出来)。仔细分析指:列出 5 个维度——任务真实目标 / 关键技术栈与边界 / 自然执行顺序 / 风险点与可能失败步骤 / 是否需要前置调研——不要简短一两句话就过;分析过后再给出 JSON。JSON 用 \`\`\`json ... \`\`\` 包裹:
541
+ {
542
+ "subtasks": [
543
+ { "title": "子任务标题", "desc": "具体描述" }
544
+ ]
545
+ }
546
+
547
+ **JSON 输出严格要求**(不遵守会导致解析失败、用户无法入库):
548
+ 1. title 和 desc 内如需引用术语 / 页面名 / 状态名,**必须使用中文引号「」或『』**,禁止使用 ASCII 双引号、单引号或反引号,否则会破坏外层 JSON 结构。
549
+ 2. JSON 中不允许尾随逗号(最后一个元素后面不能跟逗号)。
550
+ 3. JSON 中不允许写注释。
551
+ 4. 所有字符串字段必须用 ASCII 双引号包裹,字符串内部如有换行用 \\n 转义。`;
552
+
553
+ res.json({
554
+ success: true,
555
+ system: userInstruction,
556
+ user: userBlock,
557
+ hasImages: imageCount > 0,
558
+ imageCount
559
+ });
560
+ }));
561
+
562
+ // ════════════════════════════════════════════════════════════════════════
563
+ // §6. 解析子任务 JSON(前端用户手改后调)
564
+ // ════════════════════════════════════════════════════════════════════════
565
+ app.post('/api/workbench/tasks/parse-subtasks', asyncRoute(async (req, res) => {
566
+ const raw = String(req.body?.raw || '');
567
+ const { parsed, parseError, parseStage } = parseSubtaskJson(raw);
568
+ const list = Array.isArray(parsed?.subtasks) ? parsed.subtasks : [];
569
+ const subtasks = list
570
+ .map(s => ({
571
+ title: String(s?.title || '').trim(),
572
+ desc: String(s?.desc || '').trim()
573
+ }))
574
+ .filter(s => s.title);
575
+ res.json({ success: true, subtasks, parseError, parseStage });
576
+ }));
577
+
578
+ // ════════════════════════════════════════════════════════════════════════
579
+ // §7. AI 对话拆分(多轮 SSE)— 保留 try/catch
580
+ // ════════════════════════════════════════════════════════════════════════
581
+ app.post('/api/workbench/tasks/ai-chat-split', async (req, res) => {
582
+ const userMessage = String(req.body?.userMessage || '').trim();
583
+ const sessionIdInput = String(req.body?.sessionId || '').trim();
584
+ const title = String(req.body?.title || '').trim();
585
+ const desc = String(req.body?.desc || '').trim();
586
+ const taskId = String(req.body?.taskId || '').trim();
587
+ const promptId = String(req.body?.promptId || '').trim();
588
+
589
+ // SSE 头
590
+ res.set({
591
+ 'Content-Type': 'text/event-stream',
592
+ 'Cache-Control': 'no-cache, no-transform',
593
+ 'Connection': 'keep-alive',
594
+ 'X-Accel-Buffering': 'no'
595
+ });
596
+ res.flushHeaders?.();
597
+ const send = (obj) => {
598
+ try { res.write(`data: ${JSON.stringify(obj)}\n\n`); } catch {}
599
+ };
600
+ const abortController = new AbortController();
601
+ let finished = false;
602
+ if (req.socket) req.socket.once('close', () => { if (!finished) abortController.abort(); });
603
+
604
+ try {
605
+ // 加载或新建 session
606
+ let session, isNew = false;
607
+ if (sessionIdInput) {
608
+ try {
609
+ session = await readSessionFile(sessionIdInput);
610
+ } catch (err) {
611
+ if (err.statusCode === 404) {
612
+ send({ type: 'error', error: '会话不存在' });
613
+ return res.end();
614
+ }
615
+ throw err;
616
+ }
617
+ } else {
618
+ if (!title) {
619
+ send({ type: 'error', error: '新建会话时必须提供 title' });
620
+ return res.end();
621
+ }
622
+ session = {
623
+ version: 1,
624
+ sessionId: genSessionId(),
625
+ title, desc, taskId, promptId,
626
+ createdAt: nowIso(),
627
+ updatedAt: nowIso(),
628
+ messages: [],
629
+ latestSubtasks: [],
630
+ latestRaw: '',
631
+ latestParseStage: ''
632
+ };
633
+ isNew = true;
634
+ }
635
+
636
+ if (!userMessage) {
637
+ send({ type: 'error', error: '消息内容不能为空' });
638
+ return res.end();
639
+ }
640
+
641
+ // 拼 system message(只在第一轮)
642
+ if (session.messages.length === 0) {
643
+ const userInstruction = await readSubtaskInstruction(req);
644
+ const ctxLines = [];
645
+ if (title) ctxLines.push(`【任务标题】${title}`);
646
+ if (desc) ctxLines.push(`【任务描述】${desc}`);
647
+ const sysContent = ctxLines.length
648
+ ? `${userInstruction}\n\n${ctxLines.join('\n')}`
649
+ : userInstruction;
650
+ session.messages.push({ role: 'system', content: sysContent });
651
+ }
652
+
653
+ // 追加 user message
654
+ session.messages.push({ role: 'user', content: userMessage });
655
+
656
+ // 推 meta + user_echo
657
+ send({
658
+ type: 'meta',
659
+ sessionId: session.sessionId,
660
+ isNew,
661
+ prompt: { system: session.messages[0].content, user: userMessage }
662
+ });
663
+ send({ type: 'user_echo', userMessage });
664
+
665
+ // 加载 model
666
+ let model;
667
+ try {
668
+ if (!configManager) throw new Error('configManager 不可用');
669
+ const rawConfig = await configManager.readRawConfigFile();
670
+ const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
671
+ model = models.find(m => m.isDefault) || models[0];
672
+ } catch (err) {
673
+ send({ type: 'error', error: '读取 AI 配置失败: ' + err.message });
674
+ finished = true;
675
+ return res.end();
676
+ }
677
+ if (!model) {
678
+ send({ type: 'error', error: '未配置 AI 模型' });
679
+ finished = true;
680
+ return res.end();
681
+ }
682
+
683
+ // 流式调用(messages 模式)
684
+ const { content, aborted } = await callLlmStream(
685
+ model,
686
+ session.messages,
687
+ (delta) => {
688
+ if (delta.thinking) send({ type: 'thinking', delta: delta.thinking });
689
+ if (delta.content) send({ type: 'content', delta: delta.content });
690
+ },
691
+ { maxTokens: 32000, timeoutMs: 600000, signal: abortController.signal }
692
+ );
693
+
694
+ if (aborted) {
695
+ session.updatedAt = nowIso();
696
+ await writeSessionFile(session.sessionId, session).catch(() => {});
697
+ enforceSessionsRetention().catch(() => {});
698
+ send({ type: 'error', error: '已取消' });
699
+ finished = true;
700
+ return res.end();
701
+ }
702
+
703
+ // 解析 + 写盘
704
+ const { parsed, parseError, parseStage } = parseSubtaskJson(content);
705
+ const subtasks = Array.isArray(parsed?.subtasks)
706
+ ? parsed.subtasks
707
+ .map(s => ({
708
+ title: String(s?.title || '').trim(),
709
+ desc: String(s?.desc || '').trim()
710
+ }))
711
+ .filter(s => s.title)
712
+ : [];
713
+
714
+ session.messages.push({ role: 'assistant', content });
715
+ session.latestSubtasks = subtasks;
716
+ session.latestRaw = content;
717
+ session.latestParseStage = parseStage;
718
+ session.updatedAt = nowIso();
719
+ await writeSessionFile(session.sessionId, session);
720
+ enforceSessionsRetention().catch(() => {});
721
+
722
+ send({ type: 'done', subtasks, raw: content, parseError, parseStage });
723
+ finished = true;
724
+ res.end();
725
+ } catch (err) {
726
+ send({ type: 'error', error: '对话拆分失败: ' + (err?.message || String(err)) });
727
+ finished = true;
728
+ res.end();
729
+ }
730
+ });
731
+
732
+ // ════════════════════════════════════════════════════════════════════════
733
+ // §8. AI 对话拆分会话:列表 / 详情 / 删除
734
+ // ════════════════════════════════════════════════════════════════════════
735
+ app.get('/api/workbench/tasks/ai-chat-sessions', asyncRoute(async (_req, res) => {
736
+ const sessions = await listSessionsMeta();
737
+ res.json({ success: true, sessions });
738
+ }));
739
+
740
+ app.get('/api/workbench/tasks/ai-chat-sessions/:sessionId', asyncRoute(async (req, res) => {
741
+ // readSessionFile 抛带 statusCode 的 Error(400/404),让 asyncRoute 透传
742
+ const session = await readSessionFile(req.params.sessionId);
743
+ res.json({ success: true, session });
744
+ }));
745
+
746
+ app.delete('/api/workbench/tasks/ai-chat-sessions/:sessionId', asyncRoute(async (req, res) => {
747
+ await deleteSessionFile(req.params.sessionId);
748
+ res.json({ success: true });
749
+ }));
750
+
751
+ // ════════════════════════════════════════════════════════════════════════
752
+ // §9. SSE 事件流(订阅 job/sub/task 更新)
753
+ // ════════════════════════════════════════════════════════════════════════
754
+ app.get('/api/workbench/events', (req, res) => {
755
+ res.set({
756
+ 'Content-Type': 'text/event-stream',
757
+ 'Cache-Control': 'no-cache, no-transform',
758
+ 'Connection': 'keep-alive',
759
+ 'X-Accel-Buffering': 'no'
760
+ });
761
+ res.flushHeaders?.();
762
+ const send = (data) => {
763
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
764
+ };
765
+ // 初始快照
766
+ send({ event: 'hello', payload: { jobs: snapshotJobs() }, ts: nowIso() });
767
+ const handler = (evt) => send(evt);
768
+ bus.on('event', handler);
769
+ const ka = setInterval(() => res.write(`: keep-alive\n\n`), 15000);
770
+ req.on('close', () => {
771
+ clearInterval(ka);
772
+ bus.off('event', handler);
773
+ });
774
+ });
775
+
776
+ // ════════════════════════════════════════════════════════════════════════
777
+ // §10. 提示词 CRUD
778
+ // ════════════════════════════════════════════════════════════════════════
779
+ app.get('/api/workbench/prompts', asyncRoute(async (_req, res) => {
780
+ const data = await readJson(PROMPTS_FILE, { prompts: [] });
781
+ res.json({ success: true, prompts: data.prompts || [] });
782
+ }));
783
+
784
+ app.post('/api/workbench/prompts', asyncRoute(async (req, res) => {
785
+ const { id, name, content, projectPath } = req.body || {};
786
+ if (!name || typeof content !== 'string') throw new HttpError(400, 'name 和 content 必填');
787
+ const data = await readJson(PROMPTS_FILE, { prompts: [] });
788
+ const prompts = data.prompts || [];
789
+ const now = nowIso();
790
+ const normalizedProjectPath = typeof projectPath === 'string' ? projectPath.trim() : '';
791
+ if (id) {
792
+ const i = prompts.findIndex(p => p.id === id);
793
+ if (i < 0) throw new HttpError(404, '提示词不存在');
794
+ prompts[i] = {
795
+ ...prompts[i],
796
+ name,
797
+ content,
798
+ projectPath: normalizedProjectPath,
799
+ updatedAt: now
800
+ };
801
+ await writeJson(PROMPTS_FILE, { prompts });
802
+ return res.json({ success: true, prompt: prompts[i] });
803
+ }
804
+ const prompt = {
805
+ id: genId(),
806
+ name,
807
+ content,
808
+ projectPath: normalizedProjectPath,
809
+ createdAt: now,
810
+ updatedAt: now
811
+ };
812
+ prompts.push(prompt);
813
+ await writeJson(PROMPTS_FILE, { prompts });
814
+ res.json({ success: true, prompt });
815
+ }));
816
+
817
+ app.delete('/api/workbench/prompts/:id', asyncRoute(async (req, res) => {
818
+ const data = await readJson(PROMPTS_FILE, { prompts: [] });
819
+ const prompts = (data.prompts || []).filter(p => p.id !== req.params.id);
820
+ await writeJson(PROMPTS_FILE, { prompts });
821
+ res.json({ success: true });
822
+ }));
823
+
824
+ // ════════════════════════════════════════════════════════════════════════
825
+ // §11. 任务 CRUD
826
+ // ════════════════════════════════════════════════════════════════════════
827
+ app.get('/api/workbench/tasks', asyncRoute(async (_req, res) => {
828
+ const data = await readJson(TASKS_FILE, { tasks: [] });
829
+ res.json({ success: true, tasks: data.tasks || [] });
830
+ }));
831
+
832
+ app.get('/api/workbench/current-project', asyncRoute(async (_req, res) => {
833
+ const projectPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
834
+ const projectName = projectPath ? projectPath.split(/[\\/]/).filter(Boolean).pop() : '';
835
+ res.json({ success: true, projectPath, projectName });
836
+ }));
837
+
838
+ // 任务排序:拖动落盘。body: { orderedIds, groupPath? }
839
+ app.put('/api/workbench/tasks/reorder', asyncRoute(async (req, res) => {
840
+ const orderedIds = Array.isArray(req.body && req.body.orderedIds) ? req.body.orderedIds : null;
841
+ const groupPath = typeof (req.body && req.body.groupPath) === 'string' ? req.body.groupPath.trim() || null : null;
842
+ if (!orderedIds || orderedIds.length === 0) throw new HttpError(400, 'orderedIds 不能为空');
843
+ if (!orderedIds.every((x) => typeof x === 'string' && x.length > 0)) {
844
+ throw new HttpError(400, 'orderedIds 必须是字符串数组');
845
+ }
846
+ if (new Set(orderedIds).size !== orderedIds.length) {
847
+ throw new HttpError(400, 'orderedIds 包含重复 id');
848
+ }
849
+ const data = await readJson(TASKS_FILE, { tasks: [] });
850
+ const tasks = data.tasks || [];
851
+ const idIndex = new Map();
852
+ for (let i = 0; i < tasks.length; i++) idIndex.set(tasks[i].id, i);
853
+ const missing = orderedIds.filter((id) => !idIndex.has(id));
854
+ if (missing.length > 0) {
855
+ throw new HttpError(400, `id 不存在: ${missing.slice(0, 3).join(',')}`);
856
+ }
857
+
858
+ // groupPath 完整性校验
859
+ if (groupPath) {
860
+ const groupKey = groupPath || '__no_project__';
861
+ const groupTasks = tasks.filter(t => ((t.projectPath || '').trim() || '__no_project__') === groupKey);
862
+ if (groupTasks.length > 0 && orderedIds.length !== groupTasks.length) {
863
+ throw new HttpError(400, `orderedIds 不完整:该组有 ${groupTasks.length} 个任务,但只传了 ${orderedIds.length} 个 id`);
864
+ }
865
+ const nonGroupIds = orderedIds.filter(id => {
866
+ const t = tasks.find(x => x.id === id);
867
+ return !t || ((t.projectPath || '').trim() || '__no_project__') !== groupKey;
868
+ });
869
+ if (nonGroupIds.length > 0) {
870
+ throw new HttpError(400, `以下 id 不属于指定分组: ${nonGroupIds.slice(0, 3).join(',')}`);
871
+ }
872
+ }
873
+
874
+ // 安全重排:用 Map 按序取
875
+ const orderedSet = new Set(orderedIds);
876
+ const byId = new Map(tasks.map(t => [t.id, t]));
877
+ const reordered = [];
878
+ let ptr = 0;
879
+ for (const t of tasks) {
880
+ if (orderedSet.has(t.id)) {
881
+ if (ptr < orderedIds.length) {
882
+ const target = byId.get(orderedIds[ptr++]);
883
+ if (target) {
884
+ reordered.push(target);
885
+ continue;
886
+ }
887
+ }
888
+ reordered.push(t);
889
+ } else {
890
+ reordered.push(t);
891
+ }
892
+ }
893
+ // 最终校验:数量必须一致(绝对不能丢任务)
894
+ if (reordered.length !== tasks.length) {
895
+ logger.error('[reorder] 数据丢失风险!原始 %d 个,重排后 %d 个', tasks.length, reordered.length);
896
+ throw new HttpError(500, '内部错误:重排结果数量不一致');
897
+ }
898
+ data.tasks = reordered;
899
+ await writeJson(TASKS_FILE, data);
900
+ publish('tasks:reordered', { tasks: reordered });
901
+ res.json({ success: true, tasks: reordered });
902
+ }));
903
+
904
+ app.post('/api/workbench/tasks', asyncRoute(async (req, res) => {
905
+ const { id, title, desc, promptId, subtasks, type: rawType, simpleOverride, sequential: rawSequential } = req.body || {};
906
+ const safeTitle = typeof title === 'string' ? title.trim() : '';
907
+ const taskType = rawType === 'simple' ? 'simple' : 'complex';
908
+ const sequential = rawSequential === false ? false : true;
909
+ const safeOverride = typeof simpleOverride === 'string' ? simpleOverride.slice(0, 8000) : '';
910
+ const data = await readJson(TASKS_FILE, { tasks: [] });
911
+ const tasks = data.tasks || [];
912
+ const now = nowIso();
913
+ const currentProjectPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
914
+ if (id) {
915
+ const i = tasks.findIndex(t => t.id === id);
916
+ if (i < 0) throw new HttpError(404, '任务不存在');
917
+ tasks[i] = {
918
+ ...tasks[i],
919
+ title: safeTitle,
920
+ desc: desc || '',
921
+ promptId: promptId || null,
922
+ type: taskType,
923
+ sequential: taskType === 'complex' ? sequential : true,
924
+ simpleOverride: taskType === 'simple' ? safeOverride : '',
925
+ subtasks: Array.isArray(subtasks) ? subtasks.map(s => ({
926
+ id: s.id || genId(),
927
+ title: s.title || '',
928
+ desc: s.desc || '',
929
+ status: s.status || 'todo',
930
+ promptOverride: s.promptOverride || '',
931
+ error: typeof s.error === 'string' ? s.error : undefined,
932
+ errorAt: typeof s.errorAt === 'string' ? s.errorAt : undefined,
933
+ attachments: Array.isArray(s.attachments) ? s.attachments.map(a => ({
934
+ id: a.id,
935
+ originalName: a.originalName,
936
+ mimeType: a.mimeType,
937
+ size: a.size,
938
+ ext: a.ext,
939
+ storedName: a.storedName,
940
+ absolutePath: a.absolutePath,
941
+ createdAt: a.createdAt
942
+ })) : (tasks[i].subtasks.find(x => x.id === s.id)?.attachments || [])
943
+ })) : tasks[i].subtasks,
944
+ updatedAt: now
945
+ };
946
+ await writeJson(TASKS_FILE, { tasks });
947
+ return res.json({ success: true, task: tasks[i] });
948
+ }
949
+ const task = {
950
+ id: genId(),
951
+ title: safeTitle,
952
+ desc: desc || '',
953
+ promptId: promptId || null,
954
+ type: taskType,
955
+ sequential: taskType === 'complex' ? sequential : true,
956
+ simpleOverride: taskType === 'simple' ? safeOverride : '',
957
+ projectPath: currentProjectPath || '',
958
+ subtasks: Array.isArray(subtasks) ? subtasks.map(s => ({
959
+ id: s.id || genId(),
960
+ title: s.title || '',
961
+ desc: s.desc || '',
962
+ status: s.status || 'todo',
963
+ promptOverride: s.promptOverride || '',
964
+ error: typeof s.error === 'string' ? s.error : undefined,
965
+ errorAt: typeof s.errorAt === 'string' ? s.errorAt : undefined,
966
+ attachments: Array.isArray(s.attachments) ? s.attachments : []
967
+ })) : [],
968
+ status: 'todo',
969
+ createdAt: now,
970
+ updatedAt: now
971
+ };
972
+ tasks.push(task);
973
+ await writeJson(TASKS_FILE, { tasks });
974
+ res.json({ success: true, task });
975
+ }));
976
+
977
+ app.delete('/api/workbench/tasks/:id', asyncRoute(async (req, res) => {
978
+ const taskId = req.params.id;
979
+ const data = await readJson(TASKS_FILE, { tasks: [] });
980
+ const allTasks = data.tasks || [];
981
+ const task = allTasks.find(t => t.id === taskId);
982
+ if (!task) throw new HttpError(404, '任务不存在');
983
+
984
+ // 检查活跃 job —— 有 running/pending 直接拒绝
985
+ const live = [];
986
+ for (const j of jobs.values()) {
987
+ if (j.taskId !== taskId) continue;
988
+ if (j.status === 'running' || j.status === 'pending') live.push(j.id);
989
+ }
990
+ if (live.length > 0) {
991
+ throw new HttpError(400, `有 ${live.length} 个 job 正在执行,请先停止`);
992
+ }
993
+
994
+ // 清理磁盘上的图片目录(失败只 warn 不阻断)
995
+ try {
996
+ await fsp.rm(path.join(IMAGES_DIR, '_task-' + taskId), { recursive: true, force: true });
997
+ } catch (e) {
998
+ logger.warn(`[workbench] failed to remove task image dir for ${taskId}: ${e.message}`);
999
+ }
1000
+ if (Array.isArray(task.subtasks)) {
1001
+ for (const sub of task.subtasks) {
1002
+ if (!sub || !sub.id) continue;
1003
+ try {
1004
+ await fsp.rm(path.join(IMAGES_DIR, sub.id), { recursive: true, force: true });
1005
+ } catch (e) {
1006
+ logger.warn(`[workbench] failed to remove sub image dir for ${sub.id}: ${e.message}`);
1007
+ }
1008
+ }
1009
+ }
1010
+
1011
+ const tasks = allTasks.filter(t => t.id !== taskId);
1012
+ await writeJson(TASKS_FILE, { tasks });
1013
+ res.json({ success: true });
1014
+ }));
1015
+
1016
+ // ════════════════════════════════════════════════════════════════════════
1017
+ // §12. 执行任务(整批 / 从指定 sub / 简单 / 单 sub)
1018
+ // ════════════════════════════════════════════════════════════════════════
1019
+ app.post('/api/workbench/tasks/:id/run', asyncRoute(async (req, res) => {
1020
+ const data = await readJson(TASKS_FILE, { tasks: [] });
1021
+ const task = (data.tasks || []).find(t => t.id === req.params.id);
1022
+ if (!task) throw new HttpError(404, '任务不存在');
1023
+ if (!task.subtasks || task.subtasks.length === 0) {
1024
+ throw new HttpError(400, '任务没有子任务');
1025
+ }
1026
+ const repoPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
1027
+ // 异步执行,立即返回
1028
+ res.json({ success: true, message: '已开始执行' });
1029
+ runTaskQueue(task, repoPath, '').catch(err => {
1030
+ publish('task:error', { taskId: task.id, error: err.message });
1031
+ });
1032
+ }));
1033
+
1034
+ app.post('/api/workbench/tasks/:id/run-from', asyncRoute(async (req, res) => {
1035
+ const data = await readJson(TASKS_FILE, { tasks: [] });
1036
+ const task = (data.tasks || []).find(t => t.id === req.params.id);
1037
+ if (!task) throw new HttpError(404, '任务不存在');
1038
+ if (!task.subtasks || task.subtasks.length === 0) {
1039
+ throw new HttpError(400, '任务没有子任务');
1040
+ }
1041
+ const startSubIndex = Number(req.body?.startSubIndex);
1042
+ if (!Number.isInteger(startSubIndex) || startSubIndex < 0 || startSubIndex >= task.subtasks.length) {
1043
+ throw new HttpError(400, 'startSubIndex 越界');
1044
+ }
1045
+ const repoPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
1046
+ res.json({ success: true, message: `已从第 ${startSubIndex + 1} 个子任务开始执行` });
1047
+ runTaskQueue(task, repoPath, '', { fromIndex: startSubIndex }).catch(err => {
1048
+ publish('task:error', { taskId: task.id, error: err.message });
1049
+ });
1050
+ }));
1051
+
1052
+ app.post('/api/workbench/tasks/:id/run-simple', asyncRoute(async (req, res) => {
1053
+ const data = await readJson(TASKS_FILE, { tasks: [] });
1054
+ const task = (data.tasks || []).find(t => t.id === req.params.id);
1055
+ if (!task) throw new HttpError(404, '任务不存在');
1056
+ if (task.type !== 'simple') {
1057
+ throw new HttpError(400, '该任务不是简单任务,请使用普通执行接口');
1058
+ }
1059
+ const repoPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
1060
+ const virtualSub = {
1061
+ id: `${task.id}__simple`,
1062
+ title: task.title,
1063
+ desc: task.desc || '',
1064
+ status: 'todo',
1065
+ promptOverride: task.simpleOverride || '',
1066
+ attachments: Array.isArray(task.attachments) ? task.attachments : []
1067
+ };
1068
+ res.json({ success: true, message: '已开始执行简单任务' });
1069
+ runSingleSubtask(task, virtualSub, repoPath, '', []).catch(err => {
1070
+ publish('task:error', { taskId: task.id, error: err.message });
1071
+ });
1072
+ }));
1073
+
1074
+ app.post('/api/workbench/subtasks/:id/run', asyncRoute(async (req, res) => {
1075
+ const data = await readJson(TASKS_FILE, { tasks: [] });
1076
+ const subId = req.params.id;
1077
+ let foundTask = null;
1078
+ let foundSub = null;
1079
+ for (const t of (data.tasks || [])) {
1080
+ if (!Array.isArray(t.subtasks)) continue;
1081
+ const s = t.subtasks.find(x => x.id === subId);
1082
+ if (s) { foundTask = t; foundSub = s; break; }
1083
+ }
1084
+ if (!foundTask || !foundSub) throw new HttpError(404, '子任务不存在');
1085
+ if (foundSub.status === 'running') throw new HttpError(400, '该子任务正在执行中');
1086
+ // 兜底:即便磁盘状态是 todo,如果磁盘里还没归档的 job 还在跑(进程被孤儿),也拦一下
1087
+ const liveJob = snapshotJobs().find(j => j.subId === subId && (j.status === 'running' || j.status === 'pending'));
1088
+ if (liveJob) throw new HttpError(400, '该子任务已有正在执行的 job');
1089
+ const repoPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
1090
+ res.json({ success: true, message: `已开始执行子任务:${foundSub.title || subId}` });
1091
+ (async () => {
1092
+ try {
1093
+ const priorOutputs = await collectPriorOutputs(foundTask, foundSub);
1094
+ await runSingleSubtask(foundTask, foundSub, repoPath, '', priorOutputs);
1095
+ await persistTaskAfterRun(foundTask);
1096
+ } catch (err) {
1097
+ publish('task:error', { taskId: foundTask.id, error: err.message });
1098
+ }
1099
+ })();
1100
+ }));
1101
+
1102
+ // ════════════════════════════════════════════════════════════════════════
1103
+ // §13. Job 查询(兜底,SSE 断了也能拉)
1104
+ // ════════════════════════════════════════════════════════════════════════
1105
+ app.get('/api/workbench/jobs', asyncRoute(async (_req, res) => {
1106
+ try {
1107
+ const data = await readJson(JOBS_FILE, { version: 1, jobs: [] });
1108
+ const fileJobs = (data && Array.isArray(data.jobs)) ? data.jobs : [];
1109
+ const fileIds = new Set(fileJobs.map(j => j.id));
1110
+ const liveOnly = snapshotJobs().filter(j => !fileIds.has(j.id));
1111
+ return res.json({ success: true, jobs: [...fileJobs, ...liveOnly] });
1112
+ } catch (err) {
1113
+ // 读取失败兜底:只返内存的
1114
+ res.json({ success: true, jobs: snapshotJobs() });
1115
+ }
1116
+ }));
1117
+
1118
+ // ════════════════════════════════════════════════════════════════════════
1119
+ // §14. 取消正在执行的 job
1120
+ // ════════════════════════════════════════════════════════════════════════
1121
+ app.post('/api/workbench/jobs/:id/cancel', asyncRoute(async (req, res) => {
1122
+ const job = jobs.get(req.params.id);
1123
+ if (!job) throw new HttpError(404, 'job 不存在');
1124
+ if (job.status !== 'running' && job.status !== 'pending') {
1125
+ throw new HttpError(400, `当前状态 ${job.status} 不可取消`);
1126
+ }
1127
+ cancelledJobs.add(job.id);
1128
+ // 立即给前端一个状态反馈(不等 child 真正退出)
1129
+ job.status = 'cancelled';
1130
+ job.error = '用户已停止执行';
1131
+ job.endedAt = nowIso();
1132
+ publish('job:update', { ...job }); // 浅拷贝避免序列化 child 引用
1133
+ // 同步把 sub 也置 cancelled
1134
+ syncSubToCancelled(job).catch(err => logger.warn('[workbench] syncSubToCancelled failed:', err.message));
1135
+ // 终态:fire-and-forget 同步落盘
1136
+ flushJobsSaveNow().catch(err => logger.warn('[workbench] jobs save failed:', err.message));
1137
+ const child = job.child;
1138
+ if (!child) {
1139
+ return res.json({ success: true, message: '已标记取消,进程将尽快结束' });
1140
+ }
1141
+ try {
1142
+ if (process.platform === 'win32') {
1143
+ // Windows: child.kill(SIGTERM) 经常无效,用 taskkill 杀进程树
1144
+ execFile('taskkill', ['/PID', String(child.pid), '/T', '/F'], (err) => {
1145
+ if (err) {
1146
+ logger.warn(`[workbench] taskkill ${child.pid} 失败: ${err.message}`);
1147
+ }
1148
+ });
1149
+ } else {
1150
+ child.kill('SIGTERM');
1151
+ }
1152
+ res.json({ success: true, message: '已发送停止信号' });
1153
+ } catch (err) {
1154
+ cancelledJobs.delete(job.id);
1155
+ throw new HttpError(500, '发送停止信号失败: ' + err.message);
1156
+ }
1157
+ }));
1158
+
1159
+ // ════════════════════════════════════════════════════════════════════════
1160
+ // §15. 续接简单任务对话(基于上一轮 claudeSessionId 用 --resume 续接)
1161
+ // ════════════════════════════════════════════════════════════════════════
1162
+ app.post('/api/workbench/jobs/:id/continue', asyncRoute(async (req, res) => {
1163
+ const userMessage = String(req.body?.userMessage || '').trim();
1164
+ if (!userMessage) throw new HttpError(400, 'userMessage 不能为空');
1165
+ const prevJob = jobs.get(req.params.id);
1166
+ if (!prevJob) throw new HttpError(404, 'job 不存在');
1167
+ if (prevJob.status === 'running' || prevJob.status === 'pending') {
1168
+ throw new HttpError(400, '上一轮还在执行,请等结束后再续接');
1169
+ }
1170
+ if (!prevJob.claudeSessionId) {
1171
+ throw new HttpError(400, '无法续接:会话标识未捕获');
1172
+ }
1173
+ const data = await readJson(TASKS_FILE, { tasks: [] });
1174
+ const task = (data.tasks || []).find(t => t.id === prevJob.taskId);
1175
+ if (!task) throw new HttpError(404, '所属任务不存在');
1176
+ if (task.type !== 'simple') throw new HttpError(400, '仅支持简单任务的续接');
1177
+ // 计算续接轮次号
1178
+ const subIdPrefix = `${task.id}__simple`;
1179
+ let maxRound = 0;
1180
+ for (const j of jobs.values()) {
1181
+ if (j.taskId !== task.id) continue;
1182
+ if (j.subId === subIdPrefix) continue;
1183
+ const m = j.subId && j.subId.match(/__simple__r(\d+)$/);
1184
+ if (m) {
1185
+ const n = parseInt(m[1], 10);
1186
+ if (!isNaN(n) && n > maxRound) maxRound = n;
1187
+ }
1188
+ }
1189
+ const nextRound = maxRound + 1;
1190
+ const bodyAtts = req.body?.attachments;
1191
+ const carryAtts = Array.isArray(bodyAtts) && bodyAtts.length > 0
1192
+ ? bodyAtts
1193
+ : (Array.isArray(task.attachments) ? task.attachments : []);
1194
+ const virtualSub = {
1195
+ id: `${task.id}__simple__r${nextRound}`,
1196
+ title: `续接 #${nextRound}`,
1197
+ desc: '',
1198
+ status: 'todo',
1199
+ promptOverride: userMessage,
1200
+ attachments: carryAtts
1201
+ };
1202
+ const repoPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
1203
+ res.json({ success: true, message: '已加入续接队列' });
1204
+ runSingleSubtask(task, virtualSub, repoPath, '', [], { resumeSessionId: prevJob.claudeSessionId })
1205
+ .catch(err => {
1206
+ publish('task:error', { taskId: task.id, error: err.message });
1207
+ });
1208
+ }));
1209
+
1210
+ // ════════════════════════════════════════════════════════════════════════
1211
+ // §16. 执行日志管理 API(持久化 + 清理)
1212
+ // /list、/config、/batch-delete、/clear 是字面路径,必须排在 :id 之前注册
1213
+ // ════════════════════════════════════════════════════════════════════════
1214
+ async function loadAllJobs() {
1215
+ const tasksData = await readJson(TASKS_FILE, { tasks: [] });
1216
+ const taskMap = new Map((tasksData.tasks || []).map(t => [t.id, t]));
1217
+ const data = await readJson(JOBS_FILE, { version: 1, jobs: [] });
1218
+ const fileJobs = (data && Array.isArray(data.jobs)) ? data.jobs : [];
1219
+ const fileIds = new Set(fileJobs.map(j => j.id));
1220
+ const liveOnly = Array.from(jobs.values())
1221
+ .filter(j => !fileIds.has(j.id))
1222
+ .map(j => serializeJob(j, taskMap));
1223
+ return [...fileJobs, ...liveOnly];
1224
+ }
1225
+
1226
+ function applyJobsFilter(list, q) {
1227
+ const status = (q.status || '').trim();
1228
+ const taskId = (q.taskId || '').trim();
1229
+ const term = (q.q || '').trim().toLowerCase();
1230
+ return list.filter(j => {
1231
+ if (status && j.status !== status) return false;
1232
+ if (taskId && j.taskId !== taskId) return false;
1233
+ if (term) {
1234
+ const hay = `${j.title || ''} ${j.taskTitle || ''} ${j.subTitle || ''}`.toLowerCase();
1235
+ if (!hay.includes(term)) return false;
1236
+ }
1237
+ return true;
1238
+ });
1239
+ }
1240
+
1241
+ app.get('/api/workbench/jobs/list', asyncRoute(async (req, res) => {
1242
+ const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50));
1243
+ const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
1244
+ const all = await loadAllJobs();
1245
+ const filtered = applyJobsFilter(all, req.query);
1246
+ const sortKey = (j) => j.endedAt || j.startedAt || j.id || '';
1247
+ filtered.sort((a, b) => sortKey(b).localeCompare(sortKey(a)));
1248
+ const total = filtered.length;
1249
+ const page = filtered.slice(offset, offset + limit);
1250
+ const byStatus = {};
1251
+ let totalSize = 0;
1252
+ for (const j of all) {
1253
+ byStatus[j.status] = (byStatus[j.status] || 0) + 1;
1254
+ totalSize += j.size || 0;
1255
+ }
1256
+ res.json({
1257
+ success: true,
1258
+ jobs: page,
1259
+ total,
1260
+ stats: { count: all.length, sizeMB: +(totalSize / 1024 / 1024).toFixed(2), byStatus }
1261
+ });
1262
+ }));
1263
+
1264
+ app.get('/api/workbench/jobs/config', asyncRoute(async (_req, res) => {
1265
+ res.json({ success: true, config: await readJobsConfig() });
1266
+ }));
1267
+
1268
+ app.put('/api/workbench/jobs/config', asyncRoute(async (req, res) => {
1269
+ const cfg = await writeJobsConfig(req.body || {});
1270
+ // 配置变更后立刻 enforce,让已落盘的多余记录立刻被裁掉
1271
+ await enforceRetention();
1272
+ res.json({ success: true, config: cfg });
1273
+ }));
1274
+
1275
+ app.post('/api/workbench/jobs/batch-delete', asyncRoute(async (req, res) => {
1276
+ const ids = Array.isArray(req.body?.ids) ? req.body.ids.filter(s => typeof s === 'string') : [];
1277
+ if (ids.length === 0) return res.json({ success: true, removed: 0 });
1278
+ let removed = 0;
1279
+ for (const id of ids) {
1280
+ if (jobs.delete(id)) removed++;
1281
+ }
1282
+ const data = await readJson(JOBS_FILE, { version: 1, jobs: [] });
1283
+ if (data && Array.isArray(data.jobs)) {
1284
+ const set = new Set(ids);
1285
+ const before = data.jobs.length;
1286
+ data.jobs = data.jobs.filter(j => !set.has(j.id));
1287
+ removed += before - data.jobs.length;
1288
+ await writeJson(JOBS_FILE, data);
1289
+ }
1290
+ res.json({ success: true, removed });
1291
+ }));
1292
+
1293
+ app.post('/api/workbench/jobs/clear', asyncRoute(async (req, res) => {
1294
+ if (req.body?.confirm !== true) throw new HttpError(400, '需要 confirm: true');
1295
+ let removed = 0;
1296
+ for (const j of jobs.values()) {
1297
+ // 不清当前还在跑/排队的
1298
+ if (j.status === 'running' || j.status === 'pending') continue;
1299
+ jobs.delete(j.id);
1300
+ removed++;
1301
+ }
1302
+ await writeJson(JOBS_FILE, { version: 1, jobs: [] });
1303
+ res.json({ success: true, removed });
1304
+ }));
1305
+
1306
+ app.delete('/api/workbench/jobs/by-task/:taskId', asyncRoute(async (req, res) => {
1307
+ const taskId = req.params.taskId;
1308
+ if (!taskId) throw new HttpError(400, '缺少 taskId');
1309
+ const keepDone = String(req.query.keepDone || '').toLowerCase() === 'true';
1310
+ const ids = [];
1311
+ for (const j of jobs.values()) {
1312
+ if (j.taskId !== taskId) continue;
1313
+ if (j.status === 'running' || j.status === 'pending') continue;
1314
+ if (keepDone && j.status === 'done') continue;
1315
+ jobs.delete(j.id);
1316
+ ids.push(j.id);
1317
+ }
1318
+ const data = await readJson(JOBS_FILE, { version: 1, jobs: [] });
1319
+ if (data && Array.isArray(data.jobs)) {
1320
+ const before = data.jobs.length;
1321
+ data.jobs = data.jobs.filter(j => {
1322
+ if (j.taskId !== taskId) return true;
1323
+ if (keepDone && j.status === 'done') return true;
1324
+ return !ids.includes(j.id);
1325
+ });
1326
+ const removed = before - data.jobs.length;
1327
+ if (removed > 0) await writeJson(JOBS_FILE, data);
1328
+ return res.json({ success: true, removed: ids.length + removed, ids });
1329
+ }
1330
+ res.json({ success: true, removed: ids.length, ids });
1331
+ }));
1332
+
1333
+ app.post('/api/workbench/tasks/:id/clear-execution', asyncRoute(async (req, res) => {
1334
+ const taskId = req.params.id;
1335
+ if (!taskId) throw new HttpError(400, '缺少 taskId');
1336
+ // 检查活跃 job
1337
+ const live = [];
1338
+ for (const j of jobs.values()) {
1339
+ if (j.taskId !== taskId) continue;
1340
+ if (j.status === 'running' || j.status === 'pending') live.push(j.id);
1341
+ }
1342
+ if (live.length > 0) {
1343
+ throw new HttpError(400, `有 ${live.length} 个 job 正在执行,请先停止`);
1344
+ }
1345
+ // 1) 清空 jobs(内存 + 磁盘)
1346
+ const removedJobIds = [];
1347
+ for (const j of jobs.values()) {
1348
+ if (j.taskId !== taskId) continue;
1349
+ jobs.delete(j.id);
1350
+ removedJobIds.push(j.id);
1351
+ }
1352
+ const jobsData = await readJson(JOBS_FILE, { version: 1, jobs: [] });
1353
+ if (jobsData && Array.isArray(jobsData.jobs)) {
1354
+ const before = jobsData.jobs.length;
1355
+ jobsData.jobs = jobsData.jobs.filter(j => j.taskId !== taskId);
1356
+ if (jobsData.jobs.length !== before) await writeJson(JOBS_FILE, jobsData);
1357
+ }
1358
+ // 2) 重置 subtasks.status → todo
1359
+ const tasksData = await readJson(TASKS_FILE, { tasks: [] });
1360
+ const task = (tasksData.tasks || []).find(t => t.id === taskId);
1361
+ if (!task) {
1362
+ return res.json({ success: true, removedJobs: removedJobIds.length, resetSubs: 0, message: '任务不存在,仅清空 job' });
1363
+ }
1364
+ let resetSubs = 0;
1365
+ if (Array.isArray(task.subtasks)) {
1366
+ for (const s of task.subtasks) {
1367
+ if (s.status !== 'todo') {
1368
+ s.status = 'todo';
1369
+ resetSubs++;
1370
+ if (s.error) delete s.error;
1371
+ publish('sub:update', { taskId, sub: s });
1372
+ }
1373
+ }
1374
+ task.updatedAt = nowIso();
1375
+ await writeJson(TASKS_FILE, tasksData);
1376
+ publish('task:update', task);
1377
+ }
1378
+ res.json({
1379
+ success: true,
1380
+ removedJobs: removedJobIds.length,
1381
+ resetSubs,
1382
+ message: `已清空 ${removedJobIds.length} 条执行记录,${resetSubs} 个子任务重置为待执行`
1383
+ });
1384
+ }));
1385
+
1386
+ app.post('/api/workbench/tasks/:id/reset-shell', asyncRoute(async (req, res) => {
1387
+ const taskId = req.params.id;
1388
+ if (!taskId) throw new HttpError(400, '缺少 taskId');
1389
+ const live = [];
1390
+ for (const j of jobs.values()) {
1391
+ if (j.taskId !== taskId) continue;
1392
+ if (j.status === 'running' || j.status === 'pending') live.push(j.id);
1393
+ }
1394
+ if (live.length > 0) {
1395
+ throw new HttpError(400, `有 ${live.length} 个 job 正在执行,请先停止`);
1396
+ }
1397
+ const removedJobIds = [];
1398
+ for (const j of jobs.values()) {
1399
+ if (j.taskId !== taskId) continue;
1400
+ jobs.delete(j.id);
1401
+ removedJobIds.push(j.id);
1402
+ }
1403
+ const jobsData = await readJson(JOBS_FILE, { version: 1, jobs: [] });
1404
+ if (jobsData && Array.isArray(jobsData.jobs)) {
1405
+ const before = jobsData.jobs.length;
1406
+ jobsData.jobs = jobsData.jobs.filter(j => j.taskId !== taskId);
1407
+ if (jobsData.jobs.length !== before) await writeJson(JOBS_FILE, jobsData);
1408
+ }
1409
+ const tasksData = await readJson(TASKS_FILE, { tasks: [] });
1410
+ const task = (tasksData.tasks || []).find(t => t.id === taskId);
1411
+ if (!task) {
1412
+ return res.json({ success: true, removedJobs: removedJobIds.length, message: '任务不存在,仅清空 job' });
1413
+ }
1414
+ const removedSubCount = Array.isArray(task.subtasks) ? task.subtasks.length : 0;
1415
+ const removedAttCount = Array.isArray(task.attachments) ? task.attachments.length : 0;
1416
+ const hadDesc = !!(task.desc && task.desc.length > 0);
1417
+ const hadPrompt = !!task.promptId;
1418
+ const preservedProjectPath = task.projectPath;
1419
+ const preservedCreatedAt = task.createdAt;
1420
+ const preservedStatus = task.status || 'todo';
1421
+ const newTask = { id: task.id, title: task.title, status: preservedStatus };
1422
+ if (preservedProjectPath) newTask.projectPath = preservedProjectPath;
1423
+ if (preservedCreatedAt) newTask.createdAt = preservedCreatedAt;
1424
+ newTask.subtasks = [];
1425
+ newTask.attachments = [];
1426
+ newTask.updatedAt = nowIso();
1427
+ for (const k of Object.keys(task)) delete task[k];
1428
+ Object.assign(task, newTask);
1429
+ await writeJson(TASKS_FILE, tasksData);
1430
+ publish('task:update', task);
1431
+ res.json({
1432
+ success: true,
1433
+ removedJobs: removedJobIds.length,
1434
+ removedSubs: removedSubCount,
1435
+ removedAttachments: removedAttCount,
1436
+ clearedDesc: hadDesc,
1437
+ clearedPrompt: hadPrompt,
1438
+ message: `已清空:删除 ${removedSubCount} 个子任务${removedAttCount ? '、' + removedAttCount + ' 个附件' : ''}${hadDesc ? '、任务描述' : ''},任务标题保留`
1439
+ });
1440
+ }));
1441
+
1442
+ app.post('/api/workbench/tasks/:id/clear-subtasks', asyncRoute(async (req, res) => {
1443
+ const taskId = req.params.id;
1444
+ if (!taskId) throw new HttpError(400, '缺少 taskId');
1445
+ const live = [];
1446
+ for (const j of jobs.values()) {
1447
+ if (j.taskId !== taskId) continue;
1448
+ if (j.status === 'running' || j.status === 'pending') live.push(j.id);
1449
+ }
1450
+ if (live.length > 0) {
1451
+ throw new HttpError(400, `有 ${live.length} 个 job 正在执行,请先停止`);
1452
+ }
1453
+ const removedJobIds = [];
1454
+ for (const j of jobs.values()) {
1455
+ if (j.taskId !== taskId) continue;
1456
+ jobs.delete(j.id);
1457
+ removedJobIds.push(j.id);
1458
+ }
1459
+ const jobsData = await readJson(JOBS_FILE, { version: 1, jobs: [] });
1460
+ if (jobsData && Array.isArray(jobsData.jobs)) {
1461
+ const before = jobsData.jobs.length;
1462
+ jobsData.jobs = jobsData.jobs.filter(j => j.taskId !== taskId);
1463
+ if (jobsData.jobs.length !== before) await writeJson(JOBS_FILE, jobsData);
1464
+ }
1465
+ const tasksData = await readJson(TASKS_FILE, { tasks: [] });
1466
+ const task = (tasksData.tasks || []).find(t => t.id === taskId);
1467
+ if (!task) {
1468
+ return res.json({ success: true, removedJobs: removedJobIds.length, removedSubs: 0, message: '任务不存在,仅清空 job' });
1469
+ }
1470
+ const removedSubCount = Array.isArray(task.subtasks) ? task.subtasks.length : 0;
1471
+ if (removedSubCount === 0) {
1472
+ return res.json({ success: true, removedJobs: removedJobIds.length, removedSubs: 0, message: '没有子任务需要清空' });
1473
+ }
1474
+ task.subtasks = [];
1475
+ task.updatedAt = nowIso();
1476
+ await writeJson(TASKS_FILE, tasksData);
1477
+ publish('task:update', task);
1478
+ res.json({
1479
+ success: true,
1480
+ removedJobs: removedJobIds.length,
1481
+ removedSubs: removedSubCount,
1482
+ message: `已清空 ${removedSubCount} 个子任务,任务描述和附件保留`
1483
+ });
1484
+ }));
1485
+
1486
+ // GET /api/workbench/jobs/:id (放在所有字面路径之后)
1487
+ app.get('/api/workbench/jobs/:id', asyncRoute(async (req, res) => {
1488
+ // 优先查内存(含活跃)
1489
+ const live = jobs.get(req.params.id);
1490
+ if (live) {
1491
+ const tasksData = await readJson(TASKS_FILE, { tasks: [] });
1492
+ const taskMap = new Map((tasksData.tasks || []).map(t => [t.id, t]));
1493
+ return res.json({ success: true, job: serializeJob(live, taskMap) });
1494
+ }
1495
+ // 退回文件
1496
+ const data = await readJson(JOBS_FILE, { version: 1, jobs: [] });
1497
+ const j = (data.jobs || []).find(x => x.id === req.params.id);
1498
+ if (!j) throw new HttpError(404, 'job 不存在');
1499
+ res.json({ success: true, job: j });
1500
+ }));
1501
+
1502
+ app.delete('/api/workbench/jobs/:id', asyncRoute(async (req, res) => {
1503
+ const id = req.params.id;
1504
+ let removed = false;
1505
+ if (jobs.delete(id)) removed = true;
1506
+ const data = await readJson(JOBS_FILE, { version: 1, jobs: [] });
1507
+ if (data && Array.isArray(data.jobs)) {
1508
+ const before = data.jobs.length;
1509
+ data.jobs = data.jobs.filter(j => j.id !== id);
1510
+ if (data.jobs.length !== before) {
1511
+ removed = true;
1512
+ await writeJson(JOBS_FILE, data);
1513
+ }
1514
+ }
1515
+ if (!removed) throw new HttpError(404, 'job 不存在');
1516
+ res.json({ success: true, removed: 1 });
1517
+ }));
1518
+
1519
+ // ════════════════════════════════════════════════════════════════════════
1520
+ // §17. 附件:上传 / 删除 / 原文件读取
1521
+ // ════════════════════════════════════════════════════════════════════════
1522
+ const rawAttachment = express.raw({
1523
+ type: '*/*',
1524
+ limit: MAX_IMAGE_BYTES * 4 // 整体路由上限 20MB;单文件大小由业务再卡
1525
+ });
1526
+
1527
+ // 共享 helper:找到一个 attachment 所在的位置
1528
+ async function findAttachmentLocation(attId) {
1529
+ const data = await readJson(TASKS_FILE, { tasks: [] });
1530
+ for (const t of data.tasks || []) {
1531
+ const list = Array.isArray(t.attachments) ? t.attachments : [];
1532
+ const att = list.find(x => x.id === attId);
1533
+ if (att) {
1534
+ return { owner: 'task', task: t, list, att, storageDir: path.join(IMAGES_DIR, '_task-' + t.id) };
1535
+ }
1536
+ }
1537
+ for (const t of data.tasks || []) {
1538
+ for (const s of t.subtasks || []) {
1539
+ const list = Array.isArray(s.attachments) ? s.attachments : [];
1540
+ const att = list.find(x => x.id === attId);
1541
+ if (att) {
1542
+ return { owner: 'sub', task: t, sub: s, list, att, storageDir: path.join(IMAGES_DIR, s.id) };
1543
+ }
1544
+ }
1545
+ }
1546
+ return null;
1547
+ }
1548
+
1549
+ // 共享 helper:写入新附件(参数化以支持 task / sub)
1550
+ async function writeAttachmentTo({ req, target, maxCount }) {
1551
+ if (!req.body || !(req.body instanceof Buffer) || req.body.length === 0) {
1552
+ throw new HttpError(400, '请求体为空');
1553
+ }
1554
+ if (req.body.length > MAX_IMAGE_BYTES) {
1555
+ throw new HttpError(413, `单文件不得超过 ${MAX_IMAGE_BYTES / 1024 / 1024}MB`);
1556
+ }
1557
+ const originalName = String(req.get('X-Original-Name') || 'attachment').slice(0, 200);
1558
+ const mimeType = String(req.get('X-Mime-Type') || 'application/octet-stream').slice(0, 120);
1559
+ const ext = resolveExt({ originalName, mime: mimeType });
1560
+ if (!ext) {
1561
+ throw new HttpError(400, `不支持的文件类型(仅允许 ${[...ALLOWED_EXTS].join(', ')})`);
1562
+ }
1563
+ if (!Array.isArray(target.attachments)) target.attachments = [];
1564
+ if (target.attachments.length >= maxCount) {
1565
+ throw new HttpError(400, `附件已达上限 ${maxCount} 个`);
1566
+ }
1567
+
1568
+ const attId = genId();
1569
+ await fsp.mkdir(target.storageDir, { recursive: true });
1570
+ const storedName = `${attId}.${ext}`;
1571
+ const storedPath = path.join(target.storageDir, storedName);
1572
+ await fsp.writeFile(storedPath, req.body);
1573
+
1574
+ const attachment = {
1575
+ id: attId,
1576
+ originalName,
1577
+ mimeType,
1578
+ size: req.body.length,
1579
+ ext,
1580
+ storedName,
1581
+ absolutePath: storedPath,
1582
+ createdAt: nowIso()
1583
+ };
1584
+ target.attachments.push(attachment);
1585
+ target.updatedAt = nowIso();
1586
+ return attachment;
1587
+ }
1588
+
1589
+ // 子任务附件
1590
+ app.post('/api/workbench/subtasks/:subId/attachments', rawAttachment, asyncRoute(async (req, res) => {
1591
+ const { subId } = req.params;
1592
+ const data = await readJson(TASKS_FILE, { tasks: [] });
1593
+ let foundSub = null;
1594
+ for (const t of data.tasks || []) {
1595
+ const s = (t.subtasks || []).find(x => x.id === subId);
1596
+ if (s) { foundSub = s; break; }
1597
+ }
1598
+ if (!foundSub) throw new HttpError(404, '子任务不存在');
1599
+ const target = { ...foundSub, storageDir: path.join(IMAGES_DIR, subId) };
1600
+ const att = await writeAttachmentTo({ req, target, maxCount: MAX_ATTACHMENTS_PER_SUBTASK });
1601
+ // target 是 spread 出来的浅拷贝,data 引用里的 foundSub 没改;显式 push 回去
1602
+ foundSub.attachments = Array.isArray(foundSub.attachments) ? foundSub.attachments : [];
1603
+ foundSub.attachments.push(att);
1604
+ foundSub.updatedAt = nowIso();
1605
+ await writeJson(TASKS_FILE, data);
1606
+ res.json({ success: true, attachment: att });
1607
+ }));
1608
+
1609
+ app.delete('/api/workbench/subtasks/:subId/attachments/:attId', asyncRoute(async (req, res) => {
1610
+ const { subId, attId } = req.params;
1611
+ const data = await readJson(TASKS_FILE, { tasks: [] });
1612
+ let foundSub = null;
1613
+ for (const t of data.tasks || []) {
1614
+ const s = (t.subtasks || []).find(x => x.id === subId);
1615
+ if (s) { foundSub = s; break; }
1616
+ }
1617
+ if (!foundSub) throw new HttpError(404, '子任务不存在');
1618
+ const list = Array.isArray(foundSub.attachments) ? foundSub.attachments : [];
1619
+ const i = list.findIndex(a => a.id === attId);
1620
+ if (i < 0) throw new HttpError(404, '附件不存在');
1621
+ const [removed] = list.splice(i, 1);
1622
+ try {
1623
+ await fsp.unlink(path.join(IMAGES_DIR, subId, removed.storedName));
1624
+ } catch { /* 文件可能已不存在 */ }
1625
+ foundSub.updatedAt = nowIso();
1626
+ await writeJson(TASKS_FILE, data);
1627
+ res.json({ success: true });
1628
+ }));
1629
+
1630
+ // 主任务附件
1631
+ app.post('/api/workbench/tasks/:taskId/attachments', rawAttachment, asyncRoute(async (req, res) => {
1632
+ const { taskId } = req.params;
1633
+ const data = await readJson(TASKS_FILE, { tasks: [] });
1634
+ const task = (data.tasks || []).find(t => t.id === taskId);
1635
+ if (!task) throw new HttpError(404, '任务不存在');
1636
+ const target = { ...task, storageDir: path.join(IMAGES_DIR, '_task-' + taskId) };
1637
+ const att = await writeAttachmentTo({ req, target, maxCount: MAX_ATTACHMENTS_PER_SUBTASK });
1638
+ task.attachments = Array.isArray(task.attachments) ? task.attachments : [];
1639
+ task.attachments.push(att);
1640
+ task.updatedAt = nowIso();
1641
+ await writeJson(TASKS_FILE, data);
1642
+ res.json({ success: true, attachment: att });
1643
+ }));
1644
+
1645
+ app.delete('/api/workbench/tasks/:taskId/attachments/:attId', asyncRoute(async (req, res) => {
1646
+ const { taskId, attId } = req.params;
1647
+ const data = await readJson(TASKS_FILE, { tasks: [] });
1648
+ const task = (data.tasks || []).find(t => t.id === taskId);
1649
+ if (!task) throw new HttpError(404, '任务不存在');
1650
+ const list = Array.isArray(task.attachments) ? task.attachments : [];
1651
+ const i = list.findIndex(a => a.id === attId);
1652
+ if (i < 0) throw new HttpError(404, '附件不存在');
1653
+ const [removed] = list.splice(i, 1);
1654
+ try {
1655
+ await fsp.unlink(path.join(IMAGES_DIR, '_task-' + taskId, removed.storedName));
1656
+ } catch { /* 文件可能已不存在 */ }
1657
+ task.updatedAt = nowIso();
1658
+ await writeJson(TASKS_FILE, data);
1659
+ res.json({ success: true });
1660
+ }));
1661
+
1662
+ // 附件原文件读取(前端 <img> 缩略图用)—— 支持 task 和 sub 两种归属
1663
+ app.get('/api/workbench/attachments/:attId/raw', asyncRoute(async (req, res) => {
1664
+ const { attId } = req.params;
1665
+ const loc = await findAttachmentLocation(attId);
1666
+ if (!loc) throw new HttpError(404, '附件不存在');
1667
+ const filePath = path.join(loc.storageDir, loc.att.storedName);
1668
+ try {
1669
+ const stat = await fsp.stat(filePath);
1670
+ res.set('Content-Type', loc.att.mimeType || 'application/octet-stream');
1671
+ res.set('Content-Length', String(stat.size));
1672
+ res.set('Cache-Control', 'private, max-age=3600');
1673
+ const stream = fs.createReadStream(filePath);
1674
+ stream.on('error', () => res.end());
1675
+ stream.pipe(res);
1676
+ } catch {
1677
+ throw new HttpError(404, '文件已丢失');
1678
+ }
1679
+ }));
1680
+ }