@manny-est/node-red-flowpilot 0.2.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/LICENSE +21 -0
- package/PROJECT-OVERVIEW.md +98 -0
- package/README.md +144 -0
- package/USER-GUIDE.md +319 -0
- package/examples/dad-joke-demo.json +84 -0
- package/examples/getting-started.json +62 -0
- package/flowpilot.html +5673 -0
- package/flowpilot.js +1644 -0
- package/icons/flowpilot.svg +5 -0
- package/lib/default-system-prompt.js +76 -0
- package/lib/document-system-prompt.js +78 -0
- package/lib/generation-system-prompt.js +119 -0
- package/lib/modify-system-prompt.js +274 -0
- package/lib/provider-openai-compatible.js +377 -0
- package/lib/storage.js +265 -0
- package/package.json +49 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
const http = require("http");
|
|
2
|
+
const https = require("https");
|
|
3
|
+
|
|
4
|
+
function postJson(urlString, headers, body, timeoutMs) {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
let url;
|
|
7
|
+
try {
|
|
8
|
+
url = new URL(urlString);
|
|
9
|
+
} catch (err) {
|
|
10
|
+
reject(new Error(`Invalid provider URL: ${urlString}`));
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const payload = JSON.stringify(body);
|
|
15
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
16
|
+
const options = {
|
|
17
|
+
method: "POST",
|
|
18
|
+
hostname: url.hostname,
|
|
19
|
+
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
|
20
|
+
path: `${url.pathname}${url.search}`,
|
|
21
|
+
headers: Object.assign({
|
|
22
|
+
"Content-Type": "application/json",
|
|
23
|
+
"Content-Length": Buffer.byteLength(payload)
|
|
24
|
+
}, headers || {})
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const req = transport.request(options, (res) => {
|
|
28
|
+
let data = "";
|
|
29
|
+
res.setEncoding("utf8");
|
|
30
|
+
res.on("data", chunk => { data += chunk; });
|
|
31
|
+
res.on("end", () => {
|
|
32
|
+
let parsed = null;
|
|
33
|
+
try {
|
|
34
|
+
parsed = data ? JSON.parse(data) : null;
|
|
35
|
+
} catch (err) {
|
|
36
|
+
reject(new Error(`Provider returned non-JSON response (${res.statusCode}): ${data.slice(0, 500)}`));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
41
|
+
const msg = parsed && parsed.error ? JSON.stringify(parsed.error) : data;
|
|
42
|
+
reject(new Error(`Provider request failed (${res.statusCode}): ${msg}`));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
resolve(parsed);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
req.on("error", reject);
|
|
51
|
+
req.setTimeout(timeoutMs || 180000, () => {
|
|
52
|
+
req.destroy(new Error(`Provider request timed out after ${timeoutMs || 180000}ms`));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
req.write(payload);
|
|
56
|
+
req.end();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function getJson(urlString, headers, timeoutMs) {
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
let url;
|
|
63
|
+
try {
|
|
64
|
+
url = new URL(urlString);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
reject(new Error(`Invalid provider URL: ${urlString}`));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
71
|
+
const options = {
|
|
72
|
+
method: "GET",
|
|
73
|
+
hostname: url.hostname,
|
|
74
|
+
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
|
75
|
+
path: `${url.pathname}${url.search}`,
|
|
76
|
+
headers: headers || {}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const req = transport.request(options, (res) => {
|
|
80
|
+
let data = "";
|
|
81
|
+
res.setEncoding("utf8");
|
|
82
|
+
res.on("data", chunk => { data += chunk; });
|
|
83
|
+
res.on("end", () => {
|
|
84
|
+
let parsed = null;
|
|
85
|
+
try {
|
|
86
|
+
parsed = data ? JSON.parse(data) : null;
|
|
87
|
+
} catch (err) {
|
|
88
|
+
reject(new Error(`Provider returned non-JSON response (${res.statusCode}): ${data.slice(0, 500)}`));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
93
|
+
const msg = parsed && parsed.error ? JSON.stringify(parsed.error) : data;
|
|
94
|
+
reject(new Error(`Provider request failed (${res.statusCode}): ${msg}`));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
resolve(parsed);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
req.on("error", reject);
|
|
103
|
+
req.setTimeout(timeoutMs || 30000, () => {
|
|
104
|
+
req.destroy(new Error(`Provider request timed out after ${timeoutMs || 30000}ms`));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
req.end();
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------
|
|
112
|
+
// Models dropdown: lists models via the OpenAI-compatible GET /v1/models
|
|
113
|
+
// endpoint, so the settings UI can suggest valid model names instead of
|
|
114
|
+
// the user guessing and hitting a 404 from /v1/chat/completions. Unlike
|
|
115
|
+
// chat()/probeTools(), settings.model is NOT required here — this is how
|
|
116
|
+
// the user picks one. Never throws: a provider without /v1/models (or any
|
|
117
|
+
// other failure) just means an empty list with an explanatory error, which
|
|
118
|
+
// the UI shows as a hint while leaving the model field free-text.
|
|
119
|
+
// ---------------------------------------------------------------------
|
|
120
|
+
async function listModels(settings) {
|
|
121
|
+
const baseUrl = String(settings.baseUrl || "").replace(/\/+$/, "");
|
|
122
|
+
if (!baseUrl) throw new Error("Provider base URL is required.");
|
|
123
|
+
|
|
124
|
+
const headers = {};
|
|
125
|
+
if (settings.apiKey) {
|
|
126
|
+
headers.Authorization = `Bearer ${settings.apiKey}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const response = await getJson(`${baseUrl}/v1/models`, headers, 30000);
|
|
131
|
+
const data = response && Array.isArray(response.data) ? response.data : [];
|
|
132
|
+
const models = data
|
|
133
|
+
.map(function (m) { return m && m.id; })
|
|
134
|
+
.filter(function (id) { return typeof id === "string" && id; });
|
|
135
|
+
return { models: models };
|
|
136
|
+
} catch (err) {
|
|
137
|
+
return { models: [], error: err.message };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// `options.tools` (OpenAI-style tool/function definitions) and
|
|
142
|
+
// `options.toolChoice` are optional — when present, the request asks the
|
|
143
|
+
// provider to call a tool instead of (or in addition to) replying with text.
|
|
144
|
+
// `result.toolCalls` is the raw `message.tool_calls` array (or null), passed
|
|
145
|
+
// through unparsed so the agent loop can validate/dispatch each call itself.
|
|
146
|
+
// A tool-call-only response has no `content`, so the "[No assistant message
|
|
147
|
+
// returned...]" fallback only applies when there are no tool calls either.
|
|
148
|
+
async function chat(settings, messages, options) {
|
|
149
|
+
const baseUrl = String(settings.baseUrl || "").replace(/\/+$/, "");
|
|
150
|
+
if (!baseUrl) throw new Error("Provider base URL is required.");
|
|
151
|
+
if (!settings.model) throw new Error("Model is required.");
|
|
152
|
+
|
|
153
|
+
const headers = {};
|
|
154
|
+
if (settings.apiKey) {
|
|
155
|
+
headers.Authorization = `Bearer ${settings.apiKey}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const temperature = settings.temperature !== undefined ? Number(settings.temperature) : 0.2;
|
|
159
|
+
|
|
160
|
+
const body = {
|
|
161
|
+
model: settings.model,
|
|
162
|
+
messages,
|
|
163
|
+
temperature,
|
|
164
|
+
stream: false
|
|
165
|
+
};
|
|
166
|
+
if (options && Array.isArray(options.tools) && options.tools.length) {
|
|
167
|
+
body.tools = options.tools;
|
|
168
|
+
body.tool_choice = options.toolChoice || "auto";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const startedAt = Date.now();
|
|
172
|
+
const response = await postJson(`${baseUrl}/v1/chat/completions`, headers, body, 180000);
|
|
173
|
+
const totalMs = Date.now() - startedAt;
|
|
174
|
+
|
|
175
|
+
const message = response && response.choices && response.choices[0] && response.choices[0].message;
|
|
176
|
+
const content = message ? message.content : "";
|
|
177
|
+
const toolCalls = message && Array.isArray(message.tool_calls) && message.tool_calls.length
|
|
178
|
+
? message.tool_calls
|
|
179
|
+
: null;
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
raw: response,
|
|
183
|
+
content: content || (toolCalls ? "" : "[No assistant message returned by provider]"),
|
|
184
|
+
toolCalls: toolCalls,
|
|
185
|
+
timing: { totalMs },
|
|
186
|
+
usage: (response && response.usage) || null
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Streaming variant of chat(): requests `stream: true` and parses the
|
|
191
|
+
// OpenAI-compatible SSE format (lines `data: {...}`, terminated by
|
|
192
|
+
// `data: [DONE]`). Calls onDelta(text) for each content fragment as it
|
|
193
|
+
// arrives and resolves with { content } containing the full concatenated
|
|
194
|
+
// text once the stream ends. Used for chat streaming only.
|
|
195
|
+
function postStream(urlString, headers, body, timeoutMs, onDelta) {
|
|
196
|
+
return new Promise((resolve, reject) => {
|
|
197
|
+
let url;
|
|
198
|
+
try {
|
|
199
|
+
url = new URL(urlString);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
reject(new Error(`Invalid provider URL: ${urlString}`));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const payload = JSON.stringify(body);
|
|
206
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
207
|
+
const options = {
|
|
208
|
+
method: "POST",
|
|
209
|
+
hostname: url.hostname,
|
|
210
|
+
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
|
211
|
+
path: `${url.pathname}${url.search}`,
|
|
212
|
+
headers: Object.assign({
|
|
213
|
+
"Content-Type": "application/json",
|
|
214
|
+
"Content-Length": Buffer.byteLength(payload)
|
|
215
|
+
}, headers || {})
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const startedAt = Date.now();
|
|
219
|
+
let firstTokenAt = null;
|
|
220
|
+
let usage = null;
|
|
221
|
+
|
|
222
|
+
const req = transport.request(options, (res) => {
|
|
223
|
+
res.setEncoding("utf8");
|
|
224
|
+
|
|
225
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
226
|
+
let errData = "";
|
|
227
|
+
res.on("data", chunk => { errData += chunk; });
|
|
228
|
+
res.on("end", () => {
|
|
229
|
+
reject(new Error(`Provider request failed (${res.statusCode}): ${errData.slice(0, 500)}`));
|
|
230
|
+
});
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let buf = "";
|
|
235
|
+
let full = "";
|
|
236
|
+
res.on("data", (chunk) => {
|
|
237
|
+
buf += chunk;
|
|
238
|
+
const lines = buf.split("\n");
|
|
239
|
+
buf = lines.pop(); // keep the last (possibly partial) line for next time
|
|
240
|
+
|
|
241
|
+
lines.forEach((line) => {
|
|
242
|
+
line = line.trim();
|
|
243
|
+
if (!line.startsWith("data:")) { return; }
|
|
244
|
+
const dataStr = line.slice(5).trim();
|
|
245
|
+
if (!dataStr || dataStr === "[DONE]") { return; }
|
|
246
|
+
|
|
247
|
+
let evt;
|
|
248
|
+
try {
|
|
249
|
+
evt = JSON.parse(dataStr);
|
|
250
|
+
} catch (e) {
|
|
251
|
+
return; // ignore malformed/partial SSE chunk
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (evt && evt.usage) { usage = evt.usage; }
|
|
255
|
+
|
|
256
|
+
const delta = evt && evt.choices && evt.choices[0] && evt.choices[0].delta
|
|
257
|
+
? evt.choices[0].delta.content
|
|
258
|
+
: "";
|
|
259
|
+
if (delta) {
|
|
260
|
+
if (firstTokenAt === null) { firstTokenAt = Date.now(); }
|
|
261
|
+
full += delta;
|
|
262
|
+
onDelta(delta);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
res.on("end", () => {
|
|
267
|
+
const endedAt = Date.now();
|
|
268
|
+
resolve({
|
|
269
|
+
content: full,
|
|
270
|
+
ttftMs: firstTokenAt !== null ? firstTokenAt - startedAt : null,
|
|
271
|
+
totalMs: endedAt - startedAt,
|
|
272
|
+
usage: usage
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
req.on("error", reject);
|
|
278
|
+
req.setTimeout(timeoutMs || 180000, () => {
|
|
279
|
+
req.destroy(new Error(`Provider request timed out after ${timeoutMs || 180000}ms`));
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
req.write(payload);
|
|
283
|
+
req.end();
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function chatStream(settings, messages, onDelta) {
|
|
288
|
+
const baseUrl = String(settings.baseUrl || "").replace(/\/+$/, "");
|
|
289
|
+
if (!baseUrl) throw new Error("Provider base URL is required.");
|
|
290
|
+
if (!settings.model) throw new Error("Model is required.");
|
|
291
|
+
|
|
292
|
+
const headers = {};
|
|
293
|
+
if (settings.apiKey) {
|
|
294
|
+
headers.Authorization = `Bearer ${settings.apiKey}`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const temperature = settings.temperature !== undefined ? Number(settings.temperature) : 0.2;
|
|
298
|
+
|
|
299
|
+
// stream_options.include_usage asks OpenAI-compatible servers to emit a
|
|
300
|
+
// final SSE chunk carrying token usage (no delta) before [DONE]. Providers
|
|
301
|
+
// that don't support it just ignore the option; postStream treats a
|
|
302
|
+
// missing usage field as null either way.
|
|
303
|
+
const result = await postStream(`${baseUrl}/v1/chat/completions`, headers, {
|
|
304
|
+
model: settings.model,
|
|
305
|
+
messages,
|
|
306
|
+
temperature,
|
|
307
|
+
stream: true,
|
|
308
|
+
stream_options: { include_usage: true }
|
|
309
|
+
}, 180000, onDelta);
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
content: result.content || "",
|
|
313
|
+
timing: { ttftMs: result.ttftMs, totalMs: result.totalMs },
|
|
314
|
+
usage: result.usage
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------
|
|
319
|
+
// B-cap1: sends a minimal request with a trivial tool definition and a
|
|
320
|
+
// prompt that should trigger a tool call, to detect whether this provider
|
|
321
|
+
// supports OpenAI-style function/tool calling. Returns
|
|
322
|
+
// { supportsTools: boolean }, never throws — a provider that doesn't
|
|
323
|
+
// understand "tools" typically 400s on the request itself, which is a clean
|
|
324
|
+
// "no" for capability purposes (connectivity is checked separately by
|
|
325
|
+
// chat()).
|
|
326
|
+
//
|
|
327
|
+
// CAUTION: a pass here means the provider is ELIGIBLE for agentic
|
|
328
|
+
// tool-calling features, not a reliability guarantee — the agentic path must
|
|
329
|
+
// still handle malformed mid-conversation tool calls by falling back to the
|
|
330
|
+
// envelope for that turn.
|
|
331
|
+
// ---------------------------------------------------------------------
|
|
332
|
+
async function probeTools(settings) {
|
|
333
|
+
const baseUrl = String(settings.baseUrl || "").replace(/\/+$/, "");
|
|
334
|
+
if (!baseUrl) throw new Error("Provider base URL is required.");
|
|
335
|
+
if (!settings.model) throw new Error("Model is required.");
|
|
336
|
+
|
|
337
|
+
const headers = {};
|
|
338
|
+
if (settings.apiKey) {
|
|
339
|
+
headers.Authorization = `Bearer ${settings.apiKey}`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const tools = [{
|
|
343
|
+
type: "function",
|
|
344
|
+
function: {
|
|
345
|
+
name: "ping",
|
|
346
|
+
description: "Respond to a connectivity probe. Takes no arguments.",
|
|
347
|
+
parameters: { type: "object", properties: {}, additionalProperties: false }
|
|
348
|
+
}
|
|
349
|
+
}];
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
const response = await postJson(`${baseUrl}/v1/chat/completions`, headers, {
|
|
353
|
+
model: settings.model,
|
|
354
|
+
messages: [
|
|
355
|
+
{ role: "system", content: "You are being tested for tool/function-calling support." },
|
|
356
|
+
{ role: "user", content: "Call the \"ping\" tool now with no arguments. Do not reply with text." }
|
|
357
|
+
],
|
|
358
|
+
tools: tools,
|
|
359
|
+
tool_choice: "auto",
|
|
360
|
+
temperature: 0,
|
|
361
|
+
stream: false
|
|
362
|
+
}, 30000);
|
|
363
|
+
|
|
364
|
+
const message = response && response.choices && response.choices[0] && response.choices[0].message;
|
|
365
|
+
const toolCalls = message && Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
|
366
|
+
const wellFormed = toolCalls.length > 0 && toolCalls.every(function (call) {
|
|
367
|
+
return !!(call && call.function && typeof call.function.name === "string" &&
|
|
368
|
+
typeof call.function.arguments === "string");
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
return { supportsTools: wellFormed };
|
|
372
|
+
} catch (err) {
|
|
373
|
+
return { supportsTools: false, error: err.message };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
module.exports = { chat, chatStream, probeTools, listModels };
|
package/lib/storage.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
|
|
4
|
+
function ensureDir(dir) {
|
|
5
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function createStorage(userDir) {
|
|
9
|
+
const baseDir = path.join(userDir, "flowpilot");
|
|
10
|
+
const chatsDir = path.join(baseDir, "chats");
|
|
11
|
+
const backupsDir = path.join(baseDir, "backups");
|
|
12
|
+
const settingsFile = path.join(baseDir, "settings.json");
|
|
13
|
+
const auditFile = path.join(baseDir, "audit.log");
|
|
14
|
+
|
|
15
|
+
// A provider profile. Each has its own model since model names differ
|
|
16
|
+
// across providers (LocalAI vs cloud).
|
|
17
|
+
function defaultProvider() {
|
|
18
|
+
return {
|
|
19
|
+
id: "default",
|
|
20
|
+
providerName: "LocalAI",
|
|
21
|
+
baseUrl: "http://localhost:8080",
|
|
22
|
+
apiKey: "",
|
|
23
|
+
model: "",
|
|
24
|
+
temperature: 0.2
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const defaultSettings = {
|
|
29
|
+
// Multiple provider profiles; one is active at a time. The list shape is
|
|
30
|
+
// deliberately ready for a future side-by-side compare mode.
|
|
31
|
+
providers: [defaultProvider()],
|
|
32
|
+
activeProviderId: "default",
|
|
33
|
+
maxContextChars: 12000,
|
|
34
|
+
defaultContextMode: "selected",
|
|
35
|
+
allowConfigContext: false,
|
|
36
|
+
logFullContext: false,
|
|
37
|
+
streamingEnabled: true,
|
|
38
|
+
// First-run welcome/warning shows until the user saves settings once.
|
|
39
|
+
firstRunAcknowledged: false,
|
|
40
|
+
// Context-size advisory thresholds, in estimated tokens (~chars/4).
|
|
41
|
+
// Advisory only; never blocks sending.
|
|
42
|
+
contextWarnTokens: 4000,
|
|
43
|
+
contextHighTokens: 8000,
|
|
44
|
+
// How many recent chat exchanges (user+assistant pairs) the frontend
|
|
45
|
+
// includes as history with each request. Older turns are dropped
|
|
46
|
+
// client-side and the model is told when that happened.
|
|
47
|
+
historyMaxExchanges: 10,
|
|
48
|
+
// Lets the user silence the recurring secrets/size reminder bar after
|
|
49
|
+
// typing an explicit acknowledgement in settings.
|
|
50
|
+
suppressContextWarnings: false,
|
|
51
|
+
// User-defined intent buttons: array of { label, text }.
|
|
52
|
+
customIntents: [],
|
|
53
|
+
systemPrompt: require("./default-system-prompt")
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Older builds persisted a "Phase 1, READ-ONLY mode" system prompt into
|
|
57
|
+
// settings.json once and never updated it — Object.assign in getSettings
|
|
58
|
+
// lets that stale persisted copy win forever, even after
|
|
59
|
+
// default-system-prompt.js is fixed. Detect that stale text (by a phrase
|
|
60
|
+
// unique to it) and swap in the current default instead. Applied both when
|
|
61
|
+
// reading settings AND when saving them, so a browser tab that still has
|
|
62
|
+
// the stale text loaded in the System Prompt textarea can't re-persist it.
|
|
63
|
+
function fixStaleSystemPrompt(systemPrompt) {
|
|
64
|
+
if (typeof systemPrompt !== "string" || !systemPrompt.trim()) {
|
|
65
|
+
return defaultSettings.systemPrompt;
|
|
66
|
+
}
|
|
67
|
+
if (systemPrompt.indexOf("operating in READ-ONLY mode") !== -1) {
|
|
68
|
+
return defaultSettings.systemPrompt;
|
|
69
|
+
}
|
|
70
|
+
return systemPrompt;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Migrate an old flat-provider settings object (providerName/baseUrl/etc at
|
|
74
|
+
// top level) into the new providers-list shape, preserving the user's
|
|
75
|
+
// configured provider. Idempotent: leaves new-format settings untouched.
|
|
76
|
+
function migrate(parsed) {
|
|
77
|
+
if (!parsed || typeof parsed !== "object") { return parsed; }
|
|
78
|
+
if (Array.isArray(parsed.providers) && parsed.providers.length) {
|
|
79
|
+
return parsed; // already new format
|
|
80
|
+
}
|
|
81
|
+
if (parsed.providerName || parsed.baseUrl || parsed.model) {
|
|
82
|
+
const migrated = Object.assign({}, parsed);
|
|
83
|
+
migrated.providers = [{
|
|
84
|
+
id: "default",
|
|
85
|
+
providerName: parsed.providerName || "LocalAI",
|
|
86
|
+
baseUrl: parsed.baseUrl || "http://localhost:8080",
|
|
87
|
+
apiKey: parsed.apiKey || "",
|
|
88
|
+
model: parsed.model || "",
|
|
89
|
+
temperature: parsed.temperature !== undefined ? parsed.temperature : 0.2
|
|
90
|
+
}];
|
|
91
|
+
migrated.activeProviderId = "default";
|
|
92
|
+
// Remove the now-relocated flat fields.
|
|
93
|
+
delete migrated.providerName;
|
|
94
|
+
delete migrated.baseUrl;
|
|
95
|
+
delete migrated.apiKey;
|
|
96
|
+
delete migrated.model;
|
|
97
|
+
delete migrated.temperature;
|
|
98
|
+
return migrated;
|
|
99
|
+
}
|
|
100
|
+
return parsed;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Returns the currently active provider profile (or the first, or a default).
|
|
104
|
+
function getActiveProvider(settings) {
|
|
105
|
+
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];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function init() {
|
|
112
|
+
ensureDir(baseDir);
|
|
113
|
+
ensureDir(chatsDir);
|
|
114
|
+
ensureDir(backupsDir);
|
|
115
|
+
|
|
116
|
+
if (!fs.existsSync(settingsFile)) {
|
|
117
|
+
fs.writeFileSync(settingsFile, JSON.stringify(defaultSettings, null, 2), "utf8");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!fs.existsSync(auditFile)) {
|
|
121
|
+
fs.writeFileSync(auditFile, "", "utf8");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function getSettings() {
|
|
126
|
+
init();
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const raw = fs.readFileSync(settingsFile, "utf8");
|
|
130
|
+
const parsed = migrate(JSON.parse(raw));
|
|
131
|
+
// Merge top-level app settings with defaults, but take the providers
|
|
132
|
+
// list verbatim from the file (don't let defaults overwrite it).
|
|
133
|
+
const merged = Object.assign({}, defaultSettings, parsed);
|
|
134
|
+
if (Array.isArray(parsed.providers) && parsed.providers.length) {
|
|
135
|
+
merged.providers = parsed.providers;
|
|
136
|
+
}
|
|
137
|
+
merged.systemPrompt = fixStaleSystemPrompt(merged.systemPrompt);
|
|
138
|
+
return merged;
|
|
139
|
+
} catch (err) {
|
|
140
|
+
return Object.assign({}, defaultSettings, {
|
|
141
|
+
_error: err.message
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function saveSettings(settings) {
|
|
147
|
+
init();
|
|
148
|
+
|
|
149
|
+
let current = {};
|
|
150
|
+
try {
|
|
151
|
+
const raw = fs.readFileSync(settingsFile, "utf8");
|
|
152
|
+
current = migrate(JSON.parse(raw));
|
|
153
|
+
} catch (err) {
|
|
154
|
+
current = {};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const merged = Object.assign({}, defaultSettings, current, settings || {});
|
|
158
|
+
merged.systemPrompt = fixStaleSystemPrompt(merged.systemPrompt);
|
|
159
|
+
delete merged._error;
|
|
160
|
+
// If the caller sent a providers list, it wins outright (Object.assign
|
|
161
|
+
// already did this, but be explicit for clarity/safety).
|
|
162
|
+
if (settings && Array.isArray(settings.providers)) {
|
|
163
|
+
merged.providers = settings.providers;
|
|
164
|
+
}
|
|
165
|
+
// Saving settings is an explicit user action; mark first-run complete so
|
|
166
|
+
// the welcome/warning stops showing.
|
|
167
|
+
merged.firstRunAcknowledged = true;
|
|
168
|
+
|
|
169
|
+
fs.writeFileSync(settingsFile, JSON.stringify(merged, null, 2), "utf8");
|
|
170
|
+
|
|
171
|
+
return merged;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function appendAudit(entry) {
|
|
175
|
+
init();
|
|
176
|
+
|
|
177
|
+
const line = JSON.stringify({
|
|
178
|
+
timestamp: new Date().toISOString(),
|
|
179
|
+
...entry
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
fs.appendFileSync(auditFile, line + "\n", "utf8");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Per-conversation transcripts: one JSON Lines file per conversation,
|
|
186
|
+
// keyed by a frontend-generated conversationId. Callers must pass an
|
|
187
|
+
// already-validated id (flowpilot.js's sanitizeConversationId) — this is
|
|
188
|
+
// just file I/O.
|
|
189
|
+
function transcriptFile(conversationId) {
|
|
190
|
+
return path.join(chatsDir, `${conversationId}.jsonl`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function appendTranscript(conversationId, entry) {
|
|
194
|
+
init();
|
|
195
|
+
fs.appendFileSync(transcriptFile(conversationId), JSON.stringify(entry) + "\n", "utf8");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Removes a conversation's transcript file (e.g. user deletes it from the
|
|
199
|
+
// conversation list). Best-effort — a missing file is not an error.
|
|
200
|
+
function deleteTranscript(conversationId) {
|
|
201
|
+
init();
|
|
202
|
+
try { fs.unlinkSync(transcriptFile(conversationId)); } catch (err) { /* already gone */ }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function readTranscript(conversationId) {
|
|
206
|
+
init();
|
|
207
|
+
const file = transcriptFile(conversationId);
|
|
208
|
+
if (!fs.existsSync(file)) { return []; }
|
|
209
|
+
|
|
210
|
+
let raw;
|
|
211
|
+
try {
|
|
212
|
+
raw = fs.readFileSync(file, "utf8");
|
|
213
|
+
} catch (err) {
|
|
214
|
+
return [];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return raw.split("\n").filter(Boolean).map(function (line) {
|
|
218
|
+
try { return JSON.parse(line); } catch (err) { return null; }
|
|
219
|
+
}).filter(Boolean);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Used by Recall to search across OTHER conversations' transcripts. Lists
|
|
223
|
+
// every persisted conversationId (one per chats/*.jsonl file).
|
|
224
|
+
function listConversationIds() {
|
|
225
|
+
init();
|
|
226
|
+
let files;
|
|
227
|
+
try {
|
|
228
|
+
files = fs.readdirSync(chatsDir);
|
|
229
|
+
} catch (err) {
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
return files
|
|
233
|
+
.filter(function (f) { return f.endsWith(".jsonl"); })
|
|
234
|
+
.map(function (f) { return f.slice(0, -6); });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Always the CURRENT contents of lib/default-system-prompt.js — never the
|
|
238
|
+
// stale copy that may be persisted in settings.json. Lets the Settings UI
|
|
239
|
+
// offer a "Reset to default" action that picks up prompt updates shipped
|
|
240
|
+
// in later FlowPilot versions, even though a snapshot was saved once.
|
|
241
|
+
function getDefaultSystemPrompt() {
|
|
242
|
+
return defaultSettings.systemPrompt;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
init();
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
baseDir,
|
|
249
|
+
chatsDir,
|
|
250
|
+
backupsDir,
|
|
251
|
+
settingsFile,
|
|
252
|
+
auditFile,
|
|
253
|
+
getSettings,
|
|
254
|
+
saveSettings,
|
|
255
|
+
getActiveProvider,
|
|
256
|
+
getDefaultSystemPrompt,
|
|
257
|
+
appendAudit,
|
|
258
|
+
appendTranscript,
|
|
259
|
+
readTranscript,
|
|
260
|
+
deleteTranscript,
|
|
261
|
+
listConversationIds
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
module.exports = createStorage;
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@manny-est/node-red-flowpilot",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "FlowPilot for Node-RED - an AI-powered development assistant sidebar",
|
|
5
|
+
"main": "flowpilot.js",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"node-red",
|
|
8
|
+
"ai",
|
|
9
|
+
"localai",
|
|
10
|
+
"openai",
|
|
11
|
+
"flowpilot"
|
|
12
|
+
],
|
|
13
|
+
"author": "manny-est",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/manny-est/flowpilot.git"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/manny-est/flowpilot#readme",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/manny-est/flowpilot/issues"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"node-red": {
|
|
27
|
+
"version": ">=4.0.0",
|
|
28
|
+
"nodes": {
|
|
29
|
+
"flowpilot": "flowpilot.js"
|
|
30
|
+
},
|
|
31
|
+
"plugins": {
|
|
32
|
+
"flowpilot": "flowpilot.html"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=16"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"flowpilot.js",
|
|
40
|
+
"flowpilot.html",
|
|
41
|
+
"lib",
|
|
42
|
+
"icons",
|
|
43
|
+
"examples",
|
|
44
|
+
"README.md",
|
|
45
|
+
"USER-GUIDE.md",
|
|
46
|
+
"PROJECT-OVERVIEW.md",
|
|
47
|
+
"LICENSE"
|
|
48
|
+
]
|
|
49
|
+
}
|