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,717 @@
|
|
|
1
|
+
// Browser client for the meta-server's client-orchestrated flow.
|
|
2
|
+
//
|
|
3
|
+
// One request = one LLM turn. Streams the reply chunks via callbacks; resolves
|
|
4
|
+
// with the final content + any tool_calls the LLM emitted (unexecuted). The
|
|
5
|
+
// dispatcher that turns tool_calls into local action invocations (or remote
|
|
6
|
+
// MCP round-trips) is layered on top; see runChatLoop below.
|
|
7
|
+
//
|
|
8
|
+
// SHIP TARGETS: the llm_meta_client engine's chat host, and pages that embed
|
|
9
|
+
// the widget directly (e.g. PubDictionaries' text_annotation view). Kept as a
|
|
10
|
+
// single self-contained ES module so the copy-vendored PubDictionaries build
|
|
11
|
+
// stays a one-file drop-in.
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// singleLlmCall — one turn, streamed
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
//
|
|
17
|
+
// Contract:
|
|
18
|
+
// const result = await singleLlmCall({
|
|
19
|
+
// baseUrl: 'https://meta-server.example',
|
|
20
|
+
// apiKeyUuid: 'ollama-local', // or a real llm_api_key uuid
|
|
21
|
+
// modelName: 'qwen3-6-35b-fast',
|
|
22
|
+
// messages: [{role: 'user', content: 'hi'}],
|
|
23
|
+
// toolIds: [12, 34], // optional; MCP tools registered
|
|
24
|
+
// // on the meta-server
|
|
25
|
+
// localTools: [ // optional; inline schemas the
|
|
26
|
+
// { name: 'add_dictionaries', // client declares for its own
|
|
27
|
+
// description: '...', // page-embedded actions (window.
|
|
28
|
+
// inputSchema: {...JSON Schema...} } // aiActions) that it dispatches
|
|
29
|
+
// ], // itself after the LLM emits.
|
|
30
|
+
// generationSettings: {temperature: 0.7},// optional
|
|
31
|
+
// bearerToken: 'eyJhbGci...', // optional; anonymous if omitted
|
|
32
|
+
// onTextDelta: (str) => {},
|
|
33
|
+
// onThinkingDelta: (str) => {},
|
|
34
|
+
// onToolCall: (tc) => {}, // { id, name, arguments }
|
|
35
|
+
// onPhase: (name) => {}, // 'thinking' | 'tool_execution' | ...
|
|
36
|
+
// signal: abortController.signal
|
|
37
|
+
// })
|
|
38
|
+
// // result: { content, finishReason, toolCalls }
|
|
39
|
+
//
|
|
40
|
+
// Rejects if the server emits an `error` SSE event, or on network / abort.
|
|
41
|
+
export async function singleLlmCall({
|
|
42
|
+
baseUrl,
|
|
43
|
+
apiKeyUuid,
|
|
44
|
+
modelName,
|
|
45
|
+
messages,
|
|
46
|
+
toolIds = [],
|
|
47
|
+
localTools = [],
|
|
48
|
+
generationSettings = {},
|
|
49
|
+
bearerToken,
|
|
50
|
+
onTextDelta,
|
|
51
|
+
onThinkingDelta,
|
|
52
|
+
onToolCall,
|
|
53
|
+
onPhase,
|
|
54
|
+
signal,
|
|
55
|
+
}) {
|
|
56
|
+
if (!baseUrl || !apiKeyUuid || !modelName) {
|
|
57
|
+
throw new Error("singleLlmCall: baseUrl, apiKeyUuid, modelName are required")
|
|
58
|
+
}
|
|
59
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
60
|
+
throw new Error("singleLlmCall: messages must be a non-empty array")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const url = `${baseUrl.replace(/\/$/, "")}/api/llm_api_keys/${encodeURIComponent(
|
|
64
|
+
apiKeyUuid
|
|
65
|
+
)}/models/${encodeURIComponent(modelName)}/single_llm_calls`
|
|
66
|
+
|
|
67
|
+
const headers = { "Content-Type": "application/json", Accept: "text/event-stream" }
|
|
68
|
+
if (bearerToken) headers["Authorization"] = `Bearer ${bearerToken}`
|
|
69
|
+
|
|
70
|
+
const body = JSON.stringify({
|
|
71
|
+
messages,
|
|
72
|
+
tool_ids: toolIds,
|
|
73
|
+
// Convert camelCase → snake_case at the wire boundary; the server's
|
|
74
|
+
// strong-params permits `input_schema:` and rejects `inputSchema:`.
|
|
75
|
+
local_tools: (localTools || []).map((t) => ({
|
|
76
|
+
name: t.name,
|
|
77
|
+
description: t.description,
|
|
78
|
+
input_schema: t.input_schema || t.inputSchema,
|
|
79
|
+
})),
|
|
80
|
+
generation_settings: generationSettings,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const response = await fetch(url, { method: "POST", headers, body, signal })
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
// Rails may render an HTML error page for routing / auth failures before
|
|
86
|
+
// the SSE stream ever starts. Surface the status so callers can distinguish
|
|
87
|
+
// "endpoint not there" from "stream ran but errored mid-flight".
|
|
88
|
+
const text = await response.text().catch(() => "")
|
|
89
|
+
throw new Error(`singleLlmCall: HTTP ${response.status} ${response.statusText}${text ? " — " + text.slice(0, 200) : ""}`)
|
|
90
|
+
}
|
|
91
|
+
if (!response.body) {
|
|
92
|
+
throw new Error("singleLlmCall: response has no body (streaming unsupported?)")
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const toolCalls = []
|
|
96
|
+
let content = ""
|
|
97
|
+
let finishReason = null
|
|
98
|
+
|
|
99
|
+
// The `done` frame carries the authoritative content string too, but we
|
|
100
|
+
// still concatenate deltas because the callback consumer usually wants
|
|
101
|
+
// realtime rendering. The final content-from-`done` wins in case the LLM's
|
|
102
|
+
// final content differs from the streamed sum (which happens with some
|
|
103
|
+
// providers that emit their final text after tool_calls).
|
|
104
|
+
|
|
105
|
+
for await (const event of parseSseStream(response.body, signal)) {
|
|
106
|
+
switch (event.name) {
|
|
107
|
+
case "text_delta": {
|
|
108
|
+
const delta = event.data?.delta
|
|
109
|
+
if (typeof delta === "string" && delta.length > 0) {
|
|
110
|
+
content += delta
|
|
111
|
+
onTextDelta?.(delta)
|
|
112
|
+
}
|
|
113
|
+
break
|
|
114
|
+
}
|
|
115
|
+
case "thinking_delta": {
|
|
116
|
+
const delta = event.data?.delta
|
|
117
|
+
if (typeof delta === "string" && delta.length > 0) {
|
|
118
|
+
onThinkingDelta?.(delta)
|
|
119
|
+
}
|
|
120
|
+
break
|
|
121
|
+
}
|
|
122
|
+
case "tool_call": {
|
|
123
|
+
const tc = event.data?.tool_call
|
|
124
|
+
if (tc && tc.name) {
|
|
125
|
+
toolCalls.push(tc)
|
|
126
|
+
onToolCall?.(tc)
|
|
127
|
+
}
|
|
128
|
+
break
|
|
129
|
+
}
|
|
130
|
+
case "phase": {
|
|
131
|
+
const name = event.data?.name
|
|
132
|
+
if (typeof name === "string") onPhase?.(name)
|
|
133
|
+
break
|
|
134
|
+
}
|
|
135
|
+
case "done": {
|
|
136
|
+
// Server-declared final content + finish_reason. Prefer these over
|
|
137
|
+
// the delta-sum when they disagree (see comment above).
|
|
138
|
+
if (typeof event.data?.content === "string") content = event.data.content
|
|
139
|
+
if (event.data?.finish_reason != null) finishReason = event.data.finish_reason
|
|
140
|
+
return { content, finishReason, toolCalls }
|
|
141
|
+
}
|
|
142
|
+
case "error": {
|
|
143
|
+
const code = event.data?.code || "server_error"
|
|
144
|
+
const message = event.data?.message || "meta-server emitted an error event"
|
|
145
|
+
const err = new Error(`singleLlmCall: ${code}: ${message}`)
|
|
146
|
+
err.code = code
|
|
147
|
+
throw err
|
|
148
|
+
}
|
|
149
|
+
// Unknown event names are ignored on purpose — the server may add new
|
|
150
|
+
// ones (e.g. a future `usage` frame) without breaking older clients.
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Stream closed without a `done` frame. Treat as an error so the caller
|
|
155
|
+
// doesn't silently accept a truncated response as success.
|
|
156
|
+
throw new Error("singleLlmCall: stream closed without done event")
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// parseSseStream — async iterator over parsed SSE events
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
//
|
|
163
|
+
// SSE framing (per whatwg / EventSource spec):
|
|
164
|
+
// - Frames are separated by a blank line (`\n\n`, possibly `\r\n\r\n`).
|
|
165
|
+
// - Within a frame, each line is `field: value` or `:comment` (ignored).
|
|
166
|
+
// - `event:` sets the event name (default `message`).
|
|
167
|
+
// - `data:` lines accumulate; multiple `data:` lines concatenate with `\n`.
|
|
168
|
+
// - `id:` and `retry:` are ignored here (we're not using automatic reconnect).
|
|
169
|
+
//
|
|
170
|
+
// Emits: { name: string, data: object | null }
|
|
171
|
+
// data is JSON.parse'd if it looks like an object/array; malformed JSON is
|
|
172
|
+
// surfaced as { name: 'error', data: {code, message} } so callers can bail.
|
|
173
|
+
export async function* parseSseStream(readableStream, signal) {
|
|
174
|
+
const reader = readableStream.getReader()
|
|
175
|
+
const decoder = new TextDecoder("utf-8")
|
|
176
|
+
let buffer = ""
|
|
177
|
+
|
|
178
|
+
const onAbort = () => { try { reader.cancel() } catch { /* noop */ } }
|
|
179
|
+
signal?.addEventListener("abort", onAbort)
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
while (true) {
|
|
183
|
+
const { value, done } = await reader.read()
|
|
184
|
+
if (done) break
|
|
185
|
+
buffer += decoder.decode(value, { stream: true })
|
|
186
|
+
|
|
187
|
+
// Split on frame boundary. Keep the trailing partial in `buffer`.
|
|
188
|
+
let sepIdx
|
|
189
|
+
// Accept CRLF or LF-only boundaries; the meta-server emits LF-only but
|
|
190
|
+
// proxies (nginx, cloudflare) sometimes reformat.
|
|
191
|
+
while (
|
|
192
|
+
(sepIdx = indexOfEither(buffer, "\n\n", "\r\n\r\n")) !== -1
|
|
193
|
+
) {
|
|
194
|
+
const rawFrame = buffer.slice(0, sepIdx)
|
|
195
|
+
buffer = buffer.slice(
|
|
196
|
+
sepIdx + (buffer.slice(sepIdx, sepIdx + 4) === "\r\n\r\n" ? 4 : 2)
|
|
197
|
+
)
|
|
198
|
+
const parsed = parseSseFrame(rawFrame)
|
|
199
|
+
if (parsed) yield parsed
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Flush any trailing frame that wasn't followed by a blank line.
|
|
203
|
+
if (buffer.trim().length > 0) {
|
|
204
|
+
const parsed = parseSseFrame(buffer)
|
|
205
|
+
if (parsed) yield parsed
|
|
206
|
+
}
|
|
207
|
+
} finally {
|
|
208
|
+
signal?.removeEventListener("abort", onAbort)
|
|
209
|
+
try { reader.releaseLock() } catch { /* noop */ }
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function indexOfEither(str, a, b) {
|
|
214
|
+
const ai = str.indexOf(a)
|
|
215
|
+
const bi = str.indexOf(b)
|
|
216
|
+
if (ai === -1) return bi
|
|
217
|
+
if (bi === -1) return ai
|
|
218
|
+
return Math.min(ai, bi)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// runTurn — one LLM turn + fire-and-forget local dispatch
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
//
|
|
225
|
+
// The Piece-B primitive: driven by `singleLlmCall`, then after `done` invokes
|
|
226
|
+
// any tool_calls that name an entry in `aiActions` (window.aiActions on the
|
|
227
|
+
// host page). Sequential invocation preserves the order the LLM emitted them
|
|
228
|
+
// in — avoids races on shared DOM state (e.g. two aiActions mutating the same
|
|
229
|
+
// selection list).
|
|
230
|
+
//
|
|
231
|
+
// Fire-and-forget = no result is fed back to the LLM. If a caller wants
|
|
232
|
+
// LLM-loop semantics (tool result → follow-up LLM turn), that's Piece D.
|
|
233
|
+
//
|
|
234
|
+
// Contract:
|
|
235
|
+
// const result = await runTurn({
|
|
236
|
+
// ...singleLlmCall opts,
|
|
237
|
+
// aiActions: window.aiActions // { name: (args) => any|Promise }
|
|
238
|
+
// })
|
|
239
|
+
// // result: { content, finishReason, toolCalls,
|
|
240
|
+
// // dispatched: [{ toolCall, value|error }],
|
|
241
|
+
// // skipped: [ toolCall ] }
|
|
242
|
+
//
|
|
243
|
+
// `dispatched` covers everything invoked (whether it succeeded or threw);
|
|
244
|
+
// `skipped` covers tool_calls whose name isn't in aiActions — Piece D turns
|
|
245
|
+
// those into MCP proxy round-trips. Errors from aiActions are captured, NOT
|
|
246
|
+
// thrown, so one broken action doesn't stop the rest of the batch.
|
|
247
|
+
export async function runTurn(opts) {
|
|
248
|
+
const { aiActions = {}, ...singleOpts } = opts
|
|
249
|
+
const result = await singleLlmCall(singleOpts)
|
|
250
|
+
const { dispatched, skipped } = await dispatchLocalToolCalls(result.toolCalls, aiActions)
|
|
251
|
+
return { ...result, dispatched, skipped }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Standalone dispatcher — exposed for tests, and for Piece D which wants to
|
|
255
|
+
// separate the local phase from its own remote-loop logic.
|
|
256
|
+
export async function dispatchLocalToolCalls(toolCalls, aiActions) {
|
|
257
|
+
const dispatched = []
|
|
258
|
+
const skipped = []
|
|
259
|
+
for (const tc of toolCalls || []) {
|
|
260
|
+
const handler = aiActions?.[tc.name]
|
|
261
|
+
if (typeof handler !== "function") {
|
|
262
|
+
skipped.push(tc)
|
|
263
|
+
continue
|
|
264
|
+
}
|
|
265
|
+
const args = coerceArguments(tc.arguments)
|
|
266
|
+
try {
|
|
267
|
+
const value = await handler(args)
|
|
268
|
+
dispatched.push({ toolCall: tc, value })
|
|
269
|
+
} catch (error) {
|
|
270
|
+
dispatched.push({ toolCall: tc, error })
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return { dispatched, skipped }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
// runChatLoop — Piece D: client-orchestrated multi-turn loop
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
//
|
|
280
|
+
// Wraps `singleLlmCall` in an actual loop so the LLM can call tools, see
|
|
281
|
+
// their results, and produce a synthesized final response.
|
|
282
|
+
//
|
|
283
|
+
// Handles all three tool classes (see memory: project_mcp_tool_classes):
|
|
284
|
+
// Class 1 — remoteTools[]: hub-registered; POST via meta-server proxy
|
|
285
|
+
// Class 2 — hostWideTools[]: well-known; POST directly to host's /mcp
|
|
286
|
+
// Class 3 — aiActions: page-embedded; invoke JS in-process
|
|
287
|
+
//
|
|
288
|
+
// Per round:
|
|
289
|
+
// 1. call `singleLlmCall` with merged local_tools (Class 2 + 3 schemas) +
|
|
290
|
+
// tool_ids (Class 1 references)
|
|
291
|
+
// 2. classify emitted tool_calls by name (aiActions → hostWide → remote → unknown)
|
|
292
|
+
// 3. dispatch Class 3 fire-and-forget
|
|
293
|
+
// 4. dispatch Class 2 via direct MCP; Class 1 via meta-server proxy;
|
|
294
|
+
// collect results in emission order
|
|
295
|
+
// 5. if any round-trip results, append assistant-with-tool_calls turn and
|
|
296
|
+
// one role:tool message per result; loop
|
|
297
|
+
// 6. terminate when no round-trip results (Class 3 alone doesn't loop),
|
|
298
|
+
// or on hitting maxRounds
|
|
299
|
+
//
|
|
300
|
+
// Contract:
|
|
301
|
+
// const result = await runChatLoop({
|
|
302
|
+
// ...singleLlmCall opts,
|
|
303
|
+
// aiActions: window.aiActions,
|
|
304
|
+
// hostWideTools: [{ name, description, input_schema, endpoint }, ...],
|
|
305
|
+
// remoteTools: [{ id, name, description, input_schema }, ...],
|
|
306
|
+
// maxRounds: 10,
|
|
307
|
+
// onRoundStart: (idx) => {},
|
|
308
|
+
// onTextDelta: (delta, roundIdx) => {}
|
|
309
|
+
// })
|
|
310
|
+
// // result: {
|
|
311
|
+
// // content, finishReason,
|
|
312
|
+
// // rounds: [{ round, ..., localCalls, hostWideCalls, remoteCalls, unknownCalls }],
|
|
313
|
+
// // dispatched: [{ toolCall, value|error }],
|
|
314
|
+
// // skipped: [ toolCall ]
|
|
315
|
+
// // }
|
|
316
|
+
//
|
|
317
|
+
// NOTE on tool-name mapping: Class 1 remoteTools[].name must match the name
|
|
318
|
+
// the LLM sees, which is what the server declares to the provider (may be
|
|
319
|
+
// sanitized by McpToolAdapter). Class 2 and Class 3 tool names go through
|
|
320
|
+
// unmodified — LLM sees them as declared in local_tools.
|
|
321
|
+
export async function runChatLoop(opts) {
|
|
322
|
+
const {
|
|
323
|
+
aiActions = {},
|
|
324
|
+
remoteTools = [],
|
|
325
|
+
hostWideTools = [],
|
|
326
|
+
maxRounds = 10,
|
|
327
|
+
signal,
|
|
328
|
+
onRoundStart,
|
|
329
|
+
onTextDelta,
|
|
330
|
+
onThinkingDelta,
|
|
331
|
+
onToolCall,
|
|
332
|
+
onPhase,
|
|
333
|
+
...singleOpts
|
|
334
|
+
} = opts
|
|
335
|
+
|
|
336
|
+
const messages = [ ...(singleOpts.messages || []) ]
|
|
337
|
+
const remoteByName = Object.fromEntries((remoteTools || []).map((t) => [ t.name, t ]))
|
|
338
|
+
// Class 2: host-wide MCP tools (well-known). Map name→{endpoint, ...} for
|
|
339
|
+
// dispatch. If the same name appears in aiActions, Class 3 wins there
|
|
340
|
+
// (checked first in the classifier below); if it also appears in remoteTools,
|
|
341
|
+
// Class 2 wins over Class 1 (host-owned is more direct).
|
|
342
|
+
const hostWideByName = Object.fromEntries((hostWideTools || []).map((t) => [ t.name, t ]))
|
|
343
|
+
const toolIds = [ ...(singleOpts.toolIds || []), ...(remoteTools || []).map((t) => t.id) ]
|
|
344
|
+
|
|
345
|
+
// Class 2 schemas ride inline via local_tools (LLM sees them like Class 3).
|
|
346
|
+
// De-dupe by name; Class 3 wins so we don't clobber an aiAction's schema.
|
|
347
|
+
const inlineByName = {}
|
|
348
|
+
for (const t of singleOpts.localTools || []) inlineByName[t.name] = t
|
|
349
|
+
for (const t of hostWideTools || []) {
|
|
350
|
+
if (!inlineByName[t.name]) inlineByName[t.name] = { name: t.name, description: t.description, input_schema: t.input_schema }
|
|
351
|
+
}
|
|
352
|
+
const mergedLocalTools = Object.values(inlineByName)
|
|
353
|
+
|
|
354
|
+
const rounds = []
|
|
355
|
+
const allDispatched = []
|
|
356
|
+
const allSkipped = []
|
|
357
|
+
let lastResult = null
|
|
358
|
+
|
|
359
|
+
// Client-side de-dupe guard. Written for models that seemed to re-invoke
|
|
360
|
+
// the same tool with the same arguments after receiving its result.
|
|
361
|
+
// Much of that turned out to be the hub, not the model: single_llm_turn!
|
|
362
|
+
// reported every tool call in the history it was given, so the call the
|
|
363
|
+
// widget had just dispatched came back as a "new" one (fixed in
|
|
364
|
+
// llm_meta_server 4441f61, 2026-09-20). Kept as defence in depth — a
|
|
365
|
+
// genuinely repetitive model, or an older hub, still gets caught here. Track every (name, args-JSON) tuple we've already dispatched
|
|
366
|
+
// this loop; when the LLM emits a duplicate, skip it. A round in which
|
|
367
|
+
// ALL tool_calls are duplicates has nothing left to dispatch — treat as
|
|
368
|
+
// the LLM's implicit "I'm done" and terminate with a distinct reason
|
|
369
|
+
// (widget can render silently since the earlier round already produced
|
|
370
|
+
// whatever text/action the user gets).
|
|
371
|
+
const seenToolCalls = new Set()
|
|
372
|
+
const toolKey = (tc) => {
|
|
373
|
+
let args
|
|
374
|
+
try { args = coerceArguments(tc.arguments) } catch { args = tc.arguments }
|
|
375
|
+
let serialized
|
|
376
|
+
try {
|
|
377
|
+
// Stable-order stringify — sort top-level keys so semantically-
|
|
378
|
+
// equivalent tool_calls (same content, different key order in the
|
|
379
|
+
// LLM's emission) hash to the same string.
|
|
380
|
+
if (args && typeof args === "object" && !Array.isArray(args)) {
|
|
381
|
+
const sorted = Object.keys(args).sort().reduce((o, k) => { o[k] = args[k]; return o }, {})
|
|
382
|
+
serialized = JSON.stringify(sorted)
|
|
383
|
+
} else {
|
|
384
|
+
serialized = JSON.stringify(args)
|
|
385
|
+
}
|
|
386
|
+
} catch { serialized = String(args) }
|
|
387
|
+
return tc.name + "" + serialized
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
for (let round = 0; round < maxRounds; round++) {
|
|
391
|
+
// Bail immediately on external abort — don't start a new LLM turn if
|
|
392
|
+
// the user hit Clear / navigated away between rounds.
|
|
393
|
+
if (signal?.aborted) throw new DOMException("aborted", "AbortError")
|
|
394
|
+
onRoundStart?.(round)
|
|
395
|
+
|
|
396
|
+
const turnResult = await singleLlmCall({
|
|
397
|
+
...singleOpts,
|
|
398
|
+
messages,
|
|
399
|
+
toolIds,
|
|
400
|
+
localTools: mergedLocalTools,
|
|
401
|
+
signal,
|
|
402
|
+
onTextDelta: onTextDelta ? (d) => onTextDelta(d, round) : undefined,
|
|
403
|
+
onThinkingDelta: onThinkingDelta ? (d) => onThinkingDelta(d, round) : undefined,
|
|
404
|
+
onToolCall: onToolCall ? (t) => onToolCall(t, round) : undefined,
|
|
405
|
+
onPhase: onPhase ? (n) => onPhase(n, round) : undefined
|
|
406
|
+
})
|
|
407
|
+
lastResult = turnResult
|
|
408
|
+
|
|
409
|
+
// Classify tool_calls by class. Precedence: Class 3 (aiActions, no
|
|
410
|
+
// network, same-page) → Class 2 (host-wide well-known, direct MCP) →
|
|
411
|
+
// Class 1 (hub-registered, meta-server proxy) → unknown. Duplicates
|
|
412
|
+
// of prior rounds are filtered first — see seenToolCalls above.
|
|
413
|
+
const localCalls = [] // Class 3
|
|
414
|
+
const hostWideCalls = [] // Class 2
|
|
415
|
+
const remoteCalls = [] // Class 1
|
|
416
|
+
const unknownCalls = []
|
|
417
|
+
const duplicateCalls = []
|
|
418
|
+
const emittedCount = (turnResult.toolCalls || []).length
|
|
419
|
+
for (const tc of turnResult.toolCalls || []) {
|
|
420
|
+
const key = toolKey(tc)
|
|
421
|
+
if (seenToolCalls.has(key)) { duplicateCalls.push(tc); continue }
|
|
422
|
+
seenToolCalls.add(key)
|
|
423
|
+
if (typeof aiActions[tc.name] === "function") localCalls.push(tc)
|
|
424
|
+
else if (hostWideByName[tc.name]) hostWideCalls.push(tc)
|
|
425
|
+
else if (remoteByName[tc.name]) remoteCalls.push(tc)
|
|
426
|
+
else unknownCalls.push(tc)
|
|
427
|
+
}
|
|
428
|
+
// If the LLM emitted tool_calls but ALL were duplicates, it's stuck
|
|
429
|
+
// re-calling. Terminate now instead of dispatching + looping again.
|
|
430
|
+
const allDuplicates = emittedCount > 0 && duplicateCalls.length === emittedCount
|
|
431
|
+
if (allDuplicates) {
|
|
432
|
+
rounds.push({
|
|
433
|
+
round, content: turnResult.content, finishReason: turnResult.finishReason,
|
|
434
|
+
localCalls: [], hostWideCalls: [], remoteCalls: [], unknownCalls: [], duplicateCalls,
|
|
435
|
+
toolCalls: turnResult.toolCalls
|
|
436
|
+
})
|
|
437
|
+
return {
|
|
438
|
+
content: turnResult.content,
|
|
439
|
+
finishReason: turnResult.finishReason,
|
|
440
|
+
rounds, dispatched: allDispatched, skipped: allSkipped,
|
|
441
|
+
stopped_reason: "duplicate_tool_calls"
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Class 3: locals — fire-and-forget
|
|
446
|
+
const localOut = await dispatchLocalToolCalls(localCalls, aiActions)
|
|
447
|
+
allDispatched.push(...localOut.dispatched)
|
|
448
|
+
|
|
449
|
+
// Class 2: host-wide — direct MCP JSON-RPC POST to the host's own endpoint
|
|
450
|
+
const roundTripResults = []
|
|
451
|
+
for (const tc of hostWideCalls) {
|
|
452
|
+
const tool = hostWideByName[tc.name]
|
|
453
|
+
const args = coerceArguments(tc.arguments)
|
|
454
|
+
try {
|
|
455
|
+
const value = await callMcpTool({ endpoint: tool.endpoint, name: tc.name, args, signal })
|
|
456
|
+
allDispatched.push({ toolCall: tc, value })
|
|
457
|
+
roundTripResults.push({ tc, result: value })
|
|
458
|
+
} catch (error) {
|
|
459
|
+
allDispatched.push({ toolCall: tc, error })
|
|
460
|
+
roundTripResults.push({ tc, result: { error: String(error.message || error) } })
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Class 1: remote — meta-server proxy round-trip. Order preserved.
|
|
465
|
+
for (const tc of remoteCalls) {
|
|
466
|
+
const tool = remoteByName[tc.name]
|
|
467
|
+
const args = coerceArguments(tc.arguments)
|
|
468
|
+
try {
|
|
469
|
+
const value = await dispatchRemoteToolCall({
|
|
470
|
+
baseUrl: singleOpts.baseUrl,
|
|
471
|
+
bearerToken: singleOpts.bearerToken,
|
|
472
|
+
toolId: tool.id,
|
|
473
|
+
args: args,
|
|
474
|
+
signal
|
|
475
|
+
})
|
|
476
|
+
allDispatched.push({ toolCall: tc, value })
|
|
477
|
+
roundTripResults.push({ tc, result: value })
|
|
478
|
+
} catch (error) {
|
|
479
|
+
allDispatched.push({ toolCall: tc, error })
|
|
480
|
+
// Feed the error text back to the LLM as the tool result — better
|
|
481
|
+
// than dropping it (the LLM can react, apologize, retry differently).
|
|
482
|
+
roundTripResults.push({ tc, result: { error: String(error.message || error) } })
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
allSkipped.push(...unknownCalls)
|
|
487
|
+
rounds.push({
|
|
488
|
+
round, content: turnResult.content, finishReason: turnResult.finishReason,
|
|
489
|
+
localCalls, hostWideCalls, remoteCalls, unknownCalls,
|
|
490
|
+
toolCalls: turnResult.toolCalls
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
// Terminate when there's nothing to feed back. Locals (Class 3) are
|
|
494
|
+
// fire-and-forget; unknowns can't be handled; only Class 2 + Class 1
|
|
495
|
+
// execution produces tool results the LLM should see.
|
|
496
|
+
if (roundTripResults.length === 0) {
|
|
497
|
+
return {
|
|
498
|
+
content: turnResult.content,
|
|
499
|
+
finishReason: turnResult.finishReason,
|
|
500
|
+
rounds, dispatched: allDispatched, skipped: allSkipped
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Build follow-up: assistant-with-tool_calls + one tool-result per
|
|
505
|
+
// round-tripped call (both Class 2 and Class 1). Server-side:
|
|
506
|
+
// LlmRbFacade#messages_to_llm_objects preserves the assistant-with-
|
|
507
|
+
// tool_calls entry via LLM::Message.extra[:tool_calls], and
|
|
508
|
+
// split_history_from_current_input bundles the trailing tool-results
|
|
509
|
+
// as the input to the next session.chat call.
|
|
510
|
+
messages.push({
|
|
511
|
+
role: "assistant",
|
|
512
|
+
content: turnResult.content || "",
|
|
513
|
+
tool_calls: turnResult.toolCalls
|
|
514
|
+
})
|
|
515
|
+
for (const { tc, result } of roundTripResults) {
|
|
516
|
+
messages.push({
|
|
517
|
+
role: "tool",
|
|
518
|
+
tool_call_id: tc.id || "",
|
|
519
|
+
name: tc.name,
|
|
520
|
+
content: typeof result === "string" ? result : JSON.stringify(result)
|
|
521
|
+
})
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Hit the cap without terminating. Return what we have + a diagnostic flag
|
|
526
|
+
// so the widget can render "stopped after N rounds" instead of hanging.
|
|
527
|
+
return {
|
|
528
|
+
content: lastResult?.content || "",
|
|
529
|
+
finishReason: lastResult?.finishReason || null,
|
|
530
|
+
rounds, dispatched: allDispatched, skipped: allSkipped,
|
|
531
|
+
stopped_reason: `max_rounds (${maxRounds}) exceeded`
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ---------------------------------------------------------------------------
|
|
536
|
+
// Class 2: host-wide MCP tools (well-known + direct dispatch)
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
//
|
|
539
|
+
// See memory: project_mcp_tool_classes. Class 2 tools are declared by the
|
|
540
|
+
// HOST site at `${origin}/.well-known/mcp.json` and executed by the widget
|
|
541
|
+
// posting JSON-RPC 2.0 `tools/call` DIRECTLY to the host's own MCP endpoint
|
|
542
|
+
// — no meta-server proxy in the loop. This dissolves the "widget-to-meta-
|
|
543
|
+
// server auth" question for same-origin host-owned tools: browser session
|
|
544
|
+
// cookies flow through automatically.
|
|
545
|
+
//
|
|
546
|
+
// Manifest shape (widget accepts):
|
|
547
|
+
// {
|
|
548
|
+
// "servers": [
|
|
549
|
+
// { "name": "pubdictionaries",
|
|
550
|
+
// "url": "/mcp", // relative or absolute
|
|
551
|
+
// "tools": [
|
|
552
|
+
// { "name": "text_annotation",
|
|
553
|
+
// "description": "...",
|
|
554
|
+
// "input_schema": {...} }
|
|
555
|
+
// ] }
|
|
556
|
+
// ]
|
|
557
|
+
// }
|
|
558
|
+
//
|
|
559
|
+
// The `url` is resolved against the manifest URL's origin, so a same-origin
|
|
560
|
+
// host can just say `/mcp` and the widget fills in the rest.
|
|
561
|
+
|
|
562
|
+
// Fetch a well-known manifest and normalize it: absolute URLs, flat tool list
|
|
563
|
+
// with the owning server's endpoint attached to each entry for dispatch.
|
|
564
|
+
// Fails gracefully — returns [] on network / parse error so a missing or
|
|
565
|
+
// malformed manifest doesn't kill the widget's boot.
|
|
566
|
+
export async function fetchMcpManifest(manifestUrl) {
|
|
567
|
+
let manifest
|
|
568
|
+
try {
|
|
569
|
+
const response = await fetch(manifestUrl)
|
|
570
|
+
if (!response.ok) return []
|
|
571
|
+
manifest = await response.json()
|
|
572
|
+
} catch { return [] }
|
|
573
|
+
|
|
574
|
+
const base = new URL(manifestUrl)
|
|
575
|
+
const out = []
|
|
576
|
+
for (const server of manifest?.servers || []) {
|
|
577
|
+
let endpoint
|
|
578
|
+
try { endpoint = new URL(server.url, base).toString() } catch { continue }
|
|
579
|
+
for (const tool of server.tools || []) {
|
|
580
|
+
if (!tool.name) continue
|
|
581
|
+
out.push({
|
|
582
|
+
name: tool.name,
|
|
583
|
+
description: tool.description || "",
|
|
584
|
+
input_schema: tool.input_schema || tool.inputSchema || { type: "object", properties: {} },
|
|
585
|
+
endpoint,
|
|
586
|
+
serverName: server.name || null
|
|
587
|
+
})
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return out
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// JSON-RPC 2.0 `tools/call` POST to an MCP endpoint. Returns the parsed
|
|
594
|
+
// `result` value (or throws on JSON-RPC error / HTTP failure). Supports
|
|
595
|
+
// both JSON and SSE responses (MCP over HTTP allows either).
|
|
596
|
+
let _mcpReqId = 0
|
|
597
|
+
export async function callMcpTool({ endpoint, name, args, signal }) {
|
|
598
|
+
const response = await fetch(endpoint, {
|
|
599
|
+
method: "POST",
|
|
600
|
+
headers: {
|
|
601
|
+
"Content-Type": "application/json",
|
|
602
|
+
"Accept": "application/json, text/event-stream"
|
|
603
|
+
},
|
|
604
|
+
body: JSON.stringify({
|
|
605
|
+
jsonrpc: "2.0",
|
|
606
|
+
id: ++_mcpReqId,
|
|
607
|
+
method: "tools/call",
|
|
608
|
+
params: { name, arguments: args || {} }
|
|
609
|
+
}),
|
|
610
|
+
signal,
|
|
611
|
+
// Send session cookies for same-origin MCP endpoints. Cross-origin CORS
|
|
612
|
+
// with credentials requires the server to echo Access-Control-Allow-
|
|
613
|
+
// Credentials: true — which most MCP servers won't. This is fine: for
|
|
614
|
+
// cross-origin the widget doesn't send cookies; the server enforces
|
|
615
|
+
// its own auth (API key in header, etc.) if it wants any.
|
|
616
|
+
credentials: "same-origin"
|
|
617
|
+
})
|
|
618
|
+
if (!response.ok) {
|
|
619
|
+
const text = await response.text().catch(() => "")
|
|
620
|
+
throw new Error(`callMcpTool(${name}): HTTP ${response.status}${text ? " — " + text.slice(0, 200) : ""}`)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const contentType = response.headers.get("content-type") || ""
|
|
624
|
+
|
|
625
|
+
if (contentType.includes("application/json")) {
|
|
626
|
+
const body = await response.json()
|
|
627
|
+
if (body?.error) {
|
|
628
|
+
throw new Error(`callMcpTool(${name}): ${body.error.message || "JSON-RPC error"}`)
|
|
629
|
+
}
|
|
630
|
+
return body?.result
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (contentType.includes("text/event-stream")) {
|
|
634
|
+
// MCP over SSE — each SSE `data:` frame is a JSON-RPC message.
|
|
635
|
+
for await (const evt of parseSseStream(response.body, signal)) {
|
|
636
|
+
const payload = evt.data
|
|
637
|
+
if (!payload || typeof payload !== "object") continue
|
|
638
|
+
if (payload.error) {
|
|
639
|
+
throw new Error(`callMcpTool(${name}): ${payload.error.message || "JSON-RPC error"}`)
|
|
640
|
+
}
|
|
641
|
+
if ("result" in payload) return payload.result
|
|
642
|
+
}
|
|
643
|
+
throw new Error(`callMcpTool(${name}): SSE stream ended without result`)
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
throw new Error(`callMcpTool(${name}): unexpected content-type ${contentType}`)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Standalone remote dispatcher — POSTs one tool_call to the meta-server's
|
|
650
|
+
// MCP proxy endpoint. Exposed for tests + reuse.
|
|
651
|
+
export async function dispatchRemoteToolCall({ baseUrl, bearerToken, toolId, args, signal }) {
|
|
652
|
+
if (!baseUrl || toolId == null) {
|
|
653
|
+
throw new Error("dispatchRemoteToolCall: baseUrl and toolId are required")
|
|
654
|
+
}
|
|
655
|
+
const url = `${baseUrl.replace(/\/$/, "")}/api/mcp_tools/${encodeURIComponent(toolId)}/call`
|
|
656
|
+
const headers = { "Content-Type": "application/json" }
|
|
657
|
+
if (bearerToken) headers["Authorization"] = `Bearer ${bearerToken}`
|
|
658
|
+
|
|
659
|
+
const response = await fetch(url, {
|
|
660
|
+
method: "POST",
|
|
661
|
+
headers,
|
|
662
|
+
body: JSON.stringify({ arguments: args || {} }),
|
|
663
|
+
signal
|
|
664
|
+
})
|
|
665
|
+
if (!response.ok) {
|
|
666
|
+
const text = await response.text().catch(() => "")
|
|
667
|
+
throw new Error(`dispatchRemoteToolCall: HTTP ${response.status} ${response.statusText}${text ? " — " + text.slice(0, 200) : ""}`)
|
|
668
|
+
}
|
|
669
|
+
const body = await response.json()
|
|
670
|
+
return body.result
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// llm.rb's Session#extract_tool_calls delivers `arguments` as a parsed object
|
|
674
|
+
// (the OpenAI/Anthropic/Gemini adapters all parse before we see them). But
|
|
675
|
+
// SOMETIMES a provider passes through a raw JSON string (Gemini streaming
|
|
676
|
+
// edge cases have done this in the past). Coerce defensively so aiActions
|
|
677
|
+
// always receive an object.
|
|
678
|
+
function coerceArguments(raw) {
|
|
679
|
+
if (raw == null) return {}
|
|
680
|
+
if (typeof raw === "object") return raw
|
|
681
|
+
if (typeof raw === "string") {
|
|
682
|
+
try { return JSON.parse(raw) } catch { return { _raw: raw } }
|
|
683
|
+
}
|
|
684
|
+
return { _raw: raw }
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function parseSseFrame(raw) {
|
|
688
|
+
let name = "message"
|
|
689
|
+
const dataLines = []
|
|
690
|
+
|
|
691
|
+
for (const line of raw.split("\n")) {
|
|
692
|
+
if (line.length === 0 || line.startsWith(":")) continue
|
|
693
|
+
const colon = line.indexOf(":")
|
|
694
|
+
const field = colon === -1 ? line : line.slice(0, colon)
|
|
695
|
+
// Per spec: single leading space after the colon is stripped.
|
|
696
|
+
let value = colon === -1 ? "" : line.slice(colon + 1)
|
|
697
|
+
if (value.startsWith(" ")) value = value.slice(1)
|
|
698
|
+
|
|
699
|
+
if (field === "event") name = value
|
|
700
|
+
else if (field === "data") dataLines.push(value)
|
|
701
|
+
// id / retry ignored
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (dataLines.length === 0 && name === "message") return null
|
|
705
|
+
|
|
706
|
+
const dataStr = dataLines.join("\n")
|
|
707
|
+
let data = null
|
|
708
|
+
if (dataStr.length > 0) {
|
|
709
|
+
try {
|
|
710
|
+
data = JSON.parse(dataStr)
|
|
711
|
+
} catch (e) {
|
|
712
|
+
// Emit the raw string — callers that care can inspect it.
|
|
713
|
+
data = { _raw: dataStr, _parseError: e.message }
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
return { name, data }
|
|
717
|
+
}
|