llm_meta_widget 0.5.0 → 0.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e6113516f03758b2dc602dd4f2e62b515206821b710c646c07faf474e1f9aa48
4
- data.tar.gz: 5d65ceb25848ba253dfc61f92f04ed6f1110581fa5c3b7640c26718f2c4c4e51
3
+ metadata.gz: f019c93684d03e4f65ead3c8427b369af41caf2b9f4b0637f60aa2014239f080
4
+ data.tar.gz: 28bf06bc291b4bc0f745b85ae7f067f9e9c3f8adfd4902d747a4496d1b2e34da
5
5
  SHA512:
6
- metadata.gz: 6b255c67fa8f44ccc1a7fcb71a2ccb149bf1e02f1441c7bbda31a22502f98c491902dd978c7817e411af5865a54557a89581ec36dbd64e41ada6a1722397d04d
7
- data.tar.gz: dd5fa208a4fbb758fdcf7b9683f6102ea49b99270fd5a0e49182ab7eb21966705b6aca5ae7ee746694dd3a019ef6bdf662d0d24f57551dd2a8b86e0921cee0cd
6
+ metadata.gz: 50a79ea50cd118b4cafb1a26ff5af6713fd6b98dd1374ad4a2b2fa10fff24677fe417cee3b4cf4d900e121b3b4be3c013fcbc1aa95d4183f714e629a100432c8
7
+ data.tar.gz: 1bbed09f23f1c5c7f9b08500db77d2dd1b7ba97e1b564405b40f8cadd9fb1bac920a4d612e187119d08cd90db51301b96dc8d3d3ad22e0196adc04b5ee1621c7
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),
@@ -576,7 +577,26 @@
576
577
  ro.observe(root);
577
578
  }
578
579
 
579
- var conversation = []; // [{role, content}, ...] — grows across turns
580
+ // [{role, content, tools}] — grows across turns. `tools` is for the
581
+ // transcript only; the model is sent role and content.
582
+ var conversation = [];
583
+
584
+ // A page action that navigates used to take the conversation with it. The
585
+ // transcript lives in sessionStorage — per tab, gone when the tab closes —
586
+ // keyed by path, so submitting a form and landing back on the same page
587
+ // with a new query string keeps the thread.
588
+ var conversationStore = createConversationStore({
589
+ storage: window.sessionStorage,
590
+ key: conversationKeyFor(window.location)
591
+ });
592
+
593
+ function persistConversation() {
594
+ conversationStore.save({
595
+ turns: conversation,
596
+ open: !root.classList.contains("lmw-collapsed"),
597
+ model: MODEL
598
+ });
599
+ }
580
600
  var actionsSchemaEl = document.getElementById(ACTIONS_SCHEMA_ID);
581
601
  var localTools = actionsSchemaEl ? JSON.parse(actionsSchemaEl.textContent) : [];
582
602
  var remoteToolsEl = document.getElementById(REMOTE_TOOLS_SCHEMA_ID);
@@ -845,6 +865,10 @@
845
865
  var RESOURCE_BUDGET_BYTES = 8000;
846
866
 
847
867
 
868
+ // Before anything asynchronous: if this page was navigated away from and
869
+ // back, the visitor should see their conversation immediately.
870
+ restoreConversation();
871
+
848
872
  var wellKnownReady = (async function() {
849
873
  var urls = WELL_KNOWN_URLS === null
850
874
  ? [ window.location.origin + "/.well-known/mcp.json" ]
@@ -921,11 +945,39 @@
921
945
  promptsEl.style.display = "";
922
946
  }
923
947
 
948
+ // Put a saved transcript back on screen: the same bubbles, the same
949
+ // markdown, and the same record of which tools ran. Called once at boot,
950
+ // before the welcome block, which is only for a genuinely fresh start.
951
+ function restoreConversation() {
952
+ var saved = conversationStore.load();
953
+ if (!saved || !saved.turns.length) return false;
954
+
955
+ conversation = saved.turns;
956
+ saved.turns.forEach(function(turn) {
957
+ var body = appendTurn(turn.role, turn.role === "assistant" ? "" : turn.content);
958
+ if (turn.role === "assistant") renderMarkdownInto(body, turn.content || "");
959
+ if (!turn.tools || !turn.tools.length) return;
960
+ var row = document.createElement("div");
961
+ row.className = "lmw-tool-chips";
962
+ turn.tools.forEach(function(tool) {
963
+ var chip = document.createElement("span");
964
+ chip.className = "lmw-tool-chip" + (tool.error ? " error" : "");
965
+ chip.textContent = (tool.error ? "❌ " : "🔧 ") + tool.name;
966
+ row.appendChild(chip);
967
+ });
968
+ if (body.parentNode) body.parentNode.appendChild(row);
969
+ });
970
+ historyEl.scrollTop = historyEl.scrollHeight;
971
+ return true;
972
+ }
973
+
924
974
  // A blank panel tells a first-time visitor nothing. Open with a greeting
925
975
  // and the offers themselves — each template showing what it will take from
926
976
  // the page and what it will ask for — so the assistant is the page's way
927
977
  // in rather than a box you must already know how to talk to.
928
978
  function renderWelcome() {
979
+ // A restored transcript is not a fresh start; the greeting would push
980
+ // the conversation the visitor is mid-way through off the screen.
929
981
  if (!historyEl || historyEl.querySelector(".message")) return;
930
982
  historyEl.textContent = "";
931
983
 
@@ -1238,6 +1290,7 @@
1238
1290
  clearBtn.addEventListener("click", function() {
1239
1291
  if (currentAbort) { try { currentAbort.abort(); } catch (e) { /* noop */ } }
1240
1292
  conversation = [];
1293
+ conversationStore.clear();
1241
1294
  historyEl.innerHTML = "";
1242
1295
  renderWelcome();
1243
1296
  });
@@ -1280,7 +1333,7 @@
1280
1333
 
1281
1334
  var resourceLines = await resourceLinesForThisTurn();
1282
1335
  var messages = [{ role: "system", content: currentSystemPrompt(resourceLines) }]
1283
- .concat(conversation)
1336
+ .concat(conversation.map(function(t) { return { role: t.role, content: t.content }; }))
1284
1337
  .concat([{ role: "user", content: userText }]);
1285
1338
 
1286
1339
  var result = await runChatLoop({
@@ -1331,8 +1384,17 @@
1331
1384
  historyEl.scrollTop = historyEl.scrollHeight;
1332
1385
  }
1333
1386
  });
1334
- conversation.push({ role: "user", content: userText });
1335
- conversation.push({ role: "assistant", content: result.content });
1387
+ conversation.push({ role: "user", content: userText });
1388
+ conversation.push({
1389
+ role: "assistant",
1390
+ content: result.content,
1391
+ // Kept so a restored transcript still shows what ran — losing
1392
+ // that record is what made a navigating action unbearable.
1393
+ tools: result.dispatched.map(function(d) {
1394
+ return d.error ? { name: d.toolCall.name, error: true } : { name: d.toolCall.name };
1395
+ })
1396
+ });
1397
+ persistConversation();
1336
1398
 
1337
1399
  // Anything still pending never reported an outcome (a class that
1338
1400
  // 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.0"
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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - jdkim