llm_meta_widget 0.6.0 → 0.7.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.
@@ -220,7 +220,7 @@ function indexOfEither(str, a, b) {
220
220
  }
221
221
 
222
222
  // ---------------------------------------------------------------------------
223
- // runTurn — one LLM turn + fire-and-forget local dispatch
223
+ // runTurn — one LLM turn + local dispatch, results fed back
224
224
  // ---------------------------------------------------------------------------
225
225
  //
226
226
  // The Piece-B primitive: driven by `singleLlmCall`, then after `done` invokes
@@ -290,7 +290,7 @@ export async function dispatchLocalToolCalls(toolCalls, aiActions) {
290
290
  // 1. call `singleLlmCall` with merged local_tools (Class 2 + 3 schemas) +
291
291
  // tool_ids (Class 1 references)
292
292
  // 2. classify emitted tool_calls by name (aiActions → hostWide → remote → unknown)
293
- // 3. dispatch Class 3 fire-and-forget
293
+ // 3. dispatch Class 3 locally, keeping each outcome as a tool result
294
294
  // 4. dispatch Class 2 via direct MCP; Class 1 via meta-server proxy;
295
295
  // collect results in emission order
296
296
  // 5. if any round-trip results, append assistant-with-tool_calls turn and
@@ -458,7 +458,7 @@ export async function runChatLoop(opts) {
458
458
  }
459
459
  }
460
460
 
461
- // Class 3: locals — fire-and-forget
461
+ // Class 3: locals — dispatched here, round-tripped just below
462
462
  const localOut = await dispatchLocalToolCalls(localCalls, aiActions)
463
463
  allDispatched.push(...localOut.dispatched)
464
464
 
@@ -0,0 +1,363 @@
1
+ /* The chat panel's own styles. Extracted from _chat_panel.html.erb when the
2
+ panel became a custom element; bundled into the element so a non-Rails host
3
+ serves no stylesheet of its own. Conversation-surface styles (bubbles, role
4
+ labels, thinking blocks) stay in conversation.css. */
5
+ /* Widget-specific chrome — floating container, header, scroll region,
6
+ * input area. Everything BUBBLE / THINKING / ROLE-related lives in the
7
+ * shared conversation.css above. */
8
+ #llm-meta-widget-chat {
9
+ /* Positioned via JS on open (see setOpen()) so top/left are set in
10
+ * pixels — required for CSS `resize: both` to grow correctly
11
+ * (bottom/right anchoring blocks the resize handle from being
12
+ * dragged outward from viewport corner). */
13
+ position: fixed;
14
+ width: 40em; height: 55vh;
15
+ min-width: 24em; min-height: 16em;
16
+ max-width: calc(100vw - 2em); max-height: calc(100vh - 2em);
17
+ background: #ffffff;
18
+ border: 1px solid #e5e7eb;
19
+ border-radius: 8px;
20
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
21
+ padding: 10px;
22
+ z-index: 9999;
23
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
24
+ font-size: 14px;
25
+ color: #1f2937;
26
+ display: flex; flex-direction: column;
27
+ /* Native drag-to-resize handle appears at bottom-right when
28
+ * overflow != visible. overflow:hidden clips at widget edge; the
29
+ * inner .lmw-messages has its own scroll so no content is lost. */
30
+ resize: both;
31
+ overflow: hidden;
32
+ }
33
+ #llm-meta-widget-chat.lmw-collapsed { display: none; }
34
+
35
+ /* Collapsed-state toggle — a small floating chat-bubble button in the
36
+ * bottom-right corner. Always in the DOM; visible only when the chat
37
+ * panel is collapsed. Click to open the chat panel; the panel's × button
38
+ * collapses back to this. State persists across page loads via
39
+ * localStorage (see the JS below). */
40
+ #llm-meta-widget-toggle {
41
+ position: fixed; bottom: 1em; right: 1em;
42
+ width: 52px; height: 52px;
43
+ border-radius: 26px;
44
+ background-color: #3b82f6;
45
+ color: white;
46
+ border: none;
47
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
48
+ cursor: pointer;
49
+ z-index: 9999;
50
+ display: flex; align-items: center; justify-content: center;
51
+ transition: background-color 0.15s, transform 0.1s;
52
+ }
53
+ #llm-meta-widget-toggle:hover { background-color: #2563eb; }
54
+ #llm-meta-widget-toggle:active { transform: scale(0.96); }
55
+ #llm-meta-widget-toggle.lmw-hidden { display: none; }
56
+ #llm-meta-widget-toggle svg { display: block; }
57
+
58
+ /* Header hide/close: a subtle "−" button that collapses to the toggle
59
+ * bubble. Sits between the "clear" button and title. */
60
+ #llm-meta-widget-chat .lmw-hide {
61
+ background: none; border: none;
62
+ color: #6b7280;
63
+ font: inherit; font-size: 18px; line-height: 1;
64
+ cursor: pointer;
65
+ padding: 0 8px;
66
+ transition: color 0.15s;
67
+ }
68
+ #llm-meta-widget-chat .lmw-hide:hover { color: #1f2937; }
69
+ #llm-meta-widget-chat .lmw-header {
70
+ display: flex; justify-content: space-between; align-items: center;
71
+ gap: 8px;
72
+ padding-bottom: 8px; margin-bottom: 8px;
73
+ border-bottom: 2px solid #e5e7eb;
74
+ }
75
+ #llm-meta-widget-chat .lmw-header-right { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
76
+
77
+ /* Level-1 pickers row — matches chat.aibranch.org's input-container
78
+ * convention (attach / model / tools / settings in a horizontal row
79
+ * below the textarea). We only render model + tools for now. Uses
80
+ * relative positioning so the tools <details> panel can drop down
81
+ * as a floating overlay instead of pushing the input area around. */
82
+ #llm-meta-widget-chat .lmw-input-controls {
83
+ display: flex;
84
+ align-items: center;
85
+ gap: 6px;
86
+ margin-top: 6px;
87
+ position: relative;
88
+ min-height: 26px;
89
+ }
90
+ #llm-meta-widget-chat .lmw-title { font-weight: 600; color: #1f2937; }
91
+
92
+ /* Level-1 model picker — compact <select> next to the title.
93
+ * Populated by JS on widget open from GET /api/llms (anon path
94
+ * returns Ollama-only). Hidden entirely if enable_model_picker
95
+ * is false at the helper call site. */
96
+ #llm-meta-widget-chat .lmw-model-picker {
97
+ font: inherit; font-size: 12px;
98
+ padding: 3px 6px;
99
+ border: 1px solid #d1d5db;
100
+ border-radius: 4px;
101
+ background: #f9fafb;
102
+ color: #374151;
103
+ cursor: pointer;
104
+ max-width: 12em;
105
+ text-overflow: ellipsis;
106
+ }
107
+ #llm-meta-widget-chat .lmw-model-picker:focus {
108
+ outline: none;
109
+ border-color: #3b82f6;
110
+ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
111
+ }
112
+
113
+ /* Level-1 tools picker — inline chip in the input-controls row (same
114
+ * convention as chat.aibranch.org). The tools list drops down as a
115
+ * floating overlay above the input, so opening the panel doesn't
116
+ * push the textarea around. Populated by JS on widget open from
117
+ * GET /api/mcp_servers (anon path returns public_to_anonymous MCP
118
+ * servers with their tools inline). One checkbox per SERVER —
119
+ * toggling enables/disables ALL of its tools. */
120
+ #llm-meta-widget-chat .lmw-tools-picker {
121
+ position: relative; /* anchor for the absolutely-positioned list */
122
+ }
123
+ #llm-meta-widget-chat .lmw-tools-picker > summary {
124
+ list-style: none;
125
+ cursor: pointer;
126
+ padding: 3px 8px;
127
+ font-size: 12px;
128
+ color: #374151;
129
+ background: #f9fafb;
130
+ border: 1px solid #d1d5db;
131
+ border-radius: 4px;
132
+ display: inline-flex; align-items: center; gap: 6px;
133
+ }
134
+ #llm-meta-widget-chat .lmw-tools-picker > summary:hover { background: #f3f4f6; }
135
+ #llm-meta-widget-chat .lmw-tools-picker > summary::-webkit-details-marker { display: none; }
136
+ #llm-meta-widget-chat .lmw-tools-picker[open] > summary {
137
+ background: #eff6ff;
138
+ border-color: #93c5fd;
139
+ }
140
+ #llm-meta-widget-chat .lmw-tools-count {
141
+ display: inline-block;
142
+ background: #0ea5e9;
143
+ color: #fff;
144
+ font-size: 10px;
145
+ padding: 0 5px;
146
+ border-radius: 8px;
147
+ min-width: 14px;
148
+ text-align: center;
149
+ }
150
+ #llm-meta-widget-chat .lmw-tools-count.lmw-tools-count-zero {
151
+ background: #d1d5db;
152
+ color: #6b7280;
153
+ }
154
+ /* Floating dropdown panel — sits above the input area when open so
155
+ * expanding never shifts the textarea. Bottom-anchored so it opens
156
+ * UPWARD (natural direction since we're at the panel's bottom). */
157
+ #llm-meta-widget-chat .lmw-tools-list {
158
+ position: absolute;
159
+ bottom: calc(100% + 4px);
160
+ left: 0;
161
+ min-width: 220px;
162
+ max-height: 12em;
163
+ overflow-y: auto;
164
+ background: #ffffff;
165
+ border: 1px solid #d1d5db;
166
+ border-radius: 6px;
167
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.10);
168
+ padding: 4px 0;
169
+ z-index: 10;
170
+ }
171
+ #llm-meta-widget-chat .lmw-tools-item {
172
+ display: flex; align-items: center; gap: 6px;
173
+ padding: 4px 10px;
174
+ font-size: 12px;
175
+ color: #374151;
176
+ cursor: pointer;
177
+ }
178
+ #llm-meta-widget-chat .lmw-tools-item:hover { background: #eff6ff; }
179
+ #llm-meta-widget-chat .lmw-tools-item input[type="checkbox"] { margin: 0; }
180
+ #llm-meta-widget-chat .lmw-tools-empty {
181
+ padding: 6px 10px;
182
+ font-size: 12px;
183
+ color: #94a3b8;
184
+ font-style: italic;
185
+ }
186
+ /* Two-level tool selection — mirrors chat.aibranch.org's tool_selector.
187
+ * Server row (bulk checkbox + name + count + caret) is clickable to
188
+ * expand its children. Individual tool rows are indented one level. */
189
+ #llm-meta-widget-chat .lmw-tools-server + .lmw-tools-server {
190
+ border-top: 1px solid #f1f5f9;
191
+ }
192
+ #llm-meta-widget-chat .lmw-tools-server-row {
193
+ display: flex; align-items: center; gap: 6px;
194
+ padding: 4px 10px;
195
+ font-size: 12px;
196
+ font-weight: 500;
197
+ color: #1f2937;
198
+ cursor: pointer;
199
+ }
200
+ #llm-meta-widget-chat .lmw-tools-server-row:hover { background: #eff6ff; }
201
+ #llm-meta-widget-chat .lmw-tools-server-name { flex: 1; }
202
+ #llm-meta-widget-chat .lmw-tools-server-caret {
203
+ color: #94a3b8;
204
+ font-size: 10px;
205
+ line-height: 1;
206
+ flex-shrink: 0;
207
+ }
208
+ #llm-meta-widget-chat .lmw-tools-item-child {
209
+ padding-left: 26px; /* align tool checkboxes past the server bulk checkbox */
210
+ font-size: 12px;
211
+ color: #4b5563;
212
+ }
213
+ #llm-meta-widget-chat .lmw-clear {
214
+ background: #ef4444; color: white; border: none;
215
+ padding: 4px 10px; border-radius: 6px; cursor: pointer;
216
+ font: inherit; font-size: 12px;
217
+ transition: background-color 0.15s;
218
+ }
219
+ #llm-meta-widget-chat .lmw-clear:hover { background: #dc2626; }
220
+
221
+ #llm-meta-widget-chat .lmw-messages {
222
+ flex: 1; overflow-y: auto;
223
+ padding: 10px;
224
+ background-color: #f9fafb;
225
+ border-radius: 8px;
226
+ margin-bottom: 8px;
227
+ min-height: 6em;
228
+ /* No max-height — messages grows with the widget (which the user
229
+ * can drag to any size). A hard cap here would leave an ugly gap
230
+ * between the messages area and the input on tall widget sizes. */
231
+ }
232
+ #llm-meta-widget-chat .lmw-messages::-webkit-scrollbar { width: 8px; }
233
+ #llm-meta-widget-chat .lmw-messages::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 4px; }
234
+ #llm-meta-widget-chat .lmw-messages::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
235
+ #llm-meta-widget-chat .lmw-messages::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
236
+
237
+ /* Input area — mirrors llm_meta_chat's .chat-input-container +
238
+ * .chat-input + .send-button so it reads as the same UI. Multi-line
239
+ * textarea with an absolutely-positioned paper-plane send button
240
+ * (SVG inlined so no external icon-font dep). */
241
+ #llm-meta-widget-chat .lmw-input-container {
242
+ background-color: white;
243
+ border: 2px solid #e5e7eb;
244
+ border-radius: 8px;
245
+ padding: 8px;
246
+ }
247
+ #llm-meta-widget-chat .message-content details > summary {
248
+ cursor: pointer;
249
+ font-weight: 600;
250
+ }
251
+ #llm-meta-widget-chat .lmw-sent-text {
252
+ margin-top: 6px;
253
+ font-size: 12px;
254
+ opacity: 0.8;
255
+ white-space: pre-wrap;
256
+ }
257
+
258
+ /* Opening state: greeting + the offers themselves. Shown until the first
259
+ * real turn, and again after Clear. */
260
+ #llm-meta-widget-chat .lmw-welcome { padding: 4px 2px 2px; }
261
+ #llm-meta-widget-chat .lmw-welcome-hello {
262
+ margin: 0 0 12px;
263
+ font-size: 14px;
264
+ line-height: 1.5;
265
+ color: #374151;
266
+ }
267
+ #llm-meta-widget-chat .lmw-welcome-card {
268
+ background-color: #f9fafb;
269
+ border: 1px solid #e5e7eb;
270
+ border-radius: 8px;
271
+ padding: 10px 12px;
272
+ margin-bottom: 8px;
273
+ }
274
+ #llm-meta-widget-chat .lmw-welcome-start {
275
+ background-color: #eff6ff;
276
+ color: #1d4ed8;
277
+ border: 1px solid #bfdbfe;
278
+ border-radius: 999px;
279
+ padding: 4px 12px;
280
+ font-size: 13px;
281
+ font-family: inherit;
282
+ cursor: pointer;
283
+ }
284
+ #llm-meta-widget-chat .lmw-welcome-start:hover { background-color: #dbeafe; }
285
+ #llm-meta-widget-chat .lmw-welcome-why {
286
+ margin: 8px 0 0;
287
+ font-size: 13px;
288
+ line-height: 1.45;
289
+ color: #4b5563;
290
+ }
291
+ #llm-meta-widget-chat .lmw-welcome-slots {
292
+ margin: 8px 0 0;
293
+ padding-left: 16px;
294
+ font-size: 12px;
295
+ color: #6b7280;
296
+ }
297
+ #llm-meta-widget-chat .lmw-welcome-slots li { margin-bottom: 2px; }
298
+ #llm-meta-widget-chat .lmw-welcome-slots li.filled { color: #047857; }
299
+
300
+ /* Server-offered prompt templates. The row is display:none in markup and
301
+ * gets its display back only when a prompt actually exists, so the inline
302
+ * style and this rule have to agree on "flex". */
303
+ #llm-meta-widget-chat .lmw-prompts {
304
+ display: flex;
305
+ flex-wrap: wrap;
306
+ gap: 6px;
307
+ margin-bottom: 8px;
308
+ }
309
+ #llm-meta-widget-chat .lmw-prompt {
310
+ background-color: #eff6ff;
311
+ color: #1d4ed8;
312
+ border: 1px solid #bfdbfe;
313
+ border-radius: 999px;
314
+ padding: 4px 12px;
315
+ font-size: 12px;
316
+ font-family: inherit;
317
+ cursor: pointer;
318
+ transition: background-color 0.2s, border-color 0.2s;
319
+ }
320
+ #llm-meta-widget-chat .lmw-prompt:hover:not(:disabled) {
321
+ background-color: #dbeafe;
322
+ border-color: #93c5fd;
323
+ }
324
+ #llm-meta-widget-chat .lmw-prompt:disabled { opacity: 0.5; cursor: default; }
325
+ #llm-meta-widget-chat .lmw-form { display: flex; flex-direction: column; gap: 8px; }
326
+ #llm-meta-widget-chat .lmw-input-wrapper { position: relative; flex: 1; }
327
+ #llm-meta-widget-chat .lmw-input {
328
+ width: 100%;
329
+ padding: 10px 48px 10px 10px;
330
+ border: 1px solid #d1d5db;
331
+ border-radius: 6px;
332
+ font-size: 14px;
333
+ font-family: inherit;
334
+ resize: vertical;
335
+ min-height: 44px;
336
+ box-sizing: border-box;
337
+ transition: border-color 0.2s, box-shadow 0.2s;
338
+ }
339
+ #llm-meta-widget-chat .lmw-input:focus {
340
+ outline: none;
341
+ border-color: #3b82f6;
342
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
343
+ }
344
+ #llm-meta-widget-chat .lmw-send {
345
+ position: absolute;
346
+ bottom: 6px;
347
+ right: 6px;
348
+ background-color: #3b82f6;
349
+ color: white;
350
+ border: none;
351
+ width: 32px;
352
+ height: 32px;
353
+ padding: 0;
354
+ border-radius: 6px;
355
+ cursor: pointer;
356
+ display: flex;
357
+ align-items: center;
358
+ justify-content: center;
359
+ transition: background-color 0.15s, opacity 0.15s;
360
+ }
361
+ #llm-meta-widget-chat .lmw-send:hover { background-color: #2563eb; }
362
+ #llm-meta-widget-chat .lmw-send:disabled { opacity: 0.5; cursor: not-allowed; }
363
+ #llm-meta-widget-chat .lmw-send svg { display: block; }
@@ -16,6 +16,13 @@ module LlmMetaWidget
16
16
  # side effects and no authenticated context to protect.
17
17
  skip_forgery_protection
18
18
 
19
+ # The bundled element. Generated by `npm run build`, committed, and shipped
20
+ # in the gem — a gem cannot run a build step on install, so the artefact has
21
+ # to be in the package. `npm test` fails if it drifts from its sources.
22
+ ELEMENT_PATH = File.expand_path("../../assets/javascripts/llm_meta_widget/llm-meta-widget.js", __dir__).freeze
23
+ ELEMENT_BODY = File.read(ELEMENT_PATH).freeze
24
+ ELEMENT_ETAG = %("#{Digest::SHA1.hexdigest(ELEMENT_BODY)}").freeze
25
+
19
26
  ORCHESTRATOR_PATH = File.expand_path("../../assets/javascripts/llm_meta_widget/orchestrator.js", __dir__).freeze
20
27
  ORCHESTRATOR_BODY = File.read(ORCHESTRATOR_PATH).freeze
21
28
  ORCHESTRATOR_ETAG = %("#{Digest::SHA1.hexdigest(ORCHESTRATOR_BODY)}").freeze
@@ -33,6 +40,10 @@ module LlmMetaWidget
33
40
  MARKED_BODY = File.read(MARKED_PATH).freeze
34
41
  MARKED_ETAG = %("#{Digest::SHA1.hexdigest(MARKED_BODY)}").freeze
35
42
 
43
+ def element
44
+ serve_asset(ELEMENT_BODY, ELEMENT_ETAG, "text/javascript")
45
+ end
46
+
36
47
  def orchestrator
37
48
  serve_asset(ORCHESTRATOR_BODY, ORCHESTRATOR_ETAG, "text/javascript")
38
49
  end
@@ -1,16 +1,23 @@
1
1
  module LlmMetaWidget
2
2
  # Renders the client-orchestrated chat widget on the current page.
3
3
  #
4
- # <%= llm_meta_widget(base_url: "https://llmbranch.dbcls.jp",
5
- # model: "qwen3-6-35b-fast") %>
4
+ # <%= llm_meta_widget(llm_url: "https://llmbranch.dbcls.jp",
5
+ # tool_hub_url: "https://llmbranch.dbcls.jp",
6
+ # model: "qwen3-6-35b-fast") %>
7
+ #
8
+ # `llm_url` answers the chat; `tool_hub_url` names an llm_meta_server whose
9
+ # registered MCP tools to offer and is optional — absent means none, which
10
+ # does not disable page actions or the host's own .well-known/mcp.json.
6
11
  #
7
12
  # The host page must ALSO provide:
8
13
  # - <script type="application/json" id="ai-actions">…</script> — local
9
14
  # action schemas (name/description/input_schema each).
10
15
  # - window.aiState — reader functions called each turn to build the
11
16
  # system prompt with current page state.
12
- # - window.aiActions — action implementations invoked fire-and-forget
13
- # when the LLM emits a matching tool_call.
17
+ # - window.aiActions — action implementations invoked when the LLM emits
18
+ # a matching tool_call. Since 0.4.0 each one's outcome is fed back as a
19
+ # tool result and the turn continues, so a flow may write to the page
20
+ # and then keep working.
14
21
  #
15
22
  # See llm_meta_widget's README for the three MCP-tool classes it supports
16
23
  # (page-embedded aiActions / host-wide well-known / hub-registered).
@@ -28,7 +35,9 @@ module LlmMetaWidget
28
35
  # .well-known/mcp.json are unaffected.
29
36
  llm_provider: "llm_meta_server",
30
37
  tool_hub_url: nil,
31
- orchestrator_path: "/llm_meta_widget_assets/orchestrator.js",
38
+ # The one module a page loads. Override to serve it from a CDN
39
+ # instead of this gem (same file, published to npm).
40
+ element_path: "/llm_meta_widget_assets/llm-meta-widget.js",
32
41
  actions_schema_id: "ai-actions",
33
42
  state_global: "aiState",
34
43
  actions_global: "aiActions",
@@ -53,6 +62,26 @@ module LlmMetaWidget
53
62
  greeting: nil
54
63
  }.freeze
55
64
 
65
+ # Renders `name="value"`, or nothing when the value is absent. Attribute
66
+ # omission is meaningful to the element: an omitted allowlist means "no
67
+ # allowlist", and an omitted well-known-urls means "auto-discover".
68
+ def tag_attr(name, value)
69
+ return "".html_safe if value.nil? || value.to_s.empty?
70
+
71
+ %(#{name}="#{ERB::Util.html_escape(value)}").html_safe
72
+ end
73
+
74
+ # well-known-urls is tri-state, and `[]` must survive as an EMPTY attribute
75
+ # rather than be dropped the way tag_attr drops blanks:
76
+ # nil -> omitted -> auto-discover same-origin
77
+ # [] -> well-known-urls="" -> discovery off
78
+ # [a,b] -> "a,b" -> fetch those
79
+ def well_known_attr(urls)
80
+ return "".html_safe if urls.nil?
81
+
82
+ %(well-known-urls="#{ERB::Util.html_escape(Array(urls).join(","))}").html_safe
83
+ end
84
+
56
85
  def llm_meta_widget(llm_url:, model:, **overrides)
57
86
  locals = DEFAULTS.merge(llm_url: llm_url, model: model, **overrides)
58
87
  provider = locals[:llm_provider].to_s