llm_meta_widget 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6d7a58b9b553be09b66ca6e9aa01f8264a280bb725f80820ec3de506573701ae
4
- data.tar.gz: 7b171f0c86c5f45a6250975ab02da586d620602d8fc1bea95261dd963d17e67a
3
+ metadata.gz: 20aa5babd648e80015577ece5c815b37f6fe5e06d6136c91647f49831016833b
4
+ data.tar.gz: 8dfc055787110255fbf32368bc3f29cf11b3be178477dac9dadf08243a48d096
5
5
  SHA512:
6
- metadata.gz: e90d55856a37217816264730d1a65fd32af2ad4783ad0c1a09ed298fb5dfe35a618a1ca149bf77265aab346dba4437b960a98399e91b3b15695aa601ed9d2025
7
- data.tar.gz: 042b6872987730e708758fee7c6043ac27b0a83b27935e33f0751a910307cd901e746f28a2557dc54dfc32f63d5a4039f43cefcbc1952d93613cc59568e4c44a
6
+ metadata.gz: 2545397e740fefb805c8b8b3f9c90ca78814d71b2138f91a35afd38e1499f9bace74428faaf9160cbda4d9a5b51421a81f16f51df82c7ce88389d07acb97293d
7
+ data.tar.gz: a05e51a8b8e94a0153d5c7612901cba9ff2f31beb3142e656852cfa43c87b65ecc513d42c2a6a7a9b2a7ef6b0be3f240ec067bd468afd4b320684089cddfff3b
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
- task default: :test_js
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
- // Class 2: host-wide direct MCP JSON-RPC POST to the host's own endpoint
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. Locals (Class 3) are
494
- // fire-and-forget; unknowns can't be handled; only Class 2 + Class 1
495
- // execution produces tool results the LLM should see.
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. Asking consumes the turn, so the decision and
921
- // the fetch live together: a stable resource re-uses the copy read at boot,
922
- // a volatile one is read again right now.
923
- export async function resourceLinesForTurn({ plan, attacher, cached, endpoint, read, budgetBytes }) {
924
- if (!attacher || !attacher.take()) return { lines: [], cached }
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 && plan.volatility === "volatile") {
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 # e.g. ["togomcp", "pubdictionaries"] — MCP server names
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, createResourceAttacher, resourceLinesForTurn, resolvePromptArguments,
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 = loaded.context;
812
- resourcePlan = loaded.plan;
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
- function appendTurn(role, text) {
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
- body.textContent = text;
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,6 +1053,23 @@
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;
926
1075
 
@@ -931,8 +1080,17 @@
931
1080
  var details = document.createElement("details");
932
1081
  details.className = "message-thinking";
933
1082
  details.open = true;
1083
+ details.classList.add("thinking-active");
934
1084
  var summary = document.createElement("summary");
935
1085
  summary.textContent = "🤔 thinking…";
1086
+ var dots = document.createElement("span");
1087
+ dots.className = "thinking-dots";
1088
+ for (var i = 0; i < 3; i++) {
1089
+ var dot = document.createElement("span");
1090
+ dot.textContent = ".";
1091
+ dots.appendChild(dot);
1092
+ }
1093
+ summary.appendChild(dots);
936
1094
  var body = document.createElement("div");
937
1095
  body.className = "message-thinking-content";
938
1096
  details.appendChild(summary);
@@ -946,6 +1104,7 @@
946
1104
 
947
1105
  function collapseThinkingBlock() {
948
1106
  if (currentThinkingBlock) {
1107
+ currentThinkingBlock.classList.remove("thinking-active");
949
1108
  currentThinkingBlock.open = false;
950
1109
  var summary = currentThinkingBlock.querySelector("summary");
951
1110
  if (summary) summary.textContent = "🤔 thinking (finished)";
@@ -970,25 +1129,21 @@
970
1129
  "RULES for tool use:",
971
1130
  "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
1131
  "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, YOUR NEXT MESSAGE MUST BE A PLAIN-TEXT ANSWER using that result. DO NOT emit another tool call.",
974
- "4. NEVER call the SAME tool twice in a row with the same or similar arguments — its earlier result is already in the conversation history.",
1132
+ "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.",
1133
+ "4. NEVER repeat a call you have already made with the same or similar arguments — its result is already in the conversation history.",
975
1134
  "",
976
1135
  "Current page state:",
977
1136
  JSON.stringify(currentPageState(), null, 2)
978
1137
  ].concat(resourceLines || []).join("\n");
979
1138
  }
980
1139
 
981
- // Whether to attach, and how often, is the SERVER's call via the
982
- // extension's autoAttach and volatility fields, which default to
983
- // attach-once for a server that declares neither.
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.
1140
+ // Runs once per send, before the system prompt is built: a volatile
1141
+ // resource is re-read here, a stable one re-uses the copy taken at boot.
1142
+ // Either way it is attached to every turn — the server's hint governs
1143
+ // re-FETCHING; how often to include it is this client's call.
988
1144
  async function resourceLinesForThisTurn() {
989
1145
  var turn = await resourceLinesForTurn({
990
1146
  plan: resourcePlan,
991
- attacher: resourceAttacher,
992
1147
  cached: resourceContext,
993
1148
  endpoint: resourcePlan && resourcePlan.endpoint,
994
1149
  read: readMcpResource,
@@ -1006,9 +1161,7 @@
1006
1161
  if (currentAbort) { try { currentAbort.abort(); } catch (e) { /* noop */ } }
1007
1162
  conversation = [];
1008
1163
  historyEl.innerHTML = "";
1009
- // Clear starts a new conversation, so a 'once' resource is owed to it
1010
- // again — the old boolean stayed latched and silently withheld it.
1011
- if (resourcePlan) resourceAttacher = createResourceAttacher(resourcePlan);
1164
+ renderWelcome();
1012
1165
  });
1013
1166
 
1014
1167
  // Enter submits, Shift+Enter inserts a newline — matches llm_meta_chat's
@@ -1022,17 +1175,19 @@
1022
1175
  }
1023
1176
  });
1024
1177
 
1025
- formEl.addEventListener("submit", async function(e) {
1026
- e.preventDefault();
1178
+ formEl.addEventListener("submit", async function(event) {
1179
+ event.preventDefault();
1027
1180
  var userText = inputEl.value.trim();
1028
1181
  if (!userText) return;
1029
1182
  inputEl.value = "";
1030
- appendTurn("user", userText);
1183
+ appendTurn("user", userText, pendingTurnLabel);
1184
+ pendingTurnLabel = null;
1031
1185
 
1032
1186
  if (currentAbort) { try { currentAbort.abort(); } catch (e) { /* noop */ } }
1033
1187
  currentAbort = new AbortController();
1034
1188
 
1035
1189
  var assistantBody = appendTurn("assistant", "");
1190
+ markWorking(assistantBody.roleLabel);
1036
1191
  var assistantMarkdown = ""; // accumulate raw markdown, re-render on each delta
1037
1192
 
1038
1193
  try {
@@ -1058,7 +1213,16 @@
1058
1213
  aiActions: window[ACTIONS_GLOBAL] || {},
1059
1214
  maxRounds: MAX_ROUNDS,
1060
1215
  signal: currentAbort.signal,
1216
+ onPhase: function(name) {
1217
+ // 'thinking' covers the long silence before the first
1218
+ // token; 'responding' means text is on its way.
1219
+ if (name === "responding") markDone(assistantBody.roleLabel);
1220
+ else markWorking(assistantBody.roleLabel);
1221
+ },
1061
1222
  onRoundStart: function(roundIdx) {
1223
+ // A new round means more work: tool results are going back
1224
+ // to the model, which is the longest wait of all.
1225
+ markWorking(assistantBody.roleLabel);
1062
1226
  // Loop mechanics are debugging info, not user-facing signal.
1063
1227
  // Reuse the same assistant bubble across rounds — text just
1064
1228
  // keeps streaming into it (accumulating markdown). Weaker
@@ -1075,6 +1239,7 @@
1075
1239
  historyEl.scrollTop = historyEl.scrollHeight;
1076
1240
  },
1077
1241
  onTextDelta: function(delta) {
1242
+ markDone(assistantBody.roleLabel);
1078
1243
  collapseThinkingBlock();
1079
1244
  assistantMarkdown += delta;
1080
1245
  renderMarkdownInto(assistantBody, assistantMarkdown);
@@ -1125,6 +1290,12 @@
1125
1290
  appendTurn("error", err.message);
1126
1291
  }
1127
1292
  } finally {
1293
+ // Whatever happened — answered, aborted, threw, or returned
1294
+ // nothing at all — the turn is over and the label must stop
1295
+ // claiming otherwise. A spinner left running is a worse lie than
1296
+ // no spinner: it says "still working" about a turn that ended.
1297
+ markDone(assistantBody.roleLabel);
1298
+ collapseThinkingBlock();
1128
1299
  currentAbort = null;
1129
1300
  }
1130
1301
  });
@@ -1,3 +1,3 @@
1
1
  module LlmMetaWidget
2
- VERSION = "0.3.0"
2
+ VERSION = "0.4.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm_meta_widget
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - jdkim