zen-gitsync 2.13.5 → 2.13.7

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.
@@ -414,6 +414,141 @@ async function callLlmStream(model, prompt, onDelta, opts = {}) {
414
414
  return { content: fullContent, aborted }
415
415
  }
416
416
 
417
+ // AI 拆分子任务 JSON 多级降级解析。
418
+ // 模型经常会犯几类格式错:在 desc 里用 ASCII 双引号引用术语 / 末尾留尾随逗号 /
419
+ // 输出被 token 上限截断导致代码块未闭合。直接 JSON.parse 一旦失败就会让前端的
420
+ // "确认入库" 按钮永远是 (0),用户看不到原因。这里按"越简单越优先"的顺序尝试:
421
+ // ① ```json``` 代码块 / ```any``` 代码块 / 第一个完整 { ... } 范围
422
+ // ② 把候选片段去掉尾随逗号 + 块/行注释 再 parse
423
+ // ③ 用括号深度扫描,从开头找一个语法平衡的 { ... } 子串
424
+ // ④ 启发式转义模型夹在字符串内部的未转义 ASCII 双引号
425
+ // (这是实战里最常见的失败:模型用 ASCII " 引用"和书籍对话"这种术语,
426
+ // 直接打断外层 JSON。本级在字符串中遇到 " 时往后看一个非空白字符,
427
+ // 不是 ,}]: 就把它当作字面量,转义成 \"。)
428
+ // 任一步成功就返回 parsed,全部失败时返回最后一次 JSON.parse 的错误,
429
+ // 用 parseStage 告知前端"模型输出哪一步崩了",并把原始 raw 一并回传。
430
+ function parseSubtaskJson(content) {
431
+ const src = String(content || '');
432
+ if (!src.trim()) {
433
+ return { parsed: null, parseError: '模型未返回任何内容', parseStage: 'empty' };
434
+ }
435
+
436
+ const candidates = [];
437
+ const fenced = src.match(/```json\s*([\s\S]*?)```/i) || src.match(/```\s*([\s\S]*?)```/);
438
+ if (fenced) candidates.push(fenced[1]);
439
+ const bracePair = src.match(/\{[\s\S]*\}/);
440
+ if (bracePair) candidates.push(bracePair[0]);
441
+ // 兜底:整段当 JSON 试
442
+ candidates.push(src);
443
+
444
+ let lastErr = null;
445
+ for (const raw of candidates) {
446
+ const txt = String(raw || '').trim();
447
+ if (!txt) continue;
448
+ // ① 直 parse
449
+ try { return { parsed: JSON.parse(txt), parseError: '', parseStage: '' }; }
450
+ catch (e) { lastErr = e; }
451
+ // ② 清洗:去 //…/* */ 注释 + 尾随逗号
452
+ const cleaned = txt
453
+ .replace(/\/\*[\s\S]*?\*\//g, '')
454
+ .replace(/(^|[^:"'\\])\/\/[^\n]*/g, '$1')
455
+ .replace(/,(\s*[}\]])/g, '$1');
456
+ try { return { parsed: JSON.parse(cleaned), parseError: '', parseStage: 'cleaned' }; }
457
+ catch (e) { lastErr = e; }
458
+ // ③ 平衡花括号扫描
459
+ const balanced = extractBalancedJson(cleaned);
460
+ if (balanced && balanced !== cleaned) {
461
+ try { return { parsed: JSON.parse(balanced), parseError: '', parseStage: 'balanced' }; }
462
+ catch (e) { lastErr = e; }
463
+ }
464
+ // ④ 启发式转义字符串内的未转义双引号
465
+ const base = balanced || cleaned;
466
+ const reescaped = reescapeUnescapedQuotes(base);
467
+ if (reescaped && reescaped !== base) {
468
+ try { return { parsed: JSON.parse(reescaped), parseError: '', parseStage: 'reescaped' }; }
469
+ catch (e) { lastErr = e; }
470
+ }
471
+ }
472
+
473
+ return {
474
+ parsed: null,
475
+ parseError: lastErr ? (lastErr.message || String(lastErr)) : '未能从模型输出中提取出 JSON',
476
+ parseStage: 'failed'
477
+ };
478
+ }
479
+
480
+ // 从字符串中提取首个语法平衡的 { ... } 子串。
481
+ // 跟踪字符串字面量(含转义),避免把 desc 里的 } 当成结束。
482
+ function extractBalancedJson(text) {
483
+ const s = String(text || '');
484
+ const start = s.indexOf('{');
485
+ if (start < 0) return '';
486
+ let depth = 0;
487
+ let inStr = false;
488
+ let strCh = '';
489
+ let esc = false;
490
+ for (let i = start; i < s.length; i++) {
491
+ const c = s[i];
492
+ if (inStr) {
493
+ if (esc) { esc = false; continue; }
494
+ if (c === '\\') { esc = true; continue; }
495
+ if (c === strCh) { inStr = false; }
496
+ continue;
497
+ }
498
+ if (c === '"' || c === "'") { inStr = true; strCh = c; continue; }
499
+ if (c === '{') depth++;
500
+ else if (c === '}') {
501
+ depth--;
502
+ if (depth === 0) return s.slice(start, i + 1);
503
+ }
504
+ }
505
+ return '';
506
+ }
507
+
508
+ // ④ 级降级:把字符串字面量内部出现的、未转义的 ASCII 双引号自动转义。
509
+ // 实战中模型最常见的错误是 "desc": "...用户点击"保存"按钮..." 这种—
510
+ // 中间的 "保存" 把外层字符串截断成两段,后面变成裸文本,JSON.parse 必崩。
511
+ //
512
+ // 启发式判断:扫描时若处于字符串中且遇到 ",往后看第一个非空白字符:
513
+ // - 是 , } ] : 或文本结尾 → 这是真闭合,正常退出字符串
514
+ // - 否则 → 是模型乱写的字面量引号,改写为 \" 并继续留在字符串里
515
+ // 不依赖正则、不破坏已经转义的 \",对嵌套 / 多行字符串都安全。
516
+ function reescapeUnescapedQuotes(text) {
517
+ const s = String(text || '');
518
+ if (!s) return '';
519
+ const out = [];
520
+ let inStr = false;
521
+ let strCh = '';
522
+ let esc = false;
523
+ for (let i = 0; i < s.length; i++) {
524
+ const c = s[i];
525
+ if (!inStr) {
526
+ out.push(c);
527
+ if (c === '"' || c === "'") { inStr = true; strCh = c; }
528
+ continue;
529
+ }
530
+ // 字符串内部
531
+ if (esc) { out.push(c); esc = false; continue; }
532
+ if (c === '\\') { out.push(c); esc = true; continue; }
533
+ if (c !== strCh) { out.push(c); continue; }
534
+ // 遇到与开闭引号相同的字符——往后看下一个非空白
535
+ let j = i + 1;
536
+ while (j < s.length && (s[j] === ' ' || s[j] === '\t')) j++;
537
+ const next = j < s.length ? s[j] : '';
538
+ if (next === '' || next === ',' || next === '}' || next === ']'
539
+ || next === ':' || next === '\n' || next === '\r') {
540
+ // 真闭合
541
+ out.push(c);
542
+ inStr = false;
543
+ strCh = '';
544
+ } else {
545
+ // 字面量裸引号——转义
546
+ out.push('\\', c);
547
+ }
548
+ }
549
+ return out.join('');
550
+ }
551
+
417
552
  function nowIso() {
418
553
  return new Date().toISOString();
419
554
  }
@@ -807,187 +942,209 @@ async function runTaskQueue(task, repoPath, branch) {
807
942
  const priorOutputs = []
808
943
  for (const sub of task.subtasks) {
809
944
  if (sub.status === 'done') continue;
810
- const promptTemplate = sub.promptOverride || (task.promptId
811
- ? (await readJson(PROMPTS_FILE, { prompts: [] })).prompts.find(p => p.id === task.promptId)?.content
812
- : null) || '';
813
- const ctx = {
814
- task: { title: task.title, desc: task.desc || '' },
815
- sub: { title: sub.title, desc: sub.desc || '' },
816
- repo: { path: repoPath || '' },
817
- branch: branch || ''
818
- };
819
- const interpolated = interpolate(promptTemplate, ctx);
820
- const parts = [interpolated, sub.title, sub.desc].filter(s => s && s.trim());
821
- let prompt = parts.join('\n\n');
822
-
823
- // ── 前序上下文:把前几个 done 子任务的输出摘要拼到 prompt 头部 ──
824
- if (priorOutputs.length > 0) {
825
- const prevBlock = priorOutputs.map((p, i) => {
826
- const text = (p.output || '').slice(0, MAX_PREV_OUTPUT_CHARS)
827
- const truncated = (p.output || '').length > MAX_PREV_OUTPUT_CHARS ? '\n…(前文已截断)' : ''
828
- return `### [${i + 1}] ${p.title}\n${text}${truncated}`
829
- }).join('\n\n')
830
- prompt = `以下是同一任务下已经完成的前序子任务输出(仅作上下文参考,请基于这些结论继续当前子任务,无需重复执行它们):
945
+ await runSingleSubtask(task, sub, repoPath, branch, priorOutputs)
946
+ }
947
+ // 写回 tasks.json
948
+ await persistTaskAfterRun(task)
949
+ }
950
+
951
+ /**
952
+ * 执行单个子任务。被 runTaskQueue(整批)和"单 sub 执行"endpoint 共用。
953
+ *
954
+ * @param {object} task 主任务对象
955
+ * @param {object} sub 要跑的子任务
956
+ * @param {string} repoPath 仓库路径
957
+ * @param {string} branch 分支名(可空)
958
+ * @param {Array<{title:string,output:string}>} priorOutputs
959
+ * 前序 done 子任务的输出摘要(in-place 追加)。用于把同一任务下
960
+ * 前面已完成的 sub 产物拼到当前 sub prompt 头部,让 Claude
961
+ * 知道上下文。单独跑一个 sub 时,这个数组里只会有"前面 done sub"。
962
+ */
963
+ async function runSingleSubtask(task, sub, repoPath, branch, priorOutputs) {
964
+ const MAX_PREV_OUTPUT_CHARS = 2000
965
+ const promptTemplate = sub.promptOverride || (task.promptId
966
+ ? (await readJson(PROMPTS_FILE, { prompts: [] })).prompts.find(p => p.id === task.promptId)?.content
967
+ : null) || '';
968
+ const ctx = {
969
+ task: { title: task.title, desc: task.desc || '' },
970
+ sub: { title: sub.title, desc: sub.desc || '' },
971
+ repo: { path: repoPath || '' },
972
+ branch: branch || ''
973
+ };
974
+ const interpolated = interpolate(promptTemplate, ctx);
975
+ const parts = [interpolated, sub.title, sub.desc].filter(s => s && s.trim());
976
+ let prompt = parts.join('\n\n');
977
+
978
+ // ── 前序上下文:把前几个 done 子任务的输出摘要拼到 prompt 头部 ──
979
+ if (priorOutputs && priorOutputs.length > 0) {
980
+ const prevBlock = priorOutputs.map((p, i) => {
981
+ const text = (p.output || '').slice(0, MAX_PREV_OUTPUT_CHARS)
982
+ const truncated = (p.output || '').length > MAX_PREV_OUTPUT_CHARS ? '\n…(前文已截断)' : ''
983
+ return `### [${i + 1}] ${p.title}\n${text}${truncated}`
984
+ }).join('\n\n')
985
+ prompt = `以下是同一任务下已经完成的前序子任务输出(仅作上下文参考,请基于这些结论继续当前子任务,无需重复执行它们):
831
986
 
832
987
  ${prevBlock}
833
988
 
834
989
  ---
835
990
 
836
991
  ${prompt}`
837
- }
992
+ }
838
993
 
839
- // ── 附件:合并 sub.attachments + task.attachments 后拼到 prompt 末尾 ──
840
- // claude -p 字符串模式会扫描 prompt 中出现的本地文件路径并自动
841
- // 识别为附件(图片 / PDF / 文本均可)。
842
- // 主任务附件对所有 sub 都可见;子任务自己的附件只对该 sub 可见。
843
- const allAttachments = [
844
- ...(Array.isArray(task.attachments) ? task.attachments : []),
845
- ...(Array.isArray(sub.attachments) ? sub.attachments : [])
846
- ];
847
- if (allAttachments.length > 0) {
848
- const lines = allAttachments
849
- .filter(a => a && a.absolutePath)
850
- .map((a, i) => ` ${i + 1}. [${a.mimeType || 'application/octet-stream'}] ${a.absolutePath}`);
851
- if (lines.length > 0) {
852
- prompt += `\n\n---\n本任务包含 ${lines.length} 个附件(请按文件路径读取,不要让用户重新提供):\n${lines.join('\n')}\n---`;
853
- }
994
+ // ── 附件:合并 sub.attachments + task.attachments 后拼到 prompt 末尾 ──
995
+ // claude -p 字符串模式会扫描 prompt 中出现的本地文件路径并自动
996
+ // 识别为附件(图片 / PDF / 文本均可)。
997
+ // 主任务附件对所有 sub 都可见;子任务自己的附件只对该 sub 可见。
998
+ const allAttachments = [
999
+ ...(Array.isArray(task.attachments) ? task.attachments : []),
1000
+ ...(Array.isArray(sub.attachments) ? sub.attachments : [])
1001
+ ];
1002
+ if (allAttachments.length > 0) {
1003
+ const lines = allAttachments
1004
+ .filter(a => a && a.absolutePath)
1005
+ .map((a, i) => ` ${i + 1}. [${a.mimeType || 'application/octet-stream'}] ${a.absolutePath}`);
1006
+ if (lines.length > 0) {
1007
+ prompt += `\n\n---\n本任务包含 ${lines.length} 个附件(请按文件路径读取,不要让用户重新提供):\n${lines.join('\n')}\n---`;
854
1008
  }
1009
+ }
855
1010
 
856
- const jobId = genId();
857
- const job = {
858
- id: jobId,
859
- taskId: task.id,
860
- subId: sub.id,
861
- title: `${task.title} / ${sub.title}`,
862
- status: 'pending',
863
- prompt
864
- };
865
- jobs.set(jobId, job);
866
- sub.status = 'running';
867
- publish('sub:update', { taskId: task.id, sub });
1011
+ const jobId = genId();
1012
+ const job = {
1013
+ id: jobId,
1014
+ taskId: task.id,
1015
+ subId: sub.id,
1016
+ title: `${task.title} / ${sub.title}`,
1017
+ status: 'pending',
1018
+ prompt
1019
+ };
1020
+ jobs.set(jobId, job);
1021
+ sub.status = 'running';
1022
+ publish('sub:update', { taskId: task.id, sub });
1023
+ publish('job:update', job);
1024
+
1025
+ try {
1026
+ const { pid, child } = await launchClaudeInNewWindow(repoPath || process.cwd(), prompt);
1027
+ job.pid = pid;
1028
+ // 保存 child 引用,供 cancel 接口调用 kill
1029
+ job.child = child;
1030
+ job.startedAt = nowIso();
1031
+ job.status = 'running';
868
1032
  publish('job:update', job);
869
1033
 
870
- try {
871
- const { pid, child } = await launchClaudeInNewWindow(repoPath || process.cwd(), prompt);
872
- job.pid = pid;
873
- // 保存 child 引用,供 cancel 接口调用 kill
874
- job.child = child;
875
- job.startedAt = nowIso();
876
- job.status = 'running';
877
- publish('job:update', job);
878
-
879
- // 流式 NDJSON 解析:把 stdout 当作 stream-json 协议处理
880
- // assistant.text → job.output (用户主要关心的内容)
881
- // assistant.thinking → job.thinking (折叠展示,让用户知道 Claude 在想)
882
- // 其他事件(init / tool_use / result 等)忽略,避免噪声
883
- // 解析失败的行原样进 output,便于排查协议异常。
884
- // thinking 几乎不做服务端截断:Claude reasoning 一般在 KB~几十 MB 之间,
885
- // 100MB 兜底只是为了防止内存爆炸。流式 publish 时改推增量 delta(仅新拼接的
886
- // 那部分),终态或重连时才随 job:update 全量同步,避免每帧重复广播整个累积文本。
887
- const MAX_OUTPUT = 100 * 1024 * 1024;
888
- const MAX_THINKING = 100 * 1024 * 1024;
889
- job.output = '';
890
- job.thinking = '';
891
- const lineBuf = { stdout: '', stderr: '' };
892
-
893
- const parseLines = (channel, buf) => {
894
- const chunk = buf.toString('utf8');
895
- lineBuf[channel] += chunk;
896
- const lines = lineBuf[channel].split('\n');
897
- lineBuf[channel] = lines.pop() ?? ''; // 最后一段可能不完整,留给下次
898
- let pendingThinkingDelta = '';
899
- for (const line of lines) {
900
- const trimmed = line.trim();
901
- if (!trimmed) continue;
902
- if (channel === 'stderr' || !trimmed.startsWith('{')) {
903
- // stream-json 行:原样塞进 output(兼容老版本 claude / 错误信息)
1034
+ // 流式 NDJSON 解析:把 stdout 当作 stream-json 协议处理
1035
+ // assistant.text → job.output (用户主要关心的内容)
1036
+ // assistant.thinking → job.thinking (折叠展示,让用户知道 Claude 在想)
1037
+ // 其他事件(init / tool_use / result 等)忽略,避免噪声
1038
+ // 解析失败的行原样进 output,便于排查协议异常。
1039
+ // thinking 几乎不做服务端截断:Claude reasoning 一般在 KB~几十 MB 之间,
1040
+ // 100MB 兜底只是为了防止内存爆炸。流式 publish 时改推增量 delta(仅新拼接的
1041
+ // 那部分),终态或重连时才随 job:update 全量同步,避免每帧重复广播整个累积文本。
1042
+ const MAX_OUTPUT = 100 * 1024 * 1024;
1043
+ const MAX_THINKING = 100 * 1024 * 1024;
1044
+ job.output = '';
1045
+ job.thinking = '';
1046
+ const lineBuf = { stdout: '', stderr: '' };
1047
+
1048
+ const parseLines = (channel, buf) => {
1049
+ const chunk = buf.toString('utf8');
1050
+ lineBuf[channel] += chunk;
1051
+ const lines = lineBuf[channel].split('\n');
1052
+ lineBuf[channel] = lines.pop() ?? ''; // 最后一段可能不完整,留给下次
1053
+ let pendingThinkingDelta = '';
1054
+ for (const line of lines) {
1055
+ const trimmed = line.trim();
1056
+ if (!trimmed) continue;
1057
+ if (channel === 'stderr' || !trimmed.startsWith('{')) {
1058
+ // stream-json 行:原样塞进 output(兼容老版本 claude / 错误信息)
1059
+ const prevLen = job.output.length;
1060
+ job.output = (job.output + trimmed + '\n').slice(-MAX_OUTPUT);
1061
+ // output 也用 delta 推送,前端按"以 length 为锚追加"语义合并
1062
+ const delta = job.output.slice(prevLen);
1063
+ if (delta) publish('job:output-delta', { id: job.id, delta });
1064
+ continue;
1065
+ }
1066
+ let evt;
1067
+ try { evt = JSON.parse(trimmed) } catch { continue }
1068
+ if (evt.type !== 'assistant') continue;
1069
+ const blocks = evt.message?.content;
1070
+ if (!Array.isArray(blocks)) continue;
1071
+ for (const b of blocks) {
1072
+ if (b.type === 'text' && typeof b.text === 'string') {
904
1073
  const prevLen = job.output.length;
905
- job.output = (job.output + trimmed + '\n').slice(-MAX_OUTPUT);
906
- // output 也用 delta 推送,前端按"以 length 为锚追加"语义合并
1074
+ job.output = (job.output + b.text).slice(-MAX_OUTPUT);
907
1075
  const delta = job.output.slice(prevLen);
908
1076
  if (delta) publish('job:output-delta', { id: job.id, delta });
909
- continue;
1077
+ } else if (b.type === 'thinking' && typeof b.thinking === 'string') {
1078
+ const prevLen = job.thinking.length;
1079
+ job.thinking = (job.thinking + b.thinking).slice(-MAX_THINKING);
1080
+ const delta = job.thinking.slice(prevLen);
1081
+ if (delta) pendingThinkingDelta += delta;
910
1082
  }
911
- let evt;
912
- try { evt = JSON.parse(trimmed) } catch { continue }
913
- if (evt.type !== 'assistant') continue;
914
- const blocks = evt.message?.content;
915
- if (!Array.isArray(blocks)) continue;
916
- for (const b of blocks) {
1083
+ }
1084
+ }
1085
+ // 一批 NDJSON 处理完后统一发一次 thinking delta,避免高频小块 socket 占用
1086
+ if (pendingThinkingDelta) {
1087
+ publish('job:thinking-delta', { id: job.id, delta: pendingThinkingDelta });
1088
+ }
1089
+ };
1090
+ if (child.stdout) child.stdout.on('data', (buf) => parseLines('stdout', buf));
1091
+ if (child.stderr) child.stderr.on('data', (buf) => parseLines('stderr', buf));
1092
+
1093
+ // 等待进程退出(detached 不阻塞主进程,用 polling /proc 兜底)
1094
+ await waitProcessExit(pid);
1095
+ const wasCancelled = cancelledJobs.has(jobId)
1096
+ if (wasCancelled) cancelledJobs.delete(jobId)
1097
+ // 进程退出时 stdout 可能残留最后一段未换行的 NDJSON,flush 一次
1098
+ // flush 内部也按 delta 推送,保持与流式阶段一致
1099
+ if (lineBuf.stdout.trim()) {
1100
+ const outPrev = job.output.length;
1101
+ const thinkPrev = job.thinking.length;
1102
+ try {
1103
+ const evt = JSON.parse(lineBuf.stdout.trim())
1104
+ if (evt.type === 'assistant' && Array.isArray(evt.message?.content)) {
1105
+ for (const b of evt.message.content) {
917
1106
  if (b.type === 'text' && typeof b.text === 'string') {
918
- const prevLen = job.output.length;
919
- job.output = (job.output + b.text).slice(-MAX_OUTPUT);
920
- const delta = job.output.slice(prevLen);
921
- if (delta) publish('job:output-delta', { id: job.id, delta });
1107
+ job.output = (job.output + b.text).slice(-MAX_OUTPUT)
922
1108
  } else if (b.type === 'thinking' && typeof b.thinking === 'string') {
923
- const prevLen = job.thinking.length;
924
- job.thinking = (job.thinking + b.thinking).slice(-MAX_THINKING);
925
- const delta = job.thinking.slice(prevLen);
926
- if (delta) pendingThinkingDelta += delta;
1109
+ job.thinking = (job.thinking + b.thinking).slice(-MAX_THINKING)
927
1110
  }
928
1111
  }
929
1112
  }
930
- // 一批 NDJSON 处理完后统一发一次 thinking delta,避免高频小块 socket 占用
931
- if (pendingThinkingDelta) {
932
- publish('job:thinking-delta', { id: job.id, delta: pendingThinkingDelta });
933
- }
934
- };
935
- if (child.stdout) child.stdout.on('data', (buf) => parseLines('stdout', buf));
936
- if (child.stderr) child.stderr.on('data', (buf) => parseLines('stderr', buf));
937
-
938
- // 等待进程退出(detached 不阻塞主进程,用 polling /proc 兜底)
939
- await waitProcessExit(pid);
940
- const wasCancelled = cancelledJobs.has(jobId)
941
- if (wasCancelled) cancelledJobs.delete(jobId)
942
- // 进程退出时 stdout 可能残留最后一段未换行的 NDJSON,flush 一次
943
- // flush 内部也按 delta 推送,保持与流式阶段一致
944
- if (lineBuf.stdout.trim()) {
945
- const outPrev = job.output.length;
946
- const thinkPrev = job.thinking.length;
947
- try {
948
- const evt = JSON.parse(lineBuf.stdout.trim())
949
- if (evt.type === 'assistant' && Array.isArray(evt.message?.content)) {
950
- for (const b of evt.message.content) {
951
- if (b.type === 'text' && typeof b.text === 'string') {
952
- job.output = (job.output + b.text).slice(-MAX_OUTPUT)
953
- } else if (b.type === 'thinking' && typeof b.thinking === 'string') {
954
- job.thinking = (job.thinking + b.thinking).slice(-MAX_THINKING)
955
- }
956
- }
957
- }
958
- } catch { /* 不是 JSON,忽略 */ }
959
- const outDelta = job.output.slice(outPrev);
960
- if (outDelta) publish('job:output-delta', { id: job.id, delta: outDelta });
961
- const thinkDelta = job.thinking.slice(thinkPrev);
962
- if (thinkDelta) publish('job:thinking-delta', { id: job.id, delta: thinkDelta });
963
- }
964
- job.endedAt = nowIso();
965
- if (wasCancelled) {
966
- job.exitCode = 130; // 128 + SIGINT(2),约定俗成的"用户取消"退出码
967
- job.status = 'cancelled';
968
- job.error = '用户已停止执行';
969
- // sub 不改状态——cancelled 是 job 维度,同 task 后续 sub 仍可继续执行
970
- } else {
971
- job.exitCode = 0;
972
- job.status = 'done';
973
- sub.status = 'done';
974
- // 把这个 sub 的输出累积到前序上下文,喂给下一个 sub
975
- priorOutputs.push({ title: sub.title, output: job.output || '' })
976
- }
977
- } catch (err) {
978
- job.error = err && err.message ? err.message : String(err);
979
- job.status = 'error';
980
- sub.status = 'error';
981
- } finally {
982
- // 移除 child 引用——避免后续被 SSE 序列化到前端
983
- delete job.child
984
- publish('job:update', job);
985
- publish('sub:update', { taskId: task.id, sub });
986
- // 终态:fire-and-forget 同步落盘,确保 done/cancelled/error 都立即归档
987
- flushJobsSaveNow().catch(err => console.warn('[workbench] jobs save failed:', err.message))
1113
+ } catch { /* 不是 JSON,忽略 */ }
1114
+ const outDelta = job.output.slice(outPrev);
1115
+ if (outDelta) publish('job:output-delta', { id: job.id, delta: outDelta });
1116
+ const thinkDelta = job.thinking.slice(thinkPrev);
1117
+ if (thinkDelta) publish('job:thinking-delta', { id: job.id, delta: thinkDelta });
1118
+ }
1119
+ job.endedAt = nowIso();
1120
+ if (wasCancelled) {
1121
+ job.exitCode = 130; // 128 + SIGINT(2),约定俗成的"用户取消"退出码
1122
+ job.status = 'cancelled';
1123
+ job.error = '用户已停止执行';
1124
+ // sub 不改状态——cancelled 是 job 维度,同 task 后续 sub 仍可继续执行
1125
+ } else {
1126
+ job.exitCode = 0;
1127
+ job.status = 'done';
1128
+ sub.status = 'done';
1129
+ // 把这个 sub 的输出累积到前序上下文,喂给下一个 sub
1130
+ if (priorOutputs) priorOutputs.push({ title: sub.title, output: job.output || '' })
988
1131
  }
1132
+ } catch (err) {
1133
+ job.error = err && err.message ? err.message : String(err);
1134
+ job.status = 'error';
1135
+ sub.status = 'error';
1136
+ } finally {
1137
+ // 移除 child 引用——避免后续被 SSE 序列化到前端
1138
+ delete job.child
1139
+ publish('job:update', job);
1140
+ publish('sub:update', { taskId: task.id, sub });
1141
+ // 终态:fire-and-forget 同步落盘,确保 done/cancelled/error 都立即归档
1142
+ flushJobsSaveNow().catch(err => console.warn('[workbench] jobs save failed:', err.message))
989
1143
  }
990
- // 写回 tasks.json
1144
+ }
1145
+
1146
+ /** 把 task.subtasks 写回 tasks.json,并广播 task:update。runTaskQueue 和"单 sub 执行"共用。 */
1147
+ async function persistTaskAfterRun(task) {
991
1148
  const data = await readJson(TASKS_FILE, { tasks: [] });
992
1149
  const t = data.tasks.find(x => x.id === task.id);
993
1150
  if (t) {
@@ -998,6 +1155,31 @@ ${prompt}`
998
1155
  }
999
1156
  }
1000
1157
 
1158
+ /**
1159
+ * 构建单 sub 执行时的 priorOutputs:把同一 task 下"排在当前 sub 之前"且已 done 的
1160
+ * 子任务输出摘要收集起来。这样单独跑一个 sub 时,它也能拿到前序上下文。
1161
+ */
1162
+ async function collectPriorOutputs(task, targetSub) {
1163
+ const MAX_PREV_OUTPUT_CHARS = 2000
1164
+ const prior = []
1165
+ const targetIdx = task.subtasks.findIndex(s => s.id === targetSub.id)
1166
+ if (targetIdx < 0) return prior
1167
+ for (let i = 0; i < targetIdx; i++) {
1168
+ const s = task.subtasks[i]
1169
+ if (s.status !== 'done') continue
1170
+ // 从 jobs 列表里找最近一个属于这个 sub 且 status=done 的 job,
1171
+ // 取其 output 作为"前序上下文摘要"
1172
+ const job = snapshotJobs()
1173
+ .filter(j => j.subId === s.id && j.status === 'done')
1174
+ .sort((a, b) => (b.endedAt || '').localeCompare(a.endedAt || ''))[0]
1175
+ if (!job) continue
1176
+ const text = (job.output || '').slice(0, MAX_PREV_OUTPUT_CHARS)
1177
+ const truncated = (job.output || '').length > MAX_PREV_OUTPUT_CHARS ? '\n…(前文已截断)' : ''
1178
+ prior.push({ title: s.title, output: text + truncated })
1179
+ }
1180
+ return prior
1181
+ }
1182
+
1001
1183
  function waitProcessExit(pid) {
1002
1184
  return new Promise(resolve => {
1003
1185
  let exited = false;
@@ -1394,7 +1576,13 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1394
1576
  "subtasks": [
1395
1577
  { "title": "子任务标题(10-20字)", "desc": "具体描述" }
1396
1578
  ]
1397
- }`;
1579
+ }
1580
+
1581
+ **JSON 输出严格要求**(不遵守会导致解析失败、用户无法入库):
1582
+ 1. title 和 desc 内如需引用术语 / 页面名 / 状态名,**必须使用中文引号「」或『』**,禁止使用 ASCII 双引号、单引号或反引号,否则会破坏外层 JSON 结构。
1583
+ 2. JSON 中不允许尾随逗号(最后一个元素后面不能跟逗号)。
1584
+ 3. JSON 中不允许写注释。
1585
+ 4. 所有字符串字段必须用 ASCII 双引号包裹,字符串内部如有换行用 \\n 转义。`;
1398
1586
 
1399
1587
  // 先把 prompt 元信息推给前端
1400
1588
  send({ type: 'meta', prompt: { system: userInstruction, user: userBlock } });
@@ -1416,15 +1604,8 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1416
1604
  return res.end();
1417
1605
  }
1418
1606
 
1419
- // 解析 JSON:兼容 ```json ... ``` 代码块或裸 JSON
1420
- let parsed = {};
1421
- try {
1422
- const m = content.match(/```json\s*([\s\S]*?)```/i)
1423
- || content.match(/```\s*([\s\S]*?)```/)
1424
- || content.match(/(\{[\s\S]*\})/);
1425
- parsed = JSON.parse(m ? m[1] : content);
1426
- } catch { parsed = {}; }
1427
-
1607
+ // 解析 JSON:兼容 ```json ... ``` 代码块或裸 JSON,多级降级
1608
+ const { parsed, parseError, parseStage } = parseSubtaskJson(content);
1428
1609
  const list = Array.isArray(parsed?.subtasks) ? parsed.subtasks : [];
1429
1610
  const subtasks = list
1430
1611
  .map(s => ({
@@ -1434,7 +1615,7 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1434
1615
  .filter(s => s.title)
1435
1616
  .slice(0, 8);
1436
1617
 
1437
- send({ type: 'done', subtasks, raw: content });
1618
+ send({ type: 'done', subtasks, raw: content, parseError, parseStage });
1438
1619
  finished = true;
1439
1620
  res.end();
1440
1621
  } catch (err) {
@@ -1444,6 +1625,30 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1444
1625
  }
1445
1626
  });
1446
1627
 
1628
+ // POST /api/workbench/tasks/parse-subtasks
1629
+ // body: { raw: string }
1630
+ // → { success, subtasks, parseError, parseStage }
1631
+ // 让前端在 AI 拆分对话框里把"原始结果"作为可编辑文本——用户手改完
1632
+ // (比如把 ASCII 双引号改成中文「」、删尾随逗号)后直接调这个接口,
1633
+ // 不必再发起一次 LLM 调用,省 token 也省等待。
1634
+ app.post('/api/workbench/tasks/parse-subtasks', async (req, res) => {
1635
+ try {
1636
+ const raw = String(req.body?.raw || '');
1637
+ const { parsed, parseError, parseStage } = parseSubtaskJson(raw);
1638
+ const list = Array.isArray(parsed?.subtasks) ? parsed.subtasks : [];
1639
+ const subtasks = list
1640
+ .map(s => ({
1641
+ title: String(s?.title || '').trim().slice(0, 80),
1642
+ desc: String(s?.desc || '').trim().slice(0, 500)
1643
+ }))
1644
+ .filter(s => s.title)
1645
+ .slice(0, 8);
1646
+ res.json({ success: true, subtasks, parseError, parseStage });
1647
+ } catch (err) {
1648
+ res.status(500).json({ success: false, error: err?.message || String(err) });
1649
+ }
1650
+ });
1651
+
1447
1652
  // SSE 事件流
1448
1653
  app.get('/api/workbench/events', (req, res) => {
1449
1654
  res.set({
@@ -1536,8 +1741,11 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1536
1741
 
1537
1742
  app.post('/api/workbench/tasks', async (req, res) => {
1538
1743
  try {
1539
- const { id, title, desc, promptId, subtasks } = req.body || {};
1744
+ const { id, title, desc, promptId, subtasks, type: rawType, simpleOverride } = req.body || {};
1540
1745
  if (!title) return res.status(400).json({ success: false, error: 'title 必填' });
1746
+ // type 归一化:仅接受 'simple' | 'complex',缺省/未知一律按 complex
1747
+ const taskType = rawType === 'simple' ? 'simple' : 'complex';
1748
+ const safeOverride = typeof simpleOverride === 'string' ? simpleOverride.slice(0, 8000) : '';
1541
1749
  const data = await readJson(TASKS_FILE, { tasks: [] });
1542
1750
  const tasks = data.tasks || [];
1543
1751
  const now = nowIso();
@@ -1551,6 +1759,8 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1551
1759
  title,
1552
1760
  desc: desc || '',
1553
1761
  promptId: promptId || null,
1762
+ type: taskType,
1763
+ simpleOverride: taskType === 'simple' ? safeOverride : '',
1554
1764
  subtasks: Array.isArray(subtasks) ? subtasks.map(s => ({
1555
1765
  id: s.id || genId(),
1556
1766
  title: s.title || '',
@@ -1579,6 +1789,8 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1579
1789
  title,
1580
1790
  desc: desc || '',
1581
1791
  promptId: promptId || null,
1792
+ type: taskType,
1793
+ simpleOverride: taskType === 'simple' ? safeOverride : '',
1582
1794
  projectPath: currentProjectPath || '',
1583
1795
  subtasks: Array.isArray(subtasks) ? subtasks.map(s => ({
1584
1796
  id: s.id || genId(),
@@ -1631,6 +1843,91 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
1631
1843
  }
1632
1844
  });
1633
1845
 
1846
+ // ── 执行简单任务(无子任务直接跑) ──────────────────────────────────
1847
+ // POST /api/workbench/tasks/:id/run-simple
1848
+ // 行为:
1849
+ // - 适用于 type==='simple' 的任务,无需拆分子任务
1850
+ // - 在内存里合成一个虚拟 sub{title=task.title, desc=task.desc,
1851
+ // promptOverride=task.simpleOverride, status='todo'},
1852
+ // 复用 runSingleSubtask(同一套 prompt 拼装、附件、job 跟踪、取消、落盘)
1853
+ // - 不会修改 task.subtasks 持久化结构
1854
+ app.post('/api/workbench/tasks/:id/run-simple', async (req, res) => {
1855
+ try {
1856
+ const data = await readJson(TASKS_FILE, { tasks: [] });
1857
+ const task = (data.tasks || []).find(t => t.id === req.params.id);
1858
+ if (!task) return res.status(404).json({ success: false, error: '任务不存在' });
1859
+ if (task.type !== 'simple') {
1860
+ return res.status(400).json({ success: false, error: '该任务不是简单任务,请使用普通执行接口' });
1861
+ }
1862
+ const repoPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
1863
+ // 虚拟 sub:不写回 tasks.json,只用一次
1864
+ const virtualSub = {
1865
+ id: `${task.id}__simple`,
1866
+ title: task.title,
1867
+ desc: task.desc || '',
1868
+ status: 'todo',
1869
+ promptOverride: task.simpleOverride || '',
1870
+ attachments: Array.isArray(task.attachments) ? task.attachments : []
1871
+ };
1872
+ // 简单任务本身没有 subtasks 列表,直接调 runSingleSubtask(不再走 runTaskQueue 循环)
1873
+ res.json({ success: true, message: '已开始执行简单任务' });
1874
+ runSingleSubtask(task, virtualSub, repoPath, '', []).catch(err => {
1875
+ publish('task:error', { taskId: task.id, error: err.message });
1876
+ });
1877
+ } catch (err) {
1878
+ res.status(500).json({ success: false, error: err.message });
1879
+ }
1880
+ });
1881
+
1882
+ // ── 执行单个子任务 ────────────────────────────────────────────────────
1883
+ // POST /api/workbench/subtasks/:id/run
1884
+ // 行为:
1885
+ // - 仅执行指定 id 的这一个 sub,不再遍历 task 下其他 sub
1886
+ // - 拼 prompt 时会把"前面已 done 的 sub"输出摘要作为前序上下文塞进去,
1887
+ // 跟整批执行的语义保持一致
1888
+ // - 整批"执行任务"队列和单 sub 执行共用 runSingleSubtask,所以日志/状态
1889
+ // /取消/落盘逻辑完全一致
1890
+ // 限制:
1891
+ // - 该 sub 处于 running/pending 时拒绝(并发跑同一个 sub 会出现状态混乱)
1892
+ app.post('/api/workbench/subtasks/:id/run', async (req, res) => {
1893
+ try {
1894
+ const data = await readJson(TASKS_FILE, { tasks: [] });
1895
+ const subId = req.params.id;
1896
+ let foundTask = null;
1897
+ let foundSub = null;
1898
+ for (const t of (data.tasks || [])) {
1899
+ if (!Array.isArray(t.subtasks)) continue;
1900
+ const s = t.subtasks.find(x => x.id === subId);
1901
+ if (s) { foundTask = t; foundSub = s; break; }
1902
+ }
1903
+ if (!foundTask || !foundSub) {
1904
+ return res.status(404).json({ success: false, error: '子任务不存在' });
1905
+ }
1906
+ if (foundSub.status === 'running') {
1907
+ return res.status(400).json({ success: false, error: '该子任务正在执行中' });
1908
+ }
1909
+ // 兜底:即便磁盘状态是 todo,如果磁盘里还没归档的 job 还在跑(进程被孤儿),也拦一下
1910
+ const liveJob = snapshotJobs().find(j => j.subId === subId && (j.status === 'running' || j.status === 'pending'));
1911
+ if (liveJob) {
1912
+ return res.status(400).json({ success: false, error: '该子任务已有正在执行的 job' });
1913
+ }
1914
+ const repoPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
1915
+ // 异步执行,立即返回
1916
+ res.json({ success: true, message: `已开始执行子任务:${foundSub.title || subId}` });
1917
+ (async () => {
1918
+ try {
1919
+ const priorOutputs = await collectPriorOutputs(foundTask, foundSub);
1920
+ await runSingleSubtask(foundTask, foundSub, repoPath, '', priorOutputs);
1921
+ await persistTaskAfterRun(foundTask);
1922
+ } catch (err) {
1923
+ publish('task:error', { taskId: foundTask.id, error: err.message });
1924
+ }
1925
+ })();
1926
+ } catch (err) {
1927
+ res.status(500).json({ success: false, error: err.message });
1928
+ }
1929
+ });
1930
+
1634
1931
  // ── 进程状态查询(兜底,SSE 断了也能拉) ────────────────────────────
1635
1932
  app.get('/api/workbench/jobs', (_req, res) => {
1636
1933
  res.json({ success: true, jobs: snapshotJobs() });