@manny-est/node-red-flowpilot 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/core/modes.js CHANGED
@@ -144,62 +144,131 @@
144
144
  if (isChat) { pushHistory("user", prompt + note); }
145
145
  if (!promptOverride) { $promptBox.val(""); }
146
146
 
147
- var ap = activeProvider();
148
- var isAgentLoop = isChat && ap && ap.supportsTools;
149
-
150
- setBusy(true);
151
- showPending(isAgentLoop);
152
- var payload = {
153
- prompt: prompt,
154
- context: context,
155
- history: historyPayload.messages,
156
- historyTruncated: historyPayload.truncated,
157
- conversationId: conversationId
158
- };
159
-
160
- function handleSendResult(data) {
161
- hidePending();
162
- var message = data.message || JSON.stringify(data, null, 2);
163
- // Test Provider also reports tool-calling support, used by
164
- // the agentic path.
165
- if (data.capability && data.capability.label) {
166
- message += "\n\n" + data.capability.label;
147
+ function dispatch() {
148
+ var ap = activeProvider();
149
+ var isAgentLoop = isChat && ap && ap.supportsTools;
150
+
151
+ setBusy(true);
152
+ showPending(isAgentLoop);
153
+ var payload = {
154
+ prompt: prompt,
155
+ context: context,
156
+ history: historyPayload.messages,
157
+ historyTruncated: historyPayload.truncated,
158
+ conversationId: conversationId
159
+ };
160
+
161
+ function handleSendResult(data) {
162
+ hidePending();
163
+ // Render a collapsed thinking block for non-streaming reasoning models
164
+ // (the streaming path handles this live in sendChatStream instead).
165
+ if (data.reasoningContent) {
166
+ var $box = el("#fp-messages");
167
+ var approxTokens = Math.round(data.reasoningContent.length / 4);
168
+ var $thinking = $("<details>").addClass("fp-thinking");
169
+ var $summary = $("<summary>").appendTo($thinking);
170
+ $("<span>").text("Thinking").appendTo($summary);
171
+ $("<span>").addClass("fp-thinking-tokens").text(approxTokens + " tokens").appendTo($summary);
172
+ $("<div>").addClass("fp-thinking-body").text(data.reasoningContent).appendTo($thinking);
173
+ $box.append($thinking);
174
+ }
175
+ var message = data.message || JSON.stringify(data, null, 2);
176
+ // Test Provider also reports tool-calling support, used by
177
+ // the agentic path. Mirror the probe results into currentSettings
178
+ // so the auto-preflight condition (probedModel !== model) has a
179
+ // baseline to compare against without requiring a page reload.
180
+ if (data.capability && data.capability.label) {
181
+ message += "\n\n" + data.capability.label;
182
+ }
183
+ if (endpoint === "test" && data.capability && data.capability.probedModel) {
184
+ var testAp = activeProvider();
185
+ if (testAp && currentSettings && Array.isArray(currentSettings.providers)) {
186
+ currentSettings.providers = currentSettings.providers.map(function(p) {
187
+ return p.id === testAp.id ? Object.assign({}, p, {
188
+ supportsTools: data.capability.supportsTools,
189
+ isReasoningModel: data.capability.isReasoningModel,
190
+ probedModel: data.capability.probedModel
191
+ }) : p;
192
+ });
193
+ }
194
+ }
195
+ if (endpoint === "test") {
196
+ message += "\n\nAll set — try `/help` for the full briefing and shortcut list.";
197
+ }
198
+ addMessage("assistant", message);
199
+ if (isChat) {
200
+ pushHistory("assistant", data.message || "");
201
+ renderActionChip(data.suggestedAction);
202
+ renderClarifyingQuestion(data.questionOptions);
203
+ }
204
+ setBusy(false);
205
+ updateSelectionStatus();
167
206
  }
168
- if (endpoint === "test") {
169
- message += "\n\nAll set — try `/help` for the full briefing and shortcut list.";
207
+
208
+ function handleSendError(msg) {
209
+ hidePending();
210
+ if (isChat) { popDanglingUserHistory(); }
211
+ addMessage("error", msg);
212
+ setBusy(false);
170
213
  }
171
- addMessage("assistant", message);
172
- if (isChat) {
173
- pushHistory("assistant", data.message || "");
174
- renderActionChip(data.suggestedAction);
175
- renderClarifyingQuestion(data.questionOptions);
214
+
215
+ // When the active provider supports tool/function calling,
216
+ // the chat turn is offered the Tier-1 read tools and run through the
217
+ // bounded agent loop instead of a single request.
218
+ if (isAgentLoop) {
219
+ runAgentChat(payload, handleSendResult, handleSendError);
220
+ return;
176
221
  }
177
- setBusy(false);
178
- updateSelectionStatus();
179
- }
180
222
 
181
- function handleSendError(msg) {
182
- hidePending();
183
- if (isChat) { popDanglingUserHistory(); }
184
- addMessage("error", msg);
185
- setBusy(false);
186
- }
223
+ if (isChat && currentSettings.streamingEnabled) {
224
+ payload.stream = true;
225
+ sendChatStream(payload);
226
+ return;
227
+ }
187
228
 
188
- // When the active provider supports tool/function calling,
189
- // the chat turn is offered the Tier-1 read tools and run through the
190
- // bounded agent loop instead of a single request.
191
- if (isAgentLoop) {
192
- runAgentChat(payload, handleSendResult, handleSendError);
193
- return;
229
+ ajaxJson("POST", "flowpilot/" + endpoint, payload, handleSendResult, handleSendError);
194
230
  }
195
231
 
196
- if (isChat && currentSettings.streamingEnabled) {
197
- payload.stream = true;
198
- sendChatStream(payload);
232
+ // Silent preflight: if the model changed since the last probe, save and
233
+ // re-probe before routing — stale supportsTools silently misroutes chat
234
+ // (agent-loop path vs streaming/non-streaming).
235
+ // Compare against the LIVE DOM value so unsaved edits trigger correctly;
236
+ // save first so the backend probes the right model.
237
+ var ap = activeProvider();
238
+ var liveModel = (el("#fp-model").length ? el("#fp-model").val() : null) || (ap && ap.model) || "";
239
+ if (isChat && ap && ap.probedModel && liveModel && liveModel !== ap.probedModel) {
240
+ setBusy(true);
241
+ showPending(false);
242
+ setAgentNarration("Pre-flight…");
243
+ saveSettings(function() {
244
+ var ap2 = activeProvider();
245
+ ajaxJson("POST", "flowpilot/probe", {}, function(result) {
246
+ if (currentSettings && Array.isArray(currentSettings.providers)) {
247
+ var targetId = (ap2 || ap).id;
248
+ currentSettings.providers = currentSettings.providers.map(function(p) {
249
+ return p.id === targetId ? Object.assign({}, p, {
250
+ supportsTools: result.supportsTools,
251
+ isReasoningModel: result.isReasoningModel,
252
+ probedModel: result.probedModel
253
+ }) : p;
254
+ });
255
+ }
256
+ hidePending();
257
+ var caps = [];
258
+ if (result.supportsTools) { caps.push("Tools ✓"); } else { caps.push("Tools ✗"); }
259
+ if (result.isReasoningModel) { caps.push("Reasoning ✓"); }
260
+ addMessage("notice", "Pre-flight: " + (result.probedModel || liveModel) + " · " + caps.join(" · "));
261
+ dispatch();
262
+ }, function() {
263
+ hidePending();
264
+ addMessage("notice", "Pre-flight failed — continuing with cached capabilities.");
265
+ dispatch();
266
+ });
267
+ });
199
268
  return;
200
269
  }
201
270
 
202
- ajaxJson("POST", "flowpilot/" + endpoint, payload, handleSendResult, handleSendError);
271
+ dispatch();
203
272
  }
204
273
 
205
274
  // ---------------------------------------------------------------------
@@ -348,25 +417,53 @@
348
417
  // wait until real content starts arriving.
349
418
  var $msg = null;
350
419
  var $text = null;
420
+ var _chatRec = null;
421
+
422
+ // Reasoning block (shown for reasoning models that emit reasoning_content).
423
+ var $thinking = null;
424
+ var $thinkingBody = null;
425
+ var $thinkingTokens = null;
426
+ var reasoningBuf = "";
351
427
 
352
428
  var fullText = "";
353
429
  var finalData = null;
354
430
 
431
+ function ensureThinkingBlock() {
432
+ if ($thinking) { return; }
433
+ hidePending();
434
+ $thinking = $("<details>").addClass("fp-thinking").attr("open", "");
435
+ var $summary = $("<summary>").appendTo($thinking);
436
+ $("<span>").text("Thinking").appendTo($summary);
437
+ $thinkingTokens = $("<span>").addClass("fp-thinking-tokens").appendTo($summary);
438
+ $thinkingBody = $("<div>").addClass("fp-thinking-body").appendTo($thinking);
439
+ $box.append($thinking);
440
+ scrollMessagesToBottom();
441
+ }
442
+
355
443
  function ensureBubble() {
356
444
  if ($text) { return; }
357
445
  hidePending();
358
- addMessage("assistant", "");
446
+ // Collapse the thinking block the moment real content starts flowing.
447
+ if ($thinking) { $thinking.prop("open", false); }
448
+ _chatRec = addMessage("assistant", "");
359
449
  $msg = $box.find(".fp-message").last();
360
450
  $text = $msg.find("div").last();
361
451
  }
362
452
 
363
453
  function finish() {
364
454
  hidePending();
455
+ // Stamp approximate token count on the thinking block once we're done.
456
+ if ($thinking && reasoningBuf) {
457
+ var approxTokens = Math.round(reasoningBuf.length / 4);
458
+ $thinkingTokens.text(approxTokens + " tokens");
459
+ }
365
460
  if (!fullText) {
366
461
  if ($msg && $msg.length) { $msg.remove(); }
462
+ if (_chatRec) { messageRecords.splice(messageRecords.indexOf(_chatRec), 1); _chatRec = null; }
367
463
  popDanglingUserHistory();
368
464
  addMessage("error", "No response received from the provider.");
369
465
  } else {
466
+ if (_chatRec) { _chatRec.text = fullText; _chatRec.streamingComplete = true; }
370
467
  pushHistory("assistant", fullText);
371
468
  if (finalData) {
372
469
  renderActionChip(finalData.suggestedAction);
@@ -392,14 +489,10 @@
392
489
  }
393
490
 
394
491
  // Shared SSE-line parser: handles `data: {"delta":"..."}` /
395
- // `data: {"final":{...}}` / `data: {"error":"..."}` / `data: [DONE]`
396
- // lines, appending deltas to fullText and rendering them. Used by
397
- // both the streaming pump() loop (called per-chunk with the trailing
398
- // partial line held back) and the non-getReader fallback (#12,
399
- // called once with the full body split into lines), so neither path
400
- // can drift or show raw SSE text. The backend withholds any trailing
401
- // <<<FLOWPILOT_DATA>>> block from `delta`s entirely and relays its
402
- // parsed suggestedAction/questionOptions as a single `final` event.
492
+ // `data: {"reasoningDelta":"..."}` / `data: {"final":{...}}` /
493
+ // `data: {"error":"..."}` / `data: [DONE]` lines. Used by both the
494
+ // streaming pump() loop and the non-getReader fallback so neither path
495
+ // can drift or show raw SSE text.
403
496
  function processSseLines(lines) {
404
497
  lines.forEach(function (line) {
405
498
  line = line.trim();
@@ -409,10 +502,17 @@
409
502
  var evt;
410
503
  try { evt = JSON.parse(dataStr); } catch (e) { return; }
411
504
  if (evt.error) { throw new Error(evt.error); }
412
- if (evt.delta) {
505
+ if (evt.reasoningDelta) {
506
+ reasoningBuf += evt.reasoningDelta;
507
+ ensureThinkingBlock();
508
+ $thinkingBody.text(reasoningBuf);
509
+ $thinkingBody[0].scrollTop = $thinkingBody[0].scrollHeight;
510
+ scrollMessagesToBottom();
511
+ } else if (evt.delta) {
413
512
  fullText += evt.delta;
414
513
  ensureBubble();
415
514
  $text.html(renderMarkdown(fullText));
515
+ if (_chatRec) { _chatRec.text = fullText; }
416
516
  scrollMessagesToBottom();
417
517
  } else if (evt.final) {
418
518
  finalData = evt.final;
@@ -580,6 +680,7 @@
580
680
  }
581
681
 
582
682
  $box.append($row);
683
+ addRecord("chip", { chipType: "suggestedAction", suggestedAction: suggestedAction });
583
684
  scrollMessagesToBottom();
584
685
  }
585
686
 
@@ -667,6 +768,7 @@
667
768
  .appendTo($row);
668
769
 
669
770
  $box.append($row).append($otherRow);
771
+ addRecord("question", { options: options });
670
772
  scrollMessagesToBottom();
671
773
  }
672
774
 
@@ -1304,6 +1406,7 @@
1304
1406
  .appendTo($row);
1305
1407
 
1306
1408
  $box.append($row);
1409
+ addRecord("question", { options: ["Continue → AI review", "Stop loop"], loopCheckpoint: true });
1307
1410
  scrollMessagesToBottom();
1308
1411
  }
1309
1412
 
@@ -1334,7 +1437,7 @@
1334
1437
  // handleBuildReviewResult's addModifyReview callback (rather than left
1335
1438
  // as an inline closure) so the EXACT same logic can run whether the
1336
1439
  // Apply click happened in the main window or was relayed from the
1337
- // pop-out — see the "applyBuildFix" handler in initMainWindow.
1440
+ // pop-out — see the applyByRecordId handler in initMainWindow (Phase 10 0B).
1338
1441
  function applyBuildLoopFix(nodeDiffs, removeNodesArg, idMap, capReached) {
1339
1442
  applyModifications(nodeDiffs, removeNodesArg, null, idMap);
1340
1443
  if (!activeBuildLoop) { return; }
@@ -1403,10 +1506,30 @@
1403
1506
  .appendTo($actions);
1404
1507
  }
1405
1508
 
1509
+ // Replace any prior buildStep snapshot — only the latest waypoint matters.
1510
+ messageRecords = messageRecords.filter(function (r) { return r.kind !== "buildStep"; });
1511
+ addRecord("buildStep", {
1512
+ waypoint: loop.waypoint,
1513
+ iteration: loop.iteration,
1514
+ maxIterations: loop.maxIterations,
1515
+ goal: loop.goal,
1516
+ nodeIds: Array.isArray(loop.nodeIds) ? loop.nodeIds.slice() : []
1517
+ });
1518
+
1406
1519
  $box.append($msg);
1407
1520
  scrollMessagesToBottom();
1408
1521
  }
1409
1522
 
1523
+ function rerenderBuildStepRecord(rec) {
1524
+ renderLoopStepper({
1525
+ waypoint: rec.waypoint || "done",
1526
+ iteration: rec.iteration || 1,
1527
+ maxIterations: rec.maxIterations || 5,
1528
+ goal: rec.goal || "",
1529
+ nodeIds: Array.isArray(rec.nodeIds) ? rec.nodeIds : []
1530
+ });
1531
+ }
1532
+
1410
1533
  // Called once the first build proposal is actually imported (not on a
1411
1534
  // clarifying question or prose-only reply — see handleBuildResult). goal
1412
1535
  // is the original prompt text, kept verbatim so the review step can
@@ -194,7 +194,66 @@ async function chat(settings, messages, options) {
194
194
  // `data: [DONE]`). Calls onDelta(text) for each content fragment as it
195
195
  // arrives and resolves with { content } containing the full concatenated
196
196
  // text once the stream ends. Used for chat streaming only.
197
- function postStream(urlString, headers, body, timeoutMs, onDelta) {
197
+ // Splits a streaming content string on <think>...</think> tags so that
198
+ // reasoning text is routed to onReasoning and the actual response text to
199
+ // onContent. Handles tags that arrive split across multiple chunks via a
200
+ // small lookahead buffer. Handles both:
201
+ // - SGLang/Nemotron style: delta.reasoning_content (separate field)
202
+ // - llama.cpp/LocalAI style: <think>...</think> embedded in delta.content
203
+ // The two paths converge at onReasoningDelta in postStream — callers see
204
+ // one uniform reasoning callback regardless of provider format.
205
+ function createThinkTagSplitter(onContent, onReasoning) {
206
+ let phase = "seek"; // "seek" | "think" | "content"
207
+ let buf = "";
208
+ const OPEN = "<think>";
209
+ const CLOSE = "</think>";
210
+
211
+ function push(text) {
212
+ buf += text;
213
+ while (buf.length > 0) {
214
+ if (phase === "seek") {
215
+ const idx = buf.indexOf(OPEN);
216
+ if (idx === -1) {
217
+ // No opening tag visible — emit everything except the last few bytes
218
+ // that might be a partial tag, buffer the rest.
219
+ const safe = buf.length > OPEN.length - 1 ? buf.length - (OPEN.length - 1) : 0;
220
+ if (safe > 0) { onContent(buf.slice(0, safe)); buf = buf.slice(safe); }
221
+ break;
222
+ }
223
+ if (idx > 0) { onContent(buf.slice(0, idx)); }
224
+ buf = buf.slice(idx + OPEN.length);
225
+ phase = "think";
226
+ } else if (phase === "think") {
227
+ const idx = buf.indexOf(CLOSE);
228
+ if (idx === -1) {
229
+ const safe = buf.length > CLOSE.length - 1 ? buf.length - (CLOSE.length - 1) : 0;
230
+ if (safe > 0) { onReasoning(buf.slice(0, safe)); buf = buf.slice(safe); }
231
+ break;
232
+ }
233
+ if (idx > 0) { onReasoning(buf.slice(0, idx)); }
234
+ buf = buf.slice(idx + CLOSE.length);
235
+ // Skip whitespace/newline immediately after </think>
236
+ const trimmed = buf.replace(/^\s+/, "");
237
+ buf = trimmed;
238
+ phase = "content";
239
+ } else {
240
+ onContent(buf);
241
+ buf = "";
242
+ break;
243
+ }
244
+ }
245
+ }
246
+
247
+ function finish() {
248
+ if (!buf) { return; }
249
+ if (phase === "think") { onReasoning(buf); } else { onContent(buf); }
250
+ buf = "";
251
+ }
252
+
253
+ return { push, finish };
254
+ }
255
+
256
+ function postStream(urlString, headers, body, timeoutMs, onDelta, onReasoningDelta) {
198
257
  return new Promise((resolve, reject) => {
199
258
  let url;
200
259
  try {
@@ -233,12 +292,22 @@ function postStream(urlString, headers, body, timeoutMs, onDelta) {
233
292
  return;
234
293
  }
235
294
 
236
- let buf = "";
295
+ let sseBuf = "";
237
296
  let full = "";
297
+ // When onReasoningDelta is provided, intercept <think>...</think> from
298
+ // delta.content in addition to the dedicated delta.reasoning_content field
299
+ // (llama.cpp/LocalAI style vs SGLang/Nemotron style — both converge here).
300
+ const thinkSplitter = onReasoningDelta
301
+ ? createThinkTagSplitter(
302
+ function (c) { if (firstTokenAt === null) { firstTokenAt = Date.now(); } full += c; onDelta(c); },
303
+ onReasoningDelta
304
+ )
305
+ : null;
306
+
238
307
  res.on("data", (chunk) => {
239
- buf += chunk;
240
- const lines = buf.split("\n");
241
- buf = lines.pop(); // keep the last (possibly partial) line for next time
308
+ sseBuf += chunk;
309
+ const lines = sseBuf.split("\n");
310
+ sseBuf = lines.pop(); // keep the last (possibly partial) line for next time
242
311
 
243
312
  lines.forEach((line) => {
244
313
  line = line.trim();
@@ -255,17 +324,29 @@ function postStream(urlString, headers, body, timeoutMs, onDelta) {
255
324
 
256
325
  if (evt && evt.usage) { usage = evt.usage; }
257
326
 
258
- const delta = evt && evt.choices && evt.choices[0] && evt.choices[0].delta
259
- ? evt.choices[0].delta.content
260
- : "";
261
- if (delta) {
262
- if (firstTokenAt === null) { firstTokenAt = Date.now(); }
263
- full += delta;
264
- onDelta(delta);
327
+ const deltaObj = evt && evt.choices && evt.choices[0] && evt.choices[0].delta;
328
+ if (deltaObj) {
329
+ if (deltaObj.content) {
330
+ if (thinkSplitter) {
331
+ thinkSplitter.push(deltaObj.content);
332
+ } else {
333
+ if (firstTokenAt === null) { firstTokenAt = Date.now(); }
334
+ full += deltaObj.content;
335
+ onDelta(deltaObj.content);
336
+ }
337
+ }
338
+ // Separate reasoning_content field (SGLang/Nemotron style). Both
339
+ // paths run in parallel — they are mutually exclusive per provider
340
+ // (llama.cpp puts reasoning in delta.content via <think> tags;
341
+ // SGLang puts it in delta.reasoning_content with delta.content "").
342
+ if (deltaObj.reasoning_content && onReasoningDelta) {
343
+ onReasoningDelta(deltaObj.reasoning_content);
344
+ }
265
345
  }
266
346
  });
267
347
  });
268
348
  res.on("end", () => {
349
+ if (thinkSplitter) { thinkSplitter.finish(); }
269
350
  const endedAt = Date.now();
270
351
  resolve({
271
352
  content: full,
@@ -287,7 +368,7 @@ function postStream(urlString, headers, body, timeoutMs, onDelta) {
287
368
  });
288
369
  }
289
370
 
290
- async function chatStream(settings, messages, onDelta) {
371
+ async function chatStream(settings, messages, onDelta, onReasoningDelta) {
291
372
  const baseUrl = String(settings.baseUrl || "").replace(/\/+$/, "");
292
373
  if (!baseUrl) throw new Error("Provider base URL is required.");
293
374
  if (!settings.model) throw new Error("Model is required.");
@@ -309,7 +390,7 @@ async function chatStream(settings, messages, onDelta) {
309
390
  temperature,
310
391
  stream: true,
311
392
  stream_options: { include_usage: true }
312
- }, settings.requestTimeoutMs || 180000, onDelta);
393
+ }, settings.requestTimeoutMs || 180000, onDelta, onReasoningDelta);
313
394
 
314
395
  return {
315
396
  content: result.content || "",
@@ -377,4 +458,20 @@ async function probeTools(settings) {
377
458
  }
378
459
  }
379
460
 
380
- module.exports = { chat, chatStream, probeTools, listModels };
461
+ // ---------------------------------------------------------------------
462
+ // Checks whether the provider's response includes reasoning_content —
463
+ // the separate chain-of-thought field emitted by reasoning models
464
+ // (e.g. Nemotron, DeepSeek-R1, o1-style). Reuses the connectivity test
465
+ // response already obtained by the /test route rather than making a
466
+ // second round-trip: caller passes the raw response object.
467
+ // Returns { isReasoningModel: boolean }.
468
+ // ---------------------------------------------------------------------
469
+ function detectReasoning(rawResponse) {
470
+ const message = rawResponse && rawResponse.choices && rawResponse.choices[0]
471
+ && rawResponse.choices[0].message;
472
+ const isReasoningModel = !!(message && message.reasoning_content != null
473
+ && message.reasoning_content !== "");
474
+ return { isReasoningModel };
475
+ }
476
+
477
+ module.exports = { chat, chatStream, probeTools, listModels, detectReasoning };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@manny-est/node-red-flowpilot",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "FlowPilot for Node-RED - an AI-powered development assistant sidebar",
5
5
  "main": "flowpilot.js",
6
6
  "keywords": [
@@ -14,7 +14,7 @@
14
14
  "license": "MIT",
15
15
  "repository": {
16
16
  "type": "git",
17
- "url": "https://github.com/manny-est/flowpilot.git"
17
+ "url": "git+https://github.com/manny-est/flowpilot.git"
18
18
  },
19
19
  "homepage": "https://github.com/manny-est/flowpilot#readme",
20
20
  "bugs": {