@tanstack-ai-browser/afm 0.0.0-stage → 0.2.2
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/Package.swift +33 -0
- package/README.md +75 -2
- package/dist/adapter.d.ts +107 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +522 -0
- package/dist/electron.d.ts +29 -0
- package/dist/electron.d.ts.map +1 -0
- package/dist/electron.js +136 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/json-value.d.ts +9 -0
- package/dist/json-value.d.ts.map +1 -0
- package/dist/json-value.js +19 -0
- package/dist/messages.d.ts +12 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/messages.js +29 -0
- package/dist/model-meta.d.ts +23 -0
- package/dist/model-meta.d.ts.map +1 -0
- package/dist/model-meta.js +20 -0
- package/dist/native.d.ts +175 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +21 -0
- package/dist/schema.d.ts +17 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +133 -0
- package/dist/validate.d.ts +2 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +124 -0
- package/electron/AfmNode.swift +81 -0
- package/electron/afm.c +94 -0
- package/electron/afm.node +0 -0
- package/electron/afm.node.sha256 +1 -0
- package/electron/build.mjs +54 -0
- package/electron/preload.cjs +35 -0
- package/electron/sources-hash.mjs +40 -0
- package/ios/Sources/AfmCore/Bridge.swift +354 -0
- package/ios/Sources/AfmCore/Conversation.swift +49 -0
- package/ios/Sources/AfmCore/Schema.swift +90 -0
- package/ios/Sources/AfmPlugin/AfmPlugin.swift +89 -0
- package/package.json +97 -3
- package/src/adapter.test.ts +428 -0
- package/src/adapter.ts +616 -0
- package/src/electron.ts +193 -0
- package/src/index.ts +28 -0
- package/src/json-value.test.ts +26 -0
- package/src/json-value.ts +17 -0
- package/src/messages.ts +37 -0
- package/src/model-meta.ts +37 -0
- package/src/native-build.test.ts +13 -0
- package/src/native.ts +128 -0
- package/src/schema.test.ts +146 -0
- package/src/schema.ts +153 -0
- package/src/validate.test.ts +38 -0
- package/src/validate.ts +112 -0
package/src/schema.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Schema (what `chat()` gives adapters for tools and structured output)
|
|
3
|
+
* read into the framework's own schema (`NativeSchema`), which the model's
|
|
4
|
+
* output is held to: a tool's arguments always fit its parameters.
|
|
5
|
+
*
|
|
6
|
+
* Properties keep their order, which the model writes them in. Each object is
|
|
7
|
+
* named by its path, so two objects never share a name. A property that may be
|
|
8
|
+
* null is optional. `$ref`s to `$defs` (or `definitions`) become references to
|
|
9
|
+
* named definitions; one to a plain value is written in place.
|
|
10
|
+
*/
|
|
11
|
+
import type { NativeGenerationSchema, NativeSchema } from "./native.js"
|
|
12
|
+
|
|
13
|
+
type Json = Record<string, unknown>
|
|
14
|
+
|
|
15
|
+
const isObject = (value: unknown): value is Json => typeof value === "object" && value !== null && !Array.isArray(value)
|
|
16
|
+
|
|
17
|
+
/** Drops undefined fields: the native bridge takes JSON. */
|
|
18
|
+
function defined<T extends object>(value: T): T {
|
|
19
|
+
return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as T
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const isNull = (schema: unknown) =>
|
|
23
|
+
isObject(schema) && (schema.type === "null" || (Array.isArray(schema.type) && schema.type.length === 1 && schema.type[0] === "null"))
|
|
24
|
+
|
|
25
|
+
/** Whether a schema allows null: `type: [..., "null"]`, or a null choice in `anyOf`. */
|
|
26
|
+
function nullable(schema: unknown): boolean {
|
|
27
|
+
if (!isObject(schema)) return false
|
|
28
|
+
if (Array.isArray(schema.type) && schema.type.includes("null")) return true
|
|
29
|
+
const variants = schema.anyOf ?? schema.oneOf
|
|
30
|
+
return Array.isArray(variants) && variants.some(isNull)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const number = (value: unknown) => (typeof value === "number" && Number.isFinite(value) ? value : undefined)
|
|
34
|
+
|
|
35
|
+
/** A schema's name for the framework: letters, digits and underscores. */
|
|
36
|
+
const typeName = (name: string) => name.replace(/[^A-Za-z0-9_]/g, "_") || "Value"
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The framework's schema for a JSON Schema, named `name` (a tool's name, or
|
|
40
|
+
* the structured answer's).
|
|
41
|
+
*/
|
|
42
|
+
export function toNativeSchema(schema: unknown, name: string): NativeGenerationSchema {
|
|
43
|
+
const json = isObject(schema) ? schema : {}
|
|
44
|
+
const defs: Json = { ...(isObject(json.definitions) ? json.definitions : {}), ...(isObject(json.$defs) ? json.$defs : {}) }
|
|
45
|
+
const definitions = new Map<string, NativeSchema>()
|
|
46
|
+
|
|
47
|
+
const convert = (value: unknown, path: string): NativeSchema => {
|
|
48
|
+
if (!isObject(value)) return { kind: "string" }
|
|
49
|
+
if (typeof value.$ref === "string") {
|
|
50
|
+
const target = value.$ref.split("/").at(-1) ?? ""
|
|
51
|
+
const def = defs[target]
|
|
52
|
+
if (!isObject(def)) return { kind: "string" }
|
|
53
|
+
const converted = definitions.get(target) ?? convertDefinition(def, target)
|
|
54
|
+
// Only named schemas (objects, choices) can be referred to.
|
|
55
|
+
return converted.kind === "object" || converted.kind === "anyOf" ? { kind: "reference", name: typeName(target) } : converted
|
|
56
|
+
}
|
|
57
|
+
if ("const" in value && typeof value.const === "string") return { kind: "string", constant: value.const }
|
|
58
|
+
if (Array.isArray(value.enum)) {
|
|
59
|
+
const members: Array<unknown> = value.enum.filter((v) => v !== null)
|
|
60
|
+
if (members.every((v) => typeof v === "string")) {
|
|
61
|
+
return members.length > 0 ? { kind: "string", choices: members } : { kind: "string" }
|
|
62
|
+
}
|
|
63
|
+
// true and false both: any boolean. One alone, the framework can't hold it to.
|
|
64
|
+
if (members.every((v) => typeof v === "boolean")) {
|
|
65
|
+
if (new Set(members).size === 2) return { kind: "boolean" }
|
|
66
|
+
throw new Error(`${path}: an enum of only ${String(members[0])} isn't supported (Apple's framework can't hold a boolean to one value)`)
|
|
67
|
+
}
|
|
68
|
+
// Integers (and strings among them): a choice of exact values, each integer
|
|
69
|
+
// a range of itself, which the framework holds the model to. A decimal
|
|
70
|
+
// can't be: a range of one decimal fails in Apple's generator.
|
|
71
|
+
if (members.some((v) => typeof v === "number" && !Number.isInteger(v))) {
|
|
72
|
+
throw new Error(`${path}: an enum of decimal numbers isn't supported (Apple's framework can't hold a decimal to exact values)`)
|
|
73
|
+
}
|
|
74
|
+
if (members.every((v) => typeof v === "string" || Number.isInteger(v))) {
|
|
75
|
+
const choices: Array<NativeSchema> = members.map((v) =>
|
|
76
|
+
typeof v === "string" ? { kind: "string", constant: v } : { kind: "integer", minimum: v as number, maximum: v as number }
|
|
77
|
+
)
|
|
78
|
+
if (choices.length === 1) return choices[0]
|
|
79
|
+
return defined({ kind: "anyOf" as const, name: typeName(path), description: typeof value.description === "string" ? value.description : undefined, choices })
|
|
80
|
+
}
|
|
81
|
+
throw new Error(`${path}: an enum mixing ${[...new Set(members.map((v) => typeof v))].join(" and ")} values isn't supported by Apple's framework`)
|
|
82
|
+
}
|
|
83
|
+
const variants = value.anyOf ?? value.oneOf
|
|
84
|
+
if (Array.isArray(variants)) {
|
|
85
|
+
const choices = variants.filter((v) => !isNull(v))
|
|
86
|
+
if (choices.length === 1) return convert({ ...choices[0], description: value.description ?? choices[0].description }, path)
|
|
87
|
+
if (choices.length > 1) {
|
|
88
|
+
return defined({
|
|
89
|
+
kind: "anyOf" as const,
|
|
90
|
+
name: typeName(path),
|
|
91
|
+
description: typeof value.description === "string" ? value.description : undefined,
|
|
92
|
+
choices: choices.map((choice, i) => convert(choice, `${path}_${i + 1}`))
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const types = Array.isArray(value.type) ? value.type.filter((t) => t !== "null") : [value.type]
|
|
97
|
+
const type = typeof types[0] === "string" ? types[0] : isObject(value.properties) ? "object" : value.items ? "array" : "string"
|
|
98
|
+
switch (type) {
|
|
99
|
+
case "object": {
|
|
100
|
+
const required = Array.isArray(value.required) ? value.required : []
|
|
101
|
+
const properties = Object.entries(isObject(value.properties) ? value.properties : {}).map(([key, property]) =>
|
|
102
|
+
defined({
|
|
103
|
+
name: key,
|
|
104
|
+
description: isObject(property) && typeof property.description === "string" ? property.description : undefined,
|
|
105
|
+
optional: !required.includes(key) || nullable(property),
|
|
106
|
+
schema: convert(property, `${path}_${key}`)
|
|
107
|
+
})
|
|
108
|
+
)
|
|
109
|
+
return defined({
|
|
110
|
+
kind: "object" as const,
|
|
111
|
+
name: typeName(path),
|
|
112
|
+
description: typeof value.description === "string" ? value.description : undefined,
|
|
113
|
+
properties
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
case "array":
|
|
117
|
+
return defined({
|
|
118
|
+
kind: "array" as const,
|
|
119
|
+
items: convert(Array.isArray(value.items) ? value.items[0] : value.items, `${path}_item`),
|
|
120
|
+
minimumElements: number(value.minItems),
|
|
121
|
+
maximumElements: number(value.maxItems)
|
|
122
|
+
})
|
|
123
|
+
case "integer":
|
|
124
|
+
case "number":
|
|
125
|
+
return defined({
|
|
126
|
+
kind: type,
|
|
127
|
+
minimum: number(value.minimum) ?? number(value.exclusiveMinimum),
|
|
128
|
+
maximum: number(value.maximum) ?? number(value.exclusiveMaximum)
|
|
129
|
+
})
|
|
130
|
+
case "boolean":
|
|
131
|
+
return { kind: "boolean" }
|
|
132
|
+
case "null":
|
|
133
|
+
return { kind: "null" }
|
|
134
|
+
default:
|
|
135
|
+
return { kind: "string" }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const convertDefinition = (def: Json, target: string): NativeSchema => {
|
|
140
|
+
// Set first, so a definition that refers to itself finds its name.
|
|
141
|
+
definitions.set(target, { kind: "object", name: typeName(target), properties: [] })
|
|
142
|
+
const converted = convert(def, target)
|
|
143
|
+
const named = converted.kind === "object" || converted.kind === "anyOf" ? { ...converted, name: typeName(target) } : converted
|
|
144
|
+
definitions.set(target, named)
|
|
145
|
+
return named
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const root = convert(json, name)
|
|
149
|
+
return {
|
|
150
|
+
root,
|
|
151
|
+
definitions: [...definitions.values()].filter((d) => d.kind === "object" || d.kind === "anyOf")
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { test } from "node:test"
|
|
3
|
+
|
|
4
|
+
import { schemaErrors } from "./validate.js"
|
|
5
|
+
|
|
6
|
+
const city = { type: "object", properties: { city: { type: "string" } }, required: ["city"] }
|
|
7
|
+
|
|
8
|
+
test("a value that fits its schema has no errors", () => {
|
|
9
|
+
assert.deepEqual(schemaErrors(city, { city: "Paris" }), [])
|
|
10
|
+
assert.deepEqual(schemaErrors({ type: "array", items: { type: "integer" }, minItems: 1 }, [1, 2]), [])
|
|
11
|
+
assert.deepEqual(schemaErrors({ type: ["string", "null"] }, null), [])
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test("missing required fields, wrong types and values outside an enum are errors, with their paths", () => {
|
|
15
|
+
assert.deepEqual(schemaErrors(city, {}), ["/city: missing"])
|
|
16
|
+
assert.deepEqual(schemaErrors(city, { city: 123 }), ["/city: should be string, not number"])
|
|
17
|
+
assert.deepEqual(schemaErrors(city, "Paris"), ["/: should be object, not string"])
|
|
18
|
+
const action = { type: "object", properties: { operation: { type: "string", enum: ["add", "remove"] } }, required: ["operation"] }
|
|
19
|
+
assert.deepEqual(schemaErrors(action, { operation: "burn" }), ['/operation: "burn" is not one of "add", "remove"'])
|
|
20
|
+
assert.deepEqual(schemaErrors({ type: "integer" }, 1.5), ["/: should be integer, not number"])
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test("nested arrays, $refs, choices, extra properties and bounds are checked", () => {
|
|
24
|
+
const schema = {
|
|
25
|
+
type: "object",
|
|
26
|
+
properties: { actions: { type: "array", items: { $ref: "#/$defs/Action" }, maxItems: 2 } },
|
|
27
|
+
required: ["actions"],
|
|
28
|
+
$defs: { Action: { type: "object", properties: { n: { type: "integer", minimum: 1, maximum: 4 } }, required: ["n"], additionalProperties: false } }
|
|
29
|
+
}
|
|
30
|
+
assert.deepEqual(schemaErrors(schema, { actions: [{ n: 2 }] }), [])
|
|
31
|
+
assert.deepEqual(schemaErrors(schema, { actions: [{ n: 0 }, { n: 2, x: 1 }, { n: 3 }] }), [
|
|
32
|
+
"/actions: more than 2 items",
|
|
33
|
+
"/actions/0/n: less than 1",
|
|
34
|
+
"/actions/1/x: not a property of the schema"
|
|
35
|
+
])
|
|
36
|
+
assert.deepEqual(schemaErrors({ anyOf: [{ type: "string" }, { type: "integer" }] }, true), ["/: fits 0 of its anyOf choices"])
|
|
37
|
+
assert.deepEqual(schemaErrors({ const: "a" }, "b"), ['/: should be "a", not "b"'])
|
|
38
|
+
})
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a value doesn't fit a JSON Schema: each mismatch with its path
|
|
3
|
+
* ("/actions/0/operation: not one of add, remove"). Empty when it fits. It reads
|
|
4
|
+
* what afm converts for Apple's framework and the framework holds answers to:
|
|
5
|
+
* types (with null), objects (properties, required, additionalProperties),
|
|
6
|
+
* arrays (items, minItems, maxItems), enum, const, anyOf, oneOf, allOf, $ref to
|
|
7
|
+
* $defs or definitions, string lengths and patterns, and numeric bounds. Other
|
|
8
|
+
* keywords are left unchecked.
|
|
9
|
+
*/
|
|
10
|
+
type Json = Record<string, unknown>
|
|
11
|
+
|
|
12
|
+
const isObject = (value: unknown): value is Json => typeof value === "object" && value !== null && !Array.isArray(value)
|
|
13
|
+
|
|
14
|
+
const typeOf = (value: unknown) => (value === null ? "null" : Array.isArray(value) ? "array" : typeof value)
|
|
15
|
+
|
|
16
|
+
function equal(a: unknown, b: unknown): boolean {
|
|
17
|
+
if (a === b) return true
|
|
18
|
+
if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((v, i) => equal(v, b[i]))
|
|
19
|
+
if (isObject(a) && isObject(b)) {
|
|
20
|
+
const keys = Object.keys(a)
|
|
21
|
+
return keys.length === Object.keys(b).length && keys.every((k) => Object.hasOwn(b, k) && equal(a[k], b[k]))
|
|
22
|
+
}
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function fitsType(type: string, value: unknown) {
|
|
27
|
+
if (type === "integer") return typeof value === "number" && Number.isInteger(value)
|
|
28
|
+
if (type === "number") return typeof value === "number" && Number.isFinite(value)
|
|
29
|
+
return typeOf(value) === type
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const show = (value: unknown) => JSON.stringify(value)
|
|
33
|
+
|
|
34
|
+
export function schemaErrors(schema: unknown, value: unknown): Array<string> {
|
|
35
|
+
const root = isObject(schema) ? schema : {}
|
|
36
|
+
const errors: Array<string> = []
|
|
37
|
+
|
|
38
|
+
const resolve = (ref: string): unknown => {
|
|
39
|
+
const match = /^#\/(\$defs|definitions)\/(.+)$/.exec(ref)
|
|
40
|
+
const defs = match ? root[match[1]] : undefined
|
|
41
|
+
return match && isObject(defs) ? defs[decodeURIComponent(match[2])] : undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const check = (s: unknown, v: unknown, path: string, out: Array<string>) => {
|
|
45
|
+
if (s === true || s === undefined) return
|
|
46
|
+
if (s === false) return void out.push(`${path || "/"}: not allowed`)
|
|
47
|
+
if (!isObject(s)) return
|
|
48
|
+
if (typeof s.$ref === "string") {
|
|
49
|
+
const target = resolve(s.$ref)
|
|
50
|
+
if (target === undefined) return void out.push(`${path || "/"}: unknown $ref ${s.$ref}`)
|
|
51
|
+
check(target, v, path, out)
|
|
52
|
+
}
|
|
53
|
+
const at = path || "/"
|
|
54
|
+
if ("const" in s && !equal(s.const, v)) out.push(`${at}: should be ${show(s.const)}, not ${show(v)}`)
|
|
55
|
+
if (Array.isArray(s.enum) && !s.enum.some((e) => equal(e, v))) out.push(`${at}: ${show(v)} is not one of ${s.enum.map(show).join(", ")}`)
|
|
56
|
+
if (s.type !== undefined) {
|
|
57
|
+
const types = (Array.isArray(s.type) ? s.type : [s.type]).filter((t): t is string => typeof t === "string")
|
|
58
|
+
if (types.length > 0 && !types.some((t) => fitsType(t, v))) {
|
|
59
|
+
out.push(`${at}: should be ${types.join(" or ")}, not ${typeOf(v)}`)
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (Array.isArray(s.allOf)) for (const part of s.allOf) check(part, v, path, out)
|
|
64
|
+
for (const key of ["anyOf", "oneOf"] as const) {
|
|
65
|
+
const choices = s[key]
|
|
66
|
+
if (!Array.isArray(choices)) continue
|
|
67
|
+
const fitting = choices.filter((choice) => {
|
|
68
|
+
const sub: Array<string> = []
|
|
69
|
+
check(choice, v, path, sub)
|
|
70
|
+
return sub.length === 0
|
|
71
|
+
}).length
|
|
72
|
+
if (key === "anyOf" ? fitting === 0 : fitting !== 1) out.push(`${at}: fits ${fitting} of its ${key} choices`)
|
|
73
|
+
}
|
|
74
|
+
if (isObject(v)) {
|
|
75
|
+
const properties = isObject(s.properties) ? s.properties : {}
|
|
76
|
+
if (Array.isArray(s.required)) {
|
|
77
|
+
for (const key of s.required) if (typeof key === "string" && !Object.hasOwn(v, key)) out.push(`${path}/${key}: missing`)
|
|
78
|
+
}
|
|
79
|
+
for (const [key, item] of Object.entries(v)) {
|
|
80
|
+
if (Object.hasOwn(properties, key)) check(properties[key], item, `${path}/${key}`, out)
|
|
81
|
+
else if (s.additionalProperties === false) out.push(`${path}/${key}: not a property of the schema`)
|
|
82
|
+
else if (isObject(s.additionalProperties)) check(s.additionalProperties, item, `${path}/${key}`, out)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (Array.isArray(v)) {
|
|
86
|
+
if (typeof s.minItems === "number" && v.length < s.minItems) out.push(`${at}: fewer than ${s.minItems} items`)
|
|
87
|
+
if (typeof s.maxItems === "number" && v.length > s.maxItems) out.push(`${at}: more than ${s.maxItems} items`)
|
|
88
|
+
if (Array.isArray(s.items)) s.items.forEach((item, i) => i < v.length && check(item, v[i], `${path}/${i}`, out))
|
|
89
|
+
else if (s.items !== undefined) v.forEach((item, i) => check(s.items, item, `${path}/${i}`, out))
|
|
90
|
+
}
|
|
91
|
+
if (typeof v === "string") {
|
|
92
|
+
if (typeof s.minLength === "number" && [...v].length < s.minLength) out.push(`${at}: shorter than ${s.minLength}`)
|
|
93
|
+
if (typeof s.maxLength === "number" && [...v].length > s.maxLength) out.push(`${at}: longer than ${s.maxLength}`)
|
|
94
|
+
if (typeof s.pattern === "string") {
|
|
95
|
+
try {
|
|
96
|
+
if (!new RegExp(s.pattern, "u").test(v)) out.push(`${at}: doesn't match ${s.pattern}`)
|
|
97
|
+
} catch {
|
|
98
|
+
// A pattern JavaScript can't read isn't checked.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (typeof v === "number") {
|
|
103
|
+
if (typeof s.minimum === "number" && v < s.minimum) out.push(`${at}: less than ${s.minimum}`)
|
|
104
|
+
if (typeof s.maximum === "number" && v > s.maximum) out.push(`${at}: more than ${s.maximum}`)
|
|
105
|
+
if (typeof s.exclusiveMinimum === "number" && v <= s.exclusiveMinimum) out.push(`${at}: not more than ${s.exclusiveMinimum}`)
|
|
106
|
+
if (typeof s.exclusiveMaximum === "number" && v >= s.exclusiveMaximum) out.push(`${at}: not less than ${s.exclusiveMaximum}`)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
check(root, value, "", errors)
|
|
111
|
+
return errors
|
|
112
|
+
}
|