llm_meta_widget 0.5.0 → 0.6.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.
- checksums.yaml +4 -4
- data/README.md +17 -0
- data/app/assets/javascripts/llm_meta_widget/orchestrator.js +71 -0
- data/app/views/llm_meta_widget/_chat_panel.html.erb +104 -11
- data/lib/llm_meta_widget/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eea94229b42018fa2725d28578dd49970e9d0d3b2bc9bf095b9ac8fced1dcf80
|
|
4
|
+
data.tar.gz: 1b3c95b8632aa46d0bb6e8b338fae6ae2166356912629294e68db480e9124abf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7b6a4f6ac17d8601544fdb3ca94396b3482e66e13f3d5ba765c6d5839b608b17e692af5d7df16b51008a39ad527229193a8ce8d44345eb92feb98c31550f96b5
|
|
7
|
+
data.tar.gz: d8785730a6294fbfe926353c07ed3737add9ea8577a81cfc55471d020cfef51c641fdc735d5297a6701ab9cefe4b5a295df74c75897c84bac0e5f2c30e92f6d9
|
data/README.md
CHANGED
|
@@ -232,6 +232,23 @@ To lock the widget to the fixed `model:` prop and disable Class-1 tools entirely
|
|
|
232
232
|
| `models:` | `nil` | Model-name allowlist; `nil` = all anon-available |
|
|
233
233
|
| `hub_tools:` | `nil` | MCP-server-name allowlist; `nil` = all anon-public |
|
|
234
234
|
|
|
235
|
+
## Surviving navigation
|
|
236
|
+
|
|
237
|
+
A page action that navigates — submitting a form, following a link — no longer
|
|
238
|
+
loses the conversation. The transcript, the record of which tools ran, and the
|
|
239
|
+
model's own context are kept in `sessionStorage`: per tab, gone when the tab
|
|
240
|
+
closes, never sent anywhere.
|
|
241
|
+
|
|
242
|
+
The key is the page's **path**, not its full URL, so a form submit that returns
|
|
243
|
+
to the same page with a new query string keeps the thread, while moving to a
|
|
244
|
+
different page starts a fresh one. Nothing to configure.
|
|
245
|
+
|
|
246
|
+
Two consequences worth knowing. An action of yours that navigates is now safe
|
|
247
|
+
to declare — before this, offering one meant offering to wipe the visitor's
|
|
248
|
+
chat. And if the navigation lands on an error page that does not render the
|
|
249
|
+
widget, the conversation is still in storage but there is no panel to show it
|
|
250
|
+
until the visitor returns to a page that has one.
|
|
251
|
+
|
|
235
252
|
## Choosing what answers the chat, and whose tools to offer
|
|
236
253
|
|
|
237
254
|
Two independent settings, because they are two jobs. Neither implies the
|
|
@@ -1185,3 +1185,74 @@ export async function fetchOllamaModels({ baseUrl, signal }) {
|
|
|
1185
1185
|
return []
|
|
1186
1186
|
}
|
|
1187
1187
|
}
|
|
1188
|
+
|
|
1189
|
+
// ---- surviving navigation ------------------------------------------------
|
|
1190
|
+
//
|
|
1191
|
+
// A page action that navigates — submitting a form, following a link — used to
|
|
1192
|
+
// destroy the conversation, the record of what ran and the scroll position.
|
|
1193
|
+
// That made such actions unsafe to offer at all: PubDictionaries had to stop
|
|
1194
|
+
// declaring its submit action rather than let the assistant wipe the chat.
|
|
1195
|
+
//
|
|
1196
|
+
// The transcript is kept in sessionStorage: per tab, gone when the tab closes,
|
|
1197
|
+
// invisible to other visitors. Keyed by pathname rather than full URL, because
|
|
1198
|
+
// submitting a form usually returns to the same page with different query
|
|
1199
|
+
// parameters and that is exactly the case worth surviving.
|
|
1200
|
+
|
|
1201
|
+
export const CONVERSATION_FORMAT = 1
|
|
1202
|
+
|
|
1203
|
+
export function packConversation({ turns = [], open = false, model = null,
|
|
1204
|
+
maxTurns = 40, maxBytes = 200000 } = {}) {
|
|
1205
|
+
// Oldest turns go first when trimming: the recent ones carry the thread.
|
|
1206
|
+
let kept = turns.slice(-maxTurns)
|
|
1207
|
+
let payload = { v: CONVERSATION_FORMAT, savedAt: Date.now(), open, model, turns: kept }
|
|
1208
|
+
let json = JSON.stringify(payload)
|
|
1209
|
+
|
|
1210
|
+
while (json.length > maxBytes && kept.length > 1) {
|
|
1211
|
+
kept = kept.slice(1)
|
|
1212
|
+
payload = { ...payload, turns: kept }
|
|
1213
|
+
json = JSON.stringify(payload)
|
|
1214
|
+
}
|
|
1215
|
+
return json
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
export function unpackConversation(raw) {
|
|
1219
|
+
if (!raw) return null
|
|
1220
|
+
let payload
|
|
1221
|
+
try {
|
|
1222
|
+
payload = JSON.parse(raw)
|
|
1223
|
+
} catch (e) {
|
|
1224
|
+
return null
|
|
1225
|
+
}
|
|
1226
|
+
// A payload from a different format is not worth guessing at; starting
|
|
1227
|
+
// fresh is better than rendering something half-understood.
|
|
1228
|
+
if (!payload || payload.v !== CONVERSATION_FORMAT || !Array.isArray(payload.turns)) return null
|
|
1229
|
+
|
|
1230
|
+
const turns = payload.turns.filter((t) => t && typeof t.content === "string" &&
|
|
1231
|
+
(t.role === "user" || t.role === "assistant"))
|
|
1232
|
+
return { turns, open: !!payload.open, model: payload.model || null, savedAt: payload.savedAt || 0 }
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// Storage can throw — private windows, blocked site data, quota — and none of
|
|
1236
|
+
// that is a reason for the widget to stop working.
|
|
1237
|
+
export function createConversationStore({ storage, key }) {
|
|
1238
|
+
const safely = (fn, fallback = null) => {
|
|
1239
|
+
try {
|
|
1240
|
+
return fn()
|
|
1241
|
+
} catch (e) {
|
|
1242
|
+
return fallback
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
return {
|
|
1247
|
+
key,
|
|
1248
|
+
save(state) { return safely(() => { storage.setItem(key, packConversation(state)); return true }, false) },
|
|
1249
|
+
load() { return safely(() => unpackConversation(storage.getItem(key))) },
|
|
1250
|
+
clear() { return safely(() => { storage.removeItem(key); return true }, false) }
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
export function conversationKeyFor(location, prefix = "llm_meta_widget") {
|
|
1255
|
+
// Path only: a form submit returns to the same page with a new query string,
|
|
1256
|
+
// and that conversation is still the same conversation.
|
|
1257
|
+
return `${prefix}:${location.pathname}`
|
|
1258
|
+
}
|
|
@@ -452,7 +452,8 @@
|
|
|
452
452
|
import { runChatLoop, fetchMcpManifest, listMcpPrompts, getMcpPrompt,
|
|
453
453
|
listMcpResources, readMcpResource, promptMessagesToText,
|
|
454
454
|
loadHostResource, resourceLinesForTurn, resolvePromptArguments,
|
|
455
|
-
promptButtonProps, promptArgumentSummary, fetchOllamaModels
|
|
455
|
+
promptButtonProps, promptArgumentSummary, fetchOllamaModels,
|
|
456
|
+
createConversationStore, conversationKeyFor } from "<%= orchestrator_path %>";
|
|
456
457
|
import { marked } from "/llm_meta_widget_assets/marked.esm.js";
|
|
457
458
|
|
|
458
459
|
// Standard prose settings — GFM (tables, autolinks, strikethrough),
|
|
@@ -504,23 +505,39 @@
|
|
|
504
505
|
var STORAGE_KEY_OPEN = "llm_meta_widget:open";
|
|
505
506
|
var STORAGE_KEY_SIZE = "llm_meta_widget:size"; // stored as "WxH" in px
|
|
506
507
|
|
|
508
|
+
// Floors matching the CSS min-width/min-height (24em / 16em). A panel
|
|
509
|
+
// measured mid-layout reports a few pixels; saving that produced a sliver
|
|
510
|
+
// anchored off the bottom of the window, and — worse — it was stored, so
|
|
511
|
+
// every later visit reopened at the sliver size. Sizes below the floor are
|
|
512
|
+
// neither saved nor honoured, which also heals a value already stored.
|
|
513
|
+
var MIN_SAVED_WIDTH = 320;
|
|
514
|
+
var MIN_SAVED_HEIGHT = 240;
|
|
515
|
+
|
|
516
|
+
function plausibleSize(w, h) {
|
|
517
|
+
return w >= MIN_SAVED_WIDTH && h >= MIN_SAVED_HEIGHT &&
|
|
518
|
+
w <= window.innerWidth * 2 && h <= window.innerHeight * 2;
|
|
519
|
+
}
|
|
520
|
+
|
|
507
521
|
function applyStoredSize() {
|
|
508
522
|
try {
|
|
509
523
|
var raw = localStorage.getItem(STORAGE_KEY_SIZE);
|
|
510
524
|
if (!raw) return;
|
|
511
525
|
var parts = raw.split("x");
|
|
512
526
|
var w = parseInt(parts[0], 10), h = parseInt(parts[1], 10);
|
|
513
|
-
if (w
|
|
514
|
-
|
|
515
|
-
|
|
527
|
+
if (!plausibleSize(w, h)) {
|
|
528
|
+
localStorage.removeItem(STORAGE_KEY_SIZE); // heal a bad value
|
|
529
|
+
return;
|
|
516
530
|
}
|
|
531
|
+
root.style.width = w + "px";
|
|
532
|
+
root.style.height = h + "px";
|
|
517
533
|
} catch (e) { /* noop */ }
|
|
518
534
|
}
|
|
519
535
|
function saveCurrentSize() {
|
|
520
536
|
try {
|
|
521
537
|
var rect = root.getBoundingClientRect();
|
|
522
|
-
|
|
523
|
-
|
|
538
|
+
var w = Math.round(rect.width), h = Math.round(rect.height);
|
|
539
|
+
if (!plausibleSize(w, h)) return; // layout noise, not a choice
|
|
540
|
+
localStorage.setItem(STORAGE_KEY_SIZE, w + "x" + h);
|
|
524
541
|
} catch (e) { /* noop */ }
|
|
525
542
|
}
|
|
526
543
|
|
|
@@ -528,13 +545,20 @@
|
|
|
528
545
|
// size it currently has. Uses top/left in pixels (required for CSS
|
|
529
546
|
// `resize: both` — a bottom/right-pinned element can't be dragged out
|
|
530
547
|
// past the viewport edge to grow).
|
|
531
|
-
function positionAtBottomRight() {
|
|
548
|
+
function positionAtBottomRight(attempt) {
|
|
532
549
|
var margin = 16; // ~1em from the viewport edges
|
|
533
550
|
var rect = root.getBoundingClientRect();
|
|
534
551
|
var top = Math.max(margin, window.innerHeight - rect.height - margin);
|
|
535
552
|
var left = Math.max(margin, window.innerWidth - rect.width - margin);
|
|
536
553
|
root.style.top = top + "px";
|
|
537
554
|
root.style.left = left + "px";
|
|
555
|
+
|
|
556
|
+
// Measured before layout settled: the panel reports a sliver and gets
|
|
557
|
+
// anchored to the very bottom of the window. Re-measure next frame.
|
|
558
|
+
var tries = attempt || 0;
|
|
559
|
+
if (rect.height < MIN_SAVED_HEIGHT && tries < 3 && typeof requestAnimationFrame === "function") {
|
|
560
|
+
requestAnimationFrame(function() { positionAtBottomRight(tries + 1); });
|
|
561
|
+
}
|
|
538
562
|
}
|
|
539
563
|
|
|
540
564
|
function setOpen(open) {
|
|
@@ -560,6 +584,9 @@
|
|
|
560
584
|
var initialOpen = false;
|
|
561
585
|
try { initialOpen = localStorage.getItem(STORAGE_KEY_OPEN) === "1"; } catch (e) { /* noop */ }
|
|
562
586
|
setOpen(initialOpen);
|
|
587
|
+
window.addEventListener("resize", function() {
|
|
588
|
+
if (!root.classList.contains("lmw-collapsed")) positionAtBottomRight();
|
|
589
|
+
});
|
|
563
590
|
|
|
564
591
|
toggleBtn.addEventListener("click", function() { setOpen(true); });
|
|
565
592
|
hideBtn.addEventListener("click", function() { setOpen(false); });
|
|
@@ -568,15 +595,39 @@
|
|
|
568
595
|
// continuously during a drag; save + reposition on each tick so the
|
|
569
596
|
// widget stays anchored to bottom-right as the user grows it.
|
|
570
597
|
if (typeof ResizeObserver === "function") {
|
|
598
|
+
var sawFirstResizeTick = false;
|
|
571
599
|
var ro = new ResizeObserver(function() {
|
|
572
600
|
if (root.classList.contains("lmw-collapsed")) return;
|
|
573
601
|
positionAtBottomRight();
|
|
602
|
+
// ResizeObserver always delivers an initial observation, and that
|
|
603
|
+
// one reports whatever the layout happened to be mid-boot. Only
|
|
604
|
+
// later ticks can be a user dragging the corner.
|
|
605
|
+
if (!sawFirstResizeTick) { sawFirstResizeTick = true; return; }
|
|
574
606
|
saveCurrentSize();
|
|
575
607
|
});
|
|
576
608
|
ro.observe(root);
|
|
577
609
|
}
|
|
578
610
|
|
|
579
|
-
|
|
611
|
+
// [{role, content, tools}] — grows across turns. `tools` is for the
|
|
612
|
+
// transcript only; the model is sent role and content.
|
|
613
|
+
var conversation = [];
|
|
614
|
+
|
|
615
|
+
// A page action that navigates used to take the conversation with it. The
|
|
616
|
+
// transcript lives in sessionStorage — per tab, gone when the tab closes —
|
|
617
|
+
// keyed by path, so submitting a form and landing back on the same page
|
|
618
|
+
// with a new query string keeps the thread.
|
|
619
|
+
var conversationStore = createConversationStore({
|
|
620
|
+
storage: window.sessionStorage,
|
|
621
|
+
key: conversationKeyFor(window.location)
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
function persistConversation() {
|
|
625
|
+
conversationStore.save({
|
|
626
|
+
turns: conversation,
|
|
627
|
+
open: !root.classList.contains("lmw-collapsed"),
|
|
628
|
+
model: MODEL
|
|
629
|
+
});
|
|
630
|
+
}
|
|
580
631
|
var actionsSchemaEl = document.getElementById(ACTIONS_SCHEMA_ID);
|
|
581
632
|
var localTools = actionsSchemaEl ? JSON.parse(actionsSchemaEl.textContent) : [];
|
|
582
633
|
var remoteToolsEl = document.getElementById(REMOTE_TOOLS_SCHEMA_ID);
|
|
@@ -845,6 +896,10 @@
|
|
|
845
896
|
var RESOURCE_BUDGET_BYTES = 8000;
|
|
846
897
|
|
|
847
898
|
|
|
899
|
+
// Before anything asynchronous: if this page was navigated away from and
|
|
900
|
+
// back, the visitor should see their conversation immediately.
|
|
901
|
+
restoreConversation();
|
|
902
|
+
|
|
848
903
|
var wellKnownReady = (async function() {
|
|
849
904
|
var urls = WELL_KNOWN_URLS === null
|
|
850
905
|
? [ window.location.origin + "/.well-known/mcp.json" ]
|
|
@@ -921,11 +976,39 @@
|
|
|
921
976
|
promptsEl.style.display = "";
|
|
922
977
|
}
|
|
923
978
|
|
|
979
|
+
// Put a saved transcript back on screen: the same bubbles, the same
|
|
980
|
+
// markdown, and the same record of which tools ran. Called once at boot,
|
|
981
|
+
// before the welcome block, which is only for a genuinely fresh start.
|
|
982
|
+
function restoreConversation() {
|
|
983
|
+
var saved = conversationStore.load();
|
|
984
|
+
if (!saved || !saved.turns.length) return false;
|
|
985
|
+
|
|
986
|
+
conversation = saved.turns;
|
|
987
|
+
saved.turns.forEach(function(turn) {
|
|
988
|
+
var body = appendTurn(turn.role, turn.role === "assistant" ? "" : turn.content);
|
|
989
|
+
if (turn.role === "assistant") renderMarkdownInto(body, turn.content || "");
|
|
990
|
+
if (!turn.tools || !turn.tools.length) return;
|
|
991
|
+
var row = document.createElement("div");
|
|
992
|
+
row.className = "lmw-tool-chips";
|
|
993
|
+
turn.tools.forEach(function(tool) {
|
|
994
|
+
var chip = document.createElement("span");
|
|
995
|
+
chip.className = "lmw-tool-chip" + (tool.error ? " error" : "");
|
|
996
|
+
chip.textContent = (tool.error ? "❌ " : "🔧 ") + tool.name;
|
|
997
|
+
row.appendChild(chip);
|
|
998
|
+
});
|
|
999
|
+
if (body.parentNode) body.parentNode.appendChild(row);
|
|
1000
|
+
});
|
|
1001
|
+
historyEl.scrollTop = historyEl.scrollHeight;
|
|
1002
|
+
return true;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
924
1005
|
// A blank panel tells a first-time visitor nothing. Open with a greeting
|
|
925
1006
|
// and the offers themselves — each template showing what it will take from
|
|
926
1007
|
// the page and what it will ask for — so the assistant is the page's way
|
|
927
1008
|
// in rather than a box you must already know how to talk to.
|
|
928
1009
|
function renderWelcome() {
|
|
1010
|
+
// A restored transcript is not a fresh start; the greeting would push
|
|
1011
|
+
// the conversation the visitor is mid-way through off the screen.
|
|
929
1012
|
if (!historyEl || historyEl.querySelector(".message")) return;
|
|
930
1013
|
historyEl.textContent = "";
|
|
931
1014
|
|
|
@@ -1238,6 +1321,7 @@
|
|
|
1238
1321
|
clearBtn.addEventListener("click", function() {
|
|
1239
1322
|
if (currentAbort) { try { currentAbort.abort(); } catch (e) { /* noop */ } }
|
|
1240
1323
|
conversation = [];
|
|
1324
|
+
conversationStore.clear();
|
|
1241
1325
|
historyEl.innerHTML = "";
|
|
1242
1326
|
renderWelcome();
|
|
1243
1327
|
});
|
|
@@ -1280,7 +1364,7 @@
|
|
|
1280
1364
|
|
|
1281
1365
|
var resourceLines = await resourceLinesForThisTurn();
|
|
1282
1366
|
var messages = [{ role: "system", content: currentSystemPrompt(resourceLines) }]
|
|
1283
|
-
.concat(conversation)
|
|
1367
|
+
.concat(conversation.map(function(t) { return { role: t.role, content: t.content }; }))
|
|
1284
1368
|
.concat([{ role: "user", content: userText }]);
|
|
1285
1369
|
|
|
1286
1370
|
var result = await runChatLoop({
|
|
@@ -1331,8 +1415,17 @@
|
|
|
1331
1415
|
historyEl.scrollTop = historyEl.scrollHeight;
|
|
1332
1416
|
}
|
|
1333
1417
|
});
|
|
1334
|
-
conversation.push({ role: "user",
|
|
1335
|
-
conversation.push({
|
|
1418
|
+
conversation.push({ role: "user", content: userText });
|
|
1419
|
+
conversation.push({
|
|
1420
|
+
role: "assistant",
|
|
1421
|
+
content: result.content,
|
|
1422
|
+
// Kept so a restored transcript still shows what ran — losing
|
|
1423
|
+
// that record is what made a navigating action unbearable.
|
|
1424
|
+
tools: result.dispatched.map(function(d) {
|
|
1425
|
+
return d.error ? { name: d.toolCall.name, error: true } : { name: d.toolCall.name };
|
|
1426
|
+
})
|
|
1427
|
+
});
|
|
1428
|
+
persistConversation();
|
|
1336
1429
|
|
|
1337
1430
|
// Anything still pending never reported an outcome (a class that
|
|
1338
1431
|
// does not round-trip, or a dispatch that vanished). Reconcile
|