@manny-est/node-red-flowpilot 0.5.0 → 0.5.2
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 +94 -0
- package/README.md +21 -7
- package/USER-GUIDE.md +22 -13
- package/flowpilot-core.css +187 -2
- package/flowpilot.js +462 -33
- package/lib/build-system-prompt.js +26 -4
- package/lib/core/apply-review.js +494 -64
- package/lib/core/init.js +67 -132
- package/lib/core/main.js +160 -7
- package/lib/core/modes.js +876 -79
- package/lib/core/selection-context.js +28 -1
- package/lib/default-system-prompt.js +13 -9
- package/lib/document-system-prompt.js +19 -30
- package/lib/generation-system-prompt.js +24 -40
- package/lib/modify-system-prompt.js +98 -70
- package/lib/prompt-fragments.js +45 -0
- package/lib/provider-anthropic.js +385 -0
- package/lib/provider-openai-compatible.js +122 -16
- package/lib/storage.js +43 -2
- package/lib/validator.js +238 -0
- package/package.json +2 -2
package/lib/core/modes.js
CHANGED
|
@@ -144,62 +144,131 @@
|
|
|
144
144
|
if (isChat) { pushHistory("user", prompt + note); }
|
|
145
145
|
if (!promptOverride) { $promptBox.val(""); }
|
|
146
146
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
147
|
+
function dispatch() {
|
|
148
|
+
var ap = activeProvider();
|
|
149
|
+
var isAgentLoop = isChat && ap && ap.supportsTools;
|
|
150
|
+
|
|
151
|
+
setBusy(true);
|
|
152
|
+
showPending(isAgentLoop);
|
|
153
|
+
var payload = {
|
|
154
|
+
prompt: prompt,
|
|
155
|
+
context: context,
|
|
156
|
+
history: historyPayload.messages,
|
|
157
|
+
historyTruncated: historyPayload.truncated,
|
|
158
|
+
conversationId: conversationId
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
function handleSendResult(data) {
|
|
162
|
+
hidePending();
|
|
163
|
+
// Render a collapsed thinking block for non-streaming reasoning models
|
|
164
|
+
// (the streaming path handles this live in sendChatStream instead).
|
|
165
|
+
if (data.reasoningContent) {
|
|
166
|
+
var $box = el("#fp-messages");
|
|
167
|
+
var approxTokens = Math.round(data.reasoningContent.length / 4);
|
|
168
|
+
var $thinking = $("<details>").addClass("fp-thinking");
|
|
169
|
+
var $summary = $("<summary>").appendTo($thinking);
|
|
170
|
+
$("<span>").text("Thinking").appendTo($summary);
|
|
171
|
+
$("<span>").addClass("fp-thinking-tokens").text(approxTokens + " tokens").appendTo($summary);
|
|
172
|
+
$("<div>").addClass("fp-thinking-body").text(data.reasoningContent).appendTo($thinking);
|
|
173
|
+
$box.append($thinking);
|
|
174
|
+
}
|
|
175
|
+
var message = data.message || JSON.stringify(data, null, 2);
|
|
176
|
+
// Test Provider also reports tool-calling support, used by
|
|
177
|
+
// the agentic path. Mirror the probe results into currentSettings
|
|
178
|
+
// so the auto-preflight condition (probedModel !== model) has a
|
|
179
|
+
// baseline to compare against without requiring a page reload.
|
|
180
|
+
if (data.capability && data.capability.label) {
|
|
181
|
+
message += "\n\n" + data.capability.label;
|
|
182
|
+
}
|
|
183
|
+
if (endpoint === "test" && data.capability && data.capability.probedModel) {
|
|
184
|
+
var testAp = activeProvider();
|
|
185
|
+
if (testAp && currentSettings && Array.isArray(currentSettings.providers)) {
|
|
186
|
+
currentSettings.providers = currentSettings.providers.map(function(p) {
|
|
187
|
+
return p.id === testAp.id ? Object.assign({}, p, {
|
|
188
|
+
supportsTools: data.capability.supportsTools,
|
|
189
|
+
isReasoningModel: data.capability.isReasoningModel,
|
|
190
|
+
probedModel: data.capability.probedModel
|
|
191
|
+
}) : p;
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (endpoint === "test") {
|
|
196
|
+
message += "\n\nAll set — try `/help` for the full briefing and shortcut list.";
|
|
197
|
+
}
|
|
198
|
+
addMessage("assistant", message);
|
|
199
|
+
if (isChat) {
|
|
200
|
+
pushHistory("assistant", data.message || "");
|
|
201
|
+
renderActionChip(data.suggestedAction);
|
|
202
|
+
renderClarifyingQuestion(data.questionOptions);
|
|
203
|
+
}
|
|
204
|
+
setBusy(false);
|
|
205
|
+
updateSelectionStatus();
|
|
167
206
|
}
|
|
168
|
-
|
|
169
|
-
|
|
207
|
+
|
|
208
|
+
function handleSendError(msg) {
|
|
209
|
+
hidePending();
|
|
210
|
+
if (isChat) { popDanglingUserHistory(); }
|
|
211
|
+
addMessage("error", msg);
|
|
212
|
+
setBusy(false);
|
|
170
213
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
214
|
+
|
|
215
|
+
// When the active provider supports tool/function calling,
|
|
216
|
+
// the chat turn is offered the Tier-1 read tools and run through the
|
|
217
|
+
// bounded agent loop instead of a single request.
|
|
218
|
+
if (isAgentLoop) {
|
|
219
|
+
runAgentChat(payload, handleSendResult, handleSendError);
|
|
220
|
+
return;
|
|
176
221
|
}
|
|
177
|
-
setBusy(false);
|
|
178
|
-
updateSelectionStatus();
|
|
179
|
-
}
|
|
180
222
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
223
|
+
if (isChat && currentSettings.streamingEnabled) {
|
|
224
|
+
payload.stream = true;
|
|
225
|
+
sendChatStream(payload);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
187
228
|
|
|
188
|
-
|
|
189
|
-
// the chat turn is offered the Tier-1 read tools and run through the
|
|
190
|
-
// bounded agent loop instead of a single request.
|
|
191
|
-
if (isAgentLoop) {
|
|
192
|
-
runAgentChat(payload, handleSendResult, handleSendError);
|
|
193
|
-
return;
|
|
229
|
+
ajaxJson("POST", "flowpilot/" + endpoint, payload, handleSendResult, handleSendError);
|
|
194
230
|
}
|
|
195
231
|
|
|
196
|
-
if
|
|
197
|
-
|
|
198
|
-
|
|
232
|
+
// Silent preflight: if the model changed since the last probe, save and
|
|
233
|
+
// re-probe before routing — stale supportsTools silently misroutes chat
|
|
234
|
+
// (agent-loop path vs streaming/non-streaming).
|
|
235
|
+
// Compare against the LIVE DOM value so unsaved edits trigger correctly;
|
|
236
|
+
// save first so the backend probes the right model.
|
|
237
|
+
var ap = activeProvider();
|
|
238
|
+
var liveModel = (el("#fp-model").length ? el("#fp-model").val() : null) || (ap && ap.model) || "";
|
|
239
|
+
if (isChat && ap && ap.probedModel && liveModel && liveModel !== ap.probedModel) {
|
|
240
|
+
setBusy(true);
|
|
241
|
+
showPending(false);
|
|
242
|
+
setAgentNarration("Pre-flight…");
|
|
243
|
+
saveSettings(function() {
|
|
244
|
+
var ap2 = activeProvider();
|
|
245
|
+
ajaxJson("POST", "flowpilot/probe", {}, function(result) {
|
|
246
|
+
if (currentSettings && Array.isArray(currentSettings.providers)) {
|
|
247
|
+
var targetId = (ap2 || ap).id;
|
|
248
|
+
currentSettings.providers = currentSettings.providers.map(function(p) {
|
|
249
|
+
return p.id === targetId ? Object.assign({}, p, {
|
|
250
|
+
supportsTools: result.supportsTools,
|
|
251
|
+
isReasoningModel: result.isReasoningModel,
|
|
252
|
+
probedModel: result.probedModel
|
|
253
|
+
}) : p;
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
hidePending();
|
|
257
|
+
var caps = [];
|
|
258
|
+
if (result.supportsTools) { caps.push("Tools ✓"); } else { caps.push("Tools ✗"); }
|
|
259
|
+
if (result.isReasoningModel) { caps.push("Reasoning ✓"); }
|
|
260
|
+
addMessage("notice", "Pre-flight: " + (result.probedModel || liveModel) + " · " + caps.join(" · "));
|
|
261
|
+
dispatch();
|
|
262
|
+
}, function() {
|
|
263
|
+
hidePending();
|
|
264
|
+
addMessage("notice", "Pre-flight failed — continuing with cached capabilities.");
|
|
265
|
+
dispatch();
|
|
266
|
+
});
|
|
267
|
+
});
|
|
199
268
|
return;
|
|
200
269
|
}
|
|
201
270
|
|
|
202
|
-
|
|
271
|
+
dispatch();
|
|
203
272
|
}
|
|
204
273
|
|
|
205
274
|
// ---------------------------------------------------------------------
|
|
@@ -348,25 +417,53 @@
|
|
|
348
417
|
// wait until real content starts arriving.
|
|
349
418
|
var $msg = null;
|
|
350
419
|
var $text = null;
|
|
420
|
+
var _chatRec = null;
|
|
421
|
+
|
|
422
|
+
// Reasoning block (shown for reasoning models that emit reasoning_content).
|
|
423
|
+
var $thinking = null;
|
|
424
|
+
var $thinkingBody = null;
|
|
425
|
+
var $thinkingTokens = null;
|
|
426
|
+
var reasoningBuf = "";
|
|
351
427
|
|
|
352
428
|
var fullText = "";
|
|
353
429
|
var finalData = null;
|
|
354
430
|
|
|
431
|
+
function ensureThinkingBlock() {
|
|
432
|
+
if ($thinking) { return; }
|
|
433
|
+
hidePending();
|
|
434
|
+
$thinking = $("<details>").addClass("fp-thinking").attr("open", "");
|
|
435
|
+
var $summary = $("<summary>").appendTo($thinking);
|
|
436
|
+
$("<span>").text("Thinking").appendTo($summary);
|
|
437
|
+
$thinkingTokens = $("<span>").addClass("fp-thinking-tokens").appendTo($summary);
|
|
438
|
+
$thinkingBody = $("<div>").addClass("fp-thinking-body").appendTo($thinking);
|
|
439
|
+
$box.append($thinking);
|
|
440
|
+
scrollMessagesToBottom();
|
|
441
|
+
}
|
|
442
|
+
|
|
355
443
|
function ensureBubble() {
|
|
356
444
|
if ($text) { return; }
|
|
357
445
|
hidePending();
|
|
358
|
-
|
|
446
|
+
// Collapse the thinking block the moment real content starts flowing.
|
|
447
|
+
if ($thinking) { $thinking.prop("open", false); }
|
|
448
|
+
_chatRec = addMessage("assistant", "");
|
|
359
449
|
$msg = $box.find(".fp-message").last();
|
|
360
450
|
$text = $msg.find("div").last();
|
|
361
451
|
}
|
|
362
452
|
|
|
363
453
|
function finish() {
|
|
364
454
|
hidePending();
|
|
455
|
+
// Stamp approximate token count on the thinking block once we're done.
|
|
456
|
+
if ($thinking && reasoningBuf) {
|
|
457
|
+
var approxTokens = Math.round(reasoningBuf.length / 4);
|
|
458
|
+
$thinkingTokens.text(approxTokens + " tokens");
|
|
459
|
+
}
|
|
365
460
|
if (!fullText) {
|
|
366
461
|
if ($msg && $msg.length) { $msg.remove(); }
|
|
462
|
+
if (_chatRec) { messageRecords.splice(messageRecords.indexOf(_chatRec), 1); _chatRec = null; }
|
|
367
463
|
popDanglingUserHistory();
|
|
368
464
|
addMessage("error", "No response received from the provider.");
|
|
369
465
|
} else {
|
|
466
|
+
if (_chatRec) { _chatRec.text = fullText; _chatRec.streamingComplete = true; }
|
|
370
467
|
pushHistory("assistant", fullText);
|
|
371
468
|
if (finalData) {
|
|
372
469
|
renderActionChip(finalData.suggestedAction);
|
|
@@ -392,14 +489,10 @@
|
|
|
392
489
|
}
|
|
393
490
|
|
|
394
491
|
// Shared SSE-line parser: handles `data: {"delta":"..."}` /
|
|
395
|
-
// `data: {"
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
// called once with the full body split into lines), so neither path
|
|
400
|
-
// can drift or show raw SSE text. The backend withholds any trailing
|
|
401
|
-
// <<<FLOWPILOT_DATA>>> block from `delta`s entirely and relays its
|
|
402
|
-
// parsed suggestedAction/questionOptions as a single `final` event.
|
|
492
|
+
// `data: {"reasoningDelta":"..."}` / `data: {"final":{...}}` /
|
|
493
|
+
// `data: {"error":"..."}` / `data: [DONE]` lines. Used by both the
|
|
494
|
+
// streaming pump() loop and the non-getReader fallback so neither path
|
|
495
|
+
// can drift or show raw SSE text.
|
|
403
496
|
function processSseLines(lines) {
|
|
404
497
|
lines.forEach(function (line) {
|
|
405
498
|
line = line.trim();
|
|
@@ -409,10 +502,17 @@
|
|
|
409
502
|
var evt;
|
|
410
503
|
try { evt = JSON.parse(dataStr); } catch (e) { return; }
|
|
411
504
|
if (evt.error) { throw new Error(evt.error); }
|
|
412
|
-
if (evt.
|
|
505
|
+
if (evt.reasoningDelta) {
|
|
506
|
+
reasoningBuf += evt.reasoningDelta;
|
|
507
|
+
ensureThinkingBlock();
|
|
508
|
+
$thinkingBody.text(reasoningBuf);
|
|
509
|
+
$thinkingBody[0].scrollTop = $thinkingBody[0].scrollHeight;
|
|
510
|
+
scrollMessagesToBottom();
|
|
511
|
+
} else if (evt.delta) {
|
|
413
512
|
fullText += evt.delta;
|
|
414
513
|
ensureBubble();
|
|
415
514
|
$text.html(renderMarkdown(fullText));
|
|
515
|
+
if (_chatRec) { _chatRec.text = fullText; }
|
|
416
516
|
scrollMessagesToBottom();
|
|
417
517
|
} else if (evt.final) {
|
|
418
518
|
finalData = evt.final;
|
|
@@ -580,6 +680,7 @@
|
|
|
580
680
|
}
|
|
581
681
|
|
|
582
682
|
$box.append($row);
|
|
683
|
+
addRecord("chip", { chipType: "suggestedAction", suggestedAction: suggestedAction });
|
|
583
684
|
scrollMessagesToBottom();
|
|
584
685
|
}
|
|
585
686
|
|
|
@@ -667,6 +768,7 @@
|
|
|
667
768
|
.appendTo($row);
|
|
668
769
|
|
|
669
770
|
$box.append($row).append($otherRow);
|
|
771
|
+
addRecord("question", { options: options });
|
|
670
772
|
scrollMessagesToBottom();
|
|
671
773
|
}
|
|
672
774
|
|
|
@@ -710,15 +812,20 @@
|
|
|
710
812
|
var flow = Array.isArray(data.flow) ? layoutGeneratedFlow(data.flow) : data.flow;
|
|
711
813
|
addMessage("assistant", data.explanation || "(no explanation returned)");
|
|
712
814
|
pushHistory("assistant", data.explanation || "(no explanation returned)");
|
|
713
|
-
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
//
|
|
717
|
-
//
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
815
|
+
// B1: bake the deploy-verify option into the review panel as the
|
|
816
|
+
// primary chip rather than a separate chip below it. Only for
|
|
817
|
+
// executable flows (not documentation-only comment nodes) and when no
|
|
818
|
+
// loop is already active. The secondary "Just add to canvas" button is
|
|
819
|
+
// always shown alongside it as an escape hatch.
|
|
820
|
+
var _hasDeployable = flow.some(function (n) { return n && n.type !== "comment" && n.type !== "group"; });
|
|
821
|
+
var _buildOnImported = (goalPrompt && !activeBuildLoop && _hasDeployable)
|
|
822
|
+
? function (importResult) { startBuildLoop(goalPrompt, flow, importResult); }
|
|
823
|
+
: null;
|
|
824
|
+
addGeneratedReview(flow, _buildOnImported, _buildOnImported ? goalPrompt : null);
|
|
825
|
+
// Suppress a server-suggested build chip when deploy-verify is already
|
|
826
|
+
// the primary action inside the review panel — it would be a duplicate.
|
|
827
|
+
renderActionChip(_buildOnImported && data.suggestedAction && data.suggestedAction.mode === "build"
|
|
828
|
+
? null : data.suggestedAction);
|
|
722
829
|
setBusy(false);
|
|
723
830
|
updateSelectionStatus();
|
|
724
831
|
}
|
|
@@ -729,10 +836,41 @@
|
|
|
729
836
|
hidePending();
|
|
730
837
|
if (renderQuestionOrProse(data)) { return; }
|
|
731
838
|
|
|
839
|
+
// W4: parse and surface the Plan: block if present.
|
|
840
|
+
var planItems = parseTodoPlan(data.explanation || "");
|
|
841
|
+
var todoRec = null;
|
|
842
|
+
if (planItems.length) {
|
|
843
|
+
// Verification only produces one aggregate pass/fail result for
|
|
844
|
+
// the whole Modify response — mark every item active up front so
|
|
845
|
+
// they all resolve together instead of leaving items 2+ stuck at
|
|
846
|
+
// "pending" forever (only item 1 would ever flip otherwise).
|
|
847
|
+
planItems.forEach(function (item) { item.status = "active"; });
|
|
848
|
+
todoRec = addRecord("todo", { action: "modify", items: planItems });
|
|
849
|
+
rerenderTodoRecord(todoRec);
|
|
850
|
+
}
|
|
851
|
+
|
|
732
852
|
addMessage("assistant", data.explanation || "(no explanation returned)");
|
|
733
853
|
pushHistory("assistant", data.explanation || "(no explanation returned)");
|
|
734
854
|
if (data.skippedNote) { addMessage("assistant", "⚠ " + data.skippedNote); }
|
|
735
|
-
|
|
855
|
+
|
|
856
|
+
// W4 Phase 2: wrap apply to run a real graph read-back via
|
|
857
|
+
// verifySteps (Track A, server) instead of unconditionally marking
|
|
858
|
+
// the todo done. Falls back to the Phase 1 behavior (check off with
|
|
859
|
+
// no verification) when the server sent no verifySteps.
|
|
860
|
+
var verifySteps = Array.isArray(data.verifySteps) ? data.verifySteps : [];
|
|
861
|
+
var applyCallback = todoRec ? function(nodeDiffs, removeNodes, $btn, idMap) {
|
|
862
|
+
applyModifications(nodeDiffs, removeNodes, $btn, idMap);
|
|
863
|
+
if (verifySteps.length) {
|
|
864
|
+
verifyModifySteps(verifySteps, idMap, todoRec);
|
|
865
|
+
} else {
|
|
866
|
+
todoRec.items.forEach(function(item) {
|
|
867
|
+
if (item.status === "active") { item.status = "done"; }
|
|
868
|
+
});
|
|
869
|
+
rerenderTodoRecord(todoRec);
|
|
870
|
+
}
|
|
871
|
+
} : applyModifications;
|
|
872
|
+
|
|
873
|
+
addModifyReview(data.flow, data.newNodes || [], data.newWires || [], data.removeNodes || [], applyCallback, null, data.newGroups || []);
|
|
736
874
|
renderActionChip(data.suggestedAction);
|
|
737
875
|
setBusy(false);
|
|
738
876
|
updateSelectionStatus();
|
|
@@ -953,8 +1091,272 @@
|
|
|
953
1091
|
ajaxJson("POST", fullEndpoint, payload, wrappedOnResult, onError);
|
|
954
1092
|
}
|
|
955
1093
|
|
|
1094
|
+
// W4: parse a "Plan:" block from the model's explanation field.
|
|
1095
|
+
// Returns an array of { text, status } items, or [] if none found.
|
|
1096
|
+
// Each numbered/bulleted line under "Plan:" up to the first blank line
|
|
1097
|
+
// becomes one item. Status starts as "pending" for all items — the
|
|
1098
|
+
// caller sets the first to "active" before rendering.
|
|
1099
|
+
function parseTodoPlan(explanation) {
|
|
1100
|
+
if (!explanation || typeof explanation !== "string") { return []; }
|
|
1101
|
+
var planStart = explanation.indexOf("Plan:");
|
|
1102
|
+
if (planStart === -1) { return []; }
|
|
1103
|
+
var afterPlan = explanation.slice(planStart + 5);
|
|
1104
|
+
var planBlock = afterPlan.split(/\n\n/)[0];
|
|
1105
|
+
var lines = planBlock.split("\n");
|
|
1106
|
+
var items = [];
|
|
1107
|
+
lines.forEach(function (line) {
|
|
1108
|
+
var stripped = line.replace(/^\s*\d+[.):\s]+/, "").replace(/^\s*[-*]\s+/, "").trim();
|
|
1109
|
+
if (stripped) { items.push({ text: stripped, status: "pending" }); }
|
|
1110
|
+
});
|
|
1111
|
+
return items;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// W4: render or re-render a "todo" record. For a 1-item plan, renders
|
|
1115
|
+
// as a compact status line (one chip). For N>1 items, renders as a
|
|
1116
|
+
// checklist card. Updates in place when the record already has a
|
|
1117
|
+
// data-fp-todo-id element in the message box (e.g. on verify check-off).
|
|
1118
|
+
function rerenderTodoRecord(rec) {
|
|
1119
|
+
if (!rec || !rec.items) { return; }
|
|
1120
|
+
var $box = el("#fp-messages");
|
|
1121
|
+
if (!$box.length) { return; }
|
|
1122
|
+
var items = rec.items;
|
|
1123
|
+
var $existing = $box.find("[data-fp-todo-id='" + rec.id + "']");
|
|
1124
|
+
|
|
1125
|
+
var $wrap;
|
|
1126
|
+
if (items.length === 1) {
|
|
1127
|
+
var item = items[0];
|
|
1128
|
+
var icon = item.status === "done" ? "✓" : item.status === "failed" ? "✗" : "▶";
|
|
1129
|
+
$wrap = $("<div>")
|
|
1130
|
+
.addClass("fp-todo-status fp-todo-" + item.status)
|
|
1131
|
+
.attr("data-fp-todo-id", rec.id)
|
|
1132
|
+
.text(icon + " " + item.text);
|
|
1133
|
+
} else {
|
|
1134
|
+
$wrap = $("<div>")
|
|
1135
|
+
.addClass("fp-todo-card")
|
|
1136
|
+
.attr("data-fp-todo-id", rec.id);
|
|
1137
|
+
var $ul = $("<ul>").addClass("fp-todo-list");
|
|
1138
|
+
items.forEach(function (item) {
|
|
1139
|
+
var icon = item.status === "done" ? "✓" :
|
|
1140
|
+
item.status === "failed" ? "✗" :
|
|
1141
|
+
item.status === "active" ? "▶" : "○";
|
|
1142
|
+
$("<li>")
|
|
1143
|
+
.addClass("fp-todo-item fp-todo-item-" + item.status)
|
|
1144
|
+
.text(icon + " " + item.text)
|
|
1145
|
+
.appendTo($ul);
|
|
1146
|
+
});
|
|
1147
|
+
$wrap.append($ul);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
if ($existing.length) {
|
|
1151
|
+
$existing.replaceWith($wrap);
|
|
1152
|
+
} else {
|
|
1153
|
+
$box.append($wrap);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// Step-queue path for Generate (opt-in via enableStepQueue setting).
|
|
1158
|
+
// After the user clicks "Add to workspace", performs a synchronous graph
|
|
1159
|
+
// read-back — calls RED.nodes.node(id) for every node that landed on the
|
|
1160
|
+
// canvas — and surfaces the result as a verification notice. This is the
|
|
1161
|
+
// structural verification step for Generate: "did the import actually land?"
|
|
1162
|
+
// not "does it do the thing?" (that's the semantic loop, Build's domain).
|
|
1163
|
+
// Skips comment and group nodes — those aren't addressable via RED.nodes.node.
|
|
1164
|
+
// todoRec: optional todo record to check off (or fail) after verification.
|
|
1165
|
+
function verifyImportedNodes(importResult, todoRec) {
|
|
1166
|
+
if (!importResult || !importResult.nodeMap) { return; }
|
|
1167
|
+
var nodeMap = importResult.nodeMap;
|
|
1168
|
+
var total = 0, found = 0, missing = [];
|
|
1169
|
+
// Config nodes (e.g. an http-request's TLS config, an mqtt broker
|
|
1170
|
+
// config) ride along in nodeMap whenever the model's own `flow`
|
|
1171
|
+
// array included them, but they aren't part of what the user asked
|
|
1172
|
+
// for — RED.nodes.node() resolves them same as regular nodes (it
|
|
1173
|
+
// checks configNodes[id] before falling back), so left uncounted
|
|
1174
|
+
// they'd silently inflate the headline total (e.g. "8" instead of
|
|
1175
|
+
// "5"). Track and verify them separately instead.
|
|
1176
|
+
var configTotal = 0, configFound = 0;
|
|
1177
|
+
// Node-RED's own RED.nodes.import (the generateIds:true path used
|
|
1178
|
+
// for every Generate/Build import) keys nodeMap TWICE per imported
|
|
1179
|
+
// node: once under the model's own placeholder id (assigned while
|
|
1180
|
+
// constructing the node, before it's added to the live registry)
|
|
1181
|
+
// and again under the freshly-generated real editor id (assigned in
|
|
1182
|
+
// the final addNode/addGroup/addJunction registration loop) — both
|
|
1183
|
+
// entries point to the same live node object. Left undeduped this
|
|
1184
|
+
// doubles every count here (visible AND config alike), independent
|
|
1185
|
+
// of the config/visible split above. Confirmed by reading
|
|
1186
|
+
// @node-red/editor-client/public/red/red.js's importNodes directly.
|
|
1187
|
+
var seenLiveIds = {};
|
|
1188
|
+
Object.keys(nodeMap).forEach(function (pid) {
|
|
1189
|
+
var liveNode = nodeMap[pid];
|
|
1190
|
+
if (!liveNode || !liveNode.id) { return; }
|
|
1191
|
+
if (seenLiveIds[liveNode.id]) { return; }
|
|
1192
|
+
seenLiveIds[liveNode.id] = true;
|
|
1193
|
+
if (liveNode.type === "comment" || liveNode.type === "group") { return; }
|
|
1194
|
+
if (liveNode._def && liveNode._def.category === "config") {
|
|
1195
|
+
configTotal++;
|
|
1196
|
+
if (RED.nodes.node(liveNode.id)) { configFound++; }
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
total++;
|
|
1200
|
+
if (RED.nodes.node(liveNode.id)) {
|
|
1201
|
+
found++;
|
|
1202
|
+
} else {
|
|
1203
|
+
missing.push(liveNode.type || pid);
|
|
1204
|
+
}
|
|
1205
|
+
});
|
|
1206
|
+
var configMissing = configTotal - configFound;
|
|
1207
|
+
var allGood = total > 0 && missing.length === 0 && configMissing === 0;
|
|
1208
|
+
if (total === 0) {
|
|
1209
|
+
// Nothing user-visible to verify (only comments/groups/config
|
|
1210
|
+
// nodes) — skip notice.
|
|
1211
|
+
} else if (allGood) {
|
|
1212
|
+
var configSuffix = configTotal > 0
|
|
1213
|
+
? " (+" + configTotal + " supporting config node(s))"
|
|
1214
|
+
: "";
|
|
1215
|
+
addMessage("fp-notice", "✓ Verified: all " + found + " node(s) confirmed on canvas" + configSuffix + ".");
|
|
1216
|
+
} else {
|
|
1217
|
+
var allMissing = missing.slice();
|
|
1218
|
+
if (configMissing > 0) { allMissing.push(configMissing + " config node(s)"); }
|
|
1219
|
+
addMessage("fp-notice", "⚠ Verification: " + found + "/" + total + " node(s) on canvas — " +
|
|
1220
|
+
allMissing.length + " not found after import (" + allMissing.join(", ") + "). " +
|
|
1221
|
+
"These may be uninstalled node types that were silently dropped.");
|
|
1222
|
+
}
|
|
1223
|
+
// Check off (or fail) the active todo item.
|
|
1224
|
+
if (todoRec && todoRec.items) {
|
|
1225
|
+
todoRec.items.forEach(function (item) {
|
|
1226
|
+
if (item.status === "active") { item.status = allGood ? "done" : "failed"; }
|
|
1227
|
+
});
|
|
1228
|
+
rerenderTodoRecord(todoRec);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// W4 Phase 2: real graph read-back verification for Modify, using the
|
|
1233
|
+
// server-derived verifySteps (Track A — property/exists/absent/wire
|
|
1234
|
+
// checks; see finalizeModifyResult in flowpilot.js). Mirrors
|
|
1235
|
+
// verifyImportedNodes's design for Generate: aggregate pass/fail across
|
|
1236
|
+
// all steps, surface one notice, and check off (or fail) the active
|
|
1237
|
+
// todo item. idMap resolves the response-time placeholder ids that
|
|
1238
|
+
// existence/wire checks on newly-inserted nodes carry (the server can't
|
|
1239
|
+
// know the browser-assigned id at response time — applyInsertions
|
|
1240
|
+
// assigns it and returns idMap). Property/absent checks already use
|
|
1241
|
+
// real existing-node ids, so idMap[id] simply misses and falls through
|
|
1242
|
+
// to the id unchanged.
|
|
1243
|
+
function verifyModifySteps(verifySteps, idMap, todoRec) {
|
|
1244
|
+
if (!Array.isArray(verifySteps) || !verifySteps.length) { return; }
|
|
1245
|
+
idMap = idMap || {};
|
|
1246
|
+
function resolve(id) { return (idMap && idMap[id]) || id; }
|
|
1247
|
+
|
|
1248
|
+
var total = 0, passed = 0, failures = [];
|
|
1249
|
+
verifySteps.forEach(function (step) {
|
|
1250
|
+
var ok = false;
|
|
1251
|
+
switch (step.check) {
|
|
1252
|
+
case "property": {
|
|
1253
|
+
var pNode = RED.nodes.node(resolve(step.nodeId));
|
|
1254
|
+
ok = !!pNode && pNode[step.prop] === step.expected;
|
|
1255
|
+
if (!ok) { failures.push((step.prop || "property") + " on " + step.nodeId); }
|
|
1256
|
+
break;
|
|
1257
|
+
}
|
|
1258
|
+
case "exists": {
|
|
1259
|
+
ok = !!RED.nodes.node(resolve(step.nodeId));
|
|
1260
|
+
if (!ok) { failures.push(step.nodeId + " missing"); }
|
|
1261
|
+
break;
|
|
1262
|
+
}
|
|
1263
|
+
case "absent": {
|
|
1264
|
+
ok = !RED.nodes.node(resolve(step.nodeId));
|
|
1265
|
+
if (!ok) { failures.push(step.nodeId + " still present"); }
|
|
1266
|
+
break;
|
|
1267
|
+
}
|
|
1268
|
+
case "wire": {
|
|
1269
|
+
// Read from the live link registry (RED.nodes.eachLink), not
|
|
1270
|
+
// node.wires — RED.nodes.addLink/removeLink never re-sync a
|
|
1271
|
+
// live node's own .wires array mid-session (it's only set at
|
|
1272
|
+
// import and recomputed at export), so fromNode.wires[port] is
|
|
1273
|
+
// stale for any wire added/removed during the current editing
|
|
1274
|
+
// session. Mirrors computeWireDiff (apply-review.js).
|
|
1275
|
+
var fromId = resolve(step.fromId);
|
|
1276
|
+
var toId = resolve(step.toId);
|
|
1277
|
+
var port = step.fromPort || 0;
|
|
1278
|
+
ok = false;
|
|
1279
|
+
RED.nodes.eachLink(function (l) {
|
|
1280
|
+
if (ok) { return; }
|
|
1281
|
+
if (l.source && l.source.id === fromId &&
|
|
1282
|
+
(l.sourcePort || 0) === port &&
|
|
1283
|
+
l.target && l.target.id === toId) {
|
|
1284
|
+
ok = true;
|
|
1285
|
+
}
|
|
1286
|
+
});
|
|
1287
|
+
if (!ok) { failures.push("wire " + step.fromId + " → " + step.toId); }
|
|
1288
|
+
break;
|
|
1289
|
+
}
|
|
1290
|
+
default:
|
|
1291
|
+
return; // unrecognized check type — don't count it either way
|
|
1292
|
+
}
|
|
1293
|
+
total++;
|
|
1294
|
+
if (ok) { passed++; }
|
|
1295
|
+
});
|
|
1296
|
+
|
|
1297
|
+
if (total === 0) { return; }
|
|
1298
|
+
var allGood = failures.length === 0;
|
|
1299
|
+
if (allGood) {
|
|
1300
|
+
addMessage("fp-notice", "✓ Verified: all " + passed + " change(s) confirmed on canvas.");
|
|
1301
|
+
} else {
|
|
1302
|
+
addMessage("fp-notice", "⚠ Verification: " + passed + "/" + total + " change(s) confirmed — " +
|
|
1303
|
+
failures.length + " did not land as expected (" + failures.join(", ") + ").");
|
|
1304
|
+
}
|
|
1305
|
+
if (todoRec && todoRec.items) {
|
|
1306
|
+
todoRec.items.forEach(function (item) {
|
|
1307
|
+
if (item.status === "active") { item.status = allGood ? "done" : "failed"; }
|
|
1308
|
+
});
|
|
1309
|
+
rerenderTodoRecord(todoRec);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
function handleStepQueueGenerateResult(data, goalPrompt) {
|
|
1314
|
+
hidePending();
|
|
1315
|
+
if (renderQuestionOrProse(data)) { return; }
|
|
1316
|
+
var flow = Array.isArray(data.flow) ? layoutGeneratedFlow(data.flow) : data.flow;
|
|
1317
|
+
|
|
1318
|
+
// Build the todo plan. Parse "Plan:" from explanation if present;
|
|
1319
|
+
// fall back to an implicit single item from the goal prompt.
|
|
1320
|
+
var planItems = parseTodoPlan(data.explanation || "");
|
|
1321
|
+
if (!planItems.length) {
|
|
1322
|
+
planItems = [{ text: goalPrompt || "Generate flow", status: "pending" }];
|
|
1323
|
+
}
|
|
1324
|
+
// Same aggregate-verification reasoning as the Modify path: mark
|
|
1325
|
+
// every item active up front so a multi-item plan resolves together.
|
|
1326
|
+
planItems.forEach(function (item) { item.status = "active"; });
|
|
1327
|
+
var todoRec = addRecord("todo", { action: "generate", items: planItems });
|
|
1328
|
+
rerenderTodoRecord(todoRec);
|
|
1329
|
+
|
|
1330
|
+
addMessage("assistant", data.explanation || "(no explanation returned)");
|
|
1331
|
+
pushHistory("assistant", data.explanation || "(no explanation returned)");
|
|
1332
|
+
// B1: when a build loop is appropriate, bake deploy-verify into the
|
|
1333
|
+
// primary chip (same as handleSimpleGenerationResult). The callback
|
|
1334
|
+
// also runs verifyImportedNodes so the todo record still gets checked
|
|
1335
|
+
// off. Without a build loop, fall back to a plain "Add to canvas"
|
|
1336
|
+
// button that still fires the verify callback.
|
|
1337
|
+
var _hasDeployable = flow.some(function (n) { return n && n.type !== "comment" && n.type !== "group"; });
|
|
1338
|
+
var _wantLoop = goalPrompt && !activeBuildLoop && _hasDeployable;
|
|
1339
|
+
var _onImported = _wantLoop
|
|
1340
|
+
? function (importResult) {
|
|
1341
|
+
verifyImportedNodes(importResult, todoRec);
|
|
1342
|
+
startBuildLoop(goalPrompt, flow, importResult);
|
|
1343
|
+
}
|
|
1344
|
+
: function (importResult) { verifyImportedNodes(importResult, todoRec); };
|
|
1345
|
+
addGeneratedReview(flow, _onImported, _wantLoop ? goalPrompt : null);
|
|
1346
|
+
// Suppress a server-suggested build chip when deploy-verify is already
|
|
1347
|
+
// the primary action inside the review panel.
|
|
1348
|
+
renderActionChip(_wantLoop && data.suggestedAction && data.suggestedAction.mode === "build"
|
|
1349
|
+
? null : data.suggestedAction);
|
|
1350
|
+
setBusy(false);
|
|
1351
|
+
updateSelectionStatus();
|
|
1352
|
+
}
|
|
1353
|
+
|
|
956
1354
|
function generate() {
|
|
957
|
-
|
|
1355
|
+
if (currentSettings.enableStepQueue) {
|
|
1356
|
+
runGenerateLikeAction("generate", "generate", "Generate: ", handleStepQueueGenerateResult);
|
|
1357
|
+
} else {
|
|
1358
|
+
runGenerateLikeAction("generate", "generate", "Generate: ", handleSimpleGenerationResult);
|
|
1359
|
+
}
|
|
958
1360
|
}
|
|
959
1361
|
|
|
960
1362
|
// /build's first step. Reuses Generate's pipeline wholesale for the
|
|
@@ -1063,6 +1465,127 @@
|
|
|
1063
1465
|
updateSelectionStatus();
|
|
1064
1466
|
}
|
|
1065
1467
|
|
|
1468
|
+
// WS4: real consent gate for side-effecting build steps. Renders one
|
|
1469
|
+
// combined chip covering every side-effecting node this step's
|
|
1470
|
+
// classification found (classifyFlowNodes in flowpilot.js, server-side)
|
|
1471
|
+
// — a single decision rather than per-node chips, which is sufficient
|
|
1472
|
+
// because only the FIRST /flowpilot/build response carries
|
|
1473
|
+
// stepNodeClasses today (fix iterations via /flowpilot/modify don't, so
|
|
1474
|
+
// there's exactly one consent point per loop lifetime under the current
|
|
1475
|
+
// limitation — see the handleBuildResult call site).
|
|
1476
|
+
//
|
|
1477
|
+
// Reconstructed entirely from `src` (plain data, never a live closure)
|
|
1478
|
+
// on every call — the initial render from handleBuildResult AND every
|
|
1479
|
+
// later rerender (refresh, pop-out reopen, the W0A idle/focus
|
|
1480
|
+
// auto-refresh) via rerenderRecord's buildConsentGate branch below.
|
|
1481
|
+
// Mirrors rerenderReviewRecord's pattern in apply-review.js: a fresh
|
|
1482
|
+
// record is added each time from the source's stored fields (including
|
|
1483
|
+
// `decision`, once made), rather than relying on an in-memory callback
|
|
1484
|
+
// surviving a refresh. That in-memory-callback version is exactly what
|
|
1485
|
+
// broke before this fix — a refresh mid-decision fell through to
|
|
1486
|
+
// renderClarifyingQuestion's generic path, whose buttons send the
|
|
1487
|
+
// clicked label as a new chat message instead of resolving Proceed/Skip,
|
|
1488
|
+
// permanently stranding the loop.
|
|
1489
|
+
//
|
|
1490
|
+
// src fields: sideEffecting, flow, goalPrompt, fpUidManifest,
|
|
1491
|
+
// suggestedAction — everything runBuildConsentDecision needs — plus,
|
|
1492
|
+
// once resolved, `decision` ("proceed"|"skip") so a later rerender shows
|
|
1493
|
+
// a settled state instead of re-offering an already-made choice.
|
|
1494
|
+
function renderBuildConsentGate(src) {
|
|
1495
|
+
var $box = el("#fp-messages");
|
|
1496
|
+
if (!$box.length) {
|
|
1497
|
+
if (!src.decision) { runBuildConsentDecision(src, true); }
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
var _rec = addRecord("question", {
|
|
1502
|
+
buildConsentGate: true,
|
|
1503
|
+
options: ["Auto-verify", "I'll check myself"],
|
|
1504
|
+
sideEffecting: src.sideEffecting,
|
|
1505
|
+
flow: src.flow,
|
|
1506
|
+
goalPrompt: src.goalPrompt,
|
|
1507
|
+
fpUidManifest: src.fpUidManifest,
|
|
1508
|
+
suggestedAction: src.suggestedAction,
|
|
1509
|
+
decision: src.decision
|
|
1510
|
+
});
|
|
1511
|
+
|
|
1512
|
+
var sideEffecting = Array.isArray(_rec.sideEffecting) ? _rec.sideEffecting : [];
|
|
1513
|
+
var labels = sideEffecting.map(function (n) { return n.name || n.type; }).join(", ");
|
|
1514
|
+
|
|
1515
|
+
if (_rec.decision) {
|
|
1516
|
+
// Already resolved before this render (e.g. resolved earlier in
|
|
1517
|
+
// the session, now showing again after a refresh) — settled
|
|
1518
|
+
// state, not an interactive choice.
|
|
1519
|
+
addMessage("assistant", "This step calls an external service: " + labels + ".");
|
|
1520
|
+
var $settledRow = $("<div>").addClass("fp-chip-row fp-question-row");
|
|
1521
|
+
$("<button>")
|
|
1522
|
+
.addClass("fp-consent-chip")
|
|
1523
|
+
.addClass(_rec.decision === "proceed" ? "fp-consent-chip-primary" : "fp-consent-chip-alt")
|
|
1524
|
+
.attr("type", "button").prop("disabled", true)
|
|
1525
|
+
.text(_rec.decision === "proceed" ? "Auto-verify ✓" : "Checking myself ✓")
|
|
1526
|
+
.appendTo($settledRow);
|
|
1527
|
+
$box.append($settledRow);
|
|
1528
|
+
scrollMessagesToBottom();
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
addMessage("assistant", "This step calls an external service: " + labels +
|
|
1533
|
+
". Want it verified automatically once triggered, or would you rather check it yourself?");
|
|
1534
|
+
|
|
1535
|
+
var $row = $("<div>").addClass("fp-chip-row fp-question-row");
|
|
1536
|
+
|
|
1537
|
+
function decide(proceed) {
|
|
1538
|
+
$row.find("button").prop("disabled", true);
|
|
1539
|
+
_rec.decision = proceed ? "proceed" : "skip";
|
|
1540
|
+
runBuildConsentDecision(_rec, proceed);
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
$("<button>")
|
|
1544
|
+
.addClass("fp-consent-chip fp-consent-chip-primary")
|
|
1545
|
+
.attr("type", "button")
|
|
1546
|
+
.text("Auto-verify")
|
|
1547
|
+
.on("click", function () { decide(true); })
|
|
1548
|
+
.appendTo($row);
|
|
1549
|
+
$("<button>")
|
|
1550
|
+
.addClass("fp-consent-chip fp-consent-chip-alt")
|
|
1551
|
+
.attr("type", "button")
|
|
1552
|
+
.text("I'll check myself")
|
|
1553
|
+
.on("click", function () { decide(false); })
|
|
1554
|
+
.appendTo($row);
|
|
1555
|
+
|
|
1556
|
+
$box.append($row);
|
|
1557
|
+
scrollMessagesToBottom();
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// The actual "proceed with review + loop" action, factored out so both
|
|
1561
|
+
// the fresh (no side-effecting nodes) and gated (decision made) paths in
|
|
1562
|
+
// handleBuildResult, and a rerendered consent-gate record's decide(),
|
|
1563
|
+
// all run identical logic sourced from plain data — never a captured
|
|
1564
|
+
// closure. consentGranted=false builds the Skip consent object
|
|
1565
|
+
// (skippedNodeIds/fpUidManifest) that startBuildLoop resolves into real
|
|
1566
|
+
// ids via importResult.nodeMap — see startBuildLoop's own comment.
|
|
1567
|
+
function runBuildConsentDecision(src, consentGranted) {
|
|
1568
|
+
var sideEffecting = Array.isArray(src.sideEffecting) ? src.sideEffecting : [];
|
|
1569
|
+
var flow = src.flow;
|
|
1570
|
+
var goalPrompt = src.goalPrompt;
|
|
1571
|
+
var fpUidManifest = Array.isArray(src.fpUidManifest) ? src.fpUidManifest : [];
|
|
1572
|
+
|
|
1573
|
+
if (sideEffecting.length > 0) {
|
|
1574
|
+
var sideLabels = sideEffecting.map(function (n) { return n.name || n.type; }).join(", ");
|
|
1575
|
+
addMessage("fp-notice", consentGranted
|
|
1576
|
+
? "⚠ External calls: " + sideLabels + " — the deploy-test loop will auto-verify these once triggered."
|
|
1577
|
+
: "⚠ External calls: " + sideLabels + " — auto-verify skipped for these node(s); confirm the result yourself.");
|
|
1578
|
+
}
|
|
1579
|
+
var consent = consentGranted ? null : {
|
|
1580
|
+
skippedNodeIds: sideEffecting.map(function (n) { return n.id; }),
|
|
1581
|
+
fpUidManifest: fpUidManifest
|
|
1582
|
+
};
|
|
1583
|
+
addGeneratedReview(flow, function (importResult) {
|
|
1584
|
+
startBuildLoop(goalPrompt, flow, importResult, consent);
|
|
1585
|
+
}, goalPrompt);
|
|
1586
|
+
renderActionChip(src.suggestedAction);
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1066
1589
|
function handleBuildResult(data, goalPrompt) {
|
|
1067
1590
|
hidePending();
|
|
1068
1591
|
if (renderQuestionOrProse(data)) { return; }
|
|
@@ -1071,8 +1594,24 @@
|
|
|
1071
1594
|
var flow = Array.isArray(data.flow) ? layoutGeneratedFlow(data.flow) : data.flow;
|
|
1072
1595
|
addMessage("assistant", data.explanation || "(no explanation returned)");
|
|
1073
1596
|
pushHistory("assistant", data.explanation || "(no explanation returned)");
|
|
1074
|
-
|
|
1075
|
-
|
|
1597
|
+
|
|
1598
|
+
var nodeClasses = data.stepNodeClasses;
|
|
1599
|
+
var sideEffecting = (nodeClasses && Array.isArray(nodeClasses.sideEffecting))
|
|
1600
|
+
? nodeClasses.sideEffecting : [];
|
|
1601
|
+
var fpUidManifest = Array.isArray(data.fpUidManifest) ? data.fpUidManifest : [];
|
|
1602
|
+
var consentSrc = {
|
|
1603
|
+
sideEffecting: sideEffecting,
|
|
1604
|
+
flow: flow,
|
|
1605
|
+
goalPrompt: goalPrompt,
|
|
1606
|
+
fpUidManifest: fpUidManifest,
|
|
1607
|
+
suggestedAction: data.suggestedAction
|
|
1608
|
+
};
|
|
1609
|
+
|
|
1610
|
+
if (sideEffecting.length > 0) {
|
|
1611
|
+
renderBuildConsentGate(consentSrc);
|
|
1612
|
+
} else {
|
|
1613
|
+
runBuildConsentDecision(consentSrc, true);
|
|
1614
|
+
}
|
|
1076
1615
|
setBusy(false);
|
|
1077
1616
|
updateSelectionStatus();
|
|
1078
1617
|
}
|
|
@@ -1163,6 +1702,8 @@
|
|
|
1163
1702
|
addMessage("error", "Describe what you want to change.");
|
|
1164
1703
|
return;
|
|
1165
1704
|
}
|
|
1705
|
+
var existingNodeIds = context.nodes.map(function (n) { return n.id; });
|
|
1706
|
+
|
|
1166
1707
|
var label = "Modify: " + instruction + contextAttachmentNote(context);
|
|
1167
1708
|
addMessage("user", label);
|
|
1168
1709
|
// Snapshot history before pushing this turn (see send()).
|
|
@@ -1187,6 +1728,10 @@
|
|
|
1187
1728
|
handleExecuteError(msg, raw);
|
|
1188
1729
|
}
|
|
1189
1730
|
|
|
1731
|
+
function onModifyResult(data) {
|
|
1732
|
+
handleModifyResult(data);
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1190
1735
|
// Explore-then-propose, same as generate(). The
|
|
1191
1736
|
// model may call read tools (e.g. to re-check the selected node's
|
|
1192
1737
|
// current config) before producing the modify envelope; the final
|
|
@@ -1194,7 +1739,7 @@
|
|
|
1194
1739
|
if (isAgentLoop) {
|
|
1195
1740
|
runAgentLoop("flowpilot/modify", payload,
|
|
1196
1741
|
{ mode: "modify", context: context, prompt: instruction },
|
|
1197
|
-
|
|
1742
|
+
onModifyResult, onModifyError);
|
|
1198
1743
|
return;
|
|
1199
1744
|
}
|
|
1200
1745
|
|
|
@@ -1202,11 +1747,11 @@
|
|
|
1202
1747
|
// generate() for details.
|
|
1203
1748
|
if (currentSettings.streamingEnabled) {
|
|
1204
1749
|
payload.stream = true;
|
|
1205
|
-
sendExecuteStream("modify", payload,
|
|
1750
|
+
sendExecuteStream("modify", payload, onModifyResult);
|
|
1206
1751
|
return;
|
|
1207
1752
|
}
|
|
1208
1753
|
|
|
1209
|
-
ajaxJson("POST", "flowpilot/modify", payload,
|
|
1754
|
+
ajaxJson("POST", "flowpilot/modify", payload, onModifyResult, onModifyError);
|
|
1210
1755
|
}
|
|
1211
1756
|
|
|
1212
1757
|
// Render generated flow JSON in a preformatted, copyable block. Used for
|
|
@@ -1255,6 +1800,10 @@
|
|
|
1255
1800
|
// the review — see onDebugMessage for why (a forked/split flow can
|
|
1256
1801
|
// fire its debug node more than once per trigger).
|
|
1257
1802
|
var BUILD_LOOP_ATTACH_DEBOUNCE_MS = 1200;
|
|
1803
|
+
// W0.3: how many times the model can bail (emit a prose reply with a
|
|
1804
|
+
// suggestedAction mode-redirect) before the loop gives up with an
|
|
1805
|
+
// honest-timeout instead of silently treating the bail as success.
|
|
1806
|
+
var BUILD_LOOP_MAX_BAILS = 2;
|
|
1258
1807
|
var buildLoopAttachTimer = null;
|
|
1259
1808
|
// Fires when "attach" waits too long with no debug — surfaces a prompt
|
|
1260
1809
|
// for flows that don't produce automatic debug output (HTTP endpoints, etc).
|
|
@@ -1304,9 +1853,59 @@
|
|
|
1304
1853
|
.appendTo($row);
|
|
1305
1854
|
|
|
1306
1855
|
$box.append($row);
|
|
1856
|
+
addRecord("question", { options: ["Continue → AI review", "Stop loop"], loopCheckpoint: true });
|
|
1307
1857
|
scrollMessagesToBottom();
|
|
1308
1858
|
}
|
|
1309
1859
|
|
|
1860
|
+
// WS3: remove FP-UID checkpoint tap nodes that the build prompt placed
|
|
1861
|
+
// on the canvas as FlowPilot scaffolding. These are debug nodes named
|
|
1862
|
+
// FP-UID001, FP-UID002, etc. — wired in parallel to external-call nodes
|
|
1863
|
+
// so the loop can attribute debug messages to specific checkpoints.
|
|
1864
|
+
// Called at loop end (any outcome) to clean up before returning control
|
|
1865
|
+
// to the user. Replicates the remove-and-history pattern from
|
|
1866
|
+
// applyModifications in apply-review.js (confirmed group-cleanup bookkeeping).
|
|
1867
|
+
function removeFpUidTaps(loop) {
|
|
1868
|
+
if (!loop || !Array.isArray(loop.nodeIds) || !loop.nodeIds.length) { return 0; }
|
|
1869
|
+
var FP_UID_RE = /^FP-UID\d+$/;
|
|
1870
|
+
var removed = 0;
|
|
1871
|
+
loop.nodeIds.forEach(function (id) {
|
|
1872
|
+
var liveNode = RED.nodes.node(id);
|
|
1873
|
+
if (!liveNode || !FP_UID_RE.test(liveNode.name)) { return; }
|
|
1874
|
+
var connectedLinks = [];
|
|
1875
|
+
RED.nodes.eachLink(function (l) {
|
|
1876
|
+
if ((l.source && l.source.id === id) || (l.target && l.target.id === id)) {
|
|
1877
|
+
connectedLinks.push(l);
|
|
1878
|
+
}
|
|
1879
|
+
});
|
|
1880
|
+
try { RED.nodes.remove(liveNode.id); } catch (e) { return; }
|
|
1881
|
+
if (liveNode.g && RED.nodes.group) {
|
|
1882
|
+
var ownerGroup = RED.nodes.group(liveNode.g);
|
|
1883
|
+
if (ownerGroup) {
|
|
1884
|
+
var idx = ownerGroup.nodes.indexOf(liveNode);
|
|
1885
|
+
if (idx !== -1) { ownerGroup.nodes.splice(idx, 1); }
|
|
1886
|
+
RED.group.markDirty(ownerGroup);
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
RED.history.push({
|
|
1890
|
+
t: "delete",
|
|
1891
|
+
nodes: [liveNode],
|
|
1892
|
+
links: connectedLinks,
|
|
1893
|
+
groups: [],
|
|
1894
|
+
junctions: [],
|
|
1895
|
+
subflow: { id: undefined, instances: [] },
|
|
1896
|
+
subflowInputs: [],
|
|
1897
|
+
subflowOutputs: [],
|
|
1898
|
+
dirty: RED.nodes.dirty()
|
|
1899
|
+
});
|
|
1900
|
+
removed++;
|
|
1901
|
+
});
|
|
1902
|
+
if (removed) {
|
|
1903
|
+
RED.nodes.dirty(true);
|
|
1904
|
+
RED.view.redraw(true);
|
|
1905
|
+
}
|
|
1906
|
+
return removed;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1310
1909
|
// The single exit point for every way a build loop ends — Touchdown,
|
|
1311
1910
|
// the cap being reached, pausing on a clarifying question, or the user
|
|
1312
1911
|
// clicking Stop. Releases Build mode and its pinned selection too: once
|
|
@@ -1317,6 +1916,7 @@
|
|
|
1317
1916
|
// visible as a completion badge. success=false (default): remove the stepper
|
|
1318
1917
|
// (user stop, cap reached, paused for question).
|
|
1319
1918
|
function stopBuildLoop(note, success) {
|
|
1919
|
+
var tapCount = activeBuildLoop ? removeFpUidTaps(activeBuildLoop) : 0;
|
|
1320
1920
|
if (success && activeBuildLoop) {
|
|
1321
1921
|
activeBuildLoop.waypoint = "done";
|
|
1322
1922
|
renderLoopStepper(activeBuildLoop);
|
|
@@ -1327,6 +1927,7 @@
|
|
|
1327
1927
|
if (!success) { el("#fp-loop-stepper").remove(); }
|
|
1328
1928
|
disarmExecuteAction();
|
|
1329
1929
|
if (note) { addMessage("assistant", note); }
|
|
1930
|
+
if (tapCount) { addMessage("assistant", "Removed " + tapCount + " FP-UID checkpoint tap(s) from the canvas."); }
|
|
1330
1931
|
}
|
|
1331
1932
|
|
|
1332
1933
|
// Applies a build-loop review's fix envelope, then keeps the loop's
|
|
@@ -1334,7 +1935,7 @@
|
|
|
1334
1935
|
// handleBuildReviewResult's addModifyReview callback (rather than left
|
|
1335
1936
|
// as an inline closure) so the EXACT same logic can run whether the
|
|
1336
1937
|
// Apply click happened in the main window or was relayed from the
|
|
1337
|
-
// pop-out — see the
|
|
1938
|
+
// pop-out — see the applyByRecordId handler in initMainWindow (Phase 10 0B).
|
|
1338
1939
|
function applyBuildLoopFix(nodeDiffs, removeNodesArg, idMap, capReached) {
|
|
1339
1940
|
applyModifications(nodeDiffs, removeNodesArg, null, idMap);
|
|
1340
1941
|
if (!activeBuildLoop) { return; }
|
|
@@ -1389,7 +1990,21 @@
|
|
|
1389
1990
|
if (loop.waypoint === "apply") {
|
|
1390
1991
|
hint = "Click the canvas to place the new node(s), then Deploy — I'll move on automatically once you deploy.";
|
|
1391
1992
|
} else if (loop.waypoint === "attach") {
|
|
1392
|
-
|
|
1993
|
+
var eps = loop.httpEndpoints;
|
|
1994
|
+
if (eps && eps.length > 0) {
|
|
1995
|
+
var ep = eps[0];
|
|
1996
|
+
var baseUrl = (typeof window !== "undefined" && window.location)
|
|
1997
|
+
? window.location.origin : "";
|
|
1998
|
+
var curlMethod = ep.method === "GET" ? "" : " -X " + ep.method;
|
|
1999
|
+
hint = "Send " + ep.method + " " + ep.url + " to trigger the flow — " +
|
|
2000
|
+
"e.g. curl" + curlMethod + " " + baseUrl + ep.url +
|
|
2001
|
+
". I’ll attach the debug output automatically.";
|
|
2002
|
+
if (eps.length > 1) {
|
|
2003
|
+
hint += " (" + (eps.length - 1) + " more endpoint(s) in this flow.)";
|
|
2004
|
+
}
|
|
2005
|
+
} else {
|
|
2006
|
+
hint = "Trigger the flow, then check the Debug sidebar — I'll attach the next debug message automatically.";
|
|
2007
|
+
}
|
|
1393
2008
|
} else if (loop.waypoint === "review") {
|
|
1394
2009
|
hint = "Debug output attached — reviewing against the goal…";
|
|
1395
2010
|
}
|
|
@@ -1403,10 +2018,32 @@
|
|
|
1403
2018
|
.appendTo($actions);
|
|
1404
2019
|
}
|
|
1405
2020
|
|
|
2021
|
+
// Replace any prior buildStep snapshot — only the latest waypoint matters.
|
|
2022
|
+
messageRecords = messageRecords.filter(function (r) { return r.kind !== "buildStep"; });
|
|
2023
|
+
addRecord("buildStep", {
|
|
2024
|
+
waypoint: loop.waypoint,
|
|
2025
|
+
iteration: loop.iteration,
|
|
2026
|
+
maxIterations: loop.maxIterations,
|
|
2027
|
+
goal: loop.goal,
|
|
2028
|
+
nodeIds: Array.isArray(loop.nodeIds) ? loop.nodeIds.slice() : [],
|
|
2029
|
+
httpEndpoints: Array.isArray(loop.httpEndpoints) ? loop.httpEndpoints.slice() : []
|
|
2030
|
+
});
|
|
2031
|
+
|
|
1406
2032
|
$box.append($msg);
|
|
1407
2033
|
scrollMessagesToBottom();
|
|
1408
2034
|
}
|
|
1409
2035
|
|
|
2036
|
+
function rerenderBuildStepRecord(rec) {
|
|
2037
|
+
renderLoopStepper({
|
|
2038
|
+
waypoint: rec.waypoint || "done",
|
|
2039
|
+
iteration: rec.iteration || 1,
|
|
2040
|
+
maxIterations: rec.maxIterations || 5,
|
|
2041
|
+
goal: rec.goal || "",
|
|
2042
|
+
nodeIds: Array.isArray(rec.nodeIds) ? rec.nodeIds : [],
|
|
2043
|
+
httpEndpoints: Array.isArray(rec.httpEndpoints) ? rec.httpEndpoints : []
|
|
2044
|
+
});
|
|
2045
|
+
}
|
|
2046
|
+
|
|
1410
2047
|
// Called once the first build proposal is actually imported (not on a
|
|
1411
2048
|
// clarifying question or prose-only reply — see handleBuildResult). goal
|
|
1412
2049
|
// is the original prompt text, kept verbatim so the review step can
|
|
@@ -1419,12 +2056,22 @@
|
|
|
1419
2056
|
// end up on the canvas — importResult.nodeMap maps each placeholder id
|
|
1420
2057
|
// to the real live node object, which is the only way later review/fix
|
|
1421
2058
|
// requests can target the right nodes via collectSelectionContext.
|
|
1422
|
-
|
|
2059
|
+
//
|
|
2060
|
+
// consent (WS4, optional): { skippedNodeIds, fpUidManifest } from a
|
|
2061
|
+
// Skip decision at the build consent gate (see handleBuildResult /
|
|
2062
|
+
// renderBuildConsentGate) — skippedNodeIds are the side-effecting
|
|
2063
|
+
// nodes' own PLACEHOLDER ids, fpUidManifest maps each FP-UID debug tap's
|
|
2064
|
+
// placeholder id to the placeholder id of the node it's wired from
|
|
2065
|
+
// (wiredFrom). Resolved here into two REAL-id sets: the skipped nodes
|
|
2066
|
+
// themselves (onNodeStatus's status/<nodeId> path checks against these)
|
|
2067
|
+
// and the taps wired to them (onDebugMessage's msg.id check does) — so
|
|
2068
|
+
// both auto-verify evidence paths honor the same Skip decision.
|
|
2069
|
+
function startBuildLoop(goal, nodeIdsOrNodes, importResult, consent) {
|
|
1423
2070
|
var nodeIds = [];
|
|
2071
|
+
var nodeMap = importResult && importResult.nodeMap;
|
|
1424
2072
|
if (importResult) {
|
|
1425
2073
|
// Fresh build: map placeholder ids from the proposal to the real
|
|
1426
2074
|
// ids importNodes assigned on the canvas.
|
|
1427
|
-
var nodeMap = importResult.nodeMap;
|
|
1428
2075
|
if (nodeMap && Array.isArray(nodeIdsOrNodes)) {
|
|
1429
2076
|
nodeIdsOrNodes.forEach(function (n) {
|
|
1430
2077
|
var real = n && n.id && nodeMap[n.id];
|
|
@@ -1435,13 +2082,43 @@
|
|
|
1435
2082
|
// Existing-flow build: ids are already resolved real canvas ids.
|
|
1436
2083
|
nodeIds = nodeIdsOrNodes.filter(function (id) { return typeof id === "string" && id; });
|
|
1437
2084
|
}
|
|
2085
|
+
// Detect HTTP-in endpoints so the "attach" step can show a specific
|
|
2086
|
+
// trigger hint instead of the generic "trigger the flow" message.
|
|
2087
|
+
var httpEndpoints = [];
|
|
2088
|
+
nodeIds.forEach(function (id) {
|
|
2089
|
+
var n = RED.nodes.node(id);
|
|
2090
|
+
if (n && n.type === "http in" && n.url) {
|
|
2091
|
+
httpEndpoints.push({ method: (n.method || "get").toUpperCase(), url: n.url });
|
|
2092
|
+
}
|
|
2093
|
+
});
|
|
2094
|
+
|
|
2095
|
+
var skipCheckpointNodeIds = [];
|
|
2096
|
+
var skipCheckpointTapIds = [];
|
|
2097
|
+
var skippedPlaceholderIds = consent && Array.isArray(consent.skippedNodeIds) ? consent.skippedNodeIds : [];
|
|
2098
|
+
if (skippedPlaceholderIds.length && nodeMap) {
|
|
2099
|
+
skippedPlaceholderIds.forEach(function (placeholderId) {
|
|
2100
|
+
var real = nodeMap[placeholderId];
|
|
2101
|
+
if (real && real.id) { skipCheckpointNodeIds.push(real.id); }
|
|
2102
|
+
});
|
|
2103
|
+
var manifest = Array.isArray(consent.fpUidManifest) ? consent.fpUidManifest : [];
|
|
2104
|
+
manifest.forEach(function (tap) {
|
|
2105
|
+
if (!tap || skippedPlaceholderIds.indexOf(tap.wiredFrom) === -1) { return; }
|
|
2106
|
+
var realTap = nodeMap[tap.id];
|
|
2107
|
+
if (realTap && realTap.id) { skipCheckpointTapIds.push(realTap.id); }
|
|
2108
|
+
});
|
|
2109
|
+
}
|
|
2110
|
+
|
|
1438
2111
|
activeBuildLoop = {
|
|
1439
2112
|
goal: goal,
|
|
1440
2113
|
nodeIds: nodeIds,
|
|
1441
2114
|
iteration: 1,
|
|
1442
2115
|
maxIterations: getAgentLoopMaxIterations(),
|
|
1443
2116
|
waypoint: "apply",
|
|
1444
|
-
conversationId: conversationId
|
|
2117
|
+
conversationId: conversationId,
|
|
2118
|
+
bailCount: 0,
|
|
2119
|
+
httpEndpoints: httpEndpoints,
|
|
2120
|
+
skipCheckpointNodeIds: skipCheckpointNodeIds,
|
|
2121
|
+
skipCheckpointTapIds: skipCheckpointTapIds
|
|
1445
2122
|
};
|
|
1446
2123
|
renderLoopStepper(activeBuildLoop);
|
|
1447
2124
|
}
|
|
@@ -1457,7 +2134,47 @@
|
|
|
1457
2134
|
function runBuildReview(loop) {
|
|
1458
2135
|
var context = collectSelectionContext(loop.nodeIds);
|
|
1459
2136
|
context = attachDebugContext(context);
|
|
1460
|
-
var
|
|
2137
|
+
var reviewEvidence = context && Array.isArray(context.debugMessages)
|
|
2138
|
+
? context.debugMessages : [];
|
|
2139
|
+
var statusOnlyEvidence = reviewEvidence.length > 0 &&
|
|
2140
|
+
reviewEvidence.every(function (entry) {
|
|
2141
|
+
return entry && entry.sourceKind === "status";
|
|
2142
|
+
});
|
|
2143
|
+
// W0.3: framing block — suppresses the Modify escape hatch
|
|
2144
|
+
// (suggestedAction mode-redirect) inside the build loop context.
|
|
2145
|
+
// The code-side handler (handleBuildReviewResult) also detects and
|
|
2146
|
+
// counts bail attempts so N bails trigger an honest-timeout instead
|
|
2147
|
+
// of silently treating a redirect as success.
|
|
2148
|
+
var instruction = "CONTEXT: You are the fix engine inside a build-test-fix loop. " +
|
|
2149
|
+
"Your only valid responses are: (1) plain text when the goal is fully " +
|
|
2150
|
+
"satisfied, or (2) a {\"explanation\", \"changes\", ...} fix envelope " +
|
|
2151
|
+
"when something needs patching." +
|
|
2152
|
+
(statusOnlyEvidence
|
|
2153
|
+
? " (3) Because the attached evidence contains ONLY coarse node-status " +
|
|
2154
|
+
"lines, you may instead return the atomic {\"question\", " +
|
|
2155
|
+
"\"questionOptions\"} envelope described below."
|
|
2156
|
+
: "") +
|
|
2157
|
+
" Do NOT use the <<<FLOWPILOT_DATA>>> " +
|
|
2158
|
+
"block or suggest switching to chat/generate/document — you are already " +
|
|
2159
|
+
"in the right context and any mode-redirect will be ignored. If you are " +
|
|
2160
|
+
"genuinely uncertain what to fix, " +
|
|
2161
|
+
(statusOnlyEvidence
|
|
2162
|
+
? "use the status-only confirmation question below."
|
|
2163
|
+
: "describe the uncertainty inside \"explanation\" in a fix envelope.") +
|
|
2164
|
+
"\n\nEach attached evidence object has sourceKind. sourceKind:\"debug\" " +
|
|
2165
|
+
"is real message content emitted by a debug node. sourceKind:\"status\" " +
|
|
2166
|
+
"is only a coarse connection/status line synthesized from node status; " +
|
|
2167
|
+
"never treat it as proof of message payload content or successful " +
|
|
2168
|
+
"end-to-end behavior. " +
|
|
2169
|
+
(statusOnlyEvidence
|
|
2170
|
+
? "STATUS-ONLY FALLBACK: if the coarse status does not prove whether " +
|
|
2171
|
+
"the deployed node is actually connected/working, do not guess or " +
|
|
2172
|
+
"assert failure. Ask one concrete yes/no confirmation such as " +
|
|
2173
|
+
"\"Does the node show connected after deploy?\" by returning ONLY " +
|
|
2174
|
+
"{\"question\":\"...\",\"questionOptions\":[\"Yes\",\"No\"]}. "
|
|
2175
|
+
: "") +
|
|
2176
|
+
"\n\n" +
|
|
2177
|
+
"Review the attached debug output against this build goal: \"" +
|
|
1461
2178
|
loop.goal + "\". Before concluding anything, list out every distinct " +
|
|
1462
2179
|
"piece of data or behavior the goal actually requires, then check the " +
|
|
1463
2180
|
"attached debug payload(s) contain EACH one — a payload that's merely " +
|
|
@@ -1465,7 +2182,22 @@
|
|
|
1465
2182
|
"goal asked to combine two things but the payload only shows one), " +
|
|
1466
2183
|
"does NOT fully satisfy it. If more than one debug message is " +
|
|
1467
2184
|
"attached, treat them together as the full picture from one trigger, " +
|
|
1468
|
-
"not as separate independent attempts.
|
|
2185
|
+
"not as separate independent attempts. " +
|
|
2186
|
+
"SPECIAL CASE — network errors: if the debug output shows ONLY a " +
|
|
2187
|
+
"network-level error (EHOSTUNREACH, ECONNREFUSED, ETIMEDOUT, " +
|
|
2188
|
+
"ENOTFOUND, getaddrinfo ENOTFOUND, EAI_AGAIN, EAI_NODATA), the " +
|
|
2189
|
+
"flow MIGHT be correctly built — BUT you MUST first check the node " +
|
|
2190
|
+
"context: if any http-request node has an empty url field, a " +
|
|
2191
|
+
"placeholder, or a clearly malformed url (no hostname, no protocol, " +
|
|
2192
|
+
"etc.), the DNS or connection error is a CONFIGURATION problem — " +
|
|
2193
|
+
"fix the url field, do NOT declare it an infrastructure issue. Only " +
|
|
2194
|
+
"apply this special case when the url is a real, non-empty, " +
|
|
2195
|
+
"well-formed URL and the external service is simply unreachable. In " +
|
|
2196
|
+
"that case reply in plain text acknowledging the flow is structurally " +
|
|
2197
|
+
"correct and the network error is an infrastructure issue outside the " +
|
|
2198
|
+
"flow. Do NOT propose any changes; this error cannot be resolved by " +
|
|
2199
|
+
"modifying the flow. " +
|
|
2200
|
+
"If it fully satisfies the goal, " +
|
|
1469
2201
|
"say so in plain text — no changes needed. If something's wrong " +
|
|
1470
2202
|
"(including a node that never fired, or a value that's missing/empty " +
|
|
1471
2203
|
"when the goal needed it), propose the fix directly as a patch in " +
|
|
@@ -1521,6 +2253,26 @@
|
|
|
1521
2253
|
// diff-then-Apply pipeline as a manual Modify, then the loop advances
|
|
1522
2254
|
// back to "apply" for the next deploy/test cycle, or stops if the
|
|
1523
2255
|
// iteration cap is reached).
|
|
2256
|
+
// Returns false when all of data's proposed changes are sentinel-echoed
|
|
2257
|
+
// with no insertions, removals, or wire changes. Used by
|
|
2258
|
+
// handleBuildReviewResult to avoid showing an all-blocked review panel
|
|
2259
|
+
// when the model said "no changes needed" but still emitted a modify
|
|
2260
|
+
// envelope (a common model behavior after a build-loop review).
|
|
2261
|
+
function reviewHasRealDiffs(data) {
|
|
2262
|
+
if ((data.newNodes && data.newNodes.length) ||
|
|
2263
|
+
(data.removeNodes && data.removeNodes.length) ||
|
|
2264
|
+
(data.newWires && data.newWires.length) ||
|
|
2265
|
+
(data.newGroups && data.newGroups.length)) { return true; }
|
|
2266
|
+
var nodes = Array.isArray(data.flow) ? data.flow : [];
|
|
2267
|
+
return nodes.some(function (modNode) {
|
|
2268
|
+
if (!modNode || !modNode.id) { return false; }
|
|
2269
|
+
var liveNode = findLiveNode(modNode.id);
|
|
2270
|
+
if (!liveNode) { return false; }
|
|
2271
|
+
var diff = computeNodeDiff(liveNode, modNode);
|
|
2272
|
+
return diff.propertyChanges.length > 0 || diff.wiresChanged;
|
|
2273
|
+
});
|
|
2274
|
+
}
|
|
2275
|
+
|
|
1524
2276
|
function handleBuildReviewResult(data) {
|
|
1525
2277
|
hidePending();
|
|
1526
2278
|
var loop = activeBuildLoop;
|
|
@@ -1544,6 +2296,51 @@
|
|
|
1544
2296
|
}
|
|
1545
2297
|
|
|
1546
2298
|
if (data.prose) {
|
|
2299
|
+
var explanation = data.explanation || "(no content returned)";
|
|
2300
|
+
|
|
2301
|
+
// W0.3: bail detection — a prose reply with a mode-redirect
|
|
2302
|
+
// suggestedAction means the model tried to exit the loop
|
|
2303
|
+
// context via the Modify escape hatch. Count it and retry or
|
|
2304
|
+
// honest-timeout rather than treating it as success.
|
|
2305
|
+
var sa = data.suggestedAction;
|
|
2306
|
+
var isBail = sa && (sa.mode === "chat" || sa.mode === "generate" || sa.mode === "document");
|
|
2307
|
+
if (isBail) {
|
|
2308
|
+
loop.bailCount = (loop.bailCount || 0) + 1;
|
|
2309
|
+
console.warn("[FlowPilot] build-loop bail #" + loop.bailCount +
|
|
2310
|
+
" mode=" + sa.mode + ": " + explanation);
|
|
2311
|
+
addMessage("assistant", explanation);
|
|
2312
|
+
pushHistory("assistant", explanation);
|
|
2313
|
+
if (loop.bailCount >= BUILD_LOOP_MAX_BAILS) {
|
|
2314
|
+
stopBuildLoop("Build loop could not assess the debug output — the AI kept redirecting instead of reviewing. Try attaching more debug context or continuing manually with Modify.", false);
|
|
2315
|
+
setBusy(false);
|
|
2316
|
+
updateSelectionStatus();
|
|
2317
|
+
} else {
|
|
2318
|
+
addMessage("fp-notice", "Build-loop: review redirected to " + sa.mode +
|
|
2319
|
+
" — staying in build context and retrying (bail " + loop.bailCount +
|
|
2320
|
+
"/" + BUILD_LOOP_MAX_BAILS + ").");
|
|
2321
|
+
runBuildReview(loop);
|
|
2322
|
+
}
|
|
2323
|
+
return;
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
addMessage("assistant", explanation);
|
|
2327
|
+
pushHistory("assistant", explanation);
|
|
2328
|
+
renderActionChip(data.suggestedAction);
|
|
2329
|
+
var stopMsg = /EHOSTUNREACH|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|EAI_NODATA|getaddrinfo|unreachable|infrastructure/i
|
|
2330
|
+
.test(explanation)
|
|
2331
|
+
? "Build complete — the flow is correctly structured, but the external endpoint was unreachable during testing (infrastructure issue, not a flow problem)."
|
|
2332
|
+
: "Touchdown — the debug output matches the goal.";
|
|
2333
|
+
stopBuildLoop(stopMsg, true);
|
|
2334
|
+
setBusy(false);
|
|
2335
|
+
updateSelectionStatus();
|
|
2336
|
+
return;
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
// Modify envelope where every proposed change is a sentinel echo —
|
|
2340
|
+
// the model emitted a changes object but all fields are redacted
|
|
2341
|
+
// placeholders with no insertions, removals, or wire changes. Treat
|
|
2342
|
+
// it as "no changes needed" rather than showing an all-blocked panel.
|
|
2343
|
+
if (!reviewHasRealDiffs(data)) {
|
|
1547
2344
|
addMessage("assistant", data.explanation || "(no content returned)");
|
|
1548
2345
|
pushHistory("assistant", data.explanation || "");
|
|
1549
2346
|
renderActionChip(data.suggestedAction);
|