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.
- checksums.yaml +4 -4
- data/README.md +64 -2
- data/app/assets/javascripts/llm_meta_widget/config.js +79 -0
- data/app/assets/javascripts/llm_meta_widget/element.js +1133 -0
- data/app/assets/javascripts/llm_meta_widget/llm-meta-widget.js +4429 -0
- data/app/assets/javascripts/llm_meta_widget/orchestrator.js +3 -3
- data/app/assets/stylesheets/llm_meta_widget/panel.css +363 -0
- data/app/controllers/llm_meta_widget/assets_controller.rb +11 -0
- data/app/helpers/llm_meta_widget/widget_helper.rb +34 -5
- data/app/views/llm_meta_widget/_chat_panel.html.erb +39 -1435
- data/config/routes.rb +8 -0
- data/lib/llm_meta_widget/version.rb +1 -1
- metadata +5 -1
|
@@ -220,7 +220,7 @@ function indexOfEither(str, a, b) {
|
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
// ---------------------------------------------------------------------------
|
|
223
|
-
// runTurn — one LLM turn +
|
|
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
|
|
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 —
|
|
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(
|
|
5
|
-
#
|
|
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
|
|
13
|
-
#
|
|
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
|
-
|
|
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
|