@manny-est/node-red-flowpilot 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/flowpilot.js ADDED
@@ -0,0 +1,1644 @@
1
+ const http = require("http");
2
+ const createStorage = require("./lib/storage");
3
+ const provider = require("./lib/provider-openai-compatible");
4
+ const generationSystemPrompt = require("./lib/generation-system-prompt");
5
+ const documentSystemPrompt = require("./lib/document-system-prompt");
6
+ const modifySystemPrompt = require("./lib/modify-system-prompt");
7
+
8
+ module.exports = function flowPilotRuntime(RED) {
9
+ const storage = createStorage(RED.settings.userDir);
10
+
11
+ // ---------------------------------------------------------------------
12
+ // Client-held conversation history. The frontend sends a capped
13
+ // slice of the visible chat (role/content pairs) with each request; the
14
+ // backend stays stateless and just folds it into the message list. Used
15
+ // by both /chat and the generate/modify/document endpoints so the cap and
16
+ // truncation-notice behaviour can't drift between the two paths.
17
+ // ---------------------------------------------------------------------
18
+ const HISTORY_TRUNCATION_NOTICE =
19
+ "Note: earlier parts of this conversation were omitted to keep the " +
20
+ "request size manageable. Continue naturally; if you need something " +
21
+ "that may have been said earlier, ask the user.";
22
+
23
+ // ---------------------------------------------------------------------
24
+ // Tier-1 READ tools the model may call autonomously during
25
+ // a chat turn. Their data (RED.nodes, live selection, debug buffer) lives
26
+ // only in the editor, so each call is executed CLIENT-SIDE and its result
27
+ // passed back through the same sanitizer as selection context — a tool
28
+ // result can never carry a raw secret. WRITE actions are never exposed as
29
+ // tools; they stay on the existing diff/review/apply envelope.
30
+ // ---------------------------------------------------------------------
31
+ const AGENT_READ_TOOLS = [
32
+ {
33
+ type: "function",
34
+ function: {
35
+ name: "read_node",
36
+ description: "Read the sanitized configuration of a single node in " +
37
+ "the current flow editor, identified by id or by name. Use this " +
38
+ "when the user refers to a node that is not in the attached " +
39
+ "selection.",
40
+ parameters: {
41
+ type: "object",
42
+ properties: {
43
+ id: { type: "string", description: "The node's id, if known." },
44
+ name: { type: "string", description: "The node's display " +
45
+ "name (\"name\" property), if id is not known." }
46
+ },
47
+ additionalProperties: false
48
+ }
49
+ }
50
+ },
51
+ {
52
+ type: "function",
53
+ function: {
54
+ name: "list_flows",
55
+ description: "List the flow tabs AND subflow definitions in this " +
56
+ "Node-RED instance, with their labels, type (\"tab\" or " +
57
+ "\"subflow\"), enabled/disabled state, and node counts. Subflow " +
58
+ "definitions are listed separately from flow tabs (they appear " +
59
+ "as \"[Subflow] <name>\" in the editor). Use this to orient " +
60
+ "yourself before searching, and to find a subflow's id so its " +
61
+ "internal nodes can be looked up with search_flow/get_connections " +
62
+ "using that id as flowId.",
63
+ parameters: { type: "object", properties: {}, additionalProperties: false }
64
+ }
65
+ },
66
+ {
67
+ type: "function",
68
+ function: {
69
+ name: "search_flow",
70
+ description: "Search nodes across the flow editor (including nodes " +
71
+ "inside subflow definitions) by name and/or type substring " +
72
+ "(case-insensitive). Also matches subflow definitions by name " +
73
+ "(returned with type \"subflow\"), and subflow-instance nodes by " +
74
+ "their subflow's name. Returns matching items' id, name, type, " +
75
+ "and which flow tab (or subflow definition) they're on. Use this " +
76
+ "to find a node or subflow when you don't have its id.",
77
+ parameters: {
78
+ type: "object",
79
+ properties: {
80
+ query: { type: "string", description: "Substring to match " +
81
+ "against node name or type. Leave empty to list all nodes " +
82
+ "(combine with type or flowId to narrow it)." },
83
+ type: { type: "string", description: "Optional node type " +
84
+ "substring filter, e.g. \"http request\" or \"inject\"." },
85
+ flowId: { type: "string", description: "Optional flow tab id " +
86
+ "to restrict the search to." }
87
+ },
88
+ additionalProperties: false
89
+ }
90
+ }
91
+ },
92
+ {
93
+ type: "function",
94
+ function: {
95
+ name: "get_connections",
96
+ description: "Get the wiring (connections) for a node, identified " +
97
+ "by id, or — if no id is given — for the current selection, or " +
98
+ "for the whole active flow tab if nothing is selected.",
99
+ parameters: {
100
+ type: "object",
101
+ properties: {
102
+ id: { type: "string", description: "The node's id. Omit to use " +
103
+ "the current selection or active flow tab." }
104
+ },
105
+ additionalProperties: false
106
+ }
107
+ }
108
+ },
109
+ {
110
+ type: "function",
111
+ function: {
112
+ name: "read_debug",
113
+ description: "Read recent messages from the Node-RED Debug sidebar " +
114
+ "(already redacted of secret-shaped values). Use this for " +
115
+ "troubleshooting runtime behaviour without the user manually " +
116
+ "attaching debug output.",
117
+ parameters: {
118
+ type: "object",
119
+ properties: {
120
+ limit: { type: "integer", description: "Max number of recent " +
121
+ "messages to return (default 10, max 50)." }
122
+ },
123
+ additionalProperties: false
124
+ }
125
+ }
126
+ },
127
+ {
128
+ type: "function",
129
+ function: {
130
+ name: "get_selection",
131
+ description: "Get the sanitized configuration and connections of " +
132
+ "the node(s) currently selected in the editor, if any.",
133
+ parameters: { type: "object", properties: {}, additionalProperties: false }
134
+ }
135
+ }
136
+ ];
137
+
138
+ // Keep only well-formed { role: "user"|"assistant", content: <string> }
139
+ // entries. Anything else (bad shapes, empty content, other roles) is
140
+ // dropped rather than rejected outright — the history is advisory context,
141
+ // not a contract.
142
+ function sanitizeHistory(history) {
143
+ if (!Array.isArray(history)) { return []; }
144
+ return history
145
+ .filter(function (m) {
146
+ return m && (m.role === "user" || m.role === "assistant") &&
147
+ typeof m.content === "string" && m.content.trim();
148
+ })
149
+ .map(function (m) { return { role: m.role, content: m.content }; });
150
+ }
151
+
152
+ // ---------------------------------------------------------------------
153
+ // Per-conversation transcript persistence. The frontend generates a
154
+ // conversationId (kept for the life of the browser tab, reset on Clear
155
+ // Chat) and sends it with every request; the backend appends each turn to
156
+ // chats/<conversationId>.jsonl. Restricted to a safe filename charset —
157
+ // anything else is treated as "no conversation id" (transcript logging is
158
+ // best-effort, never blocks the request).
159
+ // ---------------------------------------------------------------------
160
+ function sanitizeConversationId(id) {
161
+ if (typeof id !== "string") { return null; }
162
+ const trimmed = id.trim();
163
+ return /^[A-Za-z0-9_-]{1,128}$/.test(trimmed) ? trimmed : null;
164
+ }
165
+
166
+ function recordTranscriptTurn(conversationId, mode, userText, assistantText) {
167
+ const id = sanitizeConversationId(conversationId);
168
+ if (!id) { return; }
169
+
170
+ const timestamp = new Date().toISOString();
171
+ if (userText && String(userText).trim()) {
172
+ storage.appendTranscript(id, { timestamp: timestamp, role: "user", mode: mode, content: String(userText) });
173
+ }
174
+ if (assistantText && String(assistantText).trim()) {
175
+ storage.appendTranscript(id, { timestamp: timestamp, role: "assistant", mode: mode, content: String(assistantText) });
176
+ }
177
+ }
178
+
179
+ // Pulls the natural-language part out of a generation-style result
180
+ // ({question}/{prose}/{explanation, flow}) for transcript storage — the
181
+ // same text the frontend renders as the assistant's chat bubble.
182
+ function transcriptTextFromGenerationResult(result) {
183
+ if (result.question) {
184
+ return (result.explanation ? result.explanation + "\n\n" : "") + result.question;
185
+ }
186
+ if (result.prose) { return result.prose; }
187
+ return result.explanation || "";
188
+ }
189
+
190
+ // ---------------------------------------------------------------------
191
+ // Recall: user-triggered keyword search across OTHER conversations'
192
+ // persisted transcripts (the current conversation is excluded; its own
193
+ // live history is already in context). Deliberately simple
194
+ // retrieval-injection: lowercase word-overlap scoring, no embeddings —
195
+ // provider-agnostic and works the same for every model.
196
+ // ---------------------------------------------------------------------
197
+ const RECALL_STOPWORDS = new Set([
198
+ "the", "and", "for", "are", "but", "not", "you", "all", "can", "had",
199
+ "her", "was", "one", "our", "out", "day", "get", "has", "him", "his",
200
+ "how", "man", "new", "now", "old", "see", "two", "way", "who", "boy",
201
+ "did", "its", "let", "put", "say", "she", "too", "use", "with", "this",
202
+ "that", "what", "your", "from", "have", "more", "will", "would", "there",
203
+ "their", "about", "into", "than", "then", "them", "these", "some",
204
+ "could", "should", "please", "want", "like", "just", "make", "node",
205
+ "nodes", "flow", "flowpilot"
206
+ ]);
207
+
208
+ function tokenize(text) {
209
+ return String(text || "")
210
+ .toLowerCase()
211
+ .split(/[^a-z0-9]+/)
212
+ .filter(function (w) { return w.length >= 3 && !RECALL_STOPWORDS.has(w); });
213
+ }
214
+
215
+ // Pairs up consecutive { role: "user" } / { role: "assistant" } transcript
216
+ // entries into one "exchange" so recall results read as a Q&A snippet
217
+ // rather than two disconnected lines.
218
+ function groupExchanges(entries) {
219
+ const exchanges = [];
220
+ let i = 0;
221
+ while (i < entries.length) {
222
+ const entry = entries[i];
223
+ const next = entries[i + 1];
224
+ if (entry.role === "user" && next && next.role === "assistant") {
225
+ exchanges.push({ timestamp: entry.timestamp, mode: entry.mode, user: entry.content, assistant: next.content });
226
+ i += 2;
227
+ } else {
228
+ exchanges.push({
229
+ timestamp: entry.timestamp,
230
+ mode: entry.mode,
231
+ user: entry.role === "user" ? entry.content : null,
232
+ assistant: entry.role === "assistant" ? entry.content : null
233
+ });
234
+ i += 1;
235
+ }
236
+ }
237
+ return exchanges;
238
+ }
239
+
240
+ function searchTranscripts(query, excludeConversationId) {
241
+ const queryTokens = new Set(tokenize(query));
242
+ if (queryTokens.size === 0) { return []; }
243
+
244
+ const matches = [];
245
+ storage.listConversationIds().forEach(function (id) {
246
+ if (id === excludeConversationId) { return; }
247
+ groupExchanges(storage.readTranscript(id)).forEach(function (exchange) {
248
+ const combinedTokens = tokenize((exchange.user || "") + " " + (exchange.assistant || ""));
249
+ let score = 0;
250
+ combinedTokens.forEach(function (t) { if (queryTokens.has(t)) { score++; } });
251
+ if (score > 0) {
252
+ matches.push({
253
+ conversationId: id,
254
+ timestamp: exchange.timestamp,
255
+ mode: exchange.mode,
256
+ user: exchange.user,
257
+ assistant: exchange.assistant,
258
+ score: score
259
+ });
260
+ }
261
+ });
262
+ });
263
+
264
+ matches.sort(function (a, b) {
265
+ if (b.score !== a.score) { return b.score - a.score; }
266
+ return new Date(b.timestamp) - new Date(a.timestamp);
267
+ });
268
+
269
+ return matches.slice(0, 5);
270
+ }
271
+
272
+ // ---------------------------------------------------------------------
273
+ // Palette awareness / "default nodes first": tell the model which
274
+ // optional node packages are actually installed in this Node-RED
275
+ // instance (beyond the always-available core nodes), so it can use
276
+ // their node types when relevant and otherwise stick to core nodes
277
+ // rather than proposing types that aren't installed.
278
+ //
279
+ // The node-level RED API passed to this module has no direct registry
280
+ // lookup (no RED.nodes.getNodeList), so the node list is fetched via a
281
+ // loopback call to Node-RED's own admin API (the same data the palette
282
+ // sidebar uses) and cached briefly — the palette rarely changes, and
283
+ // every chat/generate/document/modify request goes through
284
+ // buildMessages, so this must stay cheap and synchronous.
285
+ // ---------------------------------------------------------------------
286
+ let installedNodesCache = null;
287
+ let installedNodesCacheAt = 0;
288
+ let installedNodesRefreshInFlight = false;
289
+ const INSTALLED_NODES_CACHE_TTL_MS = 5 * 60 * 1000;
290
+
291
+ function buildInstalledNodesContent(list) {
292
+ if (!Array.isArray(list)) { return null; }
293
+
294
+ const typesByModule = {};
295
+ list.forEach(function (n) {
296
+ if (!n || !n.enabled) { return; }
297
+ if (n.module === "node-red" || n.module === "node-red-contrib-flowpilot") { return; }
298
+ if (!n.module) { return; }
299
+ if (!typesByModule[n.module]) { typesByModule[n.module] = new Set(); }
300
+ (n.types || []).forEach(function (t) { typesByModule[n.module].add(t); });
301
+ });
302
+
303
+ const modules = Object.keys(typesByModule).filter(function (m) { return typesByModule[m].size > 0; });
304
+ if (modules.length === 0) { return null; }
305
+
306
+ let content = "This Node-RED instance's palette (Manage palette > Installed) " +
307
+ "includes the following optional/non-default node packages, in addition " +
308
+ "to Node-RED's core/built-in nodes:\n";
309
+ modules.forEach(function (m) {
310
+ content += "- " + m + ": " + Array.from(typesByModule[m]).join(", ") + "\n";
311
+ });
312
+ content += "\nIf the user asks what's in the palette, which node " +
313
+ "packages/types are installed or available, or whether a specific node " +
314
+ "type is installed, answer directly from this list — it's already " +
315
+ "complete and current, so there's no need to call tools or inspect the " +
316
+ "current flow to answer those questions.\n\n" +
317
+ "When generating or modifying flows: default to Node-RED's core/built-in " +
318
+ "nodes (inject, function, change, switch, http request, debug, etc.) " +
319
+ "unless the user's request specifically calls for nodes from one of the " +
320
+ "optional packages listed above. Only use a non-core node type if it's " +
321
+ "listed above as installed — if a node type you'd otherwise want isn't " +
322
+ "covered by core nodes or this list, say so and note that the user would " +
323
+ "need to install it first, rather than proposing it as if it were already " +
324
+ "available.";
325
+ return content;
326
+ }
327
+
328
+ function refreshInstalledNodesCache() {
329
+ if (installedNodesRefreshInFlight) { return; }
330
+ installedNodesRefreshInFlight = true;
331
+
332
+ const root = String(RED.settings.httpAdminRoot || "/").replace(/\/+$/, "");
333
+ const req = http.get({
334
+ host: "127.0.0.1",
335
+ port: RED.settings.uiPort,
336
+ path: root + "/nodes",
337
+ headers: { Accept: "application/json" },
338
+ timeout: 5000
339
+ }, function (res) {
340
+ const chunks = [];
341
+ res.on("data", function (chunk) { chunks.push(chunk); });
342
+ res.on("end", function () {
343
+ installedNodesRefreshInFlight = false;
344
+ if (res.statusCode !== 200) { return; }
345
+ try {
346
+ const list = JSON.parse(Buffer.concat(chunks).toString("utf8"));
347
+ installedNodesCache = buildInstalledNodesContent(list);
348
+ installedNodesCacheAt = Date.now();
349
+ } catch (err) { /* leave previous cache value in place */ }
350
+ });
351
+ });
352
+ req.on("error", function () { installedNodesRefreshInFlight = false; });
353
+ req.on("timeout", function () { req.destroy(); installedNodesRefreshInFlight = false; });
354
+ }
355
+
356
+ function describeInstalledNodes() {
357
+ if (Date.now() - installedNodesCacheAt > INSTALLED_NODES_CACHE_TTL_MS) {
358
+ refreshInstalledNodesCache();
359
+ }
360
+ return installedNodesCache;
361
+ }
362
+
363
+ // Assemble the final messages array in the one place both /chat and the
364
+ // generate/modify/document endpoints use: system prompt, optional
365
+ // installed-node-package note, optional truncation notice, history,
366
+ // optional selection-context note, then the new user turn.
367
+ function buildMessages(systemPrompt, history, historyTruncated, described, userPrompt) {
368
+ const messages = [{ role: "system", content: systemPrompt }];
369
+ const installedNodes = describeInstalledNodes();
370
+ if (installedNodes) {
371
+ messages.push({ role: "system", content: installedNodes });
372
+ }
373
+ if (historyTruncated) {
374
+ messages.push({ role: "system", content: HISTORY_TRUNCATION_NOTICE });
375
+ }
376
+ (history || []).forEach(function (m) { messages.push(m); });
377
+ if (described) {
378
+ messages.push({ role: "system", content: described.content });
379
+ }
380
+ messages.push({ role: "user", content: userPrompt });
381
+ return messages;
382
+ }
383
+
384
+ // ---------------------------------------------------------------------
385
+ // Per-request performance fields for the audit log. Character
386
+ // counts are always available (provider-agnostic); token usage is included
387
+ // only when the provider returned a `usage` object. Kept separate from
388
+ // appendAudit's other fields so every chat/generate/modify/document audit
389
+ // entry reports the same shape.
390
+ // ---------------------------------------------------------------------
391
+ function performanceAuditFields(messages, content, providerResult) {
392
+ const fields = {
393
+ promptChars: (messages || []).reduce(function (sum, m) {
394
+ return sum + (m && typeof m.content === "string" ? m.content.length : 0);
395
+ }, 0),
396
+ completionChars: (content || "").length
397
+ };
398
+ if (providerResult && providerResult.timing) { fields.timing = providerResult.timing; }
399
+ if (providerResult && providerResult.usage) { fields.usage = providerResult.usage; }
400
+ return fields;
401
+ }
402
+
403
+ // ---------------------------------------------------------------------
404
+ // Shared helper: format selected-node context (sanitized by the frontend)
405
+ // into a system-message string for the model, plus counts for audit logs.
406
+ // Returns null when there's no selection — used by both /chat and
407
+ // /generate so the two describe context identically and never drift.
408
+ // ---------------------------------------------------------------------
409
+ function describeSelectionContext(context) {
410
+ const nodes = context && Array.isArray(context.nodes) ? context.nodes : [];
411
+ const debugMessages = context && Array.isArray(context.debugMessages) ? context.debugMessages : [];
412
+ if (nodes.length === 0 && debugMessages.length === 0) { return null; }
413
+
414
+ const connections = (context && context.connections) ? context.connections : {};
415
+ const edges = Array.isArray(connections.edges) ? connections.edges : [];
416
+ const perNode = Array.isArray(connections.perNode) ? connections.perNode : [];
417
+ const subFlowCount = (typeof connections.subFlowCount === "number") ? connections.subFlowCount : 0;
418
+
419
+ let content = "";
420
+ if (nodes.length > 0) {
421
+ content += "The user has selected the following Node-RED nodes as context. " +
422
+ "This is sanitized configuration; credentials are redacted.\n\n" +
423
+ "Nodes:\n```json\n" + JSON.stringify(nodes) + "\n```";
424
+ if (edges.length > 0) {
425
+ content += "\n\nConnections — directed edges by node id (a node's wires " +
426
+ "describe its OUTPUTS; one edge per output port; fromId/toId refer " +
427
+ "to the \"id\" fields in Nodes above):\n```json\n" +
428
+ JSON.stringify(edges) + "\n```";
429
+ content += "\n\nPer-node wiring summary, with readable \"Name [type]\" " +
430
+ "labels (inputs are reconstructed, since Node-RED nodes do not " +
431
+ "store their own inputs; subFlow groups nodes into connected " +
432
+ "sub-flows):\n```json\n" +
433
+ JSON.stringify(perNode) + "\n```";
434
+ }
435
+ if (subFlowCount > 1) {
436
+ content += "\n\nNote: the selection contains " + subFlowCount + " separate, " +
437
+ "unconnected sub-flows (see each node's subFlow number). Treat " +
438
+ "them as distinct unless the user says otherwise.";
439
+ }
440
+ }
441
+
442
+ if (debugMessages.length > 0) {
443
+ content += (content ? "\n\n" : "") +
444
+ "The user attached recent Node-RED Debug sidebar output for " +
445
+ "troubleshooting (runtime data, may be truncated):\n```json\n" +
446
+ JSON.stringify(debugMessages) + "\n```";
447
+ }
448
+
449
+ return { content: content, nodeCount: nodes.length, connectionCount: edges.length, debugMessageCount: debugMessages.length };
450
+ }
451
+
452
+ // ---------------------------------------------------------------------
453
+ // Shared helper: run a single-turn chat against the configured provider,
454
+ // log it, and return the result. Used by both /chat and /test so the two
455
+ // never drift apart. contextMode is recorded for the audit trail.
456
+ // ---------------------------------------------------------------------
457
+ // useTools: when true, the request offers AGENT_READ_TOOLS
458
+ // with tool_choice "auto". If the provider responds with tool_calls instead
459
+ // of a final message, we return early with `toolCalls` + the `messages`
460
+ // array built so far (so the caller/frontend can append the tool results
461
+ // and continue via /flowpilot/agent-step) — nothing is recorded to the
462
+ // transcript yet, since this isn't the final answer for the turn.
463
+ async function runChat(prompt, contextMode, context, history, historyTruncated, conversationId, useTools) {
464
+ const settings = storage.getSettings();
465
+ const activeProvider = storage.getActiveProvider(settings);
466
+
467
+ const described = describeSelectionContext(context);
468
+ const messages = buildMessages(
469
+ settings.systemPrompt || "You are FlowPilot, a Node-RED development assistant.",
470
+ history, historyTruncated, described, prompt
471
+ );
472
+
473
+ const chatOptions = useTools ? { tools: AGENT_READ_TOOLS, toolChoice: "auto" } : undefined;
474
+ const result = await provider.chat(activeProvider, messages, chatOptions);
475
+
476
+ if (result.toolCalls) {
477
+ const perf = performanceAuditFields(messages, result.content, result);
478
+ return { settings, activeProvider, result, perf, messages, toolCalls: result.toolCalls };
479
+ }
480
+
481
+ // The visible reply may end with a hidden <<<FLOWPILOT_DATA>>> block
482
+ // carrying a suggestedAction/questionOptions — split it off before
483
+ // logging or returning the message text.
484
+ const split = splitChatDataBlock(result.content || "");
485
+
486
+ recordTranscriptTurn(conversationId, "chat", prompt, split.message);
487
+
488
+ const perf = performanceAuditFields(messages, result.content, result);
489
+
490
+ return { settings, activeProvider, result, perf, chatMessage: split.message, chatData: split.data, messages };
491
+ }
492
+
493
+ // ---------------------------------------------------------------------
494
+ // Streaming variant of /chat. Relays provider SSE chunks
495
+ // to the browser as they arrive via res.write (Node-RED's httpAdmin routes
496
+ // are plain Express, so chunked relay works the same as any Express app).
497
+ // Generate/modify/document stay non-streamed (their JSON envelope can't be
498
+ // validated until complete).
499
+ // ---------------------------------------------------------------------
500
+ async function runChatStream(req, res, prompt, context, history, historyTruncated, conversationId) {
501
+ const settings = storage.getSettings();
502
+ const activeProvider = storage.getActiveProvider(settings);
503
+
504
+ const described = describeSelectionContext(context);
505
+ const messages = buildMessages(
506
+ settings.systemPrompt || "You are FlowPilot, a Node-RED development assistant.",
507
+ history, historyTruncated, described, prompt
508
+ );
509
+
510
+ res.writeHead(200, {
511
+ "Content-Type": "text/event-stream; charset=utf-8",
512
+ "Cache-Control": "no-cache, no-transform",
513
+ "Connection": "keep-alive",
514
+ "X-Accel-Buffering": "no"
515
+ });
516
+ if (typeof res.flushHeaders === "function") { res.flushHeaders(); }
517
+
518
+ // As with the non-streaming path, the reply may end with a hidden
519
+ // <<<FLOWPILOT_DATA>>> block. The splitter withholds the marker (and
520
+ // anything after it) from the relayed deltas so it's never flashed to
521
+ // the user, then we send its parsed contents as a separate `final`
522
+ // event once the stream completes.
523
+ const splitter = createChatDataStreamSplitter();
524
+ let visibleText = "";
525
+
526
+ let streamResult;
527
+ try {
528
+ streamResult = await provider.chatStream(activeProvider, messages, function (delta) {
529
+ const visible = splitter.push(delta);
530
+ if (visible) {
531
+ visibleText += visible;
532
+ res.write("data: " + JSON.stringify({ delta: visible }) + "\n\n");
533
+ }
534
+ });
535
+ } catch (err) {
536
+ res.write("data: " + JSON.stringify({ error: err.message }) + "\n\n");
537
+ res.end();
538
+ storage.appendAudit({ action: "chat_stream_error", error: err.message });
539
+ return;
540
+ }
541
+ const full = streamResult.content;
542
+
543
+ const finished = splitter.finish();
544
+ if (finished.tail) {
545
+ visibleText += finished.tail;
546
+ res.write("data: " + JSON.stringify({ delta: finished.tail }) + "\n\n");
547
+ }
548
+
549
+ const final = {};
550
+ const suggestedAction = extractSuggestedAction(finished.data);
551
+ if (suggestedAction) { final.suggestedAction = suggestedAction; }
552
+ const questionOptions = extractQuestionOptions(finished.data);
553
+ if (questionOptions) { final.questionOptions = questionOptions; }
554
+ if (final.suggestedAction || final.questionOptions) {
555
+ res.write("data: " + JSON.stringify({ final: final }) + "\n\n");
556
+ }
557
+
558
+ res.write("data: [DONE]\n\n");
559
+ res.end();
560
+
561
+ storage.appendAudit(Object.assign({
562
+ action: "chat_stream",
563
+ providerName: activeProvider.providerName,
564
+ baseUrl: activeProvider.baseUrl,
565
+ model: activeProvider.model
566
+ }, performanceAuditFields(messages, full, streamResult)));
567
+
568
+ recordTranscriptTurn(conversationId, "chat", prompt, visibleText);
569
+ }
570
+
571
+ // ---- Settings: read --------------------------------------------------
572
+
573
+ RED.httpAdmin.get("/flowpilot/settings", RED.auth.needsPermission("settings.read"), function (req, res) {
574
+ try {
575
+ res.json(storage.getSettings());
576
+ } catch (err) {
577
+ res.status(500).json({ error: err.message });
578
+ }
579
+ });
580
+
581
+ // ---- Settings: default system prompt (for "Reset to default") -------
582
+
583
+ RED.httpAdmin.get("/flowpilot/default-system-prompt", RED.auth.needsPermission("settings.read"), function (req, res) {
584
+ try {
585
+ res.json({ systemPrompt: storage.getDefaultSystemPrompt() });
586
+ } catch (err) {
587
+ res.status(500).json({ error: err.message });
588
+ }
589
+ });
590
+
591
+ // ---- Settings: write -------------------------------------------------
592
+
593
+ RED.httpAdmin.post("/flowpilot/settings", RED.auth.needsPermission("settings.write"), function (req, res) {
594
+ try {
595
+ const saved = storage.saveSettings(req.body || {});
596
+ storage.appendAudit({
597
+ action: "settings_saved",
598
+ providerName: saved.providerName,
599
+ baseUrl: saved.baseUrl,
600
+ model: saved.model
601
+ });
602
+ res.json(saved);
603
+ } catch (err) {
604
+ res.status(500).json({ error: err.message });
605
+ }
606
+ });
607
+
608
+ // ---- Models: list models via the active provider's /v1/models -------
609
+ // Always acts on the SAVED active provider (the frontend saves the form
610
+ // first, mirroring Pre-flight check), and never errors out for a provider
611
+ // that doesn't support /v1/models — see listModels().
612
+
613
+ RED.httpAdmin.post("/flowpilot/models", RED.auth.needsPermission("settings.write"), async function (req, res) {
614
+ try {
615
+ const settings = storage.getSettings();
616
+ const activeProvider = storage.getActiveProvider(settings);
617
+ const result = await provider.listModels(activeProvider);
618
+ storage.appendAudit({
619
+ action: "list_models",
620
+ providerName: activeProvider.providerName,
621
+ baseUrl: activeProvider.baseUrl,
622
+ modelCount: result.models.length,
623
+ error: result.error || null
624
+ });
625
+ res.json(result);
626
+ } catch (err) {
627
+ res.status(500).json({ error: err.message });
628
+ }
629
+ });
630
+
631
+ // ---- Chat: the real prompt endpoint ----------------------------------
632
+ // Handles message history, flow context, and streaming. Kept separate
633
+ // from /test, which stays a minimal connectivity check.
634
+
635
+ RED.httpAdmin.post("/flowpilot/chat", RED.auth.needsPermission("settings.write"), async function (req, res) {
636
+ const prompt = req.body && req.body.prompt;
637
+
638
+ if (!prompt || !String(prompt).trim()) {
639
+ return res.status(400).json({ error: "A prompt is required." });
640
+ }
641
+
642
+ const history = sanitizeHistory(req.body.history);
643
+ const historyTruncated = !!req.body.historyTruncated;
644
+
645
+ if (req.body.stream) {
646
+ try {
647
+ await runChatStream(req, res, prompt, req.body.context, history, historyTruncated, req.body.conversationId);
648
+ } catch (err) {
649
+ storage.appendAudit({ action: "chat_stream_error", error: err.message });
650
+ if (!res.headersSent) {
651
+ res.status(500).json({ error: err.message });
652
+ } else {
653
+ try { res.end(); } catch (e) { /* already closed */ }
654
+ }
655
+ }
656
+ return;
657
+ }
658
+
659
+ try {
660
+ const useTools = !!req.body.tools;
661
+ const { activeProvider, result, perf, chatMessage, chatData, messages, toolCalls } =
662
+ await runChat(prompt, "selected-nodes", req.body.context, history, historyTruncated, req.body.conversationId, useTools);
663
+
664
+ storage.appendAudit(Object.assign({
665
+ action: "chat",
666
+ providerName: activeProvider.providerName,
667
+ baseUrl: activeProvider.baseUrl,
668
+ model: activeProvider.model,
669
+ toolCallCount: toolCalls ? toolCalls.length : 0
670
+ }, perf));
671
+
672
+ if (toolCalls) {
673
+ return res.json({ toolCalls: toolCalls, messages: messages, content: result.content || null, usage: result.usage || null });
674
+ }
675
+
676
+ const body = {
677
+ message: chatMessage || "[No assistant message returned by provider]",
678
+ raw: result.raw ? "[raw response captured]" : null,
679
+ usage: result.usage || null
680
+ };
681
+ const suggestedAction = extractSuggestedAction(chatData);
682
+ if (suggestedAction) { body.suggestedAction = suggestedAction; }
683
+ const questionOptions = extractQuestionOptions(chatData);
684
+ if (questionOptions) { body.questionOptions = questionOptions; }
685
+ res.json(body);
686
+ } catch (err) {
687
+ storage.appendAudit({ action: "chat_error", error: err.message });
688
+ res.status(500).json({ error: err.message });
689
+ }
690
+ });
691
+
692
+ // ---- Agent step: continue a tool-calling loop --------------------------
693
+ // The frontend owns the loop: after executing any tool_calls returned by
694
+ // /flowpilot/{chat,generate,document,modify} (tools:true) or a prior
695
+ // /agent-step against RED.nodes, it appends { role: "assistant",
696
+ // tool_calls } and { role: "tool", ... } result messages and posts the
697
+ // full array back here. Stateless — just another provider.chat call with
698
+ // the same tool definitions.
699
+ //
700
+ // `mode` ("chat" | "generate" | "document" | "modify", default "chat")
701
+ // controls how a FINAL (non-tool-call) response is interpreted:
702
+ // - "chat": split off the <<<FLOWPILOT_DATA>>> block, same as /chat.
703
+ // - "generate"/"document"/"modify" (Step 4, explore-then-propose): parse
704
+ // the { explanation, flow|changes, ... } envelope via
705
+ // processGenerationContent + finalizeSimpleGeneration/
706
+ // finalizeModifyResult — the SAME validate step the non-streaming
707
+ // routes use, so a tool-using turn still ends in the reviewed envelope.
708
+ // `context` (for describeSelectionContext / modify's originalNodes) and
709
+ // `prompt` (for transcript recording) are passed through from the
710
+ // initial request.
711
+ RED.httpAdmin.post("/flowpilot/agent-step", RED.auth.needsPermission("settings.write"), async function (req, res) {
712
+ const messages = req.body && req.body.messages;
713
+ if (!Array.isArray(messages) || messages.length === 0) {
714
+ return res.status(400).json({ error: "messages array is required." });
715
+ }
716
+ const mode = req.body.mode || "chat";
717
+
718
+ try {
719
+ const settings = storage.getSettings();
720
+ const activeProvider = storage.getActiveProvider(settings);
721
+ const result = await provider.chat(activeProvider, messages, { tools: AGENT_READ_TOOLS, toolChoice: "auto" });
722
+
723
+ storage.appendAudit(Object.assign({
724
+ action: "agent_step",
725
+ mode: mode,
726
+ providerName: activeProvider.providerName,
727
+ baseUrl: activeProvider.baseUrl,
728
+ model: activeProvider.model,
729
+ toolCallCount: result.toolCalls ? result.toolCalls.length : 0
730
+ }, performanceAuditFields(messages, result.content, result)));
731
+
732
+ if (result.toolCalls) {
733
+ return res.json({ toolCalls: result.toolCalls, content: result.content || null, usage: result.usage || null });
734
+ }
735
+
736
+ if (mode !== "chat") {
737
+ const context = req.body.context;
738
+ const described = describeSelectionContext(context);
739
+ const generated = processGenerationContent(result.content || "", result, messages, mode, described, activeProvider);
740
+ recordTranscriptTurn(req.body.conversationId, mode, req.body.prompt || null, transcriptTextFromGenerationResult(generated));
741
+ const finalize = (mode === "modify")
742
+ ? function (r) { return finalizeModifyResult(r, (context && Array.isArray(context.nodes)) ? context.nodes : []); }
743
+ : finalizeSimpleGeneration;
744
+ const { status, body } = finalize(generated);
745
+ return res.status(status).json(body);
746
+ }
747
+
748
+ const split = splitChatDataBlock(result.content || "");
749
+ recordTranscriptTurn(req.body.conversationId, "chat", null, split.message);
750
+
751
+ const body = { message: split.message || "[No assistant message returned by provider]", usage: result.usage || null };
752
+ const suggestedAction = extractSuggestedAction(split.data);
753
+ if (suggestedAction) { body.suggestedAction = suggestedAction; }
754
+ const questionOptions = extractQuestionOptions(split.data);
755
+ if (questionOptions) { body.questionOptions = questionOptions; }
756
+ res.json(body);
757
+ } catch (err) {
758
+ sendGenerationError(res, mode + "_agent_step", err);
759
+ }
760
+ });
761
+
762
+ // ---- Recall: search past conversations' transcripts -------------------
763
+ // User-triggered, not automatic: the frontend's "Recall" button sends the
764
+ // current prompt-box text as the query. Results are returned for display
765
+ // only — nothing is injected into the model's context.
766
+
767
+ RED.httpAdmin.post("/flowpilot/recall", RED.auth.needsPermission("settings.write"), function (req, res) {
768
+ const query = req.body && req.body.query;
769
+ if (!query || !String(query).trim()) {
770
+ return res.status(400).json({ error: "Enter something to search for first." });
771
+ }
772
+
773
+ try {
774
+ const results = searchTranscripts(String(query).trim(), sanitizeConversationId(req.body.conversationId));
775
+ storage.appendAudit({ action: "recall", resultCount: results.length });
776
+ res.json({ results: results });
777
+ } catch (err) {
778
+ storage.appendAudit({ action: "recall_error", error: err.message });
779
+ res.status(500).json({ error: err.message });
780
+ }
781
+ });
782
+
783
+ // ---------------------------------------------------------------------
784
+ // Conversation list ("Flight log"), layered over the per-conversation
785
+ // transcript files. Summaries are read-only and derived on the fly — title is the
786
+ // first user message, trimmed; full transcripts are fetched on demand.
787
+ // ---------------------------------------------------------------------
788
+ function summarizeTranscript(id) {
789
+ const entries = storage.readTranscript(id);
790
+ if (!entries.length) { return null; }
791
+ const firstUser = entries.find(function (e) { return e.role === "user"; });
792
+ const last = entries[entries.length - 1];
793
+ return {
794
+ id: id,
795
+ title: firstUser ? String(firstUser.content).trim().slice(0, 80) : "(untitled)",
796
+ lastTimestamp: last.timestamp,
797
+ exchangeCount: groupExchanges(entries).length
798
+ };
799
+ }
800
+
801
+ RED.httpAdmin.get("/flowpilot/conversations", RED.auth.needsPermission("settings.read"), function (req, res) {
802
+ try {
803
+ const conversations = storage.listConversationIds()
804
+ .map(summarizeTranscript)
805
+ .filter(Boolean)
806
+ .sort(function (a, b) { return new Date(b.lastTimestamp) - new Date(a.lastTimestamp); });
807
+ res.json({ conversations: conversations });
808
+ } catch (err) {
809
+ res.status(500).json({ error: err.message });
810
+ }
811
+ });
812
+
813
+ RED.httpAdmin.get("/flowpilot/conversations/:id", RED.auth.needsPermission("settings.read"), function (req, res) {
814
+ const id = sanitizeConversationId(req.params.id);
815
+ if (!id) { return res.status(400).json({ error: "Invalid conversation id." }); }
816
+ try {
817
+ res.json({ id: id, messages: storage.readTranscript(id) });
818
+ } catch (err) {
819
+ res.status(500).json({ error: err.message });
820
+ }
821
+ });
822
+
823
+ RED.httpAdmin.delete("/flowpilot/conversations/:id", RED.auth.needsPermission("settings.write"), function (req, res) {
824
+ const id = sanitizeConversationId(req.params.id);
825
+ if (!id) { return res.status(400).json({ error: "Invalid conversation id." }); }
826
+ try {
827
+ storage.deleteTranscript(id);
828
+ storage.appendAudit({ action: "conversation_delete", conversationId: id });
829
+ res.json({ ok: true });
830
+ } catch (err) {
831
+ res.status(500).json({ error: err.message });
832
+ }
833
+ });
834
+
835
+ RED.httpAdmin.delete("/flowpilot/conversations", RED.auth.needsPermission("settings.write"), function (req, res) {
836
+ try {
837
+ const ids = storage.listConversationIds();
838
+ ids.forEach(function (id) { storage.deleteTranscript(id); });
839
+ storage.appendAudit({ action: "conversation_delete_all", count: ids.length });
840
+ res.json({ ok: true, count: ids.length });
841
+ } catch (err) {
842
+ res.status(500).json({ error: err.message });
843
+ }
844
+ });
845
+
846
+ // ---- Test: connectivity check only -----------------------------------
847
+ // Deliberately minimal. Confirms "can I reach the provider and get a
848
+ // reply at all." Never depends on chat history or flow context.
849
+
850
+ RED.httpAdmin.post("/flowpilot/test", RED.auth.needsPermission("settings.write"), async function (req, res) {
851
+ const prompt = (req.body && req.body.prompt) || "Say hello from FlowPilot.";
852
+
853
+ try {
854
+ const { settings, activeProvider, result, perf, chatMessage } = await runChat(prompt, "connectivity-test");
855
+
856
+ storage.appendAudit(Object.assign({
857
+ action: "chat_test",
858
+ providerName: activeProvider.providerName,
859
+ baseUrl: activeProvider.baseUrl,
860
+ model: activeProvider.model
861
+ }, perf));
862
+
863
+ // Capability probe — connectivity already succeeded above, so a
864
+ // probe failure here just means "no tool support", not a /test failure.
865
+ // Persist the result on the provider profile for the agentic tool-calling path.
866
+ const probe = await provider.probeTools(activeProvider);
867
+ storage.appendAudit({
868
+ action: "capability_probe",
869
+ providerName: activeProvider.providerName,
870
+ baseUrl: activeProvider.baseUrl,
871
+ model: activeProvider.model,
872
+ supportsTools: probe.supportsTools
873
+ });
874
+
875
+ const updatedProviders = (settings.providers || []).map(function (p) {
876
+ return p.id === activeProvider.id
877
+ ? Object.assign({}, p, { supportsTools: probe.supportsTools, toolsProbedAt: new Date().toISOString() })
878
+ : p;
879
+ });
880
+ storage.saveSettings(Object.assign({}, settings, { providers: updatedProviders }));
881
+
882
+ res.json({
883
+ message: chatMessage || "[No assistant message returned by provider]",
884
+ raw: result.raw ? "[raw response captured]" : null,
885
+ capability: {
886
+ supportsTools: probe.supportsTools,
887
+ label: probe.supportsTools
888
+ ? "✓ Connected · ✓ Supports tools"
889
+ : "✓ Connected · ⚠ No tool support — compatibility mode"
890
+ }
891
+ });
892
+ } catch (err) {
893
+ storage.appendAudit({ action: "chat_test_error", error: err.message });
894
+ res.status(500).json({ error: err.message });
895
+ }
896
+ });
897
+
898
+ // ---- Generate: produce an importable flow fragment --------------------
899
+ // Uses the generation system prompt and expects the model to return a single
900
+ // JSON object { explanation, flow }. This first cut does NOT validate node
901
+ // types or wire integrity yet (that's the next chunk) — it returns the parsed
902
+ // envelope so the frontend can display it for review.
903
+
904
+ function extractJsonObject(text) {
905
+ if (!text) { throw new Error("Empty response from provider."); }
906
+ let s = String(text).trim();
907
+ // Strip markdown code fences if the model wrapped the JSON.
908
+ s = s.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
909
+
910
+ const firstBrace = s.indexOf("{");
911
+ const firstBracket = s.indexOf("[");
912
+
913
+ // The model occasionally returns a bare top-level array (e.g.
914
+ // `[ {...node...} ]`) instead of the {explanation, flow} envelope. If we
915
+ // fell through to the {...} extraction below, indexOf("{")/lastIndexOf("}")
916
+ // would grab just the first node object — which has no "flow" key and
917
+ // fails validation. Detect this case up front and wrap it as a minimal
918
+ // envelope instead.
919
+ if (firstBracket !== -1 && (firstBrace === -1 || firstBracket < firstBrace)) {
920
+ const lastBracket = s.lastIndexOf("]");
921
+ if (lastBracket !== -1 && lastBracket > firstBracket) {
922
+ try {
923
+ const arr = JSON.parse(s.slice(firstBracket, lastBracket + 1));
924
+ if (Array.isArray(arr)) {
925
+ return { explanation: "", flow: arr };
926
+ }
927
+ } catch (e) {
928
+ // Not a parseable array — fall through to the {...} extraction.
929
+ }
930
+ }
931
+ }
932
+
933
+ // If there's leading/trailing prose, grab the outermost {...}.
934
+ const first = s.indexOf("{");
935
+ const last = s.lastIndexOf("}");
936
+ if (first === -1 || last === -1 || last < first) {
937
+ // No JSON object found at all — flagged separately from a found-
938
+ // but-unparseable ({...} present, JSON.parse failed) "garbled" error,
939
+ // so callers can distinguish "model just answered in prose" (tolerate)
940
+ // from "model's JSON envelope is broken" (still an error).
941
+ const err = new Error("Provider did not return a JSON object.");
942
+ err.noJsonFound = true;
943
+ throw err;
944
+ }
945
+ return JSON.parse(s.slice(first, last + 1));
946
+ }
947
+
948
+ // ---------------------------------------------------------------------
949
+ // Shared helper: resolve the active provider and assemble the messages
950
+ // array for a generation-style request (generate/document/modify). Split
951
+ // out from runFlowGeneration so the streaming variant can build the
952
+ // same request and swap provider.chat for provider.chatStream.
953
+ // ---------------------------------------------------------------------
954
+ function buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated) {
955
+ const settings = storage.getSettings();
956
+ const activeProvider = storage.getActiveProvider(settings);
957
+ const described = describeSelectionContext(context);
958
+ const messages = buildMessages(systemPrompt, history, historyTruncated, described, userPrompt);
959
+ return { activeProvider, described, messages };
960
+ }
961
+
962
+ // ---------------------------------------------------------------------
963
+ // Pull an optional "suggestedAction" (action chip) out of a
964
+ // parsed envelope. Validated but non-critical — a malformed or missing
965
+ // suggestion is just dropped (returns null), never an error, since chips
966
+ // are an additive hint on top of the real response.
967
+ // { mode: "generate"|"document"|"modify"|"chat", prompt: "...", selectionHint?: "..." }
968
+ // ---------------------------------------------------------------------
969
+ function extractSuggestedAction(parsed) {
970
+ const sa = parsed && parsed.suggestedAction;
971
+ if (!sa || typeof sa !== "object") { return null; }
972
+ if (["generate", "document", "modify", "chat"].indexOf(sa.mode) === -1) { return null; }
973
+ if (typeof sa.prompt !== "string" || !sa.prompt.trim()) { return null; }
974
+
975
+ const result = { mode: sa.mode, prompt: sa.prompt.trim() };
976
+ if (typeof sa.selectionHint === "string" && sa.selectionHint.trim()) {
977
+ result.selectionHint = sa.selectionHint.trim();
978
+ }
979
+ return result;
980
+ }
981
+
982
+ // ---------------------------------------------------------------------
983
+ // Pull an optional "questionOptions" (quick-reply buttons for a clarifying
984
+ // question) out of a parsed envelope: 2-4 short non-empty strings. The
985
+ // frontend renders these as one-click buttons plus a free-text "Other";
986
+ // anything malformed or out of range is dropped (returns null), never an
987
+ // error — same additive-hint treatment as extractSuggestedAction.
988
+ // ---------------------------------------------------------------------
989
+ function extractQuestionOptions(parsed) {
990
+ const opts = parsed && parsed.questionOptions;
991
+ if (!Array.isArray(opts)) { return null; }
992
+ const cleaned = opts
993
+ .map(function (o) { return typeof o === "string" ? o.trim() : ""; })
994
+ .filter(Boolean);
995
+ if (cleaned.length < 2 || cleaned.length > 4) { return null; }
996
+ return cleaned;
997
+ }
998
+
999
+ // ---------------------------------------------------------------------
1000
+ // Chat (free-text) responses can end with an optional, hidden data block —
1001
+ // a marker line followed by a single JSON object carrying a
1002
+ // "suggestedAction" and/or "questionOptions" (see default-system-prompt.js).
1003
+ // Unlike the generate/modify/document envelopes, the visible chat reply is
1004
+ // plain prose, so this block is split off rather than being the whole
1005
+ // response. Used by the non-streaming /chat path; streaming uses
1006
+ // createChatDataStreamSplitter below so the marker/JSON are never flashed
1007
+ // to the user mid-stream.
1008
+ // ---------------------------------------------------------------------
1009
+ const CHAT_DATA_MARKER = "<<<FLOWPILOT_DATA>>>";
1010
+
1011
+ function splitChatDataBlock(content) {
1012
+ const text = String(content || "");
1013
+ const idx = text.indexOf(CHAT_DATA_MARKER);
1014
+ if (idx === -1) { return { message: text, data: null }; }
1015
+
1016
+ const message = text.slice(0, idx).replace(/\s+$/, "");
1017
+ const jsonStr = text.slice(idx + CHAT_DATA_MARKER.length).trim();
1018
+ let data = null;
1019
+ try { data = JSON.parse(jsonStr); } catch (e) { data = null; }
1020
+ return { message: message, data: data };
1021
+ }
1022
+
1023
+ // Streaming counterpart of splitChatDataBlock: buffers just enough of the
1024
+ // tail to detect CHAT_DATA_MARKER even if it's split across provider
1025
+ // chunks, without delaying normal text. push(delta) returns the portion of
1026
+ // `delta` (plus any previously-held tail) that's safe to display now —
1027
+ // possibly "". Once the marker is seen, all further input is buffered as
1028
+ // the JSON data block instead of being displayed. finish() returns any
1029
+ // held-back text that turned out NOT to be part of the marker (a false
1030
+ // positive at end of stream) plus the parsed data block, if any.
1031
+ // ---------------------------------------------------------------------
1032
+ function createChatDataStreamSplitter() {
1033
+ let held = "";
1034
+ let inData = false;
1035
+ let dataBuf = "";
1036
+
1037
+ function push(delta) {
1038
+ if (inData) { dataBuf += delta; return ""; }
1039
+
1040
+ const combined = held + delta;
1041
+ const idx = combined.indexOf(CHAT_DATA_MARKER);
1042
+ if (idx !== -1) {
1043
+ inData = true;
1044
+ dataBuf = combined.slice(idx + CHAT_DATA_MARKER.length);
1045
+ held = "";
1046
+ return combined.slice(0, idx);
1047
+ }
1048
+
1049
+ // No full marker yet — check whether the tail of `combined` is a
1050
+ // prefix of the marker (i.e. the marker may be split across chunks)
1051
+ // and hold that part back.
1052
+ const maxOverlap = Math.min(combined.length, CHAT_DATA_MARKER.length - 1);
1053
+ let overlap = 0;
1054
+ for (let len = maxOverlap; len >= 1; len--) {
1055
+ if (combined.slice(-len) === CHAT_DATA_MARKER.slice(0, len)) { overlap = len; break; }
1056
+ }
1057
+ held = overlap ? combined.slice(-overlap) : "";
1058
+ return overlap ? combined.slice(0, -overlap) : combined;
1059
+ }
1060
+
1061
+ function finish() {
1062
+ const tail = held;
1063
+ held = "";
1064
+ let data = null;
1065
+ if (inData) {
1066
+ try { data = JSON.parse(dataBuf.trim()); } catch (e) { data = null; }
1067
+ }
1068
+ return { tail: tail, data: data };
1069
+ }
1070
+
1071
+ return { push: push, finish: finish };
1072
+ }
1073
+
1074
+ // ---------------------------------------------------------------------
1075
+ // Shared helper: parse, validate and audit a completed provider response
1076
+ // for a generation-style request, returning { question } / { prose } /
1077
+ // { explanation, flow, newNodes, newWires }, each optionally carrying a
1078
+ // `suggestedAction` (action chip). Used by both the
1079
+ // non-streaming and streaming paths, which differ only in how
1080
+ // `content` and `providerResult` were obtained (provider.chat vs
1081
+ // provider.chatStream). Throws an Error with .status and (when applicable)
1082
+ // .raw for the route to relay.
1083
+ // ---------------------------------------------------------------------
1084
+ function processGenerationContent(content, providerResult, messages, auditAction, described, activeProvider) {
1085
+ const perf = performanceAuditFields(messages, content, providerResult);
1086
+
1087
+ // Mode-mismatch redirect: the model may respond in plain prose —
1088
+ // addressing a request that doesn't belong in generate/document/modify —
1089
+ // followed by a hidden <<<FLOWPILOT_DATA>>> block suggesting a mode
1090
+ // switch, exactly like Chat. Detect this BEFORE extractJsonObject, since
1091
+ // it would otherwise grab the "{" inside the data block and treat it as
1092
+ // a broken envelope.
1093
+ let envelopeParsed;
1094
+ if (content.indexOf(CHAT_DATA_MARKER) !== -1) {
1095
+ const preSplit = splitChatDataBlock(content);
1096
+ const proseMessage = preSplit.message.trim();
1097
+ if (proseMessage && proseMessage[0] !== "{") {
1098
+ storage.appendAudit(Object.assign({ action: auditAction + "_prose" }, perf));
1099
+ const proseResult = { prose: proseMessage };
1100
+ if (preSplit.data) {
1101
+ const proseAction = extractSuggestedAction(preSplit.data);
1102
+ if (proseAction) { proseResult.suggestedAction = proseAction; }
1103
+ const proseOptions = extractQuestionOptions(preSplit.data);
1104
+ if (proseOptions) { proseResult.questionOptions = proseOptions; }
1105
+ }
1106
+ return proseResult;
1107
+ }
1108
+ // The message part is itself the JSON envelope (a full flow/changes/
1109
+ // question result), with the data block appending an additive
1110
+ // suggestedAction/questionOptions hint. Parse just the envelope (not
1111
+ // the marker/data suffix, which extractJsonObject can't handle) and
1112
+ // merge the hint in, then fall through to the normal envelope
1113
+ // handling below so modify/question/flow shapes are still validated
1114
+ // and audited correctly.
1115
+ try {
1116
+ envelopeParsed = JSON.parse(proseMessage);
1117
+ } catch (e) {
1118
+ // Not a standalone envelope after all — fall through to
1119
+ // extractJsonObject(content) below.
1120
+ }
1121
+ if (envelopeParsed && preSplit.data) {
1122
+ if (envelopeParsed.suggestedAction === undefined) { envelopeParsed.suggestedAction = preSplit.data.suggestedAction; }
1123
+ if (envelopeParsed.questionOptions === undefined) { envelopeParsed.questionOptions = preSplit.data.questionOptions; }
1124
+ }
1125
+ }
1126
+
1127
+ let parsed = envelopeParsed;
1128
+ if (!parsed) {
1129
+ try {
1130
+ parsed = extractJsonObject(content);
1131
+ } catch (parseErr) {
1132
+ // A response with no JSON envelope at all, but non-empty prose
1133
+ // (analysis, an answer, a question without the envelope) is tolerated —
1134
+ // render it as a normal assistant message and keep the action armed.
1135
+ // Errors stay reserved for empty responses or a found-but-broken {...}.
1136
+ if (parseErr.noJsonFound && content.trim()) {
1137
+ storage.appendAudit(Object.assign({ action: auditAction + "_prose" }, perf));
1138
+ // Mode-mismatch redirect: a prose reply may carry the same hidden
1139
+ // <<<FLOWPILOT_DATA>>> block as Chat, suggesting a mode switch (e.g.
1140
+ // "chat" when the request was actually a question, not a
1141
+ // generate/modify/document instruction).
1142
+ const split = splitChatDataBlock(content.trim());
1143
+ const proseResult = { prose: split.message || content.trim() };
1144
+ if (split.data) {
1145
+ const proseAction = extractSuggestedAction(split.data);
1146
+ if (proseAction) { proseResult.suggestedAction = proseAction; }
1147
+ const proseOptions = extractQuestionOptions(split.data);
1148
+ if (proseOptions) { proseResult.questionOptions = proseOptions; }
1149
+ }
1150
+ return proseResult;
1151
+ }
1152
+ storage.appendAudit(Object.assign({ action: auditAction + "_parse_error", error: parseErr.message }, perf));
1153
+ const err = new Error("Could not parse a flow from the response: " + parseErr.message);
1154
+ err.status = 422;
1155
+ err.raw = content;
1156
+ throw err;
1157
+ }
1158
+ }
1159
+
1160
+ // Clarifying-question envelope. The model may ask ONE
1161
+ // follow-up question instead of producing a flow when the request is too
1162
+ // ambiguous to act on. The frontend renders the question as a normal
1163
+ // assistant message and keeps the Execute action armed for the answer.
1164
+ if (typeof parsed.question === "string" && parsed.question.trim() &&
1165
+ (!Array.isArray(parsed.flow) || parsed.flow.length === 0)) {
1166
+ storage.appendAudit(Object.assign({ action: auditAction + "_question" }, perf));
1167
+ const questionResult = { question: parsed.question, explanation: parsed.explanation || "" };
1168
+ const questionAction = extractSuggestedAction(parsed);
1169
+ if (questionAction) { questionResult.suggestedAction = questionAction; }
1170
+ const questionOptions = extractQuestionOptions(parsed);
1171
+ if (questionOptions) { questionResult.questionOptions = questionOptions; }
1172
+ return questionResult;
1173
+ }
1174
+
1175
+ // /modify returns a sparse "changes" envelope (patches against the
1176
+ // selection) instead of a full "flow" array. "changes", "newNodes",
1177
+ // "newWires" and "removeNodes" are all individually optional — a no-op
1178
+ // modify can legitimately omit all of them — but the envelope must
1179
+ // contain at least one of those keys or a non-empty "explanation",
1180
+ // otherwise it's not recognizable as a modify response at all.
1181
+ if (auditAction === "modify") {
1182
+ const hasModifyShape = ("changes" in parsed) || ("newNodes" in parsed) ||
1183
+ ("newWires" in parsed) || ("removeNodes" in parsed) ||
1184
+ (typeof parsed.explanation === "string" && parsed.explanation.trim());
1185
+ if (!hasModifyShape) {
1186
+ const err = new Error("The response did not contain any recognizable modify fields.");
1187
+ err.status = 422;
1188
+ err.raw = content;
1189
+ throw err;
1190
+ }
1191
+
1192
+ const changes = Array.isArray(parsed.changes) ? parsed.changes : [];
1193
+ const newNodes = Array.isArray(parsed.newNodes) ? parsed.newNodes : [];
1194
+ const newWires = Array.isArray(parsed.newWires) ? parsed.newWires : [];
1195
+ const removeNodes = Array.isArray(parsed.removeNodes) ? parsed.removeNodes : [];
1196
+
1197
+ storage.appendAudit(Object.assign({
1198
+ action: auditAction,
1199
+ providerName: activeProvider.providerName,
1200
+ baseUrl: activeProvider.baseUrl,
1201
+ model: activeProvider.model,
1202
+ changeCount: changes.length,
1203
+ newNodeCount: newNodes.length,
1204
+ newWireCount: newWires.length,
1205
+ removeNodeCount: removeNodes.length,
1206
+ contextNodeCount: described ? described.nodeCount : 0,
1207
+ contextConnectionCount: described ? described.connectionCount : 0
1208
+ }, perf));
1209
+
1210
+ const modifyResult = {
1211
+ explanation: parsed.explanation || "",
1212
+ changes: changes,
1213
+ newNodes: newNodes,
1214
+ newWires: newWires,
1215
+ removeNodes: removeNodes
1216
+ };
1217
+ const modifyAction = extractSuggestedAction(parsed);
1218
+ if (modifyAction) { modifyResult.suggestedAction = modifyAction; }
1219
+ return modifyResult;
1220
+ }
1221
+
1222
+ const flow = Array.isArray(parsed.flow) ? parsed.flow : null;
1223
+ if (!flow) {
1224
+ const err = new Error("The response did not contain a 'flow' array.");
1225
+ err.status = 422;
1226
+ err.raw = content;
1227
+ throw err;
1228
+ }
1229
+
1230
+ storage.appendAudit(Object.assign({
1231
+ action: auditAction,
1232
+ providerName: activeProvider.providerName,
1233
+ baseUrl: activeProvider.baseUrl,
1234
+ model: activeProvider.model,
1235
+ nodeCount: flow.length,
1236
+ contextNodeCount: described ? described.nodeCount : 0,
1237
+ contextConnectionCount: described ? described.connectionCount : 0
1238
+ }, perf));
1239
+
1240
+ const flowResult = {
1241
+ explanation: parsed.explanation || "",
1242
+ flow: flow,
1243
+ newNodes: Array.isArray(parsed.newNodes) ? parsed.newNodes : [],
1244
+ newWires: Array.isArray(parsed.newWires) ? parsed.newWires : []
1245
+ };
1246
+ const flowAction = extractSuggestedAction(parsed);
1247
+ if (flowAction) { flowResult.suggestedAction = flowAction; }
1248
+ return flowResult;
1249
+ }
1250
+
1251
+ // ---------------------------------------------------------------------
1252
+ // Shared helper: ask the model for a { explanation, flow } envelope using
1253
+ // the given system prompt (+ optional selection context), parse and
1254
+ // validate it, audit the result, and return { explanation, flow }. Used by
1255
+ // both /generate and /document — they differ only in system prompt, audit
1256
+ // action name, and how the route validates its inputs beforehand. Throws
1257
+ // an Error with .status and (when applicable) .raw for the route to relay.
1258
+ // ---------------------------------------------------------------------
1259
+ // Step 4: useTools offers AGENT_READ_TOOLS (explore-then-propose). If the
1260
+ // provider responds with tool_calls instead of a final envelope, returns
1261
+ // early with { toolCalls, messages, content, usage } — same shape as
1262
+ // runChat's early return — so the route can hand it to the frontend
1263
+ // without running processGenerationContent yet.
1264
+ async function runFlowGeneration(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, useTools) {
1265
+ const { activeProvider, described, messages } = buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated);
1266
+ const chatOptions = useTools ? { tools: AGENT_READ_TOOLS, toolChoice: "auto" } : undefined;
1267
+ const result = await provider.chat(activeProvider, messages, chatOptions);
1268
+ if (result.toolCalls) {
1269
+ return { toolCalls: result.toolCalls, messages: messages, content: result.content || null, usage: result.usage || null };
1270
+ }
1271
+ return processGenerationContent(result.content || "", result, messages, auditAction, described, activeProvider);
1272
+ }
1273
+
1274
+ // ---------------------------------------------------------------------
1275
+ // Streaming variant of runFlowGeneration. Relays each provider delta
1276
+ // via onDelta as it arrives, then runs the SAME parse/validate/audit logic
1277
+ // as the non-streaming path once the full response is in. The frontend
1278
+ // uses onDelta to progressively render the envelope's "explanation" field
1279
+ // while the rest of the JSON (the "flow" array etc.) is buffered until
1280
+ // this resolves.
1281
+ // ---------------------------------------------------------------------
1282
+ async function runFlowGenerationStream(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, onDelta) {
1283
+ const { activeProvider, described, messages } = buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated);
1284
+ const result = await provider.chatStream(activeProvider, messages, onDelta);
1285
+ return processGenerationContent(result.content || "", result, messages, auditAction, described, activeProvider);
1286
+ }
1287
+
1288
+ // Relays a runFlowGeneration error to the client with the right status,
1289
+ // falling back to 500 for anything that didn't set .status itself.
1290
+ function sendGenerationError(res, auditAction, err) {
1291
+ if (err && err.status) {
1292
+ const body = { error: err.message };
1293
+ if (err.raw) { body.raw = err.raw; }
1294
+ return res.status(err.status).json(body);
1295
+ }
1296
+ storage.appendAudit({ action: auditAction + "_error", error: err.message });
1297
+ res.status(500).json({ error: err.message });
1298
+ }
1299
+
1300
+ // ---------------------------------------------------------------------
1301
+ // Turn a runFlowGeneration(Stream) result into a { status, body }
1302
+ // response for /generate and /document — they share identical
1303
+ // post-processing (question/prose passthrough, else the envelope as-is).
1304
+ // Used by both the non-streaming route (res.status(status).json(body)) and
1305
+ // the streaming route (relayed as the final SSE event).
1306
+ // ---------------------------------------------------------------------
1307
+ function finalizeSimpleGeneration(result) {
1308
+ if (result.question) {
1309
+ const body = { explanation: result.explanation, question: result.question, flow: null };
1310
+ if (result.suggestedAction) { body.suggestedAction = result.suggestedAction; }
1311
+ if (result.questionOptions) { body.questionOptions = result.questionOptions; }
1312
+ return { status: 200, body: body };
1313
+ }
1314
+ if (result.prose) {
1315
+ const proseBody = { explanation: result.prose, prose: true, flow: null };
1316
+ if (result.suggestedAction) { proseBody.suggestedAction = result.suggestedAction; }
1317
+ if (result.questionOptions) { proseBody.questionOptions = result.questionOptions; }
1318
+ return { status: 200, body: proseBody };
1319
+ }
1320
+ return { status: 200, body: result };
1321
+ }
1322
+
1323
+ // ---------------------------------------------------------------------
1324
+ // Turn a runFlowGeneration(Stream) result into a { status, body }
1325
+ // response for /modify — question/prose passthrough, else reconstruct the
1326
+ // full "flow" by applying the model's sparse "changes" patches on top of
1327
+ // the original selection (the model returns only
1328
+ // { id, set: {...changed props} } for nodes it actually touches, instead
1329
+ // of repeating every node's full JSON). Also runs the
1330
+ // removeNodes/newNodes/newWires validation that previously lived inline in
1331
+ // the /flowpilot/modify route handler. Used by both the non-streaming and
1332
+ // streaming routes.
1333
+ // ---------------------------------------------------------------------
1334
+ function finalizeModifyResult(result, originalNodes) {
1335
+ if (result.question) {
1336
+ const questionBody = { explanation: result.explanation, question: result.question, flow: null };
1337
+ if (result.suggestedAction) { questionBody.suggestedAction = result.suggestedAction; }
1338
+ if (result.questionOptions) { questionBody.questionOptions = result.questionOptions; }
1339
+ return { status: 200, body: questionBody };
1340
+ }
1341
+ if (result.prose) {
1342
+ const proseBody = { explanation: result.prose, prose: true, flow: null };
1343
+ if (result.suggestedAction) { proseBody.suggestedAction = result.suggestedAction; }
1344
+ if (result.questionOptions) { proseBody.questionOptions = result.questionOptions; }
1345
+ return { status: 200, body: proseBody };
1346
+ }
1347
+
1348
+ const originalIds = new Set(originalNodes.map(function (n) { return n.id; }));
1349
+
1350
+ // Validate removeNodes: all ids must be in the original selection.
1351
+ const removeNodes = Array.isArray(result.removeNodes) ? result.removeNodes : [];
1352
+ if (removeNodes.length > 0) {
1353
+ const badRemove = removeNodes.filter(function (id) { return !originalIds.has(String(id)); });
1354
+ if (badRemove.length > 0) {
1355
+ storage.appendAudit({ action: "modify_remove_ref_error", ids: badRemove });
1356
+ return {
1357
+ status: 422,
1358
+ body: {
1359
+ error: "removeNodes contains id(s) not in the selection: " + badRemove.join(", "),
1360
+ raw: JSON.stringify(result)
1361
+ }
1362
+ };
1363
+ }
1364
+ }
1365
+ const removeSet = new Set(removeNodes.map(String));
1366
+
1367
+ // "changes" is a sparse array of { id, set } patches against the
1368
+ // original selection. A node with no entry here is kept exactly as-is —
1369
+ // unlike the old full-"flow" format, omission can only mean "unchanged",
1370
+ // never "delete", so there's no "implicit removal" failure mode anymore.
1371
+ const changes = Array.isArray(result.changes) ? result.changes : [];
1372
+ const changeIds = changes
1373
+ .map(function (c) { return c && c.id; })
1374
+ .filter(function (id) { return id !== undefined && id !== null; });
1375
+
1376
+ // Validate that changes contains no hallucinated ids, and that no id is
1377
+ // both patched and marked for removal.
1378
+ const extraIds = changeIds.filter(function (id) { return !originalIds.has(String(id)); });
1379
+ const wronglyRemovedIds = changeIds.filter(function (id) { return removeSet.has(String(id)); });
1380
+
1381
+ const idProblems = [];
1382
+ if (extraIds.length) { idProblems.push("unexpected id(s) in changes: " + extraIds.join(", ")); }
1383
+ if (wronglyRemovedIds.length) { idProblems.push("id(s) in both changes and removeNodes: " + wronglyRemovedIds.join(", ")); }
1384
+
1385
+ if (idProblems.length > 0) {
1386
+ storage.appendAudit({ action: "modify_id_mismatch", problems: idProblems });
1387
+ return {
1388
+ status: 422,
1389
+ body: {
1390
+ error: "The model returned inconsistent node ids (" + idProblems.join("; ") + "). Try again.",
1391
+ raw: JSON.stringify(changes)
1392
+ }
1393
+ };
1394
+ }
1395
+
1396
+ // Each patch's "set" is shallow-merged onto a copy of the original node.
1397
+ // "id", "x", "y", "z" can never move via a patch — strip them
1398
+ // defensively even though the prompt already forbids them.
1399
+ const patchById = {};
1400
+ changes.forEach(function (c) {
1401
+ const set = (c.set && typeof c.set === "object") ? c.set : {};
1402
+ const clean = Object.assign({}, set);
1403
+ delete clean.id;
1404
+ delete clean.x;
1405
+ delete clean.y;
1406
+ delete clean.z;
1407
+ patchById[String(c.id)] = clean;
1408
+ });
1409
+
1410
+ const flow = originalNodes
1411
+ .filter(function (n) { return !removeSet.has(String(n.id)); })
1412
+ .map(function (n) {
1413
+ const patch = patchById[String(n.id)];
1414
+ return patch ? Object.assign({}, n, patch) : n;
1415
+ });
1416
+
1417
+ const finalRemoveNodes = Array.from(removeSet);
1418
+
1419
+ // "group" nodes aren't supported yet (the editor's group API needs
1420
+ // bounding-box computation + group-aware undo that applyInsertions
1421
+ // doesn't implement). The system prompt tells the model not to propose
1422
+ // them, but strip any that slip through anyway, and drop any newWires
1423
+ // that reference a stripped group's placeholder id.
1424
+ const allNewNodes = result.newNodes || [];
1425
+ const groupNodes = allNewNodes.filter(function (n) { return n && n.type === "group"; });
1426
+ const newNodes = allNewNodes.filter(function (n) { return !(n && n.type === "group"); });
1427
+ const newNodeIdSet = new Set(newNodes.map(function (n) { return n && n.id; }).filter(Boolean));
1428
+
1429
+ // Validate newWires references: each from/to must be either an existing
1430
+ // context node id or a placeholder id present in newNodes.
1431
+ let newWires = result.newWires || [];
1432
+ if (groupNodes.length > 0) {
1433
+ const groupIdSet = new Set(groupNodes.map(function (n) { return n && n.id; }).filter(Boolean));
1434
+ newWires = newWires.filter(function (wire) {
1435
+ return !groupIdSet.has(String(wire.from)) && !groupIdSet.has(String(wire.to));
1436
+ });
1437
+ }
1438
+ if (newWires.length > 0) {
1439
+ const wireProblems = [];
1440
+ newWires.forEach(function (wire, i) {
1441
+ [wire.from, wire.to].forEach(function (ref) {
1442
+ if (!ref) { wireProblems.push("wire " + i + " missing ref"); return; }
1443
+ if (!originalIds.has(String(ref)) && !newNodeIdSet.has(String(ref))) {
1444
+ wireProblems.push("wire " + i + " ref '" + ref + "' not in existing or new nodes");
1445
+ }
1446
+ });
1447
+ });
1448
+ if (wireProblems.length > 0) {
1449
+ storage.appendAudit({ action: "modify_wire_ref_error", problems: wireProblems });
1450
+ return {
1451
+ status: 422,
1452
+ body: {
1453
+ error: "Invalid wire references in newWires: " + wireProblems.join("; "),
1454
+ raw: JSON.stringify(result)
1455
+ }
1456
+ };
1457
+ }
1458
+ }
1459
+
1460
+ let explanation = result.explanation;
1461
+ if (groupNodes.length > 0) {
1462
+ storage.appendAudit({ action: "modify_group_stripped", count: groupNodes.length });
1463
+ explanation = (explanation ? explanation + "\n\n" : "") +
1464
+ "Note: grouping nodes into a visual group isn't supported yet, so that part of the request was skipped.";
1465
+ }
1466
+
1467
+ const body = {
1468
+ explanation: explanation,
1469
+ flow: flow,
1470
+ newNodes: newNodes,
1471
+ newWires: newWires,
1472
+ removeNodes: finalRemoveNodes
1473
+ };
1474
+ if (result.suggestedAction) { body.suggestedAction = result.suggestedAction; }
1475
+
1476
+ return { status: 200, body: body };
1477
+ }
1478
+
1479
+ // ---------------------------------------------------------------------
1480
+ // Streaming variant of /generate, /document and /modify. Opens an SSE
1481
+ // response, relays each provider delta as `data: {"delta":...}` (the
1482
+ // frontend uses these to progressively render the envelope's
1483
+ // "explanation" field), then runs `finalize` (finalizeSimpleGeneration or
1484
+ // finalizeModifyResult) on the completed result and sends it as a single
1485
+ // `data: {"final": <body>, "status": <status>}` event — the same
1486
+ // {status, body} shape the non-streaming routes pass to
1487
+ // res.status(status).json(body). A provider/parse error (which may carry
1488
+ // .status/.raw, e.g. a 422 parse failure) is relayed the same way, as
1489
+ // `data: {"error": <body>, "status": <status>}`, since SSE responses can't
1490
+ // change their HTTP status after headers are sent.
1491
+ // ---------------------------------------------------------------------
1492
+ async function runExecuteStream(req, res, systemPrompt, auditAction, userPrompt, context, history, historyTruncated, finalize, conversationId) {
1493
+ res.writeHead(200, {
1494
+ "Content-Type": "text/event-stream; charset=utf-8",
1495
+ "Cache-Control": "no-cache, no-transform",
1496
+ "Connection": "keep-alive",
1497
+ "X-Accel-Buffering": "no"
1498
+ });
1499
+ if (typeof res.flushHeaders === "function") { res.flushHeaders(); }
1500
+
1501
+ let result;
1502
+ try {
1503
+ result = await runFlowGenerationStream(systemPrompt, auditAction, userPrompt, context, history, historyTruncated, function (delta) {
1504
+ res.write("data: " + JSON.stringify({ delta: delta }) + "\n\n");
1505
+ });
1506
+ } catch (err) {
1507
+ const status = err && err.status ? err.status : 500;
1508
+ const body = { error: err.message };
1509
+ if (err && err.raw) { body.raw = err.raw; }
1510
+ if (!err || !err.status) { storage.appendAudit({ action: auditAction + "_error", error: err.message }); }
1511
+ res.write("data: " + JSON.stringify({ error: body, status: status }) + "\n\n");
1512
+ res.write("data: [DONE]\n\n");
1513
+ res.end();
1514
+ return;
1515
+ }
1516
+
1517
+ recordTranscriptTurn(conversationId, auditAction, userPrompt, transcriptTextFromGenerationResult(result));
1518
+
1519
+ const final = finalize(result);
1520
+ res.write("data: " + JSON.stringify({ final: final.body, status: final.status }) + "\n\n");
1521
+ res.write("data: [DONE]\n\n");
1522
+ res.end();
1523
+ }
1524
+
1525
+ RED.httpAdmin.post("/flowpilot/generate", RED.auth.needsPermission("settings.write"), async function (req, res) {
1526
+ const prompt = req.body && req.body.prompt;
1527
+
1528
+ if (!prompt || !String(prompt).trim()) {
1529
+ return res.status(400).json({ error: "A description of what to generate is required." });
1530
+ }
1531
+
1532
+ const history = sanitizeHistory(req.body.history);
1533
+ const historyTruncated = !!req.body.historyTruncated;
1534
+
1535
+ if (req.body.stream) {
1536
+ return runExecuteStream(
1537
+ req, res, generationSystemPrompt, "generate", prompt, req.body && req.body.context,
1538
+ history, historyTruncated, finalizeSimpleGeneration, req.body.conversationId
1539
+ );
1540
+ }
1541
+
1542
+ try {
1543
+ const useTools = !!req.body.tools;
1544
+ const generated = await runFlowGeneration(
1545
+ generationSystemPrompt, "generate", prompt, req.body && req.body.context,
1546
+ history, historyTruncated, useTools
1547
+ );
1548
+ if (generated.toolCalls) {
1549
+ return res.json({ toolCalls: generated.toolCalls, messages: generated.messages, content: generated.content, usage: generated.usage });
1550
+ }
1551
+ recordTranscriptTurn(req.body.conversationId, "generate", prompt, transcriptTextFromGenerationResult(generated));
1552
+ const { status, body } = finalizeSimpleGeneration(generated);
1553
+ res.status(status).json(body);
1554
+ } catch (err) {
1555
+ sendGenerationError(res, "generate", err);
1556
+ }
1557
+ });
1558
+
1559
+ RED.httpAdmin.post("/flowpilot/document", RED.auth.needsPermission("settings.write"), async function (req, res) {
1560
+ const context = req.body && req.body.context;
1561
+ const described = describeSelectionContext(context);
1562
+
1563
+ if (!described) {
1564
+ return res.status(400).json({ error: "Select the node(s) you want documented first." });
1565
+ }
1566
+
1567
+ // The prompt box holds OPTIONAL notes to steer the explanation — the
1568
+ // selection itself is the real input, so an empty prompt is fine.
1569
+ const notes = (req.body && req.body.prompt) ? String(req.body.prompt).trim() : "";
1570
+ const userPrompt = notes || "Document the selected flow.";
1571
+
1572
+ const history = sanitizeHistory(req.body.history);
1573
+ const historyTruncated = !!req.body.historyTruncated;
1574
+
1575
+ if (req.body.stream) {
1576
+ return runExecuteStream(
1577
+ req, res, documentSystemPrompt, "document", userPrompt, context,
1578
+ history, historyTruncated, finalizeSimpleGeneration, req.body.conversationId
1579
+ );
1580
+ }
1581
+
1582
+ try {
1583
+ const useTools = !!req.body.tools;
1584
+ const documented = await runFlowGeneration(
1585
+ documentSystemPrompt, "document", userPrompt, context,
1586
+ history, historyTruncated, useTools
1587
+ );
1588
+ if (documented.toolCalls) {
1589
+ return res.json({ toolCalls: documented.toolCalls, messages: documented.messages, content: documented.content, usage: documented.usage });
1590
+ }
1591
+ recordTranscriptTurn(req.body.conversationId, "document", userPrompt, transcriptTextFromGenerationResult(documented));
1592
+ const { status, body } = finalizeSimpleGeneration(documented);
1593
+ res.status(status).json(body);
1594
+ } catch (err) {
1595
+ sendGenerationError(res, "document", err);
1596
+ }
1597
+ });
1598
+
1599
+ RED.httpAdmin.post("/flowpilot/modify", RED.auth.needsPermission("settings.write"), async function (req, res) {
1600
+ const context = req.body && req.body.context;
1601
+ const described = describeSelectionContext(context);
1602
+
1603
+ if (!described) {
1604
+ return res.status(400).json({ error: "Select the node(s) you want to modify first." });
1605
+ }
1606
+
1607
+ const prompt = req.body && req.body.prompt;
1608
+ if (!prompt || !String(prompt).trim()) {
1609
+ return res.status(400).json({ error: "Describe what you want to change." });
1610
+ }
1611
+
1612
+ // The model's "changes" patches are applied on top of these original
1613
+ // nodes to reconstruct the full "flow" sent to the editor.
1614
+ const originalNodes = (context && Array.isArray(context.nodes)) ? context.nodes : [];
1615
+
1616
+ const history = sanitizeHistory(req.body.history);
1617
+ const historyTruncated = !!req.body.historyTruncated;
1618
+
1619
+ const finalize = function (result) { return finalizeModifyResult(result, originalNodes); };
1620
+
1621
+ if (req.body.stream) {
1622
+ return runExecuteStream(
1623
+ req, res, modifySystemPrompt, "modify", String(prompt).trim(), context,
1624
+ history, historyTruncated, finalize, req.body.conversationId
1625
+ );
1626
+ }
1627
+
1628
+ try {
1629
+ const useTools = !!req.body.tools;
1630
+ const result = await runFlowGeneration(
1631
+ modifySystemPrompt, "modify", String(prompt).trim(), context,
1632
+ history, historyTruncated, useTools
1633
+ );
1634
+ if (result.toolCalls) {
1635
+ return res.json({ toolCalls: result.toolCalls, messages: result.messages, content: result.content, usage: result.usage });
1636
+ }
1637
+ recordTranscriptTurn(req.body.conversationId, "modify", String(prompt).trim(), transcriptTextFromGenerationResult(result));
1638
+ const { status, body } = finalize(result);
1639
+ res.status(status).json(body);
1640
+ } catch (err) {
1641
+ sendGenerationError(res, "modify", err);
1642
+ }
1643
+ });
1644
+ };