@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.
- package/CHANGELOG.md +33 -0
- package/PROJECT-OVERVIEW.md +7 -7
- package/README.md +14 -8
- package/USER-GUIDE.md +27 -10
- package/flowpilot-core.css +44 -0
- package/flowpilot.js +56 -141
- package/lib/build-core-script.js +44 -0
- package/lib/build-system-prompt.js +6 -0
- package/lib/core/apply-review.js +1677 -0
- package/lib/core/history.js +93 -0
- package/lib/core/init.js +1510 -0
- package/lib/core/main.js +1710 -0
- package/lib/core/markdown.js +165 -0
- package/lib/core/modes.js +1567 -0
- package/lib/core/redaction.js +165 -0
- package/lib/core/selection-context.js +211 -0
- package/lib/envelope.js +136 -0
- package/lib/modify-system-prompt.js +3 -1
- package/lib/storage.js +4 -0
- package/package.json +1 -2
- package/flowpilot-core.js +0 -6416
|
@@ -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("›").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("›").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
|
+
}
|