@manny-est/node-red-flowpilot 0.4.1 → 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.
@@ -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/lib/storage.js CHANGED
@@ -58,6 +58,10 @@ function createStorage(userDir) {
58
58
  // own hardcoded AGENT_LOOP_MAX_STEPS (a different bound, for a
59
59
  // different loop).
60
60
  agentLoopMaxIterations: 5,
61
+ // When true, the build loop pauses at the "attach → review" transition
62
+ // and shows a checkpoint question ("Continue with AI review, or stop?")
63
+ // instead of auto-advancing. Default false = original auto-advance behavior.
64
+ loopHoldStep: false,
61
65
  // Lets the user silence the recurring secrets/size reminder bar after
62
66
  // typing an explicit acknowledgement in settings.
63
67
  suppressContextWarnings: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@manny-est/node-red-flowpilot",
3
- "version": "0.4.1",
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": {
@@ -38,7 +38,6 @@
38
38
  "files": [
39
39
  "flowpilot.js",
40
40
  "flowpilot.html",
41
- "flowpilot-core.js",
42
41
  "flowpilot-core.css",
43
42
  "lib",
44
43
  "icons",