llm_meta_widget 0.1.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 +7 -0
- data/LICENSE +201 -0
- data/README.md +173 -0
- data/Rakefile +11 -0
- data/app/assets/javascripts/llm_meta_widget/marked.esm.js +2432 -0
- data/app/assets/javascripts/llm_meta_widget/orchestrator.js +717 -0
- data/app/assets/stylesheets/llm_meta_widget/conversation.css +183 -0
- data/app/controllers/llm_meta_widget/assets_controller.rb +62 -0
- data/app/helpers/llm_meta_widget/widget_helper.rb +46 -0
- data/app/views/llm_meta_widget/_chat_panel.html.erb +962 -0
- data/config/routes.rb +21 -0
- data/lib/llm_meta_widget/engine.rb +13 -0
- data/lib/llm_meta_widget/version.rb +3 -0
- data/lib/llm_meta_widget.rb +5 -0
- metadata +72 -0
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
<%# Chat-panel widget for the client-orchestrated flow. Render via the %>
|
|
2
|
+
<%# llm_meta_widget helper — see llm_meta_widget_helper.rb for options. %>
|
|
3
|
+
<%# %>
|
|
4
|
+
<%# Required locals: base_url, model. %>
|
|
5
|
+
<%# Optional locals (helper defaults): %>
|
|
6
|
+
<%# api_key_uuid, orchestrator_path, actions_schema_id, state_global, %>
|
|
7
|
+
<%# actions_global, remote_tools_schema_id, well_known_urls, max_rounds. %>
|
|
8
|
+
<%# %>
|
|
9
|
+
<%# The host page must ALSO provide: %>
|
|
10
|
+
<%# (a) a JSON schema block with id = actions_schema_id, listing the %>
|
|
11
|
+
<%# local action set (name/description/input_schema each). %>
|
|
12
|
+
<%# (b) window.aiState — reader functions called each turn. %>
|
|
13
|
+
<%# (c) window.aiActions — action implementations invoked after the LLM %>
|
|
14
|
+
<%# turn completes. %>
|
|
15
|
+
<%# %>
|
|
16
|
+
<%# NOTE on ERB comments: DO NOT consolidate these lines into one <%_# ... %_> %>
|
|
17
|
+
<%# block. Rails-ERB comment blocks terminate at the FIRST closing `%_>`, so %>
|
|
18
|
+
<%# any nested <%_= ... %_> inside cuts them short and the trailing lines leak %>
|
|
19
|
+
<%# into rendered HTML — including any literal `<script>` text, which then %>
|
|
20
|
+
<%# swallows the widget div. One line per comment keeps them self-terminating. %>
|
|
21
|
+
|
|
22
|
+
<%# Bubble/thinking/role-label styles come from the shared conversation.css %>
|
|
23
|
+
<%# — the canonical "chat conversation" surface owned by this gem. See %>
|
|
24
|
+
<%# comment in /llm_meta_widget_assets/conversation.css. The widget's %>
|
|
25
|
+
<%# outer div opts in via class="llm-meta-conversation" (below). %>
|
|
26
|
+
<link rel="stylesheet" href="/llm_meta_widget_assets/conversation.css">
|
|
27
|
+
|
|
28
|
+
<style>
|
|
29
|
+
/* Widget-specific chrome — floating container, header, scroll region,
|
|
30
|
+
* input area. Everything BUBBLE / THINKING / ROLE-related lives in the
|
|
31
|
+
* shared conversation.css above. */
|
|
32
|
+
#llm-meta-widget-chat {
|
|
33
|
+
/* Positioned via JS on open (see setOpen()) so top/left are set in
|
|
34
|
+
* pixels — required for CSS `resize: both` to grow correctly
|
|
35
|
+
* (bottom/right anchoring blocks the resize handle from being
|
|
36
|
+
* dragged outward from viewport corner). */
|
|
37
|
+
position: fixed;
|
|
38
|
+
width: 40em; height: 55vh;
|
|
39
|
+
min-width: 24em; min-height: 16em;
|
|
40
|
+
max-width: calc(100vw - 2em); max-height: calc(100vh - 2em);
|
|
41
|
+
background: #ffffff;
|
|
42
|
+
border: 1px solid #e5e7eb;
|
|
43
|
+
border-radius: 8px;
|
|
44
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
|
45
|
+
padding: 10px;
|
|
46
|
+
z-index: 9999;
|
|
47
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
48
|
+
font-size: 14px;
|
|
49
|
+
color: #1f2937;
|
|
50
|
+
display: flex; flex-direction: column;
|
|
51
|
+
/* Native drag-to-resize handle appears at bottom-right when
|
|
52
|
+
* overflow != visible. overflow:hidden clips at widget edge; the
|
|
53
|
+
* inner .lmw-messages has its own scroll so no content is lost. */
|
|
54
|
+
resize: both;
|
|
55
|
+
overflow: hidden;
|
|
56
|
+
}
|
|
57
|
+
#llm-meta-widget-chat.lmw-collapsed { display: none; }
|
|
58
|
+
|
|
59
|
+
/* Collapsed-state toggle — a small floating chat-bubble button in the
|
|
60
|
+
* bottom-right corner. Always in the DOM; visible only when the chat
|
|
61
|
+
* panel is collapsed. Click to open the chat panel; the panel's × button
|
|
62
|
+
* collapses back to this. State persists across page loads via
|
|
63
|
+
* localStorage (see the JS below). */
|
|
64
|
+
#llm-meta-widget-toggle {
|
|
65
|
+
position: fixed; bottom: 1em; right: 1em;
|
|
66
|
+
width: 52px; height: 52px;
|
|
67
|
+
border-radius: 26px;
|
|
68
|
+
background-color: #3b82f6;
|
|
69
|
+
color: white;
|
|
70
|
+
border: none;
|
|
71
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
|
|
72
|
+
cursor: pointer;
|
|
73
|
+
z-index: 9999;
|
|
74
|
+
display: flex; align-items: center; justify-content: center;
|
|
75
|
+
transition: background-color 0.15s, transform 0.1s;
|
|
76
|
+
}
|
|
77
|
+
#llm-meta-widget-toggle:hover { background-color: #2563eb; }
|
|
78
|
+
#llm-meta-widget-toggle:active { transform: scale(0.96); }
|
|
79
|
+
#llm-meta-widget-toggle.lmw-hidden { display: none; }
|
|
80
|
+
#llm-meta-widget-toggle svg { display: block; }
|
|
81
|
+
|
|
82
|
+
/* Header hide/close: a subtle "−" button that collapses to the toggle
|
|
83
|
+
* bubble. Sits between the "clear" button and title. */
|
|
84
|
+
#llm-meta-widget-chat .lmw-hide {
|
|
85
|
+
background: none; border: none;
|
|
86
|
+
color: #6b7280;
|
|
87
|
+
font: inherit; font-size: 18px; line-height: 1;
|
|
88
|
+
cursor: pointer;
|
|
89
|
+
padding: 0 8px;
|
|
90
|
+
transition: color 0.15s;
|
|
91
|
+
}
|
|
92
|
+
#llm-meta-widget-chat .lmw-hide:hover { color: #1f2937; }
|
|
93
|
+
#llm-meta-widget-chat .lmw-header {
|
|
94
|
+
display: flex; justify-content: space-between; align-items: center;
|
|
95
|
+
gap: 8px;
|
|
96
|
+
padding-bottom: 8px; margin-bottom: 8px;
|
|
97
|
+
border-bottom: 2px solid #e5e7eb;
|
|
98
|
+
}
|
|
99
|
+
#llm-meta-widget-chat .lmw-header-right { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
|
100
|
+
|
|
101
|
+
/* Level-1 pickers row — matches chat.aibranch.org's input-container
|
|
102
|
+
* convention (attach / model / tools / settings in a horizontal row
|
|
103
|
+
* below the textarea). We only render model + tools for now. Uses
|
|
104
|
+
* relative positioning so the tools <details> panel can drop down
|
|
105
|
+
* as a floating overlay instead of pushing the input area around. */
|
|
106
|
+
#llm-meta-widget-chat .lmw-input-controls {
|
|
107
|
+
display: flex;
|
|
108
|
+
align-items: center;
|
|
109
|
+
gap: 6px;
|
|
110
|
+
margin-top: 6px;
|
|
111
|
+
position: relative;
|
|
112
|
+
min-height: 26px;
|
|
113
|
+
}
|
|
114
|
+
#llm-meta-widget-chat .lmw-title { font-weight: 600; color: #1f2937; }
|
|
115
|
+
|
|
116
|
+
/* Level-1 model picker — compact <select> next to the title.
|
|
117
|
+
* Populated by JS on widget open from GET /api/llms (anon path
|
|
118
|
+
* returns Ollama-only). Hidden entirely if enable_model_picker
|
|
119
|
+
* is false at the helper call site. */
|
|
120
|
+
#llm-meta-widget-chat .lmw-model-picker {
|
|
121
|
+
font: inherit; font-size: 12px;
|
|
122
|
+
padding: 3px 6px;
|
|
123
|
+
border: 1px solid #d1d5db;
|
|
124
|
+
border-radius: 4px;
|
|
125
|
+
background: #f9fafb;
|
|
126
|
+
color: #374151;
|
|
127
|
+
cursor: pointer;
|
|
128
|
+
max-width: 12em;
|
|
129
|
+
text-overflow: ellipsis;
|
|
130
|
+
}
|
|
131
|
+
#llm-meta-widget-chat .lmw-model-picker:focus {
|
|
132
|
+
outline: none;
|
|
133
|
+
border-color: #3b82f6;
|
|
134
|
+
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* Level-1 tools picker — inline chip in the input-controls row (same
|
|
138
|
+
* convention as chat.aibranch.org). The tools list drops down as a
|
|
139
|
+
* floating overlay above the input, so opening the panel doesn't
|
|
140
|
+
* push the textarea around. Populated by JS on widget open from
|
|
141
|
+
* GET /api/mcp_servers (anon path returns public_to_anonymous MCP
|
|
142
|
+
* servers with their tools inline). One checkbox per SERVER —
|
|
143
|
+
* toggling enables/disables ALL of its tools. */
|
|
144
|
+
#llm-meta-widget-chat .lmw-tools-picker {
|
|
145
|
+
position: relative; /* anchor for the absolutely-positioned list */
|
|
146
|
+
}
|
|
147
|
+
#llm-meta-widget-chat .lmw-tools-picker > summary {
|
|
148
|
+
list-style: none;
|
|
149
|
+
cursor: pointer;
|
|
150
|
+
padding: 3px 8px;
|
|
151
|
+
font-size: 12px;
|
|
152
|
+
color: #374151;
|
|
153
|
+
background: #f9fafb;
|
|
154
|
+
border: 1px solid #d1d5db;
|
|
155
|
+
border-radius: 4px;
|
|
156
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
157
|
+
}
|
|
158
|
+
#llm-meta-widget-chat .lmw-tools-picker > summary:hover { background: #f3f4f6; }
|
|
159
|
+
#llm-meta-widget-chat .lmw-tools-picker > summary::-webkit-details-marker { display: none; }
|
|
160
|
+
#llm-meta-widget-chat .lmw-tools-picker[open] > summary {
|
|
161
|
+
background: #eff6ff;
|
|
162
|
+
border-color: #93c5fd;
|
|
163
|
+
}
|
|
164
|
+
#llm-meta-widget-chat .lmw-tools-count {
|
|
165
|
+
display: inline-block;
|
|
166
|
+
background: #0ea5e9;
|
|
167
|
+
color: #fff;
|
|
168
|
+
font-size: 10px;
|
|
169
|
+
padding: 0 5px;
|
|
170
|
+
border-radius: 8px;
|
|
171
|
+
min-width: 14px;
|
|
172
|
+
text-align: center;
|
|
173
|
+
}
|
|
174
|
+
#llm-meta-widget-chat .lmw-tools-count.lmw-tools-count-zero {
|
|
175
|
+
background: #d1d5db;
|
|
176
|
+
color: #6b7280;
|
|
177
|
+
}
|
|
178
|
+
/* Floating dropdown panel — sits above the input area when open so
|
|
179
|
+
* expanding never shifts the textarea. Bottom-anchored so it opens
|
|
180
|
+
* UPWARD (natural direction since we're at the panel's bottom). */
|
|
181
|
+
#llm-meta-widget-chat .lmw-tools-list {
|
|
182
|
+
position: absolute;
|
|
183
|
+
bottom: calc(100% + 4px);
|
|
184
|
+
left: 0;
|
|
185
|
+
min-width: 220px;
|
|
186
|
+
max-height: 12em;
|
|
187
|
+
overflow-y: auto;
|
|
188
|
+
background: #ffffff;
|
|
189
|
+
border: 1px solid #d1d5db;
|
|
190
|
+
border-radius: 6px;
|
|
191
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.10);
|
|
192
|
+
padding: 4px 0;
|
|
193
|
+
z-index: 10;
|
|
194
|
+
}
|
|
195
|
+
#llm-meta-widget-chat .lmw-tools-item {
|
|
196
|
+
display: flex; align-items: center; gap: 6px;
|
|
197
|
+
padding: 4px 10px;
|
|
198
|
+
font-size: 12px;
|
|
199
|
+
color: #374151;
|
|
200
|
+
cursor: pointer;
|
|
201
|
+
}
|
|
202
|
+
#llm-meta-widget-chat .lmw-tools-item:hover { background: #eff6ff; }
|
|
203
|
+
#llm-meta-widget-chat .lmw-tools-item input[type="checkbox"] { margin: 0; }
|
|
204
|
+
#llm-meta-widget-chat .lmw-tools-empty {
|
|
205
|
+
padding: 6px 10px;
|
|
206
|
+
font-size: 12px;
|
|
207
|
+
color: #94a3b8;
|
|
208
|
+
font-style: italic;
|
|
209
|
+
}
|
|
210
|
+
/* Two-level tool selection — mirrors chat.aibranch.org's tool_selector.
|
|
211
|
+
* Server row (bulk checkbox + name + count + caret) is clickable to
|
|
212
|
+
* expand its children. Individual tool rows are indented one level. */
|
|
213
|
+
#llm-meta-widget-chat .lmw-tools-server + .lmw-tools-server {
|
|
214
|
+
border-top: 1px solid #f1f5f9;
|
|
215
|
+
}
|
|
216
|
+
#llm-meta-widget-chat .lmw-tools-server-row {
|
|
217
|
+
display: flex; align-items: center; gap: 6px;
|
|
218
|
+
padding: 4px 10px;
|
|
219
|
+
font-size: 12px;
|
|
220
|
+
font-weight: 500;
|
|
221
|
+
color: #1f2937;
|
|
222
|
+
cursor: pointer;
|
|
223
|
+
}
|
|
224
|
+
#llm-meta-widget-chat .lmw-tools-server-row:hover { background: #eff6ff; }
|
|
225
|
+
#llm-meta-widget-chat .lmw-tools-server-name { flex: 1; }
|
|
226
|
+
#llm-meta-widget-chat .lmw-tools-server-caret {
|
|
227
|
+
color: #94a3b8;
|
|
228
|
+
font-size: 10px;
|
|
229
|
+
line-height: 1;
|
|
230
|
+
flex-shrink: 0;
|
|
231
|
+
}
|
|
232
|
+
#llm-meta-widget-chat .lmw-tools-item-child {
|
|
233
|
+
padding-left: 26px; /* align tool checkboxes past the server bulk checkbox */
|
|
234
|
+
font-size: 12px;
|
|
235
|
+
color: #4b5563;
|
|
236
|
+
}
|
|
237
|
+
#llm-meta-widget-chat .lmw-clear {
|
|
238
|
+
background: #ef4444; color: white; border: none;
|
|
239
|
+
padding: 4px 10px; border-radius: 6px; cursor: pointer;
|
|
240
|
+
font: inherit; font-size: 12px;
|
|
241
|
+
transition: background-color 0.15s;
|
|
242
|
+
}
|
|
243
|
+
#llm-meta-widget-chat .lmw-clear:hover { background: #dc2626; }
|
|
244
|
+
|
|
245
|
+
#llm-meta-widget-chat .lmw-messages {
|
|
246
|
+
flex: 1; overflow-y: auto;
|
|
247
|
+
padding: 10px;
|
|
248
|
+
background-color: #f9fafb;
|
|
249
|
+
border-radius: 8px;
|
|
250
|
+
margin-bottom: 8px;
|
|
251
|
+
min-height: 6em;
|
|
252
|
+
/* No max-height — messages grows with the widget (which the user
|
|
253
|
+
* can drag to any size). A hard cap here would leave an ugly gap
|
|
254
|
+
* between the messages area and the input on tall widget sizes. */
|
|
255
|
+
}
|
|
256
|
+
#llm-meta-widget-chat .lmw-messages::-webkit-scrollbar { width: 8px; }
|
|
257
|
+
#llm-meta-widget-chat .lmw-messages::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 4px; }
|
|
258
|
+
#llm-meta-widget-chat .lmw-messages::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
|
|
259
|
+
#llm-meta-widget-chat .lmw-messages::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
|
260
|
+
|
|
261
|
+
/* Input area — mirrors llm_meta_chat's .chat-input-container +
|
|
262
|
+
* .chat-input + .send-button so it reads as the same UI. Multi-line
|
|
263
|
+
* textarea with an absolutely-positioned paper-plane send button
|
|
264
|
+
* (SVG inlined so no external icon-font dep). */
|
|
265
|
+
#llm-meta-widget-chat .lmw-input-container {
|
|
266
|
+
background-color: white;
|
|
267
|
+
border: 2px solid #e5e7eb;
|
|
268
|
+
border-radius: 8px;
|
|
269
|
+
padding: 8px;
|
|
270
|
+
}
|
|
271
|
+
#llm-meta-widget-chat .lmw-form { display: flex; flex-direction: column; gap: 8px; }
|
|
272
|
+
#llm-meta-widget-chat .lmw-input-wrapper { position: relative; flex: 1; }
|
|
273
|
+
#llm-meta-widget-chat .lmw-input {
|
|
274
|
+
width: 100%;
|
|
275
|
+
padding: 10px 48px 10px 10px;
|
|
276
|
+
border: 1px solid #d1d5db;
|
|
277
|
+
border-radius: 6px;
|
|
278
|
+
font-size: 14px;
|
|
279
|
+
font-family: inherit;
|
|
280
|
+
resize: vertical;
|
|
281
|
+
min-height: 44px;
|
|
282
|
+
box-sizing: border-box;
|
|
283
|
+
transition: border-color 0.2s, box-shadow 0.2s;
|
|
284
|
+
}
|
|
285
|
+
#llm-meta-widget-chat .lmw-input:focus {
|
|
286
|
+
outline: none;
|
|
287
|
+
border-color: #3b82f6;
|
|
288
|
+
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
|
289
|
+
}
|
|
290
|
+
#llm-meta-widget-chat .lmw-send {
|
|
291
|
+
position: absolute;
|
|
292
|
+
bottom: 6px;
|
|
293
|
+
right: 6px;
|
|
294
|
+
background-color: #3b82f6;
|
|
295
|
+
color: white;
|
|
296
|
+
border: none;
|
|
297
|
+
width: 32px;
|
|
298
|
+
height: 32px;
|
|
299
|
+
padding: 0;
|
|
300
|
+
border-radius: 6px;
|
|
301
|
+
cursor: pointer;
|
|
302
|
+
display: flex;
|
|
303
|
+
align-items: center;
|
|
304
|
+
justify-content: center;
|
|
305
|
+
transition: background-color 0.15s, opacity 0.15s;
|
|
306
|
+
}
|
|
307
|
+
#llm-meta-widget-chat .lmw-send:hover { background-color: #2563eb; }
|
|
308
|
+
#llm-meta-widget-chat .lmw-send:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
309
|
+
#llm-meta-widget-chat .lmw-send svg { display: block; }
|
|
310
|
+
</style>
|
|
311
|
+
|
|
312
|
+
<%# Floating toggle — visible when the chat panel is collapsed. See CSS above. %>
|
|
313
|
+
<button type="button" id="llm-meta-widget-toggle" title="Open AI assistant" aria-label="Open AI assistant">
|
|
314
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
|
315
|
+
<path d="M2 4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2h-3l-3.5 3.5V12H4a2 2 0 0 1-2-2V4Zm3 2a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm3 0a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm3 0a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"/>
|
|
316
|
+
</svg>
|
|
317
|
+
</button>
|
|
318
|
+
|
|
319
|
+
<div id="llm-meta-widget-chat" class="llm-meta-conversation">
|
|
320
|
+
<div class="lmw-header">
|
|
321
|
+
<span class="lmw-title">AI assistant</span>
|
|
322
|
+
<div class="lmw-header-right">
|
|
323
|
+
<button type="button" class="lmw-clear" title="Clear conversation">clear</button>
|
|
324
|
+
<button type="button" class="lmw-hide" title="Hide">−</button>
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
<div class="lmw-messages"></div>
|
|
328
|
+
<div class="lmw-input-container">
|
|
329
|
+
<form class="lmw-form">
|
|
330
|
+
<div class="lmw-input-wrapper">
|
|
331
|
+
<textarea class="lmw-input" placeholder="Enter your message..." rows="2" autocomplete="off"></textarea>
|
|
332
|
+
<button type="submit" class="lmw-send" title="Send message">
|
|
333
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
|
|
334
|
+
<path d="M15.964.686a.5.5 0 0 0-.65-.65L.293 6.011a.513.513 0 0 0 .002.947l4.708 1.878 8.94-6.94-6.94 8.94 1.879 4.708c.163.407.756.416.951.016l6.13-13.884Z"/>
|
|
335
|
+
</svg>
|
|
336
|
+
</button>
|
|
337
|
+
</div>
|
|
338
|
+
<%# Level-1 pickers — model + tools sit inline BELOW the textarea, %>
|
|
339
|
+
<%# same layout convention as chat.aibranch.org's input area. Each %>
|
|
340
|
+
<%# picker is hidden by CSS if its enable_* flag is false at the %>
|
|
341
|
+
<%# helper call site (level-0 minimalist mode). %>
|
|
342
|
+
<% if enable_model_picker || enable_tool_picker %>
|
|
343
|
+
<div class="lmw-input-controls">
|
|
344
|
+
<% if enable_model_picker %>
|
|
345
|
+
<%# Populated by JS on widget open from GET /api/llms (anon → Ollama-only). %>
|
|
346
|
+
<select class="lmw-model-picker" title="Select model" aria-label="Select model" style="display:none">
|
|
347
|
+
</select>
|
|
348
|
+
<% end %>
|
|
349
|
+
<% if enable_tool_picker %>
|
|
350
|
+
<%# Populated by JS on widget open from GET /api/mcp_servers. Each %>
|
|
351
|
+
<%# checkbox toggles ALL tools on that server. Selections are %>
|
|
352
|
+
<%# session-scoped (not persisted across widget reloads). %>
|
|
353
|
+
<details class="lmw-tools-picker">
|
|
354
|
+
<summary>
|
|
355
|
+
🔧 Tools
|
|
356
|
+
<span class="lmw-tools-count lmw-tools-count-zero">0</span>
|
|
357
|
+
</summary>
|
|
358
|
+
<div class="lmw-tools-list">
|
|
359
|
+
<div class="lmw-tools-empty">Loading…</div>
|
|
360
|
+
</div>
|
|
361
|
+
</details>
|
|
362
|
+
<% end %>
|
|
363
|
+
</div>
|
|
364
|
+
<% end %>
|
|
365
|
+
</form>
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
|
|
369
|
+
<script type="module">
|
|
370
|
+
import { runChatLoop, fetchMcpManifest } from "<%= orchestrator_path %>";
|
|
371
|
+
import { marked } from "/llm_meta_widget_assets/marked.esm.js";
|
|
372
|
+
|
|
373
|
+
// Standard prose settings — GFM (tables, autolinks, strikethrough),
|
|
374
|
+
// break single newlines into <br> so streamed LLM output that uses
|
|
375
|
+
// bare newlines still reads naturally.
|
|
376
|
+
marked.setOptions({ gfm: true, breaks: true });
|
|
377
|
+
|
|
378
|
+
var META_BASE = <%= base_url.to_json.html_safe %>;
|
|
379
|
+
var API_KEY_UUID = <%= api_key_uuid.to_json.html_safe %>;
|
|
380
|
+
var MODEL = <%= model.to_json.html_safe %>;
|
|
381
|
+
var ACTIONS_SCHEMA_ID = <%= actions_schema_id.to_json.html_safe %>;
|
|
382
|
+
var STATE_GLOBAL = <%= state_global.to_json.html_safe %>;
|
|
383
|
+
var ACTIONS_GLOBAL = <%= actions_global.to_json.html_safe %>;
|
|
384
|
+
var REMOTE_TOOLS_SCHEMA_ID = <%= remote_tools_schema_id.to_json.html_safe %>;
|
|
385
|
+
var MAX_ROUNDS = <%= max_rounds.to_json.html_safe %>;
|
|
386
|
+
var WELL_KNOWN_URLS = <%= raw(well_known_urls.nil? ? "null" : well_known_urls.to_json) %>;
|
|
387
|
+
var ENABLE_MODEL_PICKER = <%= enable_model_picker.to_json.html_safe %>;
|
|
388
|
+
var ENABLE_TOOL_PICKER = <%= enable_tool_picker.to_json.html_safe %>;
|
|
389
|
+
var MODEL_ALLOWLIST = <%= raw(models.nil? ? "null" : models.to_json) %>;
|
|
390
|
+
var HUB_TOOLS_ALLOWLIST = <%= raw(hub_tools.nil? ? "null" : hub_tools.to_json) %>;
|
|
391
|
+
|
|
392
|
+
var root = document.getElementById("llm-meta-widget-chat");
|
|
393
|
+
var toggleBtn = document.getElementById("llm-meta-widget-toggle");
|
|
394
|
+
var historyEl = root.querySelector(".lmw-messages");
|
|
395
|
+
var formEl = root.querySelector(".lmw-form");
|
|
396
|
+
var inputEl = root.querySelector(".lmw-input");
|
|
397
|
+
var clearBtn = root.querySelector(".lmw-clear");
|
|
398
|
+
var hideBtn = root.querySelector(".lmw-hide");
|
|
399
|
+
var modelPicker = root.querySelector(".lmw-model-picker"); // null when disabled
|
|
400
|
+
var toolsPicker = root.querySelector(".lmw-tools-picker"); // null when disabled
|
|
401
|
+
var toolsListEl = root.querySelector(".lmw-tools-list"); // null when disabled
|
|
402
|
+
var toolsCountEl = root.querySelector(".lmw-tools-count"); // null when disabled
|
|
403
|
+
|
|
404
|
+
// Persistence keys — open/close state + resized geometry (width/height).
|
|
405
|
+
// Position is re-computed each open so the widget lands at bottom-right
|
|
406
|
+
// of the CURRENT viewport (not wherever the user resized it last time).
|
|
407
|
+
// Size is preserved because that's a user preference; position is a
|
|
408
|
+
// spawn convention.
|
|
409
|
+
var STORAGE_KEY_OPEN = "llm_meta_widget:open";
|
|
410
|
+
var STORAGE_KEY_SIZE = "llm_meta_widget:size"; // stored as "WxH" in px
|
|
411
|
+
|
|
412
|
+
function applyStoredSize() {
|
|
413
|
+
try {
|
|
414
|
+
var raw = localStorage.getItem(STORAGE_KEY_SIZE);
|
|
415
|
+
if (!raw) return;
|
|
416
|
+
var parts = raw.split("x");
|
|
417
|
+
var w = parseInt(parts[0], 10), h = parseInt(parts[1], 10);
|
|
418
|
+
if (w > 0 && h > 0) {
|
|
419
|
+
root.style.width = w + "px";
|
|
420
|
+
root.style.height = h + "px";
|
|
421
|
+
}
|
|
422
|
+
} catch (e) { /* noop */ }
|
|
423
|
+
}
|
|
424
|
+
function saveCurrentSize() {
|
|
425
|
+
try {
|
|
426
|
+
var rect = root.getBoundingClientRect();
|
|
427
|
+
localStorage.setItem(STORAGE_KEY_SIZE,
|
|
428
|
+
Math.round(rect.width) + "x" + Math.round(rect.height));
|
|
429
|
+
} catch (e) { /* noop */ }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Anchor the widget at bottom-right of the viewport, given whatever
|
|
433
|
+
// size it currently has. Uses top/left in pixels (required for CSS
|
|
434
|
+
// `resize: both` — a bottom/right-pinned element can't be dragged out
|
|
435
|
+
// past the viewport edge to grow).
|
|
436
|
+
function positionAtBottomRight() {
|
|
437
|
+
var margin = 16; // ~1em from the viewport edges
|
|
438
|
+
var rect = root.getBoundingClientRect();
|
|
439
|
+
var top = Math.max(margin, window.innerHeight - rect.height - margin);
|
|
440
|
+
var left = Math.max(margin, window.innerWidth - rect.width - margin);
|
|
441
|
+
root.style.top = top + "px";
|
|
442
|
+
root.style.left = left + "px";
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function setOpen(open) {
|
|
446
|
+
if (open) {
|
|
447
|
+
applyStoredSize(); // restore user's preferred size (if any)
|
|
448
|
+
root.classList.remove("lmw-collapsed");
|
|
449
|
+
toggleBtn.classList.add("lmw-hidden");
|
|
450
|
+
// Position AFTER unhiding so getBoundingClientRect returns real size.
|
|
451
|
+
positionAtBottomRight();
|
|
452
|
+
try { inputEl.focus(); } catch (e) { /* noop */ }
|
|
453
|
+
// Fire-and-forget the hub-picker fetch on first open; subsequent
|
|
454
|
+
// opens are no-ops (pickerLoaded flag). We deliberately don't
|
|
455
|
+
// await — the widget is immediately usable with the initial MODEL
|
|
456
|
+
// and no hub tools; pickers populate a moment later.
|
|
457
|
+
ensurePickerLoaded().catch(function(e) { console.warn("[widget] picker load failed:", e); });
|
|
458
|
+
} else {
|
|
459
|
+
saveCurrentSize(); // capture whatever the user resized to
|
|
460
|
+
root.classList.add("lmw-collapsed");
|
|
461
|
+
toggleBtn.classList.remove("lmw-hidden");
|
|
462
|
+
}
|
|
463
|
+
try { localStorage.setItem(STORAGE_KEY_OPEN, open ? "1" : "0"); } catch (e) { /* noop */ }
|
|
464
|
+
}
|
|
465
|
+
var initialOpen = false;
|
|
466
|
+
try { initialOpen = localStorage.getItem(STORAGE_KEY_OPEN) === "1"; } catch (e) { /* noop */ }
|
|
467
|
+
setOpen(initialOpen);
|
|
468
|
+
|
|
469
|
+
toggleBtn.addEventListener("click", function() { setOpen(true); });
|
|
470
|
+
hideBtn.addEventListener("click", function() { setOpen(false); });
|
|
471
|
+
|
|
472
|
+
// Persist size on every resize interaction. ResizeObserver fires
|
|
473
|
+
// continuously during a drag; save + reposition on each tick so the
|
|
474
|
+
// widget stays anchored to bottom-right as the user grows it.
|
|
475
|
+
if (typeof ResizeObserver === "function") {
|
|
476
|
+
var ro = new ResizeObserver(function() {
|
|
477
|
+
if (root.classList.contains("lmw-collapsed")) return;
|
|
478
|
+
positionAtBottomRight();
|
|
479
|
+
saveCurrentSize();
|
|
480
|
+
});
|
|
481
|
+
ro.observe(root);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
var conversation = []; // [{role, content}, ...] — grows across turns
|
|
485
|
+
var actionsSchemaEl = document.getElementById(ACTIONS_SCHEMA_ID);
|
|
486
|
+
var localTools = actionsSchemaEl ? JSON.parse(actionsSchemaEl.textContent) : [];
|
|
487
|
+
var remoteToolsEl = document.getElementById(REMOTE_TOOLS_SCHEMA_ID);
|
|
488
|
+
// remoteTools is a mutable live list: starts from the schema-provided
|
|
489
|
+
// baseline (if any), then the level-1 picker adds/removes entries as
|
|
490
|
+
// the visitor toggles server checkboxes. Passed to runChatLoop on
|
|
491
|
+
// every submit so the current selection is respected turn-by-turn.
|
|
492
|
+
var remoteTools = remoteToolsEl ? JSON.parse(remoteToolsEl.textContent) : [];
|
|
493
|
+
|
|
494
|
+
// Level-1 picker caches. hubMcpServers[] is the list of anon-visible
|
|
495
|
+
// servers fetched from GET /api/mcp_servers; each server has an
|
|
496
|
+
// embedded `tools` array. selectedToolIds is the set of *individual*
|
|
497
|
+
// tool ids currently ON — same granularity as chat.aibranch.org's
|
|
498
|
+
// tool_selector: each tool can be toggled individually, and each
|
|
499
|
+
// server has a bulk-toggle checkbox that reflects/drives its children's
|
|
500
|
+
// state (all-on / indeterminate / all-off).
|
|
501
|
+
var hubMcpServers = [];
|
|
502
|
+
var selectedToolIds = new Set();
|
|
503
|
+
|
|
504
|
+
function anyAllowedByAllowlist(name, allowlist) {
|
|
505
|
+
return allowlist === null || allowlist.indexOf(name) >= 0;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function refreshRemoteToolsFromPicker() {
|
|
509
|
+
// Flatten every SELECTED tool (across all servers) into remoteTools,
|
|
510
|
+
// adapted to runChatLoop's expected { id, name, description, input_schema }.
|
|
511
|
+
var flat = [];
|
|
512
|
+
hubMcpServers.forEach(function(s) {
|
|
513
|
+
(s.tools || []).forEach(function(t) {
|
|
514
|
+
if (!selectedToolIds.has(t.id)) return;
|
|
515
|
+
flat.push({
|
|
516
|
+
id: t.id,
|
|
517
|
+
name: t.name,
|
|
518
|
+
description: t.description,
|
|
519
|
+
input_schema: t.input_schema
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
remoteTools = flat;
|
|
524
|
+
if (toolsCountEl) {
|
|
525
|
+
toolsCountEl.textContent = flat.length;
|
|
526
|
+
toolsCountEl.classList.toggle("lmw-tools-count-zero", flat.length === 0);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Reflect the mix of child-tool states onto a server's bulk checkbox
|
|
531
|
+
// (checked / indeterminate / unchecked). Called after any child
|
|
532
|
+
// checkbox change, and after applying a bulk toggle.
|
|
533
|
+
function refreshServerBulkCheckbox(server, bulkCheckbox) {
|
|
534
|
+
var tools = server.tools || [];
|
|
535
|
+
var n = tools.length;
|
|
536
|
+
if (n === 0) { bulkCheckbox.checked = false; bulkCheckbox.indeterminate = false; return; }
|
|
537
|
+
var onCount = 0;
|
|
538
|
+
tools.forEach(function(t) { if (selectedToolIds.has(t.id)) onCount++; });
|
|
539
|
+
bulkCheckbox.checked = (onCount === n);
|
|
540
|
+
bulkCheckbox.indeterminate = (onCount > 0 && onCount < n);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
async function loadHubResourcesForPickers() {
|
|
544
|
+
// Called once on first widget open (idempotent — pickerLoaded flag
|
|
545
|
+
// below). Fetches models + MCP servers from the hub's anon endpoints
|
|
546
|
+
// and populates each picker. Non-fatal if either fetch fails (widget
|
|
547
|
+
// still works with the initial `model:` + no hub tools).
|
|
548
|
+
var tasks = [];
|
|
549
|
+
|
|
550
|
+
if (ENABLE_MODEL_PICKER && modelPicker) {
|
|
551
|
+
tasks.push(fetch(META_BASE + "/api/llms", { headers: { "Accept": "application/json" } })
|
|
552
|
+
.then(function(r) { return r.ok ? r.json() : { llms: [] }; })
|
|
553
|
+
.then(function(payload) {
|
|
554
|
+
// /api/llms is heterogeneous per family:
|
|
555
|
+
// - OpenAI/Anthropic/Google: `models: [{ name, display_name, ... }]`
|
|
556
|
+
// - Ollama: `available_models: [{ value, label, ... }]`
|
|
557
|
+
// Anon widget uses api_key_uuid = "ollama-local" so only Ollama models
|
|
558
|
+
// are invocable; filter to that family and use its subshape.
|
|
559
|
+
var flat = [];
|
|
560
|
+
(payload.llms || []).forEach(function(llm) {
|
|
561
|
+
if (llm.family !== "ollama") return;
|
|
562
|
+
(llm.available_models || []).forEach(function(m) {
|
|
563
|
+
if (anyAllowedByAllowlist(m.value, MODEL_ALLOWLIST)) {
|
|
564
|
+
flat.push({ value: m.value, label: m.label });
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
// Ensure the host-configured MODEL is in the list even if the
|
|
569
|
+
// server doesn't return it — visitor should always see the
|
|
570
|
+
// current selection, and we don't want to silently switch.
|
|
571
|
+
if (!flat.some(function(m) { return m.value === MODEL; })) {
|
|
572
|
+
flat.unshift({ value: MODEL, label: MODEL });
|
|
573
|
+
}
|
|
574
|
+
modelPicker.innerHTML = "";
|
|
575
|
+
flat.forEach(function(m) {
|
|
576
|
+
var opt = document.createElement("option");
|
|
577
|
+
opt.value = m.value;
|
|
578
|
+
opt.textContent = m.label;
|
|
579
|
+
if (m.value === MODEL) opt.selected = true;
|
|
580
|
+
modelPicker.appendChild(opt);
|
|
581
|
+
});
|
|
582
|
+
if (flat.length > 1) modelPicker.style.display = "";
|
|
583
|
+
})
|
|
584
|
+
.catch(function(e) { console.warn("[widget] model list fetch failed:", e); }));
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (ENABLE_TOOL_PICKER && toolsListEl) {
|
|
588
|
+
tasks.push(fetch(META_BASE + "/api/mcp_servers", { headers: { "Accept": "application/json" } })
|
|
589
|
+
.then(function(r) { return r.ok ? r.json() : { mcp_servers: [] }; })
|
|
590
|
+
.then(function(payload) {
|
|
591
|
+
hubMcpServers = (payload.mcp_servers || []).filter(function(s) {
|
|
592
|
+
return anyAllowedByAllowlist(s.name, HUB_TOOLS_ALLOWLIST);
|
|
593
|
+
});
|
|
594
|
+
toolsListEl.innerHTML = "";
|
|
595
|
+
if (hubMcpServers.length === 0) {
|
|
596
|
+
var empty = document.createElement("div");
|
|
597
|
+
empty.className = "lmw-tools-empty";
|
|
598
|
+
empty.textContent = "No hub-registered tools available.";
|
|
599
|
+
toolsListEl.appendChild(empty);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
hubMcpServers.forEach(function(s) {
|
|
603
|
+
var serverBlock = document.createElement("div");
|
|
604
|
+
serverBlock.className = "lmw-tools-server";
|
|
605
|
+
|
|
606
|
+
// Server header row: bulk checkbox + name + tool count + expand caret.
|
|
607
|
+
// Bulk checkbox toggles ALL of the server's tools at once and reflects
|
|
608
|
+
// the mix of child states (indeterminate when partial).
|
|
609
|
+
var headerRow = document.createElement("div");
|
|
610
|
+
headerRow.className = "lmw-tools-server-row";
|
|
611
|
+
|
|
612
|
+
var bulkCb = document.createElement("input");
|
|
613
|
+
bulkCb.type = "checkbox";
|
|
614
|
+
bulkCb.className = "lmw-tools-server-bulk";
|
|
615
|
+
bulkCb.title = "Enable all tools on this server";
|
|
616
|
+
bulkCb.addEventListener("click", function(e) {
|
|
617
|
+
// Prevent header-row click from toggling expand.
|
|
618
|
+
e.stopPropagation();
|
|
619
|
+
});
|
|
620
|
+
bulkCb.addEventListener("change", function() {
|
|
621
|
+
var shouldOn = bulkCb.checked;
|
|
622
|
+
(s.tools || []).forEach(function(t) {
|
|
623
|
+
if (shouldOn) selectedToolIds.add(t.id);
|
|
624
|
+
else selectedToolIds.delete(t.id);
|
|
625
|
+
});
|
|
626
|
+
bulkCb.indeterminate = false;
|
|
627
|
+
// Sync every visible individual checkbox for this server.
|
|
628
|
+
childList.querySelectorAll('input[type="checkbox"]').forEach(function(cb) {
|
|
629
|
+
cb.checked = shouldOn;
|
|
630
|
+
});
|
|
631
|
+
refreshRemoteToolsFromPicker();
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
var nameSpan = document.createElement("span");
|
|
635
|
+
nameSpan.className = "lmw-tools-server-name";
|
|
636
|
+
var toolCount = (s.tools || []).length;
|
|
637
|
+
nameSpan.textContent = s.name + " (" + toolCount + " tool" + (toolCount === 1 ? "" : "s") + ")";
|
|
638
|
+
|
|
639
|
+
var caret = document.createElement("span");
|
|
640
|
+
caret.className = "lmw-tools-server-caret";
|
|
641
|
+
caret.textContent = "▸";
|
|
642
|
+
|
|
643
|
+
headerRow.appendChild(bulkCb);
|
|
644
|
+
headerRow.appendChild(nameSpan);
|
|
645
|
+
headerRow.appendChild(caret);
|
|
646
|
+
|
|
647
|
+
// Individual-tool rows — one checkbox per tool, indented.
|
|
648
|
+
// Hidden by default; header click expands. This mirrors
|
|
649
|
+
// chat.aibranch.org's lazy-expand server UX.
|
|
650
|
+
var childList = document.createElement("div");
|
|
651
|
+
childList.className = "lmw-tools-server-children";
|
|
652
|
+
childList.style.display = "none";
|
|
653
|
+
|
|
654
|
+
(s.tools || []).forEach(function(t) {
|
|
655
|
+
var toolLabel = document.createElement("label");
|
|
656
|
+
toolLabel.className = "lmw-tools-item lmw-tools-item-child";
|
|
657
|
+
toolLabel.title = t.description || t.name;
|
|
658
|
+
var cb = document.createElement("input");
|
|
659
|
+
cb.type = "checkbox";
|
|
660
|
+
cb.value = t.id;
|
|
661
|
+
cb.checked = selectedToolIds.has(t.id);
|
|
662
|
+
cb.addEventListener("change", function() {
|
|
663
|
+
if (cb.checked) selectedToolIds.add(t.id);
|
|
664
|
+
else selectedToolIds.delete(t.id);
|
|
665
|
+
refreshServerBulkCheckbox(s, bulkCb);
|
|
666
|
+
refreshRemoteToolsFromPicker();
|
|
667
|
+
});
|
|
668
|
+
var span = document.createElement("span");
|
|
669
|
+
span.textContent = t.name;
|
|
670
|
+
toolLabel.appendChild(cb);
|
|
671
|
+
toolLabel.appendChild(span);
|
|
672
|
+
childList.appendChild(toolLabel);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
headerRow.addEventListener("click", function() {
|
|
676
|
+
var isOpen = childList.style.display !== "none";
|
|
677
|
+
childList.style.display = isOpen ? "none" : "block";
|
|
678
|
+
caret.textContent = isOpen ? "▸" : "▾";
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
serverBlock.appendChild(headerRow);
|
|
682
|
+
serverBlock.appendChild(childList);
|
|
683
|
+
toolsListEl.appendChild(serverBlock);
|
|
684
|
+
|
|
685
|
+
// Initial bulk-checkbox state (all unchecked at first render).
|
|
686
|
+
refreshServerBulkCheckbox(s, bulkCb);
|
|
687
|
+
});
|
|
688
|
+
refreshRemoteToolsFromPicker();
|
|
689
|
+
})
|
|
690
|
+
.catch(function(e) { console.warn("[widget] MCP server list fetch failed:", e); }));
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
await Promise.all(tasks);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (modelPicker) {
|
|
697
|
+
modelPicker.addEventListener("change", function() { MODEL = modelPicker.value; });
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Close the tools dropdown on any click outside its area. Native
|
|
701
|
+
// <details> only closes on summary re-click; visitors expect a
|
|
702
|
+
// dropdown/popover to dismiss on outside click, matching native
|
|
703
|
+
// select/menu behavior. `.contains(target)` includes the summary
|
|
704
|
+
// AND the drop-down list (both are descendants of the <details>).
|
|
705
|
+
if (toolsPicker) {
|
|
706
|
+
document.addEventListener("click", function(e) {
|
|
707
|
+
if (!toolsPicker.open) return;
|
|
708
|
+
if (toolsPicker.contains(e.target)) return;
|
|
709
|
+
toolsPicker.open = false;
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
var pickerLoaded = false;
|
|
714
|
+
async function ensurePickerLoaded() {
|
|
715
|
+
if (pickerLoaded) return;
|
|
716
|
+
pickerLoaded = true; // set first so parallel opens don't double-fetch
|
|
717
|
+
try { await loadHubResourcesForPickers(); }
|
|
718
|
+
catch (e) { pickerLoaded = false; throw e; }
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Fetch well-known MCP manifests at boot. Auto-discovers same origin
|
|
722
|
+
// if WELL_KNOWN_URLS is null; empty array disables entirely.
|
|
723
|
+
var hostWideTools = [];
|
|
724
|
+
var wellKnownReady = (async function() {
|
|
725
|
+
var urls = WELL_KNOWN_URLS === null
|
|
726
|
+
? [ window.location.origin + "/.well-known/mcp.json" ]
|
|
727
|
+
: WELL_KNOWN_URLS;
|
|
728
|
+
for (var i = 0; i < urls.length; i++) {
|
|
729
|
+
var tools = await fetchMcpManifest(urls[i]);
|
|
730
|
+
hostWideTools = hostWideTools.concat(tools);
|
|
731
|
+
}
|
|
732
|
+
})();
|
|
733
|
+
|
|
734
|
+
function appendTurn(role, text) {
|
|
735
|
+
// Class names mirror llm_meta_chat's chats/_message.html.erb —
|
|
736
|
+
// `.message.<role>`, `.message-role`, `.message-content` — so the
|
|
737
|
+
// shared conversation.css styles apply directly (see the <link>
|
|
738
|
+
// above). The .lmw-* prefix is reserved for widget-CHROME classes
|
|
739
|
+
// (header, clear button, scroll region, input area) that aren't
|
|
740
|
+
// part of the shared conversation surface.
|
|
741
|
+
var div = document.createElement("div");
|
|
742
|
+
div.className = "message " + role;
|
|
743
|
+
var label = document.createElement("div");
|
|
744
|
+
label.className = "message-role";
|
|
745
|
+
label.textContent = roleLabel(role);
|
|
746
|
+
var body = document.createElement("div");
|
|
747
|
+
body.className = "message-content";
|
|
748
|
+
// User / system / error turns render as plain text (safe from any
|
|
749
|
+
// injection via user input or server-supplied strings). Assistant
|
|
750
|
+
// content is rendered as markdown but only after the assistant's
|
|
751
|
+
// text is streamed in via renderMarkdownInto — this appendTurn
|
|
752
|
+
// creates the empty container.
|
|
753
|
+
body.textContent = text;
|
|
754
|
+
div.appendChild(label);
|
|
755
|
+
div.appendChild(document.createTextNode(" "));
|
|
756
|
+
div.appendChild(body);
|
|
757
|
+
historyEl.appendChild(div);
|
|
758
|
+
historyEl.scrollTop = historyEl.scrollHeight;
|
|
759
|
+
return body;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Render a raw-markdown buffer into an element's innerHTML. marked's
|
|
763
|
+
// default HTML output escapes user content, so passing arbitrary
|
|
764
|
+
// LLM output is safe against XSS. Called per-delta so streaming
|
|
765
|
+
// assistant text formats live (headings/lists/code blocks/etc.).
|
|
766
|
+
function renderMarkdownInto(el, mdText) {
|
|
767
|
+
try { el.innerHTML = marked.parse(mdText); }
|
|
768
|
+
catch (e) { el.textContent = mdText; } // fall back to raw on parse error
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Role labels mirror llm_meta_chat's chats/_message.html.erb:
|
|
772
|
+
// user → "👤 You"
|
|
773
|
+
// assistant → "🤖 <model>"
|
|
774
|
+
// so the widget reads as the same UI as the full chat host.
|
|
775
|
+
function roleLabel(role) {
|
|
776
|
+
if (role === "user") return "👤 You";
|
|
777
|
+
if (role === "assistant") return "🤖 " + MODEL;
|
|
778
|
+
if (role === "system") return "• system";
|
|
779
|
+
if (role === "error") return "⚠ error";
|
|
780
|
+
return role;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
var currentThinkingBlock = null;
|
|
784
|
+
var currentThinkingBody = null;
|
|
785
|
+
|
|
786
|
+
function ensureThinkingBlock() {
|
|
787
|
+
if (currentThinkingBody) return currentThinkingBody;
|
|
788
|
+
// Class names match llm_meta_chat's .message-thinking pattern so
|
|
789
|
+
// the shared conversation.css applies directly.
|
|
790
|
+
var details = document.createElement("details");
|
|
791
|
+
details.className = "message-thinking";
|
|
792
|
+
details.open = true;
|
|
793
|
+
var summary = document.createElement("summary");
|
|
794
|
+
summary.textContent = "🤔 thinking…";
|
|
795
|
+
var body = document.createElement("div");
|
|
796
|
+
body.className = "message-thinking-content";
|
|
797
|
+
details.appendChild(summary);
|
|
798
|
+
details.appendChild(body);
|
|
799
|
+
historyEl.appendChild(details);
|
|
800
|
+
historyEl.scrollTop = historyEl.scrollHeight;
|
|
801
|
+
currentThinkingBlock = details;
|
|
802
|
+
currentThinkingBody = body;
|
|
803
|
+
return body;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function collapseThinkingBlock() {
|
|
807
|
+
if (currentThinkingBlock) {
|
|
808
|
+
currentThinkingBlock.open = false;
|
|
809
|
+
var summary = currentThinkingBlock.querySelector("summary");
|
|
810
|
+
if (summary) summary.textContent = "🤔 thinking (finished)";
|
|
811
|
+
}
|
|
812
|
+
currentThinkingBlock = null;
|
|
813
|
+
currentThinkingBody = null;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function currentPageState() {
|
|
817
|
+
var reader = window[STATE_GLOBAL] || {};
|
|
818
|
+
var out = {};
|
|
819
|
+
Object.keys(reader).forEach(function(k) {
|
|
820
|
+
try { out[k] = reader[k](); } catch (e) { out[k] = "<error: " + e.message + ">"; }
|
|
821
|
+
});
|
|
822
|
+
return out;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function currentSystemPrompt() {
|
|
826
|
+
return [
|
|
827
|
+
"You are integrated into a web page as an AI assistant. You have tools available to change page state or fetch information.",
|
|
828
|
+
"",
|
|
829
|
+
"RULES for tool use:",
|
|
830
|
+
"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.",
|
|
831
|
+
"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).",
|
|
832
|
+
"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.",
|
|
833
|
+
"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.",
|
|
834
|
+
"",
|
|
835
|
+
"Current page state:",
|
|
836
|
+
JSON.stringify(currentPageState(), null, 2)
|
|
837
|
+
].join("\n");
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// Per-turn AbortController — lets the Clear button (or a new submit)
|
|
841
|
+
// terminate an in-flight runChatLoop.
|
|
842
|
+
var currentAbort = null;
|
|
843
|
+
|
|
844
|
+
clearBtn.addEventListener("click", function() {
|
|
845
|
+
if (currentAbort) { try { currentAbort.abort(); } catch (e) { /* noop */ } }
|
|
846
|
+
conversation = [];
|
|
847
|
+
historyEl.innerHTML = "";
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
// Enter submits, Shift+Enter inserts a newline — matches llm_meta_chat's
|
|
851
|
+
// input UX so users can type multi-line prompts without accidentally
|
|
852
|
+
// firing the form.
|
|
853
|
+
inputEl.addEventListener("keydown", function(e) {
|
|
854
|
+
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
|
|
855
|
+
e.preventDefault();
|
|
856
|
+
if (typeof formEl.requestSubmit === "function") formEl.requestSubmit();
|
|
857
|
+
else formEl.dispatchEvent(new Event("submit", { cancelable: true }));
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
formEl.addEventListener("submit", async function(e) {
|
|
862
|
+
e.preventDefault();
|
|
863
|
+
var userText = inputEl.value.trim();
|
|
864
|
+
if (!userText) return;
|
|
865
|
+
inputEl.value = "";
|
|
866
|
+
appendTurn("user", userText);
|
|
867
|
+
|
|
868
|
+
if (currentAbort) { try { currentAbort.abort(); } catch (e) { /* noop */ } }
|
|
869
|
+
currentAbort = new AbortController();
|
|
870
|
+
|
|
871
|
+
var messages = [{ role: "system", content: currentSystemPrompt() }]
|
|
872
|
+
.concat(conversation)
|
|
873
|
+
.concat([{ role: "user", content: userText }]);
|
|
874
|
+
|
|
875
|
+
var assistantBody = appendTurn("assistant", "");
|
|
876
|
+
var assistantMarkdown = ""; // accumulate raw markdown, re-render on each delta
|
|
877
|
+
|
|
878
|
+
try {
|
|
879
|
+
await wellKnownReady;
|
|
880
|
+
|
|
881
|
+
var result = await runChatLoop({
|
|
882
|
+
baseUrl: META_BASE,
|
|
883
|
+
apiKeyUuid: API_KEY_UUID,
|
|
884
|
+
modelName: MODEL,
|
|
885
|
+
messages: messages,
|
|
886
|
+
localTools: localTools,
|
|
887
|
+
remoteTools: remoteTools,
|
|
888
|
+
hostWideTools: hostWideTools,
|
|
889
|
+
aiActions: window[ACTIONS_GLOBAL] || {},
|
|
890
|
+
maxRounds: MAX_ROUNDS,
|
|
891
|
+
signal: currentAbort.signal,
|
|
892
|
+
onRoundStart: function(roundIdx) {
|
|
893
|
+
// Loop mechanics are debugging info, not user-facing signal.
|
|
894
|
+
// Reuse the same assistant bubble across rounds — text just
|
|
895
|
+
// keeps streaming into it (accumulating markdown). Weaker
|
|
896
|
+
// models sometimes emit tool_calls in >1 round instead of
|
|
897
|
+
// synthesizing right away; keeping one bubble makes the
|
|
898
|
+
// resulting conversation read as ONE reply rather than a
|
|
899
|
+
// debug trace with round separators.
|
|
900
|
+
collapseThinkingBlock();
|
|
901
|
+
},
|
|
902
|
+
onThinkingDelta: function(delta) {
|
|
903
|
+
var body = ensureThinkingBlock();
|
|
904
|
+
body.appendChild(document.createTextNode(delta));
|
|
905
|
+
body.scrollTop = body.scrollHeight;
|
|
906
|
+
historyEl.scrollTop = historyEl.scrollHeight;
|
|
907
|
+
},
|
|
908
|
+
onTextDelta: function(delta) {
|
|
909
|
+
collapseThinkingBlock();
|
|
910
|
+
assistantMarkdown += delta;
|
|
911
|
+
renderMarkdownInto(assistantBody, assistantMarkdown);
|
|
912
|
+
historyEl.scrollTop = historyEl.scrollHeight;
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
conversation.push({ role: "user", content: userText });
|
|
916
|
+
conversation.push({ role: "assistant", content: result.content });
|
|
917
|
+
|
|
918
|
+
// Append a compact "tools used" footer INSIDE the assistant
|
|
919
|
+
// bubble so users have visual confirmation of what the LLM
|
|
920
|
+
// actually did — especially important when the LLM's own text
|
|
921
|
+
// is incomplete (e.g. "Let me first search…" with no follow-up
|
|
922
|
+
// synthesis, common with weaker tool-use models).
|
|
923
|
+
if (result.dispatched.length > 0) {
|
|
924
|
+
var chips = document.createElement("div");
|
|
925
|
+
chips.className = "lmw-tool-chips";
|
|
926
|
+
result.dispatched.forEach(function(d) {
|
|
927
|
+
var chip = document.createElement("span");
|
|
928
|
+
chip.className = "lmw-tool-chip" + (d.error ? " error" : "");
|
|
929
|
+
chip.textContent = (d.error ? "❌ " : "🔧 ") + d.toolCall.name;
|
|
930
|
+
if (d.error) chip.title = d.error.message;
|
|
931
|
+
chips.appendChild(chip);
|
|
932
|
+
});
|
|
933
|
+
assistantBody.appendChild(chips);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Skipped = LLM tried a tool that doesn't exist. Real signal.
|
|
937
|
+
if (result.skipped.length > 0) {
|
|
938
|
+
appendTurn("system", "Not available on this page: " +
|
|
939
|
+
result.skipped.map(function(t) { return t.name; }).join(", "));
|
|
940
|
+
}
|
|
941
|
+
// Loop termination reasons:
|
|
942
|
+
// "duplicate_tool_calls" — LLM re-called an already-invoked
|
|
943
|
+
// tool; the earlier round's dispatch already produced the
|
|
944
|
+
// answer/effect, so we silently absorb this (no user warning).
|
|
945
|
+
// otherwise (e.g. max_rounds) — the loop hit a real hard cap
|
|
946
|
+
// that likely truncated the response; user should know.
|
|
947
|
+
if (result.stopped_reason && result.stopped_reason !== "duplicate_tool_calls") {
|
|
948
|
+
appendTurn("system",
|
|
949
|
+
"The assistant reached its tool-use limit before finishing. " +
|
|
950
|
+
"The reply above may be incomplete — try asking again or rephrasing.");
|
|
951
|
+
}
|
|
952
|
+
} catch (err) {
|
|
953
|
+
if (err.name === "AbortError" || /aborted/i.test(err.message || "")) {
|
|
954
|
+
appendTurn("system", "⏹ stopped");
|
|
955
|
+
} else {
|
|
956
|
+
appendTurn("error", err.message);
|
|
957
|
+
}
|
|
958
|
+
} finally {
|
|
959
|
+
currentAbort = null;
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
</script>
|