@manny-est/node-red-flowpilot 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable changes to FlowPilot are documented here.
4
4
 
5
+ ## [0.5.1] - 2026-07-24
6
+
7
+ ### Added
8
+ - **`/refresh` command**: re-renders the entire message panel from an in-memory record store without clearing conversation history. Restores interactive Apply buttons and review panels that may have become stale after a long session or a pop-out sync. Type `/refresh` at any time.
9
+ - **Reasoning model support**: FlowPilot detects reasoning models (Nemotron, DeepSeek, QwQ, and any model returning `reasoning_content` or `<think>` blocks) at pre-flight and handles them correctly throughout. Streaming: a live collapsing "Thinking…" block shows reasoning tokens as they arrive, then auto-collapses when the real response begins. Non-streaming (agent loop): a pre-collapsed thinking block renders alongside the final response. Both the SGLang/Nemotron (`delta.reasoning_content`) and llama.cpp/LocalAI (`<think>…</think>` in `delta.content`) formats are supported. Token count shown on collapse.
10
+ - **Auto-preflight on model change**: changing the model field and sending a message now triggers a silent capability probe before the request goes out — no need to click "Test Provider" after every model switch. A notice appears in the chat thread confirming the probe result (model name, tool support, reasoning flag). Settings are saved automatically as part of the probe so the backend uses the new model. The provider status line also updates live as you type the model name.
11
+
12
+ ### Fixed
13
+ - **Pre-flight `probedModel` not reaching frontend**: after a `/test` run, `probedModel` was saved to disk but never mirrored into the in-memory provider profile, so the auto-probe condition (`probedModel !== currentModel`) could never fire after a page reload. The `/test` response now includes `probedModel` in the `capability` object and `handleSendResult` mirrors it into `currentSettings.providers`.
14
+ - **Reasoning content scrolling**: the live thinking block now scrolls to bottom on each streaming delta so long reasoning chains stay visible as they arrive.
15
+
16
+ ### Internal
17
+ - Phase 10 Workstream 0A: shadow record store added across `main.js`, `apply-review.js`, `modes.js`, `init.js` — addMessage/addModifyReview/addGeneratedReview/renderActionChip/renderClarifyingQuestion/renderLoopCheckpoint/renderLoopStepper all create typed records; rerenderRecord dispatches each kind on refresh.
18
+ - Phase 10 Workstream 0B: DOM elements now carry `data-fp-record-id` (record id) instead of inline JSON payloads in `data-fp-apply-*` attributes. Four separate pop-out bind functions consolidated into a single `bindReviewApplyButtons`; four separate parent postMessage handlers replaced by a unified `applyByRecordId` handler that dispatches on the record's subkind.
19
+
5
20
  ## [0.5.0] - 2026-07-06
6
21
 
7
22
  ### Added
@@ -103,6 +103,65 @@
103
103
  border-color: rgba(80, 130, 255, 0.35);
104
104
  }
105
105
 
106
+ /* Reasoning / thinking block — shown for models that emit reasoning_content
107
+ (e.g. Nemotron, DeepSeek-R1). Sits above the assistant response bubble.
108
+ Expanded while the model thinks; collapses automatically when content starts. */
109
+ .fp-thinking {
110
+ margin-bottom: 8px;
111
+ border-radius: 8px;
112
+ border: 1px solid rgba(120, 100, 220, 0.25);
113
+ background: rgba(100, 80, 200, 0.05);
114
+ overflow: hidden;
115
+ }
116
+
117
+ .fp-thinking summary {
118
+ display: flex;
119
+ align-items: center;
120
+ gap: 6px;
121
+ padding: 7px 12px;
122
+ cursor: pointer;
123
+ font-size: 11px;
124
+ font-weight: 600;
125
+ letter-spacing: 0.04em;
126
+ color: var(--red-ui-secondary-text-color, #888);
127
+ user-select: none;
128
+ list-style: none;
129
+ }
130
+
131
+ .fp-thinking summary::-webkit-details-marker { display: none; }
132
+
133
+ .fp-thinking summary::before {
134
+ content: "▶";
135
+ font-size: 8px;
136
+ display: inline-block;
137
+ transition: transform 0.15s ease;
138
+ color: var(--red-ui-secondary-text-color, #aaa);
139
+ flex-shrink: 0;
140
+ }
141
+
142
+ .fp-thinking[open] summary::before {
143
+ transform: rotate(90deg);
144
+ }
145
+
146
+ .fp-thinking-tokens {
147
+ margin-left: auto;
148
+ font-size: 10px;
149
+ font-weight: 400;
150
+ opacity: 0.55;
151
+ }
152
+
153
+ .fp-thinking-body {
154
+ padding: 4px 12px 10px 12px;
155
+ font-family: monospace;
156
+ font-size: 11px;
157
+ line-height: 1.5;
158
+ color: var(--red-ui-secondary-text-color, #999);
159
+ opacity: 0.8;
160
+ white-space: pre-wrap;
161
+ max-height: 180px;
162
+ overflow-y: auto;
163
+ }
164
+
106
165
  .fp-error {
107
166
  background: rgba(255, 80, 80, 0.12);
108
167
  border-color: rgba(255, 80, 80, 0.65);
package/flowpilot.js CHANGED
@@ -556,13 +556,18 @@ module.exports = function flowPilotRuntime(RED) {
556
556
 
557
557
  let streamResult;
558
558
  try {
559
- streamResult = await provider.chatStream(activeProvider, messages, function (delta) {
560
- const visible = splitter.push(delta);
561
- if (visible) {
562
- visibleText += visible;
563
- res.write("data: " + JSON.stringify({ delta: visible }) + "\n\n");
559
+ streamResult = await provider.chatStream(activeProvider, messages,
560
+ function (delta) {
561
+ const visible = splitter.push(delta);
562
+ if (visible) {
563
+ visibleText += visible;
564
+ res.write("data: " + JSON.stringify({ delta: visible }) + "\n\n");
565
+ }
566
+ },
567
+ function (reasoningDelta) {
568
+ res.write("data: " + JSON.stringify({ reasoningDelta: reasoningDelta }) + "\n\n");
564
569
  }
565
- });
570
+ );
566
571
  } catch (err) {
567
572
  res.write("data: " + JSON.stringify({ error: err.message }) + "\n\n");
568
573
  res.end();
@@ -751,6 +756,10 @@ module.exports = function flowPilotRuntime(RED) {
751
756
  if (suggestedAction) { body.suggestedAction = suggestedAction; }
752
757
  const questionOptions = extractQuestionOptions(chatData);
753
758
  if (questionOptions) { body.questionOptions = questionOptions; }
759
+ // Pass reasoning_content through so the frontend can render a thinking
760
+ // block on the non-streaming (agent-loop) path too.
761
+ const rawMsg = result.raw && result.raw.choices && result.raw.choices[0] && result.raw.choices[0].message;
762
+ if (rawMsg && rawMsg.reasoning_content) { body.reasoningContent = rawMsg.reasoning_content; }
754
763
  res.json(body);
755
764
  } catch (err) {
756
765
  storage.appendAudit({ action: "chat_error", error: err.message });
@@ -933,29 +942,42 @@ module.exports = function flowPilotRuntime(RED) {
933
942
  // probe failure here just means "no tool support", not a /test failure.
934
943
  // Persist the result on the provider profile for the agentic tool-calling path.
935
944
  const probe = await provider.probeTools(activeProvider);
945
+ const reasoning = provider.detectReasoning(result.raw);
936
946
  storage.appendAudit({
937
947
  action: "capability_probe",
938
948
  providerName: activeProvider.providerName,
939
949
  baseUrl: activeProvider.baseUrl,
940
950
  model: activeProvider.model,
941
- supportsTools: probe.supportsTools
951
+ supportsTools: probe.supportsTools,
952
+ isReasoningModel: reasoning.isReasoningModel
942
953
  });
943
954
 
944
955
  const updatedProviders = (settings.providers || []).map(function (p) {
945
956
  return p.id === activeProvider.id
946
- ? Object.assign({}, p, { supportsTools: probe.supportsTools, toolsProbedAt: new Date().toISOString() })
957
+ ? Object.assign({}, p, {
958
+ supportsTools: probe.supportsTools,
959
+ toolsProbedAt: new Date().toISOString(),
960
+ isReasoningModel: reasoning.isReasoningModel,
961
+ reasoningProbedAt: new Date().toISOString(),
962
+ probedModel: activeProvider.model
963
+ })
947
964
  : p;
948
965
  });
949
966
  storage.saveSettings(Object.assign({}, settings, { providers: updatedProviders }));
950
967
 
968
+ const toolLabel = probe.supportsTools
969
+ ? "✓ Connected · ✓ Supports tools"
970
+ : "✓ Connected · ⚠ No tool support — compatibility mode";
971
+ const reasoningLabel = reasoning.isReasoningModel ? " · Reasoning model" : "";
972
+
951
973
  res.json({
952
974
  message: chatMessage || "[No assistant message returned by provider]",
953
975
  raw: result.raw ? "[raw response captured]" : null,
954
976
  capability: {
955
977
  supportsTools: probe.supportsTools,
956
- label: probe.supportsTools
957
- ? "✓ Connected · ✓ Supports tools"
958
- : "✓ Connected · ⚠ No tool support — compatibility mode"
978
+ isReasoningModel: reasoning.isReasoningModel,
979
+ probedModel: activeProvider.model,
980
+ label: toolLabel + reasoningLabel
959
981
  }
960
982
  });
961
983
  } catch (err) {
@@ -964,6 +986,58 @@ module.exports = function flowPilotRuntime(RED) {
964
986
  }
965
987
  });
966
988
 
989
+ // ---- Probe: silent capability re-check after model change -----------
990
+ // Called by the frontend when it detects that the active provider's model
991
+ // changed since the last full pre-flight — stale supportsTools silently
992
+ // misroutes chat (agent-loop vs streaming/non-streaming). Runs probeTools
993
+ // + a minimal chat for reasoning detection, saves all results including
994
+ // probedModel, and returns { supportsTools, isReasoningModel, probedModel }.
995
+
996
+ RED.httpAdmin.post("/flowpilot/probe", RED.auth.needsPermission("settings.write"), async function (req, res) {
997
+ try {
998
+ const settings = storage.getSettings();
999
+ const activeProvider = storage.getActiveProvider(settings);
1000
+
1001
+ const probe = await provider.probeTools(activeProvider);
1002
+ const chatResult = await provider.chat(activeProvider, [
1003
+ { role: "system", content: "You are a helpful assistant." },
1004
+ { role: "user", content: "Say hello." }
1005
+ ]);
1006
+ const reasoning = provider.detectReasoning(chatResult.raw);
1007
+
1008
+ const updatedProviders = (settings.providers || []).map(function (p) {
1009
+ return p.id === activeProvider.id
1010
+ ? Object.assign({}, p, {
1011
+ supportsTools: probe.supportsTools,
1012
+ toolsProbedAt: new Date().toISOString(),
1013
+ isReasoningModel: reasoning.isReasoningModel,
1014
+ reasoningProbedAt: new Date().toISOString(),
1015
+ probedModel: activeProvider.model
1016
+ })
1017
+ : p;
1018
+ });
1019
+ storage.saveSettings(Object.assign({}, settings, { providers: updatedProviders }));
1020
+
1021
+ storage.appendAudit({
1022
+ action: "auto_probe",
1023
+ providerName: activeProvider.providerName,
1024
+ baseUrl: activeProvider.baseUrl,
1025
+ model: activeProvider.model,
1026
+ supportsTools: probe.supportsTools,
1027
+ isReasoningModel: reasoning.isReasoningModel
1028
+ });
1029
+
1030
+ res.json({
1031
+ supportsTools: probe.supportsTools,
1032
+ isReasoningModel: reasoning.isReasoningModel,
1033
+ probedModel: activeProvider.model
1034
+ });
1035
+ } catch (err) {
1036
+ storage.appendAudit({ action: "auto_probe_error", error: err.message });
1037
+ res.status(500).json({ error: err.message });
1038
+ }
1039
+ });
1040
+
967
1041
  // ---- Generate: produce an importable flow fragment --------------------
968
1042
  // Uses the generation system prompt and expects the model to return a single
969
1043
  // JSON object { explanation, flow }. This first cut does NOT validate node
@@ -863,17 +863,11 @@
863
863
  : hasNewNodes ? "Insert Nodes"
864
864
  : "Apply Changes"; // covers a request that ONLY creates/updates a group
865
865
 
866
- // Pop-out: tag this panel with everything applyInsertions/
867
- // applyModifications/applyGroupChanges need to re-run from a
868
- // relayed click nodeDiffs re-serialized without liveNode (a
869
- // live RED node object, not JSON-safe; applyModifications
870
- // re-fetches it itself via findLiveNode anyway, so nothing is
871
- // lost). A plain Modify call (applyCallback is the bare
872
- // applyModifications reference) gets "data-fp-apply-modify"; a
873
- // /build loop fix (buildFixInfo set — see applyBuildLoopFix)
874
- // gets "data-fp-apply-build-fix" instead, carrying capReached
875
- // too since the relayed click needs to run the SAME loop
876
- // bookkeeping a local click would, not just applyModifications.
866
+ // sharedApplyData is stored in the review record (Phase 10 0B)
867
+ // so the pop-out can relay an applyByRecordId intent to the
868
+ // parent without serializing payload into DOM attributes.
869
+ // nodeDiffs is re-serialized without liveNode (a RED node object,
870
+ // not JSON-safe; applyModifications re-fetches via findLiveNode).
877
871
  var sharedApplyData = {
878
872
  nodeDiffs: nodeDiffs.map(function (d) {
879
873
  return {
@@ -892,12 +886,13 @@
892
886
  existingNodeIds: nodes.map(function (n) { return n.id; }),
893
887
  hasMutations: hasMutations
894
888
  };
895
- if (applyCallback === applyModifications) {
896
- $msg.attr("data-fp-apply-modify", JSON.stringify(sharedApplyData));
897
- } else if (buildFixInfo) {
898
- sharedApplyData.capReached = !!buildFixInfo.capReached;
899
- $msg.attr("data-fp-apply-build-fix", JSON.stringify(sharedApplyData));
900
- }
889
+ if (buildFixInfo) { sharedApplyData.capReached = !!buildFixInfo.capReached; }
890
+ var _modRecord = addRecord("review", {
891
+ subkind: buildFixInfo ? "build-fix" : "modify",
892
+ sharedApplyData: sharedApplyData,
893
+ state: "pending"
894
+ });
895
+ $msg.attr("data-fp-record-id", _modRecord.id);
901
896
 
902
897
  var $applyBtn = $("<button>")
903
898
  .addClass("red-ui-button red-ui-button-primary")
@@ -916,6 +911,7 @@
916
911
  }
917
912
  if (hasMutations && applyCallback) { applyCallback(nodeDiffs, removeNodes, null, idMap); }
918
913
  if (hasNewGroups) { applyGroupChanges(newGroups, idMap); }
914
+ if (_modRecord) { _modRecord.state = "applied"; }
919
915
  $applyBtn.text("Done ✓");
920
916
  });
921
917
  $actions.append($applyBtn);
@@ -1567,18 +1563,10 @@
1567
1563
  var v = validateGeneratedFlow(nodes);
1568
1564
 
1569
1565
  var $msg = $("<div>").addClass("fp-message fp-review");
1570
- // Pop-out slice 3 (plain Generate/Document, no onImported): tag
1571
- // with the raw flow data so the relay can wire up a WORKING "Add
1572
- // to workspace" button in the pop-out. /build's first proposal
1573
- // (onImported set, buildGoal present) gets its own tag instead —
1574
- // importing it also needs to start the loop (startBuildLoop),
1575
- // which the parent does itself once it gets the relayed intent;
1576
- // see the "applyBuild" handler in initMainWindow.
1577
- if (!onImported) {
1578
- $msg.attr("data-fp-apply-flow", JSON.stringify(nodes));
1579
- } else if (buildGoal) {
1580
- $msg.attr("data-fp-apply-build", JSON.stringify({ flow: nodes, goal: buildGoal }));
1581
- }
1566
+ // The review record (created in the action row when nodes are valid)
1567
+ // carries data-fp-record-id; the pop-out uses it to relay
1568
+ // applyByRecordId to the parent see bindReviewApplyButtons/
1569
+ // applyByRecordId in initMainWindow (Phase 10 0B).
1582
1570
  $("<div>").addClass("fp-label").text("GENERATED FLOW — REVIEW").appendTo($msg);
1583
1571
 
1584
1572
  var $tabSummary = $("<button>").addClass("fp-tab fp-tab-active").attr("type", "button").text("Summary");
@@ -1656,12 +1644,21 @@
1656
1644
  } else if (!nodes.length) {
1657
1645
  $("<div>").addClass("fp-warning").text("No nodes were generated — nothing to add.").appendTo($actions);
1658
1646
  } else {
1647
+ var _genRecord = addRecord("review", {
1648
+ subkind: onImported ? "build-generate" : "generate",
1649
+ flow: nodes,
1650
+ buildGoal: buildGoal || null,
1651
+ onImported: onImported || null,
1652
+ state: "pending"
1653
+ });
1654
+ $msg.attr("data-fp-record-id", _genRecord.id);
1659
1655
  var $addBtn = $("<button>")
1660
1656
  .addClass("red-ui-button red-ui-button-primary")
1661
1657
  .attr("type", "button")
1662
1658
  .text("Add to workspace")
1663
1659
  .on("click", function () {
1664
1660
  $addBtn.prop("disabled", true).text("Click the canvas to place…");
1661
+ if (_genRecord) { _genRecord.state = "applied"; }
1665
1662
  importGeneratedFlow(nodes, onImported);
1666
1663
  });
1667
1664
  $actions.append($addBtn);
@@ -1675,3 +1672,255 @@
1675
1672
  return $msg;
1676
1673
  }
1677
1674
 
1675
+ // ---- Refresh-from-history re-render helpers (Phase 10, 0A) -------------
1676
+ // Called by rerenderRecord() in main.js during refreshView(). Reconstruct
1677
+ // review panels from stored record data — the sharedApplyData already
1678
+ // holds everything needed without re-querying the live editor.
1679
+
1680
+ function rerenderReviewRecord(rec) {
1681
+ if (!rec) { return; }
1682
+ switch (rec.subkind) {
1683
+ case "modify":
1684
+ case "build-fix":
1685
+ rerenderModifyReview(rec);
1686
+ break;
1687
+ case "generate":
1688
+ case "build-generate":
1689
+ rerenderGeneratedReview(rec);
1690
+ break;
1691
+ }
1692
+ }
1693
+
1694
+ function rerenderModifyReview(rec) {
1695
+ var $box = el("#fp-messages");
1696
+ if (!$box.length) { return; }
1697
+
1698
+ var d = rec.sharedApplyData || {};
1699
+ var nodeDiffs = Array.isArray(d.nodeDiffs) ? d.nodeDiffs : [];
1700
+ var removeNodes = Array.isArray(d.removeNodes) ? d.removeNodes : [];
1701
+ var newNodes = Array.isArray(d.newNodes) ? d.newNodes : [];
1702
+ var newWires = Array.isArray(d.newWires) ? d.newWires : [];
1703
+ var newGroups = Array.isArray(d.newGroups) ? d.newGroups : [];
1704
+ var existingIds = Array.isArray(d.existingNodeIds) ? d.existingNodeIds : [];
1705
+ var hasMutations = !!d.hasMutations;
1706
+ var capReached = !!d.capReached;
1707
+ var isBuildFix = rec.subkind === "build-fix";
1708
+
1709
+ var hasPropChanges = nodeDiffs.some(function (nd) { return nd.propertyChanges && nd.propertyChanges.length > 0; });
1710
+ var hasWireChanges = nodeDiffs.some(function (nd) { return nd.wiresDiff && (nd.wiresDiff.toRemove.length > 0 || nd.wiresDiff.toAdd.length > 0); });
1711
+ var hasNewNodes = newNodes.length > 0;
1712
+ var hasRemoveNodes = removeNodes.length > 0;
1713
+ var hasNewGroups = newGroups.length > 0;
1714
+ var nodesWithChanges = nodeDiffs.filter(function (nd) { return nd.propertyChanges && nd.propertyChanges.length > 0; }).length;
1715
+ var missingLive = nodeDiffs.some(function (nd) { return nd.propertyChanges && nd.propertyChanges.length > 0 && !findLiveNode(nd.modNode && nd.modNode.id); });
1716
+
1717
+ // Add the record first so the Apply button's closure can reference it.
1718
+ var _newRec = addRecord("review", {
1719
+ subkind: rec.subkind,
1720
+ sharedApplyData: rec.sharedApplyData,
1721
+ state: rec.state
1722
+ });
1723
+
1724
+ var $msg = $("<div>").addClass("fp-message fp-review");
1725
+ $("<div>").addClass("fp-label")
1726
+ .text(isBuildFix ? "BUILD LOOP — FIX REVIEW" : "MODIFY FLOW — REVIEW CHANGES")
1727
+ .appendTo($msg);
1728
+
1729
+ $msg.attr("data-fp-record-id", _newRec.id);
1730
+
1731
+ var $tabSummary = $("<button>").addClass("fp-tab fp-tab-active").attr("type", "button").text("Summary");
1732
+ var $tabJson = $("<button>").addClass("fp-tab").attr("type", "button").text("JSON");
1733
+ $("<div>").addClass("fp-tabs").append($tabSummary, $tabJson).appendTo($msg);
1734
+
1735
+ var $summaryPanel = $("<div>").addClass("fp-tab-panel");
1736
+ var $jsonPanel = $("<div>").addClass("fp-tab-panel fp-hidden");
1737
+ $msg.append($summaryPanel, $jsonPanel);
1738
+
1739
+ // Prop diffs
1740
+ if (hasPropChanges) {
1741
+ $("<div>").addClass("fp-review-count")
1742
+ .text(nodesWithChanges + " of " + nodeDiffs.length + " node(s) will change:")
1743
+ .appendTo($summaryPanel);
1744
+ nodeDiffs.forEach(function (nd) {
1745
+ if (!nd.propertyChanges || !nd.propertyChanges.length) { return; }
1746
+ var $section = $("<div>").addClass("fp-diff-node").appendTo($summaryPanel);
1747
+ var title = nd.type + (nd.name ? " — \"" + nd.name + "\"" : "");
1748
+ $("<div>").addClass("fp-diff-node-title").text(title).appendTo($section);
1749
+ if (!findLiveNode(nd.modNode && nd.modNode.id)) {
1750
+ $("<div>").addClass("fp-diff-warn")
1751
+ .text("⚠ Node not found in editor (id: " + (nd.modNode && nd.modNode.id) + ")")
1752
+ .appendTo($section);
1753
+ return;
1754
+ }
1755
+ nd.propertyChanges.forEach(function (c) {
1756
+ var $row = $("<div>").addClass("fp-diff-row").appendTo($section);
1757
+ $("<span>").addClass("fp-diff-key").text(c.key).appendTo($row);
1758
+ $("<span>").addClass("fp-diff-old").text(formatDiffVal(c.oldVal)).appendTo($row);
1759
+ $("<span>").addClass("fp-diff-arrow").text("→").appendTo($row);
1760
+ $("<span>").addClass("fp-diff-new").text(formatDiffVal(c.newVal)).appendTo($row);
1761
+ });
1762
+ });
1763
+ }
1764
+ // Wire diffs — same rendering as original addModifyReview
1765
+ if (hasWireChanges) {
1766
+ nodeDiffs.forEach(function (nd) {
1767
+ var wd = nd.wiresDiff;
1768
+ if (!wd || (!wd.toRemove.length && !wd.toAdd.length)) { return; }
1769
+ var $section = $("<div>").addClass("fp-diff-node").appendTo($summaryPanel);
1770
+ $("<div>").addClass("fp-diff-node-title")
1771
+ .text(nd.type + (nd.name ? " — \"" + nd.name + "\"" : ""))
1772
+ .appendTo($section);
1773
+ wd.toRemove.forEach(function (entry) {
1774
+ var tgt = RED.nodes.node ? RED.nodes.node(entry.targetId) : null;
1775
+ var tgtLabel = tgt ? (tgt.name || tgt.type || entry.targetId) : entry.targetId;
1776
+ var $row = $("<div>").addClass("fp-diff-row").appendTo($section);
1777
+ $("<span>").addClass("fp-diff-key").text("port " + entry.sourcePort).appendTo($row);
1778
+ $("<span>").addClass("fp-diff-old").text("→ " + tgtLabel).appendTo($row);
1779
+ $("<span>").addClass("fp-diff-arrow").text("✕").appendTo($row);
1780
+ $("<span>").addClass("fp-diff-new").text("").appendTo($row);
1781
+ });
1782
+ wd.toAdd.forEach(function (entry) {
1783
+ var tgt = RED.nodes.node ? RED.nodes.node(entry.targetId) : null;
1784
+ var tgtLabel = tgt ? (tgt.name || tgt.type || entry.targetId) : entry.targetId;
1785
+ var $row = $("<div>").addClass("fp-diff-row").appendTo($section);
1786
+ $("<span>").addClass("fp-diff-key").text("port " + entry.sourcePort).appendTo($row);
1787
+ $("<span>").addClass("fp-diff-old").text("").appendTo($row);
1788
+ $("<span>").addClass("fp-diff-arrow").text("→").appendTo($row);
1789
+ $("<span>").addClass("fp-diff-new").text(tgtLabel).appendTo($row);
1790
+ });
1791
+ });
1792
+ }
1793
+ // Removals
1794
+ if (hasRemoveNodes) {
1795
+ $("<div>").addClass("fp-review-count fp-diff-warn")
1796
+ .text(removeNodes.length + " node(s) to remove:")
1797
+ .appendTo($summaryPanel);
1798
+ var $rmList = $("<ul>").addClass("fp-review-list").appendTo($summaryPanel);
1799
+ removeNodes.forEach(function (id) {
1800
+ var lv = RED.nodes.node ? RED.nodes.node(id) : null;
1801
+ var label = lv ? ((lv.name || lv.type || id) + " (" + id + ")") : id + " (not found)";
1802
+ $("<li>").addClass("fp-diff-warn").text("✕ " + label).appendTo($rmList);
1803
+ });
1804
+ }
1805
+ // New nodes
1806
+ if (hasNewNodes) {
1807
+ $("<div>").addClass("fp-review-count")
1808
+ .text(newNodes.length + " node(s) to insert:")
1809
+ .appendTo($summaryPanel);
1810
+ var $newList = $("<ul>").addClass("fp-review-list").appendTo($summaryPanel);
1811
+ newNodes.forEach(function (n) {
1812
+ $("<li>").text((n.type || "unknown") + (n.name ? " — \"" + n.name + "\"" : "")).appendTo($newList);
1813
+ });
1814
+ if (newWires.length > 0) {
1815
+ $("<div>").addClass("fp-review-count").css("margin-top", "8px")
1816
+ .text(newWires.length + " wire connection(s):")
1817
+ .appendTo($summaryPanel);
1818
+ var $wireList = $("<ul>").addClass("fp-review-list").appendTo($summaryPanel);
1819
+ newWires.forEach(function (wire) {
1820
+ var fromLabel = resolveWireRef(wire.from, newNodes);
1821
+ var toLabel = resolveWireRef(wire.to, newNodes);
1822
+ var portNote = (wire.fromPort && wire.fromPort > 0) ? " [port " + wire.fromPort + "]" : "";
1823
+ $("<li>").text(fromLabel + portNote + " → " + toLabel).appendTo($wireList);
1824
+ });
1825
+ }
1826
+ }
1827
+ // Groups
1828
+ if (hasNewGroups) {
1829
+ $("<div>").addClass("fp-review-count")
1830
+ .text(newGroups.length + " group(s) to create/update:")
1831
+ .appendTo($summaryPanel);
1832
+ var $grpList = $("<ul>").addClass("fp-review-list").appendTo($summaryPanel);
1833
+ newGroups.forEach(function (g) {
1834
+ $("<li>").text((g.name ? "\"" + g.name + "\"" : "(unnamed)") +
1835
+ " — " + (Array.isArray(g.nodes) ? g.nodes.length : 0) + " node(s)").appendTo($grpList);
1836
+ });
1837
+ }
1838
+
1839
+ // JSON tab
1840
+ var jsonText = JSON.stringify(d, null, 2);
1841
+ var $copyBtn = $("<button>").addClass("red-ui-button red-ui-button-small").attr("type", "button").text("Copy")
1842
+ .on("click", function () { copyToClipboard($copyBtn, jsonText); });
1843
+ $("<div>").addClass("fp-json-toolbar").append($copyBtn).appendTo($jsonPanel);
1844
+ $("<pre>").addClass("fp-json").text(jsonText).appendTo($jsonPanel);
1845
+
1846
+ $tabSummary.on("click", function () {
1847
+ $tabSummary.addClass("fp-tab-active"); $tabJson.removeClass("fp-tab-active");
1848
+ $summaryPanel.removeClass("fp-hidden"); $jsonPanel.addClass("fp-hidden");
1849
+ });
1850
+ $tabJson.on("click", function () {
1851
+ $tabJson.addClass("fp-tab-active"); $tabSummary.removeClass("fp-tab-active");
1852
+ $jsonPanel.removeClass("fp-hidden"); $summaryPanel.addClass("fp-hidden");
1853
+ });
1854
+
1855
+ // Action row
1856
+ var $actions = $("<div>").addClass("fp-review-actions").appendTo($msg);
1857
+ if (rec.state === "applied") {
1858
+ $("<button>").addClass("red-ui-button red-ui-button-primary")
1859
+ .attr("type", "button").prop("disabled", true).text("Applied ✓")
1860
+ .appendTo($actions);
1861
+ } else if (missingLive && (hasPropChanges || hasWireChanges)) {
1862
+ $("<div>").addClass("fp-warning")
1863
+ .text("One or more nodes could not be found in the editor. Cannot apply safely.")
1864
+ .appendTo($actions);
1865
+ } else {
1866
+ var btnLabel = (hasMutations && hasNewNodes) ? "Apply & Insert"
1867
+ : hasMutations ? "Apply Changes"
1868
+ : hasNewNodes ? "Insert Nodes"
1869
+ : "Apply Changes";
1870
+ var $applyBtn = $("<button>").addClass("red-ui-button red-ui-button-primary")
1871
+ .attr("type", "button").text(btnLabel)
1872
+ .on("click", function () {
1873
+ $applyBtn.prop("disabled", true).text("Applying…");
1874
+ var idMap = {};
1875
+ if (hasNewNodes) { idMap = applyInsertions(newNodes, newWires, existingIds) || {}; }
1876
+ if (isBuildFix) {
1877
+ applyBuildLoopFix(nodeDiffs, removeNodes, idMap, capReached);
1878
+ } else {
1879
+ if (hasMutations) { applyModifications(nodeDiffs, removeNodes, null, idMap); }
1880
+ if (hasNewGroups) { applyGroupChanges(newGroups, idMap); }
1881
+ }
1882
+ if (_newRec) { _newRec.state = "applied"; }
1883
+ $applyBtn.text("Done ✓");
1884
+ });
1885
+ $actions.append($applyBtn);
1886
+ var hintParts = [];
1887
+ if (hasPropChanges || hasWireChanges) { hintParts.push("changes mutate live nodes"); }
1888
+ if (hasRemoveNodes) { hintParts.push("removals delete nodes"); }
1889
+ if (hasNewGroups) { hintParts.push("groups are created/updated"); }
1890
+ if (hasNewNodes) { hintParts.push("insertions add new nodes"); }
1891
+ if (hintParts.length) {
1892
+ $("<span>").addClass("fp-review-hint")
1893
+ .text("Review above — " + hintParts.join(", ") + ". Ctrl+Z to undo.")
1894
+ .appendTo($actions);
1895
+ }
1896
+ }
1897
+
1898
+ $box.append($msg);
1899
+ scrollMessagesToBottom();
1900
+ }
1901
+
1902
+ function rerenderGeneratedReview(rec) {
1903
+ var nodes = Array.isArray(rec.flow) ? rec.flow : [];
1904
+ if (rec.state === "applied") {
1905
+ var $box = el("#fp-messages");
1906
+ if (!$box.length) { return; }
1907
+ var $msg = $("<div>").addClass("fp-message fp-review");
1908
+ $("<div>").addClass("fp-label").text("GENERATED FLOW — APPLIED ✓").appendTo($msg);
1909
+ $("<div>").addClass("fp-review-actions")
1910
+ .append($("<button>").addClass("red-ui-button red-ui-button-primary")
1911
+ .attr("type", "button").prop("disabled", true).text("Applied ✓"))
1912
+ .appendTo($msg);
1913
+ $box.append($msg);
1914
+ addRecord("review", {
1915
+ subkind: rec.subkind, flow: rec.flow,
1916
+ buildGoal: rec.buildGoal, onImported: rec.onImported, state: "applied"
1917
+ });
1918
+ scrollMessagesToBottom();
1919
+ } else {
1920
+ // Re-call addGeneratedReview — it revalidates and creates a fully
1921
+ // interactive panel, including its own addRecord call. onImported is
1922
+ // a live function ref (valid within the same session).
1923
+ addGeneratedReview(nodes, rec.onImported || null, rec.buildGoal || null);
1924
+ }
1925
+ }
1926
+