@fieldwangai/agentflow 0.1.26 → 0.1.28

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.
@@ -12,9 +12,12 @@ import {
12
12
  import { getRunDir, PACKAGE_ROOT } from "./paths.mjs";
13
13
  import { isApplyProcessAlive } from "./run-apply-active-lock.mjs";
14
14
  import { log } from "./log.mjs";
15
+ import { writeResult } from "../pipeline/write-result.mjs";
15
16
 
16
17
  const DEFAULT_POLL_MS = 30_000;
17
18
  const RUN_CONFIG_FILENAME = "run-config.json";
19
+ const WAIT_STATE_FILENAME = "wait-state.json";
20
+ const WAIT_STATES_FILENAME = "wait-states.json";
18
21
 
19
22
  function sleep(ms) {
20
23
  return new Promise((resolve) => setTimeout(resolve, ms));
@@ -108,6 +111,104 @@ function getLatestRunUuidForFlow(workspaceRoot, flowId) {
108
111
  }
109
112
  }
110
113
 
114
+ function listRunDirsForFlow(flow) {
115
+ const flowDir = flow.path || "";
116
+ const runRoot = flowDir ? path.join(flowDir, "runBuild") : "";
117
+ if (!runRoot || !fs.existsSync(runRoot)) return [];
118
+ try {
119
+ return fs.readdirSync(runRoot, { withFileTypes: true })
120
+ .filter((e) => e.isDirectory() && /^\d{14}$/.test(e.name))
121
+ .map((e) => ({ uuid: e.name, runDir: path.join(runRoot, e.name) }));
122
+ } catch {
123
+ return [];
124
+ }
125
+ }
126
+
127
+ function readJsonObject(filePath) {
128
+ if (!fs.existsSync(filePath)) return null;
129
+ try {
130
+ const state = JSON.parse(fs.readFileSync(filePath, "utf-8"));
131
+ return state && typeof state === "object" ? state : null;
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
137
+ function waitStateKey(state) {
138
+ return String((state && (state.id || state.instanceId)) || "");
139
+ }
140
+
141
+ function persistedWaitState(state) {
142
+ const {
143
+ waitPath: _waitPath,
144
+ legacyPath: _legacyPath,
145
+ registryPath: _registryPath,
146
+ runDir: _runDir,
147
+ ...persisted
148
+ } = state && typeof state === "object" ? state : {};
149
+ return persisted;
150
+ }
151
+
152
+ function readWaitStates(runDir) {
153
+ const legacyPath = path.join(runDir, WAIT_STATE_FILENAME);
154
+ const registryPath = path.join(runDir, WAIT_STATES_FILENAME);
155
+ const states = [];
156
+ const seen = new Set();
157
+ const registry = readJsonObject(registryPath);
158
+ if (registry && Array.isArray(registry.waits)) {
159
+ for (const raw of registry.waits) {
160
+ if (!raw || typeof raw !== "object") continue;
161
+ const state = { ...raw, runDir, legacyPath, registryPath };
162
+ const key = waitStateKey(state);
163
+ if (!key || seen.has(key)) continue;
164
+ seen.add(key);
165
+ states.push(state);
166
+ }
167
+ }
168
+ const legacy = readJsonObject(legacyPath);
169
+ if (legacy) {
170
+ const state = { ...legacy, runDir, waitPath: legacyPath, legacyPath, registryPath: fs.existsSync(registryPath) ? registryPath : null };
171
+ const key = waitStateKey(state);
172
+ if (key && !seen.has(key)) states.push(state);
173
+ }
174
+ return states;
175
+ }
176
+
177
+ function writeWaitState(waitState, patch = {}) {
178
+ const next = {
179
+ ...(waitState && typeof waitState === "object" ? waitState : {}),
180
+ ...(patch && typeof patch === "object" ? patch : {}),
181
+ updatedAt: new Date().toISOString(),
182
+ };
183
+ const runDir = next.runDir || (next.waitPath ? path.dirname(next.waitPath) : "");
184
+ if (!runDir) return;
185
+
186
+ const legacyPath = next.legacyPath || next.waitPath || path.join(runDir, WAIT_STATE_FILENAME);
187
+ const registryPath = next.registryPath || path.join(runDir, WAIT_STATES_FILENAME);
188
+ const persisted = persistedWaitState(next);
189
+ const key = waitStateKey(persisted);
190
+
191
+ const registry = readJsonObject(registryPath);
192
+ if (registry && Array.isArray(registry.waits)) {
193
+ const waits = registry.waits.filter((w) => waitStateKey(w) !== key);
194
+ waits.push(persisted);
195
+ fs.writeFileSync(registryPath, JSON.stringify({ ...registry, updatedAt: new Date().toISOString(), waits }, null, 2) + "\n", "utf-8");
196
+ }
197
+ fs.writeFileSync(legacyPath, JSON.stringify(persisted, null, 2) + "\n", "utf-8");
198
+ }
199
+
200
+ function readNodeResultStatus(runDir, instanceId) {
201
+ const resultPath = path.join(runDir, "intermediate", instanceId, `${instanceId}.result.md`);
202
+ if (!fs.existsSync(resultPath)) return null;
203
+ try {
204
+ const raw = fs.readFileSync(resultPath, "utf-8");
205
+ const m = raw.match(/^\s*status:\s*["']?([^"'\s]+)["']?/m);
206
+ return m ? m[1] : null;
207
+ } catch {
208
+ return null;
209
+ }
210
+ }
211
+
111
212
  function isFlowCurrentlyRunning(workspaceRoot, flowId, state) {
112
213
  const candidates = [];
113
214
  if (state && typeof state.lastRunUuid === "string") candidates.push(state.lastRunUuid);
@@ -206,6 +307,110 @@ function startScheduledRun(workspaceRoot, flow, schedule, state) {
206
307
  return child;
207
308
  }
208
309
 
310
+ function startWaitingRunResume(workspaceRoot, flow, waitState) {
311
+ const agentflowBin = path.join(PACKAGE_ROOT, "bin", "agentflow.mjs");
312
+ const uuid = String(waitState.uuid || "");
313
+ const instanceId = String(waitState.instanceId || "");
314
+ const args = [
315
+ agentflowBin,
316
+ "resume",
317
+ flow.id,
318
+ uuid,
319
+ instanceId,
320
+ "--machine-readable",
321
+ "--workspace-root",
322
+ path.resolve(workspaceRoot),
323
+ "--force",
324
+ ];
325
+ const child = spawn(process.execPath, args, {
326
+ cwd: path.resolve(workspaceRoot),
327
+ stdio: ["ignore", "pipe", "pipe"],
328
+ env: { ...process.env, FORCE_COLOR: "0" },
329
+ detached: true,
330
+ });
331
+ child.stdout.on("data", () => {});
332
+ child.stderr.on("data", (chunk) => {
333
+ const text = chunk.toString("utf8").trim();
334
+ if (text) log.debug(`[scheduler] resume ${flow.id}/${uuid}: ${text.slice(0, 1000)}`);
335
+ });
336
+ child.on("exit", (code, signal) => {
337
+ const runDir = waitState.runDir || (waitState.waitPath ? path.dirname(waitState.waitPath) : "");
338
+ if (!runDir) return;
339
+ const latest = readWaitStates(runDir).find((s) => waitStateKey(s) === waitStateKey(waitState));
340
+ if (!latest || latest.wakeAt !== waitState.wakeAt || latest.instanceId !== waitState.instanceId) return;
341
+ writeWaitState(latest, {
342
+ status: code === 0 ? "resumed" : "waiting",
343
+ lastResumeExitCode: code,
344
+ lastResumeExitSignal: signal || "",
345
+ lastResumeFinishedAt: new Date().toISOString(),
346
+ ...(code === 0 ? {} : { lastError: `resume exited with code ${code}${signal ? ` signal ${signal}` : ""}` }),
347
+ });
348
+ });
349
+ child.unref();
350
+ return child;
351
+ }
352
+
353
+ function countActiveWaitsForFlow(flow) {
354
+ let count = 0;
355
+ for (const run of listRunDirsForFlow(flow)) {
356
+ for (const waitState of readWaitStates(run.runDir)) {
357
+ if (waitState && (waitState.status === "waiting" || waitState.status === "resuming")) count += 1;
358
+ }
359
+ }
360
+ return count;
361
+ }
362
+
363
+ function hasNodeBranchEdge(runDir, instanceId, branchName) {
364
+ const flowJsonPath = path.join(runDir, "intermediate", "flow.json");
365
+ const flow = readJsonObject(flowJsonPath);
366
+ if (!flow || !Array.isArray(flow.edges)) return false;
367
+ const outputSlotTypes = flow.outputSlotTypes && flow.outputSlotTypes[instanceId];
368
+ if (!outputSlotTypes || typeof outputSlotTypes !== "object") return false;
369
+ const idx = Object.keys(outputSlotTypes).indexOf(branchName);
370
+ if (idx < 0) return false;
371
+ const sourceHandle = `output-${idx}`;
372
+ return flow.edges.some((e) => e && e.source === instanceId && (e.sourceHandle || "output-0") === sourceHandle);
373
+ }
374
+
375
+ export function cancelScheduledRun(workspaceRoot, flowId, uuid) {
376
+ const flow = listFlowsJson(workspaceRoot).find((f) => f.id === flowId && !f.archived && f.source !== "builtin");
377
+ if (!flow) return { ok: false, error: `flow not found: ${flowId}` };
378
+ const runDir = getRunDir(workspaceRoot, flow.id, uuid);
379
+ if (!fs.existsSync(runDir)) return { ok: false, error: `run not found: ${flowId}/${uuid}` };
380
+ const cancelledAt = new Date().toISOString();
381
+ fs.writeFileSync(path.join(runDir, "cancelled.json"), JSON.stringify({ cancelled: true, cancelledAt }, null, 2) + "\n", "utf-8");
382
+ let updated = 0;
383
+ let propagated = 0;
384
+ let resumePid = null;
385
+ for (const waitState of readWaitStates(runDir)) {
386
+ if (waitState.status !== "waiting" && waitState.status !== "resuming") continue;
387
+ const instanceId = String(waitState.instanceId || "");
388
+ const canPropagate =
389
+ waitState.reason === "control_interval_loop" &&
390
+ instanceId &&
391
+ hasNodeBranchEdge(runDir, instanceId, "cancelled") &&
392
+ !resumePid;
393
+ if (canPropagate) {
394
+ writeResult(
395
+ workspaceRoot,
396
+ flow.id,
397
+ uuid,
398
+ instanceId,
399
+ { status: "success", message: "已取消", branch: "cancelled" },
400
+ { execId: Number(waitState.execId) || undefined, preserveBody: false },
401
+ );
402
+ const child = startWaitingRunResume(workspaceRoot, flow, { ...waitState, uuid, runDir, branch: "cancelled" });
403
+ resumePid = child.pid || null;
404
+ writeWaitState(waitState, { status: "resuming", branch: "cancelled", cancelledAt, resumePid, resumeStartedAt: cancelledAt });
405
+ propagated += 1;
406
+ } else {
407
+ writeWaitState(waitState, { status: "cancelled", cancelledAt });
408
+ }
409
+ updated += 1;
410
+ }
411
+ return { ok: true, flowId, uuid, cancelledAt, updatedWaits: updated, propagatedWaits: propagated, resumePid };
412
+ }
413
+
209
414
  export function listScheduleStatuses(workspaceRoot) {
210
415
  const rows = [];
211
416
  for (const flow of listFlowsJson(workspaceRoot)) {
@@ -232,6 +437,7 @@ export function listScheduleStatuses(workspaceRoot) {
232
437
  ? "workspace flow is shadowed by a user flow with the same id"
233
438
  : state.lastError || "",
234
439
  running: isFlowCurrentlyRunning(workspaceRoot, flow.id, state),
440
+ waiting: countActiveWaitsForFlow(flow),
235
441
  });
236
442
  }
237
443
  rows.sort((a, b) => {
@@ -251,6 +457,46 @@ export async function startScheduler(workspaceRoot, opts = {}) {
251
457
  for (const flow of listFlowsJson(workspaceRoot)) {
252
458
  if (flow.archived || flow.source === "builtin") continue;
253
459
  const flowSource = flow.source || "user";
460
+ let resumedWaitingRun = false;
461
+ for (const run of listRunDirsForFlow(flow)) {
462
+ if (resumedWaitingRun) break;
463
+ for (const waitState of readWaitStates(run.runDir)) {
464
+ if (!waitState || !waitState.wakeAt || !waitState.instanceId) continue;
465
+ if (waitState.status === "resuming" && !isFlowCurrentlyRunning(workspaceRoot, flow.id, { lastRunUuid: run.uuid })) {
466
+ const nodeStatus = readNodeResultStatus(run.runDir, String(waitState.instanceId));
467
+ writeWaitState(waitState, {
468
+ status: nodeStatus === "pending" ? "waiting" : "resumed",
469
+ reconciledAt: new Date().toISOString(),
470
+ });
471
+ continue;
472
+ }
473
+ if (waitState.status !== "waiting") continue;
474
+ if (Date.parse(waitState.wakeAt) > now) continue;
475
+ if (isFlowCurrentlyRunning(workspaceRoot, flow.id, { lastRunUuid: run.uuid })) continue;
476
+ const nextState = {
477
+ ...waitState,
478
+ status: "resuming",
479
+ resumePid: null,
480
+ resumeStartedAt: new Date().toISOString(),
481
+ };
482
+ try {
483
+ const child = startWaitingRunResume(workspaceRoot, flow, { ...waitState, uuid: run.uuid, runDir: run.runDir });
484
+ nextState.resumePid = child.pid || null;
485
+ writeWaitState(waitState, nextState);
486
+ resumedWaitingRun = true;
487
+ log.info(`[scheduler] resume ${flow.id}/${run.uuid} at ${waitState.instanceId}; pid=${child.pid || "?"}`);
488
+ break;
489
+ } catch (e) {
490
+ writeWaitState(waitState, {
491
+ status: "waiting",
492
+ lastError: e && e.message ? e.message : String(e),
493
+ lastErrorAt: new Date().toISOString(),
494
+ });
495
+ log.info(`[scheduler] resume failed ${flow.id}/${run.uuid}: ${e && e.message ? e.message : String(e)}`);
496
+ }
497
+ }
498
+ }
499
+
254
500
  const scheduleRes = readFlowSchedule(workspaceRoot, flow.id, flowSource);
255
501
  if (!scheduleRes.success) {
256
502
  log.debug(`[scheduler] ${flow.id}: ${scheduleRes.error}`);
@@ -26,11 +26,9 @@ import { updateModelLists } from "./model-lists.mjs";
26
26
  import {
27
27
  startComposerAgent,
28
28
  startComposerMultiStep,
29
- shouldUseMultiStep,
30
29
  runComposerPostFlowValidationAndRepair,
31
30
  buildScriptContentBlockForInstances,
32
31
  } from "./composer-agent.mjs";
33
- import { buildNodeSchemaCompactSection } from "./composer-node-schema.mjs";
34
32
  import { t } from "./i18n.mjs";
35
33
  import {
36
34
  PACKAGE_ROOT,
@@ -42,6 +40,8 @@ import { RUN_INTERRUPTED_FILENAME } from "./recent-runs.mjs";
42
40
  import {
43
41
  detectIntents,
44
42
  loadResourcesForIntents,
43
+ loadResourcesForSkillKeys,
44
+ listComposerSkills,
45
45
  buildSkillInjectionBlock,
46
46
  buildSkillCompactInjectionBlock,
47
47
  } from "./composer-skill-router.mjs";
@@ -64,6 +64,7 @@ import {
64
64
  import { runNodeScript } from "./pipeline-scripts.mjs";
65
65
  import { readFlowSchedule, writeFlowSchedule } from "./schedule-config.mjs";
66
66
  import { listScheduleStatuses } from "./scheduler.mjs";
67
+ import { installFlowDependency, listMarketplacePackages, publishNodeFromInstance } from "./marketplace.mjs";
67
68
 
68
69
  const MIME = {
69
70
  ".html": "text/html; charset=utf-8",
@@ -276,10 +277,6 @@ function buildComposerPromptWithFlowContext(p) {
276
277
  const flowDirAbs = path.dirname(p.flowYamlAbs);
277
278
  const idsLine =
278
279
  p.instanceIds.length > 0 ? p.instanceIds.map(String).join(", ") : "(无,可能为全局修改或新增节点)";
279
- const syncFs = p.editorSyncFlowSource ?? p.flowSource;
280
- const syncBody = { flowId: p.flowId, flowSource: syncFs };
281
- if (p.flowArchived) syncBody.flowArchived = true;
282
- const syncJsonArg = JSON.stringify(JSON.stringify(syncBody));
283
280
  const builtinExtra =
284
281
  p.flowSource === "builtin" && p.workspaceWriteDirAbs
285
282
  ? [
@@ -288,23 +285,6 @@ function buildComposerPromptWithFlowContext(p) {
288
285
  ]
289
286
  : [];
290
287
 
291
- // 基于用户意图动态注入 skill 和 reference 内容
292
- const intents = detectIntents(p.userPrompt);
293
- const resources = loadResourcesForIntents(intents, PACKAGE_ROOT);
294
- const skillBlock = resources.hasContext
295
- ? buildSkillInjectionBlock(resources.skills, resources.references)
296
- : "";
297
-
298
- // 无意图匹配时使用通用 skill 路径引用作为兜底
299
- const skillPathHints = resources.hasContext
300
- ? []
301
- : [
302
- "- 新增实例与边:遵循 skill `skills/agentflow-flow-add-instances/SKILL.md`(或 `.cursor/skills/.../SKILL.md`)。",
303
- "- 仅改已有实例文案/占位等:遵循 `skills/agentflow-flow-edit-node-fields/SKILL.md`,勿改 definitionId、instanceId、IO 结构与边拓扑。",
304
- ];
305
-
306
- const nodeSchemaSection = buildNodeSchemaCompactSection();
307
-
308
288
  const prefix = [
309
289
  "## AgentFlow Composer 上下文",
310
290
  `- 流水线目录(flowId=${p.flowId}):${flowDirAbs}`,
@@ -313,33 +293,15 @@ function buildComposerPromptWithFlowContext(p) {
313
293
  `- flowSource:${p.flowSource}`,
314
294
  ...builtinExtra,
315
295
  `- 当前关联的节点实例 ID(顺序:画布选中优先,再输入框 @提及):${idsLine}`,
316
- "- 请根据用户需求自行判断:如果是在问问题,只回答;如果是在要求新增、修改、完善或修复流程,请直接修改对应文件。",
317
- "- 一旦修改 flow.yaml、脚本或相关文件,必须按下方方式刷新 Web 画布。",
318
- ...skillPathHints,
319
- "",
320
- "### 节点能力选择",
321
- "**判据**:确定性任务优先 `tool_nodejs`;需要语义理解、生成、判断或多步推理时使用 `agent_subAgent`。",
322
- "- **确定性**:相同输入永远产出相同输出,可用普通代码完整描述(CLI/npm 调用、读写文件、JSON/路径转换、调现成 API 解析固定格式、跑脚手架等)。",
323
- "- **非确定性**:需要语义理解或创造(代码翻译/生成、源码/文本解析改写、多步推理决策、创意写作)。",
324
- "- 分支/循环使用 `control_toBool` / `control_agent_toBool` + `control_if` + `control_anyOne` 组合。",
325
- "- 常量输入使用 `provide_str` / `provide_file`;读取环境变量使用 `tool_get_env`;终端展示使用 `tool_print`。",
326
- "",
327
- nodeSchemaSection,
328
- "",
329
- "### tool_nodejs 的 script 与 body 关键区分",
330
- "- **`script` 字段**:实际执行的命令代码,流水线直接 spawn 执行;**tool_nodejs 必须写 script**",
331
- "- **`body` 字段**:纯文档注释,有 script 时完全不执行;**禁止在 body 写期望执行的逻辑**",
332
- "- 如果无法写出完整可执行的 script(需要 AI 理解/判断),**必须改用 agent_subAgent**,不要用 tool_nodejs",
333
- "- script 支持多行(YAML `|`)和管道,可写复杂的 curl + node 组合",
334
- "- **禁止**:tool_nodejs 只有 body 没有 script(body 中的自然语言不会被执行,节点会失败)",
335
- "",
336
- // 动态注入的 skill 和 reference 内容
337
- ...(skillBlock ? [skillBlock, ""] : []),
338
- "- **保存 flow.yaml 后必须刷新 Web 画布**:遵循 `skills/agentflow-flow-sync-ui/SKILL.md`;在终端执行(将 JSON 与上方 flowId、flowSource" +
339
- (p.flowArchived ? "、flowArchived" : "") +
340
- " 保持一致):",
341
- ` curl -sS -X POST http://127.0.0.1:${p.uiPort}/api/flow-editor-sync -H 'Content-Type: application/json' -d ${syncJsonArg}`,
296
+ "- 像普通 agent 请求一样处理用户说明:可能只是问问题,也可能要求编辑文件。不要因为存在 flowId 就默认修改 flow.yaml。",
297
+ "- 按需使用当前环境可用的 skills;如果用户点名某个 skill,遵循该 skill 的 SKILL.md。",
298
+ "- 如果你判断需要编辑 AgentFlow 流程,可按需读取这些本地 skills:",
299
+ " - `skills/agentflow-flow-add-instances/SKILL.md`:新增实例、边和布局",
300
+ " - `skills/agentflow-flow-edit-node-fields/SKILL.md`:只改已有节点字段",
301
+ " - `skills/agentflow-flow-sync-ui/SKILL.md`:保存 flow.yaml 后刷新画布",
302
+ "- 如果只是回答问题,不要修改文件。",
342
303
  "",
304
+ ...(p.selectedSkillBlock ? [p.selectedSkillBlock, ""] : []),
343
305
  ...(p.thread && p.thread.length > 0
344
306
  ? [formatThreadHistory(p.thread), ""]
345
307
  : []),
@@ -351,6 +313,15 @@ function buildComposerPromptWithFlowContext(p) {
351
313
  return prefix;
352
314
  }
353
315
 
316
+ function flowYamlChangedSince(flowYamlAbs, beforeText) {
317
+ if (!flowYamlAbs || beforeText == null) return false;
318
+ try {
319
+ return fs.readFileSync(flowYamlAbs, "utf-8") !== beforeText;
320
+ } catch {
321
+ return false;
322
+ }
323
+ }
324
+
354
325
  function normalizeContextInstanceIds(raw) {
355
326
  if (raw == null) return [];
356
327
  if (!Array.isArray(raw)) return [];
@@ -885,6 +856,77 @@ export function startUiServer({
885
856
  return;
886
857
  }
887
858
 
859
+ if (req.method === "GET" && url.pathname === "/api/marketplace/nodes") {
860
+ try {
861
+ json(res, 200, listMarketplacePackages(root));
862
+ } catch (e) {
863
+ json(res, 500, { error: (e && e.message) || String(e) });
864
+ }
865
+ return;
866
+ }
867
+
868
+ if (req.method === "POST" && url.pathname === "/api/marketplace/install-node") {
869
+ let payload;
870
+ try {
871
+ payload = JSON.parse(await readBody(req));
872
+ } catch {
873
+ json(res, 400, { error: "Invalid JSON body" });
874
+ return;
875
+ }
876
+ const flowId = payload?.flowId;
877
+ const flowSource = payload?.flowSource || "user";
878
+ const flowArchived = payload?.archived === true;
879
+ const nodeSpec = payload?.nodeSpec || payload?.definitionId || payload?.id;
880
+ if (!flowId) {
881
+ json(res, 400, { error: "Missing flowId" });
882
+ return;
883
+ }
884
+ if (!nodeSpec) {
885
+ json(res, 400, { error: "Missing nodeSpec" });
886
+ return;
887
+ }
888
+ if (flowArchived || !isValidFlowSourceWrite(flowSource)) {
889
+ json(res, 400, { error: "Cannot install marketplace nodes into builtin or archived flow" });
890
+ return;
891
+ }
892
+ try {
893
+ const resolved = resolveFlowDirForWrite(root, flowId, flowSource);
894
+ if (resolved.error || !resolved.flowDir) {
895
+ json(res, 400, { error: resolved.error || "Could not resolve flow directory" });
896
+ return;
897
+ }
898
+ const result = installFlowDependency(root, resolved.flowDir, nodeSpec);
899
+ json(res, result.ok ? 200 : 400, result);
900
+ } catch (e) {
901
+ json(res, 500, { ok: false, error: (e && e.message) || String(e) });
902
+ }
903
+ return;
904
+ }
905
+
906
+ if (req.method === "POST" && url.pathname === "/api/marketplace/publish-node-from-instance") {
907
+ let payload;
908
+ try {
909
+ payload = JSON.parse(await readBody(req));
910
+ } catch {
911
+ json(res, 400, { error: "Invalid JSON body" });
912
+ return;
913
+ }
914
+ try {
915
+ const flowId = payload?.flowId;
916
+ const flowSource = payload?.flowSource || "user";
917
+ let flowDir = "";
918
+ if (flowId && isValidFlowSourceWrite(flowSource)) {
919
+ const resolved = resolveFlowDirForWrite(root, flowId, flowSource);
920
+ if (!resolved.error && resolved.flowDir) flowDir = resolved.flowDir;
921
+ }
922
+ const result = publishNodeFromInstance(root, payload || {}, { flowDir });
923
+ json(res, result.ok ? 200 : 400, result);
924
+ } catch (e) {
925
+ json(res, 500, { ok: false, error: (e && e.message) || String(e) });
926
+ }
927
+ return;
928
+ }
929
+
888
930
  if (req.method === "GET" && url.pathname === "/api/flow") {
889
931
  const flowId = url.searchParams.get("flowId");
890
932
  const flowSource = url.searchParams.get("flowSource") || "user";
@@ -1637,6 +1679,11 @@ finishedAt: "${new Date().toISOString()}"
1637
1679
  return;
1638
1680
  }
1639
1681
 
1682
+ if (req.method === "GET" && url.pathname === "/api/skills") {
1683
+ json(res, 200, { skills: listComposerSkills(PACKAGE_ROOT, root) });
1684
+ return;
1685
+ }
1686
+
1640
1687
  if (req.method === "POST" && url.pathname === "/api/composer-agent") {
1641
1688
  let payload;
1642
1689
  try {
@@ -1656,6 +1703,9 @@ finishedAt: "${new Date().toISOString()}"
1656
1703
  json(res, 400, { error: "Invalid model" });
1657
1704
  return;
1658
1705
  }
1706
+ const selectedSkillKeys = Array.isArray(payload.selectedSkills)
1707
+ ? payload.selectedSkills.map((x) => String(x || "").trim()).filter(Boolean).slice(0, 20)
1708
+ : [];
1659
1709
 
1660
1710
  const flowIdRaw = payload.flowId;
1661
1711
  const flowSourceRaw = payload.flowSource;
@@ -1678,6 +1728,8 @@ finishedAt: "${new Date().toISOString()}"
1678
1728
  let flowSource = null;
1679
1729
  let instanceIds = [];
1680
1730
  let flowContextForMultiStep = null;
1731
+ let flowYamlBefore = null;
1732
+ const hasPhaseContext = payload.phaseContext && typeof payload.phaseContext === "object" && typeof payload.phaseContext.phaseIndex === "number";
1681
1733
 
1682
1734
  if (hasFlowId) {
1683
1735
  flowId = String(flowIdRaw).trim();
@@ -1693,6 +1745,7 @@ finishedAt: "${new Date().toISOString()}"
1693
1745
  return;
1694
1746
  }
1695
1747
  flowYamlAbs = yamlRes.path;
1748
+ try { flowYamlBefore = fs.readFileSync(flowYamlAbs, "utf-8"); } catch { flowYamlBefore = null; }
1696
1749
  let workspaceWriteDirAbs;
1697
1750
  let editorSyncFlowSource = flowSource;
1698
1751
  let flowDirForCli = path.dirname(flowYamlAbs);
@@ -1713,10 +1766,18 @@ finishedAt: "${new Date().toISOString()}"
1713
1766
  if (flowArchived) syncBody.flowArchived = true;
1714
1767
  const syncJsonArg = JSON.stringify(JSON.stringify(syncBody));
1715
1768
 
1716
- // 基于用户意图动态加载 skill 上下文
1769
+ // 多步分阶段仍需要技能上下文;普通 Composer 请求直接交给 agent + skills 自行判断。
1717
1770
  const multiStepIntents = detectIntents(prompt);
1718
- const multiStepResources = loadResourcesForIntents(multiStepIntents, PACKAGE_ROOT);
1771
+ const selectedSkillResources = selectedSkillKeys.length > 0
1772
+ ? loadResourcesForSkillKeys(selectedSkillKeys, PACKAGE_ROOT, root)
1773
+ : { skills: [], references: [], skillsHint: "", hasContext: false };
1774
+ const multiStepResources = selectedSkillResources.hasContext
1775
+ ? selectedSkillResources
1776
+ : loadResourcesForIntents(multiStepIntents, PACKAGE_ROOT);
1719
1777
  const flowPipelineDir = flowYamlAbs ? path.dirname(flowYamlAbs) : "";
1778
+ const selectedSkillBlock = selectedSkillResources.hasContext
1779
+ ? buildSkillInjectionBlock(selectedSkillResources.skills, selectedSkillResources.references)
1780
+ : "";
1720
1781
 
1721
1782
  flowContextForMultiStep = {
1722
1783
  flowYamlAbs,
@@ -1746,6 +1807,7 @@ finishedAt: "${new Date().toISOString()}"
1746
1807
  flowArchived,
1747
1808
  thread,
1748
1809
  scriptContentBlock,
1810
+ selectedSkillBlock,
1749
1811
  });
1750
1812
  cliWorkspace = composerCliWorkspaceForFlowDir(root, flowDirForCli);
1751
1813
  }
@@ -1837,10 +1899,9 @@ finishedAt: "${new Date().toISOString()}"
1837
1899
  onStreamEvent({ type: "status", line: t("composer.analyzing_task") });
1838
1900
  log.debug(`[ui] composer-agent: flowId=${flowId || "(none)"} model=${model || "default"} promptLen=${finalPrompt.length}`);
1839
1901
 
1840
- const hasPhaseContext = payload.phaseContext && typeof payload.phaseContext === "object" && typeof payload.phaseContext.phaseIndex === "number";
1841
1902
  let useMultiStep;
1842
1903
  try {
1843
- useMultiStep = hasPhaseContext || ((await shouldUseMultiStep({ flowYamlAbs, userPrompt: prompt.trim(), cliWorkspace })) && !payload.singleStep);
1904
+ useMultiStep = hasPhaseContext && !payload.singleStep;
1844
1905
  } catch (classifyErr) {
1845
1906
  log.debug(`[ui] composer classify error: ${classifyErr.message}`);
1846
1907
  logComposerEvent(composerLogPath, "composer-done", {
@@ -1937,7 +1998,8 @@ finishedAt: "${new Date().toISOString()}"
1937
1998
  endSafe();
1938
1999
  return;
1939
2000
  }
1940
- if (flowYamlAbs && flowContextForMultiStep) {
2001
+ const flowYamlChanged = flowYamlChangedSince(flowYamlAbs, flowYamlBefore);
2002
+ if (flowYamlChanged && flowYamlAbs && flowContextForMultiStep) {
1941
2003
  try {
1942
2004
  await runComposerPostFlowValidationAndRepair({
1943
2005
  uiWorkspaceRoot: root,
@@ -1966,7 +2028,7 @@ finishedAt: "${new Date().toISOString()}"
1966
2028
  flowId: flowId || null,
1967
2029
  flowSource: flowSource || null,
1968
2030
  });
1969
- if (flowId && flowSource) {
2031
+ if (flowYamlChanged && flowId && flowSource) {
1970
2032
  broadcastFlowEditorSync(flowId, flowSource, Boolean(payload.flowArchived));
1971
2033
  }
1972
2034
  try { res.write(JSON.stringify({ type: "done" }) + "\n"); } catch (_) {}
@@ -11,6 +11,7 @@ import { fileURLToPath } from "url";
11
11
 
12
12
  import { getRunDir, PIPELINES_DIR } from "../lib/paths.mjs";
13
13
  import { getFlowDir } from "../lib/workspace.mjs";
14
+ import { isMarketplaceDefinitionId, resolveMarketplaceNodePackage } from "../lib/marketplace.mjs";
14
15
  import { loadFlowDefinition } from "./parse-flow.mjs";
15
16
  import { getResolvedValues, getOutputPathForSlot } from "./get-resolved-values.mjs";
16
17
  import { loadExecId } from "./get-exec-id.mjs";
@@ -22,7 +23,8 @@ function shellQuote(s) {
22
23
  }
23
24
 
24
25
  function resolvePlaceholder(k, resolvedInputs, resolvedOutputs, opts) {
25
- const { instanceId, currentExecId, runDir } = opts;
26
+ const { instanceId, currentExecId, runDir, extra = {} } = opts;
27
+ if (Object.prototype.hasOwnProperty.call(extra, k)) return extra[k];
26
28
  const execId = currentExecId ?? 1;
27
29
  const toAbs = (rel) => (runDir && rel ? path.join(runDir, rel) : rel);
28
30
  if (k.startsWith("input.")) {
@@ -76,6 +78,39 @@ function resolveScriptCommand(
76
78
  });
77
79
  }
78
80
 
81
+ function marketplaceRuntimeCommand(marketplaceNode, resolvedInputs, resolvedOutputs, opts) {
82
+ if (!marketplaceNode || !marketplaceNode.runtime) return "";
83
+ const runtime = marketplaceNode.runtime;
84
+ const packageDir = marketplaceNode.packageDir;
85
+ const entry = runtime.entry ? path.join(packageDir, String(runtime.entry)) : "";
86
+ const language = String(runtime.language || "").trim();
87
+ const runner =
88
+ language === "python"
89
+ ? "python3"
90
+ : language === "bash"
91
+ ? "bash"
92
+ : language === "nodejs" || entry
93
+ ? "node"
94
+ : "";
95
+ const args = Array.isArray(runtime.args) ? runtime.args.map((x) => String(x)).join(" ") : "";
96
+ const command = runtime.command
97
+ ? String(runtime.command)
98
+ : entry
99
+ ? [runner, "${entry}", args].filter(Boolean).join(" ")
100
+ : "";
101
+ if (!command) return "";
102
+ return resolveScriptCommand(command, resolvedInputs, resolvedOutputs, {
103
+ ...opts,
104
+ extra: {
105
+ ...(opts.extra || {}),
106
+ entry,
107
+ packageDir,
108
+ workspaceRoot: opts.workspaceRoot || "",
109
+ runDir: opts.runDir || "",
110
+ },
111
+ });
112
+ }
113
+
79
114
  /**
80
115
  * 执行占位符替换,组装 prompt 并写入 intermediate 文件(文件名带 _execId)。
81
116
  * @param {number} [execId] - 本轮 execId,缺省则从 memory 读取
@@ -105,6 +140,10 @@ export function buildNodePrompt(workspaceRoot, flowName, uuid, instanceId, execI
105
140
 
106
141
  const flowData = loadFlowDefinition(flowDir);
107
142
  const inst = flowData?.instances?.[instanceId];
143
+ const marketplaceNode =
144
+ inst?.definitionId && isMarketplaceDefinitionId(inst.definitionId)
145
+ ? resolveMarketplaceNodePackage(workspaceRoot, flowDir, inst.definitionId, flowData)
146
+ : null;
108
147
  const instanceBody =
109
148
  inst?.body != null
110
149
  ? String(inst.body || "").trim()
@@ -115,7 +154,7 @@ export function buildNodePrompt(workspaceRoot, flowName, uuid, instanceId, execI
115
154
  : "";
116
155
 
117
156
  const { resolvedInputs = {}, resolvedOutputs = {}, systemPrompt = "" } = data;
118
- const resolveOpts = { instanceId, currentExecId: e, runDir };
157
+ const resolveOpts = { instanceId, currentExecId: e, runDir, workspaceRoot };
119
158
  const taskBody = resolvePlaceholdersInText(
120
159
  instanceBody,
121
160
  resolvedInputs,
@@ -125,6 +164,8 @@ export function buildNodePrompt(workspaceRoot, flowName, uuid, instanceId, execI
125
164
 
126
165
  const resolvedScript = instanceScript
127
166
  ? resolveScriptCommand(instanceScript, resolvedInputs, resolvedOutputs, resolveOpts)
167
+ : marketplaceNode
168
+ ? marketplaceRuntimeCommand(marketplaceNode, resolvedInputs, resolvedOutputs, resolveOpts)
128
169
  : "";
129
170
 
130
171
  const content = `## 节点上下文
@@ -248,7 +248,7 @@ function main() {
248
248
  for (const id of pendingInstances) {
249
249
  if (instanceStatus[id] !== "success") continue;
250
250
  nextPending.delete(id);
251
- if (nodeDefinitions[id] === "control_if" || nodeDefinitions[id] === "tool_user_ask") {
251
+ if (nodeDefinitions[id] === "control_if" || nodeDefinitions[id] === "tool_user_ask" || nodeDefinitions[id] === "control_interval_loop") {
252
252
  const predLatestE = latestResultExecId(execIdMap[id] ?? 1);
253
253
  const resultPath = predLatestE
254
254
  ? path.join(intermediateDir, id, intermediateResultBasename(id, predLatestE))
@@ -309,7 +309,7 @@ function main() {
309
309
  /** 判断 predecessor P 对 target N 是否算「就绪」:普通节点看 status;control_if / tool_user_ask 看 branch 与出边 sourceHandle */
310
310
  const isPredecessorReadyFor = (predSource, predDefId, targetId) => {
311
311
  if (instanceStatus[predSource] !== "success") return false;
312
- if (predDefId !== "control_if" && predDefId !== "tool_user_ask") return true;
312
+ if (predDefId !== "control_if" && predDefId !== "tool_user_ask" && predDefId !== "control_interval_loop") return true;
313
313
  const inEdges = inEdgesByTarget[targetId] || [];
314
314
  const edge = inEdges.find((ie) => ie.source === predSource);
315
315
  if (!edge) return true;