@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 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")
@@ -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
- 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) {
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
- 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); }
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
- 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); }
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
- 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); }
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 (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) {
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
- // Size line: selection context + conversation history —
1783
- // 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.
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
- 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)) {
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\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) is
2265
- // 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
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
- 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;
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: { "Content-Type": "application/json" },
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
- 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;
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: { "Content-Type": "application/json" },
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
- 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 };
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-input-text-color, #333);
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-input-text-color, #333);
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-input-text-color, #333);
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-input-text-color, #333);
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
- "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.1",
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
  }