@manny-est/node-red-flowpilot 0.2.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -0
- package/PROJECT-OVERVIEW.md +42 -7
- package/README.md +19 -0
- package/USER-GUIDE.md +45 -1
- package/flowpilot-core.css +1189 -0
- package/flowpilot-core.js +6416 -0
- package/flowpilot.html +9 -5679
- package/flowpilot.js +292 -37
- package/lib/build-system-prompt.js +32 -0
- package/lib/default-system-prompt.js +0 -2
- package/lib/generation-system-prompt.js +17 -2
- package/lib/modify-system-prompt.js +16 -6
- package/lib/persona-prompt.js +74 -0
- package/lib/popout/view.html +33 -0
- package/lib/provider-openai-compatible.js +13 -10
- package/lib/storage.js +48 -4
- package/package.json +4 -1
package/flowpilot.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
const http = require("http");
|
|
2
|
+
const path = require("path");
|
|
2
3
|
const createStorage = require("./lib/storage");
|
|
3
4
|
const provider = require("./lib/provider-openai-compatible");
|
|
4
5
|
const generationSystemPrompt = require("./lib/generation-system-prompt");
|
|
5
6
|
const documentSystemPrompt = require("./lib/document-system-prompt");
|
|
6
7
|
const modifySystemPrompt = require("./lib/modify-system-prompt");
|
|
8
|
+
const buildSystemPrompt = require("./lib/build-system-prompt");
|
|
9
|
+
const personaPrompt = require("./lib/persona-prompt");
|
|
7
10
|
|
|
8
11
|
module.exports = function flowPilotRuntime(RED) {
|
|
9
12
|
const storage = createStorage(RED.settings.userDir);
|
|
@@ -360,6 +363,16 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
360
363
|
return installedNodesCache;
|
|
361
364
|
}
|
|
362
365
|
|
|
366
|
+
// Chat-only: the user's base system prompt plus a freshly-generated
|
|
367
|
+
// persona instruction (never baked into the persisted prompt itself, so
|
|
368
|
+
// it always reflects the current personaIntensity slider value).
|
|
369
|
+
// Generate/Document/Modify use their own mode-specific prompts and don't
|
|
370
|
+
// call this — aviation flavor has no place in a structured JSON envelope.
|
|
371
|
+
function buildChatSystemPrompt(settings) {
|
|
372
|
+
const base = settings.systemPrompt || "You are FlowPilot, a Node-RED development assistant.";
|
|
373
|
+
return base + "\n\n" + personaPrompt.buildPersonaInstruction(settings.personaIntensity);
|
|
374
|
+
}
|
|
375
|
+
|
|
363
376
|
// Assemble the final messages array in the one place both /chat and the
|
|
364
377
|
// generate/modify/document endpoints use: system prompt, optional
|
|
365
378
|
// installed-node-package note, optional truncation notice, history,
|
|
@@ -406,7 +419,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
406
419
|
// Returns null when there's no selection — used by both /chat and
|
|
407
420
|
// /generate so the two describe context identically and never drift.
|
|
408
421
|
// ---------------------------------------------------------------------
|
|
409
|
-
function describeSelectionContext(context) {
|
|
422
|
+
function describeSelectionContext(context, redactionEnabled) {
|
|
410
423
|
const nodes = context && Array.isArray(context.nodes) ? context.nodes : [];
|
|
411
424
|
const debugMessages = context && Array.isArray(context.debugMessages) ? context.debugMessages : [];
|
|
412
425
|
if (nodes.length === 0 && debugMessages.length === 0) { return null; }
|
|
@@ -416,10 +429,26 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
416
429
|
const perNode = Array.isArray(connections.perNode) ? connections.perNode : [];
|
|
417
430
|
const subFlowCount = (typeof connections.subFlowCount === "number") ? connections.subFlowCount : 0;
|
|
418
431
|
|
|
432
|
+
// Node-RED's own credential store (config node "credentials" fields) is
|
|
433
|
+
// dropped by the frontend's sanitizer unconditionally — that part never
|
|
434
|
+
// changes. redactionEnabled only controls the SEPARATE secret-shaped-value
|
|
435
|
+
// scrubbing (password/token/apiKey-looking fields elsewhere in a node's
|
|
436
|
+
// config) — tell the model the truth about which protection is active.
|
|
437
|
+
const credentialNote = redactionEnabled === false
|
|
438
|
+
? "Redaction is OFF for this session — context may contain sensitive " +
|
|
439
|
+
"values the user chose to share (e.g. embedded API keys or tokens); " +
|
|
440
|
+
"handle carefully and never volunteer them. Node-RED's separate " +
|
|
441
|
+
"credential store is still never included. This is a setting in the " +
|
|
442
|
+
"editor's FlowPilot Settings panel (Context & Safety section) — you " +
|
|
443
|
+
"have no ability to read, change, or report on it beyond this note; " +
|
|
444
|
+
"if the user wants to turn it back on, tell them to uncheck it there " +
|
|
445
|
+
"(it requires re-confirming a type-to-confirm phrase, by design)."
|
|
446
|
+
: "This is sanitized configuration; credentials are redacted.";
|
|
447
|
+
|
|
419
448
|
let content = "";
|
|
420
449
|
if (nodes.length > 0) {
|
|
421
450
|
content += "The user has selected the following Node-RED nodes as context. " +
|
|
422
|
-
|
|
451
|
+
credentialNote + "\n\n" +
|
|
423
452
|
"Nodes:\n```json\n" + JSON.stringify(nodes) + "\n```";
|
|
424
453
|
if (edges.length > 0) {
|
|
425
454
|
content += "\n\nConnections — directed edges by node id (a node's wires " +
|
|
@@ -464,9 +493,9 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
464
493
|
const settings = storage.getSettings();
|
|
465
494
|
const activeProvider = storage.getActiveProvider(settings);
|
|
466
495
|
|
|
467
|
-
const described = describeSelectionContext(context);
|
|
496
|
+
const described = describeSelectionContext(context, settings.redactionEnabled);
|
|
468
497
|
const messages = buildMessages(
|
|
469
|
-
settings
|
|
498
|
+
buildChatSystemPrompt(settings),
|
|
470
499
|
history, historyTruncated, described, prompt
|
|
471
500
|
);
|
|
472
501
|
|
|
@@ -501,9 +530,9 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
501
530
|
const settings = storage.getSettings();
|
|
502
531
|
const activeProvider = storage.getActiveProvider(settings);
|
|
503
532
|
|
|
504
|
-
const described = describeSelectionContext(context);
|
|
533
|
+
const described = describeSelectionContext(context, settings.redactionEnabled);
|
|
505
534
|
const messages = buildMessages(
|
|
506
|
-
settings
|
|
535
|
+
buildChatSystemPrompt(settings),
|
|
507
536
|
history, historyTruncated, described, prompt
|
|
508
537
|
);
|
|
509
538
|
|
|
@@ -588,6 +617,27 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
588
617
|
}
|
|
589
618
|
});
|
|
590
619
|
|
|
620
|
+
// ---- Pop-out window (Phase 8.5 C1, v1 review-only) -------------------
|
|
621
|
+
// Serves the shared renderer (flowpilot-core.js, the same script
|
|
622
|
+
// flowpilot.html loads for the sidebar) plus its stylesheet and the
|
|
623
|
+
// pop-out's own minimal page — mirroring core Node-RED's debug-node
|
|
624
|
+
// pattern (RED.httpAdmin.get("/debug/view/view.html", ...) serving a
|
|
625
|
+
// static lib/debug/view.html that loads the SAME debug-utils.js the
|
|
626
|
+
// sidebar uses). Gated the same as every other FlowPilot route, unlike
|
|
627
|
+
// NR5's own debug view route, which has no permission check at all.
|
|
628
|
+
|
|
629
|
+
RED.httpAdmin.get("/flowpilot/core.js", RED.auth.needsPermission("settings.read"), function (req, res) {
|
|
630
|
+
res.sendFile(path.join(__dirname, "flowpilot-core.js"));
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
RED.httpAdmin.get("/flowpilot/core.css", RED.auth.needsPermission("settings.read"), function (req, res) {
|
|
634
|
+
res.sendFile(path.join(__dirname, "flowpilot-core.css"));
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
RED.httpAdmin.get("/flowpilot/popout/view.html", RED.auth.needsPermission("settings.read"), function (req, res) {
|
|
638
|
+
res.sendFile(path.join(__dirname, "lib", "popout", "view.html"));
|
|
639
|
+
});
|
|
640
|
+
|
|
591
641
|
// ---- Settings: write -------------------------------------------------
|
|
592
642
|
|
|
593
643
|
RED.httpAdmin.post("/flowpilot/settings", RED.auth.needsPermission("settings.write"), function (req, res) {
|
|
@@ -735,7 +785,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
735
785
|
|
|
736
786
|
if (mode !== "chat") {
|
|
737
787
|
const context = req.body.context;
|
|
738
|
-
const described = describeSelectionContext(context);
|
|
788
|
+
const described = describeSelectionContext(context, settings.redactionEnabled);
|
|
739
789
|
const generated = processGenerationContent(result.content || "", result, messages, mode, described, activeProvider);
|
|
740
790
|
recordTranscriptTurn(req.body.conversationId, mode, req.body.prompt || null, transcriptTextFromGenerationResult(generated));
|
|
741
791
|
const finalize = (mode === "modify")
|
|
@@ -901,6 +951,33 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
901
951
|
// types or wire integrity yet (that's the next chunk) — it returns the parsed
|
|
902
952
|
// envelope so the frontend can display it for review.
|
|
903
953
|
|
|
954
|
+
// Given s[startIdx] === "{", scans forward with brace-depth counting that
|
|
955
|
+
// ignores braces inside string literals (so a value like "{{payload}}"
|
|
956
|
+
// can't be mistaken for structure) to find the index of the MATCHING
|
|
957
|
+
// closing "}". Returns -1 if the braces never balance before the string
|
|
958
|
+
// ends (truncated/malformed input).
|
|
959
|
+
function findMatchingBrace(s, startIdx) {
|
|
960
|
+
let depth = 0;
|
|
961
|
+
let inString = false;
|
|
962
|
+
let escaped = false;
|
|
963
|
+
for (let i = startIdx; i < s.length; i++) {
|
|
964
|
+
const ch = s[i];
|
|
965
|
+
if (inString) {
|
|
966
|
+
if (escaped) { escaped = false; }
|
|
967
|
+
else if (ch === "\\") { escaped = true; }
|
|
968
|
+
else if (ch === "\"") { inString = false; }
|
|
969
|
+
continue;
|
|
970
|
+
}
|
|
971
|
+
if (ch === "\"") { inString = true; }
|
|
972
|
+
else if (ch === "{") { depth++; }
|
|
973
|
+
else if (ch === "}") {
|
|
974
|
+
depth--;
|
|
975
|
+
if (depth === 0) { return i; }
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
return -1;
|
|
979
|
+
}
|
|
980
|
+
|
|
904
981
|
function extractJsonObject(text) {
|
|
905
982
|
if (!text) { throw new Error("Empty response from provider."); }
|
|
906
983
|
let s = String(text).trim();
|
|
@@ -930,10 +1007,8 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
930
1007
|
}
|
|
931
1008
|
}
|
|
932
1009
|
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
const last = s.lastIndexOf("}");
|
|
936
|
-
if (first === -1 || last === -1 || last < first) {
|
|
1010
|
+
const firstObjIdx = s.indexOf("{");
|
|
1011
|
+
if (firstObjIdx === -1) {
|
|
937
1012
|
// No JSON object found at all — flagged separately from a found-
|
|
938
1013
|
// but-unparseable ({...} present, JSON.parse failed) "garbled" error,
|
|
939
1014
|
// so callers can distinguish "model just answered in prose" (tolerate)
|
|
@@ -942,7 +1017,57 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
942
1017
|
err.noJsonFound = true;
|
|
943
1018
|
throw err;
|
|
944
1019
|
}
|
|
945
|
-
|
|
1020
|
+
|
|
1021
|
+
// There may be more than one "{" before the real envelope — e.g. prose
|
|
1022
|
+
// explaining a fix that mentions inline code like "{{payload}}" before
|
|
1023
|
+
// the actual JSON (seen live: a review response started with "The
|
|
1024
|
+
// template node is using `{{payload}}` with...", and slicing from THAT
|
|
1025
|
+
// brace to the envelope's real closing "}" produced unparseable
|
|
1026
|
+
// garbage). Try each candidate "{" in order with string-aware brace
|
|
1027
|
+
// matching (findMatchingBrace, which ignores braces inside quoted
|
|
1028
|
+
// strings) rather than just slicing from the first "{" to the last
|
|
1029
|
+
// "}".
|
|
1030
|
+
//
|
|
1031
|
+
// A candidate must not just PARSE, it must also look like one of the
|
|
1032
|
+
// known envelope shapes (have at least one recognized top-level key) —
|
|
1033
|
+
// seen live: a pure-prose advice response that mentioned structured
|
|
1034
|
+
// logging included the illustrative example
|
|
1035
|
+
// `{"level":"info","event":"trivia_answer","user":"alex","correct":true}`,
|
|
1036
|
+
// which IS valid standalone JSON, so the old "first candidate that
|
|
1037
|
+
// parses wins" rule accepted it as "the envelope" and the caller threw
|
|
1038
|
+
// "no recognizable modify fields" — when the right answer was to treat
|
|
1039
|
+
// the whole reply as prose, since there was no real envelope at all.
|
|
1040
|
+
const ENVELOPE_KEYS = ["explanation", "flow", "question", "changes", "newNodes", "newWires", "removeNodes", "newGroups", "prose"];
|
|
1041
|
+
function looksLikeEnvelope(obj) {
|
|
1042
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) { return false; }
|
|
1043
|
+
return ENVELOPE_KEYS.some(function (k) { return k in obj; });
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
let lastError = null;
|
|
1047
|
+
let searchFrom = firstObjIdx;
|
|
1048
|
+
while (searchFrom !== -1 && searchFrom < s.length) {
|
|
1049
|
+
const end = findMatchingBrace(s, searchFrom);
|
|
1050
|
+
if (end !== -1) {
|
|
1051
|
+
try {
|
|
1052
|
+
const candidate = JSON.parse(s.slice(searchFrom, end + 1));
|
|
1053
|
+
if (looksLikeEnvelope(candidate)) { return candidate; }
|
|
1054
|
+
// Valid JSON, but not envelope-shaped (e.g. an illustrative
|
|
1055
|
+
// example embedded in prose) — keep searching rather than
|
|
1056
|
+
// accepting it.
|
|
1057
|
+
} catch (e) {
|
|
1058
|
+
lastError = e;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
searchFrom = s.indexOf("{", searchFrom + 1);
|
|
1062
|
+
}
|
|
1063
|
+
// No candidate both parsed AND looked like a real envelope — equivalent
|
|
1064
|
+
// to "the model just answered in prose," not "the envelope is broken."
|
|
1065
|
+
// Let callers fall back to rendering this as a normal message instead
|
|
1066
|
+
// of surfacing a parse error (same noJsonFound flag the "no { at all"
|
|
1067
|
+
// branch above uses).
|
|
1068
|
+
const err = lastError || new Error("Provider's JSON object could not be parsed.");
|
|
1069
|
+
err.noJsonFound = true;
|
|
1070
|
+
throw err;
|
|
946
1071
|
}
|
|
947
1072
|
|
|
948
1073
|
// ---------------------------------------------------------------------
|
|
@@ -954,8 +1079,13 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
954
1079
|
function buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated) {
|
|
955
1080
|
const settings = storage.getSettings();
|
|
956
1081
|
const activeProvider = storage.getActiveProvider(settings);
|
|
957
|
-
const described = describeSelectionContext(context);
|
|
958
|
-
|
|
1082
|
+
const described = describeSelectionContext(context, settings.redactionEnabled);
|
|
1083
|
+
// Persona applies to the "explanation" field only (a real hand-off/
|
|
1084
|
+
// transition moment — "here's the flow I built for you") — never to
|
|
1085
|
+
// node names, ids, or any structural JSON, which stays exactly as each
|
|
1086
|
+
// mode's own system prompt above already specifies.
|
|
1087
|
+
const personaInstruction = personaPrompt.buildPersonaInstruction(settings.personaIntensity, { scope: "explanation" });
|
|
1088
|
+
const messages = buildMessages(systemPrompt + "\n\n" + personaInstruction, history, historyTruncated, described, userPrompt);
|
|
959
1089
|
return { activeProvider, described, messages };
|
|
960
1090
|
}
|
|
961
1091
|
|
|
@@ -1180,7 +1310,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1180
1310
|
// otherwise it's not recognizable as a modify response at all.
|
|
1181
1311
|
if (auditAction === "modify") {
|
|
1182
1312
|
const hasModifyShape = ("changes" in parsed) || ("newNodes" in parsed) ||
|
|
1183
|
-
("newWires" in parsed) || ("removeNodes" in parsed) ||
|
|
1313
|
+
("newWires" in parsed) || ("removeNodes" in parsed) || ("newGroups" in parsed) ||
|
|
1184
1314
|
(typeof parsed.explanation === "string" && parsed.explanation.trim());
|
|
1185
1315
|
if (!hasModifyShape) {
|
|
1186
1316
|
const err = new Error("The response did not contain any recognizable modify fields.");
|
|
@@ -1193,6 +1323,14 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1193
1323
|
const newNodes = Array.isArray(parsed.newNodes) ? parsed.newNodes : [];
|
|
1194
1324
|
const newWires = Array.isArray(parsed.newWires) ? parsed.newWires : [];
|
|
1195
1325
|
const removeNodes = Array.isArray(parsed.removeNodes) ? parsed.removeNodes : [];
|
|
1326
|
+
// Bug found live: this object is what finalizeModifyResult later reads
|
|
1327
|
+
// as "result" — but it never copied parsed.newGroups onto itself, so
|
|
1328
|
+
// even a model correctly using the top-level "newGroups" field (per
|
|
1329
|
+
// the prompt) had it silently dropped right here, before
|
|
1330
|
+
// finalizeModifyResult's own newGroups handling (fixed earlier) ever
|
|
1331
|
+
// saw it. Only a stray type:"group" entry inside newNodes survived,
|
|
1332
|
+
// since newNodes itself is copied through.
|
|
1333
|
+
const newGroups = Array.isArray(parsed.newGroups) ? parsed.newGroups : [];
|
|
1196
1334
|
|
|
1197
1335
|
storage.appendAudit(Object.assign({
|
|
1198
1336
|
action: auditAction,
|
|
@@ -1203,6 +1341,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1203
1341
|
newNodeCount: newNodes.length,
|
|
1204
1342
|
newWireCount: newWires.length,
|
|
1205
1343
|
removeNodeCount: removeNodes.length,
|
|
1344
|
+
newGroupCount: newGroups.length,
|
|
1206
1345
|
contextNodeCount: described ? described.nodeCount : 0,
|
|
1207
1346
|
contextConnectionCount: described ? described.connectionCount : 0
|
|
1208
1347
|
}, perf));
|
|
@@ -1212,7 +1351,8 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1212
1351
|
changes: changes,
|
|
1213
1352
|
newNodes: newNodes,
|
|
1214
1353
|
newWires: newWires,
|
|
1215
|
-
removeNodes: removeNodes
|
|
1354
|
+
removeNodes: removeNodes,
|
|
1355
|
+
newGroups: newGroups
|
|
1216
1356
|
};
|
|
1217
1357
|
const modifyAction = extractSuggestedAction(parsed);
|
|
1218
1358
|
if (modifyAction) { modifyResult.suggestedAction = modifyAction; }
|
|
@@ -1347,6 +1487,16 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1347
1487
|
|
|
1348
1488
|
const originalIds = new Set(originalNodes.map(function (n) { return n.id; }));
|
|
1349
1489
|
|
|
1490
|
+
// Group ids the selection is actually inside (sanitizeNode resolves
|
|
1491
|
+
// each context node's group membership into a `group: {id, name}`
|
|
1492
|
+
// field — Phase 8.5 C2). A "changes" patch may target one of THESE
|
|
1493
|
+
// group ids too (e.g. to rename it) even though the group itself
|
|
1494
|
+
// isn't a member of originalIds — the user selected something
|
|
1495
|
+
// relevant to it, same spirit as selecting a node lets you patch it.
|
|
1496
|
+
const contextGroupIds = new Set(
|
|
1497
|
+
originalNodes.map(function (n) { return n.group && n.group.id; }).filter(Boolean)
|
|
1498
|
+
);
|
|
1499
|
+
|
|
1350
1500
|
// Validate removeNodes: all ids must be in the original selection.
|
|
1351
1501
|
const removeNodes = Array.isArray(result.removeNodes) ? result.removeNodes : [];
|
|
1352
1502
|
if (removeNodes.length > 0) {
|
|
@@ -1374,8 +1524,9 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1374
1524
|
.filter(function (id) { return id !== undefined && id !== null; });
|
|
1375
1525
|
|
|
1376
1526
|
// Validate that changes contains no hallucinated ids, and that no id is
|
|
1377
|
-
// both patched and marked for removal.
|
|
1378
|
-
|
|
1527
|
+
// both patched and marked for removal. A group id from contextGroupIds
|
|
1528
|
+
// is allowed here too (see above) even though it's not in originalIds.
|
|
1529
|
+
const extraIds = changeIds.filter(function (id) { return !originalIds.has(String(id)) && !contextGroupIds.has(String(id)); });
|
|
1379
1530
|
const wronglyRemovedIds = changeIds.filter(function (id) { return removeSet.has(String(id)); });
|
|
1380
1531
|
|
|
1381
1532
|
const idProblems = [];
|
|
@@ -1414,27 +1565,95 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1414
1565
|
return patch ? Object.assign({}, n, patch) : n;
|
|
1415
1566
|
});
|
|
1416
1567
|
|
|
1568
|
+
// A "changes" patch targeting a group id (contextGroupIds, not
|
|
1569
|
+
// originalIds — see above) has nowhere to merge onto above, since
|
|
1570
|
+
// originalNodes never includes the group itself, only nodes inside
|
|
1571
|
+
// it. Synthesize a minimal {id, type:"group", ...patch} entry for
|
|
1572
|
+
// each one instead, so it rides through the SAME flow array the
|
|
1573
|
+
// frontend's existing Tier-1 diff/apply pipeline already handles —
|
|
1574
|
+
// computeNodeDiff()/applyModifications() don't care what TYPE a node
|
|
1575
|
+
// is, and findLiveNode() already resolves a group id to the live
|
|
1576
|
+
// group object (Phase 8.5 C2 slice 1). This is how a group gets
|
|
1577
|
+
// renamed/restyled — pure property edit, no new apply-side code.
|
|
1578
|
+
const groupPatchIds = changeIds.filter(function (id) {
|
|
1579
|
+
return contextGroupIds.has(String(id)) && !originalIds.has(String(id));
|
|
1580
|
+
});
|
|
1581
|
+
groupPatchIds.forEach(function (id) {
|
|
1582
|
+
flow.push(Object.assign({ id: id, type: "group" }, patchById[String(id)]));
|
|
1583
|
+
});
|
|
1584
|
+
|
|
1417
1585
|
const finalRemoveNodes = Array.from(removeSet);
|
|
1418
1586
|
|
|
1419
|
-
//
|
|
1420
|
-
//
|
|
1421
|
-
//
|
|
1422
|
-
//
|
|
1423
|
-
//
|
|
1587
|
+
// Groups describe MEMBERSHIP, not a regular node to insert — the
|
|
1588
|
+
// model is taught (modify-system-prompt.js) to put them in their OWN
|
|
1589
|
+
// top-level "newGroups" field, never inside "newNodes" (that API
|
|
1590
|
+
// doesn't know what a group is at all — applyInsertions would try to
|
|
1591
|
+
// RED.nodes.add() it). Bug found live: this used to ONLY look for a
|
|
1592
|
+
// stray type:"group" entry INSIDE newNodes and never read
|
|
1593
|
+
// result.newGroups at all — a model correctly following the prompt's
|
|
1594
|
+
// own instructions had its groups silently dropped ("No changes
|
|
1595
|
+
// detected"). Read the real field now; still tolerate a stray
|
|
1596
|
+
// type:"group" entry left inside newNodes as a fallback, merging
|
|
1597
|
+
// both rather than requiring exactly one style. Each entry's "nodes"
|
|
1598
|
+
// is the FULL desired membership for that group id: if the id
|
|
1599
|
+
// matches an EXISTING live group, the frontend reconciles membership
|
|
1600
|
+
// to match exactly (add/remove as needed, down to zero — ungrouping
|
|
1601
|
+
// everyone); if not, it creates a new group with exactly that
|
|
1602
|
+
// membership. See applyGroupChanges() in flowpilot-core.js.
|
|
1424
1603
|
const allNewNodes = result.newNodes || [];
|
|
1425
|
-
const
|
|
1604
|
+
const strayGroupNodes = allNewNodes.filter(function (n) { return n && n.type === "group"; });
|
|
1426
1605
|
const newNodes = allNewNodes.filter(function (n) { return !(n && n.type === "group"); });
|
|
1427
1606
|
const newNodeIdSet = new Set(newNodes.map(function (n) { return n && n.id; }).filter(Boolean));
|
|
1428
1607
|
|
|
1608
|
+
const declaredGroups = Array.isArray(result.newGroups) ? result.newGroups : [];
|
|
1609
|
+
const seenGroupIds = {};
|
|
1610
|
+
const newGroups = declaredGroups.concat(strayGroupNodes).filter(function (g) {
|
|
1611
|
+
if (!g || !g.id || seenGroupIds[g.id]) { return false; }
|
|
1612
|
+
seenGroupIds[g.id] = true;
|
|
1613
|
+
return true;
|
|
1614
|
+
});
|
|
1615
|
+
|
|
1429
1616
|
// Validate newWires references: each from/to must be either an existing
|
|
1430
|
-
// context node id or a placeholder id present in newNodes.
|
|
1617
|
+
// context node id or a placeholder id present in newNodes. A group id
|
|
1618
|
+
// is never a valid wire endpoint (groups don't pass messages) — filter
|
|
1619
|
+
// those out the same as before, just without discarding the group itself.
|
|
1431
1620
|
let newWires = result.newWires || [];
|
|
1432
|
-
if (
|
|
1433
|
-
const groupIdSet = new Set(
|
|
1621
|
+
if (newGroups.length > 0) {
|
|
1622
|
+
const groupIdSet = new Set(newGroups.map(function (n) { return n && n.id; }).filter(Boolean));
|
|
1434
1623
|
newWires = newWires.filter(function (wire) {
|
|
1435
1624
|
return !groupIdSet.has(String(wire.from)) && !groupIdSet.has(String(wire.to));
|
|
1436
1625
|
});
|
|
1437
1626
|
}
|
|
1627
|
+
// Validate newGroups' own "nodes" member references: each must be an
|
|
1628
|
+
// existing context node id or a new-node placeholder id — explicitly
|
|
1629
|
+
// NOT another group's id, so nested groups-within-groups (out of scope
|
|
1630
|
+
// for v1) are naturally rejected rather than silently mis-imported.
|
|
1631
|
+
// An EMPTY "nodes" is allowed through here — meaningless for creating
|
|
1632
|
+
// a brand new group (the frontend already no-ops that case), but a
|
|
1633
|
+
// legitimate "ungroup everyone in this EXISTING group" when "id"
|
|
1634
|
+
// matches a live one, which only the frontend can tell apart.
|
|
1635
|
+
if (newGroups.length > 0) {
|
|
1636
|
+
const groupProblems = [];
|
|
1637
|
+
newGroups.forEach(function (g, i) {
|
|
1638
|
+
if (!g || !g.id) { groupProblems.push("group " + i + " missing id"); return; }
|
|
1639
|
+
const members = Array.isArray(g.nodes) ? g.nodes : [];
|
|
1640
|
+
members.forEach(function (ref) {
|
|
1641
|
+
if (!originalIds.has(String(ref)) && !newNodeIdSet.has(String(ref))) {
|
|
1642
|
+
groupProblems.push("group " + i + " member '" + ref + "' not in existing or new nodes");
|
|
1643
|
+
}
|
|
1644
|
+
});
|
|
1645
|
+
});
|
|
1646
|
+
if (groupProblems.length > 0) {
|
|
1647
|
+
storage.appendAudit({ action: "modify_group_ref_error", problems: groupProblems });
|
|
1648
|
+
return {
|
|
1649
|
+
status: 422,
|
|
1650
|
+
body: {
|
|
1651
|
+
error: "Invalid group references in newGroups: " + groupProblems.join("; "),
|
|
1652
|
+
raw: JSON.stringify(result)
|
|
1653
|
+
}
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1438
1657
|
if (newWires.length > 0) {
|
|
1439
1658
|
const wireProblems = [];
|
|
1440
1659
|
newWires.forEach(function (wire, i) {
|
|
@@ -1457,19 +1676,15 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1457
1676
|
}
|
|
1458
1677
|
}
|
|
1459
1678
|
|
|
1460
|
-
|
|
1461
|
-
if (groupNodes.length > 0) {
|
|
1462
|
-
storage.appendAudit({ action: "modify_group_stripped", count: groupNodes.length });
|
|
1463
|
-
explanation = (explanation ? explanation + "\n\n" : "") +
|
|
1464
|
-
"Note: grouping nodes into a visual group isn't supported yet, so that part of the request was skipped.";
|
|
1465
|
-
}
|
|
1679
|
+
if (newGroups.length > 0) { storage.appendAudit({ action: "modify_groups", count: newGroups.length }); }
|
|
1466
1680
|
|
|
1467
1681
|
const body = {
|
|
1468
|
-
explanation: explanation,
|
|
1682
|
+
explanation: result.explanation,
|
|
1469
1683
|
flow: flow,
|
|
1470
1684
|
newNodes: newNodes,
|
|
1471
1685
|
newWires: newWires,
|
|
1472
|
-
removeNodes: finalRemoveNodes
|
|
1686
|
+
removeNodes: finalRemoveNodes,
|
|
1687
|
+
newGroups: newGroups
|
|
1473
1688
|
};
|
|
1474
1689
|
if (result.suggestedAction) { body.suggestedAction = result.suggestedAction; }
|
|
1475
1690
|
|
|
@@ -1556,9 +1771,49 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1556
1771
|
}
|
|
1557
1772
|
});
|
|
1558
1773
|
|
|
1774
|
+
// First step of the agentic /build loop. Envelope-shaped and validated
|
|
1775
|
+
// identically to /generate (processGenerationContent only special-cases
|
|
1776
|
+
// auditAction === "modify"; "build" falls through to the same flow-array
|
|
1777
|
+
// handling "generate"/"document" already use) — the only difference is
|
|
1778
|
+
// buildSystemPrompt's planning preamble. Later loop iterations (fix
|
|
1779
|
+
// proposals) go through /flowpilot/modify instead, not this route.
|
|
1780
|
+
RED.httpAdmin.post("/flowpilot/build", RED.auth.needsPermission("settings.write"), async function (req, res) {
|
|
1781
|
+
const prompt = req.body && req.body.prompt;
|
|
1782
|
+
|
|
1783
|
+
if (!prompt || !String(prompt).trim()) {
|
|
1784
|
+
return res.status(400).json({ error: "A description of what to build is required." });
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
const history = sanitizeHistory(req.body.history);
|
|
1788
|
+
const historyTruncated = !!req.body.historyTruncated;
|
|
1789
|
+
|
|
1790
|
+
if (req.body.stream) {
|
|
1791
|
+
return runExecuteStream(
|
|
1792
|
+
req, res, buildSystemPrompt, "build", prompt, req.body && req.body.context,
|
|
1793
|
+
history, historyTruncated, finalizeSimpleGeneration, req.body.conversationId
|
|
1794
|
+
);
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
try {
|
|
1798
|
+
const useTools = !!req.body.tools;
|
|
1799
|
+
const built = await runFlowGeneration(
|
|
1800
|
+
buildSystemPrompt, "build", prompt, req.body && req.body.context,
|
|
1801
|
+
history, historyTruncated, useTools
|
|
1802
|
+
);
|
|
1803
|
+
if (built.toolCalls) {
|
|
1804
|
+
return res.json({ toolCalls: built.toolCalls, messages: built.messages, content: built.content, usage: built.usage });
|
|
1805
|
+
}
|
|
1806
|
+
recordTranscriptTurn(req.body.conversationId, "build", prompt, transcriptTextFromGenerationResult(built));
|
|
1807
|
+
const { status, body } = finalizeSimpleGeneration(built);
|
|
1808
|
+
res.status(status).json(body);
|
|
1809
|
+
} catch (err) {
|
|
1810
|
+
sendGenerationError(res, "build", err);
|
|
1811
|
+
}
|
|
1812
|
+
});
|
|
1813
|
+
|
|
1559
1814
|
RED.httpAdmin.post("/flowpilot/document", RED.auth.needsPermission("settings.write"), async function (req, res) {
|
|
1560
1815
|
const context = req.body && req.body.context;
|
|
1561
|
-
const described = describeSelectionContext(context);
|
|
1816
|
+
const described = describeSelectionContext(context, storage.getSettings().redactionEnabled);
|
|
1562
1817
|
|
|
1563
1818
|
if (!described) {
|
|
1564
1819
|
return res.status(400).json({ error: "Select the node(s) you want documented first." });
|
|
@@ -1598,7 +1853,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1598
1853
|
|
|
1599
1854
|
RED.httpAdmin.post("/flowpilot/modify", RED.auth.needsPermission("settings.write"), async function (req, res) {
|
|
1600
1855
|
const context = req.body && req.body.context;
|
|
1601
|
-
const described = describeSelectionContext(context);
|
|
1856
|
+
const described = describeSelectionContext(context, storage.getSettings().redactionEnabled);
|
|
1602
1857
|
|
|
1603
1858
|
if (!described) {
|
|
1604
1859
|
return res.status(400).json({ error: "Select the node(s) you want to modify first." });
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// /build's first step is shaped exactly like Generate's — same envelope,
|
|
2
|
+
// same "flow" array rules, same clarifying-question mechanism — so this
|
|
3
|
+
// reuses generation-system-prompt.js wholesale (require + prepend) rather
|
|
4
|
+
// than duplicating the hard-won "wires" rules/example. The only new thing
|
|
5
|
+
// is the framing: this is the FIRST step of a build -> deploy -> test -> fix
|
|
6
|
+
// loop, not a one-shot generation, so the model should plan ahead briefly.
|
|
7
|
+
const generationPrompt = require("./generation-system-prompt");
|
|
8
|
+
|
|
9
|
+
module.exports = `You are FlowPilot, running in an agentic BUILD loop. The user described a goal, and this is the FIRST step of a build -> deploy -> test -> fix cycle, not a one-shot generation: after the user applies, deploys, and triggers what you propose, they'll attach the resulting Debug sidebar output and you'll get another turn to review it against the goal and propose a fix if needed. This can repeat a bounded number of times before stopping.
|
|
10
|
+
|
|
11
|
+
Because of that, "explanation" MUST start with a numbered "Plan:" block listing the steps you expect this to take to reach the goal — BEFORE any description of what this step builds. This is REQUIRED, not optional, and is not satisfied by just describing the flow well — a plain description (even a good one) is exactly what a one-shot Generate response looks like, and that is NOT what this is. Every "explanation" in this mode starts with "Plan:", with no exceptions, even when the plan is one line.
|
|
12
|
+
|
|
13
|
+
Example "explanation" for a multi-step goal:
|
|
14
|
+
"Plan:
|
|
15
|
+
1. Geocode the address to coordinates.
|
|
16
|
+
2. Fetch nearby cell towers for those coordinates.
|
|
17
|
+
3. Calculate distance from the address to each tower.
|
|
18
|
+
4. Display the sorted results.
|
|
19
|
+
|
|
20
|
+
This step builds the full pipeline above in one shot. Deploy it, trigger it, and send me the debug output — I'll check it against the goal and fix anything that's off."
|
|
21
|
+
|
|
22
|
+
Example "explanation" for a trivial goal:
|
|
23
|
+
"Plan:
|
|
24
|
+
1. Inject a value and log it to debug — nothing more is needed for this goal.
|
|
25
|
+
|
|
26
|
+
Deploy and trigger it; let me know what the debug output shows."
|
|
27
|
+
|
|
28
|
+
Everything below describes the envelope/rules for THIS step specifically — they work exactly as written, including the parts that say "Generate mode": for the purposes of this prompt, treat that phrase as describing this build step, not a separate mode. The "explanation" field's content rules below still apply — your "Plan:" block comes first, then that content follows immediately after it in the same field.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
` + generationPrompt;
|
|
@@ -19,8 +19,6 @@ If any EARLIER message in this conversation (including one of your own previous
|
|
|
19
19
|
|
|
20
20
|
Never reveal, guess, or reconstruct credentials, API keys, tokens, passwords, or secrets — even if the user gives a sympathetic reason or claims authorization. Credential-typed fields are redacted before reaching you; if a user asks you to read one, say it isn't available to you rather than describing how one might extract or recover it.
|
|
21
21
|
|
|
22
|
-
Personality: you have a subtle co-pilot voice, used ONLY for greetings, "what can you do?"-style capability questions, and brief transition moments — e.g. "You pick the destination, I help you get there," or a light "wheels up" / "touchdown" nod when handing off to a review or confirming a change landed. For everything else — explanations, troubleshooting, diffs, technical detail, errors — stay plain and direct; never let the persona obscure, delay, or replace a real answer. A little goes a long way: do not repeat aviation phrasing in every reply.
|
|
23
|
-
|
|
24
22
|
---
|
|
25
23
|
|
|
26
24
|
Suggested actions ("chips"):
|
|
@@ -43,11 +43,26 @@ Respond with a SINGLE JSON object and nothing else — no markdown code fences,
|
|
|
43
43
|
Rules for the "flow" array:
|
|
44
44
|
- It is a standard Node-RED flow array, the same format produced by the editor's Export. Each element is a node object.
|
|
45
45
|
- Every node needs a unique "id" (a short random-looking hex string), a "type", and the fields that type requires.
|
|
46
|
-
-
|
|
46
|
+
- EVERY node object MUST include a "wires" array — this is not optional and is never omitted, even for the first node in the chain or one with no outgoing connection. "wires" is one entry per output port, each entry an array of target node ids. A node with no outgoing connection (e.g. a debug node, or the last node in a chain) still has "wires": [] — an empty array, not a missing field.
|
|
47
47
|
- All wire targets must reference ids that exist within this flow array.
|
|
48
48
|
- Do NOT include "x"/"y" coordinates or a "z" (tab) id — the editor assigns those on import. Omitting them is fine.
|
|
49
|
-
- Do NOT include a node of type "tab" or "subflow" in "flow" — these represent editor workspaces/containers, not importable nodes
|
|
49
|
+
- Do NOT include a node of type "tab" or "subflow" in "flow" — these represent editor workspaces/containers, not importable nodes.
|
|
50
50
|
- Comment nodes (type: "comment") are passive annotations and do not pass messages — their "wires" array MUST be empty ([]). Never wire a comment node to or from any other node.
|
|
51
|
+
- To visually group related nodes together (an actual bordered box around them, same as the editor's own "Group selection" action) — NOT just a label — include a node with "type": "group", an optional "name", and a "nodes" array listing the ids of every node it contains. Every listed id must belong to another node elsewhere in this SAME "flow" array — a group cannot reference a node from outside this response. A group has no "wires" (groups never pass messages, they're a visual container only) and no x/y/w/h (the editor computes its bounding box from its members automatically, same as it does for every other node's position). If you just want a label or section header rather than an actual visual boundary, a "comment" node is lighter-weight — use whichever the user's wording actually implies.
|
|
52
|
+
|
|
53
|
+
Example of a group containing two of this flow's nodes:
|
|
54
|
+
{"id": "g1", "type": "group", "name": "Weather lookup", "nodes": ["n1", "n2"]}
|
|
55
|
+
|
|
56
|
+
Example — three nodes chained inject -> function -> debug, showing "wires" on every single node including the first and last:
|
|
57
|
+
{
|
|
58
|
+
"explanation": "An inject node triggers a function that doubles its input, then a debug node logs the result.",
|
|
59
|
+
"flow": [
|
|
60
|
+
{"id": "n1", "type": "inject", "name": "Start", "props": [{"p":"payload"}], "repeat": "", "crontab": "", "once": false, "onceDelay": 0.1, "topic": "", "payload": "5", "payloadType": "num", "wires": [["n2"]]},
|
|
61
|
+
{"id": "n2", "type": "function", "name": "Double", "func": "msg.payload = msg.payload * 2;\\nreturn msg;", "outputs": 1, "wires": [["n3"]]},
|
|
62
|
+
{"id": "n3", "type": "debug", "name": "Result", "active": true, "tosidebar": true, "wires": []}
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
Notice "n1" (the very first node, nothing wires INTO it) still has its own "wires" array out to "n2", and "n3" (the last node, nothing downstream) still has an explicit "wires": [] rather than omitting the field. Every node you generate follows this same shape — a flow where any node is missing "wires" entirely will import with that node completely disconnected.
|
|
51
66
|
|
|
52
67
|
Node type rules:
|
|
53
68
|
- STRONGLY PREFER core nodes: inject, debug, function, change, switch, template, http in/out/request, mqtt in/out, link in/out, comment, junction, complete, catch, status, split, join, sort, batch, delay, trigger, range, csv, html, json, xml, yaml, file, exec, tcp/udp.
|
|
@@ -7,10 +7,11 @@ Respond with a SINGLE JSON object and nothing else — no markdown code fences,
|
|
|
7
7
|
"changes": [ ...optional: sparse patches for existing nodes whose properties change... ],
|
|
8
8
|
"newNodes": [ ...optional: new nodes to add... ],
|
|
9
9
|
"newWires": [ ...optional: wire connections crossing between new and existing nodes... ],
|
|
10
|
-
"removeNodes": [ ...optional: ids of existing nodes to delete... ]
|
|
10
|
+
"removeNodes": [ ...optional: ids of existing nodes to delete... ],
|
|
11
|
+
"newGroups": [ ...optional: visual groups to create, or existing ones to update... ]
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
"changes", "newNodes", "newWires", and "
|
|
14
|
+
"changes", "newNodes", "newWires", "removeNodes", and "newGroups" are all OPTIONAL. Only include them when the instruction calls for it. Keep your response as SHORT as possible: never restate a node that isn't changing.
|
|
14
15
|
|
|
15
16
|
---
|
|
16
17
|
|
|
@@ -96,6 +97,7 @@ Rules for "changes" (sparse patches against the existing selection):
|
|
|
96
97
|
5. Do not include "wires" in "set" unless the instruction explicitly asks to rewire that node's connections.
|
|
97
98
|
6. Never include "id", "x", or "y", or "z" inside "set" — those cannot change via a patch.
|
|
98
99
|
7. An id must not appear in both "changes" and "removeNodes".
|
|
100
|
+
8. A node's "group" field in context (when present) is INFORMATIONAL ONLY — never include "group" as a key inside "set". It has no effect; setting it does nothing and silently fails to change membership. To add/remove/rename a group, use "newGroups" instead (see below) — the ONE exception is renaming/restyling the group ITSELF: target the group's own "id" (from its "group" field) with a "changes" entry, e.g. {"id": "<group's id>", "set": {"name": "New Name"}}.
|
|
99
101
|
|
|
100
102
|
---
|
|
101
103
|
|
|
@@ -171,10 +173,8 @@ Rules for "newNodes" (only include when the instruction asks to add nodes):
|
|
|
171
173
|
- Set "wires" on each new node: use placeholder ids for outputs that connect to OTHER new nodes; use an empty array [] for outputs that connect only to existing nodes (those connections go in "newWires" instead).
|
|
172
174
|
- Include all required type-specific properties (topic, payload, func, etc.).
|
|
173
175
|
- "http request" node static headers: if the "headers" property is set, it must be an array of objects shaped like { "keyType": "other", "keyValue": "Accept", "valueType": "other", "valueValue": "application/json" } — one object per header, with the header name in "keyValue" and its value in "valueValue". A plain { "key": "...", "value": "..." } shape is silently ignored by Node-RED.
|
|
174
|
-
- Do NOT
|
|
175
|
-
|
|
176
|
-
into a group, skip that part — say so in "explanation" — but still perform
|
|
177
|
-
any other part of the instruction (e.g. adding a comment node).
|
|
176
|
+
- Do NOT include a "group" entry here (type: "group") — visual grouping goes
|
|
177
|
+
in "newGroups" instead (see below), never in "newNodes".
|
|
178
178
|
|
|
179
179
|
- Comment nodes: When adding comment nodes, their "wires" array MUST be empty ([]).
|
|
180
180
|
Comment nodes in Node-RED are passive annotations and do not pass messages.
|
|
@@ -190,6 +190,16 @@ Each entry: { "from": "<id>", "fromPort": <int>, "to": "<id>" }
|
|
|
190
190
|
- Do NOT use this for connections between two existing nodes — use a "changes" entry with "set.wires" for that instead (rewiring).
|
|
191
191
|
- Never write a "from"/"to" referring to a node that is neither an existing context node nor one of your own "newNodes" (e.g. a made-up id like "debug-node-placeholder"). If the instruction needs a connection to a node like that, ask a clarifying question instead (see below).
|
|
192
192
|
|
|
193
|
+
Rules for "newGroups" (visual groups — an actual bordered box around nodes, like the editor's own "Group selection" action; only include when the instruction asks to group/organize/rename nodes this way):
|
|
194
|
+
|
|
195
|
+
Each entry: { "id": "<id>", "name": "<optional label>", "nodes": ["<id>", ...] }
|
|
196
|
+
- "nodes" is the FULL desired membership of this group — not "nodes to add" or "nodes to remove". If you're extending or shrinking an existing group, list every member it should end up with, not just the ones changing.
|
|
197
|
+
- Each id in "nodes" must be either an existing context node id or a placeholder id from "newNodes" — never another group's id (nested groups aren't supported).
|
|
198
|
+
- If a selected node's context included a "group" field (e.g. {"id":"g1","name":"Weather lookup"}), that's an EXISTING group you can extend, shrink, rename, or fully disband — reuse its exact "id" in your entry. Renaming only (no membership change) still needs the same "nodes" list as it has now, with a different "name".
|
|
199
|
+
- To UNGROUP nodes (remove them from their group without deleting them) — e.g. "ungroup this", "take these out of the group" — use this same mechanism: an entry for the EXISTING group's id whose "nodes" list simply OMITS the ones being removed. Removing every current member this way (an empty "nodes": []) disbands the group entirely. This is the ONLY way to change group membership — never try to clear/null a node's "group" field via "changes", that field is informational only and doing so has no effect.
|
|
200
|
+
- To create a BRAND NEW group instead, invent a short placeholder "id" the same way you would for "newNodes" (e.g. "fp-group-0") — the editor assigns its real id. An empty "nodes" only makes sense for an EXISTING group (disbanding it) — a brand new group needs at least one member.
|
|
201
|
+
- A group has no "wires" — groups never pass messages, they're a visual container only.
|
|
202
|
+
|
|
193
203
|
---
|
|
194
204
|
|
|
195
205
|
Example — adding a debug node after an inject node (id "abc123"). The inject
|