@fieldwangai/agentflow 0.1.60 → 0.1.62

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.
@@ -10,8 +10,10 @@ import http from "http";
10
10
  import os from "os";
11
11
  import path from "path";
12
12
  import crypto from "crypto";
13
+ import { pathToFileURL } from "url";
13
14
  import { execFile, spawn } from "child_process";
14
15
  import busboy from "busboy";
16
+ import sharp from "sharp";
15
17
  import { log } from "./log.mjs";
16
18
  import {
17
19
  getFlowYamlAbs,
@@ -118,6 +120,25 @@ const MIME = {
118
120
  ".ico": "image/x-icon",
119
121
  };
120
122
 
123
+ function execFileBuffered(command, args, options = {}) {
124
+ return new Promise((resolve, reject) => {
125
+ execFile(command, args, {
126
+ timeout: Number(options.timeout || 30000),
127
+ maxBuffer: Number(options.maxBuffer || 2 * 1024 * 1024),
128
+ cwd: options.cwd || process.cwd(),
129
+ env: options.env || process.env,
130
+ }, (error, stdout, stderr) => {
131
+ if (error) {
132
+ error.stdout = stdout;
133
+ error.stderr = stderr;
134
+ reject(error);
135
+ return;
136
+ }
137
+ resolve({ stdout, stderr });
138
+ });
139
+ });
140
+ }
141
+
121
142
  const RUN_CONFIG_FILENAME = "run-config.json";
122
143
  const SKILL_COLLECTIONS_FILENAME = "skill-collections.json";
123
144
  const BUILTIN_SKILL_COLLECTIONS = [
@@ -1184,6 +1205,113 @@ function workspaceDownloadContentDisposition(relPath) {
1184
1205
  return `attachment; filename="${quotedName}"; filename*=UTF-8''${encodeURIComponent(fallbackName)}`;
1185
1206
  }
1186
1207
 
1208
+ function htmlEscapeAttribute(value) {
1209
+ return String(value || "")
1210
+ .replace(/&/g, "&")
1211
+ .replace(/"/g, """)
1212
+ .replace(/</g, "&lt;")
1213
+ .replace(/>/g, "&gt;");
1214
+ }
1215
+
1216
+ function fileUrlFromPath(absPath) {
1217
+ return pathToFileURL(path.resolve(absPath)).href;
1218
+ }
1219
+
1220
+ function injectHtmlBaseHref(html, baseHref) {
1221
+ const raw = String(html || "");
1222
+ const base = `<base href="${htmlEscapeAttribute(baseHref)}">`;
1223
+ if (/<base\b/i.test(raw)) return raw;
1224
+ if (/<head\b[^>]*>/i.test(raw)) return raw.replace(/<head\b([^>]*)>/i, `<head$1>${base}`);
1225
+ if (/<html\b[^>]*>/i.test(raw)) return raw.replace(/<html\b([^>]*)>/i, `<html$1><head>${base}</head>`);
1226
+ return `<!doctype html><html><head>${base}</head><body>${raw}</body></html>`;
1227
+ }
1228
+
1229
+ function chromeScreenshotCandidates() {
1230
+ const candidates = [];
1231
+ if (process.env.AGENTFLOW_CHROME_PATH) candidates.push(process.env.AGENTFLOW_CHROME_PATH);
1232
+ if (process.platform === "darwin") {
1233
+ candidates.push(
1234
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
1235
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
1236
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
1237
+ );
1238
+ } else if (process.platform === "win32") {
1239
+ candidates.push(
1240
+ path.join(process.env.PROGRAMFILES || "C:\\Program Files", "Google", "Chrome", "Application", "chrome.exe"),
1241
+ path.join(process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)", "Google", "Chrome", "Application", "chrome.exe"),
1242
+ path.join(process.env.LOCALAPPDATA || "", "Google", "Chrome", "Application", "chrome.exe"),
1243
+ );
1244
+ }
1245
+ candidates.push("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome", "msedge");
1246
+ return candidates.filter(Boolean);
1247
+ }
1248
+
1249
+ async function renderHtmlScreenshotWithChrome({ html, workspaceRoot, baseDir, width, height }) {
1250
+ const w = Math.max(240, Math.min(4096, Math.round(Number(width) || 390)));
1251
+ const h = Math.max(240, Math.min(12000, Math.round(Number(height) || 844)));
1252
+ const debug = {
1253
+ requestedWidth: Number(width) || null,
1254
+ requestedHeight: Number(height) || null,
1255
+ viewportWidth: w,
1256
+ viewportHeight: h,
1257
+ htmlChars: String(html || "").length,
1258
+ baseDir: path.resolve(baseDir || workspaceRoot),
1259
+ tried: [],
1260
+ usedCommand: "",
1261
+ pngBytes: 0,
1262
+ pngWidth: null,
1263
+ pngHeight: null,
1264
+ };
1265
+ const tmpDir = path.join(path.resolve(workspaceRoot), ".workspace", "agentflow", "tmp", `html-screenshot-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`);
1266
+ fs.mkdirSync(tmpDir, { recursive: true });
1267
+ const htmlPath = path.join(tmpDir, "snapshot.html");
1268
+ const pngPath = path.join(tmpDir, "snapshot.png");
1269
+ const baseHref = `${fileUrlFromPath(baseDir || workspaceRoot).replace(/\/?$/, "/")}`;
1270
+ fs.writeFileSync(htmlPath, injectHtmlBaseHref(html, baseHref), "utf-8");
1271
+ const args = [
1272
+ "--headless=new",
1273
+ "--disable-gpu",
1274
+ "--no-sandbox",
1275
+ "--disable-dev-shm-usage",
1276
+ "--no-first-run",
1277
+ "--no-default-browser-check",
1278
+ "--allow-file-access-from-files",
1279
+ "--hide-scrollbars",
1280
+ "--force-device-scale-factor=1",
1281
+ `--window-size=${w},${h}`,
1282
+ `--screenshot=${pngPath}`,
1283
+ fileUrlFromPath(htmlPath),
1284
+ ];
1285
+ let lastError = null;
1286
+ try {
1287
+ for (const command of chromeScreenshotCandidates()) {
1288
+ if (path.isAbsolute(command) && !fs.existsSync(command)) continue;
1289
+ debug.tried.push(command);
1290
+ try {
1291
+ await execFileBuffered(command, args, { timeout: 45000, cwd: workspaceRoot });
1292
+ if (fs.existsSync(pngPath) && fs.statSync(pngPath).size > 0) {
1293
+ const png = fs.readFileSync(pngPath);
1294
+ debug.usedCommand = command;
1295
+ debug.pngBytes = png.length;
1296
+ try {
1297
+ const meta = await sharp(png).metadata();
1298
+ debug.pngWidth = meta.width || null;
1299
+ debug.pngHeight = meta.height || null;
1300
+ } catch (_) {}
1301
+ return { png, debug };
1302
+ }
1303
+ lastError = new Error(`${command} did not produce a screenshot`);
1304
+ } catch (error) {
1305
+ lastError = error;
1306
+ debug.lastError = String(error?.message || error);
1307
+ }
1308
+ }
1309
+ throw new Error(`无法使用 Chrome 生成截图${lastError?.message ? `:${lastError.message}` : ""}`);
1310
+ } finally {
1311
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
1312
+ }
1313
+ }
1314
+
1187
1315
  const WORKSPACE_FILE_SKIP_REL_PREFIXES = [
1188
1316
  ".workspace/agentflow/worktrees",
1189
1317
  ".workspace/agentflow/git-repos",
@@ -1351,12 +1479,22 @@ function publicDisplayPayloadFromShare(root, share) {
1351
1479
  const nodes = nodeIds.map((id) => {
1352
1480
  const instance = instances[id] || {};
1353
1481
  const definitionId = String(instance.definitionId || "");
1482
+ const kind = workspaceDisplayKind(definitionId);
1483
+ const rawBody = String(instance.body || "");
1484
+ const filePath = workspaceDisplayTextFilePath(rawBody, kind);
1485
+ let body = rawBody;
1486
+ if (filePath) {
1487
+ const resolved = resolveWorkspaceFilePath(scoped.root, filePath);
1488
+ if (resolved.rel && fs.existsSync(resolved.abs) && fs.statSync(resolved.abs).isFile()) {
1489
+ body = fs.readFileSync(resolved.abs, "utf-8");
1490
+ }
1491
+ }
1354
1492
  return {
1355
1493
  id,
1356
1494
  definitionId,
1357
- kind: workspaceDisplayKind(definitionId),
1495
+ kind,
1358
1496
  label: String(instance.label || instance.displayName || id),
1359
- body: String(instance.body || ""),
1497
+ body,
1360
1498
  inputs: Array.isArray(instance.input) ? instance.input : [],
1361
1499
  outputs: Array.isArray(instance.output) ? instance.output : [],
1362
1500
  size: displayPageSizes[id] || workspaceSizes[id] || null,
@@ -1532,6 +1670,9 @@ function buildWorkspaceNodeChatPrompt(payload) {
1532
1670
  const userMessage = String(payload?.message || "").trim();
1533
1671
  const currentContent = String(payload?.currentContent || "").trim();
1534
1672
  const nodeKind = String(payload?.nodeKind || payload?.kind || "markdown").trim().toLowerCase();
1673
+ const sourceContext = String(payload?.sourceContext || "").trim();
1674
+ const targetFilePath = String(payload?.targetFilePath || "").trim();
1675
+ const directFileEdit = Boolean(targetFilePath);
1535
1676
  const history = Array.isArray(payload?.messages) ? payload.messages : [];
1536
1677
  const historyBlock = history
1537
1678
  .slice(-8)
@@ -1542,8 +1683,14 @@ function buildWorkspaceNodeChatPrompt(payload) {
1542
1683
  })
1543
1684
  .filter(Boolean)
1544
1685
  .join("\n\n");
1545
- const outputRule =
1546
- nodeKind === "html"
1686
+ const outputRule = directFileEdit
1687
+ ? [
1688
+ `直接修改当前 workspace 内的文件:${targetFilePath}`,
1689
+ "必须使用可用的文件编辑工具实际写入该文件;不要只描述改法。",
1690
+ "不要把完整文件内容输出到聊天回复。",
1691
+ "完成后只输出一句简短中文确认;如果无法完成,只输出原因,且说明文件未修改。",
1692
+ ].join("\n")
1693
+ : nodeKind === "html"
1547
1694
  ? "只输出完整或片段 HTML,不要解释,不要包裹 Markdown 代码围栏。"
1548
1695
  : nodeKind === "image"
1549
1696
  ? "只输出新的图片 src,可以是 URL、data URL 或文件路径,不要解释。"
@@ -1554,7 +1701,10 @@ function buildWorkspaceNodeChatPrompt(payload) {
1554
1701
  : "只输出新的 Markdown 正文,不要解释,不要包裹 Markdown 代码围栏。";
1555
1702
  return [
1556
1703
  "你正在微调 AgentFlow Workspace 画布中的单个展示节点。",
1557
- "根据用户 follow-up 和当前节点内容,生成一个可直接替换当前节点展示内容的候选版本。",
1704
+ directFileEdit
1705
+ ? "根据用户 follow-up 直接编辑该展示节点引用的 artifact 文件。"
1706
+ : "根据用户 follow-up 和当前节点内容,生成一个可直接替换当前节点展示内容的候选版本。",
1707
+ "上下文只来自当前展示内容、直接上游节点任务和本节点对话历史;不要引用或复述 thinking、运行日志、下游展示内容。",
1558
1708
  outputRule,
1559
1709
  "",
1560
1710
  "## 当前节点",
@@ -1562,7 +1712,9 @@ function buildWorkspaceNodeChatPrompt(payload) {
1562
1712
  `- label: ${String(node.label || "").trim() || "(unnamed)"}`,
1563
1713
  `- definitionId: ${String(node.definitionId || "").trim() || "(unknown)"}`,
1564
1714
  `- kind: ${nodeKind}`,
1565
- currentContent ? `\n## 当前展示内容\n\n${currentContent}` : "",
1715
+ targetFilePath ? `- artifactFile: ${targetFilePath}` : "",
1716
+ sourceContext ? `\n## 生成该展示的直接上游上下文(不含 thinking/log)\n\n${sourceContext}` : "",
1717
+ !directFileEdit && currentContent ? `\n## 当前展示内容\n\n${currentContent}` : "",
1566
1718
  historyBlock ? `\n## 本节点对话历史\n\n${historyBlock}` : "",
1567
1719
  `\n## 用户 follow-up\n\n${userMessage}`,
1568
1720
  ].filter(Boolean).join("\n");
@@ -1598,9 +1750,16 @@ function workspaceSourceSlotForEdge(graph, edge) {
1598
1750
  return output[workspaceHandleIndex(edge?.sourceHandle, "output")] || null;
1599
1751
  }
1600
1752
 
1753
+ function isWorkspaceSemanticOutputSlot(slot) {
1754
+ const name = String(slot?.name || "");
1755
+ const type = String(slot?.type || "");
1756
+ return type === "node" || name === "prev" || name === "next";
1757
+ }
1758
+
1601
1759
  function workspaceOutputSlotValueForEdge(graph, outputs, edge) {
1602
1760
  const sourceId = String(edge?.source || "");
1603
1761
  const slot = workspaceSourceSlotForEdge(graph, edge);
1762
+ if (isWorkspaceSemanticOutputSlot(slot)) return "";
1604
1763
  const out = outputs.get(sourceId);
1605
1764
  const sourceIndex = workspaceHandleIndex(edge?.sourceHandle, "output");
1606
1765
  const slotName = String(slot?.name || "").trim();
@@ -1789,8 +1948,98 @@ function workspaceExtractLooseResult(raw) {
1789
1948
  return "";
1790
1949
  }
1791
1950
 
1792
- function workspaceStructuredAgentOutput(content) {
1951
+ function workspaceExtractAgentflowEnvelope(raw) {
1952
+ const text = String(raw || "");
1953
+ const match = text.match(/(?:^|\n)---agentflow\s*\n([\s\S]*?)\n---end(?:\n|$)/i);
1954
+ if (!match) return null;
1955
+ const envelope = match[1] || "";
1956
+ const outside = `${text.slice(0, match.index || 0)}\n${text.slice((match.index || 0) + match[0].length)}`.trim();
1957
+ const lines = envelope.replace(/\r\n/g, "\n").split("\n");
1958
+ const outParams = {};
1959
+ let result = "";
1960
+ let resultFile = "";
1961
+
1962
+ const lineIndent = (line) => {
1963
+ const m = String(line || "").match(/^(\s*)/);
1964
+ return m ? m[1].length : 0;
1965
+ };
1966
+ const cleanScalar = (value) => String(value || "").trim().replace(/^["']|["']$/g, "");
1967
+ const collectBlock = (startIndex, baseIndent) => {
1968
+ const collected = [];
1969
+ let i = startIndex;
1970
+ for (; i < lines.length; i += 1) {
1971
+ const line = lines[i] || "";
1972
+ if (line.trim() && lineIndent(line) <= baseIndent) break;
1973
+ collected.push(line.slice(Math.min(line.length, baseIndent + 2)));
1974
+ }
1975
+ return { value: collected.join("\n").replace(/\s+$/g, ""), nextIndex: i };
1976
+ };
1977
+ const parseValue = (rawValue, currentIndex, baseIndent) => {
1978
+ const value = String(rawValue || "").trim();
1979
+ if (value === "|" || value === ">") return collectBlock(currentIndex + 1, baseIndent);
1980
+ return { value: cleanScalar(value), nextIndex: currentIndex + 1 };
1981
+ };
1982
+
1983
+ for (let i = 0; i < lines.length;) {
1984
+ const line = lines[i] || "";
1985
+ if (!line.trim() || /^\s*#/.test(line)) {
1986
+ i += 1;
1987
+ continue;
1988
+ }
1989
+ const top = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
1990
+ if (!top) {
1991
+ i += 1;
1992
+ continue;
1993
+ }
1994
+ const key = top[1];
1995
+ const rawValue = top[2] || "";
1996
+ if (key === "outParams") {
1997
+ i += 1;
1998
+ while (i < lines.length) {
1999
+ const childLine = lines[i] || "";
2000
+ if (!childLine.trim()) {
2001
+ i += 1;
2002
+ continue;
2003
+ }
2004
+ if (lineIndent(childLine) === 0) break;
2005
+ const child = childLine.match(/^\s+([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
2006
+ if (!child) {
2007
+ i += 1;
2008
+ continue;
2009
+ }
2010
+ const childKey = child[1];
2011
+ const parsed = parseValue(child[2] || "", i, lineIndent(childLine));
2012
+ if (childKey) outParams[childKey] = String(parsed.value || "").trim();
2013
+ i = parsed.nextIndex;
2014
+ }
2015
+ continue;
2016
+ }
2017
+ const parsed = parseValue(rawValue, i, 0);
2018
+ if (key === "result") result = String(parsed.value || "");
2019
+ else if (key === "resultFile") resultFile = String(parsed.value || "").trim();
2020
+ else if (key) outParams[key] = String(parsed.value || "").trim();
2021
+ i = parsed.nextIndex;
2022
+ }
2023
+ return {
2024
+ result: resultFile || result || outside,
2025
+ resultFile,
2026
+ outParams,
2027
+ structured: true,
2028
+ parsed: { result, resultFile, outParams },
2029
+ };
2030
+ }
2031
+
2032
+ function workspaceCanonicalAgentOutput(content) {
1793
2033
  const raw = String(content || "").trim();
2034
+ const match = raw.match(/(?:^|\n)(---agentflow\s*\n[\s\S]*?\n---end)(?:\n|$)/i);
2035
+ if (match?.[1]) return match[1].trim();
2036
+ return raw;
2037
+ }
2038
+
2039
+ function workspaceStructuredAgentOutput(content) {
2040
+ const raw = workspaceCanonicalAgentOutput(content);
2041
+ const agentflowEnvelope = workspaceExtractAgentflowEnvelope(raw);
2042
+ if (agentflowEnvelope) return agentflowEnvelope;
1794
2043
  const parsed = workspaceParseJsonObjectFromText(raw);
1795
2044
  if (!parsed) {
1796
2045
  const looseResult = workspaceExtractLooseResult(raw);
@@ -1816,8 +2065,10 @@ function workspaceStructuredAgentOutput(content) {
1816
2065
  const name = String(key || "").trim();
1817
2066
  if (name) outParams[name] = workspaceStringifyOutputValue(value);
1818
2067
  }
2068
+ const resultFile = workspaceStringifyOutputValue(parsed.resultFile ?? "").trim();
1819
2069
  return {
1820
- result: workspaceStringifyOutputValue(parsed.result ?? ""),
2070
+ result: resultFile || workspaceStringifyOutputValue(parsed.result ?? ""),
2071
+ resultFile,
1821
2072
  outParams,
1822
2073
  structured: true,
1823
2074
  parsed,
@@ -1831,6 +2082,10 @@ function workspaceExtractNamedOutputValue(content, slotName) {
1831
2082
  if (Object.prototype.hasOwnProperty.call(structured.outParams, name)) {
1832
2083
  return String(structured.outParams[name] ?? "");
1833
2084
  }
2085
+ const fileName = `${name}File`;
2086
+ if (Object.prototype.hasOwnProperty.call(structured.outParams, fileName)) {
2087
+ return String(structured.outParams[fileName] ?? "");
2088
+ }
1834
2089
  const parsed = structured.parsed || workspaceParseJsonObjectFromText(content);
1835
2090
  if (parsed && Object.prototype.hasOwnProperty.call(parsed, name)) {
1836
2091
  const value = parsed[name];
@@ -1869,6 +2124,8 @@ function workspaceApplyAgentOutputSlots(instance, content) {
1869
2124
  value = text;
1870
2125
  } else if (Object.prototype.hasOwnProperty.call(structured.outParams, name)) {
1871
2126
  value = structured.outParams[name];
2127
+ } else if (Object.prototype.hasOwnProperty.call(structured.outParams, `${name}File`)) {
2128
+ value = structured.outParams[`${name}File`];
1872
2129
  } else {
1873
2130
  value = workspaceExtractNamedOutputValue(text, name);
1874
2131
  }
@@ -1918,6 +2175,26 @@ function workspaceDisplayKind(definitionId) {
1918
2175
  return "";
1919
2176
  }
1920
2177
 
2178
+ function workspaceDisplayTextFilePath(value, kind = "") {
2179
+ const text = String(value || "").trim();
2180
+ if (!text || text.length > 260) return "";
2181
+ if (/[\r\n<>]/.test(text)) return "";
2182
+ if (/^(?:https?:|data:|blob:|file:|javascript:|mailto:|tel:)/i.test(text)) return "";
2183
+ const clean = text.replace(/^\/+/, "");
2184
+ if (clean.includes("..") || clean.startsWith(".")) return "";
2185
+ const ext = clean.split("?")[0].split("#")[0].toLowerCase().split(".").pop() || "";
2186
+ const allowedByKind = {
2187
+ html: new Set(["html", "htm"]),
2188
+ markdown: new Set(["md", "markdown", "txt"]),
2189
+ mermaid: new Set(["mmd", "mermaid", "txt"]),
2190
+ ascii: new Set(["txt", "log"]),
2191
+ chart: new Set(["json"]),
2192
+ table: new Set(["json", "csv", "tsv"]),
2193
+ };
2194
+ const allowed = allowedByKind[kind] || new Set(["html", "htm", "md", "markdown", "txt", "json", "csv", "tsv"]);
2195
+ return allowed.has(ext) ? clean : "";
2196
+ }
2197
+
1921
2198
  function workspaceOutputFieldForSlot(slot, index = 0) {
1922
2199
  const name = String(slot?.name || "").trim();
1923
2200
  if (!name || name === "result" || name === "content" || index === 0) return "result";
@@ -1936,10 +2213,10 @@ function workspaceDisplayKindExample(kind, field) {
1936
2213
  }
1937
2214
 
1938
2215
  function workspaceDisplayFieldRule(kind, field, slotName = "") {
1939
- const slotText = field === "result" ? "`result` 字段" : `\`${field}\``;
2216
+ const slotText = field === "result" ? "`result` 或 `resultFile`" : `\`${field}\` 或 \`${field}File\``;
1940
2217
  const prefix = slotName ? `- 输出引脚 \`${slotName}\` 连接了 ${kind} 展示节点:` : `- 下游连接了 ${kind} 展示节点:`;
1941
- if (kind === "html") return `${prefix}将可直接放入 iframe 渲染的 HTML 放在 ${slotText} 中。可以是完整 HTML 文档或 HTML fragment;不要使用 Markdown 代码围栏。`;
1942
- if (kind === "markdown") return `${prefix}将 Markdown 正文放在 ${slotText} 中;除非正文确实需要代码块,否则不要额外包裹代码围栏。`;
2218
+ if (kind === "html") return `${prefix}将可直接放入 iframe 渲染的 HTML 放在 ${slotText} 中;内容较长时优先写入 outputs/*.html 并返回文件路径。不要使用 Markdown 代码围栏。`;
2219
+ if (kind === "markdown") return `${prefix}将 Markdown 正文放在 ${slotText} 中;内容较长时优先写入 outputs/*.md 并返回文件路径。除非正文确实需要代码块,否则不要额外包裹代码围栏。`;
1943
2220
  if (kind === "mermaid") return `${prefix}将 Mermaid 图表代码放在 ${slotText} 中,例如 flowchart/sequenceDiagram;不要使用 Markdown 代码围栏。`;
1944
2221
  if (kind === "ascii") return `${prefix}将纯文本/ASCII 图或表格放在 ${slotText} 中;不要输出 HTML 或 Markdown 装饰。`;
1945
2222
  if (kind === "image") return `${prefix}将可作为 img src 使用的图片地址、data URL 或 base64 data URL 放在 ${slotText} 中;不要输出 Markdown 图片语法。`;
@@ -1956,11 +2233,13 @@ function workspaceDownstreamOutputDisplayBindings(graph, nodeId) {
1956
2233
  const bindings = [];
1957
2234
  for (const edge of edges) {
1958
2235
  if (String(edge?.source || "") !== String(nodeId)) continue;
2236
+ if (isWorkspaceSemanticInputSlot(workspaceTargetSlotForEdge(graph, edge))) continue;
1959
2237
  const target = instances[String(edge?.target || "")];
1960
2238
  const kind = workspaceDisplayKind(target?.definitionId);
1961
2239
  if (!kind) continue;
1962
2240
  const index = workspaceHandleIndex(edge?.sourceHandle, "output");
1963
2241
  const slot = output[index] || null;
2242
+ if (isWorkspaceSemanticOutputSlot(slot)) continue;
1964
2243
  const name = String(slot?.name || "").trim() || (index === 0 ? "result" : `output-${index}`);
1965
2244
  bindings.push({
1966
2245
  kind,
@@ -2040,7 +2319,7 @@ function workspaceNodeScopeGuardrails(graph, nodeId, inputValues = {}) {
2040
2319
  upstreamNodeIds.length ? `当前直接上游节点:${upstreamNodeIds.map((id) => `\`${id}\``).join("、")}。` : "当前没有直接业务上游节点。",
2041
2320
  inputNames.length ? `当前已解析输入槽:${inputNames.map((name) => `\`${name}\``).join("、")}。` : "当前没有已解析的具名业务输入槽。",
2042
2321
  "不要为了理解本节点而读取或搜索整张 workspace、`workspace.graph.json`、正式 `flow.yaml`、历史 run/log 或其它未连接节点。",
2043
- "不要把下游展示节点已有内容、下游错误信息、其它分支节点内容当作本节点输入;下游输出要求只用于决定最终 JSON 的字段和格式。",
2322
+ "不要把下游展示节点已有内容、下游错误信息、其它分支节点内容当作本节点输入;下游输出要求只用于决定输出字段和格式。",
2044
2323
  "只有当当前节点任务文本或上游输入明确要求读取/修改 `workspace.graph.json`、`flow.yaml` 或某个具体文件路径时,才可以打开对应文件。",
2045
2324
  "如果完成任务所需信息不在当前节点任务、输入槽或上游上下文中,应明确说明缺少哪个上游输入,而不是扫描整张 workspace 猜测。",
2046
2325
  ].join("\n");
@@ -2068,23 +2347,32 @@ function workspaceOutputProtocolRequirements(graph, nodeId) {
2068
2347
  const slotDisplayRules = displayBindings
2069
2348
  .map((binding) => `- 输出引脚 \`${binding.name}\` -> ${binding.kind} 展示节点:写入 \`${binding.field}\`。`)
2070
2349
  .filter((line, index, arr) => arr.indexOf(line) === index);
2350
+ const envelopeExample = [
2351
+ "---agentflow",
2352
+ "resultFile: outputs/result.html",
2353
+ slots.length ? "outParams:" : "",
2354
+ ...slots.slice(0, 3).map((name) => ` ${name}: <${name} 的短值>`),
2355
+ "---end",
2356
+ ].filter(Boolean).join("\n");
2071
2357
  return [
2072
2358
  "## Workspace 输出协议",
2073
2359
  "",
2074
- "最终回复必须是一个 JSON 对象,必须直接以 `{` 开头并以 `}` 结尾;不要使用 Markdown 代码围栏,不要在 JSON 外追加解释文字、进度说明或自然语言前后缀。",
2075
- "执行过程中不要发送 assistant 进度说明,例如“正在读取/正在生成/准备输出”;需要思考时使用内部 thinking,最终只发送一个 JSON 对象。",
2076
- "JSON 必须可被 `JSON.parse` 解析;如果 `result` 是多行 Markdown,必须在 JSON 字符串里使用 `\\n` 转义换行,不能把裸 Markdown 直接塞进未转义的字符串。",
2077
- "固定格式:",
2360
+ "如果只有一个默认输出,直接输出正文即可,系统会写入 `result` / `content` 输出口。",
2361
+ "如果需要多个输出,或需要返回文件路径,最后输出一个轻量 envelope;不要把大段 HTML/Markdown/SQL 包进 JSON 字符串。",
2362
+ "长内容、HTML、Markdown、SQL、CSV、图片等 artifact 优先写入当前 workspace 的 `outputs/` 目录,然后在 `resultFile` `outParams.<name>File` 返回相对路径。",
2363
+ "普通 assistant 文本会被当作本节点输出内容;执行过程中不要发送进度说明、解释或寒暄,例如“正在读取/正在生成/准备输出”。需要思考时只使用内部 thinking,最终只发送正文或 envelope。",
2364
+ "envelope 格式:",
2078
2365
  "",
2079
- JSON.stringify({ result: resultExample, outParams: outParamsExample }, null, 2),
2366
+ envelopeExample,
2080
2367
  "",
2081
- "- `result`:完整正文,写入 `result` / `content` 输出口,直连默认展示节点时展示它。",
2082
- "- `outParams`:具名输出参数,只写入同名输出引脚。",
2368
+ "- `result`:默认输出正文;`resultFile`:默认输出对应的 workspace 相对文件路径。",
2369
+ "- `outParams`:具名输出参数;`outParams.<name>File`:具名输出对应的 workspace 相对文件路径。",
2370
+ "- 旧格式 `{ \"result\": ..., \"outParams\": ... }` 仍兼容,但新输出优先使用正文或 envelope。",
2083
2371
  ...(slotDisplayRules.length ? ["- 当前输出引脚与展示节点映射:", ...slotDisplayRules] : []),
2084
2372
  ...(slots.length
2085
- ? [`- 当前节点具名输出槽:${slots.map((name) => `\`${name}\``).join("、")}。例如任务要求写入 \`${slots[0]}\` 时,放到 \`outParams.${slots[0]}\`。`]
2086
- : ["- 当前节点没有额外具名输出槽,`outParams` 返回空对象即可。"]),
2087
- "- 如果 `result` 需要承载表格、ChartSpec、HTML 等结构化内容,可把对象或字符串放入 `result`;系统会把它转换给下游展示节点。",
2373
+ ? [`- 当前节点具名输出槽:${slots.map((name) => `\`${name}\``).join("、")}。例如任务要求写入 \`${slots[0]}\` 时,放到 \`outParams.${slots[0]}\`;如果写成文件,放到 \`outParams.${slots[0]}File\`。`]
2374
+ : ["- 当前节点没有额外具名输出槽;只有默认输出时不需要 envelope。"]),
2375
+ "- 如果 `result` 需要承载表格、ChartSpec 等短结构化内容,可以直接输出对象或正文;系统会转换给下游展示节点。",
2088
2376
  ].join("\n");
2089
2377
  }
2090
2378
 
@@ -2144,8 +2432,9 @@ function workspaceRunPlan(graph, runNodeId) {
2144
2432
 
2145
2433
  function workspaceUpstreamText(graph, nodeId, outputs) {
2146
2434
  const edges = Array.isArray(graph?.edges) ? graph.edges : [];
2147
- const instances = graph?.instances && typeof graph.instances === "object" ? graph.instances : {};
2148
- const incoming = edges.filter((edge) => String(edge?.target || "") === String(nodeId));
2435
+ const incoming = edges
2436
+ .filter((edge) => String(edge?.target || "") === String(nodeId))
2437
+ .filter((edge) => !isWorkspaceSemanticInputSlot(workspaceTargetSlotForEdge(graph, edge)));
2149
2438
  const contentEdge = incoming.find((edge) => String(edge?.targetHandle || "") === "input-1") || incoming[0];
2150
2439
  if (!contentEdge) return "";
2151
2440
  return workspaceOutputSlotValueForEdge(graph, outputs, contentEdge);
@@ -2233,7 +2522,7 @@ function workspaceNodeTmpDirectoryBlock(nodeTmpDir) {
2233
2522
  "- 如果确实需要创建中间文件,只能写入该目录,路径也可通过环境变量 `AGENTFLOW_NODE_TMP_DIR` 获取。",
2234
2523
  "- 不要在 workspace 根目录、业务仓库根目录或当前 cwd 下创建 `temp_*`、`_out.json`、`tmp.html` 等临时产物。",
2235
2524
  "- 不要自行删除该目录或其中的最终待读文件;AgentFlow 会在节点运行结束后统一清理。",
2236
- "- 纯生成类任务应直接在最终 JSON 中返回结果;不要为了输出而写文件、cat 文件再粘贴。",
2525
+ "- 短文本结果可直接输出;长 HTML/Markdown/SQL 等正式 artifact 应写入 workspace 的 `outputs/` 目录并返回相对路径,不要为了输出而写临时文件、cat 文件再粘贴。",
2237
2526
  ].join("\n");
2238
2527
  }
2239
2528
 
@@ -2410,7 +2699,8 @@ function buildWorkspaceMcpManifestBlock(results, servers = [], selectedNames = [
2410
2699
  function workspaceWriteDisplayContent(instance, content) {
2411
2700
  const next = { ...(instance || {}) };
2412
2701
  const kind = workspaceDisplayKind(next.definitionId);
2413
- const text = kind === "html" ? normalizeHtmlDisplayContent(content) : String(content || "");
2702
+ const unwrapped = workspaceUnwrapOutputEnvelopeForDisplay(content);
2703
+ const text = kind === "html" ? normalizeHtmlDisplayContent(unwrapped) : String(unwrapped || "");
2414
2704
  const primaryName = kind === "image" ? "src" : "content";
2415
2705
  next.body = text;
2416
2706
  next.input = (Array.isArray(next.input) ? next.input : []).map((slot) => (
@@ -2426,12 +2716,21 @@ function workspaceWriteDisplayContent(instance, content) {
2426
2716
  return next;
2427
2717
  }
2428
2718
 
2719
+ function workspaceUnwrapOutputEnvelopeForDisplay(content) {
2720
+ const raw = String(content || "").trim();
2721
+ if (!raw) return "";
2722
+ if (!/---agentflow\b|["']result["']\s*:|["']outParams["']\s*:|["']resultFile["']\s*:/i.test(raw)) return raw;
2723
+ const structured = workspaceStructuredAgentOutput(raw);
2724
+ return structured.structured ? String(structured.result || "") : raw;
2725
+ }
2726
+
2429
2727
  function workspaceUpdateDirectDisplays(graph, sourceId, content, outputs = null) {
2430
2728
  const instances = graph?.instances && typeof graph.instances === "object" ? graph.instances : {};
2431
2729
  const edges = Array.isArray(graph?.edges) ? graph.edges : [];
2432
2730
  const updated = [];
2433
2731
  for (const edge of edges) {
2434
2732
  if (String(edge?.source || "") !== String(sourceId)) continue;
2733
+ if (isWorkspaceSemanticInputSlot(workspaceTargetSlotForEdge(graph, edge))) continue;
2435
2734
  const targetId = String(edge?.target || "");
2436
2735
  const target = instances[targetId];
2437
2736
  if (!target || !workspaceDisplayKind(target.definitionId)) continue;
@@ -2969,7 +3268,10 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
2969
3268
  firstAgentEventSeen = true;
2970
3269
  emitTiming(nodeId, "agent-first-event", spawnStartedAt, { attempt, firstType: ev?.type || "" });
2971
3270
  }
2972
- emit({ ...ev, nodeId });
3271
+ const eventToEmit = (ev?.type === "natural" && (ev.kind === "result" || ev.kind === "assistant") && typeof ev.text === "string")
3272
+ ? { ...ev, text: workspaceCanonicalAgentOutput(ev.text), nodeId }
3273
+ : { ...ev, nodeId };
3274
+ emit(eventToEmit);
2973
3275
  if (ev?.type === "natural" && ev.kind === "assistant" && typeof ev.text === "string") {
2974
3276
  attemptContent += (attemptContent ? "\n" : "") + ev.text;
2975
3277
  }
@@ -2988,7 +3290,7 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
2988
3290
  if (typeof opts.onActiveChild === "function") opts.onActiveChild(null);
2989
3291
  }
2990
3292
  throwIfAborted();
2991
- content = attemptContent.trim();
3293
+ content = workspaceCanonicalAgentOutput(attemptContent);
2992
3294
  break;
2993
3295
  } catch (e) {
2994
3296
  if (signal?.aborted || e?.code === "WORKSPACE_RUN_ABORTED") throwIfAborted();
@@ -3589,7 +3891,10 @@ export function startUiServer({
3589
3891
  if (ts === "workspace" || ts === "user") {
3590
3892
  targetSpace = ts;
3591
3893
  }
3592
- const existing = listFlowsJson(root, userCtx);
3894
+ const existing = listFlowsJson(root, {
3895
+ ...userCtx,
3896
+ includeWorkspaceFlows: targetSpace === "workspace",
3897
+ });
3593
3898
  if (
3594
3899
  existing.some(
3595
3900
  (f) => f.id === flowId && (f.source ?? "user") === targetSpace && !f.archived,
@@ -3645,7 +3950,10 @@ export function startUiServer({
3645
3950
  }
3646
3951
  const flowId = idCheck.flowId;
3647
3952
  const targetSpace = parsed.targetSpace === "workspace" ? "workspace" : "user";
3648
- const existing = listFlowsJson(root, userCtx);
3953
+ const existing = listFlowsJson(root, {
3954
+ ...userCtx,
3955
+ includeWorkspaceFlows: targetSpace === "workspace",
3956
+ });
3649
3957
  if (
3650
3958
  existing.some(
3651
3959
  (f) => f.id === flowId && (f.source ?? "user") === targetSpace && !f.archived,
@@ -4134,6 +4442,67 @@ export function startUiServer({
4134
4442
  return;
4135
4443
  }
4136
4444
 
4445
+ if (req.method === "POST" && url.pathname === "/api/workspace/html-screenshot") {
4446
+ let payload;
4447
+ try {
4448
+ payload = JSON.parse(await readBody(req));
4449
+ } catch {
4450
+ json(res, 400, { error: "Invalid JSON body" });
4451
+ return;
4452
+ }
4453
+ try {
4454
+ const scoped = resolveWorkspaceScopeRoot(root, {
4455
+ flowId: payload.flowId || "",
4456
+ flowSource: payload.flowSource || "user",
4457
+ archived: payload.archived === true || payload.flowArchived === true,
4458
+ }, userCtx);
4459
+ if (scoped.error) {
4460
+ json(res, 400, { error: scoped.error });
4461
+ return;
4462
+ }
4463
+ const sourceFilePath = String(payload.sourceFilePath || payload.path || "").trim();
4464
+ let html = String(payload.content || "");
4465
+ let baseDir = scoped.root;
4466
+ if (sourceFilePath) {
4467
+ const { abs } = resolveWorkspaceFilePath(scoped.root, sourceFilePath);
4468
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) {
4469
+ json(res, 404, { error: "HTML file not found" });
4470
+ return;
4471
+ }
4472
+ const stat = fs.statSync(abs);
4473
+ if (stat.size > 5 * 1024 * 1024) {
4474
+ json(res, 413, { error: "HTML file too large" });
4475
+ return;
4476
+ }
4477
+ if (!html.trim()) html = fs.readFileSync(abs, "utf-8");
4478
+ baseDir = path.dirname(abs);
4479
+ }
4480
+ if (!html.trim()) {
4481
+ json(res, 400, { error: "Missing HTML content" });
4482
+ return;
4483
+ }
4484
+ const screenshot = await renderHtmlScreenshotWithChrome({
4485
+ html,
4486
+ workspaceRoot: scoped.root,
4487
+ baseDir,
4488
+ width: payload.width,
4489
+ height: payload.height,
4490
+ });
4491
+ const png = screenshot.png;
4492
+ const filename = sanitizeWorkspaceUploadName(payload.filename || "html-render.png").replace(/\.[^.]+$/i, ".png");
4493
+ res.writeHead(200, {
4494
+ "Content-Type": "image/png",
4495
+ "Content-Length": png.length,
4496
+ "Cache-Control": "no-store",
4497
+ "Content-Disposition": workspaceDownloadContentDisposition(filename),
4498
+ });
4499
+ res.end(png);
4500
+ } catch (e) {
4501
+ json(res, 500, { error: (e && e.message) || String(e) });
4502
+ }
4503
+ return;
4504
+ }
4505
+
4137
4506
  if (req.method === "POST" && url.pathname === "/api/workspace/file") {
4138
4507
  let payload;
4139
4508
  try {
@@ -4401,6 +4770,22 @@ export function startUiServer({
4401
4770
  json(res, 400, { error: scoped.error });
4402
4771
  return;
4403
4772
  }
4773
+ const targetFilePath = String(payload?.targetFilePath || "").trim();
4774
+ let targetFile = null;
4775
+ if (targetFilePath) {
4776
+ if (scoped.archived || isReadonlyBuiltinFlowSource(scoped.flowSource)) {
4777
+ json(res, 400, { error: "Cannot edit builtin or archived pipeline workspace" });
4778
+ return;
4779
+ }
4780
+ targetFile = resolveWorkspaceFilePath(scoped.root, targetFilePath);
4781
+ if (!targetFile.rel) {
4782
+ json(res, 400, { error: "Missing artifact file path" });
4783
+ return;
4784
+ }
4785
+ }
4786
+ const beforeTargetContent = targetFile && fs.existsSync(targetFile.abs) && fs.statSync(targetFile.abs).isFile()
4787
+ ? fs.readFileSync(targetFile.abs, "utf-8")
4788
+ : null;
4404
4789
  const promptText = buildWorkspaceNodeChatPrompt(payload);
4405
4790
  const modelKey = typeof payload?.model === "string" ? payload.model.trim() : "";
4406
4791
  let content = "";
@@ -4419,12 +4804,29 @@ export function startUiServer({
4419
4804
  },
4420
4805
  });
4421
4806
  await handle.finished;
4422
- const candidateContent = content.trim();
4807
+ let candidateContent = targetFile
4808
+ ? (fs.existsSync(targetFile.abs) && fs.statSync(targetFile.abs).isFile()
4809
+ ? fs.readFileSync(targetFile.abs, "utf-8")
4810
+ : "")
4811
+ : content.trim();
4812
+ if (targetFile) {
4813
+ const unwrappedTargetContent = workspaceUnwrapOutputEnvelopeForDisplay(candidateContent);
4814
+ if (unwrappedTargetContent && unwrappedTargetContent !== candidateContent) {
4815
+ fs.writeFileSync(targetFile.abs, unwrappedTargetContent, "utf-8");
4816
+ candidateContent = unwrappedTargetContent;
4817
+ }
4818
+ }
4819
+ if (targetFile && beforeTargetContent != null && candidateContent === beforeTargetContent) {
4820
+ json(res, 500, { error: "Agent 未修改目标展示文件,请换一种更明确的描述后重试。" });
4821
+ return;
4822
+ }
4423
4823
  json(res, 200, {
4424
4824
  ok: true,
4425
4825
  sessionId: String(payload?.sessionId || "") || `nodechat_${Date.now()}`,
4426
- reply: candidateContent,
4826
+ reply: targetFile ? content.trim() : candidateContent,
4427
4827
  candidateContent,
4828
+ directFileEdit: Boolean(targetFile),
4829
+ artifactPath: targetFile?.rel || "",
4428
4830
  events,
4429
4831
  });
4430
4832
  } catch (e) {