@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 +15 -0
- package/flowpilot-core.css +59 -0
- package/flowpilot.js +85 -11
- package/lib/core/apply-review.js +278 -29
- package/lib/core/init.js +57 -132
- package/lib/core/main.js +68 -1
- package/lib/core/modes.js +180 -57
- package/lib/provider-openai-compatible.js +112 -15
- package/package.json +2 -2
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
|
package/flowpilot-core.css
CHANGED
|
@@ -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,
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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, {
|
|
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
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
package/lib/core/apply-review.js
CHANGED
|
@@ -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
|
-
//
|
|
867
|
-
//
|
|
868
|
-
//
|
|
869
|
-
//
|
|
870
|
-
// re-fetches
|
|
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 (
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
sharedApplyData
|
|
899
|
-
|
|
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
|
-
//
|
|
1571
|
-
//
|
|
1572
|
-
// to
|
|
1573
|
-
//
|
|
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
|
+
|