@aibranch/llm-meta-widget 0.12.1 → 0.13.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/README.md
CHANGED
|
@@ -61,7 +61,7 @@ Option 3 if it does not.
|
|
|
61
61
|
|
|
62
62
|
```ruby
|
|
63
63
|
# Gemfile
|
|
64
|
-
gem "llm_meta_widget", "~> 0.
|
|
64
|
+
gem "llm_meta_widget", "~> 0.13.0"
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
```bash
|
|
@@ -152,23 +152,27 @@ The widget is a custom element in a single self-contained ES module, so the
|
|
|
152
152
|
whole integration is two lines of HTML:
|
|
153
153
|
|
|
154
154
|
```html
|
|
155
|
-
<script type="module" src="https://cdn.jsdelivr.net/npm/@aibranch/llm-meta-widget@0.
|
|
155
|
+
<script type="module" src="https://cdn.jsdelivr.net/npm/@aibranch/llm-meta-widget@0.13"></script>
|
|
156
156
|
<llm-meta-widget llm-url="https://your-meta-server.example"
|
|
157
157
|
model="gpt-oss-120b"
|
|
158
158
|
greeting="Hi — ask me anything about this page."></llm-meta-widget>
|
|
159
159
|
```
|
|
160
160
|
|
|
161
|
-
**`@0.
|
|
161
|
+
**`@0.13` is a version range, and it stops at 0.13.x on purpose.** You receive
|
|
162
162
|
bug fixes without editing your page, and a release that breaks your page cannot
|
|
163
163
|
arrive on its own.
|
|
164
164
|
|
|
165
|
-
Moving to 0.
|
|
165
|
+
Moving to 0.14 is therefore a change you make yourself. Read
|
|
166
166
|
[CHANGELOG.md](CHANGELOG.md) before you do, because this is where what your page
|
|
167
167
|
must provide can change. For example, 0.10.0 changed how the widget finds a page
|
|
168
168
|
card: by media type rather than by element id. A page that declared its card the
|
|
169
169
|
new way while still loading `@0.9` lost every tool, prompt and resource it had
|
|
170
170
|
declared. Nothing failed, anywhere. The CHANGELOG entry for 0.10.0 describes it.
|
|
171
171
|
|
|
172
|
+
0.13.0 moves where a page declares its transport. That one is safe to take
|
|
173
|
+
first, because 0.13.x still reads the old spelling and warns; the page can move
|
|
174
|
+
afterwards.
|
|
175
|
+
|
|
172
176
|
A Rails app is protected from that mismatch, because the `Gemfile` will not
|
|
173
177
|
resolve a widget version the page cannot use. A page that loads the widget from
|
|
174
178
|
the CDN has no such check, so the version you write in that URL is the only
|
|
@@ -178,6 +178,8 @@ async function runChatLoop(opts) {
|
|
|
178
178
|
// Called when a tool result is dropped to make room, so the panel can say
|
|
179
179
|
// so rather than the context quietly thinning.
|
|
180
180
|
onContextEvicted,
|
|
181
|
+
// Called when one oversized result is shortened, so the panel can say so.
|
|
182
|
+
onContextTruncated,
|
|
181
183
|
// "hub" (default) talks to llm_meta_server; "ollama" talks straight to an
|
|
182
184
|
// Ollama, with no server component of ours in between. The loop is the
|
|
183
185
|
// same either way — only the call is swapped.
|
|
@@ -211,6 +213,7 @@ async function runChatLoop(opts) {
|
|
|
211
213
|
const mergedLocalTools = Object.values(inlineByName);
|
|
212
214
|
const rounds = [];
|
|
213
215
|
const evicted = [];
|
|
216
|
+
const truncatedResults = [];
|
|
214
217
|
const toolsTokens = estimateToolsTokens([].concat(
|
|
215
218
|
singleOpts.localTools || [],
|
|
216
219
|
remoteTools || [],
|
|
@@ -246,6 +249,15 @@ async function runChatLoop(opts) {
|
|
|
246
249
|
for (let round = 0; round < maxRounds; round++) {
|
|
247
250
|
if (signal?.aborted) throw new DOMException("aborted", "AbortError");
|
|
248
251
|
onRoundStart?.(round);
|
|
252
|
+
const capped = truncateOversizedToolResults({
|
|
253
|
+
messages,
|
|
254
|
+
window: contextWindow,
|
|
255
|
+
truncated: truncatedResults
|
|
256
|
+
});
|
|
257
|
+
if (capped.messages !== messages) {
|
|
258
|
+
messages = capped.messages;
|
|
259
|
+
onContextTruncated?.(truncatedResults.slice());
|
|
260
|
+
}
|
|
249
261
|
const room = evictOldToolResults({
|
|
250
262
|
messages,
|
|
251
263
|
window: contextWindow,
|
|
@@ -255,7 +267,7 @@ async function runChatLoop(opts) {
|
|
|
255
267
|
});
|
|
256
268
|
if (room.messages !== messages) {
|
|
257
269
|
messages = room.messages;
|
|
258
|
-
onContextEvicted?.(evicted.slice());
|
|
270
|
+
onContextEvicted?.({ dropped: evicted.slice(), needed: room.needed, window: room.window });
|
|
259
271
|
}
|
|
260
272
|
const call = provider === "ollama" ? ollamaChatCall : singleLlmCall;
|
|
261
273
|
const turnResult = await call({
|
|
@@ -622,10 +634,34 @@ function estimateToolsTokens(tools) {
|
|
|
622
634
|
})).join("");
|
|
623
635
|
return estimateTextTokens(text);
|
|
624
636
|
}
|
|
637
|
+
var MAX_TOOL_RESULT_SHARE = 0.4;
|
|
638
|
+
function truncateOversizedToolResults({
|
|
639
|
+
messages,
|
|
640
|
+
window: window2,
|
|
641
|
+
share = MAX_TOOL_RESULT_SHARE,
|
|
642
|
+
truncated = []
|
|
643
|
+
} = {}) {
|
|
644
|
+
if (!window2 || !Number.isFinite(window2)) return { messages, truncated };
|
|
645
|
+
const cap = Math.floor(window2 * share);
|
|
646
|
+
let out = null;
|
|
647
|
+
(messages || []).forEach((m, i) => {
|
|
648
|
+
if (m?.role !== "tool" || typeof m.content !== "string") return;
|
|
649
|
+
const tokens = estimateTextTokens(m.content);
|
|
650
|
+
if (tokens <= cap) return;
|
|
651
|
+
const keep = Math.max(1, Math.floor(m.content.length * cap / tokens));
|
|
652
|
+
out = out || messages.slice();
|
|
653
|
+
truncated.push({ name: m.name, fromTokens: tokens, toTokens: cap });
|
|
654
|
+
out[i] = Object.assign({}, m, {
|
|
655
|
+
content: m.content.slice(0, keep) + "\n\n[truncated: " + keep + " of " + m.content.length + " characters shown. One tool result may use at most " + Math.round(share * 100) + "% of the context window. Call the tool again with a narrower query to see more.]"
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
return { messages: out || messages, truncated };
|
|
659
|
+
}
|
|
625
660
|
function evictOldToolResults({ messages, window: window2, reserve = 0, dropped = [] }) {
|
|
626
661
|
if (!window2 || !Number.isFinite(window2)) return { messages, dropped };
|
|
627
662
|
const budget = window2 - reserve;
|
|
628
|
-
|
|
663
|
+
const needed = estimateTokens(messages) + reserve;
|
|
664
|
+
if (estimateTokens(messages) <= budget) return { messages, dropped, needed, window: window2 };
|
|
629
665
|
const out = messages.slice();
|
|
630
666
|
for (let i = 0; i < out.length; i++) {
|
|
631
667
|
if (estimateTokens(out) <= budget) break;
|
|
@@ -637,7 +673,7 @@ function evictOldToolResults({ messages, window: window2, reserve = 0, dropped =
|
|
|
637
673
|
content: "[dropped to make room in the context window. Call the tool again if you still need it, with a narrower query.]"
|
|
638
674
|
});
|
|
639
675
|
}
|
|
640
|
-
return { messages: out, dropped };
|
|
676
|
+
return { messages: out, dropped, needed, window: window2 };
|
|
641
677
|
}
|
|
642
678
|
function modelWindowFrom(payload, modelId) {
|
|
643
679
|
for (const family of payload && payload.llms || []) {
|
|
@@ -653,15 +689,29 @@ function modelWindowFrom(payload, modelId) {
|
|
|
653
689
|
return null;
|
|
654
690
|
}
|
|
655
691
|
var PAGE_GLOBAL_TRANSPORT = "page-global";
|
|
656
|
-
function declaredPageGlobal(card) {
|
|
692
|
+
function declaredPageGlobal(card, warn = console.warn) {
|
|
693
|
+
const declared = card && card.transports || [];
|
|
694
|
+
for (const transport of declared) {
|
|
695
|
+
if (!transport || transport.type !== PAGE_GLOBAL_TRANSPORT) continue;
|
|
696
|
+
if (typeof transport.global === "string" && transport.global.trim() !== "") {
|
|
697
|
+
return transport.global.trim();
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
if (declared.length) {
|
|
701
|
+
warn("[llm-meta-widget] page card declares transports " + JSON.stringify(declared.map((t) => t && t.type)) + ", none of which this widget supports; not dispatching to the page");
|
|
702
|
+
return null;
|
|
703
|
+
}
|
|
657
704
|
for (const remote of card && card.remotes || []) {
|
|
658
705
|
if (!remote || remote.type !== PAGE_GLOBAL_TRANSPORT) continue;
|
|
659
|
-
if (typeof remote.global === "string" && remote.global.trim() !== "")
|
|
706
|
+
if (typeof remote.global === "string" && remote.global.trim() !== "") {
|
|
707
|
+
warn(`[llm-meta-widget] page card declares its transport in top-level "remotes", which is deprecated; move it to _meta["${STATIC_PRIMITIVES_META}"].transports`);
|
|
708
|
+
return remote.global.trim();
|
|
709
|
+
}
|
|
660
710
|
}
|
|
661
711
|
return null;
|
|
662
712
|
}
|
|
663
713
|
function pageHandlers({ card, scope = {}, legacy = {}, warn = console.warn } = {}) {
|
|
664
|
-
const name = declaredPageGlobal(card);
|
|
714
|
+
const name = declaredPageGlobal(card, warn);
|
|
665
715
|
const declared = name ? scope[name] : null;
|
|
666
716
|
if (name && !declared) {
|
|
667
717
|
warn(`[llm-meta-widget] the page card declares global "${name}", but window.${name} is not defined \u2014 is the script that defines it loaded, and did it run?`);
|
|
@@ -683,16 +733,19 @@ function pageHandlers({ card, scope = {}, legacy = {}, warn = console.warn } = {
|
|
|
683
733
|
};
|
|
684
734
|
}
|
|
685
735
|
function mergeStaticPrimitivesCards(cards, warn = console.warn) {
|
|
686
|
-
const merged = { remotes: [], tools: [], prompts: [], resources: [] };
|
|
736
|
+
const merged = { transports: [], remotes: [], tools: [], prompts: [], resources: [] };
|
|
687
737
|
for (const card of cards || []) {
|
|
688
|
-
for (const
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
738
|
+
for (const kind of ["transports", "remotes"]) {
|
|
739
|
+
for (const entry of card && card[kind] || []) {
|
|
740
|
+
if (!entry || typeof entry.type !== "string") continue;
|
|
741
|
+
const clash = merged[kind].find((r) => r.type === entry.type && r.global !== entry.global);
|
|
742
|
+
if (clash) {
|
|
743
|
+
warn(`[llm-meta-widget] two blocks declare different ${entry.type} globals ("${clash.global}" and "${entry.global}"); using "${clash.global}"`);
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
if (!merged[kind].some((r) => r.type === entry.type && r.global === entry.global)) {
|
|
747
|
+
merged[kind].push(entry);
|
|
748
|
+
}
|
|
696
749
|
}
|
|
697
750
|
}
|
|
698
751
|
}
|
|
@@ -713,7 +766,7 @@ function mergeStaticPrimitivesCards(cards, warn = console.warn) {
|
|
|
713
766
|
return merged;
|
|
714
767
|
}
|
|
715
768
|
function readStaticPrimitivesCard(text, warn = console.warn) {
|
|
716
|
-
const empty = { remotes: [], tools: [], prompts: [], resources: [] };
|
|
769
|
+
const empty = { transports: [], remotes: [], tools: [], prompts: [], resources: [] };
|
|
717
770
|
if (typeof text !== "string" || text.trim() === "") return empty;
|
|
718
771
|
let doc;
|
|
719
772
|
try {
|
|
@@ -733,7 +786,12 @@ function readStaticPrimitivesCard(text, warn = console.warn) {
|
|
|
733
786
|
return ok;
|
|
734
787
|
});
|
|
735
788
|
return {
|
|
736
|
-
//
|
|
789
|
+
// Where a page says how to reach its handlers. Since SEP d44bd25a this is
|
|
790
|
+
// `transports` inside the extension data, because the Server Card's
|
|
791
|
+
// `Remote.type` enum is streamable-http|sse and `page-global` was never a
|
|
792
|
+
// valid value there. Top-level `remotes` is what 0.11.1-0.12.2 read, and is
|
|
793
|
+
// still accepted for one release — see declaredPageGlobal.
|
|
794
|
+
transports: Array.isArray(ext.transports) ? ext.transports : [],
|
|
737
795
|
remotes: Array.isArray(doc.remotes) ? doc.remotes : [],
|
|
738
796
|
tools: named(ext.tools, "tool"),
|
|
739
797
|
prompts: named(ext.prompts, "prompt"),
|
|
@@ -3952,6 +4010,22 @@ function boot(cfg) {
|
|
|
3952
4010
|
var toggleBtn = document.getElementById("llm-meta-widget-toggle");
|
|
3953
4011
|
var historyEl = root.querySelector(".lmw-messages");
|
|
3954
4012
|
var SCROLL_PIN_SLACK = 40;
|
|
4013
|
+
var skippedResources = [];
|
|
4014
|
+
var announcedSkips = false;
|
|
4015
|
+
function kb(bytes) {
|
|
4016
|
+
return Math.round((bytes || 0) / 1024) + " KB";
|
|
4017
|
+
}
|
|
4018
|
+
function noteSkippedResource(plan, budget, origin) {
|
|
4019
|
+
if (plan.reason !== "over-budget") return;
|
|
4020
|
+
var name = plan.name || plan.uri;
|
|
4021
|
+
skippedResources = skippedResources.filter(function(s) {
|
|
4022
|
+
return s.name !== name;
|
|
4023
|
+
});
|
|
4024
|
+
skippedResources.push({ name, size: plan.size, budget, origin });
|
|
4025
|
+
}
|
|
4026
|
+
function thousands(n) {
|
|
4027
|
+
return (Math.round(n / 1e3) * 1e3).toLocaleString("en-US");
|
|
4028
|
+
}
|
|
3955
4029
|
function atBottom() {
|
|
3956
4030
|
return historyEl.scrollHeight - historyEl.scrollTop - historyEl.clientHeight <= SCROLL_PIN_SLACK;
|
|
3957
4031
|
}
|
|
@@ -4343,11 +4417,16 @@ function boot(cfg) {
|
|
|
4343
4417
|
var budget = resourceBudget();
|
|
4344
4418
|
if (pageResourcePlans === null || budget !== pageResourcePlansBudget) {
|
|
4345
4419
|
pageResourcePlansBudget = budget;
|
|
4420
|
+
skippedResources = skippedResources.filter(function(s) {
|
|
4421
|
+
return s.origin !== "page";
|
|
4422
|
+
});
|
|
4423
|
+
announcedSkips = false;
|
|
4346
4424
|
pageResourcePlans = planPageResources({
|
|
4347
4425
|
entries: pageCard.resources,
|
|
4348
4426
|
budgetBytes: budget,
|
|
4349
4427
|
onSkip: function(plan) {
|
|
4350
4428
|
console.info("[llm_meta_widget] not attaching " + plan.uri + " (" + plan.reason + ", size=" + plan.size + ")");
|
|
4429
|
+
noteSkippedResource(plan, resourceBudget(), "page");
|
|
4351
4430
|
}
|
|
4352
4431
|
});
|
|
4353
4432
|
}
|
|
@@ -4378,6 +4457,7 @@ function boot(cfg) {
|
|
|
4378
4457
|
read: readMcpResource,
|
|
4379
4458
|
onSkip: function(plan) {
|
|
4380
4459
|
console.info("[llm_meta_widget] not attaching " + plan.uri + " (" + plan.reason + ", size=" + plan.size + ")");
|
|
4460
|
+
noteSkippedResource(plan, resourceBudget(), "host");
|
|
4381
4461
|
}
|
|
4382
4462
|
});
|
|
4383
4463
|
if (loaded && loaded.plan && loaded.plan.fetch) {
|
|
@@ -4643,14 +4723,27 @@ function boot(cfg) {
|
|
|
4643
4723
|
return body;
|
|
4644
4724
|
}
|
|
4645
4725
|
function collapseThinkingBlock() {
|
|
4646
|
-
|
|
4647
|
-
currentThinkingBlock.classList.remove("thinking-active");
|
|
4648
|
-
currentThinkingBlock.open = false;
|
|
4649
|
-
var summary = currentThinkingBlock.querySelector("summary");
|
|
4650
|
-
if (summary) summary.textContent = "\u{1F914} thinking (finished)";
|
|
4651
|
-
}
|
|
4726
|
+
var block2 = currentThinkingBlock;
|
|
4652
4727
|
currentThinkingBlock = null;
|
|
4653
4728
|
currentThinkingBody = null;
|
|
4729
|
+
if (!block2) return;
|
|
4730
|
+
block2.classList.remove("thinking-active");
|
|
4731
|
+
var summary = block2.querySelector("summary");
|
|
4732
|
+
if (summary) summary.textContent = "\u{1F914} thinking (finished)";
|
|
4733
|
+
var body = block2.querySelector(".message-thinking-content");
|
|
4734
|
+
if (body) {
|
|
4735
|
+
body.style.overflow = "visible";
|
|
4736
|
+
body.style.maxHeight = "none";
|
|
4737
|
+
}
|
|
4738
|
+
if (typeof requestAnimationFrame !== "function") {
|
|
4739
|
+
block2.open = false;
|
|
4740
|
+
return;
|
|
4741
|
+
}
|
|
4742
|
+
requestAnimationFrame(function() {
|
|
4743
|
+
requestAnimationFrame(function() {
|
|
4744
|
+
block2.open = false;
|
|
4745
|
+
});
|
|
4746
|
+
});
|
|
4654
4747
|
}
|
|
4655
4748
|
function stateReaders() {
|
|
4656
4749
|
var declared = window[STATE_GLOBAL] || {};
|
|
@@ -4755,6 +4848,14 @@ function boot(cfg) {
|
|
|
4755
4848
|
if (!userText) return;
|
|
4756
4849
|
inputEl.value = "";
|
|
4757
4850
|
appendTurn("user", userText, pendingTurnLabel);
|
|
4851
|
+
pageResourcePlansNow();
|
|
4852
|
+
if (skippedResources.length && !announcedSkips) {
|
|
4853
|
+
announcedSkips = true;
|
|
4854
|
+
var skipped = skippedResources[0];
|
|
4855
|
+
var others = skippedResources.length - 1;
|
|
4856
|
+
var alsoText = others > 0 ? " and " + others + " other reference" + (others > 1 ? "s" : "") : "";
|
|
4857
|
+
appendTurn("system", "Did not attach the " + skipped.name + " reference" + alsoText + " (" + kb(skipped.size) + ", and a " + MODEL + " turn allows " + kb(skipped.budget) + " for reference data), so the answer may be less precise. A model with a larger context window would make room.");
|
|
4858
|
+
}
|
|
4758
4859
|
pendingTurnLabel = null;
|
|
4759
4860
|
if (currentAbort) {
|
|
4760
4861
|
try {
|
|
@@ -4793,10 +4894,18 @@ function boot(cfg) {
|
|
|
4793
4894
|
contextWindow: modelWindow,
|
|
4794
4895
|
provider: LLM_PROVIDER === "ollama" ? "ollama" : "hub",
|
|
4795
4896
|
signal: currentAbort.signal,
|
|
4796
|
-
|
|
4797
|
-
var
|
|
4798
|
-
|
|
4799
|
-
|
|
4897
|
+
onContextTruncated: function(list2) {
|
|
4898
|
+
var cut = list2[list2.length - 1] || {};
|
|
4899
|
+
appendTurn("system", "Shortened an oversized " + (cut.name || "tool") + " result to fit the context: " + thousands(cut.fromTokens || 0) + " tokens, where one result may use at most " + thousands(cut.toTokens || 0) + ". Ask again with a narrower question for the complete result.");
|
|
4900
|
+
},
|
|
4901
|
+
onContextEvicted: function(info) {
|
|
4902
|
+
var dropped = info.dropped || [];
|
|
4903
|
+
var names = dropped.map(function(d) {
|
|
4904
|
+
return d.name || "a tool";
|
|
4905
|
+
});
|
|
4906
|
+
var what = names.length === 1 ? "the earliest tool result (" + names[0] + ")" : "the " + names.length + " earliest tool results (" + names.join(", ") + ")";
|
|
4907
|
+
var sizes = info.needed && info.window ? " About " + thousands(info.needed) + " tokens were needed and " + thousands(info.window) + " fit in " + MODEL + "'s context." : "";
|
|
4908
|
+
appendTurn("system", "Dropped " + what + " to make room." + sizes + " Ask again, or narrow the question, for an answer based on complete tool results.");
|
|
4800
4909
|
},
|
|
4801
4910
|
onToolCall: function(toolCall) {
|
|
4802
4911
|
announceToolCall(toolCall);
|
|
@@ -335,6 +335,8 @@ export async function runChatLoop(opts) {
|
|
|
335
335
|
// Called when a tool result is dropped to make room, so the panel can say
|
|
336
336
|
// so rather than the context quietly thinning.
|
|
337
337
|
onContextEvicted,
|
|
338
|
+
// Called when one oversized result is shortened, so the panel can say so.
|
|
339
|
+
onContextTruncated,
|
|
338
340
|
// "hub" (default) talks to llm_meta_server; "ollama" talks straight to an
|
|
339
341
|
// Ollama, with no server component of ours in between. The loop is the
|
|
340
342
|
// same either way — only the call is swapped.
|
|
@@ -377,6 +379,7 @@ export async function runChatLoop(opts) {
|
|
|
377
379
|
|
|
378
380
|
const rounds = []
|
|
379
381
|
const evicted = []
|
|
382
|
+
const truncatedResults = []
|
|
380
383
|
// Fixed for the turn: the declared tool set does not change between rounds.
|
|
381
384
|
const toolsTokens = estimateToolsTokens([].concat(
|
|
382
385
|
singleOpts.localTools || [], remoteTools || [], hostWideTools || []))
|
|
@@ -422,6 +425,16 @@ export async function runChatLoop(opts) {
|
|
|
422
425
|
onRoundStart?.(round)
|
|
423
426
|
|
|
424
427
|
// The loop is the same whoever answers; only the call differs.
|
|
428
|
+
// One oversized result is capped first: it frees hundreds of times more
|
|
429
|
+
// than dropping old results, and keeps what the model asked for.
|
|
430
|
+
const capped = truncateOversizedToolResults({
|
|
431
|
+
messages, window: contextWindow, truncated: truncatedResults
|
|
432
|
+
});
|
|
433
|
+
if (capped.messages !== messages) {
|
|
434
|
+
messages = capped.messages;
|
|
435
|
+
onContextTruncated?.(truncatedResults.slice());
|
|
436
|
+
}
|
|
437
|
+
|
|
425
438
|
// Make room before sending, rather than after being refused. Only old
|
|
426
439
|
// tool results are given up; see evictOldToolResults.
|
|
427
440
|
const room = evictOldToolResults({
|
|
@@ -431,7 +444,7 @@ export async function runChatLoop(opts) {
|
|
|
431
444
|
});
|
|
432
445
|
if (room.messages !== messages) {
|
|
433
446
|
messages = room.messages;
|
|
434
|
-
onContextEvicted?.(evicted.slice());
|
|
447
|
+
onContextEvicted?.({ dropped: evicted.slice(), needed: room.needed, window: room.window });
|
|
435
448
|
}
|
|
436
449
|
|
|
437
450
|
const call = provider === "ollama" ? ollamaChatCall : singleLlmCall
|
|
@@ -1003,6 +1016,55 @@ export function estimateToolsTokens(tools) {
|
|
|
1003
1016
|
return estimateTextTokens(text)
|
|
1004
1017
|
}
|
|
1005
1018
|
|
|
1019
|
+
// A single tool result may use at most this share of the window.
|
|
1020
|
+
//
|
|
1021
|
+
// Measured over 179 real tool results (2026-10-17): the median is 2.1% of the
|
|
1022
|
+
// window and the 90th percentile 20%, but one `togovar_search_variant` result
|
|
1023
|
+
// came to 94% — 120,667 tokens — and alone could not coexist with a
|
|
1024
|
+
// conversation. All six over-budget requests in that sample were caused by one
|
|
1025
|
+
// oversized result, so capping it clears every one of them while touching only
|
|
1026
|
+
// 4.5% of results at all.
|
|
1027
|
+
//
|
|
1028
|
+
// 40% keeps a useful prefix of even the worst result (42% of it) and still
|
|
1029
|
+
// leaves most of the window for the conversation, the schemas and the answer.
|
|
1030
|
+
export const MAX_TOOL_RESULT_SHARE = 0.4
|
|
1031
|
+
|
|
1032
|
+
// Shortens any single tool result that exceeds that share.
|
|
1033
|
+
//
|
|
1034
|
+
// This runs BEFORE eviction, because it is strictly the better move: on the
|
|
1035
|
+
// audited turn it frees ~69,000 tokens from one result, where dropping the
|
|
1036
|
+
// oldest results freed 160 and lost two results the model had asked for. The
|
|
1037
|
+
// model is told what happened, in the result itself, so it can narrow its
|
|
1038
|
+
// query instead of assuming it received everything.
|
|
1039
|
+
export function truncateOversizedToolResults({ messages, window,
|
|
1040
|
+
share = MAX_TOOL_RESULT_SHARE,
|
|
1041
|
+
truncated = [] } = {}) {
|
|
1042
|
+
if (!window || !Number.isFinite(window)) return { messages, truncated }
|
|
1043
|
+
|
|
1044
|
+
const cap = Math.floor(window * share)
|
|
1045
|
+
let out = null
|
|
1046
|
+
|
|
1047
|
+
;(messages || []).forEach((m, i) => {
|
|
1048
|
+
if (m?.role !== "tool" || typeof m.content !== "string") return
|
|
1049
|
+
const tokens = estimateTextTokens(m.content)
|
|
1050
|
+
if (tokens <= cap) return
|
|
1051
|
+
|
|
1052
|
+
// Scale the cut by the measured ratio rather than a fixed chars-per-token,
|
|
1053
|
+
// so a dense result is cut by what it actually costs.
|
|
1054
|
+
const keep = Math.max(1, Math.floor(m.content.length * cap / tokens))
|
|
1055
|
+
out = out || messages.slice()
|
|
1056
|
+
truncated.push({ name: m.name, fromTokens: tokens, toTokens: cap })
|
|
1057
|
+
out[i] = Object.assign({}, m, {
|
|
1058
|
+
content: m.content.slice(0, keep) +
|
|
1059
|
+
"\n\n[truncated: " + keep + " of " + m.content.length + " characters shown. " +
|
|
1060
|
+
"One tool result may use at most " + Math.round(share * 100) +
|
|
1061
|
+
"% of the context window. Call the tool again with a narrower query to see more.]"
|
|
1062
|
+
})
|
|
1063
|
+
})
|
|
1064
|
+
|
|
1065
|
+
return { messages: out || messages, truncated }
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1006
1068
|
// Makes room for the next round by dropping the OLDEST tool results, which is
|
|
1007
1069
|
// the one eviction a chat loop can justify: the model has already read them and
|
|
1008
1070
|
// said what it made of them in the assistant turn that follows, so its own
|
|
@@ -1021,7 +1083,12 @@ export function evictOldToolResults({ messages, window, reserve = 0, dropped = [
|
|
|
1021
1083
|
if (!window || !Number.isFinite(window)) return { messages, dropped }
|
|
1022
1084
|
|
|
1023
1085
|
const budget = window - reserve
|
|
1024
|
-
|
|
1086
|
+
// What the whole turn would have needed, against what the model holds. The
|
|
1087
|
+
// panel shows both, because "an old result was dropped" on its own tells a
|
|
1088
|
+
// reader nothing about whether to retry, shorten the question, or change
|
|
1089
|
+
// model.
|
|
1090
|
+
const needed = estimateTokens(messages) + reserve
|
|
1091
|
+
if (estimateTokens(messages) <= budget) return { messages, dropped, needed, window }
|
|
1025
1092
|
|
|
1026
1093
|
const out = messages.slice()
|
|
1027
1094
|
for (let i = 0; i < out.length; i++) {
|
|
@@ -1036,7 +1103,7 @@ export function evictOldToolResults({ messages, window, reserve = 0, dropped = [
|
|
|
1036
1103
|
"if you still need it, with a narrower query.]"
|
|
1037
1104
|
})
|
|
1038
1105
|
}
|
|
1039
|
-
return { messages: out, dropped }
|
|
1106
|
+
return { messages: out, dropped, needed, window }
|
|
1040
1107
|
}
|
|
1041
1108
|
|
|
1042
1109
|
export function modelWindowFrom(payload, modelId) {
|
|
@@ -1055,10 +1122,41 @@ export function modelWindowFrom(payload, modelId) {
|
|
|
1055
1122
|
|
|
1056
1123
|
export const PAGE_GLOBAL_TRANSPORT = "page-global"
|
|
1057
1124
|
|
|
1058
|
-
|
|
1125
|
+
// Which JS global holds the handlers, from the card's declared transports.
|
|
1126
|
+
//
|
|
1127
|
+
// The array is processed IN ORDER and the first entry whose type we support
|
|
1128
|
+
// wins: the page author's order is their preference, so a supported entry is
|
|
1129
|
+
// never skipped for a later one. Only `page-global` is supported today;
|
|
1130
|
+
// `page-postmessage`, `page-jsonrpc` and `page-worker` are reserved, and a card
|
|
1131
|
+
// declaring only those gets no handlers rather than the wrong ones.
|
|
1132
|
+
//
|
|
1133
|
+
// Top-level `remotes` is the pre-d44bd25a shape that 0.11.1 and 0.12.2 read. It
|
|
1134
|
+
// is accepted for one release so a page and a widget can be updated in either
|
|
1135
|
+
// order without the page going dark in between, and it warns so the author
|
|
1136
|
+
// knows to move.
|
|
1137
|
+
export function declaredPageGlobal(card, warn = console.warn) {
|
|
1138
|
+
const declared = (card && card.transports) || []
|
|
1139
|
+
for (const transport of declared) {
|
|
1140
|
+
if (!transport || transport.type !== PAGE_GLOBAL_TRANSPORT) continue
|
|
1141
|
+
if (typeof transport.global === "string" && transport.global.trim() !== "") {
|
|
1142
|
+
return transport.global.trim()
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
if (declared.length) {
|
|
1146
|
+
warn("[llm-meta-widget] page card declares transports " +
|
|
1147
|
+
JSON.stringify(declared.map((t) => t && t.type)) +
|
|
1148
|
+
", none of which this widget supports; not dispatching to the page")
|
|
1149
|
+
return null
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1059
1152
|
for (const remote of (card && card.remotes) || []) {
|
|
1060
1153
|
if (!remote || remote.type !== PAGE_GLOBAL_TRANSPORT) continue
|
|
1061
|
-
if (typeof remote.global === "string" && remote.global.trim() !== "")
|
|
1154
|
+
if (typeof remote.global === "string" && remote.global.trim() !== "") {
|
|
1155
|
+
warn('[llm-meta-widget] page card declares its transport in top-level ' +
|
|
1156
|
+
'"remotes", which is deprecated; move it to ' +
|
|
1157
|
+
`_meta["${STATIC_PRIMITIVES_META}"].transports`)
|
|
1158
|
+
return remote.global.trim()
|
|
1159
|
+
}
|
|
1062
1160
|
}
|
|
1063
1161
|
return null
|
|
1064
1162
|
}
|
|
@@ -1067,7 +1165,7 @@ export function declaredPageGlobal(card) {
|
|
|
1067
1165
|
// to its legacy global independently, so a page part-way through moving over
|
|
1068
1166
|
// keeps working instead of losing the primitives it has not moved yet.
|
|
1069
1167
|
export function pageHandlers({ card, scope = {}, legacy = {}, warn = console.warn } = {}) {
|
|
1070
|
-
const name = declaredPageGlobal(card)
|
|
1168
|
+
const name = declaredPageGlobal(card, warn)
|
|
1071
1169
|
const declared = name ? scope[name] : null
|
|
1072
1170
|
|
|
1073
1171
|
// A card that names a global the page never defines is almost always a load
|
|
@@ -1112,20 +1210,28 @@ export function pageHandlers({ card, scope = {}, legacy = {}, warn = console.war
|
|
|
1112
1210
|
// Picking the first would make which component wins depend on DOM order, which
|
|
1113
1211
|
// is not something a page author controls when the components are partials.
|
|
1114
1212
|
export function mergeStaticPrimitivesCards(cards, warn = console.warn) {
|
|
1115
|
-
const merged = { remotes: [], tools: [], prompts: [], resources: [] }
|
|
1116
|
-
// Each block is a full Server Card and may declare
|
|
1117
|
-
//
|
|
1118
|
-
//
|
|
1213
|
+
const merged = { transports: [], remotes: [], tools: [], prompts: [], resources: [] }
|
|
1214
|
+
// Each block is a full Server Card and may declare transports. They are
|
|
1215
|
+
// concatenated in document order, because the order is the author's
|
|
1216
|
+
// preference and selection honours it. A second block naming a DIFFERENT
|
|
1217
|
+
// global for the same type is an authoring error — only one of them can be
|
|
1218
|
+
// dispatched to — so the first is kept and the clash is reported.
|
|
1219
|
+
//
|
|
1220
|
+
// `remotes` is merged the same way: it is the deprecated spelling of the same
|
|
1221
|
+
// thing, and a page mid-migration may still carry it.
|
|
1119
1222
|
for (const card of cards || []) {
|
|
1120
|
-
for (const
|
|
1121
|
-
const
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1223
|
+
for (const kind of [ "transports", "remotes" ]) {
|
|
1224
|
+
for (const entry of (card && card[kind]) || []) {
|
|
1225
|
+
if (!entry || typeof entry.type !== "string") continue
|
|
1226
|
+
const clash = merged[kind].find((r) => r.type === entry.type && r.global !== entry.global)
|
|
1227
|
+
if (clash) {
|
|
1228
|
+
warn(`[llm-meta-widget] two blocks declare different ${entry.type} globals ` +
|
|
1229
|
+
`("${clash.global}" and "${entry.global}"); using "${clash.global}"`)
|
|
1230
|
+
continue
|
|
1231
|
+
}
|
|
1232
|
+
if (!merged[kind].some((r) => r.type === entry.type && r.global === entry.global)) {
|
|
1233
|
+
merged[kind].push(entry)
|
|
1234
|
+
}
|
|
1129
1235
|
}
|
|
1130
1236
|
}
|
|
1131
1237
|
}
|
|
@@ -1151,7 +1257,7 @@ export function mergeStaticPrimitivesCards(cards, warn = console.warn) {
|
|
|
1151
1257
|
}
|
|
1152
1258
|
|
|
1153
1259
|
export function readStaticPrimitivesCard(text, warn = console.warn) {
|
|
1154
|
-
const empty = { remotes: [], tools: [], prompts: [], resources: [] }
|
|
1260
|
+
const empty = { transports: [], remotes: [], tools: [], prompts: [], resources: [] }
|
|
1155
1261
|
if (typeof text !== "string" || text.trim() === "") return empty
|
|
1156
1262
|
|
|
1157
1263
|
let doc
|
|
@@ -1175,7 +1281,12 @@ export function readStaticPrimitivesCard(text, warn = console.warn) {
|
|
|
1175
1281
|
})
|
|
1176
1282
|
|
|
1177
1283
|
return {
|
|
1178
|
-
//
|
|
1284
|
+
// Where a page says how to reach its handlers. Since SEP d44bd25a this is
|
|
1285
|
+
// `transports` inside the extension data, because the Server Card's
|
|
1286
|
+
// `Remote.type` enum is streamable-http|sse and `page-global` was never a
|
|
1287
|
+
// valid value there. Top-level `remotes` is what 0.11.1-0.12.2 read, and is
|
|
1288
|
+
// still accepted for one release — see declaredPageGlobal.
|
|
1289
|
+
transports: Array.isArray(ext.transports) ? ext.transports : [],
|
|
1179
1290
|
remotes: Array.isArray(doc.remotes) ? doc.remotes : [],
|
|
1180
1291
|
tools: named(ext.tools, "tool"),
|
|
1181
1292
|
prompts: named(ext.prompts, "prompt"),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aibranch/llm-meta-widget",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"//version": "Must stay in step with lib/llm_meta_widget/version.rb. Two independently versioned copies of the same client is the drift docs/custom-element-distribution.md warns about: bump both, release both.",
|
|
5
5
|
"description": "Embeddable browser chat widget: a custom element that discovers tools at page, origin and hub scope and dispatches each tool call to the right one. Two lines of HTML on any host, Rails or not.",
|
|
6
6
|
"keywords": [
|