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

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