zen-gitsync 2.13.1 → 2.13.4

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.
@@ -29,6 +29,7 @@ const PROMPTS_FILE = path.join(DATA_DIR, 'prompts.json');
29
29
  const TASKS_FILE = path.join(DATA_DIR, 'tasks.json');
30
30
  const IMAGES_DIR = path.join(DATA_DIR, 'workbench-images');
31
31
  const INSTRUCTION_FILE = path.join(DATA_DIR, 'ai-instruction.json');
32
+ const SUBTASK_INSTRUCTION_FILE = path.join(DATA_DIR, 'ai-subtask-instruction.json');
32
33
 
33
34
  // 子项目识别 / 文件扫描时需要跳过的目录
34
35
  const SKIP_DIRS = new Set([
@@ -62,6 +63,75 @@ const DEFAULT_INSTRUCTION = `你是一名资深软件架构师。
62
63
  const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
63
64
  // 一个子任务最多挂 9 个附件
64
65
  const MAX_ATTACHMENTS_PER_SUBTASK = 9;
66
+
67
+ // AI 拆分子任务:默认系统指令(用户在 GUI 可编辑覆盖)。
68
+ // 国际化:根据请求 Accept-Language 在 zh / en 之间选默认。
69
+ // 中英两份都内置——用户保存到 ~/.zen-gitsync/ai-subtask-instruction.json 后覆盖。
70
+ const DEFAULT_SUBTASK_INSTRUCTION_ZH = `你是一名任务拆分助手。
71
+
72
+ 【思考过程】
73
+ 在给出 JSON 之前,请先在内部仔细思考(如果模型支持,把思考放在 reasoning 中;否则可以先输出一段简短分析,再输出 JSON):
74
+ - 任务的真实目标是什么?用户提供的描述/图片/上下文里有哪些关键信息?
75
+ - 涉及哪些技术栈、模块、文件、约束?是否有易被忽略的边界条件?
76
+ - 自然的执行顺序是什么?哪些步骤是前置依赖?哪些可以并行?
77
+ - 哪些步骤可能失败、需要单独验证?
78
+
79
+ 【拆分原则】
80
+ 1. 单一职责:每个子任务只做一件事,避免"做 A 和 B"。
81
+ 2. 粒度适中:单个子任务应当能在一次会话里完成(既不要"实现整个登录功能"这么大,也不要"打印 hello"这么琐碎)。
82
+ 3. 顺序合理:子任务按依赖关系和执行顺序排列(先准备、后实现、最后验证)。
83
+ 4. 可验证:每个子任务都有明确的完成标志("输出文件 xxx"、"通过测试 yyy"、"控制台打印 zzz")。
84
+ 5. 数量:拆成 3-6 个子任务为宜。任务很简单时 2-3 个;复杂时 5-6 个,不要超过 8 个。
85
+ 6. 描述具体:desc 字段要写清楚"要做什么、参考什么、产出什么",不要只是把 title 改写一遍。
86
+ 7. 如果任务里附带了图片,必须基于图片的实际内容拆分(例如指出图片中的哪个区域、哪个元素需要改),而不是泛泛而谈。
87
+
88
+ 【输出要求】
89
+ 最后必须输出 JSON,结构:
90
+ {
91
+ "subtasks": [
92
+ { "title": "子任务标题(10-20字)", "desc": "子任务的具体描述,包含要做什么、输入是什么、输出/验证标志是什么" }
93
+ ]
94
+ }
95
+
96
+ JSON 要用 \`\`\`json ... \`\`\` 代码块包裹,前面可以有分析文字,但 JSON 必须完整、合法、可解析。`;
97
+
98
+ const DEFAULT_SUBTASK_INSTRUCTION_EN = `You are a task breakdown assistant.
99
+
100
+ [Thinking process]
101
+ Before producing JSON, think carefully (put your thoughts in reasoning if the model supports it; otherwise output a short analysis first, then JSON):
102
+ - What is the real goal? What key information is in the description / images / context?
103
+ - Which stack, modules, files, constraints are involved? Any easily missed edge cases?
104
+ - What is the natural execution order? What blocks what? What can run in parallel?
105
+ - Which steps may fail and need separate verification?
106
+
107
+ [Breakdown principles]
108
+ 1. Single responsibility: each subtask does only one thing, avoid bundling "do A and B".
109
+ 2. Right granularity: a subtask should finish in one Claude session (not as big as "implement the whole login flow", not as trivial as "print hello").
110
+ 3. Sensible order: arrange subtasks by dependency / execution order (prepare first, implement, then verify).
111
+ 4. Verifiable: every subtask has a clear completion signal (e.g. "produce file xxx", "pass test yyy", "log zzz to console").
112
+ 5. Quantity: prefer 3-6 subtasks. Very simple tasks 2-3, complex ones 5-6, never exceed 8.
113
+ 6. Concrete desc: write what to do, what to reference, what to produce — don't just paraphrase the title.
114
+ 7. If the task includes images, the breakdown must reference the actual image content (which region, which element to change), not just generic talk.
115
+
116
+ [Output requirements]
117
+ End with JSON, structure:
118
+ {
119
+ "subtasks": [
120
+ { "title": "subtask title (10-20 chars)", "desc": "concrete description: what to do, what the input is, what the output / verification signal is" }
121
+ ]
122
+ }
123
+
124
+ Wrap the JSON in \`\`\`json ... \`\`\`. Analysis text before it is allowed, but the JSON must be complete and parseable.`;
125
+
126
+ // 兼容旧引用
127
+ const DEFAULT_SUBTASK_INSTRUCTION = DEFAULT_SUBTASK_INSTRUCTION_ZH
128
+
129
+ // 根据请求 Accept-Language 选默认(zh / en)
130
+ function pickDefaultSubtaskInstruction(req) {
131
+ const al = String(req?.headers?.['accept-language'] || '').toLowerCase()
132
+ if (al.startsWith('en')) return DEFAULT_SUBTASK_INSTRUCTION_EN
133
+ return DEFAULT_SUBTASK_INSTRUCTION_ZH
134
+ }
65
135
  // 白名单后缀:图片 + 常见文档(PDF / 纯文本 / Markdown)
66
136
  const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
67
137
  const DOC_EXTS = new Set(['pdf', 'txt', 'md', 'markdown', 'csv', 'json', 'log']);
@@ -120,6 +190,19 @@ const MANIFEST_FILES = [
120
190
  'Gemfile', 'pubspec.yaml'
121
191
  ];
122
192
 
193
+ // 轻量级:仅告诉 LLM 项目的主 manifest 是什么(用于 AI 拆分时让 LLM 知道项目类型)。
194
+ // 不读内容,只 stat 存在性——拆分子任务不需要细节。
195
+ async function detectProjectManifest(projectPath) {
196
+ if (!projectPath) return ''
197
+ for (const f of MANIFEST_FILES) {
198
+ try {
199
+ const stat = await fsp.stat(path.join(projectPath, f))
200
+ if (stat.isFile()) return f
201
+ } catch { /* 不存在,继续 */ }
202
+ }
203
+ return ''
204
+ }
205
+
123
206
  async function readProjectManifest(projectPath) {
124
207
  const out = {};
125
208
  for (const f of MANIFEST_FILES) {
@@ -181,15 +264,27 @@ async function listDirTree(projectPath, maxDepth = 2, maxEntries = 400) {
181
264
  }
182
265
 
183
266
  async function callLlmJson(model, prompt, opts = {}) {
184
- const { maxTokens = 1500, timeoutMs = 60000 } = opts;
267
+ const { maxTokens = 1500, timeoutMs = 60000, images = [] } = opts;
185
268
  const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }));
186
269
  const url = `${String(model.baseURL || '').replace(/\/$/, '')}/chat/completions`;
187
270
  const headers = { 'Content-Type': 'application/json' };
188
271
  if (model.apiKey) headers['Authorization'] = `Bearer ${model.apiKey}`;
189
272
 
273
+ // 有图片时改用 OpenAI multimodal content 数组(text + image_url)。
274
+ // 非多模态模型遇到 image_url 会忽略图片块,相当于退化成纯文本,不会报错。
275
+ let userContent;
276
+ if (Array.isArray(images) && images.length > 0) {
277
+ userContent = [
278
+ { type: 'text', text: prompt },
279
+ ...images.map(img => ({ type: 'image_url', image_url: { url: img } }))
280
+ ];
281
+ } else {
282
+ userContent = prompt;
283
+ }
284
+
190
285
  const body = JSON.stringify({
191
286
  model: model.model,
192
- messages: [{ role: 'user', content: prompt }],
287
+ messages: [{ role: 'user', content: userContent }],
193
288
  max_tokens: maxTokens,
194
289
  temperature: 0.4,
195
290
  response_format: { type: 'json_object' },
@@ -214,6 +309,102 @@ async function callLlmJson(model, prompt, opts = {}) {
214
309
  }
215
310
  }
216
311
 
312
+ /**
313
+ * 流式调用 OpenAI 兼容 LLM。每收到一个 chunk 调 onDelta 回调。
314
+ * onDelta 接收 { thinking?: string, content?: string },二选一。
315
+ * - reasoning_content / reasoning:部分模型(如 deepseek)放在 delta.reasoning_content
316
+ * - reasoning / reasoning_text:openai o1 风格
317
+ * - content:普通输出
318
+ * 返回完整 content 字符串。
319
+ */
320
+ async function callLlmStream(model, prompt, onDelta, opts = {}) {
321
+ const { maxTokens = 2000, timeoutMs = 600000, signal, images = [] } = opts
322
+ const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }))
323
+ const url = `${String(model.baseURL || '').replace(/\/$/, '')}/chat/completions`
324
+ const headers = { 'Content-Type': 'application/json' }
325
+ if (model.apiKey) headers['Authorization'] = `Bearer ${model.apiKey}`
326
+
327
+ let userContent
328
+ if (Array.isArray(images) && images.length > 0) {
329
+ userContent = [
330
+ { type: 'text', text: prompt },
331
+ ...images.map(img => ({ type: 'image_url', image_url: { url: img } }))
332
+ ]
333
+ } else {
334
+ userContent = prompt
335
+ }
336
+
337
+ const body = JSON.stringify({
338
+ model: model.model,
339
+ messages: [{ role: 'user', content: userContent }],
340
+ max_tokens: maxTokens,
341
+ temperature: 0.4,
342
+ // 注意:stream: true 模式下不能同时使用 response_format:{type:'json_object'},
343
+ // 部分 provider 会在收到两个一起时报错/静默卡住。改在 prompt 里约束 JSON 输出即可。
344
+ stream: true,
345
+ })
346
+
347
+ const controller = new AbortController()
348
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
349
+ // 允许外部 signal 触发取消(用户在 GUI 点停止)
350
+ const onAbort = () => controller.abort()
351
+ if (signal) {
352
+ if (signal.aborted) controller.abort()
353
+ else signal.addEventListener('abort', onAbort)
354
+ }
355
+
356
+ let fullContent = ''
357
+ let aborted = false
358
+ try {
359
+ const resp = await fetch(url, { method: 'POST', headers, body, signal: controller.signal })
360
+ if (!resp.ok || !resp.body) {
361
+ const errText = await resp.text().catch(() => '')
362
+ throw new Error(errText || `HTTP ${resp.status}`)
363
+ }
364
+
365
+ // SSE 格式:每行 "data: {...}",最后 "data: [DONE]"
366
+ const decoder = new TextDecoder('utf-8')
367
+ let buf = ''
368
+ for await (const chunk of resp.body) {
369
+ buf += decoder.decode(chunk, { stream: true })
370
+ const lines = buf.split('\n')
371
+ buf = lines.pop() ?? ''
372
+ for (const line of lines) {
373
+ const trimmed = line.trim()
374
+ if (!trimmed || !trimmed.startsWith('data:')) continue
375
+ const payload = trimmed.slice(5).trim()
376
+ if (payload === '[DONE]') continue
377
+ try {
378
+ const evt = JSON.parse(payload)
379
+ const delta = evt.choices?.[0]?.delta || {}
380
+ // thinking 在不同 provider 字段名不同,全部尝试
381
+ const thinkingChunk = delta.reasoning_content
382
+ || delta.reasoning
383
+ || delta.reasoning_text
384
+ || ''
385
+ const contentChunk = delta.content || ''
386
+ if (thinkingChunk) onDelta({ thinking: thinkingChunk })
387
+ if (contentChunk) {
388
+ fullContent += contentChunk
389
+ onDelta({ content: contentChunk })
390
+ }
391
+ } catch { /* 跳过无法解析的行 */ }
392
+ }
393
+ }
394
+ } catch (err) {
395
+ if (err?.name === 'AbortError' || controller.signal.aborted) {
396
+ aborted = true
397
+ // 中断不算错误——上层会决定怎么处理
398
+ } else {
399
+ throw err
400
+ }
401
+ } finally {
402
+ clearTimeout(timer)
403
+ if (signal) signal.removeEventListener('abort', onAbort)
404
+ }
405
+ return { content: fullContent, aborted }
406
+ }
407
+
217
408
  function nowIso() {
218
409
  return new Date().toISOString();
219
410
  }
@@ -286,6 +477,28 @@ async function writeInstruction(instruction) {
286
477
  await fsp.rename(tmp, INSTRUCTION_FILE);
287
478
  }
288
479
 
480
+ // AI 拆分子任务的指令:与生成项目架构说明的指令分开持久化,
481
+ // 因为它面向的输出形态(subtask 列表)和任务粒度完全不同。
482
+ // 支持传入 req:根据 Accept-Language 选默认(zh/en)
483
+ async function readSubtaskInstruction(req) {
484
+ try {
485
+ const buf = await fsp.readFile(SUBTASK_INSTRUCTION_FILE, 'utf-8');
486
+ const obj = JSON.parse(buf);
487
+ if (obj && typeof obj.instruction === 'string' && obj.instruction.trim()) {
488
+ return obj.instruction;
489
+ }
490
+ } catch { /* 文件不存在或解析失败 */ }
491
+ return pickDefaultSubtaskInstruction(req);
492
+ }
493
+ async function writeSubtaskInstruction(instruction) {
494
+ await ensureDataDir();
495
+ // 写入时如果与当前 locale 默认一致,不写文件——这样前端"isDefault"判定永远准确
496
+ const text = String(instruction || '').trim() || DEFAULT_SUBTASK_INSTRUCTION_ZH;
497
+ const tmp = `${SUBTASK_INSTRUCTION_FILE}.tmp`;
498
+ await fsp.writeFile(tmp, JSON.stringify({ instruction: text, updatedAt: nowIso() }, null, 2), 'utf-8');
499
+ await fsp.rename(tmp, SUBTASK_INSTRUCTION_FILE);
500
+ }
501
+
289
502
  // ── 子项目识别:递归找 .git / manifest;A 是 B 的祖先时只保留 B ─────────────
290
503
  async function findSubProjects(projectPath, opts = {}) {
291
504
  const { maxDepth = 4 } = opts;
@@ -450,6 +663,12 @@ function launchClaudeInNewWindow(cwd, promptText) {
450
663
 
451
664
  // 顺序执行一个任务下所有子任务;上一个结束再启动下一个
452
665
  async function runTaskQueue(task, repoPath, branch) {
666
+ // 前序上下文:跑完一个 sub 后把它"完成态"摘要存到这里,下一个 sub 启动时
667
+ // 拼到 prompt 头部,让 Claude 知道前面做了什么、产出了什么。
668
+ // 故意不用 raw output 全文——LLM 已经习惯"摘要 + 关键结论"的格式,且不会
669
+ // 一次塞几 MB 进 prompt 烧 token。truncate 到每条 MAX_PREV_OUTPUT_CHARS。
670
+ const MAX_PREV_OUTPUT_CHARS = 2000
671
+ const priorOutputs = []
453
672
  for (const sub of task.subtasks) {
454
673
  if (sub.status === 'done') continue;
455
674
  const promptTemplate = sub.promptOverride || (task.promptId
@@ -465,16 +684,36 @@ async function runTaskQueue(task, repoPath, branch) {
465
684
  const parts = [interpolated, sub.title, sub.desc].filter(s => s && s.trim());
466
685
  let prompt = parts.join('\n\n');
467
686
 
468
- // ── 附件:把 sub.attachments 列表里的本地绝对路径拼到 prompt 末尾 ──
687
+ // ── 前序上下文:把前几个 done 子任务的输出摘要拼到 prompt 头部 ──
688
+ if (priorOutputs.length > 0) {
689
+ const prevBlock = priorOutputs.map((p, i) => {
690
+ const text = (p.output || '').slice(0, MAX_PREV_OUTPUT_CHARS)
691
+ const truncated = (p.output || '').length > MAX_PREV_OUTPUT_CHARS ? '\n…(前文已截断)' : ''
692
+ return `### [${i + 1}] ${p.title}\n${text}${truncated}`
693
+ }).join('\n\n')
694
+ prompt = `以下是同一任务下已经完成的前序子任务输出(仅作上下文参考,请基于这些结论继续当前子任务,无需重复执行它们):
695
+
696
+ ${prevBlock}
697
+
698
+ ---
699
+
700
+ ${prompt}`
701
+ }
702
+
703
+ // ── 附件:合并 sub.attachments + task.attachments 后拼到 prompt 末尾 ──
469
704
  // claude -p 字符串模式会扫描 prompt 中出现的本地文件路径并自动
470
705
  // 识别为附件(图片 / PDF / 文本均可)。
471
- const attachments = Array.isArray(sub.attachments) ? sub.attachments : [];
472
- if (attachments.length > 0) {
473
- const lines = attachments
706
+ // 主任务附件对所有 sub 都可见;子任务自己的附件只对该 sub 可见。
707
+ const allAttachments = [
708
+ ...(Array.isArray(task.attachments) ? task.attachments : []),
709
+ ...(Array.isArray(sub.attachments) ? sub.attachments : [])
710
+ ];
711
+ if (allAttachments.length > 0) {
712
+ const lines = allAttachments
474
713
  .filter(a => a && a.absolutePath)
475
714
  .map((a, i) => ` ${i + 1}. [${a.mimeType || 'application/octet-stream'}] ${a.absolutePath}`);
476
715
  if (lines.length > 0) {
477
- prompt += `\n\n---\n本子任务包含 ${lines.length} 个附件(请按文件路径读取,不要让用户重新提供):\n${lines.join('\n')}\n---`;
716
+ prompt += `\n\n---\n本任务包含 ${lines.length} 个附件(请按文件路径读取,不要让用户重新提供):\n${lines.join('\n')}\n---`;
478
717
  }
479
718
  }
480
719
 
@@ -573,6 +812,8 @@ async function runTaskQueue(task, repoPath, branch) {
573
812
  job.exitCode = 0;
574
813
  job.status = 'done';
575
814
  sub.status = 'done';
815
+ // 把这个 sub 的输出累积到前序上下文,喂给下一个 sub
816
+ priorOutputs.push({ title: sub.title, output: job.output || '' })
576
817
  }
577
818
  } catch (err) {
578
819
  job.error = err && err.message ? err.message : String(err);
@@ -818,6 +1059,230 @@ ${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summar
818
1059
  }
819
1060
  });
820
1061
 
1062
+ // ── AI 拆分子任务:独立的指令文件、独立的端点 ───────────────────────
1063
+ // GET /api/workbench/tasks/ai-subtask-instruction
1064
+ // → { success, instruction, isDefault }
1065
+ // PUT /api/workbench/tasks/ai-subtask-instruction
1066
+ // body: { instruction: string }
1067
+ // → { success }
1068
+ app.get('/api/workbench/tasks/ai-subtask-instruction', async (req, res) => {
1069
+ try {
1070
+ const def = pickDefaultSubtaskInstruction(req);
1071
+ const instruction = await readSubtaskInstruction(req);
1072
+ // isDefault:当前 instruction 和 locale 默认完全一致
1073
+ res.json({ success: true, instruction, isDefault: instruction === def });
1074
+ } catch (err) {
1075
+ res.status(500).json({ success: false, error: err.message });
1076
+ }
1077
+ });
1078
+
1079
+ app.put('/api/workbench/tasks/ai-subtask-instruction', async (req, res) => {
1080
+ try {
1081
+ const text = req.body && typeof req.body.instruction === 'string'
1082
+ ? req.body.instruction.trim()
1083
+ : '';
1084
+ if (!text) {
1085
+ return res.status(400).json({ success: false, error: '指令不能为空' });
1086
+ }
1087
+ if (text.length > 50000) {
1088
+ return res.status(413).json({ success: false, error: '指令过长(最多 50000 字符)' });
1089
+ }
1090
+ // 如果保存的文本正好等于当前 locale 的默认——不写文件,保持 fallback 行为
1091
+ const def = pickDefaultSubtaskInstruction(req);
1092
+ if (text === def) {
1093
+ // 删除已存在的自定义文件
1094
+ try { await fsp.unlink(SUBTASK_INSTRUCTION_FILE) } catch {}
1095
+ return res.json({ success: true, isDefault: true });
1096
+ }
1097
+ await writeSubtaskInstruction(text);
1098
+ res.json({ success: true, isDefault: false });
1099
+ } catch (err) {
1100
+ res.status(500).json({ success: false, error: err.message });
1101
+ }
1102
+ });
1103
+
1104
+ // POST /api/workbench/tasks/ai-split-subtasks
1105
+ // body: { title, desc, taskId? }
1106
+ // → SSE 流:
1107
+ // data:{"type":"meta","prompt":{system,user}}\n\n
1108
+ // data:{"type":"thinking","delta":"..."}\n\n (多次)
1109
+ // data:{"type":"content","delta":"..."}\n\n (多次)
1110
+ // data:{"type":"done","subtasks":[...],"raw":"..."}\n\n
1111
+ // data:{"type":"error","error":"..."}\n\n (失败时)
1112
+ //
1113
+ // 走流式是为了让用户看到模型真实的 reasoning_content(如果模型支持),
1114
+ // 而不是前端用 setInterval 假装"打字机"——拆分质量也会因为给了模型
1115
+ // 充分的思考空间而显著提升。
1116
+ app.post('/api/workbench/tasks/ai-split-subtasks', async (req, res) => {
1117
+ const title = String(req.body?.title || '').trim();
1118
+ const desc = String(req.body?.desc || '').trim();
1119
+ const taskId = String(req.body?.taskId || '').trim();
1120
+ const promptId = String(req.body?.promptId || '').trim();
1121
+ if (!title) {
1122
+ return res.status(400).json({ success: false, error: '任务标题不能为空' });
1123
+ }
1124
+
1125
+ // 建立 SSE
1126
+ res.set({
1127
+ 'Content-Type': 'text/event-stream',
1128
+ 'Cache-Control': 'no-cache, no-transform',
1129
+ 'Connection': 'keep-alive',
1130
+ 'X-Accel-Buffering': 'no'
1131
+ });
1132
+ res.flushHeaders?.();
1133
+ const send = (obj) => {
1134
+ try { res.write(`data: ${JSON.stringify(obj)}\n\n`); } catch {}
1135
+ };
1136
+
1137
+ const abortController = new AbortController();
1138
+ let finished = false; // 标记响应是否已正常结束
1139
+ // 客户端真实断开:监听 socket close,而不是 req.close。
1140
+ // Node 22+ 的 req 'close' 事件会在 HTTP keep-alive socket 池回收时过早触发,
1141
+ // 导致正常请求中途被 abort。这里改用 socket 真实断开事件,
1142
+ // 并只在响应还没 end 时才取消上游 LLM。
1143
+ const onSocketClose = () => {
1144
+ if (!finished) abortController.abort()
1145
+ };
1146
+ if (req.socket) {
1147
+ req.socket.once('close', onSocketClose);
1148
+ }
1149
+
1150
+ try {
1151
+ let model;
1152
+ try {
1153
+ if (!configManager) throw new Error('configManager 不可用');
1154
+ const rawConfig = await configManager.readRawConfigFile();
1155
+ const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
1156
+ model = models.find(m => m.isDefault) || models[0];
1157
+ } catch (err) {
1158
+ send({ type: 'error', error: '读取 AI 配置失败: ' + err.message });
1159
+ finished = true;
1160
+ return res.end();
1161
+ }
1162
+ if (!model) {
1163
+ send({ type: 'error', error: '未配置 AI 模型,请先在通用设置中添加模型' });
1164
+ finished = true;
1165
+ return res.end();
1166
+ }
1167
+
1168
+ const userInstruction = await readSubtaskInstruction(req);
1169
+ const projectPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
1170
+ const projectName = projectPath ? path.basename(projectPath) : '(未指定项目)';
1171
+ const manifestHint = await detectProjectManifest(projectPath);
1172
+
1173
+ // 取绑定的预置模板(promptId):模板内容作为"执行模板"提示
1174
+ // 让 LLM 拆分时知道:每个 sub 最终都会被这套模板包裹后送进 claude
1175
+ let templateBlock = '';
1176
+ if (promptId) {
1177
+ try {
1178
+ const promptData = await readJson(PROMPTS_FILE, { prompts: [] });
1179
+ const p = (promptData.prompts || []).find(x => x.id === promptId);
1180
+ if (p && p.content) {
1181
+ templateBlock = `\n\n## 子任务执行模板(每个拆出的子任务最终会被这套模板包裹后送给 claude 执行;拆分时请确保子任务能让模板里的 {{sub.title}} / {{sub.desc}} 等变量填得有意义)\n模板名:${p.name || '(未命名)'}\n---\n${p.content}\n---`;
1182
+ }
1183
+ } catch { /* 模板读取失败不影响拆分 */ }
1184
+ }
1185
+
1186
+ // 取任务附件
1187
+ let attachmentBlock = '';
1188
+ const imageDataUrls = [];
1189
+ if (taskId) {
1190
+ try {
1191
+ const data = await readJson(TASKS_FILE, { tasks: [] });
1192
+ const task = (data.tasks || []).find(t => t.id === taskId);
1193
+ const atts = Array.isArray(task?.attachments) ? task.attachments : [];
1194
+ if (atts.length > 0) {
1195
+ const lines = [];
1196
+ for (let i = 0; i < atts.length; i++) {
1197
+ const a = atts[i];
1198
+ if (!a || !a.absolutePath) continue;
1199
+ lines.push(` ${i + 1}. [${a.mimeType || 'application/octet-stream'}] ${a.absolutePath}`);
1200
+ if (isImageExt(a.ext)) {
1201
+ try {
1202
+ const buf = await fsp.readFile(a.absolutePath);
1203
+ const mime = a.mimeType || 'image/png';
1204
+ imageDataUrls.push(`data:${mime};base64,${buf.toString('base64')}`);
1205
+ } catch { /* 文件丢失就跳过这张图 */ }
1206
+ }
1207
+ }
1208
+ if (lines.length > 0) {
1209
+ const imgNote = imageDataUrls.length > 0
1210
+ ? `(其中 ${imageDataUrls.length} 张图片已随消息一并发送,请直接基于图片内容拆分)`
1211
+ : '';
1212
+ attachmentBlock = `\n\n## 任务附件${imgNote}\n${lines.join('\n')}`;
1213
+ }
1214
+ }
1215
+ } catch { /* 没拿到附件不影响拆分 */ }
1216
+ }
1217
+
1218
+ const userBlock = `${userInstruction}
1219
+
1220
+ ---
1221
+
1222
+ ## 待拆分的任务
1223
+ 标题:${title}
1224
+ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateBlock}
1225
+
1226
+ ## 项目上下文(仅供参考,便于拆分时考虑项目特性)
1227
+ - 项目名称:${projectName}
1228
+ - 项目根目录:${projectPath || '(未指定)'}
1229
+ - 主要 manifest:${manifestHint || '(未识别到)'}
1230
+
1231
+ 请先简要分析(可以放在 reasoning 中或直接写出来),然后给出 JSON。JSON 用 \`\`\`json ... \`\`\` 包裹:
1232
+ {
1233
+ "subtasks": [
1234
+ { "title": "子任务标题(10-20字)", "desc": "具体描述" }
1235
+ ]
1236
+ }`;
1237
+
1238
+ // 先把 prompt 元信息推给前端
1239
+ send({ type: 'meta', prompt: { system: userInstruction, user: userBlock } });
1240
+
1241
+ // 流式调用 LLM,把 thinking / content 实时回传
1242
+ const { content, aborted } = await callLlmStream(
1243
+ model,
1244
+ userBlock,
1245
+ (delta) => {
1246
+ if (delta.thinking) send({ type: 'thinking', delta: delta.thinking });
1247
+ if (delta.content) send({ type: 'content', delta: delta.content });
1248
+ },
1249
+ { maxTokens: 4000, timeoutMs: 600000, images: imageDataUrls, signal: abortController.signal }
1250
+ );
1251
+
1252
+ if (aborted) {
1253
+ send({ type: 'error', error: '已取消' });
1254
+ finished = true;
1255
+ return res.end();
1256
+ }
1257
+
1258
+ // 解析 JSON:兼容 ```json ... ``` 代码块或裸 JSON
1259
+ let parsed = {};
1260
+ try {
1261
+ const m = content.match(/```json\s*([\s\S]*?)```/i)
1262
+ || content.match(/```\s*([\s\S]*?)```/)
1263
+ || content.match(/(\{[\s\S]*\})/);
1264
+ parsed = JSON.parse(m ? m[1] : content);
1265
+ } catch { parsed = {}; }
1266
+
1267
+ const list = Array.isArray(parsed?.subtasks) ? parsed.subtasks : [];
1268
+ const subtasks = list
1269
+ .map(s => ({
1270
+ title: String(s?.title || '').trim().slice(0, 80),
1271
+ desc: String(s?.desc || '').trim().slice(0, 500)
1272
+ }))
1273
+ .filter(s => s.title)
1274
+ .slice(0, 8);
1275
+
1276
+ send({ type: 'done', subtasks, raw: content });
1277
+ finished = true;
1278
+ res.end();
1279
+ } catch (err) {
1280
+ send({ type: 'error', error: 'AI 拆分失败: ' + (err?.message || String(err)) });
1281
+ finished = true;
1282
+ res.end();
1283
+ }
1284
+ });
1285
+
821
1286
  // SSE 事件流
822
1287
  app.get('/api/workbench/events', (req, res) => {
823
1288
  res.set({
@@ -1054,65 +1519,92 @@ ${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summar
1054
1519
  limit: MAX_IMAGE_BYTES * 4 // 整体路由上限 20MB;单文件大小由业务再卡
1055
1520
  });
1056
1521
 
1057
- app.post('/api/workbench/subtasks/:subId/attachments', rawAttachment, async (req, res) => {
1058
- try {
1059
- const { subId } = req.params;
1060
- if (!req.body || !(req.body instanceof Buffer) || req.body.length === 0) {
1061
- return res.status(400).json({ success: false, error: '请求体为空' });
1522
+ // 共享 helper:找到一个 attachment 所在的位置(task 主附件 或 sub 附件)
1523
+ // 返回 { owner, task, sub?, list, att, storageDir } 或 null
1524
+ async function findAttachmentLocation(attId) {
1525
+ const data = await readJson(TASKS_FILE, { tasks: [] });
1526
+ for (const t of data.tasks || []) {
1527
+ const list = Array.isArray(t.attachments) ? t.attachments : [];
1528
+ const att = list.find(x => x.id === attId);
1529
+ if (att) {
1530
+ return { owner: 'task', task: t, list, att, storageDir: path.join(IMAGES_DIR, '_task-' + t.id) };
1062
1531
  }
1063
- if (req.body.length > MAX_IMAGE_BYTES) {
1064
- return res.status(413).json({ success: false, error: `单文件不得超过 ${MAX_IMAGE_BYTES / 1024 / 1024}MB` });
1065
- }
1066
- const originalName = String(req.get('X-Original-Name') || 'attachment').slice(0, 200);
1067
- const mimeType = String(req.get('X-Mime-Type') || 'application/octet-stream').slice(0, 120);
1068
- const ext = resolveExt({ originalName, mime: mimeType });
1069
- if (!ext) {
1070
- return res.status(400).json({ success: false, error: `不支持的文件类型(仅允许 ${[...ALLOWED_EXTS].join(', ')})` });
1532
+ }
1533
+ for (const t of data.tasks || []) {
1534
+ for (const s of t.subtasks || []) {
1535
+ const list = Array.isArray(s.attachments) ? s.attachments : [];
1536
+ const att = list.find(x => x.id === attId);
1537
+ if (att) {
1538
+ return { owner: 'sub', task: t, sub: s, list, att, storageDir: path.join(IMAGES_DIR, s.id) };
1539
+ }
1071
1540
  }
1541
+ }
1542
+ return null;
1543
+ }
1544
+
1545
+ // 共享 helper:写入新附件(参数化以支持 task / sub)
1546
+ async function writeAttachmentTo({ req, target, maxCount }) {
1547
+ if (!req.body || !(req.body instanceof Buffer) || req.body.length === 0) {
1548
+ return { error: '请求体为空', status: 400 };
1549
+ }
1550
+ if (req.body.length > MAX_IMAGE_BYTES) {
1551
+ return { error: `单文件不得超过 ${MAX_IMAGE_BYTES / 1024 / 1024}MB`, status: 413 };
1552
+ }
1553
+ const originalName = String(req.get('X-Original-Name') || 'attachment').slice(0, 200);
1554
+ const mimeType = String(req.get('X-Mime-Type') || 'application/octet-stream').slice(0, 120);
1555
+ const ext = resolveExt({ originalName, mime: mimeType });
1556
+ if (!ext) {
1557
+ return { error: `不支持的文件类型(仅允许 ${[...ALLOWED_EXTS].join(', ')})`, status: 400 };
1558
+ }
1559
+ if (!Array.isArray(target.attachments)) target.attachments = [];
1560
+ if (target.attachments.length >= maxCount) {
1561
+ return { error: `附件已达上限 ${maxCount} 个`, status: 400 };
1562
+ }
1563
+
1564
+ const attId = genId();
1565
+ await fsp.mkdir(target.storageDir, { recursive: true });
1566
+ const storedName = `${attId}.${ext}`;
1567
+ const storedPath = path.join(target.storageDir, storedName);
1568
+ await fsp.writeFile(storedPath, req.body);
1569
+
1570
+ const attachment = {
1571
+ id: attId,
1572
+ originalName,
1573
+ mimeType,
1574
+ size: req.body.length,
1575
+ ext,
1576
+ storedName,
1577
+ absolutePath: storedPath,
1578
+ createdAt: nowIso()
1579
+ };
1580
+ target.attachments.push(attachment);
1581
+ target.updatedAt = nowIso();
1582
+ return { attachment };
1583
+ }
1072
1584
 
1073
- // 找到子任务,校验数量
1585
+ // 子任务附件
1586
+ app.post('/api/workbench/subtasks/:subId/attachments', rawAttachment, async (req, res) => {
1587
+ try {
1588
+ const { subId } = req.params;
1074
1589
  const data = await readJson(TASKS_FILE, { tasks: [] });
1075
- let foundTask = null;
1076
1590
  let foundSub = null;
1077
1591
  for (const t of data.tasks || []) {
1078
1592
  const s = (t.subtasks || []).find(x => x.id === subId);
1079
- if (s) { foundTask = t; foundSub = s; break; }
1593
+ if (s) { foundSub = s; break; }
1080
1594
  }
1081
1595
  if (!foundSub) {
1082
1596
  return res.status(404).json({ success: false, error: '子任务不存在' });
1083
1597
  }
1084
- if (!Array.isArray(foundSub.attachments)) foundSub.attachments = [];
1085
- if (foundSub.attachments.length >= MAX_ATTACHMENTS_PER_SUBTASK) {
1086
- return res.status(400).json({
1087
- success: false,
1088
- error: `每个子任务最多 ${MAX_ATTACHMENTS_PER_SUBTASK} 个附件`
1089
- });
1090
- }
1091
-
1092
- // 写入磁盘:~/.zen-gitsync/workbench-images/{subId}/{attId}.{ext}
1093
- const attId = genId();
1094
- const subDir = path.join(IMAGES_DIR, subId);
1095
- await fsp.mkdir(subDir, { recursive: true });
1096
- const storedName = `${attId}.${ext}`;
1097
- const storedPath = path.join(subDir, storedName);
1098
- await fsp.writeFile(storedPath, req.body);
1099
-
1100
- const attachment = {
1101
- id: attId,
1102
- originalName,
1103
- mimeType,
1104
- size: req.body.length,
1105
- ext,
1106
- storedName,
1107
- // 绝对路径供 claude CLI 读取;同机直接读本地
1108
- absolutePath: storedPath,
1109
- createdAt: nowIso()
1110
- };
1111
- foundSub.attachments.push(attachment);
1598
+ const target = { ...foundSub, storageDir: path.join(IMAGES_DIR, subId) };
1599
+ const result = await writeAttachmentTo({ req, target, maxCount: MAX_ATTACHMENTS_PER_SUBTASK });
1600
+ if (result.error) return res.status(result.status).json({ success: false, error: result.error });
1601
+ // target 是 spread 出来的浅拷贝,data 引用里的 foundSub 没改;显式 push 回去
1602
+ const att = result.attachment;
1603
+ foundSub.attachments = Array.isArray(foundSub.attachments) ? foundSub.attachments : [];
1604
+ foundSub.attachments.push(att);
1112
1605
  foundSub.updatedAt = nowIso();
1113
1606
  await writeJson(TASKS_FILE, data);
1114
-
1115
- res.json({ success: true, attachment });
1607
+ res.json({ success: true, attachment: att });
1116
1608
  } catch (err) {
1117
1609
  res.status(500).json({ success: false, error: err.message });
1118
1610
  }
@@ -1122,21 +1614,19 @@ ${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summar
1122
1614
  try {
1123
1615
  const { subId, attId } = req.params;
1124
1616
  const data = await readJson(TASKS_FILE, { tasks: [] });
1125
- let foundTask = null;
1126
1617
  let foundSub = null;
1127
1618
  for (const t of data.tasks || []) {
1128
1619
  const s = (t.subtasks || []).find(x => x.id === subId);
1129
- if (s) { foundTask = t; foundSub = s; break; }
1620
+ if (s) { foundSub = s; break; }
1130
1621
  }
1131
1622
  if (!foundSub) return res.status(404).json({ success: false, error: '子任务不存在' });
1132
1623
  const list = Array.isArray(foundSub.attachments) ? foundSub.attachments : [];
1133
1624
  const i = list.findIndex(a => a.id === attId);
1134
1625
  if (i < 0) return res.status(404).json({ success: false, error: '附件不存在' });
1135
1626
  const [removed] = list.splice(i, 1);
1136
- // 删磁盘文件
1137
1627
  try {
1138
1628
  await fsp.unlink(path.join(IMAGES_DIR, subId, removed.storedName));
1139
- } catch { /* 文件可能已不存在,忽略 */ }
1629
+ } catch { /* 文件可能已不存在 */ }
1140
1630
  foundSub.updatedAt = nowIso();
1141
1631
  await writeJson(TASKS_FILE, data);
1142
1632
  res.json({ success: true });
@@ -1145,25 +1635,58 @@ ${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summar
1145
1635
  }
1146
1636
  });
1147
1637
 
1148
- // 附件原文件读取(前端 <img> 缩略图用)
1638
+ // 主任务附件
1639
+ app.post('/api/workbench/tasks/:taskId/attachments', rawAttachment, async (req, res) => {
1640
+ try {
1641
+ const { taskId } = req.params;
1642
+ const data = await readJson(TASKS_FILE, { tasks: [] });
1643
+ const task = (data.tasks || []).find(t => t.id === taskId);
1644
+ if (!task) return res.status(404).json({ success: false, error: '任务不存在' });
1645
+ const target = { ...task, storageDir: path.join(IMAGES_DIR, '_task-' + taskId) };
1646
+ const result = await writeAttachmentTo({ req, target, maxCount: MAX_ATTACHMENTS_PER_SUBTASK });
1647
+ if (result.error) return res.status(result.status).json({ success: false, error: result.error });
1648
+ const att = result.attachment;
1649
+ task.attachments = Array.isArray(task.attachments) ? task.attachments : [];
1650
+ task.attachments.push(att);
1651
+ task.updatedAt = nowIso();
1652
+ await writeJson(TASKS_FILE, data);
1653
+ res.json({ success: true, attachment: att });
1654
+ } catch (err) {
1655
+ res.status(500).json({ success: false, error: err.message });
1656
+ }
1657
+ });
1658
+
1659
+ app.delete('/api/workbench/tasks/:taskId/attachments/:attId', async (req, res) => {
1660
+ try {
1661
+ const { taskId, attId } = req.params;
1662
+ const data = await readJson(TASKS_FILE, { tasks: [] });
1663
+ const task = (data.tasks || []).find(t => t.id === taskId);
1664
+ if (!task) return res.status(404).json({ success: false, error: '任务不存在' });
1665
+ const list = Array.isArray(task.attachments) ? task.attachments : [];
1666
+ const i = list.findIndex(a => a.id === attId);
1667
+ if (i < 0) return res.status(404).json({ success: false, error: '附件不存在' });
1668
+ const [removed] = list.splice(i, 1);
1669
+ try {
1670
+ await fsp.unlink(path.join(IMAGES_DIR, '_task-' + taskId, removed.storedName));
1671
+ } catch { /* 文件可能已不存在 */ }
1672
+ task.updatedAt = nowIso();
1673
+ await writeJson(TASKS_FILE, data);
1674
+ res.json({ success: true });
1675
+ } catch (err) {
1676
+ res.status(500).json({ success: false, error: err.message });
1677
+ }
1678
+ });
1679
+
1680
+ // 附件原文件读取(前端 <img> 缩略图用)—— 支持 task 和 sub 两种归属
1149
1681
  app.get('/api/workbench/attachments/:attId/raw', async (req, res) => {
1150
1682
  try {
1151
1683
  const { attId } = req.params;
1152
- const data = await readJson(TASKS_FILE, { tasks: [] });
1153
- let found = null;
1154
- let parentSubId = null;
1155
- for (const t of data.tasks || []) {
1156
- for (const s of t.subtasks || []) {
1157
- const a = (s.attachments || []).find(x => x.id === attId);
1158
- if (a) { found = a; parentSubId = s.id; break; }
1159
- }
1160
- if (found) break;
1161
- }
1162
- if (!found) return res.status(404).json({ success: false, error: '附件不存在' });
1163
- const filePath = path.join(IMAGES_DIR, parentSubId, found.storedName);
1684
+ const loc = await findAttachmentLocation(attId);
1685
+ if (!loc) return res.status(404).json({ success: false, error: '附件不存在' });
1686
+ const filePath = path.join(loc.storageDir, loc.att.storedName);
1164
1687
  try {
1165
1688
  const stat = await fsp.stat(filePath);
1166
- res.set('Content-Type', found.mimeType || 'application/octet-stream');
1689
+ res.set('Content-Type', loc.att.mimeType || 'application/octet-stream');
1167
1690
  res.set('Content-Length', String(stat.size));
1168
1691
  res.set('Cache-Control', 'private, max-age=3600');
1169
1692
  const stream = (await import('fs')).createReadStream(filePath);