@manny-est/node-red-flowpilot 0.4.1 → 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.
@@ -0,0 +1,1690 @@
1
+ // Single dispatch point for "Send" (button click and Enter key): slash
2
+ // commands are handled locally first; otherwise route to the armed
3
+ // Execute action, or a normal chat message. Bound identically in both
4
+ // the main window and the pop-out (see initPopout) — arming/disarming/
5
+ // slash commands are pure local state either way, but the FINAL
6
+ // generate/document/modify/build/chat dispatch needs live RED.*
7
+ // context that only the main window has, so the pop-out relays
8
+ // instead of calling those functions locally (see isPopoutContext
9
+ // Detects when the user's typed prompt implies a different mode than the
10
+ // one currently armed, and returns a suggestedAction chip object if so.
11
+ // High-signal phrases only — avoids false positives on common words like
12
+ // "build" that have legitimate uses in any mode.
13
+ function detectModeSuggestion(prompt, currentMode) {
14
+ var text = prompt.toLowerCase();
15
+
16
+ // Build-loop language when no loop is already running
17
+ if (!activeBuildLoop) {
18
+ var buildLoopRe = /\b(build[ -]loop|try[ -](?:a[ -])?(?:build[ -])?loop|deploy[ -](?:and[ -])?(?:test|verify)|run[ -](?:a[ -])?(?:build[ -])?loop|test[ -](?:the[ -])?loop|verify[ -]with[ -](?:a[ -])?loop)\b/;
19
+ if (buildLoopRe.test(text) && currentMode !== "build") {
20
+ return { mode: "build", prompt: prompt, customTitle: "Run deploy-verify loop on this →" };
21
+ }
22
+ }
23
+
24
+ // "Create a new flow" language while in Modify — user wants Generate
25
+ if (currentMode === "modify") {
26
+ var generateRe = /\b(create\s+(?:a\s+)?(?:new\s+)?flow|build\s+(?:a\s+)?new\s+flow|start\s+from\s+scratch|generate\s+(?:a\s+)?(?:new\s+)?flow|make\s+(?:a\s+)?(?:new\s+)?flow)\b/;
27
+ if (generateRe.test(text)) {
28
+ return { mode: "generate", prompt: prompt, customTitle: "Generate a new flow instead →" };
29
+ }
30
+ }
31
+
32
+ return null;
33
+ }
34
+
35
+ // below and the "dispatchSend" handler in initMainWindow).
36
+ function dispatchSend() {
37
+ el("#fp-send").removeClass("fp-send-breathe");
38
+ var $promptBox = el("#fp-prompt");
39
+ var raw = $promptBox.length ? $promptBox.val() : "";
40
+ if (handleSlashCommand(raw)) { return; }
41
+
42
+ // Query intents are one-shot: the template text has done its job
43
+ // once Send is pressed, so disarm back to the default amber chat
44
+ // mode (mutual exclusion already guarantees armedExecuteAction is
45
+ // null whenever a Query intent is armed).
46
+ disarmQueryIntent();
47
+
48
+ // Detect when the prompt implies a different mode and surface a chip
49
+ // instead of making the API call — prevents a wasted/confused request
50
+ // (e.g. "try a build loop" typed in Modify, where the Modify system
51
+ // prompt can't act on mode-switch text). The chip arms the right mode
52
+ // and puts the prompt back; the user reviews and re-sends.
53
+ if (!isPopoutContext) {
54
+ var promptForDetect = raw.trim();
55
+ if (promptForDetect) {
56
+ var modeSuggestion = detectModeSuggestion(promptForDetect, armedExecuteAction);
57
+ if (modeSuggestion) {
58
+ addMessage("user", promptForDetect);
59
+ el("#fp-prompt").val("");
60
+ renderActionChip(modeSuggestion);
61
+ return;
62
+ }
63
+ }
64
+ }
65
+
66
+ if (isPopoutContext) {
67
+ var mode = armedExecuteAction || "chat";
68
+ var prompt = $promptBox.length ? $promptBox.val().trim() : "";
69
+ if (!prompt) {
70
+ addMessage("error", mode === "chat" ? "Enter a prompt first." : "Describe what you'd like to " + mode + " first.");
71
+ return;
72
+ }
73
+ if (window.opener && !window.opener.closed) {
74
+ try { window.opener.postMessage({ event: "dispatchSend", mode: mode, prompt: prompt }, location.origin); } catch (e) { /* ignore */ }
75
+ }
76
+ $promptBox.val("");
77
+ return;
78
+ }
79
+
80
+ if (armedExecuteAction === "generate") {
81
+ generate();
82
+ } else if (armedExecuteAction === "build") {
83
+ buildFlow();
84
+ } else if (armedExecuteAction === "document") {
85
+ documentFlow();
86
+ } else if (armedExecuteAction === "modify") {
87
+ modifyFlow();
88
+ } else {
89
+ send("chat");
90
+ }
91
+ }
92
+
93
+ // ---- Prompting ------------------------------------------------------
94
+
95
+ function setBusy(busy) {
96
+ el("#fp-send").prop("disabled", busy);
97
+ // Allow arming/disarming execute buttons while busy so users can
98
+ // prepare their next message during a response.
99
+ // el("#fp-generate").prop("disabled", busy);
100
+ // el("#fp-document").prop("disabled", busy);
101
+ // el("#fp-modify").prop("disabled", busy);
102
+ el("#fp-test-provider").prop("disabled", busy);
103
+ el("#fp-recall").prop("disabled", busy);
104
+ }
105
+
106
+ // Shared so chat and generate describe an attached selection identically —
107
+ // "[+ N node(s), M connection(s) attached as context]".
108
+ function contextAttachmentNote(context) {
109
+ var nodeCount = (context && context.nodes) ? context.nodes.length : 0;
110
+ var connCount = (context && context.connections && context.connections.edges)
111
+ ? context.connections.edges.length : 0;
112
+ var debugCount = (context && context.debugMessages) ? context.debugMessages.length : 0;
113
+
114
+ var parts = [];
115
+ if (nodeCount) {
116
+ parts.push(nodeCount + " node(s)" + (connCount ? ", " + connCount + " connection(s)" : ""));
117
+ }
118
+ if (debugCount) {
119
+ parts.push(debugCount + " debug message(s)");
120
+ }
121
+ return parts.length ? "\n\n[+ " + parts.join(", ") + " attached as context]" : "";
122
+ }
123
+
124
+ // endpoint is "chat" (real prompt) or "test" (connectivity check)
125
+ function send(endpoint, promptOverride) {
126
+ var $promptBox = el("#fp-prompt");
127
+ var prompt = promptOverride || ($promptBox.length ? $promptBox.val().trim() : "");
128
+
129
+ if (!prompt) {
130
+ addMessage("error", "Enter a prompt first.");
131
+ return;
132
+ }
133
+
134
+ // Connectivity test never carries flow context or conversation
135
+ // history; keep it minimal and out of the conversation entirely.
136
+ var isChat = endpoint === "chat";
137
+ var context = (endpoint === "test") ? null : attachDebugContext(collectSelectionContext());
138
+ var note = contextAttachmentNote(context);
139
+ addMessage("user", prompt + note);
140
+ // Build the history payload BEFORE pushing this turn, so
141
+ // "history" means "everything before this turn" — the backend
142
+ // appends this turn separately as the final user message.
143
+ var historyPayload = isChat ? buildHistoryPayload() : { messages: [], truncated: false };
144
+ if (isChat) { pushHistory("user", prompt + note); }
145
+ if (!promptOverride) { $promptBox.val(""); }
146
+
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();
206
+ }
207
+
208
+ function handleSendError(msg) {
209
+ hidePending();
210
+ if (isChat) { popDanglingUserHistory(); }
211
+ addMessage("error", msg);
212
+ setBusy(false);
213
+ }
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;
221
+ }
222
+
223
+ if (isChat && currentSettings.streamingEnabled) {
224
+ payload.stream = true;
225
+ sendChatStream(payload);
226
+ return;
227
+ }
228
+
229
+ ajaxJson("POST", "flowpilot/" + endpoint, payload, handleSendResult, handleSendError);
230
+ }
231
+
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
+ });
268
+ return;
269
+ }
270
+
271
+ dispatch();
272
+ }
273
+
274
+ // ---------------------------------------------------------------------
275
+ // Bounded read-tool loop, shared by chat and
276
+ // generate/document/modify ("explore-then-propose"). Sends the
277
+ // first turn to firstEndpoint with tools:true; if the model returns
278
+ // tool_calls instead of a final response, executes each call locally
279
+ // (executeAgentToolCall, against RED.nodes — see above), appends the
280
+ // assistant tool-call message and the tool results, and continues via
281
+ // /flowpilot/agent-step (with stepExtra merged into the body — e.g.
282
+ // { mode: "modify", context, prompt } so the backend can parse/validate
283
+ // the final envelope the same way the non-streaming routes do). A
284
+ // malformed/missing tool result is still sent back as a {"error": "..."}
285
+ // tool message so the model can recover or answer anyway, rather than
286
+ // the request erroring out.
287
+ //
288
+ // Bounds, all per turn:
289
+ // - AGENT_LOOP_MAX_STEPS: max number of tool round-trips.
290
+ // - AGENT_LOOP_TOKEN_CEILING: cumulative usage.total_tokens across all
291
+ // steps (provider-reported; null/missing usage doesn't count against
292
+ // it, so this is a best-effort guard, not a hard limit).
293
+ // - fpAgentStopRequested: set by the "Stop" button in showPending(true);
294
+ // checked before each further round-trip.
295
+ //
296
+ // If the model's tool_calls are too malformed to continue the
297
+ // tool/assistant message round-trip (missing id or function.name), the
298
+ // turn falls back to a plain (no-tools) request to firstEndpoint rather
299
+ // than erroring out.
300
+ // ---------------------------------------------------------------------
301
+ var AGENT_LOOP_MAX_STEPS = 8;
302
+ var AGENT_LOOP_TOKEN_CEILING = 50000;
303
+
304
+ var fpAgentStopRequested = false;
305
+
306
+ function runAgentLoop(firstEndpoint, payload, stepExtra, onDone, onError) {
307
+ var step = 0;
308
+ var totalTokens = 0;
309
+ fpAgentStopRequested = false;
310
+
311
+ function addUsage(usage) {
312
+ if (usage && typeof usage.total_tokens === "number") {
313
+ totalTokens += usage.total_tokens;
314
+ }
315
+ }
316
+
317
+ function fallbackToPlain() {
318
+ setAgentNarration("Continuing without tools…");
319
+ ajaxJson("POST", firstEndpoint, payload, onDone, onError);
320
+ }
321
+
322
+ function handleStep(data, messages) {
323
+ addUsage(data.usage);
324
+
325
+ if (!data.toolCalls || !data.toolCalls.length) {
326
+ if (step > 0) { addAgentStatsNote(step, totalTokens); }
327
+ onDone(data);
328
+ return;
329
+ }
330
+
331
+ var malformed = data.toolCalls.some(function (call) {
332
+ return !call || !call.id || !call.function || typeof call.function.name !== "string";
333
+ });
334
+ if (malformed) {
335
+ fallbackToPlain();
336
+ return;
337
+ }
338
+
339
+ step++;
340
+ if (step > AGENT_LOOP_MAX_STEPS) {
341
+ onError("FlowPilot stopped after " + AGENT_LOOP_MAX_STEPS +
342
+ " tool call(s) without a final answer. Try breaking your " +
343
+ "request into smaller steps, or be more specific about " +
344
+ "which node(s) or flow you mean.");
345
+ return;
346
+ }
347
+ if (totalTokens > AGENT_LOOP_TOKEN_CEILING) {
348
+ onError("FlowPilot stopped after using " + totalTokens +
349
+ " tokens on this turn without a final answer. Try " +
350
+ "selecting fewer nodes, or asking a more specific " +
351
+ "question so fewer tool calls are needed.");
352
+ return;
353
+ }
354
+ if (fpAgentStopRequested) {
355
+ onError("Stopped after " + (step - 1) + " tool call step(s) at your request.");
356
+ return;
357
+ }
358
+
359
+ var nextMessages = (messages || data.messages || []).slice();
360
+ nextMessages.push({ role: "assistant", content: data.content || null, tool_calls: data.toolCalls });
361
+ data.toolCalls.forEach(function (call) {
362
+ setAgentNarration(describeAgentToolCall(call.function.name, parseToolCallArgs(call)) +
363
+ " (step " + step + "/" + AGENT_LOOP_MAX_STEPS + ")");
364
+ nextMessages.push({
365
+ role: "tool",
366
+ tool_call_id: call.id,
367
+ content: JSON.stringify(executeAgentToolCall(call))
368
+ });
369
+ });
370
+ setAgentNarration("Thinking… (step " + step + "/" + AGENT_LOOP_MAX_STEPS + ")");
371
+ var stepPayload = Object.assign({
372
+ messages: nextMessages,
373
+ conversationId: payload.conversationId
374
+ }, stepExtra);
375
+ ajaxJson("POST", "flowpilot/agent-step", stepPayload,
376
+ function (stepData) { handleStep(stepData, nextMessages); }, onError);
377
+ }
378
+
379
+ var firstPayload = Object.assign({}, payload, { tools: true });
380
+ ajaxJson("POST", firstEndpoint, firstPayload, function (data) {
381
+ handleStep(data, null);
382
+ }, function () {
383
+ // The provider was probed as
384
+ // supportsTools, but the very first tools:true request failed
385
+ // outright — e.g. the model was swapped since the last probe, or
386
+ // this provider errors on an unrecognized "tools" field instead
387
+ // of ignoring it. Retry once without tools so the turn still
388
+ // completes the same way it would for a non-capable provider,
389
+ // rather than surfacing a hard error for what would otherwise be
390
+ // a normal request.
391
+ fallbackToPlain();
392
+ });
393
+ }
394
+
395
+ // Chat: mode defaults to "chat" server-side, so no stepExtra.
396
+ function runAgentChat(payload, onDone, onError) {
397
+ runAgentLoop("flowpilot/chat", payload, {}, onDone, onError);
398
+ }
399
+
400
+ // Streaming chat. Posts with stream:true and reads the
401
+ // SSE response body incrementally via fetch's ReadableStream. The
402
+ // bouncing "pending" indicator (already in the DOM from showPending)
403
+ // stays up until the first real delta arrives, then ensureBubble()
404
+ // swaps it for the assistant bubble that gets filled in as chunks
405
+ // arrive — generate/modify/document never call this; their JSON envelope
406
+ // can't be rendered until complete.
407
+ function sendChatStream(payload) {
408
+ var $box = el("#fp-messages");
409
+ if (!$box.length) { return; }
410
+
411
+ // Bug #4: this used to grab the just-shown #fp-pending indicator and
412
+ // convert it into an empty bubble right here, synchronously, before
413
+ // fetch() even started — so the dots were destroyed in the same tick
414
+ // they were created and never got a chance to render. $msg/$text now
415
+ // start null and ensureBubble() (below) does the conversion lazily,
416
+ // on the FIRST actual delta — the dots stay visible for the entire
417
+ // wait until real content starts arriving.
418
+ var $msg = null;
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 = "";
427
+
428
+ var fullText = "";
429
+ var finalData = null;
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
+
443
+ function ensureBubble() {
444
+ if ($text) { return; }
445
+ hidePending();
446
+ // Collapse the thinking block the moment real content starts flowing.
447
+ if ($thinking) { $thinking.prop("open", false); }
448
+ _chatRec = addMessage("assistant", "");
449
+ $msg = $box.find(".fp-message").last();
450
+ $text = $msg.find("div").last();
451
+ }
452
+
453
+ function finish() {
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
+ }
460
+ if (!fullText) {
461
+ if ($msg && $msg.length) { $msg.remove(); }
462
+ if (_chatRec) { messageRecords.splice(messageRecords.indexOf(_chatRec), 1); _chatRec = null; }
463
+ popDanglingUserHistory();
464
+ addMessage("error", "No response received from the provider.");
465
+ } else {
466
+ if (_chatRec) { _chatRec.text = fullText; _chatRec.streamingComplete = true; }
467
+ pushHistory("assistant", fullText);
468
+ if (finalData) {
469
+ renderActionChip(finalData.suggestedAction);
470
+ renderClarifyingQuestion(finalData.questionOptions);
471
+ }
472
+ }
473
+ setBusy(false);
474
+ updateSelectionStatus();
475
+ }
476
+
477
+ function fail(err) {
478
+ hidePending();
479
+ if ($msg && $msg.length) { $msg.remove(); }
480
+ popDanglingUserHistory();
481
+ addMessage("error", (err && err.message) ? err.message : String(err));
482
+ setBusy(false);
483
+ }
484
+
485
+ if (typeof fetch !== "function") {
486
+ fail(new Error("Streaming requires a browser with fetch() support. " +
487
+ "Disable streaming in Settings to use chat."));
488
+ return;
489
+ }
490
+
491
+ // Shared SSE-line parser: handles `data: {"delta":"..."}` /
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.
496
+ function processSseLines(lines) {
497
+ lines.forEach(function (line) {
498
+ line = line.trim();
499
+ if (line.indexOf("data:") !== 0) { return; }
500
+ var dataStr = line.slice(5).trim();
501
+ if (!dataStr || dataStr === "[DONE]") { return; }
502
+ var evt;
503
+ try { evt = JSON.parse(dataStr); } catch (e) { return; }
504
+ if (evt.error) { throw new Error(evt.error); }
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) {
512
+ fullText += evt.delta;
513
+ ensureBubble();
514
+ $text.html(renderMarkdown(fullText));
515
+ if (_chatRec) { _chatRec.text = fullText; }
516
+ scrollMessagesToBottom();
517
+ } else if (evt.final) {
518
+ finalData = evt.final;
519
+ }
520
+ });
521
+ }
522
+
523
+ fetch(flowpilotUrl("flowpilot/chat"), {
524
+ method: "POST",
525
+ headers: fetchHeaders(),
526
+ body: JSON.stringify(payload)
527
+ }).then(function (resp) {
528
+ if (!resp.ok) {
529
+ return resp.text().then(function (text) {
530
+ var msg = text;
531
+ try { msg = JSON.parse(text).error || text; } catch (e) { /* not JSON */ }
532
+ throw new Error(msg || resp.statusText);
533
+ });
534
+ }
535
+ if (!resp.body || !resp.body.getReader) {
536
+ // No streaming support in this environment — parse the full
537
+ // SSE response body with the same logic as pump() below, so
538
+ // the user sees the parsed reply, not raw `data: {...}` lines.
539
+ return resp.text().then(function (text) {
540
+ processSseLines(text.split("\n"));
541
+ });
542
+ }
543
+
544
+ var reader = resp.body.getReader();
545
+ var decoder = new TextDecoder();
546
+ var buf = "";
547
+
548
+ function pump() {
549
+ return reader.read().then(function (step) {
550
+ if (step.done) { return; }
551
+ buf += decoder.decode(step.value, { stream: true });
552
+ var lines = buf.split("\n");
553
+ buf = lines.pop();
554
+ processSseLines(lines);
555
+ return pump();
556
+ });
557
+ }
558
+ return pump();
559
+ }).then(finish, fail);
560
+ }
561
+
562
+ // Incrementally extracts the value of the JSON envelope's
563
+ // "explanation" key from raw streamed text. All three generation system
564
+ // prompts put "explanation" first, so its closing quote arrives well
565
+ // before any other key streams in. Handles JSON string escapes
566
+ // (including \uXXXX) that may be split across chunks. push() returns the
567
+ // decoded text so far, or null if the "explanation" key hasn't started
568
+ // yet (nothing to render) — e.g. for a prose-only response with no JSON
569
+ // envelope at all, which never starts.
570
+ function createExplanationExtractor() {
571
+ var buffer = "";
572
+ var phase = "seeking"; // seeking -> in_string -> done
573
+ var text = "";
574
+ var ESCAPES = { '"': '"', "\\": "\\", "/": "/", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t" };
575
+
576
+ return {
577
+ push: function (delta) {
578
+ if (phase === "done") { return text; }
579
+ buffer += delta;
580
+
581
+ if (phase === "seeking") {
582
+ var m = buffer.match(/"explanation"\s*:\s*"/);
583
+ if (!m) { return null; }
584
+ buffer = buffer.slice(m.index + m[0].length);
585
+ phase = "in_string";
586
+ }
587
+
588
+ var i = 0;
589
+ while (i < buffer.length) {
590
+ var ch = buffer[i];
591
+ if (ch === "\\") {
592
+ if (i + 1 >= buffer.length) { break; } // incomplete escape, wait for more
593
+ var esc = buffer[i + 1];
594
+ if (esc === "u") {
595
+ if (i + 6 > buffer.length) { break; } // incomplete \uXXXX
596
+ text += String.fromCharCode(parseInt(buffer.slice(i + 2, i + 6), 16));
597
+ i += 6;
598
+ continue;
599
+ }
600
+ text += (ESCAPES[esc] !== undefined ? ESCAPES[esc] : esc);
601
+ i += 2;
602
+ continue;
603
+ }
604
+ if (ch === '"') {
605
+ phase = "done";
606
+ i += 1;
607
+ break;
608
+ }
609
+ text += ch;
610
+ i += 1;
611
+ }
612
+ buffer = buffer.slice(i);
613
+ return text;
614
+ },
615
+ isDone: function () { return phase === "done"; }
616
+ };
617
+ }
618
+
619
+ // Shared error handler for Generate/Document/Modify, used by both the
620
+ // non-streaming (ajaxJson) and streaming (sendExecuteStream) paths. A 422
621
+ // with raw text means the model replied but we couldn't parse/validate a
622
+ // flow; show the raw so the user can see what happened.
623
+ function handleExecuteError(msg, raw) {
624
+ popDanglingUserHistory();
625
+ addMessage("error", msg);
626
+ if (raw) { addGeneratedJson(raw, true); }
627
+ setBusy(false);
628
+ }
629
+
630
+ // Clicking an action chip arms the suggested mode, fills the compose box,
631
+ // and fires immediately — the chip itself is the confirmation.
632
+ function applySuggestedAction(suggestedAction) {
633
+ if (!suggestedAction || !suggestedAction.mode || !suggestedAction.prompt) { return; }
634
+ armExecuteAction(suggestedAction.mode);
635
+ var $promptBox = el("#fp-prompt");
636
+ if ($promptBox.length) {
637
+ $promptBox.val(suggestedAction.prompt);
638
+ }
639
+ dispatchSend();
640
+ }
641
+
642
+ // Renders an optional "suggestedAction" (action chip) below
643
+ // the latest message — a tappable next-step the model proposed. Same
644
+ // chip shape/renderer regardless of whether an envelope or a
645
+ // tool call produced it.
646
+ function renderActionChip(suggestedAction) {
647
+ if (!suggestedAction || !suggestedAction.mode || !suggestedAction.prompt) { return; }
648
+ var $box = el("#fp-messages");
649
+ if (!$box.length) { return; }
650
+
651
+ var modeLabel = suggestedAction.mode === "generate" ? "Generate"
652
+ : suggestedAction.mode === "document" ? "Document"
653
+ : suggestedAction.mode === "modify" ? "Modify"
654
+ : suggestedAction.mode === "chat" ? "Chat" : suggestedAction.mode;
655
+
656
+ var preview = suggestedAction.prompt.length > 60
657
+ ? suggestedAction.prompt.slice(0, 57) + "…"
658
+ : suggestedAction.prompt;
659
+
660
+ var isChatMode = suggestedAction.mode === "chat";
661
+ var titleText = suggestedAction.customTitle || (isChatMode ? "Switch to Chat" : "Cleared for takeoff — " + modeLabel);
662
+
663
+ var $row = $("<div>").addClass("fp-chip-row");
664
+ var $card = $("<button>")
665
+ .addClass("fp-chip fp-chip-card")
666
+ .attr("type", "button")
667
+ .attr("title", suggestedAction.prompt)
668
+ .on("click", function () { applySuggestedAction(suggestedAction); });
669
+ $("<span>").addClass("fp-chip-icon")
670
+ .append($("<i>").addClass(isChatMode ? "fa fa-comment" : "fa fa-paper-plane"))
671
+ .appendTo($card);
672
+ var $body = $("<span>").addClass("fp-chip-body").appendTo($card);
673
+ $("<span>").addClass("fp-chip-title").text(titleText).appendTo($body);
674
+ $("<span>").addClass("fp-chip-sub").text(preview).appendTo($body);
675
+ $("<span>").addClass("fp-chip-go").html("&rsaquo;").appendTo($card);
676
+ $card.appendTo($row);
677
+
678
+ if (suggestedAction.selectionHint) {
679
+ $("<div>").addClass("fp-chip-hint").text("Tip: " + suggestedAction.selectionHint).appendTo($row);
680
+ }
681
+
682
+ $box.append($row);
683
+ addRecord("chip", { chipType: "suggestedAction", suggestedAction: suggestedAction });
684
+ scrollMessagesToBottom();
685
+ }
686
+
687
+ // Renders a single-button call-to-action chip below the latest message
688
+ // — used by first-run onboarding to jump straight to Settings. Same
689
+ // big icon-tile card as renderActionChip, just without a sub-line.
690
+ function renderChip(label, iconClass, onClick) {
691
+ var $box = el("#fp-messages");
692
+ if (!$box.length) { return; }
693
+
694
+ var $row = $("<div>").addClass("fp-chip-row");
695
+ var $card = $("<button>")
696
+ .addClass("fp-chip fp-chip-card")
697
+ .attr("type", "button")
698
+ .on("click", onClick);
699
+ $("<span>").addClass("fp-chip-icon").append($("<i>").addClass(iconClass)).appendTo($card);
700
+ $("<span>").addClass("fp-chip-body")
701
+ .append($("<span>").addClass("fp-chip-title").text(label))
702
+ .appendTo($card);
703
+ $("<span>").addClass("fp-chip-go").html("&rsaquo;").appendTo($card);
704
+ $card.appendTo($row);
705
+
706
+ $box.append($row);
707
+ scrollMessagesToBottom();
708
+ }
709
+
710
+ // Renders a clarifying question's quick-reply options as one-click
711
+ // buttons, plus a free-text "Other" option, below the latest message.
712
+ // Picking an option (or submitting "Other") fills the compose box with
713
+ // that text and sends it immediately via dispatchSend() — which routes
714
+ // to whatever's currently armed (Generate/Document/Modify follow-up, or
715
+ // a normal Query/chat message) exactly as if the user had typed and sent
716
+ // it themselves.
717
+ function renderClarifyingQuestion(options) {
718
+ if (!Array.isArray(options) || !options.length) { return; }
719
+ var $box = el("#fp-messages");
720
+ if (!$box.length) { return; }
721
+
722
+ var $row = $("<div>").addClass("fp-chip-row fp-question-row");
723
+ var $otherRow; // assigned below; declared here so answer() can reach it
724
+
725
+ function answer(text) {
726
+ $row.find("button, input").prop("disabled", true);
727
+ if ($otherRow) { $otherRow.find("button, input").prop("disabled", true); }
728
+ el("#fp-prompt").val(text);
729
+ dispatchSend();
730
+ }
731
+
732
+ options.forEach(function (opt) {
733
+ $("<button>")
734
+ .addClass("red-ui-button red-ui-button-small fp-chip fp-question-option")
735
+ .attr("type", "button")
736
+ .text(opt)
737
+ .on("click", function () { answer(opt); })
738
+ .appendTo($row);
739
+ });
740
+
741
+ $otherRow = $("<div>").addClass("fp-question-other-row fp-hidden");
742
+ var $otherInput = $("<input>")
743
+ .attr("type", "text")
744
+ .attr("placeholder", "Type your own answer…")
745
+ .addClass("fp-question-other-input");
746
+ var $otherSend = $("<button>")
747
+ .addClass("red-ui-button red-ui-button-small")
748
+ .attr("type", "button")
749
+ .append($("<i>").addClass("fa fa-paper-plane"));
750
+
751
+ function submitOther() {
752
+ var val = $otherInput.val().trim();
753
+ if (!val) { return; }
754
+ answer(val);
755
+ }
756
+ $otherSend.on("click", submitOther);
757
+ $otherInput.on("keydown", function (e) { if (e.key === "Enter") { submitOther(); } });
758
+ $otherRow.append($otherInput).append($otherSend);
759
+
760
+ $("<button>")
761
+ .addClass("red-ui-button red-ui-button-small fp-chip fp-question-other")
762
+ .attr("type", "button")
763
+ .text("Other…")
764
+ .on("click", function () {
765
+ $otherRow.removeClass("fp-hidden");
766
+ $otherInput.focus();
767
+ })
768
+ .appendTo($row);
769
+
770
+ $box.append($row).append($otherRow);
771
+ addRecord("question", { options: options });
772
+ scrollMessagesToBottom();
773
+ }
774
+
775
+ // Shared by Generate/Document/Modify result handlers: renders the model's
776
+ // clarifying-question or prose-only envelope as a normal assistant
777
+ // message and leaves the action armed for a follow-up. Returns true if it
778
+ // handled the response (caller should stop there), false if the caller
779
+ // should proceed to its own success rendering (flow review, etc).
780
+ function renderQuestionOrProse(data) {
781
+ if (data.question) {
782
+ var qText = (data.explanation ? data.explanation + "\n\n" : "") + data.question;
783
+ addMessage("assistant", qText);
784
+ pushHistory("assistant", qText);
785
+ renderActionChip(data.suggestedAction);
786
+ renderClarifyingQuestion(data.questionOptions);
787
+ setBusy(false);
788
+ updateSelectionStatus();
789
+ return true;
790
+ }
791
+ if (data.prose) {
792
+ addMessage("assistant", data.explanation || "(no content returned)");
793
+ pushHistory("assistant", data.explanation || "");
794
+ renderActionChip(data.suggestedAction);
795
+ renderClarifyingQuestion(data.questionOptions);
796
+ setBusy(false);
797
+ updateSelectionStatus();
798
+ return true;
799
+ }
800
+ return false;
801
+ }
802
+
803
+ // Shared result handler for /generate and /document — used by both the
804
+ // non-streaming (ajaxJson) and streaming (sendExecuteStream) paths so
805
+ // review rendering, history, and busy/selection state can't drift between
806
+ // the two.
807
+ function handleSimpleGenerationResult(data, goalPrompt) {
808
+ hidePending();
809
+ if (renderQuestionOrProse(data)) { return; }
810
+
811
+ // Lay nodes out before review/import — see layoutGeneratedFlow for why.
812
+ var flow = Array.isArray(data.flow) ? layoutGeneratedFlow(data.flow) : data.flow;
813
+ addMessage("assistant", data.explanation || "(no explanation returned)");
814
+ pushHistory("assistant", data.explanation || "(no explanation returned)");
815
+ addGeneratedReview(flow);
816
+ // After any Generate result, offer the deploy-verify loop as a one-click
817
+ // option. Only shown when no loop is already running and the original
818
+ // prompt is available (it always is here — goalPrompt comes from the
819
+ // compose box value captured at send time via wrappedOnResult).
820
+ if (goalPrompt && !activeBuildLoop) {
821
+ renderActionChip({ mode: "build", prompt: goalPrompt, customTitle: "Run deploy-verify loop on this →" });
822
+ }
823
+ renderActionChip(data.suggestedAction);
824
+ setBusy(false);
825
+ updateSelectionStatus();
826
+ }
827
+
828
+ // Shared result handler for /modify — used by both the non-streaming
829
+ // (ajaxJson) and streaming (sendExecuteStream) paths.
830
+ function handleModifyResult(data) {
831
+ hidePending();
832
+ if (renderQuestionOrProse(data)) { return; }
833
+
834
+ addMessage("assistant", data.explanation || "(no explanation returned)");
835
+ pushHistory("assistant", data.explanation || "(no explanation returned)");
836
+ if (data.skippedNote) { addMessage("assistant", "⚠ " + data.skippedNote); }
837
+ addModifyReview(data.flow, data.newNodes || [], data.newWires || [], data.removeNodes || [], applyModifications, null, data.newGroups || []);
838
+ renderActionChip(data.suggestedAction);
839
+ setBusy(false);
840
+ updateSelectionStatus();
841
+ }
842
+
843
+ // Streaming variant of Generate/Document/Modify. Posts with
844
+ // stream:true and progressively renders the envelope's "explanation"
845
+ // field into a bubble as deltas arrive (via createExplanationExtractor),
846
+ // using the same getReader/fallback pattern as sendChatStream. Once the
847
+ // stream ends, removes the streaming bubble and hands the validated
848
+ // `final` result to resultHandler — the same function the non-streaming
849
+ // path uses, so review rendering, history, and busy/selection state stay
850
+ // identical either way.
851
+ function sendExecuteStream(endpoint, payload, resultHandler) {
852
+ var $box = el("#fp-messages");
853
+ if (!$box.length) { return; }
854
+
855
+ // See the matching comment in sendChatStream — same fix, same bug
856
+ // (#4): don't pre-convert #fp-pending before any data has arrived.
857
+ var $msg = null;
858
+ var $text = null;
859
+
860
+ var extractor = createExplanationExtractor();
861
+ var finalData = null;
862
+ var finalStatus = null;
863
+ var errorData = null;
864
+ var generatingShown = false;
865
+
866
+ function ensureBubble() {
867
+ if ($text) { return; }
868
+ hidePending();
869
+ addMessage("assistant", "");
870
+ $msg = $box.find(".fp-message").last();
871
+ $text = $msg.find("div").last();
872
+ }
873
+
874
+ function fail(err) {
875
+ hidePending();
876
+ if ($msg && $msg.length) { $msg.remove(); }
877
+ handleExecuteError((err && err.message) ? err.message : String(err), null);
878
+ }
879
+
880
+ if (typeof fetch !== "function") {
881
+ fail(new Error("Streaming requires a browser with fetch() support. Disable streaming in Settings to use Generate/Document/Modify."));
882
+ return;
883
+ }
884
+
885
+ function processSseLines(lines) {
886
+ lines.forEach(function (line) {
887
+ line = line.trim();
888
+ if (line.indexOf("data:") !== 0) { return; }
889
+ var dataStr = line.slice(5).trim();
890
+ if (!dataStr || dataStr === "[DONE]") { return; }
891
+ var evt;
892
+ try { evt = JSON.parse(dataStr); } catch (e) { return; }
893
+
894
+ if (evt.delta) {
895
+ var text = extractor.push(evt.delta);
896
+ if (text !== null) {
897
+ ensureBubble();
898
+ $text.html(renderMarkdown(text));
899
+ scrollMessagesToBottom();
900
+ }
901
+ // The explanation has fully arrived but the rest of the
902
+ // envelope (the "flow" JSON etc.) is still streaming in
903
+ // and buffered — show a pending indicator below the
904
+ // explanation so the wait for the review block doesn't
905
+ // look like nothing is happening.
906
+ if (extractor.isDone() && !generatingShown) {
907
+ generatingShown = true;
908
+ showPending();
909
+ }
910
+ } else if (evt.final) {
911
+ finalData = evt.final;
912
+ finalStatus = evt.status;
913
+ } else if (evt.error) {
914
+ errorData = evt.error;
915
+ }
916
+ });
917
+ }
918
+
919
+ fetch(flowpilotUrl("flowpilot/" + endpoint), {
920
+ method: "POST",
921
+ headers: fetchHeaders(),
922
+ body: JSON.stringify(payload)
923
+ }).then(function (resp) {
924
+ if (!resp.ok) {
925
+ return resp.text().then(function (text) {
926
+ var msg = text;
927
+ try { msg = JSON.parse(text).error || text; } catch (e) { /* not JSON */ }
928
+ throw new Error(msg || resp.statusText);
929
+ });
930
+ }
931
+ if (!resp.body || !resp.body.getReader) {
932
+ return resp.text().then(function (text) {
933
+ processSseLines(text.split("\n"));
934
+ });
935
+ }
936
+
937
+ var reader = resp.body.getReader();
938
+ var decoder = new TextDecoder();
939
+ var buf = "";
940
+
941
+ function pump() {
942
+ return reader.read().then(function (step) {
943
+ if (step.done) { return; }
944
+ buf += decoder.decode(step.value, { stream: true });
945
+ var lines = buf.split("\n");
946
+ buf = lines.pop();
947
+ processSseLines(lines);
948
+ return pump();
949
+ });
950
+ }
951
+ return pump();
952
+ }).then(function () {
953
+ hidePending();
954
+ if ($msg && $msg.length) { $msg.remove(); }
955
+
956
+ if (errorData) {
957
+ handleExecuteError(errorData.error, errorData.raw);
958
+ return;
959
+ }
960
+ if (!finalData) {
961
+ handleExecuteError("No response received from the provider.", null);
962
+ return;
963
+ }
964
+ // finalize() can return a non-2xx status (e.g. 422 for invalid
965
+ // wire/id references) even on the "final" event, since an SSE
966
+ // response can't change its HTTP status after headers are sent.
967
+ // Route those to the error renderer instead of treating
968
+ // {error, raw} as a success body.
969
+ if (finalStatus && finalStatus >= 400) {
970
+ handleExecuteError(finalData.error || "Request failed.", finalData.raw);
971
+ return;
972
+ }
973
+ resultHandler(finalData);
974
+ }, fail);
975
+ }
976
+
977
+ // Request a generated flow fragment and show it for review, validation,
978
+ // and import via addGeneratedReview.
979
+ // Shared by Generate and Build — Build's first step is Generate-shaped
980
+ // (see lib/build-system-prompt.js): same envelope, same review/import
981
+ // pipeline. Only the endpoint, audit-mode name, and the "user" chat
982
+ // bubble's label prefix differ.
983
+ function runGenerateLikeAction(endpointName, mode, labelPrefix, onResult) {
984
+ var $promptBox = el("#fp-prompt");
985
+ var prompt = $promptBox.length ? $promptBox.val().trim() : "";
986
+ if (!prompt) {
987
+ addMessage("error", "Describe what you'd like to " + mode + " first.");
988
+ return;
989
+ }
990
+
991
+ // Selection context lets the model generate something that fits with
992
+ // the nodes you've selected (e.g. "wire this into my MQTT setup").
993
+ // Falls back to the pinned selection if nothing is currently
994
+ // selected, so follow-up turns need no reselection.
995
+ var context = attachDebugContext(collectSelectionContext(activeSelectionIds()));
996
+ var label = labelPrefix + prompt + contextAttachmentNote(context);
997
+ addMessage("user", label);
998
+ if (prompt === DEMO_PROMPT) {
999
+ addMessage("assistant", "This is a large request — AI providers may take 20+ seconds to respond.");
1000
+ }
1001
+ // Snapshot history before pushing this turn (see send()).
1002
+ var historyPayload = buildHistoryPayload();
1003
+ pushHistory("user", label);
1004
+ $promptBox.val("");
1005
+
1006
+ var ap = activeProvider();
1007
+ var isAgentLoop = ap && ap.supportsTools;
1008
+
1009
+ setBusy(true);
1010
+ showPending(isAgentLoop);
1011
+ var payload = {
1012
+ prompt: prompt, context: context,
1013
+ history: historyPayload.messages, historyTruncated: historyPayload.truncated,
1014
+ conversationId: conversationId
1015
+ };
1016
+
1017
+ function onError(msg, xhr) {
1018
+ hidePending();
1019
+ // 422 with raw text means the model replied but we couldn't parse a
1020
+ // flow; show the raw so the user can see what happened.
1021
+ var raw = xhr && xhr.responseJSON && xhr.responseJSON.raw;
1022
+ handleExecuteError(msg, raw);
1023
+ }
1024
+
1025
+ // Wraps onResult so callers (currently just buildFlow(), for the
1026
+ // /build loop) can see the original prompt text alongside the
1027
+ // response — without changing the single-argument calling
1028
+ // convention that runAgentLoop/sendExecuteStream/ajaxJson all share
1029
+ // across every other mode.
1030
+ function wrappedOnResult(data) { onResult(data, prompt); }
1031
+
1032
+ var fullEndpoint = "flowpilot/" + endpointName;
1033
+
1034
+ // Explore-then-propose: the model may call read
1035
+ // tools (e.g. read_node, search_flow) before producing the
1036
+ // generation envelope; the loop's final response still goes through
1037
+ // the same validate/review pipeline via onResult.
1038
+ if (isAgentLoop) {
1039
+ runAgentLoop(fullEndpoint, payload,
1040
+ { mode: mode, context: context, prompt: prompt },
1041
+ wrappedOnResult, onError);
1042
+ return;
1043
+ }
1044
+
1045
+ // Stream the envelope's "explanation" as it's generated; the
1046
+ // "flow" JSON is buffered server-side and arrives as a single
1047
+ // validated `final` event, handled identically to the non-streaming
1048
+ // path via onResult.
1049
+ if (currentSettings.streamingEnabled) {
1050
+ payload.stream = true;
1051
+ sendExecuteStream(endpointName, payload, wrappedOnResult);
1052
+ return;
1053
+ }
1054
+
1055
+ ajaxJson("POST", fullEndpoint, payload, wrappedOnResult, onError);
1056
+ }
1057
+
1058
+ function generate() {
1059
+ runGenerateLikeAction("generate", "generate", "Generate: ", handleSimpleGenerationResult);
1060
+ }
1061
+
1062
+ // /build's first step. Reuses Generate's pipeline wholesale for the
1063
+ // proposal itself (review/import), but on a successful import also
1064
+ // starts the build loop (startBuildLoop) — unlike plain Generate, a
1065
+ // build proposal is the first waypoint of a longer apply -> deploy ->
1066
+ // attach debug -> review -> fix/done cycle, not a one-shot. The loop
1067
+ // only starts once an actual flow lands, not on a clarifying question
1068
+ // or prose-only reply (renderQuestionOrProse handles those the same as
1069
+ // Generate/Document, with no loop involved).
1070
+ function buildFlow() {
1071
+ var context = collectSelectionContext(activeSelectionIds());
1072
+ if (context && Array.isArray(context.nodes) && context.nodes.length > 0) {
1073
+ runBuildOnExistingFlow(context);
1074
+ } else {
1075
+ runGenerateLikeAction("build", "build", "Build: ", handleBuildResult);
1076
+ }
1077
+ }
1078
+
1079
+ // Build loop on EXISTING selected nodes: routes to the Modify pipeline
1080
+ // with build-loop framing so the AI patches what's already there instead
1081
+ // of generating a fresh flow. Triggered when nodes are selected at the
1082
+ // moment /build fires.
1083
+ function runBuildOnExistingFlow(context) {
1084
+ var $promptBox = el("#fp-prompt");
1085
+ var goalPrompt = $promptBox.length ? $promptBox.val().trim() : "";
1086
+ if (!goalPrompt) {
1087
+ addMessage("error", "Describe what you want to achieve first.");
1088
+ return;
1089
+ }
1090
+ context = attachDebugContext(context);
1091
+ var existingNodeIds = context.nodes.map(function (n) { return n.id; });
1092
+
1093
+ var instruction = "[BUILD LOOP — STEP 1] Goal: \"" + goalPrompt + "\"\n\n" +
1094
+ "Analyse the attached nodes and propose what changes will make them achieve " +
1095
+ "this goal. Start \"explanation\" with a \"Plan:\" block listing the steps. " +
1096
+ "Produce a Modify-style patch (changes / newNodes / newWires / removeNodes) — " +
1097
+ "not a full flow replacement — unless a complete rebuild is clearly the right call.";
1098
+
1099
+ var label = "Build: " + goalPrompt + contextAttachmentNote(context);
1100
+ addMessage("user", label);
1101
+ var historyPayload = buildHistoryPayload();
1102
+ pushHistory("user", label);
1103
+ $promptBox.val("");
1104
+
1105
+ var ap = activeProvider();
1106
+ var isAgentLoop = ap && ap.supportsTools;
1107
+ setBusy(true);
1108
+ showPending(isAgentLoop);
1109
+
1110
+ var payload = {
1111
+ prompt: instruction, context: context,
1112
+ history: historyPayload.messages, historyTruncated: historyPayload.truncated,
1113
+ conversationId: conversationId
1114
+ };
1115
+
1116
+ function onBuildExistingError(msg, xhr) {
1117
+ hidePending();
1118
+ var raw = xhr && xhr.responseJSON && xhr.responseJSON.raw;
1119
+ handleExecuteError(msg, raw);
1120
+ }
1121
+ function onBuildExistingResult(data) {
1122
+ handleBuildOnExistingResult(data, goalPrompt, existingNodeIds);
1123
+ }
1124
+
1125
+ if (isAgentLoop) {
1126
+ runAgentLoop("flowpilot/modify", payload,
1127
+ { mode: "modify", context: context, prompt: instruction },
1128
+ onBuildExistingResult, onBuildExistingError);
1129
+ return;
1130
+ }
1131
+ if (currentSettings.streamingEnabled) {
1132
+ payload.stream = true;
1133
+ sendExecuteStream("modify", payload, onBuildExistingResult);
1134
+ return;
1135
+ }
1136
+ ajaxJson("POST", "flowpilot/modify", payload, onBuildExistingResult, onBuildExistingError);
1137
+ }
1138
+
1139
+ function handleBuildOnExistingResult(data, goalPrompt, existingNodeIds) {
1140
+ hidePending();
1141
+ if (renderQuestionOrProse(data)) { return; }
1142
+
1143
+ addMessage("assistant", data.explanation || "(no explanation returned)");
1144
+ pushHistory("assistant", data.explanation || "(no explanation returned)");
1145
+ if (data.skippedNote) { addMessage("assistant", "⚠ " + data.skippedNote); }
1146
+
1147
+ // Apply patches, then start the loop. idMap has placeholder→real-id
1148
+ // mappings from applyInsertions (which runs before this callback), so
1149
+ // we can extend the loop’s tracked node set to include any new nodes.
1150
+ function applyAndStartLoop(nodeDiffs, removeNodes, $applyBtn, idMap) {
1151
+ applyModifications(nodeDiffs, removeNodes, $applyBtn, idMap);
1152
+ var loopNodeIds = existingNodeIds.slice();
1153
+ if (idMap) {
1154
+ Object.keys(idMap).forEach(function (pid) {
1155
+ var realId = idMap[pid];
1156
+ if (realId && loopNodeIds.indexOf(realId) === -1) { loopNodeIds.push(realId); }
1157
+ });
1158
+ }
1159
+ startBuildLoop(goalPrompt, loopNodeIds, null);
1160
+ }
1161
+
1162
+ addModifyReview(data.flow, data.newNodes || [], data.newWires || [],
1163
+ data.removeNodes || [], applyAndStartLoop, null, data.newGroups || []);
1164
+ setBusy(false);
1165
+ updateSelectionStatus();
1166
+ }
1167
+
1168
+ function handleBuildResult(data, goalPrompt) {
1169
+ hidePending();
1170
+ if (renderQuestionOrProse(data)) { return; }
1171
+
1172
+ // Lay nodes out before review/import — see layoutGeneratedFlow for why.
1173
+ var flow = Array.isArray(data.flow) ? layoutGeneratedFlow(data.flow) : data.flow;
1174
+ addMessage("assistant", data.explanation || "(no explanation returned)");
1175
+ pushHistory("assistant", data.explanation || "(no explanation returned)");
1176
+ addGeneratedReview(flow, function (importResult) { startBuildLoop(goalPrompt, flow, importResult); }, goalPrompt);
1177
+ renderActionChip(data.suggestedAction);
1178
+ setBusy(false);
1179
+ updateSelectionStatus();
1180
+ }
1181
+
1182
+ // Document feature: explain the SELECTED nodes and produce a single
1183
+ // comment node (prose + Mermaid diagram in its "info" field) to drop onto
1184
+ // the canvas. Reuses the same validate -> review -> import pipeline as
1185
+ // Generate — a comment node is just a regular flow-JSON node, so there's
1186
+ // nothing import-mechanism-specific to build here. The prompt box holds
1187
+ // OPTIONAL notes to steer the explanation; the selection is the real input.
1188
+ function documentFlow() {
1189
+ // Falls back to the pinned selection if nothing is currently
1190
+ // selected, so follow-up turns need no reselection.
1191
+ var context = collectSelectionContext(activeSelectionIds());
1192
+ if (!context || !Array.isArray(context.nodes) || context.nodes.length === 0) {
1193
+ addMessage("error", "Select the node(s) you want documented first.");
1194
+ return;
1195
+ }
1196
+ context = attachDebugContext(context);
1197
+
1198
+ var $promptBox = el("#fp-prompt");
1199
+ var notes = $promptBox.length ? $promptBox.val().trim() : "";
1200
+ var label = "Document selection" + (notes ? ": " + notes : "") + contextAttachmentNote(context);
1201
+ addMessage("user", label);
1202
+ // Snapshot history before pushing this turn (see send()).
1203
+ var historyPayload = buildHistoryPayload();
1204
+ pushHistory("user", label);
1205
+ $promptBox.val("");
1206
+
1207
+ var ap = activeProvider();
1208
+ var isAgentLoop = ap && ap.supportsTools;
1209
+
1210
+ setBusy(true);
1211
+ showPending(isAgentLoop);
1212
+ var payload = {
1213
+ prompt: notes, context: context,
1214
+ history: historyPayload.messages, historyTruncated: historyPayload.truncated,
1215
+ conversationId: conversationId
1216
+ };
1217
+
1218
+ function onDocumentError(msg, xhr) {
1219
+ hidePending();
1220
+ var raw = xhr && xhr.responseJSON && xhr.responseJSON.raw;
1221
+ handleExecuteError(msg, raw);
1222
+ }
1223
+
1224
+ // Explore-then-propose, same as generate().
1225
+ if (isAgentLoop) {
1226
+ runAgentLoop("flowpilot/document", payload,
1227
+ { mode: "document", context: context, prompt: notes },
1228
+ handleSimpleGenerationResult, onDocumentError);
1229
+ return;
1230
+ }
1231
+
1232
+ // Stream the envelope's "explanation" as it's generated; see
1233
+ // generate() for details.
1234
+ if (currentSettings.streamingEnabled) {
1235
+ payload.stream = true;
1236
+ sendExecuteStream("document", payload, handleSimpleGenerationResult);
1237
+ return;
1238
+ }
1239
+
1240
+ ajaxJson("POST", "flowpilot/document", payload, handleSimpleGenerationResult, onDocumentError);
1241
+ }
1242
+
1243
+ // ---- Modify flow ------------------------------------------------------
1244
+ // Fields we never include in a property diff: internal editor state that
1245
+ // the model correctly omits and that we must never overwrite on apply.
1246
+ // "outputs" is intentionally NOT skipped: for node types like "function"
1247
+ // it's a real, user-meaningful defaults field (port count) that the model
1248
+ // is expected to change when asked for "N outputs" — skipping it silently
1249
+ // dropped that change while the func code already returned an N-element
1250
+ // array. (Switch nodes derive "outputs" from rules.length separately, in
1251
+ // applyModifications's Tier 1 block, so this doesn't conflict.)
1252
+
1253
+ function modifyFlow() {
1254
+ // Falls back to the pinned selection if nothing is currently
1255
+ // selected, so follow-up turns need no reselection.
1256
+ var context = collectSelectionContext(activeSelectionIds());
1257
+ if (!context || !Array.isArray(context.nodes) || context.nodes.length === 0) {
1258
+ addMessage("error", "Select the node(s) you want to modify first.");
1259
+ return;
1260
+ }
1261
+ context = attachDebugContext(context);
1262
+ var $promptBox = el("#fp-prompt");
1263
+ var instruction = $promptBox.length ? $promptBox.val().trim() : "";
1264
+ if (!instruction) {
1265
+ addMessage("error", "Describe what you want to change.");
1266
+ return;
1267
+ }
1268
+ var label = "Modify: " + instruction + contextAttachmentNote(context);
1269
+ addMessage("user", label);
1270
+ // Snapshot history before pushing this turn (see send()).
1271
+ var historyPayload = buildHistoryPayload();
1272
+ pushHistory("user", label);
1273
+ $promptBox.val("");
1274
+
1275
+ var ap = activeProvider();
1276
+ var isAgentLoop = ap && ap.supportsTools;
1277
+
1278
+ setBusy(true);
1279
+ showPending(isAgentLoop);
1280
+ var payload = {
1281
+ prompt: instruction, context: context,
1282
+ history: historyPayload.messages, historyTruncated: historyPayload.truncated,
1283
+ conversationId: conversationId
1284
+ };
1285
+
1286
+ function onModifyError(msg, xhr) {
1287
+ hidePending();
1288
+ var raw = xhr && xhr.responseJSON && xhr.responseJSON.raw;
1289
+ handleExecuteError(msg, raw);
1290
+ }
1291
+
1292
+ // Explore-then-propose, same as generate(). The
1293
+ // model may call read tools (e.g. to re-check the selected node's
1294
+ // current config) before producing the modify envelope; the final
1295
+ // diff still goes through finalizeModifyResult via handleModifyResult.
1296
+ if (isAgentLoop) {
1297
+ runAgentLoop("flowpilot/modify", payload,
1298
+ { mode: "modify", context: context, prompt: instruction },
1299
+ handleModifyResult, onModifyError);
1300
+ return;
1301
+ }
1302
+
1303
+ // Stream the envelope's "explanation" as it's generated; see
1304
+ // generate() for details.
1305
+ if (currentSettings.streamingEnabled) {
1306
+ payload.stream = true;
1307
+ sendExecuteStream("modify", payload, handleModifyResult);
1308
+ return;
1309
+ }
1310
+
1311
+ ajaxJson("POST", "flowpilot/modify", payload, handleModifyResult, onModifyError);
1312
+ }
1313
+
1314
+ // Render generated flow JSON in a preformatted, copyable block. Used for
1315
+ // the raw-response fallback when the model's reply couldn't be parsed.
1316
+ function addGeneratedJson(flowOrRaw, isRaw) {
1317
+ var $box = el("#fp-messages");
1318
+ if (!$box.length) { return; }
1319
+ var text = isRaw ? String(flowOrRaw)
1320
+ : JSON.stringify(flowOrRaw, null, 2);
1321
+
1322
+ var $msg = $("<div>").addClass("fp-message");
1323
+ $("<div>").addClass("fp-label").text(isRaw ? "RAW RESPONSE" : "GENERATED FLOW (JSON)").appendTo($msg);
1324
+ $("<pre>").addClass("fp-json").text(text).appendTo($msg);
1325
+ $box.append($msg);
1326
+ scrollMessagesToBottom();
1327
+ }
1328
+
1329
+ // ---- Pre-import validation ---------------------------------------------
1330
+ // Static list of Node-RED's built-in node types, used only to classify a
1331
+ // generated type as "core" vs "non-core but installed". The editor's node
1332
+ // registry can tell us whether a type is INSTALLED (RED.nodes.getType),
1333
+ // but not whether it ships with Node-RED itself — there is no documented
1334
+ // API for that, so a maintained list is the simple, stable answer (it
1335
+ // mirrors the same set the generation prompt steers the model toward).
1336
+
1337
+ // ---- /build loop: state machine + stepper ------------------------------
1338
+ // /build's first proposal reuses Generate's pipeline verbatim (see
1339
+ // buildFlow() below); this is what turns that one-shot proposal into a
1340
+ // build -> apply -> deploy -> attach -> review -> fix/done cycle. Every
1341
+ // proposed change (including fix iterations, added in a later step)
1342
+ // still goes through the normal diff-review-then-Apply UI — this state
1343
+ // machine only sequences WHEN the next request happens, never what
1344
+ // happens to the canvas directly.
1345
+ //
1346
+ // null when no loop is active. waypoint is one of:
1347
+ // "apply" — proposal imported, waiting for the user to place + Deploy.
1348
+ // "attach" — deployed, waiting for debug output to try it.
1349
+ // "review" — debug output attached; review request not yet wired up
1350
+ // (next step) so this is currently the end of the line.
1351
+ // "done"/"stopped" — terminal; activeBuildLoop is cleared instead of
1352
+ // held in these states.
1353
+ var activeBuildLoop = null;
1354
+
1355
+ // How long onDebugMessage's auto-attach waits, after each matching
1356
+ // message, for another one to arrive before locking in and running
1357
+ // the review — see onDebugMessage for why (a forked/split flow can
1358
+ // fire its debug node more than once per trigger).
1359
+ var BUILD_LOOP_ATTACH_DEBOUNCE_MS = 1200;
1360
+ var buildLoopAttachTimer = null;
1361
+ // Fires when "attach" waits too long with no debug — surfaces a prompt
1362
+ // for flows that don't produce automatic debug output (HTTP endpoints, etc).
1363
+ var BUILD_LOOP_NO_DEBUG_TIMEOUT_MS = 20000;
1364
+ var buildLoopNoDebugTimer = null;
1365
+
1366
+ var BUILD_LOOP_WAYPOINTS = [
1367
+ { id: "apply", label: "Deploy" },
1368
+ { id: "attach", label: "Attach debug" },
1369
+ { id: "review", label: "Review" },
1370
+ { id: "done", label: "Done" }
1371
+ ];
1372
+
1373
+ // Pauses the loop at the "attach → review" transition and shows a
1374
+ // clarifying-question-style checkpoint instead of auto-advancing.
1375
+ // Rendered when loopHoldStep is enabled in Settings; otherwise the
1376
+ // attach debounce timer calls runBuildReview directly.
1377
+ function renderLoopCheckpoint(loop) {
1378
+ var $box = el("#fp-messages");
1379
+ if (!$box.length || !loop) { return; }
1380
+
1381
+ addMessage("assistant", "Debug output attached — continue with AI review, or stop here?");
1382
+
1383
+ var $row = $("<div>").addClass("fp-chip-row fp-question-row");
1384
+
1385
+ function onContinue() {
1386
+ $row.find("button").prop("disabled", true);
1387
+ if (!activeBuildLoop) { return; }
1388
+ runBuildReview(activeBuildLoop);
1389
+ }
1390
+ function onStop() {
1391
+ $row.find("button").prop("disabled", true);
1392
+ stopBuildLoop("Build loop stopped — applied nodes remain as-is.");
1393
+ }
1394
+
1395
+ $("<button>")
1396
+ .addClass("red-ui-button red-ui-button-small fp-chip fp-question-option")
1397
+ .attr("type", "button")
1398
+ .text("Continue → AI review")
1399
+ .on("click", onContinue)
1400
+ .appendTo($row);
1401
+ $("<button>")
1402
+ .addClass("red-ui-button red-ui-button-small fp-chip fp-question-option")
1403
+ .attr("type", "button")
1404
+ .text("Stop loop")
1405
+ .on("click", onStop)
1406
+ .appendTo($row);
1407
+
1408
+ $box.append($row);
1409
+ addRecord("question", { options: ["Continue → AI review", "Stop loop"], loopCheckpoint: true });
1410
+ scrollMessagesToBottom();
1411
+ }
1412
+
1413
+ // The single exit point for every way a build loop ends — Touchdown,
1414
+ // the cap being reached, pausing on a clarifying question, or the user
1415
+ // clicking Stop. Releases Build mode and its pinned selection too: once
1416
+ // the loop is over, there's no reason to keep the original arm-time
1417
+ // selection pinned — the user can just select fresh nodes for whatever
1418
+ // comes next.
1419
+ // success=true: update the stepper to show "Done" highlighted and leave it
1420
+ // visible as a completion badge. success=false (default): remove the stepper
1421
+ // (user stop, cap reached, paused for question).
1422
+ function stopBuildLoop(note, success) {
1423
+ if (success && activeBuildLoop) {
1424
+ activeBuildLoop.waypoint = "done";
1425
+ renderLoopStepper(activeBuildLoop);
1426
+ }
1427
+ activeBuildLoop = null;
1428
+ if (buildLoopAttachTimer) { clearTimeout(buildLoopAttachTimer); buildLoopAttachTimer = null; }
1429
+ if (buildLoopNoDebugTimer) { clearTimeout(buildLoopNoDebugTimer); buildLoopNoDebugTimer = null; }
1430
+ if (!success) { el("#fp-loop-stepper").remove(); }
1431
+ disarmExecuteAction();
1432
+ if (note) { addMessage("assistant", note); }
1433
+ }
1434
+
1435
+ // Applies a build-loop review's fix envelope, then keeps the loop's
1436
+ // tracked node ids in sync and advances/stops it. Factored out of
1437
+ // handleBuildReviewResult's addModifyReview callback (rather than left
1438
+ // as an inline closure) so the EXACT same logic can run whether the
1439
+ // Apply click happened in the main window or was relayed from the
1440
+ // pop-out — see the applyByRecordId handler in initMainWindow (Phase 10 0B).
1441
+ function applyBuildLoopFix(nodeDiffs, removeNodesArg, idMap, capReached) {
1442
+ applyModifications(nodeDiffs, removeNodesArg, null, idMap);
1443
+ if (!activeBuildLoop) { return; }
1444
+ var loop = activeBuildLoop;
1445
+ if (idMap) {
1446
+ Object.keys(idMap).forEach(function (placeholderId) {
1447
+ var realId = idMap[placeholderId];
1448
+ if (realId && loop.nodeIds.indexOf(realId) === -1) { loop.nodeIds.push(realId); }
1449
+ });
1450
+ }
1451
+ if (Array.isArray(removeNodesArg) && removeNodesArg.length) {
1452
+ loop.nodeIds = loop.nodeIds.filter(function (id) { return removeNodesArg.indexOf(id) === -1; });
1453
+ }
1454
+ // Each iteration should review its OWN fresh debug output, not a
1455
+ // stale message from a prior failed attempt.
1456
+ attachedDebugMessages = [];
1457
+ updateDebugStatus();
1458
+ if (capReached) {
1459
+ stopBuildLoop("Couldn't fully verify after " + loop.maxIterations +
1460
+ " attempt(s) — applied this last fix, but stopping the auto-loop " +
1461
+ "here. Keep iterating manually with Modify if needed.");
1462
+ } else {
1463
+ loop.iteration++;
1464
+ loop.waypoint = "apply";
1465
+ renderLoopStepper(loop);
1466
+ }
1467
+ }
1468
+
1469
+ // Re-rendered (replacing any previous one, not stacked) every time the
1470
+ // loop advances a waypoint — the chat log above it already shows the
1471
+ // turn-by-turn history, so only the CURRENT state needs to be visible
1472
+ // here. Modeled on addGeneratedReview's look (.fp-review) rather than a
1473
+ // new visual language.
1474
+ function renderLoopStepper(loop) {
1475
+ var $box = el("#fp-messages");
1476
+ if (!$box.length) { return; }
1477
+ $box.find("#fp-loop-stepper").remove();
1478
+
1479
+ var $msg = $("<div>").addClass("fp-message fp-review").attr("id", "fp-loop-stepper");
1480
+ $("<div>").addClass("fp-label")
1481
+ .text("BUILD LOOP — ITERATION " + loop.iteration + "/" + loop.maxIterations)
1482
+ .appendTo($msg);
1483
+
1484
+ var $steps = $("<div>").addClass("fp-loop-steps").appendTo($msg);
1485
+ BUILD_LOOP_WAYPOINTS.forEach(function (wp, i) {
1486
+ var $step = $("<span>").addClass("fp-loop-step").text((i + 1) + ". " + wp.label);
1487
+ if (wp.id === loop.waypoint) { $step.addClass("fp-loop-step-active"); }
1488
+ $steps.append($step);
1489
+ });
1490
+
1491
+ var hint = "";
1492
+ if (loop.waypoint === "apply") {
1493
+ hint = "Click the canvas to place the new node(s), then Deploy — I'll move on automatically once you deploy.";
1494
+ } else if (loop.waypoint === "attach") {
1495
+ hint = "Trigger the flow, then check the Debug sidebar — I'll attach the next debug message automatically.";
1496
+ } else if (loop.waypoint === "review") {
1497
+ hint = "Debug output attached — reviewing against the goal…";
1498
+ }
1499
+ if (hint) { $("<div>").addClass("fp-loop-hint").text(hint).appendTo($msg); }
1500
+
1501
+ var $actions = $("<div>").addClass("fp-loop-actions").appendTo($msg);
1502
+ if (loop.waypoint !== "done") {
1503
+ $("<button>").addClass("red-ui-button red-ui-button-small").attr("type", "button")
1504
+ .text("Stop build loop")
1505
+ .on("click", function () { stopBuildLoop("Build loop stopped — applied nodes remain as-is."); })
1506
+ .appendTo($actions);
1507
+ }
1508
+
1509
+ // Replace any prior buildStep snapshot — only the latest waypoint matters.
1510
+ messageRecords = messageRecords.filter(function (r) { return r.kind !== "buildStep"; });
1511
+ addRecord("buildStep", {
1512
+ waypoint: loop.waypoint,
1513
+ iteration: loop.iteration,
1514
+ maxIterations: loop.maxIterations,
1515
+ goal: loop.goal,
1516
+ nodeIds: Array.isArray(loop.nodeIds) ? loop.nodeIds.slice() : []
1517
+ });
1518
+
1519
+ $box.append($msg);
1520
+ scrollMessagesToBottom();
1521
+ }
1522
+
1523
+ function rerenderBuildStepRecord(rec) {
1524
+ renderLoopStepper({
1525
+ waypoint: rec.waypoint || "done",
1526
+ iteration: rec.iteration || 1,
1527
+ maxIterations: rec.maxIterations || 5,
1528
+ goal: rec.goal || "",
1529
+ nodeIds: Array.isArray(rec.nodeIds) ? rec.nodeIds : []
1530
+ });
1531
+ }
1532
+
1533
+ // Called once the first build proposal is actually imported (not on a
1534
+ // clarifying question or prose-only reply — see handleBuildResult). goal
1535
+ // is the original prompt text, kept verbatim so the review step can
1536
+ // compare debug output against what the user actually asked for rather
1537
+ // than re-deriving it from the model's own "explanation".
1538
+ //
1539
+ // proposedNodes/importResult let us recover the REAL node ids Node-RED
1540
+ // just generated: importGeneratedFlow calls RED.view.importNodes with
1541
+ // generateIds:true, so the model's own placeholder ids (e.g. "n1") never
1542
+ // end up on the canvas — importResult.nodeMap maps each placeholder id
1543
+ // to the real live node object, which is the only way later review/fix
1544
+ // requests can target the right nodes via collectSelectionContext.
1545
+ function startBuildLoop(goal, nodeIdsOrNodes, importResult) {
1546
+ var nodeIds = [];
1547
+ if (importResult) {
1548
+ // Fresh build: map placeholder ids from the proposal to the real
1549
+ // ids importNodes assigned on the canvas.
1550
+ var nodeMap = importResult.nodeMap;
1551
+ if (nodeMap && Array.isArray(nodeIdsOrNodes)) {
1552
+ nodeIdsOrNodes.forEach(function (n) {
1553
+ var real = n && n.id && nodeMap[n.id];
1554
+ if (real && real.id) { nodeIds.push(real.id); }
1555
+ });
1556
+ }
1557
+ } else if (Array.isArray(nodeIdsOrNodes)) {
1558
+ // Existing-flow build: ids are already resolved real canvas ids.
1559
+ nodeIds = nodeIdsOrNodes.filter(function (id) { return typeof id === "string" && id; });
1560
+ }
1561
+ activeBuildLoop = {
1562
+ goal: goal,
1563
+ nodeIds: nodeIds,
1564
+ iteration: 1,
1565
+ maxIterations: getAgentLoopMaxIterations(),
1566
+ waypoint: "apply",
1567
+ conversationId: conversationId
1568
+ };
1569
+ renderLoopStepper(activeBuildLoop);
1570
+ }
1571
+
1572
+ // Auto-fires once the loop reaches the "review" waypoint (see
1573
+ // onDebugMessage's auto-attach). Reuses the EXISTING /flowpilot/modify
1574
+ // route and its "Diagnostic / review instructions" handling verbatim —
1575
+ // the same path that already answers "Review this"/"What's wrong here?"
1576
+ // requests by either replying in plain text (nothing to fix) or
1577
+ // proposing a changes/newNodes/etc envelope. No new backend route or
1578
+ // prompt needed; only the instruction text and context (the loop's own
1579
+ // node ids instead of the live/pinned canvas selection) are synthetic.
1580
+ function runBuildReview(loop) {
1581
+ var context = collectSelectionContext(loop.nodeIds);
1582
+ context = attachDebugContext(context);
1583
+ var instruction = "Review the attached debug output against this build goal: \"" +
1584
+ loop.goal + "\". Before concluding anything, list out every distinct " +
1585
+ "piece of data or behavior the goal actually requires, then check the " +
1586
+ "attached debug payload(s) contain EACH one — a payload that's merely " +
1587
+ "plausible-looking, or that satisfies only PART of the goal (e.g. the " +
1588
+ "goal asked to combine two things but the payload only shows one), " +
1589
+ "does NOT fully satisfy it. If more than one debug message is " +
1590
+ "attached, treat them together as the full picture from one trigger, " +
1591
+ "not as separate independent attempts. If it fully satisfies the goal, " +
1592
+ "say so in plain text — no changes needed. If something's wrong " +
1593
+ "(including a node that never fired, or a value that's missing/empty " +
1594
+ "when the goal needed it), propose the fix directly as a patch in " +
1595
+ "this same response, exactly as you would for any other review " +
1596
+ "request — respond with ONLY the {\"explanation\", \"changes\", " +
1597
+ "...} JSON object, no sentence of analysis before it. Put your " +
1598
+ "diagnosis of what's wrong INSIDE \"explanation\" — never write it as " +
1599
+ "prose first and the JSON second; that produces no diff for the user " +
1600
+ "to review.";
1601
+
1602
+ var historyPayload = buildHistoryPayload();
1603
+ var label = "Build review (iteration " + loop.iteration + "/" + loop.maxIterations + ")";
1604
+ addMessage("user", label);
1605
+ pushHistory("user", label);
1606
+
1607
+ var ap = activeProvider();
1608
+ var isAgentLoop = ap && ap.supportsTools;
1609
+ setBusy(true);
1610
+ showPending(isAgentLoop);
1611
+
1612
+ var payload = {
1613
+ prompt: instruction, context: context,
1614
+ history: historyPayload.messages, historyTruncated: historyPayload.truncated,
1615
+ conversationId: loop.conversationId
1616
+ };
1617
+
1618
+ function onReviewError(msg, xhr) {
1619
+ hidePending();
1620
+ var raw = xhr && xhr.responseJSON && xhr.responseJSON.raw;
1621
+ handleExecuteError(msg, raw);
1622
+ // Loop stays at "review" — the next debug message (or Stop) is
1623
+ // still available; nothing to roll back since nothing changed.
1624
+ }
1625
+
1626
+ if (isAgentLoop) {
1627
+ runAgentLoop("flowpilot/modify", payload,
1628
+ { mode: "modify", context: context, prompt: instruction },
1629
+ handleBuildReviewResult, onReviewError);
1630
+ return;
1631
+ }
1632
+ if (currentSettings.streamingEnabled) {
1633
+ payload.stream = true;
1634
+ sendExecuteStream("modify", payload, handleBuildReviewResult);
1635
+ return;
1636
+ }
1637
+ ajaxJson("POST", "flowpilot/modify", payload, handleBuildReviewResult, onReviewError);
1638
+ }
1639
+
1640
+ // Result handler for runBuildReview — three possible shapes, same as any
1641
+ // /flowpilot/modify response: a clarifying question, a prose-only reply
1642
+ // (nothing to fix — the loop is done), or a changes/newNodes/etc fix
1643
+ // envelope (routed through the same addModifyReview/applyModifications
1644
+ // diff-then-Apply pipeline as a manual Modify, then the loop advances
1645
+ // back to "apply" for the next deploy/test cycle, or stops if the
1646
+ // iteration cap is reached).
1647
+ function handleBuildReviewResult(data) {
1648
+ hidePending();
1649
+ var loop = activeBuildLoop;
1650
+ if (!loop) {
1651
+ // Stopped while this request was in flight — nothing loop-
1652
+ // specific left to do, just render the response normally.
1653
+ handleModifyResult(data);
1654
+ return;
1655
+ }
1656
+
1657
+ if (data.question) {
1658
+ var qText = (data.explanation ? data.explanation + "\n\n" : "") + data.question;
1659
+ addMessage("assistant", qText);
1660
+ pushHistory("assistant", qText);
1661
+ renderClarifyingQuestion(data.questionOptions);
1662
+ stopBuildLoop("Build loop paused — the review needs your input above. " +
1663
+ "Answer it, then continue manually with Modify, or start a fresh /build.");
1664
+ setBusy(false);
1665
+ updateSelectionStatus();
1666
+ return;
1667
+ }
1668
+
1669
+ if (data.prose) {
1670
+ addMessage("assistant", data.explanation || "(no content returned)");
1671
+ pushHistory("assistant", data.explanation || "");
1672
+ renderActionChip(data.suggestedAction);
1673
+ stopBuildLoop("Touchdown — the debug output matches the goal.", true);
1674
+ setBusy(false);
1675
+ updateSelectionStatus();
1676
+ return;
1677
+ }
1678
+
1679
+ var capReached = loop.iteration >= loop.maxIterations;
1680
+ addMessage("assistant", data.explanation || "(no explanation returned)");
1681
+ pushHistory("assistant", data.explanation || "(no explanation returned)");
1682
+ addModifyReview(data.flow, data.newNodes || [], data.newWires || [], data.removeNodes || [],
1683
+ function (nodeDiffs, removeNodesArg, $applyBtn, idMap) {
1684
+ applyBuildLoopFix(nodeDiffs, removeNodesArg, idMap, capReached);
1685
+ },
1686
+ { capReached: capReached }, data.newGroups || []);
1687
+ renderActionChip(data.suggestedAction);
1688
+ setBusy(false);
1689
+ updateSelectionStatus();
1690
+ }