@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/CHANGELOG.md +15 -0
- package/flowpilot-core.css +59 -0
- package/flowpilot.js +85 -11
- package/lib/core/apply-review.js +278 -29
- package/lib/core/init.js +57 -132
- package/lib/core/main.js +68 -1
- package/lib/core/modes.js +180 -57
- package/lib/provider-openai-compatible.js +112 -15
- package/package.json +2 -2
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
169
|
-
|
|
207
|
+
|
|
208
|
+
function handleSendError(msg) {
|
|
209
|
+
hidePending();
|
|
210
|
+
if (isChat) { popDanglingUserHistory(); }
|
|
211
|
+
addMessage("error", msg);
|
|
212
|
+
setBusy(false);
|
|
170
213
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
223
|
+
if (isChat && currentSettings.streamingEnabled) {
|
|
224
|
+
payload.stream = true;
|
|
225
|
+
sendChatStream(payload);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
187
228
|
|
|
188
|
-
|
|
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
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {"
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
240
|
-
const lines =
|
|
241
|
-
|
|
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
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|