zen-gitsync 2.14.2 → 2.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/ui/public/assets/{AppVersionBadge-M9ydXpd9.js → AppVersionBadge-BoAhI0Kt.js} +2 -2
- package/src/ui/public/assets/BranchSelector--8Qfb75A.js +1 -0
- package/src/ui/public/assets/CommitForm-CobjwXah.js +6 -0
- package/src/ui/public/assets/ConsoleView-B2WZRKsP.css +1 -0
- package/src/ui/public/assets/ConsoleView-HcfiYLnM.js +17 -0
- package/src/ui/public/assets/CustomCommandManager-DQG4wRU7.js +1 -0
- package/src/ui/public/assets/{EditorView-rGLnccH3.js → EditorView-CAjuz7yY.js} +0 -0
- package/src/ui/public/assets/{FlowExecutionViewer-B4BADP8e.js → FlowExecutionViewer-C8H9uoZL.js} +1 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-C2RMFB-p.js +21 -0
- package/src/ui/public/assets/{LogList-7agk5Obq.js → LogList-8qSNrgT7.js} +4 -4
- package/src/ui/public/assets/{MindmapView-mOFG9TmR.js → MindmapView-C5pLBOs_.js} +1 -1
- package/src/ui/public/assets/{MonitorView-Dlpgy8Bb.js → MonitorView-Bijvi8pi.js} +1 -1
- package/src/ui/public/assets/ProjectStartupButton-Cb-RAZqA.js +1 -0
- package/src/ui/public/assets/{RemoteRepoCard-CFKl6CB9.js → RemoteRepoCard-DjfI188H.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-Dsc_a3t7.js → SourceMapView-DbdbojUR.js} +2 -2
- package/src/ui/public/assets/{SvgIcon-Cw2mL_zx.js → SvgIcon-BVBpj6nK.js} +1 -1
- package/src/ui/public/assets/TemplateManager-C0rm8xfC.css +1 -0
- package/src/ui/public/assets/TemplateManager-CSI1wQZY.js +1 -0
- package/src/ui/public/assets/{UserInputNode-yBT2005h.js → UserInputNode-8ocSzj-6.js} +1 -1
- package/src/ui/public/assets/WorkbenchView-CvHPPs8Q.css +1 -0
- package/src/ui/public/assets/{WorkbenchView-CiG-qMl7.js → WorkbenchView-DAXAzJTK.js} +4 -4
- package/src/ui/public/assets/_plugin-vue_export-helper-Uwc4Esmp.js +15 -0
- package/src/ui/public/assets/commandParser-wwLxoJ92.js +1 -0
- package/src/ui/public/assets/{configStore-Cq6Qwn35.js → configStore-BHFJlgWp.js} +1 -1
- package/src/ui/public/assets/element-plus-R1nMzM9j.css +1 -0
- package/src/ui/public/assets/{flow-mindmap-CAkGY2ir.js → flow-mindmap-C0IUPqjN.js} +1 -1
- package/src/ui/public/assets/index-BZk-ldXr.css +1 -0
- package/src/ui/public/assets/index-Bl8fyL3X.js +23 -0
- package/src/ui/public/assets/{vendor-BuiZXcjj.js → vendor-0hkrGFDb.js} +1 -1
- package/src/ui/public/assets/{vue-flow-C6RXt-U3.js → vue-flow-CkwNeuGO.js} +1 -1
- package/src/ui/public/index.html +9 -11
- 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/branchStatus.test.js +37 -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/npm.js +1387 -1327
- 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/utils/index.js +10 -3
- package/src/ui/public/assets/BranchSelector-scIk6OeM.js +0 -1
- package/src/ui/public/assets/CommandConsole-BikQuarw.js +0 -17
- package/src/ui/public/assets/CommandConsole-Da3j09oc.css +0 -1
- package/src/ui/public/assets/CommitForm-DuvNxUdP.js +0 -6
- package/src/ui/public/assets/CommonDialog-Cyh3xu0p.css +0 -1
- package/src/ui/public/assets/CommonDialog-G17HYMln.js +0 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-C3l3Jco7.js +0 -21
- package/src/ui/public/assets/ProjectStartupButton-CgS14qyg.js +0 -1
- package/src/ui/public/assets/WorkbenchView-OmJ1eM5g.css +0 -1
- package/src/ui/public/assets/_plugin-vue_export-helper-BMXWW0fG.js +0 -15
- package/src/ui/public/assets/element-plus-D06ZgZjR.css +0 -1
- package/src/ui/public/assets/index-CPqKvZBK.js +0 -23
- package/src/ui/public/assets/index-gbRjS0sL.css +0 -1
- /package/src/ui/public/assets/{element-plus-B6Owr5jY.js → element-plus-tbZnDtyd.js} +0 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// AI 指令文本持久化(生成指令 + 拆分子任务指令)。
|
|
16
|
+
// 拆分自原 routes/workbench.js 57-158 行 + 958-998 行。
|
|
17
|
+
//
|
|
18
|
+
// 两个独立的指令文件:
|
|
19
|
+
// - ai-instruction.json 生成项目架构说明的指令
|
|
20
|
+
// - ai-subtask-instruction.json 拆分子任务的指令(支持 zh/en 多语言默认)
|
|
21
|
+
|
|
22
|
+
import fsp from 'fs/promises';
|
|
23
|
+
import {
|
|
24
|
+
INSTRUCTION_FILE,
|
|
25
|
+
SUBTASK_INSTRUCTION_FILE,
|
|
26
|
+
ensureDataDir,
|
|
27
|
+
nowIso,
|
|
28
|
+
} from './shared.js';
|
|
29
|
+
|
|
30
|
+
// ── 生成指令(用户首次使用时作为可编辑指令的初始值) ───────────
|
|
31
|
+
export const DEFAULT_INSTRUCTION = `你是一名资深软件架构师。
|
|
32
|
+
|
|
33
|
+
【探索步骤】
|
|
34
|
+
1. 先识别项目结构:扫描根目录是否包含 .git 目录,以及 package.json / pyproject.toml / go.mod / Cargo.toml / pom.xml / build.gradle{,.kts} / composer.json / Gemfile / pubspec.yaml 这 9 种 manifest。
|
|
35
|
+
2. 如果根目录含 manifest,就把整个根目录视为一个子项目。
|
|
36
|
+
3. 如果根目录不含 manifest、但子目录(含一层 .git 或上述 manifest)形成多个子项目,对每个子项目分别探索。
|
|
37
|
+
4. 对每个子项目,重点读取:
|
|
38
|
+
- 所有识别到的 manifest(限制单文件 20KB)
|
|
39
|
+
- README.md(限制 8KB)
|
|
40
|
+
- 入口文件:package.json 的 main / scripts / workspaces 字段;pyproject.toml 的 [project.scripts];go.mod 的 module;Cargo.toml 的 [[bin]];pom.xml 的 <modules>
|
|
41
|
+
- 2 层目录树(最多 200 行)
|
|
42
|
+
|
|
43
|
+
【输出要求】
|
|
44
|
+
1. 给出一段中文「项目架构说明」,长度不限,模型自行决定篇幅与详尽程度,能写多详细就多详细。覆盖:项目整体定位、技术栈、模块划分、核心流程、关键设计决策。
|
|
45
|
+
2. 必须引用子项目里实际存在的文件路径、目录名、依赖名,不要编造。
|
|
46
|
+
3. 多个子项目时:先逐个说明,最后输出一段「整体架构」总结它们之间的关系。
|
|
47
|
+
4. 语气专业、具体、面向接手这个项目的开发者。
|
|
48
|
+
5. 只返回 JSON:{ "name": "项目名(建议:项目名+架构说明)", "summary": "架构说明正文" }。`;
|
|
49
|
+
|
|
50
|
+
// ── 拆分子任务指令:默认系统指令(用户在 GUI 可编辑覆盖) ───────
|
|
51
|
+
// 国际化:根据请求 Accept-Language 在 zh / en 之间选默认。
|
|
52
|
+
// 中英两份都内置——用户保存到 ~/.zen-gitsync/ai-subtask-instruction.json 后覆盖。
|
|
53
|
+
export const DEFAULT_SUBTASK_INSTRUCTION_ZH = `你是一名任务拆分助手。
|
|
54
|
+
|
|
55
|
+
【绝对禁止】
|
|
56
|
+
- 不要进入任何"plan 模式"、"plan 阶段"或"先写 plan 再执行"。
|
|
57
|
+
- 不要输出"我要进入 plan / 准备 / 探索 / 调研"之类的元叙述。
|
|
58
|
+
- 不要写"Plan 文件已写入 xxx"或"等用户批准后开始"这种"声明意图"的话。
|
|
59
|
+
- 你只做一件事:把用户给的任务拆成可独立执行的子任务清单,直接输出 JSON。中间任何"我要做什么"的描述都属于错误输出。
|
|
60
|
+
- 你的输出会被另一个程序读取,该程序只看最终 JSON,所有非 JSON 文字会被丢弃——所以不要把"执行步骤"写进 analysis 段,只写在 JSON 的 desc 字段里。
|
|
61
|
+
|
|
62
|
+
【思考过程】
|
|
63
|
+
在给出 JSON 之前,请先在内部仔细思考(如果模型支持,把思考放在 reasoning 中;否则可以先输出一段较长的仔细分析,再输出 JSON)。分析要覆盖足够维度,不要简短一两句话就过:
|
|
64
|
+
- 任务的真实目标是什么?用户提供的描述/图片/上下文里有哪些关键信息和隐藏诉求?
|
|
65
|
+
- 涉及哪些技术栈、模块、文件、约束?是否有易被忽略的边界条件、风险点?
|
|
66
|
+
- 自然的执行顺序是什么?哪些步骤是前置依赖?哪些可以并行?
|
|
67
|
+
- 哪些步骤可能失败、需要单独验证?验证方式是什么?
|
|
68
|
+
- 是否需要先做调研/读现有代码的步骤,再做修改?
|
|
69
|
+
|
|
70
|
+
【拆分原则】
|
|
71
|
+
1. 单一职责:每个子任务只做一件事,避免"做 A 和 B"。
|
|
72
|
+
2. 粒度适中:单个子任务应当能在一次会话里完成(既不要"实现整个登录功能"这么大,也不要"打印 hello"这么琐碎)。
|
|
73
|
+
3. 顺序合理:子任务按依赖关系和执行顺序排列(先准备、后实现、最后验证)。
|
|
74
|
+
4. 可验证:每个子任务都有明确的完成标志("输出文件 xxx"、"通过测试 yyy"、"控制台打印 zzz")。
|
|
75
|
+
5. 数量:不设上限。任务很简单时 1-2 个就够,复杂时按需拆成 10 个、20 个甚至更多子任务都行,直到覆盖所有可独立验证的步骤。
|
|
76
|
+
6. 描述具体:desc 字段要写清楚"要做什么、参考什么、产出什么",不要只是把 title 改写一遍。
|
|
77
|
+
7. 如果任务里附带了图片,必须基于图片的实际内容拆分(例如指出图片中的哪个区域、哪个元素需要改),而不是泛泛而谈。
|
|
78
|
+
|
|
79
|
+
【输出要求】
|
|
80
|
+
最后必须输出 JSON,结构:
|
|
81
|
+
{
|
|
82
|
+
"subtasks": [
|
|
83
|
+
{ "title": "子任务标题", "desc": "子任务的具体描述,包含要做什么、输入是什么、输出/验证标志是什么" }
|
|
84
|
+
]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
JSON 要用 \`\`\`json ... \`\`\` 代码块包裹,前面可以有分析文字,但 JSON 必须完整、合法、可解析。`;
|
|
88
|
+
|
|
89
|
+
export const DEFAULT_SUBTASK_INSTRUCTION_EN = `You are a task breakdown assistant.
|
|
90
|
+
|
|
91
|
+
[ABSOLUTE PROHIBITIONS]
|
|
92
|
+
- Do NOT enter any "plan mode" / "planning phase" / "I'll write a plan first, then execute".
|
|
93
|
+
- Do NOT output meta-narration like "I'm entering plan / preparing / exploring / researching".
|
|
94
|
+
- Do NOT write "Plan file written to xxx" or "waiting for user approval" style intent-declaration text.
|
|
95
|
+
- 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.
|
|
96
|
+
- 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.
|
|
97
|
+
|
|
98
|
+
[Thinking process]
|
|
99
|
+
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:
|
|
100
|
+
- What is the real goal? What key info and hidden needs are in the description / images / context?
|
|
101
|
+
- Which stack, modules, files, constraints are involved? Any easily missed edge cases or risks?
|
|
102
|
+
- What is the natural execution order? What blocks what? What can run in parallel?
|
|
103
|
+
- Which steps may fail and need separate verification? How will you verify?
|
|
104
|
+
- Should a research / read-existing-code step come before the changes?
|
|
105
|
+
|
|
106
|
+
[Breakdown principles]
|
|
107
|
+
1. Single responsibility: each subtask does only one thing, avoid bundling "do A and B".
|
|
108
|
+
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").
|
|
109
|
+
3. Sensible order: arrange subtasks by dependency / execution order (prepare first, implement, then verify).
|
|
110
|
+
4. Verifiable: every subtask has a clear completion signal (e.g. "produce file xxx", "pass test yyy", "log zzz to console").
|
|
111
|
+
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.
|
|
112
|
+
6. Concrete desc: write what to do, what to reference, what to produce — don't just paraphrase the title.
|
|
113
|
+
7. If the task includes images, the breakdown must reference the actual image content (which region, which element to change), not just generic talk.
|
|
114
|
+
|
|
115
|
+
[Output requirements]
|
|
116
|
+
End with JSON, structure:
|
|
117
|
+
{
|
|
118
|
+
"subtasks": [
|
|
119
|
+
{ "title": "subtask title", "desc": "concrete description: what to do, what the input is, what the output / verification signal is" }
|
|
120
|
+
]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
Wrap JSON in a \`\`\`json ... \`\`\` code block. Preceding analysis text is allowed, but the JSON must be complete, valid, and parseable.`;
|
|
124
|
+
|
|
125
|
+
// 兼容旧引用:未保存指令文件时回退到 ZH 版本
|
|
126
|
+
export const DEFAULT_SUBTASK_INSTRUCTION = DEFAULT_SUBTASK_INSTRUCTION_ZH;
|
|
127
|
+
|
|
128
|
+
// 根据请求 Accept-Language 选默认(zh / en)
|
|
129
|
+
export function pickDefaultSubtaskInstruction(req) {
|
|
130
|
+
const al = String(req?.headers?.['accept-language'] || '').toLowerCase();
|
|
131
|
+
if (al.startsWith('en')) return DEFAULT_SUBTASK_INSTRUCTION_EN;
|
|
132
|
+
return DEFAULT_SUBTASK_INSTRUCTION_ZH;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── 生成指令持久化(~/.zen-gitsync/ai-instruction.json) ───────
|
|
136
|
+
export async function readInstruction() {
|
|
137
|
+
try {
|
|
138
|
+
const buf = await fsp.readFile(INSTRUCTION_FILE, 'utf-8');
|
|
139
|
+
const obj = JSON.parse(buf);
|
|
140
|
+
if (obj && typeof obj.instruction === 'string' && obj.instruction.trim()) {
|
|
141
|
+
return obj.instruction;
|
|
142
|
+
}
|
|
143
|
+
} catch { /* 文件不存在或解析失败 */ }
|
|
144
|
+
return DEFAULT_INSTRUCTION;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function writeInstruction(instruction) {
|
|
148
|
+
await ensureDataDir();
|
|
149
|
+
const text = String(instruction || '').trim() || DEFAULT_INSTRUCTION;
|
|
150
|
+
const tmp = `${INSTRUCTION_FILE}.tmp`;
|
|
151
|
+
await fsp.writeFile(tmp, JSON.stringify({ instruction: text, updatedAt: nowIso() }, null, 2), 'utf-8');
|
|
152
|
+
await fsp.rename(tmp, INSTRUCTION_FILE);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── 拆分子任务指令持久化 ─────────────────────────────────────
|
|
156
|
+
// 支持传入 req:根据 Accept-Language 选默认(zh/en)
|
|
157
|
+
export async function readSubtaskInstruction(req) {
|
|
158
|
+
try {
|
|
159
|
+
const buf = await fsp.readFile(SUBTASK_INSTRUCTION_FILE, 'utf-8');
|
|
160
|
+
const obj = JSON.parse(buf);
|
|
161
|
+
if (obj && typeof obj.instruction === 'string' && obj.instruction.trim()) {
|
|
162
|
+
return obj.instruction;
|
|
163
|
+
}
|
|
164
|
+
} catch { /* 文件不存在或解析失败 */ }
|
|
165
|
+
return pickDefaultSubtaskInstruction(req);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function writeSubtaskInstruction(instruction) {
|
|
169
|
+
await ensureDataDir();
|
|
170
|
+
// 写入时如果与当前 locale 默认一致,不写文件——这样前端"isDefault"判定永远准确
|
|
171
|
+
const text = String(instruction || '').trim() || DEFAULT_SUBTASK_INSTRUCTION_ZH;
|
|
172
|
+
const tmp = `${SUBTASK_INSTRUCTION_FILE}.tmp`;
|
|
173
|
+
await fsp.writeFile(tmp, JSON.stringify({ instruction: text, updatedAt: nowIso() }, null, 2), 'utf-8');
|
|
174
|
+
await fsp.rename(tmp, SUBTASK_INSTRUCTION_FILE);
|
|
175
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// 执行日志持久化 + 进程表(jobs Map)+ 事件总线(bus)。
|
|
16
|
+
// 拆分自原 routes/workbench.js 814-955 行。
|
|
17
|
+
//
|
|
18
|
+
// 设计要点:
|
|
19
|
+
// - 写盘在流式 chunk 阶段走 1.5s debounce;终态时(finally / cancel)强制 flush
|
|
20
|
+
// - 永不持久化 child 引用(参考 cancel 路由的浅拷贝模式)
|
|
21
|
+
// - hydrate 时把 running/pending 降级为 error:原 child 进程已不存在
|
|
22
|
+
// - enforceRetention 在每次落盘后跑,按 endedAt desc FIFO 裁剪
|
|
23
|
+
//
|
|
24
|
+
// 模块级单例:bus / jobs / cancelledJobs 跨整个进程共享,
|
|
25
|
+
// SSE /api/workbench/events 与 cancel 接口都从这里读
|
|
26
|
+
|
|
27
|
+
import { EventEmitter } from 'events';
|
|
28
|
+
import fsp from 'fs/promises';
|
|
29
|
+
import {
|
|
30
|
+
logger,
|
|
31
|
+
fsp as _fsp,
|
|
32
|
+
JOBS_FILE,
|
|
33
|
+
JOBS_CONFIG_FILE,
|
|
34
|
+
TASKS_FILE,
|
|
35
|
+
JOBS_SAVE_DEBOUNCE_MS,
|
|
36
|
+
DEFAULT_JOBS_CONFIG,
|
|
37
|
+
readJson,
|
|
38
|
+
writeJson,
|
|
39
|
+
nowIso,
|
|
40
|
+
} from './shared.js';
|
|
41
|
+
|
|
42
|
+
// 进程表:记录每个子任务的运行状态
|
|
43
|
+
// jobId -> { id, taskId, subId, status, pid, startedAt, endedAt, exitCode, error, prompt, child, output, thinking, claudeSessionId }
|
|
44
|
+
export const jobs = new Map();
|
|
45
|
+
|
|
46
|
+
// 事件总线:SSE /api/workbench/events 监听 'event',路由层用 publish() 推送
|
|
47
|
+
export const bus = new EventEmitter();
|
|
48
|
+
|
|
49
|
+
// 被用户主动取消的 jobId 集合——runTaskQueue 在 waitProcessExit 之后检查这个集合
|
|
50
|
+
// 来决定把 job 标为 'cancelled' 还是 'done'。
|
|
51
|
+
// 用 Set 而不是 job.cancelled 标志,是为了在 SIGTERM 发出后到 child 真正退出之间
|
|
52
|
+
// 有一个简洁的"待回收"窗口。
|
|
53
|
+
export const cancelledJobs = new Set();
|
|
54
|
+
|
|
55
|
+
// ── 持久化层 ─────────────────────────────────────────────────
|
|
56
|
+
// jobs.json 是历史档案;jobs Map 只承载当前进程产出的活跃 job
|
|
57
|
+
let jobsSaveTimer = null;
|
|
58
|
+
|
|
59
|
+
// 序列化 job 落盘:剥离 child 引用(ChildProcess 无法 JSON.stringify),
|
|
60
|
+
// 反范式 taskTitle/subTitle 方便管理页直接读
|
|
61
|
+
export function serializeJob(j, taskMap) {
|
|
62
|
+
const { child, ...rest } = j;
|
|
63
|
+
const t = taskMap ? taskMap.get(rest.taskId) : null;
|
|
64
|
+
const sub = t && Array.isArray(t.subtasks) ? t.subtasks.find(s => s.id === rest.subId) : null;
|
|
65
|
+
const size = ((rest.prompt || '').length
|
|
66
|
+
+ (rest.output || '').length
|
|
67
|
+
+ (rest.thinking || '').length);
|
|
68
|
+
return {
|
|
69
|
+
...rest,
|
|
70
|
+
taskTitle: t ? t.title : '',
|
|
71
|
+
subTitle: sub ? sub.title : '',
|
|
72
|
+
size
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 流式 chunk 阶段 debounce 写盘;终态时由 flushJobsSaveNow() 强制立即落盘
|
|
77
|
+
export function scheduleJobsSave() {
|
|
78
|
+
if (jobsSaveTimer) clearTimeout(jobsSaveTimer);
|
|
79
|
+
jobsSaveTimer = setTimeout(() => {
|
|
80
|
+
jobsSaveTimer = null;
|
|
81
|
+
flushJobsSaveNow().catch(err => logger.warn('[workbench] jobs save failed:', err.message));
|
|
82
|
+
}, JOBS_SAVE_DEBOUNCE_MS);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 立即落盘(终态调用):把 jobs Map 当前快照写到 jobs.json,然后跑 retention
|
|
86
|
+
export async function flushJobsSaveNow() {
|
|
87
|
+
if (jobsSaveTimer) { clearTimeout(jobsSaveTimer); jobsSaveTimer = null; }
|
|
88
|
+
// 读 tasks.json 给落盘 job 反范式 taskTitle/subTitle——父任务被删后管理页仍可读
|
|
89
|
+
const tasksData = await readJson(TASKS_FILE, { tasks: [] });
|
|
90
|
+
const taskMap = new Map((tasksData.tasks || []).map(t => [t.id, t]));
|
|
91
|
+
const payload = {
|
|
92
|
+
version: 1,
|
|
93
|
+
jobs: Array.from(jobs.values()).map(j => serializeJob(j, taskMap))
|
|
94
|
+
};
|
|
95
|
+
await writeJson(JOBS_FILE, payload);
|
|
96
|
+
await enforceRetention();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// jobs-config.json 读写:maxCount / maxSizeMB 保留策略
|
|
100
|
+
export async function readJobsConfig() {
|
|
101
|
+
const cfg = await readJson(JOBS_CONFIG_FILE, null);
|
|
102
|
+
if (!cfg || typeof cfg !== 'object') return { ...DEFAULT_JOBS_CONFIG };
|
|
103
|
+
return {
|
|
104
|
+
maxCount: Number.isFinite(cfg.maxCount) ? Math.max(0, Math.floor(cfg.maxCount)) : DEFAULT_JOBS_CONFIG.maxCount,
|
|
105
|
+
maxSizeMB: Number.isFinite(cfg.maxSizeMB) ? Math.max(0, Math.floor(cfg.maxSizeMB)) : DEFAULT_JOBS_CONFIG.maxSizeMB
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function writeJobsConfig(cfg) {
|
|
110
|
+
// 校验:非负整数;硬上限防误填爆盘
|
|
111
|
+
const out = {};
|
|
112
|
+
if (cfg.maxCount !== undefined) {
|
|
113
|
+
const n = Math.floor(Number(cfg.maxCount));
|
|
114
|
+
if (!Number.isFinite(n) || n < 0 || n > 10000) throw new Error('maxCount 必须在 0-10000 之间');
|
|
115
|
+
out.maxCount = n;
|
|
116
|
+
}
|
|
117
|
+
if (cfg.maxSizeMB !== undefined) {
|
|
118
|
+
const n = Math.floor(Number(cfg.maxSizeMB));
|
|
119
|
+
if (!Number.isFinite(n) || n < 0 || n > 10240) throw new Error('maxSizeMB 必须在 0-10240 之间');
|
|
120
|
+
out.maxSizeMB = n;
|
|
121
|
+
}
|
|
122
|
+
const merged = { ...(await readJobsConfig()), ...out };
|
|
123
|
+
await writeJson(JOBS_CONFIG_FILE, merged);
|
|
124
|
+
return merged;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 进程启动时把磁盘上的历史拉回内存 Map;陈旧的 running/pending 强制降级。
|
|
128
|
+
// 陈旧 job 的 child 进程已退出,标记为 error 方便用户识别。
|
|
129
|
+
export async function hydrateJobs() {
|
|
130
|
+
let data;
|
|
131
|
+
try {
|
|
132
|
+
data = await readJson(JOBS_FILE, null);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
// 损坏文件:改名备份避免下次 flush 静默覆盖用户数据
|
|
135
|
+
logger.warn('[workbench] jobs.json 解析失败,备份原文件后重置:', err.message);
|
|
136
|
+
try { await _fsp.rename(JOBS_FILE, `${JOBS_FILE}.bak-${Date.now()}`); } catch { /* 文件可能已不在 */ }
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (!data || !Array.isArray(data.jobs)) return;
|
|
140
|
+
for (const j of data.jobs) {
|
|
141
|
+
if (j.status === 'running' || j.status === 'pending') {
|
|
142
|
+
j.status = 'error';
|
|
143
|
+
j.error = (j.error || '') + ' [重启后回收:原进程已退出]';
|
|
144
|
+
j.endedAt = j.endedAt || nowIso();
|
|
145
|
+
j.exitCode = typeof j.exitCode === 'number' ? j.exitCode : 1;
|
|
146
|
+
}
|
|
147
|
+
// 旧版本可能没 size 字段;补齐以兼容历史文件
|
|
148
|
+
if (typeof j.size !== 'number') {
|
|
149
|
+
j.size = ((j.prompt || '').length + (j.output || '').length + (j.thinking || '').length);
|
|
150
|
+
}
|
|
151
|
+
jobs.set(j.id, j);
|
|
152
|
+
}
|
|
153
|
+
// 启动后也跑一遍保留策略,让历史文件立刻缩到当前配置
|
|
154
|
+
try { await enforceRetention(); } catch (err) { logger.warn('[workbench] 启动时 enforceRetention 失败:', err.message); }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 保留策略:按 endedAt desc(fallback startedAt / id)排序,先按 maxCount 截,
|
|
158
|
+
// 再按 maxSizeMB 累计裁,淘汰同步从内存 Map 删除。
|
|
159
|
+
export async function enforceRetention() {
|
|
160
|
+
const cfg = await readJobsConfig();
|
|
161
|
+
const data = await readJson(JOBS_FILE, { version: 1, jobs: [] });
|
|
162
|
+
if (!data || !Array.isArray(data.jobs) || data.jobs.length === 0) return;
|
|
163
|
+
const sortKey = (j) => j.endedAt || j.startedAt || j.id || '';
|
|
164
|
+
data.jobs.sort((a, b) => sortKey(b).localeCompare(sortKey(a)));
|
|
165
|
+
if (cfg.maxCount > 0) data.jobs = data.jobs.slice(0, cfg.maxCount);
|
|
166
|
+
if (cfg.maxSizeMB > 0) {
|
|
167
|
+
const cap = cfg.maxSizeMB * 1024 * 1024;
|
|
168
|
+
let total = data.jobs.reduce((s, j) => s + (j.size || 0), 0);
|
|
169
|
+
while (total > cap && data.jobs.length > 1) {
|
|
170
|
+
const dropped = data.jobs.pop();
|
|
171
|
+
total -= (dropped && dropped.size) || 0;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
await writeJson(JOBS_FILE, data);
|
|
175
|
+
const keepIds = new Set(data.jobs.map(j => j.id));
|
|
176
|
+
for (const id of Array.from(jobs.keys())) {
|
|
177
|
+
if (!keepIds.has(id)) jobs.delete(id);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 进程启动时拉回历史 job(陈旧 running/pending 自动降级 error)
|
|
182
|
+
// 模块加载即触发,与原 workbench.js 顶层调用保持一致
|
|
183
|
+
hydrateJobs().catch(err => logger.warn('[workbench] hydrate jobs failed:', err.message));
|
|
184
|
+
|
|
185
|
+
// ── 事件推送 + 快照 ─────────────────────────────────────────
|
|
186
|
+
// publish 给 SSE 路由用:所有事件都走 bus.emit('event', {event, payload, ts})
|
|
187
|
+
export function publish(event, payload) {
|
|
188
|
+
bus.emit('event', { event, payload, ts: nowIso() });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 给 SSE / cancel / continue 路由用:返回当前所有 job 的可序列化快照
|
|
192
|
+
// 剥离 child 引用(不可序列化),只保留前端需要的字段
|
|
193
|
+
export function snapshotJobs() {
|
|
194
|
+
return Array.from(jobs.values()).map(j => ({
|
|
195
|
+
id: j.id,
|
|
196
|
+
taskId: j.taskId,
|
|
197
|
+
subId: j.subId,
|
|
198
|
+
title: j.title,
|
|
199
|
+
status: j.status,
|
|
200
|
+
prompt: j.prompt || '',
|
|
201
|
+
output: j.output || '',
|
|
202
|
+
pid: j.pid || null,
|
|
203
|
+
startedAt: j.startedAt || null,
|
|
204
|
+
endedAt: j.endedAt || null,
|
|
205
|
+
exitCode: typeof j.exitCode === 'number' ? j.exitCode : null,
|
|
206
|
+
error: j.error || null,
|
|
207
|
+
// 续接对话用:claude --output-format stream-json 的 system.init 事件捕获到的 session_id
|
|
208
|
+
claudeSessionId: j.claudeSessionId || null
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export const jobStore = {
|
|
213
|
+
jobs,
|
|
214
|
+
bus,
|
|
215
|
+
cancelledJobs,
|
|
216
|
+
serializeJob,
|
|
217
|
+
scheduleJobsSave,
|
|
218
|
+
flushJobsSaveNow,
|
|
219
|
+
readJobsConfig,
|
|
220
|
+
writeJobsConfig,
|
|
221
|
+
hydrateJobs,
|
|
222
|
+
enforceRetention,
|
|
223
|
+
publish,
|
|
224
|
+
snapshotJobs,
|
|
225
|
+
};
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// AI 拆分子任务 JSON 多级降级解析。
|
|
16
|
+
// 拆分自原 routes/workbench.js 395-776 行。
|
|
17
|
+
//
|
|
18
|
+
// 模型经常会犯几类格式错:在 desc 里用 ASCII 双引号引用术语 / 末尾留尾随逗号 /
|
|
19
|
+
// 输出被 token 上限截断导致代码块未闭合。直接 JSON.parse 一旦失败就会让前端的
|
|
20
|
+
// "确认入库" 按钮永远是 (0),用户看不到原因。这里按"越简单越优先"的顺序尝试:
|
|
21
|
+
// ① ```json``` 代码块 / ```any``` 代码块 / 第一个完整 { ... } 范围
|
|
22
|
+
// ② 把候选片段去掉尾随逗号 + 块/行注释 再 parse
|
|
23
|
+
// ③ 用括号深度扫描,从开头找一个语法平衡的 { ... } 子串
|
|
24
|
+
// ④ 启发式转义模型夹在字符串内部的未转义 ASCII 双引号
|
|
25
|
+
// 任一步成功就返回 parsed,全部失败时返回最后一次 JSON.parse 的错误,
|
|
26
|
+
// 用 parseStage 告知前端"模型输出哪一步崩了",并把原始 raw 一并回传。
|
|
27
|
+
|
|
28
|
+
// 剥离 LLM 输出的 思考块、```json``` 代码块围栏,以及首尾说明文字。
|
|
29
|
+
// 原贪婪正则 /({[\s\S]*})/ 会把 思考块(内含未配对花括号或字符串)一起吞进 JSON.parse,
|
|
30
|
+
// 在 DeepSeek 等会输出 推理链的模型上偶发 "Unterminated string in JSON"。
|
|
31
|
+
export function stripThinkingBlocks(content) {
|
|
32
|
+
let s = String(content || '');
|
|
33
|
+
// 1) 显式 思考块(成对标签)
|
|
34
|
+
s = s.replace(/<think(?:ing)?>[\s\S]*?<\/think(?:ing)?>/gi, '');
|
|
35
|
+
// 2) 某些 provider 流式结尾会留下裸 <think>...</think> 之外的残段(无结束标签时被截断)
|
|
36
|
+
s = s.replace(/<think(?:ing)?>[\s\S]*$/gi, '');
|
|
37
|
+
// 3) ```json ... ``` 代码块围栏(保留内部 JSON)
|
|
38
|
+
s = s.replace(/```json\s*/gi, '').replace(/```/g, '');
|
|
39
|
+
return s;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 在剥离 思考块 的文本里定位第一个「平衡花括号对象」的起点/终点。
|
|
43
|
+
// 跳过字符串内的 {/}(含转义)以及 Jinja {{ }} 模板占位符;
|
|
44
|
+
// 同时只在「JSON 特征显著」的 { 处起算 —— 即该 { 之后不远处出现 " 字符串。
|
|
45
|
+
// 这能避免把 LLM 思考文本里的裸 { ... }(如 "see { views, stores }")误当 JSON 起点。
|
|
46
|
+
export function extractFirstJsonObject(content) {
|
|
47
|
+
const s = String(content || '');
|
|
48
|
+
let depth = 0;
|
|
49
|
+
let inStr = false;
|
|
50
|
+
let escape = false;
|
|
51
|
+
let start = -1;
|
|
52
|
+
for (let i = 0; i < s.length; i++) {
|
|
53
|
+
const ch = s[i];
|
|
54
|
+
if (inStr) {
|
|
55
|
+
if (escape) { escape = false; continue; }
|
|
56
|
+
if (ch === '\\') { escape = true; continue; }
|
|
57
|
+
if (ch === '"') { inStr = false; }
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (ch === '"') { inStr = true; continue; }
|
|
61
|
+
// 跳过 Jinja {{ }} 占位符(提示词模板常用,勿当 JSON)
|
|
62
|
+
if (ch === '{' && s[i + 1] === '{') { i++; continue; }
|
|
63
|
+
if (ch === '}' && s[i - 1] === '}') { continue; }
|
|
64
|
+
if (ch === '{') {
|
|
65
|
+
if (depth === 0) {
|
|
66
|
+
// 启发式判断「这看起来像 JSON 起点」:
|
|
67
|
+
// 1. { 之前紧邻「真正的语义字符」(字母/数字/中文/下划线) → 不是 JSON 起点(散文里的花括号)
|
|
68
|
+
// 但接受 . , ; : 等句末标点(LLM 经常以「总结:{...}」直接开头 JSON)
|
|
69
|
+
// 2. { 之后跳过空白第一个字符必须是 " 或 }(空对象)
|
|
70
|
+
const before = i > 0 ? s[i - 1] : '';
|
|
71
|
+
if (/[A-Za-z0-9_一-龥]/.test(before)) continue;
|
|
72
|
+
let j = i + 1;
|
|
73
|
+
while (j < s.length && /\s/.test(s[j])) j++;
|
|
74
|
+
const firstNonWs = s[j];
|
|
75
|
+
if (firstNonWs !== '"' && firstNonWs !== '}') continue;
|
|
76
|
+
start = i;
|
|
77
|
+
}
|
|
78
|
+
depth++;
|
|
79
|
+
} else if (ch === '}') {
|
|
80
|
+
if (depth > 0) depth--;
|
|
81
|
+
if (depth === 0 && start !== -1) {
|
|
82
|
+
return s.slice(start, i + 1);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// 兜底:找不到平衡对象时返回原文(让上层 JSON.parse 报清晰错误)
|
|
87
|
+
return s;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 从字符串中提取首个语法平衡的 { ... } 子串。
|
|
91
|
+
// 跟踪字符串字面量(含转义),避免把 desc 里的 } 当成结束。
|
|
92
|
+
export function extractBalancedJson(text) {
|
|
93
|
+
const s = String(text || '');
|
|
94
|
+
const start = s.indexOf('{');
|
|
95
|
+
if (start < 0) return '';
|
|
96
|
+
let depth = 0;
|
|
97
|
+
let inStr = false;
|
|
98
|
+
let strCh = '';
|
|
99
|
+
let esc = false;
|
|
100
|
+
for (let i = start; i < s.length; i++) {
|
|
101
|
+
const c = s[i];
|
|
102
|
+
if (inStr) {
|
|
103
|
+
if (esc) { esc = false; continue; }
|
|
104
|
+
if (c === '\\') { esc = true; continue; }
|
|
105
|
+
if (c === strCh) { inStr = false; }
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (c === '"' || c === "'") { inStr = true; strCh = c; continue; }
|
|
109
|
+
if (c === '{') depth++;
|
|
110
|
+
else if (c === '}') {
|
|
111
|
+
depth--;
|
|
112
|
+
if (depth === 0) return s.slice(start, i + 1);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return '';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ④ 级降级:把字符串字面量内部出现的、未转义的 ASCII 双引号自动转义。
|
|
119
|
+
// 实战中模型最常见的错误是 "desc": "...用户点击"保存"按钮..." 这种—
|
|
120
|
+
// 中间的 "保存" 把外层字符串截断成两段,后面变成裸文本,JSON.parse 必崩。
|
|
121
|
+
//
|
|
122
|
+
// 启发式判断:扫描时若处于字符串中且遇到 ",往后看第一个非空白字符:
|
|
123
|
+
// - 是 , } ] : 或文本结尾 → 这是真闭合,正常退出字符串
|
|
124
|
+
// - 否则 → 是模型乱写的字面量引号,改写为 \" 并继续留在字符串里
|
|
125
|
+
// 不依赖正则、不破坏已经转义的 \",对嵌套 / 多行字符串都安全。
|
|
126
|
+
export function reescapeUnescapedQuotes(text) {
|
|
127
|
+
const s = String(text || '');
|
|
128
|
+
if (!s) return '';
|
|
129
|
+
const out = [];
|
|
130
|
+
let inStr = false;
|
|
131
|
+
let strCh = '';
|
|
132
|
+
let esc = false;
|
|
133
|
+
for (let i = 0; i < s.length; i++) {
|
|
134
|
+
const c = s[i];
|
|
135
|
+
if (!inStr) {
|
|
136
|
+
out.push(c);
|
|
137
|
+
if (c === '"' || c === "'") { inStr = true; strCh = c; }
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
// 字符串内部
|
|
141
|
+
if (esc) { out.push(c); esc = false; continue; }
|
|
142
|
+
if (c === '\\') { out.push(c); esc = true; continue; }
|
|
143
|
+
if (c !== strCh) { out.push(c); continue; }
|
|
144
|
+
// 遇到与开闭引号相同的字符——往后看下一个非空白
|
|
145
|
+
let j = i + 1;
|
|
146
|
+
while (j < s.length && (s[j] === ' ' || s[j] === '\t')) j++;
|
|
147
|
+
const next = j < s.length ? s[j] : '';
|
|
148
|
+
if (next === '' || next === ',' || next === '}' || next === ']'
|
|
149
|
+
|| next === ':' || next === '\n' || next === '\r') {
|
|
150
|
+
// 真闭合
|
|
151
|
+
out.push(c);
|
|
152
|
+
inStr = false;
|
|
153
|
+
strCh = '';
|
|
154
|
+
} else {
|
|
155
|
+
// 字面量裸引号——转义
|
|
156
|
+
out.push('\\', c);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return out.join('');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// AI 拆分子任务 JSON 多级降级解析主入口。
|
|
163
|
+
// 返回 { parsed, parseError, parseStage }:
|
|
164
|
+
// - parsed: 解析成功时的对象,失败时为 null
|
|
165
|
+
// - parseError: 失败时的错误信息
|
|
166
|
+
// - parseStage: 'empty' / '' (成功) / 'cleaned' / 'balanced' / 'reescaped' / 'failed'
|
|
167
|
+
export function parseSubtaskJson(content) {
|
|
168
|
+
let src = String(content || '');
|
|
169
|
+
if (!src.trim()) {
|
|
170
|
+
return { parsed: null, parseError: '模型未返回任何内容', parseStage: 'empty' };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 预处理:剥离 deepseek-r1 / QwQ 等模型在 content 前输出的 <think>...</think> 思考块
|
|
174
|
+
src = src.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/<think\s*\/>/gi, '').trim();
|
|
175
|
+
|
|
176
|
+
const candidates = [];
|
|
177
|
+
const fenced = src.match(/```json\s*([\s\S]*?)```/i) || src.match(/```\s*([\s\S]*?)```/);
|
|
178
|
+
if (fenced) candidates.push(fenced[1]);
|
|
179
|
+
// 平衡花括号扫描:避免贪婪正则把多段 JSON / 散落的 { } 全包进去
|
|
180
|
+
const balancedFirst = extractBalancedJson(src);
|
|
181
|
+
if (balancedFirst) candidates.push(balancedFirst);
|
|
182
|
+
// 兜底:整段当 JSON 试
|
|
183
|
+
candidates.push(src);
|
|
184
|
+
|
|
185
|
+
let lastErr = null;
|
|
186
|
+
for (const raw of candidates) {
|
|
187
|
+
const txt = String(raw || '').trim();
|
|
188
|
+
if (!txt) continue;
|
|
189
|
+
// ① 直 parse
|
|
190
|
+
try { return { parsed: JSON.parse(txt), parseError: '', parseStage: '' }; }
|
|
191
|
+
catch (e) { lastErr = e; }
|
|
192
|
+
// ② 清洗:去 //…/* */ 注释 + 尾随逗号
|
|
193
|
+
const cleaned = txt
|
|
194
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
195
|
+
.replace(/(^|[^:"'\\])\/\/[^\n]*/g, '$1')
|
|
196
|
+
.replace(/,(\s*[}\]])/g, '$1');
|
|
197
|
+
try { return { parsed: JSON.parse(cleaned), parseError: '', parseStage: 'cleaned' }; }
|
|
198
|
+
catch (e) { lastErr = e; }
|
|
199
|
+
// ③ 平衡花括号扫描
|
|
200
|
+
const balanced = extractBalancedJson(cleaned);
|
|
201
|
+
if (balanced && balanced !== cleaned) {
|
|
202
|
+
try { return { parsed: JSON.parse(balanced), parseError: '', parseStage: 'balanced' }; }
|
|
203
|
+
catch (e) { lastErr = e; }
|
|
204
|
+
}
|
|
205
|
+
// ④ 启发式转义字符串内的未转义双引号
|
|
206
|
+
const base = balanced || cleaned;
|
|
207
|
+
const reescaped = reescapeUnescapedQuotes(base);
|
|
208
|
+
if (reescaped && reescaped !== base) {
|
|
209
|
+
try { return { parsed: JSON.parse(reescaped), parseError: '', parseStage: 'reescaped' }; }
|
|
210
|
+
catch (e) { lastErr = e; }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
parsed: null,
|
|
216
|
+
parseError: lastErr ? (lastErr.message || String(lastErr)) : '未能从模型输出中提取出 JSON',
|
|
217
|
+
parseStage: 'failed'
|
|
218
|
+
};
|
|
219
|
+
}
|