@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/LICENSE +21 -0
- package/PROJECT-OVERVIEW.md +98 -0
- package/README.md +144 -0
- package/USER-GUIDE.md +319 -0
- package/examples/dad-joke-demo.json +84 -0
- package/examples/getting-started.json +62 -0
- package/flowpilot.html +5673 -0
- package/flowpilot.js +1644 -0
- package/icons/flowpilot.svg +5 -0
- package/lib/default-system-prompt.js +76 -0
- package/lib/document-system-prompt.js +78 -0
- package/lib/generation-system-prompt.js +119 -0
- package/lib/modify-system-prompt.js +274 -0
- package/lib/provider-openai-compatible.js +377 -0
- package/lib/storage.js +265 -0
- package/package.json +49 -0
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
|
+
};
|