@happyvertical/smrt-web 0.51.11 → 0.51.12
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.
- package/AGENTS.md +1 -0
- package/dist/ai.d.ts +358 -0
- package/dist/ai.js +291 -0
- package/dist/ai.js.map +1 -0
- package/package.json +7 -3
package/AGENTS.md
CHANGED
|
@@ -44,6 +44,7 @@ module you are editing. This file keeps what holds in every module.
|
|
|
44
44
|
| `intents.ts` + `capability-classification.ts` | the framework-agnostic declarative view-intent contract, its declaration registry, and the one capability classification rule both it and `webmcp.ts` apply (#2587, #2588). Ships as the dependency-free `@happyvertical/smrt-web/intents` entry — importing it pulls no client-data engine | — |
|
|
45
45
|
| `data-query.ts` | dependency-free browser mirror and defensive response normalizer for the canonical bounded data-query envelope (#2444) | — |
|
|
46
46
|
| `remote-query.ts` | query-shaped remote pages over a `SmrtWebCollection`, with keyed stale cache, execution modes, cancellation/latest-query-wins, and optional query-scoped live subscriptions (#2445) | — |
|
|
47
|
+
| `ai.ts` | the browser inference path — ONE call-site object over interchangeable inference backends, with resolution **per call** so a preference change applies to the next `chat`/`stream` and never to one already in flight. `InferenceBackend`/`InferenceMessage`/`InferenceResponse`/`InferenceChatOptions` are textual structural mirrors of `@happyvertical/ai`'s `AIMessage`/`AIResponse`/`ChatOptions` (text generation only), and `InferenceProgress` mirrors the browser adapters' `DownloadProgressInfo` so a `DownloadProgress` control renders it directly. `createRouteInferenceBackend` consumes `@happyvertical/smrt-chat`'s `ChatStreamEvent` frame vocabulary without depending on that package. Backends arrive as arguments; a WebLLM backend lives beside the adapter it wraps. Ships as the dependency-free `@happyvertical/smrt-web/ai` entry — importing it pulls no client-data engine | — |
|
|
47
48
|
|
|
48
49
|
## The engine-absorption boundary (ratified conditions, #1761)
|
|
49
50
|
|
package/dist/ai.d.ts
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reject a turn this module's backends cannot faithfully carry, narrowing
|
|
3
|
+
* `messages` to {@link InferenceTextMessage} on success.
|
|
4
|
+
*
|
|
5
|
+
* Every backend here is a TEXT conversation: roles `user`/`assistant`/
|
|
6
|
+
* `system`, string content, no tools. Each of those limits is a real
|
|
7
|
+
* difference between the backends' transports, so refusing up front is the
|
|
8
|
+
* only way a caller can tell "this backend can't carry my turn" from "this
|
|
9
|
+
* backend answered". The route wire contract (`chat-stream.ts`
|
|
10
|
+
* `normalizeMessages`) and the local adapter both silently DROP exactly these
|
|
11
|
+
* shapes, which is why this check is shared rather than reimplemented per
|
|
12
|
+
* backend — two copies would drift, and the whole point is that both backends
|
|
13
|
+
* agree.
|
|
14
|
+
*
|
|
15
|
+
* `backendLabel` names the backend in the message so a caller knows which one
|
|
16
|
+
* refused.
|
|
17
|
+
*/
|
|
18
|
+
export declare function assertTextOnlyTurn(backendLabel: string, messages: InferenceMessage[], options?: InferenceChatOptions): asserts messages is InferenceTextMessage[];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create the browser's single inference path over `options.backends`.
|
|
22
|
+
*
|
|
23
|
+
* Resolution happens inside every `chat`/`stream` call, so a `preference` or
|
|
24
|
+
* `select()` change made between calls is honored by the next one. A call
|
|
25
|
+
* whose resolution finds nothing usable throws `InferencePathError` with
|
|
26
|
+
* `code: 'no_usable_backend'` rather than falling back to a fake answer.
|
|
27
|
+
*/
|
|
28
|
+
export declare function createInferencePath(options: InferencePathOptions): InferencePath;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A backend over a `text/event-stream` inference route.
|
|
32
|
+
*
|
|
33
|
+
* The wire contract is `@happyvertical/smrt-chat`'s `ChatStreamEvent` framing
|
|
34
|
+
* (token deltas, then a terminal `done` or `error`), consumed here without
|
|
35
|
+
* depending on that package: smrt-web carries no inter-SMRT dependencies, so
|
|
36
|
+
* the frame vocabulary is mirrored the same way the rest of this module is.
|
|
37
|
+
*
|
|
38
|
+
* `status` is always `'ready'` — there is nothing to load — so an `auto` path
|
|
39
|
+
* picks this backend until a local one reports ready. It defines neither
|
|
40
|
+
* `load` nor `unload`, so a control must not offer those actions for it.
|
|
41
|
+
*
|
|
42
|
+
* A turn this wire cannot carry (tool turns, multimodal content, tool
|
|
43
|
+
* definitions) is REFUSED with `unsupported_message` rather than forwarded:
|
|
44
|
+
* the route silently drops those shapes, so forwarding would answer a
|
|
45
|
+
* different question than the caller asked.
|
|
46
|
+
*/
|
|
47
|
+
export declare function createRouteInferenceBackend(options: RouteInferenceBackendOptions): InferenceBackend;
|
|
48
|
+
|
|
49
|
+
export declare interface InferenceBackend {
|
|
50
|
+
/** Stable id, unique across a path. A preference names this. */
|
|
51
|
+
readonly id: string;
|
|
52
|
+
/**
|
|
53
|
+
* Where this backend runs. Purely descriptive: it exists so a UI can say
|
|
54
|
+
* "runs on your device" and so callers can order backends sensibly. The
|
|
55
|
+
* path's resolution never reads it.
|
|
56
|
+
*/
|
|
57
|
+
readonly kind: InferenceBackendKind;
|
|
58
|
+
readonly status: InferenceBackendStatus;
|
|
59
|
+
/** Present while `status` is `loading` (and after a failure). */
|
|
60
|
+
readonly progress?: InferenceProgress;
|
|
61
|
+
/** Load whatever `status: 'idle'` is waiting on. No-op when already ready. */
|
|
62
|
+
load?(onProgress?: (progress: InferenceProgress) => void): Promise<void>;
|
|
63
|
+
/** Release loaded resources. No-op for backends with nothing to release. */
|
|
64
|
+
unload?(): Promise<void>;
|
|
65
|
+
chat(messages: InferenceMessage[], options?: InferenceChatOptions): Promise<InferenceResponse>;
|
|
66
|
+
stream(messages: InferenceMessage[], options?: InferenceChatOptions): AsyncIterable<string>;
|
|
67
|
+
/**
|
|
68
|
+
* Optional change notification. A backend that exposes this lets the path
|
|
69
|
+
* re-broadcast status/progress changes to UI subscribers; one that omits it
|
|
70
|
+
* is simply non-reactive for those fields.
|
|
71
|
+
*/
|
|
72
|
+
subscribe?(listener: () => void): () => void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Where a backend executes. Reporting only — the path never branches on it. */
|
|
76
|
+
export declare type InferenceBackendKind = 'local' | 'route';
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Whether a backend can answer right now.
|
|
80
|
+
*
|
|
81
|
+
* - `unavailable` — cannot run in this environment at all (no WebGPU, model
|
|
82
|
+
* not offered). Never auto-selected.
|
|
83
|
+
* - `idle` — could run, nothing loaded yet. Not auto-selected; call `load()`.
|
|
84
|
+
* - `loading` — a load is in progress. Not auto-selected.
|
|
85
|
+
* - `ready` — can answer now. The only state auto-selection picks.
|
|
86
|
+
* - `error` — the last load or call failed. Never auto-selected.
|
|
87
|
+
*/
|
|
88
|
+
export declare type InferenceBackendStatus = 'unavailable' | 'idle' | 'loading' | 'ready' | 'error';
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Per-call options (mirrors the `ChatOptions` fields a browser backend can
|
|
92
|
+
* honor).
|
|
93
|
+
*
|
|
94
|
+
* `signal` is the cancellation seam a backend SHOULD respect — one that cannot
|
|
95
|
+
* (the browser LLM adapter has no cancel path) ignores it rather than
|
|
96
|
+
* pretending, so a caller must not read an abort as proof the work stopped.
|
|
97
|
+
*
|
|
98
|
+
* `tools` is refused, not ignored: every backend here is a text conversation,
|
|
99
|
+
* and a backend that silently dropped a tool definition would let a caller
|
|
100
|
+
* believe an answer was produced with tools it never received. Use
|
|
101
|
+
* {@link assertTextOnlyTurn} to check a turn up front.
|
|
102
|
+
*/
|
|
103
|
+
export declare interface InferenceChatOptions {
|
|
104
|
+
model?: string;
|
|
105
|
+
maxTokens?: number;
|
|
106
|
+
temperature?: number;
|
|
107
|
+
topP?: number;
|
|
108
|
+
stop?: string | string[];
|
|
109
|
+
tools?: InferenceTool[];
|
|
110
|
+
toolChoice?: 'auto' | 'none' | {
|
|
111
|
+
type: 'function';
|
|
112
|
+
function: {
|
|
113
|
+
name: string;
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
/** Token sink for a streamed reply. Backends that cannot stream ignore it. */
|
|
117
|
+
onProgress?: (chunk: string) => void;
|
|
118
|
+
/**
|
|
119
|
+
* Caller cancellation. Honored by the route backend and by bitgpu, which
|
|
120
|
+
* aborts a live generation; both compose it with `timeout`. The WebLLM
|
|
121
|
+
* adapter cannot cancel a generation already running on the GPU and does not
|
|
122
|
+
* read it.
|
|
123
|
+
*/
|
|
124
|
+
signal?: AbortSignal;
|
|
125
|
+
/**
|
|
126
|
+
* Deadline in milliseconds for one call. Honored by the route backend, which
|
|
127
|
+
* aborts the request when it elapses, and by bitgpu, which aborts the
|
|
128
|
+
* generation. The WebLLM adapter has no cancellation seam and ignores it.
|
|
129
|
+
*/
|
|
130
|
+
timeout?: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export declare type InferenceContentPart = InferenceTextPart | InferenceImagePart;
|
|
134
|
+
|
|
135
|
+
/** Image content part (mirrors `ImageContentPart`). */
|
|
136
|
+
export declare interface InferenceImagePart {
|
|
137
|
+
type: 'image_url';
|
|
138
|
+
image_url: {
|
|
139
|
+
url: string;
|
|
140
|
+
detail?: 'auto' | 'low' | 'high';
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** One conversation message (mirrors `AIMessage`). */
|
|
145
|
+
export declare interface InferenceMessage {
|
|
146
|
+
role: InferenceRole;
|
|
147
|
+
content: string | InferenceContentPart[];
|
|
148
|
+
name?: string;
|
|
149
|
+
tool_calls?: InferenceToolCall[];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export declare interface InferencePath {
|
|
153
|
+
readonly backends: readonly InferenceBackend[];
|
|
154
|
+
/** The backend the NEXT call would use; `null` when none is usable. */
|
|
155
|
+
readonly activeId: string | null;
|
|
156
|
+
/** The configured preference, or `'auto'`. Settable; takes effect next call. */
|
|
157
|
+
preference: string;
|
|
158
|
+
/** The resolution the next call would produce, without making one. */
|
|
159
|
+
resolve(): InferenceResolution;
|
|
160
|
+
/** Pin to a backend id, or back to `'auto'`. */
|
|
161
|
+
select(preference: string): void;
|
|
162
|
+
chat(messages: InferenceMessage[], options?: InferenceChatOptions): Promise<InferenceResponse>;
|
|
163
|
+
stream(messages: InferenceMessage[], options?: InferenceChatOptions): AsyncIterable<string>;
|
|
164
|
+
/**
|
|
165
|
+
* Notified when a backend reports a status/progress change or the
|
|
166
|
+
* preference changes. Returns an unsubscribe function.
|
|
167
|
+
*/
|
|
168
|
+
subscribe(listener: () => void): () => void;
|
|
169
|
+
/** Detach every backend subscription and drop all listeners. */
|
|
170
|
+
dispose(): void;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export declare class InferencePathError extends Error {
|
|
174
|
+
readonly code: InferencePathErrorCode;
|
|
175
|
+
constructor(message: string, code: InferencePathErrorCode);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export declare type InferencePathErrorCode = 'no_backends' | 'duplicate_backend' | 'unknown_backend' | 'no_usable_backend' | 'unsupported_message' | 'route_request_failed' | 'route_stream_error' | 'route_stream_truncated';
|
|
179
|
+
|
|
180
|
+
export declare interface InferencePathOptions {
|
|
181
|
+
/**
|
|
182
|
+
* Candidate backends, in priority order. Order IS the auto-selection
|
|
183
|
+
* preference: list the local model first to prefer it when it is usable.
|
|
184
|
+
*/
|
|
185
|
+
backends: readonly InferenceBackend[];
|
|
186
|
+
/** Backend id to prefer, or `'auto'`. Defaults to `'auto'`. */
|
|
187
|
+
preference?: string;
|
|
188
|
+
/**
|
|
189
|
+
* Whether a backend may answer. Defaults to `backend.status === 'ready'`,
|
|
190
|
+
* so an unloaded (`idle`) local backend is skipped rather than silently
|
|
191
|
+
* triggering a multi-gigabyte download mid-conversation.
|
|
192
|
+
*/
|
|
193
|
+
canUse?: (backend: InferenceBackend) => boolean;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Model load/download progress, mirroring the browser adapters'
|
|
198
|
+
* `DownloadProgressInfo` so a `DownloadProgress` control can render it
|
|
199
|
+
* directly.
|
|
200
|
+
*/
|
|
201
|
+
export declare interface InferenceProgress {
|
|
202
|
+
state: 'idle' | 'downloading' | 'extracting' | 'complete' | 'error';
|
|
203
|
+
bytesLoaded: number;
|
|
204
|
+
bytesTotal: number;
|
|
205
|
+
percent: number;
|
|
206
|
+
currentFile?: string;
|
|
207
|
+
estimatedTimeRemaining?: number;
|
|
208
|
+
error?: string;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Why a call was routed to the backend it was routed to. */
|
|
212
|
+
export declare interface InferenceResolution {
|
|
213
|
+
backend: InferenceBackend;
|
|
214
|
+
/**
|
|
215
|
+
* - `preferred` — an explicit preference named this backend and it is usable.
|
|
216
|
+
* - `fallback` — an explicit preference was unusable; `requested` names it.
|
|
217
|
+
* - `auto` — no explicit preference; first usable backend in declared order.
|
|
218
|
+
*/
|
|
219
|
+
reason: 'preferred' | 'fallback' | 'auto';
|
|
220
|
+
/** The preference that could not be honored (`reason: 'fallback'` only). */
|
|
221
|
+
requested?: string;
|
|
222
|
+
/** Ids skipped because `canUse` returned false, in declared order. */
|
|
223
|
+
skipped: readonly string[];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** A completion result (mirrors `AIResponse`). */
|
|
227
|
+
export declare interface InferenceResponse {
|
|
228
|
+
content: string;
|
|
229
|
+
usage?: InferenceUsage;
|
|
230
|
+
model?: string;
|
|
231
|
+
finishReason?: 'stop' | 'length' | 'tool_calls' | 'content_filter';
|
|
232
|
+
toolCalls?: InferenceToolCall[];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Message roles, mirroring `AIMessage['role']`. */
|
|
236
|
+
export declare type InferenceRole = 'system' | 'user' | 'assistant' | 'function' | 'tool';
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* A turn in the subset every backend in this module can carry: a text
|
|
240
|
+
* conversation with no tool traffic.
|
|
241
|
+
*/
|
|
242
|
+
export declare interface InferenceTextMessage {
|
|
243
|
+
role: 'system' | 'user' | 'assistant';
|
|
244
|
+
content: string;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Browser inference path — ONE call-site object over interchangeable inference
|
|
249
|
+
* backends.
|
|
250
|
+
*
|
|
251
|
+
* A caller (a component, a hook, a workbench) holds a single `InferencePath`
|
|
252
|
+
* and calls `chat()`/`stream()` on it. Which backend actually answers is
|
|
253
|
+
* resolved **per call**, never cached on the path, so changing the preference
|
|
254
|
+
* between two calls takes effect on the next one. That is what makes "switch
|
|
255
|
+
* between the local model and the server route mid-session" a property
|
|
256
|
+
* assignment rather than session state to unwind.
|
|
257
|
+
*
|
|
258
|
+
* ## Why a mirror rather than an import
|
|
259
|
+
*
|
|
260
|
+
* `InferenceMessage`/`InferenceResponse`/`InferenceChatOptions` are TEXTUAL
|
|
261
|
+
* structural mirrors of `@happyvertical/ai`'s `AIMessage`/`AIResponse`/
|
|
262
|
+
* `ChatOptions`, and `InferenceProgress` mirrors the browser adapters'
|
|
263
|
+
* `DownloadProgressInfo`. smrt-web deliberately has no `@happyvertical/*`
|
|
264
|
+
* dependency (`AGENTS.md`, "No inter-smrt dependencies"), so an `AIInterface`
|
|
265
|
+
* — the ecosystem's canonical inference boundary — is described here by shape.
|
|
266
|
+
*
|
|
267
|
+
* The mirror covers the text-generation surface only. A backend whose
|
|
268
|
+
* `chat`/`stream` accept these shapes satisfies the consumers in this
|
|
269
|
+
* repository; a consumer that needs more of `AIInterface` adapts at its own
|
|
270
|
+
* boundary rather than widening this contract.
|
|
271
|
+
*
|
|
272
|
+
* ## What this module does NOT own
|
|
273
|
+
*
|
|
274
|
+
* Backends arrive as arguments, the same way definitions and fetchers do
|
|
275
|
+
* everywhere else in this package. A WebLLM backend lives beside the adapter
|
|
276
|
+
* it wraps; `createRouteInferenceBackend` here covers the streaming-route case
|
|
277
|
+
* because that one is pure `fetch` plus an SSE frame reader.
|
|
278
|
+
*
|
|
279
|
+
* @module
|
|
280
|
+
*/
|
|
281
|
+
/** Text content part (mirrors `TextContentPart`). */
|
|
282
|
+
export declare interface InferenceTextPart {
|
|
283
|
+
type: 'text';
|
|
284
|
+
text: string;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** A tool definition offered to the model (mirrors `AITool`). */
|
|
288
|
+
export declare interface InferenceTool {
|
|
289
|
+
type: 'function';
|
|
290
|
+
function: {
|
|
291
|
+
name: string;
|
|
292
|
+
description?: string;
|
|
293
|
+
parameters?: Record<string, unknown>;
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export declare interface InferenceToolCall {
|
|
298
|
+
id: string;
|
|
299
|
+
type: 'function';
|
|
300
|
+
function: {
|
|
301
|
+
name: string;
|
|
302
|
+
arguments: string;
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Token usage (mirrors `TokenUsage`). */
|
|
307
|
+
export declare interface InferenceUsage {
|
|
308
|
+
promptTokens: number;
|
|
309
|
+
completionTokens: number;
|
|
310
|
+
totalTokens: number;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Cap on the reply a route backend's `chat()` will assemble. */
|
|
314
|
+
export declare const MAX_INFERENCE_REPLY_UNITS: number;
|
|
315
|
+
|
|
316
|
+
/** Cap on total bytes read for one route call. */
|
|
317
|
+
export declare const MAX_INFERENCE_STREAM_BYTES: number;
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Cap on one undelivered SSE line, in UTF-16 code units. Without it a peer
|
|
321
|
+
* that streams bytes with no `\n` grows the reader's buffer until the tab's
|
|
322
|
+
* heap is exhausted.
|
|
323
|
+
*/
|
|
324
|
+
export declare const MAX_INFERENCE_STREAM_LINE_UNITS = 1048576;
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Compose the caller's cancellation signal with this call's deadline, so a
|
|
328
|
+
* request with a `timeout` cannot outlive it even when no signal was passed.
|
|
329
|
+
*
|
|
330
|
+
* Exported so every backend that CAN cancel enforces `timeout` the same way: a
|
|
331
|
+
* deadline the route backend would have enforced must not be quietly ignored
|
|
332
|
+
* by a local one.
|
|
333
|
+
*/
|
|
334
|
+
export declare function requestSignal(chatOptions?: InferenceChatOptions): AbortSignal | undefined;
|
|
335
|
+
|
|
336
|
+
export declare interface RouteInferenceBackendOptions {
|
|
337
|
+
/** Full URL of a `text/event-stream` endpoint accepting `{ messages, session }`. */
|
|
338
|
+
endpoint: string;
|
|
339
|
+
/** Backend id. Defaults to `'route'`. */
|
|
340
|
+
id?: string;
|
|
341
|
+
/** Extra request headers, evaluated per call (auth tokens rotate). */
|
|
342
|
+
headers?: () => Record<string, string>;
|
|
343
|
+
/** Fetch credentials mode. Defaults to the platform default. */
|
|
344
|
+
credentials?: RequestCredentials;
|
|
345
|
+
/** Session metadata attached to the request body. */
|
|
346
|
+
session?: Record<string, unknown>;
|
|
347
|
+
/** Injectable fetch (tests, SSR-safe wrappers). */
|
|
348
|
+
fetchImpl?: typeof fetch;
|
|
349
|
+
/**
|
|
350
|
+
* Model id recorded on the returned `InferenceResponse`. Reporting only —
|
|
351
|
+
* the route resolves the model it actually uses, and a caller cannot widen
|
|
352
|
+
* it by naming one here (the generation caps live in the server-resolved
|
|
353
|
+
* stream context).
|
|
354
|
+
*/
|
|
355
|
+
model?: string;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export { }
|
package/dist/ai.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
//#region src/ai.ts
|
|
2
|
+
var MAX_INFERENCE_STREAM_LINE_UNITS = 1048576;
|
|
3
|
+
var MAX_INFERENCE_STREAM_BYTES = 64 * 1048576;
|
|
4
|
+
var MAX_INFERENCE_REPLY_UNITS = 8 * 1048576;
|
|
5
|
+
function assertTextOnlyTurn(backendLabel, messages, options) {
|
|
6
|
+
if (options?.tools?.length || options?.toolChoice !== void 0) throw new InferencePathError(`${backendLabel} cannot accept tool definitions or a tool choice. Route a tool-using turn to a backend that supports tools.`, "unsupported_message");
|
|
7
|
+
for (const message of messages) {
|
|
8
|
+
if (message.role !== "user" && message.role !== "assistant" && message.role !== "system") throw new InferencePathError(`${backendLabel} cannot carry a "${message.role}" message. Its transport keeps only user/assistant/system, so a tool observation would vanish from the conversation. Route a tool-using turn to a backend that supports it.`, "unsupported_message");
|
|
9
|
+
if (message.tool_calls?.length) throw new InferencePathError(`${backendLabel} cannot carry a record of a prior tool call. Dropping it would let the model answer as though the call never happened.`, "unsupported_message");
|
|
10
|
+
if (typeof message.content !== "string") throw new InferencePathError(`${backendLabel} cannot carry non-string (multimodal) content. Route it to a backend that supports it.`, "unsupported_message");
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
var InferencePathError = class extends Error {
|
|
14
|
+
code;
|
|
15
|
+
constructor(message, code) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "InferencePathError";
|
|
18
|
+
this.code = code;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
function createInferencePath(options) {
|
|
22
|
+
const backends = [...options.backends];
|
|
23
|
+
if (backends.length === 0) throw new InferencePathError("createInferencePath requires at least one backend", "no_backends");
|
|
24
|
+
const seen = /* @__PURE__ */ new Set();
|
|
25
|
+
for (const backend of backends) {
|
|
26
|
+
if (seen.has(backend.id)) throw new InferencePathError(`Duplicate inference backend id "${backend.id}"`, "duplicate_backend");
|
|
27
|
+
seen.add(backend.id);
|
|
28
|
+
}
|
|
29
|
+
const canUse = options.canUse ?? ((backend) => backend.status === "ready");
|
|
30
|
+
let preference = options.preference ?? "auto";
|
|
31
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
32
|
+
const backendTeardowns = /* @__PURE__ */ new Map();
|
|
33
|
+
function resolve() {
|
|
34
|
+
const requested = preference;
|
|
35
|
+
if (requested !== "auto") {
|
|
36
|
+
const preferred = backends.find((backend) => backend.id === requested);
|
|
37
|
+
if (!preferred) throw new InferencePathError(`No inference backend with id "${requested}"`, "unknown_backend");
|
|
38
|
+
if (canUse(preferred)) return {
|
|
39
|
+
backend: preferred,
|
|
40
|
+
reason: "preferred",
|
|
41
|
+
skipped: []
|
|
42
|
+
};
|
|
43
|
+
const fallback = backends.find(canUse);
|
|
44
|
+
if (!fallback) throw new InferencePathError(`Preferred inference backend "${requested}" is not usable and no other backend is`, "no_usable_backend");
|
|
45
|
+
return {
|
|
46
|
+
backend: fallback,
|
|
47
|
+
reason: "fallback",
|
|
48
|
+
requested,
|
|
49
|
+
skipped: backends.filter((backend) => !canUse(backend)).map((backend) => backend.id)
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const skipped = [];
|
|
53
|
+
for (const backend of backends) {
|
|
54
|
+
if (canUse(backend)) return {
|
|
55
|
+
backend,
|
|
56
|
+
reason: "auto",
|
|
57
|
+
skipped
|
|
58
|
+
};
|
|
59
|
+
skipped.push(backend.id);
|
|
60
|
+
}
|
|
61
|
+
throw new InferencePathError(`No usable inference backend among: ${backends.map((backend) => backend.id).join(", ")}`, "no_usable_backend");
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
backends,
|
|
65
|
+
get preference() {
|
|
66
|
+
return preference;
|
|
67
|
+
},
|
|
68
|
+
set preference(next) {
|
|
69
|
+
if (next === preference) return;
|
|
70
|
+
preference = next;
|
|
71
|
+
for (const listener of [...listeners]) listener();
|
|
72
|
+
},
|
|
73
|
+
get activeId() {
|
|
74
|
+
try {
|
|
75
|
+
return resolve().backend.id;
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
resolve,
|
|
81
|
+
select(next) {
|
|
82
|
+
this.preference = next;
|
|
83
|
+
},
|
|
84
|
+
async chat(messages, chatOptions) {
|
|
85
|
+
return resolve().backend.chat(messages, chatOptions);
|
|
86
|
+
},
|
|
87
|
+
stream(messages, chatOptions) {
|
|
88
|
+
return (async function* streamRouted() {
|
|
89
|
+
yield* resolve().backend.stream(messages, chatOptions);
|
|
90
|
+
})();
|
|
91
|
+
},
|
|
92
|
+
subscribe(listener) {
|
|
93
|
+
listeners.add(listener);
|
|
94
|
+
for (const backend of backends) {
|
|
95
|
+
if (backendTeardowns.has(backend.id)) continue;
|
|
96
|
+
const teardown = backend.subscribe?.(() => {
|
|
97
|
+
for (const subscriber of [...listeners]) subscriber();
|
|
98
|
+
});
|
|
99
|
+
if (teardown) backendTeardowns.set(backend.id, teardown);
|
|
100
|
+
}
|
|
101
|
+
return () => {
|
|
102
|
+
listeners.delete(listener);
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
dispose() {
|
|
106
|
+
for (const teardown of backendTeardowns.values()) teardown();
|
|
107
|
+
backendTeardowns.clear();
|
|
108
|
+
listeners.clear();
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function decodeRouteFrame(line) {
|
|
113
|
+
let parsed;
|
|
114
|
+
try {
|
|
115
|
+
parsed = JSON.parse(line.slice(5));
|
|
116
|
+
} catch {
|
|
117
|
+
return {
|
|
118
|
+
kind: "error",
|
|
119
|
+
message: `Inference route sent an unparseable frame: ${line.slice(0, 80)}`
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {
|
|
123
|
+
kind: "error",
|
|
124
|
+
message: `Inference route sent a frame that is not an object: ${line.slice(0, 80)}`
|
|
125
|
+
};
|
|
126
|
+
const frame = parsed;
|
|
127
|
+
if (frame.type === "token") {
|
|
128
|
+
if (frame.text !== void 0 && typeof frame.text !== "string") return {
|
|
129
|
+
kind: "error",
|
|
130
|
+
message: `Inference route sent a token frame with a non-string text: ${line.slice(0, 80)}`
|
|
131
|
+
};
|
|
132
|
+
return typeof frame.text === "string" && frame.text.length > 0 ? {
|
|
133
|
+
kind: "token",
|
|
134
|
+
text: frame.text
|
|
135
|
+
} : { kind: "ignored" };
|
|
136
|
+
}
|
|
137
|
+
if (frame.type === "done") {
|
|
138
|
+
const content = frame.message?.content;
|
|
139
|
+
if (content !== void 0 && typeof content !== "string") return {
|
|
140
|
+
kind: "error",
|
|
141
|
+
message: `Inference route sent a done frame with a non-string content: ${line.slice(0, 80)}`
|
|
142
|
+
};
|
|
143
|
+
return {
|
|
144
|
+
kind: "done",
|
|
145
|
+
content: typeof content === "string" ? content : void 0
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (frame.type === "error") return {
|
|
149
|
+
kind: "error",
|
|
150
|
+
message: typeof frame.error === "string" && frame.error.length > 0 ? frame.error : "Inference route reported an unspecified stream error"
|
|
151
|
+
};
|
|
152
|
+
return { kind: "ignored" };
|
|
153
|
+
}
|
|
154
|
+
async function* readRouteEvents(body) {
|
|
155
|
+
const reader = body.getReader();
|
|
156
|
+
const decoder = new TextDecoder();
|
|
157
|
+
let buffer = "";
|
|
158
|
+
let consumed = 0;
|
|
159
|
+
try {
|
|
160
|
+
for (;;) {
|
|
161
|
+
let chunk;
|
|
162
|
+
try {
|
|
163
|
+
chunk = await reader.read();
|
|
164
|
+
} catch (error) {
|
|
165
|
+
throw new InferencePathError(`Inference route stream read failed: ${error instanceof Error ? error.message : String(error)}`, "route_stream_error");
|
|
166
|
+
}
|
|
167
|
+
if (chunk.done) break;
|
|
168
|
+
consumed += chunk.value.byteLength;
|
|
169
|
+
if (consumed > 67108864) throw new InferencePathError(`Inference route stream exceeded ${MAX_INFERENCE_STREAM_BYTES} bytes`, "route_stream_error");
|
|
170
|
+
buffer += decoder.decode(chunk.value, { stream: true });
|
|
171
|
+
let newline = buffer.indexOf("\n");
|
|
172
|
+
while (newline !== -1) {
|
|
173
|
+
const line = buffer.slice(0, newline).trim();
|
|
174
|
+
buffer = buffer.slice(newline + 1);
|
|
175
|
+
newline = buffer.indexOf("\n");
|
|
176
|
+
if (!line.startsWith("data:")) continue;
|
|
177
|
+
if (line.length > 1048576) throw new InferencePathError(`Inference route stream sent a frame over ${MAX_INFERENCE_STREAM_LINE_UNITS} units`, "route_stream_error");
|
|
178
|
+
const decoded = decodeRouteFrame(line);
|
|
179
|
+
if (decoded.kind === "error") throw new InferencePathError(decoded.message, "route_stream_error");
|
|
180
|
+
if (decoded.kind === "ignored") continue;
|
|
181
|
+
yield decoded;
|
|
182
|
+
if (decoded.kind === "done") return;
|
|
183
|
+
}
|
|
184
|
+
if (buffer.length > 1048576) throw new InferencePathError(`Inference route stream sent an unterminated frame over ${MAX_INFERENCE_STREAM_LINE_UNITS} units`, "route_stream_error");
|
|
185
|
+
}
|
|
186
|
+
throw new InferencePathError("Inference route stream ended without a done frame", "route_stream_truncated");
|
|
187
|
+
} finally {
|
|
188
|
+
reader.cancel().catch(() => {});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
function requestSignal(chatOptions) {
|
|
192
|
+
const { signal, timeout } = chatOptions ?? {};
|
|
193
|
+
if (timeout === void 0) return signal;
|
|
194
|
+
const deadline = AbortSignal.timeout(timeout);
|
|
195
|
+
return signal ? AbortSignal.any([signal, deadline]) : deadline;
|
|
196
|
+
}
|
|
197
|
+
async function readBoundedBody(response, limit) {
|
|
198
|
+
const body = response.body;
|
|
199
|
+
if (!body) return "";
|
|
200
|
+
const reader = body.getReader();
|
|
201
|
+
const decoder = new TextDecoder();
|
|
202
|
+
let text = "";
|
|
203
|
+
try {
|
|
204
|
+
while (text.length < limit) {
|
|
205
|
+
const chunk = await reader.read();
|
|
206
|
+
if (chunk.done) break;
|
|
207
|
+
text += decoder.decode(chunk.value, { stream: true });
|
|
208
|
+
}
|
|
209
|
+
} finally {
|
|
210
|
+
reader.cancel().catch(() => {});
|
|
211
|
+
}
|
|
212
|
+
return text.slice(0, limit);
|
|
213
|
+
}
|
|
214
|
+
function createRouteInferenceBackend(options) {
|
|
215
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
216
|
+
async function openStream(messages, chatOptions) {
|
|
217
|
+
assertTextOnlyTurn("The streaming route", messages, chatOptions);
|
|
218
|
+
let response;
|
|
219
|
+
try {
|
|
220
|
+
response = await fetchImpl(options.endpoint, {
|
|
221
|
+
method: "POST",
|
|
222
|
+
headers: {
|
|
223
|
+
"content-type": "application/json",
|
|
224
|
+
...options.headers?.()
|
|
225
|
+
},
|
|
226
|
+
...options.credentials ? { credentials: options.credentials } : {},
|
|
227
|
+
body: JSON.stringify({
|
|
228
|
+
messages,
|
|
229
|
+
session: options.session
|
|
230
|
+
}),
|
|
231
|
+
signal: requestSignal(chatOptions)
|
|
232
|
+
});
|
|
233
|
+
} catch (error) {
|
|
234
|
+
throw new InferencePathError(`Inference route request failed: ${error instanceof Error ? error.message : String(error)}`, "route_request_failed");
|
|
235
|
+
}
|
|
236
|
+
if (!response.ok || !response.body) {
|
|
237
|
+
let detail = "";
|
|
238
|
+
try {
|
|
239
|
+
detail = await readBoundedBody(response, 200);
|
|
240
|
+
} catch {}
|
|
241
|
+
throw new InferencePathError(`Inference route responded ${response.status}${detail ? `: ${detail}` : ""}`, "route_request_failed");
|
|
242
|
+
}
|
|
243
|
+
return readRouteEvents(response.body);
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
id: options.id ?? "route",
|
|
247
|
+
kind: "route",
|
|
248
|
+
status: "ready",
|
|
249
|
+
async chat(messages, chatOptions) {
|
|
250
|
+
const events = await openStream(messages, chatOptions);
|
|
251
|
+
let assembled = "";
|
|
252
|
+
for await (const event of events) if (event.kind === "token") {
|
|
253
|
+
assembled += event.text;
|
|
254
|
+
if (assembled.length > 8388608) throw new InferencePathError(`Inference route reply exceeded ${MAX_INFERENCE_REPLY_UNITS} units`, "route_stream_error");
|
|
255
|
+
} else if (event.content !== void 0) {
|
|
256
|
+
if (event.content.length > 8388608) throw new InferencePathError(`Inference route reply exceeded ${MAX_INFERENCE_REPLY_UNITS} units`, "route_stream_error");
|
|
257
|
+
assembled = event.content;
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
content: assembled,
|
|
261
|
+
...options.model ? { model: options.model } : {}
|
|
262
|
+
};
|
|
263
|
+
},
|
|
264
|
+
async *stream(messages, chatOptions) {
|
|
265
|
+
const events = await openStream(messages, chatOptions);
|
|
266
|
+
let emitted = "";
|
|
267
|
+
for await (const event of events) {
|
|
268
|
+
if (event.kind === "token") {
|
|
269
|
+
emitted += event.text;
|
|
270
|
+
chatOptions?.onProgress?.(event.text);
|
|
271
|
+
yield event.text;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (event.content === void 0) return;
|
|
275
|
+
if (emitted.startsWith(event.content)) return;
|
|
276
|
+
if (event.content.startsWith(emitted)) {
|
|
277
|
+
const remainder = event.content.slice(emitted.length);
|
|
278
|
+
if (remainder.length === 0) return;
|
|
279
|
+
chatOptions?.onProgress?.(remainder);
|
|
280
|
+
yield remainder;
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
throw new InferencePathError("Inference route sent a terminal reply that contradicts the streamed preview", "route_stream_error");
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
//#endregion
|
|
289
|
+
export { InferencePathError, MAX_INFERENCE_REPLY_UNITS, MAX_INFERENCE_STREAM_BYTES, MAX_INFERENCE_STREAM_LINE_UNITS, assertTextOnlyTurn, createInferencePath, createRouteInferenceBackend, requestSignal };
|
|
290
|
+
|
|
291
|
+
//# sourceMappingURL=ai.js.map
|
package/dist/ai.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ai.js","names":[],"sources":["../src/ai.ts"],"sourcesContent":["/**\n * Browser inference path — ONE call-site object over interchangeable inference\n * backends.\n *\n * A caller (a component, a hook, a workbench) holds a single `InferencePath`\n * and calls `chat()`/`stream()` on it. Which backend actually answers is\n * resolved **per call**, never cached on the path, so changing the preference\n * between two calls takes effect on the next one. That is what makes \"switch\n * between the local model and the server route mid-session\" a property\n * assignment rather than session state to unwind.\n *\n * ## Why a mirror rather than an import\n *\n * `InferenceMessage`/`InferenceResponse`/`InferenceChatOptions` are TEXTUAL\n * structural mirrors of `@happyvertical/ai`'s `AIMessage`/`AIResponse`/\n * `ChatOptions`, and `InferenceProgress` mirrors the browser adapters'\n * `DownloadProgressInfo`. smrt-web deliberately has no `@happyvertical/*`\n * dependency (`AGENTS.md`, \"No inter-smrt dependencies\"), so an `AIInterface`\n * — the ecosystem's canonical inference boundary — is described here by shape.\n *\n * The mirror covers the text-generation surface only. A backend whose\n * `chat`/`stream` accept these shapes satisfies the consumers in this\n * repository; a consumer that needs more of `AIInterface` adapts at its own\n * boundary rather than widening this contract.\n *\n * ## What this module does NOT own\n *\n * Backends arrive as arguments, the same way definitions and fetchers do\n * everywhere else in this package. A WebLLM backend lives beside the adapter\n * it wraps; `createRouteInferenceBackend` here covers the streaming-route case\n * because that one is pure `fetch` plus an SSE frame reader.\n *\n * @module\n */\n\n// ---------------------------------------------------------------------------\n// Mirrored vocabulary\n// ---------------------------------------------------------------------------\n\n/** Text content part (mirrors `TextContentPart`). */\nexport interface InferenceTextPart {\n type: 'text';\n text: string;\n}\n\n/** Image content part (mirrors `ImageContentPart`). */\nexport interface InferenceImagePart {\n type: 'image_url';\n image_url: {\n url: string;\n detail?: 'auto' | 'low' | 'high';\n };\n}\n\nexport type InferenceContentPart = InferenceTextPart | InferenceImagePart;\n\n/** Message roles, mirroring `AIMessage['role']`. */\nexport type InferenceRole =\n | 'system'\n | 'user'\n | 'assistant'\n | 'function'\n | 'tool';\n\nexport interface InferenceToolCall {\n id: string;\n type: 'function';\n function: { name: string; arguments: string };\n}\n\n/** One conversation message (mirrors `AIMessage`). */\nexport interface InferenceMessage {\n role: InferenceRole;\n content: string | InferenceContentPart[];\n name?: string;\n tool_calls?: InferenceToolCall[];\n}\n\n/** A tool definition offered to the model (mirrors `AITool`). */\nexport interface InferenceTool {\n type: 'function';\n function: {\n name: string;\n description?: string;\n parameters?: Record<string, unknown>;\n };\n}\n\n/** Token usage (mirrors `TokenUsage`). */\nexport interface InferenceUsage {\n promptTokens: number;\n completionTokens: number;\n totalTokens: number;\n}\n\n/** A completion result (mirrors `AIResponse`). */\nexport interface InferenceResponse {\n content: string;\n usage?: InferenceUsage;\n model?: string;\n finishReason?: 'stop' | 'length' | 'tool_calls' | 'content_filter';\n toolCalls?: InferenceToolCall[];\n}\n\n/**\n * Per-call options (mirrors the `ChatOptions` fields a browser backend can\n * honor).\n *\n * `signal` is the cancellation seam a backend SHOULD respect — one that cannot\n * (the browser LLM adapter has no cancel path) ignores it rather than\n * pretending, so a caller must not read an abort as proof the work stopped.\n *\n * `tools` is refused, not ignored: every backend here is a text conversation,\n * and a backend that silently dropped a tool definition would let a caller\n * believe an answer was produced with tools it never received. Use\n * {@link assertTextOnlyTurn} to check a turn up front.\n */\nexport interface InferenceChatOptions {\n model?: string;\n maxTokens?: number;\n temperature?: number;\n topP?: number;\n stop?: string | string[];\n tools?: InferenceTool[];\n toolChoice?:\n | 'auto'\n | 'none'\n | { type: 'function'; function: { name: string } };\n /** Token sink for a streamed reply. Backends that cannot stream ignore it. */\n onProgress?: (chunk: string) => void;\n /**\n * Caller cancellation. Honored by the route backend and by bitgpu, which\n * aborts a live generation; both compose it with `timeout`. The WebLLM\n * adapter cannot cancel a generation already running on the GPU and does not\n * read it.\n */\n signal?: AbortSignal;\n /**\n * Deadline in milliseconds for one call. Honored by the route backend, which\n * aborts the request when it elapses, and by bitgpu, which aborts the\n * generation. The WebLLM adapter has no cancellation seam and ignores it.\n */\n timeout?: number;\n}\n\n/**\n * Model load/download progress, mirroring the browser adapters'\n * `DownloadProgressInfo` so a `DownloadProgress` control can render it\n * directly.\n */\nexport interface InferenceProgress {\n state: 'idle' | 'downloading' | 'extracting' | 'complete' | 'error';\n bytesLoaded: number;\n bytesTotal: number;\n percent: number;\n currentFile?: string;\n estimatedTimeRemaining?: number;\n error?: string;\n}\n\n// ---------------------------------------------------------------------------\n// Representation limits shared by every backend\n// ---------------------------------------------------------------------------\n\n/**\n * Cap on one undelivered SSE line, in UTF-16 code units. Without it a peer\n * that streams bytes with no `\\n` grows the reader's buffer until the tab's\n * heap is exhausted.\n */\nexport const MAX_INFERENCE_STREAM_LINE_UNITS = 1_048_576;\n\n/** Cap on total bytes read for one route call. */\nexport const MAX_INFERENCE_STREAM_BYTES = 64 * 1_048_576;\n\n/** Cap on the reply a route backend's `chat()` will assemble. */\nexport const MAX_INFERENCE_REPLY_UNITS = 8 * 1_048_576;\n\n/**\n * A turn in the subset every backend in this module can carry: a text\n * conversation with no tool traffic.\n */\nexport interface InferenceTextMessage {\n role: 'system' | 'user' | 'assistant';\n content: string;\n}\n\n/**\n * Reject a turn this module's backends cannot faithfully carry, narrowing\n * `messages` to {@link InferenceTextMessage} on success.\n *\n * Every backend here is a TEXT conversation: roles `user`/`assistant`/\n * `system`, string content, no tools. Each of those limits is a real\n * difference between the backends' transports, so refusing up front is the\n * only way a caller can tell \"this backend can't carry my turn\" from \"this\n * backend answered\". The route wire contract (`chat-stream.ts`\n * `normalizeMessages`) and the local adapter both silently DROP exactly these\n * shapes, which is why this check is shared rather than reimplemented per\n * backend — two copies would drift, and the whole point is that both backends\n * agree.\n *\n * `backendLabel` names the backend in the message so a caller knows which one\n * refused.\n */\nexport function assertTextOnlyTurn(\n backendLabel: string,\n messages: InferenceMessage[],\n options?: InferenceChatOptions,\n): asserts messages is InferenceTextMessage[] {\n if (options?.tools?.length || options?.toolChoice !== undefined) {\n throw new InferencePathError(\n `${backendLabel} cannot accept tool definitions or a tool choice. Route a tool-using turn to a backend that supports tools.`,\n 'unsupported_message',\n );\n }\n for (const message of messages) {\n if (\n message.role !== 'user' &&\n message.role !== 'assistant' &&\n message.role !== 'system'\n ) {\n throw new InferencePathError(\n `${backendLabel} cannot carry a \"${message.role}\" message. Its transport keeps only user/assistant/system, so a tool observation would vanish from the conversation. Route a tool-using turn to a backend that supports it.`,\n 'unsupported_message',\n );\n }\n if (message.tool_calls?.length) {\n throw new InferencePathError(\n `${backendLabel} cannot carry a record of a prior tool call. Dropping it would let the model answer as though the call never happened.`,\n 'unsupported_message',\n );\n }\n if (typeof message.content !== 'string') {\n throw new InferencePathError(\n `${backendLabel} cannot carry non-string (multimodal) content. Route it to a backend that supports it.`,\n 'unsupported_message',\n );\n }\n }\n}\n\n// ---------------------------------------------------------------------------\n// Backend contract\n// ---------------------------------------------------------------------------\n\n/**\n * Whether a backend can answer right now.\n *\n * - `unavailable` — cannot run in this environment at all (no WebGPU, model\n * not offered). Never auto-selected.\n * - `idle` — could run, nothing loaded yet. Not auto-selected; call `load()`.\n * - `loading` — a load is in progress. Not auto-selected.\n * - `ready` — can answer now. The only state auto-selection picks.\n * - `error` — the last load or call failed. Never auto-selected.\n */\nexport type InferenceBackendStatus =\n | 'unavailable'\n | 'idle'\n | 'loading'\n | 'ready'\n | 'error';\n\n/** Where a backend executes. Reporting only — the path never branches on it. */\nexport type InferenceBackendKind = 'local' | 'route';\n\nexport interface InferenceBackend {\n /** Stable id, unique across a path. A preference names this. */\n readonly id: string;\n /**\n * Where this backend runs. Purely descriptive: it exists so a UI can say\n * \"runs on your device\" and so callers can order backends sensibly. The\n * path's resolution never reads it.\n */\n readonly kind: InferenceBackendKind;\n readonly status: InferenceBackendStatus;\n /** Present while `status` is `loading` (and after a failure). */\n readonly progress?: InferenceProgress;\n\n /** Load whatever `status: 'idle'` is waiting on. No-op when already ready. */\n load?(onProgress?: (progress: InferenceProgress) => void): Promise<void>;\n /** Release loaded resources. No-op for backends with nothing to release. */\n unload?(): Promise<void>;\n\n chat(\n messages: InferenceMessage[],\n options?: InferenceChatOptions,\n ): Promise<InferenceResponse>;\n stream(\n messages: InferenceMessage[],\n options?: InferenceChatOptions,\n ): AsyncIterable<string>;\n\n /**\n * Optional change notification. A backend that exposes this lets the path\n * re-broadcast status/progress changes to UI subscribers; one that omits it\n * is simply non-reactive for those fields.\n */\n subscribe?(listener: () => void): () => void;\n}\n\n// ---------------------------------------------------------------------------\n// Path\n// ---------------------------------------------------------------------------\n\n/** Why a call was routed to the backend it was routed to. */\nexport interface InferenceResolution {\n backend: InferenceBackend;\n /**\n * - `preferred` — an explicit preference named this backend and it is usable.\n * - `fallback` — an explicit preference was unusable; `requested` names it.\n * - `auto` — no explicit preference; first usable backend in declared order.\n */\n reason: 'preferred' | 'fallback' | 'auto';\n /** The preference that could not be honored (`reason: 'fallback'` only). */\n requested?: string;\n /** Ids skipped because `canUse` returned false, in declared order. */\n skipped: readonly string[];\n}\n\nexport interface InferencePathOptions {\n /**\n * Candidate backends, in priority order. Order IS the auto-selection\n * preference: list the local model first to prefer it when it is usable.\n */\n backends: readonly InferenceBackend[];\n /** Backend id to prefer, or `'auto'`. Defaults to `'auto'`. */\n preference?: string;\n /**\n * Whether a backend may answer. Defaults to `backend.status === 'ready'`,\n * so an unloaded (`idle`) local backend is skipped rather than silently\n * triggering a multi-gigabyte download mid-conversation.\n */\n canUse?: (backend: InferenceBackend) => boolean;\n}\n\nexport interface InferencePath {\n readonly backends: readonly InferenceBackend[];\n /** The backend the NEXT call would use; `null` when none is usable. */\n readonly activeId: string | null;\n /** The configured preference, or `'auto'`. Settable; takes effect next call. */\n preference: string;\n\n /** The resolution the next call would produce, without making one. */\n resolve(): InferenceResolution;\n /** Pin to a backend id, or back to `'auto'`. */\n select(preference: string): void;\n\n chat(\n messages: InferenceMessage[],\n options?: InferenceChatOptions,\n ): Promise<InferenceResponse>;\n stream(\n messages: InferenceMessage[],\n options?: InferenceChatOptions,\n ): AsyncIterable<string>;\n\n /**\n * Notified when a backend reports a status/progress change or the\n * preference changes. Returns an unsubscribe function.\n */\n subscribe(listener: () => void): () => void;\n\n /** Detach every backend subscription and drop all listeners. */\n dispose(): void;\n}\n\nexport type InferencePathErrorCode =\n | 'no_backends'\n | 'duplicate_backend'\n | 'unknown_backend'\n | 'no_usable_backend'\n | 'unsupported_message'\n | 'route_request_failed'\n | 'route_stream_error'\n | 'route_stream_truncated';\n\nexport class InferencePathError extends Error {\n readonly code: InferencePathErrorCode;\n\n constructor(message: string, code: InferencePathErrorCode) {\n super(message);\n this.name = 'InferencePathError';\n this.code = code;\n }\n}\n\n/**\n * Create the browser's single inference path over `options.backends`.\n *\n * Resolution happens inside every `chat`/`stream` call, so a `preference` or\n * `select()` change made between calls is honored by the next one. A call\n * whose resolution finds nothing usable throws `InferencePathError` with\n * `code: 'no_usable_backend'` rather than falling back to a fake answer.\n */\nexport function createInferencePath(\n options: InferencePathOptions,\n): InferencePath {\n const backends = [...options.backends];\n if (backends.length === 0) {\n throw new InferencePathError(\n 'createInferencePath requires at least one backend',\n 'no_backends',\n );\n }\n\n const seen = new Set<string>();\n for (const backend of backends) {\n if (seen.has(backend.id)) {\n // Fail closed and atomically, before anything is routed: a duplicate id\n // makes `preference` ambiguous, and picking one would silently ignore\n // the other.\n throw new InferencePathError(\n `Duplicate inference backend id \"${backend.id}\"`,\n 'duplicate_backend',\n );\n }\n seen.add(backend.id);\n }\n\n const canUse = options.canUse ?? ((backend) => backend.status === 'ready');\n let preference = options.preference ?? 'auto';\n const listeners = new Set<() => void>();\n const backendTeardowns = new Map<string, () => void>();\n\n function resolve(): InferenceResolution {\n const requested = preference;\n if (requested !== 'auto') {\n const preferred = backends.find((backend) => backend.id === requested);\n if (!preferred) {\n throw new InferencePathError(\n `No inference backend with id \"${requested}\"`,\n 'unknown_backend',\n );\n }\n if (canUse(preferred)) {\n return {\n backend: preferred,\n reason: 'preferred',\n skipped: [],\n };\n }\n const fallback = backends.find(canUse);\n if (!fallback) {\n throw new InferencePathError(\n `Preferred inference backend \"${requested}\" is not usable and no other backend is`,\n 'no_usable_backend',\n );\n }\n return {\n backend: fallback,\n reason: 'fallback',\n requested,\n skipped: backends\n .filter((backend) => !canUse(backend))\n .map((backend) => backend.id),\n };\n }\n\n const skipped: string[] = [];\n for (const backend of backends) {\n if (canUse(backend)) {\n return { backend, reason: 'auto', skipped };\n }\n skipped.push(backend.id);\n }\n throw new InferencePathError(\n `No usable inference backend among: ${backends\n .map((backend) => backend.id)\n .join(', ')}`,\n 'no_usable_backend',\n );\n }\n\n return {\n backends,\n get preference() {\n return preference;\n },\n set preference(next: string) {\n if (next === preference) return;\n preference = next;\n for (const listener of [...listeners]) listener();\n },\n\n get activeId() {\n try {\n return resolve().backend.id;\n } catch {\n return null;\n }\n },\n\n resolve,\n select(next: string) {\n this.preference = next;\n },\n\n async chat(messages, chatOptions) {\n return resolve().backend.chat(messages, chatOptions);\n },\n\n stream(messages, chatOptions) {\n // Resolution must happen when the consumer PULLS, not when `stream` is\n // called: `chat`/`stream` are per-call, so a preference set between the\n // call and the first pull has to be the one that applies.\n return (async function* streamRouted() {\n yield* resolve().backend.stream(messages, chatOptions);\n })();\n },\n\n subscribe(listener: () => void) {\n listeners.add(listener);\n // Attach to any backend that opted into change notification, once each.\n for (const backend of backends) {\n if (backendTeardowns.has(backend.id)) continue;\n const teardown = backend.subscribe?.(() => {\n for (const subscriber of [...listeners]) subscriber();\n });\n if (teardown) backendTeardowns.set(backend.id, teardown);\n }\n return () => {\n listeners.delete(listener);\n };\n },\n\n dispose() {\n for (const teardown of backendTeardowns.values()) teardown();\n backendTeardowns.clear();\n listeners.clear();\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n// Streaming-route backend\n// ---------------------------------------------------------------------------\n\nexport interface RouteInferenceBackendOptions {\n /** Full URL of a `text/event-stream` endpoint accepting `{ messages, session }`. */\n endpoint: string;\n /** Backend id. Defaults to `'route'`. */\n id?: string;\n /** Extra request headers, evaluated per call (auth tokens rotate). */\n headers?: () => Record<string, string>;\n /** Fetch credentials mode. Defaults to the platform default. */\n credentials?: RequestCredentials;\n /** Session metadata attached to the request body. */\n session?: Record<string, unknown>;\n /** Injectable fetch (tests, SSR-safe wrappers). */\n fetchImpl?: typeof fetch;\n /**\n * Model id recorded on the returned `InferenceResponse`. Reporting only —\n * the route resolves the model it actually uses, and a caller cannot widen\n * it by naming one here (the generation caps live in the server-resolved\n * stream context).\n */\n model?: string;\n}\n\n/** One frame the consumed route can produce. */\ntype RouteEvent =\n | { kind: 'token'; text: string }\n | { kind: 'done'; content: string | undefined };\n\ninterface RouteFrame {\n type?: unknown;\n text?: unknown;\n error?: unknown;\n message?: { content?: unknown } | null;\n}\n\n/**\n * One SSE line decoded, or the frame's terminal meaning.\n *\n * `emotion`/`control`/unknown `type` values resolve to `{ kind: 'ignored' }`:\n * `chat-stream.ts` declares lanes the engine does not emit yet, so an\n * unrecognized frame is forward compatibility, not a failure.\n */\nfunction decodeRouteFrame(\n line: string,\n): RouteEvent | { kind: 'ignored' } | { kind: 'error'; message: string } {\n let parsed: unknown;\n try {\n parsed = JSON.parse(line.slice('data:'.length));\n } catch {\n return {\n kind: 'error',\n message: `Inference route sent an unparseable frame: ${line.slice(0, 80)}`,\n };\n }\n\n // `null`, a number, a bare string and an array are all valid JSON and none of\n // them is a frame. The route contract is fail-closed for malformed frames, so\n // this is a coded error rather than an ignored one: accepting a primitive and\n // then completing on a later `done` would report a successful answer for a\n // response that was never well formed.\n if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {\n return {\n kind: 'error',\n message: `Inference route sent a frame that is not an object: ${line.slice(0, 80)}`,\n };\n }\n const frame = parsed as RouteFrame;\n\n if (frame.type === 'token') {\n // A frame that CARRIES the field with the wrong type is malformed; dropping\n // it would truncate the reply indistinguishably from a short answer. An\n // absent field or an empty string stays the documented no-op.\n if (frame.text !== undefined && typeof frame.text !== 'string') {\n return {\n kind: 'error',\n message: `Inference route sent a token frame with a non-string text: ${line.slice(0, 80)}`,\n };\n }\n return typeof frame.text === 'string' && frame.text.length > 0\n ? { kind: 'token', text: frame.text }\n : { kind: 'ignored' };\n }\n if (frame.type === 'done') {\n const content = frame.message?.content;\n // Same rule: a `done` carrying a non-string content is malformed, and\n // folding it into \"carries no content\" would report a successful answer\n // assembled from the preview of a response that was never well formed.\n if (content !== undefined && typeof content !== 'string') {\n return {\n kind: 'error',\n message: `Inference route sent a done frame with a non-string content: ${line.slice(0, 80)}`,\n };\n }\n return {\n kind: 'done',\n content: typeof content === 'string' ? content : undefined,\n };\n }\n if (frame.type === 'error') {\n return {\n kind: 'error',\n message:\n typeof frame.error === 'string' && frame.error.length > 0\n ? frame.error\n : 'Inference route reported an unspecified stream error',\n };\n }\n return { kind: 'ignored' };\n}\n\n/**\n * Read one SSE response body as route events.\n *\n * The reader is cancelled in a `finally`, so a consumer that abandons the\n * iterable (an aborted turn, an unmounted component) releases the connection\n * instead of leaving it open until GC.\n */\nasync function* readRouteEvents(\n body: ReadableStream<Uint8Array>,\n): AsyncGenerator<RouteEvent> {\n const reader = body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n let consumed = 0;\n\n try {\n for (;;) {\n let chunk: ReadableStreamReadResult<Uint8Array>;\n try {\n chunk = await reader.read();\n } catch (error) {\n // A reset socket or an aborted read is a stream failure like any\n // other; every failure must leave this module as an\n // `InferencePathError` so a caller branching on `code` sees it.\n throw new InferencePathError(\n `Inference route stream read failed: ${error instanceof Error ? error.message : String(error)}`,\n 'route_stream_error',\n );\n }\n if (chunk.done) break;\n\n consumed += chunk.value.byteLength;\n if (consumed > MAX_INFERENCE_STREAM_BYTES) {\n throw new InferencePathError(\n `Inference route stream exceeded ${MAX_INFERENCE_STREAM_BYTES} bytes`,\n 'route_stream_error',\n );\n }\n\n buffer += decoder.decode(chunk.value, { stream: true });\n\n let newline = buffer.indexOf('\\n');\n while (newline !== -1) {\n const line = buffer.slice(0, newline).trim();\n buffer = buffer.slice(newline + 1);\n newline = buffer.indexOf('\\n');\n // Blank lines and `: heartbeat` comments carry no frame.\n if (!line.startsWith('data:')) continue;\n // Enforced BEFORE decoding: a peer can put one complete oversized frame\n // in a single chunk, and `JSON.parse` would materialize it in full\n // before any post-parse guard could see it.\n if (line.length > MAX_INFERENCE_STREAM_LINE_UNITS) {\n throw new InferencePathError(\n `Inference route stream sent a frame over ${MAX_INFERENCE_STREAM_LINE_UNITS} units`,\n 'route_stream_error',\n );\n }\n\n const decoded = decodeRouteFrame(line);\n if (decoded.kind === 'error') {\n throw new InferencePathError(decoded.message, 'route_stream_error');\n }\n if (decoded.kind === 'ignored') continue;\n yield decoded;\n // `done` is terminal: the route never sends another frame after it,\n // and the consumer's `for await` should not wait for the close.\n if (decoded.kind === 'done') return;\n }\n\n // Whatever is left has no terminator yet. Bounding it is what keeps the\n // `indexOf`/`slice` rescans above bounded too: an unterminated line can\n // never grow past the cap, so neither can the per-chunk rescan cost.\n if (buffer.length > MAX_INFERENCE_STREAM_LINE_UNITS) {\n throw new InferencePathError(\n `Inference route stream sent an unterminated frame over ${MAX_INFERENCE_STREAM_LINE_UNITS} units`,\n 'route_stream_error',\n );\n }\n }\n // The route always terminates with `done` or `error`. A clean close\n // without one means an intermediary cut the stream — never fabricate a\n // successful (possibly empty) reply out of a truncation.\n throw new InferencePathError(\n 'Inference route stream ended without a done frame',\n 'route_stream_truncated',\n );\n } finally {\n reader.cancel().catch(() => {\n /* stream already closed — nothing to release */\n });\n }\n}\n\n/**\n * Compose the caller's cancellation signal with this call's deadline, so a\n * request with a `timeout` cannot outlive it even when no signal was passed.\n *\n * Exported so every backend that CAN cancel enforces `timeout` the same way: a\n * deadline the route backend would have enforced must not be quietly ignored\n * by a local one.\n */\nexport function requestSignal(\n chatOptions?: InferenceChatOptions,\n): AbortSignal | undefined {\n const { signal, timeout } = chatOptions ?? {};\n if (timeout === undefined) return signal;\n const deadline = AbortSignal.timeout(timeout);\n return signal ? AbortSignal.any([signal, deadline]) : deadline;\n}\n\n/**\n * Read at most `limit` characters of a response body.\n *\n * A non-2xx response is untrusted too: `response.text()` materializes the whole\n * body before any excerpt could bound it, so the bound has to be applied while\n * reading rather than after.\n */\nasync function readBoundedBody(\n response: Response,\n limit: number,\n): Promise<string> {\n const body = response.body;\n if (!body) return '';\n const reader = body.getReader();\n const decoder = new TextDecoder();\n let text = '';\n try {\n while (text.length < limit) {\n const chunk = await reader.read();\n if (chunk.done) break;\n text += decoder.decode(chunk.value, { stream: true });\n }\n } finally {\n reader.cancel().catch(() => {\n /* already closed — nothing to release */\n });\n }\n return text.slice(0, limit);\n}\n\n/**\n * A backend over a `text/event-stream` inference route.\n *\n * The wire contract is `@happyvertical/smrt-chat`'s `ChatStreamEvent` framing\n * (token deltas, then a terminal `done` or `error`), consumed here without\n * depending on that package: smrt-web carries no inter-SMRT dependencies, so\n * the frame vocabulary is mirrored the same way the rest of this module is.\n *\n * `status` is always `'ready'` — there is nothing to load — so an `auto` path\n * picks this backend until a local one reports ready. It defines neither\n * `load` nor `unload`, so a control must not offer those actions for it.\n *\n * A turn this wire cannot carry (tool turns, multimodal content, tool\n * definitions) is REFUSED with `unsupported_message` rather than forwarded:\n * the route silently drops those shapes, so forwarding would answer a\n * different question than the caller asked.\n */\nexport function createRouteInferenceBackend(\n options: RouteInferenceBackendOptions,\n): InferenceBackend {\n const fetchImpl = options.fetchImpl ?? fetch;\n\n async function openStream(\n messages: InferenceMessage[],\n chatOptions?: InferenceChatOptions,\n ): Promise<AsyncGenerator<RouteEvent>> {\n // Refuse rather than forward: the route's own wire contract\n // (`chat-stream.ts` `normalizeMessages`) silently DROPS tool turns,\n // non-string content and any tool definition, so forwarding them would\n // return a plausible answer to a different question than the caller asked.\n assertTextOnlyTurn('The streaming route', messages, chatOptions);\n\n let response: Response;\n try {\n response = await fetchImpl(options.endpoint, {\n method: 'POST',\n headers: {\n 'content-type': 'application/json',\n ...options.headers?.(),\n },\n ...(options.credentials ? { credentials: options.credentials } : {}),\n body: JSON.stringify({ messages, session: options.session }),\n signal: requestSignal(chatOptions),\n });\n } catch (error) {\n // Offline, DNS, CORS, TLS and an aborted or timed-out request all land\n // here; a caller must be able to see that as a route failure rather than\n // as an unexplained TypeError.\n throw new InferencePathError(\n `Inference route request failed: ${error instanceof Error ? error.message : String(error)}`,\n 'route_request_failed',\n );\n }\n\n if (!response.ok || !response.body) {\n // The route renders structured errors — carry a bounded body excerpt so\n // an auth failure is distinguishable from a bad request.\n let detail = '';\n try {\n detail = await readBoundedBody(response, 200);\n } catch {\n /* body unreadable — the status alone will have to do */\n }\n throw new InferencePathError(\n `Inference route responded ${response.status}${detail ? `: ${detail}` : ''}`,\n 'route_request_failed',\n );\n }\n\n return readRouteEvents(response.body);\n }\n\n return {\n id: options.id ?? 'route',\n kind: 'route',\n status: 'ready',\n\n async chat(messages, chatOptions) {\n const events = await openStream(messages, chatOptions);\n let assembled = '';\n for await (const event of events) {\n if (event.kind === 'token') {\n assembled += event.text;\n // Bound the accumulator, so a peer that streams token frames\n // forever cannot exhaust the tab's heap.\n if (assembled.length > MAX_INFERENCE_REPLY_UNITS) {\n throw new InferencePathError(\n `Inference route reply exceeded ${MAX_INFERENCE_REPLY_UNITS} units`,\n 'route_stream_error',\n );\n }\n }\n // `done.message.content` is the server's persisted, authoritative\n // reply; the assembled tokens are a live preview that a tool-call\n // round may have narrated past. Bounded by the same cap: replacing the\n // accumulator with a larger terminal frame would otherwise bypass it.\n else if (event.content !== undefined) {\n if (event.content.length > MAX_INFERENCE_REPLY_UNITS) {\n throw new InferencePathError(\n `Inference route reply exceeded ${MAX_INFERENCE_REPLY_UNITS} units`,\n 'route_stream_error',\n );\n }\n assembled = event.content;\n }\n }\n return {\n content: assembled,\n ...(options.model ? { model: options.model } : {}),\n };\n },\n\n async *stream(messages, chatOptions) {\n const events = await openStream(messages, chatOptions);\n let emitted = '';\n for await (const event of events) {\n if (event.kind === 'token') {\n emitted += event.text;\n chatOptions?.onProgress?.(event.text);\n yield event.text;\n continue;\n }\n // `done.message.content` is the server's persisted, authoritative\n // reply; the tokens are a live preview that a tool-call round may have\n // narrated past. A delta stream cannot retract what it already yielded,\n // so reconcile against what THIS consumer has seen: it either already\n // carries the reply, or the reply extends it and only the remainder is\n // owed. Anything else is a contradiction, not a reply.\n if (event.content === undefined) return;\n // The preview already carries the reply — it IS it, or it starts with\n // it — so there is nothing the consumer has not seen.\n if (emitted.startsWith(event.content)) return;\n // The reply extends the preview: yield only the unseen remainder, so a\n // concatenating consumer reconstructs the authoritative text exactly.\n if (event.content.startsWith(emitted)) {\n const remainder = event.content.slice(emitted.length);\n if (remainder.length === 0) return;\n chatOptions?.onProgress?.(remainder);\n yield remainder;\n return;\n }\n // Neither extends nor is contained: the terminal reply contradicts what\n // was already yielded. A delta stream cannot retract it, so fail closed\n // rather than render text the server never sent.\n throw new InferencePathError(\n 'Inference route sent a terminal reply that contradicts the streamed preview',\n 'route_stream_error',\n );\n }\n },\n };\n}\n"],"mappings":";AAyKO,IAAM,kCAAkC;AAGxC,IAAM,6BAA6B,KAAK;AAGxC,IAAM,4BAA4B,IAAI;AA4BtC,SAAS,mBACd,cACA,UACA,SAC4C;CAC5C,IAAI,SAAS,OAAO,UAAU,SAAS,eAAe,KAAA,GACpD,MAAM,IAAI,mBACR,GAAG,aAAY,8GACf,qBACF;CAEF,KAAA,MAAW,WAAW,UAAU;EAC9B,IACE,QAAQ,SAAS,UACjB,QAAQ,SAAS,eACjB,QAAQ,SAAS,UAEjB,MAAM,IAAI,mBACR,GAAG,aAAY,mBAAoB,QAAQ,KAAI,8KAC/C,qBACF;EAEF,IAAI,QAAQ,YAAY,QACtB,MAAM,IAAI,mBACR,GAAG,aAAY,yHACf,qBACF;EAEF,IAAI,OAAO,QAAQ,YAAY,UAC7B,MAAM,IAAI,mBACR,GAAG,aAAY,yFACf,qBACF;CAEJ;AACF;AAyIO,IAAM,qBAAN,cAAiC,MAAM;CACnC;CAET,YAAY,SAAiB,MAA8B;EACzD,MAAM,OAAO;EACb,KAAK,OAAO;EACZ,KAAK,OAAO;CACd;AACF;AAUO,SAAS,oBACd,SACe;CACf,MAAM,WAAW,CAAC,GAAG,QAAQ,QAAQ;CACrC,IAAI,SAAS,WAAW,GACtB,MAAM,IAAI,mBACR,qDACA,aACF;CAGF,MAAM,uBAAO,IAAI,IAAY;CAC7B,KAAA,MAAW,WAAW,UAAU;EAC9B,IAAI,KAAK,IAAI,QAAQ,EAAE,GAIrB,MAAM,IAAI,mBACR,mCAAmC,QAAQ,GAAE,IAC7C,mBACF;EAEF,KAAK,IAAI,QAAQ,EAAE;CACrB;CAEA,MAAM,SAAS,QAAQ,YAAY,YAAY,QAAQ,WAAW;CAClE,IAAI,aAAa,QAAQ,cAAc;CACvC,MAAM,4BAAY,IAAI,IAAgB;CACtC,MAAM,mCAAmB,IAAI,IAAwB;CAErD,SAAS,UAA+B;EACtC,MAAM,YAAY;EAClB,IAAI,cAAc,QAAQ;GACxB,MAAM,YAAY,SAAS,MAAM,YAAY,QAAQ,OAAO,SAAS;GACrE,IAAI,CAAC,WACH,MAAM,IAAI,mBACR,iCAAiC,UAAS,IAC1C,iBACF;GAEF,IAAI,OAAO,SAAS,GAClB,OAAO;IACL,SAAS;IACT,QAAQ;IACR,SAAS,CAAC;GACZ;GAEF,MAAM,WAAW,SAAS,KAAK,MAAM;GACrC,IAAI,CAAC,UACH,MAAM,IAAI,mBACR,gCAAgC,UAAS,0CACzC,mBACF;GAEF,OAAO;IACL,SAAS;IACT,QAAQ;IACR;IACA,SAAS,SACN,QAAQ,YAAY,CAAC,OAAO,OAAO,CAAC,CAAA,CACpC,KAAK,YAAY,QAAQ,EAAE;GAChC;EACF;EAEA,MAAM,UAAoB,CAAC;EAC3B,KAAA,MAAW,WAAW,UAAU;GAC9B,IAAI,OAAO,OAAO,GAChB,OAAO;IAAE;IAAS,QAAQ;IAAQ;GAAQ;GAE5C,QAAQ,KAAK,QAAQ,EAAE;EACzB;EACA,MAAM,IAAI,mBACR,sCAAsC,SACnC,KAAK,YAAY,QAAQ,EAAE,CAAA,CAC3B,KAAK,IAAI,KACZ,mBACF;CACF;CAEA,OAAO;EACL;EACA,IAAI,aAAa;GACf,OAAO;EACT;EACA,IAAI,WAAW,MAAc;GAC3B,IAAI,SAAS,YAAY;GACzB,aAAa;GACb,KAAA,MAAW,YAAY,CAAC,GAAG,SAAS,GAAG,SAAS;EAClD;EAEA,IAAI,WAAW;GACb,IAAI;IACF,OAAO,QAAQ,CAAA,CAAE,QAAQ;GAC3B,QAAQ;IACN,OAAO;GACT;EACF;EAEA;EACA,OAAO,MAAc;GACnB,KAAK,aAAa;EACpB;EAEA,MAAM,KAAK,UAAU,aAAa;GAChC,OAAO,QAAQ,CAAA,CAAE,QAAQ,KAAK,UAAU,WAAW;EACrD;EAEA,OAAO,UAAU,aAAa;GAI5B,QAAQ,gBAAgB,eAAe;IACrC,OAAO,QAAQ,CAAA,CAAE,QAAQ,OAAO,UAAU,WAAW;GACvD,EAAA,CAAG;EACL;EAEA,UAAU,UAAsB;GAC9B,UAAU,IAAI,QAAQ;GAEtB,KAAA,MAAW,WAAW,UAAU;IAC9B,IAAI,iBAAiB,IAAI,QAAQ,EAAE,GAAG;IACtC,MAAM,WAAW,QAAQ,kBAAkB;KACzC,KAAA,MAAW,cAAc,CAAC,GAAG,SAAS,GAAG,WAAW;IACtD,CAAC;IACD,IAAI,UAAU,iBAAiB,IAAI,QAAQ,IAAI,QAAQ;GACzD;GACA,aAAa;IACX,UAAU,OAAO,QAAQ;GAC3B;EACF;EAEA,UAAU;GACR,KAAA,MAAW,YAAY,iBAAiB,OAAO,GAAG,SAAS;GAC3D,iBAAiB,MAAM;GACvB,UAAU,MAAM;EAClB;CACF;AACF;AA+CA,SAAS,iBACP,MACuE;CACvE,IAAI;CACJ,IAAI;EACF,SAAS,KAAK,MAAM,KAAK,MAAM,CAAc,CAAC;CAChD,QAAQ;EACN,OAAO;GACL,MAAM;GACN,SAAS,8CAA8C,KAAK,MAAM,GAAG,EAAE;EACzE;CACF;CAOA,IAAI,WAAW,QAAQ,OAAO,WAAW,YAAY,MAAM,QAAQ,MAAM,GACvE,OAAO;EACL,MAAM;EACN,SAAS,uDAAuD,KAAK,MAAM,GAAG,EAAE;CAClF;CAEF,MAAM,QAAQ;CAEd,IAAI,MAAM,SAAS,SAAS;EAI1B,IAAI,MAAM,SAAS,KAAA,KAAa,OAAO,MAAM,SAAS,UACpD,OAAO;GACL,MAAM;GACN,SAAS,8DAA8D,KAAK,MAAM,GAAG,EAAE;EACzF;EAEF,OAAO,OAAO,MAAM,SAAS,YAAY,MAAM,KAAK,SAAS,IACzD;GAAE,MAAM;GAAS,MAAM,MAAM;EAAK,IAClC,EAAE,MAAM,UAAU;CACxB;CACA,IAAI,MAAM,SAAS,QAAQ;EACzB,MAAM,UAAU,MAAM,SAAS;EAI/B,IAAI,YAAY,KAAA,KAAa,OAAO,YAAY,UAC9C,OAAO;GACL,MAAM;GACN,SAAS,gEAAgE,KAAK,MAAM,GAAG,EAAE;EAC3F;EAEF,OAAO;GACL,MAAM;GACN,SAAS,OAAO,YAAY,WAAW,UAAU,KAAA;EACnD;CACF;CACA,IAAI,MAAM,SAAS,SACjB,OAAO;EACL,MAAM;EACN,SACE,OAAO,MAAM,UAAU,YAAY,MAAM,MAAM,SAAS,IACpD,MAAM,QACN;CACR;CAEF,OAAO,EAAE,MAAM,UAAU;AAC3B;AASA,gBAAgB,gBACd,MAC4B;CAC5B,MAAM,SAAS,KAAK,UAAU;CAC9B,MAAM,UAAU,IAAI,YAAY;CAChC,IAAI,SAAS;CACb,IAAI,WAAW;CAEf,IAAI;EACF,SAAS;GACP,IAAI;GACJ,IAAI;IACF,QAAQ,MAAM,OAAO,KAAK;GAC5B,SAAS,OAAO;IAId,MAAM,IAAI,mBACR,uCAAuC,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,KAC5F,oBACF;GACF;GACA,IAAI,MAAM,MAAM;GAEhB,YAAY,MAAM,MAAM;GACxB,IAAI,WAAA,UACF,MAAM,IAAI,mBACR,mCAAmC,2BAA0B,SAC7D,oBACF;GAGF,UAAU,QAAQ,OAAO,MAAM,OAAO,EAAE,QAAQ,KAAK,CAAC;GAEtD,IAAI,UAAU,OAAO,QAAQ,IAAI;GACjC,OAAO,YAAY,IAAI;IACrB,MAAM,OAAO,OAAO,MAAM,GAAG,OAAO,CAAA,CAAE,KAAK;IAC3C,SAAS,OAAO,MAAM,UAAU,CAAC;IACjC,UAAU,OAAO,QAAQ,IAAI;IAE7B,IAAI,CAAC,KAAK,WAAW,OAAO,GAAG;IAI/B,IAAI,KAAK,SAAA,SACP,MAAM,IAAI,mBACR,4CAA4C,gCAA+B,SAC3E,oBACF;IAGF,MAAM,UAAU,iBAAiB,IAAI;IACrC,IAAI,QAAQ,SAAS,SACnB,MAAM,IAAI,mBAAmB,QAAQ,SAAS,oBAAoB;IAEpE,IAAI,QAAQ,SAAS,WAAW;IAChC,MAAM;IAGN,IAAI,QAAQ,SAAS,QAAQ;GAC/B;GAKA,IAAI,OAAO,SAAA,SACT,MAAM,IAAI,mBACR,0DAA0D,gCAA+B,SACzF,oBACF;EAEJ;EAIA,MAAM,IAAI,mBACR,qDACA,wBACF;CACF,UAAE;EACA,OAAO,OAAO,CAAA,CAAE,YAAY,CAE5B,CAAC;CACH;AACF;AAUO,SAAS,cACd,aACyB;CACzB,MAAM,EAAE,QAAQ,YAAY,eAAe,CAAC;CAC5C,IAAI,YAAY,KAAA,GAAW,OAAO;CAClC,MAAM,WAAW,YAAY,QAAQ,OAAO;CAC5C,OAAO,SAAS,YAAY,IAAI,CAAC,QAAQ,QAAQ,CAAC,IAAI;AACxD;AASA,eAAe,gBACb,UACA,OACiB;CACjB,MAAM,OAAO,SAAS;CACtB,IAAI,CAAC,MAAM,OAAO;CAClB,MAAM,SAAS,KAAK,UAAU;CAC9B,MAAM,UAAU,IAAI,YAAY;CAChC,IAAI,OAAO;CACX,IAAI;EACF,OAAO,KAAK,SAAS,OAAO;GAC1B,MAAM,QAAQ,MAAM,OAAO,KAAK;GAChC,IAAI,MAAM,MAAM;GAChB,QAAQ,QAAQ,OAAO,MAAM,OAAO,EAAE,QAAQ,KAAK,CAAC;EACtD;CACF,UAAE;EACA,OAAO,OAAO,CAAA,CAAE,YAAY,CAE5B,CAAC;CACH;CACA,OAAO,KAAK,MAAM,GAAG,KAAK;AAC5B;AAmBO,SAAS,4BACd,SACkB;CAClB,MAAM,YAAY,QAAQ,aAAa;CAEvC,eAAe,WACb,UACA,aACqC;EAKrC,mBAAmB,uBAAuB,UAAU,WAAW;EAE/D,IAAI;EACJ,IAAI;GACF,WAAW,MAAM,UAAU,QAAQ,UAAU;IAC3C,QAAQ;IACR,SAAS;KACP,gBAAgB;KAChB,GAAG,QAAQ,UAAU;IACvB;IACA,GAAI,QAAQ,cAAc,EAAE,aAAa,QAAQ,YAAY,IAAI,CAAC;IAClE,MAAM,KAAK,UAAU;KAAE;KAAU,SAAS,QAAQ;IAAQ,CAAC;IAC3D,QAAQ,cAAc,WAAW;GACnC,CAAC;EACH,SAAS,OAAO;GAId,MAAM,IAAI,mBACR,mCAAmC,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,KACxF,sBACF;EACF;EAEA,IAAI,CAAC,SAAS,MAAM,CAAC,SAAS,MAAM;GAGlC,IAAI,SAAS;GACb,IAAI;IACF,SAAS,MAAM,gBAAgB,UAAU,GAAG;GAC9C,QAAQ,CAER;GACA,MAAM,IAAI,mBACR,6BAA6B,SAAS,SAAS,SAAS,KAAK,WAAW,MACxE,sBACF;EACF;EAEA,OAAO,gBAAgB,SAAS,IAAI;CACtC;CAEA,OAAO;EACL,IAAI,QAAQ,MAAM;EAClB,MAAM;EACN,QAAQ;EAER,MAAM,KAAK,UAAU,aAAa;GAChC,MAAM,SAAS,MAAM,WAAW,UAAU,WAAW;GACrD,IAAI,YAAY;GAChB,WAAA,MAAiB,SAAS,QACxB,IAAI,MAAM,SAAS,SAAS;IAC1B,aAAa,MAAM;IAGnB,IAAI,UAAU,SAAA,SACZ,MAAM,IAAI,mBACR,kCAAkC,0BAAyB,SAC3D,oBACF;GAEJ,OAAA,IAKS,MAAM,YAAY,KAAA,GAAW;IACpC,IAAI,MAAM,QAAQ,SAAA,SAChB,MAAM,IAAI,mBACR,kCAAkC,0BAAyB,SAC3D,oBACF;IAEF,YAAY,MAAM;GACpB;GAEF,OAAO;IACL,SAAS;IACT,GAAI,QAAQ,QAAQ,EAAE,OAAO,QAAQ,MAAM,IAAI,CAAC;GAClD;EACF;EAEA,OAAO,OAAO,UAAU,aAAa;GACnC,MAAM,SAAS,MAAM,WAAW,UAAU,WAAW;GACrD,IAAI,UAAU;GACd,WAAA,MAAiB,SAAS,QAAQ;IAChC,IAAI,MAAM,SAAS,SAAS;KAC1B,WAAW,MAAM;KACjB,aAAa,aAAa,MAAM,IAAI;KACpC,MAAM,MAAM;KACZ;IACF;IAOA,IAAI,MAAM,YAAY,KAAA,GAAW;IAGjC,IAAI,QAAQ,WAAW,MAAM,OAAO,GAAG;IAGvC,IAAI,MAAM,QAAQ,WAAW,OAAO,GAAG;KACrC,MAAM,YAAY,MAAM,QAAQ,MAAM,QAAQ,MAAM;KACpD,IAAI,UAAU,WAAW,GAAG;KAC5B,aAAa,aAAa,SAAS;KACnC,MAAM;KACN;IACF;IAIA,MAAM,IAAI,mBACR,+EACA,oBACF;GACF;EACF;CACF;AACF"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@happyvertical/smrt-web",
|
|
3
|
-
"version": "0.51.
|
|
3
|
+
"version": "0.51.12",
|
|
4
4
|
"description": "SMRT browser client data runtime: typed collection factory wrapping the client-data engine over generated REST clients",
|
|
5
5
|
"author": "HappyVertical",
|
|
6
6
|
"type": "module",
|
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
"types": "./dist/intents.d.ts",
|
|
26
26
|
"import": "./dist/intents.js"
|
|
27
27
|
},
|
|
28
|
+
"./ai": {
|
|
29
|
+
"types": "./dist/ai.d.ts",
|
|
30
|
+
"import": "./dist/ai.js"
|
|
31
|
+
},
|
|
28
32
|
"./webmcp-tool-names": {
|
|
29
33
|
"types": "./dist/webmcp-tool-names.d.ts",
|
|
30
34
|
"import": "./dist/webmcp-tool-names.js"
|
|
@@ -36,8 +40,8 @@
|
|
|
36
40
|
"@tanstack/query-db-collection": "^1.0.46"
|
|
37
41
|
},
|
|
38
42
|
"devDependencies": {
|
|
39
|
-
"@happyvertical/smrt-core": "0.51.
|
|
40
|
-
"@happyvertical/smrt-scanner": "0.51.
|
|
43
|
+
"@happyvertical/smrt-core": "0.51.12",
|
|
44
|
+
"@happyvertical/smrt-scanner": "0.51.12",
|
|
41
45
|
"@types/node": "24.13.2",
|
|
42
46
|
"fake-indexeddb": "^6.2.5",
|
|
43
47
|
"typescript": "5.9.3",
|