@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/dist/schema.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2
|
+
/** Drops undefined fields: the native bridge takes JSON. */
|
|
3
|
+
function defined(value) {
|
|
4
|
+
return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined));
|
|
5
|
+
}
|
|
6
|
+
const isNull = (schema) => isObject(schema) && (schema.type === "null" || (Array.isArray(schema.type) && schema.type.length === 1 && schema.type[0] === "null"));
|
|
7
|
+
/** Whether a schema allows null: `type: [..., "null"]`, or a null choice in `anyOf`. */
|
|
8
|
+
function nullable(schema) {
|
|
9
|
+
if (!isObject(schema))
|
|
10
|
+
return false;
|
|
11
|
+
if (Array.isArray(schema.type) && schema.type.includes("null"))
|
|
12
|
+
return true;
|
|
13
|
+
const variants = schema.anyOf ?? schema.oneOf;
|
|
14
|
+
return Array.isArray(variants) && variants.some(isNull);
|
|
15
|
+
}
|
|
16
|
+
const number = (value) => (typeof value === "number" && Number.isFinite(value) ? value : undefined);
|
|
17
|
+
/** A schema's name for the framework: letters, digits and underscores. */
|
|
18
|
+
const typeName = (name) => name.replace(/[^A-Za-z0-9_]/g, "_") || "Value";
|
|
19
|
+
/**
|
|
20
|
+
* The framework's schema for a JSON Schema, named `name` (a tool's name, or
|
|
21
|
+
* the structured answer's).
|
|
22
|
+
*/
|
|
23
|
+
export function toNativeSchema(schema, name) {
|
|
24
|
+
const json = isObject(schema) ? schema : {};
|
|
25
|
+
const defs = { ...(isObject(json.definitions) ? json.definitions : {}), ...(isObject(json.$defs) ? json.$defs : {}) };
|
|
26
|
+
const definitions = new Map();
|
|
27
|
+
const convert = (value, path) => {
|
|
28
|
+
if (!isObject(value))
|
|
29
|
+
return { kind: "string" };
|
|
30
|
+
if (typeof value.$ref === "string") {
|
|
31
|
+
const target = value.$ref.split("/").at(-1) ?? "";
|
|
32
|
+
const def = defs[target];
|
|
33
|
+
if (!isObject(def))
|
|
34
|
+
return { kind: "string" };
|
|
35
|
+
const converted = definitions.get(target) ?? convertDefinition(def, target);
|
|
36
|
+
// Only named schemas (objects, choices) can be referred to.
|
|
37
|
+
return converted.kind === "object" || converted.kind === "anyOf" ? { kind: "reference", name: typeName(target) } : converted;
|
|
38
|
+
}
|
|
39
|
+
if ("const" in value && typeof value.const === "string")
|
|
40
|
+
return { kind: "string", constant: value.const };
|
|
41
|
+
if (Array.isArray(value.enum)) {
|
|
42
|
+
const members = value.enum.filter((v) => v !== null);
|
|
43
|
+
if (members.every((v) => typeof v === "string")) {
|
|
44
|
+
return members.length > 0 ? { kind: "string", choices: members } : { kind: "string" };
|
|
45
|
+
}
|
|
46
|
+
// true and false both: any boolean. One alone, the framework can't hold it to.
|
|
47
|
+
if (members.every((v) => typeof v === "boolean")) {
|
|
48
|
+
if (new Set(members).size === 2)
|
|
49
|
+
return { kind: "boolean" };
|
|
50
|
+
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)`);
|
|
51
|
+
}
|
|
52
|
+
// Integers (and strings among them): a choice of exact values, each integer
|
|
53
|
+
// a range of itself, which the framework holds the model to. A decimal
|
|
54
|
+
// can't be: a range of one decimal fails in Apple's generator.
|
|
55
|
+
if (members.some((v) => typeof v === "number" && !Number.isInteger(v))) {
|
|
56
|
+
throw new Error(`${path}: an enum of decimal numbers isn't supported (Apple's framework can't hold a decimal to exact values)`);
|
|
57
|
+
}
|
|
58
|
+
if (members.every((v) => typeof v === "string" || Number.isInteger(v))) {
|
|
59
|
+
const choices = members.map((v) => typeof v === "string" ? { kind: "string", constant: v } : { kind: "integer", minimum: v, maximum: v });
|
|
60
|
+
if (choices.length === 1)
|
|
61
|
+
return choices[0];
|
|
62
|
+
return defined({ kind: "anyOf", name: typeName(path), description: typeof value.description === "string" ? value.description : undefined, choices });
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`${path}: an enum mixing ${[...new Set(members.map((v) => typeof v))].join(" and ")} values isn't supported by Apple's framework`);
|
|
65
|
+
}
|
|
66
|
+
const variants = value.anyOf ?? value.oneOf;
|
|
67
|
+
if (Array.isArray(variants)) {
|
|
68
|
+
const choices = variants.filter((v) => !isNull(v));
|
|
69
|
+
if (choices.length === 1)
|
|
70
|
+
return convert({ ...choices[0], description: value.description ?? choices[0].description }, path);
|
|
71
|
+
if (choices.length > 1) {
|
|
72
|
+
return defined({
|
|
73
|
+
kind: "anyOf",
|
|
74
|
+
name: typeName(path),
|
|
75
|
+
description: typeof value.description === "string" ? value.description : undefined,
|
|
76
|
+
choices: choices.map((choice, i) => convert(choice, `${path}_${i + 1}`))
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const types = Array.isArray(value.type) ? value.type.filter((t) => t !== "null") : [value.type];
|
|
81
|
+
const type = typeof types[0] === "string" ? types[0] : isObject(value.properties) ? "object" : value.items ? "array" : "string";
|
|
82
|
+
switch (type) {
|
|
83
|
+
case "object": {
|
|
84
|
+
const required = Array.isArray(value.required) ? value.required : [];
|
|
85
|
+
const properties = Object.entries(isObject(value.properties) ? value.properties : {}).map(([key, property]) => defined({
|
|
86
|
+
name: key,
|
|
87
|
+
description: isObject(property) && typeof property.description === "string" ? property.description : undefined,
|
|
88
|
+
optional: !required.includes(key) || nullable(property),
|
|
89
|
+
schema: convert(property, `${path}_${key}`)
|
|
90
|
+
}));
|
|
91
|
+
return defined({
|
|
92
|
+
kind: "object",
|
|
93
|
+
name: typeName(path),
|
|
94
|
+
description: typeof value.description === "string" ? value.description : undefined,
|
|
95
|
+
properties
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
case "array":
|
|
99
|
+
return defined({
|
|
100
|
+
kind: "array",
|
|
101
|
+
items: convert(Array.isArray(value.items) ? value.items[0] : value.items, `${path}_item`),
|
|
102
|
+
minimumElements: number(value.minItems),
|
|
103
|
+
maximumElements: number(value.maxItems)
|
|
104
|
+
});
|
|
105
|
+
case "integer":
|
|
106
|
+
case "number":
|
|
107
|
+
return defined({
|
|
108
|
+
kind: type,
|
|
109
|
+
minimum: number(value.minimum) ?? number(value.exclusiveMinimum),
|
|
110
|
+
maximum: number(value.maximum) ?? number(value.exclusiveMaximum)
|
|
111
|
+
});
|
|
112
|
+
case "boolean":
|
|
113
|
+
return { kind: "boolean" };
|
|
114
|
+
case "null":
|
|
115
|
+
return { kind: "null" };
|
|
116
|
+
default:
|
|
117
|
+
return { kind: "string" };
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
const convertDefinition = (def, target) => {
|
|
121
|
+
// Set first, so a definition that refers to itself finds its name.
|
|
122
|
+
definitions.set(target, { kind: "object", name: typeName(target), properties: [] });
|
|
123
|
+
const converted = convert(def, target);
|
|
124
|
+
const named = converted.kind === "object" || converted.kind === "anyOf" ? { ...converted, name: typeName(target) } : converted;
|
|
125
|
+
definitions.set(target, named);
|
|
126
|
+
return named;
|
|
127
|
+
};
|
|
128
|
+
const root = convert(json, name);
|
|
129
|
+
return {
|
|
130
|
+
root,
|
|
131
|
+
definitions: [...definitions.values()].filter((d) => d.kind === "object" || d.kind === "anyOf")
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAiCA,wBAAgB,YAAY,CAAC,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,GAAG,KAAK,CAAC,MAAM,CAAC,CA8E3E"}
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2
|
+
const typeOf = (value) => (value === null ? "null" : Array.isArray(value) ? "array" : typeof value);
|
|
3
|
+
function equal(a, b) {
|
|
4
|
+
if (a === b)
|
|
5
|
+
return true;
|
|
6
|
+
if (Array.isArray(a) && Array.isArray(b))
|
|
7
|
+
return a.length === b.length && a.every((v, i) => equal(v, b[i]));
|
|
8
|
+
if (isObject(a) && isObject(b)) {
|
|
9
|
+
const keys = Object.keys(a);
|
|
10
|
+
return keys.length === Object.keys(b).length && keys.every((k) => Object.hasOwn(b, k) && equal(a[k], b[k]));
|
|
11
|
+
}
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
function fitsType(type, value) {
|
|
15
|
+
if (type === "integer")
|
|
16
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
17
|
+
if (type === "number")
|
|
18
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
19
|
+
return typeOf(value) === type;
|
|
20
|
+
}
|
|
21
|
+
const show = (value) => JSON.stringify(value);
|
|
22
|
+
export function schemaErrors(schema, value) {
|
|
23
|
+
const root = isObject(schema) ? schema : {};
|
|
24
|
+
const errors = [];
|
|
25
|
+
const resolve = (ref) => {
|
|
26
|
+
const match = /^#\/(\$defs|definitions)\/(.+)$/.exec(ref);
|
|
27
|
+
const defs = match ? root[match[1]] : undefined;
|
|
28
|
+
return match && isObject(defs) ? defs[decodeURIComponent(match[2])] : undefined;
|
|
29
|
+
};
|
|
30
|
+
const check = (s, v, path, out) => {
|
|
31
|
+
if (s === true || s === undefined)
|
|
32
|
+
return;
|
|
33
|
+
if (s === false)
|
|
34
|
+
return void out.push(`${path || "/"}: not allowed`);
|
|
35
|
+
if (!isObject(s))
|
|
36
|
+
return;
|
|
37
|
+
if (typeof s.$ref === "string") {
|
|
38
|
+
const target = resolve(s.$ref);
|
|
39
|
+
if (target === undefined)
|
|
40
|
+
return void out.push(`${path || "/"}: unknown $ref ${s.$ref}`);
|
|
41
|
+
check(target, v, path, out);
|
|
42
|
+
}
|
|
43
|
+
const at = path || "/";
|
|
44
|
+
if ("const" in s && !equal(s.const, v))
|
|
45
|
+
out.push(`${at}: should be ${show(s.const)}, not ${show(v)}`);
|
|
46
|
+
if (Array.isArray(s.enum) && !s.enum.some((e) => equal(e, v)))
|
|
47
|
+
out.push(`${at}: ${show(v)} is not one of ${s.enum.map(show).join(", ")}`);
|
|
48
|
+
if (s.type !== undefined) {
|
|
49
|
+
const types = (Array.isArray(s.type) ? s.type : [s.type]).filter((t) => typeof t === "string");
|
|
50
|
+
if (types.length > 0 && !types.some((t) => fitsType(t, v))) {
|
|
51
|
+
out.push(`${at}: should be ${types.join(" or ")}, not ${typeOf(v)}`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (Array.isArray(s.allOf))
|
|
56
|
+
for (const part of s.allOf)
|
|
57
|
+
check(part, v, path, out);
|
|
58
|
+
for (const key of ["anyOf", "oneOf"]) {
|
|
59
|
+
const choices = s[key];
|
|
60
|
+
if (!Array.isArray(choices))
|
|
61
|
+
continue;
|
|
62
|
+
const fitting = choices.filter((choice) => {
|
|
63
|
+
const sub = [];
|
|
64
|
+
check(choice, v, path, sub);
|
|
65
|
+
return sub.length === 0;
|
|
66
|
+
}).length;
|
|
67
|
+
if (key === "anyOf" ? fitting === 0 : fitting !== 1)
|
|
68
|
+
out.push(`${at}: fits ${fitting} of its ${key} choices`);
|
|
69
|
+
}
|
|
70
|
+
if (isObject(v)) {
|
|
71
|
+
const properties = isObject(s.properties) ? s.properties : {};
|
|
72
|
+
if (Array.isArray(s.required)) {
|
|
73
|
+
for (const key of s.required)
|
|
74
|
+
if (typeof key === "string" && !Object.hasOwn(v, key))
|
|
75
|
+
out.push(`${path}/${key}: missing`);
|
|
76
|
+
}
|
|
77
|
+
for (const [key, item] of Object.entries(v)) {
|
|
78
|
+
if (Object.hasOwn(properties, key))
|
|
79
|
+
check(properties[key], item, `${path}/${key}`, out);
|
|
80
|
+
else if (s.additionalProperties === false)
|
|
81
|
+
out.push(`${path}/${key}: not a property of the schema`);
|
|
82
|
+
else if (isObject(s.additionalProperties))
|
|
83
|
+
check(s.additionalProperties, item, `${path}/${key}`, out);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (Array.isArray(v)) {
|
|
87
|
+
if (typeof s.minItems === "number" && v.length < s.minItems)
|
|
88
|
+
out.push(`${at}: fewer than ${s.minItems} items`);
|
|
89
|
+
if (typeof s.maxItems === "number" && v.length > s.maxItems)
|
|
90
|
+
out.push(`${at}: more than ${s.maxItems} items`);
|
|
91
|
+
if (Array.isArray(s.items))
|
|
92
|
+
s.items.forEach((item, i) => i < v.length && check(item, v[i], `${path}/${i}`, out));
|
|
93
|
+
else if (s.items !== undefined)
|
|
94
|
+
v.forEach((item, i) => check(s.items, item, `${path}/${i}`, out));
|
|
95
|
+
}
|
|
96
|
+
if (typeof v === "string") {
|
|
97
|
+
if (typeof s.minLength === "number" && [...v].length < s.minLength)
|
|
98
|
+
out.push(`${at}: shorter than ${s.minLength}`);
|
|
99
|
+
if (typeof s.maxLength === "number" && [...v].length > s.maxLength)
|
|
100
|
+
out.push(`${at}: longer than ${s.maxLength}`);
|
|
101
|
+
if (typeof s.pattern === "string") {
|
|
102
|
+
try {
|
|
103
|
+
if (!new RegExp(s.pattern, "u").test(v))
|
|
104
|
+
out.push(`${at}: doesn't match ${s.pattern}`);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// A pattern JavaScript can't read isn't checked.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (typeof v === "number") {
|
|
112
|
+
if (typeof s.minimum === "number" && v < s.minimum)
|
|
113
|
+
out.push(`${at}: less than ${s.minimum}`);
|
|
114
|
+
if (typeof s.maximum === "number" && v > s.maximum)
|
|
115
|
+
out.push(`${at}: more than ${s.maximum}`);
|
|
116
|
+
if (typeof s.exclusiveMinimum === "number" && v <= s.exclusiveMinimum)
|
|
117
|
+
out.push(`${at}: not more than ${s.exclusiveMinimum}`);
|
|
118
|
+
if (typeof s.exclusiveMaximum === "number" && v >= s.exclusiveMaximum)
|
|
119
|
+
out.push(`${at}: not less than ${s.exclusiveMaximum}`);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
check(root, value, "", errors);
|
|
123
|
+
return errors;
|
|
124
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// AfmBridge for the Node-API addon (afm.c) that serveAfm() loads: plain C functions,
|
|
2
|
+
// JSON strings in and out, built into one afm.node with AfmCore (build.mjs). macOS only.
|
|
3
|
+
import Foundation
|
|
4
|
+
|
|
5
|
+
/// The C function the addon gives for events: called with each event in JSON, from
|
|
6
|
+
/// any thread.
|
|
7
|
+
public typealias AfmListener = @convention(c) (UnsafePointer<CChar>) -> Void
|
|
8
|
+
|
|
9
|
+
private final class State: @unchecked Sendable {
|
|
10
|
+
let lock = NSLock()
|
|
11
|
+
var listener: AfmListener?
|
|
12
|
+
var bridge: AnyObject?
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
private let state = State()
|
|
16
|
+
|
|
17
|
+
private func json(_ object: Any) -> String {
|
|
18
|
+
guard JSONSerialization.isValidJSONObject(object),
|
|
19
|
+
let data = try? JSONSerialization.data(withJSONObject: object) else { return "{}" }
|
|
20
|
+
return String(decoding: data, as: UTF8.self)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private func emit(_ event: [String: Any]) {
|
|
24
|
+
let listener = state.lock.withLock { state.listener }
|
|
25
|
+
guard let listener else { return }
|
|
26
|
+
json(event).withCString { listener($0) }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@available(macOS 26.0, *)
|
|
30
|
+
private func bridge() -> AfmBridge {
|
|
31
|
+
state.lock.withLock {
|
|
32
|
+
if let bridge = state.bridge as? AfmBridge { return bridge }
|
|
33
|
+
let bridge = AfmBridge { emit($0) }
|
|
34
|
+
state.bridge = bridge
|
|
35
|
+
return bridge
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Sets the function events go to.
|
|
40
|
+
@_cdecl("afm_set_listener")
|
|
41
|
+
public func afm_set_listener(_ listener: AfmListener?) {
|
|
42
|
+
state.lock.withLock { state.listener = listener }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// Calls one of AfmBridge's methods with its options in JSON. Returns
|
|
46
|
+
/// `{ "result": … }` or `{ "error": … }` in JSON, which the caller frees with `afm_free`.
|
|
47
|
+
@_cdecl("afm_call")
|
|
48
|
+
public func afm_call(_ method: UnsafePointer<CChar>, _ options: UnsafePointer<CChar>) -> UnsafeMutablePointer<CChar> {
|
|
49
|
+
let name = String(cString: method)
|
|
50
|
+
let object = (try? JSONSerialization.jsonObject(with: Data(String(cString: options).utf8))) as? [String: Any] ?? [:]
|
|
51
|
+
var reply: [String: Any]
|
|
52
|
+
if #available(macOS 26.0, *) {
|
|
53
|
+
do {
|
|
54
|
+
switch name {
|
|
55
|
+
case "availability": reply = ["result": AfmBridge.availability(object)]
|
|
56
|
+
case "createSession": try bridge().createSession(object); reply = ["result": NSNull()]
|
|
57
|
+
case "prewarm": try bridge().prewarm(object); reply = ["result": NSNull()]
|
|
58
|
+
case "respond": try bridge().respond(object); reply = ["result": NSNull()]
|
|
59
|
+
case "submitToolResult": try bridge().submitToolResult(object); reply = ["result": NSNull()]
|
|
60
|
+
case "cancel": try bridge().cancel(object); reply = ["result": NSNull()]
|
|
61
|
+
case "deleteSession":
|
|
62
|
+
bridge().deleteSession(object["sessionId"] as? String ?? "")
|
|
63
|
+
reply = ["result": NSNull()]
|
|
64
|
+
default: reply = ["error": "Unknown method \(name)"]
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
reply = ["error": String(describing: error)]
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
reply = name == "availability"
|
|
71
|
+
? ["result": ["available": false, "reason": "unsupportedOS"]]
|
|
72
|
+
: ["error": "Apple's on-device model needs macOS 26 or later"]
|
|
73
|
+
}
|
|
74
|
+
return strdup(json(reply))!
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// Frees a string `afm_call` returned.
|
|
78
|
+
@_cdecl("afm_free")
|
|
79
|
+
public func afm_free(_ pointer: UnsafeMutablePointer<CChar>?) {
|
|
80
|
+
free(pointer)
|
|
81
|
+
}
|
package/electron/afm.c
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The Node-API addon around AfmBridge (AfmNode.swift): `call(method, optionsJson)`
|
|
2
|
+
// returns `{ result }` or `{ error }` in JSON, and `setListener(fn)` gets each
|
|
3
|
+
// event in JSON. Events come from Swift's threads, so they reach JavaScript through
|
|
4
|
+
// a thread-safe function, on Node's own thread.
|
|
5
|
+
#define NAPI_VERSION 8
|
|
6
|
+
#include <node_api.h>
|
|
7
|
+
#include <stdlib.h>
|
|
8
|
+
#include <string.h>
|
|
9
|
+
|
|
10
|
+
typedef void (*afm_listener)(const char *json);
|
|
11
|
+
extern void afm_set_listener(afm_listener listener);
|
|
12
|
+
extern char *afm_call(const char *method, const char *options);
|
|
13
|
+
extern void afm_free(char *pointer);
|
|
14
|
+
|
|
15
|
+
static napi_threadsafe_function events = NULL;
|
|
16
|
+
|
|
17
|
+
static void call_js(napi_env env, napi_value callback, void *context, void *data) {
|
|
18
|
+
(void)context;
|
|
19
|
+
char *json = data;
|
|
20
|
+
if (env != NULL && callback != NULL) {
|
|
21
|
+
napi_value argument, undefined;
|
|
22
|
+
napi_create_string_utf8(env, json, NAPI_AUTO_LENGTH, &argument);
|
|
23
|
+
napi_get_undefined(env, &undefined);
|
|
24
|
+
napi_call_function(env, undefined, callback, 1, &argument, NULL);
|
|
25
|
+
}
|
|
26
|
+
free(json);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static void on_event(const char *json) {
|
|
30
|
+
napi_threadsafe_function target = events;
|
|
31
|
+
if (target == NULL) return;
|
|
32
|
+
char *copy = strdup(json);
|
|
33
|
+
if (napi_call_threadsafe_function(target, copy, napi_tsfn_nonblocking) != napi_ok) free(copy);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
static char *string_argument(napi_env env, napi_value value) {
|
|
37
|
+
size_t length = 0;
|
|
38
|
+
if (napi_get_value_string_utf8(env, value, NULL, 0, &length) != napi_ok) return NULL;
|
|
39
|
+
char *string = malloc(length + 1);
|
|
40
|
+
napi_get_value_string_utf8(env, value, string, length + 1, &length);
|
|
41
|
+
return string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
static napi_value set_listener(napi_env env, napi_callback_info info) {
|
|
45
|
+
size_t count = 1;
|
|
46
|
+
napi_value arguments[1];
|
|
47
|
+
napi_get_cb_info(env, info, &count, arguments, NULL, NULL);
|
|
48
|
+
if (events != NULL) {
|
|
49
|
+
afm_set_listener(NULL);
|
|
50
|
+
napi_release_threadsafe_function(events, napi_tsfn_abort);
|
|
51
|
+
events = NULL;
|
|
52
|
+
}
|
|
53
|
+
napi_valuetype type = napi_undefined;
|
|
54
|
+
if (count > 0) napi_typeof(env, arguments[0], &type);
|
|
55
|
+
if (type == napi_function) {
|
|
56
|
+
napi_value name;
|
|
57
|
+
napi_create_string_utf8(env, "afm events", NAPI_AUTO_LENGTH, &name);
|
|
58
|
+
napi_create_threadsafe_function(env, arguments[0], NULL, name, 0, 1, NULL, NULL, NULL, call_js, &events);
|
|
59
|
+
// Events don't keep the process running.
|
|
60
|
+
napi_unref_threadsafe_function(env, events);
|
|
61
|
+
afm_set_listener(on_event);
|
|
62
|
+
}
|
|
63
|
+
return NULL;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
static napi_value call(napi_env env, napi_callback_info info) {
|
|
67
|
+
size_t count = 2;
|
|
68
|
+
napi_value arguments[2];
|
|
69
|
+
napi_get_cb_info(env, info, &count, arguments, NULL, NULL);
|
|
70
|
+
char *method = count > 0 ? string_argument(env, arguments[0]) : NULL;
|
|
71
|
+
char *options = count > 1 ? string_argument(env, arguments[1]) : NULL;
|
|
72
|
+
if (method == NULL || options == NULL) {
|
|
73
|
+
free(method);
|
|
74
|
+
free(options);
|
|
75
|
+
napi_throw_type_error(env, NULL, "call(method, optionsJson) takes two strings");
|
|
76
|
+
return NULL;
|
|
77
|
+
}
|
|
78
|
+
char *reply = afm_call(method, options);
|
|
79
|
+
napi_value result;
|
|
80
|
+
napi_create_string_utf8(env, reply, NAPI_AUTO_LENGTH, &result);
|
|
81
|
+
afm_free(reply);
|
|
82
|
+
free(method);
|
|
83
|
+
free(options);
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
NAPI_MODULE_INIT() {
|
|
88
|
+
napi_property_descriptor properties[] = {
|
|
89
|
+
{ "call", NULL, call, NULL, NULL, NULL, napi_default, NULL },
|
|
90
|
+
{ "setListener", NULL, set_listener, NULL, NULL, NULL, napi_default, NULL }
|
|
91
|
+
};
|
|
92
|
+
napi_define_properties(env, exports, 2, properties);
|
|
93
|
+
return exports;
|
|
94
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
7242528a429eafaaa0928604d5ca0130429ded1465d72e765876097777d1f9e1
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Builds the Node-API addon serveAfm() loads, electron/afm.node: the Swift bridge
|
|
2
|
+
// (ios/Sources/AfmCore, which the Capacitor plugin uses too), AfmNode.swift (its C
|
|
3
|
+
// functions) and afm.c (Node-API), in one Mach-O bundle for Apple silicon Macs.
|
|
4
|
+
// Node-API is the same in every Node and Electron, so one build loads in all of them.
|
|
5
|
+
// Needs Xcode on macOS.
|
|
6
|
+
//
|
|
7
|
+
// node electron/build.mjs
|
|
8
|
+
import { execFileSync } from "node:child_process"
|
|
9
|
+
import { readdirSync, writeFileSync } from "node:fs"
|
|
10
|
+
import { createRequire } from "node:module"
|
|
11
|
+
import { dirname, join } from "node:path"
|
|
12
|
+
import { fileURLToPath } from "node:url"
|
|
13
|
+
|
|
14
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
15
|
+
const require = createRequire(import.meta.url)
|
|
16
|
+
const core = join(here, "..", "ios", "Sources", "AfmCore")
|
|
17
|
+
const headers = join(dirname(require.resolve("node-api-headers/package.json")), "include")
|
|
18
|
+
// The oldest macOS the addon loads on; Apple's model needs macOS 26 (checked at run time).
|
|
19
|
+
const target = "arm64-apple-macos13.0"
|
|
20
|
+
|
|
21
|
+
const run = (command, args) => execFileSync("xcrun", [command, ...args], { stdio: "inherit" })
|
|
22
|
+
|
|
23
|
+
const object = join(here, "afm.o")
|
|
24
|
+
run("clang", ["-c", "-O2", "-target", target, "-I", headers, join(here, "afm.c"), "-o", object])
|
|
25
|
+
run("swiftc", [
|
|
26
|
+
"-O",
|
|
27
|
+
"-emit-library",
|
|
28
|
+
"-module-name",
|
|
29
|
+
"AfmNode",
|
|
30
|
+
"-target",
|
|
31
|
+
target,
|
|
32
|
+
...readdirSync(core)
|
|
33
|
+
.filter((file) => file.endsWith(".swift"))
|
|
34
|
+
.map((file) => join(core, file)),
|
|
35
|
+
join(here, "AfmNode.swift"),
|
|
36
|
+
object,
|
|
37
|
+
"-o",
|
|
38
|
+
join(here, "afm.node"),
|
|
39
|
+
// Node-API comes from the process that loads the addon.
|
|
40
|
+
"-Xlinker",
|
|
41
|
+
"-undefined",
|
|
42
|
+
"-Xlinker",
|
|
43
|
+
"dynamic_lookup",
|
|
44
|
+
// macOS before 26 has no FoundationModels: the addon still loads there.
|
|
45
|
+
"-Xlinker",
|
|
46
|
+
"-weak_framework",
|
|
47
|
+
"-Xlinker",
|
|
48
|
+
"FoundationModels"
|
|
49
|
+
])
|
|
50
|
+
execFileSync("rm", ["-f", object])
|
|
51
|
+
// Which sources this addon is from: committed with it, and checked by a test.
|
|
52
|
+
const { hashFile, sourcesHash } = await import("./sources-hash.mjs")
|
|
53
|
+
writeFileSync(hashFile, `${sourcesHash()}\n`)
|
|
54
|
+
console.log(`Built ${join(here, "afm.node")}`)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The preload serveAfm() adds to the app's pages: Apple's on-device model as
|
|
2
|
+
// window.afm (the AfmNative interface), which afmText() finds on its own. The main
|
|
3
|
+
// process runs the model; this only talks to it. CommonJS needing nothing but
|
|
4
|
+
// Electron, as a sandboxed preload must.
|
|
5
|
+
"use strict"
|
|
6
|
+
|
|
7
|
+
const { contextBridge, ipcRenderer } = require("electron")
|
|
8
|
+
|
|
9
|
+
const call =
|
|
10
|
+
(method) =>
|
|
11
|
+
(options = {}) =>
|
|
12
|
+
ipcRenderer.invoke("afm:call", method, options)
|
|
13
|
+
|
|
14
|
+
const afm = {
|
|
15
|
+
availability: call("availability"),
|
|
16
|
+
createSession: call("createSession"),
|
|
17
|
+
prewarm: call("prewarm"),
|
|
18
|
+
respond: call("respond"),
|
|
19
|
+
submitToolResult: call("submitToolResult"),
|
|
20
|
+
cancel: call("cancel"),
|
|
21
|
+
deleteSession: call("deleteSession"),
|
|
22
|
+
addListener(_name, listener) {
|
|
23
|
+
const forward = (_event, event) => listener(event)
|
|
24
|
+
ipcRenderer.on("afm:event", forward)
|
|
25
|
+
return Promise.resolve({
|
|
26
|
+
remove: () => {
|
|
27
|
+
ipcRenderer.removeListener("afm:event", forward)
|
|
28
|
+
return Promise.resolve()
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (process.contextIsolated) contextBridge.exposeInMainWorld("afm", afm)
|
|
35
|
+
else globalThis.afm = afm
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// The addon's sources, hashed: electron/afm.node is committed (so packages build
|
|
2
|
+
// without Xcode), and electron/afm.node.sha256 records which sources it was built
|
|
3
|
+
// from. build.mjs writes it; a test fails when the sources no longer match, so a
|
|
4
|
+
// change to the Swift or C can't ship with a stale addon.
|
|
5
|
+
import { createHash } from "node:crypto"
|
|
6
|
+
import { readdirSync, readFileSync } from "node:fs"
|
|
7
|
+
import { dirname, join, relative } from "node:path"
|
|
8
|
+
import { fileURLToPath } from "node:url"
|
|
9
|
+
|
|
10
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
11
|
+
const root = join(here, "..")
|
|
12
|
+
|
|
13
|
+
/** The files afm.node is built from, in a fixed order. */
|
|
14
|
+
export function sourceFiles() {
|
|
15
|
+
const core = join(root, "ios", "Sources", "AfmCore")
|
|
16
|
+
return [
|
|
17
|
+
...readdirSync(core)
|
|
18
|
+
.filter((file) => file.endsWith(".swift"))
|
|
19
|
+
.sort()
|
|
20
|
+
.map((file) => join(core, file)),
|
|
21
|
+
join(here, "AfmNode.swift"),
|
|
22
|
+
join(here, "afm.c"),
|
|
23
|
+
join(here, "build.mjs")
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A SHA-256 of the sources: each file's path (from the package) and contents. */
|
|
28
|
+
export function sourcesHash() {
|
|
29
|
+
const hash = createHash("sha256")
|
|
30
|
+
for (const file of sourceFiles()) {
|
|
31
|
+
hash.update(relative(root, file))
|
|
32
|
+
hash.update("\0")
|
|
33
|
+
hash.update(readFileSync(file))
|
|
34
|
+
hash.update("\0")
|
|
35
|
+
}
|
|
36
|
+
return hash.digest("hex")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Where the hash of the sources the committed addon was built from is kept. */
|
|
40
|
+
export const hashFile = join(here, "afm.node.sha256")
|