@manny-est/node-red-flowpilot 0.4.1 → 0.5.0
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 +27 -0
- package/PROJECT-OVERVIEW.md +7 -7
- package/README.md +14 -8
- package/USER-GUIDE.md +27 -10
- package/flowpilot-core.css +44 -0
- package/flowpilot.js +38 -135
- package/lib/build-core-script.js +44 -0
- package/lib/build-system-prompt.js +6 -0
- package/lib/core/apply-review.js +1677 -0
- package/lib/core/history.js +93 -0
- package/lib/core/init.js +1510 -0
- package/lib/core/main.js +1710 -0
- package/lib/core/markdown.js +165 -0
- package/lib/core/modes.js +1567 -0
- package/lib/core/redaction.js +165 -0
- package/lib/core/selection-context.js +211 -0
- package/lib/envelope.js +136 -0
- package/lib/modify-system-prompt.js +3 -1
- package/lib/storage.js +4 -0
- package/package.json +1 -2
- package/flowpilot-core.js +0 -6427
package/lib/core/main.js
ADDED
|
@@ -0,0 +1,1710 @@
|
|
|
1
|
+
|
|
2
|
+
var VERSION = "0.5.0";
|
|
3
|
+
|
|
4
|
+
// Idempotency guard: Node-RED can invoke a plugin's onadd more than once
|
|
5
|
+
// in a single editor load. Without this, each call builds another #fp-root
|
|
6
|
+
// and we end up with an orphaned detached copy alongside the live one.
|
|
7
|
+
var initialised = false;
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------
|
|
10
|
+
// All state and DOM references are scoped to this closure.
|
|
11
|
+
// We look elements up *within the inserted content* ($root.find(...))
|
|
12
|
+
// rather than document.getElementById, so we are never coupled to global
|
|
13
|
+
// IDs and never depend on global assignment timing.
|
|
14
|
+
// ---------------------------------------------------------------------
|
|
15
|
+
var $root = null;
|
|
16
|
+
|
|
17
|
+
// Pop-out window (Phase 8.5 C1, v1 review-only): a detached browser
|
|
18
|
+
// window showing a read-only mirror of the chat thread. null when no
|
|
19
|
+
// pop-out is open. The same flowpilot-core.js loads in that window too
|
|
20
|
+
// (see initPopout) — this var is only ever non-null in the MAIN
|
|
21
|
+
// window's own execution context.
|
|
22
|
+
var popoutWindow = null;
|
|
23
|
+
var popoutObserver = null;
|
|
24
|
+
|
|
25
|
+
// True only inside the pop-out window's OWN execution context (set at
|
|
26
|
+
// the top of initPopout — never true in the main window). Checked in
|
|
27
|
+
// the few places that would otherwise touch dead RED.* state: the
|
|
28
|
+
// final dispatch in dispatchSend() and the /compact+/expand case in
|
|
29
|
+
// handleSlashCommand(). Everything else (arming, slash-command text,
|
|
30
|
+
// settings) is pure local state and needs no flag at all.
|
|
31
|
+
var isPopoutContext = false;
|
|
32
|
+
|
|
33
|
+
// Holds the most recently loaded settings so warning logic can read the
|
|
34
|
+
// user's thresholds and suppression preference without refetching.
|
|
35
|
+
var currentSettings = {};
|
|
36
|
+
|
|
37
|
+
// JSON snapshot of collectSettings() as of the last load/save, used by
|
|
38
|
+
// the explicit Save button to tell "no changes" from "saved" without
|
|
39
|
+
// hitting the backend for a no-op write.
|
|
40
|
+
var savedSettingsSnapshot = null;
|
|
41
|
+
var saveStatusTimer = null;
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------
|
|
44
|
+
// Live debug-message context: a rolling local-only buffer of recent
|
|
45
|
+
// Node-RED Debug sidebar output (populated via RED.comms "debug"
|
|
46
|
+
// subscription, see plugin onadd), plus the subset the user has
|
|
47
|
+
// explicitly attached as context for upcoming requests — "select it like
|
|
48
|
+
// Recall messages" (sticky, like conversationHistory, until removed or
|
|
49
|
+
// Clear Chat). Nothing here is sent anywhere until attached AND a
|
|
50
|
+
// request is sent.
|
|
51
|
+
// ---------------------------------------------------------------------
|
|
52
|
+
var DEBUG_BUFFER_MAX = 50;
|
|
53
|
+
// Two different caps for two different jobs. PREVIEW is just for the
|
|
54
|
+
// scannable debug-log list (many short entries). SEND is what actually
|
|
55
|
+
// gets attached/transmitted — much higher, because a value truncated
|
|
56
|
+
// mid-JSON at 500 chars (e.g. cut inside a string or property name) can
|
|
57
|
+
// arrive at the model as malformed JSON, which looks indistinguishable
|
|
58
|
+
// from "the model ignored the attached data".
|
|
59
|
+
var DEBUG_VALUE_PREVIEW_MAX_CHARS = 500;
|
|
60
|
+
var DEBUG_VALUE_SEND_MAX_CHARS = 20000;
|
|
61
|
+
var debugMessageBuffer = [];
|
|
62
|
+
var attachedDebugMessages = [];
|
|
63
|
+
var nextDebugMessageId = 1;
|
|
64
|
+
|
|
65
|
+
// RED.comms.subscribe callbacks receive (topic, msg) — the topic is
|
|
66
|
+
// always "debug" here since that's the only topic we subscribed to.
|
|
67
|
+
//
|
|
68
|
+
// Secrets are redacted HERE, at capture time, before anything enters
|
|
69
|
+
// debugMessageBuffer — the raw value is never buffered, let alone sent.
|
|
70
|
+
// Redact first, then truncate, so a secret can't survive by being cut
|
|
71
|
+
// off mid-value rather than recognized and replaced.
|
|
72
|
+
function onDebugMessage(topic, msg) {
|
|
73
|
+
if (!msg) { return; }
|
|
74
|
+
var redactedValue = redactDebugValue(msg.msg, undefined);
|
|
75
|
+
var redactedTopic = redactDebugValue(msg.topic || "", undefined);
|
|
76
|
+
var stringified = stringifyDebugValue(redactedValue);
|
|
77
|
+
var entry = {
|
|
78
|
+
id: nextDebugMessageId++,
|
|
79
|
+
timestamp: Date.now(),
|
|
80
|
+
name: msg.name || msg.id || "(unnamed node)",
|
|
81
|
+
topic: redactedTopic,
|
|
82
|
+
// previewValue: short, for the scannable debug-log list only.
|
|
83
|
+
// value: the much-less-truncated version that actually gets
|
|
84
|
+
// attached/sent and shown by "Preview debug" — never the raw
|
|
85
|
+
// unredacted value either way.
|
|
86
|
+
previewValue: truncateForDebug(stringified, DEBUG_VALUE_PREVIEW_MAX_CHARS),
|
|
87
|
+
value: truncateForDebug(stringified, DEBUG_VALUE_SEND_MAX_CHARS)
|
|
88
|
+
};
|
|
89
|
+
debugMessageBuffer.push(entry);
|
|
90
|
+
if (debugMessageBuffer.length > DEBUG_BUFFER_MAX) {
|
|
91
|
+
debugMessageBuffer = debugMessageBuffer.slice(-DEBUG_BUFFER_MAX);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// /build loop: auto-attach is gated to debug messages from a debug
|
|
95
|
+
// NODE THE LOOP ITSELF BUILT (msg.id is the emitting debug node's
|
|
96
|
+
// own id — see core 21-debug.js's sendDebug({id: node.id, ...})),
|
|
97
|
+
// not just whatever debug message happens to arrive next. Without
|
|
98
|
+
// this, an unrelated debug node firing elsewhere in the workspace
|
|
99
|
+
// (a different flow tab, a startup error, etc.) could win the race
|
|
100
|
+
// and get reviewed instead of the flow this loop actually built —
|
|
101
|
+
// confirmed live: a Home Assistant node's unrelated error got
|
|
102
|
+
// auto-attached and "reviewed" instead of the real output.
|
|
103
|
+
//
|
|
104
|
+
// Debounced rather than reviewing on the FIRST matching message —
|
|
105
|
+
// confirmed live: a generated flow whose wiring forked/split before
|
|
106
|
+
// the debug node fired it more than once per trigger, and the
|
|
107
|
+
// review judged success against only the first (incomplete)
|
|
108
|
+
// message, missing the goal entirely. A short window lets EVERY
|
|
109
|
+
// message from one trigger accumulate into attachedDebugMessages
|
|
110
|
+
// before review actually runs, so the model sees the full picture
|
|
111
|
+
// instead of whichever message happened to arrive first.
|
|
112
|
+
if (activeBuildLoop && activeBuildLoop.waypoint === "attach" &&
|
|
113
|
+
activeBuildLoop.nodeIds.indexOf(msg.id) !== -1) {
|
|
114
|
+
attachedDebugMessages.push(entry);
|
|
115
|
+
updateDebugStatus();
|
|
116
|
+
if (buildLoopNoDebugTimer) { clearTimeout(buildLoopNoDebugTimer); buildLoopNoDebugTimer = null; }
|
|
117
|
+
if (buildLoopAttachTimer) { clearTimeout(buildLoopAttachTimer); }
|
|
118
|
+
buildLoopAttachTimer = setTimeout(function () {
|
|
119
|
+
buildLoopAttachTimer = null;
|
|
120
|
+
if (!activeBuildLoop || activeBuildLoop.waypoint !== "attach") { return; }
|
|
121
|
+
activeBuildLoop.waypoint = "review";
|
|
122
|
+
renderLoopStepper(activeBuildLoop);
|
|
123
|
+
if (currentSettings.loopHoldStep) {
|
|
124
|
+
renderLoopCheckpoint(activeBuildLoop);
|
|
125
|
+
} else {
|
|
126
|
+
runBuildReview(activeBuildLoop);
|
|
127
|
+
}
|
|
128
|
+
}, BUILD_LOOP_ATTACH_DEBOUNCE_MS);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The exact shape sent to the backend (and shown by "Preview debug") —
|
|
133
|
+
// excludes previewValue, which exists only for the debug-log list.
|
|
134
|
+
function buildDebugMessagesForSend() {
|
|
135
|
+
return attachedDebugMessages.map(function (m) {
|
|
136
|
+
return { id: m.id, timestamp: m.timestamp, name: m.name, topic: m.topic, value: m.value };
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Merges any attached debug messages into a request's context object —
|
|
141
|
+
// called right after collectSelectionContext() in send/generate/
|
|
142
|
+
// documentFlow/modifyFlow. Leaves context untouched (including null) when
|
|
143
|
+
// nothing is attached.
|
|
144
|
+
function attachDebugContext(context) {
|
|
145
|
+
if (!attachedDebugMessages.length) { return context; }
|
|
146
|
+
context = context || { nodes: [], connections: {} };
|
|
147
|
+
return Object.assign({}, context, { debugMessages: buildDebugMessagesForSend() });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Updates the "🐛 N debug message(s) attached" indicator in the context
|
|
151
|
+
// strip, shown only when something is attached.
|
|
152
|
+
function updateDebugStatus() {
|
|
153
|
+
var $status = el("#fp-debug-status");
|
|
154
|
+
if (!$status.length) { return; }
|
|
155
|
+
if (!attachedDebugMessages.length) {
|
|
156
|
+
$status.addClass("fp-hidden").empty();
|
|
157
|
+
relayStatusStripToPopout();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
$status.removeClass("fp-hidden").empty();
|
|
161
|
+
var n = attachedDebugMessages.length;
|
|
162
|
+
$status.append(document.createTextNode("🐛 " + n + " debug message" + (n === 1 ? "" : "s") + " attached "));
|
|
163
|
+
var $preview = $("<a>").attr("href", "#").text("preview").attr("title", "Show the exact debug payload that will be sent");
|
|
164
|
+
$preview.on("click", function (ev) {
|
|
165
|
+
ev.preventDefault();
|
|
166
|
+
showJsonPreview("Debug payload preview — exactly what will be sent", buildDebugMessagesForSend());
|
|
167
|
+
});
|
|
168
|
+
$status.append($preview).append(document.createTextNode(" "));
|
|
169
|
+
var $clear = $("<a>").attr("href", "#").text("✕").attr("title", "Remove all attached debug messages");
|
|
170
|
+
$clear.on("click", function (ev) {
|
|
171
|
+
ev.preventDefault();
|
|
172
|
+
attachedDebugMessages = [];
|
|
173
|
+
updateDebugStatus();
|
|
174
|
+
});
|
|
175
|
+
$status.append($clear);
|
|
176
|
+
relayStatusStripToPopout();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Diagnostic tool: dumps `data` as a fenced JSON code block into the chat
|
|
180
|
+
// thread (UI-only — never added to conversationHistory). Lets the user
|
|
181
|
+
// see exactly what a request would carry, instead of guessing whether
|
|
182
|
+
// the model received it or silently ignored it.
|
|
183
|
+
function showJsonPreview(title, data) {
|
|
184
|
+
addMessage("assistant", "**" + title + "**\n\n```json\n" + JSON.stringify(data, null, 2) + "\n```");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function getAgentLoopMaxIterations() {
|
|
188
|
+
var n = Number(currentSettings.agentLoopMaxIterations);
|
|
189
|
+
return (isFinite(n) && n >= 1) ? n : 5;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Which Execute action (if any) Send currently triggers. null = ordinary
|
|
193
|
+
// chat. Only one Execute action can be armed at a time — clicking an
|
|
194
|
+
// armed one again disarms it back to chat.
|
|
195
|
+
var armedExecuteAction = null;
|
|
196
|
+
|
|
197
|
+
// ids of the selection PINNED for the current armed session. Set when
|
|
198
|
+
// arming with a selection, or refreshed whenever the live selection
|
|
199
|
+
// changes while armed (non-empty only — deselecting keeps the pin so
|
|
200
|
+
// follow-up turns need no reselection). Cleared on disarm/Clear Chat.
|
|
201
|
+
var pinnedSelectionIds = null;
|
|
202
|
+
|
|
203
|
+
function disarmExecuteAction() {
|
|
204
|
+
if (!armedExecuteAction) { return; }
|
|
205
|
+
armedExecuteAction = null;
|
|
206
|
+
pinnedSelectionIds = null;
|
|
207
|
+
el("#fp-generate").removeClass("fp-action-armed");
|
|
208
|
+
el("#fp-document").removeClass("fp-action-armed");
|
|
209
|
+
el("#fp-modify").removeClass("fp-action-armed");
|
|
210
|
+
el("#fp-send").text("Send").removeClass("fp-send-armed");
|
|
211
|
+
el(".fp-compose").removeClass("fp-mode-execute");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function setArmedExecuteAction(action) {
|
|
215
|
+
if (armedExecuteAction === action) { disarmExecuteAction(); return; }
|
|
216
|
+
disarmQueryIntent();
|
|
217
|
+
armedExecuteAction = action;
|
|
218
|
+
pinCurrentSelection();
|
|
219
|
+
el("#fp-generate").toggleClass("fp-action-armed", armedExecuteAction === "generate");
|
|
220
|
+
el("#fp-document").toggleClass("fp-action-armed", armedExecuteAction === "document");
|
|
221
|
+
el("#fp-modify").toggleClass("fp-action-armed", armedExecuteAction === "modify");
|
|
222
|
+
var label = "Send";
|
|
223
|
+
if (armedExecuteAction === "generate") { label = "Send (Generate)"; }
|
|
224
|
+
else if (armedExecuteAction === "document") { label = "Send (Document)"; }
|
|
225
|
+
else if (armedExecuteAction === "modify") { label = "Send (Modify)"; }
|
|
226
|
+
else if (armedExecuteAction === "build") { label = "Send (Build)"; }
|
|
227
|
+
el("#fp-send").text(label).addClass("fp-send-armed");
|
|
228
|
+
el(".fp-compose").addClass("fp-mode-execute");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Arms the given Execute action regardless of current
|
|
232
|
+
// state. Unlike setArmedExecuteAction (which TOGGLES an already-armed
|
|
233
|
+
// action back off), an action chip always means "switch to this mode" —
|
|
234
|
+
// never "disarm."
|
|
235
|
+
function armExecuteAction(action) {
|
|
236
|
+
// A "chat" suggestion means "switch back to ordinary chat" — there's
|
|
237
|
+
// no Execute button for it, so disarm whatever's currently armed
|
|
238
|
+
// instead of trying to set armedExecuteAction to "chat".
|
|
239
|
+
if (action === "chat") { disarmExecuteAction(); return; }
|
|
240
|
+
if (armedExecuteAction === action) { return; }
|
|
241
|
+
setArmedExecuteAction(action);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Which Query intent (if any) is currently armed: lit button + the
|
|
245
|
+
// amber "mode readout" border on #fp-prompt, mirroring the Execute
|
|
246
|
+
// side's blue treatment. Identified by label since that's already
|
|
247
|
+
// unique across built-in and custom intents (addCustomIntent enforces
|
|
248
|
+
// it). One-shot — dispatchSend() disarms it, since Query intents are
|
|
249
|
+
// just templated chat messages with no backend mode of their own.
|
|
250
|
+
var armedQueryIntentLabel = null;
|
|
251
|
+
var $armedQueryButton = null;
|
|
252
|
+
|
|
253
|
+
function disarmQueryIntent() {
|
|
254
|
+
if (!armedQueryIntentLabel) { return; }
|
|
255
|
+
armedQueryIntentLabel = null;
|
|
256
|
+
if ($armedQueryButton) { $armedQueryButton.removeClass("fp-action-armed"); }
|
|
257
|
+
$armedQueryButton = null;
|
|
258
|
+
el(".fp-compose").removeClass("fp-mode-query");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function armQueryIntent(intent, $btn) {
|
|
262
|
+
if (armedQueryIntentLabel === intent.label) { disarmQueryIntent(); return; }
|
|
263
|
+
disarmExecuteAction();
|
|
264
|
+
disarmQueryIntent();
|
|
265
|
+
armedQueryIntentLabel = intent.label;
|
|
266
|
+
$armedQueryButton = $btn || null;
|
|
267
|
+
if ($armedQueryButton) { $armedQueryButton.addClass("fp-action-armed"); }
|
|
268
|
+
el(".fp-compose").addClass("fp-mode-query");
|
|
269
|
+
applyIntentText(intent.text);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---- Intent modes ------------------------------------------------------
|
|
273
|
+
// Single source of truth for the one-click intents. Each button is
|
|
274
|
+
// generated from this list. Clicking a button fills the prompt box with
|
|
275
|
+
// the instruction (the user can then edit before sending). Intent is
|
|
276
|
+
// deliberately kept separate from "scope" (what gets sent) so future
|
|
277
|
+
// scope modes (selected flow, entire instance) slot in without touching
|
|
278
|
+
// this. To add an intent, add an entry here.
|
|
279
|
+
var INTENTS = [
|
|
280
|
+
{
|
|
281
|
+
id: "explain",
|
|
282
|
+
label: "Explain",
|
|
283
|
+
text: "Explain what this selection does, step by step, in plain " +
|
|
284
|
+
"language. Describe the message path and what each node " +
|
|
285
|
+
"contributes."
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
id: "troubleshoot",
|
|
289
|
+
label: "Troubleshoot",
|
|
290
|
+
text: "Help diagnose why this selection may not be working as " +
|
|
291
|
+
"intended. Point out disabled nodes, dead-end wires, outputs " +
|
|
292
|
+
"that never fire, and likely misconfigurations. Be specific " +
|
|
293
|
+
"about what you can and cannot see."
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
id: "review",
|
|
297
|
+
label: "Review",
|
|
298
|
+
text: "Review this selection as an architecture and design " +
|
|
299
|
+
"critique: coupling, missing error handling, fragile " +
|
|
300
|
+
"patterns, and concrete suggestions to improve " +
|
|
301
|
+
"maintainability."
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
id: "suggest",
|
|
305
|
+
label: "Suggest",
|
|
306
|
+
text: "Suggest improvements or relevant Node-RED nodes that would " +
|
|
307
|
+
"make this selection better, simpler, or more robust."
|
|
308
|
+
}
|
|
309
|
+
];
|
|
310
|
+
|
|
311
|
+
function applyIntentText(text) {
|
|
312
|
+
if (!text) { return; }
|
|
313
|
+
// Replace rather than append/prepend: appending let alternating
|
|
314
|
+
// clicks between two intents stack both texts repeatedly (each click
|
|
315
|
+
// only guarded against re-adding ITSELF, not the other one already
|
|
316
|
+
// in the box). A clean replace is simple and predictable — use the
|
|
317
|
+
// clear button (✕) if you want an empty box again.
|
|
318
|
+
var $box = el("#fp-prompt");
|
|
319
|
+
$box.val(text);
|
|
320
|
+
$box.focus();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Built-in intents + user-defined customIntents from settings. Custom
|
|
324
|
+
// intents are { label, text } objects persisted in settings.json.
|
|
325
|
+
function getAllIntents() {
|
|
326
|
+
var custom = Array.isArray(currentSettings.customIntents)
|
|
327
|
+
? currentSettings.customIntents : [];
|
|
328
|
+
var builtin = INTENTS.map(function (i) {
|
|
329
|
+
return { id: i.id, label: i.label, text: i.text, custom: false };
|
|
330
|
+
});
|
|
331
|
+
var user = custom.filter(function (c) {
|
|
332
|
+
return c && c.label && c.text;
|
|
333
|
+
}).map(function (c) {
|
|
334
|
+
return { label: c.label, text: c.text, custom: true };
|
|
335
|
+
});
|
|
336
|
+
return builtin.concat(user);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Custom query actions beyond this count collapse into a "…" dropdown so
|
|
340
|
+
// the action bar doesn't grow without bound as users add more.
|
|
341
|
+
var INLINE_CUSTOM_INTENT_LIMIT = 2;
|
|
342
|
+
|
|
343
|
+
// Cockpit pass: built-in Query intents render as icon buttons (tooltip
|
|
344
|
+
// carries the label + template text, same as before). Custom intents
|
|
345
|
+
// keep their text label — an icon would be ambiguous for an
|
|
346
|
+
// arbitrary user-defined button.
|
|
347
|
+
var QUERY_INTENT_ICONS = {
|
|
348
|
+
explain: "fa-question-circle",
|
|
349
|
+
troubleshoot: "fa-wrench",
|
|
350
|
+
review: "fa-list-alt",
|
|
351
|
+
suggest: "fa-lightbulb-o"
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
function renderIntents($container) {
|
|
355
|
+
if (!$container || !$container.length) { $container = el("#fp-intents"); }
|
|
356
|
+
if (!$container.length) { return; }
|
|
357
|
+
$container.empty();
|
|
358
|
+
|
|
359
|
+
function addIntentButton(intent) {
|
|
360
|
+
var icon = !intent.custom && QUERY_INTENT_ICONS[intent.id];
|
|
361
|
+
var $btn = $("<button>")
|
|
362
|
+
.addClass("red-ui-button red-ui-button-small fp-intent-btn")
|
|
363
|
+
.toggleClass("fp-intent-custom", !!intent.custom)
|
|
364
|
+
.toggleClass("fp-icon-btn fp-icon-btn-query", !!icon)
|
|
365
|
+
.toggleClass("fp-action-armed", armedQueryIntentLabel === intent.label)
|
|
366
|
+
.attr("type", "button")
|
|
367
|
+
.attr("title", icon ? (intent.label + " — " + intent.text) : intent.text)
|
|
368
|
+
.on("click", function () { armQueryIntent(intent, $btn); })
|
|
369
|
+
.appendTo($container);
|
|
370
|
+
if (icon) {
|
|
371
|
+
$("<i>").addClass("fa " + icon).appendTo($btn);
|
|
372
|
+
} else {
|
|
373
|
+
$btn.text(intent.label);
|
|
374
|
+
}
|
|
375
|
+
// Re-render (e.g. after editing custom intents) can recreate the
|
|
376
|
+
// armed button — keep the tracked reference pointing at the live
|
|
377
|
+
// element so disarmQueryIntent() can still find it.
|
|
378
|
+
if (armedQueryIntentLabel === intent.label) { $armedQueryButton = $btn; }
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
var all = getAllIntents();
|
|
382
|
+
var builtin = all.filter(function (i) { return !i.custom; });
|
|
383
|
+
var custom = all.filter(function (i) { return i.custom; });
|
|
384
|
+
|
|
385
|
+
builtin.forEach(addIntentButton);
|
|
386
|
+
custom.slice(0, INLINE_CUSTOM_INTENT_LIMIT).forEach(addIntentButton);
|
|
387
|
+
|
|
388
|
+
var overflow = custom.slice(INLINE_CUSTOM_INTENT_LIMIT);
|
|
389
|
+
if (overflow.length) {
|
|
390
|
+
var $menu = $("<div>").addClass("fp-intent-menu fp-hidden");
|
|
391
|
+
overflow.forEach(function (intent) {
|
|
392
|
+
$("<a>")
|
|
393
|
+
.attr("href", "#")
|
|
394
|
+
.attr("title", intent.text)
|
|
395
|
+
.text(intent.label)
|
|
396
|
+
.on("click", function (e) {
|
|
397
|
+
e.preventDefault();
|
|
398
|
+
$menu.addClass("fp-hidden");
|
|
399
|
+
armQueryIntent(intent, null);
|
|
400
|
+
})
|
|
401
|
+
.appendTo($menu);
|
|
402
|
+
});
|
|
403
|
+
var $toggle = $("<button>")
|
|
404
|
+
.addClass("red-ui-button red-ui-button-small fp-intent-more fp-icon-btn fp-icon-btn-query")
|
|
405
|
+
.attr("type", "button")
|
|
406
|
+
.attr("title", "More query actions")
|
|
407
|
+
.append($("<i>").addClass("fa fa-ellipsis-h"))
|
|
408
|
+
.on("click", function (e) {
|
|
409
|
+
e.stopPropagation();
|
|
410
|
+
$(".fp-intent-menu").not($menu).addClass("fp-hidden");
|
|
411
|
+
$menu.toggleClass("fp-hidden");
|
|
412
|
+
});
|
|
413
|
+
$("<div>")
|
|
414
|
+
.addClass("fp-intent-more-wrap")
|
|
415
|
+
.append($toggle)
|
|
416
|
+
.append($menu)
|
|
417
|
+
.appendTo($container);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Working copy of custom intents while editing in settings. Seeded from
|
|
422
|
+
// currentSettings each time the list is rendered, edited in place, and
|
|
423
|
+
// read back by collectSettings().
|
|
424
|
+
var editingCustomIntents = [];
|
|
425
|
+
|
|
426
|
+
function renderCustomIntentList() {
|
|
427
|
+
var $list = el("#fp-custom-intents");
|
|
428
|
+
if (!$list.length) { return; }
|
|
429
|
+
editingCustomIntents = Array.isArray(currentSettings.customIntents)
|
|
430
|
+
? currentSettings.customIntents.map(function (c) {
|
|
431
|
+
return { label: c.label, text: c.text };
|
|
432
|
+
})
|
|
433
|
+
: [];
|
|
434
|
+
$list.empty();
|
|
435
|
+
if (!editingCustomIntents.length) {
|
|
436
|
+
$("<div>").addClass("fp-consent-hint")
|
|
437
|
+
.text("No custom buttons yet. Add one below.")
|
|
438
|
+
.appendTo($list);
|
|
439
|
+
}
|
|
440
|
+
editingCustomIntents.forEach(function (item, idx) {
|
|
441
|
+
var $row = $("<div>").addClass("fp-custom-intent-row");
|
|
442
|
+
$("<span>").addClass("fp-custom-intent-label").text(item.label).appendTo($row);
|
|
443
|
+
$("<button>")
|
|
444
|
+
.addClass("red-ui-button red-ui-button-small")
|
|
445
|
+
.attr("type", "button")
|
|
446
|
+
.text("Remove")
|
|
447
|
+
.on("click", function () {
|
|
448
|
+
editingCustomIntents.splice(idx, 1);
|
|
449
|
+
// Persist immediately so buttons update; reuses saveSettings.
|
|
450
|
+
currentSettings.customIntents = editingCustomIntents.slice();
|
|
451
|
+
saveSettings();
|
|
452
|
+
})
|
|
453
|
+
.appendTo($row);
|
|
454
|
+
$row.appendTo($list);
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function addCustomIntent() {
|
|
459
|
+
var label = (el("#fp-new-intent-label").val() || "").trim();
|
|
460
|
+
var text = (el("#fp-new-intent-text").val() || "").trim();
|
|
461
|
+
if (!label || !text) {
|
|
462
|
+
addMessage("error", "A custom button needs both a label and instruction text.");
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
// Catch label collisions with the built-in Query buttons (Explain/
|
|
466
|
+
// Troubleshoot/Review/Suggest) or an existing custom button — two
|
|
467
|
+
// same-named buttons in the action bar are confusingly ambiguous.
|
|
468
|
+
var list = Array.isArray(currentSettings.customIntents)
|
|
469
|
+
? currentSettings.customIntents.slice() : [];
|
|
470
|
+
var taken = INTENTS.map(function (i) { return i.label.toLowerCase(); })
|
|
471
|
+
.concat(list.map(function (c) { return (c.label || "").toLowerCase(); }));
|
|
472
|
+
if (taken.indexOf(label.toLowerCase()) !== -1) {
|
|
473
|
+
addMessage("error", "A button named \"" + label + "\" already exists. Choose a different label.");
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
list.push({ label: label, text: text });
|
|
477
|
+
currentSettings.customIntents = list;
|
|
478
|
+
el("#fp-new-intent-label").val("");
|
|
479
|
+
el("#fp-new-intent-text").val("");
|
|
480
|
+
saveSettings();
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function el(sel) {
|
|
484
|
+
return $root ? $root.find(sel) : $();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ---- View switching -------------------------------------------------
|
|
488
|
+
|
|
489
|
+
function showChat() {
|
|
490
|
+
el("#fp-chat-panel").removeClass("fp-hidden");
|
|
491
|
+
el("#fp-settings-panel").addClass("fp-hidden");
|
|
492
|
+
el("#fp-history-panel").addClass("fp-hidden");
|
|
493
|
+
el("#fp-show-chat").addClass("fp-active");
|
|
494
|
+
el("#fp-show-settings").removeClass("fp-active");
|
|
495
|
+
el("#fp-show-history").removeClass("fp-active");
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function showSettings() {
|
|
499
|
+
el("#fp-settings-panel").removeClass("fp-hidden");
|
|
500
|
+
el("#fp-chat-panel").addClass("fp-hidden");
|
|
501
|
+
el("#fp-history-panel").addClass("fp-hidden");
|
|
502
|
+
el("#fp-show-settings").addClass("fp-active");
|
|
503
|
+
el("#fp-show-chat").removeClass("fp-active");
|
|
504
|
+
el("#fp-show-history").removeClass("fp-active");
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function showHistory() {
|
|
508
|
+
el("#fp-history-panel").removeClass("fp-hidden");
|
|
509
|
+
el("#fp-chat-panel").addClass("fp-hidden");
|
|
510
|
+
el("#fp-settings-panel").addClass("fp-hidden");
|
|
511
|
+
el("#fp-show-history").addClass("fp-active");
|
|
512
|
+
el("#fp-show-chat").removeClass("fp-active");
|
|
513
|
+
el("#fp-show-settings").removeClass("fp-active");
|
|
514
|
+
loadConversationList();
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Clears the visible chat AND resets the conversation history the model
|
|
518
|
+
// sees — "start a fresh conversation".
|
|
519
|
+
function clearChat() {
|
|
520
|
+
el("#fp-messages").empty();
|
|
521
|
+
relayClearMessagesToPopout();
|
|
522
|
+
conversationHistory = [];
|
|
523
|
+
attachedDebugMessages = [];
|
|
524
|
+
activeBuildLoop = null;
|
|
525
|
+
disarmExecuteAction(); // also clears pinnedSelectionIds
|
|
526
|
+
conversationId = newConversationId();
|
|
527
|
+
fpChatSnappedToBottom = true;
|
|
528
|
+
updateSelectionStatus();
|
|
529
|
+
updateDebugStatus();
|
|
530
|
+
showChat();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// "Flight log" — a conversation list layered over the per-conversation
|
|
534
|
+
// transcript files. Loading a conversation rehydrates conversationHistory and the
|
|
535
|
+
// visible chat from its saved transcript, so a follow-up message picks
|
|
536
|
+
// up that conversation's memory rather than starting a fresh one.
|
|
537
|
+
function formatRelativeTime(timestamp) {
|
|
538
|
+
var then = new Date(timestamp).getTime();
|
|
539
|
+
if (!isFinite(then)) { return ""; }
|
|
540
|
+
var seconds = Math.max(0, (Date.now() - then) / 1000);
|
|
541
|
+
if (seconds < 60) { return "just now"; }
|
|
542
|
+
var minutes = seconds / 60;
|
|
543
|
+
if (minutes < 60) { return Math.floor(minutes) + " min ago"; }
|
|
544
|
+
var hours = minutes / 60;
|
|
545
|
+
if (hours < 24) { return Math.floor(hours) + " hr ago"; }
|
|
546
|
+
var days = hours / 24;
|
|
547
|
+
if (days < 30) { return Math.floor(days) + " day" + (Math.floor(days) === 1 ? "" : "s") + " ago"; }
|
|
548
|
+
return new Date(timestamp).toLocaleDateString();
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function loadConversationList() {
|
|
552
|
+
var $list = el("#fp-history-list");
|
|
553
|
+
if (!$list.length) { return; }
|
|
554
|
+
$list.empty().append($("<div>").addClass("fp-consent-hint").text("Loading…"));
|
|
555
|
+
|
|
556
|
+
if (isPopoutContext) {
|
|
557
|
+
if (window.opener && !window.opener.closed) {
|
|
558
|
+
try { window.opener.postMessage({ event: "requestConversationList" }, location.origin); } catch (e) { /* ignore */ }
|
|
559
|
+
}
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
ajaxJson("GET", "flowpilot/conversations", null, function (data) {
|
|
563
|
+
renderHistoryList(data.conversations || []);
|
|
564
|
+
}, function (msg) {
|
|
565
|
+
$list.empty();
|
|
566
|
+
$("<div>").addClass("fp-consent-hint").text("Unable to load conversation list: " + msg).appendTo($list);
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function deleteAllConversations() {
|
|
571
|
+
if (!window.confirm("Delete ALL saved conversation transcripts? This can't be undone.")) { return; }
|
|
572
|
+
if (isPopoutContext) {
|
|
573
|
+
if (window.opener && !window.opener.closed) {
|
|
574
|
+
try { window.opener.postMessage({ event: "deleteAllConversations" }, location.origin); } catch (e) { /* ignore */ }
|
|
575
|
+
}
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
ajaxJson("DELETE", "flowpilot/conversations", null, function () {
|
|
579
|
+
loadConversationList();
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function renderHistoryList(conversations) {
|
|
584
|
+
var $list = el("#fp-history-list");
|
|
585
|
+
if (!$list.length) { return; }
|
|
586
|
+
$list.empty();
|
|
587
|
+
|
|
588
|
+
if (!conversations.length) {
|
|
589
|
+
$("<div>").addClass("fp-consent-hint").text("No saved conversations yet.").appendTo($list);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
conversations.forEach(function (c) {
|
|
594
|
+
var $item = $("<div>").addClass("fp-history-item");
|
|
595
|
+
if (c.id === conversationId) { $item.addClass("fp-history-current"); }
|
|
596
|
+
|
|
597
|
+
var $main = $("<div>").addClass("fp-history-main");
|
|
598
|
+
$("<div>").addClass("fp-history-title").text(c.title || "(untitled)").appendTo($main);
|
|
599
|
+
var meta = c.exchangeCount + (c.exchangeCount === 1 ? " exchange" : " exchanges") +
|
|
600
|
+
" · " + formatRelativeTime(c.lastTimestamp);
|
|
601
|
+
$("<div>").addClass("fp-history-meta").text(meta).appendTo($main);
|
|
602
|
+
$main.on("click", function () {
|
|
603
|
+
if (isPopoutContext) {
|
|
604
|
+
showChat();
|
|
605
|
+
if (window.opener && !window.opener.closed) {
|
|
606
|
+
try { window.opener.postMessage({ event: "loadConversation", id: c.id }, location.origin); } catch (e) { /* ignore */ }
|
|
607
|
+
}
|
|
608
|
+
} else {
|
|
609
|
+
loadConversation(c.id);
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
$item.append($main);
|
|
613
|
+
|
|
614
|
+
var $del = $("<button>").addClass("fp-history-delete red-ui-button red-ui-button-small")
|
|
615
|
+
.attr("type", "button").attr("title", "Delete this conversation's saved transcript permanently")
|
|
616
|
+
.append($("<i>").addClass("fa fa-trash"));
|
|
617
|
+
$del.on("click", function (ev) {
|
|
618
|
+
ev.stopPropagation();
|
|
619
|
+
if (!window.confirm("Delete this conversation's saved transcript? This can't be undone.")) { return; }
|
|
620
|
+
if (isPopoutContext) {
|
|
621
|
+
if (window.opener && !window.opener.closed) {
|
|
622
|
+
try { window.opener.postMessage({ event: "deleteConversation", id: c.id }, location.origin); } catch (e) { /* ignore */ }
|
|
623
|
+
}
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
ajaxJson("DELETE", "flowpilot/conversations/" + encodeURIComponent(c.id), null, function () {
|
|
627
|
+
loadConversationList();
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
$item.append($del);
|
|
631
|
+
|
|
632
|
+
$list.append($item);
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Switches to a past conversation: rebuilds conversationHistory and the
|
|
637
|
+
// visible chat from its saved transcript, and continues using its
|
|
638
|
+
// conversationId so new turns append to the same transcript file.
|
|
639
|
+
function loadConversation(id) {
|
|
640
|
+
ajaxJson("GET", "flowpilot/conversations/" + encodeURIComponent(id), null, function (data) {
|
|
641
|
+
conversationId = id;
|
|
642
|
+
try { sessionStorage.setItem("fp-conversation-id", id); } catch (e) { /* storage unavailable */ }
|
|
643
|
+
|
|
644
|
+
conversationHistory = [];
|
|
645
|
+
relayClearMessagesToPopout();
|
|
646
|
+
el("#fp-messages").empty();
|
|
647
|
+
fpChatSnappedToBottom = true;
|
|
648
|
+
(data.messages || []).forEach(function (m) {
|
|
649
|
+
if (m.role !== "user" && m.role !== "assistant") { return; }
|
|
650
|
+
conversationHistory.push({ role: m.role, content: String(m.content || "") });
|
|
651
|
+
addMessage(m.role, m.content);
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
pinnedSelectionIds = null;
|
|
655
|
+
updateSelectionStatus();
|
|
656
|
+
showChat();
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Recall — searches OTHER past conversations' transcripts for the
|
|
661
|
+
// text currently in the prompt box, and shows matches in the chat for the
|
|
662
|
+
// user to read/reference. Nothing is sent automatically; each result has
|
|
663
|
+
// a "Use this" button that the user can click to add that exchange
|
|
664
|
+
// to conversationHistory, so the model sees it on the next message.
|
|
665
|
+
function recallSearch() {
|
|
666
|
+
showChat();
|
|
667
|
+
var $promptBox = el("#fp-prompt");
|
|
668
|
+
var query = $promptBox.length ? $promptBox.val().trim() : "";
|
|
669
|
+
if (!query) {
|
|
670
|
+
addMessage("error", "Type what you're looking for, then click Recall.");
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
setBusy(true);
|
|
675
|
+
showPending();
|
|
676
|
+
if (isPopoutContext) {
|
|
677
|
+
if (window.opener && !window.opener.closed) {
|
|
678
|
+
try { window.opener.postMessage({ event: "requestRecallSearch", query: query }, location.origin); } catch (e) { /* ignore */ }
|
|
679
|
+
}
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
ajaxJson("POST", "flowpilot/recall", { query: query, conversationId: conversationId }, function (data) {
|
|
683
|
+
hidePending();
|
|
684
|
+
renderRecallResults(data.results);
|
|
685
|
+
setBusy(false);
|
|
686
|
+
}, function (msg) {
|
|
687
|
+
hidePending();
|
|
688
|
+
addMessage("error", msg);
|
|
689
|
+
setBusy(false);
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Renders Recall's results as a special message — date/mode per match,
|
|
694
|
+
// plus the user prompt and assistant reply that matched (truncated).
|
|
695
|
+
function renderRecallResults(results) {
|
|
696
|
+
var $box = el("#fp-messages");
|
|
697
|
+
if (!$box.length) { return; }
|
|
698
|
+
|
|
699
|
+
if (!results || results.length === 0) {
|
|
700
|
+
addMessage("assistant", "No matching earlier conversations found.");
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function truncate(text, max) {
|
|
705
|
+
text = String(text || "");
|
|
706
|
+
return text.length > max ? text.slice(0, max - 1) + "…" : text;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
var $msg = $("<div>").addClass("fp-message fp-recall");
|
|
710
|
+
$("<div>").addClass("fp-label").text("RECALLED").appendTo($msg);
|
|
711
|
+
|
|
712
|
+
results.forEach(function (r) {
|
|
713
|
+
var $item = $("<div>").addClass("fp-recall-item");
|
|
714
|
+
var when = r.timestamp ? new Date(r.timestamp).toLocaleString() : "";
|
|
715
|
+
var meta = when + (r.mode ? " · " + r.mode : "");
|
|
716
|
+
$("<div>").addClass("fp-recall-meta").text(meta).appendTo($item);
|
|
717
|
+
if (r.user) { $("<div>").addClass("fp-recall-text").text("You: " + truncate(r.user, 200)).appendTo($item); }
|
|
718
|
+
if (r.assistant) { $("<div>").addClass("fp-recall-text").text("FlowPilot: " + truncate(r.assistant, 300)).appendTo($item); }
|
|
719
|
+
|
|
720
|
+
var $use = $("<button>").addClass("fp-recall-use red-ui-button red-ui-button-small")
|
|
721
|
+
.attr("type", "button").text("Use this");
|
|
722
|
+
$use.on("click", function () {
|
|
723
|
+
if (isPopoutContext) {
|
|
724
|
+
if (window.opener && !window.opener.closed) {
|
|
725
|
+
try { window.opener.postMessage({ event: "useRecallItem", user: r.user || null, assistant: r.assistant || null }, location.origin); } catch (e) { /* ignore */ }
|
|
726
|
+
}
|
|
727
|
+
} else {
|
|
728
|
+
if (r.user) { conversationHistory.push({ role: "user", content: String(r.user) }); }
|
|
729
|
+
if (r.assistant) { conversationHistory.push({ role: "assistant", content: String(r.assistant) }); }
|
|
730
|
+
}
|
|
731
|
+
$use.prop("disabled", true).text("Added to context");
|
|
732
|
+
});
|
|
733
|
+
$item.append($use);
|
|
734
|
+
|
|
735
|
+
$msg.append($item);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
$box.append($msg);
|
|
739
|
+
scrollMessagesToBottom();
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Debug log: shows the recent Debug-sidebar messages buffered via
|
|
743
|
+
// RED.comms (most recent first), each with an "Attach" button — same
|
|
744
|
+
// interaction as Recall's "Use this" (renderRecallResults above), per
|
|
745
|
+
// the user's preference for that pattern. Attaching adds the entry to
|
|
746
|
+
// attachedDebugMessages, which is merged into the next request(s)'
|
|
747
|
+
// context by attachDebugContext().
|
|
748
|
+
function showDebugMessages() {
|
|
749
|
+
showChat();
|
|
750
|
+
var $box = el("#fp-messages");
|
|
751
|
+
if (!$box.length) { return; }
|
|
752
|
+
|
|
753
|
+
if (!debugMessageBuffer.length) {
|
|
754
|
+
addMessage("assistant", "No debug messages captured yet. Trigger a flow with a Debug node wired to the sidebar, then try again.");
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
var attachedIds = {};
|
|
759
|
+
attachedDebugMessages.forEach(function (m) { attachedIds[m.id] = true; });
|
|
760
|
+
|
|
761
|
+
var $msg = $("<div>").addClass("fp-message fp-recall");
|
|
762
|
+
$("<div>").addClass("fp-label").text("DEBUG LOG").appendTo($msg);
|
|
763
|
+
$("<div>").addClass("fp-debug-warning").text("Debug payloads can contain credentials from connected " +
|
|
764
|
+
"systems. Common secret patterns are redacted automatically, but review before attaching.").appendTo($msg);
|
|
765
|
+
|
|
766
|
+
// Oldest first, newest last — matches the chat panel's natural
|
|
767
|
+
// top-to-bottom, auto-scroll-to-bottom behavior, so the most recent
|
|
768
|
+
// message is immediately visible without scrolling up past everything
|
|
769
|
+
// else. debugMessageBuffer is already append-ordered (oldest-first).
|
|
770
|
+
debugMessageBuffer.slice().forEach(function (entry) {
|
|
771
|
+
var $item = $("<div>").addClass("fp-recall-item");
|
|
772
|
+
var when = new Date(entry.timestamp).toLocaleTimeString();
|
|
773
|
+
var meta = when + " · " + entry.name + (entry.topic ? " · topic: " + entry.topic : "");
|
|
774
|
+
$("<div>").addClass("fp-recall-meta").text(meta).appendTo($item);
|
|
775
|
+
$("<div>").addClass("fp-recall-text").text(entry.previewValue).appendTo($item);
|
|
776
|
+
|
|
777
|
+
var already = !!attachedIds[entry.id];
|
|
778
|
+
var $use = $("<button>").addClass("fp-recall-use red-ui-button red-ui-button-small")
|
|
779
|
+
.attr("type", "button")
|
|
780
|
+
.attr("data-fp-debug-id", entry.id)
|
|
781
|
+
.prop("disabled", already)
|
|
782
|
+
.text(already ? "Attached" : "Attach");
|
|
783
|
+
$use.on("click", function () {
|
|
784
|
+
attachedDebugMessages.push(entry);
|
|
785
|
+
$use.prop("disabled", true).text("Attached");
|
|
786
|
+
updateDebugStatus();
|
|
787
|
+
});
|
|
788
|
+
$item.append($use);
|
|
789
|
+
|
|
790
|
+
$msg.append($item);
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
$box.append($msg);
|
|
794
|
+
scrollMessagesToBottom();
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// ---- Messages -------------------------------------------------------
|
|
798
|
+
|
|
799
|
+
// Whether the chat should auto-follow new content. Starts true (a fresh
|
|
800
|
+
// chat is at the bottom); the #fp-messages "scroll" handler keeps this in
|
|
801
|
+
// sync as the user scrolls. While "Cruising…"/streaming, repeated
|
|
802
|
+
// scroll-to-bottom calls otherwise fight any attempt to scroll up to
|
|
803
|
+
// re-read earlier messages.
|
|
804
|
+
var fpChatSnappedToBottom = true;
|
|
805
|
+
var FP_SCROLL_SNAP_PX = 24;
|
|
806
|
+
|
|
807
|
+
// Scrolls #fp-messages to the bottom if the user is currently snapped
|
|
808
|
+
// there (or if `force` — used when the user sends a new message, which
|
|
809
|
+
// should always jump to the bottom and resume auto-follow).
|
|
810
|
+
function scrollMessagesToBottom(force) {
|
|
811
|
+
var $box = el("#fp-messages");
|
|
812
|
+
if (!$box.length) { return; }
|
|
813
|
+
if (force || fpChatSnappedToBottom) {
|
|
814
|
+
$box.scrollTop($box[0].scrollHeight);
|
|
815
|
+
fpChatSnappedToBottom = true;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function addMessage(role, text) {
|
|
820
|
+
var $box = el("#fp-messages");
|
|
821
|
+
if (!$box.length) { return; }
|
|
822
|
+
|
|
823
|
+
var label = role === "user" ? "YOU" : role === "error" ? "ERROR" : "FLOWPILOT";
|
|
824
|
+
var cls = "fp-message" + (role === "user" ? " fp-user" : role === "error" ? " fp-error" : "");
|
|
825
|
+
|
|
826
|
+
var $msg = $("<div>").addClass(cls);
|
|
827
|
+
$("<div>").addClass("fp-label").text(label).appendTo($msg);
|
|
828
|
+
$("<div>").addClass("fp-md").html(renderMarkdown(text || "")).appendTo($msg);
|
|
829
|
+
|
|
830
|
+
$box.append($msg);
|
|
831
|
+
// Sending a message always jumps to the bottom and resumes
|
|
832
|
+
// auto-follow; an incoming message only follows if already snapped.
|
|
833
|
+
scrollMessagesToBottom(role === "user");
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Pending "typing" indicator shown in the thread while awaiting a reply.
|
|
837
|
+
// Lives where the answer will appear, so the eye is already there. Always
|
|
838
|
+
// removed in both the success and error paths so it can't get stuck.
|
|
839
|
+
// showStop adds a "Stop" button, used by the agent loop
|
|
840
|
+
// (runAgentChat) so the user can interrupt a multi-step tool-call run.
|
|
841
|
+
function showPending(showStop) {
|
|
842
|
+
var $box = el("#fp-messages");
|
|
843
|
+
if (!$box.length) { return; }
|
|
844
|
+
// Guard against duplicates (e.g. fast double-send).
|
|
845
|
+
$box.find("#fp-pending").remove();
|
|
846
|
+
|
|
847
|
+
var $msg = $("<div>").addClass("fp-message").attr("id", "fp-pending");
|
|
848
|
+
$("<div>").addClass("fp-label").text("FLOWPILOT").appendTo($msg);
|
|
849
|
+
var $dots = $("<div>").addClass("fp-typing").attr("title", "Working…");
|
|
850
|
+
$dots.append($("<span>"), $("<span>"), $("<span>"));
|
|
851
|
+
$dots.append($("<span>").addClass("fp-typing-label").text("Cruising…"));
|
|
852
|
+
if (showStop) {
|
|
853
|
+
$dots.append($("<button>")
|
|
854
|
+
.addClass("fp-agent-stop red-ui-button red-ui-button-small")
|
|
855
|
+
.attr("type", "button")
|
|
856
|
+
.text("Stop")
|
|
857
|
+
.on("click", function () {
|
|
858
|
+
fpAgentStopRequested = true;
|
|
859
|
+
$(this).prop("disabled", true).text("Stopping…");
|
|
860
|
+
}));
|
|
861
|
+
}
|
|
862
|
+
$msg.append($dots);
|
|
863
|
+
|
|
864
|
+
$box.append($msg);
|
|
865
|
+
scrollMessagesToBottom();
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function hidePending() {
|
|
869
|
+
el("#fp-messages").find("#fp-pending").remove();
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// Updates the narration text shown in the pending indicator while the
|
|
873
|
+
// agent loop runs (see runAgentChat / describeAgentToolCall).
|
|
874
|
+
function setAgentNarration(text) {
|
|
875
|
+
el("#fp-pending .fp-typing-label").text(text);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Cost transparency for a completed agent-loop turn.
|
|
879
|
+
// Appended just before the final response when at least one tool round
|
|
880
|
+
// trip happened, so the user can see what exploration cost without
|
|
881
|
+
// digging into the audit log.
|
|
882
|
+
function addAgentStatsNote(steps, totalTokens) {
|
|
883
|
+
var $box = el("#fp-messages");
|
|
884
|
+
if (!$box.length) { return; }
|
|
885
|
+
var text = "🔧 " + steps + " tool call step" + (steps === 1 ? "" : "s") +
|
|
886
|
+
" · ~" + totalTokens.toLocaleString() + " tokens this turn";
|
|
887
|
+
$("<div>").addClass("fp-consent-hint fp-agent-stats").text(text).appendTo($box);
|
|
888
|
+
scrollMessagesToBottom();
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// ---- Server communication -------------------------------------------
|
|
892
|
+
// Note: no leading slash. Node-RED serves admin endpoints under a base
|
|
893
|
+
// path (httpAdminRoot) that may not be "/". A relative URL respects it.
|
|
894
|
+
|
|
895
|
+
// Node-RED's editor auto-attaches the admin-API auth token to $.ajax
|
|
896
|
+
// calls via a global $.ajaxSetup beforeSend (red.js) — but ONLY for a
|
|
897
|
+
// bare relative URL ("flowpilot/settings"); it explicitly skips any
|
|
898
|
+
// URL starting with "/", "http(s):", or ".". flowpilotUrl() below
|
|
899
|
+
// always returns a leading-slash absolute path (needed so the pop-out's
|
|
900
|
+
// nested route still resolves correctly), which means neither $.ajax
|
|
901
|
+
// (ajaxJson, below) nor raw fetch() (SSE streaming) ever got the token
|
|
902
|
+
// attached automatically — confirmed live as "Unable to load FlowPilot
|
|
903
|
+
// settings: Unauthorized" on an adminAuth-enabled instance (v0.4.1).
|
|
904
|
+
// Both attach it themselves instead, via this same lookup.
|
|
905
|
+
function fetchHeaders() {
|
|
906
|
+
var headers = { "Content-Type": "application/json" };
|
|
907
|
+
var tokens = RED.settings.get("auth-tokens");
|
|
908
|
+
if (tokens && tokens.access_token) {
|
|
909
|
+
headers.Authorization = "Bearer " + tokens.access_token;
|
|
910
|
+
headers["Node-RED-API-Version"] = "v2";
|
|
911
|
+
}
|
|
912
|
+
return headers;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Every FlowPilot route is registered at an ABSOLUTE path under the
|
|
916
|
+
// admin root (e.g. RED.httpAdmin.get("/flowpilot/settings", ...)). A
|
|
917
|
+
// bare relative string like "flowpilot/settings" only resolves
|
|
918
|
+
// correctly when the CURRENT PAGE happens to sit at the admin root
|
|
919
|
+
// itself — true for the main editor, but NOT for the pop-out (served
|
|
920
|
+
// from the nested /flowpilot/popout/view.html route), where the same
|
|
921
|
+
// relative string resolves one level too deep
|
|
922
|
+
// (/flowpilot/popout/flowpilot/settings) and 404s. Confirmed live:
|
|
923
|
+
// loadSettings() failing in the pop-out with exactly that 404. Always
|
|
924
|
+
// anchor to root instead, regardless of which page is calling.
|
|
925
|
+
function flowpilotUrl(path) {
|
|
926
|
+
return path.charAt(0) === "/" ? path : ("/" + path);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function ajaxJson(method, url, payload, onSuccess, onError) {
|
|
930
|
+
$.ajax({
|
|
931
|
+
url: flowpilotUrl(url),
|
|
932
|
+
method: method,
|
|
933
|
+
contentType: "application/json",
|
|
934
|
+
data: payload ? JSON.stringify(payload) : undefined,
|
|
935
|
+
beforeSend: function (jqXHR) {
|
|
936
|
+
var tokens = RED.settings.get("auth-tokens");
|
|
937
|
+
if (tokens && tokens.access_token) {
|
|
938
|
+
jqXHR.setRequestHeader("Authorization", "Bearer " + tokens.access_token);
|
|
939
|
+
}
|
|
940
|
+
},
|
|
941
|
+
success: onSuccess,
|
|
942
|
+
error: function (xhr) {
|
|
943
|
+
var msg = (xhr.responseJSON && xhr.responseJSON.error) ||
|
|
944
|
+
xhr.responseText || xhr.statusText || "Unknown error";
|
|
945
|
+
if (onError) { onError(msg, xhr); }
|
|
946
|
+
else { addMessage("error", msg); }
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// ---- Settings -------------------------------------------------------
|
|
952
|
+
|
|
953
|
+
// Helpers for the providers list living in currentSettings.
|
|
954
|
+
function providersList() {
|
|
955
|
+
return Array.isArray(currentSettings.providers) ? currentSettings.providers : [];
|
|
956
|
+
}
|
|
957
|
+
function activeProvider() {
|
|
958
|
+
var list = providersList();
|
|
959
|
+
if (!list.length) { return null; }
|
|
960
|
+
var found = list.filter(function (p) { return p.id === currentSettings.activeProviderId; })[0];
|
|
961
|
+
return found || list[0];
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function renderProviderDropdown() {
|
|
965
|
+
var $sel = el("#fp-provider-select");
|
|
966
|
+
if (!$sel.length) { return; }
|
|
967
|
+
$sel.empty();
|
|
968
|
+
providersList().forEach(function (p) {
|
|
969
|
+
$("<option>")
|
|
970
|
+
.attr("value", p.id)
|
|
971
|
+
.text(p.providerName + (p.model ? (" / " + p.model) : " (no model)"))
|
|
972
|
+
.appendTo($sel);
|
|
973
|
+
});
|
|
974
|
+
var active = activeProvider();
|
|
975
|
+
if (active) { $sel.val(active.id); }
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// Write the form fields from a given provider profile.
|
|
979
|
+
function fillProviderFields(p) {
|
|
980
|
+
p = p || {};
|
|
981
|
+
el("#fp-provider-name").val(p.providerName || "");
|
|
982
|
+
el("#fp-base-url").val(p.baseUrl || "");
|
|
983
|
+
el("#fp-api-key").val(p.apiKey || "");
|
|
984
|
+
el("#fp-model").val(p.model || "");
|
|
985
|
+
el("#fp-temperature").val(p.temperature !== undefined ? p.temperature : 0.2);
|
|
986
|
+
// Test provider is disabled until this provider has a model.
|
|
987
|
+
el("#fp-test-provider").prop("disabled", !(p.model && String(p.model).trim()));
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// Short descriptive band shown under the Personality slider, matching
|
|
991
|
+
// the reference points lib/persona-prompt.js gives the model.
|
|
992
|
+
function personaLabelFor(n) {
|
|
993
|
+
n = Number(n);
|
|
994
|
+
if (n <= 1) { return "Plain engineer — no aviation language at all."; }
|
|
995
|
+
if (n <= 4) { return "Subtle co-pilot (default) — light, occasional flavor."; }
|
|
996
|
+
if (n <= 7) { return "Noticeable captain energy — more frequent, more colorful."; }
|
|
997
|
+
if (n <= 9) { return "Heavy captain energy — leans hard into the bit."; }
|
|
998
|
+
return "Full captain — comically over-the-top.";
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function updatePersonaLabel() {
|
|
1002
|
+
var n = el("#fp-persona-intensity").val();
|
|
1003
|
+
el("#fp-persona-value").text(n);
|
|
1004
|
+
el("#fp-persona-label").text(personaLabelFor(n));
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function fillSettings(settings) {
|
|
1008
|
+
settings = settings || {};
|
|
1009
|
+
currentSettings = settings;
|
|
1010
|
+
|
|
1011
|
+
renderProviderDropdown();
|
|
1012
|
+
fillProviderFields(activeProvider());
|
|
1013
|
+
|
|
1014
|
+
el("#fp-system-prompt").val(settings.systemPrompt || "");
|
|
1015
|
+
el("#fp-persona-intensity").val(settings.personaIntensity !== undefined ? settings.personaIntensity : 3);
|
|
1016
|
+
updatePersonaLabel();
|
|
1017
|
+
el("#fp-warn-tokens").val(settings.contextWarnTokens || 4000);
|
|
1018
|
+
el("#fp-high-tokens").val(settings.contextHighTokens || 8000);
|
|
1019
|
+
el("#fp-history-max").val(settings.historyMaxExchanges !== undefined ? settings.historyMaxExchanges : 10);
|
|
1020
|
+
el("#fp-streaming-enabled").prop("checked", !!settings.streamingEnabled);
|
|
1021
|
+
el("#fp-request-timeout").val(Math.round((settings.requestTimeoutMs !== undefined ? settings.requestTimeoutMs : 180000) / 1000));
|
|
1022
|
+
el("#fp-agent-loop-max-iterations").val(settings.agentLoopMaxIterations !== undefined ? settings.agentLoopMaxIterations : 5);
|
|
1023
|
+
el("#fp-loop-hold-step").prop("checked", !!settings.loopHoldStep);
|
|
1024
|
+
el("#fp-suppress-warnings").prop("checked", !!settings.suppressContextWarnings);
|
|
1025
|
+
el("#fp-redaction-disabled").prop("checked", settings.redactionEnabled === false);
|
|
1026
|
+
|
|
1027
|
+
// The dev/test banner is part of the warning set the user can silence
|
|
1028
|
+
// via the type-to-confirm acknowledgement.
|
|
1029
|
+
if (settings.suppressContextWarnings) {
|
|
1030
|
+
el("#fp-dev-banner").addClass("fp-hidden");
|
|
1031
|
+
} else {
|
|
1032
|
+
el("#fp-dev-banner").removeClass("fp-hidden");
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Custom intents may have changed; rebuild buttons and the editor list.
|
|
1036
|
+
renderIntents(el("#fp-intents"));
|
|
1037
|
+
renderCustomIntentList();
|
|
1038
|
+
|
|
1039
|
+
var ap = activeProvider();
|
|
1040
|
+
var providerText = (ap && ap.model)
|
|
1041
|
+
? (ap.providerName + " / " + ap.model)
|
|
1042
|
+
: ((ap ? ap.providerName : "Provider") + ": model not configured");
|
|
1043
|
+
el("#fp-provider-status").text("Provider: " + providerText);
|
|
1044
|
+
|
|
1045
|
+
// Anchor point for "no changes to save" detection — this is the form
|
|
1046
|
+
// state as of the last successful load/save.
|
|
1047
|
+
savedSettingsSnapshot = JSON.stringify(collectSettings());
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Shows a short-lived status message next to the Save settings button.
|
|
1051
|
+
// Only used for that explicit, user-initiated action — the many internal
|
|
1052
|
+
// saveSettings() calls (Pre-flight check, Refresh models, custom intent
|
|
1053
|
+
// add/remove) have their own dedicated feedback elsewhere and would just
|
|
1054
|
+
// add noise here.
|
|
1055
|
+
function showSaveStatus(text, isError) {
|
|
1056
|
+
var $status = el("#fp-save-status");
|
|
1057
|
+
clearTimeout(saveStatusTimer);
|
|
1058
|
+
$status.text(text).toggleClass("fp-save-status-error", !!isError).removeClass("fp-hidden");
|
|
1059
|
+
saveStatusTimer = setTimeout(function () {
|
|
1060
|
+
$status.addClass("fp-hidden");
|
|
1061
|
+
}, 4000);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// Read the form's provider fields back into the active provider profile.
|
|
1065
|
+
function captureProviderFields() {
|
|
1066
|
+
var list = providersList();
|
|
1067
|
+
var ap = activeProvider();
|
|
1068
|
+
if (!ap) { return; }
|
|
1069
|
+
ap.providerName = el("#fp-provider-name").val() || "Provider";
|
|
1070
|
+
ap.baseUrl = el("#fp-base-url").val() || "";
|
|
1071
|
+
ap.apiKey = el("#fp-api-key").val() || "";
|
|
1072
|
+
ap.model = el("#fp-model").val() || "";
|
|
1073
|
+
ap.temperature = Number(el("#fp-temperature").val() || 0.2);
|
|
1074
|
+
currentSettings.providers = list;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function collectSettings() {
|
|
1078
|
+
// Only honour the suppression toggle if the confirmation phrase was
|
|
1079
|
+
// typed exactly. Otherwise warnings stay on regardless of the checkbox.
|
|
1080
|
+
var wantSuppress = el("#fp-suppress-warnings").prop("checked");
|
|
1081
|
+
var typed = (el("#fp-suppress-confirm").val() || "").trim();
|
|
1082
|
+
var suppress = wantSuppress && typed === "I understand the risk";
|
|
1083
|
+
|
|
1084
|
+
// Same type-to-confirm gate as suppressContextWarnings above, and for
|
|
1085
|
+
// the same reason: the confirm box is never pre-filled from settings,
|
|
1086
|
+
// so disabling redaction stays off unless re-confirmed on every save —
|
|
1087
|
+
// "off-able, not off-by-accident".
|
|
1088
|
+
var wantRedactionOff = el("#fp-redaction-disabled").prop("checked");
|
|
1089
|
+
var redactionTyped = (el("#fp-redaction-confirm").val() || "").trim();
|
|
1090
|
+
var redactionEnabled = !(wantRedactionOff && redactionTyped === "disable redaction");
|
|
1091
|
+
|
|
1092
|
+
// Fold the form's provider fields back into the active profile first.
|
|
1093
|
+
captureProviderFields();
|
|
1094
|
+
|
|
1095
|
+
var historyMax = Number(el("#fp-history-max").val());
|
|
1096
|
+
if (!isFinite(historyMax) || historyMax < 0) { historyMax = 10; }
|
|
1097
|
+
|
|
1098
|
+
var requestTimeoutSec = Number(el("#fp-request-timeout").val());
|
|
1099
|
+
if (!isFinite(requestTimeoutSec) || requestTimeoutSec < 5) { requestTimeoutSec = 180; }
|
|
1100
|
+
|
|
1101
|
+
var personaIntensity = Number(el("#fp-persona-intensity").val());
|
|
1102
|
+
if (!isFinite(personaIntensity) || personaIntensity < 1 || personaIntensity > 10) { personaIntensity = 3; }
|
|
1103
|
+
|
|
1104
|
+
var agentLoopMaxIterations = Number(el("#fp-agent-loop-max-iterations").val());
|
|
1105
|
+
if (!isFinite(agentLoopMaxIterations) || agentLoopMaxIterations < 1) { agentLoopMaxIterations = 5; }
|
|
1106
|
+
|
|
1107
|
+
return {
|
|
1108
|
+
providers: providersList(),
|
|
1109
|
+
activeProviderId: currentSettings.activeProviderId,
|
|
1110
|
+
systemPrompt: el("#fp-system-prompt").val(),
|
|
1111
|
+
personaIntensity: personaIntensity,
|
|
1112
|
+
contextWarnTokens: Number(el("#fp-warn-tokens").val() || 4000),
|
|
1113
|
+
contextHighTokens: Number(el("#fp-high-tokens").val() || 8000),
|
|
1114
|
+
historyMaxExchanges: historyMax,
|
|
1115
|
+
streamingEnabled: el("#fp-streaming-enabled").prop("checked"),
|
|
1116
|
+
requestTimeoutMs: Math.round(requestTimeoutSec * 1000),
|
|
1117
|
+
agentLoopMaxIterations: agentLoopMaxIterations,
|
|
1118
|
+
loopHoldStep: el("#fp-loop-hold-step").prop("checked"),
|
|
1119
|
+
suppressContextWarnings: suppress,
|
|
1120
|
+
redactionEnabled: redactionEnabled,
|
|
1121
|
+
customIntents: Array.isArray(currentSettings.customIntents)
|
|
1122
|
+
? currentSettings.customIntents : []
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// Switch which provider the form edits. Captures the current form into the
|
|
1127
|
+
// outgoing provider first, so unsaved edits aren't lost when switching.
|
|
1128
|
+
function switchProvider(newId) {
|
|
1129
|
+
captureProviderFields();
|
|
1130
|
+
currentSettings.activeProviderId = newId;
|
|
1131
|
+
fillProviderFields(activeProvider());
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function addProvider() {
|
|
1135
|
+
captureProviderFields();
|
|
1136
|
+
var list = providersList();
|
|
1137
|
+
// Generate a unique default name so adding several doesn't immediately
|
|
1138
|
+
// collide (the save-time check enforces uniqueness, but this avoids the
|
|
1139
|
+
// obvious "New provider" / "New provider" clash up front).
|
|
1140
|
+
var base = "New provider";
|
|
1141
|
+
var name = base;
|
|
1142
|
+
var n = 1;
|
|
1143
|
+
var taken = {};
|
|
1144
|
+
list.forEach(function (p) {
|
|
1145
|
+
taken[String(p.providerName || "").trim().toLowerCase()] = true;
|
|
1146
|
+
});
|
|
1147
|
+
while (taken[name.toLowerCase()]) { n += 1; name = base + " " + n; }
|
|
1148
|
+
|
|
1149
|
+
var id = "p" + Date.now().toString(36);
|
|
1150
|
+
list.push({
|
|
1151
|
+
id: id,
|
|
1152
|
+
providerName: name,
|
|
1153
|
+
baseUrl: "http://localhost:8080",
|
|
1154
|
+
apiKey: "",
|
|
1155
|
+
model: "",
|
|
1156
|
+
temperature: 0.2
|
|
1157
|
+
});
|
|
1158
|
+
currentSettings.providers = list;
|
|
1159
|
+
currentSettings.activeProviderId = id;
|
|
1160
|
+
renderProviderDropdown();
|
|
1161
|
+
fillProviderFields(activeProvider());
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function removeProvider() {
|
|
1165
|
+
var list = providersList();
|
|
1166
|
+
if (list.length <= 1) {
|
|
1167
|
+
addMessage("error", "At least one provider is required.");
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
var ap = activeProvider();
|
|
1171
|
+
currentSettings.providers = list.filter(function (p) { return p.id !== ap.id; });
|
|
1172
|
+
currentSettings.activeProviderId = currentSettings.providers[0].id;
|
|
1173
|
+
renderProviderDropdown();
|
|
1174
|
+
fillProviderFields(activeProvider());
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// Replace the System Prompt textarea with FlowPilot's current built-in
|
|
1178
|
+
// default. Settings.json can carry a snapshot saved by an older version
|
|
1179
|
+
// that predates newer instructions (chips, clarifying questions, etc.) —
|
|
1180
|
+
// this lets the user pick up those updates without losing the ability to
|
|
1181
|
+
// customize the prompt afterwards. Not saved until the user clicks Save.
|
|
1182
|
+
function resetSystemPrompt() {
|
|
1183
|
+
if (!window.confirm("Replace the System Prompt text with FlowPilot's current default? This discards any customizations in the box until you save.")) {
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
ajaxJson("GET", "flowpilot/default-system-prompt", null, function (data) {
|
|
1187
|
+
el("#fp-system-prompt").val(data.systemPrompt || "");
|
|
1188
|
+
addMessage("assistant", "System prompt reset to the current default. Click Save Settings to apply.");
|
|
1189
|
+
}, function (msg) {
|
|
1190
|
+
addMessage("error", "Unable to load the default system prompt: " + msg);
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// Models dropdown: populate #fp-model-options from a /flowpilot/models
|
|
1195
|
+
// result, and show a hint explaining what happened. #fp-model stays
|
|
1196
|
+
// free-text (list="fp-model-options") — providers that don't implement
|
|
1197
|
+
// /v1/models (or return nothing useful) just leave the field as-is.
|
|
1198
|
+
function populateModelOptions(models, error) {
|
|
1199
|
+
var $list = el("#fp-model-options");
|
|
1200
|
+
$list.empty();
|
|
1201
|
+
(models || []).forEach(function (m) {
|
|
1202
|
+
$("<option>").attr("value", m).appendTo($list);
|
|
1203
|
+
});
|
|
1204
|
+
var $hint = el("#fp-models-hint").removeClass("fp-hidden");
|
|
1205
|
+
if (error) {
|
|
1206
|
+
$hint.text("Couldn't load model list: " + error + ". You can still type a model name manually.");
|
|
1207
|
+
} else if (!models || !models.length) {
|
|
1208
|
+
$hint.text("Provider returned no models. You can still type a model name manually.");
|
|
1209
|
+
} else {
|
|
1210
|
+
$hint.text(models.length + " model(s) loaded — pick from the dropdown or type your own.");
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// Refresh models: save the form first (like Pre-flight check, so the
|
|
1215
|
+
// backend queries the provider the user is looking at), then fetch
|
|
1216
|
+
// GET /v1/models via the backend and populate the datalist.
|
|
1217
|
+
function refreshModels() {
|
|
1218
|
+
saveSettings(function () {
|
|
1219
|
+
var $btn = el("#fp-refresh-models");
|
|
1220
|
+
$btn.prop("disabled", true);
|
|
1221
|
+
ajaxJson("POST", "flowpilot/models", {}, function (data) {
|
|
1222
|
+
$btn.prop("disabled", false);
|
|
1223
|
+
populateModelOptions(data.models, data.error);
|
|
1224
|
+
}, function (msg) {
|
|
1225
|
+
$btn.prop("disabled", false);
|
|
1226
|
+
populateModelOptions([], msg);
|
|
1227
|
+
});
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// Test provider: switch to chat, fill the test prompt, and send it.
|
|
1232
|
+
function testProvider() {
|
|
1233
|
+
// Make sure the active provider reflects unsaved form edits, then save
|
|
1234
|
+
// so the backend tests what the user sees, then run the test.
|
|
1235
|
+
saveSettings(function () {
|
|
1236
|
+
showChat();
|
|
1237
|
+
send("test", "Say hello from FlowPilot. Keep it brief.");
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
function loadSettings() {
|
|
1242
|
+
if (isPopoutContext) {
|
|
1243
|
+
if (window.opener && !window.opener.closed) {
|
|
1244
|
+
try { window.opener.postMessage({ event: "requestSettings" }, location.origin); } catch (e) { /* ignore */ }
|
|
1245
|
+
}
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
ajaxJson("GET", "flowpilot/settings", null, function (data) {
|
|
1249
|
+
fillSettings(data);
|
|
1250
|
+
maybeShowFirstRun(data);
|
|
1251
|
+
updateSelectionStatus();
|
|
1252
|
+
}, function (msg) {
|
|
1253
|
+
addMessage("error", "Unable to load FlowPilot settings: " + msg);
|
|
1254
|
+
el("#fp-provider-status").text("Provider: settings load failed");
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// `announce` is true only for the explicit Save settings button — the
|
|
1259
|
+
// many internal callers (Pre-flight check, Refresh models, custom intent
|
|
1260
|
+
// add/remove) save as a side effect of some other action and already
|
|
1261
|
+
// have their own feedback, so they stay silent here.
|
|
1262
|
+
function saveSettings(callback, announce) {
|
|
1263
|
+
var payload = collectSettings();
|
|
1264
|
+
var list = payload.providers || [];
|
|
1265
|
+
|
|
1266
|
+
// Validation 1: every provider needs a base URL (the one field a
|
|
1267
|
+
// provider cannot function without).
|
|
1268
|
+
var noUrl = list.filter(function (p) {
|
|
1269
|
+
return !p.baseUrl || !String(p.baseUrl).trim();
|
|
1270
|
+
});
|
|
1271
|
+
if (noUrl.length) {
|
|
1272
|
+
var urlNames = noUrl.map(function (p) { return p.providerName || "(unnamed)"; }).join(", ");
|
|
1273
|
+
var noUrlMsg = "Cannot save: these provider(s) need a Base URL: " + urlNames + ".";
|
|
1274
|
+
addMessage("error", noUrlMsg);
|
|
1275
|
+
if (announce) { showSaveStatus(noUrlMsg, true); }
|
|
1276
|
+
showSettings();
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// Validation 2: provider names must be present and unique. Name is the
|
|
1281
|
+
// identity of a provider — base URL / model / key may all legitimately
|
|
1282
|
+
// repeat (e.g. same endpoint, different billing key), so the name is
|
|
1283
|
+
// what must distinguish them.
|
|
1284
|
+
var blankName = list.filter(function (p) {
|
|
1285
|
+
return !p.providerName || !String(p.providerName).trim();
|
|
1286
|
+
});
|
|
1287
|
+
if (blankName.length) {
|
|
1288
|
+
var blankNameMsg = "Cannot save: every provider needs a name.";
|
|
1289
|
+
addMessage("error", blankNameMsg);
|
|
1290
|
+
if (announce) { showSaveStatus(blankNameMsg, true); }
|
|
1291
|
+
showSettings();
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
var seen = {};
|
|
1295
|
+
var dupes = [];
|
|
1296
|
+
list.forEach(function (p) {
|
|
1297
|
+
var key = String(p.providerName).trim().toLowerCase();
|
|
1298
|
+
if (seen[key]) {
|
|
1299
|
+
if (dupes.indexOf(p.providerName) === -1) { dupes.push(p.providerName); }
|
|
1300
|
+
}
|
|
1301
|
+
seen[key] = true;
|
|
1302
|
+
});
|
|
1303
|
+
if (dupes.length) {
|
|
1304
|
+
var dupesMsg = "Cannot save: provider names must be unique. Duplicate: " + dupes.join(", ") + ".";
|
|
1305
|
+
addMessage("error", dupesMsg);
|
|
1306
|
+
if (announce) { showSaveStatus(dupesMsg, true); }
|
|
1307
|
+
showSettings();
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// Nothing changed since the last load/save — skip the round trip.
|
|
1312
|
+
if (announce && savedSettingsSnapshot !== null && JSON.stringify(payload) === savedSettingsSnapshot) {
|
|
1313
|
+
showSaveStatus("No changes to save.");
|
|
1314
|
+
if (callback) { callback(); }
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
ajaxJson("POST", "flowpilot/settings", payload, function (data) {
|
|
1319
|
+
fillSettings(data);
|
|
1320
|
+
addMessage("assistant", "Settings saved.");
|
|
1321
|
+
if (announce) { showSaveStatus("Settings saved."); }
|
|
1322
|
+
updateSelectionStatus();
|
|
1323
|
+
if (callback) { callback(); }
|
|
1324
|
+
}, function (msg) {
|
|
1325
|
+
addMessage("error", "Unable to save FlowPilot settings: " + msg);
|
|
1326
|
+
if (announce) { showSaveStatus("Unable to save: " + msg, true); }
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// ---- Tier-1 read tools ---------------------------------------------
|
|
1331
|
+
// Executed CLIENT-SIDE against RED.nodes (the only place this data
|
|
1332
|
+
// lives) when the model calls a tool during runAgentChat()'s loop.
|
|
1333
|
+
// Results pass through sanitizeNode(), same as selection context, so a
|
|
1334
|
+
// tool result can never carry a raw secret.
|
|
1335
|
+
|
|
1336
|
+
function executeReadNodeTool(args) {
|
|
1337
|
+
args = args || {};
|
|
1338
|
+
var node = null;
|
|
1339
|
+
if (args.id) { node = RED.nodes.node(args.id); }
|
|
1340
|
+
if (!node && args.name) {
|
|
1341
|
+
RED.nodes.eachNode(function (n) {
|
|
1342
|
+
if (!node && n.name === args.name) { node = n; }
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
if (!node) {
|
|
1346
|
+
return { error: "No node found matching " + JSON.stringify(args) + "." };
|
|
1347
|
+
}
|
|
1348
|
+
return sanitizeNode(node);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function executeListFlowsTool() {
|
|
1352
|
+
var flows = [];
|
|
1353
|
+
var counts = {};
|
|
1354
|
+
RED.nodes.eachNode(function (n) {
|
|
1355
|
+
counts[n.z] = (counts[n.z] || 0) + 1;
|
|
1356
|
+
});
|
|
1357
|
+
RED.nodes.eachWorkspace(function (ws) {
|
|
1358
|
+
flows.push({
|
|
1359
|
+
id: ws.id,
|
|
1360
|
+
label: ws.label,
|
|
1361
|
+
type: "tab",
|
|
1362
|
+
disabled: !!ws.disabled,
|
|
1363
|
+
nodeCount: counts[ws.id] || 0
|
|
1364
|
+
});
|
|
1365
|
+
});
|
|
1366
|
+
// Subflow definitions live in their own tabs ("[Subflow] <name>" in
|
|
1367
|
+
// the editor) and are NOT included in eachWorkspace — list them
|
|
1368
|
+
// separately so a subflow can be found by name/id without the model
|
|
1369
|
+
// having to guess it exists.
|
|
1370
|
+
if (RED.nodes.eachSubflow) {
|
|
1371
|
+
RED.nodes.eachSubflow(function (sf) {
|
|
1372
|
+
flows.push({
|
|
1373
|
+
id: sf.id,
|
|
1374
|
+
label: "[Subflow] " + (sf.name || sf.id),
|
|
1375
|
+
type: "subflow",
|
|
1376
|
+
nodeCount: counts[sf.id] || 0,
|
|
1377
|
+
inputs: (sf.in || []).length,
|
|
1378
|
+
outputs: (sf.out || []).length
|
|
1379
|
+
});
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
return { flows: flows };
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
var SEARCH_FLOW_MAX_RESULTS = 50;
|
|
1386
|
+
|
|
1387
|
+
function executeSearchFlowTool(args) {
|
|
1388
|
+
args = args || {};
|
|
1389
|
+
var query = args.query ? String(args.query).toLowerCase() : "";
|
|
1390
|
+
var typeFilter = args.type ? String(args.type).toLowerCase() : "";
|
|
1391
|
+
var flowFilter = args.flowId || "";
|
|
1392
|
+
var results = [];
|
|
1393
|
+
var truncated = false;
|
|
1394
|
+
|
|
1395
|
+
function pushResult(entry) {
|
|
1396
|
+
if (results.length >= SEARCH_FLOW_MAX_RESULTS) { truncated = true; return; }
|
|
1397
|
+
results.push(entry);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// Subflow definitions behave like named "flows" but aren't nodes
|
|
1401
|
+
// themselves — match them by name so e.g. "the Dad joke subflow" can
|
|
1402
|
+
// be found even though no individual node is named "Dad joke".
|
|
1403
|
+
if (!flowFilter && !typeFilter && RED.nodes.eachSubflow) {
|
|
1404
|
+
RED.nodes.eachSubflow(function (sf) {
|
|
1405
|
+
var name = String(sf.name || "").toLowerCase();
|
|
1406
|
+
if (query && name.indexOf(query) === -1) { return; }
|
|
1407
|
+
pushResult({ id: sf.id, name: sf.name || "", type: "subflow", flowId: null });
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
RED.nodes.eachNode(function (n) {
|
|
1412
|
+
if (flowFilter && n.z !== flowFilter) { return; }
|
|
1413
|
+
var type = String(n.type || "").toLowerCase();
|
|
1414
|
+
if (typeFilter && type.indexOf(typeFilter) === -1) { return; }
|
|
1415
|
+
var name = String(n.name || "").toLowerCase();
|
|
1416
|
+
// Subflow-instance nodes (type "subflow:<id>") often have no
|
|
1417
|
+
// name of their own; fall back to the referenced subflow's name
|
|
1418
|
+
// so the instance can be found by that name too.
|
|
1419
|
+
if (!name && type.indexOf("subflow:") === 0 && RED.nodes.subflow) {
|
|
1420
|
+
var sf = RED.nodes.subflow(n.type.slice("subflow:".length));
|
|
1421
|
+
if (sf && sf.name) { name = String(sf.name).toLowerCase(); }
|
|
1422
|
+
}
|
|
1423
|
+
if (query && name.indexOf(query) === -1 && type.indexOf(query) === -1) { return; }
|
|
1424
|
+
pushResult({ id: n.id, name: n.name || "", type: n.type, flowId: n.z });
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
var out = { results: results };
|
|
1428
|
+
if (truncated) {
|
|
1429
|
+
out.truncated = true;
|
|
1430
|
+
out.note = "Results truncated at " + SEARCH_FLOW_MAX_RESULTS + ". Narrow the search with " +
|
|
1431
|
+
"a more specific query, type, or flowId.";
|
|
1432
|
+
}
|
|
1433
|
+
return out;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
function executeGetConnectionsTool(args) {
|
|
1437
|
+
args = args || {};
|
|
1438
|
+
var nodes, links;
|
|
1439
|
+
|
|
1440
|
+
if (args.id) {
|
|
1441
|
+
var node = RED.nodes.node(args.id);
|
|
1442
|
+
if (!node) { return { error: "No node found for id " + args.id + "." }; }
|
|
1443
|
+
nodes = [node];
|
|
1444
|
+
links = [];
|
|
1445
|
+
if (RED.nodes.eachLink) {
|
|
1446
|
+
RED.nodes.eachLink(function (l) {
|
|
1447
|
+
var srcId = l.source && l.source.id;
|
|
1448
|
+
var tgtId = l.target && l.target.id;
|
|
1449
|
+
if (srcId === node.id || tgtId === node.id) { links.push(l); }
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
return buildConnections(nodes, links);
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
var sel = (RED.view && RED.view.selection) ? RED.view.selection() : null;
|
|
1456
|
+
if (sel && sel.nodes && sel.nodes.length) {
|
|
1457
|
+
return buildConnections(sel.nodes, sel.links || []);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
// Nothing selected and no id given: describe the whole active flow tab.
|
|
1461
|
+
var activeId = RED.workspaces && RED.workspaces.active ? RED.workspaces.active() : null;
|
|
1462
|
+
nodes = [];
|
|
1463
|
+
RED.nodes.eachNode(function (n) { if (n.z === activeId) { nodes.push(n); } });
|
|
1464
|
+
var ids = nodes.map(function (n) { return n.id; });
|
|
1465
|
+
links = [];
|
|
1466
|
+
if (RED.nodes.eachLink) {
|
|
1467
|
+
RED.nodes.eachLink(function (l) {
|
|
1468
|
+
var srcId = l.source && l.source.id;
|
|
1469
|
+
var tgtId = l.target && l.target.id;
|
|
1470
|
+
if (ids.indexOf(srcId) !== -1 || ids.indexOf(tgtId) !== -1) { links.push(l); }
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
return buildConnections(nodes, links);
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
var READ_DEBUG_DEFAULT_LIMIT = 10;
|
|
1477
|
+
var READ_DEBUG_MAX_LIMIT = 50;
|
|
1478
|
+
|
|
1479
|
+
function executeReadDebugTool(args) {
|
|
1480
|
+
args = args || {};
|
|
1481
|
+
var limit = parseInt(args.limit, 10);
|
|
1482
|
+
if (!limit || limit < 1) { limit = READ_DEBUG_DEFAULT_LIMIT; }
|
|
1483
|
+
limit = Math.min(limit, READ_DEBUG_MAX_LIMIT);
|
|
1484
|
+
return {
|
|
1485
|
+
messages: debugMessageBuffer.slice(-limit).slice().reverse(),
|
|
1486
|
+
totalBuffered: debugMessageBuffer.length
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
function executeGetSelectionTool() {
|
|
1491
|
+
var context = collectSelectionContext();
|
|
1492
|
+
if (!context) {
|
|
1493
|
+
return { selected: false, message: "Nothing is currently selected in the editor." };
|
|
1494
|
+
}
|
|
1495
|
+
return Object.assign({ selected: true }, context);
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// Per-step narration: a short human-readable description of what a tool
|
|
1499
|
+
// call is about to do, shown in the pending indicator (see runAgentChat).
|
|
1500
|
+
function describeAgentToolCall(name, args) {
|
|
1501
|
+
args = args || {};
|
|
1502
|
+
switch (name) {
|
|
1503
|
+
case "read_node":
|
|
1504
|
+
return "Reading node " + JSON.stringify(args.name || args.id || "?") + "…";
|
|
1505
|
+
case "list_flows":
|
|
1506
|
+
return "Listing flows…";
|
|
1507
|
+
case "search_flow":
|
|
1508
|
+
return "Searching the flow" + (args.query ? " for " + JSON.stringify(args.query) : "") + "…";
|
|
1509
|
+
case "get_connections":
|
|
1510
|
+
return "Checking connections…";
|
|
1511
|
+
case "read_debug":
|
|
1512
|
+
return "Checking the debug log…";
|
|
1513
|
+
case "get_selection":
|
|
1514
|
+
return "Checking the current selection…";
|
|
1515
|
+
default:
|
|
1516
|
+
return "Running " + (name || "a tool") + "…";
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
// Shared by executeAgentToolCall (execution) and runAgentChat
|
|
1521
|
+
// (narration). Malformed/missing arguments fall back to {} so the tool
|
|
1522
|
+
// can still run and report what it can't find, rather than erroring.
|
|
1523
|
+
function parseToolCallArgs(call) {
|
|
1524
|
+
try { return JSON.parse((call.function && call.function.arguments) || "{}"); }
|
|
1525
|
+
catch (e) { return {}; }
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
function executeAgentToolCall(call) {
|
|
1529
|
+
var name = call && call.function && call.function.name;
|
|
1530
|
+
var args = parseToolCallArgs(call);
|
|
1531
|
+
switch (name) {
|
|
1532
|
+
case "read_node":
|
|
1533
|
+
return executeReadNodeTool(args);
|
|
1534
|
+
case "list_flows":
|
|
1535
|
+
return executeListFlowsTool();
|
|
1536
|
+
case "search_flow":
|
|
1537
|
+
return executeSearchFlowTool(args);
|
|
1538
|
+
case "get_connections":
|
|
1539
|
+
return executeGetConnectionsTool(args);
|
|
1540
|
+
case "read_debug":
|
|
1541
|
+
return executeReadDebugTool(args);
|
|
1542
|
+
case "get_selection":
|
|
1543
|
+
return executeGetSelectionTool();
|
|
1544
|
+
default:
|
|
1545
|
+
return { error: "Unknown tool: " + name };
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
// Rough token estimate. ~4 chars per token is the standard cheap
|
|
1550
|
+
// approximation; good enough for an advisory size warning. We measure the
|
|
1551
|
+
// serialized context (nodes + connections) exactly as it will be sent.
|
|
1552
|
+
function estimateTokens(context) {
|
|
1553
|
+
if (!context) { return 0; }
|
|
1554
|
+
var chars = 0;
|
|
1555
|
+
try { chars = JSON.stringify(context).length; } catch (e) { chars = 0; }
|
|
1556
|
+
return Math.ceil(chars / 4);
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// Live indicator so the user knows what will be sent BEFORE they hit Send.
|
|
1560
|
+
// Driven by the "view:selection-changed" editor event. Shows three things:
|
|
1561
|
+
// the selection count, a size estimate with advisory tier, and a standing
|
|
1562
|
+
// secrets reminder (unless the user has suppressed it in settings).
|
|
1563
|
+
function updateSelectionStatus() {
|
|
1564
|
+
var $status = el("#fp-selection-status");
|
|
1565
|
+
if (!$status.length) { return; }
|
|
1566
|
+
|
|
1567
|
+
var sel = (RED.view && RED.view.selection) ? RED.view.selection() : null;
|
|
1568
|
+
var expandedSel = expandGroupSelection((sel && sel.nodes) ? sel.nodes : []);
|
|
1569
|
+
var liveCount = expandedSel.nodes.length;
|
|
1570
|
+
var liveGroupCount = expandedSel.groupCount;
|
|
1571
|
+
|
|
1572
|
+
var $size = el("#fp-size-status");
|
|
1573
|
+
var $secrets = el("#fp-secrets-status");
|
|
1574
|
+
|
|
1575
|
+
// While armed, a pinned selection is sent as context even with
|
|
1576
|
+
// nothing currently selected — re-resolve against live nodes so
|
|
1577
|
+
// deleted nodes drop out of the count.
|
|
1578
|
+
var pinnedContext = (armedExecuteAction && liveCount === 0 && pinnedSelectionIds)
|
|
1579
|
+
? collectSelectionContext(pinnedSelectionIds) : null;
|
|
1580
|
+
var pinnedCount = pinnedContext ? pinnedContext.nodes.length : 0;
|
|
1581
|
+
var count = liveCount || pinnedCount;
|
|
1582
|
+
|
|
1583
|
+
// pinnedSelectionIds is already flattened to real node ids (see
|
|
1584
|
+
// pinCurrentSelection/expandGroupSelection) — group membership
|
|
1585
|
+
// isn't tracked once pinned, so the group count only ever applies
|
|
1586
|
+
// to a CURRENTLY live selection, not a pinned fallback one.
|
|
1587
|
+
var groupNote = liveCount > 0 && liveGroupCount > 0
|
|
1588
|
+
? (", " + liveGroupCount + " group" + (liveGroupCount === 1 ? "" : "s"))
|
|
1589
|
+
: "";
|
|
1590
|
+
|
|
1591
|
+
if (count === 0) {
|
|
1592
|
+
$status.text("No nodes selected").removeClass("fp-has-selection");
|
|
1593
|
+
} else if (liveCount === 0 && pinnedCount > 0) {
|
|
1594
|
+
var actionLabel = armedExecuteAction === "generate" ? "Generate"
|
|
1595
|
+
: armedExecuteAction === "document" ? "Document"
|
|
1596
|
+
: armedExecuteAction === "modify" ? "Modify"
|
|
1597
|
+
: armedExecuteAction === "build" ? "Build" : "Execute";
|
|
1598
|
+
$status.text("Pinned: " + count + (count === 1 ? " node" : " nodes") +
|
|
1599
|
+
" for " + actionLabel + " — will be sent as context")
|
|
1600
|
+
.addClass("fp-has-selection");
|
|
1601
|
+
} else {
|
|
1602
|
+
$status.text(count + (count === 1 ? " node" : " nodes") + groupNote +
|
|
1603
|
+
" selected — will be sent as context")
|
|
1604
|
+
.addClass("fp-has-selection");
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
el("#fp-preview-nodes").toggleClass("fp-hidden", count === 0);
|
|
1608
|
+
|
|
1609
|
+
// Size line: selection context + attached debug messages +
|
|
1610
|
+
// conversation history.
|
|
1611
|
+
var contextTokens = liveCount > 0 ? estimateTokens(collectSelectionContext())
|
|
1612
|
+
: pinnedContext ? estimateTokens(pinnedContext) : 0;
|
|
1613
|
+
var debugTokens = attachedDebugMessages.length ? estimateTokens(buildDebugMessagesForSend()) : 0;
|
|
1614
|
+
var historyPayload = buildHistoryPayload();
|
|
1615
|
+
var historyTokens = estimateTokens(historyPayload.messages);
|
|
1616
|
+
var tokens = contextTokens + debugTokens + historyTokens;
|
|
1617
|
+
|
|
1618
|
+
if (tokens === 0) {
|
|
1619
|
+
$size.text("").addClass("fp-hidden");
|
|
1620
|
+
} else {
|
|
1621
|
+
var warnAt = Number(currentSettings.contextWarnTokens) || 4000;
|
|
1622
|
+
var highAt = Number(currentSettings.contextHighTokens) || 8000;
|
|
1623
|
+
|
|
1624
|
+
var parts = [];
|
|
1625
|
+
if (contextTokens) { parts.push("context ~" + contextTokens.toLocaleString()); }
|
|
1626
|
+
if (debugTokens) { parts.push("debug ~" + debugTokens.toLocaleString()); }
|
|
1627
|
+
if (historyTokens) {
|
|
1628
|
+
parts.push("history ~" + historyTokens.toLocaleString() +
|
|
1629
|
+
(historyPayload.truncated ? " (earlier messages omitted)" : ""));
|
|
1630
|
+
}
|
|
1631
|
+
var sizeText = "~" + tokens.toLocaleString() + " tokens" +
|
|
1632
|
+
(parts.length ? " (" + parts.join(", ") + ")" : "");
|
|
1633
|
+
|
|
1634
|
+
$size.removeClass("fp-hidden fp-size-warn fp-size-high");
|
|
1635
|
+
if (tokens >= highAt) {
|
|
1636
|
+
$size.text(sizeText + " — large; may exceed smaller local models. " +
|
|
1637
|
+
"Consider selecting fewer nodes, clearing chat history, or splitting your request.")
|
|
1638
|
+
.addClass("fp-size-high");
|
|
1639
|
+
} else if (tokens >= warnAt) {
|
|
1640
|
+
$size.text(sizeText + " — getting large; consider selecting fewer nodes or clearing chat history.")
|
|
1641
|
+
.addClass("fp-size-warn");
|
|
1642
|
+
} else {
|
|
1643
|
+
$size.text(sizeText);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
// Secrets reminder (suppressible) — only relevant when a selection is
|
|
1648
|
+
// attached as context. When redaction is actually OFF, this can't be
|
|
1649
|
+
// suppressed and gets a starker tooltip — at that point the warning is
|
|
1650
|
+
// no longer "just in case", it's literally true.
|
|
1651
|
+
var redactionOff = currentSettings.redactionEnabled === false;
|
|
1652
|
+
if (count === 0 || (currentSettings.suppressContextWarnings && !redactionOff)) {
|
|
1653
|
+
$secrets.addClass("fp-hidden");
|
|
1654
|
+
} else {
|
|
1655
|
+
$secrets.removeClass("fp-hidden").toggleClass("fp-secrets-status-off", redactionOff);
|
|
1656
|
+
$secrets.attr("title", redactionOff
|
|
1657
|
+
? "Redaction is OFF — secret-shaped values are sent as-is, unredacted. Don't send credentials or proprietary data unless you trust this AI provider."
|
|
1658
|
+
: "Context may include node config and code. Don't send credentials or proprietary data. Local/private AI recommended.");
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
relayStatusStripToPopout();
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
// First-run welcome + cockpit tour. Shows in the chat until the user
|
|
1665
|
+
// saves settings once (saveSettings stamps firstRunAcknowledged
|
|
1666
|
+
// server-side) — saving happens automatically as part of Pre-flight
|
|
1667
|
+
// check, so adding and testing a provider is enough to dismiss this.
|
|
1668
|
+
function maybeShowFirstRun(settings) {
|
|
1669
|
+
if (settings && settings.firstRunAcknowledged) { return; }
|
|
1670
|
+
|
|
1671
|
+
addMessage("assistant",
|
|
1672
|
+
"Welcome to FlowPilot. You pick the destination, I help you get there.\n\n" +
|
|
1673
|
+
"FlowPilot sends your selected Node-RED nodes — including their " +
|
|
1674
|
+
"configuration and any code inside function or template nodes — to " +
|
|
1675
|
+
"the AI provider you configure. Please keep in mind:\n\n" +
|
|
1676
|
+
"- Do not include credentials, API keys, or proprietary information " +
|
|
1677
|
+
"in anything you send.\n" +
|
|
1678
|
+
"- A local or private AI provider (e.g. LocalAI, Ollama) is strongly " +
|
|
1679
|
+
"recommended over a cloud provider.\n" +
|
|
1680
|
+
"- Generate/Modify/Document changes are always shown as a review or " +
|
|
1681
|
+
"diff first — nothing is applied until you click Apply or import.");
|
|
1682
|
+
|
|
1683
|
+
addMessage("assistant",
|
|
1684
|
+
"### Quick tour of the cockpit\n\n" +
|
|
1685
|
+
"- **Compose box** (bottom) — type a question or instruction, then " +
|
|
1686
|
+
"**Send** (Enter to send, Shift+Enter for a new line).\n" +
|
|
1687
|
+
"- **Query buttons** (orange, left of the prompt) — Explain / " +
|
|
1688
|
+
"Troubleshoot / Review / Suggest: one-click prompts about your " +
|
|
1689
|
+
"current selection.\n" +
|
|
1690
|
+
"- **Execute buttons** (blue, right of the prompt) — Document / " +
|
|
1691
|
+
"Generate / Modify: arm one, describe what you want, then Send. " +
|
|
1692
|
+
"Every change is shown as a review before anything is applied.\n" +
|
|
1693
|
+
"- **Header icons** — eraser clears the chat, magnifying glass " +
|
|
1694
|
+
"searches past conversations (Recall), bug icon attaches recent " +
|
|
1695
|
+
"Debug sidebar output, paper-plane returns to Chat, clock opens " +
|
|
1696
|
+
"your Flight log (past conversations), and the gear opens " +
|
|
1697
|
+
"Settings.\n" +
|
|
1698
|
+
"- Type `/help` any time for the full briefing, or `/demo` to see " +
|
|
1699
|
+
"Generate in action.");
|
|
1700
|
+
|
|
1701
|
+
addMessage("assistant",
|
|
1702
|
+
"### One more thing before takeoff\n\n" +
|
|
1703
|
+
"FlowPilot needs an AI provider to talk to. Click **Settings** " +
|
|
1704
|
+
"(gear icon) and add one — base URL, optional API key, and a " +
|
|
1705
|
+
"model name. Then hit **Pre-flight check** to save and test it. " +
|
|
1706
|
+
"Once that succeeds, you're all set.");
|
|
1707
|
+
|
|
1708
|
+
renderChip("Open Settings", "fa fa-cog", showSettings);
|
|
1709
|
+
}
|
|
1710
|
+
|