llm_meta_widget 0.11.0 → 0.12.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 +4 -4
- data/CHANGELOG.md +83 -0
- data/README.md +10 -6
- data/app/assets/javascripts/llm_meta_widget/element.js +164 -38
- data/app/assets/javascripts/llm_meta_widget/llm-meta-widget.js +183 -31
- data/app/assets/javascripts/llm_meta_widget/orchestrator.js +132 -1
- data/app/assets/stylesheets/llm_meta_widget/conversation.css +20 -4
- 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: b2ba425d2cce6f582baf43df6ca5512bd344ccd78f1e74b56887eb6f3ccdaff6
|
|
4
|
+
data.tar.gz: a0127db4a9a59f6a88c052e9f6d54d1d0664c168c4de6cd16fe480fa95a73327
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7b3d3ddd173ef207ccd651ef5a455ffbdc2941fd0d00bc9e1bce687d824cf789b14b8e0d09f898fbfc0db132d4e384cdb7637289467c3259d180f7e71fdd1c16
|
|
7
|
+
data.tar.gz: eea0ceefa6b8cecc7472d754c16d0de51a04ad92afb69dfe237c31c1448873761d7a1c7fa90c317fc9b29c568c4c2eecf7fe3f7dead0b467e1e9d68b93dafd57
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,89 @@
|
|
|
3
3
|
This file starts at 0.8.0, the first release with a breaking change worth
|
|
4
4
|
calling out. Earlier releases are in the git history (`git log --oneline v0.7.6`).
|
|
5
5
|
|
|
6
|
+
## 0.12.0
|
|
7
|
+
|
|
8
|
+
Nothing a page must provide changes in this release: no new attribute, no
|
|
9
|
+
change to how the widget finds anything a page declares. A host moving a CDN
|
|
10
|
+
pin from `@0.11` to `@0.12` has nothing to edit.
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Fixed: the panel can be read while a turn is still running
|
|
14
|
+
|
|
15
|
+
Adding content mid-turn always scrolled the panel back to the end — a tool
|
|
16
|
+
chip, a thinking block, or any delta. On a short answer nobody notices. On a
|
|
17
|
+
long tool-using turn the deltas keep coming for minutes, so scrolling back
|
|
18
|
+
through the answer was impossible by any means: wheel, trackpad and keyboard
|
|
19
|
+
all appeared dead, because each attempt was undone within milliseconds.
|
|
20
|
+
|
|
21
|
+
The panel now follows the output only while the reader is at the end of it.
|
|
22
|
+
Scroll away and it leaves you alone; come back to the end and it resumes.
|
|
23
|
+
Asking a new question always returns you to the end, and a conversation
|
|
24
|
+
restored after a page load still opens at its end.
|
|
25
|
+
|
|
26
|
+
### Fixed: a wide table is readable instead of one letter per column
|
|
27
|
+
|
|
28
|
+
A markdown table was itself the scrolling box (`display: block;
|
|
29
|
+
overflow-x: auto`), so it was capped at the bubble width, squeezed its columns,
|
|
30
|
+
and `word-wrap: break-word` then broke every word one character per line. A
|
|
31
|
+
six-column table of genomic notation became a column of single letters. Tables
|
|
32
|
+
now sit in a wrapper that scrolls sideways while the table keeps its natural
|
|
33
|
+
width: the same answer went from 3,155px to 1,426px of message height in
|
|
34
|
+
Chromium, and the columns are readable.
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
### Added: a turn is sized against the model's real context window
|
|
38
|
+
|
|
39
|
+
The widget now reads each model's context window from the hub (`/api/llms`
|
|
40
|
+
reports `max_input_tokens`) and uses it for two decisions it used to make from
|
|
41
|
+
fixed guesses.
|
|
42
|
+
|
|
43
|
+
**Old tool results are given up rather than letting the round be refused.**
|
|
44
|
+
When the next round would not fit, the oldest tool results are replaced — one
|
|
45
|
+
at a time, stopping as soon as the round fits — with a short note saying the
|
|
46
|
+
result was dropped and may be fetched again. Nothing else is touched: not the
|
|
47
|
+
system prompt, not the questions, and not what the assistant said about those
|
|
48
|
+
results, so the model keeps its own summary of what it read. The panel says
|
|
49
|
+
which tool's result went, so a thinner context is visible rather than silent.
|
|
50
|
+
|
|
51
|
+
A tool message is replaced, never removed, because a provider rejects a
|
|
52
|
+
`tool_call` that has no matching result.
|
|
53
|
+
|
|
54
|
+
The declared tools are counted too, not only the messages. Their names,
|
|
55
|
+
descriptions and schemas are part of every request — around twenty tools is a
|
|
56
|
+
few thousand tokens before the conversation starts — so a budget that counted
|
|
57
|
+
messages alone would evict too late to help.
|
|
58
|
+
|
|
59
|
+
**The reference-resource budget scales with the window.** It was a fixed 8 KB
|
|
60
|
+
whatever the model. It is now about 6% of the window — the same share 8 KB was
|
|
61
|
+
of the 32,000-token model it was chosen for — so a 128,000-token model may
|
|
62
|
+
attach about 32 KB, up to a 64 KB cap. A model whose window the hub does not
|
|
63
|
+
report keeps the old 8 KB.
|
|
64
|
+
|
|
65
|
+
Nothing changes for a host that does not want this: there is no new attribute,
|
|
66
|
+
and a hub that does not report a window makes the widget behave exactly as
|
|
67
|
+
before. It is a release of llm_meta_server, not the widget, that turns the
|
|
68
|
+
behaviour on.
|
|
69
|
+
|
|
70
|
+
## 0.11.1
|
|
71
|
+
|
|
72
|
+
### Fixed: a page action that navigates no longer loses the turn
|
|
73
|
+
|
|
74
|
+
The transcript was saved once, after the chat loop finished. A page action that
|
|
75
|
+
navigates — submitting a form, following a link — unloads the page while the
|
|
76
|
+
loop is still running, so that turn was never saved: not the question, not
|
|
77
|
+
whatever the assistant had already said, and not the record of which tools ran.
|
|
78
|
+
After the navigation the panel came back with the previous turns and nothing of
|
|
79
|
+
the one that had just happened.
|
|
80
|
+
|
|
81
|
+
The turn is now saved as it happens. The question is saved before the model is
|
|
82
|
+
called, streamed text is saved as it arrives, and a save always happens
|
|
83
|
+
immediately before a tool call runs, because that call may be the one that
|
|
84
|
+
navigates.
|
|
85
|
+
|
|
86
|
+
This matters to any page that declares an action which navigates. Nothing to
|
|
87
|
+
change in your page.
|
|
88
|
+
|
|
6
89
|
## 0.11.0
|
|
7
90
|
|
|
8
91
|
### Added: a page can say where its handlers live
|
data/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.12.0"
|
|
65
65
|
```
|
|
66
66
|
|
|
67
67
|
```bash
|
|
@@ -152,17 +152,17 @@ 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.12"></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.12` is a version range, and it stops at 0.12.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.13 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
|
|
@@ -698,8 +698,12 @@ the model's context. The widget reads two optional fields the
|
|
|
698
698
|
`_meta`; the widget still reads that when `size` is absent, but it will be
|
|
699
699
|
removed, so emit `size`.
|
|
700
700
|
The widget decides whether it can afford the resource *before* fetching it. If
|
|
701
|
-
the resource is over
|
|
702
|
-
|
|
701
|
+
the resource is over that budget, the bytes never cross the wire, and the LLM
|
|
702
|
+
asks for what it needs through your tools instead.
|
|
703
|
+
The budget is a share of the model's own context window, which the widget
|
|
704
|
+
reads from the hub: about 8 KB for a 32,000-token model, about 32 KB for a
|
|
705
|
+
128,000-token one, and at most 64 KB. When the hub does not report a window,
|
|
706
|
+
the budget is 8 KB.
|
|
703
707
|
- **`volatility`** — `"stable"` (read once, re-used) or `"volatile"` (re-read
|
|
704
708
|
on every turn that uses it).
|
|
705
709
|
- **`autoAttach`** — may the widget put this in the model's context without
|
|
@@ -26,7 +26,8 @@ import { runChatLoop, fetchMcpManifest, listMcpPrompts, getMcpPrompt,
|
|
|
26
26
|
readStaticPrimitivesCard, getPagePrompt,
|
|
27
27
|
planPageResources, pageResourceLinesForTurn,
|
|
28
28
|
promptArgState, mergeStaticPrimitivesCards,
|
|
29
|
-
PAGE_CARD_MEDIA_TYPE, pageHandlers
|
|
29
|
+
PAGE_CARD_MEDIA_TYPE, pageHandlers,
|
|
30
|
+
modelWindowFrom, resourceBudgetBytes } from "./orchestrator.js";
|
|
30
31
|
import { marked } from "./marked.esm.js";
|
|
31
32
|
import { readConfig } from "./config.js";
|
|
32
33
|
import conversationCss from "../../stylesheets/llm_meta_widget/conversation.css";
|
|
@@ -154,6 +155,28 @@ function boot(cfg) {
|
|
|
154
155
|
var root = document.getElementById("llm-meta-widget-chat");
|
|
155
156
|
var toggleBtn = document.getElementById("llm-meta-widget-toggle");
|
|
156
157
|
var historyEl = root.querySelector(".lmw-messages");
|
|
158
|
+
|
|
159
|
+
// Following the output, or reading back through it?
|
|
160
|
+
//
|
|
161
|
+
// Every mid-turn pin used to be unconditional: a tool chip, a thinking
|
|
162
|
+
// block, or any delta slammed the view back to the end. On a short answer
|
|
163
|
+
// nobody notices. On a long tool turn the deltas keep coming for minutes,
|
|
164
|
+
// and the reader cannot scroll anywhere by any means — wheel, trackpad,
|
|
165
|
+
// keyboard — because each attempt is undone within milliseconds. Reported
|
|
166
|
+
// from TogoMCP and reproduced on PubDictionaries (2026-10-16).
|
|
167
|
+
//
|
|
168
|
+
// The reader's own scrolling says which they are doing: leave the bottom
|
|
169
|
+
// and we stop following; come back to it and we resume.
|
|
170
|
+
var SCROLL_PIN_SLACK = 40; // px from the end still counts as "at the end"
|
|
171
|
+
var followingOutput = true;
|
|
172
|
+
|
|
173
|
+
function atBottom() {
|
|
174
|
+
return historyEl.scrollHeight - historyEl.scrollTop - historyEl.clientHeight <= SCROLL_PIN_SLACK;
|
|
175
|
+
}
|
|
176
|
+
function pinToBottom() { historyEl.scrollTop = historyEl.scrollHeight; }
|
|
177
|
+
// Called wherever content is added mid-turn.
|
|
178
|
+
function pinIfFollowing() { if (followingOutput) pinToBottom(); }
|
|
179
|
+
historyEl.addEventListener("scroll", function() { followingOutput = atBottom(); });
|
|
157
180
|
var formEl = root.querySelector(".lmw-form");
|
|
158
181
|
var inputEl = root.querySelector(".lmw-input");
|
|
159
182
|
var clearBtn = root.querySelector(".lmw-clear");
|
|
@@ -302,6 +325,17 @@ function boot(cfg) {
|
|
|
302
325
|
model: MODEL
|
|
303
326
|
});
|
|
304
327
|
}
|
|
328
|
+
|
|
329
|
+
// Saving on every streamed delta would write to sessionStorage hundreds of
|
|
330
|
+
// times for one answer, so the stream saves at most this often. The moments
|
|
331
|
+
// that MUST save at once — the turn starting, a tool call about to run, the
|
|
332
|
+
// turn ending — call persistConversation directly.
|
|
333
|
+
var lastPersist = 0;
|
|
334
|
+
function persistSoon() {
|
|
335
|
+
if (Date.now() - lastPersist < 400) return;
|
|
336
|
+
lastPersist = Date.now();
|
|
337
|
+
persistConversation();
|
|
338
|
+
}
|
|
305
339
|
// The page card is the same static-primitives envelope a server puts in
|
|
306
340
|
// its Server Card, delivered in the document. Its `tools` REPLACE the
|
|
307
341
|
// older #ai-actions schema tag rather than merging with it: two sources
|
|
@@ -368,8 +402,6 @@ function boot(cfg) {
|
|
|
368
402
|
}
|
|
369
403
|
|
|
370
404
|
function refreshRemoteToolsFromPicker() {
|
|
371
|
-
// Flatten every SELECTED tool (across all servers) into remoteTools,
|
|
372
|
-
// adapted to runChatLoop's expected { id, name, description, input_schema }.
|
|
373
405
|
var flat = [];
|
|
374
406
|
var seen = {};
|
|
375
407
|
hubMcpServers.forEach(function(s) {
|
|
@@ -445,6 +477,10 @@ function boot(cfg) {
|
|
|
445
477
|
// - Ollama: `available_models: [{ value, label, ... }]`
|
|
446
478
|
// Anon widget uses api_key_uuid = "ollama-local" so only Ollama models
|
|
447
479
|
// are invocable; filter to that family and use its subshape.
|
|
480
|
+
// Learned from every family, not only the one the picker lists: the
|
|
481
|
+
// configured model is often hosted while the picker shows local ones.
|
|
482
|
+
llmsPayload = payload;
|
|
483
|
+
modelWindow = modelWindowFrom(payload, MODEL);
|
|
448
484
|
var flat = [];
|
|
449
485
|
(payload.llms || []).forEach(function(llm) {
|
|
450
486
|
if (llm.family !== "ollama") return;
|
|
@@ -590,7 +626,12 @@ function boot(cfg) {
|
|
|
590
626
|
}
|
|
591
627
|
|
|
592
628
|
if (modelPicker) {
|
|
593
|
-
modelPicker.addEventListener("change", function() {
|
|
629
|
+
modelPicker.addEventListener("change", function() {
|
|
630
|
+
MODEL = modelPicker.value;
|
|
631
|
+
// The window belongs to the model, not to the session: a switch has to
|
|
632
|
+
// move it, or every budget below is sized for the model left behind.
|
|
633
|
+
modelWindow = modelWindowFrom(llmsPayload, MODEL);
|
|
634
|
+
});
|
|
594
635
|
}
|
|
595
636
|
|
|
596
637
|
// Close the tools dropdown on any click outside its area. Native
|
|
@@ -623,25 +664,52 @@ function boot(cfg) {
|
|
|
623
664
|
var hostWidePrompts = pageCard.prompts.map(function(p) {
|
|
624
665
|
return Object.assign({}, p, { source: "page" });
|
|
625
666
|
});
|
|
667
|
+
// How much room the selected model has, from /api/llms. null until that
|
|
668
|
+
// call answers, and null forever if the hub is older than the field — in
|
|
669
|
+
// which case the loop budgets nothing, exactly as it always did.
|
|
670
|
+
var modelWindow = null;
|
|
671
|
+
// The /api/llms answer, kept so switching model in the picker can resolve
|
|
672
|
+
// that model's window. Without it a visitor who switches from a 262k model
|
|
673
|
+
// to a 32k one keeps the big window — no eviction, and a resource budget
|
|
674
|
+
// far larger than the small model can hold.
|
|
675
|
+
var llmsPayload = null;
|
|
626
676
|
var resourceContext = null; // payload of the host's reference resource
|
|
627
677
|
var resourcePlan = null; // the pre-flight decision, kept so Clear can re-arm the gate
|
|
628
678
|
|
|
629
679
|
// Beyond ~2k tokens a reference resource starts crowding out the
|
|
630
680
|
// conversation on a 32k-context local model — production PubDictionaries
|
|
631
|
-
// is 218 dictionaries / ~35KB / ~10k tokens.
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
//
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
681
|
+
// is 218 dictionaries / ~35KB / ~10k tokens. That share of the window is
|
|
682
|
+
// what scales now the window is known: 8000 remains the 32k case, and the
|
|
683
|
+
// floor for a model whose window the hub does not report.
|
|
684
|
+
function resourceBudget() { return resourceBudgetBytes(modelWindow); }
|
|
685
|
+
|
|
686
|
+
// The page's own resources, planned from the card — but NOT here. Unlike
|
|
687
|
+
// the hub's single reference resource these are a list: a page declares one
|
|
688
|
+
// per thing it knows, each with its own description.
|
|
689
|
+
//
|
|
690
|
+
// Planning them at this point would pin every one of them to the floor
|
|
691
|
+
// budget: the budget follows the model's window, which arrives with
|
|
692
|
+
// /api/llms a moment after this runs. The budget was derived and then
|
|
693
|
+
// never used, so a 128k model could attach nothing a 32k one could not.
|
|
694
|
+
// Re-planned whenever the budget changes, which is at most once per model
|
|
695
|
+
// switch.
|
|
696
|
+
var pageResourcePlans = null;
|
|
697
|
+
var pageResourcePlansBudget = null;
|
|
698
|
+
function pageResourcePlansNow() {
|
|
699
|
+
var budget = resourceBudget();
|
|
700
|
+
if (pageResourcePlans === null || budget !== pageResourcePlansBudget) {
|
|
701
|
+
pageResourcePlansBudget = budget;
|
|
702
|
+
pageResourcePlans = planPageResources({
|
|
703
|
+
entries: pageCard.resources,
|
|
704
|
+
budgetBytes: budget,
|
|
705
|
+
onSkip: function(plan) {
|
|
706
|
+
console.info("[llm_meta_widget] not attaching " + plan.uri +
|
|
707
|
+
" (" + plan.reason + ", size=" + plan.size + ")");
|
|
708
|
+
}
|
|
709
|
+
});
|
|
643
710
|
}
|
|
644
|
-
|
|
711
|
+
return pageResourcePlans;
|
|
712
|
+
}
|
|
645
713
|
var pageResourceCache = {};
|
|
646
714
|
|
|
647
715
|
|
|
@@ -683,7 +751,7 @@ function boot(cfg) {
|
|
|
683
751
|
// the bug this shape prevents.
|
|
684
752
|
var loaded = await loadHostResource({
|
|
685
753
|
endpoint: endpoint,
|
|
686
|
-
budgetBytes:
|
|
754
|
+
budgetBytes: resourceBudget(),
|
|
687
755
|
list: listMcpResources,
|
|
688
756
|
read: readMcpResource,
|
|
689
757
|
onSkip: function(plan) {
|
|
@@ -761,7 +829,9 @@ function boot(cfg) {
|
|
|
761
829
|
});
|
|
762
830
|
if (body.parentNode) body.parentNode.appendChild(row);
|
|
763
831
|
});
|
|
764
|
-
|
|
832
|
+
// Unconditional: a restored conversation opens at its end, which is where
|
|
833
|
+
// the reader left off. Nothing is streaming yet, so nobody is being yanked.
|
|
834
|
+
pinToBottom();
|
|
765
835
|
return true;
|
|
766
836
|
}
|
|
767
837
|
|
|
@@ -903,7 +973,7 @@ function boot(cfg) {
|
|
|
903
973
|
div.appendChild(document.createTextNode(" "));
|
|
904
974
|
div.appendChild(body);
|
|
905
975
|
historyEl.appendChild(div);
|
|
906
|
-
|
|
976
|
+
pinIfFollowing();
|
|
907
977
|
body.roleLabel = label; // so a turn in flight can show it is working
|
|
908
978
|
return body;
|
|
909
979
|
}
|
|
@@ -915,6 +985,31 @@ function boot(cfg) {
|
|
|
915
985
|
function renderMarkdownInto(el, mdText) {
|
|
916
986
|
try { el.innerHTML = marked.parse(mdText); }
|
|
917
987
|
catch (e) { el.textContent = mdText; } // fall back to raw on parse error
|
|
988
|
+
wrapTablesForScrolling(el);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// A markdown table gets its own horizontal scroller.
|
|
992
|
+
//
|
|
993
|
+
// `display: block; overflow-x: auto; max-width: 100%` on the table alone
|
|
994
|
+
// does NOT do this: the box is capped at the bubble width, so the table
|
|
995
|
+
// re-lays out to fit and squeezes its columns, and `word-wrap: break-word`
|
|
996
|
+
// from .message then breaks every word one character per line. A six-column
|
|
997
|
+
// table of genomic notation became a column of single letters thousands of
|
|
998
|
+
// pixels tall (seen on PubDictionaries, 2026-10-16).
|
|
999
|
+
//
|
|
1000
|
+
// The table has to sit INSIDE a capped box and keep its own natural width,
|
|
1001
|
+
// which is what a wrapper gives it.
|
|
1002
|
+
function wrapTablesForScrolling(el) {
|
|
1003
|
+
var tables = el.querySelectorAll("table");
|
|
1004
|
+
for (var i = 0; i < tables.length; i++) {
|
|
1005
|
+
var table = tables[i];
|
|
1006
|
+
// No already-wrapped guard: renderMarkdownInto replaces the whole
|
|
1007
|
+
// innerHTML first, so every table reaching here is freshly parsed.
|
|
1008
|
+
var wrap = document.createElement("div");
|
|
1009
|
+
wrap.className = "lmw-table-scroll";
|
|
1010
|
+
table.parentNode.insertBefore(wrap, table);
|
|
1011
|
+
wrap.appendChild(table);
|
|
1012
|
+
}
|
|
918
1013
|
}
|
|
919
1014
|
|
|
920
1015
|
// Role labels mirror llm_meta_chat's chats/_message.html.erb:
|
|
@@ -974,7 +1069,7 @@ function boot(cfg) {
|
|
|
974
1069
|
chip.dataset.tool = toolCall.name;
|
|
975
1070
|
row.appendChild(chip);
|
|
976
1071
|
pendingChips.push(chip);
|
|
977
|
-
|
|
1072
|
+
pinIfFollowing();
|
|
978
1073
|
}
|
|
979
1074
|
|
|
980
1075
|
function resolveToolCall(outcome) {
|
|
@@ -1022,7 +1117,7 @@ function boot(cfg) {
|
|
|
1022
1117
|
} else {
|
|
1023
1118
|
historyEl.appendChild(details);
|
|
1024
1119
|
}
|
|
1025
|
-
|
|
1120
|
+
pinIfFollowing();
|
|
1026
1121
|
currentThinkingBlock = details;
|
|
1027
1122
|
currentThinkingBody = body;
|
|
1028
1123
|
return body;
|
|
@@ -1126,7 +1221,7 @@ function boot(cfg) {
|
|
|
1126
1221
|
cached: resourceContext,
|
|
1127
1222
|
endpoint: resourcePlan && resourcePlan.endpoint,
|
|
1128
1223
|
read: readMcpResource,
|
|
1129
|
-
budgetBytes:
|
|
1224
|
+
budgetBytes: resourceBudget()
|
|
1130
1225
|
});
|
|
1131
1226
|
resourceContext = turn.cached;
|
|
1132
1227
|
|
|
@@ -1134,9 +1229,9 @@ function boot(cfg) {
|
|
|
1134
1229
|
// Volatile ones are re-read here, which is the whole point: a form field
|
|
1135
1230
|
// read at boot is already stale by the time anyone sends a message.
|
|
1136
1231
|
var page = await pageResourceLinesForTurn({
|
|
1137
|
-
plans:
|
|
1232
|
+
plans: pageResourcePlansNow(),
|
|
1138
1233
|
resources: handlers.resources,
|
|
1139
|
-
budgetBytes:
|
|
1234
|
+
budgetBytes: resourceBudget(),
|
|
1140
1235
|
cache: pageResourceCache
|
|
1141
1236
|
});
|
|
1142
1237
|
pageResourceCache = page.cache;
|
|
@@ -1171,6 +1266,9 @@ function boot(cfg) {
|
|
|
1171
1266
|
var userText = inputEl.value.trim();
|
|
1172
1267
|
if (!userText) return;
|
|
1173
1268
|
inputEl.value = "";
|
|
1269
|
+
// Asking something is a request to see the answer: whatever the reader was
|
|
1270
|
+
// looking at before, they are following the output again from here.
|
|
1271
|
+
followingOutput = true;
|
|
1174
1272
|
appendTurn("user", userText, pendingTurnLabel);
|
|
1175
1273
|
pendingTurnLabel = null;
|
|
1176
1274
|
|
|
@@ -1196,6 +1294,14 @@ function boot(cfg) {
|
|
|
1196
1294
|
.concat(conversation.map(function(t) { return { role: t.role, content: t.content }; }))
|
|
1197
1295
|
.concat([{ role: "user", content: userText }]);
|
|
1198
1296
|
|
|
1297
|
+
// Recorded before the loop runs, and saved, so a page action that
|
|
1298
|
+
// navigates cannot take the turn with it. `messages` above was already
|
|
1299
|
+
// built from the previous turns, so this does not double-send anything.
|
|
1300
|
+
conversation.push({ role: "user", content: userText });
|
|
1301
|
+
var liveTurn = { role: "assistant", content: "", tools: [] };
|
|
1302
|
+
conversation.push(liveTurn);
|
|
1303
|
+
persistConversation();
|
|
1304
|
+
|
|
1199
1305
|
var result = await runChatLoop({
|
|
1200
1306
|
baseUrl: LLM_BASE,
|
|
1201
1307
|
toolHubUrl: TOOL_HUB_BASE,
|
|
@@ -1207,10 +1313,29 @@ function boot(cfg) {
|
|
|
1207
1313
|
hostWideTools: hostWideTools,
|
|
1208
1314
|
aiActions: handlers.tools,
|
|
1209
1315
|
maxRounds: MAX_ROUNDS,
|
|
1316
|
+
contextWindow: modelWindow,
|
|
1210
1317
|
provider: LLM_PROVIDER === "ollama" ? "ollama" : "hub",
|
|
1211
1318
|
signal: currentAbort.signal,
|
|
1212
|
-
|
|
1213
|
-
|
|
1319
|
+
onContextEvicted: function(dropped) {
|
|
1320
|
+
// Said out loud: a context that quietly thins out makes a later answer
|
|
1321
|
+
// look inexplicably worse, with nothing on screen to explain it.
|
|
1322
|
+
var last = dropped[dropped.length - 1];
|
|
1323
|
+
var name = (last && last.name) ? last.name : "tool";
|
|
1324
|
+
appendTurn("system", "Dropped an earlier " + name + " result to make room in the model's context. Ask again if you need it.");
|
|
1325
|
+
},
|
|
1326
|
+
onToolCall: function(toolCall) {
|
|
1327
|
+
announceToolCall(toolCall);
|
|
1328
|
+
// At once, not throttled: this call may be the one that navigates, and
|
|
1329
|
+
// after it nothing of this turn would be saved.
|
|
1330
|
+
persistConversation();
|
|
1331
|
+
},
|
|
1332
|
+
onToolDispatched: function(outcome) {
|
|
1333
|
+
resolveToolCall(outcome);
|
|
1334
|
+
liveTurn.tools = liveTurn.tools.concat([ outcome.error
|
|
1335
|
+
? { name: outcome.toolCall.name, error: true }
|
|
1336
|
+
: { name: outcome.toolCall.name } ]);
|
|
1337
|
+
persistConversation();
|
|
1338
|
+
},
|
|
1214
1339
|
onPhase: function(name) {
|
|
1215
1340
|
// 'thinking' covers the long silence before the first
|
|
1216
1341
|
// token; 'responding' means text is on its way.
|
|
@@ -1234,25 +1359,26 @@ function boot(cfg) {
|
|
|
1234
1359
|
var body = ensureThinkingBlock();
|
|
1235
1360
|
body.appendChild(document.createTextNode(delta));
|
|
1236
1361
|
body.scrollTop = body.scrollHeight;
|
|
1237
|
-
|
|
1362
|
+
pinIfFollowing();
|
|
1238
1363
|
},
|
|
1239
1364
|
onTextDelta: function(delta) {
|
|
1240
1365
|
markDone(assistantBody.roleLabel);
|
|
1241
1366
|
collapseThinkingBlock();
|
|
1242
1367
|
assistantMarkdown += delta;
|
|
1243
1368
|
renderMarkdownInto(assistantBody, assistantMarkdown);
|
|
1244
|
-
|
|
1369
|
+
liveTurn.content = assistantMarkdown;
|
|
1370
|
+
persistSoon();
|
|
1371
|
+
pinIfFollowing();
|
|
1245
1372
|
}
|
|
1246
1373
|
});
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
})
|
|
1374
|
+
// The turn was recorded here until 0.11.1, after the loop returned — which
|
|
1375
|
+
// a page action that navigates never lets happen. Submitting a form
|
|
1376
|
+
// mid-turn therefore lost the whole turn: the question, whatever the
|
|
1377
|
+
// assistant had already said, and the record of what ran. It is recorded as
|
|
1378
|
+
// it happens now, and only finalised here.
|
|
1379
|
+
liveTurn.content = result.content;
|
|
1380
|
+
liveTurn.tools = result.dispatched.map(function(d) {
|
|
1381
|
+
return d.error ? { name: d.toolCall.name, error: true } : { name: d.toolCall.name };
|
|
1256
1382
|
});
|
|
1257
1383
|
persistConversation();
|
|
1258
1384
|
|
|
@@ -168,6 +168,16 @@ async function runChatLoop(opts) {
|
|
|
168
168
|
remoteTools = [],
|
|
169
169
|
hostWideTools = [],
|
|
170
170
|
maxRounds = 10,
|
|
171
|
+
// The model's own context window, from /api/llms. Without it the loop
|
|
172
|
+
// cannot know how much room is left, so it budgets nothing and a long
|
|
173
|
+
// tool-heavy turn is refused by the provider instead of degrading.
|
|
174
|
+
contextWindow = null,
|
|
175
|
+
// Tokens held back for the answer: a request that fills the window leaves
|
|
176
|
+
// nothing to generate into.
|
|
177
|
+
outputReserve = 2048,
|
|
178
|
+
// Called when a tool result is dropped to make room, so the panel can say
|
|
179
|
+
// so rather than the context quietly thinning.
|
|
180
|
+
onContextEvicted,
|
|
171
181
|
// "hub" (default) talks to llm_meta_server; "ollama" talks straight to an
|
|
172
182
|
// Ollama, with no server component of ours in between. The loop is the
|
|
173
183
|
// same either way — only the call is swapped.
|
|
@@ -189,7 +199,7 @@ async function runChatLoop(opts) {
|
|
|
189
199
|
onPhase,
|
|
190
200
|
...singleOpts
|
|
191
201
|
} = opts;
|
|
192
|
-
|
|
202
|
+
let messages = [...singleOpts.messages || []];
|
|
193
203
|
const remoteByName = Object.fromEntries((remoteTools || []).map((t) => [t.name, t]));
|
|
194
204
|
const hostWideByName = Object.fromEntries((hostWideTools || []).map((t) => [t.name, t]));
|
|
195
205
|
const toolIds = [...singleOpts.toolIds || [], ...(remoteTools || []).map((t) => t.id)];
|
|
@@ -200,6 +210,12 @@ async function runChatLoop(opts) {
|
|
|
200
210
|
}
|
|
201
211
|
const mergedLocalTools = Object.values(inlineByName);
|
|
202
212
|
const rounds = [];
|
|
213
|
+
const evicted = [];
|
|
214
|
+
const toolsTokens = estimateToolsTokens([].concat(
|
|
215
|
+
singleOpts.localTools || [],
|
|
216
|
+
remoteTools || [],
|
|
217
|
+
hostWideTools || []
|
|
218
|
+
));
|
|
203
219
|
const allDispatched = [];
|
|
204
220
|
const allSkipped = [];
|
|
205
221
|
let lastResult = null;
|
|
@@ -230,6 +246,17 @@ async function runChatLoop(opts) {
|
|
|
230
246
|
for (let round = 0; round < maxRounds; round++) {
|
|
231
247
|
if (signal?.aborted) throw new DOMException("aborted", "AbortError");
|
|
232
248
|
onRoundStart?.(round);
|
|
249
|
+
const room = evictOldToolResults({
|
|
250
|
+
messages,
|
|
251
|
+
window: contextWindow,
|
|
252
|
+
dropped: evicted,
|
|
253
|
+
// The tools take their share of the window before any message does.
|
|
254
|
+
reserve: outputReserve + toolsTokens
|
|
255
|
+
});
|
|
256
|
+
if (room.messages !== messages) {
|
|
257
|
+
messages = room.messages;
|
|
258
|
+
onContextEvicted?.(evicted.slice());
|
|
259
|
+
}
|
|
233
260
|
const call = provider === "ollama" ? ollamaChatCall : singleLlmCall;
|
|
234
261
|
const turnResult = await call({
|
|
235
262
|
...singleOpts,
|
|
@@ -561,6 +588,58 @@ function parseSseFrame(raw) {
|
|
|
561
588
|
}
|
|
562
589
|
var STATIC_PRIMITIVES_META = "io.modelcontextprotocol/static-primitives";
|
|
563
590
|
var PAGE_CARD_MEDIA_TYPE = "application/mcp-static-primitives+json";
|
|
591
|
+
var RESOURCE_SHARE_OF_WINDOW = 0.0625;
|
|
592
|
+
function resourceBudgetBytes(window2, { floor = 8e3, cap = 64e3 } = {}) {
|
|
593
|
+
if (!window2 || !Number.isFinite(window2)) return floor;
|
|
594
|
+
const share = Math.round(window2 * RESOURCE_SHARE_OF_WINDOW * CHARS_PER_TOKEN);
|
|
595
|
+
return Math.min(cap, Math.max(floor, share));
|
|
596
|
+
}
|
|
597
|
+
var CHARS_PER_TOKEN = 4;
|
|
598
|
+
var MESSAGE_OVERHEAD_TOKENS = 4;
|
|
599
|
+
function estimateTokens(messages) {
|
|
600
|
+
return (messages || []).reduce((sum, m) => {
|
|
601
|
+
const content = typeof m?.content === "string" ? m.content : JSON.stringify(m?.content ?? "");
|
|
602
|
+
return sum + Math.ceil(content.length / CHARS_PER_TOKEN) + MESSAGE_OVERHEAD_TOKENS;
|
|
603
|
+
}, 0);
|
|
604
|
+
}
|
|
605
|
+
function estimateToolsTokens(tools) {
|
|
606
|
+
const text = (tools || []).map((t) => JSON.stringify({
|
|
607
|
+
name: t?.name,
|
|
608
|
+
description: t?.description,
|
|
609
|
+
schema: t?.input_schema || t?.inputSchema
|
|
610
|
+
})).join("");
|
|
611
|
+
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
|
612
|
+
}
|
|
613
|
+
function evictOldToolResults({ messages, window: window2, reserve = 0, dropped = [] }) {
|
|
614
|
+
if (!window2 || !Number.isFinite(window2)) return { messages, dropped };
|
|
615
|
+
const budget = window2 - reserve;
|
|
616
|
+
if (estimateTokens(messages) <= budget) return { messages, dropped };
|
|
617
|
+
const out = messages.slice();
|
|
618
|
+
for (let i = 0; i < out.length; i++) {
|
|
619
|
+
if (estimateTokens(out) <= budget) break;
|
|
620
|
+
const m = out[i];
|
|
621
|
+
if (m?.role !== "tool") continue;
|
|
622
|
+
if (typeof m.content === "string" && m.content.startsWith("[dropped")) continue;
|
|
623
|
+
dropped.push({ name: m.name, tokens: estimateTokens([m]) });
|
|
624
|
+
out[i] = Object.assign({}, m, {
|
|
625
|
+
content: "[dropped to make room in the context window. Call the tool again if you still need it, with a narrower query.]"
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
return { messages: out, dropped };
|
|
629
|
+
}
|
|
630
|
+
function modelWindowFrom(payload, modelId) {
|
|
631
|
+
for (const family of payload && payload.llms || []) {
|
|
632
|
+
const entries = [].concat(family.available_models || [], family.models || []);
|
|
633
|
+
for (const entry of entries) {
|
|
634
|
+
if (!entry) continue;
|
|
635
|
+
const id = entry.value || entry.name;
|
|
636
|
+
if (id !== modelId) continue;
|
|
637
|
+
const window2 = Number(entry.max_input_tokens);
|
|
638
|
+
return Number.isFinite(window2) && window2 > 0 ? window2 : null;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
564
643
|
var PAGE_GLOBAL_TRANSPORT = "page-global";
|
|
565
644
|
function declaredPageGlobal(card) {
|
|
566
645
|
for (const remote of card && card.remotes || []) {
|
|
@@ -3237,12 +3316,28 @@ var conversation_default = `/*
|
|
|
3237
3316
|
white-space: pre;
|
|
3238
3317
|
}
|
|
3239
3318
|
.llm-meta-conversation .message-content pre code { background: none; padding: 0; }
|
|
3319
|
+
/* The scroller is the WRAPPER, not the table: a table that is itself the
|
|
3320
|
+
* scrolling box gets capped at the bubble width and squeezes its columns
|
|
3321
|
+
* instead of overflowing, and .message's word-wrap then breaks every word
|
|
3322
|
+
* one character per line. The wrapper is capped; the table inside keeps its
|
|
3323
|
+
* natural width and scrolls sideways. */
|
|
3324
|
+
.llm-meta-conversation .message-content .lmw-table-scroll {
|
|
3325
|
+
overflow-x: auto;
|
|
3326
|
+
margin: 0.5em 0;
|
|
3327
|
+
}
|
|
3240
3328
|
.llm-meta-conversation .message-content table {
|
|
3241
3329
|
border-collapse: collapse;
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3330
|
+
/* min-width so a narrow table still fills the bubble, width:max-content so
|
|
3331
|
+
* a wide one is laid out at its natural size and the wrapper scrolls. */
|
|
3332
|
+
width: max-content;
|
|
3333
|
+
min-width: 100%;
|
|
3334
|
+
}
|
|
3335
|
+
/* Inside a table, breaking mid-token is what produced the one-letter columns;
|
|
3336
|
+
* cells keep their words whole and the wrapper scrolls instead. */
|
|
3337
|
+
.llm-meta-conversation .message-content th,
|
|
3338
|
+
.llm-meta-conversation .message-content td {
|
|
3339
|
+
word-wrap: normal;
|
|
3340
|
+
overflow-wrap: normal;
|
|
3246
3341
|
}
|
|
3247
3342
|
.llm-meta-conversation .message-content th,
|
|
3248
3343
|
.llm-meta-conversation .message-content td {
|
|
@@ -3844,6 +3939,20 @@ function boot(cfg) {
|
|
|
3844
3939
|
var root = document.getElementById("llm-meta-widget-chat");
|
|
3845
3940
|
var toggleBtn = document.getElementById("llm-meta-widget-toggle");
|
|
3846
3941
|
var historyEl = root.querySelector(".lmw-messages");
|
|
3942
|
+
var SCROLL_PIN_SLACK = 40;
|
|
3943
|
+
var followingOutput = true;
|
|
3944
|
+
function atBottom() {
|
|
3945
|
+
return historyEl.scrollHeight - historyEl.scrollTop - historyEl.clientHeight <= SCROLL_PIN_SLACK;
|
|
3946
|
+
}
|
|
3947
|
+
function pinToBottom() {
|
|
3948
|
+
historyEl.scrollTop = historyEl.scrollHeight;
|
|
3949
|
+
}
|
|
3950
|
+
function pinIfFollowing() {
|
|
3951
|
+
if (followingOutput) pinToBottom();
|
|
3952
|
+
}
|
|
3953
|
+
historyEl.addEventListener("scroll", function() {
|
|
3954
|
+
followingOutput = atBottom();
|
|
3955
|
+
});
|
|
3847
3956
|
var formEl = root.querySelector(".lmw-form");
|
|
3848
3957
|
var inputEl = root.querySelector(".lmw-input");
|
|
3849
3958
|
var clearBtn = root.querySelector(".lmw-clear");
|
|
@@ -3963,6 +4072,12 @@ function boot(cfg) {
|
|
|
3963
4072
|
model: MODEL
|
|
3964
4073
|
});
|
|
3965
4074
|
}
|
|
4075
|
+
var lastPersist = 0;
|
|
4076
|
+
function persistSoon() {
|
|
4077
|
+
if (Date.now() - lastPersist < 400) return;
|
|
4078
|
+
lastPersist = Date.now();
|
|
4079
|
+
persistConversation();
|
|
4080
|
+
}
|
|
3966
4081
|
var typedCards = document.querySelectorAll('script[type="' + PAGE_CARD_MEDIA_TYPE + '"]');
|
|
3967
4082
|
var pageCard;
|
|
3968
4083
|
if (typedCards.length) {
|
|
@@ -4063,6 +4178,8 @@ function boot(cfg) {
|
|
|
4063
4178
|
tasks.push(fetch(LLM_BASE + "/api/llms", { headers: { "Accept": "application/json" } }).then(function(r) {
|
|
4064
4179
|
return r.ok ? r.json() : { llms: [] };
|
|
4065
4180
|
}).then(function(payload) {
|
|
4181
|
+
llmsPayload = payload;
|
|
4182
|
+
modelWindow = modelWindowFrom(payload, MODEL);
|
|
4066
4183
|
var flat = [];
|
|
4067
4184
|
(payload.llms || []).forEach(function(llm) {
|
|
4068
4185
|
if (llm.family !== "ollama") return;
|
|
@@ -4183,6 +4300,7 @@ function boot(cfg) {
|
|
|
4183
4300
|
if (modelPicker) {
|
|
4184
4301
|
modelPicker.addEventListener("change", function() {
|
|
4185
4302
|
MODEL = modelPicker.value;
|
|
4303
|
+
modelWindow = modelWindowFrom(llmsPayload, MODEL);
|
|
4186
4304
|
});
|
|
4187
4305
|
}
|
|
4188
4306
|
if (toolsPicker) {
|
|
@@ -4207,16 +4325,29 @@ function boot(cfg) {
|
|
|
4207
4325
|
var hostWidePrompts = pageCard.prompts.map(function(p) {
|
|
4208
4326
|
return Object.assign({}, p, { source: "page" });
|
|
4209
4327
|
});
|
|
4328
|
+
var modelWindow = null;
|
|
4329
|
+
var llmsPayload = null;
|
|
4210
4330
|
var resourceContext = null;
|
|
4211
4331
|
var resourcePlan = null;
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4332
|
+
function resourceBudget() {
|
|
4333
|
+
return resourceBudgetBytes(modelWindow);
|
|
4334
|
+
}
|
|
4335
|
+
var pageResourcePlans = null;
|
|
4336
|
+
var pageResourcePlansBudget = null;
|
|
4337
|
+
function pageResourcePlansNow() {
|
|
4338
|
+
var budget = resourceBudget();
|
|
4339
|
+
if (pageResourcePlans === null || budget !== pageResourcePlansBudget) {
|
|
4340
|
+
pageResourcePlansBudget = budget;
|
|
4341
|
+
pageResourcePlans = planPageResources({
|
|
4342
|
+
entries: pageCard.resources,
|
|
4343
|
+
budgetBytes: budget,
|
|
4344
|
+
onSkip: function(plan) {
|
|
4345
|
+
console.info("[llm_meta_widget] not attaching " + plan.uri + " (" + plan.reason + ", size=" + plan.size + ")");
|
|
4346
|
+
}
|
|
4347
|
+
});
|
|
4218
4348
|
}
|
|
4219
|
-
|
|
4349
|
+
return pageResourcePlans;
|
|
4350
|
+
}
|
|
4220
4351
|
var pageResourceCache = {};
|
|
4221
4352
|
restoreConversation();
|
|
4222
4353
|
var promptsEl = root.querySelector(".lmw-prompts");
|
|
@@ -4237,7 +4368,7 @@ function boot(cfg) {
|
|
|
4237
4368
|
if (resourcePlan === null) {
|
|
4238
4369
|
var loaded = await loadHostResource({
|
|
4239
4370
|
endpoint,
|
|
4240
|
-
budgetBytes:
|
|
4371
|
+
budgetBytes: resourceBudget(),
|
|
4241
4372
|
list: listMcpResources,
|
|
4242
4373
|
read: readMcpResource,
|
|
4243
4374
|
onSkip: function(plan) {
|
|
@@ -4299,7 +4430,7 @@ function boot(cfg) {
|
|
|
4299
4430
|
});
|
|
4300
4431
|
if (body.parentNode) body.parentNode.appendChild(row);
|
|
4301
4432
|
});
|
|
4302
|
-
|
|
4433
|
+
pinToBottom();
|
|
4303
4434
|
return true;
|
|
4304
4435
|
}
|
|
4305
4436
|
function renderWelcome() {
|
|
@@ -4400,7 +4531,7 @@ function boot(cfg) {
|
|
|
4400
4531
|
div.appendChild(document.createTextNode(" "));
|
|
4401
4532
|
div.appendChild(body);
|
|
4402
4533
|
historyEl.appendChild(div);
|
|
4403
|
-
|
|
4534
|
+
pinIfFollowing();
|
|
4404
4535
|
body.roleLabel = label;
|
|
4405
4536
|
return body;
|
|
4406
4537
|
}
|
|
@@ -4410,6 +4541,17 @@ function boot(cfg) {
|
|
|
4410
4541
|
} catch (e) {
|
|
4411
4542
|
el.textContent = mdText;
|
|
4412
4543
|
}
|
|
4544
|
+
wrapTablesForScrolling(el);
|
|
4545
|
+
}
|
|
4546
|
+
function wrapTablesForScrolling(el) {
|
|
4547
|
+
var tables = el.querySelectorAll("table");
|
|
4548
|
+
for (var i = 0; i < tables.length; i++) {
|
|
4549
|
+
var table = tables[i];
|
|
4550
|
+
var wrap = document.createElement("div");
|
|
4551
|
+
wrap.className = "lmw-table-scroll";
|
|
4552
|
+
table.parentNode.insertBefore(wrap, table);
|
|
4553
|
+
wrap.appendChild(table);
|
|
4554
|
+
}
|
|
4413
4555
|
}
|
|
4414
4556
|
function roleLabel(role) {
|
|
4415
4557
|
if (role === "user") return "\u{1F464} You";
|
|
@@ -4447,7 +4589,7 @@ function boot(cfg) {
|
|
|
4447
4589
|
chip.dataset.tool = toolCall.name;
|
|
4448
4590
|
row.appendChild(chip);
|
|
4449
4591
|
pendingChips.push(chip);
|
|
4450
|
-
|
|
4592
|
+
pinIfFollowing();
|
|
4451
4593
|
}
|
|
4452
4594
|
function resolveToolCall(outcome) {
|
|
4453
4595
|
var idx = pendingChips.findIndex(function(c) {
|
|
@@ -4487,7 +4629,7 @@ function boot(cfg) {
|
|
|
4487
4629
|
} else {
|
|
4488
4630
|
historyEl.appendChild(details);
|
|
4489
4631
|
}
|
|
4490
|
-
|
|
4632
|
+
pinIfFollowing();
|
|
4491
4633
|
currentThinkingBlock = details;
|
|
4492
4634
|
currentThinkingBody = body;
|
|
4493
4635
|
return body;
|
|
@@ -4567,13 +4709,13 @@ function boot(cfg) {
|
|
|
4567
4709
|
cached: resourceContext,
|
|
4568
4710
|
endpoint: resourcePlan && resourcePlan.endpoint,
|
|
4569
4711
|
read: readMcpResource,
|
|
4570
|
-
budgetBytes:
|
|
4712
|
+
budgetBytes: resourceBudget()
|
|
4571
4713
|
});
|
|
4572
4714
|
resourceContext = turn.cached;
|
|
4573
4715
|
var page = await pageResourceLinesForTurn({
|
|
4574
|
-
plans:
|
|
4716
|
+
plans: pageResourcePlansNow(),
|
|
4575
4717
|
resources: handlers.resources,
|
|
4576
|
-
budgetBytes:
|
|
4718
|
+
budgetBytes: resourceBudget(),
|
|
4577
4719
|
cache: pageResourceCache
|
|
4578
4720
|
});
|
|
4579
4721
|
pageResourceCache = page.cache;
|
|
@@ -4604,6 +4746,7 @@ function boot(cfg) {
|
|
|
4604
4746
|
var userText = inputEl.value.trim();
|
|
4605
4747
|
if (!userText) return;
|
|
4606
4748
|
inputEl.value = "";
|
|
4749
|
+
followingOutput = true;
|
|
4607
4750
|
appendTurn("user", userText, pendingTurnLabel);
|
|
4608
4751
|
pendingTurnLabel = null;
|
|
4609
4752
|
if (currentAbort) {
|
|
@@ -4625,6 +4768,10 @@ function boot(cfg) {
|
|
|
4625
4768
|
var messages = [{ role: "system", content: currentSystemPrompt(resourceLines) }].concat(conversation.map(function(t) {
|
|
4626
4769
|
return { role: t.role, content: t.content };
|
|
4627
4770
|
})).concat([{ role: "user", content: userText }]);
|
|
4771
|
+
conversation.push({ role: "user", content: userText });
|
|
4772
|
+
var liveTurn = { role: "assistant", content: "", tools: [] };
|
|
4773
|
+
conversation.push(liveTurn);
|
|
4774
|
+
persistConversation();
|
|
4628
4775
|
var result = await runChatLoop({
|
|
4629
4776
|
baseUrl: LLM_BASE,
|
|
4630
4777
|
toolHubUrl: TOOL_HUB_BASE,
|
|
@@ -4636,13 +4783,22 @@ function boot(cfg) {
|
|
|
4636
4783
|
hostWideTools,
|
|
4637
4784
|
aiActions: handlers.tools,
|
|
4638
4785
|
maxRounds: MAX_ROUNDS,
|
|
4786
|
+
contextWindow: modelWindow,
|
|
4639
4787
|
provider: LLM_PROVIDER === "ollama" ? "ollama" : "hub",
|
|
4640
4788
|
signal: currentAbort.signal,
|
|
4789
|
+
onContextEvicted: function(dropped) {
|
|
4790
|
+
var last = dropped[dropped.length - 1];
|
|
4791
|
+
var name = last && last.name ? last.name : "tool";
|
|
4792
|
+
appendTurn("system", "Dropped an earlier " + name + " result to make room in the model's context. Ask again if you need it.");
|
|
4793
|
+
},
|
|
4641
4794
|
onToolCall: function(toolCall) {
|
|
4642
4795
|
announceToolCall(toolCall);
|
|
4796
|
+
persistConversation();
|
|
4643
4797
|
},
|
|
4644
4798
|
onToolDispatched: function(outcome) {
|
|
4645
4799
|
resolveToolCall(outcome);
|
|
4800
|
+
liveTurn.tools = liveTurn.tools.concat([outcome.error ? { name: outcome.toolCall.name, error: true } : { name: outcome.toolCall.name }]);
|
|
4801
|
+
persistConversation();
|
|
4646
4802
|
},
|
|
4647
4803
|
onPhase: function(name) {
|
|
4648
4804
|
if (name === "responding") markDone(assistantBody.roleLabel);
|
|
@@ -4656,25 +4812,21 @@ function boot(cfg) {
|
|
|
4656
4812
|
var body = ensureThinkingBlock();
|
|
4657
4813
|
body.appendChild(document.createTextNode(delta));
|
|
4658
4814
|
body.scrollTop = body.scrollHeight;
|
|
4659
|
-
|
|
4815
|
+
pinIfFollowing();
|
|
4660
4816
|
},
|
|
4661
4817
|
onTextDelta: function(delta) {
|
|
4662
4818
|
markDone(assistantBody.roleLabel);
|
|
4663
4819
|
collapseThinkingBlock();
|
|
4664
4820
|
assistantMarkdown += delta;
|
|
4665
4821
|
renderMarkdownInto(assistantBody, assistantMarkdown);
|
|
4666
|
-
|
|
4822
|
+
liveTurn.content = assistantMarkdown;
|
|
4823
|
+
persistSoon();
|
|
4824
|
+
pinIfFollowing();
|
|
4667
4825
|
}
|
|
4668
4826
|
});
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
content: result.content,
|
|
4673
|
-
// Kept so a restored transcript still shows what ran — losing
|
|
4674
|
-
// that record is what made a navigating action unbearable.
|
|
4675
|
-
tools: result.dispatched.map(function(d) {
|
|
4676
|
-
return d.error ? { name: d.toolCall.name, error: true } : { name: d.toolCall.name };
|
|
4677
|
-
})
|
|
4827
|
+
liveTurn.content = result.content;
|
|
4828
|
+
liveTurn.tools = result.dispatched.map(function(d) {
|
|
4829
|
+
return d.error ? { name: d.toolCall.name, error: true } : { name: d.toolCall.name };
|
|
4678
4830
|
});
|
|
4679
4831
|
persistConversation();
|
|
4680
4832
|
result.dispatched.forEach(function(d) {
|
|
@@ -325,6 +325,16 @@ export async function runChatLoop(opts) {
|
|
|
325
325
|
remoteTools = [],
|
|
326
326
|
hostWideTools = [],
|
|
327
327
|
maxRounds = 10,
|
|
328
|
+
// The model's own context window, from /api/llms. Without it the loop
|
|
329
|
+
// cannot know how much room is left, so it budgets nothing and a long
|
|
330
|
+
// tool-heavy turn is refused by the provider instead of degrading.
|
|
331
|
+
contextWindow = null,
|
|
332
|
+
// Tokens held back for the answer: a request that fills the window leaves
|
|
333
|
+
// nothing to generate into.
|
|
334
|
+
outputReserve = 2048,
|
|
335
|
+
// Called when a tool result is dropped to make room, so the panel can say
|
|
336
|
+
// so rather than the context quietly thinning.
|
|
337
|
+
onContextEvicted,
|
|
328
338
|
// "hub" (default) talks to llm_meta_server; "ollama" talks straight to an
|
|
329
339
|
// Ollama, with no server component of ours in between. The loop is the
|
|
330
340
|
// same either way — only the call is swapped.
|
|
@@ -347,7 +357,7 @@ export async function runChatLoop(opts) {
|
|
|
347
357
|
...singleOpts
|
|
348
358
|
} = opts
|
|
349
359
|
|
|
350
|
-
|
|
360
|
+
let messages = [ ...(singleOpts.messages || []) ]
|
|
351
361
|
const remoteByName = Object.fromEntries((remoteTools || []).map((t) => [ t.name, t ]))
|
|
352
362
|
// Class 2: host-wide MCP tools (well-known). Map name→{endpoint, ...} for
|
|
353
363
|
// dispatch. If the same name appears in aiActions, Class 3 wins there
|
|
@@ -366,6 +376,10 @@ export async function runChatLoop(opts) {
|
|
|
366
376
|
const mergedLocalTools = Object.values(inlineByName)
|
|
367
377
|
|
|
368
378
|
const rounds = []
|
|
379
|
+
const evicted = []
|
|
380
|
+
// Fixed for the turn: the declared tool set does not change between rounds.
|
|
381
|
+
const toolsTokens = estimateToolsTokens([].concat(
|
|
382
|
+
singleOpts.localTools || [], remoteTools || [], hostWideTools || []))
|
|
369
383
|
const allDispatched = []
|
|
370
384
|
const allSkipped = []
|
|
371
385
|
let lastResult = null
|
|
@@ -408,6 +422,18 @@ export async function runChatLoop(opts) {
|
|
|
408
422
|
onRoundStart?.(round)
|
|
409
423
|
|
|
410
424
|
// The loop is the same whoever answers; only the call differs.
|
|
425
|
+
// Make room before sending, rather than after being refused. Only old
|
|
426
|
+
// tool results are given up; see evictOldToolResults.
|
|
427
|
+
const room = evictOldToolResults({
|
|
428
|
+
messages, window: contextWindow, dropped: evicted,
|
|
429
|
+
// The tools take their share of the window before any message does.
|
|
430
|
+
reserve: outputReserve + toolsTokens
|
|
431
|
+
});
|
|
432
|
+
if (room.messages !== messages) {
|
|
433
|
+
messages = room.messages;
|
|
434
|
+
onContextEvicted?.(evicted.slice());
|
|
435
|
+
}
|
|
436
|
+
|
|
411
437
|
const call = provider === "ollama" ? ollamaChatCall : singleLlmCall
|
|
412
438
|
const turnResult = await call({
|
|
413
439
|
...singleOpts,
|
|
@@ -891,6 +917,111 @@ export const PAGE_CARD_MEDIA_TYPE = "application/mcp-static-primitives+json"
|
|
|
891
917
|
// card, find the handlers and dispatch. The three legacy globals
|
|
892
918
|
// (aiActions/aiPrompts/aiResources) are names only this widget knows, and are
|
|
893
919
|
// configurable per page, so nothing outside could rely on them.
|
|
920
|
+
// How much room a model has, read from /api/llms — the one call the widget
|
|
921
|
+
// already makes before a turn. Scans every family, because the picker shows
|
|
922
|
+
// only the local ones while the configured model is often hosted, and the two
|
|
923
|
+
// families use different shapes for the same thing (`value` vs `name`).
|
|
924
|
+
//
|
|
925
|
+
// Returns null when the hub does not say. Null means "no budget to work with",
|
|
926
|
+
// which is not the same as zero, and every caller below treats it as "do not
|
|
927
|
+
// guess" rather than "no room".
|
|
928
|
+
// How many bytes of reference resources a turn may attach, derived from the
|
|
929
|
+
// model's window instead of guessed. The old fixed 8000 was chosen as "~2k
|
|
930
|
+
// tokens, beyond which a resource crowds out the conversation on a 32k local
|
|
931
|
+
// model" — which is 6.25% of that window, so the same share reproduces 8000 at
|
|
932
|
+
// 32k exactly and gives a 128k model the 32 KB it can afford.
|
|
933
|
+
//
|
|
934
|
+
// The floor keeps the previous behaviour whenever the window is unknown. The
|
|
935
|
+
// cap is there because these bytes are fetched and inlined on every turn: the
|
|
936
|
+
// largest real catalog (PubDictionaries, 218 dictionaries) is ~35 KB, so 64 KB
|
|
937
|
+
// is already generous and a 1M-token model does not need 250 KB of it.
|
|
938
|
+
export const RESOURCE_SHARE_OF_WINDOW = 0.0625
|
|
939
|
+
|
|
940
|
+
export function resourceBudgetBytes(window, { floor = 8000, cap = 64_000 } = {}) {
|
|
941
|
+
if (!window || !Number.isFinite(window)) return floor
|
|
942
|
+
const share = Math.round(window * RESOURCE_SHARE_OF_WINDOW * CHARS_PER_TOKEN)
|
|
943
|
+
return Math.min(cap, Math.max(floor, share))
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// A rough token count, the same shape the hub's own estimator uses: characters
|
|
947
|
+
// over four, plus a fixed cost per message. Deliberately approximate — it is
|
|
948
|
+
// used to decide "drop the oldest tool result", never to fill the window
|
|
949
|
+
// exactly, and it errs high so the decision is made early rather than late.
|
|
950
|
+
export const CHARS_PER_TOKEN = 4
|
|
951
|
+
export const MESSAGE_OVERHEAD_TOKENS = 4
|
|
952
|
+
|
|
953
|
+
export function estimateTokens(messages) {
|
|
954
|
+
return (messages || []).reduce((sum, m) => {
|
|
955
|
+
const content = typeof m?.content === "string" ? m.content : JSON.stringify(m?.content ?? "")
|
|
956
|
+
return sum + Math.ceil(content.length / CHARS_PER_TOKEN) + MESSAGE_OVERHEAD_TOKENS
|
|
957
|
+
}, 0)
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
// What the declared tools cost in the window. They are part of every request:
|
|
961
|
+
// names, descriptions and JSON schemas. On the hub path the browser sends only
|
|
962
|
+
// `tool_ids` and the hub expands the schemas, but the browser listed those
|
|
963
|
+
// tools to build its picker, so it still has what it needs to price them.
|
|
964
|
+
//
|
|
965
|
+
// This is not a detail. TogoMCP declares around twenty tools, which is a few
|
|
966
|
+
// thousand tokens standing in the window before the conversation starts; a
|
|
967
|
+
// budget counting only messages runs low and evicts too late to help.
|
|
968
|
+
export function estimateToolsTokens(tools) {
|
|
969
|
+
const text = (tools || []).map((t) => JSON.stringify({
|
|
970
|
+
name: t?.name, description: t?.description, schema: t?.input_schema || t?.inputSchema
|
|
971
|
+
})).join("")
|
|
972
|
+
return Math.ceil(text.length / CHARS_PER_TOKEN)
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// Makes room for the next round by dropping the OLDEST tool results, which is
|
|
976
|
+
// the one eviction a chat loop can justify: the model has already read them and
|
|
977
|
+
// said what it made of them in the assistant turn that follows, so its own
|
|
978
|
+
// summary survives. Nothing else is touched — not the system prompt, not the
|
|
979
|
+
// questions, not the assistant's words.
|
|
980
|
+
//
|
|
981
|
+
// Each dropped result leaves a marker in its place, because a tool message must
|
|
982
|
+
// still answer its tool_call_id: a provider that sees a call with no result
|
|
983
|
+
// rejects the request, and a model that sees no mention of the result would
|
|
984
|
+
// happily call the same tool again.
|
|
985
|
+
//
|
|
986
|
+
// Returns the messages unchanged when the window is unknown. Guessing a window
|
|
987
|
+
// would be worse than not budgeting: too low throws away context the turn
|
|
988
|
+
// needed, and too high fails at the provider anyway.
|
|
989
|
+
export function evictOldToolResults({ messages, window, reserve = 0, dropped = [] }) {
|
|
990
|
+
if (!window || !Number.isFinite(window)) return { messages, dropped }
|
|
991
|
+
|
|
992
|
+
const budget = window - reserve
|
|
993
|
+
if (estimateTokens(messages) <= budget) return { messages, dropped }
|
|
994
|
+
|
|
995
|
+
const out = messages.slice()
|
|
996
|
+
for (let i = 0; i < out.length; i++) {
|
|
997
|
+
if (estimateTokens(out) <= budget) break
|
|
998
|
+
const m = out[i]
|
|
999
|
+
if (m?.role !== "tool") continue
|
|
1000
|
+
if (typeof m.content === "string" && m.content.startsWith("[dropped")) continue
|
|
1001
|
+
|
|
1002
|
+
dropped.push({ name: m.name, tokens: estimateTokens([ m ]) })
|
|
1003
|
+
out[i] = Object.assign({}, m, {
|
|
1004
|
+
content: "[dropped to make room in the context window. Call the tool again " +
|
|
1005
|
+
"if you still need it, with a narrower query.]"
|
|
1006
|
+
})
|
|
1007
|
+
}
|
|
1008
|
+
return { messages: out, dropped }
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
export function modelWindowFrom(payload, modelId) {
|
|
1012
|
+
for (const family of (payload && payload.llms) || []) {
|
|
1013
|
+
const entries = [].concat(family.available_models || [], family.models || [])
|
|
1014
|
+
for (const entry of entries) {
|
|
1015
|
+
if (!entry) continue
|
|
1016
|
+
const id = entry.value || entry.name
|
|
1017
|
+
if (id !== modelId) continue
|
|
1018
|
+
const window = Number(entry.max_input_tokens)
|
|
1019
|
+
return Number.isFinite(window) && window > 0 ? window : null
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
return null
|
|
1023
|
+
}
|
|
1024
|
+
|
|
894
1025
|
export const PAGE_GLOBAL_TRANSPORT = "page-global"
|
|
895
1026
|
|
|
896
1027
|
export function declaredPageGlobal(card) {
|
|
@@ -109,12 +109,28 @@
|
|
|
109
109
|
white-space: pre;
|
|
110
110
|
}
|
|
111
111
|
.llm-meta-conversation .message-content pre code { background: none; padding: 0; }
|
|
112
|
+
/* The scroller is the WRAPPER, not the table: a table that is itself the
|
|
113
|
+
* scrolling box gets capped at the bubble width and squeezes its columns
|
|
114
|
+
* instead of overflowing, and .message's word-wrap then breaks every word
|
|
115
|
+
* one character per line. The wrapper is capped; the table inside keeps its
|
|
116
|
+
* natural width and scrolls sideways. */
|
|
117
|
+
.llm-meta-conversation .message-content .lmw-table-scroll {
|
|
118
|
+
overflow-x: auto;
|
|
119
|
+
margin: 0.5em 0;
|
|
120
|
+
}
|
|
112
121
|
.llm-meta-conversation .message-content table {
|
|
113
122
|
border-collapse: collapse;
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
123
|
+
/* min-width so a narrow table still fills the bubble, width:max-content so
|
|
124
|
+
* a wide one is laid out at its natural size and the wrapper scrolls. */
|
|
125
|
+
width: max-content;
|
|
126
|
+
min-width: 100%;
|
|
127
|
+
}
|
|
128
|
+
/* Inside a table, breaking mid-token is what produced the one-letter columns;
|
|
129
|
+
* cells keep their words whole and the wrapper scrolls instead. */
|
|
130
|
+
.llm-meta-conversation .message-content th,
|
|
131
|
+
.llm-meta-conversation .message-content td {
|
|
132
|
+
word-wrap: normal;
|
|
133
|
+
overflow-wrap: normal;
|
|
118
134
|
}
|
|
119
135
|
.llm-meta-conversation .message-content th,
|
|
120
136
|
.llm-meta-conversation .message-content td {
|