zen-gitsync 2.13.4 → 2.13.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/ui/public/assets/EditorView-BLK-Sohi.js +0 -0
- package/src/ui/public/assets/SourceMapView-CMJnpleg.js +3 -0
- package/src/ui/public/assets/WorkbenchView-CPcGSyVM.css +1 -0
- package/src/ui/public/assets/WorkbenchView-CTB3QNSR.js +6 -0
- package/src/ui/public/assets/{_plugin-vue_export-helper-CCKxXnb6.js → _plugin-vue_export-helper-Dw3U5p9d.js} +3 -3
- package/src/ui/public/assets/index-DyR_trSU.js +66 -0
- package/src/ui/public/assets/{vendor-DpYOE9sy.css → vendor-Bq2rS2vY.css} +1 -1
- package/src/ui/public/assets/{vendor-WSdfT3f8.js → vendor-C30huq-U.js} +242 -242
- package/src/ui/public/index.html +4 -4
- package/src/ui/server/routes/workbench.js +527 -15
- package/src/ui/public/assets/EditorView-CNH166hf.js +0 -0
- package/src/ui/public/assets/SourceMapView-p6CE2eoi.js +0 -3
- package/src/ui/public/assets/WorkbenchView-Cmrq7MJT.js +0 -2
- package/src/ui/public/assets/WorkbenchView-DnSRDRkp.css +0 -1
- package/src/ui/public/assets/index-B9iw-Qvb.js +0 -66
|
@@ -30,6 +30,15 @@ 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
32
|
const SUBTASK_INSTRUCTION_FILE = path.join(DATA_DIR, 'ai-subtask-instruction.json');
|
|
33
|
+
// 执行日志持久化:jobs.json 是历史档案,jobs-config.json 是保留策略。
|
|
34
|
+
// jobs Map 仍只承载当前进程产出的活跃 job;管理页直接读 jobs.json。
|
|
35
|
+
const JOBS_FILE = path.join(DATA_DIR, 'jobs.json');
|
|
36
|
+
const JOBS_CONFIG_FILE = path.join(DATA_DIR, 'jobs-config.json');
|
|
37
|
+
// 流式 chunk 写盘会撑爆 IO;用 1.5s debounce 把高频写入折叠成一次。
|
|
38
|
+
// 终态时由 flushJobsSaveNow() 强制立即落盘。
|
|
39
|
+
const JOBS_SAVE_DEBOUNCE_MS = 1500;
|
|
40
|
+
let jobsSaveTimer = null;
|
|
41
|
+
const DEFAULT_JOBS_CONFIG = { maxCount: 500, maxSizeMB: 256 };
|
|
33
42
|
|
|
34
43
|
// 子项目识别 / 文件扫描时需要跳过的目录
|
|
35
44
|
const SKIP_DIRS = new Set([
|
|
@@ -405,6 +414,141 @@ async function callLlmStream(model, prompt, onDelta, opts = {}) {
|
|
|
405
414
|
return { content: fullContent, aborted }
|
|
406
415
|
}
|
|
407
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
|
+
|
|
408
552
|
function nowIso() {
|
|
409
553
|
return new Date().toISOString();
|
|
410
554
|
}
|
|
@@ -434,6 +578,131 @@ async function writeJson(file, data) {
|
|
|
434
578
|
await fsp.rename(tmp, file);
|
|
435
579
|
}
|
|
436
580
|
|
|
581
|
+
// ── 执行日志持久化 ────────────────────────────────────────────
|
|
582
|
+
// 设计要点:
|
|
583
|
+
// - 写盘在流式 chunk 阶段走 1.5s debounce;终态时(finally / cancel)强制 flush。
|
|
584
|
+
// - 永不持久化 child 引用(参考 cancel 路由的浅拷贝模式)。
|
|
585
|
+
// - hydrate 时把 running/pending 降级为 error:原 child 进程已不存在。
|
|
586
|
+
// - enforceRetention 在每次落盘后跑,按 endedAt desc FIFO 裁剪。
|
|
587
|
+
|
|
588
|
+
function serializeJob(j, taskMap) {
|
|
589
|
+
// child 是 ChildProcess 引用,序列化会爆;剥离后 size 用三字段累加预计算
|
|
590
|
+
const { child, ...rest } = j
|
|
591
|
+
const t = taskMap ? taskMap.get(rest.taskId) : null
|
|
592
|
+
const sub = t && Array.isArray(t.subtasks) ? t.subtasks.find(s => s.id === rest.subId) : null
|
|
593
|
+
const size = ((rest.prompt || '').length
|
|
594
|
+
+ (rest.output || '').length
|
|
595
|
+
+ (rest.thinking || '').length)
|
|
596
|
+
return {
|
|
597
|
+
...rest,
|
|
598
|
+
taskTitle: t ? t.title : '',
|
|
599
|
+
subTitle: sub ? sub.title : '',
|
|
600
|
+
size
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function scheduleJobsSave() {
|
|
605
|
+
if (jobsSaveTimer) clearTimeout(jobsSaveTimer)
|
|
606
|
+
jobsSaveTimer = setTimeout(() => {
|
|
607
|
+
jobsSaveTimer = null
|
|
608
|
+
flushJobsSaveNow().catch(err => console.warn('[workbench] jobs save failed:', err.message))
|
|
609
|
+
}, JOBS_SAVE_DEBOUNCE_MS)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async function flushJobsSaveNow() {
|
|
613
|
+
if (jobsSaveTimer) { clearTimeout(jobsSaveTimer); jobsSaveTimer = null }
|
|
614
|
+
// 读 tasks.json 给落盘 job 反范式 taskTitle/subTitle——父任务被删后管理页仍可读
|
|
615
|
+
const tasksData = await readJson(TASKS_FILE, { tasks: [] })
|
|
616
|
+
const taskMap = new Map((tasksData.tasks || []).map(t => [t.id, t]))
|
|
617
|
+
const payload = {
|
|
618
|
+
version: 1,
|
|
619
|
+
jobs: Array.from(jobs.values()).map(j => serializeJob(j, taskMap))
|
|
620
|
+
}
|
|
621
|
+
await writeJson(JOBS_FILE, payload)
|
|
622
|
+
await enforceRetention()
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async function readJobsConfig() {
|
|
626
|
+
const cfg = await readJson(JOBS_CONFIG_FILE, null)
|
|
627
|
+
if (!cfg || typeof cfg !== 'object') return { ...DEFAULT_JOBS_CONFIG }
|
|
628
|
+
return {
|
|
629
|
+
maxCount: Number.isFinite(cfg.maxCount) ? Math.max(0, Math.floor(cfg.maxCount)) : DEFAULT_JOBS_CONFIG.maxCount,
|
|
630
|
+
maxSizeMB: Number.isFinite(cfg.maxSizeMB) ? Math.max(0, Math.floor(cfg.maxSizeMB)) : DEFAULT_JOBS_CONFIG.maxSizeMB
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function writeJobsConfig(cfg) {
|
|
635
|
+
// 校验:非负整数;硬上限防误填爆盘
|
|
636
|
+
const out = {}
|
|
637
|
+
if (cfg.maxCount !== undefined) {
|
|
638
|
+
const n = Math.floor(Number(cfg.maxCount))
|
|
639
|
+
if (!Number.isFinite(n) || n < 0 || n > 10000) throw new Error('maxCount 必须在 0-10000 之间')
|
|
640
|
+
out.maxCount = n
|
|
641
|
+
}
|
|
642
|
+
if (cfg.maxSizeMB !== undefined) {
|
|
643
|
+
const n = Math.floor(Number(cfg.maxSizeMB))
|
|
644
|
+
if (!Number.isFinite(n) || n < 0 || n > 10240) throw new Error('maxSizeMB 必须在 0-10240 之间')
|
|
645
|
+
out.maxSizeMB = n
|
|
646
|
+
}
|
|
647
|
+
const merged = { ...(await readJobsConfig()), ...out }
|
|
648
|
+
await writeJson(JOBS_CONFIG_FILE, merged)
|
|
649
|
+
return merged
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// 进程启动时把磁盘上的历史拉回内存 Map;陈旧的 running/pending 强制降级。
|
|
653
|
+
// 陈旧 job 的 child 进程已退出,标记为 error 方便用户识别。
|
|
654
|
+
async function hydrateJobs() {
|
|
655
|
+
let data
|
|
656
|
+
try {
|
|
657
|
+
data = await readJson(JOBS_FILE, null)
|
|
658
|
+
} catch (err) {
|
|
659
|
+
// 损坏文件:改名备份避免下次 flush 静默覆盖用户数据
|
|
660
|
+
console.warn('[workbench] jobs.json 解析失败,备份原文件后重置:', err.message)
|
|
661
|
+
try { await fsp.rename(JOBS_FILE, `${JOBS_FILE}.bak-${Date.now()}`) } catch { /* 文件可能已不在 */ }
|
|
662
|
+
return
|
|
663
|
+
}
|
|
664
|
+
if (!data || !Array.isArray(data.jobs)) return
|
|
665
|
+
for (const j of data.jobs) {
|
|
666
|
+
if (j.status === 'running' || j.status === 'pending') {
|
|
667
|
+
j.status = 'error'
|
|
668
|
+
j.error = (j.error || '') + ' [重启后回收:原进程已退出]'
|
|
669
|
+
j.endedAt = j.endedAt || nowIso()
|
|
670
|
+
j.exitCode = typeof j.exitCode === 'number' ? j.exitCode : 1
|
|
671
|
+
}
|
|
672
|
+
// 旧版本可能没 size 字段;补齐以兼容历史文件
|
|
673
|
+
if (typeof j.size !== 'number') {
|
|
674
|
+
j.size = ((j.prompt || '').length + (j.output || '').length + (j.thinking || '').length)
|
|
675
|
+
}
|
|
676
|
+
jobs.set(j.id, j)
|
|
677
|
+
}
|
|
678
|
+
// 启动后也跑一遍保留策略,让历史文件立刻缩到当前配置
|
|
679
|
+
try { await enforceRetention() } catch (err) { console.warn('[workbench] 启动时 enforceRetention 失败:', err.message) }
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// 保留策略:按 endedAt desc(fallback startedAt / id)排序,先按 maxCount 截,
|
|
683
|
+
// 再按 maxSizeMB 累计裁,淘汰同步从内存 Map 删除。
|
|
684
|
+
async function enforceRetention() {
|
|
685
|
+
const cfg = await readJobsConfig()
|
|
686
|
+
const data = await readJson(JOBS_FILE, { version: 1, jobs: [] })
|
|
687
|
+
if (!data || !Array.isArray(data.jobs) || data.jobs.length === 0) return
|
|
688
|
+
const sortKey = (j) => j.endedAt || j.startedAt || j.id || ''
|
|
689
|
+
data.jobs.sort((a, b) => sortKey(b).localeCompare(sortKey(a)))
|
|
690
|
+
if (cfg.maxCount > 0) data.jobs = data.jobs.slice(0, cfg.maxCount)
|
|
691
|
+
if (cfg.maxSizeMB > 0) {
|
|
692
|
+
const cap = cfg.maxSizeMB * 1024 * 1024
|
|
693
|
+
let total = data.jobs.reduce((s, j) => s + (j.size || 0), 0)
|
|
694
|
+
while (total > cap && data.jobs.length > 1) {
|
|
695
|
+
const dropped = data.jobs.pop()
|
|
696
|
+
total -= (dropped && dropped.size) || 0
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
await writeJson(JOBS_FILE, data)
|
|
700
|
+
const keepIds = new Set(data.jobs.map(j => j.id))
|
|
701
|
+
for (const id of Array.from(jobs.keys())) {
|
|
702
|
+
if (!keepIds.has(id)) jobs.delete(id)
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
437
706
|
// 简单的 Mustache 风格变量插值:{{task.title}} / {{task.desc}} / {{repo.path}} / {{branch}}
|
|
438
707
|
function interpolate(template, ctx) {
|
|
439
708
|
if (typeof template !== 'string') return template;
|
|
@@ -456,6 +725,8 @@ const jobs = new Map(); // jobId -> { id, taskId, subId, status, pid, startedAt,
|
|
|
456
725
|
// 用 Set 而不是 job.cancelled 标志,是为了在 SIGTERM 发出后到 child 真正退出之间
|
|
457
726
|
// 有一个简洁的"待回收"窗口。
|
|
458
727
|
const cancelledJobs = new Set();
|
|
728
|
+
// 启动时从磁盘拉回历史 job(陈旧 running/pending 自动降级 error)
|
|
729
|
+
hydrateJobs().catch(err => console.warn('[workbench] hydrate jobs failed:', err.message))
|
|
459
730
|
|
|
460
731
|
// ── 生成指令持久化(~/.zen-gitsync/ai-instruction.json) ────────────────────
|
|
461
732
|
async function readInstruction() {
|
|
@@ -745,9 +1016,11 @@ ${prompt}`
|
|
|
745
1016
|
// assistant.thinking → job.thinking (折叠展示,让用户知道 Claude 在想)
|
|
746
1017
|
// 其他事件(init / tool_use / result 等)忽略,避免噪声
|
|
747
1018
|
// 解析失败的行原样进 output,便于排查协议异常。
|
|
748
|
-
//
|
|
749
|
-
|
|
750
|
-
|
|
1019
|
+
// thinking 几乎不做服务端截断:Claude reasoning 一般在 KB~几十 MB 之间,
|
|
1020
|
+
// 100MB 兜底只是为了防止内存爆炸。流式 publish 时改推增量 delta(仅新拼接的
|
|
1021
|
+
// 那部分),终态或重连时才随 job:update 全量同步,避免每帧重复广播整个累积文本。
|
|
1022
|
+
const MAX_OUTPUT = 100 * 1024 * 1024;
|
|
1023
|
+
const MAX_THINKING = 100 * 1024 * 1024;
|
|
751
1024
|
job.output = '';
|
|
752
1025
|
job.thinking = '';
|
|
753
1026
|
const lineBuf = { stdout: '', stderr: '' };
|
|
@@ -757,12 +1030,17 @@ ${prompt}`
|
|
|
757
1030
|
lineBuf[channel] += chunk;
|
|
758
1031
|
const lines = lineBuf[channel].split('\n');
|
|
759
1032
|
lineBuf[channel] = lines.pop() ?? ''; // 最后一段可能不完整,留给下次
|
|
1033
|
+
let pendingThinkingDelta = '';
|
|
760
1034
|
for (const line of lines) {
|
|
761
1035
|
const trimmed = line.trim();
|
|
762
1036
|
if (!trimmed) continue;
|
|
763
1037
|
if (channel === 'stderr' || !trimmed.startsWith('{')) {
|
|
764
1038
|
// 非 stream-json 行:原样塞进 output(兼容老版本 claude / 错误信息)
|
|
1039
|
+
const prevLen = job.output.length;
|
|
765
1040
|
job.output = (job.output + trimmed + '\n').slice(-MAX_OUTPUT);
|
|
1041
|
+
// output 也用 delta 推送,前端按"以 length 为锚追加"语义合并
|
|
1042
|
+
const delta = job.output.slice(prevLen);
|
|
1043
|
+
if (delta) publish('job:output-delta', { id: job.id, delta });
|
|
766
1044
|
continue;
|
|
767
1045
|
}
|
|
768
1046
|
let evt;
|
|
@@ -772,13 +1050,22 @@ ${prompt}`
|
|
|
772
1050
|
if (!Array.isArray(blocks)) continue;
|
|
773
1051
|
for (const b of blocks) {
|
|
774
1052
|
if (b.type === 'text' && typeof b.text === 'string') {
|
|
1053
|
+
const prevLen = job.output.length;
|
|
775
1054
|
job.output = (job.output + b.text).slice(-MAX_OUTPUT);
|
|
1055
|
+
const delta = job.output.slice(prevLen);
|
|
1056
|
+
if (delta) publish('job:output-delta', { id: job.id, delta });
|
|
776
1057
|
} else if (b.type === 'thinking' && typeof b.thinking === 'string') {
|
|
1058
|
+
const prevLen = job.thinking.length;
|
|
777
1059
|
job.thinking = (job.thinking + b.thinking).slice(-MAX_THINKING);
|
|
1060
|
+
const delta = job.thinking.slice(prevLen);
|
|
1061
|
+
if (delta) pendingThinkingDelta += delta;
|
|
778
1062
|
}
|
|
779
1063
|
}
|
|
780
1064
|
}
|
|
781
|
-
|
|
1065
|
+
// 一批 NDJSON 处理完后统一发一次 thinking delta,避免高频小块 socket 占用
|
|
1066
|
+
if (pendingThinkingDelta) {
|
|
1067
|
+
publish('job:thinking-delta', { id: job.id, delta: pendingThinkingDelta });
|
|
1068
|
+
}
|
|
782
1069
|
};
|
|
783
1070
|
if (child.stdout) child.stdout.on('data', (buf) => parseLines('stdout', buf));
|
|
784
1071
|
if (child.stderr) child.stderr.on('data', (buf) => parseLines('stderr', buf));
|
|
@@ -788,7 +1075,10 @@ ${prompt}`
|
|
|
788
1075
|
const wasCancelled = cancelledJobs.has(jobId)
|
|
789
1076
|
if (wasCancelled) cancelledJobs.delete(jobId)
|
|
790
1077
|
// 进程退出时 stdout 可能残留最后一段未换行的 NDJSON,flush 一次
|
|
1078
|
+
// flush 内部也按 delta 推送,保持与流式阶段一致
|
|
791
1079
|
if (lineBuf.stdout.trim()) {
|
|
1080
|
+
const outPrev = job.output.length;
|
|
1081
|
+
const thinkPrev = job.thinking.length;
|
|
792
1082
|
try {
|
|
793
1083
|
const evt = JSON.parse(lineBuf.stdout.trim())
|
|
794
1084
|
if (evt.type === 'assistant' && Array.isArray(evt.message?.content)) {
|
|
@@ -801,6 +1091,10 @@ ${prompt}`
|
|
|
801
1091
|
}
|
|
802
1092
|
}
|
|
803
1093
|
} catch { /* 不是 JSON,忽略 */ }
|
|
1094
|
+
const outDelta = job.output.slice(outPrev);
|
|
1095
|
+
if (outDelta) publish('job:output-delta', { id: job.id, delta: outDelta });
|
|
1096
|
+
const thinkDelta = job.thinking.slice(thinkPrev);
|
|
1097
|
+
if (thinkDelta) publish('job:thinking-delta', { id: job.id, delta: thinkDelta });
|
|
804
1098
|
}
|
|
805
1099
|
job.endedAt = nowIso();
|
|
806
1100
|
if (wasCancelled) {
|
|
@@ -824,6 +1118,8 @@ ${prompt}`
|
|
|
824
1118
|
delete job.child
|
|
825
1119
|
publish('job:update', job);
|
|
826
1120
|
publish('sub:update', { taskId: task.id, sub });
|
|
1121
|
+
// 终态:fire-and-forget 同步落盘,确保 done/cancelled/error 都立即归档
|
|
1122
|
+
flushJobsSaveNow().catch(err => console.warn('[workbench] jobs save failed:', err.message))
|
|
827
1123
|
}
|
|
828
1124
|
}
|
|
829
1125
|
// 写回 tasks.json
|
|
@@ -1233,7 +1529,13 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
|
|
|
1233
1529
|
"subtasks": [
|
|
1234
1530
|
{ "title": "子任务标题(10-20字)", "desc": "具体描述" }
|
|
1235
1531
|
]
|
|
1236
|
-
}
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
**JSON 输出严格要求**(不遵守会导致解析失败、用户无法入库):
|
|
1535
|
+
1. title 和 desc 内如需引用术语 / 页面名 / 状态名,**必须使用中文引号「」或『』**,禁止使用 ASCII 双引号、单引号或反引号,否则会破坏外层 JSON 结构。
|
|
1536
|
+
2. JSON 中不允许尾随逗号(最后一个元素后面不能跟逗号)。
|
|
1537
|
+
3. JSON 中不允许写注释。
|
|
1538
|
+
4. 所有字符串字段必须用 ASCII 双引号包裹,字符串内部如有换行用 \\n 转义。`;
|
|
1237
1539
|
|
|
1238
1540
|
// 先把 prompt 元信息推给前端
|
|
1239
1541
|
send({ type: 'meta', prompt: { system: userInstruction, user: userBlock } });
|
|
@@ -1255,15 +1557,8 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
|
|
|
1255
1557
|
return res.end();
|
|
1256
1558
|
}
|
|
1257
1559
|
|
|
1258
|
-
// 解析 JSON:兼容 ```json ... ``` 代码块或裸 JSON
|
|
1259
|
-
|
|
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
|
-
|
|
1560
|
+
// 解析 JSON:兼容 ```json ... ``` 代码块或裸 JSON,多级降级
|
|
1561
|
+
const { parsed, parseError, parseStage } = parseSubtaskJson(content);
|
|
1267
1562
|
const list = Array.isArray(parsed?.subtasks) ? parsed.subtasks : [];
|
|
1268
1563
|
const subtasks = list
|
|
1269
1564
|
.map(s => ({
|
|
@@ -1273,7 +1568,7 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
|
|
|
1273
1568
|
.filter(s => s.title)
|
|
1274
1569
|
.slice(0, 8);
|
|
1275
1570
|
|
|
1276
|
-
send({ type: 'done', subtasks, raw: content });
|
|
1571
|
+
send({ type: 'done', subtasks, raw: content, parseError, parseStage });
|
|
1277
1572
|
finished = true;
|
|
1278
1573
|
res.end();
|
|
1279
1574
|
} catch (err) {
|
|
@@ -1283,6 +1578,30 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
|
|
|
1283
1578
|
}
|
|
1284
1579
|
});
|
|
1285
1580
|
|
|
1581
|
+
// POST /api/workbench/tasks/parse-subtasks
|
|
1582
|
+
// body: { raw: string }
|
|
1583
|
+
// → { success, subtasks, parseError, parseStage }
|
|
1584
|
+
// 让前端在 AI 拆分对话框里把"原始结果"作为可编辑文本——用户手改完
|
|
1585
|
+
// (比如把 ASCII 双引号改成中文「」、删尾随逗号)后直接调这个接口,
|
|
1586
|
+
// 不必再发起一次 LLM 调用,省 token 也省等待。
|
|
1587
|
+
app.post('/api/workbench/tasks/parse-subtasks', async (req, res) => {
|
|
1588
|
+
try {
|
|
1589
|
+
const raw = String(req.body?.raw || '');
|
|
1590
|
+
const { parsed, parseError, parseStage } = parseSubtaskJson(raw);
|
|
1591
|
+
const list = Array.isArray(parsed?.subtasks) ? parsed.subtasks : [];
|
|
1592
|
+
const subtasks = list
|
|
1593
|
+
.map(s => ({
|
|
1594
|
+
title: String(s?.title || '').trim().slice(0, 80),
|
|
1595
|
+
desc: String(s?.desc || '').trim().slice(0, 500)
|
|
1596
|
+
}))
|
|
1597
|
+
.filter(s => s.title)
|
|
1598
|
+
.slice(0, 8);
|
|
1599
|
+
res.json({ success: true, subtasks, parseError, parseStage });
|
|
1600
|
+
} catch (err) {
|
|
1601
|
+
res.status(500).json({ success: false, error: err?.message || String(err) });
|
|
1602
|
+
}
|
|
1603
|
+
});
|
|
1604
|
+
|
|
1286
1605
|
// SSE 事件流
|
|
1287
1606
|
app.get('/api/workbench/events', (req, res) => {
|
|
1288
1607
|
res.set({
|
|
@@ -1362,6 +1681,17 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
|
|
|
1362
1681
|
}
|
|
1363
1682
|
});
|
|
1364
1683
|
|
|
1684
|
+
// 当前选中的项目路径(侧边栏按项目分组时要用)
|
|
1685
|
+
app.get('/api/workbench/current-project', async (_req, res) => {
|
|
1686
|
+
try {
|
|
1687
|
+
const projectPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
|
|
1688
|
+
const projectName = projectPath ? projectPath.split(/[\\/]/).filter(Boolean).pop() : '';
|
|
1689
|
+
res.json({ success: true, projectPath, projectName });
|
|
1690
|
+
} catch (err) {
|
|
1691
|
+
res.status(500).json({ success: false, error: err.message });
|
|
1692
|
+
}
|
|
1693
|
+
});
|
|
1694
|
+
|
|
1365
1695
|
app.post('/api/workbench/tasks', async (req, res) => {
|
|
1366
1696
|
try {
|
|
1367
1697
|
const { id, title, desc, promptId, subtasks } = req.body || {};
|
|
@@ -1369,6 +1699,8 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
|
|
|
1369
1699
|
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
1370
1700
|
const tasks = data.tasks || [];
|
|
1371
1701
|
const now = nowIso();
|
|
1702
|
+
// 创建任务时记录当时所属项目;编辑已有任务不覆盖(避免切换项目后老任务被改归属)
|
|
1703
|
+
const currentProjectPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
|
|
1372
1704
|
if (id) {
|
|
1373
1705
|
const i = tasks.findIndex(t => t.id === id);
|
|
1374
1706
|
if (i < 0) return res.status(404).json({ success: false, error: '任务不存在' });
|
|
@@ -1405,6 +1737,7 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
|
|
|
1405
1737
|
title,
|
|
1406
1738
|
desc: desc || '',
|
|
1407
1739
|
promptId: promptId || null,
|
|
1740
|
+
projectPath: currentProjectPath || '',
|
|
1408
1741
|
subtasks: Array.isArray(subtasks) ? subtasks.map(s => ({
|
|
1409
1742
|
id: s.id || genId(),
|
|
1410
1743
|
title: s.title || '',
|
|
@@ -1482,6 +1815,8 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
|
|
|
1482
1815
|
job.error = '用户已停止执行'
|
|
1483
1816
|
job.endedAt = nowIso()
|
|
1484
1817
|
publish('job:update', { ...job }) // 用浅拷贝避免序列化 child 引用
|
|
1818
|
+
// 终态:fire-and-forget 同步落盘,cancel 是显式操作,要保证不丢
|
|
1819
|
+
flushJobsSaveNow().catch(err => console.warn('[workbench] jobs save failed:', err.message))
|
|
1485
1820
|
const child = job.child
|
|
1486
1821
|
if (!child) {
|
|
1487
1822
|
return res.json({ success: true, message: '已标记取消,进程将尽快结束' })
|
|
@@ -1504,6 +1839,183 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
|
|
|
1504
1839
|
}
|
|
1505
1840
|
});
|
|
1506
1841
|
|
|
1842
|
+
// ── 执行日志管理 API(持久化 + 清理) ────────────────────────────
|
|
1843
|
+
// 路径都在 /api/workbench/jobs/* 下;/list、/config、/batch-delete、/clear
|
|
1844
|
+
// 是字面路径,必须排在 :id 路由之前注册,避免被 :id 匹配吞掉。
|
|
1845
|
+
//
|
|
1846
|
+
// 数据来源:
|
|
1847
|
+
// - 文件 jobs.json:已落盘的历史 job(含反范式 taskTitle/subTitle)
|
|
1848
|
+
// - 内存 jobs Map:当前进程刚创建还没刷盘的(尤其是 running/pending)
|
|
1849
|
+
// 合并后再过滤分页,保证管理页能看到"刚启动还没结束"的任务。
|
|
1850
|
+
|
|
1851
|
+
async function loadAllJobs() {
|
|
1852
|
+
// 读 tasks.json 一次,给内存里没落盘的 job 反范式补 title
|
|
1853
|
+
const tasksData = await readJson(TASKS_FILE, { tasks: [] })
|
|
1854
|
+
const taskMap = new Map((tasksData.tasks || []).map(t => [t.id, t]))
|
|
1855
|
+
const data = await readJson(JOBS_FILE, { version: 1, jobs: [] })
|
|
1856
|
+
const fileJobs = (data && Array.isArray(data.jobs)) ? data.jobs : []
|
|
1857
|
+
const fileIds = new Set(fileJobs.map(j => j.id))
|
|
1858
|
+
// 内存里有但文件里没有:running/pending 或最近刚起还没刷盘的
|
|
1859
|
+
const liveOnly = Array.from(jobs.values())
|
|
1860
|
+
.filter(j => !fileIds.has(j.id))
|
|
1861
|
+
.map(j => serializeJob(j, taskMap))
|
|
1862
|
+
return [...fileJobs, ...liveOnly]
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
function applyJobsFilter(list, q) {
|
|
1866
|
+
const status = (q.status || '').trim()
|
|
1867
|
+
const taskId = (q.taskId || '').trim()
|
|
1868
|
+
const term = (q.q || '').trim().toLowerCase()
|
|
1869
|
+
return list.filter(j => {
|
|
1870
|
+
if (status && j.status !== status) return false
|
|
1871
|
+
if (taskId && j.taskId !== taskId) return false
|
|
1872
|
+
if (term) {
|
|
1873
|
+
const hay = `${j.title || ''} ${j.taskTitle || ''} ${j.subTitle || ''}`.toLowerCase()
|
|
1874
|
+
if (!hay.includes(term)) return false
|
|
1875
|
+
}
|
|
1876
|
+
return true
|
|
1877
|
+
})
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
// GET /api/workbench/jobs/list?status=&q=&taskId=&limit=&offset=
|
|
1881
|
+
app.get('/api/workbench/jobs/list', async (req, res) => {
|
|
1882
|
+
try {
|
|
1883
|
+
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50))
|
|
1884
|
+
const offset = Math.max(0, parseInt(req.query.offset, 10) || 0)
|
|
1885
|
+
const all = await loadAllJobs()
|
|
1886
|
+
const filtered = applyJobsFilter(all, req.query)
|
|
1887
|
+
// 按 endedAt desc;没有就 startedAt desc;都没有就 id desc(id 时间戳前缀可比)
|
|
1888
|
+
const sortKey = (j) => j.endedAt || j.startedAt || j.id || ''
|
|
1889
|
+
filtered.sort((a, b) => sortKey(b).localeCompare(sortKey(a)))
|
|
1890
|
+
const total = filtered.length
|
|
1891
|
+
const page = filtered.slice(offset, offset + limit)
|
|
1892
|
+
// 统计:按 status 分组 + 总 size(基于全集,给顶部条用)
|
|
1893
|
+
const byStatus = {}
|
|
1894
|
+
let totalSize = 0
|
|
1895
|
+
for (const j of all) {
|
|
1896
|
+
byStatus[j.status] = (byStatus[j.status] || 0) + 1
|
|
1897
|
+
totalSize += j.size || 0
|
|
1898
|
+
}
|
|
1899
|
+
res.json({
|
|
1900
|
+
success: true,
|
|
1901
|
+
jobs: page,
|
|
1902
|
+
total,
|
|
1903
|
+
stats: { count: all.length, sizeMB: +(totalSize / 1024 / 1024).toFixed(2), byStatus }
|
|
1904
|
+
})
|
|
1905
|
+
} catch (err) {
|
|
1906
|
+
res.status(500).json({ success: false, error: 'list jobs 失败: ' + (err.message || String(err)) })
|
|
1907
|
+
}
|
|
1908
|
+
})
|
|
1909
|
+
|
|
1910
|
+
// GET /api/workbench/jobs/config
|
|
1911
|
+
app.get('/api/workbench/jobs/config', async (_req, res) => {
|
|
1912
|
+
try {
|
|
1913
|
+
res.json({ success: true, config: await readJobsConfig() })
|
|
1914
|
+
} catch (err) {
|
|
1915
|
+
res.status(500).json({ success: false, error: '读取配置失败: ' + err.message })
|
|
1916
|
+
}
|
|
1917
|
+
})
|
|
1918
|
+
|
|
1919
|
+
// PUT /api/workbench/jobs/config
|
|
1920
|
+
app.put('/api/workbench/jobs/config', async (req, res) => {
|
|
1921
|
+
try {
|
|
1922
|
+
const cfg = await writeJobsConfig(req.body || {})
|
|
1923
|
+
// 配置变更后立刻 enforce,让已落盘的多余记录立刻被裁掉
|
|
1924
|
+
await enforceRetention()
|
|
1925
|
+
res.json({ success: true, config: cfg })
|
|
1926
|
+
} catch (err) {
|
|
1927
|
+
res.status(400).json({ success: false, error: err.message || String(err) })
|
|
1928
|
+
}
|
|
1929
|
+
})
|
|
1930
|
+
|
|
1931
|
+
// POST /api/workbench/jobs/batch-delete
|
|
1932
|
+
app.post('/api/workbench/jobs/batch-delete', async (req, res) => {
|
|
1933
|
+
try {
|
|
1934
|
+
const ids = Array.isArray(req.body?.ids) ? req.body.ids.filter(s => typeof s === 'string') : []
|
|
1935
|
+
if (ids.length === 0) return res.json({ success: true, removed: 0 })
|
|
1936
|
+
// 1) 删内存 Map
|
|
1937
|
+
let removed = 0
|
|
1938
|
+
for (const id of ids) {
|
|
1939
|
+
if (jobs.delete(id)) removed++
|
|
1940
|
+
}
|
|
1941
|
+
// 2) 改写文件
|
|
1942
|
+
const data = await readJson(JOBS_FILE, { version: 1, jobs: [] })
|
|
1943
|
+
if (data && Array.isArray(data.jobs)) {
|
|
1944
|
+
const set = new Set(ids)
|
|
1945
|
+
const before = data.jobs.length
|
|
1946
|
+
data.jobs = data.jobs.filter(j => !set.has(j.id))
|
|
1947
|
+
removed += before - data.jobs.length
|
|
1948
|
+
await writeJson(JOBS_FILE, data)
|
|
1949
|
+
}
|
|
1950
|
+
res.json({ success: true, removed })
|
|
1951
|
+
} catch (err) {
|
|
1952
|
+
res.status(500).json({ success: false, error: '批量删除失败: ' + (err.message || String(err)) })
|
|
1953
|
+
}
|
|
1954
|
+
})
|
|
1955
|
+
|
|
1956
|
+
// POST /api/workbench/jobs/clear
|
|
1957
|
+
app.post('/api/workbench/jobs/clear', async (req, res) => {
|
|
1958
|
+
try {
|
|
1959
|
+
if (req.body?.confirm !== true) {
|
|
1960
|
+
return res.status(400).json({ success: false, error: '需要 confirm: true' })
|
|
1961
|
+
}
|
|
1962
|
+
let removed = 0
|
|
1963
|
+
for (const j of jobs.values()) {
|
|
1964
|
+
// 不清当前还在跑/排队的(防止误清活跃 job;用户应逐个取消或等结束)
|
|
1965
|
+
if (j.status === 'running' || j.status === 'pending') continue
|
|
1966
|
+
jobs.delete(j.id)
|
|
1967
|
+
removed++
|
|
1968
|
+
}
|
|
1969
|
+
// 写一个空 jobs.json
|
|
1970
|
+
await writeJson(JOBS_FILE, { version: 1, jobs: [] })
|
|
1971
|
+
res.json({ success: true, removed })
|
|
1972
|
+
} catch (err) {
|
|
1973
|
+
res.status(500).json({ success: false, error: '清空失败: ' + (err.message || String(err)) })
|
|
1974
|
+
}
|
|
1975
|
+
})
|
|
1976
|
+
|
|
1977
|
+
// GET /api/workbench/jobs/:id
|
|
1978
|
+
app.get('/api/workbench/jobs/:id', async (req, res) => {
|
|
1979
|
+
try {
|
|
1980
|
+
// 优先查内存(含活跃)
|
|
1981
|
+
const live = jobs.get(req.params.id)
|
|
1982
|
+
if (live) {
|
|
1983
|
+
const tasksData = await readJson(TASKS_FILE, { tasks: [] })
|
|
1984
|
+
const taskMap = new Map((tasksData.tasks || []).map(t => [t.id, t]))
|
|
1985
|
+
return res.json({ success: true, job: serializeJob(live, taskMap) })
|
|
1986
|
+
}
|
|
1987
|
+
// 退回文件
|
|
1988
|
+
const data = await readJson(JOBS_FILE, { version: 1, jobs: [] })
|
|
1989
|
+
const j = (data.jobs || []).find(x => x.id === req.params.id)
|
|
1990
|
+
if (!j) return res.status(404).json({ success: false, error: 'job 不存在' })
|
|
1991
|
+
res.json({ success: true, job: j })
|
|
1992
|
+
} catch (err) {
|
|
1993
|
+
res.status(500).json({ success: false, error: '查询失败: ' + (err.message || String(err)) })
|
|
1994
|
+
}
|
|
1995
|
+
})
|
|
1996
|
+
|
|
1997
|
+
// DELETE /api/workbench/jobs/:id
|
|
1998
|
+
app.delete('/api/workbench/jobs/:id', async (req, res) => {
|
|
1999
|
+
try {
|
|
2000
|
+
const id = req.params.id
|
|
2001
|
+
let removed = false
|
|
2002
|
+
if (jobs.delete(id)) removed = true
|
|
2003
|
+
const data = await readJson(JOBS_FILE, { version: 1, jobs: [] })
|
|
2004
|
+
if (data && Array.isArray(data.jobs)) {
|
|
2005
|
+
const before = data.jobs.length
|
|
2006
|
+
data.jobs = data.jobs.filter(j => j.id !== id)
|
|
2007
|
+
if (data.jobs.length !== before) {
|
|
2008
|
+
removed = true
|
|
2009
|
+
await writeJson(JOBS_FILE, data)
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
if (!removed) return res.status(404).json({ success: false, error: 'job 不存在' })
|
|
2013
|
+
res.json({ success: true, removed: 1 })
|
|
2014
|
+
} catch (err) {
|
|
2015
|
+
res.status(500).json({ success: false, error: '删除失败: ' + (err.message || String(err)) })
|
|
2016
|
+
}
|
|
2017
|
+
})
|
|
2018
|
+
|
|
1507
2019
|
// ── 子任务附件:上传 / 删除 / 列表 ───────────────────────────────
|
|
1508
2020
|
// 上传:POST /api/workbench/subtasks/:subId/attachments
|
|
1509
2021
|
// header: X-Original-Name, X-Mime-Type
|
|
Binary file
|