@tanstack-ai-browser/prompt-api 0.0.0-stage → 0.2.1
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 +21 -0
- package/README.md +96 -2
- package/dist/adapter.d.ts +160 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +414 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/limit.d.ts +119 -0
- package/dist/limit.d.ts.map +1 -0
- package/dist/limit.js +203 -0
- package/dist/messages.d.ts +20 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/messages.js +95 -0
- package/dist/model-meta.d.ts +21 -0
- package/dist/model-meta.d.ts.map +1 -0
- package/dist/model-meta.js +18 -0
- package/dist/model.d.ts +28 -0
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +53 -0
- package/dist/reasoning.d.ts +42 -0
- package/dist/reasoning.d.ts.map +1 -0
- package/dist/reasoning.js +113 -0
- package/dist/sessions.d.ts +48 -0
- package/dist/sessions.d.ts.map +1 -0
- package/dist/sessions.js +275 -0
- package/dist/shared-load.d.ts +48 -0
- package/dist/shared-load.d.ts.map +1 -0
- package/dist/shared-load.js +103 -0
- package/dist/store.d.ts +8 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +17 -0
- package/dist/tools.d.ts +50 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +314 -0
- package/dist/types.d.ts +81 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +13 -0
- package/package.json +60 -4
- package/src/adapter.test.ts +571 -0
- package/src/adapter.ts +522 -0
- package/src/index.ts +13 -0
- package/src/messages.ts +102 -0
- package/src/model-meta.ts +35 -0
- package/src/model.ts +75 -0
- package/src/tools.test.ts +129 -0
- package/src/tools.ts +311 -0
- package/src/types.ts +110 -0
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A TanStack AI text adapter for the browser's built-in language model,
|
|
3
|
+
* through the Prompt API (`LanguageModel`): Gemini Nano in Chrome,
|
|
4
|
+
* Phi-4-mini in Edge. The browser downloads and runs the model itself;
|
|
5
|
+
* nothing leaves the device. Hand it to `chat()` like any hosted model's
|
|
6
|
+
* adapter: `chat()` runs tools and the agent loop, and each `chatStream`
|
|
7
|
+
* call here is one step of the model, streamed back as AG-UI events.
|
|
8
|
+
*
|
|
9
|
+
* Each thread (`threadId`) keeps a session: a step prompts it with only the
|
|
10
|
+
* messages it hasn't seen, and a conversation that changed (an edit, a new
|
|
11
|
+
* system prompt) starts a new one. Tools go by prompt (`tools.ts`);
|
|
12
|
+
* structured output prompts the thread's session under the Prompt API's own
|
|
13
|
+
* `responseConstraint`.
|
|
14
|
+
*/
|
|
15
|
+
import { buildBaseUsage, EventType, normalizeSystemPrompts } from "@tanstack/ai"
|
|
16
|
+
import type { AdapterYieldChunk, ModelMessage, TextOptions, TokenUsage, Tool } from "@tanstack/ai"
|
|
17
|
+
import { BaseTextAdapter } from "@tanstack/ai/adapters"
|
|
18
|
+
import type { StructuredOutputOptions, StructuredOutputResult } from "@tanstack/ai/adapters"
|
|
19
|
+
|
|
20
|
+
import { contentText, mediaIn, toLanguageModelMessages } from "./messages.js"
|
|
21
|
+
import { baseOptions, createSession, DEFAULT_LANGUAGES, MEDIA_INPUTS, promptApiAvailability } from "./model.js"
|
|
22
|
+
import type { Availability, MediaInput } from "./model.js"
|
|
23
|
+
import type { PromptApiChatModel } from "./model-meta.js"
|
|
24
|
+
import { toolCallReader, toolSystemPrompt } from "./tools.js"
|
|
25
|
+
import type { DownloadProgressCallback, LanguageModelInput, LanguageModelSession, SamplingMode } from "./types.js"
|
|
26
|
+
|
|
27
|
+
export type PromptApiConfig = {
|
|
28
|
+
/**
|
|
29
|
+
* Languages the conversation is in, input and output: ["en"] by default.
|
|
30
|
+
* Chrome's model takes "en", "es", "ja", "de" and "fr", and checks its
|
|
31
|
+
* safety filters for the output language.
|
|
32
|
+
*/
|
|
33
|
+
languages?: ReadonlyArray<string>
|
|
34
|
+
/**
|
|
35
|
+
* Kinds of input besides text the conversation will have ("image",
|
|
36
|
+
* "audio"), for `availability()`: images need more of the device, audio a
|
|
37
|
+
* GPU. Text only by default. Sessions expect what each conversation has.
|
|
38
|
+
*/
|
|
39
|
+
inputs?: ReadonlyArray<MediaInput>
|
|
40
|
+
/**
|
|
41
|
+
* The browser's download of its model, 0 to 1, when a call starts it; then
|
|
42
|
+
* 1 once a session has started. To download ahead of time, see `create`.
|
|
43
|
+
*/
|
|
44
|
+
onDownloadProgress?: DownloadProgressCallback
|
|
45
|
+
/**
|
|
46
|
+
* Called when a thread's session overflows its context window (the
|
|
47
|
+
* session's `contextoverflow` event): Chrome then drops the oldest
|
|
48
|
+
* messages after the system prompt to make room.
|
|
49
|
+
*/
|
|
50
|
+
onContextOverflow?: (event: Event) => void
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Per-call `modelOptions`: the session's sampling. */
|
|
54
|
+
export type PromptApiTextModelOptions = {
|
|
55
|
+
/**
|
|
56
|
+
* Sampling preset, "most-predictable" to "most-creative" ("balanced" by
|
|
57
|
+
* default). Chrome takes it on web pages only in its Sampling Parameters
|
|
58
|
+
* origin trial.
|
|
59
|
+
*/
|
|
60
|
+
samplingMode?: SamplingMode
|
|
61
|
+
/** Raw sampling, for extensions (web pages ignore it). Chrome refuses it together with `samplingMode`. */
|
|
62
|
+
temperature?: number
|
|
63
|
+
topK?: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type MetadataByModality = {
|
|
67
|
+
text: unknown
|
|
68
|
+
image: unknown
|
|
69
|
+
audio: unknown
|
|
70
|
+
video: unknown
|
|
71
|
+
document: unknown
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Threads that keep a session open; the least recently used closes first. A
|
|
76
|
+
* session holds its conversation in the model's memory (about 100 MB for
|
|
77
|
+
* 6,000 tokens in Chrome 156), freed only by `destroy()`.
|
|
78
|
+
*/
|
|
79
|
+
const KEPT_SESSIONS = 4
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A thread's session (kept from the moment a step reserves it, while the
|
|
83
|
+
* browser makes it), the settings it was made with, and the messages it holds.
|
|
84
|
+
*/
|
|
85
|
+
type Kept = { session: Promise<LanguageModelSession>; settings: string; seen: Array<string>; busy: boolean }
|
|
86
|
+
|
|
87
|
+
/** Closes a session once it's made; one that failed to start has nothing to close. */
|
|
88
|
+
const close = (session: Promise<LanguageModelSession>) => void session.then((s) => s.destroy(), () => undefined)
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A message as compared with what a session holds. The model's own replies
|
|
92
|
+
* go by their text alone: the session has them as the model wrote them.
|
|
93
|
+
*/
|
|
94
|
+
const messageKey = (m: ModelMessage) =>
|
|
95
|
+
m.role === "assistant"
|
|
96
|
+
? JSON.stringify(["assistant", contentText(m.content).trim()])
|
|
97
|
+
: JSON.stringify([m.role, m.content, m.toolCallId ?? null, m.error ?? null])
|
|
98
|
+
|
|
99
|
+
/** The tools a step offers: none for `toolChoice: "none"`, only the chosen one for a named choice. */
|
|
100
|
+
function offeredTools(options: TextOptions<PromptApiTextModelOptions>): ReadonlyArray<Tool> {
|
|
101
|
+
const { toolChoice, tools = [] } = options
|
|
102
|
+
if (toolChoice === "none") return []
|
|
103
|
+
if (typeof toolChoice === "object") return tools.filter((tool) => tool.name === toolChoice.name)
|
|
104
|
+
return tools
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A step's system prompt (the caller's, then the tools), and the settings its session is made with. */
|
|
108
|
+
function sessionSettings(options: TextOptions<PromptApiTextModelOptions>, tools: ReadonlyArray<Tool>) {
|
|
109
|
+
const system = toolSystemPrompt(
|
|
110
|
+
normalizeSystemPrompts(options.systemPrompts)
|
|
111
|
+
.map((p) => p.content)
|
|
112
|
+
.join("\n\n"),
|
|
113
|
+
tools
|
|
114
|
+
)
|
|
115
|
+
const { samplingMode, temperature, topK } = options.modelOptions ?? {}
|
|
116
|
+
return { system, settings: JSON.stringify([system, samplingMode, temperature, topK, mediaIn(options.messages)]) }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Whether a conversation (its messages' keys) goes on from what a kept session holds, with its settings. */
|
|
120
|
+
const goesOnFrom = (held: Kept, settings: string, keys: Array<string>) =>
|
|
121
|
+
!held.busy && held.settings === settings && held.seen.length < keys.length && held.seen.every((key, i) => key === keys[i])
|
|
122
|
+
|
|
123
|
+
let ids = 0
|
|
124
|
+
const nextId = (prefix: string) => `${prefix}_${Date.now().toString(36)}_${(ids++).toString(36)}`
|
|
125
|
+
|
|
126
|
+
/** The caller's signal: the legacy `abortController` first, then `request`. */
|
|
127
|
+
const signalOf = (options: TextOptions<PromptApiTextModelOptions>) =>
|
|
128
|
+
options.abortController?.signal ?? (options.request as RequestInit | undefined)?.signal ?? undefined
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
export class PromptApiTextAdapter extends BaseTextAdapter<
|
|
132
|
+
PromptApiChatModel,
|
|
133
|
+
PromptApiTextModelOptions,
|
|
134
|
+
readonly ["text", "image", "audio"],
|
|
135
|
+
MetadataByModality
|
|
136
|
+
> {
|
|
137
|
+
override readonly kind = "text" as const
|
|
138
|
+
readonly name = "prompt-api" as const
|
|
139
|
+
override readonly provider = "prompt-api" as const
|
|
140
|
+
override readonly api = "prompt-api" as const
|
|
141
|
+
override readonly inputModalities = ["text", "image", "audio"] as const
|
|
142
|
+
private readonly languages: ReadonlyArray<string>
|
|
143
|
+
private readonly inputs: ReadonlyArray<MediaInput>
|
|
144
|
+
private readonly onDownloadProgress: DownloadProgressCallback | undefined
|
|
145
|
+
private readonly onContextOverflow: ((event: Event) => void) | undefined
|
|
146
|
+
/** Each thread's session, least recently used first. */
|
|
147
|
+
private readonly kept = new Map<string, Kept>()
|
|
148
|
+
|
|
149
|
+
constructor(config: PromptApiConfig, model: PromptApiChatModel) {
|
|
150
|
+
super({}, model)
|
|
151
|
+
this.languages = config.languages ?? DEFAULT_LANGUAGES
|
|
152
|
+
this.inputs = config.inputs ?? []
|
|
153
|
+
this.onDownloadProgress = config.onDownloadProgress
|
|
154
|
+
this.onContextOverflow = config.onContextOverflow
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The browser's own `LanguageModel.availability()` for this adapter's
|
|
159
|
+
* `languages` and `inputs`: "available" (on the device), "downloadable"
|
|
160
|
+
* or "downloading" (it downloads when a session starts, or at `create`),
|
|
161
|
+
* or "unavailable" (no Prompt API, or the device can't run it).
|
|
162
|
+
*/
|
|
163
|
+
availability(): Promise<Availability> {
|
|
164
|
+
return promptApiAvailability(baseOptions(this.languages, this.inputs))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The kinds of input the browser's model takes here, of the
|
|
169
|
+
* `inputModalities` the Prompt API has: each kind the browser's
|
|
170
|
+
* `LanguageModel.availability()` doesn't call "unavailable" for this
|
|
171
|
+
* adapter's `languages` (images need more of the device, audio a GPU).
|
|
172
|
+
* Empty where there's no model. Sessions expect what each conversation has.
|
|
173
|
+
*/
|
|
174
|
+
async availableInputModalities(): Promise<Array<"text" | MediaInput>> {
|
|
175
|
+
if ((await promptApiAvailability(baseOptions(this.languages))) === "unavailable") return []
|
|
176
|
+
const media = await Promise.all(
|
|
177
|
+
MEDIA_INPUTS.map(async (kind) =>
|
|
178
|
+
(await promptApiAvailability(baseOptions(this.languages, [kind]))) === "unavailable" ? [] : [kind]
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
return ["text", ...media.flat()]
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Downloads the browser's model if it isn't on the device and starts it,
|
|
186
|
+
* with the download's progress, 0 to 1, then 1 once it's ready. Resolves
|
|
187
|
+
* to this adapter; rejects if it can't start (fine to leave unawaited).
|
|
188
|
+
* `chat()` doesn't need it, but the browser asks for a click on the page
|
|
189
|
+
* before a download: call it from the click that turns the model on.
|
|
190
|
+
* Aborting `signal` aborts the browser's `create()`.
|
|
191
|
+
*/
|
|
192
|
+
create(options: { onDownloadProgress?: DownloadProgressCallback; signal?: AbortSignal } = {}): Promise<this> {
|
|
193
|
+
const listeners = [this.onDownloadProgress, options.onDownloadProgress].filter((l) => !!l)
|
|
194
|
+
const ready = createSession(baseOptions(this.languages), options.signal, (progress) => {
|
|
195
|
+
for (const listener of listeners) listener(progress)
|
|
196
|
+
}).then((session) => {
|
|
197
|
+
session.destroy()
|
|
198
|
+
return this
|
|
199
|
+
})
|
|
200
|
+
// Callers may leave it: an unawaited failure isn't an unhandled rejection.
|
|
201
|
+
ready.catch(() => undefined)
|
|
202
|
+
return ready
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Tokens a thread's session holds (the session's `contextUsage`): the
|
|
207
|
+
* system prompt, the tools and the conversation so far. The thread is
|
|
208
|
+
* `chat()`'s `threadId` (or its `promptCache.key`); left out, the thread
|
|
209
|
+
* used last. Undefined where the thread keeps no session.
|
|
210
|
+
*/
|
|
211
|
+
async contextUsage(threadId?: string): Promise<number | undefined> {
|
|
212
|
+
return (await this.keptSession(threadId))?.contextUsage
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Tokens a thread's session can hold (the session's `contextWindow`), for
|
|
217
|
+
* the thread as `contextUsage` takes it. Undefined where the thread keeps
|
|
218
|
+
* no session.
|
|
219
|
+
*/
|
|
220
|
+
async contextWindow(threadId?: string): Promise<number | undefined> {
|
|
221
|
+
return (await this.keptSession(threadId))?.contextWindow
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** A thread's kept session, or the last used thread's; undefined if there's none, or it failed to start. */
|
|
225
|
+
private keptSession(threadId?: string): Promise<LanguageModelSession | undefined> {
|
|
226
|
+
const kept = threadId === undefined ? [...this.kept.values()].at(-1) : this.kept.get(threadId)
|
|
227
|
+
return kept ? kept.session.catch(() => undefined) : Promise.resolve(undefined)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Closes every thread's session. The browser keeps the model. */
|
|
231
|
+
async destroy(): Promise<void> {
|
|
232
|
+
for (const kept of this.kept.values()) close(kept.session)
|
|
233
|
+
this.kept.clear()
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Keeps a thread's session, closing the least recently used past the limit. */
|
|
237
|
+
private keep(threadId: string, kept: Kept) {
|
|
238
|
+
this.kept.delete(threadId)
|
|
239
|
+
this.kept.set(threadId, kept)
|
|
240
|
+
for (const [id, old] of this.kept) {
|
|
241
|
+
if (this.kept.size <= KEPT_SESSIONS) break
|
|
242
|
+
if (old.busy) continue
|
|
243
|
+
close(old.session)
|
|
244
|
+
this.kept.delete(id)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Closes a thread's session, unless a step is using it. */
|
|
249
|
+
private drop(threadId: string) {
|
|
250
|
+
const kept = this.kept.get(threadId)
|
|
251
|
+
if (!kept || kept.busy) return
|
|
252
|
+
close(kept.session)
|
|
253
|
+
this.kept.delete(threadId)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** A session for this call, holding the system prompt, that reports an overflow. */
|
|
257
|
+
private async session(
|
|
258
|
+
options: TextOptions<PromptApiTextModelOptions>,
|
|
259
|
+
system: string,
|
|
260
|
+
signal: AbortSignal | undefined
|
|
261
|
+
): Promise<LanguageModelSession> {
|
|
262
|
+
const { samplingMode, temperature, topK } = options.modelOptions ?? {}
|
|
263
|
+
const session = await createSession(
|
|
264
|
+
{
|
|
265
|
+
...baseOptions(this.languages, mediaIn(options.messages)),
|
|
266
|
+
...(system && { initialPrompts: [{ role: "system", content: system }] }),
|
|
267
|
+
...(samplingMode !== undefined && { samplingMode }),
|
|
268
|
+
...(temperature !== undefined && { temperature }),
|
|
269
|
+
...(topK !== undefined && { topK })
|
|
270
|
+
},
|
|
271
|
+
signal,
|
|
272
|
+
this.onDownloadProgress
|
|
273
|
+
)
|
|
274
|
+
if (this.onContextOverflow) session.addEventListener("contextoverflow", this.onContextOverflow)
|
|
275
|
+
return session
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The step's session and the messages to prompt it with: the thread's
|
|
280
|
+
* kept session if the conversation goes on from what it holds (only the
|
|
281
|
+
* new messages), else a new one with the whole conversation. The session
|
|
282
|
+
* is reserved before anything is awaited, so a second step on a busy
|
|
283
|
+
* thread gets a session of its own (`kept` undefined), closed after it.
|
|
284
|
+
* Threads go by `promptCache.key`; retention "none" keeps no session.
|
|
285
|
+
*/
|
|
286
|
+
private async begin(options: TextOptions<PromptApiTextModelOptions>, signal: AbortSignal | undefined) {
|
|
287
|
+
const key = options.promptCache?.key ?? options.threadId
|
|
288
|
+
const retain = options.promptCache?.retention !== "none"
|
|
289
|
+
const tools = offeredTools(options)
|
|
290
|
+
const { system, settings } = sessionSettings(options, tools)
|
|
291
|
+
const keys = options.messages.map(messageKey)
|
|
292
|
+
const held = retain ? this.kept.get(key) : undefined
|
|
293
|
+
const goesOn = !!held && goesOnFrom(held, settings, keys)
|
|
294
|
+
let kept: Kept | undefined
|
|
295
|
+
if (goesOn) kept = held
|
|
296
|
+
else if (retain && !held?.busy) {
|
|
297
|
+
this.drop(key)
|
|
298
|
+
kept = { session: this.session(options, system, signal), settings, seen: [], busy: false }
|
|
299
|
+
}
|
|
300
|
+
if (kept) {
|
|
301
|
+
kept.busy = true
|
|
302
|
+
this.keep(key, kept)
|
|
303
|
+
}
|
|
304
|
+
const pending = kept?.session ?? this.session(options, system, signal)
|
|
305
|
+
try {
|
|
306
|
+
const session = await pending
|
|
307
|
+
const input = await toLanguageModelMessages(options.messages, goesOn ? held.seen.length : 0)
|
|
308
|
+
return { key, session, kept, keys, input, tools, goesOn }
|
|
309
|
+
} catch (error) {
|
|
310
|
+
// A session that goes on still holds the thread; a new one is dropped.
|
|
311
|
+
this.end(key, pending, kept, !goesOn)
|
|
312
|
+
throw error
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Frees a step's session: a kept one stays for the thread unless the step failed. */
|
|
317
|
+
private end(key: string, session: Promise<LanguageModelSession>, kept: Kept | undefined, failed: boolean) {
|
|
318
|
+
if (!kept) return close(session)
|
|
319
|
+
kept.busy = false
|
|
320
|
+
if (failed && this.kept.get(key) === kept) this.drop(key)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* A step's token counts, which the Prompt API doesn't report, from the
|
|
325
|
+
* session's own: the tokens it held before the prompt, the prompt as the
|
|
326
|
+
* session measures it, and the reply measured the same way, less an empty
|
|
327
|
+
* message's tokens (the turn markers the session counts around any
|
|
328
|
+
* message). What a kept session held is cached: it isn't read again. A
|
|
329
|
+
* new session read what it holds (the system prompt) in this step.
|
|
330
|
+
* Nothing where the browser can't measure.
|
|
331
|
+
*/
|
|
332
|
+
private async usage(
|
|
333
|
+
session: LanguageModelSession,
|
|
334
|
+
held: number,
|
|
335
|
+
goesOn: boolean,
|
|
336
|
+
input: LanguageModelInput,
|
|
337
|
+
reply: string
|
|
338
|
+
): Promise<TokenUsage | undefined> {
|
|
339
|
+
try {
|
|
340
|
+
const [read, written, markers] = await Promise.all([
|
|
341
|
+
session.measureContextUsage(input),
|
|
342
|
+
session.measureContextUsage(reply),
|
|
343
|
+
session.measureContextUsage("")
|
|
344
|
+
])
|
|
345
|
+
const counts = [held, read, written, markers]
|
|
346
|
+
if (!counts.every((count) => Number.isFinite(count))) return undefined
|
|
347
|
+
const promptTokens = held + read
|
|
348
|
+
const completionTokens = Math.max(0, written - markers)
|
|
349
|
+
return {
|
|
350
|
+
...buildBaseUsage({ promptTokens, completionTokens, totalTokens: promptTokens + completionTokens }),
|
|
351
|
+
// A new session read all of it in this step: none was cached.
|
|
352
|
+
promptTokensDetails: { cachedTokens: goesOn ? held : 0 }
|
|
353
|
+
}
|
|
354
|
+
} catch {
|
|
355
|
+
return undefined
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async *chatStream(options: TextOptions<PromptApiTextModelOptions>): AsyncIterable<AdapterYieldChunk> {
|
|
360
|
+
const runId = options.runId ?? nextId("run")
|
|
361
|
+
const { threadId } = options
|
|
362
|
+
const messageId = nextId("msg")
|
|
363
|
+
const model = this.model
|
|
364
|
+
const signal = signalOf(options)
|
|
365
|
+
const timestamp = () => Date.now()
|
|
366
|
+
yield {
|
|
367
|
+
type: EventType.RUN_STARTED,
|
|
368
|
+
runId,
|
|
369
|
+
threadId,
|
|
370
|
+
model,
|
|
371
|
+
timestamp: timestamp(),
|
|
372
|
+
...(options.parentRunId && { parentRunId: options.parentRunId })
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
let step: Awaited<ReturnType<typeof this.begin>> | undefined
|
|
376
|
+
let failed = false
|
|
377
|
+
// Stops the generation once a tool call is read.
|
|
378
|
+
const stop = new AbortController()
|
|
379
|
+
try {
|
|
380
|
+
step = await this.begin(options, signal)
|
|
381
|
+
const { session, kept, keys, input, tools, goesOn } = step
|
|
382
|
+
// Without tools to offer, the reply is all text.
|
|
383
|
+
const reader = tools.length > 0 ? toolCallReader(tools.map((tool) => tool.name)) : undefined
|
|
384
|
+
let text = ""
|
|
385
|
+
const textEvents = function* (delta: string): Generator<AdapterYieldChunk> {
|
|
386
|
+
if (!delta) return
|
|
387
|
+
if (!text) yield { type: EventType.TEXT_MESSAGE_START, messageId, role: "assistant", model, timestamp: timestamp() }
|
|
388
|
+
text += delta
|
|
389
|
+
yield { type: EventType.TEXT_MESSAGE_CONTENT, messageId, delta, content: text, model, timestamp: timestamp() }
|
|
390
|
+
}
|
|
391
|
+
const generation = signal ? AbortSignal.any([signal, stop.signal]) : stop.signal
|
|
392
|
+
// What the session holds before this prompt, and all the model writes (a tool call's text too).
|
|
393
|
+
const held = session.contextUsage
|
|
394
|
+
let written = ""
|
|
395
|
+
try {
|
|
396
|
+
for await (const piece of session.promptStreaming(input, { signal: generation })) {
|
|
397
|
+
written += piece
|
|
398
|
+
yield* textEvents(reader ? reader.push(piece) : piece)
|
|
399
|
+
if (reader?.call) {
|
|
400
|
+
stop.abort()
|
|
401
|
+
break
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
} catch (error) {
|
|
405
|
+
if (!stop.signal.aborted || signal?.aborted) throw error
|
|
406
|
+
}
|
|
407
|
+
const call = reader?.call
|
|
408
|
+
if (reader) {
|
|
409
|
+
yield* textEvents(reader.end())
|
|
410
|
+
// Text after the call, in the piece that closed it, is shown too.
|
|
411
|
+
yield* textEvents(reader.trailing)
|
|
412
|
+
}
|
|
413
|
+
if (text) yield { type: EventType.TEXT_MESSAGE_END, messageId, model, timestamp: timestamp() }
|
|
414
|
+
// A finished reply is in the session now. One stopped at a tool call
|
|
415
|
+
// isn't: Chrome drops a stopped turn whole, so the next step sends it.
|
|
416
|
+
if (kept && !stop.signal.aborted) kept.seen = [...keys, messageKey({ role: "assistant", content: text })]
|
|
417
|
+
if (call) {
|
|
418
|
+
const toolCallId = nextId("call")
|
|
419
|
+
const args = JSON.stringify(call.arguments)
|
|
420
|
+
yield {
|
|
421
|
+
type: EventType.TOOL_CALL_START,
|
|
422
|
+
toolCallId,
|
|
423
|
+
toolCallName: call.name,
|
|
424
|
+
parentMessageId: messageId,
|
|
425
|
+
index: 0,
|
|
426
|
+
model,
|
|
427
|
+
timestamp: timestamp()
|
|
428
|
+
}
|
|
429
|
+
yield { type: EventType.TOOL_CALL_ARGS, toolCallId, delta: args, args, model, timestamp: timestamp() }
|
|
430
|
+
yield {
|
|
431
|
+
type: EventType.TOOL_CALL_END,
|
|
432
|
+
toolCallId,
|
|
433
|
+
toolCallName: call.name,
|
|
434
|
+
args,
|
|
435
|
+
input: call.arguments,
|
|
436
|
+
model,
|
|
437
|
+
timestamp: timestamp()
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const usage = await this.usage(session, held, goesOn, input, written)
|
|
441
|
+
yield {
|
|
442
|
+
type: EventType.RUN_FINISHED,
|
|
443
|
+
runId,
|
|
444
|
+
threadId,
|
|
445
|
+
model,
|
|
446
|
+
timestamp: timestamp(),
|
|
447
|
+
finishReason: call ? "tool_calls" : "stop",
|
|
448
|
+
...(usage && { usage })
|
|
449
|
+
}
|
|
450
|
+
} catch (error) {
|
|
451
|
+
// An abort keeps the thread's session: Chrome drops the stopped turn.
|
|
452
|
+
const aborted = !!signal?.aborted
|
|
453
|
+
failed = !aborted
|
|
454
|
+
options.logger.errors("prompt-api chatStream failed", { error, source: "prompt-api.chatStream" })
|
|
455
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
456
|
+
yield {
|
|
457
|
+
type: EventType.RUN_ERROR,
|
|
458
|
+
runId,
|
|
459
|
+
model,
|
|
460
|
+
timestamp: timestamp(),
|
|
461
|
+
message,
|
|
462
|
+
error: { message, ...(aborted && { code: "aborted" }) }
|
|
463
|
+
}
|
|
464
|
+
} finally {
|
|
465
|
+
if (step) this.end(step.key, Promise.resolve(step.session), step.kept, failed)
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* A JSON answer for `chat({ outputSchema })`: the thread's session
|
|
471
|
+
* answers the conversation under the Prompt API's `responseConstraint`,
|
|
472
|
+
* which holds the model to the schema as given. `chat()` validates the
|
|
473
|
+
* result.
|
|
474
|
+
*/
|
|
475
|
+
async structuredOutput(
|
|
476
|
+
options: StructuredOutputOptions<PromptApiTextModelOptions>
|
|
477
|
+
): Promise<StructuredOutputResult<unknown>> {
|
|
478
|
+
const { chatOptions, outputSchema } = options
|
|
479
|
+
const signal = signalOf(chatOptions)
|
|
480
|
+
let step: Awaited<ReturnType<typeof this.begin>> | undefined
|
|
481
|
+
let failed = false
|
|
482
|
+
try {
|
|
483
|
+
step = await this.begin(chatOptions, signal)
|
|
484
|
+
const { session, kept, keys, input, goesOn } = step
|
|
485
|
+
const held = session.contextUsage
|
|
486
|
+
const rawText = await session.prompt(input, { responseConstraint: outputSchema, signal })
|
|
487
|
+
if (kept) kept.seen = [...keys, messageKey({ role: "assistant", content: rawText })]
|
|
488
|
+
const data = JSON.parse(rawText) as unknown
|
|
489
|
+
const usage = await this.usage(session, held, goesOn, input, rawText)
|
|
490
|
+
return { data, rawText, model: this.model, ...(usage && { usage }) }
|
|
491
|
+
} catch (error) {
|
|
492
|
+
failed = true
|
|
493
|
+
throw error
|
|
494
|
+
} finally {
|
|
495
|
+
if (step) this.end(step.key, Promise.resolve(step.session), step.kept, failed)
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* A TanStack AI text adapter for the browser's built-in model, through the
|
|
502
|
+
* Prompt API: Gemini Nano in Chrome, Phi-4-mini in Edge. The model is
|
|
503
|
+
* whichever the browser has ("text", as other Prompt API libraries name it), so naming it is optional.
|
|
504
|
+
*
|
|
505
|
+
* @example
|
|
506
|
+
* ```ts
|
|
507
|
+
* const adapter = promptApiText()
|
|
508
|
+
* if ((await adapter.availability()) !== "unavailable") {
|
|
509
|
+
* chat({ adapter, messages, tools })
|
|
510
|
+
* }
|
|
511
|
+
* ```
|
|
512
|
+
*/
|
|
513
|
+
export function promptApiText(config?: PromptApiConfig): PromptApiTextAdapter
|
|
514
|
+
export function promptApiText(model: PromptApiChatModel, config?: PromptApiConfig): PromptApiTextAdapter
|
|
515
|
+
export function promptApiText(
|
|
516
|
+
modelOrConfig?: PromptApiChatModel | PromptApiConfig,
|
|
517
|
+
config: PromptApiConfig = {}
|
|
518
|
+
): PromptApiTextAdapter {
|
|
519
|
+
return typeof modelOrConfig === "string"
|
|
520
|
+
? new PromptApiTextAdapter(config, modelOrConfig)
|
|
521
|
+
: new PromptApiTextAdapter(modelOrConfig ?? {}, "text")
|
|
522
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TanStack AI adapter for the browser's built-in language model, through
|
|
3
|
+
* the Prompt API: Gemini Nano in Chrome, Phi-4-mini in Edge. The model
|
|
4
|
+
* catalog (model-meta.ts) is exported here too.
|
|
5
|
+
*/
|
|
6
|
+
export { PromptApiTextAdapter, promptApiText } from "./adapter.js"
|
|
7
|
+
export type {
|
|
8
|
+
PromptApiConfig,
|
|
9
|
+
PromptApiTextModelOptions
|
|
10
|
+
} from "./adapter.js"
|
|
11
|
+
export type { Availability, DownloadProgressCallback } from "./types.js"
|
|
12
|
+
export { PROMPT_API_CHAT_MODELS, PROMPT_API_MODEL_META } from "./model-meta.js"
|
|
13
|
+
export type { PromptApiChatModel, PromptApiModelMeta } from "./model-meta.js"
|
package/src/messages.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The conversation as the Prompt API reads it: user messages with their
|
|
3
|
+
* images and audio (loaded as Blobs), the model's messages with their tool
|
|
4
|
+
* calls as ```tool_call fences, and tool results as user messages in a
|
|
5
|
+
* ```tool_result fence.
|
|
6
|
+
*/
|
|
7
|
+
import type { ContentPart, ModelMessage } from "@tanstack/ai"
|
|
8
|
+
|
|
9
|
+
import type { MediaInput } from "./model.js"
|
|
10
|
+
import { toolCallFence, toolResultFence, toolResultLine } from "./tools.js"
|
|
11
|
+
import type { LanguageModelContent, LanguageModelMessage } from "./types.js"
|
|
12
|
+
|
|
13
|
+
/** The text of a message's content, without its images or audio. */
|
|
14
|
+
export function contentText(content: ModelMessage["content"]) {
|
|
15
|
+
if (typeof content === "string") return content
|
|
16
|
+
return (content ?? []).map((part) => (part.type === "text" ? part.content : "")).join("")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The kinds of media in a conversation, which its session must expect. */
|
|
20
|
+
export function mediaIn(messages: ReadonlyArray<ModelMessage>): Array<MediaInput> {
|
|
21
|
+
const kinds = new Set<MediaInput>()
|
|
22
|
+
for (const m of messages) {
|
|
23
|
+
if (m.role !== "user" || typeof m.content === "string") continue
|
|
24
|
+
for (const part of m.content ?? []) {
|
|
25
|
+
if (part.type === "image" || part.type === "audio") kinds.add(part.type)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return [...kinds].sort()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function base64Blob(value: string, mimeType: string | undefined) {
|
|
32
|
+
const data = value.startsWith("data:") ? value.slice(value.indexOf(",") + 1) : value
|
|
33
|
+
const bytes = Uint8Array.from(atob(data), (c) => c.charCodeAt(0))
|
|
34
|
+
return new Blob([bytes], { type: mimeType ?? "" })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function loadMedia(type: MediaInput, source: { type: string; value: string; mimeType?: string }) {
|
|
38
|
+
if (source.type === "data") return base64Blob(source.value, source.mimeType)
|
|
39
|
+
if (source.type !== "url") {
|
|
40
|
+
throw new Error(`The Prompt API takes ${type} as data or a URL, not a ${source.type} source.`)
|
|
41
|
+
}
|
|
42
|
+
const response = await fetch(source.value)
|
|
43
|
+
if (!response.ok) throw new Error(`${source.value.slice(0, 120)}: HTTP ${response.status}`)
|
|
44
|
+
return response.blob()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function userContent(content: ModelMessage["content"]): Promise<LanguageModelMessage["content"]> {
|
|
48
|
+
if (typeof content === "string" || !content) return content ?? ""
|
|
49
|
+
if (content.every((part) => part.type === "text")) return contentText(content)
|
|
50
|
+
return Promise.all(
|
|
51
|
+
content.map(async (part: ContentPart): Promise<LanguageModelContent> => {
|
|
52
|
+
if (part.type === "text") return { type: "text", value: part.content }
|
|
53
|
+
if (part.type === "image" || part.type === "audio") {
|
|
54
|
+
return { type: part.type, value: await loadMedia(part.type, part.source) }
|
|
55
|
+
}
|
|
56
|
+
throw new Error(`The Prompt API takes text, images and audio, not ${part.type}.`)
|
|
57
|
+
})
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The conversation as Prompt API messages, from `start` on (a kept session
|
|
63
|
+
* holds the rest); a step's tool results share one message. Tool names come
|
|
64
|
+
* from the whole conversation.
|
|
65
|
+
*/
|
|
66
|
+
export async function toLanguageModelMessages(
|
|
67
|
+
messages: ReadonlyArray<ModelMessage>,
|
|
68
|
+
start = 0
|
|
69
|
+
): Promise<Array<LanguageModelMessage>> {
|
|
70
|
+
const out: Array<LanguageModelMessage> = []
|
|
71
|
+
const toolNames = new Map<string, string>()
|
|
72
|
+
for (const m of messages.slice(0, start)) {
|
|
73
|
+
for (const call of m.toolCalls ?? []) toolNames.set(call.id, call.function.name)
|
|
74
|
+
}
|
|
75
|
+
let results: Array<string> | undefined
|
|
76
|
+
for (const m of messages.slice(start)) {
|
|
77
|
+
if (m.role === "tool") {
|
|
78
|
+
const id = m.toolCallId ?? ""
|
|
79
|
+
const line = toolResultLine(id, toolNames.get(id) ?? "tool", contentText(m.content), m.error)
|
|
80
|
+
if (results) {
|
|
81
|
+
results.push(line)
|
|
82
|
+
out[out.length - 1] = { role: "user", content: toolResultFence(results) }
|
|
83
|
+
} else {
|
|
84
|
+
results = [line]
|
|
85
|
+
out.push({ role: "user", content: toolResultFence(results) })
|
|
86
|
+
}
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
results = undefined
|
|
90
|
+
if (m.role === "assistant") {
|
|
91
|
+
for (const call of m.toolCalls ?? []) toolNames.set(call.id, call.function.name)
|
|
92
|
+
const content = [contentText(m.content), ...(m.toolCalls?.length ? [toolCallFence(m.toolCalls)] : [])]
|
|
93
|
+
.filter(Boolean)
|
|
94
|
+
.join("\n")
|
|
95
|
+
if (content) out.push({ role: "assistant", content })
|
|
96
|
+
} else {
|
|
97
|
+
const content = await userContent(m.content)
|
|
98
|
+
if (content.length > 0) out.push({ role: "user", content })
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return out
|
|
102
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model the Prompt API gives: the browser's own text model ("text", as other Prompt API libraries name it), Gemini
|
|
3
|
+
* Nano in Chrome and Phi-4-mini in Edge, with what it supports.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** What the browser's model supports. */
|
|
7
|
+
export type PromptApiModelMeta = {
|
|
8
|
+
/** The model's name for `promptApiText`: "text". */
|
|
9
|
+
name: string
|
|
10
|
+
supports: {
|
|
11
|
+
input: ReadonlyArray<"text" | "image" | "audio">
|
|
12
|
+
output: ReadonlyArray<"text">
|
|
13
|
+
capabilities: ReadonlyArray<"tools">
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const BUILT_IN = {
|
|
18
|
+
name: "text",
|
|
19
|
+
supports: {
|
|
20
|
+
input: ["text", "image", "audio"],
|
|
21
|
+
output: ["text"],
|
|
22
|
+
capabilities: ["tools"]
|
|
23
|
+
}
|
|
24
|
+
} as const satisfies PromptApiModelMeta
|
|
25
|
+
|
|
26
|
+
/** The model: whichever one the browser provides. */
|
|
27
|
+
export const PROMPT_API_CHAT_MODELS = [BUILT_IN.name] as const
|
|
28
|
+
|
|
29
|
+
/** The model: whichever one the browser provides. */
|
|
30
|
+
export type PromptApiChatModel = (typeof PROMPT_API_CHAT_MODELS)[number]
|
|
31
|
+
|
|
32
|
+
/** The model's metadata, by name. */
|
|
33
|
+
export const PROMPT_API_MODEL_META: Readonly<Record<PromptApiChatModel, PromptApiModelMeta>> = {
|
|
34
|
+
[BUILT_IN.name]: BUILT_IN
|
|
35
|
+
}
|