llm_meta_widget 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +122 -12
- data/Rakefile +11 -2
- data/app/assets/javascripts/llm_meta_widget/orchestrator.js +43 -26
- data/app/assets/stylesheets/llm_meta_widget/conversation.css +48 -0
- data/app/helpers/llm_meta_widget/widget_helper.rb +5 -1
- data/app/views/llm_meta_widget/_chat_panel.html.erb +210 -29
- 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: 17cad71af08d5e4c49a3e7418055bd9c034880d9ab6cae2cbb3fb6cf3a0f0804
|
|
4
|
+
data.tar.gz: 24e7aa0c38400bccee9ddc832a6da10cf2788f42e343b67901d0cfc9a7748e24
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2e55a245ad87d21f2d235fe9c211751520172df72f7197ad70b558a546c0b80e62ce63e9f00718ac0f91145d2a14bf920db808f70a550c7822d603c20a565833
|
|
7
|
+
data.tar.gz: bdad3eda95fd643db1dc11fc10c4e2c48d0d3442d3e2a1a79012f1e8bb2af769dc98217d897b6fd7b173a17f06e7ba55ab3209d50a3cd7186349dbce5920030b
|
data/README.md
CHANGED
|
@@ -6,28 +6,71 @@ Client-orchestrated: the widget fetches host-side action schemas + host-publishe
|
|
|
6
6
|
|
|
7
7
|
**No** Devise, DB migrations, ChatManager, or PromptNavigator. Adds only `rails >= 8.0` as a runtime dep — so hosts that haven't bumped to 8.1 can adopt it without a Rails upgrade.
|
|
8
8
|
|
|
9
|
-
##
|
|
9
|
+
## What you need first
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
The widget is a browser front end. It does not talk to OpenAI, Anthropic or
|
|
12
|
+
Ollama itself — it talks to **[llm_meta_server](https://github.com/pubannotation)**,
|
|
13
|
+
a hub that holds the provider credentials and streams responses back over SSE.
|
|
14
|
+
So adopting the widget means either running a hub or being given the URL of one.
|
|
15
|
+
|
|
16
|
+
Nothing else is required: no database, no migrations, no JavaScript build step,
|
|
17
|
+
no Node at runtime. The gem ships plain ES modules that the engine serves.
|
|
18
|
+
|
|
19
|
+
| Requirement | Version |
|
|
20
|
+
|---|---|
|
|
21
|
+
| Ruby | >= 3.2 |
|
|
22
|
+
| Rails | >= 8.0 (8.1 not required) |
|
|
23
|
+
| A reachable llm_meta_server | any |
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
12
26
|
|
|
13
27
|
```ruby
|
|
14
|
-
|
|
28
|
+
# Gemfile
|
|
29
|
+
gem "llm_meta_widget", "~> 0.4"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
bundle install
|
|
15
34
|
```
|
|
16
35
|
|
|
17
|
-
|
|
36
|
+
That is the whole install. There is **no `mount` to add** — the engine
|
|
37
|
+
deliberately skips `isolate_namespace`, so the helper is included into
|
|
38
|
+
ActionView and the asset routes appear at the host's top level on their own.
|
|
39
|
+
There are no migrations and no generators to run.
|
|
18
40
|
|
|
19
|
-
##
|
|
41
|
+
## Minimal working example
|
|
20
42
|
|
|
21
|
-
|
|
43
|
+
Put this on any view — a fresh `pages/demo.html.erb` is fine:
|
|
22
44
|
|
|
23
45
|
```erb
|
|
46
|
+
<h1>Demo</h1>
|
|
47
|
+
|
|
24
48
|
<%= llm_meta_widget(base_url: "https://your-meta-server.example",
|
|
25
|
-
model: "qwen3-6-35b-fast"
|
|
49
|
+
model: "qwen3-6-35b-fast",
|
|
50
|
+
greeting: "Hi — ask me anything about this page.") %>
|
|
26
51
|
```
|
|
27
52
|
|
|
28
|
-
|
|
53
|
+
Start the app, open that page, and click the round toggle at the bottom right.
|
|
54
|
+
**Success looks like:** a panel opens showing your greeting, you type "hello",
|
|
55
|
+
the role label turns into a spinning gear reading "Working…", and the reply
|
|
56
|
+
streams in.
|
|
57
|
+
|
|
58
|
+
If the panel opens but nothing comes back, it is almost always CORS. The hub
|
|
59
|
+
must list your app's origin in its `config/initializers/cors.rb` for the
|
|
60
|
+
`/api/*` resource, because the widget calls the hub **from the visitor's
|
|
61
|
+
browser**, not from your server. Open the browser console: a blocked request
|
|
62
|
+
says so explicitly.
|
|
63
|
+
|
|
64
|
+
Two more things worth knowing before you go further:
|
|
65
|
+
|
|
66
|
+
- `base_url` must be reachable from your visitors' browsers, not just from
|
|
67
|
+
your server. `localhost` works only while you are the visitor.
|
|
68
|
+
- The model name is the hub's name for it (`GET /api/llms` lists them), not
|
|
69
|
+
the provider's.
|
|
29
70
|
|
|
30
|
-
|
|
71
|
+
Once that works, the widget can converse but cannot *do* anything. To let the
|
|
72
|
+
LLM act on your page or call your own services, declare tools using any of the
|
|
73
|
+
**three tool classes** below.
|
|
31
74
|
|
|
32
75
|
## Three tool classes
|
|
33
76
|
|
|
@@ -72,7 +115,17 @@ Declared inline on the same view as the widget. Runs as JavaScript in the browse
|
|
|
72
115
|
|
|
73
116
|
**What the LLM sees.** The `ai-actions` JSON is passed to the meta-server as `local_tools` for every turn. Alongside it, every reader in `window.aiState` is invoked (each turn) and the results are JSON-serialized into a `Current page state:` block appended to the system prompt — the LLM can answer from state directly instead of tool-calling for lookups.
|
|
74
117
|
|
|
75
|
-
**When the tool_call fires.**
|
|
118
|
+
**When the tool_call fires.** During the turn, as soon as the LLM emits the
|
|
119
|
+
call. Since 0.4.0 the action's outcome — `{ok: true, applied: "<name>"}`, or
|
|
120
|
+
the error message if it raised — is fed back to the LLM as a tool result, and
|
|
121
|
+
the turn continues. Before 0.4.0 these were fire-and-forget, which meant a
|
|
122
|
+
turn whose only calls were page actions ended there: any task shaped *change
|
|
123
|
+
the page, then do something with it* was cut off after the write. The change
|
|
124
|
+
also means the LLM can see that an action failed and correct itself, instead
|
|
125
|
+
of the failure being visible only to the visitor.
|
|
126
|
+
|
|
127
|
+
Budget for it: every page action now costs a round, so a flow that writes twice
|
|
128
|
+
and then calls a tool needs roughly five or six. See `max_rounds:` below.
|
|
76
129
|
|
|
77
130
|
**Args**: parsed JS object matching the `input_schema`. Return value ignored. Async allowed (widget doesn't await, but browser will still execute the promise). Exceptions logged and surfaced as an `❌ <name>` chip in the message footer.
|
|
78
131
|
|
|
@@ -115,7 +168,7 @@ Declared out-of-band on the meta-server (via the hub's admin UI or `/user/:id/mc
|
|
|
115
168
|
|
|
116
169
|
**What the LLM sees.** Only tools from servers the visitor has **enabled via the tool picker** (see "Level-1 pickers" below). Nothing is auto-selected — the visitor opts in per session.
|
|
117
170
|
|
|
118
|
-
**When the tool_call fires.** Synchronously during the turn — widget POSTs to the hub's `/api/llm_api_keys/:uuid/models/:name/
|
|
171
|
+
**When the tool_call fires.** Synchronously during the turn — widget POSTs to the hub's `/api/llm_api_keys/:uuid/models/:name/single_llm_calls` endpoint with `tool_ids: [...]`; the hub proxies to each MCP server and streams results back through SSE.
|
|
119
172
|
|
|
120
173
|
**Error path.** Hub-side errors (rate limit, timeout, MCP server unavailable, upstream failure) arrive as SSE `event: error` frames with typed codes (`mcp_unavailable`, `timeout`, `rate_limit`, …); the widget surfaces them in the message bubble with a per-code prefix.
|
|
121
174
|
|
|
@@ -162,12 +215,69 @@ To lock the widget to the fixed `model:` prop and disable Class-1 tools entirely
|
|
|
162
215
|
| `actions_global:` | `"aiActions"` | Global window object holding Class-3 implementations |
|
|
163
216
|
| `remote_tools_schema_id:` | `"remote-mcp-tools"` | Optional DOM id for pre-configured Class-1 tools (bypasses picker) |
|
|
164
217
|
| `well_known_urls:` | `nil` | `nil` = auto-discover same-origin; explicit array = fetch those; `[]` = disable |
|
|
165
|
-
| `
|
|
218
|
+
| `greeting:` | `nil` | First thing a visitor sees when the panel opens, above the offered prompt templates. `nil` = a generic line |
|
|
219
|
+
| `max_rounds:` | `3` | Cap on tool-call rounds per LLM turn. Page actions cost a round each since 0.4.0 — raise it for multi-step flows |
|
|
166
220
|
| `enable_model_picker:` | `true` | Show model dropdown (Level-1) |
|
|
167
221
|
| `enable_tool_picker:` | `true` | Show tool picker (Level-1) |
|
|
168
222
|
| `models:` | `nil` | Model-name allowlist; `nil` = all anon-available |
|
|
169
223
|
| `hub_tools:` | `nil` | MCP-server-name allowlist; `nil` = all anon-public |
|
|
170
224
|
|
|
225
|
+
## Declaring resources and prompts (static-primitives extension)
|
|
226
|
+
|
|
227
|
+
If you operate the MCP endpoint behind your `.well-known/mcp.json`, you can
|
|
228
|
+
also offer **prompts** (one-click starting points, shown as buttons above the
|
|
229
|
+
input) and **resources** (reference data the widget can put in the model's
|
|
230
|
+
context). The widget reads two optional fields the
|
|
231
|
+
`io.modelcontextprotocol/static-primitives` extension defines on a
|
|
232
|
+
`resources/list` entry:
|
|
233
|
+
|
|
234
|
+
```json
|
|
235
|
+
{
|
|
236
|
+
"uri": "yourhost://catalog",
|
|
237
|
+
"name": "Catalog",
|
|
238
|
+
"mimeType": "application/json",
|
|
239
|
+
"_meta": {
|
|
240
|
+
"io.modelcontextprotocol/static-primitives": {
|
|
241
|
+
"sizeBytes": 1298,
|
|
242
|
+
"volatility": "stable",
|
|
243
|
+
"autoAttach": true
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
- **`sizeBytes`** — the byte length of the payload `resources/read` returns.
|
|
250
|
+
The widget decides whether it can afford the resource *before* fetching it;
|
|
251
|
+
over its budget (8 KB), the bytes never cross the wire and the LLM is left
|
|
252
|
+
to ask for what it needs through your tools instead.
|
|
253
|
+
- **`volatility`** — `"stable"` (read once, re-used) or `"volatile"` (re-read
|
|
254
|
+
on every turn that uses it).
|
|
255
|
+
- **`autoAttach`** — may the widget put this in the model's context without
|
|
256
|
+
the visitor asking?
|
|
257
|
+
|
|
258
|
+
All three are optional. A server that declares none behaves exactly as one
|
|
259
|
+
that predates the extension: the resource is fetched, trimmed to budget and
|
|
260
|
+
carried in the model's context.
|
|
261
|
+
|
|
262
|
+
A prompt is offered as a button labelled by its `title`. Declare its arguments
|
|
263
|
+
as **optional** and fill them from page state where you can: an argument the
|
|
264
|
+
visitor must supply by hand makes the button useless to the visitor who needed
|
|
265
|
+
it most, since they must already know your form to press it.
|
|
266
|
+
|
|
267
|
+
## Further reading
|
|
268
|
+
|
|
269
|
+
- **The static-primitives extension** — `io.modelcontextprotocol/static-primitives`,
|
|
270
|
+
a follow-on to [SEP-2127](https://github.com/modelcontextprotocol/modelcontextprotocol)
|
|
271
|
+
(Server Cards). The fields above are a reference implementation of its
|
|
272
|
+
current draft shape.
|
|
273
|
+
- **MCP itself** — <https://modelcontextprotocol.io>
|
|
274
|
+
- **A worked adopter** — PubDictionaries' annotation page
|
|
275
|
+
(<https://pubdictionaries.org/text_annotation>) runs this widget with all
|
|
276
|
+
three tool classes plus prompts and resources. Its
|
|
277
|
+
`app/views/annotation/text_annotation.html.erb` and `app/controllers/mcp_controller.rb`
|
|
278
|
+
are the fullest example available.
|
|
279
|
+
- **Issues and questions** — <https://github.com/jdkim/llm_meta_widget/issues>
|
|
280
|
+
|
|
171
281
|
## License
|
|
172
282
|
|
|
173
283
|
Apache-2.0.
|
data/Rakefile
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# `rake build` / `rake release`, used by .github/workflows/gem_release.yml.
|
|
2
2
|
# The widget has no Ruby test suite of its own: its logic is the browser
|
|
3
|
-
# orchestrator, tested with `node --test` (see `rake test_js`)
|
|
3
|
+
# orchestrator, tested with `node --test` (see `rake test_js`) and linted
|
|
4
|
+
# with eslint (`rake lint_js`). The lint covers the panel's script too, by
|
|
5
|
+
# extracting it from the ERB — three bugs reached a browser through that gap
|
|
6
|
+
# before it existed: an undefined variable, a stale import, a redeclared one.
|
|
4
7
|
require "bundler/gem_tasks"
|
|
5
8
|
|
|
6
9
|
desc "Run the orchestrator's JavaScript tests"
|
|
@@ -8,4 +11,10 @@ task :test_js do
|
|
|
8
11
|
sh "node --test app/assets/javascripts/llm_meta_widget/orchestrator.test.mjs"
|
|
9
12
|
end
|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
desc "Lint the widget's JavaScript, including the panel script inside the ERB"
|
|
15
|
+
task :lint_js do
|
|
16
|
+
sh "npm install --silent" unless Dir.exist?("node_modules")
|
|
17
|
+
sh "npm run lint"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
task default: [ :lint_js, :test_js ]
|
|
@@ -446,8 +446,22 @@ export async function runChatLoop(opts) {
|
|
|
446
446
|
const localOut = await dispatchLocalToolCalls(localCalls, aiActions)
|
|
447
447
|
allDispatched.push(...localOut.dispatched)
|
|
448
448
|
|
|
449
|
-
//
|
|
449
|
+
// A page action's outcome goes back to the model like any other tool
|
|
450
|
+
// result. It used to be dropped, on the grounds that a write to the page
|
|
451
|
+
// has nothing to report — but a turn whose only calls were page actions
|
|
452
|
+
// then ended the loop, so any task that writes to the page and THEN needs
|
|
453
|
+
// a tool ("select these dictionaries, now annotate") was cut off after
|
|
454
|
+
// the write. It also means a failed action is something the model can
|
|
455
|
+
// see and correct, instead of a red mark only the user notices.
|
|
450
456
|
const roundTripResults = []
|
|
457
|
+
for (const { toolCall, error } of localOut.dispatched) {
|
|
458
|
+
roundTripResults.push({
|
|
459
|
+
tc: toolCall,
|
|
460
|
+
result: error ? { error: String(error.message || error) } : { ok: true, applied: toolCall.name }
|
|
461
|
+
})
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Class 2: host-wide — direct MCP JSON-RPC POST to the host's own endpoint
|
|
451
465
|
for (const tc of hostWideCalls) {
|
|
452
466
|
const tool = hostWideByName[tc.name]
|
|
453
467
|
const args = coerceArguments(tc.arguments)
|
|
@@ -490,9 +504,10 @@ export async function runChatLoop(opts) {
|
|
|
490
504
|
toolCalls: turnResult.toolCalls
|
|
491
505
|
})
|
|
492
506
|
|
|
493
|
-
// Terminate when there's nothing to feed back
|
|
494
|
-
//
|
|
495
|
-
//
|
|
507
|
+
// Terminate when there's nothing to feed back: a turn with no tool calls
|
|
508
|
+
// at all, or one whose only calls were unknown. Anything that executed —
|
|
509
|
+
// page action, host tool or proxied tool — produces a result the model
|
|
510
|
+
// sees, and gets another round to act on.
|
|
496
511
|
if (roundTripResults.length === 0) {
|
|
497
512
|
return {
|
|
498
513
|
content: turnResult.content,
|
|
@@ -836,22 +851,6 @@ export function planResourceAttachment(entry, budgetBytes) {
|
|
|
836
851
|
}
|
|
837
852
|
}
|
|
838
853
|
|
|
839
|
-
// Per-turn gate. A stable resource is attached once and re-used; a volatile
|
|
840
|
-
// one is owed a fresh copy every turn.
|
|
841
|
-
export function createResourceAttacher(plan) {
|
|
842
|
-
let attachedOnce = false
|
|
843
|
-
return {
|
|
844
|
-
take() {
|
|
845
|
-
if (!plan || !plan.autoAttach) return false
|
|
846
|
-
if (plan.volatility === "volatile") return true
|
|
847
|
-
if (attachedOnce) return false
|
|
848
|
-
attachedOnce = true
|
|
849
|
-
return true
|
|
850
|
-
},
|
|
851
|
-
get volatility() { return plan ? plan.volatility : null }
|
|
852
|
-
}
|
|
853
|
-
}
|
|
854
|
-
|
|
855
854
|
// Safety net for a server that declares no size: shrink an oversized
|
|
856
855
|
// catalog to its names rather than dropping it, and hard-truncate anything
|
|
857
856
|
// that is not a recognised catalog shape.
|
|
@@ -917,13 +916,21 @@ export function resourceContextLines(context) {
|
|
|
917
916
|
return [ "", "Reference data — " + context.name + " (" + context.uri + "):", context.text ]
|
|
918
917
|
}
|
|
919
918
|
|
|
920
|
-
// What to attach on THIS turn.
|
|
921
|
-
//
|
|
922
|
-
//
|
|
923
|
-
|
|
924
|
-
|
|
919
|
+
// What to attach on THIS turn.
|
|
920
|
+
//
|
|
921
|
+
// `volatility` governs RE-FETCHING, not re-inclusion: a stable resource is
|
|
922
|
+
// read once and then re-used, but it stays in every turn's system prompt for
|
|
923
|
+
// as long as the conversation lasts. Showing it only on the first turn put
|
|
924
|
+
// the reference data in the one turn that could not use it — the model then
|
|
925
|
+
// spent several tool calls rediscovering what it had already been given,
|
|
926
|
+
// which costs more tokens than simply keeping it.
|
|
927
|
+
//
|
|
928
|
+
// Whether a resource is affordable at all is decided once, from sizeBytes,
|
|
929
|
+
// before it is ever fetched.
|
|
930
|
+
export async function resourceLinesForTurn({ plan, cached, endpoint, read, budgetBytes }) {
|
|
931
|
+
if (!plan || !plan.autoAttach) return { lines: [], cached }
|
|
925
932
|
|
|
926
|
-
if (plan
|
|
933
|
+
if (plan.volatility === "volatile") {
|
|
927
934
|
const fresh = await readResourceContext({
|
|
928
935
|
endpoint, uri: plan.uri, name: plan.name, read, budgetBytes
|
|
929
936
|
})
|
|
@@ -971,6 +978,16 @@ export function resolvePromptArguments(prompt, state) {
|
|
|
971
978
|
return { args, missing }
|
|
972
979
|
}
|
|
973
980
|
|
|
981
|
+
// What a template will take from the page, and what it will have to ask for.
|
|
982
|
+
// Shown to a first-time visitor so the offer is concrete rather than a bare
|
|
983
|
+
// verb: they can see that the text box is empty before they click.
|
|
984
|
+
export function promptArgumentSummary(prompt, state) {
|
|
985
|
+
return ((prompt && prompt.arguments) || []).map((arg) => {
|
|
986
|
+
const value = promptArgFromState(arg.name, state)
|
|
987
|
+
return { name: arg.name, value, filled: value.length > 0, required: !!arg.required }
|
|
988
|
+
})
|
|
989
|
+
}
|
|
990
|
+
|
|
974
991
|
export function promptButtonProps(prompt) {
|
|
975
992
|
return {
|
|
976
993
|
label: prompt.title || prompt.name,
|
|
@@ -181,3 +181,51 @@
|
|
|
181
181
|
max-height: 10em;
|
|
182
182
|
overflow-y: auto;
|
|
183
183
|
}
|
|
184
|
+
|
|
185
|
+
/* ---- "working" signals, ported from llm_meta_chat's chats.css ------------
|
|
186
|
+
*
|
|
187
|
+
* The wait is often long on local models — a tool round re-processes the
|
|
188
|
+
* whole prompt plus the tool results before the first token — so a still
|
|
189
|
+
* label reads as a hang. Same class names as the chat app, so the two
|
|
190
|
+
* surfaces behave identically. */
|
|
191
|
+
.message-role .role-spinner {
|
|
192
|
+
display: inline-block;
|
|
193
|
+
animation: role-working-spin 1.8s linear infinite;
|
|
194
|
+
transform-origin: 50% 50%;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
@keyframes role-working-spin {
|
|
198
|
+
from { transform: rotate(0deg); }
|
|
199
|
+
to { transform: rotate(360deg); }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* Staggered three-dot indicator on the reasoning summary while thinking is
|
|
203
|
+
* still streaming. Hidden unless .thinking-active is present. */
|
|
204
|
+
.thinking-dots {
|
|
205
|
+
display: none;
|
|
206
|
+
margin-left: 0.35em;
|
|
207
|
+
letter-spacing: 0.15em;
|
|
208
|
+
font-weight: bold;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.message-thinking.thinking-active .thinking-dots { display: inline-block; }
|
|
212
|
+
|
|
213
|
+
.message-thinking.thinking-active .thinking-dots span {
|
|
214
|
+
display: inline-block;
|
|
215
|
+
opacity: 0.25;
|
|
216
|
+
animation: thinking-dot 1.4s ease-in-out infinite;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.message-thinking.thinking-active .thinking-dots span:nth-child(2) { animation-delay: 0.2s; }
|
|
220
|
+
.message-thinking.thinking-active .thinking-dots span:nth-child(3) { animation-delay: 0.4s; }
|
|
221
|
+
|
|
222
|
+
@keyframes thinking-dot {
|
|
223
|
+
0%, 60%, 100% { opacity: 0.25; }
|
|
224
|
+
30% { opacity: 1; }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/* Respect a reader's motion preference: keep the label, drop the movement. */
|
|
228
|
+
@media (prefers-reduced-motion: reduce) {
|
|
229
|
+
.message-role .role-spinner,
|
|
230
|
+
.message-thinking.thinking-active .thinking-dots span { animation: none; }
|
|
231
|
+
}
|
|
@@ -35,7 +35,11 @@ module LlmMetaWidget
|
|
|
35
35
|
# anon" (all Ollama models / all public_to_anonymous MCP servers).
|
|
36
36
|
# Pass arrays to curate.
|
|
37
37
|
models: nil, # e.g. ["qwen3-6-35b-fast", "qwen3-6-35b-no-think"]
|
|
38
|
-
hub_tools: nil
|
|
38
|
+
hub_tools: nil, # e.g. ["togomcp", "pubdictionaries"] — MCP server names
|
|
39
|
+
# First thing a visitor sees when the panel opens, above the offered
|
|
40
|
+
# prompt templates. nil → a generic line. A blank panel tells a
|
|
41
|
+
# first-time visitor nothing about what the assistant can do for them.
|
|
42
|
+
greeting: nil
|
|
39
43
|
}.freeze
|
|
40
44
|
|
|
41
45
|
def llm_meta_widget(base_url:, model:, **overrides)
|
|
@@ -268,6 +268,59 @@
|
|
|
268
268
|
border-radius: 8px;
|
|
269
269
|
padding: 8px;
|
|
270
270
|
}
|
|
271
|
+
#llm-meta-widget-chat .message-content details > summary {
|
|
272
|
+
cursor: pointer;
|
|
273
|
+
font-weight: 600;
|
|
274
|
+
}
|
|
275
|
+
#llm-meta-widget-chat .lmw-sent-text {
|
|
276
|
+
margin-top: 6px;
|
|
277
|
+
font-size: 12px;
|
|
278
|
+
opacity: 0.8;
|
|
279
|
+
white-space: pre-wrap;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/* Opening state: greeting + the offers themselves. Shown until the first
|
|
283
|
+
* real turn, and again after Clear. */
|
|
284
|
+
#llm-meta-widget-chat .lmw-welcome { padding: 4px 2px 2px; }
|
|
285
|
+
#llm-meta-widget-chat .lmw-welcome-hello {
|
|
286
|
+
margin: 0 0 12px;
|
|
287
|
+
font-size: 14px;
|
|
288
|
+
line-height: 1.5;
|
|
289
|
+
color: #374151;
|
|
290
|
+
}
|
|
291
|
+
#llm-meta-widget-chat .lmw-welcome-card {
|
|
292
|
+
background-color: #f9fafb;
|
|
293
|
+
border: 1px solid #e5e7eb;
|
|
294
|
+
border-radius: 8px;
|
|
295
|
+
padding: 10px 12px;
|
|
296
|
+
margin-bottom: 8px;
|
|
297
|
+
}
|
|
298
|
+
#llm-meta-widget-chat .lmw-welcome-start {
|
|
299
|
+
background-color: #eff6ff;
|
|
300
|
+
color: #1d4ed8;
|
|
301
|
+
border: 1px solid #bfdbfe;
|
|
302
|
+
border-radius: 999px;
|
|
303
|
+
padding: 4px 12px;
|
|
304
|
+
font-size: 13px;
|
|
305
|
+
font-family: inherit;
|
|
306
|
+
cursor: pointer;
|
|
307
|
+
}
|
|
308
|
+
#llm-meta-widget-chat .lmw-welcome-start:hover { background-color: #dbeafe; }
|
|
309
|
+
#llm-meta-widget-chat .lmw-welcome-why {
|
|
310
|
+
margin: 8px 0 0;
|
|
311
|
+
font-size: 13px;
|
|
312
|
+
line-height: 1.45;
|
|
313
|
+
color: #4b5563;
|
|
314
|
+
}
|
|
315
|
+
#llm-meta-widget-chat .lmw-welcome-slots {
|
|
316
|
+
margin: 8px 0 0;
|
|
317
|
+
padding-left: 16px;
|
|
318
|
+
font-size: 12px;
|
|
319
|
+
color: #6b7280;
|
|
320
|
+
}
|
|
321
|
+
#llm-meta-widget-chat .lmw-welcome-slots li { margin-bottom: 2px; }
|
|
322
|
+
#llm-meta-widget-chat .lmw-welcome-slots li.filled { color: #047857; }
|
|
323
|
+
|
|
271
324
|
/* Server-offered prompt templates. The row is display:none in markup and
|
|
272
325
|
* gets its display back only when a prompt actually exists, so the inline
|
|
273
326
|
* style and this rule have to agree on "flex". */
|
|
@@ -398,8 +451,8 @@
|
|
|
398
451
|
<script type="module">
|
|
399
452
|
import { runChatLoop, fetchMcpManifest, listMcpPrompts, getMcpPrompt,
|
|
400
453
|
listMcpResources, readMcpResource, promptMessagesToText,
|
|
401
|
-
loadHostResource,
|
|
402
|
-
promptButtonProps } from "<%= orchestrator_path %>";
|
|
454
|
+
loadHostResource, resourceLinesForTurn, resolvePromptArguments,
|
|
455
|
+
promptButtonProps, promptArgumentSummary } from "<%= orchestrator_path %>";
|
|
403
456
|
import { marked } from "/llm_meta_widget_assets/marked.esm.js";
|
|
404
457
|
|
|
405
458
|
// Standard prose settings — GFM (tables, autolinks, strikethrough),
|
|
@@ -411,6 +464,7 @@
|
|
|
411
464
|
var API_KEY_UUID = <%= api_key_uuid.to_json.html_safe %>;
|
|
412
465
|
var MODEL = <%= model.to_json.html_safe %>;
|
|
413
466
|
var ACTIONS_SCHEMA_ID = <%= actions_schema_id.to_json.html_safe %>;
|
|
467
|
+
var GREETING = <%= greeting.to_json.html_safe %>;
|
|
414
468
|
var STATE_GLOBAL = <%= state_global.to_json.html_safe %>;
|
|
415
469
|
var ACTIONS_GLOBAL = <%= actions_global.to_json.html_safe %>;
|
|
416
470
|
var REMOTE_TOOLS_SCHEMA_ID = <%= remote_tools_schema_id.to_json.html_safe %>;
|
|
@@ -755,7 +809,6 @@
|
|
|
755
809
|
var hostWideTools = [];
|
|
756
810
|
var hostWidePrompts = [];
|
|
757
811
|
var resourceContext = null; // payload of the host's reference resource
|
|
758
|
-
var resourceAttacher = null; // per-turn gate, built from the server's hint
|
|
759
812
|
var resourcePlan = null; // the pre-flight decision, kept so Clear can re-arm the gate
|
|
760
813
|
|
|
761
814
|
// Beyond ~2k tokens a reference resource starts crowding out the
|
|
@@ -763,9 +816,6 @@
|
|
|
763
816
|
// is 218 dictionaries / ~35KB / ~10k tokens.
|
|
764
817
|
var RESOURCE_BUDGET_BYTES = 8000;
|
|
765
818
|
|
|
766
|
-
// Set when a server declared a resource we deliberately did not attach,
|
|
767
|
-
// so the reason is inspectable rather than a silent absence.
|
|
768
|
-
var resourceSkip = null;
|
|
769
819
|
|
|
770
820
|
var wellKnownReady = (async function() {
|
|
771
821
|
var urls = WELL_KNOWN_URLS === null
|
|
@@ -799,7 +849,6 @@
|
|
|
799
849
|
list: listMcpResources,
|
|
800
850
|
read: readMcpResource,
|
|
801
851
|
onSkip: function(plan) {
|
|
802
|
-
resourceSkip = plan;
|
|
803
852
|
console.info("[llm_meta_widget] not attaching " + plan.uri +
|
|
804
853
|
" (" + plan.reason + ", sizeBytes=" + plan.sizeBytes + ")");
|
|
805
854
|
}
|
|
@@ -808,13 +857,13 @@
|
|
|
808
857
|
// per turn instead — so the plan, not the payload, is what says
|
|
809
858
|
// whether this endpoint offered anything worth attaching.
|
|
810
859
|
if (loaded && loaded.plan && loaded.plan.fetch) {
|
|
811
|
-
resourceContext
|
|
812
|
-
resourcePlan
|
|
813
|
-
resourceAttacher = createResourceAttacher(loaded.plan);
|
|
860
|
+
resourceContext = loaded.context;
|
|
861
|
+
resourcePlan = loaded.plan;
|
|
814
862
|
}
|
|
815
863
|
}
|
|
816
864
|
}
|
|
817
865
|
renderPromptButtons();
|
|
866
|
+
renderWelcome();
|
|
818
867
|
})();
|
|
819
868
|
|
|
820
869
|
// ---- server-offered prompt templates --------------------------------
|
|
@@ -844,6 +893,66 @@
|
|
|
844
893
|
promptsEl.style.display = "";
|
|
845
894
|
}
|
|
846
895
|
|
|
896
|
+
// A blank panel tells a first-time visitor nothing. Open with a greeting
|
|
897
|
+
// and the offers themselves — each template showing what it will take from
|
|
898
|
+
// the page and what it will ask for — so the assistant is the page's way
|
|
899
|
+
// in rather than a box you must already know how to talk to.
|
|
900
|
+
function renderWelcome() {
|
|
901
|
+
if (!historyEl || historyEl.querySelector(".message")) return;
|
|
902
|
+
historyEl.textContent = "";
|
|
903
|
+
|
|
904
|
+
var box = document.createElement("div");
|
|
905
|
+
box.className = "lmw-welcome";
|
|
906
|
+
|
|
907
|
+
var hello = document.createElement("p");
|
|
908
|
+
hello.className = "lmw-welcome-hello";
|
|
909
|
+
hello.textContent = GREETING ||
|
|
910
|
+
"Hi — I can work this page for you. Tell me what you need in your own words" +
|
|
911
|
+
(hostWidePrompts.length ? ", or start with one of these:" : ".");
|
|
912
|
+
box.appendChild(hello);
|
|
913
|
+
|
|
914
|
+
var state = window[STATE_GLOBAL] || {};
|
|
915
|
+
hostWidePrompts.forEach(function(prompt) {
|
|
916
|
+
var props = promptButtonProps(prompt);
|
|
917
|
+
var card = document.createElement("div");
|
|
918
|
+
card.className = "lmw-welcome-card";
|
|
919
|
+
|
|
920
|
+
var start = document.createElement("button");
|
|
921
|
+
start.type = "button";
|
|
922
|
+
start.className = "lmw-welcome-start";
|
|
923
|
+
start.textContent = props.label;
|
|
924
|
+
start.addEventListener("click", function() { runPromptTemplate(prompt, start); });
|
|
925
|
+
card.appendChild(start);
|
|
926
|
+
|
|
927
|
+
if (prompt.description) {
|
|
928
|
+
var why = document.createElement("p");
|
|
929
|
+
why.className = "lmw-welcome-why";
|
|
930
|
+
why.textContent = prompt.description;
|
|
931
|
+
card.appendChild(why);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// Name the placeholders, filled or not, so the offer is concrete:
|
|
935
|
+
// a newcomer can see the text box is empty before clicking.
|
|
936
|
+
var summary = promptArgumentSummary(prompt, state);
|
|
937
|
+
if (summary.length) {
|
|
938
|
+
var slots = document.createElement("ul");
|
|
939
|
+
slots.className = "lmw-welcome-slots";
|
|
940
|
+
summary.forEach(function(slot) {
|
|
941
|
+
var li = document.createElement("li");
|
|
942
|
+
li.className = slot.filled ? "filled" : "empty";
|
|
943
|
+
li.textContent = slot.filled
|
|
944
|
+
? slot.name + ": " + (slot.value.length > 60 ? slot.value.slice(0, 60) + "…" : slot.value)
|
|
945
|
+
: slot.name + ": not set yet — I'll ask, or work it out";
|
|
946
|
+
slots.appendChild(li);
|
|
947
|
+
});
|
|
948
|
+
card.appendChild(slots);
|
|
949
|
+
}
|
|
950
|
+
box.appendChild(card);
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
historyEl.appendChild(box);
|
|
954
|
+
}
|
|
955
|
+
|
|
847
956
|
async function runPromptTemplate(prompt, button) {
|
|
848
957
|
var resolved = resolvePromptArguments(prompt, window[STATE_GLOBAL] || {});
|
|
849
958
|
var args = resolved.args;
|
|
@@ -863,6 +972,7 @@
|
|
|
863
972
|
var text = promptMessagesToText(result);
|
|
864
973
|
if (!text) { appendTurn("error", "The server returned an empty prompt."); return; }
|
|
865
974
|
inputEl.value = text;
|
|
975
|
+
pendingTurnLabel = promptButtonProps(prompt).label;
|
|
866
976
|
if (typeof formEl.requestSubmit === "function") formEl.requestSubmit();
|
|
867
977
|
else formEl.dispatchEvent(new Event("submit", { cancelable: true }));
|
|
868
978
|
} catch (e) {
|
|
@@ -872,13 +982,22 @@
|
|
|
872
982
|
}
|
|
873
983
|
}
|
|
874
984
|
|
|
875
|
-
|
|
985
|
+
// Set just before a template submits, so its turn is labelled by what the
|
|
986
|
+
// user actually did — "Annotate text" — instead of showing a paragraph of
|
|
987
|
+
// server-written instructions as though they had typed it. The instructions
|
|
988
|
+
// stay one click away rather than hidden: what was sent is what is shown.
|
|
989
|
+
var pendingTurnLabel = null;
|
|
990
|
+
|
|
991
|
+
function appendTurn(role, text, turnLabel) {
|
|
876
992
|
// Class names mirror llm_meta_chat's chats/_message.html.erb —
|
|
877
993
|
// `.message.<role>`, `.message-role`, `.message-content` — so the
|
|
878
994
|
// shared conversation.css styles apply directly (see the <link>
|
|
879
995
|
// above). The .lmw-* prefix is reserved for widget-CHROME classes
|
|
880
996
|
// (header, clear button, scroll region, input area) that aren't
|
|
881
997
|
// part of the shared conversation surface.
|
|
998
|
+
var welcome = historyEl.querySelector(".lmw-welcome");
|
|
999
|
+
if (welcome) welcome.remove();
|
|
1000
|
+
|
|
882
1001
|
var div = document.createElement("div");
|
|
883
1002
|
div.className = "message " + role;
|
|
884
1003
|
var label = document.createElement("div");
|
|
@@ -891,12 +1010,25 @@
|
|
|
891
1010
|
// content is rendered as markdown but only after the assistant's
|
|
892
1011
|
// text is streamed in via renderMarkdownInto — this appendTurn
|
|
893
1012
|
// creates the empty container.
|
|
894
|
-
|
|
1013
|
+
if (turnLabel) {
|
|
1014
|
+
var details = document.createElement("details");
|
|
1015
|
+
var summary = document.createElement("summary");
|
|
1016
|
+
summary.textContent = turnLabel;
|
|
1017
|
+
var full = document.createElement("div");
|
|
1018
|
+
full.className = "lmw-sent-text";
|
|
1019
|
+
full.textContent = text;
|
|
1020
|
+
details.appendChild(summary);
|
|
1021
|
+
details.appendChild(full);
|
|
1022
|
+
body.appendChild(details);
|
|
1023
|
+
} else {
|
|
1024
|
+
body.textContent = text;
|
|
1025
|
+
}
|
|
895
1026
|
div.appendChild(label);
|
|
896
1027
|
div.appendChild(document.createTextNode(" "));
|
|
897
1028
|
div.appendChild(body);
|
|
898
1029
|
historyEl.appendChild(div);
|
|
899
1030
|
historyEl.scrollTop = historyEl.scrollHeight;
|
|
1031
|
+
body.roleLabel = label; // so a turn in flight can show it is working
|
|
900
1032
|
return body;
|
|
901
1033
|
}
|
|
902
1034
|
|
|
@@ -921,8 +1053,29 @@
|
|
|
921
1053
|
return role;
|
|
922
1054
|
}
|
|
923
1055
|
|
|
1056
|
+
// While a turn is in flight the assistant's label becomes a turning gear.
|
|
1057
|
+
// Same markup and class names as llm_meta_chat's message_stream_controller,
|
|
1058
|
+
// so the shared conversation.css drives both. Without it there is no sign
|
|
1059
|
+
// whether the assistant is still working or has quietly stopped — and on a
|
|
1060
|
+
// local model a tool round can take a long time before the first token.
|
|
1061
|
+
function markWorking(label) {
|
|
1062
|
+
if (!label || label.classList.contains("is-working")) return;
|
|
1063
|
+
label.innerHTML = '<span class="role-spinner" aria-hidden="true">\u2699\uFE0F</span> Working…';
|
|
1064
|
+
label.classList.add("is-working");
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function markDone(label) {
|
|
1068
|
+
if (!label || !label.classList.contains("is-working")) return;
|
|
1069
|
+
label.classList.remove("is-working");
|
|
1070
|
+
label.textContent = roleLabel("assistant");
|
|
1071
|
+
}
|
|
1072
|
+
|
|
924
1073
|
var currentThinkingBlock = null;
|
|
925
1074
|
var currentThinkingBody = null;
|
|
1075
|
+
// The assistant bubble of the turn in flight. Reasoning belongs ABOVE that
|
|
1076
|
+
// bubble's content, the way llm_meta_chat places it — appending it to the
|
|
1077
|
+
// transcript instead left it below the answer it was reasoning towards.
|
|
1078
|
+
var activeAssistantBody = null;
|
|
926
1079
|
|
|
927
1080
|
function ensureThinkingBlock() {
|
|
928
1081
|
if (currentThinkingBody) return currentThinkingBody;
|
|
@@ -931,13 +1084,26 @@
|
|
|
931
1084
|
var details = document.createElement("details");
|
|
932
1085
|
details.className = "message-thinking";
|
|
933
1086
|
details.open = true;
|
|
1087
|
+
details.classList.add("thinking-active");
|
|
934
1088
|
var summary = document.createElement("summary");
|
|
935
1089
|
summary.textContent = "🤔 thinking…";
|
|
1090
|
+
var dots = document.createElement("span");
|
|
1091
|
+
dots.className = "thinking-dots";
|
|
1092
|
+
for (var i = 0; i < 3; i++) {
|
|
1093
|
+
var dot = document.createElement("span");
|
|
1094
|
+
dot.textContent = ".";
|
|
1095
|
+
dots.appendChild(dot);
|
|
1096
|
+
}
|
|
1097
|
+
summary.appendChild(dots);
|
|
936
1098
|
var body = document.createElement("div");
|
|
937
1099
|
body.className = "message-thinking-content";
|
|
938
1100
|
details.appendChild(summary);
|
|
939
1101
|
details.appendChild(body);
|
|
940
|
-
|
|
1102
|
+
if (activeAssistantBody && activeAssistantBody.parentNode) {
|
|
1103
|
+
activeAssistantBody.parentNode.insertBefore(details, activeAssistantBody);
|
|
1104
|
+
} else {
|
|
1105
|
+
historyEl.appendChild(details);
|
|
1106
|
+
}
|
|
941
1107
|
historyEl.scrollTop = historyEl.scrollHeight;
|
|
942
1108
|
currentThinkingBlock = details;
|
|
943
1109
|
currentThinkingBody = body;
|
|
@@ -946,6 +1112,7 @@
|
|
|
946
1112
|
|
|
947
1113
|
function collapseThinkingBlock() {
|
|
948
1114
|
if (currentThinkingBlock) {
|
|
1115
|
+
currentThinkingBlock.classList.remove("thinking-active");
|
|
949
1116
|
currentThinkingBlock.open = false;
|
|
950
1117
|
var summary = currentThinkingBlock.querySelector("summary");
|
|
951
1118
|
if (summary) summary.textContent = "🤔 thinking (finished)";
|
|
@@ -970,25 +1137,21 @@
|
|
|
970
1137
|
"RULES for tool use:",
|
|
971
1138
|
"1. If the user's question can be answered from the Current page state below, answer directly with a plain-text response — do NOT invoke a tool.",
|
|
972
1139
|
"2. If the user requests a state change, or needs information not in the page state, invoke the matching tool via a function call. Do not describe your intent in text without actually invoking (a textual promise like \"I will add X\" is a failure).",
|
|
973
|
-
"3. After a tool returns a result,
|
|
974
|
-
"4. NEVER
|
|
1140
|
+
"3. After a tool returns a result, use it: either take the next step the task needs, or — if the task is done — answer in plain text. Do not stop silently after a tool call.",
|
|
1141
|
+
"4. NEVER repeat a call you have already made with the same or similar arguments — its result is already in the conversation history.",
|
|
975
1142
|
"",
|
|
976
1143
|
"Current page state:",
|
|
977
1144
|
JSON.stringify(currentPageState(), null, 2)
|
|
978
1145
|
].concat(resourceLines || []).join("\n");
|
|
979
1146
|
}
|
|
980
1147
|
|
|
981
|
-
//
|
|
982
|
-
//
|
|
983
|
-
//
|
|
984
|
-
//
|
|
985
|
-
// Asking consumes the turn, and a volatile resource is re-read here rather
|
|
986
|
-
// than re-using the boot-time copy — so this runs once per send, before
|
|
987
|
-
// the system prompt is built.
|
|
1148
|
+
// Runs once per send, before the system prompt is built: a volatile
|
|
1149
|
+
// resource is re-read here, a stable one re-uses the copy taken at boot.
|
|
1150
|
+
// Either way it is attached to every turn — the server's hint governs
|
|
1151
|
+
// re-FETCHING; how often to include it is this client's call.
|
|
988
1152
|
async function resourceLinesForThisTurn() {
|
|
989
1153
|
var turn = await resourceLinesForTurn({
|
|
990
1154
|
plan: resourcePlan,
|
|
991
|
-
attacher: resourceAttacher,
|
|
992
1155
|
cached: resourceContext,
|
|
993
1156
|
endpoint: resourcePlan && resourcePlan.endpoint,
|
|
994
1157
|
read: readMcpResource,
|
|
@@ -1006,9 +1169,7 @@
|
|
|
1006
1169
|
if (currentAbort) { try { currentAbort.abort(); } catch (e) { /* noop */ } }
|
|
1007
1170
|
conversation = [];
|
|
1008
1171
|
historyEl.innerHTML = "";
|
|
1009
|
-
|
|
1010
|
-
// again — the old boolean stayed latched and silently withheld it.
|
|
1011
|
-
if (resourcePlan) resourceAttacher = createResourceAttacher(resourcePlan);
|
|
1172
|
+
renderWelcome();
|
|
1012
1173
|
});
|
|
1013
1174
|
|
|
1014
1175
|
// Enter submits, Shift+Enter inserts a newline — matches llm_meta_chat's
|
|
@@ -1022,17 +1183,20 @@
|
|
|
1022
1183
|
}
|
|
1023
1184
|
});
|
|
1024
1185
|
|
|
1025
|
-
formEl.addEventListener("submit", async function(
|
|
1026
|
-
|
|
1186
|
+
formEl.addEventListener("submit", async function(event) {
|
|
1187
|
+
event.preventDefault();
|
|
1027
1188
|
var userText = inputEl.value.trim();
|
|
1028
1189
|
if (!userText) return;
|
|
1029
1190
|
inputEl.value = "";
|
|
1030
|
-
appendTurn("user", userText);
|
|
1191
|
+
appendTurn("user", userText, pendingTurnLabel);
|
|
1192
|
+
pendingTurnLabel = null;
|
|
1031
1193
|
|
|
1032
1194
|
if (currentAbort) { try { currentAbort.abort(); } catch (e) { /* noop */ } }
|
|
1033
1195
|
currentAbort = new AbortController();
|
|
1034
1196
|
|
|
1035
1197
|
var assistantBody = appendTurn("assistant", "");
|
|
1198
|
+
activeAssistantBody = assistantBody;
|
|
1199
|
+
markWorking(assistantBody.roleLabel);
|
|
1036
1200
|
var assistantMarkdown = ""; // accumulate raw markdown, re-render on each delta
|
|
1037
1201
|
|
|
1038
1202
|
try {
|
|
@@ -1058,7 +1222,16 @@
|
|
|
1058
1222
|
aiActions: window[ACTIONS_GLOBAL] || {},
|
|
1059
1223
|
maxRounds: MAX_ROUNDS,
|
|
1060
1224
|
signal: currentAbort.signal,
|
|
1225
|
+
onPhase: function(name) {
|
|
1226
|
+
// 'thinking' covers the long silence before the first
|
|
1227
|
+
// token; 'responding' means text is on its way.
|
|
1228
|
+
if (name === "responding") markDone(assistantBody.roleLabel);
|
|
1229
|
+
else markWorking(assistantBody.roleLabel);
|
|
1230
|
+
},
|
|
1061
1231
|
onRoundStart: function(roundIdx) {
|
|
1232
|
+
// A new round means more work: tool results are going back
|
|
1233
|
+
// to the model, which is the longest wait of all.
|
|
1234
|
+
markWorking(assistantBody.roleLabel);
|
|
1062
1235
|
// Loop mechanics are debugging info, not user-facing signal.
|
|
1063
1236
|
// Reuse the same assistant bubble across rounds — text just
|
|
1064
1237
|
// keeps streaming into it (accumulating markdown). Weaker
|
|
@@ -1075,6 +1248,7 @@
|
|
|
1075
1248
|
historyEl.scrollTop = historyEl.scrollHeight;
|
|
1076
1249
|
},
|
|
1077
1250
|
onTextDelta: function(delta) {
|
|
1251
|
+
markDone(assistantBody.roleLabel);
|
|
1078
1252
|
collapseThinkingBlock();
|
|
1079
1253
|
assistantMarkdown += delta;
|
|
1080
1254
|
renderMarkdownInto(assistantBody, assistantMarkdown);
|
|
@@ -1125,6 +1299,13 @@
|
|
|
1125
1299
|
appendTurn("error", err.message);
|
|
1126
1300
|
}
|
|
1127
1301
|
} finally {
|
|
1302
|
+
// Whatever happened — answered, aborted, threw, or returned
|
|
1303
|
+
// nothing at all — the turn is over and the label must stop
|
|
1304
|
+
// claiming otherwise. A spinner left running is a worse lie than
|
|
1305
|
+
// no spinner: it says "still working" about a turn that ended.
|
|
1306
|
+
markDone(assistantBody.roleLabel);
|
|
1307
|
+
collapseThinkingBlock();
|
|
1308
|
+
activeAssistantBody = null;
|
|
1128
1309
|
currentAbort = null;
|
|
1129
1310
|
}
|
|
1130
1311
|
});
|