@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 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
- var DEBUG_VALUE_MAX_CHARS = 500;
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
- value: truncateForDebug(stringifyDebugValue(redactedValue), DEBUG_VALUE_MAX_CHARS)
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: attachedDebugMessages.slice() });
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.value).appendTo($item);
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
- function saveSettings(callback) {
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
- addMessage("error", "Cannot save: these provider(s) need a Base URL: " + urlNames + ".");
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
- addMessage("error", "Cannot save: every provider needs a name.");
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
- addMessage("error", "Cannot save: provider names must be unique. Duplicate: " + dupes.join(", ") + ".");
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 (SECRET_KEY.test(k) && typeof v === "string" && v.length > SECRET_NAME_MIN_LEN) {
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
- // Size line: selection context + conversation history —
1798
- // whichever of the two apply right now.
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
- if (count === 0 || currentSettings.suppressContextWarnings) {
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\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) is
2280
- // converted in place into the assistant bubble and filled in as chunks
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
- var $msg = $box.find("#fp-pending");
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
- var $msg = $box.find("#fp-pending");
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
- return { summary: summary, typeWarnings: typeWarnings, brokenWires: brokenWires };
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
- "This is sanitized configuration; credentials are redacted.\n\n" +
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
- - Use "wires" to connect nodes: wires is an array (one entry per output port), each entry an array of target node ids. A node with no outputs (e.g. debug) has wires: [].
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
- req.setTimeout(timeoutMs || 180000, () => {
52
- req.destroy(new Error(`Provider request timed out after ${timeoutMs || 180000}ms`));
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
- req.setTimeout(timeoutMs || 30000, () => {
104
- req.destroy(new Error(`Provider request timed out after ${timeoutMs || 30000}ms`));
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
- req.setTimeout(timeoutMs || 180000, () => {
279
- req.destroy(new Error(`Provider request timed out after ${timeoutMs || 180000}ms`));
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 default).
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
- if (!list.length) { return defaultProvider(); }
107
- const found = list.filter(function (p) { return p.id === settings.activeProviderId; })[0];
108
- return found || list[0];
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.2.2",
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
  }