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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e6113516f03758b2dc602dd4f2e62b515206821b710c646c07faf474e1f9aa48
4
- data.tar.gz: 5d65ceb25848ba253dfc61f92f04ed6f1110581fa5c3b7640c26718f2c4c4e51
3
+ metadata.gz: eea94229b42018fa2725d28578dd49970e9d0d3b2bc9bf095b9ac8fced1dcf80
4
+ data.tar.gz: 1b3c95b8632aa46d0bb6e8b338fae6ae2166356912629294e68db480e9124abf
5
5
  SHA512:
6
- metadata.gz: 6b255c67fa8f44ccc1a7fcb71a2ccb149bf1e02f1441c7bbda31a22502f98c491902dd978c7817e411af5865a54557a89581ec36dbd64e41ada6a1722397d04d
7
- data.tar.gz: dd5fa208a4fbb758fdcf7b9683f6102ea49b99270fd5a0e49182ab7eb21966705b6aca5ae7ee746694dd3a019ef6bdf662d0d24f57551dd2a8b86e0921cee0cd
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 } from "<%= orchestrator_path %>";
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 > 0 && h > 0) {
514
- root.style.width = w + "px";
515
- root.style.height = h + "px";
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
- localStorage.setItem(STORAGE_KEY_SIZE,
523
- Math.round(rect.width) + "x" + Math.round(rect.height));
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
- var conversation = []; // [{role, content}, ...] — grows across turns
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", content: userText });
1335
- conversation.push({ role: "assistant", content: result.content });
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
@@ -1,3 +1,3 @@
1
1
  module LlmMetaWidget
2
- VERSION = "0.5.0"
2
+ VERSION = "0.6.1"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm_meta_widget
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - jdkim