@swiftbrowser/web 0.18.3 → 0.21.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/app/index.html +11 -7
- package/package.json +1 -1
- package/src/appFiles.ts +12 -3
- package/src/audioEngine.ts +1467 -0
- package/src/chartDom.ts +1 -1
- package/src/deviceFrame.ts +188 -0
- package/src/dragDrop.ts +66 -10
- package/src/files.ts +68 -13
- package/src/hostRequests.ts +53 -3
- package/src/interaction.ts +6 -1
- package/src/languageModel.ts +309 -0
- package/src/layout/engine.ts +128 -48
- package/src/layoutDom.ts +43 -3
- package/src/main.ts +12 -1
- package/src/page.css +65 -0
- package/src/pinnedViews.ts +38 -1
- package/src/protocol.ts +70 -5
- package/src/renderer.ts +299 -53
- package/src/scrollTransition.ts +2 -1
- package/src/styles.css +374 -42
- package/src/styles.ts +13 -15
- package/src/symbols.ts +6 -0
- package/src/viewRegistry.ts +58 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Foundation Models for a JavaScript app, the page's side (docs/ops-protocol.md,
|
|
3
|
+
* "The on-device language model"): `LanguageModelSession` in
|
|
4
|
+
* @swiftbrowser/react sends `languageModel` commands, which natively
|
|
5
|
+
* FoundationModels answers (packages/native, LanguageModelBridge.swift) and on
|
|
6
|
+
* a page Chrome's Prompt API (`LanguageModel`), the browser's own on-device
|
|
7
|
+
* model, where the page has it. Without it the model is unavailable, as on an
|
|
8
|
+
* iPhone without Apple Intelligence (`deviceNotEligible`).
|
|
9
|
+
*
|
|
10
|
+
* - `availability` → `{ status: "available" }` or
|
|
11
|
+
* `{ status: "unavailable", reason: "deviceNotEligible" |
|
|
12
|
+
* "appleIntelligenceNotEnabled" | "modelNotReady" }` (a model the browser
|
|
13
|
+
* can download counts as available, its first prompt starting the download;
|
|
14
|
+
* one downloading is not ready). Natively, a model the app fetches itself
|
|
15
|
+
* (SwiftBrowserLocalModel) adds `download`: its size, whether it is here,
|
|
16
|
+
* a download's progress and failure.
|
|
17
|
+
* - `download` starts fetching such a model and `deleteDownload` removes it
|
|
18
|
+
* (nothing on a page: the browser manages its own model).
|
|
19
|
+
* - Natively, availability lists `models` to choose from (Apple Intelligence
|
|
20
|
+
* where it answers, then an app's own), and `select` picks one.
|
|
21
|
+
* - `open` may give the session `tools`; natively each `toolCall` answers the
|
|
22
|
+
* model's next call (`{ call, name, arguments }`) for the app to run and
|
|
23
|
+
* answer with `toolResult`. A page has no tool calls: `toolCall` answers
|
|
24
|
+
* `{ closed: true }` and its sessions answer without tools.
|
|
25
|
+
* - `open` names a session and its instructions; `prewarm` loads it; `close`
|
|
26
|
+
* ends it. A session keeps its conversation, as FoundationModels' does.
|
|
27
|
+
* - `respond` → `{ content }`, the whole response.
|
|
28
|
+
* - `stream` starts a response; each `next` answers `{ content, done }`, the
|
|
29
|
+
* response so far, once it has grown or ended (snapshots, never deltas: a
|
|
30
|
+
* slow reader skips what it missed); `cancel` ends it.
|
|
31
|
+
*
|
|
32
|
+
* A failure rejects with a tagged message, as FoundationModels'
|
|
33
|
+
* `GenerationError`: `unavailable: …` (`assetsUnavailable`), `context: …`
|
|
34
|
+
* (`exceededContextWindowSize`), `guardrail: …` (`guardrailViolation`),
|
|
35
|
+
* `language: …` (`unsupportedLanguageOrLocale`), `concurrent: …` (a session
|
|
36
|
+
* asked while it is still responding, `concurrentRequests`), `rateLimited: …`,
|
|
37
|
+
* `refusal: …`.
|
|
38
|
+
*
|
|
39
|
+
* Generation options (`temperature`, `maximumResponseTokens`, `sampling`) are
|
|
40
|
+
* the native model's; Chrome takes its sampling per session and is left at its
|
|
41
|
+
* defaults.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
export type LanguageModelUnavailableReason = 'deviceNotEligible' | 'appleIntelligenceNotEnabled' | 'modelNotReady';
|
|
45
|
+
|
|
46
|
+
/** Where a model the app fetches itself stands (natively, SwiftBrowserLocalModel). */
|
|
47
|
+
export interface LanguageModelDownload {
|
|
48
|
+
/** In bytes, where known. */
|
|
49
|
+
size?: number;
|
|
50
|
+
/** Whether the model is on the device. */
|
|
51
|
+
downloaded: boolean;
|
|
52
|
+
/** From 0 to 1 while a download is under way. */
|
|
53
|
+
progress?: number;
|
|
54
|
+
/** Why the last download failed, until the next starts. */
|
|
55
|
+
failure?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A model the viewer can choose (natively: Apple Intelligence, an app's own). */
|
|
59
|
+
export interface LanguageModelChoice {
|
|
60
|
+
id: string;
|
|
61
|
+
name: string;
|
|
62
|
+
summary?: string;
|
|
63
|
+
selected: boolean;
|
|
64
|
+
download?: LanguageModelDownload;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type LanguageModelAvailability = ({ status: 'available' } | { status: 'unavailable'; reason: LanguageModelUnavailableReason }) & {
|
|
68
|
+
download?: LanguageModelDownload;
|
|
69
|
+
/** The models to choose from, when there is a choice. */
|
|
70
|
+
models?: LanguageModelChoice[];
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** A tool as the command carries it: its arguments a JSON schema. */
|
|
74
|
+
export interface ToolSpec {
|
|
75
|
+
name: string;
|
|
76
|
+
description: string;
|
|
77
|
+
parameters: Record<string, unknown>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `GenerationOptions`, as the command carries them. */
|
|
81
|
+
export interface GenerationOptionsSpec {
|
|
82
|
+
temperature?: number;
|
|
83
|
+
maximumResponseTokens?: number;
|
|
84
|
+
/** `greedy` always picks the likeliest token; `random` (the default) samples. */
|
|
85
|
+
sampling?: 'greedy' | 'random';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type LanguageModelCommand =
|
|
89
|
+
| { action: 'availability' }
|
|
90
|
+
| { action: 'download' }
|
|
91
|
+
| { action: 'deleteDownload' }
|
|
92
|
+
| { action: 'open'; session: number; instructions?: string; tools?: ToolSpec[] }
|
|
93
|
+
| { action: 'select'; id: string }
|
|
94
|
+
| { action: 'toolCall'; session: number }
|
|
95
|
+
| { action: 'toolResult'; session: number; call: number; output?: string; error?: string }
|
|
96
|
+
| { action: 'prewarm'; session: number }
|
|
97
|
+
| { action: 'respond'; session: number; prompt: string; options?: GenerationOptionsSpec }
|
|
98
|
+
| { action: 'stream'; session: number; stream: number; prompt: string; options?: GenerationOptionsSpec }
|
|
99
|
+
| { action: 'next'; stream: number }
|
|
100
|
+
| { action: 'cancel'; stream: number }
|
|
101
|
+
| { action: 'close'; session: number };
|
|
102
|
+
|
|
103
|
+
/** What answers a `LanguageModelSession`'s commands. */
|
|
104
|
+
export interface LanguageModelHost {
|
|
105
|
+
perform(command: LanguageModelCommand): Promise<unknown>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// MARK: Chrome's Prompt API, as much of it as this uses
|
|
109
|
+
|
|
110
|
+
export type PromptAvailability = 'available' | 'downloadable' | 'downloading' | 'unavailable';
|
|
111
|
+
|
|
112
|
+
export interface PromptSessionLike {
|
|
113
|
+
prompt(input: string, options?: { signal?: AbortSignal }): Promise<string>;
|
|
114
|
+
promptStreaming(input: string, options?: { signal?: AbortSignal }): AsyncIterable<string>;
|
|
115
|
+
destroy(): void;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface PromptAPI {
|
|
119
|
+
availability(): Promise<PromptAvailability>;
|
|
120
|
+
create(options: { initialPrompts?: { role: 'system'; content: string }[] }): Promise<PromptSessionLike>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Chrome's `LanguageModel`, where the page has it. */
|
|
124
|
+
export function browserPromptAPI(): PromptAPI | undefined {
|
|
125
|
+
const api = (globalThis as unknown as { LanguageModel?: PromptAPI }).LanguageModel;
|
|
126
|
+
return api && typeof api.create === 'function' && typeof api.availability === 'function' ? api : undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// MARK: The host
|
|
130
|
+
|
|
131
|
+
interface SessionState {
|
|
132
|
+
instructions: string | undefined;
|
|
133
|
+
model: Promise<PromptSessionLike> | undefined;
|
|
134
|
+
responding: boolean;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface StreamState {
|
|
138
|
+
content: string;
|
|
139
|
+
done: boolean;
|
|
140
|
+
error: unknown;
|
|
141
|
+
/** Whether the reader has had the content as it is now. */
|
|
142
|
+
delivered: boolean;
|
|
143
|
+
/** Wakes a `next` waiting for the content to grow. */
|
|
144
|
+
wake: (() => void) | undefined;
|
|
145
|
+
abort: AbortController;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const UNAVAILABLE = 'unavailable: the on-device language model is not available in this browser';
|
|
149
|
+
|
|
150
|
+
/** A failure of Chrome's, tagged as FoundationModels' `GenerationError` would be. */
|
|
151
|
+
function tagged(error: unknown): Error {
|
|
152
|
+
const name = error instanceof Error || (typeof DOMException !== 'undefined' && error instanceof DOMException) ? (error as Error).name : '';
|
|
153
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
154
|
+
if (/^(unavailable|context|guardrail|language|concurrent|rateLimited|refusal):/.test(message)) return new Error(message);
|
|
155
|
+
switch (name) {
|
|
156
|
+
case 'QuotaExceededError':
|
|
157
|
+
return new Error(`context: ${message}`);
|
|
158
|
+
case 'NotSupportedError':
|
|
159
|
+
return new Error(`language: ${message}`);
|
|
160
|
+
case 'NotAllowedError':
|
|
161
|
+
case 'NotReadableError':
|
|
162
|
+
return new Error(`unavailable: ${message}`);
|
|
163
|
+
default:
|
|
164
|
+
return error instanceof Error ? error : new Error(message);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The commands over a Prompt API (`browserPromptAPI()`, or a stand-in in
|
|
170
|
+
* tests); without one every session fails with `unavailable`.
|
|
171
|
+
*/
|
|
172
|
+
export class PromptLanguageModel implements LanguageModelHost {
|
|
173
|
+
private readonly sessions = new Map<number, SessionState>();
|
|
174
|
+
private readonly streams = new Map<number, StreamState>();
|
|
175
|
+
|
|
176
|
+
constructor(private readonly api: PromptAPI | undefined) {}
|
|
177
|
+
|
|
178
|
+
async perform(command: LanguageModelCommand): Promise<unknown> {
|
|
179
|
+
switch (command.action) {
|
|
180
|
+
case 'availability':
|
|
181
|
+
return this.availability();
|
|
182
|
+
case 'download':
|
|
183
|
+
case 'deleteDownload':
|
|
184
|
+
case 'select':
|
|
185
|
+
case 'toolResult':
|
|
186
|
+
// The browser fetches and keeps its own model, of which there is one.
|
|
187
|
+
return {};
|
|
188
|
+
case 'toolCall':
|
|
189
|
+
// Chrome's model calls no tools here.
|
|
190
|
+
return { closed: true };
|
|
191
|
+
case 'open':
|
|
192
|
+
this.sessions.set(command.session, { instructions: command.instructions, model: undefined, responding: false });
|
|
193
|
+
return {};
|
|
194
|
+
case 'prewarm':
|
|
195
|
+
if (this.api) this.model(command.session).catch(() => {});
|
|
196
|
+
return {};
|
|
197
|
+
case 'respond':
|
|
198
|
+
return { content: await this.respond(command.session, command.prompt) };
|
|
199
|
+
case 'stream':
|
|
200
|
+
this.stream(command.session, command.stream, command.prompt);
|
|
201
|
+
return {};
|
|
202
|
+
case 'next':
|
|
203
|
+
return this.next(command.stream);
|
|
204
|
+
case 'cancel': {
|
|
205
|
+
const stream = this.streams.get(command.stream);
|
|
206
|
+
if (stream && !stream.done) {
|
|
207
|
+
stream.abort.abort();
|
|
208
|
+
this.finish(stream);
|
|
209
|
+
}
|
|
210
|
+
return {};
|
|
211
|
+
}
|
|
212
|
+
case 'close': {
|
|
213
|
+
const session = this.sessions.get(command.session);
|
|
214
|
+
this.sessions.delete(command.session);
|
|
215
|
+
session?.model?.then((model) => model.destroy()).catch(() => {});
|
|
216
|
+
return {};
|
|
217
|
+
}
|
|
218
|
+
default:
|
|
219
|
+
throw new Error(`unsupported: languageModel action ${String((command as { action: unknown }).action)}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private async availability(): Promise<LanguageModelAvailability> {
|
|
224
|
+
if (!this.api) return { status: 'unavailable', reason: 'deviceNotEligible' };
|
|
225
|
+
const availability = await this.api.availability();
|
|
226
|
+
// A model the browser can download is ready to use: the first prompt
|
|
227
|
+
// (a tap, which a download needs) starts it. One already downloading is
|
|
228
|
+
// not ready yet, as Apple Intelligence's model while it downloads.
|
|
229
|
+
if (availability === 'available' || availability === 'downloadable') return { status: 'available' };
|
|
230
|
+
if (availability === 'downloading') return { status: 'unavailable', reason: 'modelNotReady' };
|
|
231
|
+
return { status: 'unavailable', reason: 'deviceNotEligible' };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The session's Prompt API session, made on first use with its instructions. */
|
|
235
|
+
private model(id: number): Promise<PromptSessionLike> {
|
|
236
|
+
const session = this.sessions.get(id);
|
|
237
|
+
if (!session) return Promise.reject(new Error(`languageModel: no session ${id}`));
|
|
238
|
+
if (!this.api) return Promise.reject(new Error(UNAVAILABLE));
|
|
239
|
+
if (!session.model) {
|
|
240
|
+
const instructions = session.instructions?.trim();
|
|
241
|
+
session.model = this.api.create(instructions ? { initialPrompts: [{ role: 'system', content: instructions }] } : {});
|
|
242
|
+
session.model.catch(() => {
|
|
243
|
+
session.model = undefined;
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
return session.model;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Marks the session responding for the length of `work`, refusing a second request meanwhile. */
|
|
250
|
+
private async responding<T>(id: number, work: (model: PromptSessionLike) => Promise<T>): Promise<T> {
|
|
251
|
+
const session = this.sessions.get(id);
|
|
252
|
+
if (!session) throw new Error(`languageModel: no session ${id}`);
|
|
253
|
+
if (session.responding) throw new Error('concurrent: the session is still responding to a prompt');
|
|
254
|
+
session.responding = true;
|
|
255
|
+
try {
|
|
256
|
+
return await work(await this.model(id));
|
|
257
|
+
} catch (error) {
|
|
258
|
+
throw tagged(error);
|
|
259
|
+
} finally {
|
|
260
|
+
session.responding = false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private respond(id: number, prompt: string): Promise<string> {
|
|
265
|
+
return this.responding(id, (model) => model.prompt(prompt));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private stream(id: number, streamId: number, prompt: string): void {
|
|
269
|
+
const stream: StreamState = { content: '', done: false, error: undefined, delivered: true, wake: undefined, abort: new AbortController() };
|
|
270
|
+
this.streams.set(streamId, stream);
|
|
271
|
+
this.responding(id, async (model) => {
|
|
272
|
+
for await (const chunk of model.promptStreaming(prompt, { signal: stream.abort.signal })) {
|
|
273
|
+
if (stream.done) break;
|
|
274
|
+
// Chrome has sent the response so far and, since, what it added: take either.
|
|
275
|
+
stream.content = stream.content && chunk.startsWith(stream.content) ? chunk : stream.content + chunk;
|
|
276
|
+
stream.delivered = false;
|
|
277
|
+
stream.wake?.();
|
|
278
|
+
}
|
|
279
|
+
}).then(
|
|
280
|
+
() => this.finish(stream),
|
|
281
|
+
(error: unknown) => {
|
|
282
|
+
if (!stream.abort.signal.aborted) stream.error = error;
|
|
283
|
+
this.finish(stream);
|
|
284
|
+
},
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private finish(stream: StreamState): void {
|
|
289
|
+
stream.done = true;
|
|
290
|
+
stream.delivered = false;
|
|
291
|
+
stream.wake?.();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private async next(id: number): Promise<{ content: string; done: boolean }> {
|
|
295
|
+
const stream = this.streams.get(id);
|
|
296
|
+
if (!stream) throw new Error(`languageModel: no stream ${id}`);
|
|
297
|
+
while (stream.delivered) await new Promise<void>((resolve) => (stream.wake = resolve));
|
|
298
|
+
stream.wake = undefined;
|
|
299
|
+
stream.delivered = true;
|
|
300
|
+
if (stream.done) this.streams.delete(id);
|
|
301
|
+
if (stream.error !== undefined) throw stream.error;
|
|
302
|
+
return { content: stream.content, done: stream.done };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** The page's language model: Chrome's, or none (every session `unavailable`). */
|
|
307
|
+
export function browserLanguageModel(): LanguageModelHost {
|
|
308
|
+
return new PromptLanguageModel(browserPromptAPI());
|
|
309
|
+
}
|