@leuria/prompt-api 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,33 @@
1
+ # @leuria/prompt-api
2
+
3
+ The [Prompt API](https://github.com/webmachinelearning/prompt-api) (`LanguageModel`) where the browser can't run it, served by the visitor's own AI through [Leuria](https://leuria.eu). Write your feature for the web standard once: where the browser has a built-in model that runs, it answers; elsewhere, the visitor's AI does (ChatGPT, Claude, Mistral or a local model), in any browser.
4
+
5
+ ```ts
6
+ import { installPromptAPI } from "@leuria/prompt-api"
7
+
8
+ await installPromptAPI({ app: "My notes" })
9
+
10
+ if ((await LanguageModel.availability()) !== "unavailable") {
11
+ // From a click the first time: the visitor approves your site in Leuria.
12
+ const session = await LanguageModel.create({ initialPrompts: [{ role: "system", content: "You are concise." }] })
13
+ console.log(await session.prompt("Summarize this note: …"))
14
+ }
15
+ ```
16
+
17
+ `installPromptAPI` keeps the browser's own `LanguageModel` wherever it can run (`when: "unavailable"`, the default). Use `when: "missing"` to step in only where the browser has none, or `createLanguageModel()` to get the class without touching the global.
18
+
19
+ ## How the API maps onto Leuria
20
+
21
+ | Prompt API | With Leuria |
22
+ | --- | --- |
23
+ | `availability()` | `"available"` once the site is connected, `"downloadable"` before (connecting is the "download"), `"unavailable"` without Leuria |
24
+ | `create()` | From a click the first time: Leuria asks the visitor to approve the site. `downloadprogress` reports 0, then 1 |
25
+ | `prompt()`, `promptStreaming()` | One session of the visitor's AI per `LanguageModel`; each prompt sends only what's new |
26
+ | `tools` | Run in your page, called by the visitor's AI |
27
+ | `responseConstraint` | JSON Schema or RegExp, given to the AI and checked; a mismatch goes back to the AI to fix |
28
+ | `append()`, `clone()`, `destroy()`, `signal` | Supported |
29
+ | Images | Supported when the visitor's AI takes them. Audio is not supported |
30
+ | `contextUsage`, `measureContextUsage()` | Estimates (about 4 characters per token) |
31
+ | `topK`, `temperature`, `samplingMode` | Accepted and ignored: the visitor's AI sets its own |
32
+
33
+ Docs: [leuria.dev](https://leuria.dev). Apache-2.0.
@@ -0,0 +1,231 @@
1
+ import { BridgeProviderOptions, Provider, Message, ProviderSession } from '@leuria/client';
2
+
3
+ /**
4
+ * `responseConstraint`: a JSON Schema or a RegExp. The visitor's AI gets
5
+ * it as an instruction, and the answer is checked here; a mismatch is
6
+ * sent back to the AI to fix, a few times at most.
7
+ */
8
+ type ResponseConstraint = Record<string, unknown> | RegExp;
9
+
10
+ /**
11
+ * Prompt API input (`LanguageModelPrompt`, `initialPrompts`) to Leuria
12
+ * messages. The spec's rules on roles and `prefix` are enforced here, with
13
+ * the exceptions Chrome throws.
14
+ */
15
+
16
+ type LanguageModelMessageRole = "system" | "user" | "assistant";
17
+ type LanguageModelMessageType = "text" | "image" | "audio" | "tool-call" | "tool-response";
18
+ interface LanguageModelMessageContent {
19
+ type: LanguageModelMessageType;
20
+ value: unknown;
21
+ }
22
+ interface LanguageModelMessage {
23
+ role: LanguageModelMessageRole;
24
+ content: string | LanguageModelMessageContent[];
25
+ prefix?: boolean;
26
+ }
27
+ type LanguageModelPrompt = string | LanguageModelMessage[];
28
+
29
+ /**
30
+ * `LanguageModel`, the Prompt API's interface, over a Leuria provider: by
31
+ * default the visitor's own AI through the Leuria app.
32
+ *
33
+ * How the spec maps onto Leuria:
34
+ * - `availability()`: "available" once the site is connected,
35
+ * "downloadable" while it isn't (connecting is the "download": it needs
36
+ * a click, like a model download), "unavailable" without Leuria.
37
+ * - `create()` from a click connects the site, reporting `downloadprogress`
38
+ * 0 then 1. The AI session itself starts with the first prompt.
39
+ * - Each `LanguageModel` is one session of the visitor's AI, which keeps
40
+ * the context: a prompt sends only what's new.
41
+ * - `tools` run in the page, called by the visitor's AI.
42
+ * - `responseConstraint` is given to the AI as an instruction and checked
43
+ * here; a mismatch goes back to the AI to fix.
44
+ * - Token counts are estimates (about 4 characters per token).
45
+ */
46
+
47
+ type Availability = "unavailable" | "downloadable" | "downloading" | "available";
48
+ type LanguageModelSamplingMode = "most-predictable" | "predictable" | "slightly-predictable" | "balanced" | "slightly-creative" | "creative" | "most-creative";
49
+ interface LanguageModelExpected {
50
+ type: LanguageModelMessageType;
51
+ languages?: string[];
52
+ }
53
+ interface LanguageModelTool {
54
+ name: string;
55
+ description: string;
56
+ /** JSON Schema of the arguments. */
57
+ inputSchema: object;
58
+ /** Called with the arguments object; its result goes back to the model. */
59
+ execute: (...args: any[]) => Promise<string> | string;
60
+ }
61
+ interface LanguageModelCreateCoreOptions {
62
+ /** Accepted and ignored: the visitor's AI sets its own sampling. */
63
+ topK?: number;
64
+ /** Accepted and ignored: the visitor's AI sets its own sampling. */
65
+ temperature?: number;
66
+ /** Accepted and ignored: the visitor's AI sets its own sampling. */
67
+ samplingMode?: LanguageModelSamplingMode;
68
+ expectedInputs?: LanguageModelExpected[];
69
+ expectedOutputs?: LanguageModelExpected[];
70
+ tools?: LanguageModelTool[];
71
+ }
72
+ interface LanguageModelCreateOptions extends LanguageModelCreateCoreOptions {
73
+ signal?: AbortSignal;
74
+ monitor?: (monitor: EventTarget) => void;
75
+ initialPrompts?: LanguageModelMessage[];
76
+ }
77
+ interface LanguageModelPromptOptions {
78
+ responseConstraint?: ResponseConstraint;
79
+ /** Accepted and ignored: the visitor's AI always needs the constraint in its prompt. */
80
+ omitResponseConstraintInput?: boolean;
81
+ signal?: AbortSignal;
82
+ }
83
+ interface LanguageModelAppendOptions {
84
+ signal?: AbortSignal;
85
+ }
86
+ interface LanguageModelCloneOptions {
87
+ signal?: AbortSignal;
88
+ }
89
+ interface PromptAPIOptions extends BridgeProviderOptions {
90
+ /** The AI behind `LanguageModel`. Default: `leuria()` with these options. */
91
+ provider?: Provider;
92
+ /** Instructions for the AI when the page gives no system prompt. */
93
+ system?: string;
94
+ /** Most tool calls per prompt. Default 20. */
95
+ maxSteps?: number;
96
+ /** How many times an answer that misses the `responseConstraint` goes back to the AI. Default 2. */
97
+ retries?: number;
98
+ }
99
+ /** @internal What a `LanguageModel` starts from (its constructor is not public). */
100
+ interface LanguageModelInit {
101
+ options: LanguageModelCreateCoreOptions;
102
+ system: string;
103
+ history: Message[];
104
+ }
105
+ /** Set on Leuria's `LanguageModel`, so code can tell it from the browser's own. */
106
+ declare const LEURIA_MARK: unique symbol;
107
+ declare function createLanguageModel(options?: PromptAPIOptions): {
108
+ new (token: symbol, init: LanguageModelInit): {
109
+ readonly "__#private@#options": LanguageModelCreateCoreOptions;
110
+ readonly "__#private@#system": string;
111
+ readonly "__#private@#tools": Map<string, LanguageModelTool>;
112
+ readonly "__#private@#history": Message[];
113
+ /** Appended messages, sent with the next prompt. */
114
+ "__#private@#pending": Message[];
115
+ "__#private@#session": ProviderSession | null;
116
+ "__#private@#queue": Promise<unknown>;
117
+ "__#private@#destroyed": boolean;
118
+ readonly "__#private@#lifetime": AbortController;
119
+ "__#private@#overflow": ((event: Event) => void) | null;
120
+ get contextUsage(): number;
121
+ get contextWindow(): number;
122
+ get samplingMode(): LanguageModelSamplingMode | null;
123
+ get oncontextoverflow(): ((event: Event) => void) | null;
124
+ set oncontextoverflow(handler: ((event: Event) => void) | null);
125
+ prompt(input: LanguageModelPrompt, options?: LanguageModelPromptOptions): Promise<string>;
126
+ promptStreaming(input: LanguageModelPrompt, options?: LanguageModelPromptOptions): ReadableStream<string>;
127
+ append(input: LanguageModelPrompt, options?: LanguageModelAppendOptions): Promise<void>;
128
+ measureContextUsage(input: LanguageModelPrompt, options?: LanguageModelPromptOptions): Promise<number>;
129
+ clone(options?: LanguageModelCloneOptions): Promise</*elided*/ any>;
130
+ destroy(): void;
131
+ "__#private@#prompt"(input: LanguageModelPrompt, options: LanguageModelPromptOptions, onDelta?: (delta: string) => void): Promise<string>;
132
+ "__#private@#turn"(messages: Message[], prefix: string | undefined, constraint: ResponseConstraint | undefined, signal: AbortSignal, onDelta?: (delta: string) => void): Promise<string>;
133
+ "__#private@#send"(session: ProviderSession, message: Message, signal: AbortSignal, onDelta?: (delta: string) => void): Promise<string>;
134
+ "__#private@#runTool"(name: string, args: Record<string, unknown>): Promise<{
135
+ ok: false;
136
+ error: string;
137
+ result?: undefined;
138
+ } | {
139
+ ok: true;
140
+ result: string;
141
+ error?: undefined;
142
+ }>;
143
+ "__#private@#ensureSession"(): Promise<ProviderSession>;
144
+ "__#private@#dropSession"(): void;
145
+ "__#private@#assertLive"(): void;
146
+ addEventListener(type: string, callback: EventListenerOrEventListenerObject | null, options?: AddEventListenerOptions | boolean): void;
147
+ dispatchEvent(event: Event): boolean;
148
+ removeEventListener(type: string, callback: EventListenerOrEventListenerObject | null, options?: EventListenerOptions | boolean): void;
149
+ };
150
+ availability(options?: LanguageModelCreateCoreOptions): Promise<Availability>;
151
+ /** Only in extension contexts in the spec; null here. */
152
+ params(): Promise<null>;
153
+ create(options?: LanguageModelCreateOptions): Promise<{
154
+ readonly "__#private@#options": LanguageModelCreateCoreOptions;
155
+ readonly "__#private@#system": string;
156
+ readonly "__#private@#tools": Map<string, LanguageModelTool>;
157
+ readonly "__#private@#history": Message[];
158
+ /** Appended messages, sent with the next prompt. */
159
+ "__#private@#pending": Message[];
160
+ "__#private@#session": ProviderSession | null;
161
+ "__#private@#queue": Promise<unknown>;
162
+ "__#private@#destroyed": boolean;
163
+ readonly "__#private@#lifetime": AbortController;
164
+ "__#private@#overflow": ((event: Event) => void) | null;
165
+ get contextUsage(): number;
166
+ get contextWindow(): number;
167
+ get samplingMode(): LanguageModelSamplingMode | null;
168
+ get oncontextoverflow(): ((event: Event) => void) | null;
169
+ set oncontextoverflow(handler: ((event: Event) => void) | null);
170
+ prompt(input: LanguageModelPrompt, options?: LanguageModelPromptOptions): Promise<string>;
171
+ promptStreaming(input: LanguageModelPrompt, options?: LanguageModelPromptOptions): ReadableStream<string>;
172
+ append(input: LanguageModelPrompt, options?: LanguageModelAppendOptions): Promise<void>;
173
+ measureContextUsage(input: LanguageModelPrompt, options?: LanguageModelPromptOptions): Promise<number>;
174
+ clone(options?: LanguageModelCloneOptions): Promise</*elided*/ any>;
175
+ destroy(): void;
176
+ "__#private@#prompt"(input: LanguageModelPrompt, options: LanguageModelPromptOptions, onDelta?: (delta: string) => void): Promise<string>;
177
+ "__#private@#turn"(messages: Message[], prefix: string | undefined, constraint: ResponseConstraint | undefined, signal: AbortSignal, onDelta?: (delta: string) => void): Promise<string>;
178
+ "__#private@#send"(session: ProviderSession, message: Message, signal: AbortSignal, onDelta?: (delta: string) => void): Promise<string>;
179
+ "__#private@#runTool"(name: string, args: Record<string, unknown>): Promise<{
180
+ ok: false;
181
+ error: string;
182
+ result?: undefined;
183
+ } | {
184
+ ok: true;
185
+ result: string;
186
+ error?: undefined;
187
+ }>;
188
+ "__#private@#ensureSession"(): Promise<ProviderSession>;
189
+ "__#private@#dropSession"(): void;
190
+ "__#private@#assertLive"(): void;
191
+ addEventListener(type: string, callback: EventListenerOrEventListenerObject | null, options?: AddEventListenerOptions | boolean): void;
192
+ dispatchEvent(event: Event): boolean;
193
+ removeEventListener(type: string, callback: EventListenerOrEventListenerObject | null, options?: EventListenerOptions | boolean): void;
194
+ }>;
195
+ readonly [LEURIA_MARK]: true;
196
+ };
197
+ type LanguageModelConstructor = ReturnType<typeof createLanguageModel>;
198
+ type LanguageModel = InstanceType<LanguageModelConstructor>;
199
+
200
+ /**
201
+ * @leuria/prompt-api: the Prompt API (`LanguageModel`) where the browser
202
+ * can't run it, served by the visitor's own AI through Leuria.
203
+ *
204
+ * import { installPromptAPI } from "@leuria/prompt-api"
205
+ *
206
+ * await installPromptAPI({ app: "My notes" })
207
+ * if ((await LanguageModel.availability()) !== "unavailable") {
208
+ * const session = await LanguageModel.create() // from a click, the first time
209
+ * console.log(await session.prompt("Summarize this note: …"))
210
+ * }
211
+ */
212
+
213
+ interface InstallOptions extends PromptAPIOptions {
214
+ /**
215
+ * When Leuria provides `LanguageModel`:
216
+ * - `"unavailable"` (default): where the browser has none, or has one
217
+ * that can't run on this device. The browser's own model is used
218
+ * wherever it can run, even if it must download first.
219
+ * - `"missing"`: only where the browser has none.
220
+ * - `"always"`: always, in place of the browser's.
221
+ */
222
+ when?: "unavailable" | "missing" | "always";
223
+ }
224
+ /**
225
+ * Define `globalThis.LanguageModel` with Leuria, unless the browser's own
226
+ * model serves (see `when`). Resolves with who answers: `"browser"` or
227
+ * `"leuria"`.
228
+ */
229
+ declare function installPromptAPI(options?: InstallOptions): Promise<"browser" | "leuria">;
230
+
231
+ export { type Availability, type InstallOptions, LEURIA_MARK, type LanguageModel, type LanguageModelAppendOptions, type LanguageModelCloneOptions, type LanguageModelConstructor, type LanguageModelCreateCoreOptions, type LanguageModelCreateOptions, type LanguageModelExpected, type LanguageModelMessage, type LanguageModelMessageContent, type LanguageModelMessageRole, type LanguageModelMessageType, type LanguageModelPrompt, type LanguageModelPromptOptions, type LanguageModelSamplingMode, type LanguageModelTool, type PromptAPIOptions, type ResponseConstraint, createLanguageModel, installPromptAPI };
package/dist/index.js ADDED
@@ -0,0 +1,639 @@
1
+ // src/language-model.ts
2
+ import {
3
+ leuria,
4
+ messageText as messageText2,
5
+ newId as newId2
6
+ } from "@leuria/client";
7
+
8
+ // src/constraint.ts
9
+ import { extractJson } from "@leuria/client";
10
+ function constraintInstruction(constraint) {
11
+ if (constraint instanceof RegExp) {
12
+ return `Answer with text that matches this regular expression, and nothing else: ${constraint.toString()}`;
13
+ }
14
+ return `Answer with JSON only (no code fences, no other text), matching this JSON Schema:
15
+ ${JSON.stringify(constraint)}`;
16
+ }
17
+ function checkAnswer(text, constraint) {
18
+ if (constraint instanceof RegExp) {
19
+ const whole = new RegExp(`^(?:${constraint.source})$`, constraint.flags.replace(/[gy]/g, ""));
20
+ for (const candidate of [text, text.trim(), unfence(text.trim())]) {
21
+ if (whole.test(candidate)) return { ok: true, text: candidate };
22
+ }
23
+ return { ok: false, error: `the answer does not match ${constraint.toString()}` };
24
+ }
25
+ const value = extractJson(text);
26
+ if (value === void 0) return { ok: false, error: "the answer is not JSON" };
27
+ const error = validate(value, constraint, "$");
28
+ return error ? { ok: false, error } : { ok: true, text: JSON.stringify(value) };
29
+ }
30
+ function unfence(text) {
31
+ return /^```\w*\s*([\s\S]*?)\s*```$/.exec(text)?.[1] ?? text.replace(/^"([\s\S]*)"$/, "$1");
32
+ }
33
+ function validate(value, schema, path) {
34
+ if (typeof schema !== "object" || schema === null) return null;
35
+ if ("const" in schema && JSON.stringify(value) !== JSON.stringify(schema.const)) return `${path} must be ${JSON.stringify(schema.const)}`;
36
+ if (Array.isArray(schema.enum) && !schema.enum.some((e) => JSON.stringify(e) === JSON.stringify(value))) {
37
+ return `${path} must be one of ${JSON.stringify(schema.enum)}`;
38
+ }
39
+ for (const key of ["anyOf", "oneOf"]) {
40
+ const options = schema[key];
41
+ if (Array.isArray(options)) {
42
+ const matches = options.filter((s) => validate(value, s, path) === null).length;
43
+ if (matches === 0 || key === "oneOf" && matches > 1) return `${path} does not match ${key}`;
44
+ }
45
+ }
46
+ if (Array.isArray(schema.allOf)) {
47
+ for (const s of schema.allOf) {
48
+ const error = validate(value, s, path);
49
+ if (error) return error;
50
+ }
51
+ }
52
+ const types = schema.type === void 0 ? void 0 : [schema.type].flat();
53
+ if (types && !types.some((t) => isType(value, t))) return `${path} must be ${types.join(" or ")}`;
54
+ if (typeof value === "string") {
55
+ if (typeof schema.minLength === "number" && value.length < schema.minLength) return `${path} is too short`;
56
+ if (typeof schema.maxLength === "number" && value.length > schema.maxLength) return `${path} is too long`;
57
+ if (typeof schema.pattern === "string" && !new RegExp(schema.pattern, "u").test(value)) return `${path} must match ${schema.pattern}`;
58
+ }
59
+ if (typeof value === "number") {
60
+ if (typeof schema.minimum === "number" && value < schema.minimum) return `${path} must be at least ${schema.minimum}`;
61
+ if (typeof schema.maximum === "number" && value > schema.maximum) return `${path} must be at most ${schema.maximum}`;
62
+ if (typeof schema.exclusiveMinimum === "number" && value <= schema.exclusiveMinimum) return `${path} must be more than ${schema.exclusiveMinimum}`;
63
+ if (typeof schema.exclusiveMaximum === "number" && value >= schema.exclusiveMaximum) return `${path} must be less than ${schema.exclusiveMaximum}`;
64
+ }
65
+ if (Array.isArray(value)) {
66
+ if (typeof schema.minItems === "number" && value.length < schema.minItems) return `${path} needs at least ${schema.minItems} items`;
67
+ if (typeof schema.maxItems === "number" && value.length > schema.maxItems) return `${path} has more than ${schema.maxItems} items`;
68
+ if (schema.items && typeof schema.items === "object" && !Array.isArray(schema.items)) {
69
+ for (const [i, item] of value.entries()) {
70
+ const error = validate(item, schema.items, `${path}[${i}]`);
71
+ if (error) return error;
72
+ }
73
+ }
74
+ }
75
+ if (isType(value, "object")) {
76
+ const object = value;
77
+ const properties = schema.properties ?? {};
78
+ for (const key of Array.isArray(schema.required) ? schema.required : []) {
79
+ if (!(key in object)) return `${path}.${key} is required`;
80
+ }
81
+ for (const [key, item] of Object.entries(object)) {
82
+ if (key in properties) {
83
+ const error = validate(item, properties[key], `${path}.${key}`);
84
+ if (error) return error;
85
+ } else if (schema.additionalProperties === false) {
86
+ return `${path}.${key} is not allowed`;
87
+ } else if (typeof schema.additionalProperties === "object" && schema.additionalProperties !== null) {
88
+ const error = validate(item, schema.additionalProperties, `${path}.${key}`);
89
+ if (error) return error;
90
+ }
91
+ }
92
+ }
93
+ return null;
94
+ }
95
+ function isType(value, type) {
96
+ switch (type) {
97
+ case "string":
98
+ return typeof value === "string";
99
+ case "number":
100
+ return typeof value === "number" && Number.isFinite(value);
101
+ case "integer":
102
+ return Number.isInteger(value);
103
+ case "boolean":
104
+ return typeof value === "boolean";
105
+ case "null":
106
+ return value === null;
107
+ case "array":
108
+ return Array.isArray(value);
109
+ case "object":
110
+ return typeof value === "object" && value !== null && !Array.isArray(value);
111
+ default:
112
+ return true;
113
+ }
114
+ }
115
+
116
+ // src/content.ts
117
+ import { messageFiles, messageText, newId } from "@leuria/client";
118
+
119
+ // src/errors.ts
120
+ function domError(name, message) {
121
+ if (typeof DOMException !== "undefined") return new DOMException(message, name);
122
+ const error = new Error(message);
123
+ error.name = name;
124
+ return error;
125
+ }
126
+ function abortReason(signal) {
127
+ return signal.reason ?? domError("AbortError", "The operation was aborted.");
128
+ }
129
+
130
+ // src/content.ts
131
+ async function convert(input, initial = false) {
132
+ if (typeof input === "string") return { system: "", messages: [userText(input)] };
133
+ if (!Array.isArray(input)) throw new TypeError("The prompt must be a string or an array of messages.");
134
+ const system = [];
135
+ const messages = [];
136
+ let prefix;
137
+ for (const [index, message] of input.entries()) {
138
+ if (message.role === "system") {
139
+ if (!initial) throw domError("NotSupportedError", "System messages can only be given in initialPrompts.");
140
+ if (index !== 0) throw new TypeError("A system message must come first in initialPrompts.");
141
+ } else if (message.role !== "user" && message.role !== "assistant") {
142
+ throw new TypeError(`Unknown role: ${String(message.role)}`);
143
+ }
144
+ if (message.prefix) {
145
+ if (message.role !== "assistant" || index !== input.length - 1 || initial) {
146
+ throw domError("SyntaxError", "Only the last message, from the assistant, can be a prefix.");
147
+ }
148
+ }
149
+ const parts = await toParts(message.content);
150
+ if (message.role === "system") {
151
+ system.push(textOf(parts));
152
+ } else if (message.prefix) {
153
+ prefix = textOf(parts);
154
+ } else {
155
+ messages.push({ id: newId(), role: message.role, parts });
156
+ }
157
+ }
158
+ return { system: system.join("\n\n"), messages, prefix };
159
+ }
160
+ function userText(text) {
161
+ return { id: newId(), role: "user", parts: [{ type: "text", text }] };
162
+ }
163
+ function textOf(parts) {
164
+ return messageText({ id: "", role: "user", parts });
165
+ }
166
+ async function toParts(content) {
167
+ if (typeof content === "string") return [{ type: "text", text: content }];
168
+ if (!Array.isArray(content)) throw new TypeError("A message's content must be a string or an array.");
169
+ const parts = [];
170
+ for (const item of content) {
171
+ switch (item.type) {
172
+ case "text":
173
+ if (typeof item.value !== "string") throw new TypeError("A text content's value must be a string.");
174
+ parts.push({ type: "text", text: item.value });
175
+ break;
176
+ case "image":
177
+ parts.push({ type: "file", ...await imageData(item.value) });
178
+ break;
179
+ case "audio":
180
+ throw domError("NotSupportedError", "Audio input is not supported.");
181
+ case "tool-call":
182
+ case "tool-response":
183
+ parts.push({ type: "text", text: `[${item.type}] ${typeof item.value === "string" ? item.value : JSON.stringify(item.value)}` });
184
+ break;
185
+ default:
186
+ throw new TypeError(`Unknown content type: ${String(item.type)}`);
187
+ }
188
+ }
189
+ return parts;
190
+ }
191
+ function asOneTurn(messages) {
192
+ if (messages.length === 1 && messages[0].role === "user") return messages[0];
193
+ const text = messages.map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${messageText(m)}`).join("\n\n");
194
+ return { id: newId(), role: "user", parts: [{ type: "text", text }, ...messages.flatMap(messageFiles)] };
195
+ }
196
+ async function imageData(value) {
197
+ if (typeof Blob !== "undefined" && value instanceof Blob) {
198
+ const bytes = new Uint8Array(await value.arrayBuffer());
199
+ return encode(bytes, value.type || sniff(bytes));
200
+ }
201
+ if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
202
+ const bytes = value instanceof ArrayBuffer ? new Uint8Array(value) : new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
203
+ return encode(bytes, sniff(bytes));
204
+ }
205
+ if (value && typeof value === "object") return encode(await drawToPng(value), "image/png");
206
+ throw new TypeError("An image's value must be an image, a canvas, a Blob or bytes.");
207
+ }
208
+ function encode(bytes, mediaType) {
209
+ if (!mediaType.startsWith("image/")) throw domError("NotSupportedError", "This image format is not supported.");
210
+ let binary = "";
211
+ for (let i = 0; i < bytes.length; i += 32768) binary += String.fromCharCode(...bytes.subarray(i, i + 32768));
212
+ return { mediaType, url: `data:${mediaType};base64,${btoa(binary)}` };
213
+ }
214
+ function sniff(bytes) {
215
+ const starts = (...sig) => sig.every((b, i) => bytes[i] === b);
216
+ if (starts(137, 80, 78, 71)) return "image/png";
217
+ if (starts(255, 216, 255)) return "image/jpeg";
218
+ if (starts(71, 73, 70, 56)) return "image/gif";
219
+ if (starts(82, 73, 70, 70) && bytes[8] === 87 && bytes[9] === 69 && bytes[10] === 66 && bytes[11] === 80) return "image/webp";
220
+ return "application/octet-stream";
221
+ }
222
+ async function drawToPng(source) {
223
+ if (typeof HTMLImageElement !== "undefined" && source instanceof HTMLImageElement && !source.complete) await source.decode();
224
+ const isData = typeof ImageData !== "undefined" && source instanceof ImageData;
225
+ const size = source;
226
+ const width = size.naturalWidth || size.videoWidth || size.displayWidth || size.width || 0;
227
+ const height = size.naturalHeight || size.videoHeight || size.displayHeight || size.height || 0;
228
+ if (!width || !height) throw domError("InvalidStateError", "The image has no size (not loaded yet?).");
229
+ let blob;
230
+ if (typeof OffscreenCanvas !== "undefined") {
231
+ const canvas = new OffscreenCanvas(width, height);
232
+ const ctx = canvas.getContext("2d");
233
+ if (isData) ctx.putImageData(source, 0, 0);
234
+ else ctx.drawImage(source, 0, 0);
235
+ blob = await canvas.convertToBlob({ type: "image/png" });
236
+ } else if (typeof document !== "undefined") {
237
+ const canvas = document.createElement("canvas");
238
+ canvas.width = width;
239
+ canvas.height = height;
240
+ const ctx = canvas.getContext("2d");
241
+ if (isData) ctx.putImageData(source, 0, 0);
242
+ else ctx.drawImage(source, 0, 0);
243
+ blob = await new Promise((resolve, reject) => canvas.toBlob((b) => b ? resolve(b) : reject(domError("InvalidStateError", "The image could not be read.")), "image/png"));
244
+ } else {
245
+ throw domError("NotSupportedError", "Images need a browser.");
246
+ }
247
+ return new Uint8Array(await blob.arrayBuffer());
248
+ }
249
+
250
+ // src/language-model.ts
251
+ var LEURIA_MARK = /* @__PURE__ */ Symbol.for("leuria.prompt-api");
252
+ var DEFAULT_SYSTEM = "You answer requests from a web page. Reply with the answer only, without preamble or follow-up questions. Use only the tools the page gives you, if any.";
253
+ var CONTEXT_WINDOW = 128e3;
254
+ var estimateTokens = (text) => Math.ceil(text.length / 4);
255
+ function createLanguageModel(options = {}) {
256
+ const { provider: given, system: defaultSystem = DEFAULT_SYSTEM, maxSteps = 20, retries = 2, ...bridgeOptions } = options;
257
+ let shared = given;
258
+ const provider = () => shared ??= leuria(bridgeOptions);
259
+ const key = /* @__PURE__ */ Symbol("LanguageModel");
260
+ const live = /* @__PURE__ */ new Set();
261
+ let watching = false;
262
+ const watchUnload = () => {
263
+ if (watching || typeof window === "undefined") return;
264
+ watching = true;
265
+ window.addEventListener("pagehide", () => {
266
+ for (const model of [...live]) model.destroy();
267
+ });
268
+ };
269
+ class LanguageModel extends EventTarget {
270
+ static [LEURIA_MARK] = true;
271
+ static async availability(options2 = {}) {
272
+ validateCore(options2);
273
+ return availabilityOf(provider(), options2);
274
+ }
275
+ /** Only in extension contexts in the spec; null here. */
276
+ static async params() {
277
+ return null;
278
+ }
279
+ static async create(options2 = {}) {
280
+ validateCore(options2);
281
+ const { signal } = options2;
282
+ if (signal?.aborted) throw abortReason(signal);
283
+ const initial = await convert(options2.initialPrompts ?? [], true);
284
+ const ai = provider();
285
+ let availability = await availabilityOf(ai, options2);
286
+ if (availability === "unavailable") throw domError("NotSupportedError", "No AI can serve these options here.");
287
+ const monitor = new EventTarget();
288
+ options2.monitor?.(monitor);
289
+ if (availability !== "available") {
290
+ if (!hasActivation()) {
291
+ throw domError("NotAllowedError", "Connecting the visitor's AI needs a user gesture: call create() from a click.");
292
+ }
293
+ if (!ai.connect) throw domError("NotSupportedError", "This AI cannot be connected from the page.");
294
+ progress(monitor, 0);
295
+ await untilAborted(ai.connect(), signal);
296
+ availability = await availabilityOf(ai, options2);
297
+ if (availability !== "available") throw domError("NotAllowedError", "The visitor's AI was not connected.");
298
+ progress(monitor, 1);
299
+ }
300
+ if (signal?.aborted) throw abortReason(signal);
301
+ const { signal: _signal, monitor: _monitor, initialPrompts: _initial, ...core } = options2;
302
+ return new LanguageModel(key, { options: core, system: initial.system || defaultSystem, history: initial.messages });
303
+ }
304
+ #options;
305
+ #system;
306
+ #tools;
307
+ #history;
308
+ /** Appended messages, sent with the next prompt. */
309
+ #pending = [];
310
+ #session = null;
311
+ #queue = Promise.resolve();
312
+ #destroyed = false;
313
+ #lifetime = new AbortController();
314
+ #overflow = null;
315
+ constructor(token, init) {
316
+ if (token !== key) throw new TypeError("Illegal constructor");
317
+ super();
318
+ this.#options = init.options;
319
+ this.#system = init.system;
320
+ this.#history = [...init.history];
321
+ this.#tools = new Map((init.options.tools ?? []).map((tool) => [tool.name, tool]));
322
+ live.add(this);
323
+ watchUnload();
324
+ }
325
+ get contextUsage() {
326
+ return estimateTokens([this.#system, ...[...this.#history, ...this.#pending].map(messageText2)].join("\n"));
327
+ }
328
+ get contextWindow() {
329
+ return CONTEXT_WINDOW;
330
+ }
331
+ get samplingMode() {
332
+ return this.#options.samplingMode ?? null;
333
+ }
334
+ get oncontextoverflow() {
335
+ return this.#overflow;
336
+ }
337
+ set oncontextoverflow(handler) {
338
+ if (this.#overflow) this.removeEventListener("contextoverflow", this.#overflow);
339
+ this.#overflow = typeof handler === "function" ? handler : null;
340
+ if (this.#overflow) this.addEventListener("contextoverflow", this.#overflow);
341
+ }
342
+ prompt(input, options2 = {}) {
343
+ return this.#prompt(input, options2);
344
+ }
345
+ promptStreaming(input, options2 = {}) {
346
+ const cancel = new AbortController();
347
+ const stream = new ReadableStream({
348
+ start: (controller) => {
349
+ const enqueue = (delta) => {
350
+ try {
351
+ controller.enqueue(delta);
352
+ } catch {
353
+ }
354
+ };
355
+ this.#prompt(input, { ...options2, signal: anySignal(options2.signal, cancel.signal) }, enqueue).then(
356
+ () => controller.close(),
357
+ (error) => {
358
+ try {
359
+ controller.error(error);
360
+ } catch {
361
+ }
362
+ }
363
+ );
364
+ },
365
+ cancel: (reason) => cancel.abort(reason ?? domError("AbortError", "The stream was cancelled."))
366
+ });
367
+ return asyncIterable(stream);
368
+ }
369
+ async append(input, options2 = {}) {
370
+ this.#assertLive();
371
+ const signal = anySignal(options2.signal, this.#lifetime.signal);
372
+ if (signal.aborted) throw abortReason(signal);
373
+ const { messages, prefix } = await convert(input);
374
+ if (prefix !== void 0) throw domError("SyntaxError", "append() takes no prefix message.");
375
+ const work = this.#queue.then(() => {
376
+ if (signal.aborted) throw abortReason(signal);
377
+ this.#pending.push(...messages);
378
+ });
379
+ this.#queue = work.catch(() => void 0);
380
+ return untilAborted(work, signal);
381
+ }
382
+ async measureContextUsage(input, options2 = {}) {
383
+ this.#assertLive();
384
+ const { messages, prefix } = await convert(input);
385
+ const constraint = options2.responseConstraint ? constraintInstruction(options2.responseConstraint) : "";
386
+ return estimateTokens([...messages.map(messageText2), prefix ?? "", constraint].join("\n"));
387
+ }
388
+ async clone(options2 = {}) {
389
+ this.#assertLive();
390
+ const signal = anySignal(options2.signal, this.#lifetime.signal);
391
+ await untilAborted(this.#queue, signal);
392
+ this.#assertLive();
393
+ return new LanguageModel(key, { options: this.#options, system: this.#system, history: [...this.#history, ...this.#pending] });
394
+ }
395
+ destroy() {
396
+ if (this.#destroyed) return;
397
+ this.#destroyed = true;
398
+ this.#lifetime.abort(domError("AbortError", "The session was destroyed."));
399
+ this.#dropSession();
400
+ live.delete(this);
401
+ }
402
+ // ── Turns ───────────────────────────────────────────────────────
403
+ async #prompt(input, options2, onDelta) {
404
+ this.#assertLive();
405
+ const signal = anySignal(options2.signal, this.#lifetime.signal);
406
+ if (signal.aborted) throw abortReason(signal);
407
+ const constraint = options2.responseConstraint;
408
+ if (constraint !== void 0 && (typeof constraint !== "object" || constraint === null)) {
409
+ throw new TypeError("responseConstraint must be a JSON Schema or a RegExp.");
410
+ }
411
+ const { messages, prefix } = await convert(input);
412
+ const work = this.#queue.then(() => this.#turn(messages, prefix, constraint, signal, onDelta));
413
+ this.#queue = work.catch(() => void 0);
414
+ return untilAborted(work, signal);
415
+ }
416
+ async #turn(messages, prefix, constraint, signal, onDelta) {
417
+ if (signal.aborted) throw abortReason(signal);
418
+ const sending = [...this.#pending, ...messages];
419
+ const user = asOneTurn(sending.length ? sending : [{ id: newId2(), role: "user", parts: [{ type: "text", text: "" }] }]);
420
+ const hasImages = user.parts.some((p) => p.type === "file" && p.mediaType.startsWith("image/"));
421
+ if (hasImages && !provider().getState().capabilities.includes("images")) {
422
+ throw domError("NotSupportedError", "The visitor's AI does not take images.");
423
+ }
424
+ const instructions = [
425
+ constraint ? constraintInstruction(constraint) : void 0,
426
+ prefix !== void 0 ? `Begin your answer with exactly this text, then continue it:
427
+ ${prefix}` : void 0
428
+ ].filter((s) => Boolean(s));
429
+ const session = await this.#ensureSession();
430
+ let answer;
431
+ if (constraint) {
432
+ let text = await this.#send(session, withInstructions(user, instructions), signal);
433
+ let check = checkAnswer(stripPrefix(text, prefix), constraint);
434
+ for (let attempt = 0; !check.ok && attempt < retries; attempt++) {
435
+ const fix = `That answer is not valid: ${check.error}. ${constraintInstruction(constraint)}`;
436
+ text = await this.#send(session, { id: newId2(), role: "user", parts: [{ type: "text", text: fix }] }, signal);
437
+ check = checkAnswer(stripPrefix(text, prefix), constraint);
438
+ }
439
+ if (!check.ok) throw domError("UnknownError", `The answer did not match the response constraint: ${check.error}.`);
440
+ answer = check.text;
441
+ if (answer) onDelta?.(answer);
442
+ } else {
443
+ const strip = prefixStripper(prefix, onDelta);
444
+ const text = await this.#send(session, withInstructions(user, instructions), signal, strip.push);
445
+ strip.flush();
446
+ answer = stripPrefix(text, prefix);
447
+ }
448
+ this.#pending = [];
449
+ this.#history.push(user, { id: newId2(), role: "assistant", parts: [{ type: "text", text: (prefix ?? "") + answer }] });
450
+ return answer;
451
+ }
452
+ async #send(session, message, signal, onDelta) {
453
+ const context = {
454
+ turnId: newId2("turn"),
455
+ signal,
456
+ text: (delta) => {
457
+ if (delta && !signal.aborted) onDelta?.(delta);
458
+ },
459
+ reasoning: () => void 0,
460
+ status: () => void 0,
461
+ runTool: ({ name, args }) => this.#runTool(name, args)
462
+ };
463
+ try {
464
+ return (await session.send(message, context)).text;
465
+ } catch (error) {
466
+ if (signal.aborted) throw abortReason(signal);
467
+ this.#dropSession();
468
+ throw domError("UnknownError", error instanceof Error ? error.message : String(error));
469
+ }
470
+ }
471
+ async #runTool(name, args) {
472
+ const tool = this.#tools.get(name);
473
+ if (!tool) return { ok: false, error: `Unknown tool: ${name}` };
474
+ try {
475
+ const result = await tool.execute(args);
476
+ return { ok: true, result: typeof result === "string" ? result : JSON.stringify(result ?? null) };
477
+ } catch (error) {
478
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
479
+ }
480
+ }
481
+ async #ensureSession() {
482
+ if (this.#session && !this.#session.closed) return this.#session;
483
+ this.#dropSession();
484
+ const ai = provider();
485
+ if (ai.getState().status !== "ready") await ai.detect().catch(() => void 0);
486
+ const state = ai.getState();
487
+ if (state.status !== "ready") throw domError("InvalidStateError", "The visitor's AI is not connected anymore.");
488
+ const tools = [...this.#tools.values()].map(({ name, description, inputSchema }) => ({
489
+ name,
490
+ description,
491
+ inputSchema
492
+ }));
493
+ if (tools.length && !state.capabilities.includes("tools")) throw domError("NotSupportedError", "The visitor's AI cannot use tools.");
494
+ this.#session = await ai.createSession({ system: this.#system, tools, history: [...this.#history], maxSteps });
495
+ return this.#session;
496
+ }
497
+ #dropSession() {
498
+ try {
499
+ this.#session?.close();
500
+ } catch {
501
+ }
502
+ this.#session = null;
503
+ }
504
+ #assertLive() {
505
+ if (this.#destroyed) throw domError("InvalidStateError", "The session was destroyed.");
506
+ }
507
+ }
508
+ return LanguageModel;
509
+ }
510
+ function validateCore(options) {
511
+ if (options.samplingMode !== void 0 && (options.topK !== void 0 || options.temperature !== void 0)) {
512
+ throw new TypeError("samplingMode cannot be combined with topK or temperature.");
513
+ }
514
+ for (const expected of [...options.expectedInputs ?? [], ...options.expectedOutputs ?? []]) {
515
+ if (expected.languages) Intl.getCanonicalLocales(expected.languages);
516
+ }
517
+ }
518
+ async function availabilityOf(ai, options) {
519
+ const inputs = (options.expectedInputs ?? []).map((e) => e.type);
520
+ const outputs = (options.expectedOutputs ?? []).map((e) => e.type);
521
+ if (inputs.includes("audio") || outputs.some((t) => t !== "text" && t !== "tool-call")) return "unavailable";
522
+ await ai.detect().catch(() => void 0);
523
+ const state = ai.getState();
524
+ const caps = new Set(state.capabilities);
525
+ if (inputs.includes("image") && !caps.has("images")) return "unavailable";
526
+ if (options.tools?.length && !caps.has("tools")) return "unavailable";
527
+ switch (state.status) {
528
+ case "ready":
529
+ return "available";
530
+ case "needs-action":
531
+ return "downloadable";
532
+ case "downloading":
533
+ return "downloading";
534
+ default:
535
+ return "unavailable";
536
+ }
537
+ }
538
+ function hasActivation() {
539
+ const activation = typeof navigator === "undefined" ? void 0 : navigator.userActivation;
540
+ return activation ? activation.isActive : true;
541
+ }
542
+ function progress(target, loaded) {
543
+ const event = typeof ProgressEvent !== "undefined" ? new ProgressEvent("downloadprogress", { loaded, total: 1 }) : Object.assign(new Event("downloadprogress"), { loaded, total: 1 });
544
+ target.dispatchEvent(event);
545
+ }
546
+ function withInstructions(message, instructions) {
547
+ if (!instructions.length) return message;
548
+ const text = [messageText2(message), ...instructions].filter(Boolean).join("\n\n");
549
+ return { ...message, parts: [{ type: "text", text }, ...message.parts.filter((p) => p.type !== "text")] };
550
+ }
551
+ function stripPrefix(text, prefix) {
552
+ if (!prefix) return text;
553
+ const start = text.trimStart();
554
+ return start.startsWith(prefix) ? start.slice(prefix.length) : text;
555
+ }
556
+ function prefixStripper(prefix, onDelta) {
557
+ if (!prefix) return { push: (delta) => onDelta?.(delta), flush: () => void 0 };
558
+ let buffer = "";
559
+ let decided = false;
560
+ return {
561
+ push(delta) {
562
+ if (decided) return onDelta?.(delta);
563
+ buffer += delta;
564
+ const start = buffer.trimStart();
565
+ if (start.length < prefix.length && prefix.startsWith(start)) return;
566
+ decided = true;
567
+ const rest = start.startsWith(prefix) ? start.slice(prefix.length) : buffer;
568
+ if (rest) onDelta?.(rest);
569
+ },
570
+ flush() {
571
+ if (!decided && !prefix.startsWith(buffer.trimStart())) onDelta?.(buffer);
572
+ }
573
+ };
574
+ }
575
+ function anySignal(...signals) {
576
+ const list = signals.filter((s) => Boolean(s));
577
+ if (list.length === 1) return list[0];
578
+ if (typeof AbortSignal.any === "function") return AbortSignal.any(list);
579
+ const controller = new AbortController();
580
+ for (const signal of list) {
581
+ if (signal.aborted) {
582
+ controller.abort(signal.reason);
583
+ break;
584
+ }
585
+ signal.addEventListener("abort", () => controller.abort(signal.reason), { once: true });
586
+ }
587
+ return controller.signal;
588
+ }
589
+ function untilAborted(promise, signal) {
590
+ if (!signal) return promise;
591
+ if (signal.aborted) return Promise.reject(abortReason(signal));
592
+ return new Promise((resolve, reject) => {
593
+ const onAbort = () => reject(abortReason(signal));
594
+ signal.addEventListener("abort", onAbort, { once: true });
595
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
596
+ });
597
+ }
598
+ function asyncIterable(stream) {
599
+ if (Symbol.asyncIterator in stream) return stream;
600
+ Object.defineProperty(stream, Symbol.asyncIterator, {
601
+ value: async function* () {
602
+ const reader = stream.getReader();
603
+ try {
604
+ for (; ; ) {
605
+ const { value, done } = await reader.read();
606
+ if (done) return;
607
+ yield value;
608
+ }
609
+ } finally {
610
+ reader.releaseLock();
611
+ }
612
+ }
613
+ });
614
+ return stream;
615
+ }
616
+
617
+ // src/index.ts
618
+ async function installPromptAPI(options = {}) {
619
+ const { when = "unavailable", ...rest } = options;
620
+ const scope = globalThis;
621
+ const native = scope.LanguageModel;
622
+ if (native && !native[LEURIA_MARK] && when !== "always") {
623
+ if (when === "missing") return "browser";
624
+ const availability = await Promise.resolve(native.availability?.()).catch(() => "unavailable");
625
+ if (availability !== "unavailable") return "browser";
626
+ }
627
+ Object.defineProperty(globalThis, "LanguageModel", {
628
+ value: createLanguageModel(rest),
629
+ writable: true,
630
+ configurable: true,
631
+ enumerable: false
632
+ });
633
+ return "leuria";
634
+ }
635
+ export {
636
+ LEURIA_MARK,
637
+ createLanguageModel,
638
+ installPromptAPI
639
+ };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@leuria/prompt-api",
3
+ "version": "0.1.0",
4
+ "description": "The Prompt API (LanguageModel) in every browser, served by the visitor's own AI through Leuria when the browser has no built-in model.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/leur-ia/leuria.git",
10
+ "directory": "packages/prompt-api"
11
+ },
12
+ "homepage": "https://leuria.dev",
13
+ "keywords": [
14
+ "prompt-api",
15
+ "LanguageModel",
16
+ "polyfill",
17
+ "built-in-ai",
18
+ "web-ai"
19
+ ],
20
+ "main": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js"
26
+ }
27
+ },
28
+ "files": [
29
+ "dist"
30
+ ],
31
+ "sideEffects": false,
32
+ "dependencies": {
33
+ "@leuria/client": "^0.1.4"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^22.0.0",
37
+ "tsup": "^8.4.0",
38
+ "typescript": "^5.9.0",
39
+ "vitest": "^3.2.0"
40
+ },
41
+ "scripts": {
42
+ "build": "tsup",
43
+ "check": "tsc --noEmit && tsc -p test",
44
+ "test": "vitest run"
45
+ }
46
+ }