zen-gitsync 2.14.2 → 2.14.3
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/{AppVersionBadge-M9ydXpd9.js → AppVersionBadge-z2kqhmtD.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-scIk6OeM.js → BranchSelector-CSjsCeeJ.js} +1 -1
- package/src/ui/public/assets/{CommandConsole-BikQuarw.js → CommandConsole-96MUoG1F.js} +2 -2
- package/src/ui/public/assets/{CommitForm-DuvNxUdP.js → CommitForm-BBKQ0GqO.js} +1 -1
- package/src/ui/public/assets/{CommonDialog-G17HYMln.js → CommonDialog-CgQovpCJ.js} +1 -1
- package/src/ui/public/assets/{EditorView-rGLnccH3.js → EditorView-aoA30emq.js} +0 -0
- package/src/ui/public/assets/{FlowExecutionViewer-B4BADP8e.js → FlowExecutionViewer-BvXlaQai.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-C3l3Jco7.js → FlowOrchestrationWorkspace-CgWUF1Jc.js} +1 -1
- package/src/ui/public/assets/{LogList-7agk5Obq.js → LogList-uF4tir23.js} +1 -1
- package/src/ui/public/assets/{MindmapView-mOFG9TmR.js → MindmapView-B8wr0EFL.js} +1 -1
- package/src/ui/public/assets/{MonitorView-Dlpgy8Bb.js → MonitorView-DaJ0h8Im.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-CgS14qyg.js → ProjectStartupButton-CcH1NL2W.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-CFKl6CB9.js → RemoteRepoCard-BDbJ76uf.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-Dsc_a3t7.js → SourceMapView-2HjAlHnp.js} +1 -1
- package/src/ui/public/assets/{SvgIcon-Cw2mL_zx.js → SvgIcon-CK6VGHb5.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-yBT2005h.js → UserInputNode-Cx8r5geA.js} +1 -1
- package/src/ui/public/assets/{WorkbenchView-CiG-qMl7.js → WorkbenchView-CG54iND-.js} +4 -4
- package/src/ui/public/assets/WorkbenchView-CvHPPs8Q.css +1 -0
- package/src/ui/public/assets/{_plugin-vue_export-helper-BMXWW0fG.js → _plugin-vue_export-helper-CJMndG_W.js} +2 -2
- package/src/ui/public/assets/{configStore-Cq6Qwn35.js → configStore-DDnE5eie.js} +1 -1
- package/src/ui/public/assets/index-B7FGNx_l.js +23 -0
- package/src/ui/public/assets/{index-gbRjS0sL.css → index-f6UhuE5j.css} +1 -1
- package/src/ui/public/index.html +6 -6
- package/src/ui/server/index.js +632 -625
- package/src/ui/server/middleware/errorHandler.js +84 -0
- package/src/ui/server/middleware/errorHandler.test.js +114 -0
- package/src/ui/server/routes/exec.js +0 -21
- package/src/ui/server/routes/gitOps.js +62 -0
- package/src/ui/server/routes/monitor.js +42 -26
- package/src/ui/server/routes/terminal.js +318 -320
- package/src/ui/server/routes/workbench/attachmentUtils.js +76 -0
- package/src/ui/server/routes/workbench/index.js +1680 -0
- package/src/ui/server/routes/workbench/instructionStore.js +175 -0
- package/src/ui/server/routes/workbench/jobStore.js +225 -0
- package/src/ui/server/routes/workbench/jsonParse.js +219 -0
- package/src/ui/server/routes/workbench/llmClient.js +214 -0
- package/src/ui/server/routes/workbench/projectScan.js +191 -0
- package/src/ui/server/routes/workbench/sessionStore.js +126 -0
- package/src/ui/server/routes/workbench/shared.js +115 -0
- package/src/ui/server/routes/workbench/taskRunner.js +493 -0
- package/src/ui/server/routes/workbench.js +16 -3523
- package/src/ui/public/assets/WorkbenchView-OmJ1eM5g.css +0 -1
- package/src/ui/public/assets/index-CPqKvZBK.js +0 -23
|
@@ -12,3528 +12,21 @@
|
|
|
12
12
|
// See the License for the specific language governing permissions and
|
|
13
13
|
// limitations under the License.
|
|
14
14
|
//
|
|
15
|
-
//
|
|
16
|
-
// 并以 bypassPermissions 模式依次 spawn claude CLI 执行子任务(每次新窗口)。
|
|
17
|
-
// 数据存到用户主目录 ~/.zen-gitsync/,跨项目共享。
|
|
18
|
-
|
|
19
|
-
import fs from 'fs';
|
|
20
|
-
import logger from '../utils/logger.js'
|
|
21
|
-
import fsp from 'fs/promises';
|
|
22
|
-
import path from 'path';
|
|
23
|
-
import os from 'os';
|
|
24
|
-
import { spawn, execFileSync, execFile } from 'child_process';
|
|
25
|
-
import { EventEmitter } from 'events';
|
|
26
|
-
import express from 'express';
|
|
27
|
-
|
|
28
|
-
const DATA_DIR = path.join(os.homedir(), '.zen-gitsync');
|
|
29
|
-
const PROMPTS_FILE = path.join(DATA_DIR, 'prompts.json');
|
|
30
|
-
const TASKS_FILE = path.join(DATA_DIR, 'tasks.json');
|
|
31
|
-
const IMAGES_DIR = path.join(DATA_DIR, 'workbench-images');
|
|
32
|
-
const INSTRUCTION_FILE = path.join(DATA_DIR, 'ai-instruction.json');
|
|
33
|
-
const SUBTASK_INSTRUCTION_FILE = path.join(DATA_DIR, 'ai-subtask-instruction.json');
|
|
34
|
-
// AI 对话拆分会话存档:每个 sessionId 一个文件,重启可恢复多轮上下文
|
|
35
|
-
const SESSIONS_DIR = path.join(DATA_DIR, 'ai-split-sessions');
|
|
36
|
-
const MAX_SESSIONS = 100; // 软上限,触发清理
|
|
37
|
-
const SESSIONS_KEEP = 50; // 超过上限时保留最新 N 个
|
|
38
|
-
// 执行日志持久化:jobs.json 是历史档案,jobs-config.json 是保留策略。
|
|
39
|
-
// jobs Map 仍只承载当前进程产出的活跃 job;管理页直接读 jobs.json。
|
|
40
|
-
const JOBS_FILE = path.join(DATA_DIR, 'jobs.json');
|
|
41
|
-
const JOBS_CONFIG_FILE = path.join(DATA_DIR, 'jobs-config.json');
|
|
42
|
-
// 流式 chunk 写盘会撑爆 IO;用 1.5s debounce 把高频写入折叠成一次。
|
|
43
|
-
// 终态时由 flushJobsSaveNow() 强制立即落盘。
|
|
44
|
-
const JOBS_SAVE_DEBOUNCE_MS = 1500;
|
|
45
|
-
let jobsSaveTimer = null;
|
|
46
|
-
const DEFAULT_JOBS_CONFIG = { maxCount: 500, maxSizeMB: 256 };
|
|
47
|
-
|
|
48
|
-
// 子项目识别 / 文件扫描时需要跳过的目录
|
|
49
|
-
const SKIP_DIRS = new Set([
|
|
50
|
-
'node_modules', 'dist', 'build', '.next', '.nuxt', '__pycache__',
|
|
51
|
-
'target', 'out', 'coverage', 'vendor', '.git', '.svn', '.hg',
|
|
52
|
-
'.idea', '.vscode', '.gradle', '.terraform', '.cache', '.parcel-cache',
|
|
53
|
-
'.turbo', '.svelte-kit', 'storybook-static'
|
|
54
|
-
]);
|
|
55
|
-
|
|
56
|
-
// 默认生成指令:用户首次使用时作为可编辑指令的初始值
|
|
57
|
-
const DEFAULT_INSTRUCTION = `你是一名资深软件架构师。
|
|
58
|
-
|
|
59
|
-
【探索步骤】
|
|
60
|
-
1. 先识别项目结构:扫描根目录是否包含 .git 目录,以及 package.json / pyproject.toml / go.mod / Cargo.toml / pom.xml / build.gradle{,.kts} / composer.json / Gemfile / pubspec.yaml 这 9 种 manifest。
|
|
61
|
-
2. 如果根目录含 manifest,就把整个根目录视为一个子项目。
|
|
62
|
-
3. 如果根目录不含 manifest、但子目录(含一层 .git 或上述 manifest)形成多个子项目,对每个子项目分别探索。
|
|
63
|
-
4. 对每个子项目,重点读取:
|
|
64
|
-
- 所有识别到的 manifest(限制单文件 20KB)
|
|
65
|
-
- README.md(限制 8KB)
|
|
66
|
-
- 入口文件:package.json 的 main / scripts / workspaces 字段;pyproject.toml 的 [project.scripts];go.mod 的 module;Cargo.toml 的 [[bin]];pom.xml 的 <modules>
|
|
67
|
-
- 2 层目录树(最多 200 行)
|
|
68
|
-
|
|
69
|
-
【输出要求】
|
|
70
|
-
1. 给出一段中文「项目架构说明」,长度不限,模型自行决定篇幅与详尽程度,能写多详细就多详细。覆盖:项目整体定位、技术栈、模块划分、核心流程、关键设计决策。
|
|
71
|
-
2. 必须引用子项目里实际存在的文件路径、目录名、依赖名,不要编造。
|
|
72
|
-
3. 多个子项目时:先逐个说明,最后输出一段「整体架构」总结它们之间的关系。
|
|
73
|
-
4. 语气专业、具体、面向接手这个项目的开发者。
|
|
74
|
-
5. 只返回 JSON:{ "name": "项目名(建议:项目名+架构说明)", "summary": "架构说明正文" }。`;
|
|
75
|
-
|
|
76
|
-
// 单个附件最大 5MB;与 Anthropic Messages API 文档约束一致
|
|
77
|
-
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
78
|
-
// 一个子任务最多挂 9 个附件
|
|
79
|
-
const MAX_ATTACHMENTS_PER_SUBTASK = 9;
|
|
80
|
-
|
|
81
|
-
// AI 拆分子任务:默认系统指令(用户在 GUI 可编辑覆盖)。
|
|
82
|
-
// 国际化:根据请求 Accept-Language 在 zh / en 之间选默认。
|
|
83
|
-
// 中英两份都内置——用户保存到 ~/.zen-gitsync/ai-subtask-instruction.json 后覆盖。
|
|
84
|
-
const DEFAULT_SUBTASK_INSTRUCTION_ZH = `你是一名任务拆分助手。
|
|
85
|
-
|
|
86
|
-
【绝对禁止】
|
|
87
|
-
- 不要进入任何"plan 模式"、"plan 阶段"或"先写 plan 再执行"。
|
|
88
|
-
- 不要输出"我要进入 plan / 准备 / 探索 / 调研"之类的元叙述。
|
|
89
|
-
- 不要写"Plan 文件已写入 xxx"或"等用户批准后开始"这种"声明意图"的话。
|
|
90
|
-
- 你只做一件事:把用户给的任务拆成可独立执行的子任务清单,直接输出 JSON。中间任何"我要做什么"的描述都属于错误输出。
|
|
91
|
-
- 你的输出会被另一个程序读取,该程序只看最终 JSON,所有非 JSON 文字会被丢弃——所以不要把"执行步骤"写进 analysis 段,只写在 JSON 的 desc 字段里。
|
|
92
|
-
|
|
93
|
-
【思考过程】
|
|
94
|
-
在给出 JSON 之前,请先在内部仔细思考(如果模型支持,把思考放在 reasoning 中;否则可以先输出一段较长的仔细分析,再输出 JSON)。分析要覆盖足够维度,不要简短一两句话就过:
|
|
95
|
-
- 任务的真实目标是什么?用户提供的描述/图片/上下文里有哪些关键信息和隐藏诉求?
|
|
96
|
-
- 涉及哪些技术栈、模块、文件、约束?是否有易被忽略的边界条件、风险点?
|
|
97
|
-
- 自然的执行顺序是什么?哪些步骤是前置依赖?哪些可以并行?
|
|
98
|
-
- 哪些步骤可能失败、需要单独验证?验证方式是什么?
|
|
99
|
-
- 是否需要先做调研/读现有代码的步骤,再做修改?
|
|
100
|
-
|
|
101
|
-
【拆分原则】
|
|
102
|
-
1. 单一职责:每个子任务只做一件事,避免"做 A 和 B"。
|
|
103
|
-
2. 粒度适中:单个子任务应当能在一次会话里完成(既不要"实现整个登录功能"这么大,也不要"打印 hello"这么琐碎)。
|
|
104
|
-
3. 顺序合理:子任务按依赖关系和执行顺序排列(先准备、后实现、最后验证)。
|
|
105
|
-
4. 可验证:每个子任务都有明确的完成标志("输出文件 xxx"、"通过测试 yyy"、"控制台打印 zzz")。
|
|
106
|
-
5. 数量:不设上限。任务很简单时 1-2 个就够,复杂时按需拆成 10 个、20 个甚至更多子任务都行,直到覆盖所有可独立验证的步骤。
|
|
107
|
-
6. 描述具体:desc 字段要写清楚"要做什么、参考什么、产出什么",不要只是把 title 改写一遍。
|
|
108
|
-
7. 如果任务里附带了图片,必须基于图片的实际内容拆分(例如指出图片中的哪个区域、哪个元素需要改),而不是泛泛而谈。
|
|
109
|
-
|
|
110
|
-
【输出要求】
|
|
111
|
-
最后必须输出 JSON,结构:
|
|
112
|
-
{
|
|
113
|
-
"subtasks": [
|
|
114
|
-
{ "title": "子任务标题", "desc": "子任务的具体描述,包含要做什么、输入是什么、输出/验证标志是什么" }
|
|
115
|
-
]
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
JSON 要用 \`\`\`json ... \`\`\` 代码块包裹,前面可以有分析文字,但 JSON 必须完整、合法、可解析。`;
|
|
119
|
-
|
|
120
|
-
const DEFAULT_SUBTASK_INSTRUCTION_EN = `You are a task breakdown assistant.
|
|
121
|
-
|
|
122
|
-
[ABSOLUTE PROHIBITIONS]
|
|
123
|
-
- Do NOT enter any "plan mode" / "planning phase" / "I'll write a plan first, then execute".
|
|
124
|
-
- Do NOT output meta-narration like "I'm entering plan / preparing / exploring / researching".
|
|
125
|
-
- Do NOT write "Plan file written to xxx" or "waiting for user approval" style intent-declaration text.
|
|
126
|
-
- You only do one thing: split the user's task into an independently executable subtask list and output JSON directly. Any "what I'm going to do" description in the middle is an error.
|
|
127
|
-
- Your output is consumed by another program that only reads the final JSON. All non-JSON text is discarded — so put execution steps in the desc field of JSON, not in the analysis section.
|
|
128
|
-
|
|
129
|
-
[Thinking process]
|
|
130
|
-
Before producing JSON, think carefully (put your thoughts in reasoning if the model supports it; otherwise output a longer careful analysis first, then JSON). Cover enough dimensions — don't settle for a one-or-two-sentence summary:
|
|
131
|
-
- What is the real goal? What key info and hidden needs are in the description / images / context?
|
|
132
|
-
- Which stack, modules, files, constraints are involved? Any easily missed edge cases or risks?
|
|
133
|
-
- What is the natural execution order? What blocks what? What can run in parallel?
|
|
134
|
-
- Which steps may fail and need separate verification? How will you verify?
|
|
135
|
-
- Should a research / read-existing-code step come before the changes?
|
|
136
|
-
|
|
137
|
-
[Breakdown principles]
|
|
138
|
-
1. Single responsibility: each subtask does only one thing, avoid bundling "do A and B".
|
|
139
|
-
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").
|
|
140
|
-
3. Sensible order: arrange subtasks by dependency / execution order (prepare first, implement, then verify).
|
|
141
|
-
4. Verifiable: every subtask has a clear completion signal (e.g. "produce file xxx", "pass test yyy", "log zzz to console").
|
|
142
|
-
5. Quantity: no hard limit. Very simple tasks need only 1-2; complex tasks can be split into 10, 20 or more subtasks until every independently verifiable step is covered.
|
|
143
|
-
6. Concrete desc: write what to do, what to reference, what to produce — don't just paraphrase the title.
|
|
144
|
-
7. If the task includes images, the breakdown must reference the actual image content (which region, which element to change), not just generic talk.
|
|
145
|
-
|
|
146
|
-
[Output requirements]
|
|
147
|
-
End with JSON, structure:
|
|
148
|
-
{
|
|
149
|
-
"subtasks": [
|
|
150
|
-
{ "title": "subtask title", "desc": "concrete description: what to do, what the input is, what the output / verification signal is" }
|
|
151
|
-
]
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
Wrap the JSON in \`\`\`json ... \`\`\`. Analysis text before it is allowed, but the JSON must be complete and parseable.`;
|
|
155
|
-
|
|
156
|
-
// 兼容旧引用
|
|
157
|
-
const DEFAULT_SUBTASK_INSTRUCTION = DEFAULT_SUBTASK_INSTRUCTION_ZH
|
|
158
|
-
|
|
159
|
-
// 根据请求 Accept-Language 选默认(zh / en)
|
|
160
|
-
function pickDefaultSubtaskInstruction(req) {
|
|
161
|
-
const al = String(req?.headers?.['accept-language'] || '').toLowerCase()
|
|
162
|
-
if (al.startsWith('en')) return DEFAULT_SUBTASK_INSTRUCTION_EN
|
|
163
|
-
return DEFAULT_SUBTASK_INSTRUCTION_ZH
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// ── AI 对话拆分: session 持久化层 ────────────────────────────
|
|
167
|
-
// 文件命名: {sessionId}.json
|
|
168
|
-
// 内容: { version, sessionId, title, desc, taskId, promptId, createdAt, updatedAt,
|
|
169
|
-
// messages: [{role, content}], latestSubtasks, latestRaw, latestParseStage }
|
|
170
|
-
// 写入用临时文件 + rename 原子替换(与 workbench 现有 writeJson 模式一致),
|
|
171
|
-
// 避免半写状态被读到。并发安全: 单进程内同一 session 串行调用,
|
|
172
|
-
// 跨端点并发写同 session 概率极低,丢失只会是最后一次 user/assistant pair,可重发。
|
|
173
|
-
function genSessionId() {
|
|
174
|
-
// 16 字符: 时间戳后 8 位 + 随机 8 位 base36,既可读也基本不冲突
|
|
175
|
-
return `${Date.now().toString(36).slice(-8)}-${Math.random().toString(36).slice(2, 10)}`
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
async function readSessionFile(sessionId) {
|
|
179
|
-
if (!/^[a-z0-9-]{4,32}$/i.test(sessionId)) {
|
|
180
|
-
const err = new Error('非法 sessionId'); err.statusCode = 400; throw err
|
|
181
|
-
}
|
|
182
|
-
const file = path.join(SESSIONS_DIR, `${sessionId}.json`)
|
|
183
|
-
try {
|
|
184
|
-
return JSON.parse(await fsp.readFile(file, 'utf-8'))
|
|
185
|
-
} catch (err) {
|
|
186
|
-
if (err.code === 'ENOENT') {
|
|
187
|
-
const e = new Error('会话不存在'); e.statusCode = 404; throw e
|
|
188
|
-
}
|
|
189
|
-
throw err
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
async function writeSessionFile(sessionId, data) {
|
|
194
|
-
await fsp.mkdir(SESSIONS_DIR, { recursive: true })
|
|
195
|
-
const file = path.join(SESSIONS_DIR, `${sessionId}.json`)
|
|
196
|
-
const tmp = `${file}.tmp.${process.pid}.${Date.now()}`
|
|
197
|
-
await fsp.writeFile(tmp, JSON.stringify(data, null, 2), 'utf-8')
|
|
198
|
-
await fsp.rename(tmp, file)
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
async function deleteSessionFile(sessionId) {
|
|
202
|
-
if (!/^[a-z0-9-]{4,32}$/i.test(sessionId)) {
|
|
203
|
-
const err = new Error('非法 sessionId'); err.statusCode = 400; throw err
|
|
204
|
-
}
|
|
205
|
-
const file = path.join(SESSIONS_DIR, `${sessionId}.json`)
|
|
206
|
-
try {
|
|
207
|
-
await fsp.unlink(file)
|
|
208
|
-
} catch (err) {
|
|
209
|
-
if (err.code === 'ENOENT') {
|
|
210
|
-
const e = new Error('会话不存在'); e.statusCode = 404; throw e
|
|
211
|
-
}
|
|
212
|
-
throw err
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// 列表只读 metadata 不读 messages 内容,启动无需预加载
|
|
217
|
-
async function listSessionsMeta() {
|
|
218
|
-
await fsp.mkdir(SESSIONS_DIR, { recursive: true })
|
|
219
|
-
const files = await fsp.readdir(SESSIONS_DIR)
|
|
220
|
-
const list = []
|
|
221
|
-
for (const f of files) {
|
|
222
|
-
if (!f.endsWith('.json')) continue
|
|
223
|
-
const sid = f.slice(0, -5)
|
|
224
|
-
try {
|
|
225
|
-
const file = path.join(SESSIONS_DIR, f)
|
|
226
|
-
const stat = await fsp.stat(file)
|
|
227
|
-
const data = JSON.parse(await fsp.readFile(file, 'utf-8'))
|
|
228
|
-
list.push({
|
|
229
|
-
sessionId: data.sessionId || sid,
|
|
230
|
-
title: data.title || '(无标题)',
|
|
231
|
-
taskId: data.taskId || '',
|
|
232
|
-
createdAt: data.createdAt || '',
|
|
233
|
-
updatedAt: data.updatedAt || '',
|
|
234
|
-
messageCount: Array.isArray(data.messages) ? data.messages.length : 0,
|
|
235
|
-
latestSubtaskCount: Array.isArray(data.latestSubtasks) ? data.latestSubtasks.length : 0,
|
|
236
|
-
size: stat.size
|
|
237
|
-
})
|
|
238
|
-
} catch { /* 跳过坏文件 */ }
|
|
239
|
-
}
|
|
240
|
-
list.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''))
|
|
241
|
-
return list
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
// 容量控制: 超过 MAX_SESSIONS 时按 mtime 删旧的,保留最新 SESSIONS_KEEP 个
|
|
245
|
-
async function enforceSessionsRetention() {
|
|
246
|
-
const files = await fsp.readdir(SESSIONS_DIR).catch(() => [])
|
|
247
|
-
const jsonFiles = files.filter(f => f.endsWith('.json'))
|
|
248
|
-
if (jsonFiles.length < MAX_SESSIONS) return
|
|
249
|
-
const stats = await Promise.all(jsonFiles.map(async f => {
|
|
250
|
-
const full = path.join(SESSIONS_DIR, f)
|
|
251
|
-
const s = await fsp.stat(full)
|
|
252
|
-
return { file: full, mtime: s.mtimeMs }
|
|
253
|
-
}))
|
|
254
|
-
stats.sort((a, b) => b.mtime - a.mtime)
|
|
255
|
-
const toDelete = stats.slice(SESSIONS_KEEP)
|
|
256
|
-
await Promise.all(toDelete.map(s => fsp.unlink(s.file).catch(() => {})))
|
|
257
|
-
if (toDelete.length > 0) {
|
|
258
|
-
logger.info(`[workbench] ai-split sessions: cleaned up ${toDelete.length} old files`)
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
// 白名单后缀:图片 + 常见文档(PDF / 纯文本 / Markdown)
|
|
262
|
-
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
|
|
263
|
-
const DOC_EXTS = new Set(['pdf', 'txt', 'md', 'markdown', 'csv', 'json', 'log']);
|
|
264
|
-
const ALLOWED_EXTS = new Set([...IMAGE_EXTS, ...DOC_EXTS]);
|
|
265
|
-
|
|
266
|
-
// mime → 文件后缀;与前端 el-upload accept 对齐
|
|
267
|
-
const MIME_TO_EXT = {
|
|
268
|
-
'image/png': 'png',
|
|
269
|
-
'image/jpeg': 'jpg',
|
|
270
|
-
'image/jpg': 'jpg',
|
|
271
|
-
'image/gif': 'gif',
|
|
272
|
-
'image/webp': 'webp',
|
|
273
|
-
'image/bmp': 'bmp',
|
|
274
|
-
'image/svg+xml': 'svg',
|
|
275
|
-
'image/x-icon': 'ico',
|
|
276
|
-
'image/vnd.microsoft.icon': 'ico',
|
|
277
|
-
'application/pdf': 'pdf',
|
|
278
|
-
'text/plain': 'txt',
|
|
279
|
-
'text/markdown': 'md',
|
|
280
|
-
'text/x-markdown': 'md',
|
|
281
|
-
'text/csv': 'csv',
|
|
282
|
-
'application/json': 'json',
|
|
283
|
-
'text/json': 'json',
|
|
284
|
-
'text/x-log': 'log',
|
|
285
|
-
};
|
|
286
|
-
|
|
287
|
-
function sanitizeExt(name, fallback = 'bin') {
|
|
288
|
-
if (typeof name !== 'string') return fallback;
|
|
289
|
-
const m = name.toLowerCase().match(/\.([a-z0-9]+)$/);
|
|
290
|
-
if (!m) return fallback;
|
|
291
|
-
return ALLOWED_EXTS.has(m[1]) ? m[1] : fallback;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
function isImageExt(ext) {
|
|
295
|
-
return IMAGE_EXTS.has(String(ext || '').toLowerCase());
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
async function ensureImagesDir() {
|
|
299
|
-
await fsp.mkdir(IMAGES_DIR, { recursive: true });
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// 把 mime 或文件名规范成统一后缀;遇到不在白名单的情况返回 null
|
|
303
|
-
function resolveExt({ originalName, mime }) {
|
|
304
|
-
if (mime && MIME_TO_EXT[mime.toLowerCase()]) {
|
|
305
|
-
return MIME_TO_EXT[mime.toLowerCase()];
|
|
306
|
-
}
|
|
307
|
-
const fromName = sanitizeExt(originalName, '');
|
|
308
|
-
if (fromName) return fromName;
|
|
309
|
-
return null;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// 解析 manifest 文件名(按优先级)
|
|
313
|
-
const MANIFEST_FILES = [
|
|
314
|
-
'package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml',
|
|
315
|
-
'pom.xml', 'build.gradle', 'build.gradle.kts', 'composer.json',
|
|
316
|
-
'Gemfile', 'pubspec.yaml'
|
|
317
|
-
];
|
|
318
|
-
|
|
319
|
-
// 轻量级:仅告诉 LLM 项目的主 manifest 是什么(用于 AI 拆分时让 LLM 知道项目类型)。
|
|
320
|
-
// 不读内容,只 stat 存在性——拆分子任务不需要细节。
|
|
321
|
-
async function detectProjectManifest(projectPath) {
|
|
322
|
-
if (!projectPath) return ''
|
|
323
|
-
for (const f of MANIFEST_FILES) {
|
|
324
|
-
try {
|
|
325
|
-
const stat = await fsp.stat(path.join(projectPath, f))
|
|
326
|
-
if (stat.isFile()) return f
|
|
327
|
-
} catch { /* 不存在,继续 */ }
|
|
328
|
-
}
|
|
329
|
-
return ''
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
async function readProjectManifest(projectPath) {
|
|
333
|
-
const out = {};
|
|
334
|
-
for (const f of MANIFEST_FILES) {
|
|
335
|
-
const p = path.join(projectPath, f);
|
|
336
|
-
try {
|
|
337
|
-
const stat = await fsp.stat(p);
|
|
338
|
-
if (!stat.isFile()) continue;
|
|
339
|
-
// 限制大小,避免巨型 pom.xml 把上下文打爆
|
|
340
|
-
const content = stat.size > 20000
|
|
341
|
-
? (await safeReadFile(p, 20000))
|
|
342
|
-
: (await fsp.readFile(p, 'utf8'));
|
|
343
|
-
out[f] = content;
|
|
344
|
-
} catch { /* 不存在就跳过 */ }
|
|
345
|
-
}
|
|
346
|
-
return out;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
async function safeReadFile(filePath, maxBytes = 200000) {
|
|
350
|
-
try {
|
|
351
|
-
const stat = await fsp.stat(filePath);
|
|
352
|
-
if (stat.size > maxBytes) {
|
|
353
|
-
const buf = Buffer.alloc(maxBytes);
|
|
354
|
-
const fd = await fsp.open(filePath, 'r');
|
|
355
|
-
await fd.read(buf, 0, maxBytes, 0);
|
|
356
|
-
await fd.close();
|
|
357
|
-
return buf.toString('utf8').slice(0, maxBytes);
|
|
358
|
-
}
|
|
359
|
-
return await fsp.readFile(filePath, 'utf8');
|
|
360
|
-
} catch {
|
|
361
|
-
return '';
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
async function listDirTree(projectPath, maxDepth = 2, maxEntries = 400) {
|
|
366
|
-
const lines = [];
|
|
367
|
-
async function walk(dir, depth) {
|
|
368
|
-
if (depth > maxDepth || lines.length >= maxEntries) return;
|
|
369
|
-
let entries;
|
|
370
|
-
try { entries = await fsp.readdir(dir, { withFileTypes: true }); }
|
|
371
|
-
catch { return; }
|
|
372
|
-
const filtered = entries.filter(e => {
|
|
373
|
-
if (e.name.startsWith('.')) return false;
|
|
374
|
-
if (['node_modules', 'dist', 'build', '.next', '.nuxt', '__pycache__', 'target', 'out', 'coverage', 'vendor'].includes(e.name)) return false;
|
|
375
|
-
return true;
|
|
376
|
-
});
|
|
377
|
-
const indent = ' '.repeat(depth);
|
|
378
|
-
for (const e of filtered) {
|
|
379
|
-
if (lines.length >= maxEntries) return;
|
|
380
|
-
if (e.isDirectory()) {
|
|
381
|
-
lines.push(`${indent}${e.name}/`);
|
|
382
|
-
await walk(path.join(dir, e.name), depth + 1);
|
|
383
|
-
} else if (e.isFile()) {
|
|
384
|
-
lines.push(`${indent}${e.name}`);
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
await walk(projectPath, 0);
|
|
389
|
-
return lines.join('\n');
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
// 剥离 LLM 输出的 思考块、```json``` 代码块围栏,以及首尾说明文字。
|
|
393
|
-
// 原贪婪正则 /({[\s\S]*})/ 会把 思考块(内含未配对花括号或字符串)一起吞进 JSON.parse,
|
|
394
|
-
// 在 DeepSeek 等会输出 推理链的模型上偶发 "Unterminated string in JSON"。
|
|
395
|
-
function stripThinkingBlocks(content) {
|
|
396
|
-
let s = String(content || '');
|
|
397
|
-
// 1) 显式 思考块(成对标签)
|
|
398
|
-
s = s.replace(/<think(?:ing)?>[\s\S]*?<\/think(?:ing)?>/gi, '');
|
|
399
|
-
// 2) 某些 provider 流式结尾会留下裸 <think>...</think> 之外的残段(无结束标签时被截断)
|
|
400
|
-
s = s.replace(/<think(?:ing)?>[\s\S]*$/gi, '');
|
|
401
|
-
// 3) ```json ... ``` 代码块围栏(保留内部 JSON)
|
|
402
|
-
s = s.replace(/```json\s*/gi, '').replace(/```/g, '');
|
|
403
|
-
return s;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
// 在剥离 思考块 的文本里定位第一个「平衡花括号对象」的起点/终点。
|
|
407
|
-
// 跳过字符串内的 {/}(含转义)以及 Jinja {{ }} 模板占位符;
|
|
408
|
-
// 同时只在「JSON 特征显著」的 { 处起算 —— 即该 { 之后不远处出现 " 字符串。
|
|
409
|
-
// 这能避免把 LLM 思考文本里的裸 { ... }(如 "see { views, stores }")误当 JSON 起点。
|
|
410
|
-
function extractFirstJsonObject(content) {
|
|
411
|
-
const s = String(content || '');
|
|
412
|
-
let depth = 0;
|
|
413
|
-
let inStr = false;
|
|
414
|
-
let escape = false;
|
|
415
|
-
let start = -1;
|
|
416
|
-
for (let i = 0; i < s.length; i++) {
|
|
417
|
-
const ch = s[i];
|
|
418
|
-
if (inStr) {
|
|
419
|
-
if (escape) { escape = false; continue; }
|
|
420
|
-
if (ch === '\\') { escape = true; continue; }
|
|
421
|
-
if (ch === '"') { inStr = false; }
|
|
422
|
-
continue;
|
|
423
|
-
}
|
|
424
|
-
if (ch === '"') { inStr = true; continue; }
|
|
425
|
-
// 跳过 Jinja {{ }} 占位符(提示词模板常用,勿当 JSON)
|
|
426
|
-
if (ch === '{' && s[i + 1] === '{') { i++; continue; }
|
|
427
|
-
if (ch === '}' && s[i - 1] === '}') { continue; }
|
|
428
|
-
if (ch === '{') {
|
|
429
|
-
if (depth === 0) {
|
|
430
|
-
// 启发式判断「这看起来像 JSON 起点」:
|
|
431
|
-
// 1. { 之前紧邻「真正的语义字符」(字母/数字/中文/下划线) → 不是 JSON 起点(散文里的花括号)
|
|
432
|
-
// 但接受 . , ; : 等句末标点(LLM 经常以「总结:{...}」直接开头 JSON)
|
|
433
|
-
// 2. { 之后跳过空白第一个字符必须是 " 或 }(空对象)
|
|
434
|
-
const before = i > 0 ? s[i - 1] : '';
|
|
435
|
-
if (/[A-Za-z0-9_一-龥]/.test(before)) continue;
|
|
436
|
-
let j = i + 1;
|
|
437
|
-
while (j < s.length && /\s/.test(s[j])) j++;
|
|
438
|
-
const firstNonWs = s[j];
|
|
439
|
-
if (firstNonWs !== '"' && firstNonWs !== '}') continue;
|
|
440
|
-
start = i;
|
|
441
|
-
}
|
|
442
|
-
depth++;
|
|
443
|
-
} else if (ch === '}') {
|
|
444
|
-
if (depth > 0) depth--;
|
|
445
|
-
if (depth === 0 && start !== -1) {
|
|
446
|
-
return s.slice(start, i + 1);
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
// 兜底:找不到平衡对象时返回原文(让上层 JSON.parse 报清晰错误)
|
|
451
|
-
return s;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
async function callLlmJson(model, prompt, opts = {}) {
|
|
455
|
-
const { timeoutMs = 60000, images = [] } = opts;
|
|
456
|
-
const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }));
|
|
457
|
-
const url = `${String(model.baseURL || '').replace(/\/$/, '')}/chat/completions`;
|
|
458
|
-
const headers = { 'Content-Type': 'application/json' };
|
|
459
|
-
if (model.apiKey) headers['Authorization'] = `Bearer ${model.apiKey}`;
|
|
460
|
-
|
|
461
|
-
// 有图片时改用 OpenAI multimodal content 数组(text + image_url)。
|
|
462
|
-
// 非多模态模型遇到 image_url 会忽略图片块,相当于退化成纯文本,不会报错。
|
|
463
|
-
let userContent;
|
|
464
|
-
if (Array.isArray(images) && images.length > 0) {
|
|
465
|
-
userContent = [
|
|
466
|
-
{ type: 'text', text: prompt },
|
|
467
|
-
...images.map(img => ({ type: 'image_url', image_url: { url: img } }))
|
|
468
|
-
];
|
|
469
|
-
} else {
|
|
470
|
-
userContent = prompt;
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
const body = JSON.stringify({
|
|
474
|
-
model: model.model,
|
|
475
|
-
messages: [{ role: 'user', content: userContent }],
|
|
476
|
-
temperature: 0.4,
|
|
477
|
-
response_format: { type: 'json_object' },
|
|
478
|
-
stream: false,
|
|
479
|
-
});
|
|
480
|
-
|
|
481
|
-
const controller = new AbortController();
|
|
482
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
483
|
-
try {
|
|
484
|
-
const resp = await fetch(url, { method: 'POST', headers, body, signal: controller.signal });
|
|
485
|
-
const data = await resp.json().catch(() => ({}));
|
|
486
|
-
if (!resp.ok) throw new Error(data?.error?.message || `HTTP ${resp.status}`);
|
|
487
|
-
const content = data?.choices?.[0]?.message?.content || '';
|
|
488
|
-
if (!content.trim()) {
|
|
489
|
-
throw new Error('LLM 返回内容为空');
|
|
490
|
-
}
|
|
491
|
-
try {
|
|
492
|
-
const cleaned = stripThinkingBlocks(content);
|
|
493
|
-
return JSON.parse(extractFirstJsonObject(cleaned));
|
|
494
|
-
} catch (err) {
|
|
495
|
-
const snippet = content.length > 500 ? content.slice(0, 500) + '…' : content;
|
|
496
|
-
throw new Error(`LLM 返回非 JSON 或被截断:${err.message};原始内容片段:${snippet}`);
|
|
497
|
-
}
|
|
498
|
-
} finally {
|
|
499
|
-
clearTimeout(timer);
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
async function callLlmJsonWithRetry(model, prompt, opts = {}, retries = 1) {
|
|
504
|
-
let lastErr;
|
|
505
|
-
for (let i = 0; i <= retries; i++) {
|
|
506
|
-
try {
|
|
507
|
-
return await callLlmJson(model, prompt, opts);
|
|
508
|
-
} catch (err) {
|
|
509
|
-
lastErr = err;
|
|
510
|
-
if (i < retries) {
|
|
511
|
-
await new Promise(r => setTimeout(r, 800 * (i + 1)));
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
throw lastErr;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
/**
|
|
519
|
-
* 流式调用 OpenAI 兼容 LLM。每收到一个 chunk 调 onDelta 回调。
|
|
520
|
-
* onDelta 接收 { thinking?: string, content?: string },二选一。
|
|
521
|
-
* - reasoning_content / reasoning:部分模型(如 deepseek)放在 delta.reasoning_content
|
|
522
|
-
* - reasoning / reasoning_text:openai o1 风格
|
|
523
|
-
* - content:普通输出
|
|
524
|
-
* 返回完整 content 字符串。
|
|
525
|
-
*
|
|
526
|
-
* input 支持两种形态(判别 union):
|
|
527
|
-
* - string: 旧模式,拼成单条 user message,支持 opts.images 多模态
|
|
528
|
-
* - ChatMsg[]: 新模式(对话拆分),直接作为 messages 发送;opts.images/systemPrompt 忽略
|
|
529
|
-
*/
|
|
530
|
-
function cloneMsgContent(c) {
|
|
531
|
-
if (typeof c === 'string') return c
|
|
532
|
-
if (Array.isArray(c)) return c.map(p => ({ ...p }))
|
|
533
|
-
return c
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
async function callLlmStream(model, input, onDelta, opts = {}) {
|
|
537
|
-
const { maxTokens = 2000, timeoutMs = 600000, signal, images = [], systemPrompt } = opts
|
|
538
|
-
const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }))
|
|
539
|
-
const url = `${String(model.baseURL || '').replace(/\/$/, '')}/chat/completions`
|
|
540
|
-
const headers = { 'Content-Type': 'application/json' }
|
|
541
|
-
if (model.apiKey) headers['Authorization'] = `Bearer ${model.apiKey}`
|
|
542
|
-
|
|
543
|
-
// 判别 input: string(旧,直接拆分)或 messages 数组(新,对话拆分)
|
|
544
|
-
let messages
|
|
545
|
-
if (Array.isArray(input)) {
|
|
546
|
-
// messages 模式: 深拷贝避免外部 mutation 污染 LLM 请求
|
|
547
|
-
// images / systemPrompt opts 在 messages 模式下不生效(由 caller 自管)
|
|
548
|
-
messages = input.map(m => ({ role: m.role, content: cloneMsgContent(m.content) }))
|
|
549
|
-
} else {
|
|
550
|
-
// 旧模式: string prompt 拼成单条 user message,支持多模态
|
|
551
|
-
const text = String(input || '')
|
|
552
|
-
let userContent
|
|
553
|
-
if (Array.isArray(images) && images.length > 0) {
|
|
554
|
-
userContent = [
|
|
555
|
-
{ type: 'text', text },
|
|
556
|
-
...images.map(img => ({ type: 'image_url', image_url: { url: img } }))
|
|
557
|
-
]
|
|
558
|
-
} else {
|
|
559
|
-
userContent = text
|
|
560
|
-
}
|
|
561
|
-
messages = []
|
|
562
|
-
if (systemPrompt) messages.push({ role: 'system', content: String(systemPrompt) })
|
|
563
|
-
messages.push({ role: 'user', content: userContent })
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
const body = JSON.stringify({
|
|
567
|
-
model: model.model,
|
|
568
|
-
messages,
|
|
569
|
-
max_tokens: maxTokens,
|
|
570
|
-
temperature: 0.4,
|
|
571
|
-
// 注意:stream: true 模式下不能同时使用 response_format:{type:'json_object'},
|
|
572
|
-
// 部分 provider 会在收到两个一起时报错/静默卡住。改在 prompt 里约束 JSON 输出即可。
|
|
573
|
-
stream: true,
|
|
574
|
-
})
|
|
575
|
-
|
|
576
|
-
const controller = new AbortController()
|
|
577
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
578
|
-
// 允许外部 signal 触发取消(用户在 GUI 点停止)
|
|
579
|
-
const onAbort = () => controller.abort()
|
|
580
|
-
if (signal) {
|
|
581
|
-
if (signal.aborted) controller.abort()
|
|
582
|
-
else signal.addEventListener('abort', onAbort)
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
let fullContent = ''
|
|
586
|
-
let aborted = false
|
|
587
|
-
try {
|
|
588
|
-
const resp = await fetch(url, { method: 'POST', headers, body, signal: controller.signal })
|
|
589
|
-
if (!resp.ok || !resp.body) {
|
|
590
|
-
const errText = await resp.text().catch(() => '')
|
|
591
|
-
throw new Error(errText || `HTTP ${resp.status}`)
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
// SSE 格式:每行 "data: {...}",最后 "data: [DONE]"
|
|
595
|
-
const decoder = new TextDecoder('utf-8')
|
|
596
|
-
let buf = ''
|
|
597
|
-
for await (const chunk of resp.body) {
|
|
598
|
-
buf += decoder.decode(chunk, { stream: true })
|
|
599
|
-
const lines = buf.split('\n')
|
|
600
|
-
buf = lines.pop() ?? ''
|
|
601
|
-
for (const line of lines) {
|
|
602
|
-
const trimmed = line.trim()
|
|
603
|
-
if (!trimmed || !trimmed.startsWith('data:')) continue
|
|
604
|
-
const payload = trimmed.slice(5).trim()
|
|
605
|
-
if (payload === '[DONE]') continue
|
|
606
|
-
try {
|
|
607
|
-
const evt = JSON.parse(payload)
|
|
608
|
-
const delta = evt.choices?.[0]?.delta || {}
|
|
609
|
-
// thinking 在不同 provider 字段名不同,全部尝试
|
|
610
|
-
const thinkingChunk = delta.reasoning_content
|
|
611
|
-
|| delta.reasoning
|
|
612
|
-
|| delta.reasoning_text
|
|
613
|
-
|| ''
|
|
614
|
-
const contentChunk = delta.content || ''
|
|
615
|
-
if (thinkingChunk) onDelta({ thinking: thinkingChunk })
|
|
616
|
-
if (contentChunk) {
|
|
617
|
-
fullContent += contentChunk
|
|
618
|
-
onDelta({ content: contentChunk })
|
|
619
|
-
}
|
|
620
|
-
} catch { /* 跳过无法解析的行 */ }
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
} catch (err) {
|
|
624
|
-
if (err?.name === 'AbortError' || controller.signal.aborted) {
|
|
625
|
-
aborted = true
|
|
626
|
-
// 中断不算错误——上层会决定怎么处理
|
|
627
|
-
} else {
|
|
628
|
-
throw err
|
|
629
|
-
}
|
|
630
|
-
} finally {
|
|
631
|
-
clearTimeout(timer)
|
|
632
|
-
if (signal) signal.removeEventListener('abort', onAbort)
|
|
633
|
-
}
|
|
634
|
-
return { content: fullContent, aborted }
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
// AI 拆分子任务 JSON 多级降级解析。
|
|
638
|
-
// 模型经常会犯几类格式错:在 desc 里用 ASCII 双引号引用术语 / 末尾留尾随逗号 /
|
|
639
|
-
// 输出被 token 上限截断导致代码块未闭合。直接 JSON.parse 一旦失败就会让前端的
|
|
640
|
-
// "确认入库" 按钮永远是 (0),用户看不到原因。这里按"越简单越优先"的顺序尝试:
|
|
641
|
-
// ① ```json``` 代码块 / ```any``` 代码块 / 第一个完整 { ... } 范围
|
|
642
|
-
// ② 把候选片段去掉尾随逗号 + 块/行注释 再 parse
|
|
643
|
-
// ③ 用括号深度扫描,从开头找一个语法平衡的 { ... } 子串
|
|
644
|
-
// ④ 启发式转义模型夹在字符串内部的未转义 ASCII 双引号
|
|
645
|
-
// (这是实战里最常见的失败:模型用 ASCII " 引用"和书籍对话"这种术语,
|
|
646
|
-
// 直接打断外层 JSON。本级在字符串中遇到 " 时往后看一个非空白字符,
|
|
647
|
-
// 不是 ,}]: 就把它当作字面量,转义成 \"。)
|
|
648
|
-
// 任一步成功就返回 parsed,全部失败时返回最后一次 JSON.parse 的错误,
|
|
649
|
-
// 用 parseStage 告知前端"模型输出哪一步崩了",并把原始 raw 一并回传。
|
|
650
|
-
function parseSubtaskJson(content) {
|
|
651
|
-
let src = String(content || '');
|
|
652
|
-
if (!src.trim()) {
|
|
653
|
-
return { parsed: null, parseError: '模型未返回任何内容', parseStage: 'empty' };
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
// 预处理:剥离 deepseek-r1 / QwQ 等模型在 content 前输出的 <think>...</think> 思考块。
|
|
657
|
-
// 思考块通常跨行、可能多段,也可能自闭合 <think/>。剥离后下面的 JSON 扫描
|
|
658
|
-
// 才能命中真正的 subtasks JSON 块,避免贪婪正则把整段 think 包进 candidates。
|
|
659
|
-
src = src.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/<think\s*\/>/gi, '').trim();
|
|
660
|
-
|
|
661
|
-
const candidates = [];
|
|
662
|
-
const fenced = src.match(/```json\s*([\s\S]*?)```/i) || src.match(/```\s*([\s\S]*?)```/);
|
|
663
|
-
if (fenced) candidates.push(fenced[1]);
|
|
664
|
-
// 平衡花括号扫描:避免贪婪正则把多段 JSON / 散落的 { } 全包进去
|
|
665
|
-
const balancedFirst = extractBalancedJson(src);
|
|
666
|
-
if (balancedFirst) candidates.push(balancedFirst);
|
|
667
|
-
// 兜底:整段当 JSON 试
|
|
668
|
-
candidates.push(src);
|
|
669
|
-
|
|
670
|
-
let lastErr = null;
|
|
671
|
-
for (const raw of candidates) {
|
|
672
|
-
const txt = String(raw || '').trim();
|
|
673
|
-
if (!txt) continue;
|
|
674
|
-
// ① 直 parse
|
|
675
|
-
try { return { parsed: JSON.parse(txt), parseError: '', parseStage: '' }; }
|
|
676
|
-
catch (e) { lastErr = e; }
|
|
677
|
-
// ② 清洗:去 //…/* */ 注释 + 尾随逗号
|
|
678
|
-
const cleaned = txt
|
|
679
|
-
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
680
|
-
.replace(/(^|[^:"'\\])\/\/[^\n]*/g, '$1')
|
|
681
|
-
.replace(/,(\s*[}\]])/g, '$1');
|
|
682
|
-
try { return { parsed: JSON.parse(cleaned), parseError: '', parseStage: 'cleaned' }; }
|
|
683
|
-
catch (e) { lastErr = e; }
|
|
684
|
-
// ③ 平衡花括号扫描
|
|
685
|
-
const balanced = extractBalancedJson(cleaned);
|
|
686
|
-
if (balanced && balanced !== cleaned) {
|
|
687
|
-
try { return { parsed: JSON.parse(balanced), parseError: '', parseStage: 'balanced' }; }
|
|
688
|
-
catch (e) { lastErr = e; }
|
|
689
|
-
}
|
|
690
|
-
// ④ 启发式转义字符串内的未转义双引号
|
|
691
|
-
const base = balanced || cleaned;
|
|
692
|
-
const reescaped = reescapeUnescapedQuotes(base);
|
|
693
|
-
if (reescaped && reescaped !== base) {
|
|
694
|
-
try { return { parsed: JSON.parse(reescaped), parseError: '', parseStage: 'reescaped' }; }
|
|
695
|
-
catch (e) { lastErr = e; }
|
|
696
|
-
}
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
return {
|
|
700
|
-
parsed: null,
|
|
701
|
-
parseError: lastErr ? (lastErr.message || String(lastErr)) : '未能从模型输出中提取出 JSON',
|
|
702
|
-
parseStage: 'failed'
|
|
703
|
-
};
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
// 从字符串中提取首个语法平衡的 { ... } 子串。
|
|
707
|
-
// 跟踪字符串字面量(含转义),避免把 desc 里的 } 当成结束。
|
|
708
|
-
function extractBalancedJson(text) {
|
|
709
|
-
const s = String(text || '');
|
|
710
|
-
const start = s.indexOf('{');
|
|
711
|
-
if (start < 0) return '';
|
|
712
|
-
let depth = 0;
|
|
713
|
-
let inStr = false;
|
|
714
|
-
let strCh = '';
|
|
715
|
-
let esc = false;
|
|
716
|
-
for (let i = start; i < s.length; i++) {
|
|
717
|
-
const c = s[i];
|
|
718
|
-
if (inStr) {
|
|
719
|
-
if (esc) { esc = false; continue; }
|
|
720
|
-
if (c === '\\') { esc = true; continue; }
|
|
721
|
-
if (c === strCh) { inStr = false; }
|
|
722
|
-
continue;
|
|
723
|
-
}
|
|
724
|
-
if (c === '"' || c === "'") { inStr = true; strCh = c; continue; }
|
|
725
|
-
if (c === '{') depth++;
|
|
726
|
-
else if (c === '}') {
|
|
727
|
-
depth--;
|
|
728
|
-
if (depth === 0) return s.slice(start, i + 1);
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
return '';
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
// ④ 级降级:把字符串字面量内部出现的、未转义的 ASCII 双引号自动转义。
|
|
735
|
-
// 实战中模型最常见的错误是 "desc": "...用户点击"保存"按钮..." 这种—
|
|
736
|
-
// 中间的 "保存" 把外层字符串截断成两段,后面变成裸文本,JSON.parse 必崩。
|
|
15
|
+
// 工作台后端路由入口。
|
|
737
16
|
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
// -
|
|
741
|
-
//
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
out.push(c);
|
|
753
|
-
if (c === '"' || c === "'") { inStr = true; strCh = c; }
|
|
754
|
-
continue;
|
|
755
|
-
}
|
|
756
|
-
// 字符串内部
|
|
757
|
-
if (esc) { out.push(c); esc = false; continue; }
|
|
758
|
-
if (c === '\\') { out.push(c); esc = true; continue; }
|
|
759
|
-
if (c !== strCh) { out.push(c); continue; }
|
|
760
|
-
// 遇到与开闭引号相同的字符——往后看下一个非空白
|
|
761
|
-
let j = i + 1;
|
|
762
|
-
while (j < s.length && (s[j] === ' ' || s[j] === '\t')) j++;
|
|
763
|
-
const next = j < s.length ? s[j] : '';
|
|
764
|
-
if (next === '' || next === ',' || next === '}' || next === ']'
|
|
765
|
-
|| next === ':' || next === '\n' || next === '\r') {
|
|
766
|
-
// 真闭合
|
|
767
|
-
out.push(c);
|
|
768
|
-
inStr = false;
|
|
769
|
-
strCh = '';
|
|
770
|
-
} else {
|
|
771
|
-
// 字面量裸引号——转义
|
|
772
|
-
out.push('\\', c);
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
return out.join('');
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
function nowIso() {
|
|
779
|
-
return new Date().toISOString();
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
function genId() {
|
|
783
|
-
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
async function ensureDataDir() {
|
|
787
|
-
await fsp.mkdir(DATA_DIR, { recursive: true });
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
async function readJson(file, fallback) {
|
|
791
|
-
try {
|
|
792
|
-
const buf = await fsp.readFile(file, 'utf-8');
|
|
793
|
-
return JSON.parse(buf);
|
|
794
|
-
} catch (err) {
|
|
795
|
-
if (err && err.code === 'ENOENT') return fallback;
|
|
796
|
-
throw err;
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
async function writeJson(file, data) {
|
|
801
|
-
await ensureDataDir();
|
|
802
|
-
const tmp = `${file}.tmp`;
|
|
803
|
-
await fsp.writeFile(tmp, JSON.stringify(data, null, 2), 'utf-8');
|
|
804
|
-
await fsp.rename(tmp, file);
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
// ── 执行日志持久化 ────────────────────────────────────────────
|
|
808
|
-
// 设计要点:
|
|
809
|
-
// - 写盘在流式 chunk 阶段走 1.5s debounce;终态时(finally / cancel)强制 flush。
|
|
810
|
-
// - 永不持久化 child 引用(参考 cancel 路由的浅拷贝模式)。
|
|
811
|
-
// - hydrate 时把 running/pending 降级为 error:原 child 进程已不存在。
|
|
812
|
-
// - enforceRetention 在每次落盘后跑,按 endedAt desc FIFO 裁剪。
|
|
813
|
-
|
|
814
|
-
function serializeJob(j, taskMap) {
|
|
815
|
-
// child 是 ChildProcess 引用,序列化会爆;剥离后 size 用三字段累加预计算
|
|
816
|
-
const { child, ...rest } = j
|
|
817
|
-
const t = taskMap ? taskMap.get(rest.taskId) : null
|
|
818
|
-
const sub = t && Array.isArray(t.subtasks) ? t.subtasks.find(s => s.id === rest.subId) : null
|
|
819
|
-
const size = ((rest.prompt || '').length
|
|
820
|
-
+ (rest.output || '').length
|
|
821
|
-
+ (rest.thinking || '').length)
|
|
822
|
-
return {
|
|
823
|
-
...rest,
|
|
824
|
-
taskTitle: t ? t.title : '',
|
|
825
|
-
subTitle: sub ? sub.title : '',
|
|
826
|
-
size
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
function scheduleJobsSave() {
|
|
831
|
-
if (jobsSaveTimer) clearTimeout(jobsSaveTimer)
|
|
832
|
-
jobsSaveTimer = setTimeout(() => {
|
|
833
|
-
jobsSaveTimer = null
|
|
834
|
-
flushJobsSaveNow().catch(err => logger.warn('[workbench] jobs save failed:', err.message))
|
|
835
|
-
}, JOBS_SAVE_DEBOUNCE_MS)
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
async function flushJobsSaveNow() {
|
|
839
|
-
if (jobsSaveTimer) { clearTimeout(jobsSaveTimer); jobsSaveTimer = null }
|
|
840
|
-
// 读 tasks.json 给落盘 job 反范式 taskTitle/subTitle——父任务被删后管理页仍可读
|
|
841
|
-
const tasksData = await readJson(TASKS_FILE, { tasks: [] })
|
|
842
|
-
const taskMap = new Map((tasksData.tasks || []).map(t => [t.id, t]))
|
|
843
|
-
const payload = {
|
|
844
|
-
version: 1,
|
|
845
|
-
jobs: Array.from(jobs.values()).map(j => serializeJob(j, taskMap))
|
|
846
|
-
}
|
|
847
|
-
await writeJson(JOBS_FILE, payload)
|
|
848
|
-
await enforceRetention()
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
async function readJobsConfig() {
|
|
852
|
-
const cfg = await readJson(JOBS_CONFIG_FILE, null)
|
|
853
|
-
if (!cfg || typeof cfg !== 'object') return { ...DEFAULT_JOBS_CONFIG }
|
|
854
|
-
return {
|
|
855
|
-
maxCount: Number.isFinite(cfg.maxCount) ? Math.max(0, Math.floor(cfg.maxCount)) : DEFAULT_JOBS_CONFIG.maxCount,
|
|
856
|
-
maxSizeMB: Number.isFinite(cfg.maxSizeMB) ? Math.max(0, Math.floor(cfg.maxSizeMB)) : DEFAULT_JOBS_CONFIG.maxSizeMB
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
async function writeJobsConfig(cfg) {
|
|
861
|
-
// 校验:非负整数;硬上限防误填爆盘
|
|
862
|
-
const out = {}
|
|
863
|
-
if (cfg.maxCount !== undefined) {
|
|
864
|
-
const n = Math.floor(Number(cfg.maxCount))
|
|
865
|
-
if (!Number.isFinite(n) || n < 0 || n > 10000) throw new Error('maxCount 必须在 0-10000 之间')
|
|
866
|
-
out.maxCount = n
|
|
867
|
-
}
|
|
868
|
-
if (cfg.maxSizeMB !== undefined) {
|
|
869
|
-
const n = Math.floor(Number(cfg.maxSizeMB))
|
|
870
|
-
if (!Number.isFinite(n) || n < 0 || n > 10240) throw new Error('maxSizeMB 必须在 0-10240 之间')
|
|
871
|
-
out.maxSizeMB = n
|
|
872
|
-
}
|
|
873
|
-
const merged = { ...(await readJobsConfig()), ...out }
|
|
874
|
-
await writeJson(JOBS_CONFIG_FILE, merged)
|
|
875
|
-
return merged
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
// 进程启动时把磁盘上的历史拉回内存 Map;陈旧的 running/pending 强制降级。
|
|
879
|
-
// 陈旧 job 的 child 进程已退出,标记为 error 方便用户识别。
|
|
880
|
-
async function hydrateJobs() {
|
|
881
|
-
let data
|
|
882
|
-
try {
|
|
883
|
-
data = await readJson(JOBS_FILE, null)
|
|
884
|
-
} catch (err) {
|
|
885
|
-
// 损坏文件:改名备份避免下次 flush 静默覆盖用户数据
|
|
886
|
-
logger.warn('[workbench] jobs.json 解析失败,备份原文件后重置:', err.message)
|
|
887
|
-
try { await fsp.rename(JOBS_FILE, `${JOBS_FILE}.bak-${Date.now()}`) } catch { /* 文件可能已不在 */ }
|
|
888
|
-
return
|
|
889
|
-
}
|
|
890
|
-
if (!data || !Array.isArray(data.jobs)) return
|
|
891
|
-
for (const j of data.jobs) {
|
|
892
|
-
if (j.status === 'running' || j.status === 'pending') {
|
|
893
|
-
j.status = 'error'
|
|
894
|
-
j.error = (j.error || '') + ' [重启后回收:原进程已退出]'
|
|
895
|
-
j.endedAt = j.endedAt || nowIso()
|
|
896
|
-
j.exitCode = typeof j.exitCode === 'number' ? j.exitCode : 1
|
|
897
|
-
}
|
|
898
|
-
// 旧版本可能没 size 字段;补齐以兼容历史文件
|
|
899
|
-
if (typeof j.size !== 'number') {
|
|
900
|
-
j.size = ((j.prompt || '').length + (j.output || '').length + (j.thinking || '').length)
|
|
901
|
-
}
|
|
902
|
-
jobs.set(j.id, j)
|
|
903
|
-
}
|
|
904
|
-
// 启动后也跑一遍保留策略,让历史文件立刻缩到当前配置
|
|
905
|
-
try { await enforceRetention() } catch (err) { logger.warn('[workbench] 启动时 enforceRetention 失败:', err.message) }
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
// 保留策略:按 endedAt desc(fallback startedAt / id)排序,先按 maxCount 截,
|
|
909
|
-
// 再按 maxSizeMB 累计裁,淘汰同步从内存 Map 删除。
|
|
910
|
-
async function enforceRetention() {
|
|
911
|
-
const cfg = await readJobsConfig()
|
|
912
|
-
const data = await readJson(JOBS_FILE, { version: 1, jobs: [] })
|
|
913
|
-
if (!data || !Array.isArray(data.jobs) || data.jobs.length === 0) return
|
|
914
|
-
const sortKey = (j) => j.endedAt || j.startedAt || j.id || ''
|
|
915
|
-
data.jobs.sort((a, b) => sortKey(b).localeCompare(sortKey(a)))
|
|
916
|
-
if (cfg.maxCount > 0) data.jobs = data.jobs.slice(0, cfg.maxCount)
|
|
917
|
-
if (cfg.maxSizeMB > 0) {
|
|
918
|
-
const cap = cfg.maxSizeMB * 1024 * 1024
|
|
919
|
-
let total = data.jobs.reduce((s, j) => s + (j.size || 0), 0)
|
|
920
|
-
while (total > cap && data.jobs.length > 1) {
|
|
921
|
-
const dropped = data.jobs.pop()
|
|
922
|
-
total -= (dropped && dropped.size) || 0
|
|
923
|
-
}
|
|
924
|
-
}
|
|
925
|
-
await writeJson(JOBS_FILE, data)
|
|
926
|
-
const keepIds = new Set(data.jobs.map(j => j.id))
|
|
927
|
-
for (const id of Array.from(jobs.keys())) {
|
|
928
|
-
if (!keepIds.has(id)) jobs.delete(id)
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
// 简单的 Mustache 风格变量插值:{{task.title}} / {{task.desc}} / {{repo.path}} / {{branch}}
|
|
933
|
-
function interpolate(template, ctx) {
|
|
934
|
-
if (typeof template !== 'string') return template;
|
|
935
|
-
return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, key) => {
|
|
936
|
-
const parts = key.split('.');
|
|
937
|
-
let cur = ctx;
|
|
938
|
-
for (const p of parts) {
|
|
939
|
-
if (cur == null) return '';
|
|
940
|
-
cur = cur[p];
|
|
941
|
-
}
|
|
942
|
-
return cur == null ? '' : String(cur);
|
|
943
|
-
});
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
// ── 进程表:记录每个子任务的运行状态 ──────────────────────────────────────
|
|
947
|
-
const bus = new EventEmitter();
|
|
948
|
-
const jobs = new Map(); // jobId -> { id, taskId, subId, status, pid, startedAt, endedAt, exitCode, error, prompt }
|
|
949
|
-
// 被用户主动取消的 jobId 集合——runTaskQueue 在 waitProcessExit 之后检查这个集合
|
|
950
|
-
// 来决定把 job 标为 'cancelled' 还是 'done'。
|
|
951
|
-
// 用 Set 而不是 job.cancelled 标志,是为了在 SIGTERM 发出后到 child 真正退出之间
|
|
952
|
-
// 有一个简洁的"待回收"窗口。
|
|
953
|
-
const cancelledJobs = new Set();
|
|
954
|
-
// 启动时从磁盘拉回历史 job(陈旧 running/pending 自动降级 error)
|
|
955
|
-
hydrateJobs().catch(err => logger.warn('[workbench] hydrate jobs failed:', err.message))
|
|
956
|
-
|
|
957
|
-
// ── 生成指令持久化(~/.zen-gitsync/ai-instruction.json) ────────────────────
|
|
958
|
-
async function readInstruction() {
|
|
959
|
-
try {
|
|
960
|
-
const buf = await fsp.readFile(INSTRUCTION_FILE, 'utf-8');
|
|
961
|
-
const obj = JSON.parse(buf);
|
|
962
|
-
if (obj && typeof obj.instruction === 'string' && obj.instruction.trim()) {
|
|
963
|
-
return obj.instruction;
|
|
964
|
-
}
|
|
965
|
-
} catch { /* 文件不存在或解析失败 */ }
|
|
966
|
-
return DEFAULT_INSTRUCTION;
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
async function writeInstruction(instruction) {
|
|
970
|
-
await ensureDataDir();
|
|
971
|
-
const text = String(instruction || '').trim() || DEFAULT_INSTRUCTION;
|
|
972
|
-
const tmp = `${INSTRUCTION_FILE}.tmp`;
|
|
973
|
-
await fsp.writeFile(tmp, JSON.stringify({ instruction: text, updatedAt: nowIso() }, null, 2), 'utf-8');
|
|
974
|
-
await fsp.rename(tmp, INSTRUCTION_FILE);
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
// AI 拆分子任务的指令:与生成项目架构说明的指令分开持久化,
|
|
978
|
-
// 因为它面向的输出形态(subtask 列表)和任务粒度完全不同。
|
|
979
|
-
// 支持传入 req:根据 Accept-Language 选默认(zh/en)
|
|
980
|
-
async function readSubtaskInstruction(req) {
|
|
981
|
-
try {
|
|
982
|
-
const buf = await fsp.readFile(SUBTASK_INSTRUCTION_FILE, 'utf-8');
|
|
983
|
-
const obj = JSON.parse(buf);
|
|
984
|
-
if (obj && typeof obj.instruction === 'string' && obj.instruction.trim()) {
|
|
985
|
-
return obj.instruction;
|
|
986
|
-
}
|
|
987
|
-
} catch { /* 文件不存在或解析失败 */ }
|
|
988
|
-
return pickDefaultSubtaskInstruction(req);
|
|
989
|
-
}
|
|
990
|
-
async function writeSubtaskInstruction(instruction) {
|
|
991
|
-
await ensureDataDir();
|
|
992
|
-
// 写入时如果与当前 locale 默认一致,不写文件——这样前端"isDefault"判定永远准确
|
|
993
|
-
const text = String(instruction || '').trim() || DEFAULT_SUBTASK_INSTRUCTION_ZH;
|
|
994
|
-
const tmp = `${SUBTASK_INSTRUCTION_FILE}.tmp`;
|
|
995
|
-
await fsp.writeFile(tmp, JSON.stringify({ instruction: text, updatedAt: nowIso() }, null, 2), 'utf-8');
|
|
996
|
-
await fsp.rename(tmp, SUBTASK_INSTRUCTION_FILE);
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
// ── 子项目识别:递归找 .git / manifest;A 是 B 的祖先时只保留 B ─────────────
|
|
1000
|
-
async function findSubProjects(projectPath, opts = {}) {
|
|
1001
|
-
const { maxDepth = 4 } = opts;
|
|
1002
|
-
const candidates = [];
|
|
1003
|
-
|
|
1004
|
-
async function walk(dir, depth) {
|
|
1005
|
-
if (depth > maxDepth) return;
|
|
1006
|
-
let entries;
|
|
1007
|
-
try { entries = await fsp.readdir(dir, { withFileTypes: true }); }
|
|
1008
|
-
catch { return; }
|
|
1009
|
-
|
|
1010
|
-
let hasManifest = false;
|
|
1011
|
-
let hasGit = false;
|
|
1012
|
-
const subDirs = [];
|
|
1013
|
-
for (const e of entries) {
|
|
1014
|
-
if (!e.isDirectory() && !e.isFile()) continue;
|
|
1015
|
-
if (e.name.startsWith('.')) {
|
|
1016
|
-
if (e.name === '.git' && e.isDirectory()) hasGit = true;
|
|
1017
|
-
continue;
|
|
1018
|
-
}
|
|
1019
|
-
if (e.isDirectory()) {
|
|
1020
|
-
if (SKIP_DIRS.has(e.name)) continue;
|
|
1021
|
-
subDirs.push(path.join(dir, e.name));
|
|
1022
|
-
} else if (e.isFile() && MANIFEST_FILES.includes(e.name)) {
|
|
1023
|
-
hasManifest = true;
|
|
1024
|
-
}
|
|
1025
|
-
}
|
|
1026
|
-
if (hasManifest || hasGit) {
|
|
1027
|
-
candidates.push(dir);
|
|
1028
|
-
return; // 子目录里若还有 manifest,会被自己发现;这里不再下钻避免冗余
|
|
1029
|
-
}
|
|
1030
|
-
if (depth >= maxDepth) return;
|
|
1031
|
-
for (const sub of subDirs) {
|
|
1032
|
-
await walk(sub, depth + 1);
|
|
1033
|
-
}
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
await walk(projectPath, 0);
|
|
1037
|
-
|
|
1038
|
-
// 去重:若 candidates 里 A 是 B 的祖先,只保留更深一级的 B
|
|
1039
|
-
candidates.sort((a, b) => a.length - b.length);
|
|
1040
|
-
const kept = [];
|
|
1041
|
-
for (const c of candidates) {
|
|
1042
|
-
let dominated = false;
|
|
1043
|
-
for (const k of kept) {
|
|
1044
|
-
if (c === k || c.startsWith(k + path.sep)) { dominated = true; break; }
|
|
1045
|
-
}
|
|
1046
|
-
if (!dominated) kept.push(c);
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
// 收集每个子项目的关键文件
|
|
1050
|
-
const result = [];
|
|
1051
|
-
for (const root of kept) {
|
|
1052
|
-
const manifests = {};
|
|
1053
|
-
for (const m of MANIFEST_FILES) {
|
|
1054
|
-
const p = path.join(root, m);
|
|
1055
|
-
try {
|
|
1056
|
-
const stat = await fsp.stat(p);
|
|
1057
|
-
if (stat.isFile()) {
|
|
1058
|
-
manifests[m] = stat.size > 20000
|
|
1059
|
-
? await safeReadFile(p, 20000)
|
|
1060
|
-
: await fsp.readFile(p, 'utf8');
|
|
1061
|
-
}
|
|
1062
|
-
} catch { /* 不存在就跳过 */ }
|
|
1063
|
-
}
|
|
1064
|
-
let readme = '';
|
|
1065
|
-
try {
|
|
1066
|
-
const stat = await fsp.stat(path.join(root, 'README.md'));
|
|
1067
|
-
if (stat.isFile()) readme = await safeReadFile(path.join(root, 'README.md'), 8000);
|
|
1068
|
-
} catch { /* 不存在就跳过 */ }
|
|
1069
|
-
const dirTree = await listDirTree(root, 2, 200);
|
|
1070
|
-
result.push({
|
|
1071
|
-
root,
|
|
1072
|
-
name: path.basename(root) || path.basename(projectPath),
|
|
1073
|
-
manifests,
|
|
1074
|
-
readme,
|
|
1075
|
-
dirTree
|
|
1076
|
-
});
|
|
1077
|
-
}
|
|
1078
|
-
return result;
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
function publish(event, payload) {
|
|
1082
|
-
bus.emit('event', { event, payload, ts: nowIso() });
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
function snapshotJobs() {
|
|
1086
|
-
return Array.from(jobs.values()).map(j => ({
|
|
1087
|
-
id: j.id,
|
|
1088
|
-
taskId: j.taskId,
|
|
1089
|
-
subId: j.subId,
|
|
1090
|
-
title: j.title,
|
|
1091
|
-
status: j.status,
|
|
1092
|
-
prompt: j.prompt || '',
|
|
1093
|
-
output: j.output || '',
|
|
1094
|
-
pid: j.pid || null,
|
|
1095
|
-
startedAt: j.startedAt || null,
|
|
1096
|
-
endedAt: j.endedAt || null,
|
|
1097
|
-
exitCode: typeof j.exitCode === 'number' ? j.exitCode : null,
|
|
1098
|
-
error: j.error || null,
|
|
1099
|
-
// 续接对话用:claude --output-format stream-json 的 system.init 事件捕获到的 session_id
|
|
1100
|
-
claudeSessionId: j.claudeSessionId || null
|
|
1101
|
-
}));
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
// 用 detached 进程跑 claude;进程退出时回填状态。
|
|
1105
|
-
// 返回 { pid, child }:调用方可以监听 child.stdout/stderr 实时收集输出。
|
|
1106
|
-
// 不再走 cmd /k 弹窗——claude -p 是非交互模式,输出通过 stdout pipe 实时回传
|
|
1107
|
-
// 到前端面板展示。
|
|
1108
|
-
function launchClaudeInNewWindow(cwd, promptText, resumeSessionId) {
|
|
1109
|
-
return new Promise((resolve, reject) => {
|
|
1110
|
-
const args = [];
|
|
1111
|
-
// 续接历史会话:--resume 必须放在 -p 之前,确保 claude CLI 先识别 resume 上下文
|
|
1112
|
-
if (resumeSessionId) {
|
|
1113
|
-
args.push('--resume', String(resumeSessionId));
|
|
1114
|
-
}
|
|
1115
|
-
args.push(
|
|
1116
|
-
// -p '-' 让 claude CLI 从 stdin 读取 prompt —— 避免 Windows 命令行 32K 长度上限
|
|
1117
|
-
// (spawn argv 会拼成 cmdline 给 CreateProcess,长 prompt 直接 ENAMETOOLONG)
|
|
1118
|
-
'-p', '-',
|
|
1119
|
-
'--output-format', 'stream-json',
|
|
1120
|
-
'--verbose',
|
|
1121
|
-
'--permission-mode', 'bypassPermissions',
|
|
1122
|
-
'--dangerously-skip-permissions'
|
|
1123
|
-
);
|
|
1124
|
-
let child;
|
|
1125
|
-
let spawnedExe = 'claude';
|
|
1126
|
-
if (process.platform === 'win32') {
|
|
1127
|
-
// 直接 spawn claude.exe(npm 全局 @anthropic-ai/claude-code 里的真实二进制),
|
|
1128
|
-
// 避开两件事:
|
|
1129
|
-
// 1. Node 23 在 Windows 上拒绝 spawn .cmd/.bat(EINVAL)
|
|
1130
|
-
// 2. shell:true 会把 argv 拼成命令行交给 cmd 解释,prompt 里的 \n 被切成多段
|
|
1131
|
-
// 用 `where claude` 找到 claude.cmd,再从 cmd 内容推断对应 .exe 路径。
|
|
1132
|
-
let claudeExe = 'claude.exe';
|
|
1133
|
-
try {
|
|
1134
|
-
const cmdShim = execFileSync('where', ['claude'], { encoding: 'utf8' })
|
|
1135
|
-
.split(/\r?\n/).map(s => s.trim()).find(s => /\.cmd$/i.test(s));
|
|
1136
|
-
if (cmdShim) {
|
|
1137
|
-
const txt = fs.readFileSync(cmdShim, 'utf8');
|
|
1138
|
-
if (/%dp0%\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude\.exe/i.test(txt)) {
|
|
1139
|
-
claudeExe = path.join(path.dirname(cmdShim), 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe');
|
|
1140
|
-
}
|
|
1141
|
-
}
|
|
1142
|
-
} catch { /* fallback */ }
|
|
1143
|
-
spawnedExe = claudeExe;
|
|
1144
|
-
child = spawn(claudeExe, args, {
|
|
1145
|
-
cwd,
|
|
1146
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1147
|
-
windowsHide: false,
|
|
1148
|
-
env: { ...process.env, LANG: 'zh_CN.UTF-8' }
|
|
1149
|
-
});
|
|
1150
|
-
} else {
|
|
1151
|
-
// macOS / Linux:直接 spawn claude(Node spawn 不走 shell,
|
|
1152
|
-
// prompt 中的引号 / 反斜杠无需手动 escape)
|
|
1153
|
-
child = spawn('claude', args, {
|
|
1154
|
-
cwd,
|
|
1155
|
-
detached: true,
|
|
1156
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1157
|
-
env: { ...process.env, LANG: 'zh_CN.UTF-8' }
|
|
1158
|
-
});
|
|
1159
|
-
}
|
|
1160
|
-
child.on('error', reject);
|
|
1161
|
-
child.on('spawn', () => {
|
|
1162
|
-
// unref 让 claude 独立于父进程事件循环;返回 child 引用让调用方继续读 stdout。
|
|
1163
|
-
child.unref();
|
|
1164
|
-
// 长 prompt 通过 stdin 喂入(避开 Windows CreateProcess 32K 命令行上限)。
|
|
1165
|
-
// 必须在 spawn 事件回调里 write,而不是 resolve 前同步 write——因为 spawn
|
|
1166
|
-
// 返回时 child.stdin 句柄可能尚未绑定到真正的 pipe fd。
|
|
1167
|
-
// write 完成后必须 end(),否则 claude CLI 会一直阻塞在读 stdin 上 → hang。
|
|
1168
|
-
try {
|
|
1169
|
-
child.stdin.write(promptText, () => {
|
|
1170
|
-
try { child.stdin.end() } catch { /* 子进程已关闭,忽略 */ }
|
|
1171
|
-
});
|
|
1172
|
-
} catch (err) {
|
|
1173
|
-
// stdin 写入失败不要让 spawn 整体 reject —— 让 child 自然以错误状态收尾,
|
|
1174
|
-
// 后面的 stdout/stderr 监听会捕获到 LLM 端反馈。
|
|
1175
|
-
logger.warn('[workbench] stdin write failed:', err && err.message || err);
|
|
1176
|
-
}
|
|
1177
|
-
resolve({ pid: child.pid, child });
|
|
1178
|
-
});
|
|
1179
|
-
});
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
/**
|
|
1183
|
-
* 顺序执行一个任务下所有子任务;上一个结束再启动下一个。
|
|
1184
|
-
* 入参 fromIndex 指定从哪个 sub 开始(0-based,默认 0),用于"从此处开始"入口;
|
|
1185
|
-
* fromIndex>0 时,把 [0, fromIndex) 区间内已 done 的 sub 输出预填到 priorOutputs,
|
|
1186
|
-
* 这样后续 sub 仍能拿到前序上下文(跟单 sub 执行的语义保持一致)。
|
|
1187
|
-
*/
|
|
1188
|
-
async function runTaskQueue(task, repoPath, branch, opts) {
|
|
1189
|
-
// 前序上下文:跑完一个 sub 后把它"完成态"输出存到这里,下一个 sub 启动时
|
|
1190
|
-
// 拼到 prompt 头部,让 Claude 知道前面做了什么、产出了什么。
|
|
1191
|
-
// 现在 LLM 都是百万 token 上下文窗口,完整透传 raw output,不做截断——
|
|
1192
|
-
// 关键产物(生成的代码块、JSON、结论)在中间被砍掉反而会让后续 sub 失去依据。
|
|
1193
|
-
const requested = Number(opts && opts.fromIndex)
|
|
1194
|
-
const fromIndex = Number.isInteger(requested) && requested >= 0 && requested < task.subtasks.length
|
|
1195
|
-
? requested
|
|
1196
|
-
: 0
|
|
1197
|
-
// 从 fromIndex 开始时,把前面已 done 的 sub 输出预填进 priorOutputs,
|
|
1198
|
-
// 否则"从中间开始"会丢失前序上下文。
|
|
1199
|
-
const priorOutputs = fromIndex > 0
|
|
1200
|
-
? await collectPriorOutputsUpTo(task, fromIndex)
|
|
1201
|
-
: []
|
|
1202
|
-
// 连续模式(默认):任意 sub 终态非 done(cancelled / error)→ 整批停,后续 sub 保持 todo。
|
|
1203
|
-
// AI 拆出来的 sub 一般前后强依赖(前一步产出是后一步输入),出错就停下来让用户决策。
|
|
1204
|
-
// 关闭后回退旧行为:单个 sub 失败不影响后续 sub 继续跑。
|
|
1205
|
-
const sequential = task.sequential !== false
|
|
1206
|
-
for (let i = fromIndex; i < task.subtasks.length; i++) {
|
|
1207
|
-
const sub = task.subtasks[i]
|
|
1208
|
-
if (sub.status === 'done') continue;
|
|
1209
|
-
const outcome = await runSingleSubtask(task, sub, repoPath, branch, priorOutputs)
|
|
1210
|
-
// 逐 sub 落盘:之前只在队列跑完才 persistTaskAfterRun,中途崩溃会丢已完成
|
|
1211
|
-
// sub 的状态。runSingleSubtask 已经把 sub.status 改完,这里补一次落盘。
|
|
1212
|
-
await persistTaskAfterRun(task)
|
|
1213
|
-
if (sequential && outcome !== 'done') {
|
|
1214
|
-
// cancelled / error → 后续 sub 全部保持 todo,不再继续
|
|
1215
|
-
break
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
/**
|
|
1221
|
-
* 执行单个子任务。被 runTaskQueue(整批)和"单 sub 执行"endpoint 共用。
|
|
1222
|
-
*
|
|
1223
|
-
* @param {object} task 主任务对象
|
|
1224
|
-
* @param {object} sub 要跑的子任务
|
|
1225
|
-
* @param {string} repoPath 仓库路径
|
|
1226
|
-
* @param {string} branch 分支名(可空)
|
|
1227
|
-
* @param {Array<{title:string,output:string}>} priorOutputs
|
|
1228
|
-
* 前序 done 子任务的输出摘要(in-place 追加)。用于把同一任务下
|
|
1229
|
-
* 前面已完成的 sub 产物拼到当前 sub 的 prompt 头部,让 Claude
|
|
1230
|
-
* 知道上下文。单独跑一个 sub 时,这个数组里只会有"前面 done 的 sub"。
|
|
1231
|
-
* @param {object} [options]
|
|
1232
|
-
* @param {string|null} [options.resumeSessionId]
|
|
1233
|
-
* 续接历史 claude 会话:传入上一轮通过 stream-json 的 system.init
|
|
1234
|
-
* 事件捕获到的 session_id,本轮以 --resume <id> 启动,claude 会带着
|
|
1235
|
-
* 上下文继续对话。前端"简单任务执行完成后继续聊"走的就是这条路径。
|
|
1236
|
-
*/
|
|
1237
|
-
async function runSingleSubtask(task, sub, repoPath, branch, priorOutputs, options) {
|
|
1238
|
-
const opts = options || {}
|
|
1239
|
-
const resumeSessionId = opts.resumeSessionId || null
|
|
1240
|
-
const promptTemplate = sub.promptOverride || (task.promptId
|
|
1241
|
-
? (await readJson(PROMPTS_FILE, { prompts: [] })).prompts.find(p => p.id === task.promptId)?.content
|
|
1242
|
-
: null) || '';
|
|
1243
|
-
const ctx = {
|
|
1244
|
-
task: { title: task.title, desc: task.desc || '' },
|
|
1245
|
-
sub: { title: sub.title, desc: sub.desc || '' },
|
|
1246
|
-
repo: { path: repoPath || '' },
|
|
1247
|
-
branch: branch || ''
|
|
1248
|
-
};
|
|
1249
|
-
const interpolated = interpolate(promptTemplate, ctx);
|
|
1250
|
-
const parts = [interpolated, sub.title, sub.desc].filter(s => s && s.trim());
|
|
1251
|
-
let prompt = parts.join('\n\n');
|
|
1252
|
-
|
|
1253
|
-
// ── 前序上下文:把前几个 done 子任务的输出完整拼到 prompt 头部 ──
|
|
1254
|
-
// 完整透传,不做字符截断;只在输出真为空时跳过该条。
|
|
1255
|
-
if (priorOutputs && priorOutputs.length > 0) {
|
|
1256
|
-
const prevBlock = priorOutputs.map((p, i) => {
|
|
1257
|
-
return `### [${i + 1}] ${p.title}\n${p.output || ''}`
|
|
1258
|
-
}).join('\n\n')
|
|
1259
|
-
prompt = `以下是同一任务下已经完成的前序子任务输出(仅作上下文参考,请基于这些结论继续当前子任务,无需重复执行它们):
|
|
1260
|
-
|
|
1261
|
-
${prevBlock}
|
|
1262
|
-
|
|
1263
|
-
---
|
|
1264
|
-
|
|
1265
|
-
${prompt}`
|
|
1266
|
-
}
|
|
1267
|
-
|
|
1268
|
-
// ── 附件:合并 sub.attachments + task.attachments 后拼到 prompt 末尾 ──
|
|
1269
|
-
// claude -p 字符串模式会扫描 prompt 中出现的本地文件路径并自动
|
|
1270
|
-
// 识别为附件(图片 / PDF / 文本均可)。
|
|
1271
|
-
// 主任务附件对所有 sub 都可见;子任务自己的附件只对该 sub 可见。
|
|
1272
|
-
// 注意:run-simple 路径下 virtualSub.attachments 就是 task.attachments 的同一引用,
|
|
1273
|
-
// 不去重会把同一张图在 prompt 里列两遍。按 absolutePath 去重。
|
|
1274
|
-
const taskAtts = Array.isArray(task.attachments) ? task.attachments : [];
|
|
1275
|
-
const subAtts = Array.isArray(sub.attachments) ? sub.attachments : [];
|
|
1276
|
-
const seen = new Set();
|
|
1277
|
-
const allAttachments = [];
|
|
1278
|
-
for (const a of [...subAtts, ...taskAtts]) {
|
|
1279
|
-
if (!a || !a.absolutePath) continue;
|
|
1280
|
-
if (seen.has(a.absolutePath)) continue;
|
|
1281
|
-
seen.add(a.absolutePath);
|
|
1282
|
-
allAttachments.push(a);
|
|
1283
|
-
}
|
|
1284
|
-
if (allAttachments.length > 0) {
|
|
1285
|
-
const lines = allAttachments
|
|
1286
|
-
.filter(a => a && a.absolutePath)
|
|
1287
|
-
.map((a, i) => ` ${i + 1}. [${a.mimeType || 'application/octet-stream'}] ${a.absolutePath}`);
|
|
1288
|
-
if (lines.length > 0) {
|
|
1289
|
-
prompt += `\n\n---\n本任务包含 ${lines.length} 个附件(请按文件路径读取,不要让用户重新提供):\n${lines.join('\n')}\n---`;
|
|
1290
|
-
}
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
const jobId = genId();
|
|
1294
|
-
const job = {
|
|
1295
|
-
id: jobId,
|
|
1296
|
-
taskId: task.id,
|
|
1297
|
-
subId: sub.id,
|
|
1298
|
-
title: `${task.title} / ${sub.title}`,
|
|
1299
|
-
status: 'pending',
|
|
1300
|
-
prompt,
|
|
1301
|
-
// 续接历史会话时先填上,首条 system.init 事件回来后再用真值覆盖(通常等同)
|
|
1302
|
-
claudeSessionId: resumeSessionId
|
|
1303
|
-
};
|
|
1304
|
-
jobs.set(jobId, job);
|
|
1305
|
-
sub.status = 'running';
|
|
1306
|
-
publish('sub:update', { taskId: task.id, sub });
|
|
1307
|
-
publish('job:update', job);
|
|
1308
|
-
|
|
1309
|
-
try {
|
|
1310
|
-
const { pid, child } = await launchClaudeInNewWindow(repoPath || process.cwd(), prompt, resumeSessionId);
|
|
1311
|
-
job.pid = pid;
|
|
1312
|
-
// 保存 child 引用,供 cancel 接口调用 kill
|
|
1313
|
-
job.child = child;
|
|
1314
|
-
job.startedAt = nowIso();
|
|
1315
|
-
job.status = 'running';
|
|
1316
|
-
publish('job:update', job);
|
|
1317
|
-
|
|
1318
|
-
// 流式 NDJSON 解析:把 stdout 当作 stream-json 协议处理
|
|
1319
|
-
// assistant.text → job.output (用户主要关心的内容)
|
|
1320
|
-
// assistant.thinking → job.thinking (折叠展示,让用户知道 Claude 在想)
|
|
1321
|
-
// 其他事件(init / tool_use / result 等)忽略,避免噪声
|
|
1322
|
-
// 解析失败的行原样进 output,便于排查协议异常。
|
|
1323
|
-
// thinking 几乎不做服务端截断:Claude reasoning 一般在 KB~几十 MB 之间,
|
|
1324
|
-
// 100MB 兜底只是为了防止内存爆炸。流式 publish 时改推增量 delta(仅新拼接的
|
|
1325
|
-
// 那部分),终态或重连时才随 job:update 全量同步,避免每帧重复广播整个累积文本。
|
|
1326
|
-
const MAX_OUTPUT = 100 * 1024 * 1024;
|
|
1327
|
-
const MAX_THINKING = 100 * 1024 * 1024;
|
|
1328
|
-
job.output = '';
|
|
1329
|
-
job.thinking = '';
|
|
1330
|
-
const lineBuf = { stdout: '', stderr: '' };
|
|
1331
|
-
|
|
1332
|
-
const parseLines = (channel, buf) => {
|
|
1333
|
-
const chunk = buf.toString('utf8');
|
|
1334
|
-
lineBuf[channel] += chunk;
|
|
1335
|
-
const lines = lineBuf[channel].split('\n');
|
|
1336
|
-
lineBuf[channel] = lines.pop() ?? ''; // 最后一段可能不完整,留给下次
|
|
1337
|
-
let pendingThinkingDelta = '';
|
|
1338
|
-
for (const line of lines) {
|
|
1339
|
-
const trimmed = line.trim();
|
|
1340
|
-
if (!trimmed) continue;
|
|
1341
|
-
if (channel === 'stderr' || !trimmed.startsWith('{')) {
|
|
1342
|
-
// 非 stream-json 行:原样塞进 output(兼容老版本 claude / 错误信息)
|
|
1343
|
-
const prevLen = job.output.length;
|
|
1344
|
-
job.output = (job.output + trimmed + '\n').slice(-MAX_OUTPUT);
|
|
1345
|
-
// output 也用 delta 推送,前端按"以 length 为锚追加"语义合并
|
|
1346
|
-
const delta = job.output.slice(prevLen);
|
|
1347
|
-
if (delta) publish('job:output-delta', { id: job.id, delta });
|
|
1348
|
-
continue;
|
|
1349
|
-
}
|
|
1350
|
-
let evt;
|
|
1351
|
-
try { evt = JSON.parse(trimmed) } catch { continue }
|
|
1352
|
-
// 捕获 system.init 里的 session_id,供后续"续接对话"用(--resume <id>)。
|
|
1353
|
-
// 一个 claude -p 调用只在首行发一次,所以仅在 job 还没记到时写入。
|
|
1354
|
-
if (evt.type === 'system' && evt.subtype === 'init' && typeof evt.session_id === 'string') {
|
|
1355
|
-
if (!job.claudeSessionId || job.claudeSessionId !== evt.session_id) {
|
|
1356
|
-
job.claudeSessionId = evt.session_id;
|
|
1357
|
-
publish('job:update', job);
|
|
1358
|
-
}
|
|
1359
|
-
continue;
|
|
1360
|
-
}
|
|
1361
|
-
if (evt.type !== 'assistant') continue;
|
|
1362
|
-
const blocks = evt.message?.content;
|
|
1363
|
-
if (!Array.isArray(blocks)) continue;
|
|
1364
|
-
for (const b of blocks) {
|
|
1365
|
-
if (b.type === 'text' && typeof b.text === 'string') {
|
|
1366
|
-
const prevLen = job.output.length;
|
|
1367
|
-
job.output = (job.output + b.text).slice(-MAX_OUTPUT);
|
|
1368
|
-
const delta = job.output.slice(prevLen);
|
|
1369
|
-
if (delta) publish('job:output-delta', { id: job.id, delta });
|
|
1370
|
-
} else if (b.type === 'thinking' && typeof b.thinking === 'string') {
|
|
1371
|
-
const prevLen = job.thinking.length;
|
|
1372
|
-
job.thinking = (job.thinking + b.thinking).slice(-MAX_THINKING);
|
|
1373
|
-
const delta = job.thinking.slice(prevLen);
|
|
1374
|
-
if (delta) pendingThinkingDelta += delta;
|
|
1375
|
-
}
|
|
1376
|
-
}
|
|
1377
|
-
}
|
|
1378
|
-
// 一批 NDJSON 处理完后统一发一次 thinking delta,避免高频小块 socket 占用
|
|
1379
|
-
if (pendingThinkingDelta) {
|
|
1380
|
-
publish('job:thinking-delta', { id: job.id, delta: pendingThinkingDelta });
|
|
1381
|
-
}
|
|
1382
|
-
};
|
|
1383
|
-
if (child.stdout) child.stdout.on('data', (buf) => parseLines('stdout', buf));
|
|
1384
|
-
if (child.stderr) child.stderr.on('data', (buf) => parseLines('stderr', buf));
|
|
1385
|
-
|
|
1386
|
-
// 等待进程退出(detached 不阻塞主进程,用 polling /proc 兜底)
|
|
1387
|
-
await waitProcessExit(pid);
|
|
1388
|
-
const wasCancelled = cancelledJobs.has(jobId)
|
|
1389
|
-
if (wasCancelled) cancelledJobs.delete(jobId)
|
|
1390
|
-
// 进程退出时 stdout 可能残留最后一段未换行的 NDJSON,flush 一次
|
|
1391
|
-
// flush 内部也按 delta 推送,保持与流式阶段一致
|
|
1392
|
-
if (lineBuf.stdout.trim()) {
|
|
1393
|
-
const outPrev = job.output.length;
|
|
1394
|
-
const thinkPrev = job.thinking.length;
|
|
1395
|
-
try {
|
|
1396
|
-
const evt = JSON.parse(lineBuf.stdout.trim())
|
|
1397
|
-
if (evt.type === 'assistant' && Array.isArray(evt.message?.content)) {
|
|
1398
|
-
for (const b of evt.message.content) {
|
|
1399
|
-
if (b.type === 'text' && typeof b.text === 'string') {
|
|
1400
|
-
job.output = (job.output + b.text).slice(-MAX_OUTPUT)
|
|
1401
|
-
} else if (b.type === 'thinking' && typeof b.thinking === 'string') {
|
|
1402
|
-
job.thinking = (job.thinking + b.thinking).slice(-MAX_THINKING)
|
|
1403
|
-
}
|
|
1404
|
-
}
|
|
1405
|
-
}
|
|
1406
|
-
} catch { /* 不是 JSON,忽略 */ }
|
|
1407
|
-
const outDelta = job.output.slice(outPrev);
|
|
1408
|
-
if (outDelta) publish('job:output-delta', { id: job.id, delta: outDelta });
|
|
1409
|
-
const thinkDelta = job.thinking.slice(thinkPrev);
|
|
1410
|
-
if (thinkDelta) publish('job:thinking-delta', { id: job.id, delta: thinkDelta });
|
|
1411
|
-
}
|
|
1412
|
-
job.endedAt = nowIso();
|
|
1413
|
-
if (wasCancelled) {
|
|
1414
|
-
job.exitCode = 130; // 128 + SIGINT(2),约定俗成的"用户取消"退出码
|
|
1415
|
-
job.status = 'cancelled';
|
|
1416
|
-
job.error = '用户已停止执行';
|
|
1417
|
-
// 同步把闭包里的 sub 也置 cancelled(cancel 接口的 syncSubToCancelled 改了磁盘 task
|
|
1418
|
-
// 引用,但 runSingleSubtask 形参里的 sub 是另一份内存引用)。否则 finally publish sub:update
|
|
1419
|
-
// 会用 status='running' 覆盖掉前端已渲染的 cancelled 状态,导致 UI 反复跳回 running。
|
|
1420
|
-
// 同时 sequential=true 时 runTaskQueue 会看到 outcome='cancelled',break 不再跑后续 sub。
|
|
1421
|
-
sub.status = 'cancelled';
|
|
1422
|
-
if (!sub.error) sub.error = '用户已停止执行';
|
|
1423
|
-
} else {
|
|
1424
|
-
job.exitCode = 0;
|
|
1425
|
-
job.status = 'done';
|
|
1426
|
-
sub.status = 'done';
|
|
1427
|
-
// 把这个 sub 的输出累积到前序上下文,喂给下一个 sub
|
|
1428
|
-
if (priorOutputs) priorOutputs.push({ title: sub.title, output: job.output || '' })
|
|
1429
|
-
}
|
|
1430
|
-
} catch (err) {
|
|
1431
|
-
const errMsg = err && err.message ? err.message : String(err);
|
|
1432
|
-
job.error = errMsg;
|
|
1433
|
-
job.status = 'error';
|
|
1434
|
-
sub.status = 'error';
|
|
1435
|
-
sub.error = errMsg;
|
|
1436
|
-
sub.errorAt = nowIso();
|
|
1437
|
-
} finally {
|
|
1438
|
-
// 移除 child 引用——避免后续被 SSE 序列化到前端
|
|
1439
|
-
delete job.child
|
|
1440
|
-
publish('job:update', job);
|
|
1441
|
-
publish('sub:update', { taskId: task.id, sub });
|
|
1442
|
-
// 终态:await 同步落盘,确保 done/cancelled/error 全部立即归档。
|
|
1443
|
-
// 历史上这里用 fire-and-forget(`.catch(err => ...)`) → runSingleSubtask 返回时
|
|
1444
|
-
// flushJobsSaveNow 还在写盘 microtask 里 → runTaskQueue 进入下一个 sub →
|
|
1445
|
-
// 如果用户此时调了 clearJobsByTask / 切页面 / 后端退出 → 写盘未完成就丢 job
|
|
1446
|
-
// (但 sub.status=done 已经被 publish 出去 → 磁盘状态 = "sub done 但无 job",
|
|
1447
|
-
// 跟用户截图里的"6 个 done 但 jobs.json 全空"现象吻合)。
|
|
1448
|
-
// 改 await 后:runSingleSubtask 返回时,本 sub 的 job 已经落盘 → 安全。
|
|
1449
|
-
// 出错也只是 log warn,不会抛出影响后续 sub 的执行。
|
|
1450
|
-
try {
|
|
1451
|
-
await flushJobsSaveNow()
|
|
1452
|
-
} catch (err) {
|
|
1453
|
-
logger.warn('[workbench] flushJobsSaveNow failed (job id=' + job.id + ', status=' + job.status + '):', err && err.message || err)
|
|
1454
|
-
}
|
|
1455
|
-
}
|
|
1456
|
-
// 把 sub 的终态返回给 runTaskQueue,用于「连续模式」判断要不要 break 整批队列
|
|
1457
|
-
return job.status // 'done' | 'cancelled' | 'error'
|
|
1458
|
-
}
|
|
1459
|
-
|
|
1460
|
-
/**
|
|
1461
|
-
* 把被取消的 sub 同步置 'cancelled' 并落盘。
|
|
1462
|
-
* cancelJob 路径专用:前端 taskIsRunning/sub.is-running 都看 sub.status,不改就会出现
|
|
1463
|
-
* "主任务黄点 + sub running 动效 + 右侧执行完成"三处不一致。
|
|
1464
|
-
*
|
|
1465
|
-
* 简单任务的虚拟 subId 不在 tasks.json 里(task.subtasks 是 complex 才有),所以这里
|
|
1466
|
-
* 找不到 sub 时静默返回;简单任务的 running 状态由 job 数组单独维护(见 taskIsRunning)。
|
|
1467
|
-
*
|
|
1468
|
-
* @returns {{ taskId: string, sub: object } | null} 找到并更新时返回新 sub,否则 null
|
|
1469
|
-
*/
|
|
1470
|
-
async function syncSubToCancelled(job) {
|
|
1471
|
-
if (!job || !job.taskId || !job.subId) return null
|
|
1472
|
-
const data = await readJson(TASKS_FILE, { tasks: [] })
|
|
1473
|
-
const task = (data.tasks || []).find(x => x.id === job.taskId)
|
|
1474
|
-
if (!task || !Array.isArray(task.subtasks)) return null
|
|
1475
|
-
const sub = task.subtasks.find(s => s && s.id === job.subId)
|
|
1476
|
-
if (!sub) return null
|
|
1477
|
-
if (sub.status === 'cancelled') return { taskId: task.id, sub } // 已置过,幂等返回
|
|
1478
|
-
sub.status = 'cancelled'
|
|
1479
|
-
sub.error = '用户已停止执行'
|
|
1480
|
-
sub.errorAt = nowIso()
|
|
1481
|
-
task.updatedAt = nowIso()
|
|
1482
|
-
await writeJson(TASKS_FILE, data)
|
|
1483
|
-
publish('sub:update', { taskId: task.id, sub })
|
|
1484
|
-
publish('task:update', task)
|
|
1485
|
-
return { taskId: task.id, sub }
|
|
1486
|
-
}
|
|
1487
|
-
|
|
1488
|
-
/** 把 task.subtasks 写回 tasks.json,并广播 task:update。runTaskQueue 和"单 sub 执行"共用。 */
|
|
1489
|
-
async function persistTaskAfterRun(task) {
|
|
1490
|
-
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
1491
|
-
const t = data.tasks.find(x => x.id === task.id);
|
|
1492
|
-
if (t) {
|
|
1493
|
-
// 仅同步 status 之外的 error/errorAt 字段,避免覆盖用户编辑过的 title/desc 等。
|
|
1494
|
-
// 历史 subtasks 持久化只写 status → 失败信息丢,hover "执行出错" 标签看不到原因。
|
|
1495
|
-
const newMap = new Map(task.subtasks.map(s => [s.id, s]));
|
|
1496
|
-
t.subtasks = (t.subtasks || []).map(old => {
|
|
1497
|
-
const fresh = newMap.get(old.id);
|
|
1498
|
-
if (!fresh) return old;
|
|
1499
|
-
return {
|
|
1500
|
-
...old,
|
|
1501
|
-
status: fresh.status ?? old.status,
|
|
1502
|
-
error: fresh.error ?? old.error,
|
|
1503
|
-
errorAt: fresh.errorAt ?? old.errorAt,
|
|
1504
|
-
};
|
|
1505
|
-
});
|
|
1506
|
-
t.updatedAt = nowIso();
|
|
1507
|
-
await writeJson(TASKS_FILE, data);
|
|
1508
|
-
publish('task:update', t);
|
|
1509
|
-
}
|
|
1510
|
-
}
|
|
1511
|
-
|
|
1512
|
-
/**
|
|
1513
|
-
* 构建单 sub 执行时的 priorOutputs:把同一 task 下"排在当前 sub 之前"且已 done 的
|
|
1514
|
-
* 子任务输出摘要收集起来。这样单独跑一个 sub 时,它也能拿到前序上下文。
|
|
1515
|
-
*/
|
|
1516
|
-
async function collectPriorOutputs(task, targetSub) {
|
|
1517
|
-
const targetIdx = task.subtasks.findIndex(s => s.id === targetSub.id)
|
|
1518
|
-
if (targetIdx < 0) return []
|
|
1519
|
-
return collectPriorOutputsUpTo(task, targetIdx)
|
|
1520
|
-
}
|
|
1521
|
-
|
|
1522
|
-
/**
|
|
1523
|
-
* 收集 [0, endIdx) 区间内已 done 的 sub 输出摘要,作为队列内 sub 的前序上下文。
|
|
1524
|
-
* runTaskQueue 在 fromIndex>0 时调这个,让"从此处开始"也能拼上前序 done sub 的结论。
|
|
1525
|
-
*/
|
|
1526
|
-
async function collectPriorOutputsUpTo(task, endIdx) {
|
|
1527
|
-
const prior = []
|
|
1528
|
-
for (let i = 0; i < endIdx; i++) {
|
|
1529
|
-
const s = task.subtasks[i]
|
|
1530
|
-
if (s.status !== 'done') continue
|
|
1531
|
-
// 从 jobs 列表里找最近一个属于这个 sub 且 status=done 的 job,
|
|
1532
|
-
// 取其 output 作为"前序上下文"。完整透传,不做字符截断。
|
|
1533
|
-
const job = snapshotJobs()
|
|
1534
|
-
.filter(j => j.subId === s.id && j.status === 'done')
|
|
1535
|
-
.sort((a, b) => (b.endedAt || '').localeCompare(a.endedAt || ''))[0]
|
|
1536
|
-
if (!job) continue
|
|
1537
|
-
prior.push({ title: s.title, output: job.output || '' })
|
|
1538
|
-
}
|
|
1539
|
-
return prior
|
|
1540
|
-
}
|
|
1541
|
-
|
|
1542
|
-
function waitProcessExit(pid) {
|
|
1543
|
-
return new Promise(resolve => {
|
|
1544
|
-
let exited = false;
|
|
1545
|
-
const tryCheck = () => {
|
|
1546
|
-
if (exited) return;
|
|
1547
|
-
try {
|
|
1548
|
-
process.kill(pid, 0); // 信号 0 = 探测存活
|
|
1549
|
-
} catch (err) {
|
|
1550
|
-
// 只在进程真的消失(ESRCH / EPERM)时才 resolve;
|
|
1551
|
-
// 其他错误(比如参数类型)保留 polling 状态,由超时兜底。
|
|
1552
|
-
if (err && (err.code === 'ESRCH' || err.code === 'EPERM')) {
|
|
1553
|
-
exited = true;
|
|
1554
|
-
resolve();
|
|
1555
|
-
return;
|
|
1556
|
-
}
|
|
1557
|
-
}
|
|
1558
|
-
setTimeout(tryCheck, 1500);
|
|
1559
|
-
};
|
|
1560
|
-
tryCheck();
|
|
1561
|
-
// 兜底:30 分钟超时自动结束
|
|
1562
|
-
setTimeout(() => { if (!exited) { exited = true; resolve(); } }, 30 * 60 * 1000);
|
|
1563
|
-
});
|
|
1564
|
-
}
|
|
1565
|
-
|
|
1566
|
-
export function registerWorkbenchRoutes({ app, getCurrentProjectPath, getProjectRoomId, io, configManager }) {
|
|
1567
|
-
// ── AI 生成提示词(基于当前项目) ─────────────────────────────────────
|
|
1568
|
-
app.post('/api/workbench/prompts/ai-generate', async (req, res) => {
|
|
1569
|
-
try {
|
|
1570
|
-
const projectPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
|
|
1571
|
-
if (!projectPath) {
|
|
1572
|
-
return res.status(400).json({ success: false, error: '未选中项目' });
|
|
1573
|
-
}
|
|
1574
|
-
let stat;
|
|
1575
|
-
try { stat = await fsp.stat(projectPath); }
|
|
1576
|
-
catch { return res.status(400).json({ success: false, error: '项目路径不存在' }); }
|
|
1577
|
-
if (!stat.isDirectory()) {
|
|
1578
|
-
return res.status(400).json({ success: false, error: '项目路径不是目录' });
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
|
-
// 取模型
|
|
1582
|
-
let model;
|
|
1583
|
-
try {
|
|
1584
|
-
if (!configManager) throw new Error('configManager 不可用');
|
|
1585
|
-
const rawConfig = await configManager.readRawConfigFile();
|
|
1586
|
-
const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
|
|
1587
|
-
model = models.find(m => m.isDefault) || models[0];
|
|
1588
|
-
} catch (err) {
|
|
1589
|
-
return res.status(500).json({ success: false, error: '读取 AI 配置失败: ' + err.message });
|
|
1590
|
-
}
|
|
1591
|
-
if (!model) {
|
|
1592
|
-
return res.status(400).json({ success: false, error: '未配置 AI 模型,请先在通用设置中添加模型' });
|
|
1593
|
-
}
|
|
1594
|
-
|
|
1595
|
-
// 读取用户可编辑的生成指令;没存就用默认
|
|
1596
|
-
const userInstruction = await readInstruction();
|
|
1597
|
-
|
|
1598
|
-
// 递归识别多子项目
|
|
1599
|
-
const subProjects = await findSubProjects(projectPath);
|
|
1600
|
-
if (subProjects.length === 0) {
|
|
1601
|
-
// 没识别到任何子项目:回退到根目录本身
|
|
1602
|
-
const fallbackTree = await listDirTree(projectPath, 2, 400);
|
|
1603
|
-
const fallbackManifest = await readProjectManifest(projectPath);
|
|
1604
|
-
const fallbackReadme = await safeReadFile(path.join(projectPath, 'README.md'), 8000);
|
|
1605
|
-
subProjects.push({
|
|
1606
|
-
root: projectPath,
|
|
1607
|
-
name: path.basename(projectPath),
|
|
1608
|
-
manifests: fallbackManifest,
|
|
1609
|
-
readme: fallbackReadme,
|
|
1610
|
-
dirTree: fallbackTree
|
|
1611
|
-
});
|
|
1612
|
-
}
|
|
1613
|
-
|
|
1614
|
-
const projectName = path.basename(projectPath);
|
|
1615
|
-
const LLM_OPTS = { timeoutMs: 1200000 };
|
|
1616
|
-
|
|
1617
|
-
// ── 第一阶段:基于可编辑指令 + 根目录概览,生成「可复用的提示词模板」 ──
|
|
1618
|
-
const overviewBlock = subProjects.map(sp =>
|
|
1619
|
-
`### 子项目 ${sp.name} (${sp.root})\n目录:\n${sp.dirTree || '(无)'}`
|
|
1620
|
-
).join('\n\n');
|
|
1621
|
-
|
|
1622
|
-
const firstPrompt = `${userInstruction}
|
|
1623
|
-
|
|
1624
|
-
---
|
|
1625
|
-
|
|
1626
|
-
以下是你需要分析的项目(请先生成「可复用的提示词模板」,不要直接给总结):
|
|
1627
|
-
|
|
1628
|
-
项目根目录:${projectPath}
|
|
1629
|
-
项目名称:${projectName}
|
|
1630
|
-
子项目数:${subProjects.length}
|
|
1631
|
-
|
|
1632
|
-
## 子项目概览
|
|
1633
|
-
${overviewBlock || '(无)'}
|
|
1634
|
-
|
|
1635
|
-
## 各子项目 manifest 与 README
|
|
1636
|
-
${subProjects.map(sp => {
|
|
1637
|
-
const manifestBlock = Object.entries(sp.manifests)
|
|
1638
|
-
.map(([n, c]) => `\n--- ${n} ---\n${c}`)
|
|
1639
|
-
.join('\n');
|
|
1640
|
-
return `\n### ${sp.name}\n${manifestBlock || '(无 manifest)'}\n\nREADME(前 8KB):\n${sp.readme || '(无)'}`;
|
|
1641
|
-
}).join('\n')}
|
|
1642
|
-
|
|
1643
|
-
只返回 JSON:
|
|
1644
|
-
{
|
|
1645
|
-
"name": "项目名(建议:项目名+架构说明,可根据实际项目特征调整)",
|
|
1646
|
-
"template": "可复用的提示词模板,长度不限,请充分覆盖 {{task.title}} / {{task.desc}} / {{sub.title}} / {{sub.desc}} / {{repo.path}} / {{branch}} 这 6 个变量的用法与上下文"
|
|
1647
|
-
}`;
|
|
1648
|
-
|
|
1649
|
-
const first = await callLlmJsonWithRetry(model, firstPrompt, LLM_OPTS);
|
|
1650
|
-
const templateName = String(first.name || '').trim() || `${projectName}架构说明`;
|
|
1651
|
-
const template = String(first.template || '').trim();
|
|
1652
|
-
|
|
1653
|
-
// ── 第二阶段:为每个子项目分别生成总结(单子项目 = 现在的行为) ──
|
|
1654
|
-
async function summarizeOneSub(sp) {
|
|
1655
|
-
const manifestBlock = Object.entries(sp.manifests)
|
|
1656
|
-
.map(([n, c]) => `\n--- ${n} ---\n${c}`)
|
|
1657
|
-
.join('\n');
|
|
1658
|
-
const subPrompt = `${template}
|
|
1659
|
-
|
|
1660
|
-
---
|
|
1661
|
-
|
|
1662
|
-
以下是你需要分析的一个子项目(请直接基于这些数据输出该子项目的架构说明):
|
|
1663
|
-
|
|
1664
|
-
子项目根目录:${sp.root}
|
|
1665
|
-
子项目名称:${sp.name}
|
|
1666
|
-
|
|
1667
|
-
## 目录结构(前 2 层)
|
|
1668
|
-
${sp.dirTree || '(无)'}
|
|
1669
|
-
|
|
1670
|
-
## manifest
|
|
1671
|
-
${manifestBlock || '(无)'}
|
|
1672
|
-
|
|
1673
|
-
## README
|
|
1674
|
-
${sp.readme || '(无)'}
|
|
1675
|
-
|
|
1676
|
-
只返回 JSON:
|
|
1677
|
-
{
|
|
1678
|
-
"summary": "该子项目的架构说明,长度不限,模型自行决定篇幅与详尽程度,能写多详细就多详细"
|
|
1679
|
-
}`;
|
|
1680
|
-
const r = await callLlmJsonWithRetry(model, subPrompt, LLM_OPTS);
|
|
1681
|
-
return { name: sp.name, root: sp.root, summary: String(r.summary || '').trim() };
|
|
1682
|
-
}
|
|
1683
|
-
|
|
1684
|
-
// 串行执行,避免并发触发 provider 限流(429)导致整批失败
|
|
1685
|
-
const subSummaries = [];
|
|
1686
|
-
for (const sp of subProjects) {
|
|
1687
|
-
subSummaries.push(await summarizeOneSub(sp));
|
|
1688
|
-
}
|
|
1689
|
-
|
|
1690
|
-
// ── 第三阶段:仅多子项目时合并(单子项目直接拿它的 summary) ──
|
|
1691
|
-
let finalSummary = '';
|
|
1692
|
-
let finalName = templateName;
|
|
1693
|
-
|
|
1694
|
-
if (subSummaries.length === 1) {
|
|
1695
|
-
finalSummary = subSummaries[0].summary;
|
|
1696
|
-
} else {
|
|
1697
|
-
const mergePrompt = `你是项目架构师。下列是同一仓库下 N 个子项目的架构说明,请合并输出**单一**的「项目架构说明」,长度不限,模型自行决定篇幅与详尽程度。覆盖:项目整体定位、技术栈、模块划分、子项目间关系、核心流程、关键设计决策。
|
|
1698
|
-
子项目之间用清晰的小标题或编号分隔。最后输出一段「整体架构」总结它们如何协同。
|
|
1699
|
-
只引用实际出现的子项目名 / 文件路径 / 依赖名,不要编造。只返回 JSON:
|
|
1700
|
-
|
|
1701
|
-
{
|
|
1702
|
-
"name": "项目名(建议:项目名+架构说明)",
|
|
1703
|
-
"summary": "合并后的架构说明"
|
|
1704
|
-
}
|
|
1705
|
-
|
|
1706
|
-
## 子项目说明
|
|
1707
|
-
${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summary || '(空)'}`).join('\n')}`;
|
|
1708
|
-
|
|
1709
|
-
const merged = await callLlmJsonWithRetry(model, mergePrompt, LLM_OPTS);
|
|
1710
|
-
finalSummary = String(merged.summary || '').trim()
|
|
1711
|
-
|| subSummaries.map(s => `### ${s.name}\n${s.summary}`).join('\n\n');
|
|
1712
|
-
finalName = String(merged.name || '').trim() || templateName;
|
|
1713
|
-
}
|
|
1714
|
-
|
|
1715
|
-
// 名称始终固定为「<项目名>架构说明」,不信任模型返回的 name 字段
|
|
1716
|
-
finalName = `${projectName}架构说明`;
|
|
1717
|
-
|
|
1718
|
-
if (!finalSummary) {
|
|
1719
|
-
return res.status(502).json({
|
|
1720
|
-
success: false,
|
|
1721
|
-
error: '模型返回为空,请稍后重试'
|
|
1722
|
-
});
|
|
1723
|
-
}
|
|
1724
|
-
|
|
1725
|
-
// 顶层 request 已经自带 20 分钟(1200s)超时;
|
|
1726
|
-
// 这里在 express 处理器内部不再额外加整体超时。
|
|
1727
|
-
res.json({
|
|
1728
|
-
success: true,
|
|
1729
|
-
name: finalName,
|
|
1730
|
-
template,
|
|
1731
|
-
result: finalSummary,
|
|
1732
|
-
content: finalSummary
|
|
1733
|
-
});
|
|
1734
|
-
} catch (err) {
|
|
1735
|
-
res.status(500).json({ success: false, error: err.message });
|
|
1736
|
-
}
|
|
1737
|
-
});
|
|
1738
|
-
|
|
1739
|
-
// ── 生成指令:读 / 写(用户可在弹窗里自定义) ───────────────────────
|
|
1740
|
-
app.get('/api/workbench/prompts/ai-instruction', async (_req, res) => {
|
|
1741
|
-
try {
|
|
1742
|
-
const instruction = await readInstruction();
|
|
1743
|
-
res.json({ success: true, instruction, isDefault: instruction === DEFAULT_INSTRUCTION });
|
|
1744
|
-
} catch (err) {
|
|
1745
|
-
res.status(500).json({ success: false, error: err.message });
|
|
1746
|
-
}
|
|
1747
|
-
});
|
|
1748
|
-
|
|
1749
|
-
app.put('/api/workbench/prompts/ai-instruction', async (req, res) => {
|
|
1750
|
-
try {
|
|
1751
|
-
const text = req.body && typeof req.body.instruction === 'string'
|
|
1752
|
-
? req.body.instruction.trim()
|
|
1753
|
-
: '';
|
|
1754
|
-
if (!text) {
|
|
1755
|
-
return res.status(400).json({ success: false, error: '指令不能为空' });
|
|
1756
|
-
}
|
|
1757
|
-
if (text.length > 500000) {
|
|
1758
|
-
return res.status(413).json({ success: false, error: '指令过长(最多 500000 字符)' });
|
|
1759
|
-
}
|
|
1760
|
-
await writeInstruction(text);
|
|
1761
|
-
res.json({ success: true });
|
|
1762
|
-
} catch (err) {
|
|
1763
|
-
res.status(500).json({ success: false, error: err.message });
|
|
1764
|
-
}
|
|
1765
|
-
});
|
|
1766
|
-
|
|
1767
|
-
// ── AI 拆分子任务:独立的指令文件、独立的端点 ───────────────────────
|
|
1768
|
-
// GET /api/workbench/tasks/ai-subtask-instruction
|
|
1769
|
-
// → { success, instruction, isDefault }
|
|
1770
|
-
// PUT /api/workbench/tasks/ai-subtask-instruction
|
|
1771
|
-
// body: { instruction: string }
|
|
1772
|
-
// → { success }
|
|
1773
|
-
app.get('/api/workbench/tasks/ai-subtask-instruction', async (req, res) => {
|
|
1774
|
-
try {
|
|
1775
|
-
const def = pickDefaultSubtaskInstruction(req);
|
|
1776
|
-
const instruction = await readSubtaskInstruction(req);
|
|
1777
|
-
// isDefault:当前 instruction 和 locale 默认完全一致
|
|
1778
|
-
res.json({ success: true, instruction, isDefault: instruction === def });
|
|
1779
|
-
} catch (err) {
|
|
1780
|
-
res.status(500).json({ success: false, error: err.message });
|
|
1781
|
-
}
|
|
1782
|
-
});
|
|
1783
|
-
|
|
1784
|
-
app.put('/api/workbench/tasks/ai-subtask-instruction', async (req, res) => {
|
|
1785
|
-
try {
|
|
1786
|
-
const text = req.body && typeof req.body.instruction === 'string'
|
|
1787
|
-
? req.body.instruction.trim()
|
|
1788
|
-
: '';
|
|
1789
|
-
if (!text) {
|
|
1790
|
-
return res.status(400).json({ success: false, error: '指令不能为空' });
|
|
1791
|
-
}
|
|
1792
|
-
if (text.length > 500000) {
|
|
1793
|
-
return res.status(413).json({ success: false, error: '指令过长(最多 500000 字符)' });
|
|
1794
|
-
}
|
|
1795
|
-
// 如果保存的文本正好等于当前 locale 的默认——不写文件,保持 fallback 行为
|
|
1796
|
-
const def = pickDefaultSubtaskInstruction(req);
|
|
1797
|
-
if (text === def) {
|
|
1798
|
-
// 删除已存在的自定义文件
|
|
1799
|
-
try { await fsp.unlink(SUBTASK_INSTRUCTION_FILE) } catch {}
|
|
1800
|
-
return res.json({ success: true, isDefault: true });
|
|
1801
|
-
}
|
|
1802
|
-
await writeSubtaskInstruction(text);
|
|
1803
|
-
res.json({ success: true, isDefault: false });
|
|
1804
|
-
} catch (err) {
|
|
1805
|
-
res.status(500).json({ success: false, error: err.message });
|
|
1806
|
-
}
|
|
1807
|
-
});
|
|
1808
|
-
|
|
1809
|
-
// POST /api/workbench/tasks/ai-split-subtasks
|
|
1810
|
-
// body: { title, desc, taskId? }
|
|
1811
|
-
// → SSE 流:
|
|
1812
|
-
// data:{"type":"meta","prompt":{system,user}}\n\n
|
|
1813
|
-
// data:{"type":"thinking","delta":"..."}\n\n (多次)
|
|
1814
|
-
// data:{"type":"content","delta":"..."}\n\n (多次)
|
|
1815
|
-
// data:{"type":"done","subtasks":[...],"raw":"..."}\n\n
|
|
1816
|
-
// data:{"type":"error","error":"..."}\n\n (失败时)
|
|
1817
|
-
//
|
|
1818
|
-
// 走流式是为了让用户看到模型真实的 reasoning_content(如果模型支持),
|
|
1819
|
-
// 而不是前端用 setInterval 假装"打字机"——拆分质量也会因为给了模型
|
|
1820
|
-
// 充分的思考空间而显著提升。
|
|
1821
|
-
app.post('/api/workbench/tasks/ai-split-subtasks', async (req, res) => {
|
|
1822
|
-
const title = String(req.body?.title || '').trim();
|
|
1823
|
-
const desc = String(req.body?.desc || '').trim();
|
|
1824
|
-
const taskId = String(req.body?.taskId || '').trim();
|
|
1825
|
-
const promptId = String(req.body?.promptId || '').trim();
|
|
1826
|
-
// customUserBlock:用户在前端 prompt 预览页编辑过的 user prompt。非空时
|
|
1827
|
-
// 直接拿它发给 LLM,跳过服务端拼装;为空时按老逻辑现拼。
|
|
1828
|
-
// 注意:即便传了 customUserBlock,attachments/imageDataUrls 仍按 taskId 重新读,
|
|
1829
|
-
// 因为图片要随消息发,不能跟 prompt 文本一起塞。
|
|
1830
|
-
const customUserBlock = typeof req.body?.customUserBlock === 'string' ? req.body.customUserBlock : '';
|
|
1831
|
-
if (!title) {
|
|
1832
|
-
return res.status(400).json({ success: false, error: '任务标题不能为空' });
|
|
1833
|
-
}
|
|
1834
|
-
|
|
1835
|
-
// 建立 SSE
|
|
1836
|
-
res.set({
|
|
1837
|
-
'Content-Type': 'text/event-stream',
|
|
1838
|
-
'Cache-Control': 'no-cache, no-transform',
|
|
1839
|
-
'Connection': 'keep-alive',
|
|
1840
|
-
'X-Accel-Buffering': 'no'
|
|
1841
|
-
});
|
|
1842
|
-
res.flushHeaders?.();
|
|
1843
|
-
const send = (obj) => {
|
|
1844
|
-
try { res.write(`data: ${JSON.stringify(obj)}\n\n`); } catch {}
|
|
1845
|
-
};
|
|
1846
|
-
|
|
1847
|
-
const abortController = new AbortController();
|
|
1848
|
-
let finished = false; // 标记响应是否已正常结束
|
|
1849
|
-
// 客户端真实断开:监听 socket close,而不是 req.close。
|
|
1850
|
-
// Node 22+ 的 req 'close' 事件会在 HTTP keep-alive socket 池回收时过早触发,
|
|
1851
|
-
// 导致正常请求中途被 abort。这里改用 socket 真实断开事件,
|
|
1852
|
-
// 并只在响应还没 end 时才取消上游 LLM。
|
|
1853
|
-
const onSocketClose = () => {
|
|
1854
|
-
if (!finished) abortController.abort()
|
|
1855
|
-
};
|
|
1856
|
-
if (req.socket) {
|
|
1857
|
-
req.socket.once('close', onSocketClose);
|
|
1858
|
-
}
|
|
1859
|
-
|
|
1860
|
-
try {
|
|
1861
|
-
let model;
|
|
1862
|
-
try {
|
|
1863
|
-
if (!configManager) throw new Error('configManager 不可用');
|
|
1864
|
-
const rawConfig = await configManager.readRawConfigFile();
|
|
1865
|
-
const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
|
|
1866
|
-
model = models.find(m => m.isDefault) || models[0];
|
|
1867
|
-
} catch (err) {
|
|
1868
|
-
send({ type: 'error', error: '读取 AI 配置失败: ' + err.message });
|
|
1869
|
-
finished = true;
|
|
1870
|
-
return res.end();
|
|
1871
|
-
}
|
|
1872
|
-
if (!model) {
|
|
1873
|
-
send({ type: 'error', error: '未配置 AI 模型,请先在通用设置中添加模型' });
|
|
1874
|
-
finished = true;
|
|
1875
|
-
return res.end();
|
|
1876
|
-
}
|
|
1877
|
-
|
|
1878
|
-
const userInstruction = await readSubtaskInstruction(req);
|
|
1879
|
-
const projectPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
|
|
1880
|
-
const projectName = projectPath ? path.basename(projectPath) : '(未指定项目)';
|
|
1881
|
-
const manifestHint = await detectProjectManifest(projectPath);
|
|
1882
|
-
|
|
1883
|
-
// 取绑定的预置模板(promptId):模板内容作为"执行模板"提示
|
|
1884
|
-
// 让 LLM 拆分时知道:每个 sub 最终都会被这套模板包裹后送进 claude
|
|
1885
|
-
let templateBlock = '';
|
|
1886
|
-
if (promptId) {
|
|
1887
|
-
try {
|
|
1888
|
-
const promptData = await readJson(PROMPTS_FILE, { prompts: [] });
|
|
1889
|
-
const p = (promptData.prompts || []).find(x => x.id === promptId);
|
|
1890
|
-
if (p && p.content) {
|
|
1891
|
-
templateBlock = `\n\n## 子任务执行模板(每个拆出的子任务最终会被这套模板包裹后送给 claude 执行;拆分时请确保子任务能让模板里的 {{sub.title}} / {{sub.desc}} 等变量填得有意义)\n模板名:${p.name || '(未命名)'}\n---\n${p.content}\n---`;
|
|
1892
|
-
}
|
|
1893
|
-
} catch { /* 模板读取失败不影响拆分 */ }
|
|
1894
|
-
}
|
|
1895
|
-
|
|
1896
|
-
// 取任务附件
|
|
1897
|
-
let attachmentBlock = '';
|
|
1898
|
-
const imageDataUrls = [];
|
|
1899
|
-
if (taskId) {
|
|
1900
|
-
try {
|
|
1901
|
-
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
1902
|
-
const task = (data.tasks || []).find(t => t.id === taskId);
|
|
1903
|
-
const atts = Array.isArray(task?.attachments) ? task.attachments : [];
|
|
1904
|
-
if (atts.length > 0) {
|
|
1905
|
-
const lines = [];
|
|
1906
|
-
for (let i = 0; i < atts.length; i++) {
|
|
1907
|
-
const a = atts[i];
|
|
1908
|
-
if (!a || !a.absolutePath) continue;
|
|
1909
|
-
lines.push(` ${i + 1}. [${a.mimeType || 'application/octet-stream'}] ${a.absolutePath}`);
|
|
1910
|
-
if (isImageExt(a.ext)) {
|
|
1911
|
-
try {
|
|
1912
|
-
const buf = await fsp.readFile(a.absolutePath);
|
|
1913
|
-
const mime = a.mimeType || 'image/png';
|
|
1914
|
-
imageDataUrls.push(`data:${mime};base64,${buf.toString('base64')}`);
|
|
1915
|
-
} catch { /* 文件丢失就跳过这张图 */ }
|
|
1916
|
-
}
|
|
1917
|
-
}
|
|
1918
|
-
if (lines.length > 0) {
|
|
1919
|
-
const imgNote = imageDataUrls.length > 0
|
|
1920
|
-
? `(其中 ${imageDataUrls.length} 张图片已随消息一并发送,请直接基于图片内容拆分)`
|
|
1921
|
-
: '';
|
|
1922
|
-
attachmentBlock = `\n\n## 任务附件${imgNote}\n${lines.join('\n')}`;
|
|
1923
|
-
}
|
|
1924
|
-
}
|
|
1925
|
-
} catch { /* 没拿到附件不影响拆分 */ }
|
|
1926
|
-
}
|
|
1927
|
-
|
|
1928
|
-
const userBlock = customUserBlock.trim() ? customUserBlock : `${userInstruction}
|
|
1929
|
-
|
|
1930
|
-
---
|
|
1931
|
-
|
|
1932
|
-
## 待拆分的任务
|
|
1933
|
-
标题:${title}
|
|
1934
|
-
${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateBlock}
|
|
1935
|
-
|
|
1936
|
-
## 项目上下文(仅供参考,便于拆分时考虑项目特性)
|
|
1937
|
-
- 项目名称:${projectName}
|
|
1938
|
-
- 项目根目录:${projectPath || '(未指定)'}
|
|
1939
|
-
- 主要 manifest:${manifestHint || '(未识别到)'}
|
|
1940
|
-
|
|
1941
|
-
请先仔细分析(可以放在 reasoning 中或直接写出来)。仔细分析指:列出 5 个维度——任务真实目标 / 关键技术栈与边界 / 自然执行顺序 / 风险点与可能失败步骤 / 是否需要前置调研——不要简短一两句话就过;分析过后再给出 JSON。JSON 用 \`\`\`json ... \`\`\` 包裹:
|
|
1942
|
-
{
|
|
1943
|
-
"subtasks": [
|
|
1944
|
-
{ "title": "子任务标题", "desc": "具体描述" }
|
|
1945
|
-
]
|
|
1946
|
-
}
|
|
1947
|
-
|
|
1948
|
-
**JSON 输出严格要求**(不遵守会导致解析失败、用户无法入库):
|
|
1949
|
-
1. title 和 desc 内如需引用术语 / 页面名 / 状态名,**必须使用中文引号「」或『』**,禁止使用 ASCII 双引号、单引号或反引号,否则会破坏外层 JSON 结构。
|
|
1950
|
-
2. JSON 中不允许尾随逗号(最后一个元素后面不能跟逗号)。
|
|
1951
|
-
3. JSON 中不允许写注释。
|
|
1952
|
-
4. 所有字符串字段必须用 ASCII 双引号包裹,字符串内部如有换行用 \\n 转义。`;
|
|
1953
|
-
|
|
1954
|
-
// 先把 prompt 元信息推给前端
|
|
1955
|
-
send({ type: 'meta', prompt: { system: userInstruction, user: userBlock } });
|
|
1956
|
-
|
|
1957
|
-
// 流式调用 LLM,把 thinking / content 实时回传
|
|
1958
|
-
const { content, aborted } = await callLlmStream(
|
|
1959
|
-
model,
|
|
1960
|
-
userBlock,
|
|
1961
|
-
(delta) => {
|
|
1962
|
-
if (delta.thinking) send({ type: 'thinking', delta: delta.thinking });
|
|
1963
|
-
if (delta.content) send({ type: 'content', delta: delta.content });
|
|
1964
|
-
},
|
|
1965
|
-
{ maxTokens: 32000, timeoutMs: 600000, images: imageDataUrls, signal: abortController.signal }
|
|
1966
|
-
);
|
|
1967
|
-
|
|
1968
|
-
if (aborted) {
|
|
1969
|
-
send({ type: 'error', error: '已取消' });
|
|
1970
|
-
finished = true;
|
|
1971
|
-
return res.end();
|
|
1972
|
-
}
|
|
1973
|
-
|
|
1974
|
-
// 解析 JSON:兼容 ```json ... ``` 代码块或裸 JSON,多级降级
|
|
1975
|
-
const { parsed, parseError, parseStage } = parseSubtaskJson(content);
|
|
1976
|
-
const list = Array.isArray(parsed?.subtasks) ? parsed.subtasks : [];
|
|
1977
|
-
const subtasks = list
|
|
1978
|
-
.map(s => ({
|
|
1979
|
-
title: String(s?.title || '').trim(),
|
|
1980
|
-
desc: String(s?.desc || '').trim()
|
|
1981
|
-
}))
|
|
1982
|
-
.filter(s => s.title);
|
|
1983
|
-
|
|
1984
|
-
send({ type: 'done', subtasks, raw: content, parseError, parseStage });
|
|
1985
|
-
finished = true;
|
|
1986
|
-
res.end();
|
|
1987
|
-
} catch (err) {
|
|
1988
|
-
send({ type: 'error', error: 'AI 拆分失败: ' + (err?.message || String(err)) });
|
|
1989
|
-
finished = true;
|
|
1990
|
-
res.end();
|
|
1991
|
-
}
|
|
1992
|
-
});
|
|
1993
|
-
|
|
1994
|
-
// POST /api/workbench/tasks/ai-split-preview
|
|
1995
|
-
// body: { title, desc?, taskId?, promptId? }
|
|
1996
|
-
// → { success, system, user, hasImages, imageCount }
|
|
1997
|
-
// "AI 拆分前预览/编辑 prompt" 用:跟 /ai-split-subtasks 做完全一样的拼装,
|
|
1998
|
-
// 但**不调 LLM**,只把拼好的 system + user 返回。前端在 UI 上 textarea 展示,
|
|
1999
|
-
// 用户改完按"发送给 AI"再正式调 /ai-split-subtasks 并把改过的文本作为
|
|
2000
|
-
// customUserBlock 传回。这样省一次 LLM 调用,且让用户能修正提示词后再拆分。
|
|
2001
|
-
app.post('/api/workbench/tasks/ai-split-preview', async (req, res) => {
|
|
2002
|
-
try {
|
|
2003
|
-
const title = String(req.body?.title || '').trim();
|
|
2004
|
-
const desc = String(req.body?.desc || '').trim();
|
|
2005
|
-
const taskId = String(req.body?.taskId || '').trim();
|
|
2006
|
-
const promptId = String(req.body?.promptId || '').trim();
|
|
2007
|
-
if (!title) {
|
|
2008
|
-
return res.status(400).json({ success: false, error: '任务标题不能为空' });
|
|
2009
|
-
}
|
|
2010
|
-
|
|
2011
|
-
const userInstruction = await readSubtaskInstruction(req);
|
|
2012
|
-
const projectPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
|
|
2013
|
-
const projectName = projectPath ? path.basename(projectPath) : '(未指定项目)';
|
|
2014
|
-
const manifestHint = await detectProjectManifest(projectPath);
|
|
2015
|
-
|
|
2016
|
-
let templateBlock = '';
|
|
2017
|
-
if (promptId) {
|
|
2018
|
-
try {
|
|
2019
|
-
const promptData = await readJson(PROMPTS_FILE, { prompts: [] });
|
|
2020
|
-
const p = (promptData.prompts || []).find(x => x.id === promptId);
|
|
2021
|
-
if (p && p.content) {
|
|
2022
|
-
templateBlock = `\n\n## 子任务执行模板(每个拆出的子任务最终会被这套模板包裹后送给 claude 执行;拆分时请确保子任务能让模板里的 {{sub.title}} / {{sub.desc}} 等变量填得有意义)\n模板名:${p.name || '(未命名)'}\n---\n${p.content}\n---`;
|
|
2023
|
-
}
|
|
2024
|
-
} catch { /* 模板读取失败不影响预览 */ }
|
|
2025
|
-
}
|
|
2026
|
-
|
|
2027
|
-
// 附件:只列条目,不读图片二进制(preview 阶段不需要 image_data_url)
|
|
2028
|
-
let attachmentBlock = '';
|
|
2029
|
-
let imageCount = 0;
|
|
2030
|
-
if (taskId) {
|
|
2031
|
-
try {
|
|
2032
|
-
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
2033
|
-
const task = (data.tasks || []).find(t => t.id === taskId);
|
|
2034
|
-
const atts = Array.isArray(task?.attachments) ? task.attachments : [];
|
|
2035
|
-
if (atts.length > 0) {
|
|
2036
|
-
const lines = [];
|
|
2037
|
-
for (let i = 0; i < atts.length; i++) {
|
|
2038
|
-
const a = atts[i];
|
|
2039
|
-
if (!a || !a.absolutePath) continue;
|
|
2040
|
-
lines.push(` ${i + 1}. [${a.mimeType || 'application/octet-stream'}] ${a.absolutePath}`);
|
|
2041
|
-
if (isImageExt(a.ext)) imageCount++;
|
|
2042
|
-
}
|
|
2043
|
-
if (lines.length > 0) {
|
|
2044
|
-
const imgNote = imageCount > 0
|
|
2045
|
-
? `(其中 ${imageCount} 张图片已随消息一并发送,请直接基于图片内容拆分)`
|
|
2046
|
-
: '';
|
|
2047
|
-
attachmentBlock = `\n\n## 任务附件${imgNote}\n${lines.join('\n')}`;
|
|
2048
|
-
}
|
|
2049
|
-
}
|
|
2050
|
-
} catch { /* 没拿到附件不影响预览 */ }
|
|
2051
|
-
}
|
|
2052
|
-
|
|
2053
|
-
const userBlock = `${userInstruction}
|
|
2054
|
-
|
|
2055
|
-
---
|
|
2056
|
-
|
|
2057
|
-
## 待拆分的任务
|
|
2058
|
-
标题:${title}
|
|
2059
|
-
${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateBlock}
|
|
2060
|
-
|
|
2061
|
-
## 项目上下文(仅供参考,便于拆分时考虑项目特性)
|
|
2062
|
-
- 项目名称:${projectName}
|
|
2063
|
-
- 项目根目录:${projectPath || '(未指定)'}
|
|
2064
|
-
- 主要 manifest:${manifestHint || '(未识别到)'}
|
|
2065
|
-
|
|
2066
|
-
请先仔细分析(可以放在 reasoning 中或直接写出来)。仔细分析指:列出 5 个维度——任务真实目标 / 关键技术栈与边界 / 自然执行顺序 / 风险点与可能失败步骤 / 是否需要前置调研——不要简短一两句话就过;分析过后再给出 JSON。JSON 用 \`\`\`json ... \`\`\` 包裹:
|
|
2067
|
-
{
|
|
2068
|
-
"subtasks": [
|
|
2069
|
-
{ "title": "子任务标题", "desc": "具体描述" }
|
|
2070
|
-
]
|
|
2071
|
-
}
|
|
2072
|
-
|
|
2073
|
-
**JSON 输出严格要求**(不遵守会导致解析失败、用户无法入库):
|
|
2074
|
-
1. title 和 desc 内如需引用术语 / 页面名 / 状态名,**必须使用中文引号「」或『』**,禁止使用 ASCII 双引号、单引号或反引号,否则会破坏外层 JSON 结构。
|
|
2075
|
-
2. JSON 中不允许尾随逗号(最后一个元素后面不能跟逗号)。
|
|
2076
|
-
3. JSON 中不允许写注释。
|
|
2077
|
-
4. 所有字符串字段必须用 ASCII 双引号包裹,字符串内部如有换行用 \\n 转义。`;
|
|
2078
|
-
|
|
2079
|
-
res.json({
|
|
2080
|
-
success: true,
|
|
2081
|
-
system: userInstruction,
|
|
2082
|
-
user: userBlock,
|
|
2083
|
-
hasImages: imageCount > 0,
|
|
2084
|
-
imageCount
|
|
2085
|
-
});
|
|
2086
|
-
} catch (err) {
|
|
2087
|
-
res.status(500).json({ success: false, error: '生成预览失败: ' + (err?.message || String(err)) });
|
|
2088
|
-
}
|
|
2089
|
-
});
|
|
2090
|
-
|
|
2091
|
-
// POST /api/workbench/tasks/parse-subtasks
|
|
2092
|
-
// body: { raw: string }
|
|
2093
|
-
// → { success, subtasks, parseError, parseStage }
|
|
2094
|
-
// 让前端在 AI 拆分对话框里把"原始结果"作为可编辑文本——用户手改完
|
|
2095
|
-
// (比如把 ASCII 双引号改成中文「」、删尾随逗号)后直接调这个接口,
|
|
2096
|
-
// 不必再发起一次 LLM 调用,省 token 也省等待。
|
|
2097
|
-
app.post('/api/workbench/tasks/parse-subtasks', async (req, res) => {
|
|
2098
|
-
try {
|
|
2099
|
-
const raw = String(req.body?.raw || '');
|
|
2100
|
-
const { parsed, parseError, parseStage } = parseSubtaskJson(raw);
|
|
2101
|
-
const list = Array.isArray(parsed?.subtasks) ? parsed.subtasks : [];
|
|
2102
|
-
const subtasks = list
|
|
2103
|
-
.map(s => ({
|
|
2104
|
-
title: String(s?.title || '').trim(),
|
|
2105
|
-
desc: String(s?.desc || '').trim()
|
|
2106
|
-
}))
|
|
2107
|
-
.filter(s => s.title);
|
|
2108
|
-
res.json({ success: true, subtasks, parseError, parseStage });
|
|
2109
|
-
} catch (err) {
|
|
2110
|
-
res.status(500).json({ success: false, error: err?.message || String(err) });
|
|
2111
|
-
}
|
|
2112
|
-
});
|
|
2113
|
-
|
|
2114
|
-
// ── AI 对话拆分: 多轮 SSE 端点 ───────────────────────────────────────
|
|
2115
|
-
// POST /api/workbench/tasks/ai-chat-split (SSE)
|
|
2116
|
-
// body: { sessionId?, title, desc?, taskId?, promptId?, userMessage }
|
|
2117
|
-
// → meta / user_echo / thinking / content / done / error
|
|
2118
|
-
// 第一轮可省略 sessionId,后端会新建;后续轮带 sessionId 继续追加 messages。
|
|
2119
|
-
// 历史对话持久化在 SESSIONS_DIR,前端可从 GET 列表/GET 详情恢复。
|
|
2120
|
-
app.post('/api/workbench/tasks/ai-chat-split', async (req, res) => {
|
|
2121
|
-
const userMessage = String(req.body?.userMessage || '').trim();
|
|
2122
|
-
const sessionIdInput = String(req.body?.sessionId || '').trim();
|
|
2123
|
-
const title = String(req.body?.title || '').trim();
|
|
2124
|
-
const desc = String(req.body?.desc || '').trim();
|
|
2125
|
-
const taskId = String(req.body?.taskId || '').trim();
|
|
2126
|
-
const promptId = String(req.body?.promptId || '').trim();
|
|
2127
|
-
|
|
2128
|
-
// SSE 头
|
|
2129
|
-
res.set({
|
|
2130
|
-
'Content-Type': 'text/event-stream',
|
|
2131
|
-
'Cache-Control': 'no-cache, no-transform',
|
|
2132
|
-
'Connection': 'keep-alive',
|
|
2133
|
-
'X-Accel-Buffering': 'no'
|
|
2134
|
-
});
|
|
2135
|
-
res.flushHeaders?.();
|
|
2136
|
-
const send = (obj) => {
|
|
2137
|
-
try { res.write(`data: ${JSON.stringify(obj)}\n\n`); } catch {}
|
|
2138
|
-
};
|
|
2139
|
-
const abortController = new AbortController();
|
|
2140
|
-
let finished = false;
|
|
2141
|
-
if (req.socket) req.socket.once('close', () => { if (!finished) abortController.abort(); });
|
|
2142
|
-
|
|
2143
|
-
try {
|
|
2144
|
-
// 加载或新建 session
|
|
2145
|
-
let session, isNew = false;
|
|
2146
|
-
if (sessionIdInput) {
|
|
2147
|
-
try {
|
|
2148
|
-
session = await readSessionFile(sessionIdInput);
|
|
2149
|
-
} catch (err) {
|
|
2150
|
-
if (err.statusCode === 404) {
|
|
2151
|
-
send({ type: 'error', error: '会话不存在' });
|
|
2152
|
-
return res.end();
|
|
2153
|
-
}
|
|
2154
|
-
throw err;
|
|
2155
|
-
}
|
|
2156
|
-
} else {
|
|
2157
|
-
if (!title) {
|
|
2158
|
-
send({ type: 'error', error: '新建会话时必须提供 title' });
|
|
2159
|
-
return res.end();
|
|
2160
|
-
}
|
|
2161
|
-
session = {
|
|
2162
|
-
version: 1,
|
|
2163
|
-
sessionId: genSessionId(),
|
|
2164
|
-
title, desc, taskId, promptId,
|
|
2165
|
-
createdAt: nowIso(),
|
|
2166
|
-
updatedAt: nowIso(),
|
|
2167
|
-
messages: [],
|
|
2168
|
-
latestSubtasks: [],
|
|
2169
|
-
latestRaw: '',
|
|
2170
|
-
latestParseStage: ''
|
|
2171
|
-
};
|
|
2172
|
-
isNew = true;
|
|
2173
|
-
}
|
|
2174
|
-
|
|
2175
|
-
if (!userMessage) {
|
|
2176
|
-
send({ type: 'error', error: '消息内容不能为空' });
|
|
2177
|
-
return res.end();
|
|
2178
|
-
}
|
|
2179
|
-
|
|
2180
|
-
// 拼 system message(只在第一轮)
|
|
2181
|
-
if (session.messages.length === 0) {
|
|
2182
|
-
const userInstruction = await readSubtaskInstruction(req);
|
|
2183
|
-
// 第一轮时把任务上下文拼到 system message 末尾,让 LLM 一开始就知道要拆哪个任务。
|
|
2184
|
-
// 直接拆分端点把 title/desc 拼在 user prompt 里同样达到此效果;对话模式因 user 消息
|
|
2185
|
-
// 可能只是「拆一下」这种简短追问,必须把上下文放在 system 里 LLM 才不会反问。
|
|
2186
|
-
// 后续轮次不再追加,避免任务变更(切换 taskId)时影响已建立的多轮上下文。
|
|
2187
|
-
const ctxLines = [];
|
|
2188
|
-
if (title) ctxLines.push(`【任务标题】${title}`);
|
|
2189
|
-
if (desc) ctxLines.push(`【任务描述】${desc}`);
|
|
2190
|
-
const sysContent = ctxLines.length
|
|
2191
|
-
? `${userInstruction}\n\n${ctxLines.join('\n')}`
|
|
2192
|
-
: userInstruction;
|
|
2193
|
-
session.messages.push({ role: 'system', content: sysContent });
|
|
2194
|
-
}
|
|
2195
|
-
|
|
2196
|
-
// 追加 user message
|
|
2197
|
-
session.messages.push({ role: 'user', content: userMessage });
|
|
2198
|
-
|
|
2199
|
-
// 推 meta + user_echo
|
|
2200
|
-
send({
|
|
2201
|
-
type: 'meta',
|
|
2202
|
-
sessionId: session.sessionId,
|
|
2203
|
-
isNew,
|
|
2204
|
-
prompt: { system: session.messages[0].content, user: userMessage }
|
|
2205
|
-
});
|
|
2206
|
-
send({ type: 'user_echo', userMessage });
|
|
2207
|
-
|
|
2208
|
-
// 加载 model
|
|
2209
|
-
let model;
|
|
2210
|
-
try {
|
|
2211
|
-
if (!configManager) throw new Error('configManager 不可用');
|
|
2212
|
-
const rawConfig = await configManager.readRawConfigFile();
|
|
2213
|
-
const models = Array.isArray(rawConfig.models) ? rawConfig.models : [];
|
|
2214
|
-
model = models.find(m => m.isDefault) || models[0];
|
|
2215
|
-
} catch (err) {
|
|
2216
|
-
send({ type: 'error', error: '读取 AI 配置失败: ' + err.message });
|
|
2217
|
-
finished = true;
|
|
2218
|
-
return res.end();
|
|
2219
|
-
}
|
|
2220
|
-
if (!model) {
|
|
2221
|
-
send({ type: 'error', error: '未配置 AI 模型' });
|
|
2222
|
-
finished = true;
|
|
2223
|
-
return res.end();
|
|
2224
|
-
}
|
|
2225
|
-
|
|
2226
|
-
// 流式调用(messages 模式)
|
|
2227
|
-
const { content, aborted } = await callLlmStream(
|
|
2228
|
-
model,
|
|
2229
|
-
session.messages,
|
|
2230
|
-
(delta) => {
|
|
2231
|
-
if (delta.thinking) send({ type: 'thinking', delta: delta.thinking });
|
|
2232
|
-
if (delta.content) send({ type: 'content', delta: delta.content });
|
|
2233
|
-
},
|
|
2234
|
-
{ maxTokens: 32000, timeoutMs: 600000, signal: abortController.signal }
|
|
2235
|
-
);
|
|
2236
|
-
|
|
2237
|
-
if (aborted) {
|
|
2238
|
-
session.updatedAt = nowIso();
|
|
2239
|
-
await writeSessionFile(session.sessionId, session).catch(() => {});
|
|
2240
|
-
enforceSessionsRetention().catch(() => {});
|
|
2241
|
-
send({ type: 'error', error: '已取消' });
|
|
2242
|
-
finished = true;
|
|
2243
|
-
return res.end();
|
|
2244
|
-
}
|
|
2245
|
-
|
|
2246
|
-
// 解析 + 写盘
|
|
2247
|
-
const { parsed, parseError, parseStage } = parseSubtaskJson(content);
|
|
2248
|
-
const subtasks = Array.isArray(parsed?.subtasks)
|
|
2249
|
-
? parsed.subtasks
|
|
2250
|
-
.map(s => ({
|
|
2251
|
-
title: String(s?.title || '').trim(),
|
|
2252
|
-
desc: String(s?.desc || '').trim()
|
|
2253
|
-
}))
|
|
2254
|
-
.filter(s => s.title)
|
|
2255
|
-
: [];
|
|
2256
|
-
|
|
2257
|
-
session.messages.push({ role: 'assistant', content });
|
|
2258
|
-
session.latestSubtasks = subtasks;
|
|
2259
|
-
session.latestRaw = content;
|
|
2260
|
-
session.latestParseStage = parseStage;
|
|
2261
|
-
session.updatedAt = nowIso();
|
|
2262
|
-
await writeSessionFile(session.sessionId, session);
|
|
2263
|
-
enforceSessionsRetention().catch(() => {});
|
|
2264
|
-
|
|
2265
|
-
send({ type: 'done', subtasks, raw: content, parseError, parseStage });
|
|
2266
|
-
finished = true;
|
|
2267
|
-
res.end();
|
|
2268
|
-
} catch (err) {
|
|
2269
|
-
send({ type: 'error', error: '对话拆分失败: ' + (err?.message || String(err)) });
|
|
2270
|
-
finished = true;
|
|
2271
|
-
res.end();
|
|
2272
|
-
}
|
|
2273
|
-
});
|
|
2274
|
-
|
|
2275
|
-
// GET /api/workbench/tasks/ai-chat-sessions
|
|
2276
|
-
// → { success, sessions: [{ sessionId, title, taskId, createdAt, updatedAt, messageCount, latestSubtaskCount, size }] }
|
|
2277
|
-
// 列表只读 metadata,启动时无需预加载
|
|
2278
|
-
app.get('/api/workbench/tasks/ai-chat-sessions', async (_req, res) => {
|
|
2279
|
-
try {
|
|
2280
|
-
const sessions = await listSessionsMeta();
|
|
2281
|
-
res.json({ success: true, sessions });
|
|
2282
|
-
} catch (err) {
|
|
2283
|
-
res.status(500).json({ success: false, error: err?.message || String(err) });
|
|
2284
|
-
}
|
|
2285
|
-
});
|
|
2286
|
-
|
|
2287
|
-
// GET /api/workbench/tasks/ai-chat-sessions/:sessionId
|
|
2288
|
-
// → { success, session: { ...完整,含 messages } }
|
|
2289
|
-
app.get('/api/workbench/tasks/ai-chat-sessions/:sessionId', async (req, res) => {
|
|
2290
|
-
try {
|
|
2291
|
-
const session = await readSessionFile(req.params.sessionId);
|
|
2292
|
-
res.json({ success: true, session });
|
|
2293
|
-
} catch (err) {
|
|
2294
|
-
const code = err.statusCode || 500;
|
|
2295
|
-
res.status(code).json({ success: false, error: err.message });
|
|
2296
|
-
}
|
|
2297
|
-
});
|
|
2298
|
-
|
|
2299
|
-
// DELETE /api/workbench/tasks/ai-chat-sessions/:sessionId
|
|
2300
|
-
// → { success: true } (硬删文件)
|
|
2301
|
-
app.delete('/api/workbench/tasks/ai-chat-sessions/:sessionId', async (req, res) => {
|
|
2302
|
-
try {
|
|
2303
|
-
await deleteSessionFile(req.params.sessionId);
|
|
2304
|
-
res.json({ success: true });
|
|
2305
|
-
} catch (err) {
|
|
2306
|
-
const code = err.statusCode || 500;
|
|
2307
|
-
res.status(code).json({ success: false, error: err.message });
|
|
2308
|
-
}
|
|
2309
|
-
});
|
|
2310
|
-
|
|
2311
|
-
// SSE 事件流
|
|
2312
|
-
app.get('/api/workbench/events', (req, res) => {
|
|
2313
|
-
res.set({
|
|
2314
|
-
'Content-Type': 'text/event-stream',
|
|
2315
|
-
'Cache-Control': 'no-cache, no-transform',
|
|
2316
|
-
'Connection': 'keep-alive',
|
|
2317
|
-
'X-Accel-Buffering': 'no'
|
|
2318
|
-
});
|
|
2319
|
-
res.flushHeaders?.();
|
|
2320
|
-
const send = (data) => {
|
|
2321
|
-
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
2322
|
-
};
|
|
2323
|
-
// 初始快照
|
|
2324
|
-
send({ event: 'hello', payload: { jobs: snapshotJobs() }, ts: nowIso() });
|
|
2325
|
-
const handler = (evt) => send(evt);
|
|
2326
|
-
bus.on('event', handler);
|
|
2327
|
-
const ka = setInterval(() => res.write(`: keep-alive\n\n`), 15000);
|
|
2328
|
-
req.on('close', () => {
|
|
2329
|
-
clearInterval(ka);
|
|
2330
|
-
bus.off('event', handler);
|
|
2331
|
-
});
|
|
2332
|
-
});
|
|
2333
|
-
|
|
2334
|
-
// ── 提示词 CRUD ─────────────────────────────────────────────────────
|
|
2335
|
-
app.get('/api/workbench/prompts', async (_req, res) => {
|
|
2336
|
-
try {
|
|
2337
|
-
const data = await readJson(PROMPTS_FILE, { prompts: [] });
|
|
2338
|
-
res.json({ success: true, prompts: data.prompts || [] });
|
|
2339
|
-
} catch (err) {
|
|
2340
|
-
res.status(500).json({ success: false, error: err.message });
|
|
2341
|
-
}
|
|
2342
|
-
});
|
|
2343
|
-
|
|
2344
|
-
app.post('/api/workbench/prompts', async (req, res) => {
|
|
2345
|
-
try {
|
|
2346
|
-
const { id, name, content, projectPath } = req.body || {};
|
|
2347
|
-
if (!name || typeof content !== 'string') {
|
|
2348
|
-
return res.status(400).json({ success: false, error: 'name 和 content 必填' });
|
|
2349
|
-
}
|
|
2350
|
-
const data = await readJson(PROMPTS_FILE, { prompts: [] });
|
|
2351
|
-
const prompts = data.prompts || [];
|
|
2352
|
-
const now = nowIso();
|
|
2353
|
-
// projectPath 规范化:undefined / '' / 非法类型 → '' (全局)
|
|
2354
|
-
// 留空字符串 = 全局提示词(所有项目都能看到);非空 = 归属具体项目
|
|
2355
|
-
const normalizedProjectPath = typeof projectPath === 'string' ? projectPath.trim() : '';
|
|
2356
|
-
if (id) {
|
|
2357
|
-
const i = prompts.findIndex(p => p.id === id);
|
|
2358
|
-
if (i < 0) return res.status(404).json({ success: false, error: '提示词不存在' });
|
|
2359
|
-
prompts[i] = {
|
|
2360
|
-
...prompts[i],
|
|
2361
|
-
name,
|
|
2362
|
-
content,
|
|
2363
|
-
projectPath: normalizedProjectPath,
|
|
2364
|
-
updatedAt: now
|
|
2365
|
-
};
|
|
2366
|
-
await writeJson(PROMPTS_FILE, { prompts });
|
|
2367
|
-
return res.json({ success: true, prompt: prompts[i] });
|
|
2368
|
-
}
|
|
2369
|
-
const prompt = {
|
|
2370
|
-
id: genId(),
|
|
2371
|
-
name,
|
|
2372
|
-
content,
|
|
2373
|
-
projectPath: normalizedProjectPath,
|
|
2374
|
-
createdAt: now,
|
|
2375
|
-
updatedAt: now
|
|
2376
|
-
};
|
|
2377
|
-
prompts.push(prompt);
|
|
2378
|
-
await writeJson(PROMPTS_FILE, { prompts });
|
|
2379
|
-
res.json({ success: true, prompt });
|
|
2380
|
-
} catch (err) {
|
|
2381
|
-
res.status(500).json({ success: false, error: err.message });
|
|
2382
|
-
}
|
|
2383
|
-
});
|
|
2384
|
-
|
|
2385
|
-
app.delete('/api/workbench/prompts/:id', async (req, res) => {
|
|
2386
|
-
try {
|
|
2387
|
-
const data = await readJson(PROMPTS_FILE, { prompts: [] });
|
|
2388
|
-
const prompts = (data.prompts || []).filter(p => p.id !== req.params.id);
|
|
2389
|
-
await writeJson(PROMPTS_FILE, { prompts });
|
|
2390
|
-
res.json({ success: true });
|
|
2391
|
-
} catch (err) {
|
|
2392
|
-
res.status(500).json({ success: false, error: err.message });
|
|
2393
|
-
}
|
|
2394
|
-
});
|
|
2395
|
-
|
|
2396
|
-
// ── 任务 CRUD ───────────────────────────────────────────────────────
|
|
2397
|
-
app.get('/api/workbench/tasks', async (_req, res) => {
|
|
2398
|
-
try {
|
|
2399
|
-
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
2400
|
-
res.json({ success: true, tasks: data.tasks || [] });
|
|
2401
|
-
} catch (err) {
|
|
2402
|
-
res.status(500).json({ success: false, error: err.message });
|
|
2403
|
-
}
|
|
2404
|
-
});
|
|
2405
|
-
|
|
2406
|
-
// 当前选中的项目路径(侧边栏按项目分组时要用)
|
|
2407
|
-
app.get('/api/workbench/current-project', async (_req, res) => {
|
|
2408
|
-
try {
|
|
2409
|
-
const projectPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
|
|
2410
|
-
const projectName = projectPath ? projectPath.split(/[\\/]/).filter(Boolean).pop() : '';
|
|
2411
|
-
res.json({ success: true, projectPath, projectName });
|
|
2412
|
-
} catch (err) {
|
|
2413
|
-
res.status(500).json({ success: false, error: err.message });
|
|
2414
|
-
}
|
|
2415
|
-
});
|
|
2416
|
-
|
|
2417
|
-
// 任务排序:拖动落盘。body: { orderedIds: string[], groupPath?: string }
|
|
2418
|
-
// - orderedIds 是同组内所有任务的 id,按新顺序排列(必须完整,不能遗漏)
|
|
2419
|
-
// - groupPath 标识目标分组(projectPath),后端据此做完整性校验
|
|
2420
|
-
// - 仅重排该组任务的相对位置,其他组/其他任务保持原位
|
|
2421
|
-
// - 成功后 publish('tasks:reordered', { tasks: 新顺序数组 }) 给所有 SSE 客户端。
|
|
2422
|
-
app.put('/api/workbench/tasks/reorder', async (req, res) => {
|
|
2423
|
-
try {
|
|
2424
|
-
const orderedIds = Array.isArray(req.body && req.body.orderedIds) ? req.body.orderedIds : null;
|
|
2425
|
-
const groupPath = typeof (req.body && req.body.groupPath) === 'string' ? req.body.groupPath.trim() || null : null;
|
|
2426
|
-
if (!orderedIds || orderedIds.length === 0) {
|
|
2427
|
-
return res.status(400).json({ success: false, error: 'orderedIds 不能为空' });
|
|
2428
|
-
}
|
|
2429
|
-
if (!orderedIds.every((x) => typeof x === 'string' && x.length > 0)) {
|
|
2430
|
-
return res.status(400).json({ success: false, error: 'orderedIds 必须是字符串数组' });
|
|
2431
|
-
}
|
|
2432
|
-
if (new Set(orderedIds).size !== orderedIds.length) {
|
|
2433
|
-
return res.status(400).json({ success: false, error: 'orderedIds 包含重复 id' });
|
|
2434
|
-
}
|
|
2435
|
-
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
2436
|
-
const tasks = data.tasks || [];
|
|
2437
|
-
const idIndex = new Map();
|
|
2438
|
-
for (let i = 0; i < tasks.length; i++) idIndex.set(tasks[i].id, i);
|
|
2439
|
-
const missing = orderedIds.filter((id) => !idIndex.has(id));
|
|
2440
|
-
if (missing.length > 0) {
|
|
2441
|
-
return res.status(400).json({ success: false, error: `id 不存在: ${missing.slice(0, 3).join(',')}` });
|
|
2442
|
-
}
|
|
2443
|
-
|
|
2444
|
-
// 如果传了 groupPath,校验 orderedIds 是否完整包含了该组的所有任务
|
|
2445
|
-
if (groupPath) {
|
|
2446
|
-
const groupKey = groupPath || '__no_project__';
|
|
2447
|
-
const groupTasks = tasks.filter(t => ((t.projectPath || '').trim() || '__no_project__') === groupKey);
|
|
2448
|
-
if (groupTasks.length > 0 && orderedIds.length !== groupTasks.length) {
|
|
2449
|
-
return res.status(400).json({
|
|
2450
|
-
success: false,
|
|
2451
|
-
error: `orderedIds 不完整:该组有 ${groupTasks.length} 个任务,但只传了 ${orderedIds.length} 个 id`
|
|
2452
|
-
});
|
|
2453
|
-
}
|
|
2454
|
-
// 额外校验:所有 orderedIds 确实都属于该组
|
|
2455
|
-
const nonGroupIds = orderedIds.filter(id => {
|
|
2456
|
-
const t = tasks.find(x => x.id === id);
|
|
2457
|
-
return !t || ((t.projectPath || '').trim() || '__no_project__') !== groupKey;
|
|
2458
|
-
});
|
|
2459
|
-
if (nonGroupIds.length > 0) {
|
|
2460
|
-
return res.status(400).json({
|
|
2461
|
-
success: false,
|
|
2462
|
-
error: `以下 id 不属于指定分组: ${nonGroupIds.slice(0, 3).join(',')}`
|
|
2463
|
-
});
|
|
2464
|
-
}
|
|
2465
|
-
}
|
|
2466
|
-
|
|
2467
|
-
// 安全的重排算法:用 Map 按序取,确保每个 orderedId 对应的 task 都被保留
|
|
2468
|
-
const orderedSet = new Set(orderedIds);
|
|
2469
|
-
const byId = new Map(tasks.map(t => [t.id, t]));
|
|
2470
|
-
const reordered = [];
|
|
2471
|
-
let ptr = 0;
|
|
2472
|
-
for (const t of tasks) {
|
|
2473
|
-
if (orderedSet.has(t.id)) {
|
|
2474
|
-
// 用 ptr 从 orderedIds 取下一个 id,从 byId 查找对应 task
|
|
2475
|
-
if (ptr < orderedIds.length) {
|
|
2476
|
-
const target = byId.get(orderedIds[ptr++]);
|
|
2477
|
-
if (target) {
|
|
2478
|
-
reordered.push(target);
|
|
2479
|
-
continue; // 成功匹配,跳过下面的 fallback
|
|
2480
|
-
}
|
|
2481
|
-
}
|
|
2482
|
-
// fallback:如果有序列错乱导致没匹配上,仍保留原 task(防御性)
|
|
2483
|
-
reordered.push(t);
|
|
2484
|
-
} else {
|
|
2485
|
-
reordered.push(t);
|
|
2486
|
-
}
|
|
2487
|
-
}
|
|
2488
|
-
// 最终校验:输出数量必须等于输入数量(绝对不能丢任务)
|
|
2489
|
-
if (reordered.length !== tasks.length) {
|
|
2490
|
-
console.error('[reorder] 数据丢失风险!原始 %d 个,重排后 %d 个', tasks.length, reordered.length);
|
|
2491
|
-
return res.status(500).json({ success: false, error: '内部错误:重排结果数量不一致' });
|
|
2492
|
-
}
|
|
2493
|
-
data.tasks = reordered;
|
|
2494
|
-
await writeJson(TASKS_FILE, data);
|
|
2495
|
-
publish('tasks:reordered', { tasks: reordered });
|
|
2496
|
-
res.json({ success: true, tasks: reordered });
|
|
2497
|
-
} catch (err) {
|
|
2498
|
-
res.status(500).json({ success: false, error: err.message });
|
|
2499
|
-
}
|
|
2500
|
-
});
|
|
2501
|
-
|
|
2502
|
-
app.post('/api/workbench/tasks', async (req, res) => {
|
|
2503
|
-
try {
|
|
2504
|
-
const { id, title, desc, promptId, subtasks, type: rawType, simpleOverride, sequential: rawSequential } = req.body || {};
|
|
2505
|
-
// title 非必填:允许空字符串,UI 层会用"未命名任务"占位
|
|
2506
|
-
const safeTitle = typeof title === 'string' ? title.trim() : '';
|
|
2507
|
-
// type 归一化:仅接受 'simple' | 'complex',缺省/未知一律按 complex
|
|
2508
|
-
const taskType = rawType === 'simple' ? 'simple' : 'complex';
|
|
2509
|
-
// sequential 归一化:仅接受 boolean,缺省/非布尔一律 true(连续模式),
|
|
2510
|
-
// 旧任务没字段时也是 true,保留连续默认行为
|
|
2511
|
-
const sequential = rawSequential === false ? false : true;
|
|
2512
|
-
const safeOverride = typeof simpleOverride === 'string' ? simpleOverride.slice(0, 8000) : '';
|
|
2513
|
-
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
2514
|
-
const tasks = data.tasks || [];
|
|
2515
|
-
const now = nowIso();
|
|
2516
|
-
// 创建任务时记录当时所属项目;编辑已有任务不覆盖(避免切换项目后老任务被改归属)
|
|
2517
|
-
const currentProjectPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
|
|
2518
|
-
if (id) {
|
|
2519
|
-
const i = tasks.findIndex(t => t.id === id);
|
|
2520
|
-
if (i < 0) return res.status(404).json({ success: false, error: '任务不存在' });
|
|
2521
|
-
tasks[i] = {
|
|
2522
|
-
...tasks[i],
|
|
2523
|
-
title: safeTitle,
|
|
2524
|
-
desc: desc || '',
|
|
2525
|
-
promptId: promptId || null,
|
|
2526
|
-
type: taskType,
|
|
2527
|
-
// 复杂任务可显式关闭连续模式;简单任务无意义,固定 true
|
|
2528
|
-
sequential: taskType === 'complex' ? sequential : true,
|
|
2529
|
-
simpleOverride: taskType === 'simple' ? safeOverride : '',
|
|
2530
|
-
subtasks: Array.isArray(subtasks) ? subtasks.map(s => ({
|
|
2531
|
-
id: s.id || genId(),
|
|
2532
|
-
title: s.title || '',
|
|
2533
|
-
desc: s.desc || '',
|
|
2534
|
-
status: s.status || 'todo',
|
|
2535
|
-
promptOverride: s.promptOverride || '',
|
|
2536
|
-
// 保留失败信息:编辑已 error 的 sub 时不能丢 context,否则 hover popover 看不到原因
|
|
2537
|
-
error: typeof s.error === 'string' ? s.error : undefined,
|
|
2538
|
-
errorAt: typeof s.errorAt === 'string' ? s.errorAt : undefined,
|
|
2539
|
-
// 保留附件元数据(仅保留基础字段,丢弃客户端临时字段)
|
|
2540
|
-
attachments: Array.isArray(s.attachments) ? s.attachments.map(a => ({
|
|
2541
|
-
id: a.id,
|
|
2542
|
-
originalName: a.originalName,
|
|
2543
|
-
mimeType: a.mimeType,
|
|
2544
|
-
size: a.size,
|
|
2545
|
-
ext: a.ext,
|
|
2546
|
-
storedName: a.storedName,
|
|
2547
|
-
absolutePath: a.absolutePath,
|
|
2548
|
-
createdAt: a.createdAt
|
|
2549
|
-
})) : (tasks[i].subtasks.find(x => x.id === s.id)?.attachments || [])
|
|
2550
|
-
})) : tasks[i].subtasks,
|
|
2551
|
-
updatedAt: now
|
|
2552
|
-
};
|
|
2553
|
-
await writeJson(TASKS_FILE, { tasks });
|
|
2554
|
-
return res.json({ success: true, task: tasks[i] });
|
|
2555
|
-
}
|
|
2556
|
-
const task = {
|
|
2557
|
-
id: genId(),
|
|
2558
|
-
title: safeTitle,
|
|
2559
|
-
desc: desc || '',
|
|
2560
|
-
promptId: promptId || null,
|
|
2561
|
-
type: taskType,
|
|
2562
|
-
// 新建任务:简单任务固定 true,复杂任务用传入值(默认 true)
|
|
2563
|
-
sequential: taskType === 'complex' ? sequential : true,
|
|
2564
|
-
simpleOverride: taskType === 'simple' ? safeOverride : '',
|
|
2565
|
-
projectPath: currentProjectPath || '',
|
|
2566
|
-
subtasks: Array.isArray(subtasks) ? subtasks.map(s => ({
|
|
2567
|
-
id: s.id || genId(),
|
|
2568
|
-
title: s.title || '',
|
|
2569
|
-
desc: s.desc || '',
|
|
2570
|
-
status: s.status || 'todo',
|
|
2571
|
-
promptOverride: s.promptOverride || '',
|
|
2572
|
-
error: typeof s.error === 'string' ? s.error : undefined,
|
|
2573
|
-
errorAt: typeof s.errorAt === 'string' ? s.errorAt : undefined,
|
|
2574
|
-
attachments: Array.isArray(s.attachments) ? s.attachments : []
|
|
2575
|
-
})) : [],
|
|
2576
|
-
status: 'todo',
|
|
2577
|
-
createdAt: now,
|
|
2578
|
-
updatedAt: now
|
|
2579
|
-
};
|
|
2580
|
-
tasks.push(task);
|
|
2581
|
-
await writeJson(TASKS_FILE, { tasks });
|
|
2582
|
-
res.json({ success: true, task });
|
|
2583
|
-
} catch (err) {
|
|
2584
|
-
res.status(500).json({ success: false, error: err.message });
|
|
2585
|
-
}
|
|
2586
|
-
});
|
|
2587
|
-
|
|
2588
|
-
// DELETE /api/workbench/tasks/:id
|
|
2589
|
-
// 行为:
|
|
2590
|
-
// - 该 task 下有 running/pending 的 job 时拒绝(避免误杀正在跑的实例)
|
|
2591
|
-
// - 同步清理磁盘上的图片/附件目录,避免孤儿文件:
|
|
2592
|
-
// 1) 主任务附件 IMAGES_DIR/_task-<id>/
|
|
2593
|
-
// 2) 每个子任务附件 IMAGES_DIR/<subId>/
|
|
2594
|
-
// - 从 tasks.json 中删除 task 条目
|
|
2595
|
-
// 前端 sidebar 已用 v-if="!taskIsRunning(t)" 隐藏执行中的删除按钮,这里兜底
|
|
2596
|
-
// 防 DevTools / curl / 第三方脚本绕过。
|
|
2597
|
-
app.delete('/api/workbench/tasks/:id', async (req, res) => {
|
|
2598
|
-
try {
|
|
2599
|
-
const taskId = req.params.id;
|
|
2600
|
-
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
2601
|
-
const allTasks = data.tasks || [];
|
|
2602
|
-
const task = allTasks.find(t => t.id === taskId);
|
|
2603
|
-
if (!task) return res.status(404).json({ success: false, error: '任务不存在' });
|
|
2604
|
-
|
|
2605
|
-
// 1) 检查活跃 job —— 有 running/pending 直接拒绝
|
|
2606
|
-
const live = [];
|
|
2607
|
-
for (const j of jobs.values()) {
|
|
2608
|
-
if (j.taskId !== taskId) continue;
|
|
2609
|
-
if (j.status === 'running' || j.status === 'pending') live.push(j.id);
|
|
2610
|
-
}
|
|
2611
|
-
if (live.length > 0) {
|
|
2612
|
-
return res.status(400).json({ success: false, error: `有 ${live.length} 个 job 正在执行,请先停止` });
|
|
2613
|
-
}
|
|
2614
|
-
|
|
2615
|
-
// 2) 清理磁盘上的图片目录(force: true 让目录不存在也不抛错)
|
|
2616
|
-
// 失败只 warn 不阻断 —— 元数据删除优先,孤儿文件是次要问题
|
|
2617
|
-
try {
|
|
2618
|
-
await fsp.rm(path.join(IMAGES_DIR, '_task-' + taskId), { recursive: true, force: true });
|
|
2619
|
-
} catch (e) {
|
|
2620
|
-
console.warn(`[workbench] failed to remove task image dir for ${taskId}:`, e.message);
|
|
2621
|
-
}
|
|
2622
|
-
if (Array.isArray(task.subtasks)) {
|
|
2623
|
-
for (const sub of task.subtasks) {
|
|
2624
|
-
if (!sub || !sub.id) continue;
|
|
2625
|
-
try {
|
|
2626
|
-
await fsp.rm(path.join(IMAGES_DIR, sub.id), { recursive: true, force: true });
|
|
2627
|
-
} catch (e) {
|
|
2628
|
-
console.warn(`[workbench] failed to remove sub image dir for ${sub.id}:`, e.message);
|
|
2629
|
-
}
|
|
2630
|
-
}
|
|
2631
|
-
}
|
|
2632
|
-
|
|
2633
|
-
// 3) 从 tasks.json 中删除
|
|
2634
|
-
const tasks = allTasks.filter(t => t.id !== taskId);
|
|
2635
|
-
await writeJson(TASKS_FILE, { tasks });
|
|
2636
|
-
res.json({ success: true });
|
|
2637
|
-
} catch (err) {
|
|
2638
|
-
res.status(500).json({ success: false, error: err.message });
|
|
2639
|
-
}
|
|
2640
|
-
});
|
|
2641
|
-
|
|
2642
|
-
// ── 执行任务 ────────────────────────────────────────────────────────
|
|
2643
|
-
app.post('/api/workbench/tasks/:id/run', async (req, res) => {
|
|
2644
|
-
try {
|
|
2645
|
-
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
2646
|
-
const task = (data.tasks || []).find(t => t.id === req.params.id);
|
|
2647
|
-
if (!task) return res.status(404).json({ success: false, error: '任务不存在' });
|
|
2648
|
-
if (!task.subtasks || task.subtasks.length === 0) {
|
|
2649
|
-
return res.status(400).json({ success: false, error: '任务没有子任务' });
|
|
2650
|
-
}
|
|
2651
|
-
const repoPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
|
|
2652
|
-
// 异步执行,立即返回
|
|
2653
|
-
res.json({ success: true, message: '已开始执行' });
|
|
2654
|
-
runTaskQueue(task, repoPath, '').catch(err => {
|
|
2655
|
-
publish('task:error', { taskId: task.id, error: err.message });
|
|
2656
|
-
});
|
|
2657
|
-
} catch (err) {
|
|
2658
|
-
res.status(500).json({ success: false, error: err.message });
|
|
2659
|
-
}
|
|
2660
|
-
});
|
|
2661
|
-
|
|
2662
|
-
// ── 从指定子任务开始执行 ───────────────────────────────────────────
|
|
2663
|
-
// POST /api/workbench/tasks/:id/run-from
|
|
2664
|
-
// 入参 body: { startSubIndex: number }
|
|
2665
|
-
// 行为:
|
|
2666
|
-
// - 从 task.subtasks[startSubIndex] 开始按序执行,前面 [0, startSubIndex) 区间的
|
|
2667
|
-
// done sub 输出会自动作为前序上下文(同单 sub 执行的语义)
|
|
2668
|
-
// - 前面 error / running 的 sub 不会被自动重跑;从 startSubIndex 开始重新走队列
|
|
2669
|
-
// - 适合"中间某个 sub 失败/被取消后,从该 sub 继续"的场景
|
|
2670
|
-
// - startSubIndex 必须落在 [0, task.subtasks.length) 范围内
|
|
2671
|
-
app.post('/api/workbench/tasks/:id/run-from', async (req, res) => {
|
|
2672
|
-
try {
|
|
2673
|
-
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
2674
|
-
const task = (data.tasks || []).find(t => t.id === req.params.id);
|
|
2675
|
-
if (!task) return res.status(404).json({ success: false, error: '任务不存在' });
|
|
2676
|
-
if (!task.subtasks || task.subtasks.length === 0) {
|
|
2677
|
-
return res.status(400).json({ success: false, error: '任务没有子任务' });
|
|
2678
|
-
}
|
|
2679
|
-
const startSubIndex = Number(req.body?.startSubIndex);
|
|
2680
|
-
if (!Number.isInteger(startSubIndex)
|
|
2681
|
-
|| startSubIndex < 0
|
|
2682
|
-
|| startSubIndex >= task.subtasks.length) {
|
|
2683
|
-
return res.status(400).json({ success: false, error: 'startSubIndex 越界' });
|
|
2684
|
-
}
|
|
2685
|
-
const repoPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
|
|
2686
|
-
// 异步执行,立即返回(同 /run 的 fire-and-forget 模式)
|
|
2687
|
-
res.json({ success: true, message: `已从第 ${startSubIndex + 1} 个子任务开始执行` });
|
|
2688
|
-
runTaskQueue(task, repoPath, '', { fromIndex: startSubIndex }).catch(err => {
|
|
2689
|
-
publish('task:error', { taskId: task.id, error: err.message });
|
|
2690
|
-
});
|
|
2691
|
-
} catch (err) {
|
|
2692
|
-
res.status(500).json({ success: false, error: err.message });
|
|
2693
|
-
}
|
|
2694
|
-
});
|
|
2695
|
-
|
|
2696
|
-
// ── 执行简单任务(无子任务直接跑) ──────────────────────────────────
|
|
2697
|
-
// POST /api/workbench/tasks/:id/run-simple
|
|
2698
|
-
// 行为:
|
|
2699
|
-
// - 适用于 type==='simple' 的任务,无需拆分子任务
|
|
2700
|
-
// - 在内存里合成一个虚拟 sub{title=task.title, desc=task.desc,
|
|
2701
|
-
// promptOverride=task.simpleOverride, status='todo'},
|
|
2702
|
-
// 复用 runSingleSubtask(同一套 prompt 拼装、附件、job 跟踪、取消、落盘)
|
|
2703
|
-
// - 不会修改 task.subtasks 持久化结构
|
|
2704
|
-
app.post('/api/workbench/tasks/:id/run-simple', async (req, res) => {
|
|
2705
|
-
try {
|
|
2706
|
-
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
2707
|
-
const task = (data.tasks || []).find(t => t.id === req.params.id);
|
|
2708
|
-
if (!task) return res.status(404).json({ success: false, error: '任务不存在' });
|
|
2709
|
-
if (task.type !== 'simple') {
|
|
2710
|
-
return res.status(400).json({ success: false, error: '该任务不是简单任务,请使用普通执行接口' });
|
|
2711
|
-
}
|
|
2712
|
-
const repoPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
|
|
2713
|
-
// 虚拟 sub:不写回 tasks.json,只用一次
|
|
2714
|
-
const virtualSub = {
|
|
2715
|
-
id: `${task.id}__simple`,
|
|
2716
|
-
title: task.title,
|
|
2717
|
-
desc: task.desc || '',
|
|
2718
|
-
status: 'todo',
|
|
2719
|
-
promptOverride: task.simpleOverride || '',
|
|
2720
|
-
attachments: Array.isArray(task.attachments) ? task.attachments : []
|
|
2721
|
-
};
|
|
2722
|
-
// 简单任务本身没有 subtasks 列表,直接调 runSingleSubtask(不再走 runTaskQueue 循环)
|
|
2723
|
-
res.json({ success: true, message: '已开始执行简单任务' });
|
|
2724
|
-
runSingleSubtask(task, virtualSub, repoPath, '', []).catch(err => {
|
|
2725
|
-
publish('task:error', { taskId: task.id, error: err.message });
|
|
2726
|
-
});
|
|
2727
|
-
} catch (err) {
|
|
2728
|
-
res.status(500).json({ success: false, error: err.message });
|
|
2729
|
-
}
|
|
2730
|
-
});
|
|
2731
|
-
|
|
2732
|
-
// ── 执行单个子任务 ────────────────────────────────────────────────────
|
|
2733
|
-
// POST /api/workbench/subtasks/:id/run
|
|
2734
|
-
// 行为:
|
|
2735
|
-
// - 仅执行指定 id 的这一个 sub,不再遍历 task 下其他 sub
|
|
2736
|
-
// - 拼 prompt 时会把"前面已 done 的 sub"输出摘要作为前序上下文塞进去,
|
|
2737
|
-
// 跟整批执行的语义保持一致
|
|
2738
|
-
// - 整批"执行任务"队列和单 sub 执行共用 runSingleSubtask,所以日志/状态
|
|
2739
|
-
// /取消/落盘逻辑完全一致
|
|
2740
|
-
// 限制:
|
|
2741
|
-
// - 该 sub 处于 running/pending 时拒绝(并发跑同一个 sub 会出现状态混乱)
|
|
2742
|
-
app.post('/api/workbench/subtasks/:id/run', async (req, res) => {
|
|
2743
|
-
try {
|
|
2744
|
-
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
2745
|
-
const subId = req.params.id;
|
|
2746
|
-
let foundTask = null;
|
|
2747
|
-
let foundSub = null;
|
|
2748
|
-
for (const t of (data.tasks || [])) {
|
|
2749
|
-
if (!Array.isArray(t.subtasks)) continue;
|
|
2750
|
-
const s = t.subtasks.find(x => x.id === subId);
|
|
2751
|
-
if (s) { foundTask = t; foundSub = s; break; }
|
|
2752
|
-
}
|
|
2753
|
-
if (!foundTask || !foundSub) {
|
|
2754
|
-
return res.status(404).json({ success: false, error: '子任务不存在' });
|
|
2755
|
-
}
|
|
2756
|
-
if (foundSub.status === 'running') {
|
|
2757
|
-
return res.status(400).json({ success: false, error: '该子任务正在执行中' });
|
|
2758
|
-
}
|
|
2759
|
-
// 兜底:即便磁盘状态是 todo,如果磁盘里还没归档的 job 还在跑(进程被孤儿),也拦一下
|
|
2760
|
-
const liveJob = snapshotJobs().find(j => j.subId === subId && (j.status === 'running' || j.status === 'pending'));
|
|
2761
|
-
if (liveJob) {
|
|
2762
|
-
return res.status(400).json({ success: false, error: '该子任务已有正在执行的 job' });
|
|
2763
|
-
}
|
|
2764
|
-
const repoPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
|
|
2765
|
-
// 异步执行,立即返回
|
|
2766
|
-
res.json({ success: true, message: `已开始执行子任务:${foundSub.title || subId}` });
|
|
2767
|
-
(async () => {
|
|
2768
|
-
try {
|
|
2769
|
-
const priorOutputs = await collectPriorOutputs(foundTask, foundSub);
|
|
2770
|
-
await runSingleSubtask(foundTask, foundSub, repoPath, '', priorOutputs);
|
|
2771
|
-
await persistTaskAfterRun(foundTask);
|
|
2772
|
-
} catch (err) {
|
|
2773
|
-
publish('task:error', { taskId: foundTask.id, error: err.message });
|
|
2774
|
-
}
|
|
2775
|
-
})();
|
|
2776
|
-
} catch (err) {
|
|
2777
|
-
res.status(500).json({ success: false, error: err.message });
|
|
2778
|
-
}
|
|
2779
|
-
});
|
|
2780
|
-
|
|
2781
|
-
// ── 进程状态查询(兜底,SSE 断了也能拉) ────────────────────────────
|
|
2782
|
-
// 合并内存 jobs Map(当前进程的活跃 job)+ jobs.json 落盘历史。
|
|
2783
|
-
// 之前只走 snapshotJobs():进程重启后内存清空,新开页签就看不到旧 job 的输出。
|
|
2784
|
-
// WorkbenchView 在 onMounted 调一次,靠这个接口把历史 job 重新塞回 jobs.value,
|
|
2785
|
-
// 才能用 jobOf(subId) 找到对应记录渲染 JobLogDetails。
|
|
2786
|
-
// 文件里的字段已经是落盘时 serialize 过的精简形态,跟 snapshotJobs() 同 schema,
|
|
2787
|
-
// 直接合并即可,不需要再走 serializeJob 补 taskTitle/subTitle(WorkbenchView 不用)。
|
|
2788
|
-
app.get('/api/workbench/jobs', async (_req, res) => {
|
|
2789
|
-
try {
|
|
2790
|
-
const data = await readJson(JOBS_FILE, { version: 1, jobs: [] })
|
|
2791
|
-
const fileJobs = (data && Array.isArray(data.jobs)) ? data.jobs : []
|
|
2792
|
-
const fileIds = new Set(fileJobs.map(j => j.id))
|
|
2793
|
-
// 内存里 running/pending 或刚起还没刷盘的,文件里没的,必须补
|
|
2794
|
-
const liveOnly = snapshotJobs().filter(j => !fileIds.has(j.id))
|
|
2795
|
-
res.json({ success: true, jobs: [...fileJobs, ...liveOnly] })
|
|
2796
|
-
} catch (err) {
|
|
2797
|
-
// 读取失败兜底:只返内存的,UI 至少能看到当前活跃 job
|
|
2798
|
-
res.json({ success: true, jobs: snapshotJobs() })
|
|
2799
|
-
}
|
|
2800
|
-
});
|
|
2801
|
-
|
|
2802
|
-
// ── 取消正在执行的 job ───────────────────────────────────────────
|
|
2803
|
-
// POST /api/workbench/jobs/:id/cancel
|
|
2804
|
-
// 行为:
|
|
2805
|
-
// - 找到正在运行的 job,调 child.kill() 终止 claude 进程
|
|
2806
|
-
// - Windows 下用 taskkill /T /F 杀进程树(claude 进程可能 fork 出子进程)
|
|
2807
|
-
// - 加入 cancelledJobs 集合,runTaskQueue 退出循环后会把 job 标为 'cancelled'
|
|
2808
|
-
// - 同步把 sub.status 改为 'cancelled' 并 publish sub:update,
|
|
2809
|
-
// 否则前端 sub 列表/任务头黄点会一直显示 running(sub.status 没动)。
|
|
2810
|
-
// 历史这里只改 job.status 导致 UI 误显示「执行中」。
|
|
2811
|
-
// - 只影响这一个 sub;同 task 后续 sub 在 sequential=false 时继续执行,
|
|
2812
|
-
// sequential=true 时由 runTaskQueue 自然 break(见 runSingleSubtask 的 cancelled 分支)
|
|
2813
|
-
app.post('/api/workbench/jobs/:id/cancel', (req, res) => {
|
|
2814
|
-
const job = jobs.get(req.params.id)
|
|
2815
|
-
if (!job) {
|
|
2816
|
-
return res.status(404).json({ success: false, error: 'job 不存在' })
|
|
2817
|
-
}
|
|
2818
|
-
if (job.status !== 'running' && job.status !== 'pending') {
|
|
2819
|
-
return res.status(400).json({ success: false, error: `当前状态 ${job.status} 不可取消` })
|
|
2820
|
-
}
|
|
2821
|
-
cancelledJobs.add(job.id)
|
|
2822
|
-
// 立即给前端一个状态反馈(不等 child 真正退出)
|
|
2823
|
-
job.status = 'cancelled'
|
|
2824
|
-
job.error = '用户已停止执行'
|
|
2825
|
-
job.endedAt = nowIso()
|
|
2826
|
-
publish('job:update', { ...job }) // 用浅拷贝避免序列化 child 引用
|
|
2827
|
-
// 同步把 sub 也置 cancelled,否则 taskIsRunning(sub.status==='running') 一直 true
|
|
2828
|
-
// → 左侧任务头黄点 + 右侧 sub 列表 running 动效都不消失
|
|
2829
|
-
const subInfo = syncSubToCancelled(job)
|
|
2830
|
-
// 终态:fire-and-forget 同步落盘,cancel 是显式操作,要保证不丢
|
|
2831
|
-
flushJobsSaveNow().catch(err => logger.warn('[workbench] jobs save failed:', err.message))
|
|
2832
|
-
const child = job.child
|
|
2833
|
-
if (!child) {
|
|
2834
|
-
return res.json({ success: true, message: '已标记取消,进程将尽快结束' })
|
|
2835
|
-
}
|
|
2836
|
-
try {
|
|
2837
|
-
if (process.platform === 'win32') {
|
|
2838
|
-
// Windows: child.kill(SIGTERM) 经常无效,用 taskkill 杀进程树
|
|
2839
|
-
execFile('taskkill', ['/PID', String(child.pid), '/T', '/F'], (err) => {
|
|
2840
|
-
if (err) {
|
|
2841
|
-
logger.warn(`[workbench] taskkill ${child.pid} 失败:`, err.message)
|
|
2842
|
-
}
|
|
2843
|
-
})
|
|
2844
|
-
} else {
|
|
2845
|
-
child.kill('SIGTERM')
|
|
2846
|
-
}
|
|
2847
|
-
res.json({ success: true, message: '已发送停止信号' })
|
|
2848
|
-
} catch (err) {
|
|
2849
|
-
cancelledJobs.delete(job.id)
|
|
2850
|
-
res.status(500).json({ success: false, error: '发送停止信号失败: ' + err.message })
|
|
2851
|
-
}
|
|
2852
|
-
});
|
|
2853
|
-
|
|
2854
|
-
// ── 续接简单任务对话 ────────────────────────────────────────────
|
|
2855
|
-
// POST /api/workbench/jobs/:id/continue
|
|
2856
|
-
// 入参 body: { userMessage: string, attachments?: Attachment[] }
|
|
2857
|
-
// 行为:
|
|
2858
|
-
// - 仅用于 type==='simple' 的任务,基于上一轮 job 捕获到的 claudeSessionId,
|
|
2859
|
-
// 用 claude --resume <id> 续接历史对话,**新一轮 = 新 job**
|
|
2860
|
-
// - 新 job 的 subId 形如 `${task.id}__simple__r${n}`,跟首轮 `${task.id}__simple`
|
|
2861
|
-
// 区分;前端按 subId 前缀聚合渲染对话流
|
|
2862
|
-
// - 上一轮还在 pending/running 时拒绝(避免 session 并发跑乱)
|
|
2863
|
-
// - 上一轮未捕获到 session_id(claude 在 init 前就 crash)时拒绝
|
|
2864
|
-
// - 附件处理(2026-06 改):
|
|
2865
|
-
// · 默认复用 task.attachments(claude --resume 上下文里已经看过这些图,
|
|
2866
|
-
// 不重复推,节省 token)
|
|
2867
|
-
// · 前端可显式传 attachments 数组做"追加"(用户续聊时新增的图片)
|
|
2868
|
-
// · 透传到 virtualSub.attachments,继续走 runSingleSubtask 的拼装逻辑
|
|
2869
|
-
app.post('/api/workbench/jobs/:id/continue', async (req, res) => {
|
|
2870
|
-
try {
|
|
2871
|
-
const userMessage = String(req.body?.userMessage || '').trim();
|
|
2872
|
-
if (!userMessage) {
|
|
2873
|
-
return res.status(400).json({ success: false, error: 'userMessage 不能为空' });
|
|
2874
|
-
}
|
|
2875
|
-
const prevJob = jobs.get(req.params.id);
|
|
2876
|
-
if (!prevJob) {
|
|
2877
|
-
return res.status(404).json({ success: false, error: 'job 不存在' });
|
|
2878
|
-
}
|
|
2879
|
-
if (prevJob.status === 'running' || prevJob.status === 'pending') {
|
|
2880
|
-
return res.status(400).json({ success: false, error: '上一轮还在执行,请等结束后再续接' });
|
|
2881
|
-
}
|
|
2882
|
-
if (!prevJob.claudeSessionId) {
|
|
2883
|
-
return res.status(400).json({ success: false, error: '无法续接:会话标识未捕获' });
|
|
2884
|
-
}
|
|
2885
|
-
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
2886
|
-
const task = (data.tasks || []).find(t => t.id === prevJob.taskId);
|
|
2887
|
-
if (!task) {
|
|
2888
|
-
return res.status(404).json({ success: false, error: '所属任务不存在' });
|
|
2889
|
-
}
|
|
2890
|
-
if (task.type !== 'simple') {
|
|
2891
|
-
return res.status(400).json({ success: false, error: '仅支持简单任务的续接' });
|
|
2892
|
-
}
|
|
2893
|
-
// 计算续接轮次号:扫所有同 task 下的 __simple / __simple__rN job,取最大 N + 1
|
|
2894
|
-
const subIdPrefix = `${task.id}__simple`;
|
|
2895
|
-
let maxRound = 0;
|
|
2896
|
-
for (const j of jobs.values()) {
|
|
2897
|
-
if (j.taskId !== task.id) continue;
|
|
2898
|
-
if (j.subId === subIdPrefix) continue;
|
|
2899
|
-
const m = j.subId && j.subId.match(/__simple__r(\d+)$/);
|
|
2900
|
-
if (m) {
|
|
2901
|
-
const n = parseInt(m[1], 10);
|
|
2902
|
-
if (!isNaN(n) && n > maxRound) maxRound = n;
|
|
2903
|
-
}
|
|
2904
|
-
}
|
|
2905
|
-
const nextRound = maxRound + 1;
|
|
2906
|
-
// 续接轮的 attachments:
|
|
2907
|
-
// - 前端显式传 attachments:用前端值(复用 task.attachments + 续聊时新增的)
|
|
2908
|
-
// - 前端没传:回退到 task.attachments(向后兼容)
|
|
2909
|
-
// 注意:本轮 attachments 不写盘,只用于拼 prompt,claude --resume 上下文已看过
|
|
2910
|
-
// 大部分图(默认复用的那些),真正"新加"的图是这次对话的关键信息。
|
|
2911
|
-
const bodyAtts = req.body?.attachments;
|
|
2912
|
-
const carryAtts = Array.isArray(bodyAtts) && bodyAtts.length > 0
|
|
2913
|
-
? bodyAtts
|
|
2914
|
-
: (Array.isArray(task.attachments) ? task.attachments : []);
|
|
2915
|
-
const virtualSub = {
|
|
2916
|
-
id: `${task.id}__simple__r${nextRound}`,
|
|
2917
|
-
title: `续接 #${nextRound}`,
|
|
2918
|
-
desc: '',
|
|
2919
|
-
status: 'todo',
|
|
2920
|
-
// 用户输入的续接消息直接作为本轮 prompt(走 promptOverride,不再拼模板)
|
|
2921
|
-
promptOverride: userMessage,
|
|
2922
|
-
// 透传:复用 task 上已有附件 + 本轮新增(如果有)
|
|
2923
|
-
attachments: carryAtts
|
|
2924
|
-
};
|
|
2925
|
-
const repoPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
|
|
2926
|
-
res.json({ success: true, message: '已加入续接队列' });
|
|
2927
|
-
runSingleSubtask(task, virtualSub, repoPath, '', [], { resumeSessionId: prevJob.claudeSessionId })
|
|
2928
|
-
.catch(err => {
|
|
2929
|
-
publish('task:error', { taskId: task.id, error: err.message });
|
|
2930
|
-
});
|
|
2931
|
-
} catch (err) {
|
|
2932
|
-
res.status(500).json({ success: false, error: err.message });
|
|
2933
|
-
}
|
|
2934
|
-
});
|
|
2935
|
-
|
|
2936
|
-
// ── 执行日志管理 API(持久化 + 清理) ────────────────────────────
|
|
2937
|
-
// 路径都在 /api/workbench/jobs/* 下;/list、/config、/batch-delete、/clear
|
|
2938
|
-
// 是字面路径,必须排在 :id 路由之前注册,避免被 :id 匹配吞掉。
|
|
2939
|
-
//
|
|
2940
|
-
// 数据来源:
|
|
2941
|
-
// - 文件 jobs.json:已落盘的历史 job(含反范式 taskTitle/subTitle)
|
|
2942
|
-
// - 内存 jobs Map:当前进程刚创建还没刷盘的(尤其是 running/pending)
|
|
2943
|
-
// 合并后再过滤分页,保证管理页能看到"刚启动还没结束"的任务。
|
|
2944
|
-
|
|
2945
|
-
async function loadAllJobs() {
|
|
2946
|
-
// 读 tasks.json 一次,给内存里没落盘的 job 反范式补 title
|
|
2947
|
-
const tasksData = await readJson(TASKS_FILE, { tasks: [] })
|
|
2948
|
-
const taskMap = new Map((tasksData.tasks || []).map(t => [t.id, t]))
|
|
2949
|
-
const data = await readJson(JOBS_FILE, { version: 1, jobs: [] })
|
|
2950
|
-
const fileJobs = (data && Array.isArray(data.jobs)) ? data.jobs : []
|
|
2951
|
-
const fileIds = new Set(fileJobs.map(j => j.id))
|
|
2952
|
-
// 内存里有但文件里没有:running/pending 或最近刚起还没刷盘的
|
|
2953
|
-
const liveOnly = Array.from(jobs.values())
|
|
2954
|
-
.filter(j => !fileIds.has(j.id))
|
|
2955
|
-
.map(j => serializeJob(j, taskMap))
|
|
2956
|
-
return [...fileJobs, ...liveOnly]
|
|
2957
|
-
}
|
|
2958
|
-
|
|
2959
|
-
function applyJobsFilter(list, q) {
|
|
2960
|
-
const status = (q.status || '').trim()
|
|
2961
|
-
const taskId = (q.taskId || '').trim()
|
|
2962
|
-
const term = (q.q || '').trim().toLowerCase()
|
|
2963
|
-
return list.filter(j => {
|
|
2964
|
-
if (status && j.status !== status) return false
|
|
2965
|
-
if (taskId && j.taskId !== taskId) return false
|
|
2966
|
-
if (term) {
|
|
2967
|
-
const hay = `${j.title || ''} ${j.taskTitle || ''} ${j.subTitle || ''}`.toLowerCase()
|
|
2968
|
-
if (!hay.includes(term)) return false
|
|
2969
|
-
}
|
|
2970
|
-
return true
|
|
2971
|
-
})
|
|
2972
|
-
}
|
|
2973
|
-
|
|
2974
|
-
// GET /api/workbench/jobs/list?status=&q=&taskId=&limit=&offset=
|
|
2975
|
-
app.get('/api/workbench/jobs/list', async (req, res) => {
|
|
2976
|
-
try {
|
|
2977
|
-
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit, 10) || 50))
|
|
2978
|
-
const offset = Math.max(0, parseInt(req.query.offset, 10) || 0)
|
|
2979
|
-
const all = await loadAllJobs()
|
|
2980
|
-
const filtered = applyJobsFilter(all, req.query)
|
|
2981
|
-
// 按 endedAt desc;没有就 startedAt desc;都没有就 id desc(id 时间戳前缀可比)
|
|
2982
|
-
const sortKey = (j) => j.endedAt || j.startedAt || j.id || ''
|
|
2983
|
-
filtered.sort((a, b) => sortKey(b).localeCompare(sortKey(a)))
|
|
2984
|
-
const total = filtered.length
|
|
2985
|
-
const page = filtered.slice(offset, offset + limit)
|
|
2986
|
-
// 统计:按 status 分组 + 总 size(基于全集,给顶部条用)
|
|
2987
|
-
const byStatus = {}
|
|
2988
|
-
let totalSize = 0
|
|
2989
|
-
for (const j of all) {
|
|
2990
|
-
byStatus[j.status] = (byStatus[j.status] || 0) + 1
|
|
2991
|
-
totalSize += j.size || 0
|
|
2992
|
-
}
|
|
2993
|
-
res.json({
|
|
2994
|
-
success: true,
|
|
2995
|
-
jobs: page,
|
|
2996
|
-
total,
|
|
2997
|
-
stats: { count: all.length, sizeMB: +(totalSize / 1024 / 1024).toFixed(2), byStatus }
|
|
2998
|
-
})
|
|
2999
|
-
} catch (err) {
|
|
3000
|
-
res.status(500).json({ success: false, error: 'list jobs 失败: ' + (err.message || String(err)) })
|
|
3001
|
-
}
|
|
3002
|
-
})
|
|
3003
|
-
|
|
3004
|
-
// GET /api/workbench/jobs/config
|
|
3005
|
-
app.get('/api/workbench/jobs/config', async (_req, res) => {
|
|
3006
|
-
try {
|
|
3007
|
-
res.json({ success: true, config: await readJobsConfig() })
|
|
3008
|
-
} catch (err) {
|
|
3009
|
-
res.status(500).json({ success: false, error: '读取配置失败: ' + err.message })
|
|
3010
|
-
}
|
|
3011
|
-
})
|
|
3012
|
-
|
|
3013
|
-
// PUT /api/workbench/jobs/config
|
|
3014
|
-
app.put('/api/workbench/jobs/config', async (req, res) => {
|
|
3015
|
-
try {
|
|
3016
|
-
const cfg = await writeJobsConfig(req.body || {})
|
|
3017
|
-
// 配置变更后立刻 enforce,让已落盘的多余记录立刻被裁掉
|
|
3018
|
-
await enforceRetention()
|
|
3019
|
-
res.json({ success: true, config: cfg })
|
|
3020
|
-
} catch (err) {
|
|
3021
|
-
res.status(400).json({ success: false, error: err.message || String(err) })
|
|
3022
|
-
}
|
|
3023
|
-
})
|
|
3024
|
-
|
|
3025
|
-
// POST /api/workbench/jobs/batch-delete
|
|
3026
|
-
app.post('/api/workbench/jobs/batch-delete', async (req, res) => {
|
|
3027
|
-
try {
|
|
3028
|
-
const ids = Array.isArray(req.body?.ids) ? req.body.ids.filter(s => typeof s === 'string') : []
|
|
3029
|
-
if (ids.length === 0) return res.json({ success: true, removed: 0 })
|
|
3030
|
-
// 1) 删内存 Map
|
|
3031
|
-
let removed = 0
|
|
3032
|
-
for (const id of ids) {
|
|
3033
|
-
if (jobs.delete(id)) removed++
|
|
3034
|
-
}
|
|
3035
|
-
// 2) 改写文件
|
|
3036
|
-
const data = await readJson(JOBS_FILE, { version: 1, jobs: [] })
|
|
3037
|
-
if (data && Array.isArray(data.jobs)) {
|
|
3038
|
-
const set = new Set(ids)
|
|
3039
|
-
const before = data.jobs.length
|
|
3040
|
-
data.jobs = data.jobs.filter(j => !set.has(j.id))
|
|
3041
|
-
removed += before - data.jobs.length
|
|
3042
|
-
await writeJson(JOBS_FILE, data)
|
|
3043
|
-
}
|
|
3044
|
-
res.json({ success: true, removed })
|
|
3045
|
-
} catch (err) {
|
|
3046
|
-
res.status(500).json({ success: false, error: '批量删除失败: ' + (err.message || String(err)) })
|
|
3047
|
-
}
|
|
3048
|
-
})
|
|
3049
|
-
|
|
3050
|
-
// POST /api/workbench/jobs/clear
|
|
3051
|
-
app.post('/api/workbench/jobs/clear', async (req, res) => {
|
|
3052
|
-
try {
|
|
3053
|
-
if (req.body?.confirm !== true) {
|
|
3054
|
-
return res.status(400).json({ success: false, error: '需要 confirm: true' })
|
|
3055
|
-
}
|
|
3056
|
-
let removed = 0
|
|
3057
|
-
for (const j of jobs.values()) {
|
|
3058
|
-
// 不清当前还在跑/排队的(防止误清活跃 job;用户应逐个取消或等结束)
|
|
3059
|
-
if (j.status === 'running' || j.status === 'pending') continue
|
|
3060
|
-
jobs.delete(j.id)
|
|
3061
|
-
removed++
|
|
3062
|
-
}
|
|
3063
|
-
// 写一个空 jobs.json
|
|
3064
|
-
await writeJson(JOBS_FILE, { version: 1, jobs: [] })
|
|
3065
|
-
res.json({ success: true, removed })
|
|
3066
|
-
} catch (err) {
|
|
3067
|
-
res.status(500).json({ success: false, error: '清空失败: ' + (err.message || String(err)) })
|
|
3068
|
-
}
|
|
3069
|
-
})
|
|
3070
|
-
|
|
3071
|
-
// DELETE /api/workbench/jobs/by-task/:taskId
|
|
3072
|
-
// 重新执行任务前清空该 task 下的所有旧 job(内存 + 文件双写)。
|
|
3073
|
-
// 不清活跃的 running/pending,避免误杀正在跑的实例;前端启动新 run 时
|
|
3074
|
-
// 应确认旧任务已结束,或在活跃态时拒绝调用本接口。
|
|
3075
|
-
// ?keepDone=true 时只清 non-done 的 job(保留 done 历史的日志),用于
|
|
3076
|
-
// "只跑未完成" 场景——避免偷偷清掉 done sub 的日志,让前端 jobOf(subId)
|
|
3077
|
-
// 在新页签上还能找到。
|
|
3078
|
-
app.delete('/api/workbench/jobs/by-task/:taskId', async (req, res) => {
|
|
3079
|
-
try {
|
|
3080
|
-
const taskId = req.params.taskId
|
|
3081
|
-
if (!taskId) return res.status(400).json({ success: false, error: '缺少 taskId' })
|
|
3082
|
-
const keepDone = String(req.query.keepDone || '').toLowerCase() === 'true'
|
|
3083
|
-
const ids = []
|
|
3084
|
-
for (const j of jobs.values()) {
|
|
3085
|
-
if (j.taskId !== taskId) continue
|
|
3086
|
-
if (j.status === 'running' || j.status === 'pending') continue
|
|
3087
|
-
if (keepDone && j.status === 'done') continue
|
|
3088
|
-
jobs.delete(j.id)
|
|
3089
|
-
ids.push(j.id)
|
|
3090
|
-
}
|
|
3091
|
-
const data = await readJson(JOBS_FILE, { version: 1, jobs: [] })
|
|
3092
|
-
if (data && Array.isArray(data.jobs)) {
|
|
3093
|
-
const before = data.jobs.length
|
|
3094
|
-
data.jobs = data.jobs.filter(j => {
|
|
3095
|
-
if (j.taskId !== taskId) return true
|
|
3096
|
-
if (keepDone && j.status === 'done') return true
|
|
3097
|
-
return !ids.includes(j.id)
|
|
3098
|
-
})
|
|
3099
|
-
const removed = before - data.jobs.length
|
|
3100
|
-
if (removed > 0) await writeJson(JOBS_FILE, data)
|
|
3101
|
-
return res.json({ success: true, removed: ids.length + removed, ids })
|
|
3102
|
-
}
|
|
3103
|
-
res.json({ success: true, removed: ids.length, ids })
|
|
3104
|
-
} catch (err) {
|
|
3105
|
-
res.status(500).json({ success: false, error: '按 task 清空失败: ' + (err.message || String(err)) })
|
|
3106
|
-
}
|
|
3107
|
-
})
|
|
3108
|
-
|
|
3109
|
-
// POST /api/workbench/tasks/:id/clear-execution
|
|
3110
|
-
// 一次性清空指定 task 的所有执行痕迹:
|
|
3111
|
-
// 1. 删除内存 + 磁盘 jobs.json 中该 task 的全部 job(跳过 running/pending)
|
|
3112
|
-
// 2. 把 tasks.json 中该 task 的所有 subtasks status 重置为 'todo'(让"重新执行"能从头跑)
|
|
3113
|
-
// 3. 同步清掉 sub 的 error 字段,broadcast task:update + 一组 sub:update
|
|
3114
|
-
// 任务描述、附件、提示词模板等"内容"字段保留不动——只清"执行痕迹"。
|
|
3115
|
-
// 活跃态(有 running/pending job)时拒绝,避免误杀正在跑的实例。
|
|
3116
|
-
app.post('/api/workbench/tasks/:id/clear-execution', async (req, res) => {
|
|
3117
|
-
try {
|
|
3118
|
-
const taskId = req.params.id
|
|
3119
|
-
if (!taskId) return res.status(400).json({ success: false, error: '缺少 taskId' })
|
|
3120
|
-
// 检查活跃 job
|
|
3121
|
-
const live = []
|
|
3122
|
-
for (const j of jobs.values()) {
|
|
3123
|
-
if (j.taskId !== taskId) continue
|
|
3124
|
-
if (j.status === 'running' || j.status === 'pending') live.push(j.id)
|
|
3125
|
-
}
|
|
3126
|
-
if (live.length > 0) {
|
|
3127
|
-
return res.status(400).json({ success: false, error: `有 ${live.length} 个 job 正在执行,请先停止` })
|
|
3128
|
-
}
|
|
3129
|
-
// 1) 清空 jobs(内存 + 磁盘)
|
|
3130
|
-
const removedJobIds = []
|
|
3131
|
-
for (const j of jobs.values()) {
|
|
3132
|
-
if (j.taskId !== taskId) continue
|
|
3133
|
-
jobs.delete(j.id)
|
|
3134
|
-
removedJobIds.push(j.id)
|
|
3135
|
-
}
|
|
3136
|
-
const jobsData = await readJson(JOBS_FILE, { version: 1, jobs: [] })
|
|
3137
|
-
if (jobsData && Array.isArray(jobsData.jobs)) {
|
|
3138
|
-
const before = jobsData.jobs.length
|
|
3139
|
-
jobsData.jobs = jobsData.jobs.filter(j => j.taskId !== taskId)
|
|
3140
|
-
if (jobsData.jobs.length !== before) await writeJson(JOBS_FILE, jobsData)
|
|
3141
|
-
}
|
|
3142
|
-
// 2) 重置 subtasks.status → todo
|
|
3143
|
-
const tasksData = await readJson(TASKS_FILE, { tasks: [] })
|
|
3144
|
-
const task = (tasksData.tasks || []).find(t => t.id === taskId)
|
|
3145
|
-
if (!task) {
|
|
3146
|
-
return res.json({ success: true, removedJobs: removedJobIds.length, resetSubs: 0, message: '任务不存在,仅清空 job' })
|
|
3147
|
-
}
|
|
3148
|
-
let resetSubs = 0
|
|
3149
|
-
if (Array.isArray(task.subtasks)) {
|
|
3150
|
-
for (const s of task.subtasks) {
|
|
3151
|
-
if (s.status !== 'todo') {
|
|
3152
|
-
s.status = 'todo'
|
|
3153
|
-
resetSubs++
|
|
3154
|
-
if (s.error) delete s.error
|
|
3155
|
-
publish('sub:update', { taskId, sub: s })
|
|
3156
|
-
}
|
|
3157
|
-
}
|
|
3158
|
-
task.updatedAt = nowIso()
|
|
3159
|
-
await writeJson(TASKS_FILE, tasksData)
|
|
3160
|
-
publish('task:update', task)
|
|
3161
|
-
}
|
|
3162
|
-
res.json({
|
|
3163
|
-
success: true,
|
|
3164
|
-
removedJobs: removedJobIds.length,
|
|
3165
|
-
resetSubs,
|
|
3166
|
-
message: `已清空 ${removedJobIds.length} 条执行记录,${resetSubs} 个子任务重置为待执行`
|
|
3167
|
-
})
|
|
3168
|
-
} catch (err) {
|
|
3169
|
-
res.status(500).json({ success: false, error: '清空执行痕迹失败: ' + (err.message || String(err)) })
|
|
3170
|
-
}
|
|
3171
|
-
})
|
|
3172
|
-
|
|
3173
|
-
// POST /api/workbench/tasks/:id/reset-shell
|
|
3174
|
-
// 把 task 还原成"只有标题的空壳",清掉 desc / attachments / promptId / subtasks。
|
|
3175
|
-
// 与 clear-execution 的区别:
|
|
3176
|
-
// - clear-execution 只重置 sub.status + 清 jobs(保留拆分/描述/附件)
|
|
3177
|
-
// - reset-shell 直接删除 subtasks 数组和描述/附件,任务只剩标题
|
|
3178
|
-
// 同时清空该 task 的所有 job(沿用 by-task 删除语义,跳过 running/pending)。
|
|
3179
|
-
// 活跃态拒绝,避免误杀正在跑的实例。
|
|
3180
|
-
app.post('/api/workbench/tasks/:id/reset-shell', async (req, res) => {
|
|
3181
|
-
try {
|
|
3182
|
-
const taskId = req.params.id
|
|
3183
|
-
if (!taskId) return res.status(400).json({ success: false, error: '缺少 taskId' })
|
|
3184
|
-
// 检查活跃 job
|
|
3185
|
-
const live = []
|
|
3186
|
-
for (const j of jobs.values()) {
|
|
3187
|
-
if (j.taskId !== taskId) continue
|
|
3188
|
-
if (j.status === 'running' || j.status === 'pending') live.push(j.id)
|
|
3189
|
-
}
|
|
3190
|
-
if (live.length > 0) {
|
|
3191
|
-
return res.status(400).json({ success: false, error: `有 ${live.length} 个 job 正在执行,请先停止` })
|
|
3192
|
-
}
|
|
3193
|
-
// 1) 清空 jobs(内存 + 磁盘)
|
|
3194
|
-
const removedJobIds = []
|
|
3195
|
-
for (const j of jobs.values()) {
|
|
3196
|
-
if (j.taskId !== taskId) continue
|
|
3197
|
-
jobs.delete(j.id)
|
|
3198
|
-
removedJobIds.push(j.id)
|
|
3199
|
-
}
|
|
3200
|
-
const jobsData = await readJson(JOBS_FILE, { version: 1, jobs: [] })
|
|
3201
|
-
if (jobsData && Array.isArray(jobsData.jobs)) {
|
|
3202
|
-
const before = jobsData.jobs.length
|
|
3203
|
-
jobsData.jobs = jobsData.jobs.filter(j => j.taskId !== taskId)
|
|
3204
|
-
if (jobsData.jobs.length !== before) await writeJson(JOBS_FILE, jobsData)
|
|
3205
|
-
}
|
|
3206
|
-
// 2) 重置 task 字段:只留 id / title / createdAt / status / projectPath(如存在)
|
|
3207
|
-
const tasksData = await readJson(TASKS_FILE, { tasks: [] })
|
|
3208
|
-
const task = (tasksData.tasks || []).find(t => t.id === taskId)
|
|
3209
|
-
if (!task) {
|
|
3210
|
-
return res.json({ success: true, removedJobs: removedJobIds.length, message: '任务不存在,仅清空 job' })
|
|
3211
|
-
}
|
|
3212
|
-
const removedSubCount = Array.isArray(task.subtasks) ? task.subtasks.length : 0
|
|
3213
|
-
const removedAttCount = Array.isArray(task.attachments) ? task.attachments.length : 0
|
|
3214
|
-
const hadDesc = !!(task.desc && task.desc.length > 0)
|
|
3215
|
-
const hadPrompt = !!task.promptId
|
|
3216
|
-
// 保留字段:id / title / createdAt / status / projectPath
|
|
3217
|
-
// 清理:desc / attachments / promptId / subtasks
|
|
3218
|
-
const preservedProjectPath = task.projectPath
|
|
3219
|
-
const preservedCreatedAt = task.createdAt
|
|
3220
|
-
const preservedStatus = task.status || 'todo'
|
|
3221
|
-
// 重建对象(顺序保留 title 在前,符合视觉习惯)
|
|
3222
|
-
const newTask = { id: task.id, title: task.title, status: preservedStatus }
|
|
3223
|
-
if (preservedProjectPath) newTask.projectPath = preservedProjectPath
|
|
3224
|
-
if (preservedCreatedAt) newTask.createdAt = preservedCreatedAt
|
|
3225
|
-
newTask.subtasks = []
|
|
3226
|
-
newTask.attachments = []
|
|
3227
|
-
newTask.updatedAt = nowIso()
|
|
3228
|
-
// 用 Object.assign 替换 task 的所有自身属性,保留原型链
|
|
3229
|
-
for (const k of Object.keys(task)) delete task[k]
|
|
3230
|
-
Object.assign(task, newTask)
|
|
3231
|
-
await writeJson(TASKS_FILE, tasksData)
|
|
3232
|
-
publish('task:update', task)
|
|
3233
|
-
res.json({
|
|
3234
|
-
success: true,
|
|
3235
|
-
removedJobs: removedJobIds.length,
|
|
3236
|
-
removedSubs: removedSubCount,
|
|
3237
|
-
removedAttachments: removedAttCount,
|
|
3238
|
-
clearedDesc: hadDesc,
|
|
3239
|
-
clearedPrompt: hadPrompt,
|
|
3240
|
-
message: `已清空:删除 ${removedSubCount} 个子任务${removedAttCount ? '、' + removedAttCount + ' 个附件' : ''}${hadDesc ? '、任务描述' : ''},任务标题保留`
|
|
3241
|
-
})
|
|
3242
|
-
} catch (err) {
|
|
3243
|
-
res.status(500).json({ success: false, error: '清空任务内容失败: ' + (err.message || String(err)) })
|
|
3244
|
-
}
|
|
3245
|
-
})
|
|
3246
|
-
|
|
3247
|
-
// POST /api/workbench/tasks/:id/clear-subtasks
|
|
3248
|
-
// 只清空 subtasks 数组(及关联的 jobs),保留 desc / attachments / promptId / projectPath。
|
|
3249
|
-
// 与 reset-shell 的区别:reset-shell 把任务还原成只有标题的空壳,这个只删拆分结果。
|
|
3250
|
-
// 活跃 job 拒绝,避免误杀正在跑的实例。
|
|
3251
|
-
app.post('/api/workbench/tasks/:id/clear-subtasks', async (req, res) => {
|
|
3252
|
-
try {
|
|
3253
|
-
const taskId = req.params.id
|
|
3254
|
-
if (!taskId) return res.status(400).json({ success: false, error: '缺少 taskId' })
|
|
3255
|
-
// 1) 检查活跃 job
|
|
3256
|
-
const live = []
|
|
3257
|
-
for (const j of jobs.values()) {
|
|
3258
|
-
if (j.taskId !== taskId) continue
|
|
3259
|
-
if (j.status === 'running' || j.status === 'pending') live.push(j.id)
|
|
3260
|
-
}
|
|
3261
|
-
if (live.length > 0) {
|
|
3262
|
-
return res.status(400).json({ success: false, error: `有 ${live.length} 个 job 正在执行,请先停止` })
|
|
3263
|
-
}
|
|
3264
|
-
// 2) 清空该 task 的所有 jobs(内存 + 磁盘)
|
|
3265
|
-
const removedJobIds = []
|
|
3266
|
-
for (const j of jobs.values()) {
|
|
3267
|
-
if (j.taskId !== taskId) continue
|
|
3268
|
-
jobs.delete(j.id)
|
|
3269
|
-
removedJobIds.push(j.id)
|
|
3270
|
-
}
|
|
3271
|
-
const jobsData = await readJson(JOBS_FILE, { version: 1, jobs: [] })
|
|
3272
|
-
if (jobsData && Array.isArray(jobsData.jobs)) {
|
|
3273
|
-
const before = jobsData.jobs.length
|
|
3274
|
-
jobsData.jobs = jobsData.jobs.filter(j => j.taskId !== taskId)
|
|
3275
|
-
if (jobsData.jobs.length !== before) await writeJson(JOBS_FILE, jobsData)
|
|
3276
|
-
}
|
|
3277
|
-
// 3) 更新 task:只清 subtasks,其他字段(尤其 desc / attachments / promptId)保留
|
|
3278
|
-
const tasksData = await readJson(TASKS_FILE, { tasks: [] })
|
|
3279
|
-
const task = (tasksData.tasks || []).find(t => t.id === taskId)
|
|
3280
|
-
if (!task) {
|
|
3281
|
-
return res.json({ success: true, removedJobs: removedJobIds.length, removedSubs: 0, message: '任务不存在,仅清空 job' })
|
|
3282
|
-
}
|
|
3283
|
-
const removedSubCount = Array.isArray(task.subtasks) ? task.subtasks.length : 0
|
|
3284
|
-
if (removedSubCount === 0) {
|
|
3285
|
-
return res.json({ success: true, removedJobs: removedJobIds.length, removedSubs: 0, message: '没有子任务需要清空' })
|
|
3286
|
-
}
|
|
3287
|
-
task.subtasks = []
|
|
3288
|
-
task.updatedAt = nowIso()
|
|
3289
|
-
await writeJson(TASKS_FILE, tasksData)
|
|
3290
|
-
publish('task:update', task)
|
|
3291
|
-
res.json({
|
|
3292
|
-
success: true,
|
|
3293
|
-
removedJobs: removedJobIds.length,
|
|
3294
|
-
removedSubs: removedSubCount,
|
|
3295
|
-
message: `已清空 ${removedSubCount} 个子任务,任务描述和附件保留`
|
|
3296
|
-
})
|
|
3297
|
-
} catch (err) {
|
|
3298
|
-
res.status(500).json({ success: false, error: '清空子任务失败: ' + (err.message || String(err)) })
|
|
3299
|
-
}
|
|
3300
|
-
})
|
|
3301
|
-
|
|
3302
|
-
// GET /api/workbench/jobs/:id
|
|
3303
|
-
app.get('/api/workbench/jobs/:id', async (req, res) => {
|
|
3304
|
-
try {
|
|
3305
|
-
// 优先查内存(含活跃)
|
|
3306
|
-
const live = jobs.get(req.params.id)
|
|
3307
|
-
if (live) {
|
|
3308
|
-
const tasksData = await readJson(TASKS_FILE, { tasks: [] })
|
|
3309
|
-
const taskMap = new Map((tasksData.tasks || []).map(t => [t.id, t]))
|
|
3310
|
-
return res.json({ success: true, job: serializeJob(live, taskMap) })
|
|
3311
|
-
}
|
|
3312
|
-
// 退回文件
|
|
3313
|
-
const data = await readJson(JOBS_FILE, { version: 1, jobs: [] })
|
|
3314
|
-
const j = (data.jobs || []).find(x => x.id === req.params.id)
|
|
3315
|
-
if (!j) return res.status(404).json({ success: false, error: 'job 不存在' })
|
|
3316
|
-
res.json({ success: true, job: j })
|
|
3317
|
-
} catch (err) {
|
|
3318
|
-
res.status(500).json({ success: false, error: '查询失败: ' + (err.message || String(err)) })
|
|
3319
|
-
}
|
|
3320
|
-
})
|
|
3321
|
-
|
|
3322
|
-
// DELETE /api/workbench/jobs/:id
|
|
3323
|
-
app.delete('/api/workbench/jobs/:id', async (req, res) => {
|
|
3324
|
-
try {
|
|
3325
|
-
const id = req.params.id
|
|
3326
|
-
let removed = false
|
|
3327
|
-
if (jobs.delete(id)) removed = true
|
|
3328
|
-
const data = await readJson(JOBS_FILE, { version: 1, jobs: [] })
|
|
3329
|
-
if (data && Array.isArray(data.jobs)) {
|
|
3330
|
-
const before = data.jobs.length
|
|
3331
|
-
data.jobs = data.jobs.filter(j => j.id !== id)
|
|
3332
|
-
if (data.jobs.length !== before) {
|
|
3333
|
-
removed = true
|
|
3334
|
-
await writeJson(JOBS_FILE, data)
|
|
3335
|
-
}
|
|
3336
|
-
}
|
|
3337
|
-
if (!removed) return res.status(404).json({ success: false, error: 'job 不存在' })
|
|
3338
|
-
res.json({ success: true, removed: 1 })
|
|
3339
|
-
} catch (err) {
|
|
3340
|
-
res.status(500).json({ success: false, error: '删除失败: ' + (err.message || String(err)) })
|
|
3341
|
-
}
|
|
3342
|
-
})
|
|
3343
|
-
|
|
3344
|
-
// ── 子任务附件:上传 / 删除 / 列表 ───────────────────────────────
|
|
3345
|
-
// 上传:POST /api/workbench/subtasks/:subId/attachments
|
|
3346
|
-
// header: X-Original-Name, X-Mime-Type
|
|
3347
|
-
// body: raw binary
|
|
3348
|
-
// 删除:DELETE /api/workbench/subtasks/:subId/attachments/:attId
|
|
3349
|
-
// 列表:GET /api/workbench/subtasks/:subId/attachments
|
|
3350
|
-
//
|
|
3351
|
-
// 文件存到 ~/.zen-gitsync/workbench-images/{subId}/{attId}.{ext}
|
|
3352
|
-
// 元数据(id / originalName / mime / size / storedName)通过 sub.attachments
|
|
3353
|
-
// 跟随 tasks.json 一起持久化。
|
|
3354
|
-
const rawAttachment = express.raw({
|
|
3355
|
-
type: '*/*',
|
|
3356
|
-
limit: MAX_IMAGE_BYTES * 4 // 整体路由上限 20MB;单文件大小由业务再卡
|
|
3357
|
-
});
|
|
3358
|
-
|
|
3359
|
-
// 共享 helper:找到一个 attachment 所在的位置(task 主附件 或 sub 附件)
|
|
3360
|
-
// 返回 { owner, task, sub?, list, att, storageDir } 或 null
|
|
3361
|
-
async function findAttachmentLocation(attId) {
|
|
3362
|
-
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
3363
|
-
for (const t of data.tasks || []) {
|
|
3364
|
-
const list = Array.isArray(t.attachments) ? t.attachments : [];
|
|
3365
|
-
const att = list.find(x => x.id === attId);
|
|
3366
|
-
if (att) {
|
|
3367
|
-
return { owner: 'task', task: t, list, att, storageDir: path.join(IMAGES_DIR, '_task-' + t.id) };
|
|
3368
|
-
}
|
|
3369
|
-
}
|
|
3370
|
-
for (const t of data.tasks || []) {
|
|
3371
|
-
for (const s of t.subtasks || []) {
|
|
3372
|
-
const list = Array.isArray(s.attachments) ? s.attachments : [];
|
|
3373
|
-
const att = list.find(x => x.id === attId);
|
|
3374
|
-
if (att) {
|
|
3375
|
-
return { owner: 'sub', task: t, sub: s, list, att, storageDir: path.join(IMAGES_DIR, s.id) };
|
|
3376
|
-
}
|
|
3377
|
-
}
|
|
3378
|
-
}
|
|
3379
|
-
return null;
|
|
3380
|
-
}
|
|
3381
|
-
|
|
3382
|
-
// 共享 helper:写入新附件(参数化以支持 task / sub)
|
|
3383
|
-
async function writeAttachmentTo({ req, target, maxCount }) {
|
|
3384
|
-
if (!req.body || !(req.body instanceof Buffer) || req.body.length === 0) {
|
|
3385
|
-
return { error: '请求体为空', status: 400 };
|
|
3386
|
-
}
|
|
3387
|
-
if (req.body.length > MAX_IMAGE_BYTES) {
|
|
3388
|
-
return { error: `单文件不得超过 ${MAX_IMAGE_BYTES / 1024 / 1024}MB`, status: 413 };
|
|
3389
|
-
}
|
|
3390
|
-
const originalName = String(req.get('X-Original-Name') || 'attachment').slice(0, 200);
|
|
3391
|
-
const mimeType = String(req.get('X-Mime-Type') || 'application/octet-stream').slice(0, 120);
|
|
3392
|
-
const ext = resolveExt({ originalName, mime: mimeType });
|
|
3393
|
-
if (!ext) {
|
|
3394
|
-
return { error: `不支持的文件类型(仅允许 ${[...ALLOWED_EXTS].join(', ')})`, status: 400 };
|
|
3395
|
-
}
|
|
3396
|
-
if (!Array.isArray(target.attachments)) target.attachments = [];
|
|
3397
|
-
if (target.attachments.length >= maxCount) {
|
|
3398
|
-
return { error: `附件已达上限 ${maxCount} 个`, status: 400 };
|
|
3399
|
-
}
|
|
3400
|
-
|
|
3401
|
-
const attId = genId();
|
|
3402
|
-
await fsp.mkdir(target.storageDir, { recursive: true });
|
|
3403
|
-
const storedName = `${attId}.${ext}`;
|
|
3404
|
-
const storedPath = path.join(target.storageDir, storedName);
|
|
3405
|
-
await fsp.writeFile(storedPath, req.body);
|
|
3406
|
-
|
|
3407
|
-
const attachment = {
|
|
3408
|
-
id: attId,
|
|
3409
|
-
originalName,
|
|
3410
|
-
mimeType,
|
|
3411
|
-
size: req.body.length,
|
|
3412
|
-
ext,
|
|
3413
|
-
storedName,
|
|
3414
|
-
absolutePath: storedPath,
|
|
3415
|
-
createdAt: nowIso()
|
|
3416
|
-
};
|
|
3417
|
-
target.attachments.push(attachment);
|
|
3418
|
-
target.updatedAt = nowIso();
|
|
3419
|
-
return { attachment };
|
|
3420
|
-
}
|
|
3421
|
-
|
|
3422
|
-
// 子任务附件
|
|
3423
|
-
app.post('/api/workbench/subtasks/:subId/attachments', rawAttachment, async (req, res) => {
|
|
3424
|
-
try {
|
|
3425
|
-
const { subId } = req.params;
|
|
3426
|
-
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
3427
|
-
let foundSub = null;
|
|
3428
|
-
for (const t of data.tasks || []) {
|
|
3429
|
-
const s = (t.subtasks || []).find(x => x.id === subId);
|
|
3430
|
-
if (s) { foundSub = s; break; }
|
|
3431
|
-
}
|
|
3432
|
-
if (!foundSub) {
|
|
3433
|
-
return res.status(404).json({ success: false, error: '子任务不存在' });
|
|
3434
|
-
}
|
|
3435
|
-
const target = { ...foundSub, storageDir: path.join(IMAGES_DIR, subId) };
|
|
3436
|
-
const result = await writeAttachmentTo({ req, target, maxCount: MAX_ATTACHMENTS_PER_SUBTASK });
|
|
3437
|
-
if (result.error) return res.status(result.status).json({ success: false, error: result.error });
|
|
3438
|
-
// target 是 spread 出来的浅拷贝,data 引用里的 foundSub 没改;显式 push 回去
|
|
3439
|
-
const att = result.attachment;
|
|
3440
|
-
foundSub.attachments = Array.isArray(foundSub.attachments) ? foundSub.attachments : [];
|
|
3441
|
-
foundSub.attachments.push(att);
|
|
3442
|
-
foundSub.updatedAt = nowIso();
|
|
3443
|
-
await writeJson(TASKS_FILE, data);
|
|
3444
|
-
res.json({ success: true, attachment: att });
|
|
3445
|
-
} catch (err) {
|
|
3446
|
-
res.status(500).json({ success: false, error: err.message });
|
|
3447
|
-
}
|
|
3448
|
-
});
|
|
3449
|
-
|
|
3450
|
-
app.delete('/api/workbench/subtasks/:subId/attachments/:attId', async (req, res) => {
|
|
3451
|
-
try {
|
|
3452
|
-
const { subId, attId } = req.params;
|
|
3453
|
-
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
3454
|
-
let foundSub = null;
|
|
3455
|
-
for (const t of data.tasks || []) {
|
|
3456
|
-
const s = (t.subtasks || []).find(x => x.id === subId);
|
|
3457
|
-
if (s) { foundSub = s; break; }
|
|
3458
|
-
}
|
|
3459
|
-
if (!foundSub) return res.status(404).json({ success: false, error: '子任务不存在' });
|
|
3460
|
-
const list = Array.isArray(foundSub.attachments) ? foundSub.attachments : [];
|
|
3461
|
-
const i = list.findIndex(a => a.id === attId);
|
|
3462
|
-
if (i < 0) return res.status(404).json({ success: false, error: '附件不存在' });
|
|
3463
|
-
const [removed] = list.splice(i, 1);
|
|
3464
|
-
try {
|
|
3465
|
-
await fsp.unlink(path.join(IMAGES_DIR, subId, removed.storedName));
|
|
3466
|
-
} catch { /* 文件可能已不存在 */ }
|
|
3467
|
-
foundSub.updatedAt = nowIso();
|
|
3468
|
-
await writeJson(TASKS_FILE, data);
|
|
3469
|
-
res.json({ success: true });
|
|
3470
|
-
} catch (err) {
|
|
3471
|
-
res.status(500).json({ success: false, error: err.message });
|
|
3472
|
-
}
|
|
3473
|
-
});
|
|
3474
|
-
|
|
3475
|
-
// 主任务附件
|
|
3476
|
-
app.post('/api/workbench/tasks/:taskId/attachments', rawAttachment, async (req, res) => {
|
|
3477
|
-
try {
|
|
3478
|
-
const { taskId } = req.params;
|
|
3479
|
-
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
3480
|
-
const task = (data.tasks || []).find(t => t.id === taskId);
|
|
3481
|
-
if (!task) return res.status(404).json({ success: false, error: '任务不存在' });
|
|
3482
|
-
const target = { ...task, storageDir: path.join(IMAGES_DIR, '_task-' + taskId) };
|
|
3483
|
-
const result = await writeAttachmentTo({ req, target, maxCount: MAX_ATTACHMENTS_PER_SUBTASK });
|
|
3484
|
-
if (result.error) return res.status(result.status).json({ success: false, error: result.error });
|
|
3485
|
-
const att = result.attachment;
|
|
3486
|
-
task.attachments = Array.isArray(task.attachments) ? task.attachments : [];
|
|
3487
|
-
task.attachments.push(att);
|
|
3488
|
-
task.updatedAt = nowIso();
|
|
3489
|
-
await writeJson(TASKS_FILE, data);
|
|
3490
|
-
res.json({ success: true, attachment: att });
|
|
3491
|
-
} catch (err) {
|
|
3492
|
-
res.status(500).json({ success: false, error: err.message });
|
|
3493
|
-
}
|
|
3494
|
-
});
|
|
3495
|
-
|
|
3496
|
-
app.delete('/api/workbench/tasks/:taskId/attachments/:attId', async (req, res) => {
|
|
3497
|
-
try {
|
|
3498
|
-
const { taskId, attId } = req.params;
|
|
3499
|
-
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
3500
|
-
const task = (data.tasks || []).find(t => t.id === taskId);
|
|
3501
|
-
if (!task) return res.status(404).json({ success: false, error: '任务不存在' });
|
|
3502
|
-
const list = Array.isArray(task.attachments) ? task.attachments : [];
|
|
3503
|
-
const i = list.findIndex(a => a.id === attId);
|
|
3504
|
-
if (i < 0) return res.status(404).json({ success: false, error: '附件不存在' });
|
|
3505
|
-
const [removed] = list.splice(i, 1);
|
|
3506
|
-
try {
|
|
3507
|
-
await fsp.unlink(path.join(IMAGES_DIR, '_task-' + taskId, removed.storedName));
|
|
3508
|
-
} catch { /* 文件可能已不存在 */ }
|
|
3509
|
-
task.updatedAt = nowIso();
|
|
3510
|
-
await writeJson(TASKS_FILE, data);
|
|
3511
|
-
res.json({ success: true });
|
|
3512
|
-
} catch (err) {
|
|
3513
|
-
res.status(500).json({ success: false, error: err.message });
|
|
3514
|
-
}
|
|
3515
|
-
});
|
|
17
|
+
// 本文件原本是 3539 行的巨型文件(含所有顶层工具函数 + 45 个路由端点)。
|
|
18
|
+
// 2026-06-29 已按业务域拆分到 ./workbench/ 子目录的 9 个模块:
|
|
19
|
+
// - shared.js 常量 + 通用工具 (nowIso, genId, readJson, writeJson, interpolate)
|
|
20
|
+
// - jsonParse.js JSON 解析降级链 (parseSubtaskJson 等)
|
|
21
|
+
// - llmClient.js LLM 客户端 (callLlmJson, callLlmStream)
|
|
22
|
+
// - projectScan.js 子项目识别 (findSubProjects, detectProjectManifest)
|
|
23
|
+
// - attachmentUtils.js 附件白名单 (sanitizeExt, resolveExt, MIME_TO_EXT)
|
|
24
|
+
// - sessionStore.js AI 对话拆分会话持久化
|
|
25
|
+
// - instructionStore.js AI 指令读写
|
|
26
|
+
// - jobStore.js jobs Map + bus + 持久化 + retention
|
|
27
|
+
// - taskRunner.js 任务执行引擎 (runTaskQueue, runSingleSubtask)
|
|
28
|
+
// - index.js registerWorkbenchRoutes 入口聚合(45 个路由)
|
|
29
|
+
//
|
|
30
|
+
// 此处保留 re-export 是为了让 src/ui/server/index.js 的 import 路径不变。
|
|
3516
31
|
|
|
3517
|
-
|
|
3518
|
-
app.get('/api/workbench/attachments/:attId/raw', async (req, res) => {
|
|
3519
|
-
try {
|
|
3520
|
-
const { attId } = req.params;
|
|
3521
|
-
const loc = await findAttachmentLocation(attId);
|
|
3522
|
-
if (!loc) return res.status(404).json({ success: false, error: '附件不存在' });
|
|
3523
|
-
const filePath = path.join(loc.storageDir, loc.att.storedName);
|
|
3524
|
-
try {
|
|
3525
|
-
const stat = await fsp.stat(filePath);
|
|
3526
|
-
res.set('Content-Type', loc.att.mimeType || 'application/octet-stream');
|
|
3527
|
-
res.set('Content-Length', String(stat.size));
|
|
3528
|
-
res.set('Cache-Control', 'private, max-age=3600');
|
|
3529
|
-
const stream = (await import('fs')).createReadStream(filePath);
|
|
3530
|
-
stream.on('error', () => res.end());
|
|
3531
|
-
stream.pipe(res);
|
|
3532
|
-
} catch {
|
|
3533
|
-
res.status(404).json({ success: false, error: '文件已丢失' });
|
|
3534
|
-
}
|
|
3535
|
-
} catch (err) {
|
|
3536
|
-
res.status(500).json({ success: false, error: err.message });
|
|
3537
|
-
}
|
|
3538
|
-
});
|
|
3539
|
-
}
|
|
32
|
+
export { registerWorkbenchRoutes } from './workbench/index.js';
|