@aibranch/llm-meta-widget 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.
|
@@ -0,0 +1,1258 @@
|
|
|
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
|
+
// onToolDispatched: ({toolCall, value, error}) => {}, // after it ran
|
|
36
|
+
// onPhase: (name) => {}, // 'thinking' | 'tool_execution' | ...
|
|
37
|
+
// signal: abortController.signal
|
|
38
|
+
// })
|
|
39
|
+
// // result: { content, finishReason, toolCalls }
|
|
40
|
+
//
|
|
41
|
+
// Rejects if the server emits an `error` SSE event, or on network / abort.
|
|
42
|
+
export async function singleLlmCall({
|
|
43
|
+
baseUrl,
|
|
44
|
+
apiKeyUuid,
|
|
45
|
+
modelName,
|
|
46
|
+
messages,
|
|
47
|
+
toolIds = [],
|
|
48
|
+
localTools = [],
|
|
49
|
+
generationSettings = {},
|
|
50
|
+
bearerToken,
|
|
51
|
+
onTextDelta,
|
|
52
|
+
onThinkingDelta,
|
|
53
|
+
onToolCall,
|
|
54
|
+
onPhase,
|
|
55
|
+
signal,
|
|
56
|
+
}) {
|
|
57
|
+
if (!baseUrl || !apiKeyUuid || !modelName) {
|
|
58
|
+
throw new Error("singleLlmCall: baseUrl, apiKeyUuid, modelName are required")
|
|
59
|
+
}
|
|
60
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
61
|
+
throw new Error("singleLlmCall: messages must be a non-empty array")
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const url = `${baseUrl.replace(/\/$/, "")}/api/llm_api_keys/${encodeURIComponent(
|
|
65
|
+
apiKeyUuid
|
|
66
|
+
)}/models/${encodeURIComponent(modelName)}/single_llm_calls`
|
|
67
|
+
|
|
68
|
+
const headers = { "Content-Type": "application/json", Accept: "text/event-stream" }
|
|
69
|
+
if (bearerToken) headers["Authorization"] = `Bearer ${bearerToken}`
|
|
70
|
+
|
|
71
|
+
const body = JSON.stringify({
|
|
72
|
+
messages,
|
|
73
|
+
tool_ids: toolIds,
|
|
74
|
+
// Convert camelCase → snake_case at the wire boundary; the server's
|
|
75
|
+
// strong-params permits `input_schema:` and rejects `inputSchema:`.
|
|
76
|
+
local_tools: (localTools || []).map((t) => ({
|
|
77
|
+
name: t.name,
|
|
78
|
+
description: t.description,
|
|
79
|
+
input_schema: t.input_schema || t.inputSchema,
|
|
80
|
+
})),
|
|
81
|
+
generation_settings: generationSettings,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const response = await fetch(url, { method: "POST", headers, body, signal })
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
// Rails may render an HTML error page for routing / auth failures before
|
|
87
|
+
// the SSE stream ever starts. Surface the status so callers can distinguish
|
|
88
|
+
// "endpoint not there" from "stream ran but errored mid-flight".
|
|
89
|
+
const text = await response.text().catch(() => "")
|
|
90
|
+
throw new Error(`singleLlmCall: HTTP ${response.status} ${response.statusText}${text ? " — " + text.slice(0, 200) : ""}`)
|
|
91
|
+
}
|
|
92
|
+
if (!response.body) {
|
|
93
|
+
throw new Error("singleLlmCall: response has no body (streaming unsupported?)")
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const toolCalls = []
|
|
97
|
+
let content = ""
|
|
98
|
+
let finishReason = null
|
|
99
|
+
|
|
100
|
+
// The `done` frame carries the authoritative content string too, but we
|
|
101
|
+
// still concatenate deltas because the callback consumer usually wants
|
|
102
|
+
// realtime rendering. The final content-from-`done` wins in case the LLM's
|
|
103
|
+
// final content differs from the streamed sum (which happens with some
|
|
104
|
+
// providers that emit their final text after tool_calls).
|
|
105
|
+
|
|
106
|
+
for await (const event of parseSseStream(response.body, signal)) {
|
|
107
|
+
switch (event.name) {
|
|
108
|
+
case "text_delta": {
|
|
109
|
+
const delta = event.data?.delta
|
|
110
|
+
if (typeof delta === "string" && delta.length > 0) {
|
|
111
|
+
content += delta
|
|
112
|
+
onTextDelta?.(delta)
|
|
113
|
+
}
|
|
114
|
+
break
|
|
115
|
+
}
|
|
116
|
+
case "thinking_delta": {
|
|
117
|
+
const delta = event.data?.delta
|
|
118
|
+
if (typeof delta === "string" && delta.length > 0) {
|
|
119
|
+
onThinkingDelta?.(delta)
|
|
120
|
+
}
|
|
121
|
+
break
|
|
122
|
+
}
|
|
123
|
+
case "tool_call": {
|
|
124
|
+
const tc = event.data?.tool_call
|
|
125
|
+
if (tc && tc.name) {
|
|
126
|
+
toolCalls.push(tc)
|
|
127
|
+
onToolCall?.(tc)
|
|
128
|
+
}
|
|
129
|
+
break
|
|
130
|
+
}
|
|
131
|
+
case "phase": {
|
|
132
|
+
const name = event.data?.name
|
|
133
|
+
if (typeof name === "string") onPhase?.(name)
|
|
134
|
+
break
|
|
135
|
+
}
|
|
136
|
+
case "done": {
|
|
137
|
+
// Server-declared final content + finish_reason. Prefer these over
|
|
138
|
+
// the delta-sum when they disagree (see comment above).
|
|
139
|
+
if (typeof event.data?.content === "string") content = event.data.content
|
|
140
|
+
if (event.data?.finish_reason != null) finishReason = event.data.finish_reason
|
|
141
|
+
return { content, finishReason, toolCalls }
|
|
142
|
+
}
|
|
143
|
+
case "error": {
|
|
144
|
+
const code = event.data?.code || "server_error"
|
|
145
|
+
const message = event.data?.message || "meta-server emitted an error event"
|
|
146
|
+
const err = new Error(`singleLlmCall: ${code}: ${message}`)
|
|
147
|
+
err.code = code
|
|
148
|
+
throw err
|
|
149
|
+
}
|
|
150
|
+
// Unknown event names are ignored on purpose — the server may add new
|
|
151
|
+
// ones (e.g. a future `usage` frame) without breaking older clients.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Stream closed without a `done` frame. Treat as an error so the caller
|
|
156
|
+
// doesn't silently accept a truncated response as success.
|
|
157
|
+
throw new Error("singleLlmCall: stream closed without done event")
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// parseSseStream — async iterator over parsed SSE events
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
//
|
|
164
|
+
// SSE framing (per whatwg / EventSource spec):
|
|
165
|
+
// - Frames are separated by a blank line (`\n\n`, possibly `\r\n\r\n`).
|
|
166
|
+
// - Within a frame, each line is `field: value` or `:comment` (ignored).
|
|
167
|
+
// - `event:` sets the event name (default `message`).
|
|
168
|
+
// - `data:` lines accumulate; multiple `data:` lines concatenate with `\n`.
|
|
169
|
+
// - `id:` and `retry:` are ignored here (we're not using automatic reconnect).
|
|
170
|
+
//
|
|
171
|
+
// Emits: { name: string, data: object | null }
|
|
172
|
+
// data is JSON.parse'd if it looks like an object/array; malformed JSON is
|
|
173
|
+
// surfaced as { name: 'error', data: {code, message} } so callers can bail.
|
|
174
|
+
export async function* parseSseStream(readableStream, signal) {
|
|
175
|
+
const reader = readableStream.getReader()
|
|
176
|
+
const decoder = new TextDecoder("utf-8")
|
|
177
|
+
let buffer = ""
|
|
178
|
+
|
|
179
|
+
const onAbort = () => { try { reader.cancel() } catch { /* noop */ } }
|
|
180
|
+
signal?.addEventListener("abort", onAbort)
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
while (true) {
|
|
184
|
+
const { value, done } = await reader.read()
|
|
185
|
+
if (done) break
|
|
186
|
+
buffer += decoder.decode(value, { stream: true })
|
|
187
|
+
|
|
188
|
+
// Split on frame boundary. Keep the trailing partial in `buffer`.
|
|
189
|
+
let sepIdx
|
|
190
|
+
// Accept CRLF or LF-only boundaries; the meta-server emits LF-only but
|
|
191
|
+
// proxies (nginx, cloudflare) sometimes reformat.
|
|
192
|
+
while (
|
|
193
|
+
(sepIdx = indexOfEither(buffer, "\n\n", "\r\n\r\n")) !== -1
|
|
194
|
+
) {
|
|
195
|
+
const rawFrame = buffer.slice(0, sepIdx)
|
|
196
|
+
buffer = buffer.slice(
|
|
197
|
+
sepIdx + (buffer.slice(sepIdx, sepIdx + 4) === "\r\n\r\n" ? 4 : 2)
|
|
198
|
+
)
|
|
199
|
+
const parsed = parseSseFrame(rawFrame)
|
|
200
|
+
if (parsed) yield parsed
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Flush any trailing frame that wasn't followed by a blank line.
|
|
204
|
+
if (buffer.trim().length > 0) {
|
|
205
|
+
const parsed = parseSseFrame(buffer)
|
|
206
|
+
if (parsed) yield parsed
|
|
207
|
+
}
|
|
208
|
+
} finally {
|
|
209
|
+
signal?.removeEventListener("abort", onAbort)
|
|
210
|
+
try { reader.releaseLock() } catch { /* noop */ }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function indexOfEither(str, a, b) {
|
|
215
|
+
const ai = str.indexOf(a)
|
|
216
|
+
const bi = str.indexOf(b)
|
|
217
|
+
if (ai === -1) return bi
|
|
218
|
+
if (bi === -1) return ai
|
|
219
|
+
return Math.min(ai, bi)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// runTurn — one LLM turn + local dispatch, results fed back
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
//
|
|
226
|
+
// The Piece-B primitive: driven by `singleLlmCall`, then after `done` invokes
|
|
227
|
+
// any tool_calls that name an entry in `aiActions` (window.aiActions on the
|
|
228
|
+
// host page). Sequential invocation preserves the order the LLM emitted them
|
|
229
|
+
// in — avoids races on shared DOM state (e.g. two aiActions mutating the same
|
|
230
|
+
// selection list).
|
|
231
|
+
//
|
|
232
|
+
// Fire-and-forget = no result is fed back to the LLM. If a caller wants
|
|
233
|
+
// LLM-loop semantics (tool result → follow-up LLM turn), that's Piece D.
|
|
234
|
+
//
|
|
235
|
+
// Contract:
|
|
236
|
+
// const result = await runTurn({
|
|
237
|
+
// ...singleLlmCall opts,
|
|
238
|
+
// aiActions: window.aiActions // { name: (args) => any|Promise }
|
|
239
|
+
// })
|
|
240
|
+
// // result: { content, finishReason, toolCalls,
|
|
241
|
+
// // dispatched: [{ toolCall, value|error }],
|
|
242
|
+
// // skipped: [ toolCall ] }
|
|
243
|
+
//
|
|
244
|
+
// `dispatched` covers everything invoked (whether it succeeded or threw);
|
|
245
|
+
// `skipped` covers tool_calls whose name isn't in aiActions — Piece D turns
|
|
246
|
+
// those into MCP proxy round-trips. Errors from aiActions are captured, NOT
|
|
247
|
+
// thrown, so one broken action doesn't stop the rest of the batch.
|
|
248
|
+
export async function runTurn(opts) {
|
|
249
|
+
const { aiActions = {}, ...singleOpts } = opts
|
|
250
|
+
const result = await singleLlmCall(singleOpts)
|
|
251
|
+
const { dispatched, skipped } = await dispatchLocalToolCalls(result.toolCalls, aiActions)
|
|
252
|
+
return { ...result, dispatched, skipped }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Standalone dispatcher — exposed for tests, and for Piece D which wants to
|
|
256
|
+
// separate the local phase from its own remote-loop logic.
|
|
257
|
+
export async function dispatchLocalToolCalls(toolCalls, aiActions) {
|
|
258
|
+
const dispatched = []
|
|
259
|
+
const skipped = []
|
|
260
|
+
for (const tc of toolCalls || []) {
|
|
261
|
+
const handler = aiActions?.[tc.name]
|
|
262
|
+
if (typeof handler !== "function") {
|
|
263
|
+
skipped.push(tc)
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
const args = coerceArguments(tc.arguments)
|
|
267
|
+
try {
|
|
268
|
+
const value = await handler(args)
|
|
269
|
+
dispatched.push({ toolCall: tc, value })
|
|
270
|
+
} catch (error) {
|
|
271
|
+
dispatched.push({ toolCall: tc, error })
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return { dispatched, skipped }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// runChatLoop — Piece D: client-orchestrated multi-turn loop
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
//
|
|
281
|
+
// Wraps `singleLlmCall` in an actual loop so the LLM can call tools, see
|
|
282
|
+
// their results, and produce a synthesized final response.
|
|
283
|
+
//
|
|
284
|
+
// Handles all three tool classes (see memory: project_mcp_tool_classes):
|
|
285
|
+
// Class 1 — remoteTools[]: hub-registered; POST via meta-server proxy
|
|
286
|
+
// Class 2 — hostWideTools[]: well-known; POST directly to host's /mcp
|
|
287
|
+
// Class 3 — aiActions: page-embedded; invoke JS in-process
|
|
288
|
+
//
|
|
289
|
+
// Per round:
|
|
290
|
+
// 1. call `singleLlmCall` with merged local_tools (Class 2 + 3 schemas) +
|
|
291
|
+
// tool_ids (Class 1 references)
|
|
292
|
+
// 2. classify emitted tool_calls by name (aiActions → hostWide → remote → unknown)
|
|
293
|
+
// 3. dispatch Class 3 locally, keeping each outcome as a tool result
|
|
294
|
+
// 4. dispatch Class 2 via direct MCP; Class 1 via meta-server proxy;
|
|
295
|
+
// collect results in emission order
|
|
296
|
+
// 5. if any round-trip results, append assistant-with-tool_calls turn and
|
|
297
|
+
// one role:tool message per result; loop
|
|
298
|
+
// 6. terminate when no round-trip results (Class 3 alone doesn't loop),
|
|
299
|
+
// or on hitting maxRounds
|
|
300
|
+
//
|
|
301
|
+
// Contract:
|
|
302
|
+
// const result = await runChatLoop({
|
|
303
|
+
// ...singleLlmCall opts,
|
|
304
|
+
// aiActions: window.aiActions,
|
|
305
|
+
// hostWideTools: [{ name, description, input_schema, endpoint }, ...],
|
|
306
|
+
// remoteTools: [{ id, name, description, input_schema }, ...],
|
|
307
|
+
// maxRounds: 10,
|
|
308
|
+
// onRoundStart: (idx) => {},
|
|
309
|
+
// onTextDelta: (delta, roundIdx) => {}
|
|
310
|
+
// })
|
|
311
|
+
// // result: {
|
|
312
|
+
// // content, finishReason,
|
|
313
|
+
// // rounds: [{ round, ..., localCalls, hostWideCalls, remoteCalls, unknownCalls }],
|
|
314
|
+
// // dispatched: [{ toolCall, value|error }],
|
|
315
|
+
// // skipped: [ toolCall ]
|
|
316
|
+
// // }
|
|
317
|
+
//
|
|
318
|
+
// NOTE on tool-name mapping: Class 1 remoteTools[].name must match the name
|
|
319
|
+
// the LLM sees, which is what the server declares to the provider (may be
|
|
320
|
+
// sanitized by McpToolAdapter). Class 2 and Class 3 tool names go through
|
|
321
|
+
// unmodified — LLM sees them as declared in local_tools.
|
|
322
|
+
export async function runChatLoop(opts) {
|
|
323
|
+
const {
|
|
324
|
+
aiActions = {},
|
|
325
|
+
remoteTools = [],
|
|
326
|
+
hostWideTools = [],
|
|
327
|
+
maxRounds = 10,
|
|
328
|
+
// "hub" (default) talks to llm_meta_server; "ollama" talks straight to an
|
|
329
|
+
// Ollama, with no server component of ours in between. The loop is the
|
|
330
|
+
// same either way — only the call is swapped.
|
|
331
|
+
provider = "hub",
|
|
332
|
+
// Where Class 1 (hub-registered) tools are proxied. Usually the same
|
|
333
|
+
// llm_meta_server that answers the chat, but not when the chat goes
|
|
334
|
+
// straight to an Ollama — the tools still belong to the hub.
|
|
335
|
+
toolHubUrl,
|
|
336
|
+
signal,
|
|
337
|
+
onRoundStart,
|
|
338
|
+
onTextDelta,
|
|
339
|
+
onThinkingDelta,
|
|
340
|
+
onToolCall,
|
|
341
|
+
// Fired as each tool finishes, so a caller can show what ran WHILE the
|
|
342
|
+
// turn is still going. The returned `dispatched` array says the same
|
|
343
|
+
// thing, but only once the whole loop ends — which on a slow model is
|
|
344
|
+
// minutes after the work happened, with the user watching unnamed tools.
|
|
345
|
+
onToolDispatched,
|
|
346
|
+
onPhase,
|
|
347
|
+
...singleOpts
|
|
348
|
+
} = opts
|
|
349
|
+
|
|
350
|
+
const messages = [ ...(singleOpts.messages || []) ]
|
|
351
|
+
const remoteByName = Object.fromEntries((remoteTools || []).map((t) => [ t.name, t ]))
|
|
352
|
+
// Class 2: host-wide MCP tools (well-known). Map name→{endpoint, ...} for
|
|
353
|
+
// dispatch. If the same name appears in aiActions, Class 3 wins there
|
|
354
|
+
// (checked first in the classifier below); if it also appears in remoteTools,
|
|
355
|
+
// Class 2 wins over Class 1 (host-owned is more direct).
|
|
356
|
+
const hostWideByName = Object.fromEntries((hostWideTools || []).map((t) => [ t.name, t ]))
|
|
357
|
+
const toolIds = [ ...(singleOpts.toolIds || []), ...(remoteTools || []).map((t) => t.id) ]
|
|
358
|
+
|
|
359
|
+
// Class 2 schemas ride inline via local_tools (LLM sees them like Class 3).
|
|
360
|
+
// De-dupe by name; Class 3 wins so we don't clobber an aiAction's schema.
|
|
361
|
+
const inlineByName = {}
|
|
362
|
+
for (const t of singleOpts.localTools || []) inlineByName[t.name] = t
|
|
363
|
+
for (const t of hostWideTools || []) {
|
|
364
|
+
if (!inlineByName[t.name]) inlineByName[t.name] = { name: t.name, description: t.description, input_schema: t.input_schema }
|
|
365
|
+
}
|
|
366
|
+
const mergedLocalTools = Object.values(inlineByName)
|
|
367
|
+
|
|
368
|
+
const rounds = []
|
|
369
|
+
const allDispatched = []
|
|
370
|
+
const allSkipped = []
|
|
371
|
+
let lastResult = null
|
|
372
|
+
|
|
373
|
+
// Client-side de-dupe guard. Written for models that seemed to re-invoke
|
|
374
|
+
// the same tool with the same arguments after receiving its result.
|
|
375
|
+
// Much of that turned out to be the hub, not the model: single_llm_turn!
|
|
376
|
+
// reported every tool call in the history it was given, so the call the
|
|
377
|
+
// widget had just dispatched came back as a "new" one (fixed in
|
|
378
|
+
// llm_meta_server 4441f61, 2026-09-20). Kept as defence in depth — a
|
|
379
|
+
// genuinely repetitive model, or an older hub, still gets caught here. Track every (name, args-JSON) tuple we've already dispatched
|
|
380
|
+
// this loop; when the LLM emits a duplicate, skip it. A round in which
|
|
381
|
+
// ALL tool_calls are duplicates has nothing left to dispatch — treat as
|
|
382
|
+
// the LLM's implicit "I'm done" and terminate with a distinct reason
|
|
383
|
+
// (widget can render silently since the earlier round already produced
|
|
384
|
+
// whatever text/action the user gets).
|
|
385
|
+
const seenToolCalls = new Set()
|
|
386
|
+
const toolKey = (tc) => {
|
|
387
|
+
let args
|
|
388
|
+
try { args = coerceArguments(tc.arguments) } catch { args = tc.arguments }
|
|
389
|
+
let serialized
|
|
390
|
+
try {
|
|
391
|
+
// Stable-order stringify — sort top-level keys so semantically-
|
|
392
|
+
// equivalent tool_calls (same content, different key order in the
|
|
393
|
+
// LLM's emission) hash to the same string.
|
|
394
|
+
if (args && typeof args === "object" && !Array.isArray(args)) {
|
|
395
|
+
const sorted = Object.keys(args).sort().reduce((o, k) => { o[k] = args[k]; return o }, {})
|
|
396
|
+
serialized = JSON.stringify(sorted)
|
|
397
|
+
} else {
|
|
398
|
+
serialized = JSON.stringify(args)
|
|
399
|
+
}
|
|
400
|
+
} catch { serialized = String(args) }
|
|
401
|
+
return tc.name + "" + serialized
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
for (let round = 0; round < maxRounds; round++) {
|
|
405
|
+
// Bail immediately on external abort — don't start a new LLM turn if
|
|
406
|
+
// the user hit Clear / navigated away between rounds.
|
|
407
|
+
if (signal?.aborted) throw new DOMException("aborted", "AbortError")
|
|
408
|
+
onRoundStart?.(round)
|
|
409
|
+
|
|
410
|
+
// The loop is the same whoever answers; only the call differs.
|
|
411
|
+
const call = provider === "ollama" ? ollamaChatCall : singleLlmCall
|
|
412
|
+
const turnResult = await call({
|
|
413
|
+
...singleOpts,
|
|
414
|
+
messages,
|
|
415
|
+
toolIds,
|
|
416
|
+
localTools: mergedLocalTools,
|
|
417
|
+
signal,
|
|
418
|
+
onTextDelta: onTextDelta ? (d) => onTextDelta(d, round) : undefined,
|
|
419
|
+
onThinkingDelta: onThinkingDelta ? (d) => onThinkingDelta(d, round) : undefined,
|
|
420
|
+
onToolCall: onToolCall ? (t) => onToolCall(t, round) : undefined,
|
|
421
|
+
onPhase: onPhase ? (n) => onPhase(n, round) : undefined
|
|
422
|
+
})
|
|
423
|
+
lastResult = turnResult
|
|
424
|
+
|
|
425
|
+
// Classify tool_calls by class. Precedence: Class 3 (aiActions, no
|
|
426
|
+
// network, same-page) → Class 2 (host-wide well-known, direct MCP) →
|
|
427
|
+
// Class 1 (hub-registered, meta-server proxy) → unknown. Duplicates
|
|
428
|
+
// of prior rounds are filtered first — see seenToolCalls above.
|
|
429
|
+
const localCalls = [] // Class 3
|
|
430
|
+
const hostWideCalls = [] // Class 2
|
|
431
|
+
const remoteCalls = [] // Class 1
|
|
432
|
+
const unknownCalls = []
|
|
433
|
+
const duplicateCalls = []
|
|
434
|
+
const emittedCount = (turnResult.toolCalls || []).length
|
|
435
|
+
for (const tc of turnResult.toolCalls || []) {
|
|
436
|
+
const key = toolKey(tc)
|
|
437
|
+
if (seenToolCalls.has(key)) { duplicateCalls.push(tc); continue }
|
|
438
|
+
seenToolCalls.add(key)
|
|
439
|
+
if (typeof aiActions[tc.name] === "function") localCalls.push(tc)
|
|
440
|
+
else if (hostWideByName[tc.name]) hostWideCalls.push(tc)
|
|
441
|
+
else if (remoteByName[tc.name]) remoteCalls.push(tc)
|
|
442
|
+
else unknownCalls.push(tc)
|
|
443
|
+
}
|
|
444
|
+
// If the LLM emitted tool_calls but ALL were duplicates, it's stuck
|
|
445
|
+
// re-calling. Terminate now instead of dispatching + looping again.
|
|
446
|
+
const allDuplicates = emittedCount > 0 && duplicateCalls.length === emittedCount
|
|
447
|
+
if (allDuplicates) {
|
|
448
|
+
rounds.push({
|
|
449
|
+
round, content: turnResult.content, finishReason: turnResult.finishReason,
|
|
450
|
+
localCalls: [], hostWideCalls: [], remoteCalls: [], unknownCalls: [], duplicateCalls,
|
|
451
|
+
toolCalls: turnResult.toolCalls
|
|
452
|
+
})
|
|
453
|
+
return {
|
|
454
|
+
content: turnResult.content,
|
|
455
|
+
finishReason: turnResult.finishReason,
|
|
456
|
+
rounds, dispatched: allDispatched, skipped: allSkipped,
|
|
457
|
+
stopped_reason: "duplicate_tool_calls"
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Class 3: locals — dispatched here, round-tripped just below
|
|
462
|
+
const localOut = await dispatchLocalToolCalls(localCalls, aiActions)
|
|
463
|
+
allDispatched.push(...localOut.dispatched)
|
|
464
|
+
|
|
465
|
+
// A page action's outcome goes back to the model like any other tool
|
|
466
|
+
// result. It used to be dropped, on the grounds that a write to the page
|
|
467
|
+
// has nothing to report — but a turn whose only calls were page actions
|
|
468
|
+
// then ended the loop, so any task that writes to the page and THEN needs
|
|
469
|
+
// a tool ("select these dictionaries, now annotate") was cut off after
|
|
470
|
+
// the write. It also means a failed action is something the model can
|
|
471
|
+
// see and correct, instead of a red mark only the user notices.
|
|
472
|
+
const roundTripResults = []
|
|
473
|
+
for (const { toolCall, value, error } of localOut.dispatched) {
|
|
474
|
+
onToolDispatched?.({ toolCall, value, error })
|
|
475
|
+
roundTripResults.push({
|
|
476
|
+
tc: toolCall,
|
|
477
|
+
result: error ? { error: String(error.message || error) } : { ok: true, applied: toolCall.name }
|
|
478
|
+
})
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Class 2: host-wide — direct MCP JSON-RPC POST to the host's own endpoint
|
|
482
|
+
for (const tc of hostWideCalls) {
|
|
483
|
+
const tool = hostWideByName[tc.name]
|
|
484
|
+
const args = coerceArguments(tc.arguments)
|
|
485
|
+
try {
|
|
486
|
+
const value = await callMcpTool({ endpoint: tool.endpoint, name: tc.name, args, signal })
|
|
487
|
+
allDispatched.push({ toolCall: tc, value })
|
|
488
|
+
onToolDispatched?.({ toolCall: tc, value })
|
|
489
|
+
roundTripResults.push({ tc, result: value })
|
|
490
|
+
} catch (error) {
|
|
491
|
+
allDispatched.push({ toolCall: tc, error })
|
|
492
|
+
onToolDispatched?.({ toolCall: tc, error })
|
|
493
|
+
roundTripResults.push({ tc, result: { error: String(error.message || error) } })
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Class 1: remote — meta-server proxy round-trip. Order preserved.
|
|
498
|
+
for (const tc of remoteCalls) {
|
|
499
|
+
const tool = remoteByName[tc.name]
|
|
500
|
+
const args = coerceArguments(tc.arguments)
|
|
501
|
+
try {
|
|
502
|
+
const value = await dispatchRemoteToolCall({
|
|
503
|
+
baseUrl: toolHubUrl || singleOpts.baseUrl,
|
|
504
|
+
bearerToken: singleOpts.bearerToken,
|
|
505
|
+
toolId: tool.id,
|
|
506
|
+
args: args,
|
|
507
|
+
signal
|
|
508
|
+
})
|
|
509
|
+
allDispatched.push({ toolCall: tc, value })
|
|
510
|
+
onToolDispatched?.({ toolCall: tc, value })
|
|
511
|
+
roundTripResults.push({ tc, result: value })
|
|
512
|
+
} catch (error) {
|
|
513
|
+
allDispatched.push({ toolCall: tc, error })
|
|
514
|
+
onToolDispatched?.({ toolCall: tc, error })
|
|
515
|
+
// Feed the error text back to the LLM as the tool result — better
|
|
516
|
+
// than dropping it (the LLM can react, apologize, retry differently).
|
|
517
|
+
roundTripResults.push({ tc, result: { error: String(error.message || error) } })
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
allSkipped.push(...unknownCalls)
|
|
522
|
+
rounds.push({
|
|
523
|
+
round, content: turnResult.content, finishReason: turnResult.finishReason,
|
|
524
|
+
localCalls, hostWideCalls, remoteCalls, unknownCalls,
|
|
525
|
+
toolCalls: turnResult.toolCalls
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
// Terminate when there's nothing to feed back: a turn with no tool calls
|
|
529
|
+
// at all, or one whose only calls were unknown. Anything that executed —
|
|
530
|
+
// page action, host tool or proxied tool — produces a result the model
|
|
531
|
+
// sees, and gets another round to act on.
|
|
532
|
+
if (roundTripResults.length === 0) {
|
|
533
|
+
return {
|
|
534
|
+
content: turnResult.content,
|
|
535
|
+
finishReason: turnResult.finishReason,
|
|
536
|
+
rounds, dispatched: allDispatched, skipped: allSkipped
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Build follow-up: assistant-with-tool_calls + one tool-result per
|
|
541
|
+
// round-tripped call (both Class 2 and Class 1). Server-side:
|
|
542
|
+
// LlmRbFacade#messages_to_llm_objects preserves the assistant-with-
|
|
543
|
+
// tool_calls entry via LLM::Message.extra[:tool_calls], and
|
|
544
|
+
// split_history_from_current_input bundles the trailing tool-results
|
|
545
|
+
// as the input to the next session.chat call.
|
|
546
|
+
messages.push({
|
|
547
|
+
role: "assistant",
|
|
548
|
+
content: turnResult.content || "",
|
|
549
|
+
tool_calls: turnResult.toolCalls
|
|
550
|
+
})
|
|
551
|
+
for (const { tc, result } of roundTripResults) {
|
|
552
|
+
messages.push({
|
|
553
|
+
role: "tool",
|
|
554
|
+
tool_call_id: tc.id || "",
|
|
555
|
+
name: tc.name,
|
|
556
|
+
content: typeof result === "string" ? result : JSON.stringify(result)
|
|
557
|
+
})
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Hit the cap without terminating. Return what we have + a diagnostic flag
|
|
562
|
+
// so the widget can render "stopped after N rounds" instead of hanging.
|
|
563
|
+
return {
|
|
564
|
+
content: lastResult?.content || "",
|
|
565
|
+
finishReason: lastResult?.finishReason || null,
|
|
566
|
+
rounds, dispatched: allDispatched, skipped: allSkipped,
|
|
567
|
+
stopped_reason: `max_rounds (${maxRounds}) exceeded`
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
// Class 2: host-wide MCP tools (well-known + direct dispatch)
|
|
573
|
+
// ---------------------------------------------------------------------------
|
|
574
|
+
//
|
|
575
|
+
// See memory: project_mcp_tool_classes. Class 2 tools are declared by the
|
|
576
|
+
// HOST site at `${origin}/.well-known/mcp.json` and executed by the widget
|
|
577
|
+
// posting JSON-RPC 2.0 `tools/call` DIRECTLY to the host's own MCP endpoint
|
|
578
|
+
// — no meta-server proxy in the loop. This dissolves the "widget-to-meta-
|
|
579
|
+
// server auth" question for same-origin host-owned tools: browser session
|
|
580
|
+
// cookies flow through automatically.
|
|
581
|
+
//
|
|
582
|
+
// Manifest shape (widget accepts):
|
|
583
|
+
// {
|
|
584
|
+
// "servers": [
|
|
585
|
+
// { "name": "pubdictionaries",
|
|
586
|
+
// "url": "/mcp", // relative or absolute
|
|
587
|
+
// "tools": [
|
|
588
|
+
// { "name": "text_annotation",
|
|
589
|
+
// "description": "...",
|
|
590
|
+
// "input_schema": {...} }
|
|
591
|
+
// ] }
|
|
592
|
+
// ]
|
|
593
|
+
// }
|
|
594
|
+
//
|
|
595
|
+
// The `url` is resolved against the manifest URL's origin, so a same-origin
|
|
596
|
+
// host can just say `/mcp` and the widget fills in the rest.
|
|
597
|
+
|
|
598
|
+
// Fetch a well-known manifest and normalize it: absolute URLs, flat tool list
|
|
599
|
+
// with the owning server's endpoint attached to each entry for dispatch.
|
|
600
|
+
// Fails gracefully — returns [] on network / parse error so a missing or
|
|
601
|
+
// malformed manifest doesn't kill the widget's boot.
|
|
602
|
+
export async function fetchMcpManifest(manifestUrl) {
|
|
603
|
+
let manifest
|
|
604
|
+
try {
|
|
605
|
+
const response = await fetch(manifestUrl)
|
|
606
|
+
if (!response.ok) return []
|
|
607
|
+
manifest = await response.json()
|
|
608
|
+
} catch { return [] }
|
|
609
|
+
|
|
610
|
+
const base = new URL(manifestUrl)
|
|
611
|
+
const out = []
|
|
612
|
+
for (const server of manifest?.servers || []) {
|
|
613
|
+
let endpoint
|
|
614
|
+
try { endpoint = new URL(server.url, base).toString() } catch { continue }
|
|
615
|
+
for (const tool of server.tools || []) {
|
|
616
|
+
if (!tool.name) continue
|
|
617
|
+
out.push({
|
|
618
|
+
name: tool.name,
|
|
619
|
+
description: tool.description || "",
|
|
620
|
+
input_schema: tool.input_schema || tool.inputSchema || { type: "object", properties: {} },
|
|
621
|
+
endpoint,
|
|
622
|
+
serverName: server.name || null
|
|
623
|
+
})
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return out
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// JSON-RPC 2.0 `tools/call` POST to an MCP endpoint. Returns the parsed
|
|
630
|
+
// `result` value (or throws on JSON-RPC error / HTTP failure). Supports
|
|
631
|
+
// both JSON and SSE responses (MCP over HTTP allows either).
|
|
632
|
+
let _mcpReqId = 0
|
|
633
|
+
|
|
634
|
+
// One JSON-RPC round trip to an MCP endpoint. Extracted from callMcpTool so
|
|
635
|
+
// prompts/* and resources/* reuse the same transport — MCP over HTTP may
|
|
636
|
+
// answer with either JSON or SSE, and duplicating that handling per method
|
|
637
|
+
// is how the two drift apart.
|
|
638
|
+
export async function mcpRpc({ endpoint, method, params, signal, label }) {
|
|
639
|
+
const what = label || method
|
|
640
|
+
const response = await fetch(endpoint, {
|
|
641
|
+
method: "POST",
|
|
642
|
+
headers: {
|
|
643
|
+
"Content-Type": "application/json",
|
|
644
|
+
"Accept": "application/json, text/event-stream"
|
|
645
|
+
},
|
|
646
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: ++_mcpReqId, method, params: params || {} }),
|
|
647
|
+
signal,
|
|
648
|
+
// Send session cookies for same-origin MCP endpoints. Cross-origin CORS
|
|
649
|
+
// with credentials requires the server to echo Access-Control-Allow-
|
|
650
|
+
// Credentials: true — which most MCP servers won't. This is fine: for
|
|
651
|
+
// cross-origin the widget doesn't send cookies; the server enforces
|
|
652
|
+
// its own auth (API key in header, etc.) if it wants any.
|
|
653
|
+
credentials: "same-origin"
|
|
654
|
+
})
|
|
655
|
+
if (!response.ok) {
|
|
656
|
+
const text = await response.text().catch(() => "")
|
|
657
|
+
throw new Error(`${what}: HTTP ${response.status}${text ? " — " + text.slice(0, 200) : ""}`)
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const contentType = response.headers.get("content-type") || ""
|
|
661
|
+
|
|
662
|
+
if (contentType.includes("application/json")) {
|
|
663
|
+
const body = await response.json()
|
|
664
|
+
if (body?.error) throw new Error(`${what}: ${body.error.message || "JSON-RPC error"}`)
|
|
665
|
+
return body?.result
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (contentType.includes("text/event-stream")) {
|
|
669
|
+
// MCP over SSE — each SSE `data:` frame is a JSON-RPC message.
|
|
670
|
+
for await (const evt of parseSseStream(response.body, signal)) {
|
|
671
|
+
const payload = evt.data
|
|
672
|
+
if (!payload || typeof payload !== "object") continue
|
|
673
|
+
if (payload.error) throw new Error(`${what}: ${payload.error.message || "JSON-RPC error"}`)
|
|
674
|
+
if ("result" in payload) return payload.result
|
|
675
|
+
}
|
|
676
|
+
throw new Error(`${what}: SSE stream ended without result`)
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
throw new Error(`${what}: unexpected content-type ${contentType}`)
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export async function callMcpTool({ endpoint, name, args, signal }) {
|
|
683
|
+
return mcpRpc({
|
|
684
|
+
endpoint, signal, method: "tools/call",
|
|
685
|
+
params: { name, arguments: args || {} },
|
|
686
|
+
label: `callMcpTool(${name})`
|
|
687
|
+
})
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// ---------------------------------------------------------------------------
|
|
691
|
+
// Prompt and resource primitives
|
|
692
|
+
// ---------------------------------------------------------------------------
|
|
693
|
+
//
|
|
694
|
+
// Optional in MCP, so every one of these returns empty rather than throwing
|
|
695
|
+
// when a server doesn't implement them: the widget must stay usable against a
|
|
696
|
+
// tools-only server. A server that answers `prompts/list` with -32601 is
|
|
697
|
+
// telling us "not supported"; one that errors some other way is broken, and
|
|
698
|
+
// either way the widget carries on with whatever it did get.
|
|
699
|
+
|
|
700
|
+
export async function listMcpPrompts({ endpoint, signal }) {
|
|
701
|
+
try {
|
|
702
|
+
const result = await mcpRpc({ endpoint, method: "prompts/list", signal })
|
|
703
|
+
return (result?.prompts || []).map((p) => ({ ...p, endpoint }))
|
|
704
|
+
} catch { return [] }
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
export async function getMcpPrompt({ endpoint, name, args, signal }) {
|
|
708
|
+
return mcpRpc({
|
|
709
|
+
endpoint, signal, method: "prompts/get",
|
|
710
|
+
params: { name, arguments: args || {} },
|
|
711
|
+
label: `getMcpPrompt(${name})`
|
|
712
|
+
})
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
export async function listMcpResources({ endpoint, signal }) {
|
|
716
|
+
try {
|
|
717
|
+
const result = await mcpRpc({ endpoint, method: "resources/list", signal })
|
|
718
|
+
return (result?.resources || []).map((r) => ({ ...r, endpoint }))
|
|
719
|
+
} catch { return [] }
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
export async function readMcpResource({ endpoint, uri, signal }) {
|
|
723
|
+
return mcpRpc({
|
|
724
|
+
endpoint, signal, method: "resources/read",
|
|
725
|
+
params: { uri }, label: `readMcpResource(${uri})`
|
|
726
|
+
})
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Flatten a prompts/get result into the plain string the hub's messages take.
|
|
730
|
+
// The spec allows `content` to be an object or an array of content blocks;
|
|
731
|
+
// anything non-text is dropped, which is worth knowing — a prompt carrying an
|
|
732
|
+
// image would silently lose it on this path.
|
|
733
|
+
export function promptMessagesToText(result) {
|
|
734
|
+
return (result?.messages || [])
|
|
735
|
+
.map((m) => {
|
|
736
|
+
const c = m.content
|
|
737
|
+
if (typeof c === "string") return c
|
|
738
|
+
if (Array.isArray(c)) return c.filter((b) => b?.type === "text").map((b) => b.text).join("\n")
|
|
739
|
+
if (c && c.type === "text") return c.text
|
|
740
|
+
return ""
|
|
741
|
+
})
|
|
742
|
+
.filter(Boolean)
|
|
743
|
+
.join("\n\n")
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Standalone remote dispatcher — POSTs one tool_call to the meta-server's
|
|
747
|
+
// MCP proxy endpoint. Exposed for tests + reuse.
|
|
748
|
+
export async function dispatchRemoteToolCall({ baseUrl, bearerToken, toolId, args, signal }) {
|
|
749
|
+
if (!baseUrl || toolId == null) {
|
|
750
|
+
throw new Error("dispatchRemoteToolCall: baseUrl and toolId are required")
|
|
751
|
+
}
|
|
752
|
+
const url = `${baseUrl.replace(/\/$/, "")}/api/mcp_tools/${encodeURIComponent(toolId)}/call`
|
|
753
|
+
const headers = { "Content-Type": "application/json" }
|
|
754
|
+
if (bearerToken) headers["Authorization"] = `Bearer ${bearerToken}`
|
|
755
|
+
|
|
756
|
+
const response = await fetch(url, {
|
|
757
|
+
method: "POST",
|
|
758
|
+
headers,
|
|
759
|
+
body: JSON.stringify({ arguments: args || {} }),
|
|
760
|
+
signal
|
|
761
|
+
})
|
|
762
|
+
if (!response.ok) {
|
|
763
|
+
const text = await response.text().catch(() => "")
|
|
764
|
+
throw new Error(`dispatchRemoteToolCall: HTTP ${response.status} ${response.statusText}${text ? " — " + text.slice(0, 200) : ""}`)
|
|
765
|
+
}
|
|
766
|
+
const body = await response.json()
|
|
767
|
+
return body.result
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// llm.rb's Session#extract_tool_calls delivers `arguments` as a parsed object
|
|
771
|
+
// (the OpenAI/Anthropic/Gemini adapters all parse before we see them). But
|
|
772
|
+
// SOMETIMES a provider passes through a raw JSON string (Gemini streaming
|
|
773
|
+
// edge cases have done this in the past). Coerce defensively so aiActions
|
|
774
|
+
// always receive an object.
|
|
775
|
+
function coerceArguments(raw) {
|
|
776
|
+
if (raw == null) return {}
|
|
777
|
+
if (typeof raw === "object") return raw
|
|
778
|
+
if (typeof raw === "string") {
|
|
779
|
+
try { return JSON.parse(raw) } catch { return { _raw: raw } }
|
|
780
|
+
}
|
|
781
|
+
return { _raw: raw }
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function parseSseFrame(raw) {
|
|
785
|
+
let name = "message"
|
|
786
|
+
const dataLines = []
|
|
787
|
+
|
|
788
|
+
for (const line of raw.split("\n")) {
|
|
789
|
+
if (line.length === 0 || line.startsWith(":")) continue
|
|
790
|
+
const colon = line.indexOf(":")
|
|
791
|
+
const field = colon === -1 ? line : line.slice(0, colon)
|
|
792
|
+
// Per spec: single leading space after the colon is stripped.
|
|
793
|
+
let value = colon === -1 ? "" : line.slice(colon + 1)
|
|
794
|
+
if (value.startsWith(" ")) value = value.slice(1)
|
|
795
|
+
|
|
796
|
+
if (field === "event") name = value
|
|
797
|
+
else if (field === "data") dataLines.push(value)
|
|
798
|
+
// id / retry ignored
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (dataLines.length === 0 && name === "message") return null
|
|
802
|
+
|
|
803
|
+
const dataStr = dataLines.join("\n")
|
|
804
|
+
let data = null
|
|
805
|
+
if (dataStr.length > 0) {
|
|
806
|
+
try {
|
|
807
|
+
data = JSON.parse(dataStr)
|
|
808
|
+
} catch (e) {
|
|
809
|
+
// Emit the raw string — callers that care can inspect it.
|
|
810
|
+
data = { _raw: dataStr, _parseError: e.message }
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return { name, data }
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// ---- static-primitives extension ---------------------------------------
|
|
817
|
+
//
|
|
818
|
+
// Prototype of `io.modelcontextprotocol/static-primitives` (the SEP-2127
|
|
819
|
+
// follow-on). Three optional fields a server may declare on a resources/list
|
|
820
|
+
// entry:
|
|
821
|
+
//
|
|
822
|
+
// sizeBytes - byte length of the payload resources/read would return, so a
|
|
823
|
+
// client can decide whether to attach it BEFORE fetching it.
|
|
824
|
+
// volatility - "stable" (content fixed) or "volatile" (varies between
|
|
825
|
+
// turns, so a client re-reads it each turn).
|
|
826
|
+
// autoAttach - may a client attach this without the user asking for it?
|
|
827
|
+
//
|
|
828
|
+
// volatility and autoAttach are separate on purpose. Whether content changes
|
|
829
|
+
// says nothing about whether it may be attached unasked, and the client needs
|
|
830
|
+
// autoAttach as a boolean anyway, because its own byte budget can withhold a
|
|
831
|
+
// resource the server was happy to hand over.
|
|
832
|
+
//
|
|
833
|
+
// All three are optional, and a server declaring none must behave exactly as
|
|
834
|
+
// it did before the extension existed: fetch once, trim to budget, attach on
|
|
835
|
+
// the first turn only.
|
|
836
|
+
export const STATIC_PRIMITIVES_META = "io.modelcontextprotocol/static-primitives"
|
|
837
|
+
|
|
838
|
+
const VOLATILITIES = [ "stable", "volatile" ]
|
|
839
|
+
|
|
840
|
+
export function resourceHints(entry) {
|
|
841
|
+
const meta = (entry && entry._meta && entry._meta[STATIC_PRIMITIVES_META]) || {}
|
|
842
|
+
return {
|
|
843
|
+
// A non-numeric or absent size means "unknown", never 0 — 0 would read
|
|
844
|
+
// as a free resource and sail through every budget check.
|
|
845
|
+
sizeBytes: typeof meta.sizeBytes === "number" && isFinite(meta.sizeBytes) ? meta.sizeBytes : null,
|
|
846
|
+
volatility: VOLATILITIES.indexOf(meta.volatility) === -1 ? "stable" : meta.volatility,
|
|
847
|
+
autoAttach: typeof meta.autoAttach === "boolean" ? meta.autoAttach : true
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// The pre-flight decision, made from the resources/list entry alone.
|
|
852
|
+
// `fetch: false` means the bytes never cross the wire at all.
|
|
853
|
+
export function planResourceAttachment(entry, budgetBytes) {
|
|
854
|
+
const { sizeBytes, volatility, autoAttach } = resourceHints(entry)
|
|
855
|
+
const base = { sizeBytes, volatility, uri: entry && entry.uri }
|
|
856
|
+
|
|
857
|
+
if (!autoAttach) {
|
|
858
|
+
return { ...base, fetch: false, autoAttach: false, reason: "not-auto-attach" }
|
|
859
|
+
}
|
|
860
|
+
if (sizeBytes !== null && sizeBytes > budgetBytes) {
|
|
861
|
+
// The client's budget overrides the server's willingness — which is why
|
|
862
|
+
// autoAttach has to be a boolean here rather than a restatement of a hint.
|
|
863
|
+
return { ...base, fetch: false, autoAttach: false, reason: "over-budget" }
|
|
864
|
+
}
|
|
865
|
+
return {
|
|
866
|
+
...base,
|
|
867
|
+
fetch: true,
|
|
868
|
+
autoAttach: true,
|
|
869
|
+
// Unknown size still gets fetched, then trimmed — the trim is the
|
|
870
|
+
// safety net for servers that do not declare a size.
|
|
871
|
+
reason: sizeBytes === null ? "size-unknown" : "within-budget"
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Safety net for a server that declares no size: shrink an oversized
|
|
876
|
+
// catalog to its names rather than dropping it, and hard-truncate anything
|
|
877
|
+
// that is not a recognised catalog shape.
|
|
878
|
+
export function trimResourceText(text, maxBytes) {
|
|
879
|
+
if (text.length <= maxBytes) return text
|
|
880
|
+
try {
|
|
881
|
+
const parsed = JSON.parse(text)
|
|
882
|
+
const rows = parsed && parsed.dictionaries
|
|
883
|
+
if (Array.isArray(rows)) {
|
|
884
|
+
const names = rows.map((d) => d && d.name).filter(Boolean)
|
|
885
|
+
return JSON.stringify({ dictionaries: names, note: "names only — full catalog too large to inline" })
|
|
886
|
+
}
|
|
887
|
+
} catch (e) { /* fall through to a hard truncation */ }
|
|
888
|
+
return text.slice(0, maxBytes) + "\n…truncated"
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Read one resource and shape it for the system prompt. The trim is applied
|
|
892
|
+
// on every read, not just the first: a declared size can go stale, and a
|
|
893
|
+
// volatile resource is a fresh gamble each turn.
|
|
894
|
+
async function readResourceContext({ endpoint, uri, name, read, budgetBytes }) {
|
|
895
|
+
try {
|
|
896
|
+
const result = await read({ endpoint, uri })
|
|
897
|
+
const entry = ((result && result.contents) || [])[0]
|
|
898
|
+
if (!entry || !entry.text) return null
|
|
899
|
+
return { uri, name: name || uri, text: trimResourceText(entry.text, budgetBytes) }
|
|
900
|
+
} catch (e) {
|
|
901
|
+
// Optional context — a failed read must never block the widget.
|
|
902
|
+
return null
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// The discovery sequence for one endpoint's reference resource, kept here
|
|
907
|
+
// rather than in the panel so the decision AND the call site are testable
|
|
908
|
+
// together: a gate nothing consults is exactly the bug this shape prevents.
|
|
909
|
+
//
|
|
910
|
+
// A volatile resource is NOT read here. Its content is only meaningful for
|
|
911
|
+
// the turn it is attached to, so reading it at boot would buy a copy that is
|
|
912
|
+
// already suspect by the time anyone sends a message.
|
|
913
|
+
export async function loadHostResource({ endpoint, budgetBytes, list, read, onSkip }) {
|
|
914
|
+
const resources = await list({ endpoint })
|
|
915
|
+
const candidate = (resources || []).filter((r) => (r.mimeType || "") === "application/json")[0]
|
|
916
|
+
if (!candidate) return null
|
|
917
|
+
|
|
918
|
+
const plan = planResourceAttachment(candidate, budgetBytes)
|
|
919
|
+
plan.name = candidate.name || candidate.uri
|
|
920
|
+
// Carried so a volatile re-read knows where to go: discovery happens once
|
|
921
|
+
// at boot, but the fetch it authorises happens on every later turn.
|
|
922
|
+
plan.endpoint = endpoint
|
|
923
|
+
if (!plan.fetch) {
|
|
924
|
+
if (onSkip) onSkip(plan)
|
|
925
|
+
return { plan, context: null }
|
|
926
|
+
}
|
|
927
|
+
if (plan.volatility === "volatile") return { plan, context: null }
|
|
928
|
+
|
|
929
|
+
const context = await readResourceContext({
|
|
930
|
+
endpoint, uri: candidate.uri, name: plan.name, read, budgetBytes
|
|
931
|
+
})
|
|
932
|
+
return { plan, context }
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
export function resourceContextLines(context) {
|
|
936
|
+
if (!context) return []
|
|
937
|
+
return [ "", "Reference data — " + context.name + " (" + context.uri + "):", context.text ]
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// What to attach on THIS turn.
|
|
941
|
+
//
|
|
942
|
+
// `volatility` governs RE-FETCHING, not re-inclusion: a stable resource is
|
|
943
|
+
// read once and then re-used, but it stays in every turn's system prompt for
|
|
944
|
+
// as long as the conversation lasts. Showing it only on the first turn put
|
|
945
|
+
// the reference data in the one turn that could not use it — the model then
|
|
946
|
+
// spent several tool calls rediscovering what it had already been given,
|
|
947
|
+
// which costs more tokens than simply keeping it.
|
|
948
|
+
//
|
|
949
|
+
// Whether a resource is affordable at all is decided once, from sizeBytes,
|
|
950
|
+
// before it is ever fetched.
|
|
951
|
+
export async function resourceLinesForTurn({ plan, cached, endpoint, read, budgetBytes }) {
|
|
952
|
+
if (!plan || !plan.autoAttach) return { lines: [], cached }
|
|
953
|
+
|
|
954
|
+
if (plan.volatility === "volatile") {
|
|
955
|
+
const fresh = await readResourceContext({
|
|
956
|
+
endpoint, uri: plan.uri, name: plan.name, read, budgetBytes
|
|
957
|
+
})
|
|
958
|
+
return fresh ? { lines: resourceContextLines(fresh), cached: fresh } : { lines: [], cached }
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
return { lines: resourceContextLines(cached), cached }
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// ---- prompt templates ---------------------------------------------------
|
|
965
|
+
//
|
|
966
|
+
// A server-declared prompt is only useful if the HOST PAGE can fill its
|
|
967
|
+
// arguments, so an argument is matched against the page's own state readers
|
|
968
|
+
// (window.aiState) by name. `dictionaries` is read from a
|
|
969
|
+
// `selected_dictionaries` reader: the page selects a LIST, and the prompt
|
|
970
|
+
// declares the CSV shape the text_annotation tool already takes.
|
|
971
|
+
const PROMPT_ARG_ALIASES = { dictionaries: "selected_dictionaries" }
|
|
972
|
+
|
|
973
|
+
export function promptArgFromState(argName, state) {
|
|
974
|
+
const reader = (state && state[argName]) || (state && state[PROMPT_ARG_ALIASES[argName]])
|
|
975
|
+
if (typeof reader !== "function") return ""
|
|
976
|
+
let value
|
|
977
|
+
try {
|
|
978
|
+
value = reader()
|
|
979
|
+
} catch (e) {
|
|
980
|
+
// A throwing page reader is the page's bug, not a reason to break the
|
|
981
|
+
// widget; treat it as "nothing to fill with".
|
|
982
|
+
return ""
|
|
983
|
+
}
|
|
984
|
+
if (value === null || value === undefined) return ""
|
|
985
|
+
return Array.isArray(value) ? value.join(",") : String(value)
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Splits a prompt's declared arguments into what the page can supply and
|
|
989
|
+
// what it cannot. Naming the missing ones lets the widget point at the empty
|
|
990
|
+
// field on screen instead of relaying the server's -32602.
|
|
991
|
+
export function resolvePromptArguments(prompt, state) {
|
|
992
|
+
const args = {}
|
|
993
|
+
const missing = []
|
|
994
|
+
for (const arg of (prompt && prompt.arguments) || []) {
|
|
995
|
+
const value = promptArgFromState(arg.name, state)
|
|
996
|
+
if (value) args[arg.name] = value
|
|
997
|
+
else if (arg.required) missing.push(arg.name)
|
|
998
|
+
}
|
|
999
|
+
return { args, missing }
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// What a template will take from the page, and what it will have to ask for.
|
|
1003
|
+
// Shown to a first-time visitor so the offer is concrete rather than a bare
|
|
1004
|
+
// verb: they can see that the text box is empty before they click.
|
|
1005
|
+
export function promptArgumentSummary(prompt, state) {
|
|
1006
|
+
return ((prompt && prompt.arguments) || []).map((arg) => {
|
|
1007
|
+
const value = promptArgFromState(arg.name, state)
|
|
1008
|
+
return { name: arg.name, value, filled: value.length > 0, required: !!arg.required }
|
|
1009
|
+
})
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
export function promptButtonProps(prompt) {
|
|
1013
|
+
return {
|
|
1014
|
+
label: prompt.title || prompt.name,
|
|
1015
|
+
title: prompt.description || ("Run the " + prompt.name + " prompt")
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
// ---- direct-to-Ollama provider ------------------------------------------
|
|
1021
|
+
//
|
|
1022
|
+
// The smallest adoption: a Rails app and an Ollama, with no hub of ours in
|
|
1023
|
+
// between. Ollama's /api/chat already does the hard part — it takes tool
|
|
1024
|
+
// schemas and returns tool_calls — so what differs from the hub path is the
|
|
1025
|
+
// envelope: NDJSON rather than SSE, and its own request shape.
|
|
1026
|
+
//
|
|
1027
|
+
// What an adopter gives up is Class 1 (hub-registered MCP), which is the
|
|
1028
|
+
// hub's job by definition. Page actions and the host's own .well-known MCP
|
|
1029
|
+
// both work unchanged, and nothing the visitor types leaves their machine.
|
|
1030
|
+
//
|
|
1031
|
+
// Ollama must be told to accept the page's origin (OLLAMA_ORIGINS), the same
|
|
1032
|
+
// CORS story as the hub, configured elsewhere.
|
|
1033
|
+
|
|
1034
|
+
export async function* parseNdjsonStream(readableStream, signal) {
|
|
1035
|
+
const reader = readableStream.getReader()
|
|
1036
|
+
const decoder = new TextDecoder("utf-8")
|
|
1037
|
+
let buffer = ""
|
|
1038
|
+
|
|
1039
|
+
const onAbort = () => { try { reader.cancel() } catch { /* noop */ } }
|
|
1040
|
+
signal?.addEventListener("abort", onAbort)
|
|
1041
|
+
|
|
1042
|
+
try {
|
|
1043
|
+
while (true) {
|
|
1044
|
+
const { value, done } = await reader.read()
|
|
1045
|
+
if (done) break
|
|
1046
|
+
buffer += decoder.decode(value, { stream: true })
|
|
1047
|
+
|
|
1048
|
+
let nl
|
|
1049
|
+
while ((nl = buffer.indexOf("\n")) !== -1) {
|
|
1050
|
+
const line = buffer.slice(0, nl).trim()
|
|
1051
|
+
buffer = buffer.slice(nl + 1)
|
|
1052
|
+
if (!line) continue
|
|
1053
|
+
try {
|
|
1054
|
+
yield JSON.parse(line)
|
|
1055
|
+
} catch (e) {
|
|
1056
|
+
// A truncated or non-JSON line is not worth aborting a stream for.
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
const tail = buffer.trim()
|
|
1061
|
+
if (tail) { try { yield JSON.parse(tail) } catch (e) { /* ignore */ } }
|
|
1062
|
+
} finally {
|
|
1063
|
+
signal?.removeEventListener("abort", onAbort)
|
|
1064
|
+
try { reader.releaseLock() } catch { /* noop */ }
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Tool schemas travel as MCP-flavoured `input_schema`; Ollama wants OpenAI's
|
|
1069
|
+
// function shape.
|
|
1070
|
+
function toolsForOllama(localTools) {
|
|
1071
|
+
return (localTools || []).map((t) => ({
|
|
1072
|
+
type: "function",
|
|
1073
|
+
function: {
|
|
1074
|
+
name: t.name,
|
|
1075
|
+
description: t.description,
|
|
1076
|
+
parameters: t.input_schema || t.inputSchema || { type: "object", properties: {} }
|
|
1077
|
+
}
|
|
1078
|
+
}))
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// The hub's history carries `tool_call_id`; Ollama identifies a result by the
|
|
1082
|
+
// tool's name instead, and rejects unknown keys on some versions.
|
|
1083
|
+
function messagesForOllama(messages) {
|
|
1084
|
+
return (messages || []).map((m) => {
|
|
1085
|
+
if (m.role === "tool") {
|
|
1086
|
+
return { role: "tool", tool_name: m.name, content: m.content }
|
|
1087
|
+
}
|
|
1088
|
+
if (m.tool_calls) {
|
|
1089
|
+
return {
|
|
1090
|
+
role: m.role,
|
|
1091
|
+
content: m.content || "",
|
|
1092
|
+
tool_calls: m.tool_calls.map((tc) => ({
|
|
1093
|
+
function: { name: tc.name, arguments: coerceArguments(tc.arguments) }
|
|
1094
|
+
}))
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
return { role: m.role, content: m.content }
|
|
1098
|
+
})
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Same signature and return shape as singleLlmCall, so runChatLoop does not
|
|
1102
|
+
// care which provider answered.
|
|
1103
|
+
export async function ollamaChatCall({
|
|
1104
|
+
baseUrl,
|
|
1105
|
+
modelName,
|
|
1106
|
+
messages,
|
|
1107
|
+
localTools = [],
|
|
1108
|
+
generationSettings = {},
|
|
1109
|
+
onTextDelta,
|
|
1110
|
+
onThinkingDelta,
|
|
1111
|
+
onToolCall,
|
|
1112
|
+
onPhase,
|
|
1113
|
+
signal,
|
|
1114
|
+
}) {
|
|
1115
|
+
if (!baseUrl || !modelName) throw new Error("ollamaChatCall: baseUrl and modelName are required")
|
|
1116
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
1117
|
+
throw new Error("ollamaChatCall: messages must be a non-empty array")
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const { think, ...options } = generationSettings || {}
|
|
1121
|
+
const body = { model: modelName, messages: messagesForOllama(messages), stream: true }
|
|
1122
|
+
if (localTools && localTools.length) body.tools = toolsForOllama(localTools)
|
|
1123
|
+
if (think !== undefined) body.think = think
|
|
1124
|
+
if (Object.keys(options).length) body.options = options
|
|
1125
|
+
|
|
1126
|
+
const response = await fetch(`${baseUrl.replace(/\/$/, "")}/api/chat`, {
|
|
1127
|
+
method: "POST",
|
|
1128
|
+
headers: { "Content-Type": "application/json" },
|
|
1129
|
+
body: JSON.stringify(body),
|
|
1130
|
+
signal
|
|
1131
|
+
})
|
|
1132
|
+
if (!response.ok) {
|
|
1133
|
+
const text = await response.text().catch(() => "")
|
|
1134
|
+
throw new Error(`ollamaChatCall: HTTP ${response.status} ${response.statusText}${text ? " — " + text.slice(0, 200) : ""}`)
|
|
1135
|
+
}
|
|
1136
|
+
if (!response.body) throw new Error("ollamaChatCall: response has no body (streaming unsupported?)")
|
|
1137
|
+
|
|
1138
|
+
const toolCalls = []
|
|
1139
|
+
let content = ""
|
|
1140
|
+
let finishReason = null
|
|
1141
|
+
let announcedPhase = null
|
|
1142
|
+
|
|
1143
|
+
const phase = (name) => {
|
|
1144
|
+
if (announcedPhase === name) return
|
|
1145
|
+
announcedPhase = name
|
|
1146
|
+
onPhase?.(name)
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
for await (const frame of parseNdjsonStream(response.body, signal)) {
|
|
1150
|
+
const message = frame.message || {}
|
|
1151
|
+
|
|
1152
|
+
if (message.thinking) {
|
|
1153
|
+
phase("thinking")
|
|
1154
|
+
onThinkingDelta?.(message.thinking)
|
|
1155
|
+
}
|
|
1156
|
+
if (message.content) {
|
|
1157
|
+
phase("responding")
|
|
1158
|
+
content += message.content
|
|
1159
|
+
onTextDelta?.(message.content)
|
|
1160
|
+
}
|
|
1161
|
+
for (const tc of message.tool_calls || []) {
|
|
1162
|
+
const call = {
|
|
1163
|
+
id: tc.id || `ollama-${toolCalls.length}`,
|
|
1164
|
+
name: tc.function?.name,
|
|
1165
|
+
arguments: tc.function?.arguments ?? {}
|
|
1166
|
+
}
|
|
1167
|
+
toolCalls.push(call)
|
|
1168
|
+
onToolCall?.(call)
|
|
1169
|
+
}
|
|
1170
|
+
if (frame.done) finishReason = frame.done_reason || "stop"
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
return { content, toolCalls, finishReason }
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
// Ollama's own catalogue, for the model picker when there is no hub to ask.
|
|
1177
|
+
export async function fetchOllamaModels({ baseUrl, signal }) {
|
|
1178
|
+
try {
|
|
1179
|
+
const response = await fetch(`${baseUrl.replace(/\/$/, "")}/api/tags`, { signal })
|
|
1180
|
+
if (!response.ok) return []
|
|
1181
|
+
const data = await response.json()
|
|
1182
|
+
return (data.models || []).map((m) => m.name).filter(Boolean)
|
|
1183
|
+
} catch (e) {
|
|
1184
|
+
// A picker that cannot be populated is not a reason to break the widget.
|
|
1185
|
+
return []
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// ---- surviving navigation ------------------------------------------------
|
|
1190
|
+
//
|
|
1191
|
+
// A page action that navigates — submitting a form, following a link — used to
|
|
1192
|
+
// destroy the conversation, the record of what ran and the scroll position.
|
|
1193
|
+
// That made such actions unsafe to offer at all: PubDictionaries had to stop
|
|
1194
|
+
// declaring its submit action rather than let the assistant wipe the chat.
|
|
1195
|
+
//
|
|
1196
|
+
// The transcript is kept in sessionStorage: per tab, gone when the tab closes,
|
|
1197
|
+
// invisible to other visitors. Keyed by pathname rather than full URL, because
|
|
1198
|
+
// submitting a form usually returns to the same page with different query
|
|
1199
|
+
// parameters and that is exactly the case worth surviving.
|
|
1200
|
+
|
|
1201
|
+
export const CONVERSATION_FORMAT = 1
|
|
1202
|
+
|
|
1203
|
+
export function packConversation({ turns = [], open = false, model = null,
|
|
1204
|
+
maxTurns = 40, maxBytes = 200000 } = {}) {
|
|
1205
|
+
// Oldest turns go first when trimming: the recent ones carry the thread.
|
|
1206
|
+
let kept = turns.slice(-maxTurns)
|
|
1207
|
+
let payload = { v: CONVERSATION_FORMAT, savedAt: Date.now(), open, model, turns: kept }
|
|
1208
|
+
let json = JSON.stringify(payload)
|
|
1209
|
+
|
|
1210
|
+
while (json.length > maxBytes && kept.length > 1) {
|
|
1211
|
+
kept = kept.slice(1)
|
|
1212
|
+
payload = { ...payload, turns: kept }
|
|
1213
|
+
json = JSON.stringify(payload)
|
|
1214
|
+
}
|
|
1215
|
+
return json
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
export function unpackConversation(raw) {
|
|
1219
|
+
if (!raw) return null
|
|
1220
|
+
let payload
|
|
1221
|
+
try {
|
|
1222
|
+
payload = JSON.parse(raw)
|
|
1223
|
+
} catch (e) {
|
|
1224
|
+
return null
|
|
1225
|
+
}
|
|
1226
|
+
// A payload from a different format is not worth guessing at; starting
|
|
1227
|
+
// fresh is better than rendering something half-understood.
|
|
1228
|
+
if (!payload || payload.v !== CONVERSATION_FORMAT || !Array.isArray(payload.turns)) return null
|
|
1229
|
+
|
|
1230
|
+
const turns = payload.turns.filter((t) => t && typeof t.content === "string" &&
|
|
1231
|
+
(t.role === "user" || t.role === "assistant"))
|
|
1232
|
+
return { turns, open: !!payload.open, model: payload.model || null, savedAt: payload.savedAt || 0 }
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// Storage can throw — private windows, blocked site data, quota — and none of
|
|
1236
|
+
// that is a reason for the widget to stop working.
|
|
1237
|
+
export function createConversationStore({ storage, key }) {
|
|
1238
|
+
const safely = (fn, fallback = null) => {
|
|
1239
|
+
try {
|
|
1240
|
+
return fn()
|
|
1241
|
+
} catch (e) {
|
|
1242
|
+
return fallback
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
return {
|
|
1247
|
+
key,
|
|
1248
|
+
save(state) { return safely(() => { storage.setItem(key, packConversation(state)); return true }, false) },
|
|
1249
|
+
load() { return safely(() => unpackConversation(storage.getItem(key))) },
|
|
1250
|
+
clear() { return safely(() => { storage.removeItem(key); return true }, false) }
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
export function conversationKeyFor(location, prefix = "llm_meta_widget") {
|
|
1255
|
+
// Path only: a form submit returns to the same page with a new query string,
|
|
1256
|
+
// and that conversation is still the same conversation.
|
|
1257
|
+
return `${prefix}:${location.pathname}`
|
|
1258
|
+
}
|