@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/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
|