@tanstack-ai-browser/afm 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/Package.swift +33 -0
- package/README.md +62 -2
- package/dist/adapter.d.ts +96 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +490 -0
- package/dist/electron.d.ts +22 -0
- package/dist/electron.d.ts.map +1 -0
- package/dist/electron.js +131 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -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 +163 -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/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 +315 -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 +98 -4
- package/src/adapter.test.ts +380 -0
- package/src/adapter.ts +574 -0
- package/src/electron.ts +180 -0
- package/src/index.ts +27 -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 +119 -0
- package/src/schema.test.ts +146 -0
- package/src/schema.ts +153 -0
package/dist/adapter.js
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A TanStack AI text adapter for Apple's on-device model, through the
|
|
3
|
+
* Foundation Models framework itself: the Capacitor plugin on iOS, or any
|
|
4
|
+
* host that implements `AfmNative`. Hand it to `chat()` like any
|
|
5
|
+
* other adapter: `chat()` runs the tools and the agent loop, and each
|
|
6
|
+
* `chatStream` call here is one step of the model, streamed as AG-UI events.
|
|
7
|
+
*
|
|
8
|
+
* Tools are the framework's own, so the model's arguments always fit their
|
|
9
|
+
* parameters, and it calls several at once. The framework runs a tool in the
|
|
10
|
+
* middle of a response; here the call is reported to `chat()` (the step ends
|
|
11
|
+
* with `tool_calls`) while the native response waits, and the next step hands
|
|
12
|
+
* it the results: the response goes on where it was, without the session
|
|
13
|
+
* reading the conversation again.
|
|
14
|
+
*
|
|
15
|
+
* Each thread (`threadId`) keeps a session: a step prompts it with only the
|
|
16
|
+
* new message. A conversation that changed (an edit, other tools, a new
|
|
17
|
+
* system prompt) starts a new session from the messages.
|
|
18
|
+
*/
|
|
19
|
+
import { buildBaseUsage, EventType, normalizeSystemPrompts } from "@tanstack/ai";
|
|
20
|
+
import { BaseTextAdapter } from "@tanstack/ai/adapters";
|
|
21
|
+
import { contentText, toNativeMessages } from "./messages.js";
|
|
22
|
+
import { Afm } from "./native.js";
|
|
23
|
+
import { toNativeSchema } from "./schema.js";
|
|
24
|
+
/** Events of one native response, as they come. */
|
|
25
|
+
class EventQueue {
|
|
26
|
+
events = [];
|
|
27
|
+
wake;
|
|
28
|
+
push(event) {
|
|
29
|
+
this.events.push(event);
|
|
30
|
+
this.wake?.();
|
|
31
|
+
this.wake = undefined;
|
|
32
|
+
}
|
|
33
|
+
async next(signal) {
|
|
34
|
+
// Stopped: not even an event already here (a buffered done) goes out.
|
|
35
|
+
signal?.throwIfAborted();
|
|
36
|
+
while (this.events.length === 0) {
|
|
37
|
+
signal?.throwIfAborted();
|
|
38
|
+
await new Promise((resolve, reject) => {
|
|
39
|
+
const abort = () => reject(signal?.reason ?? new DOMException("Aborted", "AbortError"));
|
|
40
|
+
this.wake = () => {
|
|
41
|
+
signal?.removeEventListener("abort", abort);
|
|
42
|
+
resolve();
|
|
43
|
+
};
|
|
44
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return this.events.shift();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const KEPT_SESSIONS = 4;
|
|
51
|
+
/** A message, as the session holds it: an assistant's text and its tool calls (ids, names, arguments). */
|
|
52
|
+
const messageKey = (m) => m.role === "assistant"
|
|
53
|
+
? JSON.stringify([
|
|
54
|
+
"assistant",
|
|
55
|
+
contentText(m.content).trim(),
|
|
56
|
+
(m.toolCalls ?? []).map((call) => [call.id, call.function.name, call.function.arguments])
|
|
57
|
+
])
|
|
58
|
+
: JSON.stringify([m.role, contentText(m.content), m.toolCallId ?? null]);
|
|
59
|
+
function offeredTools(options) {
|
|
60
|
+
const { toolChoice, tools = [] } = options;
|
|
61
|
+
if (toolChoice === "none")
|
|
62
|
+
return [];
|
|
63
|
+
if (typeof toolChoice === "object")
|
|
64
|
+
return tools.filter((tool) => tool.name === toolChoice.name);
|
|
65
|
+
return tools;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* When to call tools, added to the instructions of a session that has some.
|
|
69
|
+
* Given tools and no word on when to use them, Apple's model on an iPhone
|
|
70
|
+
* (iOS 27.2) calls one for every prompt, "Hi" and "What is 2 + 2?" included;
|
|
71
|
+
* on a Mac it doesn't. With this, the iPhone's answered greetings, small talk,
|
|
72
|
+
* math, explanations and writing itself, and called the weather, location and
|
|
73
|
+
* web search tools for the weather, "here", current facts and the news. Told
|
|
74
|
+
* only to answer such requests "directly", it refused to write poems ("I
|
|
75
|
+
* cannot write a haiku"); naming poems, stories and emails as its own work
|
|
76
|
+
* keeps it writing.
|
|
77
|
+
*/
|
|
78
|
+
const TOOL_USE = "Most requests need no tool: greetings and small talk, math, writing (poems, stories, emails), explanations and general knowledge. Answer those yourself, without calling a tool. Call a tool only for what you can't do yourself: information that changes or that you don't have, or something only a tool can do.";
|
|
79
|
+
/** The framework's tool-calling mode for a step: "required" when a tool must be called ("required", or the one tool named). */
|
|
80
|
+
const toolCallingModeOf = (options) => options.toolChoice === "required" || typeof options.toolChoice === "object" ? "required" : undefined;
|
|
81
|
+
/** The system prompts, and when to call tools if the session has some. */
|
|
82
|
+
const instructionsOf = (options, tools) => [...normalizeSystemPrompts(options.systemPrompts).map((p) => p.content), ...(tools.length > 0 ? [TOOL_USE] : [])]
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
.join("\n\n");
|
|
85
|
+
const AVAILABILITY = {
|
|
86
|
+
deviceNotEligible: "unavailable",
|
|
87
|
+
appleIntelligenceNotEnabled: "unavailable",
|
|
88
|
+
unsupportedOS: "unavailable",
|
|
89
|
+
unavailable: "unavailable",
|
|
90
|
+
// Apple Intelligence is on, and its model is still downloading.
|
|
91
|
+
modelNotReady: "downloading"
|
|
92
|
+
};
|
|
93
|
+
const usageOf = (usage) => usage && {
|
|
94
|
+
...buildBaseUsage({
|
|
95
|
+
promptTokens: usage.inputTokens,
|
|
96
|
+
completionTokens: usage.outputTokens,
|
|
97
|
+
totalTokens: usage.inputTokens + usage.outputTokens
|
|
98
|
+
}),
|
|
99
|
+
// 0 too: nothing was cached. Left out where the framework can't say.
|
|
100
|
+
...(usage.cachedTokens !== undefined && { promptTokensDetails: { cachedTokens: usage.cachedTokens } })
|
|
101
|
+
};
|
|
102
|
+
let ids = 0;
|
|
103
|
+
const nextId = (prefix) => `${prefix}_${Date.now().toString(36)}_${(ids++).toString(36)}`;
|
|
104
|
+
const signalOf = (options) => options.abortController?.signal ?? options.request?.signal ?? undefined;
|
|
105
|
+
const parse = (json) => {
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(json);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return {};
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
export class AfmTextAdapter extends BaseTextAdapter {
|
|
114
|
+
kind = "text";
|
|
115
|
+
name = "afm";
|
|
116
|
+
provider = "afm";
|
|
117
|
+
api = "afm";
|
|
118
|
+
inputModalities = ["text"];
|
|
119
|
+
native;
|
|
120
|
+
threads = new Map();
|
|
121
|
+
queues = new Map();
|
|
122
|
+
/**
|
|
123
|
+
* Responses waiting for tool results in sessions no thread keeps (prompt
|
|
124
|
+
* cache retention "none"), by thread: the next step resumes them.
|
|
125
|
+
*/
|
|
126
|
+
paused = new Map();
|
|
127
|
+
listening;
|
|
128
|
+
constructor(config, model) {
|
|
129
|
+
super({}, model);
|
|
130
|
+
this.native = config.native ?? globalThis.afm ?? Afm;
|
|
131
|
+
}
|
|
132
|
+
/** Whether Apple's model can run here: "downloading" while Apple Intelligence gets it. */
|
|
133
|
+
async availability() {
|
|
134
|
+
try {
|
|
135
|
+
const answer = await this.native.availability();
|
|
136
|
+
return answer.available ? "available" : AVAILABILITY[answer.reason ?? "unavailable"];
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return "unavailable";
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Tokens a session holds, where the OS says (26.4 and later). */
|
|
143
|
+
async contextSize() {
|
|
144
|
+
try {
|
|
145
|
+
return (await this.native.availability()).contextSize;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** Resolves to this adapter if the model can run here, else rejects. */
|
|
152
|
+
async create(options = {}) {
|
|
153
|
+
options.signal?.throwIfAborted();
|
|
154
|
+
const answer = await this.native.availability();
|
|
155
|
+
options.signal?.throwIfAborted();
|
|
156
|
+
if (!answer.available)
|
|
157
|
+
throw new Error(`Apple's on-device model is unavailable here (${answer.reason ?? "unavailable"})`);
|
|
158
|
+
return this;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Ends every session: the threads', responses waiting for tool results, and
|
|
162
|
+
* responses streaming now (structured output's, and those of sessions no
|
|
163
|
+
* thread keeps), each once.
|
|
164
|
+
*/
|
|
165
|
+
async destroy() {
|
|
166
|
+
const all = new Set([...this.threads.values(), ...this.paused.values(), ...this.queues.values()]);
|
|
167
|
+
for (const thread of all)
|
|
168
|
+
this.close(thread);
|
|
169
|
+
this.threads.clear();
|
|
170
|
+
this.paused.clear();
|
|
171
|
+
}
|
|
172
|
+
/** Routes the native events to their thread's response. Listens once. */
|
|
173
|
+
listen() {
|
|
174
|
+
this.listening ??= this.native.addListener("event", (event) => {
|
|
175
|
+
const thread = this.queues.get(event.sessionId);
|
|
176
|
+
if (thread && event.turn === thread.turn)
|
|
177
|
+
thread.queue?.push(event);
|
|
178
|
+
});
|
|
179
|
+
return this.listening;
|
|
180
|
+
}
|
|
181
|
+
/** Ends a session. A response still streaming from it ends with an error. */
|
|
182
|
+
close(thread) {
|
|
183
|
+
this.queues.delete(thread.sessionId);
|
|
184
|
+
thread.queue?.push({ type: "error", code: "closed", message: "The session was closed", sessionId: thread.sessionId, turn: thread.turn });
|
|
185
|
+
thread.queue = undefined;
|
|
186
|
+
void this.native.deleteSession({ sessionId: thread.sessionId }).catch(() => undefined);
|
|
187
|
+
}
|
|
188
|
+
/** Keeps a thread's session, ending the least recently used past the limit. */
|
|
189
|
+
keep(key, thread) {
|
|
190
|
+
this.threads.delete(key);
|
|
191
|
+
this.threads.set(key, thread);
|
|
192
|
+
for (const [id, old] of this.threads) {
|
|
193
|
+
if (this.threads.size <= KEPT_SESSIONS)
|
|
194
|
+
break;
|
|
195
|
+
if (old.busy)
|
|
196
|
+
continue;
|
|
197
|
+
this.close(old);
|
|
198
|
+
this.threads.delete(id);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** A new native session for a thread, holding `messages`. */
|
|
202
|
+
async open(options, tools, settings, messages) {
|
|
203
|
+
const thread = {
|
|
204
|
+
sessionId: nextId("session"),
|
|
205
|
+
settings,
|
|
206
|
+
seen: [],
|
|
207
|
+
turn: 0,
|
|
208
|
+
queue: undefined,
|
|
209
|
+
written: "",
|
|
210
|
+
waiting: new Set(),
|
|
211
|
+
busy: true
|
|
212
|
+
};
|
|
213
|
+
const instructions = instructionsOf(options, tools);
|
|
214
|
+
await this.native.createSession({
|
|
215
|
+
sessionId: thread.sessionId,
|
|
216
|
+
...(instructions && { instructions }),
|
|
217
|
+
tools: tools.map((tool) => ({
|
|
218
|
+
name: tool.name,
|
|
219
|
+
description: tool.description ?? "",
|
|
220
|
+
parameters: toNativeSchema(tool.inputSchema ?? { type: "object", properties: {} }, tool.name)
|
|
221
|
+
})),
|
|
222
|
+
messages
|
|
223
|
+
});
|
|
224
|
+
return thread;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* The step's thread, and what it sends: the results of the tool calls its
|
|
228
|
+
* response waits on, a new prompt to its session, or (for a conversation the
|
|
229
|
+
* session doesn't hold) a new session and the last message as its prompt.
|
|
230
|
+
*/
|
|
231
|
+
async begin(options) {
|
|
232
|
+
await this.listen();
|
|
233
|
+
const key = options.promptCache?.key ?? options.threadId;
|
|
234
|
+
const retain = options.promptCache?.retention !== "none";
|
|
235
|
+
const tools = offeredTools(options);
|
|
236
|
+
const { temperature, maximumResponseTokens } = options.modelOptions ?? {};
|
|
237
|
+
const settings = JSON.stringify([
|
|
238
|
+
instructionsOf(options, tools),
|
|
239
|
+
tools.map((tool) => [tool.name, tool.description, tool.inputSchema]),
|
|
240
|
+
temperature,
|
|
241
|
+
maximumResponseTokens,
|
|
242
|
+
// A response keeps the mode it started with: a step with another mode
|
|
243
|
+
// (required, then auto) can't resume it, and starts a new session.
|
|
244
|
+
toolCallingModeOf(options)
|
|
245
|
+
]);
|
|
246
|
+
const keys = options.messages.map(messageKey);
|
|
247
|
+
// A kept session, or one whose response waits for tool results.
|
|
248
|
+
const held = (retain ? this.threads.get(key) : undefined) ?? this.paused.get(key);
|
|
249
|
+
const holds = !!held && !held.busy && held.settings === settings && held.seen.every((k, i) => k === keys[i]);
|
|
250
|
+
// The results of the tool calls the session's response waits on.
|
|
251
|
+
if (holds && held.queue && held.waiting.size > 0) {
|
|
252
|
+
const results = options.messages
|
|
253
|
+
.slice(held.seen.length)
|
|
254
|
+
.filter((m) => m.role === "tool" && !!m.toolCallId && held.waiting.has(m.toolCallId));
|
|
255
|
+
if (results.length === held.waiting.size) {
|
|
256
|
+
held.busy = true;
|
|
257
|
+
this.paused.delete(key);
|
|
258
|
+
return { key, thread: held, kept: this.threads.get(key) === held, keys, results };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// A new message to the session's finished response.
|
|
262
|
+
const last = options.messages.at(-1);
|
|
263
|
+
if (holds && !held.queue && keys.length === held.seen.length + 1 && last?.role === "user") {
|
|
264
|
+
held.busy = true;
|
|
265
|
+
return { key, thread: held, kept: true, keys, prompt: contentText(last.content) };
|
|
266
|
+
}
|
|
267
|
+
// A new session for the conversation, prompted with its last message.
|
|
268
|
+
if (held && !held.busy) {
|
|
269
|
+
this.close(held);
|
|
270
|
+
if (this.threads.get(key) === held)
|
|
271
|
+
this.threads.delete(key);
|
|
272
|
+
if (this.paused.get(key) === held)
|
|
273
|
+
this.paused.delete(key);
|
|
274
|
+
}
|
|
275
|
+
const prompted = last?.role === "user";
|
|
276
|
+
const before = prompted ? options.messages.slice(0, -1) : options.messages;
|
|
277
|
+
const thread = await this.open(options, tools, settings, toNativeMessages(before));
|
|
278
|
+
const kept = retain && !this.threads.get(key)?.busy;
|
|
279
|
+
if (kept)
|
|
280
|
+
this.keep(key, thread);
|
|
281
|
+
return { key, thread, kept, keys, prompt: prompted ? contentText(last.content) : "" };
|
|
282
|
+
}
|
|
283
|
+
async *chatStream(options) {
|
|
284
|
+
const runId = options.runId ?? nextId("run");
|
|
285
|
+
const { threadId } = options;
|
|
286
|
+
const messageId = nextId("msg");
|
|
287
|
+
const model = this.model;
|
|
288
|
+
const signal = signalOf(options);
|
|
289
|
+
const timestamp = () => Date.now();
|
|
290
|
+
yield {
|
|
291
|
+
type: EventType.RUN_STARTED,
|
|
292
|
+
runId,
|
|
293
|
+
threadId,
|
|
294
|
+
model,
|
|
295
|
+
timestamp: timestamp(),
|
|
296
|
+
...(options.parentRunId && { parentRunId: options.parentRunId })
|
|
297
|
+
};
|
|
298
|
+
let step;
|
|
299
|
+
let paused = false;
|
|
300
|
+
try {
|
|
301
|
+
// A stopped request starts no native work (and one stopped meanwhile ends here).
|
|
302
|
+
signal?.throwIfAborted();
|
|
303
|
+
step = await this.begin(options);
|
|
304
|
+
signal?.throwIfAborted();
|
|
305
|
+
const { thread, keys } = step;
|
|
306
|
+
if (step.results) {
|
|
307
|
+
for (const result of step.results) {
|
|
308
|
+
const callId = result.toolCallId;
|
|
309
|
+
thread.waiting.delete(callId);
|
|
310
|
+
await this.native.submitToolResult({ sessionId: thread.sessionId, callId, output: contentText(result.content) });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
const { temperature, maximumResponseTokens } = options.modelOptions ?? {};
|
|
315
|
+
thread.turn += 1;
|
|
316
|
+
thread.written = "";
|
|
317
|
+
thread.queue = new EventQueue();
|
|
318
|
+
this.queues.set(thread.sessionId, thread);
|
|
319
|
+
await this.native.respond({
|
|
320
|
+
sessionId: thread.sessionId,
|
|
321
|
+
turn: thread.turn,
|
|
322
|
+
prompt: step.prompt ?? "",
|
|
323
|
+
options: {
|
|
324
|
+
...(temperature !== undefined && { temperature }),
|
|
325
|
+
...(maximumResponseTokens !== undefined && { maximumResponseTokens }),
|
|
326
|
+
...(toolCallingModeOf(options) && { toolCallingMode: toolCallingModeOf(options) })
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
// This step's text (its assistant message), and the events that write it.
|
|
331
|
+
let text = "";
|
|
332
|
+
const textEvents = function* (delta) {
|
|
333
|
+
if (!delta)
|
|
334
|
+
return;
|
|
335
|
+
if (!text)
|
|
336
|
+
yield { type: EventType.TEXT_MESSAGE_START, messageId, role: "assistant", model, timestamp: timestamp() };
|
|
337
|
+
text += delta;
|
|
338
|
+
thread.written += delta;
|
|
339
|
+
yield { type: EventType.TEXT_MESSAGE_CONTENT, messageId, delta, content: text, model, timestamp: timestamp() };
|
|
340
|
+
};
|
|
341
|
+
const endText = function* () {
|
|
342
|
+
if (text)
|
|
343
|
+
yield { type: EventType.TEXT_MESSAGE_END, messageId, model, timestamp: timestamp() };
|
|
344
|
+
};
|
|
345
|
+
const queue = thread.queue;
|
|
346
|
+
for (;;) {
|
|
347
|
+
const event = await queue.next(signal);
|
|
348
|
+
if (event.type === "delta") {
|
|
349
|
+
yield* textEvents(event.text);
|
|
350
|
+
}
|
|
351
|
+
else if (event.type === "toolCalls") {
|
|
352
|
+
yield* endText();
|
|
353
|
+
for (const [index, call] of event.calls.entries()) {
|
|
354
|
+
thread.waiting.add(call.id);
|
|
355
|
+
yield {
|
|
356
|
+
type: EventType.TOOL_CALL_START,
|
|
357
|
+
toolCallId: call.id,
|
|
358
|
+
toolCallName: call.name,
|
|
359
|
+
parentMessageId: messageId,
|
|
360
|
+
index,
|
|
361
|
+
model,
|
|
362
|
+
timestamp: timestamp()
|
|
363
|
+
};
|
|
364
|
+
yield { type: EventType.TOOL_CALL_ARGS, toolCallId: call.id, delta: call.arguments, args: call.arguments, model, timestamp: timestamp() };
|
|
365
|
+
yield {
|
|
366
|
+
type: EventType.TOOL_CALL_END,
|
|
367
|
+
toolCallId: call.id,
|
|
368
|
+
toolCallName: call.name,
|
|
369
|
+
args: call.arguments,
|
|
370
|
+
input: parse(call.arguments),
|
|
371
|
+
model,
|
|
372
|
+
timestamp: timestamp()
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
// The response waits for the results, which the next step sends.
|
|
376
|
+
thread.seen = keys;
|
|
377
|
+
paused = true;
|
|
378
|
+
// A session no thread keeps is still found by the step with the results.
|
|
379
|
+
if (!step.kept)
|
|
380
|
+
this.paused.set(step.key, thread);
|
|
381
|
+
yield { type: EventType.RUN_FINISHED, runId, threadId, model, timestamp: timestamp(), finishReason: "tool_calls" };
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
else if (event.type === "done") {
|
|
385
|
+
// Any of the response not sent as deltas.
|
|
386
|
+
if (event.text.startsWith(thread.written))
|
|
387
|
+
yield* textEvents(event.text.slice(thread.written.length));
|
|
388
|
+
yield* endText();
|
|
389
|
+
thread.queue = undefined;
|
|
390
|
+
this.queues.delete(thread.sessionId);
|
|
391
|
+
thread.seen = [...keys, messageKey({ role: "assistant", content: text })];
|
|
392
|
+
const usage = usageOf(event.usage);
|
|
393
|
+
yield {
|
|
394
|
+
type: EventType.RUN_FINISHED,
|
|
395
|
+
runId,
|
|
396
|
+
threadId,
|
|
397
|
+
model,
|
|
398
|
+
timestamp: timestamp(),
|
|
399
|
+
finishReason: "stop",
|
|
400
|
+
...(usage && { usage })
|
|
401
|
+
};
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
throw Object.assign(new Error(event.message), { code: event.code });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
catch (error) {
|
|
410
|
+
const aborted = !!signal?.aborted;
|
|
411
|
+
if (step) {
|
|
412
|
+
if (aborted)
|
|
413
|
+
void this.native.cancel({ sessionId: step.thread.sessionId }).catch(() => undefined);
|
|
414
|
+
// A stopped or failed response leaves the session behind: the next step starts a new one.
|
|
415
|
+
this.close(step.thread);
|
|
416
|
+
if (this.threads.get(step.key) === step.thread)
|
|
417
|
+
this.threads.delete(step.key);
|
|
418
|
+
if (this.paused.get(step.key) === step.thread)
|
|
419
|
+
this.paused.delete(step.key);
|
|
420
|
+
}
|
|
421
|
+
options.logger.errors("afm chatStream failed", { error, source: "afm.chatStream" });
|
|
422
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
423
|
+
const code = aborted ? "aborted" : error?.code;
|
|
424
|
+
yield {
|
|
425
|
+
type: EventType.RUN_ERROR,
|
|
426
|
+
runId,
|
|
427
|
+
model,
|
|
428
|
+
timestamp: timestamp(),
|
|
429
|
+
message,
|
|
430
|
+
error: { message, ...(typeof code === "string" && { code }) }
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
finally {
|
|
434
|
+
if (step) {
|
|
435
|
+
step.thread.busy = false;
|
|
436
|
+
// A session no thread keeps ends with its step, unless its response waits for tools.
|
|
437
|
+
if (!step.kept && !paused)
|
|
438
|
+
this.close(step.thread);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* A JSON answer for `chat({ outputSchema })`: a session holding the
|
|
444
|
+
* conversation answers its last message, held to the schema by the
|
|
445
|
+
* framework. `chat()` validates the result.
|
|
446
|
+
*/
|
|
447
|
+
async structuredOutput(options) {
|
|
448
|
+
const { chatOptions, outputSchema } = options;
|
|
449
|
+
const signal = signalOf(chatOptions);
|
|
450
|
+
signal?.throwIfAborted();
|
|
451
|
+
await this.listen();
|
|
452
|
+
const last = chatOptions.messages.at(-1);
|
|
453
|
+
const prompted = last?.role === "user";
|
|
454
|
+
const before = prompted ? chatOptions.messages.slice(0, -1) : chatOptions.messages;
|
|
455
|
+
const thread = await this.open(chatOptions, [], "", toNativeMessages(before));
|
|
456
|
+
try {
|
|
457
|
+
thread.turn = 1;
|
|
458
|
+
thread.queue = new EventQueue();
|
|
459
|
+
this.queues.set(thread.sessionId, thread);
|
|
460
|
+
const { temperature, maximumResponseTokens } = chatOptions.modelOptions ?? {};
|
|
461
|
+
await this.native.respond({
|
|
462
|
+
sessionId: thread.sessionId,
|
|
463
|
+
turn: 1,
|
|
464
|
+
prompt: prompted ? contentText(last.content) : "",
|
|
465
|
+
schema: toNativeSchema(outputSchema, "Answer"),
|
|
466
|
+
options: {
|
|
467
|
+
...(temperature !== undefined && { temperature }),
|
|
468
|
+
...(maximumResponseTokens !== undefined && { maximumResponseTokens })
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
for (;;) {
|
|
472
|
+
const event = await thread.queue.next(signal);
|
|
473
|
+
if (event.type === "done") {
|
|
474
|
+
const usage = usageOf(event.usage);
|
|
475
|
+
return { data: JSON.parse(event.text), rawText: event.text, model: this.model, ...(usage && { usage }) };
|
|
476
|
+
}
|
|
477
|
+
if (event.type === "error")
|
|
478
|
+
throw Object.assign(new Error(event.message), { code: event.code });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
finally {
|
|
482
|
+
this.close(thread);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
export function afmText(modelOrConfig, config = {}) {
|
|
487
|
+
return typeof modelOrConfig === "string"
|
|
488
|
+
? new AfmTextAdapter(config, modelOrConfig)
|
|
489
|
+
: new AfmTextAdapter(modelOrConfig ?? {}, "system");
|
|
490
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type ServeAfmOptions = {
|
|
2
|
+
/**
|
|
3
|
+
* The folder with the package's Electron files (afm.node and preload.cjs):
|
|
4
|
+
* by default the package's own `electron` folder, found from the app's
|
|
5
|
+
* node_modules. An app that leaves node_modules out of its package copies
|
|
6
|
+
* the two files in and passes their folder. Inside an asar archive, the
|
|
7
|
+
* unpacked afm.node beside it is loaded (native code can't load from an archive).
|
|
8
|
+
*/
|
|
9
|
+
directory?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Which pages may use the model, by URL. By default the app's own: any page
|
|
12
|
+
* but an http(s) one on another host than localhost.
|
|
13
|
+
*/
|
|
14
|
+
allow?: (url: string) => boolean;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Runs Apple's on-device model for the app's pages, where `afmText()` finds it.
|
|
18
|
+
* Call it once in the main process, before opening windows. On a Mac before
|
|
19
|
+
* macOS 26, or elsewhere, the pages hear the model is unavailable.
|
|
20
|
+
*/
|
|
21
|
+
export declare function serveAfm(options?: ServeAfmOptions): void;
|
|
22
|
+
//# sourceMappingURL=electron.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"electron.d.ts","sourceRoot":"","sources":["../src/electron.ts"],"names":[],"mappings":"AA6CA,MAAM,MAAM,eAAe,GAAG;IAC5B;;;;;;OAMG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;OAGG;IACH,KAAK,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAA;CACjC,CAAA;AAgBD;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,OAAO,GAAE,eAAoB,GAAG,IAAI,CAmG5D"}
|
package/dist/electron.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
const PACKAGE = "@tanstack-ai-browser/afm";
|
|
2
|
+
const CALL = "afm:call";
|
|
3
|
+
const EVENT = "afm:event";
|
|
4
|
+
const appPages = (url) => {
|
|
5
|
+
try {
|
|
6
|
+
const { protocol, hostname } = new URL(url);
|
|
7
|
+
if (protocol !== "http:" && protocol !== "https:")
|
|
8
|
+
return true;
|
|
9
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
const unpacked = (file) => file.replace(/app\.asar(?=[\\/])/, "app.asar.unpacked");
|
|
16
|
+
let served = false;
|
|
17
|
+
/**
|
|
18
|
+
* Runs Apple's on-device model for the app's pages, where `afmText()` finds it.
|
|
19
|
+
* Call it once in the main process, before opening windows. On a Mac before
|
|
20
|
+
* macOS 26, or elsewhere, the pages hear the model is unavailable.
|
|
21
|
+
*/
|
|
22
|
+
export function serveAfm(options = {}) {
|
|
23
|
+
if (served)
|
|
24
|
+
return;
|
|
25
|
+
served = true;
|
|
26
|
+
const node = globalThis.process;
|
|
27
|
+
const { createRequire } = node.getBuiltinModule("node:module");
|
|
28
|
+
const path = node.getBuiltinModule("node:path");
|
|
29
|
+
// Electron answers `require("electron")` from anywhere in the main process.
|
|
30
|
+
const electron = createRequire(node.execPath)("electron");
|
|
31
|
+
const { app } = electron;
|
|
32
|
+
const allow = options.allow ?? appPages;
|
|
33
|
+
let directory = options.directory;
|
|
34
|
+
if (!directory) {
|
|
35
|
+
try {
|
|
36
|
+
const fromApp = createRequire(path.join(app.getAppPath(), "package.json"));
|
|
37
|
+
directory = path.join(path.dirname(fromApp.resolve(`${PACKAGE}/package.json`)), "electron");
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
console.error(`Apple's on-device model is unavailable: ${PACKAGE} isn't in the app's node_modules (pass serveAfm's directory)`, error);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
let addon;
|
|
44
|
+
if (directory && node.platform === "darwin") {
|
|
45
|
+
try {
|
|
46
|
+
addon = createRequire(node.execPath)(unpacked(path.join(directory, "afm.node")));
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
console.error("Apple's on-device model is unavailable: its addon didn't load", error);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// The native sessions, by native id: `${frame id}/${the page's id}`.
|
|
53
|
+
const owners = new Map();
|
|
54
|
+
const frameIdOf = (frame) => `${frame.processId}:${frame.frameToken}`;
|
|
55
|
+
const endSessions = (ended) => {
|
|
56
|
+
for (const [id, owner] of owners) {
|
|
57
|
+
if (!ended(owner))
|
|
58
|
+
continue;
|
|
59
|
+
owners.delete(id);
|
|
60
|
+
addon?.call("deleteSession", JSON.stringify({ sessionId: id }));
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
// A page's sessions end with its document: a navigation, or its contents closing.
|
|
64
|
+
const watched = new WeakSet();
|
|
65
|
+
const watch = (contents) => {
|
|
66
|
+
if (watched.has(contents))
|
|
67
|
+
return;
|
|
68
|
+
watched.add(contents);
|
|
69
|
+
const contentsId = contents.id;
|
|
70
|
+
contents.on("did-start-navigation", ({ isSameDocument, frame }) => {
|
|
71
|
+
if (isSameDocument || !frame)
|
|
72
|
+
return;
|
|
73
|
+
const frameId = frameIdOf(frame);
|
|
74
|
+
endSessions((owner) => owner.frameId === frameId);
|
|
75
|
+
});
|
|
76
|
+
contents.once("destroyed", () => endSessions((owner) => owner.contentsId === contentsId));
|
|
77
|
+
};
|
|
78
|
+
electron.ipcMain.handle(CALL, (event, method, args) => {
|
|
79
|
+
const frame = event.senderFrame;
|
|
80
|
+
if (!frame || !allow(frame.url))
|
|
81
|
+
throw new Error("This page may not use Apple's on-device model");
|
|
82
|
+
if (!addon) {
|
|
83
|
+
if (method === "availability")
|
|
84
|
+
return { available: false, reason: "unsupportedOS" };
|
|
85
|
+
throw new Error("Apple's on-device model needs macOS 26 on a Mac with Apple silicon");
|
|
86
|
+
}
|
|
87
|
+
const options = { ...(typeof args === "object" && args !== null ? args : {}) };
|
|
88
|
+
// The frame's own session: another frame's can't be reached.
|
|
89
|
+
if (typeof options.sessionId === "string") {
|
|
90
|
+
const frameId = frameIdOf(frame);
|
|
91
|
+
const id = `${frameId}/${options.sessionId}`;
|
|
92
|
+
if (method === "createSession") {
|
|
93
|
+
owners.set(id, { frame, frameId, contentsId: event.sender.id, sessionId: options.sessionId });
|
|
94
|
+
watch(event.sender);
|
|
95
|
+
}
|
|
96
|
+
else if (method === "deleteSession") {
|
|
97
|
+
owners.delete(id);
|
|
98
|
+
}
|
|
99
|
+
options.sessionId = id;
|
|
100
|
+
}
|
|
101
|
+
const reply = JSON.parse(addon.call(method, JSON.stringify(options)));
|
|
102
|
+
if (reply.error)
|
|
103
|
+
throw new Error(reply.error);
|
|
104
|
+
return reply.result ?? null;
|
|
105
|
+
});
|
|
106
|
+
// Each event to its session's frame only, with the page's id, while its page is allowed.
|
|
107
|
+
addon?.setListener((json) => {
|
|
108
|
+
const event = JSON.parse(json);
|
|
109
|
+
const owner = typeof event.sessionId === "string" ? owners.get(event.sessionId) : undefined;
|
|
110
|
+
if (!owner || owner.frame.isDestroyed() || owner.frame.detached || !allow(owner.frame.url))
|
|
111
|
+
return;
|
|
112
|
+
owner.frame.send(EVENT, { ...event, sessionId: owner.sessionId });
|
|
113
|
+
});
|
|
114
|
+
// The preload, in every session's pages: window.afm.
|
|
115
|
+
if (!directory)
|
|
116
|
+
return;
|
|
117
|
+
const preload = path.join(directory, "preload.cjs");
|
|
118
|
+
const preloaded = new WeakSet();
|
|
119
|
+
const addPreload = (session) => {
|
|
120
|
+
if (preloaded.has(session))
|
|
121
|
+
return;
|
|
122
|
+
preloaded.add(session);
|
|
123
|
+
session.registerPreloadScript({ type: "frame", id: "tanstack-ai-browser-afm", filePath: preload });
|
|
124
|
+
};
|
|
125
|
+
// Now if the app is ready, so a window opened next has it.
|
|
126
|
+
if (app.isReady())
|
|
127
|
+
addPreload(electron.session.defaultSession);
|
|
128
|
+
else
|
|
129
|
+
void app.whenReady().then(() => addPreload(electron.session.defaultSession));
|
|
130
|
+
app.on("session-created", addPreload);
|
|
131
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TanStack AI adapter for Apple's on-device Foundation Model, through the
|
|
3
|
+
* Foundation Models framework, with its native tool calling: a Capacitor
|
|
4
|
+
* plugin for iOS (`Afm`), Electron's main process on macOS (`serveAfm()`), or
|
|
5
|
+
* any host implementing `AfmNative`. The model catalog (model-meta.ts) is
|
|
6
|
+
* exported here too.
|
|
7
|
+
*/
|
|
8
|
+
export { AfmTextAdapter, afmText } from "./adapter.js";
|
|
9
|
+
export type { Availability, AfmConfig, AfmTextModelOptions } from "./adapter.js";
|
|
10
|
+
export { Afm } from "./native.js";
|
|
11
|
+
export { serveAfm } from "./electron.js";
|
|
12
|
+
export type { ServeAfmOptions } from "./electron.js";
|
|
13
|
+
export type { AfmNative, NativeAvailability, NativeEvent, NativeGenerationSchema, NativeMessage, NativeProperty, NativeSchema, NativeTool, NativeUsage, UnavailableReason } from "./native.js";
|
|
14
|
+
export { toNativeSchema } from "./schema.js";
|
|
15
|
+
export { AFM_CHAT_MODELS, AFM_MODEL_META } from "./model-meta.js";
|
|
16
|
+
export type { AfmChatModel, AfmModelMeta } from "./model-meta.js";
|
|
17
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AACtD,YAAY,EAAE,YAAY,EAAE,SAAS,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAA;AAChF,OAAO,EAAE,GAAG,EAAE,MAAM,aAAa,CAAA;AACjC,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AACxC,YAAY,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AACpD,YAAY,EACV,SAAS,EACT,kBAAkB,EAClB,WAAW,EACX,sBAAsB,EACtB,aAAa,EACb,cAAc,EACd,YAAY,EACZ,UAAU,EACV,WAAW,EACX,iBAAiB,EAClB,MAAM,aAAa,CAAA;AACpB,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AAC5C,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AACjE,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TanStack AI adapter for Apple's on-device Foundation Model, through the
|
|
3
|
+
* Foundation Models framework, with its native tool calling: a Capacitor
|
|
4
|
+
* plugin for iOS (`Afm`), Electron's main process on macOS (`serveAfm()`), or
|
|
5
|
+
* any host implementing `AfmNative`. The model catalog (model-meta.ts) is
|
|
6
|
+
* exported here too.
|
|
7
|
+
*/
|
|
8
|
+
export { AfmTextAdapter, afmText } from "./adapter.js";
|
|
9
|
+
export { Afm } from "./native.js";
|
|
10
|
+
export { serveAfm } from "./electron.js";
|
|
11
|
+
export { toNativeSchema } from "./schema.js";
|
|
12
|
+
export { AFM_CHAT_MODELS, AFM_MODEL_META } from "./model-meta.js";
|