@manny-est/node-red-flowpilot 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/README.md +4 -0
- package/flowpilot.html +253 -36
- package/flowpilot.js +24 -8
- package/lib/generation-system-prompt.js +12 -1
- package/lib/provider-openai-compatible.js +13 -10
- package/lib/storage.js +22 -4
- package/package.json +2 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to FlowPilot are documented here.
|
|
4
|
+
|
|
5
|
+
## [0.3.0] - 2026-06-23
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- `/feedback` slash command — links to the repo and issue tracker, with a note on what makes a good bug report.
|
|
9
|
+
- Temperature setting now has an explanatory hint (closes #3).
|
|
10
|
+
- Request timeout is now configurable in Settings → Behavior, instead of a hardcoded 180000ms (closes #2).
|
|
11
|
+
- "Preview JSON" / "Preview debug" links — show the exact node JSON or debug payload a request will send, for diagnosing "did the AI actually get this" confusion.
|
|
12
|
+
- Inline feedback on the Save settings button ("Settings saved." / "No changes to save." / validation errors).
|
|
13
|
+
- Declared Node-RED 5.x as tested/supported (previously only the `>=4.0.0` floor was documented).
|
|
14
|
+
- Redaction opt-out toggle in Settings → Context & Safety (redaction is on by default; disabling it requires checking a box and typing "disable redaction" to confirm). Intended for local/private AI setups. Node-RED's own credentials field is still always dropped either way. The secrets-warning badge in the status strip turns red with a stronger tooltip while redaction is off.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- The "Provider settings... stored locally..." banner now only shows inside the Providers section, not across the whole Settings panel.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- Debug messages attached as context were truncated to 500 characters at capture time — before being attached — which could cut a value mid-JSON and make it look like the AI ignored the attached data. Raised to 20,000 characters for what's actually sent (the debug-log list preview stays short).
|
|
21
|
+
- The streaming "Cruising…" indicator never visibly appeared during streaming Chat/Generate/Document/Modify — it was converted into an empty bubble in the same tick it was created, before the request even went out (closes #4).
|
|
22
|
+
- Generate would sometimes produce nodes with no `"wires"` connecting them, landing disconnected on the canvas. Added a worked example to the generation prompt (smaller/local models follow concrete examples more reliably than prose rules) and a visible warning in the review panel when a generated flow has zero connections.
|
|
23
|
+
- When redaction was off, FlowPilot's chat replies implied it could "re-enable redaction" itself if asked — it has no such ability (no tool/function gives it write access to settings). Clarified the context note so it correctly tells the user to use the Settings panel instead of offering to do it for them.
|
|
24
|
+
|
|
25
|
+
## [0.2.2] - 2026-06-15
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- Raw `fetch()` calls (chat/generate/document/modify streaming) were missing the `Authorization` header that jQuery's `$.ajax` gets automatically from the Node-RED editor, causing 401s on instances with `adminAuth` enabled.
|
|
29
|
+
- Dark themes: the prompt box and settings inputs used a non-existent CSS variable (`--red-ui-form-input-text-color`) and always fell back to a hardcoded dark color, making text unreadable against a dark background.
|
|
30
|
+
|
|
31
|
+
## [0.2.1] - 2026-06-15
|
|
32
|
+
|
|
33
|
+
Initial public release — published to npm and listed on the Node-RED Flow Library.
|
package/README.md
CHANGED
|
@@ -74,6 +74,10 @@ See the [User Guide](USER-GUIDE.md#privacy-and-safety) for the full details.
|
|
|
74
74
|
safely, FlowPilot asks ONE question instead of guessing.
|
|
75
75
|
- **Streaming replies** — optional SSE streaming for chat responses.
|
|
76
76
|
|
|
77
|
+
## Requirements
|
|
78
|
+
|
|
79
|
+
Node-RED 4.x and 5.x, tested. Node.js 16+.
|
|
80
|
+
|
|
77
81
|
## Install in a local Node-RED user directory
|
|
78
82
|
|
|
79
83
|
From your Node-RED user directory:
|
package/flowpilot.html
CHANGED
|
@@ -21,6 +21,12 @@
|
|
|
21
21
|
// user's thresholds and suppression preference without refetching.
|
|
22
22
|
var currentSettings = {};
|
|
23
23
|
|
|
24
|
+
// JSON snapshot of collectSettings() as of the last load/save, used by
|
|
25
|
+
// the explicit Save button to tell "no changes" from "saved" without
|
|
26
|
+
// hitting the backend for a no-op write.
|
|
27
|
+
var savedSettingsSnapshot = null;
|
|
28
|
+
var saveStatusTimer = null;
|
|
29
|
+
|
|
24
30
|
// ---------------------------------------------------------------------
|
|
25
31
|
// Identifies this conversation for server-side transcript
|
|
26
32
|
// persistence (chats/<conversationId>.jsonl). Kept in sessionStorage so
|
|
@@ -112,7 +118,14 @@
|
|
|
112
118
|
// the name-based check. Structural data (numbers, booleans, short
|
|
113
119
|
// strings, object/array shape) passes through untouched, preserving
|
|
114
120
|
// diagnostic value.
|
|
121
|
+
//
|
|
122
|
+
// currentSettings.redactionEnabled defaults true (settings.json default,
|
|
123
|
+
// and an empty {} before settings ever load) — explicitly opting OUT via
|
|
124
|
+
// the type-to-confirm Settings toggle is required to disable this. The
|
|
125
|
+
// Node-RED credentials field is a SEPARATE, always-on mechanism (dropped
|
|
126
|
+
// entirely in sanitizeNode's INTERNAL_FIELDS) and is unaffected either way.
|
|
115
127
|
function redactDebugValue(value, key) {
|
|
128
|
+
if (currentSettings.redactionEnabled === false) { return value; }
|
|
116
129
|
if (typeof value === "string") {
|
|
117
130
|
var kind = matchSecretValue(value);
|
|
118
131
|
if (kind) { return "[redacted: " + kind + ", " + value.length + " chars]"; }
|
|
@@ -150,7 +163,14 @@
|
|
|
150
163
|
// request is sent.
|
|
151
164
|
// ---------------------------------------------------------------------
|
|
152
165
|
var DEBUG_BUFFER_MAX = 50;
|
|
153
|
-
|
|
166
|
+
// Two different caps for two different jobs. PREVIEW is just for the
|
|
167
|
+
// scannable debug-log list (many short entries). SEND is what actually
|
|
168
|
+
// gets attached/transmitted — much higher, because a value truncated
|
|
169
|
+
// mid-JSON at 500 chars (e.g. cut inside a string or property name) can
|
|
170
|
+
// arrive at the model as malformed JSON, which looks indistinguishable
|
|
171
|
+
// from "the model ignored the attached data".
|
|
172
|
+
var DEBUG_VALUE_PREVIEW_MAX_CHARS = 500;
|
|
173
|
+
var DEBUG_VALUE_SEND_MAX_CHARS = 20000;
|
|
154
174
|
var debugMessageBuffer = [];
|
|
155
175
|
var attachedDebugMessages = [];
|
|
156
176
|
var nextDebugMessageId = 1;
|
|
@@ -179,12 +199,18 @@
|
|
|
179
199
|
if (!msg) { return; }
|
|
180
200
|
var redactedValue = redactDebugValue(msg.msg, undefined);
|
|
181
201
|
var redactedTopic = redactDebugValue(msg.topic || "", undefined);
|
|
202
|
+
var stringified = stringifyDebugValue(redactedValue);
|
|
182
203
|
var entry = {
|
|
183
204
|
id: nextDebugMessageId++,
|
|
184
205
|
timestamp: Date.now(),
|
|
185
206
|
name: msg.name || msg.id || "(unnamed node)",
|
|
186
207
|
topic: redactedTopic,
|
|
187
|
-
|
|
208
|
+
// previewValue: short, for the scannable debug-log list only.
|
|
209
|
+
// value: the much-less-truncated version that actually gets
|
|
210
|
+
// attached/sent and shown by "Preview debug" — never the raw
|
|
211
|
+
// unredacted value either way.
|
|
212
|
+
previewValue: truncateForDebug(stringified, DEBUG_VALUE_PREVIEW_MAX_CHARS),
|
|
213
|
+
value: truncateForDebug(stringified, DEBUG_VALUE_SEND_MAX_CHARS)
|
|
188
214
|
};
|
|
189
215
|
debugMessageBuffer.push(entry);
|
|
190
216
|
if (debugMessageBuffer.length > DEBUG_BUFFER_MAX) {
|
|
@@ -192,6 +218,14 @@
|
|
|
192
218
|
}
|
|
193
219
|
}
|
|
194
220
|
|
|
221
|
+
// The exact shape sent to the backend (and shown by "Preview debug") —
|
|
222
|
+
// excludes previewValue, which exists only for the debug-log list.
|
|
223
|
+
function buildDebugMessagesForSend() {
|
|
224
|
+
return attachedDebugMessages.map(function (m) {
|
|
225
|
+
return { id: m.id, timestamp: m.timestamp, name: m.name, topic: m.topic, value: m.value };
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
195
229
|
// Merges any attached debug messages into a request's context object —
|
|
196
230
|
// called right after collectSelectionContext() in send/generate/
|
|
197
231
|
// documentFlow/modifyFlow. Leaves context untouched (including null) when
|
|
@@ -199,7 +233,7 @@
|
|
|
199
233
|
function attachDebugContext(context) {
|
|
200
234
|
if (!attachedDebugMessages.length) { return context; }
|
|
201
235
|
context = context || { nodes: [], connections: {} };
|
|
202
|
-
return Object.assign({}, context, { debugMessages:
|
|
236
|
+
return Object.assign({}, context, { debugMessages: buildDebugMessagesForSend() });
|
|
203
237
|
}
|
|
204
238
|
|
|
205
239
|
// Updates the "🐛 N debug message(s) attached" indicator in the context
|
|
@@ -214,6 +248,12 @@
|
|
|
214
248
|
$status.removeClass("fp-hidden").empty();
|
|
215
249
|
var n = attachedDebugMessages.length;
|
|
216
250
|
$status.append(document.createTextNode("🐛 " + n + " debug message" + (n === 1 ? "" : "s") + " attached "));
|
|
251
|
+
var $preview = $("<a>").attr("href", "#").text("preview").attr("title", "Show the exact debug payload that will be sent");
|
|
252
|
+
$preview.on("click", function (ev) {
|
|
253
|
+
ev.preventDefault();
|
|
254
|
+
showJsonPreview("Debug payload preview — exactly what will be sent", buildDebugMessagesForSend());
|
|
255
|
+
});
|
|
256
|
+
$status.append($preview).append(document.createTextNode(" "));
|
|
217
257
|
var $clear = $("<a>").attr("href", "#").text("✕").attr("title", "Remove all attached debug messages");
|
|
218
258
|
$clear.on("click", function (ev) {
|
|
219
259
|
ev.preventDefault();
|
|
@@ -223,6 +263,29 @@
|
|
|
223
263
|
$status.append($clear);
|
|
224
264
|
}
|
|
225
265
|
|
|
266
|
+
// Diagnostic tool: dumps `data` as a fenced JSON code block into the chat
|
|
267
|
+
// thread (UI-only — never added to conversationHistory). Lets the user
|
|
268
|
+
// see exactly what a request would carry, instead of guessing whether
|
|
269
|
+
// the model received it or silently ignored it.
|
|
270
|
+
function showJsonPreview(title, data) {
|
|
271
|
+
addMessage("assistant", "**" + title + "**\n\n```json\n" + JSON.stringify(data, null, 2) + "\n```");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Resolves the node-selection context that would actually be sent right
|
|
275
|
+
// now: the live canvas selection, or (while an Execute action is armed
|
|
276
|
+
// with nothing currently selected) the pinned selection from when it was
|
|
277
|
+
// armed. Used by "Preview JSON" so it can never show different context
|
|
278
|
+
// than what Send actually uses.
|
|
279
|
+
function resolveCurrentSelectionContext() {
|
|
280
|
+
var sel = (RED.view && RED.view.selection) ? RED.view.selection() : null;
|
|
281
|
+
var liveCount = (sel && sel.nodes) ? sel.nodes.length : 0;
|
|
282
|
+
if (liveCount > 0) { return collectSelectionContext(); }
|
|
283
|
+
if (armedExecuteAction && pinnedSelectionIds) {
|
|
284
|
+
return collectSelectionContext(pinnedSelectionIds);
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
226
289
|
function getHistoryMaxExchanges() {
|
|
227
290
|
var n = Number(currentSettings.historyMaxExchanges);
|
|
228
291
|
return (isFinite(n) && n >= 0) ? n : 10;
|
|
@@ -812,7 +875,7 @@
|
|
|
812
875
|
var when = new Date(entry.timestamp).toLocaleTimeString();
|
|
813
876
|
var meta = when + " · " + entry.name + (entry.topic ? " · topic: " + entry.topic : "");
|
|
814
877
|
$("<div>").addClass("fp-recall-meta").text(meta).appendTo($item);
|
|
815
|
-
$("<div>").addClass("fp-recall-text").text(entry.
|
|
878
|
+
$("<div>").addClass("fp-recall-text").text(entry.previewValue).appendTo($item);
|
|
816
879
|
|
|
817
880
|
var already = !!attachedIds[entry.id];
|
|
818
881
|
var $use = $("<button>").addClass("fp-recall-use red-ui-button red-ui-button-small")
|
|
@@ -1118,7 +1181,9 @@
|
|
|
1118
1181
|
el("#fp-high-tokens").val(settings.contextHighTokens || 8000);
|
|
1119
1182
|
el("#fp-history-max").val(settings.historyMaxExchanges !== undefined ? settings.historyMaxExchanges : 10);
|
|
1120
1183
|
el("#fp-streaming-enabled").prop("checked", !!settings.streamingEnabled);
|
|
1184
|
+
el("#fp-request-timeout").val(Math.round((settings.requestTimeoutMs !== undefined ? settings.requestTimeoutMs : 180000) / 1000));
|
|
1121
1185
|
el("#fp-suppress-warnings").prop("checked", !!settings.suppressContextWarnings);
|
|
1186
|
+
el("#fp-redaction-disabled").prop("checked", settings.redactionEnabled === false);
|
|
1122
1187
|
|
|
1123
1188
|
// The dev/test banner is part of the warning set the user can silence
|
|
1124
1189
|
// via the type-to-confirm acknowledgement.
|
|
@@ -1137,6 +1202,24 @@
|
|
|
1137
1202
|
? (ap.providerName + " / " + ap.model)
|
|
1138
1203
|
: ((ap ? ap.providerName : "Provider") + ": model not configured");
|
|
1139
1204
|
el("#fp-provider-status").text("Provider: " + providerText);
|
|
1205
|
+
|
|
1206
|
+
// Anchor point for "no changes to save" detection — this is the form
|
|
1207
|
+
// state as of the last successful load/save.
|
|
1208
|
+
savedSettingsSnapshot = JSON.stringify(collectSettings());
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// Shows a short-lived status message next to the Save settings button.
|
|
1212
|
+
// Only used for that explicit, user-initiated action — the many internal
|
|
1213
|
+
// saveSettings() calls (Pre-flight check, Refresh models, custom intent
|
|
1214
|
+
// add/remove) have their own dedicated feedback elsewhere and would just
|
|
1215
|
+
// add noise here.
|
|
1216
|
+
function showSaveStatus(text, isError) {
|
|
1217
|
+
var $status = el("#fp-save-status");
|
|
1218
|
+
clearTimeout(saveStatusTimer);
|
|
1219
|
+
$status.text(text).toggleClass("fp-save-status-error", !!isError).removeClass("fp-hidden");
|
|
1220
|
+
saveStatusTimer = setTimeout(function () {
|
|
1221
|
+
$status.addClass("fp-hidden");
|
|
1222
|
+
}, 4000);
|
|
1140
1223
|
}
|
|
1141
1224
|
|
|
1142
1225
|
// Read the form's provider fields back into the active provider profile.
|
|
@@ -1159,12 +1242,23 @@
|
|
|
1159
1242
|
var typed = (el("#fp-suppress-confirm").val() || "").trim();
|
|
1160
1243
|
var suppress = wantSuppress && typed === "I understand the risk";
|
|
1161
1244
|
|
|
1245
|
+
// Same type-to-confirm gate as suppressContextWarnings above, and for
|
|
1246
|
+
// the same reason: the confirm box is never pre-filled from settings,
|
|
1247
|
+
// so disabling redaction stays off unless re-confirmed on every save —
|
|
1248
|
+
// "off-able, not off-by-accident".
|
|
1249
|
+
var wantRedactionOff = el("#fp-redaction-disabled").prop("checked");
|
|
1250
|
+
var redactionTyped = (el("#fp-redaction-confirm").val() || "").trim();
|
|
1251
|
+
var redactionEnabled = !(wantRedactionOff && redactionTyped === "disable redaction");
|
|
1252
|
+
|
|
1162
1253
|
// Fold the form's provider fields back into the active profile first.
|
|
1163
1254
|
captureProviderFields();
|
|
1164
1255
|
|
|
1165
1256
|
var historyMax = Number(el("#fp-history-max").val());
|
|
1166
1257
|
if (!isFinite(historyMax) || historyMax < 0) { historyMax = 10; }
|
|
1167
1258
|
|
|
1259
|
+
var requestTimeoutSec = Number(el("#fp-request-timeout").val());
|
|
1260
|
+
if (!isFinite(requestTimeoutSec) || requestTimeoutSec < 5) { requestTimeoutSec = 180; }
|
|
1261
|
+
|
|
1168
1262
|
return {
|
|
1169
1263
|
providers: providersList(),
|
|
1170
1264
|
activeProviderId: currentSettings.activeProviderId,
|
|
@@ -1173,7 +1267,9 @@
|
|
|
1173
1267
|
contextHighTokens: Number(el("#fp-high-tokens").val() || 8000),
|
|
1174
1268
|
historyMaxExchanges: historyMax,
|
|
1175
1269
|
streamingEnabled: el("#fp-streaming-enabled").prop("checked"),
|
|
1270
|
+
requestTimeoutMs: Math.round(requestTimeoutSec * 1000),
|
|
1176
1271
|
suppressContextWarnings: suppress,
|
|
1272
|
+
redactionEnabled: redactionEnabled,
|
|
1177
1273
|
customIntents: Array.isArray(currentSettings.customIntents)
|
|
1178
1274
|
? currentSettings.customIntents : []
|
|
1179
1275
|
};
|
|
@@ -1305,7 +1401,11 @@
|
|
|
1305
1401
|
});
|
|
1306
1402
|
}
|
|
1307
1403
|
|
|
1308
|
-
|
|
1404
|
+
// `announce` is true only for the explicit Save settings button — the
|
|
1405
|
+
// many internal callers (Pre-flight check, Refresh models, custom intent
|
|
1406
|
+
// add/remove) save as a side effect of some other action and already
|
|
1407
|
+
// have their own feedback, so they stay silent here.
|
|
1408
|
+
function saveSettings(callback, announce) {
|
|
1309
1409
|
var payload = collectSettings();
|
|
1310
1410
|
var list = payload.providers || [];
|
|
1311
1411
|
|
|
@@ -1316,7 +1416,9 @@
|
|
|
1316
1416
|
});
|
|
1317
1417
|
if (noUrl.length) {
|
|
1318
1418
|
var urlNames = noUrl.map(function (p) { return p.providerName || "(unnamed)"; }).join(", ");
|
|
1319
|
-
|
|
1419
|
+
var noUrlMsg = "Cannot save: these provider(s) need a Base URL: " + urlNames + ".";
|
|
1420
|
+
addMessage("error", noUrlMsg);
|
|
1421
|
+
if (announce) { showSaveStatus(noUrlMsg, true); }
|
|
1320
1422
|
showSettings();
|
|
1321
1423
|
return;
|
|
1322
1424
|
}
|
|
@@ -1329,7 +1431,9 @@
|
|
|
1329
1431
|
return !p.providerName || !String(p.providerName).trim();
|
|
1330
1432
|
});
|
|
1331
1433
|
if (blankName.length) {
|
|
1332
|
-
|
|
1434
|
+
var blankNameMsg = "Cannot save: every provider needs a name.";
|
|
1435
|
+
addMessage("error", blankNameMsg);
|
|
1436
|
+
if (announce) { showSaveStatus(blankNameMsg, true); }
|
|
1333
1437
|
showSettings();
|
|
1334
1438
|
return;
|
|
1335
1439
|
}
|
|
@@ -1343,18 +1447,29 @@
|
|
|
1343
1447
|
seen[key] = true;
|
|
1344
1448
|
});
|
|
1345
1449
|
if (dupes.length) {
|
|
1346
|
-
|
|
1450
|
+
var dupesMsg = "Cannot save: provider names must be unique. Duplicate: " + dupes.join(", ") + ".";
|
|
1451
|
+
addMessage("error", dupesMsg);
|
|
1452
|
+
if (announce) { showSaveStatus(dupesMsg, true); }
|
|
1347
1453
|
showSettings();
|
|
1348
1454
|
return;
|
|
1349
1455
|
}
|
|
1350
1456
|
|
|
1457
|
+
// Nothing changed since the last load/save — skip the round trip.
|
|
1458
|
+
if (announce && savedSettingsSnapshot !== null && JSON.stringify(payload) === savedSettingsSnapshot) {
|
|
1459
|
+
showSaveStatus("No changes to save.");
|
|
1460
|
+
if (callback) { callback(); }
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1351
1464
|
ajaxJson("POST", "flowpilot/settings", payload, function (data) {
|
|
1352
1465
|
fillSettings(data);
|
|
1353
1466
|
addMessage("assistant", "Settings saved.");
|
|
1467
|
+
if (announce) { showSaveStatus("Settings saved."); }
|
|
1354
1468
|
updateSelectionStatus();
|
|
1355
1469
|
if (callback) { callback(); }
|
|
1356
1470
|
}, function (msg) {
|
|
1357
1471
|
addMessage("error", "Unable to save FlowPilot settings: " + msg);
|
|
1472
|
+
if (announce) { showSaveStatus("Unable to save: " + msg, true); }
|
|
1358
1473
|
});
|
|
1359
1474
|
}
|
|
1360
1475
|
|
|
@@ -1391,7 +1506,8 @@
|
|
|
1391
1506
|
// way to reason about (or fix) auth configuration while protecting
|
|
1392
1507
|
// nothing. Real credential VALUES are already excluded entirely via
|
|
1393
1508
|
// INTERNAL_FIELDS.credentials.
|
|
1394
|
-
if (
|
|
1509
|
+
if (currentSettings.redactionEnabled !== false &&
|
|
1510
|
+
SECRET_KEY.test(k) && typeof v === "string" && v.length > SECRET_NAME_MIN_LEN) {
|
|
1395
1511
|
out[k] = "[redacted: secret field, " + v.length + " chars]";
|
|
1396
1512
|
return;
|
|
1397
1513
|
}
|
|
@@ -1794,13 +1910,16 @@
|
|
|
1794
1910
|
.addClass("fp-has-selection");
|
|
1795
1911
|
}
|
|
1796
1912
|
|
|
1797
|
-
|
|
1798
|
-
|
|
1913
|
+
el("#fp-preview-nodes").toggleClass("fp-hidden", count === 0);
|
|
1914
|
+
|
|
1915
|
+
// Size line: selection context + attached debug messages +
|
|
1916
|
+
// conversation history.
|
|
1799
1917
|
var contextTokens = liveCount > 0 ? estimateTokens(collectSelectionContext())
|
|
1800
1918
|
: pinnedContext ? estimateTokens(pinnedContext) : 0;
|
|
1919
|
+
var debugTokens = attachedDebugMessages.length ? estimateTokens(buildDebugMessagesForSend()) : 0;
|
|
1801
1920
|
var historyPayload = buildHistoryPayload();
|
|
1802
1921
|
var historyTokens = estimateTokens(historyPayload.messages);
|
|
1803
|
-
var tokens = contextTokens + historyTokens;
|
|
1922
|
+
var tokens = contextTokens + debugTokens + historyTokens;
|
|
1804
1923
|
|
|
1805
1924
|
if (tokens === 0) {
|
|
1806
1925
|
$size.text("").addClass("fp-hidden");
|
|
@@ -1810,6 +1929,7 @@
|
|
|
1810
1929
|
|
|
1811
1930
|
var parts = [];
|
|
1812
1931
|
if (contextTokens) { parts.push("context ~" + contextTokens.toLocaleString()); }
|
|
1932
|
+
if (debugTokens) { parts.push("debug ~" + debugTokens.toLocaleString()); }
|
|
1813
1933
|
if (historyTokens) {
|
|
1814
1934
|
parts.push("history ~" + historyTokens.toLocaleString() +
|
|
1815
1935
|
(historyPayload.truncated ? " (earlier messages omitted)" : ""));
|
|
@@ -1831,11 +1951,17 @@
|
|
|
1831
1951
|
}
|
|
1832
1952
|
|
|
1833
1953
|
// Secrets reminder (suppressible) — only relevant when a selection is
|
|
1834
|
-
// attached as context.
|
|
1835
|
-
|
|
1954
|
+
// attached as context. When redaction is actually OFF, this can't be
|
|
1955
|
+
// suppressed and gets a starker tooltip — at that point the warning is
|
|
1956
|
+
// no longer "just in case", it's literally true.
|
|
1957
|
+
var redactionOff = currentSettings.redactionEnabled === false;
|
|
1958
|
+
if (count === 0 || (currentSettings.suppressContextWarnings && !redactionOff)) {
|
|
1836
1959
|
$secrets.addClass("fp-hidden");
|
|
1837
1960
|
} else {
|
|
1838
|
-
$secrets.removeClass("fp-hidden");
|
|
1961
|
+
$secrets.removeClass("fp-hidden").toggleClass("fp-secrets-status-off", redactionOff);
|
|
1962
|
+
$secrets.attr("title", redactionOff
|
|
1963
|
+
? "Redaction is OFF — secret-shaped values are sent as-is, unredacted. Don't send credentials or proprietary data unless you trust this AI provider."
|
|
1964
|
+
: "Context may include node config and code. Don't send credentials or proprietary data. Local/private AI recommended.");
|
|
1839
1965
|
}
|
|
1840
1966
|
}
|
|
1841
1967
|
|
|
@@ -1907,7 +2033,8 @@
|
|
|
1907
2033
|
"- `/history` — open the Flight log (past conversations)\n" +
|
|
1908
2034
|
"- `/settings` — open the Hangar (providers, behavior, safety)\n\n" +
|
|
1909
2035
|
"Typing a shortcut with extra text, e.g. `/modify add a debug node`, switches mode and leaves the rest in the box so you can review before sending.\n\n" +
|
|
1910
|
-
"- `/demo` — load a sample Generate request (a dad joke flow) into the compose box\n
|
|
2036
|
+
"- `/demo` — load a sample Generate request (a dad joke flow) into the compose box\n" +
|
|
2037
|
+
"- `/feedback` — bug report / feature request info\n\n" +
|
|
1911
2038
|
"### Also worth knowing\n\n" +
|
|
1912
2039
|
"- Action chips (paper-plane buttons) offer a one-click follow-up — review and send, nothing fires automatically.\n" +
|
|
1913
2040
|
"- When I ask a clarifying question, I'll often offer quick-reply buttons (plus \"Other\" for your own answer) — clicking one sends it right away.\n" +
|
|
@@ -1919,6 +2046,17 @@
|
|
|
1919
2046
|
// with a small, fast-to-generate flow.
|
|
1920
2047
|
var DEMO_PROMPT = "Using an online dad joke API (e.g. https://icanhazdadjoke.com/ with an \"Accept: application/json\" header), make an API call for a dad joke when triggered by an inject node. Set the node's status to show the joke text, and wire a debug node to output the joke itself.";
|
|
1921
2048
|
|
|
2049
|
+
// /feedback: links back to the repo/issues, shown entirely client-side.
|
|
2050
|
+
var FEEDBACK_TEXT = "## Thanks for flying with FlowPilot\n\n" +
|
|
2051
|
+
"Bug? Rough edge? Idea for a feature? I'd love to hear about it — the " +
|
|
2052
|
+
"human crew reads every report.\n\n" +
|
|
2053
|
+
"- **Report an issue**: https://github.com/manny-est/flowpilot/issues\n" +
|
|
2054
|
+
"- **Browse the repo**: https://github.com/manny-est/flowpilot\n\n" +
|
|
2055
|
+
"A good report travels light but packs the essentials: your Node-RED " +
|
|
2056
|
+
"version, the provider/model you're flying with, and the steps to " +
|
|
2057
|
+
"reproduce. That's usually enough to get a fix off the ground.\n\n" +
|
|
2058
|
+
"Safe travels.";
|
|
2059
|
+
|
|
1922
2060
|
var demoTypeTimer = null;
|
|
1923
2061
|
|
|
1924
2062
|
// Streams `text` into the prompt box a few characters at a time, as if
|
|
@@ -2002,6 +2140,10 @@
|
|
|
2002
2140
|
el("#fp-send").addClass("fp-send-breathe");
|
|
2003
2141
|
});
|
|
2004
2142
|
break;
|
|
2143
|
+
case "/feedback":
|
|
2144
|
+
addMessage("assistant", FEEDBACK_TEXT);
|
|
2145
|
+
if ($promptBox.length) { $promptBox.val(""); }
|
|
2146
|
+
break;
|
|
2005
2147
|
default:
|
|
2006
2148
|
addMessage("assistant", "Unrecognized command `" + command + "`. Type `/help` for the full list.");
|
|
2007
2149
|
if ($promptBox.length) { $promptBox.val(""); }
|
|
@@ -2276,21 +2418,24 @@
|
|
|
2276
2418
|
|
|
2277
2419
|
// Streaming chat. Posts with stream:true and reads the
|
|
2278
2420
|
// SSE response body incrementally via fetch's ReadableStream. The
|
|
2279
|
-
// bouncing "pending" indicator (already in the DOM from showPending)
|
|
2280
|
-
//
|
|
2421
|
+
// bouncing "pending" indicator (already in the DOM from showPending)
|
|
2422
|
+
// stays up until the first real delta arrives, then ensureBubble()
|
|
2423
|
+
// swaps it for the assistant bubble that gets filled in as chunks
|
|
2281
2424
|
// arrive — generate/modify/document never call this; their JSON envelope
|
|
2282
2425
|
// can't be rendered until complete.
|
|
2283
2426
|
function sendChatStream(payload) {
|
|
2284
2427
|
var $box = el("#fp-messages");
|
|
2285
2428
|
if (!$box.length) { return; }
|
|
2286
2429
|
|
|
2287
|
-
|
|
2430
|
+
// Bug #4: this used to grab the just-shown #fp-pending indicator and
|
|
2431
|
+
// convert it into an empty bubble right here, synchronously, before
|
|
2432
|
+
// fetch() even started — so the dots were destroyed in the same tick
|
|
2433
|
+
// they were created and never got a chance to render. $msg/$text now
|
|
2434
|
+
// start null and ensureBubble() (below) does the conversion lazily,
|
|
2435
|
+
// on the FIRST actual delta — the dots stay visible for the entire
|
|
2436
|
+
// wait until real content starts arriving.
|
|
2437
|
+
var $msg = null;
|
|
2288
2438
|
var $text = null;
|
|
2289
|
-
if ($msg.length) {
|
|
2290
|
-
$msg.removeAttr("id").empty();
|
|
2291
|
-
$("<div>").addClass("fp-label").text("FLOWPILOT").appendTo($msg);
|
|
2292
|
-
$text = $("<div>").addClass("fp-md").appendTo($msg);
|
|
2293
|
-
}
|
|
2294
2439
|
|
|
2295
2440
|
var fullText = "";
|
|
2296
2441
|
var finalData = null;
|
|
@@ -2674,13 +2819,10 @@
|
|
|
2674
2819
|
var $box = el("#fp-messages");
|
|
2675
2820
|
if (!$box.length) { return; }
|
|
2676
2821
|
|
|
2677
|
-
|
|
2822
|
+
// See the matching comment in sendChatStream — same fix, same bug
|
|
2823
|
+
// (#4): don't pre-convert #fp-pending before any data has arrived.
|
|
2824
|
+
var $msg = null;
|
|
2678
2825
|
var $text = null;
|
|
2679
|
-
if ($msg.length) {
|
|
2680
|
-
$msg.removeAttr("id").empty();
|
|
2681
|
-
$("<div>").addClass("fp-label").text("FLOWPILOT").appendTo($msg);
|
|
2682
|
-
$text = $("<div>").addClass("fp-md").appendTo($msg);
|
|
2683
|
-
}
|
|
2684
2826
|
|
|
2685
2827
|
var extractor = createExplanationExtractor();
|
|
2686
2828
|
var finalData = null;
|
|
@@ -3968,6 +4110,7 @@
|
|
|
3968
4110
|
var summary = [];
|
|
3969
4111
|
var typeWarnings = [];
|
|
3970
4112
|
var brokenWires = [];
|
|
4113
|
+
var realWireCount = 0;
|
|
3971
4114
|
|
|
3972
4115
|
nodes.forEach(function (n) {
|
|
3973
4116
|
if (!n || !n.id || !n.type) { return; }
|
|
@@ -3984,12 +4127,22 @@
|
|
|
3984
4127
|
(Array.isArray(port) ? port : []).forEach(function (targetId) {
|
|
3985
4128
|
if (!ids[targetId]) {
|
|
3986
4129
|
brokenWires.push({ from: n.id, type: n.type, target: targetId });
|
|
4130
|
+
} else {
|
|
4131
|
+
realWireCount++;
|
|
3987
4132
|
}
|
|
3988
4133
|
});
|
|
3989
4134
|
});
|
|
3990
4135
|
});
|
|
3991
4136
|
|
|
3992
|
-
|
|
4137
|
+
// A multi-node flow with zero connections anywhere is almost always
|
|
4138
|
+
// a generation slip (the model omitted "wires" on every node) rather
|
|
4139
|
+
// than something the user actually wanted — flag it, but don't
|
|
4140
|
+
// block import; a handful of genuinely independent nodes is rare
|
|
4141
|
+
// but not impossible.
|
|
4142
|
+
var nonCommentCount = nodes.filter(function (n) { return n && n.type !== "comment"; }).length;
|
|
4143
|
+
var noConnections = nonCommentCount > 1 && realWireCount === 0;
|
|
4144
|
+
|
|
4145
|
+
return { summary: summary, typeWarnings: typeWarnings, brokenWires: brokenWires, noConnections: noConnections };
|
|
3993
4146
|
}
|
|
3994
4147
|
|
|
3995
4148
|
// The generation prompt deliberately omits x/y ("the editor assigns those
|
|
@@ -4299,6 +4452,14 @@
|
|
|
4299
4452
|
$list.append($li);
|
|
4300
4453
|
});
|
|
4301
4454
|
|
|
4455
|
+
if (v.noConnections) {
|
|
4456
|
+
var $wireWarn = $("<div>").addClass("fp-warning fp-review-warning").appendTo($summaryPanel);
|
|
4457
|
+
$("<strong>").text("⚠ These nodes aren't wired to each other.").appendTo($wireWarn);
|
|
4458
|
+
$("<div>").text("None of the " + nodes.length + " generated nodes connect to one another — " +
|
|
4459
|
+
"they'll land on the canvas disconnected. You can wire them manually, or ask FlowPilot " +
|
|
4460
|
+
"to regenerate.").appendTo($wireWarn);
|
|
4461
|
+
}
|
|
4462
|
+
|
|
4302
4463
|
if (v.typeWarnings.length) {
|
|
4303
4464
|
var $warn = $("<div>").addClass("fp-warning fp-review-warning").appendTo($summaryPanel);
|
|
4304
4465
|
$("<strong>").text("Type warnings — review before adding:").appendTo($warn);
|
|
@@ -4423,6 +4584,7 @@
|
|
|
4423
4584
|
' </div>' +
|
|
4424
4585
|
' <div class="fp-status-strip">' +
|
|
4425
4586
|
' <span id="fp-selection-status" class="fp-selection-status">No nodes selected</span>' +
|
|
4587
|
+
' <a href="#" id="fp-preview-nodes" class="fp-preview-link fp-hidden" title="Show the exact sanitized node JSON that will be sent">Preview JSON</a>' +
|
|
4426
4588
|
' <span id="fp-size-status" class="fp-size-status fp-hidden"></span>' +
|
|
4427
4589
|
' <span id="fp-secrets-status" class="fp-secrets-status fp-hidden" title="Context may include node config and code. Don\'t send credentials or proprietary data. Local/private AI recommended.">⚠</span>' +
|
|
4428
4590
|
' <span id="fp-debug-status" class="fp-debug-status fp-hidden"></span>' +
|
|
@@ -4435,14 +4597,14 @@
|
|
|
4435
4597
|
' </div>' +
|
|
4436
4598
|
|
|
4437
4599
|
' <div id="fp-settings-panel" class="fp-panel fp-hidden">' +
|
|
4438
|
-
' <div class="fp-warning">' +
|
|
4439
|
-
' <strong>Provider settings.</strong><br>' +
|
|
4440
|
-
' Stored locally under the Node-RED user directory in <code>flowpilot/settings.json</code>.' +
|
|
4441
|
-
' </div>' +
|
|
4442
4600
|
' <div class="fp-form">' +
|
|
4443
4601
|
|
|
4444
4602
|
' <details class="fp-settings-group" open>' +
|
|
4445
4603
|
' <summary title="Hangar — where your AI providers are configured">Providers</summary>' +
|
|
4604
|
+
' <div class="fp-warning">' +
|
|
4605
|
+
' <strong>Provider settings.</strong><br>' +
|
|
4606
|
+
' Stored locally under the Node-RED user directory in <code>flowpilot/settings.json</code>.' +
|
|
4607
|
+
' </div>' +
|
|
4446
4608
|
' <label>Active provider</label>' +
|
|
4447
4609
|
' <select id="fp-provider-select"></select>' +
|
|
4448
4610
|
' <div class="fp-provider-actions">' +
|
|
@@ -4461,6 +4623,9 @@
|
|
|
4461
4623
|
' <div id="fp-models-hint" class="fp-consent-hint fp-hidden"></div>' +
|
|
4462
4624
|
' <label>Temperature</label>' +
|
|
4463
4625
|
' <input id="fp-temperature" type="number" min="0" max="2" step="0.1" placeholder="0.2">' +
|
|
4626
|
+
' <div class="fp-consent-hint">Controls randomness. Lower (e.g. 0.2) is more ' +
|
|
4627
|
+
' focused and consistent; higher is more creative and varied. 0.2 is a good ' +
|
|
4628
|
+
' default for flow generation.</div>' +
|
|
4464
4629
|
' <div class="fp-settings-actions">' +
|
|
4465
4630
|
' <button id="fp-test-provider" class="red-ui-button" type="button" title="Pre-flight check — save and send a quick test to this provider">Pre-flight check</button>' +
|
|
4466
4631
|
' <button id="fp-refresh-models" class="red-ui-button" type="button" title="Save settings, then fetch this provider\'s model list via GET /v1/models">Refresh models</button>' +
|
|
@@ -4489,6 +4654,13 @@
|
|
|
4489
4654
|
' <div class="fp-consent-hint">Only applies to chat replies — Generate, ' +
|
|
4490
4655
|
' Document, and Modify always wait for the full response.</div>' +
|
|
4491
4656
|
|
|
4657
|
+
' <div class="fp-settings-section">Request timeout</div>' +
|
|
4658
|
+
' <label>Give up after (seconds)</label>' +
|
|
4659
|
+
' <input id="fp-request-timeout" type="number" min="5" step="5" placeholder="180">' +
|
|
4660
|
+
' <div class="fp-consent-hint">How long to wait for a provider response before ' +
|
|
4661
|
+
' giving up. Raise this if you\'re running a large local model on slow hardware ' +
|
|
4662
|
+
' (e.g. Ollama without a GPU) and seeing timeout errors.</div>' +
|
|
4663
|
+
|
|
4492
4664
|
' <div class="fp-settings-section">Custom intent buttons</div>' +
|
|
4493
4665
|
' <div class="fp-consent-hint">Add your own one-click prompt buttons. ' +
|
|
4494
4666
|
' They appear next to the built-in ones above the prompt.</div>' +
|
|
@@ -4519,10 +4691,25 @@
|
|
|
4519
4691
|
' <strong>I understand the risk</strong> below. ' +
|
|
4520
4692
|
' Anything you send may leave this Node-RED instance.</div>' +
|
|
4521
4693
|
' <input id="fp-suppress-confirm" type="text" placeholder="Type: I understand the risk">' +
|
|
4694
|
+
|
|
4695
|
+
' <div class="fp-settings-section">Redaction</div>' +
|
|
4696
|
+
' <label class="fp-checkbox-row">' +
|
|
4697
|
+
' <input id="fp-redaction-disabled" type="checkbox"> ' +
|
|
4698
|
+
' Disable secret-shaped-value redaction' +
|
|
4699
|
+
' </label>' +
|
|
4700
|
+
' <div class="fp-consent-hint">By default, values that look like secrets ' +
|
|
4701
|
+
' (passwords, tokens, API keys) are replaced with a placeholder before sending. ' +
|
|
4702
|
+
' This opt-out is intended for environments using local or private AIs. Debug ' +
|
|
4703
|
+
' nodes and context may share confidential data, including secret keys, and ' +
|
|
4704
|
+
' credentials. Use at your own risk! Node-RED\'s own credential store is never ' +
|
|
4705
|
+
' sent either way. To disable, check the box and type ' +
|
|
4706
|
+
' <strong>disable redaction</strong> below.</div>' +
|
|
4707
|
+
' <input id="fp-redaction-confirm" type="text" placeholder="Type: disable redaction">' +
|
|
4522
4708
|
' </details>' +
|
|
4523
4709
|
|
|
4524
4710
|
' <div class="fp-settings-actions">' +
|
|
4525
4711
|
' <button id="fp-save-settings" class="red-ui-button red-ui-button-primary" type="button">Save settings</button>' +
|
|
4712
|
+
' <span id="fp-save-status" class="fp-save-status fp-hidden"></span>' +
|
|
4526
4713
|
' </div>' +
|
|
4527
4714
|
' </div>' +
|
|
4528
4715
|
' </div>' +
|
|
@@ -4560,6 +4747,10 @@
|
|
|
4560
4747
|
content.find("#fp-clear-chat").on("click", clearChat);
|
|
4561
4748
|
content.find("#fp-recall").on("click", recallSearch);
|
|
4562
4749
|
content.find("#fp-debug-log").on("click", showDebugMessages);
|
|
4750
|
+
content.find("#fp-preview-nodes").on("click", function (ev) {
|
|
4751
|
+
ev.preventDefault();
|
|
4752
|
+
showJsonPreview("Node JSON preview — exactly what will be sent", resolveCurrentSelectionContext());
|
|
4753
|
+
});
|
|
4563
4754
|
|
|
4564
4755
|
// Subscribe once to the same RED.comms "debug" topic the built-in
|
|
4565
4756
|
// Debug sidebar uses, to buffer recent messages locally for
|
|
@@ -4614,7 +4805,7 @@
|
|
|
4614
4805
|
});
|
|
4615
4806
|
|
|
4616
4807
|
content.find("#fp-save-settings").on("click", function () {
|
|
4617
|
-
saveSettings();
|
|
4808
|
+
saveSettings(null, true);
|
|
4618
4809
|
});
|
|
4619
4810
|
content.find("#fp-add-intent").on("click", function () {
|
|
4620
4811
|
addCustomIntent();
|
|
@@ -5402,6 +5593,12 @@
|
|
|
5402
5593
|
flex: 0 0 auto;
|
|
5403
5594
|
}
|
|
5404
5595
|
|
|
5596
|
+
.fp-secrets-status-off {
|
|
5597
|
+
background: rgba(192, 57, 43, 0.18);
|
|
5598
|
+
border-color: rgba(192, 57, 43, 0.6);
|
|
5599
|
+
color: #c0392b;
|
|
5600
|
+
}
|
|
5601
|
+
|
|
5405
5602
|
.fp-debug-status {
|
|
5406
5603
|
font-size: 12px;
|
|
5407
5604
|
color: var(--red-ui-secondary-text-color, #888);
|
|
@@ -5419,6 +5616,16 @@
|
|
|
5419
5616
|
text-decoration: underline;
|
|
5420
5617
|
}
|
|
5421
5618
|
|
|
5619
|
+
.fp-preview-link {
|
|
5620
|
+
font-size: 12px;
|
|
5621
|
+
color: var(--red-ui-secondary-text-color, #888);
|
|
5622
|
+
text-decoration: none;
|
|
5623
|
+
}
|
|
5624
|
+
|
|
5625
|
+
.fp-preview-link:hover {
|
|
5626
|
+
text-decoration: underline;
|
|
5627
|
+
}
|
|
5628
|
+
|
|
5422
5629
|
.fp-settings-section {
|
|
5423
5630
|
margin-top: 18px;
|
|
5424
5631
|
padding-top: 12px;
|
|
@@ -5588,6 +5795,16 @@
|
|
|
5588
5795
|
gap: 8px;
|
|
5589
5796
|
}
|
|
5590
5797
|
|
|
5798
|
+
.fp-save-status {
|
|
5799
|
+
font-size: 12px;
|
|
5800
|
+
align-self: center;
|
|
5801
|
+
color: var(--red-ui-secondary-text-color, #888);
|
|
5802
|
+
}
|
|
5803
|
+
|
|
5804
|
+
.fp-save-status-error {
|
|
5805
|
+
color: #c0392b;
|
|
5806
|
+
}
|
|
5807
|
+
|
|
5591
5808
|
.fp-form {
|
|
5592
5809
|
flex: 1;
|
|
5593
5810
|
overflow-y: auto;
|
package/flowpilot.js
CHANGED
|
@@ -406,7 +406,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
406
406
|
// Returns null when there's no selection — used by both /chat and
|
|
407
407
|
// /generate so the two describe context identically and never drift.
|
|
408
408
|
// ---------------------------------------------------------------------
|
|
409
|
-
function describeSelectionContext(context) {
|
|
409
|
+
function describeSelectionContext(context, redactionEnabled) {
|
|
410
410
|
const nodes = context && Array.isArray(context.nodes) ? context.nodes : [];
|
|
411
411
|
const debugMessages = context && Array.isArray(context.debugMessages) ? context.debugMessages : [];
|
|
412
412
|
if (nodes.length === 0 && debugMessages.length === 0) { return null; }
|
|
@@ -416,10 +416,26 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
416
416
|
const perNode = Array.isArray(connections.perNode) ? connections.perNode : [];
|
|
417
417
|
const subFlowCount = (typeof connections.subFlowCount === "number") ? connections.subFlowCount : 0;
|
|
418
418
|
|
|
419
|
+
// Node-RED's own credential store (config node "credentials" fields) is
|
|
420
|
+
// dropped by the frontend's sanitizer unconditionally — that part never
|
|
421
|
+
// changes. redactionEnabled only controls the SEPARATE secret-shaped-value
|
|
422
|
+
// scrubbing (password/token/apiKey-looking fields elsewhere in a node's
|
|
423
|
+
// config) — tell the model the truth about which protection is active.
|
|
424
|
+
const credentialNote = redactionEnabled === false
|
|
425
|
+
? "Redaction is OFF for this session — context may contain sensitive " +
|
|
426
|
+
"values the user chose to share (e.g. embedded API keys or tokens); " +
|
|
427
|
+
"handle carefully and never volunteer them. Node-RED's separate " +
|
|
428
|
+
"credential store is still never included. This is a setting in the " +
|
|
429
|
+
"editor's FlowPilot Settings panel (Context & Safety section) — you " +
|
|
430
|
+
"have no ability to read, change, or report on it beyond this note; " +
|
|
431
|
+
"if the user wants to turn it back on, tell them to uncheck it there " +
|
|
432
|
+
"(it requires re-confirming a type-to-confirm phrase, by design)."
|
|
433
|
+
: "This is sanitized configuration; credentials are redacted.";
|
|
434
|
+
|
|
419
435
|
let content = "";
|
|
420
436
|
if (nodes.length > 0) {
|
|
421
437
|
content += "The user has selected the following Node-RED nodes as context. " +
|
|
422
|
-
|
|
438
|
+
credentialNote + "\n\n" +
|
|
423
439
|
"Nodes:\n```json\n" + JSON.stringify(nodes) + "\n```";
|
|
424
440
|
if (edges.length > 0) {
|
|
425
441
|
content += "\n\nConnections — directed edges by node id (a node's wires " +
|
|
@@ -464,7 +480,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
464
480
|
const settings = storage.getSettings();
|
|
465
481
|
const activeProvider = storage.getActiveProvider(settings);
|
|
466
482
|
|
|
467
|
-
const described = describeSelectionContext(context);
|
|
483
|
+
const described = describeSelectionContext(context, settings.redactionEnabled);
|
|
468
484
|
const messages = buildMessages(
|
|
469
485
|
settings.systemPrompt || "You are FlowPilot, a Node-RED development assistant.",
|
|
470
486
|
history, historyTruncated, described, prompt
|
|
@@ -501,7 +517,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
501
517
|
const settings = storage.getSettings();
|
|
502
518
|
const activeProvider = storage.getActiveProvider(settings);
|
|
503
519
|
|
|
504
|
-
const described = describeSelectionContext(context);
|
|
520
|
+
const described = describeSelectionContext(context, settings.redactionEnabled);
|
|
505
521
|
const messages = buildMessages(
|
|
506
522
|
settings.systemPrompt || "You are FlowPilot, a Node-RED development assistant.",
|
|
507
523
|
history, historyTruncated, described, prompt
|
|
@@ -735,7 +751,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
735
751
|
|
|
736
752
|
if (mode !== "chat") {
|
|
737
753
|
const context = req.body.context;
|
|
738
|
-
const described = describeSelectionContext(context);
|
|
754
|
+
const described = describeSelectionContext(context, settings.redactionEnabled);
|
|
739
755
|
const generated = processGenerationContent(result.content || "", result, messages, mode, described, activeProvider);
|
|
740
756
|
recordTranscriptTurn(req.body.conversationId, mode, req.body.prompt || null, transcriptTextFromGenerationResult(generated));
|
|
741
757
|
const finalize = (mode === "modify")
|
|
@@ -954,7 +970,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
954
970
|
function buildGenerationContext(systemPrompt, userPrompt, context, history, historyTruncated) {
|
|
955
971
|
const settings = storage.getSettings();
|
|
956
972
|
const activeProvider = storage.getActiveProvider(settings);
|
|
957
|
-
const described = describeSelectionContext(context);
|
|
973
|
+
const described = describeSelectionContext(context, settings.redactionEnabled);
|
|
958
974
|
const messages = buildMessages(systemPrompt, history, historyTruncated, described, userPrompt);
|
|
959
975
|
return { activeProvider, described, messages };
|
|
960
976
|
}
|
|
@@ -1558,7 +1574,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1558
1574
|
|
|
1559
1575
|
RED.httpAdmin.post("/flowpilot/document", RED.auth.needsPermission("settings.write"), async function (req, res) {
|
|
1560
1576
|
const context = req.body && req.body.context;
|
|
1561
|
-
const described = describeSelectionContext(context);
|
|
1577
|
+
const described = describeSelectionContext(context, storage.getSettings().redactionEnabled);
|
|
1562
1578
|
|
|
1563
1579
|
if (!described) {
|
|
1564
1580
|
return res.status(400).json({ error: "Select the node(s) you want documented first." });
|
|
@@ -1598,7 +1614,7 @@ module.exports = function flowPilotRuntime(RED) {
|
|
|
1598
1614
|
|
|
1599
1615
|
RED.httpAdmin.post("/flowpilot/modify", RED.auth.needsPermission("settings.write"), async function (req, res) {
|
|
1600
1616
|
const context = req.body && req.body.context;
|
|
1601
|
-
const described = describeSelectionContext(context);
|
|
1617
|
+
const described = describeSelectionContext(context, storage.getSettings().redactionEnabled);
|
|
1602
1618
|
|
|
1603
1619
|
if (!described) {
|
|
1604
1620
|
return res.status(400).json({ error: "Select the node(s) you want to modify first." });
|
|
@@ -43,12 +43,23 @@ Respond with a SINGLE JSON object and nothing else — no markdown code fences,
|
|
|
43
43
|
Rules for the "flow" array:
|
|
44
44
|
- It is a standard Node-RED flow array, the same format produced by the editor's Export. Each element is a node object.
|
|
45
45
|
- Every node needs a unique "id" (a short random-looking hex string), a "type", and the fields that type requires.
|
|
46
|
-
-
|
|
46
|
+
- EVERY node object MUST include a "wires" array — this is not optional and is never omitted, even for the first node in the chain or one with no outgoing connection. "wires" is one entry per output port, each entry an array of target node ids. A node with no outgoing connection (e.g. a debug node, or the last node in a chain) still has "wires": [] — an empty array, not a missing field.
|
|
47
47
|
- All wire targets must reference ids that exist within this flow array.
|
|
48
48
|
- Do NOT include "x"/"y" coordinates or a "z" (tab) id — the editor assigns those on import. Omitting them is fine.
|
|
49
49
|
- Do NOT include a node of type "tab" or "subflow" in "flow" — these represent editor workspaces/containers, not importable nodes, and including one will not behave as a grouping mechanism. To label or group related nodes, use "comment" nodes as section headers instead.
|
|
50
50
|
- Comment nodes (type: "comment") are passive annotations and do not pass messages — their "wires" array MUST be empty ([]). Never wire a comment node to or from any other node.
|
|
51
51
|
|
|
52
|
+
Example — three nodes chained inject -> function -> debug, showing "wires" on every single node including the first and last:
|
|
53
|
+
{
|
|
54
|
+
"explanation": "An inject node triggers a function that doubles its input, then a debug node logs the result.",
|
|
55
|
+
"flow": [
|
|
56
|
+
{"id": "n1", "type": "inject", "name": "Start", "props": [{"p":"payload"}], "repeat": "", "crontab": "", "once": false, "onceDelay": 0.1, "topic": "", "payload": "5", "payloadType": "num", "wires": [["n2"]]},
|
|
57
|
+
{"id": "n2", "type": "function", "name": "Double", "func": "msg.payload = msg.payload * 2;\\nreturn msg;", "outputs": 1, "wires": [["n3"]]},
|
|
58
|
+
{"id": "n3", "type": "debug", "name": "Result", "active": true, "tosidebar": true, "wires": []}
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
Notice "n1" (the very first node, nothing wires INTO it) still has its own "wires" array out to "n2", and "n3" (the last node, nothing downstream) still has an explicit "wires": [] rather than omitting the field. Every node you generate follows this same shape — a flow where any node is missing "wires" entirely will import with that node completely disconnected.
|
|
62
|
+
|
|
52
63
|
Node type rules:
|
|
53
64
|
- STRONGLY PREFER core nodes: inject, debug, function, change, switch, template, http in/out/request, mqtt in/out, link in/out, comment, junction, complete, catch, status, split, join, sort, batch, delay, trigger, range, csv, html, json, xml, yaml, file, exec, tcp/udp.
|
|
54
65
|
- Only use a non-core (contrib) node type if the user EXPLICITLY names it. Custom nodes are often unmaintained and may not be installed; core nodes are stable across versions.
|
|
@@ -48,8 +48,9 @@ function postJson(urlString, headers, body, timeoutMs) {
|
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
req.on("error", reject);
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
const effectiveTimeoutMs = timeoutMs || 180000;
|
|
52
|
+
req.setTimeout(effectiveTimeoutMs, () => {
|
|
53
|
+
req.destroy(new Error(`Provider request timed out after ${effectiveTimeoutMs}ms — increase the request timeout in Settings → Behavior for slower hardware.`));
|
|
53
54
|
});
|
|
54
55
|
|
|
55
56
|
req.write(payload);
|
|
@@ -100,8 +101,9 @@ function getJson(urlString, headers, timeoutMs) {
|
|
|
100
101
|
});
|
|
101
102
|
|
|
102
103
|
req.on("error", reject);
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
const effectiveTimeoutMs = timeoutMs || 30000;
|
|
105
|
+
req.setTimeout(effectiveTimeoutMs, () => {
|
|
106
|
+
req.destroy(new Error(`Provider request timed out after ${effectiveTimeoutMs}ms — increase the request timeout in Settings → Behavior for slower hardware.`));
|
|
105
107
|
});
|
|
106
108
|
|
|
107
109
|
req.end();
|
|
@@ -127,7 +129,7 @@ async function listModels(settings) {
|
|
|
127
129
|
}
|
|
128
130
|
|
|
129
131
|
try {
|
|
130
|
-
const response = await getJson(`${baseUrl}/v1/models`, headers, 30000);
|
|
132
|
+
const response = await getJson(`${baseUrl}/v1/models`, headers, settings.requestTimeoutMs || 30000);
|
|
131
133
|
const data = response && Array.isArray(response.data) ? response.data : [];
|
|
132
134
|
const models = data
|
|
133
135
|
.map(function (m) { return m && m.id; })
|
|
@@ -169,7 +171,7 @@ async function chat(settings, messages, options) {
|
|
|
169
171
|
}
|
|
170
172
|
|
|
171
173
|
const startedAt = Date.now();
|
|
172
|
-
const response = await postJson(`${baseUrl}/v1/chat/completions`, headers, body, 180000);
|
|
174
|
+
const response = await postJson(`${baseUrl}/v1/chat/completions`, headers, body, settings.requestTimeoutMs || 180000);
|
|
173
175
|
const totalMs = Date.now() - startedAt;
|
|
174
176
|
|
|
175
177
|
const message = response && response.choices && response.choices[0] && response.choices[0].message;
|
|
@@ -275,8 +277,9 @@ function postStream(urlString, headers, body, timeoutMs, onDelta) {
|
|
|
275
277
|
});
|
|
276
278
|
|
|
277
279
|
req.on("error", reject);
|
|
278
|
-
|
|
279
|
-
|
|
280
|
+
const effectiveTimeoutMs = timeoutMs || 180000;
|
|
281
|
+
req.setTimeout(effectiveTimeoutMs, () => {
|
|
282
|
+
req.destroy(new Error(`Provider request timed out after ${effectiveTimeoutMs}ms — increase the request timeout in Settings → Behavior for slower hardware.`));
|
|
280
283
|
});
|
|
281
284
|
|
|
282
285
|
req.write(payload);
|
|
@@ -306,7 +309,7 @@ async function chatStream(settings, messages, onDelta) {
|
|
|
306
309
|
temperature,
|
|
307
310
|
stream: true,
|
|
308
311
|
stream_options: { include_usage: true }
|
|
309
|
-
}, 180000, onDelta);
|
|
312
|
+
}, settings.requestTimeoutMs || 180000, onDelta);
|
|
310
313
|
|
|
311
314
|
return {
|
|
312
315
|
content: result.content || "",
|
|
@@ -359,7 +362,7 @@ async function probeTools(settings) {
|
|
|
359
362
|
tool_choice: "auto",
|
|
360
363
|
temperature: 0,
|
|
361
364
|
stream: false
|
|
362
|
-
}, 30000);
|
|
365
|
+
}, settings.requestTimeoutMs || 30000);
|
|
363
366
|
|
|
364
367
|
const message = response && response.choices && response.choices[0] && response.choices[0].message;
|
|
365
368
|
const toolCalls = message && Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
package/lib/storage.js
CHANGED
|
@@ -45,9 +45,20 @@ function createStorage(userDir) {
|
|
|
45
45
|
// includes as history with each request. Older turns are dropped
|
|
46
46
|
// client-side and the model is told when that happened.
|
|
47
47
|
historyMaxExchanges: 10,
|
|
48
|
+
// How long to wait for a provider response before giving up. Slow local
|
|
49
|
+
// hardware (e.g. Ollama on a big model with no GPU) can take much longer
|
|
50
|
+
// than cloud providers; users on that hardware raise this in Behavior
|
|
51
|
+
// settings rather than living with a hardcoded ceiling.
|
|
52
|
+
requestTimeoutMs: 180000,
|
|
48
53
|
// Lets the user silence the recurring secrets/size reminder bar after
|
|
49
54
|
// typing an explicit acknowledgement in settings.
|
|
50
55
|
suppressContextWarnings: false,
|
|
56
|
+
// Secret-shaped-value scrubbing (password/token/apiKey-looking fields in
|
|
57
|
+
// node config and debug output) — on by default. Local/private-AI users
|
|
58
|
+
// can turn it off via a separate type-to-confirm gate in Settings; the
|
|
59
|
+
// dedicated Node-RED credentials field is dropped by the frontend
|
|
60
|
+
// regardless of this setting, via a different, always-on mechanism.
|
|
61
|
+
redactionEnabled: true,
|
|
51
62
|
// User-defined intent buttons: array of { label, text }.
|
|
52
63
|
customIntents: [],
|
|
53
64
|
systemPrompt: require("./default-system-prompt")
|
|
@@ -100,12 +111,19 @@ function createStorage(userDir) {
|
|
|
100
111
|
return parsed;
|
|
101
112
|
}
|
|
102
113
|
|
|
103
|
-
// Returns the currently active provider profile (or the first, or a
|
|
114
|
+
// Returns the currently active provider profile (or the first, or a
|
|
115
|
+
// default), with the app-level requestTimeoutMs folded in. Every
|
|
116
|
+
// provider.chat/chatStream/listModels/probeTools call takes this object as
|
|
117
|
+
// its `settings` argument, so merging the timeout in here is what threads
|
|
118
|
+
// it through all of them without touching each call site.
|
|
104
119
|
function getActiveProvider(settings) {
|
|
105
120
|
const list = Array.isArray(settings.providers) ? settings.providers : [];
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
121
|
+
const base = list.length
|
|
122
|
+
? (list.filter(function (p) { return p.id === settings.activeProviderId; })[0] || list[0])
|
|
123
|
+
: defaultProvider();
|
|
124
|
+
const requestTimeoutMs = settings.requestTimeoutMs !== undefined
|
|
125
|
+
? settings.requestTimeoutMs : defaultSettings.requestTimeoutMs;
|
|
126
|
+
return Object.assign({}, base, { requestTimeoutMs: requestTimeoutMs });
|
|
109
127
|
}
|
|
110
128
|
|
|
111
129
|
function init() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@manny-est/node-red-flowpilot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "FlowPilot for Node-RED - an AI-powered development assistant sidebar",
|
|
5
5
|
"main": "flowpilot.js",
|
|
6
6
|
"keywords": [
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"README.md",
|
|
45
45
|
"USER-GUIDE.md",
|
|
46
46
|
"PROJECT-OVERVIEW.md",
|
|
47
|
+
"CHANGELOG.md",
|
|
47
48
|
"LICENSE"
|
|
48
49
|
]
|
|
49
50
|
}
|