@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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/Package.swift +33 -0
  3. package/README.md +62 -2
  4. package/dist/adapter.d.ts +96 -0
  5. package/dist/adapter.d.ts.map +1 -0
  6. package/dist/adapter.js +490 -0
  7. package/dist/electron.d.ts +22 -0
  8. package/dist/electron.d.ts.map +1 -0
  9. package/dist/electron.js +131 -0
  10. package/dist/index.d.ts +17 -0
  11. package/dist/index.d.ts.map +1 -0
  12. package/dist/index.js +12 -0
  13. package/dist/messages.d.ts +12 -0
  14. package/dist/messages.d.ts.map +1 -0
  15. package/dist/messages.js +29 -0
  16. package/dist/model-meta.d.ts +23 -0
  17. package/dist/model-meta.d.ts.map +1 -0
  18. package/dist/model-meta.js +20 -0
  19. package/dist/native.d.ts +163 -0
  20. package/dist/native.d.ts.map +1 -0
  21. package/dist/native.js +21 -0
  22. package/dist/schema.d.ts +17 -0
  23. package/dist/schema.d.ts.map +1 -0
  24. package/dist/schema.js +133 -0
  25. package/electron/AfmNode.swift +81 -0
  26. package/electron/afm.c +94 -0
  27. package/electron/afm.node +0 -0
  28. package/electron/afm.node.sha256 +1 -0
  29. package/electron/build.mjs +54 -0
  30. package/electron/preload.cjs +35 -0
  31. package/electron/sources-hash.mjs +40 -0
  32. package/ios/Sources/AfmCore/Bridge.swift +315 -0
  33. package/ios/Sources/AfmCore/Conversation.swift +49 -0
  34. package/ios/Sources/AfmCore/Schema.swift +90 -0
  35. package/ios/Sources/AfmPlugin/AfmPlugin.swift +89 -0
  36. package/package.json +98 -4
  37. package/src/adapter.test.ts +380 -0
  38. package/src/adapter.ts +574 -0
  39. package/src/electron.ts +180 -0
  40. package/src/index.ts +27 -0
  41. package/src/messages.ts +37 -0
  42. package/src/model-meta.ts +37 -0
  43. package/src/native-build.test.ts +13 -0
  44. package/src/native.ts +119 -0
  45. package/src/schema.test.ts +146 -0
  46. package/src/schema.ts +153 -0
package/src/adapter.ts ADDED
@@ -0,0 +1,574 @@
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 type { AdapterYieldChunk, ModelMessage, TextOptions, TokenUsage, Tool } from "@tanstack/ai"
21
+ import { BaseTextAdapter } from "@tanstack/ai/adapters"
22
+ import type { StructuredOutputOptions, StructuredOutputResult } from "@tanstack/ai/adapters"
23
+
24
+ import { contentText, toNativeMessages } from "./messages.js"
25
+ import type { AfmChatModel } from "./model-meta.js"
26
+ import { Afm } from "./native.js"
27
+ import type { AfmNative, NativeEvent, NativeMessage, NativeUsage, UnavailableReason } from "./native.js"
28
+ import { toNativeSchema } from "./schema.js"
29
+
30
+ export type AfmConfig = {
31
+ /**
32
+ * The native side. By default `window.afm` where a host put it there
33
+ * (Electron, with `serveAfm()` in the main process), else the Capacitor
34
+ * plugin (`Afm`).
35
+ */
36
+ native?: AfmNative
37
+ }
38
+
39
+ /** Per-call `modelOptions`: the response's sampling and length. */
40
+ export type AfmTextModelOptions = {
41
+ temperature?: number
42
+ /** Most tokens to generate. */
43
+ maximumResponseTokens?: number
44
+ }
45
+
46
+ export type Availability = "unavailable" | "downloadable" | "downloading" | "available"
47
+
48
+ type MetadataByModality = {
49
+ text: unknown
50
+ image: unknown
51
+ audio: unknown
52
+ video: unknown
53
+ document: unknown
54
+ }
55
+
56
+ /** Events of one native response, as they come. */
57
+ class EventQueue {
58
+ private readonly events: Array<NativeEvent> = []
59
+ private wake: (() => void) | undefined
60
+
61
+ push(event: NativeEvent) {
62
+ this.events.push(event)
63
+ this.wake?.()
64
+ this.wake = undefined
65
+ }
66
+
67
+ async next(signal: AbortSignal | undefined): Promise<NativeEvent> {
68
+ // Stopped: not even an event already here (a buffered done) goes out.
69
+ signal?.throwIfAborted()
70
+ while (this.events.length === 0) {
71
+ signal?.throwIfAborted()
72
+ await new Promise<void>((resolve, reject) => {
73
+ const abort = () => reject(signal?.reason ?? new DOMException("Aborted", "AbortError"))
74
+ this.wake = () => {
75
+ signal?.removeEventListener("abort", abort)
76
+ resolve()
77
+ }
78
+ signal?.addEventListener("abort", abort, { once: true })
79
+ })
80
+ }
81
+ return this.events.shift() as NativeEvent
82
+ }
83
+ }
84
+
85
+ /** A thread's native session, and its response in progress (waiting at tool calls). */
86
+ type Thread = {
87
+ sessionId: string
88
+ settings: string
89
+ /** The conversation the session holds, by message key. */
90
+ seen: Array<string>
91
+ turn: number
92
+ /** The response in progress: set from `respond` until `done` or an error. */
93
+ queue: EventQueue | undefined
94
+ /** The response's text so far, over all its steps. */
95
+ written: string
96
+ /** Tool calls reported to `chat()` that wait for results. */
97
+ waiting: Set<string>
98
+ busy: boolean
99
+ }
100
+
101
+ const KEPT_SESSIONS = 4
102
+
103
+ /** A message, as the session holds it: an assistant's text and its tool calls (ids, names, arguments). */
104
+ const messageKey = (m: ModelMessage) =>
105
+ m.role === "assistant"
106
+ ? JSON.stringify([
107
+ "assistant",
108
+ contentText(m.content).trim(),
109
+ (m.toolCalls ?? []).map((call) => [call.id, call.function.name, call.function.arguments])
110
+ ])
111
+ : JSON.stringify([m.role, contentText(m.content), m.toolCallId ?? null])
112
+
113
+ function offeredTools(options: TextOptions<AfmTextModelOptions>): ReadonlyArray<Tool> {
114
+ const { toolChoice, tools = [] } = options
115
+ if (toolChoice === "none") return []
116
+ if (typeof toolChoice === "object") return tools.filter((tool) => tool.name === toolChoice.name)
117
+ return tools
118
+ }
119
+
120
+ /**
121
+ * When to call tools, added to the instructions of a session that has some.
122
+ * Given tools and no word on when to use them, Apple's model on an iPhone
123
+ * (iOS 27.2) calls one for every prompt, "Hi" and "What is 2 + 2?" included;
124
+ * on a Mac it doesn't. With this, the iPhone's answered greetings, small talk,
125
+ * math, explanations and writing itself, and called the weather, location and
126
+ * web search tools for the weather, "here", current facts and the news. Told
127
+ * only to answer such requests "directly", it refused to write poems ("I
128
+ * cannot write a haiku"); naming poems, stories and emails as its own work
129
+ * keeps it writing.
130
+ */
131
+ const TOOL_USE =
132
+ "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."
133
+
134
+ /** The framework's tool-calling mode for a step: "required" when a tool must be called ("required", or the one tool named). */
135
+ const toolCallingModeOf = (options: TextOptions<AfmTextModelOptions>) =>
136
+ options.toolChoice === "required" || typeof options.toolChoice === "object" ? ("required" as const) : undefined
137
+
138
+ /** The system prompts, and when to call tools if the session has some. */
139
+ const instructionsOf = (options: TextOptions<AfmTextModelOptions>, tools: ReadonlyArray<Tool>) =>
140
+ [...normalizeSystemPrompts(options.systemPrompts).map((p) => p.content), ...(tools.length > 0 ? [TOOL_USE] : [])]
141
+ .filter(Boolean)
142
+ .join("\n\n")
143
+
144
+ const AVAILABILITY: Record<UnavailableReason, Availability> = {
145
+ deviceNotEligible: "unavailable",
146
+ appleIntelligenceNotEnabled: "unavailable",
147
+ unsupportedOS: "unavailable",
148
+ unavailable: "unavailable",
149
+ // Apple Intelligence is on, and its model is still downloading.
150
+ modelNotReady: "downloading"
151
+ }
152
+
153
+ const usageOf = (usage: NativeUsage | undefined): TokenUsage | undefined =>
154
+ usage && {
155
+ ...buildBaseUsage({
156
+ promptTokens: usage.inputTokens,
157
+ completionTokens: usage.outputTokens,
158
+ totalTokens: usage.inputTokens + usage.outputTokens
159
+ }),
160
+ // 0 too: nothing was cached. Left out where the framework can't say.
161
+ ...(usage.cachedTokens !== undefined && { promptTokensDetails: { cachedTokens: usage.cachedTokens } })
162
+ }
163
+
164
+ let ids = 0
165
+ const nextId = (prefix: string) => `${prefix}_${Date.now().toString(36)}_${(ids++).toString(36)}`
166
+
167
+ const signalOf = (options: TextOptions<AfmTextModelOptions>) =>
168
+ options.abortController?.signal ?? (options.request as RequestInit | undefined)?.signal ?? undefined
169
+
170
+ const parse = (json: string): unknown => {
171
+ try {
172
+ return JSON.parse(json)
173
+ } catch {
174
+ return {}
175
+ }
176
+ }
177
+
178
+ export class AfmTextAdapter extends BaseTextAdapter<
179
+ AfmChatModel,
180
+ AfmTextModelOptions,
181
+ readonly ["text"],
182
+ MetadataByModality
183
+ > {
184
+ override readonly kind = "text" as const
185
+ readonly name = "afm" as const
186
+ override readonly provider = "afm" as const
187
+ override readonly api = "afm" as const
188
+ override readonly inputModalities = ["text"] as const
189
+ private readonly native: AfmNative
190
+ private readonly threads = new Map<string, Thread>()
191
+ private readonly queues = new Map<string, Thread>()
192
+ /**
193
+ * Responses waiting for tool results in sessions no thread keeps (prompt
194
+ * cache retention "none"), by thread: the next step resumes them.
195
+ */
196
+ private readonly paused = new Map<string, Thread>()
197
+ private listening: Promise<unknown> | undefined
198
+
199
+ constructor(config: AfmConfig, model: AfmChatModel) {
200
+ super({}, model)
201
+ this.native = config.native ?? (globalThis as { afm?: AfmNative }).afm ?? Afm
202
+ }
203
+
204
+ /** Whether Apple's model can run here: "downloading" while Apple Intelligence gets it. */
205
+ async availability(): Promise<Availability> {
206
+ try {
207
+ const answer = await this.native.availability()
208
+ return answer.available ? "available" : AVAILABILITY[answer.reason ?? "unavailable"]
209
+ } catch {
210
+ return "unavailable"
211
+ }
212
+ }
213
+
214
+ /** Tokens a session holds, where the OS says (26.4 and later). */
215
+ async contextSize(): Promise<number | undefined> {
216
+ try {
217
+ return (await this.native.availability()).contextSize
218
+ } catch {
219
+ return undefined
220
+ }
221
+ }
222
+
223
+ /** Resolves to this adapter if the model can run here, else rejects. */
224
+ async create(options: { signal?: AbortSignal } = {}): Promise<this> {
225
+ options.signal?.throwIfAborted()
226
+ const answer = await this.native.availability()
227
+ options.signal?.throwIfAborted()
228
+ if (!answer.available) throw new Error(`Apple's on-device model is unavailable here (${answer.reason ?? "unavailable"})`)
229
+ return this
230
+ }
231
+
232
+ /**
233
+ * Ends every session: the threads', responses waiting for tool results, and
234
+ * responses streaming now (structured output's, and those of sessions no
235
+ * thread keeps), each once.
236
+ */
237
+ async destroy(): Promise<void> {
238
+ const all = new Set([...this.threads.values(), ...this.paused.values(), ...this.queues.values()])
239
+ for (const thread of all) this.close(thread)
240
+ this.threads.clear()
241
+ this.paused.clear()
242
+ }
243
+
244
+ /** Routes the native events to their thread's response. Listens once. */
245
+ private listen() {
246
+ this.listening ??= this.native.addListener("event", (event) => {
247
+ const thread = this.queues.get(event.sessionId)
248
+ if (thread && event.turn === thread.turn) thread.queue?.push(event)
249
+ })
250
+ return this.listening
251
+ }
252
+
253
+ /** Ends a session. A response still streaming from it ends with an error. */
254
+ private close(thread: Thread) {
255
+ this.queues.delete(thread.sessionId)
256
+ thread.queue?.push({ type: "error", code: "closed", message: "The session was closed", sessionId: thread.sessionId, turn: thread.turn })
257
+ thread.queue = undefined
258
+ void this.native.deleteSession({ sessionId: thread.sessionId }).catch(() => undefined)
259
+ }
260
+
261
+ /** Keeps a thread's session, ending the least recently used past the limit. */
262
+ private keep(key: string, thread: Thread) {
263
+ this.threads.delete(key)
264
+ this.threads.set(key, thread)
265
+ for (const [id, old] of this.threads) {
266
+ if (this.threads.size <= KEPT_SESSIONS) break
267
+ if (old.busy) continue
268
+ this.close(old)
269
+ this.threads.delete(id)
270
+ }
271
+ }
272
+
273
+ /** A new native session for a thread, holding `messages`. */
274
+ private async open(
275
+ options: TextOptions<AfmTextModelOptions>,
276
+ tools: ReadonlyArray<Tool>,
277
+ settings: string,
278
+ messages: Array<NativeMessage>
279
+ ): Promise<Thread> {
280
+ const thread: Thread = {
281
+ sessionId: nextId("session"),
282
+ settings,
283
+ seen: [],
284
+ turn: 0,
285
+ queue: undefined,
286
+ written: "",
287
+ waiting: new Set(),
288
+ busy: true
289
+ }
290
+ const instructions = instructionsOf(options, tools)
291
+ await this.native.createSession({
292
+ sessionId: thread.sessionId,
293
+ ...(instructions && { instructions }),
294
+ tools: tools.map((tool) => ({
295
+ name: tool.name,
296
+ description: tool.description ?? "",
297
+ parameters: toNativeSchema(tool.inputSchema ?? { type: "object", properties: {} }, tool.name)
298
+ })),
299
+ messages
300
+ })
301
+ return thread
302
+ }
303
+
304
+ /**
305
+ * The step's thread, and what it sends: the results of the tool calls its
306
+ * response waits on, a new prompt to its session, or (for a conversation the
307
+ * session doesn't hold) a new session and the last message as its prompt.
308
+ */
309
+ private async begin(options: TextOptions<AfmTextModelOptions>) {
310
+ await this.listen()
311
+ const key = options.promptCache?.key ?? options.threadId
312
+ const retain = options.promptCache?.retention !== "none"
313
+ const tools = offeredTools(options)
314
+ const { temperature, maximumResponseTokens } = options.modelOptions ?? {}
315
+ const settings = JSON.stringify([
316
+ instructionsOf(options, tools),
317
+ tools.map((tool) => [tool.name, tool.description, tool.inputSchema]),
318
+ temperature,
319
+ maximumResponseTokens,
320
+ // A response keeps the mode it started with: a step with another mode
321
+ // (required, then auto) can't resume it, and starts a new session.
322
+ toolCallingModeOf(options)
323
+ ])
324
+ const keys = options.messages.map(messageKey)
325
+ // A kept session, or one whose response waits for tool results.
326
+ const held = (retain ? this.threads.get(key) : undefined) ?? this.paused.get(key)
327
+ const holds = !!held && !held.busy && held.settings === settings && held.seen.every((k, i) => k === keys[i])
328
+
329
+ // The results of the tool calls the session's response waits on.
330
+ if (holds && held.queue && held.waiting.size > 0) {
331
+ const results = options.messages
332
+ .slice(held.seen.length)
333
+ .filter((m) => m.role === "tool" && !!m.toolCallId && held.waiting.has(m.toolCallId))
334
+ if (results.length === held.waiting.size) {
335
+ held.busy = true
336
+ this.paused.delete(key)
337
+ return { key, thread: held, kept: this.threads.get(key) === held, keys, results }
338
+ }
339
+ }
340
+ // A new message to the session's finished response.
341
+ const last = options.messages.at(-1)
342
+ if (holds && !held.queue && keys.length === held.seen.length + 1 && last?.role === "user") {
343
+ held.busy = true
344
+ return { key, thread: held, kept: true, keys, prompt: contentText(last.content) }
345
+ }
346
+ // A new session for the conversation, prompted with its last message.
347
+ if (held && !held.busy) {
348
+ this.close(held)
349
+ if (this.threads.get(key) === held) this.threads.delete(key)
350
+ if (this.paused.get(key) === held) this.paused.delete(key)
351
+ }
352
+ const prompted = last?.role === "user"
353
+ const before = prompted ? options.messages.slice(0, -1) : options.messages
354
+ const thread = await this.open(options, tools, settings, toNativeMessages(before))
355
+ const kept = retain && !this.threads.get(key)?.busy
356
+ if (kept) this.keep(key, thread)
357
+ return { key, thread, kept, keys, prompt: prompted ? contentText(last.content) : "" }
358
+ }
359
+
360
+ async *chatStream(options: TextOptions<AfmTextModelOptions>): AsyncIterable<AdapterYieldChunk> {
361
+ const runId = options.runId ?? nextId("run")
362
+ const { threadId } = options
363
+ const messageId = nextId("msg")
364
+ const model = this.model
365
+ const signal = signalOf(options)
366
+ const timestamp = () => Date.now()
367
+ yield {
368
+ type: EventType.RUN_STARTED,
369
+ runId,
370
+ threadId,
371
+ model,
372
+ timestamp: timestamp(),
373
+ ...(options.parentRunId && { parentRunId: options.parentRunId })
374
+ }
375
+
376
+ let step: Awaited<ReturnType<typeof this.begin>> | undefined
377
+ let paused = false
378
+ try {
379
+ // A stopped request starts no native work (and one stopped meanwhile ends here).
380
+ signal?.throwIfAborted()
381
+ step = await this.begin(options)
382
+ signal?.throwIfAborted()
383
+ const { thread, keys } = step
384
+ if (step.results) {
385
+ for (const result of step.results) {
386
+ const callId = result.toolCallId as string
387
+ thread.waiting.delete(callId)
388
+ await this.native.submitToolResult({ sessionId: thread.sessionId, callId, output: contentText(result.content) })
389
+ }
390
+ } else {
391
+ const { temperature, maximumResponseTokens } = options.modelOptions ?? {}
392
+ thread.turn += 1
393
+ thread.written = ""
394
+ thread.queue = new EventQueue()
395
+ this.queues.set(thread.sessionId, thread)
396
+ await this.native.respond({
397
+ sessionId: thread.sessionId,
398
+ turn: thread.turn,
399
+ prompt: step.prompt ?? "",
400
+ options: {
401
+ ...(temperature !== undefined && { temperature }),
402
+ ...(maximumResponseTokens !== undefined && { maximumResponseTokens }),
403
+ ...(toolCallingModeOf(options) && { toolCallingMode: toolCallingModeOf(options) })
404
+ }
405
+ })
406
+ }
407
+
408
+ // This step's text (its assistant message), and the events that write it.
409
+ let text = ""
410
+ const textEvents = function* (delta: string): Generator<AdapterYieldChunk> {
411
+ if (!delta) return
412
+ if (!text) yield { type: EventType.TEXT_MESSAGE_START, messageId, role: "assistant", model, timestamp: timestamp() }
413
+ text += delta
414
+ thread.written += delta
415
+ yield { type: EventType.TEXT_MESSAGE_CONTENT, messageId, delta, content: text, model, timestamp: timestamp() }
416
+ }
417
+ const endText = function* (): Generator<AdapterYieldChunk> {
418
+ if (text) yield { type: EventType.TEXT_MESSAGE_END, messageId, model, timestamp: timestamp() }
419
+ }
420
+ const queue = thread.queue as EventQueue
421
+ for (;;) {
422
+ const event = await queue.next(signal)
423
+ if (event.type === "delta") {
424
+ yield* textEvents(event.text)
425
+ } else if (event.type === "toolCalls") {
426
+ yield* endText()
427
+ for (const [index, call] of event.calls.entries()) {
428
+ thread.waiting.add(call.id)
429
+ yield {
430
+ type: EventType.TOOL_CALL_START,
431
+ toolCallId: call.id,
432
+ toolCallName: call.name,
433
+ parentMessageId: messageId,
434
+ index,
435
+ model,
436
+ timestamp: timestamp()
437
+ }
438
+ yield { type: EventType.TOOL_CALL_ARGS, toolCallId: call.id, delta: call.arguments, args: call.arguments, model, timestamp: timestamp() }
439
+ yield {
440
+ type: EventType.TOOL_CALL_END,
441
+ toolCallId: call.id,
442
+ toolCallName: call.name,
443
+ args: call.arguments,
444
+ input: parse(call.arguments),
445
+ model,
446
+ timestamp: timestamp()
447
+ }
448
+ }
449
+ // The response waits for the results, which the next step sends.
450
+ thread.seen = keys
451
+ paused = true
452
+ // A session no thread keeps is still found by the step with the results.
453
+ if (!step.kept) this.paused.set(step.key, thread)
454
+ yield { type: EventType.RUN_FINISHED, runId, threadId, model, timestamp: timestamp(), finishReason: "tool_calls" }
455
+ return
456
+ } else if (event.type === "done") {
457
+ // Any of the response not sent as deltas.
458
+ if (event.text.startsWith(thread.written)) yield* textEvents(event.text.slice(thread.written.length))
459
+ yield* endText()
460
+ thread.queue = undefined
461
+ this.queues.delete(thread.sessionId)
462
+ thread.seen = [...keys, messageKey({ role: "assistant", content: text })]
463
+ const usage = usageOf(event.usage)
464
+ yield {
465
+ type: EventType.RUN_FINISHED,
466
+ runId,
467
+ threadId,
468
+ model,
469
+ timestamp: timestamp(),
470
+ finishReason: "stop",
471
+ ...(usage && { usage })
472
+ }
473
+ return
474
+ } else {
475
+ throw Object.assign(new Error(event.message), { code: event.code })
476
+ }
477
+ }
478
+ } catch (error) {
479
+ const aborted = !!signal?.aborted
480
+ if (step) {
481
+ if (aborted) void this.native.cancel({ sessionId: step.thread.sessionId }).catch(() => undefined)
482
+ // A stopped or failed response leaves the session behind: the next step starts a new one.
483
+ this.close(step.thread)
484
+ if (this.threads.get(step.key) === step.thread) this.threads.delete(step.key)
485
+ if (this.paused.get(step.key) === step.thread) this.paused.delete(step.key)
486
+ }
487
+ options.logger.errors("afm chatStream failed", { error, source: "afm.chatStream" })
488
+ const message = error instanceof Error ? error.message : String(error)
489
+ const code = aborted ? "aborted" : (error as { code?: unknown })?.code
490
+ yield {
491
+ type: EventType.RUN_ERROR,
492
+ runId,
493
+ model,
494
+ timestamp: timestamp(),
495
+ message,
496
+ error: { message, ...(typeof code === "string" && { code }) }
497
+ }
498
+ } finally {
499
+ if (step) {
500
+ step.thread.busy = false
501
+ // A session no thread keeps ends with its step, unless its response waits for tools.
502
+ if (!step.kept && !paused) this.close(step.thread)
503
+ }
504
+ }
505
+ }
506
+
507
+ /**
508
+ * A JSON answer for `chat({ outputSchema })`: a session holding the
509
+ * conversation answers its last message, held to the schema by the
510
+ * framework. `chat()` validates the result.
511
+ */
512
+ async structuredOutput(
513
+ options: StructuredOutputOptions<AfmTextModelOptions>
514
+ ): Promise<StructuredOutputResult<unknown>> {
515
+ const { chatOptions, outputSchema } = options
516
+ const signal = signalOf(chatOptions)
517
+ signal?.throwIfAborted()
518
+ await this.listen()
519
+ const last = chatOptions.messages.at(-1)
520
+ const prompted = last?.role === "user"
521
+ const before = prompted ? chatOptions.messages.slice(0, -1) : chatOptions.messages
522
+ const thread = await this.open(chatOptions, [], "", toNativeMessages(before))
523
+ try {
524
+ thread.turn = 1
525
+ thread.queue = new EventQueue()
526
+ this.queues.set(thread.sessionId, thread)
527
+ const { temperature, maximumResponseTokens } = chatOptions.modelOptions ?? {}
528
+ await this.native.respond({
529
+ sessionId: thread.sessionId,
530
+ turn: 1,
531
+ prompt: prompted ? contentText(last.content) : "",
532
+ schema: toNativeSchema(outputSchema, "Answer"),
533
+ options: {
534
+ ...(temperature !== undefined && { temperature }),
535
+ ...(maximumResponseTokens !== undefined && { maximumResponseTokens })
536
+ }
537
+ })
538
+ for (;;) {
539
+ const event = await thread.queue.next(signal)
540
+ if (event.type === "done") {
541
+ const usage = usageOf(event.usage)
542
+ return { data: JSON.parse(event.text) as unknown, rawText: event.text, model: this.model, ...(usage && { usage }) }
543
+ }
544
+ if (event.type === "error") throw Object.assign(new Error(event.message), { code: event.code })
545
+ }
546
+ } finally {
547
+ this.close(thread)
548
+ }
549
+ }
550
+ }
551
+
552
+ /**
553
+ * A TanStack AI text adapter for Apple's on-device model, through the
554
+ * Foundation Models framework (the Capacitor plugin on iOS, by default).
555
+ * The model is Apple's system model ("system"), so naming it is optional.
556
+ *
557
+ * @example
558
+ * ```ts
559
+ * const adapter = afmText()
560
+ * if ((await adapter.availability()) === "available") {
561
+ * chat({ adapter, messages, tools })
562
+ * }
563
+ * ```
564
+ */
565
+ export function afmText(config?: AfmConfig): AfmTextAdapter
566
+ export function afmText(model: AfmChatModel, config?: AfmConfig): AfmTextAdapter
567
+ export function afmText(
568
+ modelOrConfig?: AfmChatModel | AfmConfig,
569
+ config: AfmConfig = {}
570
+ ): AfmTextAdapter {
571
+ return typeof modelOrConfig === "string"
572
+ ? new AfmTextAdapter(config, modelOrConfig)
573
+ : new AfmTextAdapter(modelOrConfig ?? {}, "system")
574
+ }