@manny-est/node-red-flowpilot 0.5.0 → 0.5.2
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 +94 -0
- package/README.md +21 -7
- package/USER-GUIDE.md +22 -13
- package/flowpilot-core.css +187 -2
- package/flowpilot.js +462 -33
- package/lib/build-system-prompt.js +26 -4
- package/lib/core/apply-review.js +494 -64
- package/lib/core/init.js +67 -132
- package/lib/core/main.js +160 -7
- package/lib/core/modes.js +876 -79
- package/lib/core/selection-context.js +28 -1
- package/lib/default-system-prompt.js +13 -9
- package/lib/document-system-prompt.js +19 -30
- package/lib/generation-system-prompt.js +24 -40
- package/lib/modify-system-prompt.js +98 -70
- package/lib/prompt-fragments.js +45 -0
- package/lib/provider-anthropic.js +385 -0
- package/lib/provider-openai-compatible.js +122 -16
- package/lib/storage.js +43 -2
- package/lib/validator.js +238 -0
- package/package.json +2 -2
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
const https = require("https");
|
|
2
|
+
const http = require("http");
|
|
3
|
+
|
|
4
|
+
const ANTHROPIC_API_BASE = "https://api.anthropic.com";
|
|
5
|
+
const ANTHROPIC_VERSION = "2023-06-01";
|
|
6
|
+
const DEFAULT_MAX_TOKENS = 8192;
|
|
7
|
+
|
|
8
|
+
// ---- HTTP helpers ----
|
|
9
|
+
|
|
10
|
+
function postJson(urlString, headers, body, timeoutMs) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
let url;
|
|
13
|
+
try { url = new URL(urlString); } catch (err) { reject(new Error("Invalid provider URL: " + urlString)); return; }
|
|
14
|
+
|
|
15
|
+
const payload = JSON.stringify(body);
|
|
16
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
17
|
+
const req = transport.request({
|
|
18
|
+
method: "POST",
|
|
19
|
+
hostname: url.hostname,
|
|
20
|
+
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
|
21
|
+
path: url.pathname + url.search,
|
|
22
|
+
headers: Object.assign({ "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }, headers || {})
|
|
23
|
+
}, (res) => {
|
|
24
|
+
let data = "";
|
|
25
|
+
res.setEncoding("utf8");
|
|
26
|
+
res.on("data", chunk => { data += chunk; });
|
|
27
|
+
res.on("end", () => {
|
|
28
|
+
let parsed = null;
|
|
29
|
+
try { parsed = data ? JSON.parse(data) : null; } catch (err) {
|
|
30
|
+
reject(new Error("Provider returned non-JSON response (" + res.statusCode + "): " + data.slice(0, 500)));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
34
|
+
const msg = parsed && parsed.error ? JSON.stringify(parsed.error) : data;
|
|
35
|
+
reject(new Error("Provider request failed (" + res.statusCode + "): " + msg));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
resolve(parsed);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
req.on("error", reject);
|
|
42
|
+
req.setTimeout(timeoutMs || 180000, () => {
|
|
43
|
+
req.destroy(new Error("Provider request timed out after " + (timeoutMs || 180000) + "ms — increase the request timeout in Settings → Behavior for slower hardware."));
|
|
44
|
+
});
|
|
45
|
+
req.write(payload);
|
|
46
|
+
req.end();
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getJson(urlString, headers, timeoutMs) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
let url;
|
|
53
|
+
try { url = new URL(urlString); } catch (err) { reject(new Error("Invalid provider URL: " + urlString)); return; }
|
|
54
|
+
|
|
55
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
56
|
+
const req = transport.request({
|
|
57
|
+
method: "GET",
|
|
58
|
+
hostname: url.hostname,
|
|
59
|
+
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
|
60
|
+
path: url.pathname + url.search,
|
|
61
|
+
headers: headers || {}
|
|
62
|
+
}, (res) => {
|
|
63
|
+
let data = "";
|
|
64
|
+
res.setEncoding("utf8");
|
|
65
|
+
res.on("data", chunk => { data += chunk; });
|
|
66
|
+
res.on("end", () => {
|
|
67
|
+
let parsed = null;
|
|
68
|
+
try { parsed = data ? JSON.parse(data) : null; } catch (err) {
|
|
69
|
+
reject(new Error("Provider returned non-JSON response (" + res.statusCode + "): " + data.slice(0, 500)));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
73
|
+
const msg = parsed && parsed.error ? JSON.stringify(parsed.error) : data;
|
|
74
|
+
reject(new Error("Provider request failed (" + res.statusCode + "): " + msg));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
resolve(parsed);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
req.on("error", reject);
|
|
81
|
+
req.setTimeout(timeoutMs || 30000, () => {
|
|
82
|
+
req.destroy(new Error("Timeout"));
|
|
83
|
+
});
|
|
84
|
+
req.end();
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function postStream(urlString, headers, body, timeoutMs, onDelta, onReasoningDelta) {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
let url;
|
|
91
|
+
try { url = new URL(urlString); } catch (err) { reject(new Error("Invalid provider URL: " + urlString)); return; }
|
|
92
|
+
|
|
93
|
+
const payload = JSON.stringify(body);
|
|
94
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
95
|
+
const startedAt = Date.now();
|
|
96
|
+
let firstTokenAt = null;
|
|
97
|
+
let usage = null;
|
|
98
|
+
let full = "";
|
|
99
|
+
|
|
100
|
+
const req = transport.request({
|
|
101
|
+
method: "POST",
|
|
102
|
+
hostname: url.hostname,
|
|
103
|
+
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
|
104
|
+
path: url.pathname + url.search,
|
|
105
|
+
headers: Object.assign({ "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }, headers || {})
|
|
106
|
+
}, (res) => {
|
|
107
|
+
res.setEncoding("utf8");
|
|
108
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
109
|
+
let errData = "";
|
|
110
|
+
res.on("data", chunk => { errData += chunk; });
|
|
111
|
+
res.on("end", () => { reject(new Error("Provider request failed (" + res.statusCode + "): " + errData.slice(0, 500))); });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let sseBuf = "";
|
|
116
|
+
res.on("data", (chunk) => {
|
|
117
|
+
sseBuf += chunk;
|
|
118
|
+
const lines = sseBuf.split("\n");
|
|
119
|
+
sseBuf = lines.pop();
|
|
120
|
+
lines.forEach((line) => {
|
|
121
|
+
line = line.trim();
|
|
122
|
+
if (!line.startsWith("data:")) { return; }
|
|
123
|
+
const dataStr = line.slice(5).trim();
|
|
124
|
+
if (!dataStr) { return; }
|
|
125
|
+
let evt;
|
|
126
|
+
try { evt = JSON.parse(dataStr); } catch (e) { return; }
|
|
127
|
+
|
|
128
|
+
// Anthropic SSE event types used here:
|
|
129
|
+
// message_start: carries input_tokens
|
|
130
|
+
// content_block_delta: text_delta or thinking_delta
|
|
131
|
+
// message_delta: carries output_tokens
|
|
132
|
+
if (evt.type === "message_start" && evt.message && evt.message.usage) {
|
|
133
|
+
usage = { prompt_tokens: evt.message.usage.input_tokens || 0, completion_tokens: 0 };
|
|
134
|
+
} else if (evt.type === "content_block_delta" && evt.delta) {
|
|
135
|
+
if (evt.delta.type === "text_delta" && evt.delta.text) {
|
|
136
|
+
if (firstTokenAt === null) { firstTokenAt = Date.now(); }
|
|
137
|
+
full += evt.delta.text;
|
|
138
|
+
onDelta(evt.delta.text);
|
|
139
|
+
} else if (evt.delta.type === "thinking_delta" && evt.delta.thinking && onReasoningDelta) {
|
|
140
|
+
onReasoningDelta(evt.delta.thinking);
|
|
141
|
+
}
|
|
142
|
+
} else if (evt.type === "message_delta" && evt.usage) {
|
|
143
|
+
if (usage) { usage.completion_tokens = evt.usage.output_tokens || 0; }
|
|
144
|
+
else { usage = { prompt_tokens: 0, completion_tokens: evt.usage.output_tokens || 0 }; }
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
res.on("end", () => {
|
|
149
|
+
resolve({ content: full, ttftMs: firstTokenAt !== null ? firstTokenAt - startedAt : null, totalMs: Date.now() - startedAt, usage: usage });
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
req.on("error", reject);
|
|
154
|
+
req.setTimeout(timeoutMs || 180000, () => {
|
|
155
|
+
req.destroy(new Error("Provider request timed out after " + (timeoutMs || 180000) + "ms — increase the request timeout in Settings → Behavior for slower hardware."));
|
|
156
|
+
});
|
|
157
|
+
req.write(payload);
|
|
158
|
+
req.end();
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---- Message format conversion ----
|
|
163
|
+
|
|
164
|
+
// OpenAI {type:"function", function:{name,description,parameters}} →
|
|
165
|
+
// Anthropic {name, description, input_schema}
|
|
166
|
+
function toAnthropicTool(tool) {
|
|
167
|
+
const fn = tool && tool.function;
|
|
168
|
+
if (!fn) { return null; }
|
|
169
|
+
return {
|
|
170
|
+
name: fn.name,
|
|
171
|
+
description: fn.description || "",
|
|
172
|
+
input_schema: fn.parameters || { type: "object", properties: {} }
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Anthropic {type:"tool_use", id, name, input} →
|
|
177
|
+
// OpenAI {id, type:"function", function:{name, arguments:string}}
|
|
178
|
+
function toOpenAiToolCall(block) {
|
|
179
|
+
return {
|
|
180
|
+
id: block.id,
|
|
181
|
+
type: "function",
|
|
182
|
+
function: {
|
|
183
|
+
name: block.name,
|
|
184
|
+
arguments: JSON.stringify(block.input || {})
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Convert an OpenAI-shaped messages array to Anthropic format.
|
|
190
|
+
// Returns { system: string, messages: [...] }
|
|
191
|
+
//
|
|
192
|
+
// - role:"system" messages are extracted and concatenated into top-level system param
|
|
193
|
+
// - role:"assistant" + tool_calls converted to Anthropic tool_use content blocks
|
|
194
|
+
// - role:"tool" results converted and grouped into user messages with tool_result blocks
|
|
195
|
+
// - role:"user" and plain role:"assistant" pass through unchanged
|
|
196
|
+
function convertMessages(messages) {
|
|
197
|
+
const systemParts = [];
|
|
198
|
+
const converted = [];
|
|
199
|
+
|
|
200
|
+
for (let i = 0; i < messages.length; i++) {
|
|
201
|
+
const msg = messages[i];
|
|
202
|
+
|
|
203
|
+
if (msg.role === "system") {
|
|
204
|
+
if (typeof msg.content === "string" && msg.content) {
|
|
205
|
+
systemParts.push(msg.content);
|
|
206
|
+
}
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Group consecutive tool result messages into one user message
|
|
211
|
+
if (msg.role === "tool") {
|
|
212
|
+
const toolResults = [];
|
|
213
|
+
while (i < messages.length && messages[i].role === "tool") {
|
|
214
|
+
toolResults.push({
|
|
215
|
+
type: "tool_result",
|
|
216
|
+
tool_use_id: messages[i].tool_call_id,
|
|
217
|
+
content: messages[i].content || ""
|
|
218
|
+
});
|
|
219
|
+
i++;
|
|
220
|
+
}
|
|
221
|
+
i--; // outer loop will increment
|
|
222
|
+
converted.push({ role: "user", content: toolResults });
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Assistant with tool_calls → Anthropic tool_use content blocks
|
|
227
|
+
if (msg.role === "assistant" && Array.isArray(msg.tool_calls) && msg.tool_calls.length) {
|
|
228
|
+
const content = [];
|
|
229
|
+
if (msg.content) { content.push({ type: "text", text: msg.content }); }
|
|
230
|
+
msg.tool_calls.forEach(function (tc) {
|
|
231
|
+
let input = {};
|
|
232
|
+
try { input = JSON.parse(tc.function.arguments || "{}"); } catch (e) {}
|
|
233
|
+
content.push({ type: "tool_use", id: tc.id, name: tc.function.name, input: input });
|
|
234
|
+
});
|
|
235
|
+
converted.push({ role: "assistant", content: content });
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
converted.push({ role: msg.role, content: msg.content });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return { system: systemParts.join("\n\n"), messages: converted };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function anthropicHeaders(settings) {
|
|
246
|
+
return {
|
|
247
|
+
"x-api-key": settings.apiKey || "",
|
|
248
|
+
"anthropic-version": ANTHROPIC_VERSION
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function resolveBaseUrl(settings) {
|
|
253
|
+
return String(settings.baseUrl || ANTHROPIC_API_BASE).replace(/\/+$/, "") || ANTHROPIC_API_BASE;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---- chat ----
|
|
257
|
+
|
|
258
|
+
async function chat(settings, messages, options) {
|
|
259
|
+
if (!settings.model) { throw new Error("Model is required."); }
|
|
260
|
+
const baseUrl = resolveBaseUrl(settings);
|
|
261
|
+
const { system, messages: anthropicMessages } = convertMessages(messages);
|
|
262
|
+
const temperature = settings.temperature !== undefined ? Number(settings.temperature) : 0.2;
|
|
263
|
+
|
|
264
|
+
const body = {
|
|
265
|
+
model: settings.model,
|
|
266
|
+
messages: anthropicMessages,
|
|
267
|
+
max_tokens: DEFAULT_MAX_TOKENS,
|
|
268
|
+
temperature: temperature,
|
|
269
|
+
stream: false
|
|
270
|
+
};
|
|
271
|
+
if (system) { body.system = system; }
|
|
272
|
+
if (options && Array.isArray(options.tools) && options.tools.length) {
|
|
273
|
+
body.tools = options.tools.map(toAnthropicTool).filter(Boolean);
|
|
274
|
+
body.tool_choice = { type: "auto" };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const startedAt = Date.now();
|
|
278
|
+
const response = await postJson(baseUrl + "/v1/messages", anthropicHeaders(settings), body, settings.requestTimeoutMs || 180000);
|
|
279
|
+
const totalMs = Date.now() - startedAt;
|
|
280
|
+
|
|
281
|
+
const contentArray = (response && Array.isArray(response.content)) ? response.content : [];
|
|
282
|
+
const textContent = contentArray.filter(function (b) { return b.type === "text"; }).map(function (b) { return b.text; }).join("");
|
|
283
|
+
const toolUseBlocks = contentArray.filter(function (b) { return b.type === "tool_use"; });
|
|
284
|
+
const toolCalls = toolUseBlocks.length ? toolUseBlocks.map(toOpenAiToolCall) : null;
|
|
285
|
+
const usage = (response && response.usage)
|
|
286
|
+
? { prompt_tokens: response.usage.input_tokens, completion_tokens: response.usage.output_tokens }
|
|
287
|
+
: null;
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
raw: response,
|
|
291
|
+
content: textContent || (toolCalls ? "" : "[No assistant message returned by provider]"),
|
|
292
|
+
toolCalls: toolCalls,
|
|
293
|
+
timing: { totalMs: totalMs },
|
|
294
|
+
usage: usage
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---- chatStream ----
|
|
299
|
+
|
|
300
|
+
async function chatStream(settings, messages, onDelta, onReasoningDelta) {
|
|
301
|
+
if (!settings.model) { throw new Error("Model is required."); }
|
|
302
|
+
const baseUrl = resolveBaseUrl(settings);
|
|
303
|
+
const { system, messages: anthropicMessages } = convertMessages(messages);
|
|
304
|
+
const temperature = settings.temperature !== undefined ? Number(settings.temperature) : 0.2;
|
|
305
|
+
|
|
306
|
+
const body = {
|
|
307
|
+
model: settings.model,
|
|
308
|
+
messages: anthropicMessages,
|
|
309
|
+
max_tokens: DEFAULT_MAX_TOKENS,
|
|
310
|
+
temperature: temperature,
|
|
311
|
+
stream: true
|
|
312
|
+
};
|
|
313
|
+
if (system) { body.system = system; }
|
|
314
|
+
|
|
315
|
+
const result = await postStream(baseUrl + "/v1/messages", anthropicHeaders(settings), body, settings.requestTimeoutMs || 180000, onDelta, onReasoningDelta);
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
content: result.content || "",
|
|
319
|
+
timing: { ttftMs: result.ttftMs, totalMs: result.totalMs },
|
|
320
|
+
usage: result.usage
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---- listModels ----
|
|
325
|
+
// Tries GET /v1/models; falls back to a hardcoded list if that endpoint
|
|
326
|
+
// is unavailable (non-standard proxy) or returns an error.
|
|
327
|
+
async function listModels(settings) {
|
|
328
|
+
const baseUrl = resolveBaseUrl(settings);
|
|
329
|
+
try {
|
|
330
|
+
const response = await getJson(baseUrl + "/v1/models", anthropicHeaders(settings), settings.requestTimeoutMs || 30000);
|
|
331
|
+
const data = response && Array.isArray(response.data) ? response.data : [];
|
|
332
|
+
const models = data.map(function (m) { return m && m.id; }).filter(function (id) { return typeof id === "string" && id; });
|
|
333
|
+
if (models.length) { return { models: models }; }
|
|
334
|
+
throw new Error("Empty model list from provider");
|
|
335
|
+
} catch (err) {
|
|
336
|
+
return {
|
|
337
|
+
models: [
|
|
338
|
+
"claude-opus-4-8",
|
|
339
|
+
"claude-sonnet-5",
|
|
340
|
+
"claude-haiku-4-5-20251001"
|
|
341
|
+
],
|
|
342
|
+
error: err.message
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---- probeTools ----
|
|
348
|
+
async function probeTools(settings) {
|
|
349
|
+
if (!settings.model) { throw new Error("Model is required."); }
|
|
350
|
+
const baseUrl = resolveBaseUrl(settings);
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
const response = await postJson(baseUrl + "/v1/messages", anthropicHeaders(settings), {
|
|
354
|
+
model: settings.model,
|
|
355
|
+
messages: [{ role: "user", content: "Call the \"ping\" tool now with no arguments." }],
|
|
356
|
+
system: "You are being tested for tool/function-calling support.",
|
|
357
|
+
tools: [{
|
|
358
|
+
name: "ping",
|
|
359
|
+
description: "Respond to a connectivity probe. Takes no arguments.",
|
|
360
|
+
input_schema: { type: "object", properties: {}, additionalProperties: false }
|
|
361
|
+
}],
|
|
362
|
+
tool_choice: { type: "auto" },
|
|
363
|
+
max_tokens: 128,
|
|
364
|
+
temperature: 0,
|
|
365
|
+
stream: false
|
|
366
|
+
}, settings.requestTimeoutMs || 30000);
|
|
367
|
+
|
|
368
|
+
const contentArray = (response && Array.isArray(response.content)) ? response.content : [];
|
|
369
|
+
const hasToolUse = contentArray.some(function (b) { return b.type === "tool_use" && b.name === "ping"; });
|
|
370
|
+
return { supportsTools: hasToolUse };
|
|
371
|
+
} catch (err) {
|
|
372
|
+
return { supportsTools: false, error: err.message };
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ---- detectReasoning ----
|
|
377
|
+
// Extended thinking responses include content blocks with type:"thinking".
|
|
378
|
+
// Standard non-thinking responses never include them.
|
|
379
|
+
function detectReasoning(rawResponse) {
|
|
380
|
+
const contentArray = (rawResponse && Array.isArray(rawResponse.content)) ? rawResponse.content : [];
|
|
381
|
+
const isReasoningModel = contentArray.some(function (b) { return b && b.type === "thinking" && b.thinking; });
|
|
382
|
+
return { isReasoningModel: isReasoningModel };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
module.exports = { chat, chatStream, probeTools, listModels, detectReasoning };
|
|
@@ -169,6 +169,9 @@ async function chat(settings, messages, options) {
|
|
|
169
169
|
body.tools = options.tools;
|
|
170
170
|
body.tool_choice = options.toolChoice || "auto";
|
|
171
171
|
}
|
|
172
|
+
if (options && options.responseFormat) {
|
|
173
|
+
body.response_format = options.responseFormat;
|
|
174
|
+
}
|
|
172
175
|
|
|
173
176
|
const startedAt = Date.now();
|
|
174
177
|
const response = await postJson(`${baseUrl}/v1/chat/completions`, headers, body, settings.requestTimeoutMs || 180000);
|
|
@@ -194,7 +197,66 @@ async function chat(settings, messages, options) {
|
|
|
194
197
|
// `data: [DONE]`). Calls onDelta(text) for each content fragment as it
|
|
195
198
|
// arrives and resolves with { content } containing the full concatenated
|
|
196
199
|
// text once the stream ends. Used for chat streaming only.
|
|
197
|
-
|
|
200
|
+
// Splits a streaming content string on <think>...</think> tags so that
|
|
201
|
+
// reasoning text is routed to onReasoning and the actual response text to
|
|
202
|
+
// onContent. Handles tags that arrive split across multiple chunks via a
|
|
203
|
+
// small lookahead buffer. Handles both:
|
|
204
|
+
// - SGLang/Nemotron style: delta.reasoning_content (separate field)
|
|
205
|
+
// - llama.cpp/LocalAI style: <think>...</think> embedded in delta.content
|
|
206
|
+
// The two paths converge at onReasoningDelta in postStream — callers see
|
|
207
|
+
// one uniform reasoning callback regardless of provider format.
|
|
208
|
+
function createThinkTagSplitter(onContent, onReasoning) {
|
|
209
|
+
let phase = "seek"; // "seek" | "think" | "content"
|
|
210
|
+
let buf = "";
|
|
211
|
+
const OPEN = "<think>";
|
|
212
|
+
const CLOSE = "</think>";
|
|
213
|
+
|
|
214
|
+
function push(text) {
|
|
215
|
+
buf += text;
|
|
216
|
+
while (buf.length > 0) {
|
|
217
|
+
if (phase === "seek") {
|
|
218
|
+
const idx = buf.indexOf(OPEN);
|
|
219
|
+
if (idx === -1) {
|
|
220
|
+
// No opening tag visible — emit everything except the last few bytes
|
|
221
|
+
// that might be a partial tag, buffer the rest.
|
|
222
|
+
const safe = buf.length > OPEN.length - 1 ? buf.length - (OPEN.length - 1) : 0;
|
|
223
|
+
if (safe > 0) { onContent(buf.slice(0, safe)); buf = buf.slice(safe); }
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
if (idx > 0) { onContent(buf.slice(0, idx)); }
|
|
227
|
+
buf = buf.slice(idx + OPEN.length);
|
|
228
|
+
phase = "think";
|
|
229
|
+
} else if (phase === "think") {
|
|
230
|
+
const idx = buf.indexOf(CLOSE);
|
|
231
|
+
if (idx === -1) {
|
|
232
|
+
const safe = buf.length > CLOSE.length - 1 ? buf.length - (CLOSE.length - 1) : 0;
|
|
233
|
+
if (safe > 0) { onReasoning(buf.slice(0, safe)); buf = buf.slice(safe); }
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
if (idx > 0) { onReasoning(buf.slice(0, idx)); }
|
|
237
|
+
buf = buf.slice(idx + CLOSE.length);
|
|
238
|
+
// Skip whitespace/newline immediately after </think>
|
|
239
|
+
const trimmed = buf.replace(/^\s+/, "");
|
|
240
|
+
buf = trimmed;
|
|
241
|
+
phase = "content";
|
|
242
|
+
} else {
|
|
243
|
+
onContent(buf);
|
|
244
|
+
buf = "";
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function finish() {
|
|
251
|
+
if (!buf) { return; }
|
|
252
|
+
if (phase === "think") { onReasoning(buf); } else { onContent(buf); }
|
|
253
|
+
buf = "";
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return { push, finish };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function postStream(urlString, headers, body, timeoutMs, onDelta, onReasoningDelta) {
|
|
198
260
|
return new Promise((resolve, reject) => {
|
|
199
261
|
let url;
|
|
200
262
|
try {
|
|
@@ -233,12 +295,22 @@ function postStream(urlString, headers, body, timeoutMs, onDelta) {
|
|
|
233
295
|
return;
|
|
234
296
|
}
|
|
235
297
|
|
|
236
|
-
let
|
|
298
|
+
let sseBuf = "";
|
|
237
299
|
let full = "";
|
|
300
|
+
// When onReasoningDelta is provided, intercept <think>...</think> from
|
|
301
|
+
// delta.content in addition to the dedicated delta.reasoning_content field
|
|
302
|
+
// (llama.cpp/LocalAI style vs SGLang/Nemotron style — both converge here).
|
|
303
|
+
const thinkSplitter = onReasoningDelta
|
|
304
|
+
? createThinkTagSplitter(
|
|
305
|
+
function (c) { if (firstTokenAt === null) { firstTokenAt = Date.now(); } full += c; onDelta(c); },
|
|
306
|
+
onReasoningDelta
|
|
307
|
+
)
|
|
308
|
+
: null;
|
|
309
|
+
|
|
238
310
|
res.on("data", (chunk) => {
|
|
239
|
-
|
|
240
|
-
const lines =
|
|
241
|
-
|
|
311
|
+
sseBuf += chunk;
|
|
312
|
+
const lines = sseBuf.split("\n");
|
|
313
|
+
sseBuf = lines.pop(); // keep the last (possibly partial) line for next time
|
|
242
314
|
|
|
243
315
|
lines.forEach((line) => {
|
|
244
316
|
line = line.trim();
|
|
@@ -255,17 +327,29 @@ function postStream(urlString, headers, body, timeoutMs, onDelta) {
|
|
|
255
327
|
|
|
256
328
|
if (evt && evt.usage) { usage = evt.usage; }
|
|
257
329
|
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
330
|
+
const deltaObj = evt && evt.choices && evt.choices[0] && evt.choices[0].delta;
|
|
331
|
+
if (deltaObj) {
|
|
332
|
+
if (deltaObj.content) {
|
|
333
|
+
if (thinkSplitter) {
|
|
334
|
+
thinkSplitter.push(deltaObj.content);
|
|
335
|
+
} else {
|
|
336
|
+
if (firstTokenAt === null) { firstTokenAt = Date.now(); }
|
|
337
|
+
full += deltaObj.content;
|
|
338
|
+
onDelta(deltaObj.content);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// Separate reasoning_content field (SGLang/Nemotron style). Both
|
|
342
|
+
// paths run in parallel — they are mutually exclusive per provider
|
|
343
|
+
// (llama.cpp puts reasoning in delta.content via <think> tags;
|
|
344
|
+
// SGLang puts it in delta.reasoning_content with delta.content "").
|
|
345
|
+
if (deltaObj.reasoning_content && onReasoningDelta) {
|
|
346
|
+
onReasoningDelta(deltaObj.reasoning_content);
|
|
347
|
+
}
|
|
265
348
|
}
|
|
266
349
|
});
|
|
267
350
|
});
|
|
268
351
|
res.on("end", () => {
|
|
352
|
+
if (thinkSplitter) { thinkSplitter.finish(); }
|
|
269
353
|
const endedAt = Date.now();
|
|
270
354
|
resolve({
|
|
271
355
|
content: full,
|
|
@@ -287,7 +371,7 @@ function postStream(urlString, headers, body, timeoutMs, onDelta) {
|
|
|
287
371
|
});
|
|
288
372
|
}
|
|
289
373
|
|
|
290
|
-
async function chatStream(settings, messages, onDelta) {
|
|
374
|
+
async function chatStream(settings, messages, onDelta, onReasoningDelta, options) {
|
|
291
375
|
const baseUrl = String(settings.baseUrl || "").replace(/\/+$/, "");
|
|
292
376
|
if (!baseUrl) throw new Error("Provider base URL is required.");
|
|
293
377
|
if (!settings.model) throw new Error("Model is required.");
|
|
@@ -303,13 +387,19 @@ async function chatStream(settings, messages, onDelta) {
|
|
|
303
387
|
// final SSE chunk carrying token usage (no delta) before [DONE]. Providers
|
|
304
388
|
// that don't support it just ignore the option; postStream treats a
|
|
305
389
|
// missing usage field as null either way.
|
|
306
|
-
const
|
|
390
|
+
const body = {
|
|
307
391
|
model: settings.model,
|
|
308
392
|
messages,
|
|
309
393
|
temperature,
|
|
310
394
|
stream: true,
|
|
311
395
|
stream_options: { include_usage: true }
|
|
312
|
-
}
|
|
396
|
+
};
|
|
397
|
+
if (options && options.responseFormat) {
|
|
398
|
+
body.response_format = options.responseFormat;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const result = await postStream(`${baseUrl}/v1/chat/completions`, headers,
|
|
402
|
+
body, settings.requestTimeoutMs || 180000, onDelta, onReasoningDelta);
|
|
313
403
|
|
|
314
404
|
return {
|
|
315
405
|
content: result.content || "",
|
|
@@ -377,4 +467,20 @@ async function probeTools(settings) {
|
|
|
377
467
|
}
|
|
378
468
|
}
|
|
379
469
|
|
|
380
|
-
|
|
470
|
+
// ---------------------------------------------------------------------
|
|
471
|
+
// Checks whether the provider's response includes reasoning_content —
|
|
472
|
+
// the separate chain-of-thought field emitted by reasoning models
|
|
473
|
+
// (e.g. Nemotron, DeepSeek-R1, o1-style). Reuses the connectivity test
|
|
474
|
+
// response already obtained by the /test route rather than making a
|
|
475
|
+
// second round-trip: caller passes the raw response object.
|
|
476
|
+
// Returns { isReasoningModel: boolean }.
|
|
477
|
+
// ---------------------------------------------------------------------
|
|
478
|
+
function detectReasoning(rawResponse) {
|
|
479
|
+
const message = rawResponse && rawResponse.choices && rawResponse.choices[0]
|
|
480
|
+
&& rawResponse.choices[0].message;
|
|
481
|
+
const isReasoningModel = !!(message && message.reasoning_content != null
|
|
482
|
+
&& message.reasoning_content !== "");
|
|
483
|
+
return { isReasoningModel };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
module.exports = { chat, chatStream, probeTools, listModels, detectReasoning };
|
package/lib/storage.js
CHANGED
|
@@ -18,10 +18,15 @@ function createStorage(userDir) {
|
|
|
18
18
|
return {
|
|
19
19
|
id: "default",
|
|
20
20
|
providerName: "LocalAI",
|
|
21
|
+
// "openai-compatible" (default) or "anthropic"
|
|
22
|
+
type: "openai-compatible",
|
|
21
23
|
baseUrl: "http://localhost:8080",
|
|
22
24
|
apiKey: "",
|
|
23
25
|
model: "",
|
|
24
|
-
temperature: 0.2
|
|
26
|
+
temperature: 0.2,
|
|
27
|
+
// Configured context window size for this provider in tokens (0 = unknown).
|
|
28
|
+
// When set, FlowPilot warns when the assembled prompt approaches the limit.
|
|
29
|
+
numCtx: 0
|
|
25
30
|
};
|
|
26
31
|
}
|
|
27
32
|
|
|
@@ -33,7 +38,11 @@ function createStorage(userDir) {
|
|
|
33
38
|
maxContextChars: 12000,
|
|
34
39
|
defaultContextMode: "selected",
|
|
35
40
|
allowConfigContext: false,
|
|
36
|
-
|
|
41
|
+
// When true, each assembled prompt (post-redaction messages array) plus the
|
|
42
|
+
// raw provider response and parse outcome is appended to assembled-prompts.log
|
|
43
|
+
// (0600 perms). Auth headers/keys are never logged — only the content bytes
|
|
44
|
+
// that the provider actually received. Off by default (diagnostic tool).
|
|
45
|
+
logAssembledPrompts: false,
|
|
37
46
|
streamingEnabled: true,
|
|
38
47
|
// First-run welcome/warning shows until the user saves settings once.
|
|
39
48
|
firstRunAcknowledged: false,
|
|
@@ -62,6 +71,10 @@ function createStorage(userDir) {
|
|
|
62
71
|
// and shows a checkpoint question ("Continue with AI review, or stop?")
|
|
63
72
|
// instead of auto-advancing. Default false = original auto-advance behavior.
|
|
64
73
|
loopHoldStep: false,
|
|
74
|
+
// Routes Generate through the Phase 10 step-queue engine (graph read-back
|
|
75
|
+
// verification after import). The legacy path remains available when a
|
|
76
|
+
// user explicitly disables this setting.
|
|
77
|
+
enableStepQueue: true,
|
|
65
78
|
// Lets the user silence the recurring secrets/size reminder bar after
|
|
66
79
|
// typing an explicit acknowledgement in settings.
|
|
67
80
|
suppressContextWarnings: false,
|
|
@@ -290,6 +303,32 @@ function createStorage(userDir) {
|
|
|
290
303
|
return defaultSettings.systemPrompt;
|
|
291
304
|
}
|
|
292
305
|
|
|
306
|
+
const assembledPromptsFile = path.join(baseDir, "assembled-prompts.log");
|
|
307
|
+
|
|
308
|
+
// W0.4: Append one JSON-lines entry to assembled-prompts.log.
|
|
309
|
+
// Written 0600 — diagnostic data, never world-readable.
|
|
310
|
+
// Auth keys are NOT included (only baseUrl + model from the provider
|
|
311
|
+
// profile, never apiKey). The bytes logged are post-redaction: the same
|
|
312
|
+
// content the provider actually received.
|
|
313
|
+
function appendAssembledPromptLog(entry) {
|
|
314
|
+
init();
|
|
315
|
+
const line = JSON.stringify({
|
|
316
|
+
timestamp: new Date().toISOString(),
|
|
317
|
+
...entry
|
|
318
|
+
});
|
|
319
|
+
try {
|
|
320
|
+
// Open with O_APPEND | O_CREAT, mode 0600 so secrets never land
|
|
321
|
+
// in a world-readable file even on first write.
|
|
322
|
+
const fd = fs.openSync(assembledPromptsFile, "a", 0o600);
|
|
323
|
+
fs.writeSync(fd, line + "\n");
|
|
324
|
+
fs.closeSync(fd);
|
|
325
|
+
// Ensure 0600 regardless of umask on subsequent opens.
|
|
326
|
+
fs.chmodSync(assembledPromptsFile, 0o600);
|
|
327
|
+
} catch (err) {
|
|
328
|
+
console.error("[FlowPilot] assembled-prompts log write failed:", err.message);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
293
332
|
init();
|
|
294
333
|
|
|
295
334
|
return {
|
|
@@ -298,11 +337,13 @@ function createStorage(userDir) {
|
|
|
298
337
|
backupsDir,
|
|
299
338
|
settingsFile,
|
|
300
339
|
auditFile,
|
|
340
|
+
assembledPromptsFile,
|
|
301
341
|
getSettings,
|
|
302
342
|
saveSettings,
|
|
303
343
|
getActiveProvider,
|
|
304
344
|
getDefaultSystemPrompt,
|
|
305
345
|
appendAudit,
|
|
346
|
+
appendAssembledPromptLog,
|
|
306
347
|
appendTranscript,
|
|
307
348
|
readTranscript,
|
|
308
349
|
deleteTranscript,
|