@tanstack-ai-browser/prompt-api 0.0.0-stage → 0.2.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +96 -2
  3. package/dist/adapter.d.ts +160 -0
  4. package/dist/adapter.d.ts.map +1 -0
  5. package/dist/adapter.js +413 -0
  6. package/dist/index.d.ts +11 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +7 -0
  9. package/dist/limit.d.ts +119 -0
  10. package/dist/limit.d.ts.map +1 -0
  11. package/dist/limit.js +203 -0
  12. package/dist/messages.d.ts +20 -0
  13. package/dist/messages.d.ts.map +1 -0
  14. package/dist/messages.js +95 -0
  15. package/dist/model-meta.d.ts +21 -0
  16. package/dist/model-meta.d.ts.map +1 -0
  17. package/dist/model-meta.js +18 -0
  18. package/dist/model.d.ts +28 -0
  19. package/dist/model.d.ts.map +1 -0
  20. package/dist/model.js +53 -0
  21. package/dist/reasoning.d.ts +42 -0
  22. package/dist/reasoning.d.ts.map +1 -0
  23. package/dist/reasoning.js +113 -0
  24. package/dist/sessions.d.ts +48 -0
  25. package/dist/sessions.d.ts.map +1 -0
  26. package/dist/sessions.js +275 -0
  27. package/dist/shared-load.d.ts +48 -0
  28. package/dist/shared-load.d.ts.map +1 -0
  29. package/dist/shared-load.js +103 -0
  30. package/dist/store.d.ts +8 -0
  31. package/dist/store.d.ts.map +1 -0
  32. package/dist/store.js +17 -0
  33. package/dist/tools.d.ts +50 -0
  34. package/dist/tools.d.ts.map +1 -0
  35. package/dist/tools.js +314 -0
  36. package/dist/types.d.ts +81 -0
  37. package/dist/types.d.ts.map +1 -0
  38. package/dist/types.js +13 -0
  39. package/package.json +59 -3
  40. package/src/adapter.test.ts +571 -0
  41. package/src/adapter.ts +521 -0
  42. package/src/index.ts +13 -0
  43. package/src/messages.ts +102 -0
  44. package/src/model-meta.ts +35 -0
  45. package/src/model.ts +75 -0
  46. package/src/tools.test.ts +129 -0
  47. package/src/tools.ts +311 -0
  48. package/src/types.ts +110 -0
package/src/model.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The browser's built-in model: whether this browser has one, and its
3
+ * sessions. The browser downloads the model once for all sites (Chrome
4
+ * keeps it in its profile) and loads it when a session is created.
5
+ */
6
+ import { languageModel } from "./types.js"
7
+ import type {
8
+ Availability,
9
+ DownloadProgressCallback,
10
+ DownloadProgressEvent,
11
+ LanguageModelCreateOptions,
12
+ LanguageModelSession
13
+ } from "./types.js"
14
+
15
+ export type { Availability, SamplingMode } from "./types.js"
16
+
17
+ /** Kinds of input besides text a session can be told to expect. */
18
+ export type MediaInput = "image" | "audio"
19
+
20
+ /** Every kind of input besides text, in the order `availableInputModalities` asks. */
21
+ export const MEDIA_INPUTS: ReadonlyArray<MediaInput> = ["image", "audio"]
22
+
23
+ /** Languages the model is told to expect, in and out, by default. */
24
+ export const DEFAULT_LANGUAGES = ["en"] as const
25
+
26
+ /**
27
+ * What every session expects: text in and out in the languages given (Chrome
28
+ * checks its safety filters for the output language), and images or audio
29
+ * in if the conversation has them.
30
+ */
31
+ export function baseOptions(
32
+ languages: ReadonlyArray<string> = DEFAULT_LANGUAGES,
33
+ media: ReadonlyArray<MediaInput> = []
34
+ ): LanguageModelCreateOptions {
35
+ return {
36
+ expectedInputs: [{ type: "text", languages: [...languages] }, ...media.map((type) => ({ type }))],
37
+ expectedOutputs: [{ type: "text", languages: [...languages] }]
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Whether this browser can run its built-in model with these options:
43
+ * "available" (on the device), "downloadable" or "downloading" (it
44
+ * downloads when a session is created), or "unavailable".
45
+ */
46
+ export async function promptApiAvailability(options: LanguageModelCreateOptions): Promise<Availability> {
47
+ return (await languageModel()?.availability(options)) ?? "unavailable"
48
+ }
49
+
50
+ /**
51
+ * A new session. `onDownloadProgress` hears a download, 0 to 1 (Chrome
52
+ * reports 0 and 1 on every create, so only the steps between are passed
53
+ * on), then 1 once the session has started. Aborting `signal` stops the
54
+ * create and destroys the session.
55
+ */
56
+ export async function createSession(
57
+ options: LanguageModelCreateOptions,
58
+ signal?: AbortSignal,
59
+ onDownloadProgress?: DownloadProgressCallback
60
+ ): Promise<LanguageModelSession> {
61
+ const api = languageModel()
62
+ if (!api) throw new Error("This browser has no Prompt API (LanguageModel).")
63
+ const session = await api.create({
64
+ ...options,
65
+ ...(signal && { signal }),
66
+ monitor(monitor) {
67
+ monitor.addEventListener("downloadprogress", (event) => {
68
+ const { loaded } = event as DownloadProgressEvent
69
+ if (loaded > 0 && loaded < 1) onDownloadProgress?.(loaded)
70
+ })
71
+ }
72
+ })
73
+ onDownloadProgress?.(1)
74
+ return session
75
+ }
@@ -0,0 +1,129 @@
1
+ import assert from "node:assert/strict"
2
+ import { test } from "node:test"
3
+
4
+ import {
5
+ parseToolCall,
6
+ toolCallFence,
7
+ toolCallReader,
8
+ toolResultFence,
9
+ toolResultLine,
10
+ toolSystemPrompt
11
+ } from "./tools.js"
12
+
13
+ const weather = {
14
+ name: "get_weather",
15
+ description: "Weather for a city",
16
+ inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] }
17
+ }
18
+
19
+ /** What a reader shows for these pieces, and the call it read. */
20
+ function read(pieces: Array<string>) {
21
+ const reader = toolCallReader()
22
+ let shown = ""
23
+ for (const piece of pieces) {
24
+ shown += reader.push(piece)
25
+ if (reader.call) break
26
+ }
27
+ shown += reader.end()
28
+ return { shown, call: reader.call }
29
+ }
30
+
31
+ test("the system prompt lists the tools as JSON after the caller's prompt, with the fence formats", () => {
32
+ const prompt = toolSystemPrompt("Be brief.", [weather, { name: "clock", description: "" }])
33
+ assert.ok(prompt.startsWith("Be brief.\n\nYou are a helpful AI assistant with access to tools.\n\n# Available Tools\n"))
34
+ const json = prompt.slice(prompt.indexOf("["), prompt.indexOf("\n]\n") + 2)
35
+ assert.deepEqual(JSON.parse(json), [
36
+ { name: "get_weather", description: "Weather for a city", parameters: weather.inputSchema },
37
+ { name: "clock", description: "", parameters: { type: "object", properties: {} } }
38
+ ])
39
+ assert.match(prompt, /```tool_call\n\{"name": "tool_name", "arguments": \{"param1": "value1", "param2": "value2"\}\}\n```/)
40
+ assert.match(prompt, /```tool_result\n\{"id": "call_123", "name": "tool_name", "result": \{\.\.\.\}, "error": false\}\n```/)
41
+ assert.match(prompt, /Only request one tool call at a time/)
42
+ assert.match(prompt, /If no tool is needed, respond directly without tool_call fences$/)
43
+ assert.equal(toolSystemPrompt("Be brief.", []), "Be brief.")
44
+ assert.ok(toolSystemPrompt("", [weather]).startsWith("You are a helpful AI assistant with access to tools."))
45
+ })
46
+
47
+ test("past calls and results are written in the fences the prompt describes", () => {
48
+ assert.equal(
49
+ toolCallFence([{ id: "c1", type: "function", function: { name: "get_weather", arguments: '{"city":"Paris"}' } }]),
50
+ '```tool_call\n{"name":"get_weather","arguments":{"city":"Paris"},"id":"c1"}\n```'
51
+ )
52
+ assert.equal(toolResultLine("c1", "get_weather", '{"temp":12}'), '{"name":"get_weather","result":{"temp":12},"error":false,"id":"c1"}')
53
+ assert.equal(toolResultLine("c1", "get_weather", "sunny"), '{"name":"get_weather","result":"sunny","error":false,"id":"c1"}')
54
+ assert.equal(toolResultLine("c1", "get_weather", "", "no such city"), '{"name":"get_weather","result":"no such city","error":true,"id":"c1"}')
55
+ assert.equal(toolResultFence(["a", "b"]), "```tool_result\na\nb\n```")
56
+ })
57
+
58
+ test("a call's JSON: arguments or parameters, a JSON-string argument, an array or lines; nothing else", () => {
59
+ assert.deepEqual(parseToolCall('{"name":"a","arguments":{"x":1}}'), { name: "a", arguments: { x: 1 } })
60
+ assert.deepEqual(parseToolCall('{"name":"a","parameters":{"x":1}}'), { name: "a", arguments: { x: 1 } })
61
+ assert.deepEqual(parseToolCall('{"name":"a","arguments":"{\\"x\\":1}"}'), { name: "a", arguments: { x: 1 } })
62
+ assert.deepEqual(parseToolCall('[{"name":"a"},{"name":"b"}]'), { name: "a", arguments: {} })
63
+ assert.deepEqual(parseToolCall('{"name":"a"}\n{"name":"b"}'), { name: "a", arguments: {} })
64
+ assert.equal(parseToolCall("not json"), undefined)
65
+ assert.equal(parseToolCall('{"arguments":{}}'), undefined)
66
+ assert.equal(parseToolCall('{"tool":"a","arguments":{}}'), undefined)
67
+ })
68
+
69
+ test("no call: all the text is shown", () => {
70
+ assert.deepEqual(read(["It's ", "sunny ", "today."]), { shown: "It's sunny today.", call: undefined })
71
+ })
72
+
73
+ test("a call split across pieces is held back and read; text before it is shown", () => {
74
+ const { shown, call } = read([
75
+ "Let me check. `",
76
+ "``tool",
77
+ '_call\n{"name": "get_',
78
+ 'weather", "arguments": {"city": ',
79
+ '"Paris"}}\n`',
80
+ "``",
81
+ "Ignored after the call."
82
+ ])
83
+ assert.equal(shown, "Let me check. ")
84
+ assert.deepEqual(call, { name: "get_weather", arguments: { city: "Paris" } })
85
+ })
86
+
87
+ test("the tool-call spelling is read too", () => {
88
+ assert.deepEqual(read(['```tool-call\n{"name":"a","arguments":{}}\n```']).call, { name: "a", arguments: {} })
89
+ })
90
+
91
+ test("text after a closed fence, in the piece that closed it, is kept as trailing", () => {
92
+ const reader = toolCallReader()
93
+ assert.equal(reader.push('Checking.\n```tool_call\n{"name":"a","arguments":{}}\n``` Back soon.'), "Checking.\n")
94
+ assert.deepEqual(reader.call, { name: "a", arguments: {} })
95
+ assert.equal(reader.trailing, " Back soon.")
96
+ })
97
+
98
+ test("other ways small models write a call are read too: tags, delimiters, Python style and call:name{}", () => {
99
+ const weather = { name: "get_weather", arguments: { city: "Oslo" } }
100
+ assert.deepEqual(read(['<tool_call>{"name": "get_weather", "arguments": {"city": "Oslo"}}</tool_call>']).call, weather)
101
+ assert.deepEqual(read(['<|tool_call>call:get_weather{city:Oslo}<tool_call|>']).call, weather)
102
+ assert.deepEqual(read(['<|tool_call_start|>[get_weather(city="Oslo")]<|tool_call_end|>']).call, weather)
103
+ assert.deepEqual(read(['Sure. [get_weather(city=', '"Oslo")] ok']), { shown: "Sure. ", call: weather })
104
+ assert.deepEqual(
105
+ read(["[search(query='rain, today', limit=3, exact=True, tags=['a', 'b'], extra=None)]"]).call?.arguments,
106
+ { query: "rain, today", limit: 3, exact: true, tags: ["a", "b"], extra: null }
107
+ )
108
+ assert.deepEqual(read(["<|tool_call>call:get_weather{days:3,metric:true}<tool_call|>"]).call?.arguments, { days: 3, metric: true })
109
+ })
110
+
111
+ test("a fence without a call, an unclosed fence, bare JSON and other code fences are text", () => {
112
+ const reader = toolCallReader()
113
+ assert.equal(reader.push("Hm. ```tool_call\nnope\n``` then "), "Hm. ```tool_call\nnope\n``` then ")
114
+ assert.equal(reader.push('```tool_call\n{"name":"a"}\n```'), "")
115
+ assert.deepEqual(reader.call, { name: "a", arguments: {} })
116
+ assert.deepEqual(read(["```tool_call\n{broken"]), { shown: "```tool_call\n{broken", call: undefined })
117
+ assert.deepEqual(read(['{"name":"a","arguments":{}}']), { shown: '{"name":"a","arguments":{}}', call: undefined })
118
+ assert.deepEqual(read(["```js\nx()\n```"]), { shown: "```js\nx()\n```", call: undefined })
119
+ assert.deepEqual(read(["a ``", "`"]), { shown: "a ```", call: undefined })
120
+ })
121
+
122
+ test("a JSON call is read as JSON: call syntax in its string arguments is just text", () => {
123
+ for (const query of ["call:bogus{arg:value}", "[bogus(x=1)]", "bogus(x=1)"]) {
124
+ const body = JSON.stringify({ name: "search", arguments: { query } })
125
+ assert.deepEqual(parseToolCall(body), { name: "search", arguments: { query } })
126
+ assert.deepEqual(read(["```tool_call\n" + body + "\n```"]).call, { name: "search", arguments: { query } })
127
+ }
128
+ assert.equal(parseToolCall("see call:bogus{arg:value} here"), undefined, "the fallback syntax is the whole body")
129
+ })
package/src/tools.ts ADDED
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Tool calls by prompt. Chrome's Prompt API has no
3
+ * working tool use on web pages (a session that expects tool calls fails to
4
+ * start, and `tools` alone are never called), so the system prompt lists the
5
+ * tools as JSON and asks for a call as JSON in a ```tool_call fence; results
6
+ * go back in a ```tool_result fence. The adapter reads calls out of the
7
+ * streamed text; `chat()` checks them against the tools.
8
+ */
9
+ import type { ModelMessage, Tool } from "@tanstack/ai"
10
+
11
+ /** A tool call read from the model's text. */
12
+ export type ParsedToolCall = { name: string; arguments: unknown }
13
+
14
+ /** Where a call starts and ends: the fence the prompt asks for, then other ways small models write one. */
15
+ type Delimiter = { start: string; end: string }
16
+ const DELIMITERS: Array<Delimiter> = [
17
+ { start: "```tool_call", end: "```" },
18
+ { start: "```tool-call", end: "```" },
19
+ { start: "<tool_call>", end: "</tool_call>" },
20
+ { start: "<|tool_call>", end: "<tool_call|>" },
21
+ { start: "<|tool_call_start|>", end: "<|tool_call_end|>" }
22
+ ]
23
+ /** A Python-style call, `[name(...)]`. */
24
+ const PYTHON_CALL = /\[(\w+)\(/
25
+
26
+ const isObject = (value: unknown): value is Record<string, unknown> =>
27
+ !!value && typeof value === "object" && !Array.isArray(value)
28
+
29
+ /**
30
+ * The system prompt with the tools and how to call them; unchanged without
31
+ * tools.
32
+ */
33
+ export function toolSystemPrompt(system: string, tools: ReadonlyArray<Tool>): string {
34
+ if (tools.length === 0) return system
35
+ const definitions = tools.map((tool) => ({
36
+ name: tool.name,
37
+ description: tool.description ?? "No description provided.",
38
+ parameters: tool.inputSchema ?? { type: "object", properties: {} }
39
+ }))
40
+ const instructions = `You are a helpful AI assistant with access to tools.
41
+
42
+ # Available Tools
43
+ ${JSON.stringify(definitions, null, 2)}
44
+
45
+ # Tool Calling Instructions
46
+ Only request one tool call at a time. Wait for tool results before asking for another tool.
47
+
48
+ To call a tool, output JSON in this exact format inside a \`\`\`tool_call code fence:
49
+
50
+ \`\`\`tool_call
51
+ {"name": "tool_name", "arguments": {"param1": "value1", "param2": "value2"}}
52
+ \`\`\`
53
+
54
+ Tool responses will be provided in \`\`\`tool_result fences. Each line contains JSON like:
55
+ \`\`\`tool_result
56
+ {"id": "call_123", "name": "tool_name", "result": {...}, "error": false}
57
+ \`\`\`
58
+ Use the \`result\` payload (and treat \`error\` as a boolean flag) when continuing the conversation.
59
+
60
+ Important:
61
+ - Use exact tool and parameter names from the schema above
62
+ - Arguments must be a valid JSON object matching the tool's parameters
63
+ - You can include brief reasoning before or after the tool call
64
+ - If no tool is needed, respond directly without tool_call fences`
65
+ return system.trim() ? `${system.trim()}\n\n${instructions}` : instructions
66
+ }
67
+
68
+ /** Earlier tool calls in the conversation, as the model would have written them. */
69
+ export function toolCallFence(calls: NonNullable<ModelMessage["toolCalls"]>): string {
70
+ const lines = calls.map((call) => {
71
+ let args: unknown
72
+ try {
73
+ args = JSON.parse(call.function.arguments || "{}")
74
+ } catch {
75
+ args = call.function.arguments
76
+ }
77
+ return JSON.stringify({ name: call.function.name, arguments: args, ...(call.id && { id: call.id }) })
78
+ })
79
+ return `\`\`\`tool_call\n${lines.join("\n")}\n\`\`\``
80
+ }
81
+
82
+ /** A tool's result line for a ```tool_result fence. */
83
+ export function toolResultLine(id: string, name: string, content: string, error?: string): string {
84
+ let result: unknown = error ?? content
85
+ if (error === undefined) {
86
+ try {
87
+ result = JSON.parse(content)
88
+ } catch {
89
+ // Plain text.
90
+ }
91
+ }
92
+ return JSON.stringify({ name, result: result ?? null, error: error !== undefined, ...(id && { id }) })
93
+ }
94
+
95
+ /** Tool results as the model reads them. */
96
+ export const toolResultFence = (lines: ReadonlyArray<string>) => `\`\`\`tool_result\n${lines.join("\n")}\n\`\`\``
97
+
98
+ /**
99
+ * The first tool call in a fence's body: a JSON object (or an array of them,
100
+ * or one per line) with `name` and `arguments` (or `parameters`), the
101
+ * arguments possibly a JSON string; else, as the whole body, a Python-style
102
+ * call, `[name(key="v")]`, or `call:name{key:value}`.
103
+ */
104
+ export function parseToolCall(body: string): ParsedToolCall | undefined {
105
+ const text = body.trim()
106
+ const candidates: Array<unknown> = []
107
+ try {
108
+ const value = JSON.parse(text) as unknown
109
+ candidates.push(...(Array.isArray(value) ? value : [value]))
110
+ } catch {
111
+ for (const line of text.split("\n")) {
112
+ try {
113
+ candidates.push(JSON.parse(line) as unknown)
114
+ } catch {
115
+ // Not JSON.
116
+ }
117
+ }
118
+ }
119
+ for (const call of candidates) {
120
+ if (!isObject(call) || typeof call.name !== "string" || !call.name) continue
121
+ let args: unknown = call.arguments ?? call.parameters ?? {}
122
+ if (typeof args === "string") {
123
+ try {
124
+ args = JSON.parse(args) as unknown
125
+ } catch {
126
+ // Left as the model wrote it.
127
+ }
128
+ }
129
+ return { name: call.name, arguments: args }
130
+ }
131
+ // `call:name{key:value,…}`
132
+ const colon = /^call:(\w+)\{([^}]*)\}$/.exec(text)
133
+ if (colon) return { name: colon[1], arguments: parseColonArguments(colon[2]) }
134
+ // Python style, `[name(key="value")]` or `name(key="value")`
135
+ return parsePythonCall(text)
136
+ }
137
+
138
+ /** `key:value,…` pairs, values read as numbers, booleans or null where they are. */
139
+ function parseColonArguments(text: string): Record<string, unknown> {
140
+ const args: Record<string, unknown> = {}
141
+ for (const pair of text.split(",")) {
142
+ const at = pair.indexOf(":")
143
+ if (at <= 0) continue
144
+ const key = pair.slice(0, at).trim()
145
+ const raw = pair.slice(at + 1).trim()
146
+ const number = Number(raw)
147
+ args[key] =
148
+ raw === "true" ? true : raw === "false" ? false : raw === "null" ? null : raw !== "" && !Number.isNaN(number) ? number : raw
149
+ }
150
+ return args
151
+ }
152
+
153
+ /** The index of the bracket that closes the one at `open`, past quoted text, or -1. */
154
+ function closing(text: string, open: number) {
155
+ const pairs: Record<string, string> = { "(": ")", "[": "]", "{": "}" }
156
+ const stack: Array<string> = []
157
+ let quote = ""
158
+ for (let i = open; i < text.length; i++) {
159
+ const c = text[i]
160
+ if (quote) {
161
+ if (c === "\\") i++
162
+ else if (c === quote) quote = ""
163
+ } else if (c === '"' || c === "'") quote = c
164
+ else if (pairs[c]) stack.push(pairs[c])
165
+ else if (c === stack.at(-1)) {
166
+ stack.pop()
167
+ if (stack.length === 0) return i
168
+ }
169
+ }
170
+ return -1
171
+ }
172
+
173
+ /** `text` split at its top-level commas (not inside quotes or brackets). */
174
+ function splitTopLevel(text: string) {
175
+ const parts: Array<string> = []
176
+ let depth = 0
177
+ let quote = ""
178
+ let from = 0
179
+ for (let i = 0; i < text.length; i++) {
180
+ const c = text[i]
181
+ if (quote) {
182
+ if (c === "\\") i++
183
+ else if (c === quote) quote = ""
184
+ } else if (c === '"' || c === "'") quote = c
185
+ else if ("([{".includes(c)) depth++
186
+ else if (")]}".includes(c)) depth--
187
+ else if (c === "," && depth === 0) {
188
+ parts.push(text.slice(from, i))
189
+ from = i + 1
190
+ }
191
+ }
192
+ parts.push(text.slice(from))
193
+ return parts.map((part) => part.trim()).filter(Boolean)
194
+ }
195
+
196
+ /** A Python literal (string, number, True/False/None, list, dict) as a value. */
197
+ function pythonValue(raw: string): unknown {
198
+ const text = raw.trim()
199
+ if (text === "True") return true
200
+ if (text === "False") return false
201
+ if (text === "None") return null
202
+ if (/^(["']).*\1$/s.test(text)) return text.slice(1, -1).replace(/\\(["'\\])/g, "$1")
203
+ const json = text
204
+ .replace(/'((?:[^'\\]|\\.)*)'/g, (_, inner: string) => JSON.stringify(inner.replace(/\\'/g, "'")))
205
+ .replace(/\bTrue\b/g, "true")
206
+ .replace(/\bFalse\b/g, "false")
207
+ .replace(/\bNone\b/g, "null")
208
+ try {
209
+ return JSON.parse(json) as unknown
210
+ } catch {
211
+ return text
212
+ }
213
+ }
214
+
215
+ /** `text` as one Python-style call: `name(key=value, …)`, maybe in brackets. */
216
+ function parsePythonCall(text: string): ParsedToolCall | undefined {
217
+ const match = /^(\[?\s*)(\w+)\(/.exec(text)
218
+ if (!match) return undefined
219
+ const open = match[0].length - 1
220
+ const close = closing(text, open)
221
+ if (close === -1 || text.slice(close + 1).trim() !== (match[1] ? "]" : "")) return undefined
222
+ const args: Record<string, unknown> = {}
223
+ for (const part of splitTopLevel(text.slice(open + 1, close))) {
224
+ const at = part.indexOf("=")
225
+ if (at <= 0) continue
226
+ args[part.slice(0, at).trim()] = pythonValue(part.slice(at + 1))
227
+ }
228
+ return { name: match[2], arguments: args }
229
+ }
230
+
231
+ /** Characters at the end of `text` that could start a fence. */
232
+ function partialFenceStart(text: string) {
233
+ for (let size = Math.min(text.length, Math.max(...DELIMITERS.map((d) => d.start.length)) - 1); size > 0; size--) {
234
+ const end = text.slice(-size)
235
+ if (DELIMITERS.some((d) => d.start.startsWith(end))) return size
236
+ }
237
+ return 0
238
+ }
239
+
240
+ /**
241
+ * Reads a streamed reply: `push` each piece and get back the text that can
242
+ * be shown now; text inside a ```tool_call fence (or another call's
243
+ * delimiters: `<tool_call>`, `<|tool_call>`, `<|tool_call_start|>`, or a
244
+ * Python-style `[name(…)]`) is held back. The first fence that closes with
245
+ * a call in it ends the reply (stop generating then): `call` is the call,
246
+ * and `trailing` the text after it. A fence without a call, a call to a tool
247
+ * not in `names` (when given), and other code fences, are text. `end` gives
248
+ * the rest, an unclosed fence as text.
249
+ */
250
+ export function toolCallReader(names?: ReadonlyArray<string>) {
251
+ let buffer = ""
252
+ // The delimiter of the call being read; a Python-style call is read whole.
253
+ let start: (Delimiter & { python?: boolean }) | undefined
254
+ let call: ParsedToolCall | undefined
255
+ // Text after the call, in the piece that closed it.
256
+ let trailing = ""
257
+
258
+ const read = (): string => {
259
+ let shown = ""
260
+ while (!call) {
261
+ if (!start) {
262
+ const python = PYTHON_CALL.exec(buffer)
263
+ const found = [
264
+ ...DELIMITERS.map((d) => ({ d, at: buffer.indexOf(d.start) })),
265
+ ...(python ? [{ d: { start: python[0], end: ")]", python: true }, at: python.index }] : [])
266
+ ]
267
+ .filter((f) => f.at !== -1)
268
+ .sort((a, b) => a.at - b.at)[0]
269
+ if (!found) {
270
+ const keep = partialFenceStart(buffer)
271
+ shown += buffer.slice(0, buffer.length - keep)
272
+ buffer = buffer.slice(buffer.length - keep)
273
+ return shown
274
+ }
275
+ shown += buffer.slice(0, found.at)
276
+ buffer = buffer.slice(found.at)
277
+ start = found.d
278
+ }
279
+ const close = buffer.indexOf(start.end, start.start.length)
280
+ if (close === -1) return shown
281
+ const fence = buffer.slice(0, close + start.end.length)
282
+ const parsed = parseToolCall(start.python ? fence : fence.slice(start.start.length, close))
283
+ call = parsed && (!names || names.includes(parsed.name)) ? parsed : undefined
284
+ buffer = buffer.slice(fence.length)
285
+ start = undefined
286
+ if (call) trailing = buffer
287
+ else shown += fence
288
+ }
289
+ return shown
290
+ }
291
+
292
+ return {
293
+ get call() {
294
+ return call
295
+ },
296
+ /** Text that came after the call: shown after it. */
297
+ get trailing() {
298
+ return trailing
299
+ },
300
+ push(piece: string): string {
301
+ buffer += piece
302
+ return read()
303
+ },
304
+ end(): string {
305
+ if (call) return ""
306
+ const rest = buffer
307
+ buffer = ""
308
+ return rest
309
+ }
310
+ }
311
+ }
package/src/types.ts ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The parts of the Prompt API (https://github.com/webmachinelearning/prompt-api)
3
+ * this package uses. TypeScript's DOM library has no types for it yet.
4
+ * Shapes follow the spec and `@types/dom-chromium-ai`, kept here instead of
5
+ * depending on that package: it declares `LanguageModel` as a global (in
6
+ * every app that uses this package). Tools are left out: Chrome's tool use
7
+ * doesn't work on web pages, so the adapter calls tools by prompt
8
+ * (`tools.ts`). Names follow the spec: `contextUsage`,
9
+ * `contextWindow` and `contextoverflow` (web pages no longer have the old
10
+ * `inputUsage`, `inputQuota` and `quotaoverflow`).
11
+ */
12
+
13
+ export type Availability =
14
+ "unavailable" | "downloadable" | "downloading" | "available"
15
+
16
+ /** A download's progress, from 0 to 1. */
17
+ export type DownloadProgressCallback = (progress: number) => void
18
+
19
+ /** Sampling presets, for web pages (the raw `temperature` and `topK` are for extensions). */
20
+ export type SamplingMode =
21
+ | "most-predictable"
22
+ | "predictable"
23
+ | "slightly-predictable"
24
+ | "balanced"
25
+ | "slightly-creative"
26
+ | "creative"
27
+ | "most-creative"
28
+
29
+ export type LanguageModelMessageType = "text" | "image" | "audio"
30
+
31
+ export type LanguageModelExpected = {
32
+ type: LanguageModelMessageType
33
+ languages?: Array<string>
34
+ }
35
+
36
+ /**
37
+ * A piece of a message: text, an image (`Blob`, `ImageBitmap`, canvas...)
38
+ * or audio (`Blob`, `AudioBuffer`...).
39
+ */
40
+ export type LanguageModelContent = {
41
+ type: LanguageModelMessageType
42
+ value: unknown
43
+ }
44
+
45
+ export type LanguageModelMessage = {
46
+ role: "system" | "user" | "assistant"
47
+ content: string | Array<LanguageModelContent>
48
+ }
49
+
50
+ /** Options `availability()` takes, which `create()` takes too. */
51
+ export type LanguageModelCoreOptions = {
52
+ expectedInputs?: Array<LanguageModelExpected>
53
+ expectedOutputs?: Array<LanguageModelExpected>
54
+ /** Web pages get it only with Chrome's Sampling Parameters origin trial. */
55
+ samplingMode?: SamplingMode
56
+ /** Extensions only; web pages ignore them. Chrome refuses them together with `samplingMode`. */
57
+ temperature?: number
58
+ topK?: number
59
+ }
60
+
61
+ export type LanguageModelCreateOptions = LanguageModelCoreOptions & {
62
+ initialPrompts?: Array<LanguageModelMessage>
63
+ /** Aborting it destroys the session, during `create()` or after. */
64
+ signal?: AbortSignal
65
+ monitor?: (monitor: EventTarget) => void
66
+ }
67
+
68
+ export type LanguageModelPromptOptions = {
69
+ signal?: AbortSignal
70
+ /** A JSON Schema (or RegExp) the reply must match. */
71
+ responseConstraint?: object
72
+ omitResponseConstraintInput?: boolean
73
+ }
74
+
75
+ export type LanguageModelInput = string | Array<LanguageModelMessage>
76
+
77
+ export interface LanguageModelSession extends EventTarget {
78
+ /** Tokens the session holds. */
79
+ readonly contextUsage: number
80
+ /** Tokens it can hold. */
81
+ readonly contextWindow: number
82
+ /** The preset the session samples with; null when made with raw parameters. */
83
+ readonly samplingMode?: SamplingMode | null
84
+ prompt(
85
+ input: LanguageModelInput,
86
+ options?: LanguageModelPromptOptions
87
+ ): Promise<string>
88
+ /** The reply's text, in pieces as it's written. */
89
+ promptStreaming(
90
+ input: LanguageModelInput,
91
+ options?: LanguageModelPromptOptions
92
+ ): ReadableStream<string> & AsyncIterable<string>
93
+ measureContextUsage(
94
+ input: LanguageModelInput,
95
+ options?: LanguageModelPromptOptions
96
+ ): Promise<number>
97
+ destroy(): void
98
+ }
99
+
100
+ export type LanguageModelStatic = {
101
+ availability(options?: LanguageModelCoreOptions): Promise<Availability>
102
+ create(options?: LanguageModelCreateOptions): Promise<LanguageModelSession>
103
+ }
104
+
105
+ /** The `downloadprogress` event: `loaded` goes from 0 to 1. */
106
+ export type DownloadProgressEvent = Event & { loaded: number; total?: number }
107
+
108
+ /** The page's `LanguageModel`, or undefined where the browser has none. */
109
+ export const languageModel = () =>
110
+ (globalThis as { LanguageModel?: LanguageModelStatic }).LanguageModel