@manny-est/node-red-flowpilot 0.2.1 → 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 +274 -42
- 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")
|
|
@@ -1036,6 +1099,21 @@
|
|
|
1036
1099
|
// Note: no leading slash. Node-RED serves admin endpoints under a base
|
|
1037
1100
|
// path (httpAdminRoot) that may not be "/". A relative URL respects it.
|
|
1038
1101
|
|
|
1102
|
+
// jQuery's $.ajax (used by ajaxJson) gets the admin-API auth token
|
|
1103
|
+
// attached automatically by Node-RED's editor via $.ajaxSetup, but raw
|
|
1104
|
+
// fetch() calls (used for SSE streaming below) do not. On instances with
|
|
1105
|
+
// adminAuth enabled, an unauthenticated fetch() gets a 401 even though
|
|
1106
|
+
// ajaxJson() calls (preflight, models, settings) succeed.
|
|
1107
|
+
function fetchHeaders() {
|
|
1108
|
+
var headers = { "Content-Type": "application/json" };
|
|
1109
|
+
var tokens = RED.settings.get("auth-tokens");
|
|
1110
|
+
if (tokens && tokens.access_token) {
|
|
1111
|
+
headers.Authorization = "Bearer " + tokens.access_token;
|
|
1112
|
+
headers["Node-RED-API-Version"] = "v2";
|
|
1113
|
+
}
|
|
1114
|
+
return headers;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1039
1117
|
function ajaxJson(method, url, payload, onSuccess, onError) {
|
|
1040
1118
|
$.ajax({
|
|
1041
1119
|
url: url,
|
|
@@ -1103,7 +1181,9 @@
|
|
|
1103
1181
|
el("#fp-high-tokens").val(settings.contextHighTokens || 8000);
|
|
1104
1182
|
el("#fp-history-max").val(settings.historyMaxExchanges !== undefined ? settings.historyMaxExchanges : 10);
|
|
1105
1183
|
el("#fp-streaming-enabled").prop("checked", !!settings.streamingEnabled);
|
|
1184
|
+
el("#fp-request-timeout").val(Math.round((settings.requestTimeoutMs !== undefined ? settings.requestTimeoutMs : 180000) / 1000));
|
|
1106
1185
|
el("#fp-suppress-warnings").prop("checked", !!settings.suppressContextWarnings);
|
|
1186
|
+
el("#fp-redaction-disabled").prop("checked", settings.redactionEnabled === false);
|
|
1107
1187
|
|
|
1108
1188
|
// The dev/test banner is part of the warning set the user can silence
|
|
1109
1189
|
// via the type-to-confirm acknowledgement.
|
|
@@ -1122,6 +1202,24 @@
|
|
|
1122
1202
|
? (ap.providerName + " / " + ap.model)
|
|
1123
1203
|
: ((ap ? ap.providerName : "Provider") + ": model not configured");
|
|
1124
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);
|
|
1125
1223
|
}
|
|
1126
1224
|
|
|
1127
1225
|
// Read the form's provider fields back into the active provider profile.
|
|
@@ -1144,12 +1242,23 @@
|
|
|
1144
1242
|
var typed = (el("#fp-suppress-confirm").val() || "").trim();
|
|
1145
1243
|
var suppress = wantSuppress && typed === "I understand the risk";
|
|
1146
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
|
+
|
|
1147
1253
|
// Fold the form's provider fields back into the active profile first.
|
|
1148
1254
|
captureProviderFields();
|
|
1149
1255
|
|
|
1150
1256
|
var historyMax = Number(el("#fp-history-max").val());
|
|
1151
1257
|
if (!isFinite(historyMax) || historyMax < 0) { historyMax = 10; }
|
|
1152
1258
|
|
|
1259
|
+
var requestTimeoutSec = Number(el("#fp-request-timeout").val());
|
|
1260
|
+
if (!isFinite(requestTimeoutSec) || requestTimeoutSec < 5) { requestTimeoutSec = 180; }
|
|
1261
|
+
|
|
1153
1262
|
return {
|
|
1154
1263
|
providers: providersList(),
|
|
1155
1264
|
activeProviderId: currentSettings.activeProviderId,
|
|
@@ -1158,7 +1267,9 @@
|
|
|
1158
1267
|
contextHighTokens: Number(el("#fp-high-tokens").val() || 8000),
|
|
1159
1268
|
historyMaxExchanges: historyMax,
|
|
1160
1269
|
streamingEnabled: el("#fp-streaming-enabled").prop("checked"),
|
|
1270
|
+
requestTimeoutMs: Math.round(requestTimeoutSec * 1000),
|
|
1161
1271
|
suppressContextWarnings: suppress,
|
|
1272
|
+
redactionEnabled: redactionEnabled,
|
|
1162
1273
|
customIntents: Array.isArray(currentSettings.customIntents)
|
|
1163
1274
|
? currentSettings.customIntents : []
|
|
1164
1275
|
};
|
|
@@ -1290,7 +1401,11 @@
|
|
|
1290
1401
|
});
|
|
1291
1402
|
}
|
|
1292
1403
|
|
|
1293
|
-
|
|
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) {
|
|
1294
1409
|
var payload = collectSettings();
|
|
1295
1410
|
var list = payload.providers || [];
|
|
1296
1411
|
|
|
@@ -1301,7 +1416,9 @@
|
|
|
1301
1416
|
});
|
|
1302
1417
|
if (noUrl.length) {
|
|
1303
1418
|
var urlNames = noUrl.map(function (p) { return p.providerName || "(unnamed)"; }).join(", ");
|
|
1304
|
-
|
|
1419
|
+
var noUrlMsg = "Cannot save: these provider(s) need a Base URL: " + urlNames + ".";
|
|
1420
|
+
addMessage("error", noUrlMsg);
|
|
1421
|
+
if (announce) { showSaveStatus(noUrlMsg, true); }
|
|
1305
1422
|
showSettings();
|
|
1306
1423
|
return;
|
|
1307
1424
|
}
|
|
@@ -1314,7 +1431,9 @@
|
|
|
1314
1431
|
return !p.providerName || !String(p.providerName).trim();
|
|
1315
1432
|
});
|
|
1316
1433
|
if (blankName.length) {
|
|
1317
|
-
|
|
1434
|
+
var blankNameMsg = "Cannot save: every provider needs a name.";
|
|
1435
|
+
addMessage("error", blankNameMsg);
|
|
1436
|
+
if (announce) { showSaveStatus(blankNameMsg, true); }
|
|
1318
1437
|
showSettings();
|
|
1319
1438
|
return;
|
|
1320
1439
|
}
|
|
@@ -1328,18 +1447,29 @@
|
|
|
1328
1447
|
seen[key] = true;
|
|
1329
1448
|
});
|
|
1330
1449
|
if (dupes.length) {
|
|
1331
|
-
|
|
1450
|
+
var dupesMsg = "Cannot save: provider names must be unique. Duplicate: " + dupes.join(", ") + ".";
|
|
1451
|
+
addMessage("error", dupesMsg);
|
|
1452
|
+
if (announce) { showSaveStatus(dupesMsg, true); }
|
|
1332
1453
|
showSettings();
|
|
1333
1454
|
return;
|
|
1334
1455
|
}
|
|
1335
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
|
+
|
|
1336
1464
|
ajaxJson("POST", "flowpilot/settings", payload, function (data) {
|
|
1337
1465
|
fillSettings(data);
|
|
1338
1466
|
addMessage("assistant", "Settings saved.");
|
|
1467
|
+
if (announce) { showSaveStatus("Settings saved."); }
|
|
1339
1468
|
updateSelectionStatus();
|
|
1340
1469
|
if (callback) { callback(); }
|
|
1341
1470
|
}, function (msg) {
|
|
1342
1471
|
addMessage("error", "Unable to save FlowPilot settings: " + msg);
|
|
1472
|
+
if (announce) { showSaveStatus("Unable to save: " + msg, true); }
|
|
1343
1473
|
});
|
|
1344
1474
|
}
|
|
1345
1475
|
|
|
@@ -1376,7 +1506,8 @@
|
|
|
1376
1506
|
// way to reason about (or fix) auth configuration while protecting
|
|
1377
1507
|
// nothing. Real credential VALUES are already excluded entirely via
|
|
1378
1508
|
// INTERNAL_FIELDS.credentials.
|
|
1379
|
-
if (
|
|
1509
|
+
if (currentSettings.redactionEnabled !== false &&
|
|
1510
|
+
SECRET_KEY.test(k) && typeof v === "string" && v.length > SECRET_NAME_MIN_LEN) {
|
|
1380
1511
|
out[k] = "[redacted: secret field, " + v.length + " chars]";
|
|
1381
1512
|
return;
|
|
1382
1513
|
}
|
|
@@ -1779,13 +1910,16 @@
|
|
|
1779
1910
|
.addClass("fp-has-selection");
|
|
1780
1911
|
}
|
|
1781
1912
|
|
|
1782
|
-
|
|
1783
|
-
|
|
1913
|
+
el("#fp-preview-nodes").toggleClass("fp-hidden", count === 0);
|
|
1914
|
+
|
|
1915
|
+
// Size line: selection context + attached debug messages +
|
|
1916
|
+
// conversation history.
|
|
1784
1917
|
var contextTokens = liveCount > 0 ? estimateTokens(collectSelectionContext())
|
|
1785
1918
|
: pinnedContext ? estimateTokens(pinnedContext) : 0;
|
|
1919
|
+
var debugTokens = attachedDebugMessages.length ? estimateTokens(buildDebugMessagesForSend()) : 0;
|
|
1786
1920
|
var historyPayload = buildHistoryPayload();
|
|
1787
1921
|
var historyTokens = estimateTokens(historyPayload.messages);
|
|
1788
|
-
var tokens = contextTokens + historyTokens;
|
|
1922
|
+
var tokens = contextTokens + debugTokens + historyTokens;
|
|
1789
1923
|
|
|
1790
1924
|
if (tokens === 0) {
|
|
1791
1925
|
$size.text("").addClass("fp-hidden");
|
|
@@ -1795,6 +1929,7 @@
|
|
|
1795
1929
|
|
|
1796
1930
|
var parts = [];
|
|
1797
1931
|
if (contextTokens) { parts.push("context ~" + contextTokens.toLocaleString()); }
|
|
1932
|
+
if (debugTokens) { parts.push("debug ~" + debugTokens.toLocaleString()); }
|
|
1798
1933
|
if (historyTokens) {
|
|
1799
1934
|
parts.push("history ~" + historyTokens.toLocaleString() +
|
|
1800
1935
|
(historyPayload.truncated ? " (earlier messages omitted)" : ""));
|
|
@@ -1816,11 +1951,17 @@
|
|
|
1816
1951
|
}
|
|
1817
1952
|
|
|
1818
1953
|
// Secrets reminder (suppressible) — only relevant when a selection is
|
|
1819
|
-
// attached as context.
|
|
1820
|
-
|
|
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)) {
|
|
1821
1959
|
$secrets.addClass("fp-hidden");
|
|
1822
1960
|
} else {
|
|
1823
|
-
$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.");
|
|
1824
1965
|
}
|
|
1825
1966
|
}
|
|
1826
1967
|
|
|
@@ -1892,7 +2033,8 @@
|
|
|
1892
2033
|
"- `/history` — open the Flight log (past conversations)\n" +
|
|
1893
2034
|
"- `/settings` — open the Hangar (providers, behavior, safety)\n\n" +
|
|
1894
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" +
|
|
1895
|
-
"- `/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" +
|
|
1896
2038
|
"### Also worth knowing\n\n" +
|
|
1897
2039
|
"- Action chips (paper-plane buttons) offer a one-click follow-up — review and send, nothing fires automatically.\n" +
|
|
1898
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" +
|
|
@@ -1904,6 +2046,17 @@
|
|
|
1904
2046
|
// with a small, fast-to-generate flow.
|
|
1905
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.";
|
|
1906
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
|
+
|
|
1907
2060
|
var demoTypeTimer = null;
|
|
1908
2061
|
|
|
1909
2062
|
// Streams `text` into the prompt box a few characters at a time, as if
|
|
@@ -1987,6 +2140,10 @@
|
|
|
1987
2140
|
el("#fp-send").addClass("fp-send-breathe");
|
|
1988
2141
|
});
|
|
1989
2142
|
break;
|
|
2143
|
+
case "/feedback":
|
|
2144
|
+
addMessage("assistant", FEEDBACK_TEXT);
|
|
2145
|
+
if ($promptBox.length) { $promptBox.val(""); }
|
|
2146
|
+
break;
|
|
1990
2147
|
default:
|
|
1991
2148
|
addMessage("assistant", "Unrecognized command `" + command + "`. Type `/help` for the full list.");
|
|
1992
2149
|
if ($promptBox.length) { $promptBox.val(""); }
|
|
@@ -2261,21 +2418,24 @@
|
|
|
2261
2418
|
|
|
2262
2419
|
// Streaming chat. Posts with stream:true and reads the
|
|
2263
2420
|
// SSE response body incrementally via fetch's ReadableStream. The
|
|
2264
|
-
// bouncing "pending" indicator (already in the DOM from showPending)
|
|
2265
|
-
//
|
|
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
|
|
2266
2424
|
// arrive — generate/modify/document never call this; their JSON envelope
|
|
2267
2425
|
// can't be rendered until complete.
|
|
2268
2426
|
function sendChatStream(payload) {
|
|
2269
2427
|
var $box = el("#fp-messages");
|
|
2270
2428
|
if (!$box.length) { return; }
|
|
2271
2429
|
|
|
2272
|
-
|
|
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;
|
|
2273
2438
|
var $text = null;
|
|
2274
|
-
if ($msg.length) {
|
|
2275
|
-
$msg.removeAttr("id").empty();
|
|
2276
|
-
$("<div>").addClass("fp-label").text("FLOWPILOT").appendTo($msg);
|
|
2277
|
-
$text = $("<div>").addClass("fp-md").appendTo($msg);
|
|
2278
|
-
}
|
|
2279
2439
|
|
|
2280
2440
|
var fullText = "";
|
|
2281
2441
|
var finalData = null;
|
|
@@ -2348,7 +2508,7 @@
|
|
|
2348
2508
|
|
|
2349
2509
|
fetch("flowpilot/chat", {
|
|
2350
2510
|
method: "POST",
|
|
2351
|
-
headers:
|
|
2511
|
+
headers: fetchHeaders(),
|
|
2352
2512
|
body: JSON.stringify(payload)
|
|
2353
2513
|
}).then(function (resp) {
|
|
2354
2514
|
if (!resp.ok) {
|
|
@@ -2659,13 +2819,10 @@
|
|
|
2659
2819
|
var $box = el("#fp-messages");
|
|
2660
2820
|
if (!$box.length) { return; }
|
|
2661
2821
|
|
|
2662
|
-
|
|
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;
|
|
2663
2825
|
var $text = null;
|
|
2664
|
-
if ($msg.length) {
|
|
2665
|
-
$msg.removeAttr("id").empty();
|
|
2666
|
-
$("<div>").addClass("fp-label").text("FLOWPILOT").appendTo($msg);
|
|
2667
|
-
$text = $("<div>").addClass("fp-md").appendTo($msg);
|
|
2668
|
-
}
|
|
2669
2826
|
|
|
2670
2827
|
var extractor = createExplanationExtractor();
|
|
2671
2828
|
var finalData = null;
|
|
@@ -2728,7 +2885,7 @@
|
|
|
2728
2885
|
|
|
2729
2886
|
fetch("flowpilot/" + endpoint, {
|
|
2730
2887
|
method: "POST",
|
|
2731
|
-
headers:
|
|
2888
|
+
headers: fetchHeaders(),
|
|
2732
2889
|
body: JSON.stringify(payload)
|
|
2733
2890
|
}).then(function (resp) {
|
|
2734
2891
|
if (!resp.ok) {
|
|
@@ -3953,6 +4110,7 @@
|
|
|
3953
4110
|
var summary = [];
|
|
3954
4111
|
var typeWarnings = [];
|
|
3955
4112
|
var brokenWires = [];
|
|
4113
|
+
var realWireCount = 0;
|
|
3956
4114
|
|
|
3957
4115
|
nodes.forEach(function (n) {
|
|
3958
4116
|
if (!n || !n.id || !n.type) { return; }
|
|
@@ -3969,12 +4127,22 @@
|
|
|
3969
4127
|
(Array.isArray(port) ? port : []).forEach(function (targetId) {
|
|
3970
4128
|
if (!ids[targetId]) {
|
|
3971
4129
|
brokenWires.push({ from: n.id, type: n.type, target: targetId });
|
|
4130
|
+
} else {
|
|
4131
|
+
realWireCount++;
|
|
3972
4132
|
}
|
|
3973
4133
|
});
|
|
3974
4134
|
});
|
|
3975
4135
|
});
|
|
3976
4136
|
|
|
3977
|
-
|
|
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 };
|
|
3978
4146
|
}
|
|
3979
4147
|
|
|
3980
4148
|
// The generation prompt deliberately omits x/y ("the editor assigns those
|
|
@@ -4284,6 +4452,14 @@
|
|
|
4284
4452
|
$list.append($li);
|
|
4285
4453
|
});
|
|
4286
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
|
+
|
|
4287
4463
|
if (v.typeWarnings.length) {
|
|
4288
4464
|
var $warn = $("<div>").addClass("fp-warning fp-review-warning").appendTo($summaryPanel);
|
|
4289
4465
|
$("<strong>").text("Type warnings — review before adding:").appendTo($warn);
|
|
@@ -4408,6 +4584,7 @@
|
|
|
4408
4584
|
' </div>' +
|
|
4409
4585
|
' <div class="fp-status-strip">' +
|
|
4410
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>' +
|
|
4411
4588
|
' <span id="fp-size-status" class="fp-size-status fp-hidden"></span>' +
|
|
4412
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>' +
|
|
4413
4590
|
' <span id="fp-debug-status" class="fp-debug-status fp-hidden"></span>' +
|
|
@@ -4420,14 +4597,14 @@
|
|
|
4420
4597
|
' </div>' +
|
|
4421
4598
|
|
|
4422
4599
|
' <div id="fp-settings-panel" class="fp-panel fp-hidden">' +
|
|
4423
|
-
' <div class="fp-warning">' +
|
|
4424
|
-
' <strong>Provider settings.</strong><br>' +
|
|
4425
|
-
' Stored locally under the Node-RED user directory in <code>flowpilot/settings.json</code>.' +
|
|
4426
|
-
' </div>' +
|
|
4427
4600
|
' <div class="fp-form">' +
|
|
4428
4601
|
|
|
4429
4602
|
' <details class="fp-settings-group" open>' +
|
|
4430
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>' +
|
|
4431
4608
|
' <label>Active provider</label>' +
|
|
4432
4609
|
' <select id="fp-provider-select"></select>' +
|
|
4433
4610
|
' <div class="fp-provider-actions">' +
|
|
@@ -4446,6 +4623,9 @@
|
|
|
4446
4623
|
' <div id="fp-models-hint" class="fp-consent-hint fp-hidden"></div>' +
|
|
4447
4624
|
' <label>Temperature</label>' +
|
|
4448
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>' +
|
|
4449
4629
|
' <div class="fp-settings-actions">' +
|
|
4450
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>' +
|
|
4451
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>' +
|
|
@@ -4474,6 +4654,13 @@
|
|
|
4474
4654
|
' <div class="fp-consent-hint">Only applies to chat replies — Generate, ' +
|
|
4475
4655
|
' Document, and Modify always wait for the full response.</div>' +
|
|
4476
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
|
+
|
|
4477
4664
|
' <div class="fp-settings-section">Custom intent buttons</div>' +
|
|
4478
4665
|
' <div class="fp-consent-hint">Add your own one-click prompt buttons. ' +
|
|
4479
4666
|
' They appear next to the built-in ones above the prompt.</div>' +
|
|
@@ -4504,10 +4691,25 @@
|
|
|
4504
4691
|
' <strong>I understand the risk</strong> below. ' +
|
|
4505
4692
|
' Anything you send may leave this Node-RED instance.</div>' +
|
|
4506
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">' +
|
|
4507
4708
|
' </details>' +
|
|
4508
4709
|
|
|
4509
4710
|
' <div class="fp-settings-actions">' +
|
|
4510
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>' +
|
|
4511
4713
|
' </div>' +
|
|
4512
4714
|
' </div>' +
|
|
4513
4715
|
' </div>' +
|
|
@@ -4545,6 +4747,10 @@
|
|
|
4545
4747
|
content.find("#fp-clear-chat").on("click", clearChat);
|
|
4546
4748
|
content.find("#fp-recall").on("click", recallSearch);
|
|
4547
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
|
+
});
|
|
4548
4754
|
|
|
4549
4755
|
// Subscribe once to the same RED.comms "debug" topic the built-in
|
|
4550
4756
|
// Debug sidebar uses, to buffer recent messages locally for
|
|
@@ -4599,7 +4805,7 @@
|
|
|
4599
4805
|
});
|
|
4600
4806
|
|
|
4601
4807
|
content.find("#fp-save-settings").on("click", function () {
|
|
4602
|
-
saveSettings();
|
|
4808
|
+
saveSettings(null, true);
|
|
4603
4809
|
});
|
|
4604
4810
|
content.find("#fp-add-intent").on("click", function () {
|
|
4605
4811
|
addCustomIntent();
|
|
@@ -4934,7 +5140,7 @@
|
|
|
4934
5140
|
border-radius: 4px;
|
|
4935
5141
|
border: 1px solid var(--red-ui-form-input-border-color, #ccc);
|
|
4936
5142
|
background: var(--red-ui-form-input-background, #fff);
|
|
4937
|
-
color: var(--red-ui-form-
|
|
5143
|
+
color: var(--red-ui-form-text-color, #333);
|
|
4938
5144
|
font-family: inherit;
|
|
4939
5145
|
font-size: 13px;
|
|
4940
5146
|
}
|
|
@@ -5280,7 +5486,7 @@
|
|
|
5280
5486
|
border-radius: 5px;
|
|
5281
5487
|
border: 1px solid var(--red-ui-form-input-border-color, #ccc);
|
|
5282
5488
|
background: var(--red-ui-form-input-background, #fff);
|
|
5283
|
-
color: var(--red-ui-form-
|
|
5489
|
+
color: var(--red-ui-form-text-color, #333);
|
|
5284
5490
|
}
|
|
5285
5491
|
|
|
5286
5492
|
.fp-provider-actions {
|
|
@@ -5387,6 +5593,12 @@
|
|
|
5387
5593
|
flex: 0 0 auto;
|
|
5388
5594
|
}
|
|
5389
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
|
+
|
|
5390
5602
|
.fp-debug-status {
|
|
5391
5603
|
font-size: 12px;
|
|
5392
5604
|
color: var(--red-ui-secondary-text-color, #888);
|
|
@@ -5404,6 +5616,16 @@
|
|
|
5404
5616
|
text-decoration: underline;
|
|
5405
5617
|
}
|
|
5406
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
|
+
|
|
5407
5629
|
.fp-settings-section {
|
|
5408
5630
|
margin-top: 18px;
|
|
5409
5631
|
padding-top: 12px;
|
|
@@ -5500,7 +5722,7 @@
|
|
|
5500
5722
|
border-radius: 6px;
|
|
5501
5723
|
border: 1px solid var(--red-ui-form-input-border-color, #ccc);
|
|
5502
5724
|
background: var(--red-ui-form-input-background, #fff);
|
|
5503
|
-
color: var(--red-ui-form-
|
|
5725
|
+
color: var(--red-ui-form-text-color, #333);
|
|
5504
5726
|
font-family: inherit;
|
|
5505
5727
|
font-size: 14px;
|
|
5506
5728
|
box-shadow: 0 0 0 1px rgba(255, 152, 0, 0.35);
|
|
@@ -5573,6 +5795,16 @@
|
|
|
5573
5795
|
gap: 8px;
|
|
5574
5796
|
}
|
|
5575
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
|
+
|
|
5576
5808
|
.fp-form {
|
|
5577
5809
|
flex: 1;
|
|
5578
5810
|
overflow-y: auto;
|
|
@@ -5592,7 +5824,7 @@
|
|
|
5592
5824
|
box-sizing: border-box;
|
|
5593
5825
|
border: 1px solid var(--red-ui-form-input-border-color, #ccc);
|
|
5594
5826
|
background: var(--red-ui-form-input-background, #fff);
|
|
5595
|
-
color: var(--red-ui-form-
|
|
5827
|
+
color: var(--red-ui-form-text-color, #333);
|
|
5596
5828
|
border-radius: 5px;
|
|
5597
5829
|
padding: 8px;
|
|
5598
5830
|
}
|
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
|
}
|