@fieldwangai/agentflow 0.1.25

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.
Files changed (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -0
  3. package/README.zh-CN.md +201 -0
  4. package/agents/agentflow-node-executor-code.md +32 -0
  5. package/agents/agentflow-node-executor-planning.md +32 -0
  6. package/agents/agentflow-node-executor-requirement.md +32 -0
  7. package/agents/agentflow-node-executor-test.md +32 -0
  8. package/agents/agentflow-node-executor-ui.md +32 -0
  9. package/agents/agentflow-node-executor.md +32 -0
  10. package/agents/agents.json +8 -0
  11. package/agents/en/agentflow-node-executor.md +32 -0
  12. package/agents/zh/agentflow-node-executor.md +32 -0
  13. package/bin/agentflow.mjs +52 -0
  14. package/bin/ensure-workspace-reference.mjs +35 -0
  15. package/bin/lib/agent-runners.mjs +1199 -0
  16. package/bin/lib/agents-path.mjs +61 -0
  17. package/bin/lib/api-runner.mjs +361 -0
  18. package/bin/lib/apply.mjs +852 -0
  19. package/bin/lib/catalog-agents.mjs +300 -0
  20. package/bin/lib/catalog-flows.mjs +532 -0
  21. package/bin/lib/composer-agent.mjs +884 -0
  22. package/bin/lib/composer-flow-instances.mjs +68 -0
  23. package/bin/lib/composer-flow-skeleton.mjs +334 -0
  24. package/bin/lib/composer-flow-validate.mjs +47 -0
  25. package/bin/lib/composer-log.mjs +197 -0
  26. package/bin/lib/composer-model-router.mjs +160 -0
  27. package/bin/lib/composer-node-schema.mjs +299 -0
  28. package/bin/lib/composer-planner.mjs +749 -0
  29. package/bin/lib/composer-script-ops.mjs +233 -0
  30. package/bin/lib/composer-skill-router.mjs +384 -0
  31. package/bin/lib/flow-import.mjs +305 -0
  32. package/bin/lib/flow-normalize.mjs +71 -0
  33. package/bin/lib/flow-write.mjs +395 -0
  34. package/bin/lib/help.mjs +139 -0
  35. package/bin/lib/hub-login.mjs +54 -0
  36. package/bin/lib/hub-publish.mjs +159 -0
  37. package/bin/lib/hub-remote.mjs +189 -0
  38. package/bin/lib/hub.mjs +299 -0
  39. package/bin/lib/i18n.mjs +233 -0
  40. package/bin/lib/locales/en.json +344 -0
  41. package/bin/lib/locales/zh.json +344 -0
  42. package/bin/lib/log.mjs +37 -0
  43. package/bin/lib/main.mjs +611 -0
  44. package/bin/lib/model-config.mjs +118 -0
  45. package/bin/lib/model-lists.mjs +188 -0
  46. package/bin/lib/node-exec-context.mjs +336 -0
  47. package/bin/lib/node-execute.mjs +513 -0
  48. package/bin/lib/normalize-node-tool-command.mjs +97 -0
  49. package/bin/lib/paths.mjs +216 -0
  50. package/bin/lib/pipeline-scripts.mjs +41 -0
  51. package/bin/lib/recent-runs.mjs +173 -0
  52. package/bin/lib/run-apply-active-lock.mjs +82 -0
  53. package/bin/lib/run-events.mjs +85 -0
  54. package/bin/lib/run-node-statuses-from-disk.mjs +85 -0
  55. package/bin/lib/schedule-config.mjs +227 -0
  56. package/bin/lib/scheduler.mjs +312 -0
  57. package/bin/lib/table.mjs +4 -0
  58. package/bin/lib/terminal.mjs +42 -0
  59. package/bin/lib/ui-print.mjs +94 -0
  60. package/bin/lib/ui-server.mjs +2113 -0
  61. package/bin/lib/workspace-tree.mjs +266 -0
  62. package/bin/lib/workspace.mjs +180 -0
  63. package/bin/pipeline/build-node-prompt.mjs +179 -0
  64. package/bin/pipeline/check-cache.mjs +191 -0
  65. package/bin/pipeline/check-flow.mjs +543 -0
  66. package/bin/pipeline/collect-nodes.mjs +212 -0
  67. package/bin/pipeline/compute-cache-md5.mjs +177 -0
  68. package/bin/pipeline/ensure-run-dir.mjs +71 -0
  69. package/bin/pipeline/extract-thinking.mjs +308 -0
  70. package/bin/pipeline/gc.mjs +129 -0
  71. package/bin/pipeline/get-env.mjs +83 -0
  72. package/bin/pipeline/get-exec-id.mjs +145 -0
  73. package/bin/pipeline/get-ready-nodes.mjs +435 -0
  74. package/bin/pipeline/get-resolved-values.mjs +337 -0
  75. package/bin/pipeline/load-key.mjs +62 -0
  76. package/bin/pipeline/parse-bool.mjs +33 -0
  77. package/bin/pipeline/parse-flow.mjs +698 -0
  78. package/bin/pipeline/post-process-control-if.mjs +23 -0
  79. package/bin/pipeline/post-process-node.mjs +490 -0
  80. package/bin/pipeline/pre-process-node.mjs +449 -0
  81. package/bin/pipeline/resolve-inputs.mjs +201 -0
  82. package/bin/pipeline/run-log.mjs +34 -0
  83. package/bin/pipeline/run-tool-nodejs.mjs +160 -0
  84. package/bin/pipeline/save-key.mjs +93 -0
  85. package/bin/pipeline/snapshot-prior-round.mjs +70 -0
  86. package/bin/pipeline/validate-flow.mjs +825 -0
  87. package/bin/pipeline/validate-for-ui.mjs +226 -0
  88. package/bin/pipeline/validate-script-output.mjs +130 -0
  89. package/bin/pipeline/write-result.mjs +182 -0
  90. package/builtin/nodes/agent_subAgent.md +14 -0
  91. package/builtin/nodes/control_agent_toBool.md +20 -0
  92. package/builtin/nodes/control_anyOne.md +17 -0
  93. package/builtin/nodes/control_end.md +11 -0
  94. package/builtin/nodes/control_if.md +20 -0
  95. package/builtin/nodes/control_start.md +11 -0
  96. package/builtin/nodes/control_toBool.md +21 -0
  97. package/builtin/nodes/provide_file.md +11 -0
  98. package/builtin/nodes/provide_str.md +11 -0
  99. package/builtin/nodes/tool_get_env.md +14 -0
  100. package/builtin/nodes/tool_load_key.md +20 -0
  101. package/builtin/nodes/tool_nodejs.md +40 -0
  102. package/builtin/nodes/tool_print.md +14 -0
  103. package/builtin/nodes/tool_save_key.md +20 -0
  104. package/builtin/nodes/tool_user_ask.md +23 -0
  105. package/builtin/nodes/tool_user_check.md +22 -0
  106. package/builtin/pipelines/module-migrate/flow.yaml +819 -0
  107. package/builtin/pipelines/module-migrate/scripts/check_imports.mjs +700 -0
  108. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Makefile +362 -0
  109. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/.deps/Release/obj.target/node_modules/node-addon-api/node_addon_api_except.stamp.d +1 -0
  110. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/.deps/Release/obj.target/tree_sitter_kotlin_binding/bindings/node/binding.o.d +17 -0
  111. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/.deps/Release/obj.target/tree_sitter_kotlin_binding/src/parser.o.d +5 -0
  112. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/.deps/Release/obj.target/tree_sitter_kotlin_binding/src/scanner.o.d +8 -0
  113. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/.deps/Release/tree_sitter_kotlin_binding.node.d +1 -0
  114. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/obj.target/node_modules/node-addon-api/node_addon_api_except.stamp +0 -0
  115. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/obj.target/tree_sitter_kotlin_binding/bindings/node/binding.o +0 -0
  116. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/obj.target/tree_sitter_kotlin_binding/src/parser.o +0 -0
  117. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/obj.target/tree_sitter_kotlin_binding/src/scanner.o +0 -0
  118. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/Release/tree_sitter_kotlin_binding.node +0 -0
  119. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/binding.Makefile +6 -0
  120. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/gyp-mac-tool +768 -0
  121. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/node_modules/node-addon-api/node_addon_api.Makefile +6 -0
  122. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/node_modules/node-addon-api/node_addon_api.target.mk +122 -0
  123. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/node_modules/node-addon-api/node_addon_api_except.target.mk +126 -0
  124. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/node_modules/node-addon-api/node_addon_api_maybe.target.mk +122 -0
  125. package/builtin/pipelines/module-migrate/scripts/node_modules/tree-sitter-kotlin/build/tree_sitter_kotlin_binding.target.mk +203 -0
  126. package/builtin/pipelines/new/flow.yaml +545 -0
  127. package/builtin/pipelines/new/scripts/check-flow.mjs +9 -0
  128. package/builtin/pipelines/new/scripts/collect-nodes.mjs +211 -0
  129. package/builtin/pipelines/scripts/adjust-node-positions.mjs +113 -0
  130. package/builtin/web-ui/dist/agentflow-icon.svg +23 -0
  131. package/builtin/web-ui/dist/assets/index-CZkUPcXE.css +1 -0
  132. package/builtin/web-ui/dist/assets/index-DkkhNESc.js +190 -0
  133. package/builtin/web-ui/dist/index.html +24 -0
  134. package/package.json +67 -0
  135. package/reference/flow-control-capabilities.md +274 -0
  136. package/reference/flow-layout.md +84 -0
  137. package/reference/flow-prompt-handler-check.md +12 -0
  138. package/reference/flow-result-semantics.md +14 -0
@@ -0,0 +1,2113 @@
1
+ /**
2
+ * 本地 HTTP:静态 UI + /api/flows(GET/POST/HEAD)、/api/flows/import(POST multipart 导入 .yaml/.zip)、/api/flow/archive(POST)、/api/flow/delete(POST 永久删除)、/api/model-lists、/api/ui-context、/api/pipeline-recent-runs、/api/run-node-statuses(GET 某次 run 各节点磁盘状态)、/api/workspace-tree(GET 工作区目录树)、/api/nodes、/api/flow(GET/POST)、
3
+ * /api/flow-editor-sync(POST 通知画布刷新)、/api/flow-editor-sync-events(GET SSE)、/api/flow/run(POST NDJSON 流式执行 agentflow apply --machine-readable)、/api/flow/run/stop(POST 终止运行)、
4
+ * /api/composer-agent(POST NDJSON;有 flow 时结束后 validate-flow,失败则自动 agent 修复至多 5 次)、
5
+ * /api/agentflow-config(GET/POST 读写 ~/agentflow/config.json 的 opencodeProvider;POST 后执行 update-model-lists)、/api/update-model-lists(POST 可选 JSON body.opencodeProvider 覆盖本次拉取用的 Provider,未保存 config 也可用);
6
+ * listen 后后台 updateModelLists
7
+ */
8
+ import fs from "fs";
9
+ import http from "http";
10
+ import path from "path";
11
+ import { spawn } from "child_process";
12
+ import busboy from "busboy";
13
+ import { log } from "./log.mjs";
14
+ import { getFlowYamlAbs, listFlowsJson, listNodesJson, readFlowJson } from "./catalog-flows.mjs";
15
+ import {
16
+ FLOW_YAML_FILENAME,
17
+ archiveFlowPipeline,
18
+ buildEmptyUserFlowYaml,
19
+ deleteFlowPipeline,
20
+ moveFlowDirectory,
21
+ resolveFlowDirForWrite,
22
+ validateUserPipelineId,
23
+ writeFlowYaml,
24
+ } from "./flow-write.mjs";
25
+ import { updateModelLists } from "./model-lists.mjs";
26
+ import {
27
+ startComposerAgent,
28
+ startComposerMultiStep,
29
+ shouldUseMultiStep,
30
+ runComposerPostFlowValidationAndRepair,
31
+ buildScriptContentBlockForInstances,
32
+ buildQueryContextBlock,
33
+ } from "./composer-agent.mjs";
34
+ import { t } from "./i18n.mjs";
35
+ import {
36
+ PACKAGE_ROOT,
37
+ getAgentflowUserConfigAbs,
38
+ getModelListsAbs,
39
+ getRunDir,
40
+ } from "./paths.mjs";
41
+ import { RUN_INTERRUPTED_FILENAME } from "./recent-runs.mjs";
42
+ import {
43
+ detectIntents,
44
+ classifyIntentCategory,
45
+ loadResourcesForIntents,
46
+ buildSkillInjectionBlock,
47
+ buildSkillCompactInjectionBlock,
48
+ } from "./composer-skill-router.mjs";
49
+ import { COMPOSER_NODE_SPEC_FILENAME } from "./composer-planner.mjs";
50
+ import { listRecentRunsFromDisk } from "./recent-runs.mjs";
51
+ import {
52
+ unzipAndNormalizePipelineZip,
53
+ validateImportedFlowYaml,
54
+ writePipelineTree,
55
+ } from "./flow-import.mjs";
56
+ import { getWorkspaceTree, getPipelineFiles } from "./workspace-tree.mjs";
57
+ import {
58
+ createComposerSession,
59
+ logComposerEvent,
60
+ truncateForLog,
61
+ listRecentComposerSessions,
62
+ parseComposerLogFile,
63
+ readComposerSessionMeta,
64
+ } from "./composer-log.mjs";
65
+ import { runNodeScript } from "./pipeline-scripts.mjs";
66
+ import { readFlowSchedule, writeFlowSchedule } from "./schedule-config.mjs";
67
+ import { listScheduleStatuses } from "./scheduler.mjs";
68
+
69
+ const MIME = {
70
+ ".html": "text/html; charset=utf-8",
71
+ ".js": "text/javascript; charset=utf-8",
72
+ ".css": "text/css; charset=utf-8",
73
+ ".json": "application/json; charset=utf-8",
74
+ ".ico": "image/x-icon",
75
+ ".svg": "image/svg+xml",
76
+ };
77
+
78
+ const RUN_CONFIG_FILENAME = "run-config.json";
79
+
80
+ function json(res, status, obj) {
81
+ const body = JSON.stringify(obj);
82
+ res.writeHead(status, {
83
+ "Content-Type": "application/json; charset=utf-8",
84
+ "Content-Length": Buffer.byteLength(body),
85
+ });
86
+ res.end(body);
87
+ }
88
+
89
+ function readAgentflowUserConfigObject() {
90
+ const p = getAgentflowUserConfigAbs();
91
+ try {
92
+ if (!fs.existsSync(p)) return {};
93
+ const data = JSON.parse(fs.readFileSync(p, "utf-8"));
94
+ return data && typeof data === "object" && !Array.isArray(data) ? data : {};
95
+ } catch {
96
+ return {};
97
+ }
98
+ }
99
+
100
+ function readModelListsFromDisk(workspaceRoot) {
101
+ const p = getModelListsAbs();
102
+ const empty = {
103
+ cursor: [],
104
+ opencode: [],
105
+ claudeCode: [],
106
+ cursorFetchedAt: null,
107
+ opencodeFetchedAt: null,
108
+ claudeCodeFetchedAt: null,
109
+ };
110
+ try {
111
+ if (!fs.existsSync(p)) return empty;
112
+ const data = JSON.parse(fs.readFileSync(p, "utf-8"));
113
+ return {
114
+ cursor: Array.isArray(data.cursor) ? data.cursor.map(String) : [],
115
+ opencode: Array.isArray(data.opencode) ? data.opencode.map(String) : [],
116
+ claudeCode: Array.isArray(data.claudeCode) ? data.claudeCode.map(String) : [],
117
+ cursorFetchedAt: data.cursorFetchedAt ?? null,
118
+ opencodeFetchedAt: data.opencodeFetchedAt ?? null,
119
+ claudeCodeFetchedAt: data.claudeCodeFetchedAt ?? null,
120
+ };
121
+ } catch {
122
+ return empty;
123
+ }
124
+ }
125
+
126
+ function readBody(req) {
127
+ return new Promise((resolve, reject) => {
128
+ const chunks = [];
129
+ req.on("data", (c) => chunks.push(c));
130
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
131
+ req.on("error", reject);
132
+ });
133
+ }
134
+
135
+ /** ZIP 本地头:PK\x03\x04 / \x05\x06 / \x07\x08 */
136
+ function bufferLooksLikeZip(buf) {
137
+ return (
138
+ buf.length >= 4 &&
139
+ buf[0] === 0x50 &&
140
+ buf[1] === 0x4b &&
141
+ (buf[2] === 0x03 || buf[2] === 0x05 || buf[2] === 0x07) &&
142
+ (buf[3] === 0x04 || buf[3] === 0x06 || buf[3] === 0x08)
143
+ );
144
+ }
145
+
146
+ /**
147
+ * @param {import('http').IncomingMessage} req
148
+ * @returns {Promise<{ targetSpace: string, flowIdField: string, file: Buffer, filename: string, gotFile: boolean }>}
149
+ */
150
+ function parseFlowsImportForm(req) {
151
+ return new Promise((resolve, reject) => {
152
+ const bb = busboy({
153
+ headers: req.headers,
154
+ limits: { files: 1, fileSize: 10 * 1024 * 1024, parts: 32 },
155
+ });
156
+ let targetSpace = "user";
157
+ let flowIdField = "";
158
+ /** @type {Buffer[]} */
159
+ const chunks = [];
160
+ let filename = "";
161
+ let gotFile = false;
162
+
163
+ bb.on("field", (name, val) => {
164
+ if (name === "targetSpace" && (val === "workspace" || val === "user")) {
165
+ targetSpace = val;
166
+ }
167
+ if (name === "flowId" && typeof val === "string") {
168
+ flowIdField = val;
169
+ }
170
+ });
171
+
172
+ bb.on("file", (name, file, info) => {
173
+ if (name !== "file") {
174
+ file.resume();
175
+ return;
176
+ }
177
+ gotFile = true;
178
+ filename = info.filename || "";
179
+ file.on("data", (d) => chunks.push(d));
180
+ file.on("limit", () => {
181
+ reject(new Error("FILE_TOO_LARGE"));
182
+ });
183
+ });
184
+
185
+ bb.on("finish", () => {
186
+ resolve({
187
+ targetSpace,
188
+ flowIdField: flowIdField.trim(),
189
+ file: Buffer.concat(chunks),
190
+ filename,
191
+ gotFile,
192
+ });
193
+ });
194
+ bb.on("error", reject);
195
+ req.pipe(bb);
196
+ });
197
+ }
198
+
199
+ /** GET 读 flow / nodes / SSE 等 */
200
+ function isValidFlowSourceRead(s) {
201
+ return s === "builtin" || s === "user" || s === "workspace";
202
+ }
203
+
204
+ /** POST 写 flow */
205
+ function isValidFlowSourceWrite(s) {
206
+ return s === "user" || s === "workspace";
207
+ }
208
+
209
+ /** Composer 打开的画布通过 SSE 订阅;POST /api/flow-editor-sync 向对应 flow 推送刷新 */
210
+ const flowEditorSyncSubscribers = new Map();
211
+ /** 每次 broadcastFlowEditorSync 时递增,供轮询端点 /api/flow-editor-sync-poll 使用 */
212
+ const flowEditorSyncVersions = new Map();
213
+
214
+ function flowEditorSyncKey(flowId, flowSource, flowArchived) {
215
+ return `${String(flowId)}\t${String(flowSource)}\t${flowArchived ? "1" : "0"}`;
216
+ }
217
+
218
+ function broadcastFlowEditorSync(flowId, flowSource, flowArchived = false) {
219
+ const key = flowEditorSyncKey(flowId, flowSource, flowArchived);
220
+
221
+ /* 递增轮询版本号 */
222
+ flowEditorSyncVersions.set(key, (flowEditorSyncVersions.get(key) ?? 0) + 1);
223
+
224
+ const set = flowEditorSyncSubscribers.get(key);
225
+ if (!set || set.size === 0) return;
226
+ const payload = JSON.stringify({ type: "refresh" });
227
+ const chunk = `data: ${payload}\n\n`;
228
+ for (const clientRes of set) {
229
+ try {
230
+ clientRes.write(chunk);
231
+ } catch (_) {}
232
+ }
233
+ }
234
+
235
+ /** 正在执行的 flow run(flowId → { child, runUuid });同一 flow 只允许一个 run */
236
+ const activeFlowRuns = new Map();
237
+
238
+ /** Cursor/OpenCode 执行目录统一使用当前 UI 启动 workspace。 */
239
+ function composerCliWorkspaceForFlowDir(workspaceRoot, _flowDir) {
240
+ return path.resolve(workspaceRoot);
241
+ }
242
+
243
+ /**
244
+ * @param {object} p
245
+ * @param {string} p.flowYamlAbs
246
+ * @param {string} p.flowId
247
+ * @param {"builtin" | "user" | "workspace"} p.flowSource
248
+ * @param {string} [p.workspaceWriteDirAbs] builtin 时可写副本根目录(…/pipelines/<flowId>)
249
+ * @param {"user" | "workspace"} [p.editorSyncFlowSource] flow-editor-sync 使用的 flowSource(builtin 时为 workspace)
250
+ * @param {string[]} p.instanceIds
251
+ * @param {string} p.userPrompt
252
+ * @param {number} p.uiPort 本地 Web UI 端口(用于 flow 保存后通知浏览器刷新)
253
+ * @param {boolean} [p.flowArchived]
254
+ */
255
+ const THREAD_HISTORY_MAX_CHARS = 8000;
256
+ const THREAD_HISTORY_MAX_TURNS = 20;
257
+
258
+ function formatThreadHistory(thread) {
259
+ if (!thread || thread.length === 0) return "";
260
+ const recent = thread.slice(-THREAD_HISTORY_MAX_TURNS);
261
+ const lines = [];
262
+ let chars = 0;
263
+ for (let i = recent.length - 1; i >= 0; i--) {
264
+ const m = recent[i];
265
+ const label = m.role === "user" ? "用户" : "助手";
266
+ const text = m.text.length > 1500 ? m.text.slice(0, 1500) + "…(截断)" : m.text;
267
+ const line = `${label}:${text}`;
268
+ if (chars + line.length > THREAD_HISTORY_MAX_CHARS) break;
269
+ lines.unshift(line);
270
+ chars += line.length;
271
+ }
272
+ if (lines.length === 0) return "";
273
+ return "## 对话历史\n\n" + lines.join("\n\n");
274
+ }
275
+
276
+ function buildComposerPromptWithFlowContext(p) {
277
+ const intentCategory = p.intentCategory || "generic";
278
+ const flowDirAbs = path.dirname(p.flowYamlAbs);
279
+
280
+ // ── query 轻量路径:只注入节点上下文 + 问题,跳过全部编辑规则 ──
281
+ if (intentCategory === "query") {
282
+ const queryCtx = buildQueryContextBlock(p.flowYamlAbs, p.instanceIds);
283
+ const parts = [
284
+ "## AgentFlow 问答上下文",
285
+ `- 流水线(flowId=${p.flowId}):${flowDirAbs}`,
286
+ `- 图定义文件:${p.flowYamlAbs}`,
287
+ "",
288
+ ];
289
+ if (queryCtx) {
290
+ parts.push(queryCtx, "");
291
+ }
292
+ parts.push(
293
+ "请基于上方注入的节点 YAML 与脚本内容回答用户的问题。**不要修改任何文件。**",
294
+ ""
295
+ );
296
+ if (p.thread && p.thread.length > 0) {
297
+ parts.push(formatThreadHistory(p.thread), "");
298
+ }
299
+ parts.push("## 用户说明", "", p.userPrompt.trim());
300
+ return parts.join("\n");
301
+ }
302
+
303
+ // ── 编辑路径(edit-node / add-node / add-flow / edit-flow / generic) ──
304
+ const idsLine =
305
+ p.instanceIds.length > 0 ? p.instanceIds.map(String).join(", ") : "(无,可能为全局修改或新增节点)";
306
+ const syncFs = p.editorSyncFlowSource ?? p.flowSource;
307
+ const syncBody = { flowId: p.flowId, flowSource: syncFs };
308
+ if (p.flowArchived) syncBody.flowArchived = true;
309
+ const syncJsonArg = JSON.stringify(JSON.stringify(syncBody));
310
+ const builtinExtra =
311
+ p.flowSource === "builtin" && p.workspaceWriteDirAbs
312
+ ? [
313
+ `- 包内 builtin 模板为只读;若保存修改请写入工作区副本目录:${p.workspaceWriteDirAbs}(flow.yaml 与同 id)`,
314
+ "- 保存后刷新 Web 画布时,flow-editor-sync 的 JSON 须使用 flowSource: workspace(与上方 curl 一致)。",
315
+ ]
316
+ : [];
317
+
318
+ // 基于用户意图动态注入 skill 和 reference 内容
319
+ const intents = detectIntents(p.userPrompt);
320
+ const resources = loadResourcesForIntents(intents, PACKAGE_ROOT);
321
+ const skillBlock = resources.hasContext
322
+ ? buildSkillInjectionBlock(resources.skills, resources.references)
323
+ : "";
324
+
325
+ // 无意图匹配时使用通用 skill 路径引用作为兜底
326
+ const skillPathHints = resources.hasContext
327
+ ? []
328
+ : [
329
+ "- 新增实例与边:遵循 skill `skills/agentflow-flow-add-instances/SKILL.md`(或 `.cursor/skills/.../SKILL.md`)。",
330
+ "- 仅改已有实例文案/占位等:遵循 `skills/agentflow-flow-edit-node-fields/SKILL.md`,勿改 definitionId、instanceId、IO 结构与边拓扑。",
331
+ ];
332
+
333
+ // edit-node: 不需要重型节点类型选择规则和 tool_nodejs 区分
334
+ const needsNodeTypeRules = intentCategory !== "edit-node";
335
+
336
+ const prefix = [
337
+ "## AgentFlow 编辑上下文",
338
+ `- 流水线目录(flowId=${p.flowId}):${flowDirAbs}`,
339
+ `- 图定义文件(必读/必改此文件):${p.flowYamlAbs}`,
340
+ `- flowId:${p.flowId}`,
341
+ `- flowSource:${p.flowSource}`,
342
+ ...builtinExtra,
343
+ `- 当前关联的节点实例 ID(顺序:画布选中优先,再输入框 @提及):${idsLine}`,
344
+ ...skillPathHints,
345
+ "",
346
+ ...(needsNodeTypeRules ? [
347
+ "### 节点类型选择(必须遵守)",
348
+ "**判据**:**确定性任务 → `tool_nodejs`;非确定性任务 → `agent_subAgent`**。",
349
+ "- **确定性**:相同输入永远产出相同输出,可用普通代码完整描述(CLI/npm 调用、读写文件、JSON/路径转换、调现成 API 解析固定格式、跑脚手架等)",
350
+ "- **非确定性**:需要语义理解或创造(代码翻译/生成、源码/文本解析改写、多步推理决策、创意写作)",
351
+ "| 场景 | 推荐节点 |",
352
+ "|------|----------|",
353
+ "| 确定性逻辑(跑命令、读写文件、转换格式、调 API) | **tool_nodejs** + `script` |",
354
+ "| 醒目输出 | **tool_print** |",
355
+ "| 代码翻译/生成、源码/文本理解、多步决策、创意写作 | **agent_subAgent** |",
356
+ "**反例**:「Android 转 RN/TS」「分析代码生成测试」「代码 review」必须 agent——做成 tool_nodejs 必然失败。",
357
+ "",
358
+ "tool_nodejs + script 示例(打印文本):",
359
+ "```yaml",
360
+ "print_hello:",
361
+ " definitionId: tool_nodejs",
362
+ " label: 打印Hello",
363
+ ' script: node -e "console.log(${value})"',
364
+ " input:",
365
+ " - { type: 节点, name: prev, value: '' }",
366
+ " - { type: 文本, name: value, value: '' }",
367
+ " output:",
368
+ " - { type: 节点, name: next, value: '' }",
369
+ " - { type: 文本, name: result, value: '' }",
370
+ "```",
371
+ "script 成败以 exit code 为准(0=success),stdout 直接作为 result 槽位内容(纯文本即可,如 console.log)。",
372
+ "常见误用:用 agent_subAgent 做「打印一段文字」「执行已有脚本」→ 应改用 tool_nodejs + script 或 tool_print。",
373
+ "",
374
+ ] : []),
375
+ "### tool_nodejs 的 script 与 body 关键区分",
376
+ "- **`script` 字段**:实际执行的命令代码,流水线直接 spawn 执行;**tool_nodejs 必须写 script**",
377
+ "- **`body` 字段**:纯文档注释,有 script 时完全不执行;**禁止在 body 写期望执行的逻辑**",
378
+ "- 如果无法写出完整可执行的 script(需要 AI 理解/判断),**必须改用 agent_subAgent**,不要用 tool_nodejs",
379
+ "- script 支持多行(YAML `|`)和管道,可写复杂的 curl + node 组合",
380
+ "- **禁止**:tool_nodejs 只有 body 没有 script(body 中的自然语言不会被执行,节点会失败)",
381
+ "",
382
+ // 动态注入的 skill 和 reference 内容
383
+ ...(skillBlock ? [skillBlock, ""] : []),
384
+ "- **保存 flow.yaml 后必须刷新 Web 画布**:遵循 `skills/agentflow-flow-sync-ui/SKILL.md`;在终端执行(将 JSON 与上方 flowId、flowSource" +
385
+ (p.flowArchived ? "、flowArchived" : "") +
386
+ " 保持一致):",
387
+ ` curl -sS -X POST http://127.0.0.1:${p.uiPort}/api/flow-editor-sync -H 'Content-Type: application/json' -d ${syncJsonArg}`,
388
+ "",
389
+ ...(p.thread && p.thread.length > 0
390
+ ? [formatThreadHistory(p.thread), ""]
391
+ : []),
392
+ ...(p.scriptContentBlock ? [p.scriptContentBlock, ""] : []),
393
+ "## 用户说明",
394
+ "",
395
+ p.userPrompt.trim(),
396
+ ].join("\n");
397
+ return prefix;
398
+ }
399
+
400
+ function normalizeContextInstanceIds(raw) {
401
+ if (raw == null) return [];
402
+ if (!Array.isArray(raw)) return [];
403
+ const out = [];
404
+ const seen = new Set();
405
+ for (const x of raw) {
406
+ const s = typeof x === "string" ? x.trim() : String(x ?? "").trim();
407
+ if (!s || seen.has(s)) continue;
408
+ seen.add(s);
409
+ out.push(s);
410
+ }
411
+ return out;
412
+ }
413
+
414
+ /**
415
+ * @param {object} opts
416
+ * @param {string} opts.workspaceRoot
417
+ * @param {number} opts.port
418
+ * @param {string} [opts.staticDir] 默认 PACKAGE_ROOT/builtin/web-ui/dist(npm run build 产出)
419
+ * @returns {Promise<import('http').Server>}
420
+ */
421
+ export function startUiServer({
422
+ workspaceRoot,
423
+ port,
424
+ host = "127.0.0.1",
425
+ staticDir = path.join(PACKAGE_ROOT, "builtin", "web-ui", "dist"),
426
+ }) {
427
+ const root = path.resolve(workspaceRoot);
428
+ const uiPort = port;
429
+
430
+ const server = http.createServer(async (req, res) => {
431
+ const url = new URL(req.url || "/", "http://127.0.0.1");
432
+ const reqStart = Date.now();
433
+ log.debug(`[ui] ${req.method} ${url.pathname}${url.search || ""}`);
434
+
435
+ const origEnd = res.end.bind(res);
436
+ res.end = function (...args) {
437
+ log.debug(`[ui] ${req.method} ${url.pathname} → ${res.statusCode} (${Date.now() - reqStart}ms)`);
438
+ return origEnd(...args);
439
+ };
440
+
441
+ if (url.pathname === "/api/flows") {
442
+ if (req.method === "GET") {
443
+ try {
444
+ json(res, 200, listFlowsJson(root));
445
+ } catch (e) {
446
+ json(res, 500, { error: (e && e.message) || String(e) });
447
+ }
448
+ return;
449
+ }
450
+ if (req.method === "HEAD") {
451
+ res.writeHead(200, { "Content-Type": "application/json" });
452
+ res.end();
453
+ return;
454
+ }
455
+ if (req.method === "POST") {
456
+ let payload;
457
+ try {
458
+ payload = JSON.parse(await readBody(req));
459
+ } catch {
460
+ json(res, 400, { error: "Invalid JSON body" });
461
+ return;
462
+ }
463
+ const idCheck = validateUserPipelineId(payload.flowId);
464
+ if (!idCheck.ok) {
465
+ json(res, 400, { error: idCheck.error });
466
+ return;
467
+ }
468
+ const flowId = idCheck.flowId;
469
+ const desc =
470
+ payload.description != null && typeof payload.description === "string"
471
+ ? payload.description
472
+ : "";
473
+ let targetSpace = "user";
474
+ const ts = payload.targetSpace;
475
+ if (ts === "workspace" || ts === "user") {
476
+ targetSpace = ts;
477
+ }
478
+ const existing = listFlowsJson(root);
479
+ if (
480
+ existing.some(
481
+ (f) => f.id === flowId && (f.source ?? "user") === targetSpace && !f.archived,
482
+ )
483
+ ) {
484
+ json(res, 409, { error: "已存在同名流水线,请换一个名称" });
485
+ return;
486
+ }
487
+ const flowYaml = buildEmptyUserFlowYaml({ description: desc });
488
+ const result = writeFlowYaml(root, flowId, targetSpace, flowYaml);
489
+ if (!result.success) {
490
+ json(res, 400, result);
491
+ return;
492
+ }
493
+ json(res, 200, { success: true, flowId, flowSource: targetSpace });
494
+ return;
495
+ }
496
+ const body405 = JSON.stringify({ error: "Method not allowed" });
497
+ res.writeHead(405, {
498
+ "Content-Type": "application/json; charset=utf-8",
499
+ Allow: "GET, POST, HEAD",
500
+ "Content-Length": Buffer.byteLength(body405),
501
+ });
502
+ res.end(body405);
503
+ return;
504
+ }
505
+
506
+ if (req.method === "POST" && url.pathname === "/api/flows/import") {
507
+ const ct = req.headers["content-type"] || "";
508
+ if (!ct.toLowerCase().startsWith("multipart/form-data")) {
509
+ json(res, 415, { error: "需要 multipart/form-data" });
510
+ return;
511
+ }
512
+ let parsed;
513
+ try {
514
+ parsed = await parseFlowsImportForm(req);
515
+ } catch (e) {
516
+ if (e && e.message === "FILE_TOO_LARGE") {
517
+ json(res, 400, { error: "文件过大(最大 10MB)" });
518
+ return;
519
+ }
520
+ json(res, 400, { error: (e && e.message) || String(e) });
521
+ return;
522
+ }
523
+ if (!parsed.gotFile || !parsed.file.length) {
524
+ json(res, 400, { error: "请上传文件(字段名 file)" });
525
+ return;
526
+ }
527
+ const idCheck = validateUserPipelineId(parsed.flowIdField);
528
+ if (!idCheck.ok) {
529
+ json(res, 400, { error: idCheck.error });
530
+ return;
531
+ }
532
+ const flowId = idCheck.flowId;
533
+ const targetSpace = parsed.targetSpace === "workspace" ? "workspace" : "user";
534
+ const existing = listFlowsJson(root);
535
+ if (
536
+ existing.some(
537
+ (f) => f.id === flowId && (f.source ?? "user") === targetSpace && !f.archived,
538
+ )
539
+ ) {
540
+ json(res, 409, { error: "已存在同名流水线,请换一个名称" });
541
+ return;
542
+ }
543
+
544
+ const buf = parsed.file;
545
+ /** @type {Map<string, Buffer> | null} */
546
+ let filesMap = null;
547
+
548
+ if (bufferLooksLikeZip(buf)) {
549
+ const norm = unzipAndNormalizePipelineZip(buf);
550
+ if (!norm.ok) {
551
+ json(res, 400, { error: norm.error });
552
+ return;
553
+ }
554
+ filesMap = norm.files;
555
+ } else {
556
+ const text = buf.toString("utf8");
557
+ const v = validateImportedFlowYaml(text);
558
+ if (!v.ok) {
559
+ json(res, 400, { error: v.error });
560
+ return;
561
+ }
562
+ filesMap = new Map([["flow.yaml", Buffer.from(text, "utf8")]]);
563
+ }
564
+
565
+ const w = writePipelineTree(root, flowId, targetSpace, filesMap);
566
+ if (!w.success) {
567
+ json(res, 400, { error: w.error });
568
+ return;
569
+ }
570
+ json(res, 200, { success: true, flowId, flowSource: targetSpace });
571
+ return;
572
+ }
573
+
574
+ // ── Node execution context (run-mode sidebar) ──
575
+ if (req.method === "GET" && url.pathname === "/api/node-exec-context") {
576
+ try {
577
+ const flowId = url.searchParams.get("flowId") || "";
578
+ const instanceId = url.searchParams.get("instanceId") || "";
579
+ const runId = url.searchParams.get("runId") || "";
580
+ if (!flowId || !instanceId) {
581
+ json(res, 400, { error: "Missing flowId or instanceId" });
582
+ return;
583
+ }
584
+ const { getNodeExecContext } = await import("./node-exec-context.mjs");
585
+ json(res, 200, getNodeExecContext(root, flowId, instanceId, runId));
586
+ } catch (e) {
587
+ json(res, 500, { error: (e && e.message) || String(e) });
588
+ }
589
+ return;
590
+ }
591
+
592
+ if (req.method === "GET" && url.pathname === "/api/pipeline-recent-runs") {
593
+ try {
594
+ json(res, 200, { runs: listRecentRunsFromDisk(root) });
595
+ } catch (e) {
596
+ json(res, 500, { error: (e && e.message) || String(e) });
597
+ }
598
+ return;
599
+ }
600
+
601
+ if (req.method === "GET" && url.pathname === "/api/run-node-statuses") {
602
+ try {
603
+ const flowId = url.searchParams.get("flowId") || "";
604
+ const runId = url.searchParams.get("runId") || "";
605
+ if (!flowId || !runId) {
606
+ json(res, 400, { error: "Missing flowId or runId" });
607
+ return;
608
+ }
609
+ const { getRunNodeStatusesFromDisk } = await import("./run-node-statuses-from-disk.mjs");
610
+ json(res, 200, { statuses: getRunNodeStatusesFromDisk(root, flowId, runId) });
611
+ } catch (e) {
612
+ json(res, 500, { error: (e && e.message) || String(e) });
613
+ }
614
+ return;
615
+ }
616
+
617
+ if (req.method === "GET" && url.pathname === "/api/run-log") {
618
+ try {
619
+ const flowId = url.searchParams.get("flowId") || "";
620
+ const runId = url.searchParams.get("runId") || "";
621
+ const sinceBytes = Math.max(0, parseInt(url.searchParams.get("sinceBytes") || "0", 10) || 0);
622
+ // tailBytes: 仅返回文件末尾 N 字节。用于初次打开长跑 run 时避免拉取整份日志。
623
+ const tailBytesRaw = url.searchParams.get("tailBytes");
624
+ const tailBytes = tailBytesRaw != null ? Math.max(0, parseInt(tailBytesRaw, 10) || 0) : 0;
625
+ if (!flowId || !runId) {
626
+ json(res, 400, { error: "Missing flowId or runId" });
627
+ return;
628
+ }
629
+ const { getRunDir } = await import("./workspace.mjs");
630
+ const { RUN_LOG_REL } = await import("./paths.mjs");
631
+ const { default: fsMod } = await import("node:fs");
632
+ const logPath = path.join(getRunDir(root, flowId, runId), RUN_LOG_REL);
633
+ if (!fsMod.existsSync(logPath)) {
634
+ json(res, 200, { bytes: 0, text: "" });
635
+ return;
636
+ }
637
+ const stat = fsMod.statSync(logPath);
638
+ const size = stat.size;
639
+ const startOffset = tailBytes > 0 ? Math.max(sinceBytes, size - tailBytes) : sinceBytes;
640
+ if (startOffset >= size) {
641
+ json(res, 200, { bytes: size, text: "" });
642
+ return;
643
+ }
644
+ const fd = fsMod.openSync(logPath, "r");
645
+ try {
646
+ const len = size - startOffset;
647
+ const buf = Buffer.alloc(len);
648
+ fsMod.readSync(fd, buf, 0, len, startOffset);
649
+ let text = buf.toString("utf-8");
650
+ // 截断点可能落在一行中间,扔掉残行的前缀,保证解析端按行起步。
651
+ if (tailBytes > 0 && startOffset > 0) {
652
+ const nl = text.indexOf("\n");
653
+ if (nl >= 0) text = text.slice(nl + 1);
654
+ }
655
+ json(res, 200, { bytes: size, text });
656
+ } finally {
657
+ fsMod.closeSync(fd);
658
+ }
659
+ } catch (e) {
660
+ json(res, 500, { error: (e && e.message) || String(e) });
661
+ }
662
+ return;
663
+ }
664
+
665
+ if (req.method === "GET" && url.pathname === "/api/workspace-tree") {
666
+ try {
667
+ json(res, 200, getWorkspaceTree(root));
668
+ } catch (e) {
669
+ json(res, 500, { error: (e && e.message) || String(e) });
670
+ }
671
+ return;
672
+ }
673
+
674
+ if (req.method === "GET" && url.pathname === "/api/pipeline-files") {
675
+ const flowId = url.searchParams.get("flowId");
676
+ const flowSource = url.searchParams.get("flowSource") || "user";
677
+ const archived = url.searchParams.get("archived") === "1";
678
+ if (!flowId) {
679
+ json(res, 400, { error: "Missing flowId" });
680
+ return;
681
+ }
682
+ try {
683
+ const result = getPipelineFiles(root, flowId, flowSource, archived);
684
+ json(res, 200, result);
685
+ } catch (e) {
686
+ json(res, 500, { error: (e && e.message) || String(e) });
687
+ }
688
+ return;
689
+ }
690
+
691
+ if (req.method === "GET" && url.pathname === "/api/pipeline-file-content") {
692
+ const flowId = url.searchParams.get("flowId");
693
+ const flowSource = url.searchParams.get("flowSource") || "user";
694
+ const archived = url.searchParams.get("archived") === "1";
695
+ const filePath = url.searchParams.get("path");
696
+ if (!flowId || !filePath) {
697
+ json(res, 400, { error: "Missing flowId or path" });
698
+ return;
699
+ }
700
+ try {
701
+ const result = getPipelineFiles(root, flowId, flowSource, archived);
702
+ if (result.error) {
703
+ json(res, 404, { error: result.error });
704
+ return;
705
+ }
706
+ const absPath = path.join(result.path, filePath);
707
+ if (!absPath.startsWith(result.path)) {
708
+ json(res, 403, { error: "Path traversal not allowed" });
709
+ return;
710
+ }
711
+ if (!fs.existsSync(absPath) || !fs.statSync(absPath).isFile()) {
712
+ json(res, 404, { error: "File not found" });
713
+ return;
714
+ }
715
+ const content = fs.readFileSync(absPath, "utf-8");
716
+ json(res, 200, { content, path: absPath });
717
+ } catch (e) {
718
+ json(res, 500, { error: (e && e.message) || String(e) });
719
+ }
720
+ return;
721
+ }
722
+
723
+ if (req.method === "POST" && url.pathname === "/api/pipeline-file-save") {
724
+ const flowId = url.searchParams.get("flowId");
725
+ const flowSource = url.searchParams.get("flowSource") || "user";
726
+ const archived = url.searchParams.get("archived") === "1";
727
+ const filePath = url.searchParams.get("path");
728
+ if (!flowId || !filePath) {
729
+ json(res, 400, { error: "Missing flowId or path" });
730
+ return;
731
+ }
732
+ let body;
733
+ try {
734
+ body = await readBody(req);
735
+ } catch {
736
+ json(res, 400, { error: "Invalid request body" });
737
+ return;
738
+ }
739
+ let content;
740
+ try {
741
+ const parsed = JSON.parse(body);
742
+ content = typeof parsed.content === "string" ? parsed.content : "";
743
+ } catch {
744
+ content = String(body);
745
+ }
746
+ try {
747
+ const result = getPipelineFiles(root, flowId, flowSource, archived);
748
+ if (result.error) {
749
+ json(res, 404, { error: result.error });
750
+ return;
751
+ }
752
+ const absPath = path.join(result.path, filePath);
753
+ if (!absPath.startsWith(result.path)) {
754
+ json(res, 403, { error: "Path traversal not allowed" });
755
+ return;
756
+ }
757
+ if (!fs.existsSync(absPath)) {
758
+ json(res, 404, { error: "File not found" });
759
+ return;
760
+ }
761
+ fs.writeFileSync(absPath, content, "utf-8");
762
+ json(res, 200, { success: true, path: absPath, size: content.length });
763
+ } catch (e) {
764
+ json(res, 500, { error: (e && e.message) || String(e) });
765
+ }
766
+ return;
767
+ }
768
+
769
+ if (req.method === "GET" && url.pathname === "/api/model-lists") {
770
+ try {
771
+ json(res, 200, readModelListsFromDisk(root));
772
+ } catch (e) {
773
+ json(res, 500, { error: (e && e.message) || String(e) });
774
+ }
775
+ return;
776
+ }
777
+
778
+ if (req.method === "GET" && url.pathname === "/api/ui-context") {
779
+ try {
780
+ json(res, 200, { workspaceRoot: root });
781
+ } catch (e) {
782
+ json(res, 500, { error: (e && e.message) || String(e) });
783
+ }
784
+ return;
785
+ }
786
+
787
+ if (req.method === "GET" && url.pathname === "/api/dev-info") {
788
+ const isDev = process.env.AGENTFLOW_DEV === "1";
789
+ json(res, 200, { isDev });
790
+ return;
791
+ }
792
+
793
+ if (req.method === "GET" && url.pathname === "/api/composer-logs") {
794
+ try {
795
+ const flowIdFilter = url.searchParams.get("flowId") || "";
796
+ const limit = Math.max(1, Math.min(200, Number(url.searchParams.get("limit")) || 50));
797
+ const sessions = listRecentComposerSessions(root, 200);
798
+ const enriched = sessions.map((s) => {
799
+ const meta = readComposerSessionMeta(s.logPath);
800
+ return {
801
+ sessionId: s.sessionId,
802
+ monthDir: s.monthDir,
803
+ size: s.size,
804
+ mtime: s.mtime,
805
+ flowId: meta.flowId,
806
+ flowSource: meta.flowSource,
807
+ model: meta.model,
808
+ promptPreview: meta.prompt ? meta.prompt.slice(0, 200) : null,
809
+ };
810
+ });
811
+ const filtered = flowIdFilter ? enriched.filter((e) => e.flowId === flowIdFilter) : enriched;
812
+ json(res, 200, { sessions: filtered.slice(0, limit) });
813
+ } catch (e) {
814
+ json(res, 500, { error: (e && e.message) || String(e) });
815
+ }
816
+ return;
817
+ }
818
+
819
+ if (req.method === "GET" && url.pathname.startsWith("/api/composer-logs/")) {
820
+ try {
821
+ const sessionId = decodeURIComponent(url.pathname.slice("/api/composer-logs/".length));
822
+ if (!sessionId || sessionId.includes("..") || sessionId.includes("/")) {
823
+ json(res, 400, { error: "Invalid sessionId" });
824
+ return;
825
+ }
826
+ const all = listRecentComposerSessions(root, 1000);
827
+ const found = all.find((s) => s.sessionId === sessionId);
828
+ if (!found) {
829
+ json(res, 404, { error: "Session not found" });
830
+ return;
831
+ }
832
+ const events = parseComposerLogFile(found.logPath);
833
+ const meta = readComposerSessionMeta(found.logPath);
834
+ json(res, 200, {
835
+ sessionId: found.sessionId,
836
+ logPath: found.logPath,
837
+ monthDir: found.monthDir,
838
+ size: found.size,
839
+ mtime: found.mtime,
840
+ meta,
841
+ events,
842
+ });
843
+ } catch (e) {
844
+ json(res, 500, { error: (e && e.message) || String(e) });
845
+ }
846
+ return;
847
+ }
848
+
849
+ if (req.method === "GET" && url.pathname === "/api/agentflow-config") {
850
+ try {
851
+ const cfg = readAgentflowUserConfigObject();
852
+ const opencodeProvider = typeof cfg.opencodeProvider === "string" ? cfg.opencodeProvider : "";
853
+ json(res, 200, { opencodeProvider });
854
+ } catch (e) {
855
+ json(res, 500, { error: (e && e.message) || String(e) });
856
+ }
857
+ return;
858
+ }
859
+
860
+ if (req.method === "POST" && url.pathname === "/api/agentflow-config") {
861
+ let payload;
862
+ try {
863
+ payload = JSON.parse(await readBody(req));
864
+ } catch {
865
+ json(res, 400, { error: "Invalid JSON body" });
866
+ return;
867
+ }
868
+ const raw = payload.opencodeProvider;
869
+ const opencodeProvider = typeof raw === "string" ? raw.trim() : "";
870
+ try {
871
+ const cfgPath = getAgentflowUserConfigAbs();
872
+ const prev = readAgentflowUserConfigObject();
873
+ const next = { ...prev };
874
+ if (opencodeProvider) next.opencodeProvider = opencodeProvider;
875
+ else delete next.opencodeProvider;
876
+ fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
877
+ fs.writeFileSync(cfgPath, JSON.stringify(next, null, 2), "utf-8");
878
+ await updateModelLists(root);
879
+ json(res, 200, {
880
+ success: true,
881
+ opencodeProvider: opencodeProvider || "",
882
+ modelLists: readModelListsFromDisk(root),
883
+ });
884
+ } catch (e) {
885
+ json(res, 500, { error: (e && e.message) || String(e) });
886
+ }
887
+ return;
888
+ }
889
+
890
+ if (req.method === "POST" && url.pathname === "/api/update-model-lists") {
891
+ try {
892
+ let opencodeProviderOverride = "";
893
+ const raw = await readBody(req);
894
+ if (raw && String(raw).trim()) {
895
+ try {
896
+ const payload = JSON.parse(raw);
897
+ const o = payload?.opencodeProvider;
898
+ if (typeof o === "string") opencodeProviderOverride = o.trim();
899
+ } catch {
900
+ /* 忽略非 JSON body,仍按 config 拉取 */
901
+ }
902
+ }
903
+ await updateModelLists(root, { opencodeProviderOverride });
904
+ json(res, 200, { success: true, modelLists: readModelListsFromDisk(root) });
905
+ } catch (e) {
906
+ json(res, 500, { error: (e && e.message) || String(e) });
907
+ }
908
+ return;
909
+ }
910
+
911
+ if (req.method === "GET" && url.pathname === "/api/nodes") {
912
+ const flowId = url.searchParams.get("flowId");
913
+ const flowSource = url.searchParams.get("flowSource") || "user";
914
+ const lang = url.searchParams.get("lang") || "en";
915
+ if (!flowId) {
916
+ json(res, 400, { error: "Missing flowId" });
917
+ return;
918
+ }
919
+ if (!isValidFlowSourceRead(flowSource)) {
920
+ json(res, 400, { error: "Invalid flowSource" });
921
+ return;
922
+ }
923
+ const nodesArchived = url.searchParams.get("archived") === "1";
924
+ try {
925
+ const { setLanguage } = await import("./i18n.mjs");
926
+ setLanguage(lang);
927
+ json(res, 200, listNodesJson(root, flowId, flowSource, { archived: nodesArchived }));
928
+ } catch (e) {
929
+ json(res, 500, { error: (e && e.message) || String(e) });
930
+ }
931
+ return;
932
+ }
933
+
934
+ if (req.method === "GET" && url.pathname === "/api/flow") {
935
+ const flowId = url.searchParams.get("flowId");
936
+ const flowSource = url.searchParams.get("flowSource") || "user";
937
+ if (!flowId) {
938
+ json(res, 400, { error: "Missing flowId" });
939
+ return;
940
+ }
941
+ if (!isValidFlowSourceRead(flowSource)) {
942
+ json(res, 400, { error: "Invalid flowSource" });
943
+ return;
944
+ }
945
+ const flowArchived = url.searchParams.get("archived") === "1";
946
+ const result = readFlowJson(root, flowId, flowSource, { archived: flowArchived });
947
+ if (result.error) {
948
+ json(res, 404, result);
949
+ return;
950
+ }
951
+ json(res, 200, result);
952
+ return;
953
+ }
954
+
955
+ if (req.method === "POST" && url.pathname === "/api/flow") {
956
+ let payload;
957
+ try {
958
+ payload = JSON.parse(await readBody(req));
959
+ } catch {
960
+ json(res, 400, { error: "Invalid JSON body" });
961
+ return;
962
+ }
963
+
964
+ if (payload.action === "save-user-check-content") {
965
+ const runUuid = payload.runUuid;
966
+ const instanceId = payload.instanceId;
967
+ const content = payload.content;
968
+ if (!runUuid || !instanceId || typeof content !== "string") {
969
+ json(res, 400, { error: "Missing runUuid, instanceId, or content" });
970
+ return;
971
+ }
972
+ const runDir = path.join(getRunDir(root, payload.flowId || "unknown", runUuid));
973
+ const outputPath = path.join(runDir, `output/${instanceId}/node_${instanceId}_content.md`);
974
+ try {
975
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
976
+ fs.writeFileSync(outputPath, content, "utf-8");
977
+ json(res, 200, { ok: true, savedPath: outputPath });
978
+ } catch (e) {
979
+ json(res, 500, { ok: false, error: e.message });
980
+ }
981
+ return;
982
+ }
983
+
984
+ if (payload.action === "ai-edit-user-check-content") {
985
+ const runUuid = payload.runUuid;
986
+ const instanceId = payload.instanceId;
987
+ const content = payload.content;
988
+ const aiPrompt = payload.prompt;
989
+ if (!runUuid || !instanceId || typeof content !== "string" || typeof aiPrompt !== "string") {
990
+ json(res, 400, { error: "Missing runUuid, instanceId, content, or prompt" });
991
+ return;
992
+ }
993
+
994
+ const fullPrompt = `请根据以下指令修改内容。直接输出修改后的完整内容,不要解释。
995
+
996
+ 原始内容:
997
+ ---
998
+ ${content}
999
+ ---
1000
+
1001
+ 修改指令:${aiPrompt}
1002
+
1003
+ 请直接输出修改后的完整内容(保持原有格式):`;
1004
+
1005
+ const opencodeCmd = process.env.OPENCODE_CMD || "opencode";
1006
+ const tmpPromptFile = path.join(
1007
+ getRunDir(root, payload.flowId || "unknown", runUuid),
1008
+ "intermediate",
1009
+ `${instanceId}_ai_edit_prompt.txt`,
1010
+ );
1011
+ try {
1012
+ fs.mkdirSync(path.dirname(tmpPromptFile), { recursive: true });
1013
+ fs.writeFileSync(tmpPromptFile, fullPrompt, "utf-8");
1014
+ } catch (e) {
1015
+ json(res, 500, { ok: false, error: `Failed to write prompt file: ${e.message}` });
1016
+ return;
1017
+ }
1018
+
1019
+ const child = spawn(opencodeCmd, ["--prompt-file", tmpPromptFile, "--print"], {
1020
+ cwd: root,
1021
+ env: { ...process.env, OPENCODE_NON_INTERACTIVE: "1" },
1022
+ stdio: ["ignore", "pipe", "pipe"],
1023
+ });
1024
+
1025
+ let stdout = "";
1026
+ let stderr = "";
1027
+ child.stdout.on("data", (d) => { stdout += String(d); });
1028
+ child.stderr.on("data", (d) => { stderr += String(d); });
1029
+ child.on("close", (code) => {
1030
+ try { fs.unlinkSync(tmpPromptFile); } catch (_) {}
1031
+ if (code === 0 && stdout.trim()) {
1032
+ json(res, 200, { ok: true, content: stdout.trim() });
1033
+ } else {
1034
+ json(res, 500, { ok: false, error: stderr.trim() || `OpenCode exited with code ${code}` });
1035
+ }
1036
+ });
1037
+ child.on("error", (err) => {
1038
+ try { fs.unlinkSync(tmpPromptFile); } catch (_) {}
1039
+ json(res, 500, { ok: false, error: `Failed to run OpenCode: ${err.message}` });
1040
+ });
1041
+ return;
1042
+ }
1043
+
1044
+ if (payload.action === "confirm-user-check") {
1045
+ const runUuid = payload.runUuid;
1046
+ const instanceId = payload.instanceId;
1047
+ const execId = payload.execId ?? 1;
1048
+ if (!runUuid || !instanceId) {
1049
+ json(res, 400, { error: "Missing runUuid or instanceId" });
1050
+ return;
1051
+ }
1052
+ const runDir = path.join(getRunDir(root, payload.flowId || "unknown", runUuid));
1053
+ const resultPath = path.join(runDir, `intermediate/${instanceId}/${instanceId}.result.md`);
1054
+ try {
1055
+ fs.mkdirSync(path.dirname(resultPath), { recursive: true });
1056
+ const resultContent = `---
1057
+ status: "success"
1058
+ execId: "${execId}"
1059
+ message: "用户确认通过"
1060
+ finishedAt: "${new Date().toISOString()}"
1061
+ ---
1062
+ `;
1063
+ fs.writeFileSync(resultPath, resultContent, "utf-8");
1064
+ json(res, 200, { ok: true, resultPath });
1065
+ } catch (e) {
1066
+ json(res, 500, { ok: false, error: e.message });
1067
+ }
1068
+ return;
1069
+ }
1070
+
1071
+ if (payload.action === "confirm-user-ask") {
1072
+ const runUuid = payload.runUuid;
1073
+ const instanceId = payload.instanceId;
1074
+ const execId = payload.execId ?? 1;
1075
+ const branch = payload.branch;
1076
+ const selectedIndex = payload.selectedIndex;
1077
+ const selectedLabel = payload.selectedLabel;
1078
+ if (!runUuid || !instanceId || !branch) {
1079
+ json(res, 400, { error: "Missing runUuid, instanceId, or branch" });
1080
+ return;
1081
+ }
1082
+ const runDir = path.join(getRunDir(root, payload.flowId || "unknown", runUuid));
1083
+ const resultPath = path.join(runDir, `intermediate/${instanceId}/${instanceId}.result.md`);
1084
+ try {
1085
+ fs.mkdirSync(path.dirname(resultPath), { recursive: true });
1086
+ const escapeYaml = (v) => {
1087
+ const s = String(v ?? "");
1088
+ if (/[\n"\\:]/.test(s)) return '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n") + '"';
1089
+ return '"' + s + '"';
1090
+ };
1091
+ const lines = [
1092
+ "---",
1093
+ `status: "success"`,
1094
+ `execId: "${execId}"`,
1095
+ `branch: ${escapeYaml(branch)}`,
1096
+ `message: ${escapeYaml(selectedLabel ? `用户选择 ${branch} (${selectedLabel})` : `用户选择 ${branch}`)}`,
1097
+ `finishedAt: "${new Date().toISOString()}"`,
1098
+ ];
1099
+ if (selectedIndex != null && Number.isFinite(Number(selectedIndex))) {
1100
+ lines.push(`selectedIndex: ${Number(selectedIndex)}`);
1101
+ }
1102
+ if (selectedLabel != null && String(selectedLabel).trim() !== "") {
1103
+ lines.push(`selectedLabel: ${escapeYaml(selectedLabel)}`);
1104
+ }
1105
+ lines.push("---", "");
1106
+ fs.writeFileSync(resultPath, lines.join("\n"), "utf-8");
1107
+ json(res, 200, { ok: true, resultPath });
1108
+ } catch (e) {
1109
+ json(res, 500, { ok: false, error: e.message });
1110
+ }
1111
+ return;
1112
+ }
1113
+
1114
+ const flowId = payload.flowId;
1115
+ const flowSource = payload.flowSource || "user";
1116
+ const flowYaml = payload.flowYaml;
1117
+ if (!flowId || typeof flowId !== "string") {
1118
+ json(res, 400, { error: "Missing or invalid flowId" });
1119
+ return;
1120
+ }
1121
+ if (!isValidFlowSourceWrite(flowSource)) {
1122
+ json(res, 400, { error: "Invalid flowSource (use user or workspace; builtin is read-only)" });
1123
+ return;
1124
+ }
1125
+ if (typeof flowYaml !== "string") {
1126
+ json(res, 400, { error: "Missing or invalid flowYaml" });
1127
+ return;
1128
+ }
1129
+ const flowArchived = Boolean(payload.flowArchived);
1130
+ const result = writeFlowYaml(root, flowId, flowSource, flowYaml, { archived: flowArchived });
1131
+ if (!result.success) {
1132
+ json(res, 400, result);
1133
+ return;
1134
+ }
1135
+ json(res, 200, { success: true });
1136
+ return;
1137
+ }
1138
+
1139
+ if (req.method === "POST" && url.pathname === "/api/flow-editor-sync") {
1140
+ let payload;
1141
+ try {
1142
+ payload = JSON.parse(await readBody(req));
1143
+ } catch {
1144
+ json(res, 400, { error: "Invalid JSON body" });
1145
+ return;
1146
+ }
1147
+ const flowId = payload.flowId;
1148
+ const flowSource = payload.flowSource || "user";
1149
+ if (!flowId || typeof flowId !== "string") {
1150
+ json(res, 400, { error: "Missing or invalid flowId" });
1151
+ return;
1152
+ }
1153
+ if (!isValidFlowSourceRead(flowSource)) {
1154
+ json(res, 400, { error: "Invalid flowSource" });
1155
+ return;
1156
+ }
1157
+ const flowArchived = Boolean(payload.flowArchived);
1158
+ broadcastFlowEditorSync(flowId, flowSource, flowArchived);
1159
+ json(res, 200, { ok: true });
1160
+ return;
1161
+ }
1162
+
1163
+ if (req.method === "GET" && url.pathname === "/api/flow-editor-sync-events") {
1164
+ const flowId = url.searchParams.get("flowId");
1165
+ const flowSource = url.searchParams.get("flowSource") || "user";
1166
+ if (!flowId) {
1167
+ json(res, 400, { error: "Missing flowId" });
1168
+ return;
1169
+ }
1170
+ if (!isValidFlowSourceRead(flowSource)) {
1171
+ json(res, 400, { error: "Invalid flowSource" });
1172
+ return;
1173
+ }
1174
+ const flowArchived = url.searchParams.get("archived") === "1";
1175
+ const key = flowEditorSyncKey(flowId, flowSource, flowArchived);
1176
+ let set = flowEditorSyncSubscribers.get(key);
1177
+ if (!set) {
1178
+ set = new Set();
1179
+ flowEditorSyncSubscribers.set(key, set);
1180
+ }
1181
+ res.writeHead(200, {
1182
+ "Content-Type": "text/event-stream; charset=utf-8",
1183
+ "Cache-Control": "no-cache, no-transform",
1184
+ Connection: "keep-alive",
1185
+ "X-Content-Type-Options": "nosniff",
1186
+ });
1187
+ res.write(": connected\n\n");
1188
+ set.add(res);
1189
+ const detach = () => {
1190
+ try {
1191
+ set.delete(res);
1192
+ if (set.size === 0) flowEditorSyncSubscribers.delete(key);
1193
+ } catch (_) {}
1194
+ };
1195
+ req.on("close", detach);
1196
+ res.on("close", detach);
1197
+ return;
1198
+ }
1199
+
1200
+ /* 轮询替代 SSE:客户端传上次已知的 version,若服务端 version 更大则返回 changed:true */
1201
+ if (req.method === "GET" && url.pathname === "/api/flow-editor-sync-poll") {
1202
+ const flowId = url.searchParams.get("flowId");
1203
+ const flowSource = url.searchParams.get("flowSource") || "user";
1204
+ if (!flowId) {
1205
+ json(res, 400, { error: "Missing flowId" });
1206
+ return;
1207
+ }
1208
+ const flowArchived = url.searchParams.get("archived") === "1";
1209
+ const key = flowEditorSyncKey(flowId, flowSource, flowArchived);
1210
+ const serverVer = flowEditorSyncVersions.get(key) ?? 0;
1211
+ const clientVer = parseInt(url.searchParams.get("v") ?? "0", 10) || 0;
1212
+ json(res, 200, { version: serverVer, changed: serverVer > clientVer });
1213
+ return;
1214
+ }
1215
+
1216
+ if (req.method === "POST" && url.pathname === "/api/flow/move") {
1217
+ let payload;
1218
+ try {
1219
+ payload = JSON.parse(await readBody(req));
1220
+ } catch {
1221
+ json(res, 400, { error: "Invalid JSON body" });
1222
+ return;
1223
+ }
1224
+ const flowId = payload.flowId;
1225
+ const fromSource = payload.fromSource;
1226
+ const toSource = payload.toSource;
1227
+ if (!flowId || typeof flowId !== "string") {
1228
+ json(res, 400, { error: "Missing or invalid flowId" });
1229
+ return;
1230
+ }
1231
+ if (fromSource !== "user" && fromSource !== "workspace") {
1232
+ json(res, 400, { error: "Invalid fromSource" });
1233
+ return;
1234
+ }
1235
+ if (toSource !== "user" && toSource !== "workspace") {
1236
+ json(res, 400, { error: "Invalid toSource" });
1237
+ return;
1238
+ }
1239
+ const result = moveFlowDirectory(root, flowId.trim(), fromSource, toSource);
1240
+ if (!result.success) {
1241
+ json(res, 400, { error: result.error || "Move failed" });
1242
+ return;
1243
+ }
1244
+ json(res, 200, { success: true, flowId: flowId.trim(), flowSource: result.flowSource });
1245
+ return;
1246
+ }
1247
+
1248
+ if (req.method === "POST" && url.pathname === "/api/flow/rename") {
1249
+ let payload;
1250
+ try {
1251
+ payload = JSON.parse(await readBody(req));
1252
+ } catch {
1253
+ json(res, 400, { error: "Invalid JSON body" });
1254
+ return;
1255
+ }
1256
+ const flowId = typeof payload.flowId === "string" ? payload.flowId.trim() : "";
1257
+ const flowSource = payload.flowSource || "user";
1258
+ const newFlowId = typeof payload.newFlowId === "string" ? payload.newFlowId.trim() : "";
1259
+ if (!flowId || !newFlowId) {
1260
+ json(res, 400, { error: "Missing flowId or newFlowId" });
1261
+ return;
1262
+ }
1263
+ if (flowSource !== "user" && flowSource !== "workspace") {
1264
+ json(res, 400, { error: "仅支持重命名用户目录或工作区流水线" });
1265
+ return;
1266
+ }
1267
+ const validation = validateUserPipelineId(newFlowId);
1268
+ if (!validation.ok) {
1269
+ json(res, 400, { error: validation.error });
1270
+ return;
1271
+ }
1272
+ if (flowId === validation.flowId) {
1273
+ json(res, 200, { success: true, flowId, flowSource });
1274
+ return;
1275
+ }
1276
+ const yamlRes = getFlowYamlAbs(root, flowId, flowSource, { archived: false });
1277
+ if (yamlRes.error || !yamlRes.path) {
1278
+ json(res, 404, { error: yamlRes.error || "找不到流水线" });
1279
+ return;
1280
+ }
1281
+ const fromDir = path.dirname(yamlRes.path);
1282
+ const toDir = path.join(path.dirname(fromDir), validation.flowId);
1283
+ if (fs.existsSync(toDir)) {
1284
+ json(res, 409, { error: "目标名称已存在" });
1285
+ return;
1286
+ }
1287
+ try {
1288
+ fs.renameSync(fromDir, toDir);
1289
+ json(res, 200, { success: true, flowId: validation.flowId, flowSource });
1290
+ } catch (e) {
1291
+ json(res, 500, { error: (e && e.message) || String(e) });
1292
+ }
1293
+ return;
1294
+ }
1295
+
1296
+ if (req.method === "POST" && url.pathname === "/api/flow/archive") {
1297
+ let payload;
1298
+ try {
1299
+ payload = JSON.parse(await readBody(req));
1300
+ } catch {
1301
+ json(res, 400, { error: "Invalid JSON body" });
1302
+ return;
1303
+ }
1304
+ const flowId = typeof payload.flowId === "string" ? payload.flowId.trim() : "";
1305
+ const flowSource = payload.flowSource || "user";
1306
+ const confirm = typeof payload.confirmFlowId === "string" ? payload.confirmFlowId.trim() : "";
1307
+ if (!flowId) {
1308
+ json(res, 400, { error: "Missing or invalid flowId" });
1309
+ return;
1310
+ }
1311
+ if (confirm !== flowId) {
1312
+ json(res, 400, { error: "确认名称与流水线 ID 不一致" });
1313
+ return;
1314
+ }
1315
+ if (flowSource !== "user" && flowSource !== "workspace") {
1316
+ json(res, 400, { error: "仅支持归档用户目录或工作区流水线" });
1317
+ return;
1318
+ }
1319
+ const result = archiveFlowPipeline(root, flowId, flowSource);
1320
+ if (!result.success) {
1321
+ json(res, 400, { error: result.error || "归档失败" });
1322
+ return;
1323
+ }
1324
+ json(res, 200, { success: true, flowId, flowSource, archived: true });
1325
+ return;
1326
+ }
1327
+
1328
+ if (req.method === "POST" && url.pathname === "/api/flow/delete") {
1329
+ let payload;
1330
+ try {
1331
+ payload = JSON.parse(await readBody(req));
1332
+ } catch {
1333
+ json(res, 400, { error: "Invalid JSON body" });
1334
+ return;
1335
+ }
1336
+ const flowId = typeof payload.flowId === "string" ? payload.flowId.trim() : "";
1337
+ const flowSource = payload.flowSource || "user";
1338
+ const confirm = typeof payload.confirmFlowId === "string" ? payload.confirmFlowId.trim() : "";
1339
+ const flowArchived = Boolean(payload.flowArchived);
1340
+ if (!flowId) {
1341
+ json(res, 400, { error: "Missing or invalid flowId" });
1342
+ return;
1343
+ }
1344
+ if (confirm !== flowId) {
1345
+ json(res, 400, { error: "确认名称与流水线 ID 不一致" });
1346
+ return;
1347
+ }
1348
+ if (flowSource !== "user" && flowSource !== "workspace") {
1349
+ json(res, 400, { error: "仅支持删除用户目录或工作区流水线" });
1350
+ return;
1351
+ }
1352
+ const result = deleteFlowPipeline(root, flowId, flowSource, { archived: flowArchived });
1353
+ if (!result.success) {
1354
+ json(res, 400, { error: result.error || "删除失败" });
1355
+ return;
1356
+ }
1357
+ json(res, 200, { success: true, flowId, flowSource, deleted: true });
1358
+ return;
1359
+ }
1360
+
1361
+ if (req.method === "GET" && url.pathname === "/api/flow/run-config") {
1362
+ const flowId = url.searchParams.get("flowId");
1363
+ const flowSource = url.searchParams.get("flowSource") || "user";
1364
+ const flowArchived = url.searchParams.get("archived") === "1";
1365
+ if (!flowId) {
1366
+ json(res, 400, { error: "Missing flowId" });
1367
+ return;
1368
+ }
1369
+ if (!isValidFlowSourceRead(flowSource)) {
1370
+ json(res, 400, { error: "Invalid flowSource" });
1371
+ return;
1372
+ }
1373
+ const yamlRes = getFlowYamlAbs(root, flowId, flowSource, { archived: flowArchived });
1374
+ if (yamlRes.error) {
1375
+ json(res, 404, { error: yamlRes.error });
1376
+ return;
1377
+ }
1378
+ const configPath = path.join(path.dirname(yamlRes.path), RUN_CONFIG_FILENAME);
1379
+ try {
1380
+ if (!fs.existsSync(configPath)) {
1381
+ json(res, 200, { presets: {}, activePreset: null });
1382
+ return;
1383
+ }
1384
+ const data = JSON.parse(fs.readFileSync(configPath, "utf-8"));
1385
+ json(res, 200, {
1386
+ presets: data.presets && typeof data.presets === "object" ? data.presets : {},
1387
+ activePreset: typeof data.activePreset === "string" ? data.activePreset : null,
1388
+ });
1389
+ } catch (e) {
1390
+ json(res, 500, { error: e.message });
1391
+ }
1392
+ return;
1393
+ }
1394
+
1395
+ if (req.method === "POST" && url.pathname === "/api/flow/run-config") {
1396
+ let payload;
1397
+ try {
1398
+ payload = JSON.parse(await readBody(req));
1399
+ } catch {
1400
+ json(res, 400, { error: "Invalid JSON body" });
1401
+ return;
1402
+ }
1403
+ const flowId = payload.flowId;
1404
+ const flowSource = payload.flowSource || "user";
1405
+ const flowArchived = payload.archived === true;
1406
+ if (!flowId) {
1407
+ json(res, 400, { error: "Missing flowId" });
1408
+ return;
1409
+ }
1410
+ if (!isValidFlowSourceWrite(flowSource)) {
1411
+ json(res, 400, { error: "Cannot save config to builtin or archived flow" });
1412
+ return;
1413
+ }
1414
+ const yamlRes = getFlowYamlAbs(root, flowId, flowSource, { archived: flowArchived });
1415
+ if (yamlRes.error) {
1416
+ json(res, 404, { error: yamlRes.error });
1417
+ return;
1418
+ }
1419
+ const configPath = path.join(path.dirname(yamlRes.path), RUN_CONFIG_FILENAME);
1420
+ try {
1421
+ const presets = payload.presets && typeof payload.presets === "object" ? payload.presets : {};
1422
+ const activePreset = typeof payload.activePreset === "string" ? payload.activePreset : null;
1423
+ const data = { presets, activePreset };
1424
+ fs.writeFileSync(configPath, JSON.stringify(data, null, 2), "utf-8");
1425
+ json(res, 200, { success: true });
1426
+ } catch (e) {
1427
+ json(res, 500, { error: e.message });
1428
+ }
1429
+ return;
1430
+ }
1431
+
1432
+ if (req.method === "GET" && url.pathname === "/api/flow/schedule") {
1433
+ const flowId = url.searchParams.get("flowId");
1434
+ const flowSource = url.searchParams.get("flowSource") || "user";
1435
+ const flowArchived = url.searchParams.get("archived") === "1";
1436
+ if (!flowId) {
1437
+ json(res, 400, { error: "Missing flowId" });
1438
+ return;
1439
+ }
1440
+ if (!isValidFlowSourceRead(flowSource)) {
1441
+ json(res, 400, { error: "Invalid flowSource" });
1442
+ return;
1443
+ }
1444
+ const result = readFlowSchedule(root, flowId, flowSource, { archived: flowArchived });
1445
+ if (!result.success) {
1446
+ json(res, 400, { error: result.error || "Could not read schedule" });
1447
+ return;
1448
+ }
1449
+ const status = listScheduleStatuses(root).find(
1450
+ (s) => s.flowId === flowId && (s.flowSource || "user") === (flowSource || "user"),
1451
+ );
1452
+ json(res, 200, { schedule: result.schedule, state: result.state || {}, status: status || null });
1453
+ return;
1454
+ }
1455
+
1456
+ if (req.method === "POST" && url.pathname === "/api/flow/schedule") {
1457
+ let payload;
1458
+ try {
1459
+ payload = JSON.parse(await readBody(req));
1460
+ } catch {
1461
+ json(res, 400, { error: "Invalid JSON body" });
1462
+ return;
1463
+ }
1464
+ const flowId = payload.flowId;
1465
+ const flowSource = payload.flowSource || "user";
1466
+ const flowArchived = payload.archived === true;
1467
+ if (!flowId) {
1468
+ json(res, 400, { error: "Missing flowId" });
1469
+ return;
1470
+ }
1471
+ if (flowArchived || !isValidFlowSourceWrite(flowSource)) {
1472
+ json(res, 400, { error: "Cannot save schedule to builtin or archived flow" });
1473
+ return;
1474
+ }
1475
+ const result = writeFlowSchedule(root, flowId, flowSource, payload.schedule || {});
1476
+ if (!result.success) {
1477
+ json(res, 400, { error: result.error || "Could not save schedule" });
1478
+ return;
1479
+ }
1480
+ json(res, 200, { success: true, schedule: result.schedule });
1481
+ return;
1482
+ }
1483
+
1484
+ if (req.method === "POST" && url.pathname === "/api/flow/run") {
1485
+ let payload;
1486
+ try {
1487
+ payload = JSON.parse(await readBody(req));
1488
+ } catch {
1489
+ json(res, 400, { error: "Invalid JSON body" });
1490
+ return;
1491
+ }
1492
+ const flowId = typeof payload.flowId === "string" ? payload.flowId.trim() : "";
1493
+ if (!flowId) {
1494
+ json(res, 400, { error: "Missing flowId" });
1495
+ return;
1496
+ }
1497
+ const runUuid = typeof payload.uuid === "string" ? payload.uuid.trim() : "";
1498
+ if (activeFlowRuns.has(flowId)) {
1499
+ json(res, 409, { error: "该流水线已在运行中" });
1500
+ return;
1501
+ }
1502
+
1503
+ // resume: 清除上次 Pause 写入的中断标记,否则 inferRunStatusFromRunDir 仍返回 "stopped",
1504
+ // UI 轮询会把 runMode 翻回 stopped,即便 CLI 正在运行也显示 PAUSED。
1505
+ if (runUuid) {
1506
+ try {
1507
+ const runDir = getRunDir(root, flowId, runUuid);
1508
+ const interruptedPath = path.join(runDir, RUN_INTERRUPTED_FILENAME);
1509
+ if (fs.existsSync(interruptedPath)) fs.unlinkSync(interruptedPath);
1510
+ } catch (e) {
1511
+ log.debug(`[ui] flow/run: could not clear ${RUN_INTERRUPTED_FILENAME}: ${e && e.message}`);
1512
+ }
1513
+ }
1514
+
1515
+ const agentflowBin = path.join(PACKAGE_ROOT, "bin", "agentflow.mjs");
1516
+ const args = [agentflowBin, runUuid ? "resume" : "apply", flowId];
1517
+ if (runUuid) args.push(runUuid);
1518
+ args.push("--machine-readable", "--workspace-root", root);
1519
+ if (payload.force !== false) args.push("--force");
1520
+
1521
+ if (payload.cliInputs && typeof payload.cliInputs === "object") {
1522
+ for (const [name, val] of Object.entries(payload.cliInputs)) {
1523
+ if (!name || typeof name !== "string") continue;
1524
+ if (!val || typeof val !== "object") continue;
1525
+ const type = val.type;
1526
+ if (type === "file" && typeof val.path === "string") {
1527
+ args.push("--input", `${name}=file:${val.path}`);
1528
+ } else if (type === "str" && typeof val.value === "string") {
1529
+ args.push("--input", `${name}=${val.value}`);
1530
+ }
1531
+ }
1532
+ }
1533
+
1534
+ res.writeHead(200, {
1535
+ "Content-Type": "application/x-ndjson; charset=utf-8",
1536
+ "Cache-Control": "no-cache",
1537
+ Connection: "keep-alive",
1538
+ "X-Content-Type-Options": "nosniff",
1539
+ });
1540
+ try {
1541
+ res.socket?.setNoDelay?.(true);
1542
+ } catch (_) {}
1543
+
1544
+ let responseEnded = false;
1545
+ let clientDisconnected = false;
1546
+ const endSafe = () => {
1547
+ if (responseEnded) return;
1548
+ responseEnded = true;
1549
+ activeFlowRuns.delete(flowId);
1550
+ try {
1551
+ res.end();
1552
+ } catch (_) {}
1553
+ };
1554
+ const writeLine = (obj) => {
1555
+ if (responseEnded || clientDisconnected) return;
1556
+ try { res.write(JSON.stringify(obj) + "\n"); } catch (_) { clientDisconnected = true; }
1557
+ };
1558
+
1559
+ let child;
1560
+ try {
1561
+ child = spawn(process.execPath, args, {
1562
+ cwd: root,
1563
+ stdio: ["ignore", "pipe", "pipe"],
1564
+ env: { ...process.env, FORCE_COLOR: "0" },
1565
+ // detached: true 使 child 成为新进程组 leader,/api/flow/run/stop 时
1566
+ // 用 process.kill(-pid) 可以一次性 SIGTERM 整棵进程树(含 cursor-agent 等孙进程)
1567
+ detached: true,
1568
+ });
1569
+ } catch (e) {
1570
+ writeLine({ type: "error", message: `启动失败: ${e.message}` });
1571
+ endSafe();
1572
+ return;
1573
+ }
1574
+
1575
+ /** @type {{ child: import("child_process").ChildProcess, runUuid: string | null }} */
1576
+ const runEntry = { child, runUuid: runUuid || null };
1577
+ activeFlowRuns.set(flowId, runEntry);
1578
+ log.debug(`[ui] flow/run: spawned pid=${child.pid} flowId=${flowId}${runUuid ? ` uuid=${runUuid}` : ""}`);
1579
+
1580
+ let stdoutBuf = "";
1581
+ child.stdout.on("data", (chunk) => {
1582
+ stdoutBuf += chunk.toString("utf8");
1583
+ const lines = stdoutBuf.split("\n");
1584
+ stdoutBuf = lines.pop();
1585
+ for (const line of lines) {
1586
+ if (!line.trim()) continue;
1587
+ try {
1588
+ const evt = JSON.parse(line);
1589
+ if (evt && evt.event === "apply-start" && typeof evt.uuid === "string" && evt.uuid.trim()) {
1590
+ runEntry.runUuid = evt.uuid.trim();
1591
+ }
1592
+ writeLine({ type: "event", ...evt });
1593
+ } catch {
1594
+ writeLine({ type: "log", text: line });
1595
+ }
1596
+ }
1597
+ });
1598
+
1599
+ let stderrBuf = "";
1600
+ child.stderr.on("data", (chunk) => {
1601
+ stderrBuf += chunk.toString("utf8");
1602
+ const lines = stderrBuf.split("\n");
1603
+ stderrBuf = lines.pop();
1604
+ for (const line of lines) {
1605
+ if (!line.trim()) continue;
1606
+ writeLine({ type: "log", text: line });
1607
+ }
1608
+ });
1609
+
1610
+ child.on("close", (code) => {
1611
+ if (stderrBuf.trim()) writeLine({ type: "log", text: stderrBuf.trim() });
1612
+ if (stdoutBuf.trim()) {
1613
+ try {
1614
+ const evt = JSON.parse(stdoutBuf.trim());
1615
+ writeLine({ type: "event", ...evt });
1616
+ } catch {
1617
+ writeLine({ type: "log", text: stdoutBuf.trim() });
1618
+ }
1619
+ }
1620
+ writeLine({ type: "done", exitCode: code ?? 0 });
1621
+ endSafe();
1622
+ });
1623
+
1624
+ child.on("error", (e) => {
1625
+ writeLine({ type: "error", message: e.message });
1626
+ endSafe();
1627
+ });
1628
+
1629
+ req.on("close", () => {
1630
+ // 浏览器断开(刷新/关闭 tab)时不再杀子进程,让 flow 自然跑完。
1631
+ // 用户需显式停止请走 /api/flow/run/stop。
1632
+ clientDisconnected = true;
1633
+ });
1634
+ return;
1635
+ }
1636
+
1637
+ if (req.method === "POST" && url.pathname === "/api/flow/run/stop") {
1638
+ let payload;
1639
+ try {
1640
+ payload = JSON.parse(await readBody(req));
1641
+ } catch {
1642
+ json(res, 400, { error: "Invalid JSON body" });
1643
+ return;
1644
+ }
1645
+ const flowId = typeof payload.flowId === "string" ? payload.flowId.trim() : "";
1646
+ if (!flowId) {
1647
+ json(res, 400, { error: "Missing flowId" });
1648
+ return;
1649
+ }
1650
+ const entry = activeFlowRuns.get(flowId);
1651
+ if (!entry || !entry.child) {
1652
+ json(res, 404, { error: "该流水线未在运行" });
1653
+ return;
1654
+ }
1655
+ // 先尝试杀整个进程组(涵盖 cursor-agent / opencode 等孙进程)
1656
+ const pid = entry.child.pid;
1657
+ let killedGroup = false;
1658
+ if (pid && pid > 0) {
1659
+ try {
1660
+ process.kill(-pid, "SIGTERM");
1661
+ killedGroup = true;
1662
+ } catch (_) { /* 组不存在则降级 */ }
1663
+ }
1664
+ if (!killedGroup) {
1665
+ try { entry.child.kill("SIGTERM"); } catch (_) {}
1666
+ }
1667
+ const uuid = entry.runUuid;
1668
+ activeFlowRuns.delete(flowId);
1669
+ if (uuid) {
1670
+ try {
1671
+ const runDir = getRunDir(root, flowId, uuid);
1672
+ fs.mkdirSync(runDir, { recursive: true });
1673
+ fs.writeFileSync(
1674
+ path.join(runDir, RUN_INTERRUPTED_FILENAME),
1675
+ JSON.stringify({ reason: "user_stop", at: Date.now() }, null, 2),
1676
+ "utf-8",
1677
+ );
1678
+ } catch (e) {
1679
+ log.debug(`[ui] flow/run/stop: could not write ${RUN_INTERRUPTED_FILENAME}: ${e && e.message}`);
1680
+ }
1681
+ }
1682
+ json(res, 200, { ok: true });
1683
+ return;
1684
+ }
1685
+
1686
+ if (req.method === "POST" && url.pathname === "/api/composer-agent") {
1687
+ let payload;
1688
+ try {
1689
+ payload = JSON.parse(await readBody(req));
1690
+ } catch {
1691
+ json(res, 400, { error: "Invalid JSON body" });
1692
+ return;
1693
+ }
1694
+ const prompt = payload.prompt;
1695
+ const model = payload.model;
1696
+ const phaseRole = typeof payload.phaseRole === "string" ? payload.phaseRole.trim() : "";
1697
+ if (typeof prompt !== "string" || !prompt.trim()) {
1698
+ json(res, 400, { error: "Missing or empty prompt" });
1699
+ return;
1700
+ }
1701
+ if (typeof model !== "string" && model != null) {
1702
+ json(res, 400, { error: "Invalid model" });
1703
+ return;
1704
+ }
1705
+
1706
+ const flowIdRaw = payload.flowId;
1707
+ const flowSourceRaw = payload.flowSource;
1708
+ const hasFlowId = flowIdRaw != null && String(flowIdRaw).trim() !== "";
1709
+ const hasFlowSource = flowSourceRaw != null && String(flowSourceRaw).trim() !== "";
1710
+ if (hasFlowId !== hasFlowSource) {
1711
+ json(res, 400, { error: "flowId and flowSource must both be set or both omitted" });
1712
+ return;
1713
+ }
1714
+
1715
+ const threadRaw = Array.isArray(payload.thread) ? payload.thread : [];
1716
+ const thread = threadRaw
1717
+ .filter((m) => m && typeof m.text === "string" && m.text.trim() && (m.role === "user" || m.role === "assistant"))
1718
+ .map((m) => ({ role: m.role, text: String(m.text) }));
1719
+
1720
+ let finalPrompt = prompt.trim();
1721
+ let cliWorkspace = root;
1722
+ let flowYamlAbs = null;
1723
+ let flowId = null;
1724
+ let flowSource = null;
1725
+ let instanceIds = [];
1726
+ let flowContextForMultiStep = null;
1727
+
1728
+ if (hasFlowId) {
1729
+ flowId = String(flowIdRaw).trim();
1730
+ flowSource = String(flowSourceRaw).trim();
1731
+ if (!isValidFlowSourceRead(flowSource)) {
1732
+ json(res, 400, { error: "Invalid flowSource" });
1733
+ return;
1734
+ }
1735
+ const flowArchived = Boolean(payload.flowArchived);
1736
+ const yamlRes = getFlowYamlAbs(root, flowId, flowSource, { archived: flowArchived });
1737
+ if (yamlRes.error || !yamlRes.path) {
1738
+ json(res, 400, { error: yamlRes.error || "Could not resolve flow.yaml" });
1739
+ return;
1740
+ }
1741
+ flowYamlAbs = yamlRes.path;
1742
+ let workspaceWriteDirAbs;
1743
+ let editorSyncFlowSource = flowSource;
1744
+ let flowDirForCli = path.dirname(flowYamlAbs);
1745
+ if (flowSource === "builtin") {
1746
+ const w = resolveFlowDirForWrite(root, flowId, "workspace");
1747
+ if (w.error || !w.flowDir) {
1748
+ json(res, 400, { error: w.error || "Could not resolve workspace flow directory" });
1749
+ return;
1750
+ }
1751
+ workspaceWriteDirAbs = w.flowDir;
1752
+ editorSyncFlowSource = "workspace";
1753
+ flowDirForCli = w.flowDir;
1754
+ }
1755
+ instanceIds = normalizeContextInstanceIds(payload.contextInstanceIds);
1756
+
1757
+ const syncFs = editorSyncFlowSource ?? flowSource;
1758
+ const syncBody = { flowId, flowSource: syncFs };
1759
+ if (flowArchived) syncBody.flowArchived = true;
1760
+ const syncJsonArg = JSON.stringify(JSON.stringify(syncBody));
1761
+
1762
+ // 基于用户意图动态加载 skill 上下文
1763
+ const multiStepIntents = detectIntents(prompt);
1764
+ const intentCategory = classifyIntentCategory(multiStepIntents);
1765
+ const multiStepResources = loadResourcesForIntents(multiStepIntents, PACKAGE_ROOT);
1766
+ const flowPipelineDir = flowYamlAbs ? path.dirname(flowYamlAbs) : "";
1767
+
1768
+ flowContextForMultiStep = {
1769
+ flowYamlAbs,
1770
+ flowId,
1771
+ flowSource,
1772
+ intents: multiStepIntents,
1773
+ intentCategory,
1774
+ canvasInstanceIds: instanceIds,
1775
+ skillsHint: multiStepResources.skillsHint,
1776
+ skillInjectionBlock: multiStepResources.hasContext
1777
+ ? buildSkillCompactInjectionBlock(multiStepResources.skills, multiStepResources.references)
1778
+ : "",
1779
+ syncCurlHint: `curl -sS -X POST http://127.0.0.1:${uiPort}/api/flow-editor-sync -H 'Content-Type: application/json' -d ${syncJsonArg}`,
1780
+ composerSpecAbs: flowPipelineDir ? path.join(flowPipelineDir, COMPOSER_NODE_SPEC_FILENAME) : "",
1781
+ pipelineScriptsDirAbs: flowPipelineDir ? path.join(flowPipelineDir, "scripts") : "",
1782
+ };
1783
+
1784
+ const scriptContentBlock = buildScriptContentBlockForInstances(flowYamlAbs, instanceIds);
1785
+ finalPrompt = buildComposerPromptWithFlowContext({
1786
+ flowYamlAbs,
1787
+ flowId,
1788
+ flowSource,
1789
+ workspaceWriteDirAbs,
1790
+ editorSyncFlowSource,
1791
+ instanceIds,
1792
+ userPrompt: prompt,
1793
+ uiPort,
1794
+ flowArchived,
1795
+ thread,
1796
+ scriptContentBlock,
1797
+ intentCategory,
1798
+ });
1799
+ cliWorkspace = composerCliWorkspaceForFlowDir(root, flowDirForCli);
1800
+ }
1801
+
1802
+ if (!hasFlowId && thread.length > 0) {
1803
+ finalPrompt = formatThreadHistory(thread) + "\n\n## 用户说明\n\n" + finalPrompt;
1804
+ }
1805
+
1806
+ let child = null;
1807
+ let multiStepAbort = null;
1808
+ let responseEnded = false;
1809
+ let clientDisconnected = false;
1810
+
1811
+ const composerSession = createComposerSession(root);
1812
+ const composerLogPath = composerSession.logPath;
1813
+
1814
+ const endSafe = () => {
1815
+ if (responseEnded) return;
1816
+ responseEnded = true;
1817
+ try {
1818
+ res.end();
1819
+ } catch (_) {}
1820
+ };
1821
+ const killChild = () => {
1822
+ if (multiStepAbort) {
1823
+ multiStepAbort();
1824
+ return;
1825
+ }
1826
+ if (child && !child.killed) {
1827
+ try {
1828
+ child.kill("SIGTERM");
1829
+ } catch (_) {}
1830
+ }
1831
+ };
1832
+
1833
+ // 先发送响应头,建立 NDJSON 流连接,避免后续分类阻塞导致前端超时
1834
+ res.writeHead(200, {
1835
+ "Content-Type": "application/x-ndjson; charset=utf-8",
1836
+ "Cache-Control": "no-cache",
1837
+ Connection: "keep-alive",
1838
+ "X-Content-Type-Options": "nosniff",
1839
+ });
1840
+
1841
+ const onStreamEvent = (ev) => {
1842
+ if (responseEnded) return;
1843
+
1844
+ // ai-log:full prompt/response 全文落盘,不写入 NDJSON 流(前端通过 /api/composer-logs 拉)
1845
+ if (ev && ev.type === "ai-log") {
1846
+ logComposerEvent(composerLogPath, ev.tag || "ai-log", {
1847
+ text: typeof ev.text === "string" ? ev.text : "",
1848
+ meta: ev.meta || {},
1849
+ });
1850
+ return;
1851
+ }
1852
+
1853
+ // CLI natural 事件按 kind 拆 tag,便于日志按 AI 类别过滤;error kind 单独成 error tag
1854
+ let logTag = ev.type || "event";
1855
+ if (ev && ev.type === "natural" && ev.kind) {
1856
+ if (ev.kind === "error") logTag = "error";
1857
+ else logTag = `ai-${ev.kind}`; // ai-thinking | ai-assistant | ai-result | ai-tool
1858
+ }
1859
+
1860
+ // 其他事件:全文落盘(不再截断),同时写入 NDJSON 流
1861
+ logComposerEvent(composerLogPath, logTag, ev);
1862
+
1863
+ try {
1864
+ res.write(JSON.stringify(ev) + "\n");
1865
+ } catch (_) {
1866
+ killChild();
1867
+ }
1868
+ };
1869
+
1870
+ req.on("close", () => {
1871
+ clientDisconnected = true;
1872
+ if (!responseEnded) killChild();
1873
+ });
1874
+
1875
+ logComposerEvent(composerLogPath, "composer-start", {
1876
+ sessionId: composerSession.sessionId,
1877
+ flowId: flowId || null,
1878
+ flowSource: flowSource || null,
1879
+ model: model || null,
1880
+ prompt: truncateForLog(prompt.trim(), 1000),
1881
+ hasFlowId,
1882
+ threadLength: thread.length,
1883
+ instanceIds: instanceIds.slice(0, 10),
1884
+ });
1885
+
1886
+ onStreamEvent({ type: "status", line: t("composer.analyzing_task") });
1887
+ log.debug(`[ui] composer-agent: flowId=${flowId || "(none)"} model=${model || "default"} promptLen=${finalPrompt.length}`);
1888
+
1889
+ const hasPhaseContext = payload.phaseContext && typeof payload.phaseContext === "object" && typeof payload.phaseContext.phaseIndex === "number";
1890
+ // query 意图:跳过 planner,直接走单步轻量路径
1891
+ const resolvedIntentCategory = flowContextForMultiStep?.intentCategory || "generic";
1892
+ let useMultiStep;
1893
+ try {
1894
+ useMultiStep = resolvedIntentCategory === "query"
1895
+ ? false
1896
+ : (hasPhaseContext || ((await shouldUseMultiStep({ flowYamlAbs, userPrompt: prompt.trim(), cliWorkspace })) && !payload.singleStep));
1897
+ } catch (classifyErr) {
1898
+ log.debug(`[ui] composer classify error: ${classifyErr.message}`);
1899
+ logComposerEvent(composerLogPath, "composer-done", {
1900
+ status: "failed",
1901
+ error: truncateForLog(classifyErr?.message || String(classifyErr), 500),
1902
+ code: "CLASSIFY_FAIL",
1903
+ });
1904
+ onStreamEvent({ type: "error", message: t("composer.classify_failed", { message: classifyErr.message }), code: "CLASSIFY_FAIL" });
1905
+ endSafe();
1906
+ return;
1907
+ }
1908
+
1909
+ log.debug(`[ui] composer mode: ${useMultiStep ? "multi-step" : "single-step"}`);
1910
+
1911
+ logComposerEvent(composerLogPath, "classify", {
1912
+ mode: useMultiStep ? "multi-step" : "single-step",
1913
+ hasPhaseContext,
1914
+ });
1915
+
1916
+ if (useMultiStep) {
1917
+ try {
1918
+ onStreamEvent({ type: "status", line: t("composer.multi_step_starting") });
1919
+ const phaseContext = payload.phaseContext && typeof payload.phaseContext === "object" ? payload.phaseContext : undefined;
1920
+ const handle = startComposerMultiStep({
1921
+ uiWorkspaceRoot: root,
1922
+ cliWorkspace,
1923
+ userPrompt: prompt.trim(),
1924
+ fullPrompt: finalPrompt,
1925
+ modelKey: typeof model === "string" ? model.trim() : "",
1926
+ flowYamlAbs,
1927
+ flowId,
1928
+ flowSource,
1929
+ instanceIds,
1930
+ flowContext: flowContextForMultiStep,
1931
+ thread,
1932
+ phaseContext,
1933
+ phaseRole: phaseRole || undefined,
1934
+ force: true,
1935
+ onStreamEvent,
1936
+ });
1937
+ multiStepAbort = handle.abort;
1938
+ handle.finished
1939
+ .then(() => {
1940
+ if (!responseEnded) {
1941
+ logComposerEvent(composerLogPath, "composer-done", {
1942
+ status: "success",
1943
+ flowId: flowId || null,
1944
+ flowSource: flowSource || null,
1945
+ });
1946
+ if (flowId && flowSource) {
1947
+ broadcastFlowEditorSync(flowId, flowSource, Boolean(payload.flowArchived));
1948
+ }
1949
+ try { res.write(JSON.stringify({ type: "done" }) + "\n"); } catch (_) {}
1950
+ }
1951
+ endSafe();
1952
+ })
1953
+ .catch((e) => {
1954
+ if (!responseEnded) {
1955
+ logComposerEvent(composerLogPath, "composer-done", {
1956
+ status: "failed",
1957
+ error: truncateForLog(e?.message || String(e), 500),
1958
+ code: "MULTI_STEP_FAIL",
1959
+ });
1960
+ try {
1961
+ res.write(JSON.stringify({ type: "error", message: (e && e.message) || String(e), code: "MULTI_STEP_FAIL" }) + "\n");
1962
+ } catch (_) {}
1963
+ }
1964
+ endSafe();
1965
+ });
1966
+ } catch (e) {
1967
+ logComposerEvent(composerLogPath, "composer-done", {
1968
+ status: "failed",
1969
+ error: truncateForLog(e?.message || String(e), 500),
1970
+ code: "MULTI_STEP_INIT_FAIL",
1971
+ });
1972
+ try {
1973
+ res.write(JSON.stringify({ type: "error", message: (e && e.message) || String(e), code: "MULTI_STEP_INIT_FAIL" }) + "\n");
1974
+ } catch (_) {}
1975
+ endSafe();
1976
+ }
1977
+ } else {
1978
+ try {
1979
+ const handle = startComposerAgent({
1980
+ uiWorkspaceRoot: root,
1981
+ cliWorkspace,
1982
+ prompt: finalPrompt,
1983
+ modelKey: typeof model === "string" ? model.trim() : "",
1984
+ onStreamEvent,
1985
+ });
1986
+ child = handle.child;
1987
+ handle.finished
1988
+ .then(async () => {
1989
+ if (responseEnded) {
1990
+ endSafe();
1991
+ return;
1992
+ }
1993
+ if (flowYamlAbs && flowContextForMultiStep) {
1994
+ try {
1995
+ await runComposerPostFlowValidationAndRepair({
1996
+ uiWorkspaceRoot: root,
1997
+ cliWorkspace,
1998
+ flowYamlAbs,
1999
+ flowContext: flowContextForMultiStep,
2000
+ modelKey: typeof model === "string" ? model.trim() : "",
2001
+ force: true,
2002
+ onStreamEvent,
2003
+ getAborted: () => clientDisconnected || responseEnded,
2004
+ setCurrentChild: (c) => {
2005
+ child = c;
2006
+ },
2007
+ });
2008
+ } catch (e) {
2009
+ onStreamEvent({
2010
+ type: "natural",
2011
+ kind: "error",
2012
+ text: `校验修复异常: ${(e && e.message) || String(e)}`,
2013
+ });
2014
+ }
2015
+ }
2016
+ if (!responseEnded) {
2017
+ logComposerEvent(composerLogPath, "composer-done", {
2018
+ status: "success",
2019
+ flowId: flowId || null,
2020
+ flowSource: flowSource || null,
2021
+ });
2022
+ if (flowId && flowSource) {
2023
+ broadcastFlowEditorSync(flowId, flowSource, Boolean(payload.flowArchived));
2024
+ }
2025
+ try { res.write(JSON.stringify({ type: "done" }) + "\n"); } catch (_) {}
2026
+ }
2027
+ endSafe();
2028
+ })
2029
+ .catch((e) => {
2030
+ if (!responseEnded) {
2031
+ logComposerEvent(composerLogPath, "composer-done", {
2032
+ status: "failed",
2033
+ error: truncateForLog(e?.message || String(e), 500),
2034
+ code: "SINGLE_STEP_FAIL",
2035
+ });
2036
+ try {
2037
+ res.write(JSON.stringify({ type: "error", message: (e && e.message) || String(e), code: "SINGLE_STEP_FAIL" }) + "\n");
2038
+ } catch (_) {}
2039
+ }
2040
+ endSafe();
2041
+ });
2042
+ } catch (e) {
2043
+ logComposerEvent(composerLogPath, "composer-done", {
2044
+ status: "failed",
2045
+ error: truncateForLog(e?.message || String(e), 500),
2046
+ code: "SINGLE_STEP_INIT_FAIL",
2047
+ });
2048
+ try {
2049
+ res.write(JSON.stringify({ type: "error", message: (e && e.message) || String(e), code: "SINGLE_STEP_INIT_FAIL" }) + "\n");
2050
+ } catch (_) {}
2051
+ endSafe();
2052
+ }
2053
+ }
2054
+ return;
2055
+ }
2056
+
2057
+ if (req.method !== "GET") {
2058
+ res.writeHead(405, { Allow: "GET, POST" });
2059
+ res.end();
2060
+ return;
2061
+ }
2062
+
2063
+ const safeRoot = path.resolve(staticDir);
2064
+ let rel = url.pathname.replace(/^\/+/, "") || "index.html";
2065
+ if (rel.includes("..") || path.isAbsolute(rel)) {
2066
+ res.writeHead(403);
2067
+ res.end();
2068
+ return;
2069
+ }
2070
+ let filePath = path.resolve(safeRoot, rel);
2071
+ if (filePath !== safeRoot && !filePath.startsWith(safeRoot + path.sep)) {
2072
+ res.writeHead(403);
2073
+ res.end();
2074
+ return;
2075
+ }
2076
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
2077
+ filePath = path.join(filePath, "index.html");
2078
+ }
2079
+ if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
2080
+ // 避免 /agentflow-icon.svg 缺失时回退成 index.html(浏览器当图片解析会破图)
2081
+ if (rel === "agentflow-icon.svg") {
2082
+ const pkgIcon = path.join(PACKAGE_ROOT, "builtin", "web-ui", "src", "assets", "agentflow-icon.svg");
2083
+ if (fs.existsSync(pkgIcon) && fs.statSync(pkgIcon).isFile()) {
2084
+ filePath = pkgIcon;
2085
+ }
2086
+ }
2087
+ }
2088
+ if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
2089
+ const fallback = path.join(staticDir, "index.html");
2090
+ if (fs.existsSync(fallback)) {
2091
+ filePath = fallback;
2092
+ } else {
2093
+ res.writeHead(404);
2094
+ res.end("Not found");
2095
+ return;
2096
+ }
2097
+ }
2098
+ const ext = path.extname(filePath).toLowerCase();
2099
+ const type = MIME[ext] || "application/octet-stream";
2100
+ const data = fs.readFileSync(filePath);
2101
+ res.writeHead(200, { "Content-Type": type, "Content-Length": data.length });
2102
+ res.end(data);
2103
+ });
2104
+
2105
+ return new Promise((resolve, reject) => {
2106
+ server.once("error", reject);
2107
+ server.listen(port, host, () => {
2108
+ log.debug(`[ui] server listening on ${host}:${port}, workspace=${root}, static=${staticDir}`);
2109
+ updateModelLists(root).catch(() => {});
2110
+ resolve(server);
2111
+ });
2112
+ });
2113
+ }