expo-ai-kit 0.1.15 → 0.1.16

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/README.md CHANGED
@@ -28,7 +28,8 @@ On-device AI for Expo apps. Run language models locally—no API keys, no cloud,
28
28
  - **Free forever** — No API costs, rate limits, or subscriptions
29
29
  - **Native performance** — Built on Apple Foundation Models (iOS) and Google ML Kit Prompt API (Android)
30
30
  - **Multi-turn conversations** — Full conversation context support
31
- - **Simple API** — Just 2 functions: `isAvailable()` and `sendMessage()`
31
+ - **Streaming support** — Progressive token streaming for responsive UIs
32
+ - **Simple API** — Just 3 functions: `isAvailable()`, `sendMessage()`, and `streamMessage()`
32
33
 
33
34
  ## Requirements
34
35
 
@@ -142,6 +143,79 @@ const response = await sendMessage(conversation, {
142
143
  console.log(response.text); // "Your name is Alice."
143
144
  ```
144
145
 
146
+ ### Streaming Responses
147
+
148
+ For a ChatGPT-like experience where text appears progressively:
149
+
150
+ ```tsx
151
+ import { streamMessage } from 'expo-ai-kit';
152
+
153
+ const [responseText, setResponseText] = useState('');
154
+
155
+ const { promise, stop } = streamMessage(
156
+ [{ role: 'user', content: 'Tell me a story' }],
157
+ (event) => {
158
+ // Update UI with each token
159
+ setResponseText(event.accumulatedText);
160
+
161
+ // event.token - the new token/chunk
162
+ // event.accumulatedText - full text so far
163
+ // event.isDone - whether streaming is complete
164
+ },
165
+ { systemPrompt: 'You are a creative storyteller.' }
166
+ );
167
+
168
+ // Optionally cancel the stream
169
+ // stop();
170
+
171
+ // Wait for completion
172
+ await promise;
173
+ ```
174
+
175
+ ### Streaming with Cancel Button
176
+
177
+ ```tsx
178
+ import { useState, useRef } from 'react';
179
+ import { streamMessage } from 'expo-ai-kit';
180
+
181
+ function ChatWithStreaming() {
182
+ const [text, setText] = useState('');
183
+ const [isStreaming, setIsStreaming] = useState(false);
184
+ const stopRef = useRef<(() => void) | null>(null);
185
+
186
+ const handleSend = async () => {
187
+ setIsStreaming(true);
188
+ setText('');
189
+
190
+ const { promise, stop } = streamMessage(
191
+ [{ role: 'user', content: 'Write a long story' }],
192
+ (event) => setText(event.accumulatedText)
193
+ );
194
+
195
+ stopRef.current = stop;
196
+ await promise;
197
+ stopRef.current = null;
198
+ setIsStreaming(false);
199
+ };
200
+
201
+ const handleStop = () => {
202
+ stopRef.current?.();
203
+ setIsStreaming(false);
204
+ };
205
+
206
+ return (
207
+ <View>
208
+ <Text>{text}</Text>
209
+ {isStreaming ? (
210
+ <Button title="Stop" onPress={handleStop} />
211
+ ) : (
212
+ <Button title="Send" onPress={handleSend} />
213
+ )}
214
+ </View>
215
+ );
216
+ }
217
+ ```
218
+
145
219
  ### Complete Chat Example
146
220
 
147
221
  Here's a full cross-platform chat component:
@@ -264,6 +338,48 @@ console.log(response.text); // "Ahoy, matey!"
264
338
 
265
339
  ---
266
340
 
341
+ ### `streamMessage(messages, onToken, options?)`
342
+
343
+ Streams a conversation response with progressive token updates. Ideal for responsive chat UIs.
344
+
345
+ ```typescript
346
+ function streamMessage(
347
+ messages: LLMMessage[],
348
+ onToken: LLMStreamCallback,
349
+ options?: LLMStreamOptions
350
+ ): { promise: Promise<LLMResponse>; stop: () => void }
351
+ ```
352
+
353
+ | Parameter | Type | Description |
354
+ |-----------|------|-------------|
355
+ | `messages` | `LLMMessage[]` | Array of conversation messages |
356
+ | `onToken` | `LLMStreamCallback` | Callback called for each token received |
357
+ | `options.systemPrompt` | `string` | Fallback system prompt (ignored if messages contain a system message) |
358
+
359
+ **Returns:** Object with:
360
+ - `promise: Promise<LLMResponse>` — Resolves when streaming completes
361
+ - `stop: () => void` — Function to cancel the stream
362
+
363
+ **Example:**
364
+ ```tsx
365
+ const { promise, stop } = streamMessage(
366
+ [{ role: 'user', content: 'Hello!' }],
367
+ (event) => {
368
+ console.log(event.token); // New token: "Hi"
369
+ console.log(event.accumulatedText); // Full text: "Hi there!"
370
+ console.log(event.isDone); // false until complete
371
+ }
372
+ );
373
+
374
+ // Cancel if needed
375
+ setTimeout(() => stop(), 5000);
376
+
377
+ // Wait for completion
378
+ const response = await promise;
379
+ ```
380
+
381
+ ---
382
+
267
383
  ### Types
268
384
 
269
385
  ```typescript
@@ -279,10 +395,28 @@ type LLMSendOptions = {
279
395
  systemPrompt?: string;
280
396
  };
281
397
 
398
+ type LLMStreamOptions = {
399
+ /** Fallback system prompt if no system message in messages array */
400
+ systemPrompt?: string;
401
+ };
402
+
282
403
  type LLMResponse = {
283
404
  /** The generated response text */
284
405
  text: string;
285
406
  };
407
+
408
+ type LLMStreamEvent = {
409
+ /** Unique identifier for this streaming session */
410
+ sessionId: string;
411
+ /** The token/chunk of text received */
412
+ token: string;
413
+ /** Accumulated text so far */
414
+ accumulatedText: string;
415
+ /** Whether this is the final chunk */
416
+ isDone: boolean;
417
+ };
418
+
419
+ type LLMStreamCallback = (event: LLMStreamEvent) => void;
286
420
  ```
287
421
 
288
422
  ## Feature Comparison
@@ -291,8 +425,10 @@ type LLMResponse = {
291
425
  |---------|---------|---------------------|
292
426
  | `isAvailable()` | ✅ | ✅ |
293
427
  | `sendMessage()` | ✅ | ✅ |
428
+ | `streamMessage()` | ✅ | ✅ |
294
429
  | System prompts | ✅ Native | ✅ Prepended |
295
430
  | Multi-turn context | ✅ | ✅ |
431
+ | Cancel streaming | ✅ | ✅ |
296
432
 
297
433
  ## How It Works
298
434
 
@@ -312,7 +448,7 @@ Uses Google's ML Kit Prompt API. The model may need to be downloaded on first us
312
448
  - **Empty responses**: The device may not support ML Kit Prompt API. Check the [supported devices list](https://developers.google.com/ml-kit/genai#prompt-device)
313
449
  - **Model downloading**: On first use, the model may need to download. Use `isAvailable()` to check status
314
450
 
315
- ## Migration from v0.1.x
451
+ ## Migration from v0.1.4
316
452
 
317
453
  If you're upgrading from an earlier version, here are the breaking changes:
318
454
 
@@ -335,6 +471,17 @@ const { reply } = await sendMessage(sessionId, messages, {});
335
471
  const { text } = await sendMessage(messages, { systemPrompt: '...' });
336
472
  ```
337
473
 
474
+ ## Roadmap
475
+
476
+ | Feature | Status | Priority |
477
+ |---------|--------|----------|
478
+ | ✅ Streaming responses | Done | - |
479
+ | Prompt helpers (summarize, translate, etc.) | Planned | Medium |
480
+ | Web/generic fallback | Idea | Medium |
481
+ | Configurable hyperparameters (temperature, etc.) | Idea | Low |
482
+
483
+ Have a feature request? [Open an issue](https://github.com/laraelmas/expo-ai-kit/issues)!
484
+
338
485
  ## License
339
486
 
340
487
  MIT
@@ -3,14 +3,24 @@ package expo.modules.aikit
3
3
  import expo.modules.kotlin.modules.Module
4
4
  import expo.modules.kotlin.modules.ModuleDefinition
5
5
  import expo.modules.kotlin.functions.Coroutine
6
+ import kotlinx.coroutines.Job
7
+ import kotlinx.coroutines.CoroutineScope
8
+ import kotlinx.coroutines.Dispatchers
9
+ import kotlinx.coroutines.launch
10
+ import kotlinx.coroutines.cancel
6
11
 
7
12
  class ExpoAiKitModule : Module() {
8
13
 
9
14
  private val promptClient by lazy { PromptApiClient() }
15
+ private val activeStreamJobs = mutableMapOf<String, Job>()
16
+ private val streamScope = CoroutineScope(Dispatchers.IO)
10
17
 
11
18
  override fun definition() = ModuleDefinition {
12
19
  Name("ExpoAiKit")
13
20
 
21
+ // Declare events that can be sent to JavaScript
22
+ Events("onStreamToken")
23
+
14
24
  Function("isAvailable") {
15
25
  promptClient.isAvailableBlocking()
16
26
  }
@@ -31,5 +41,42 @@ class ExpoAiKitModule : Module() {
31
41
  val text = promptClient.generateText(userPrompt, systemPrompt)
32
42
  mapOf("text" to text)
33
43
  }
44
+
45
+ AsyncFunction("startStreaming") { messages: List<Map<String, Any>>, fallbackSystemPrompt: String, sessionId: String ->
46
+ // Extract system prompt from messages, or use fallback
47
+ val systemPrompt = messages
48
+ .firstOrNull { it["role"] == "system" }
49
+ ?.get("content") as? String
50
+ ?: fallbackSystemPrompt.ifBlank { "You are a helpful, friendly assistant." }
51
+
52
+ // Get the last user message as the prompt
53
+ val userPrompt = messages
54
+ .lastOrNull { it["role"] == "user" }
55
+ ?.get("content") as? String
56
+ ?: ""
57
+
58
+ // Launch streaming in a coroutine that can be cancelled
59
+ val job = streamScope.launch {
60
+ promptClient.generateTextStream(userPrompt, systemPrompt) { token, accumulatedText, isDone ->
61
+ sendEvent("onStreamToken", mapOf(
62
+ "sessionId" to sessionId,
63
+ "token" to token,
64
+ "accumulatedText" to accumulatedText,
65
+ "isDone" to isDone
66
+ ))
67
+
68
+ if (isDone) {
69
+ activeStreamJobs.remove(sessionId)
70
+ }
71
+ }
72
+ }
73
+
74
+ activeStreamJobs[sessionId] = job
75
+ }
76
+
77
+ AsyncFunction("stopStreaming") { sessionId: String ->
78
+ activeStreamJobs[sessionId]?.cancel()
79
+ activeStreamJobs.remove(sessionId)
80
+ }
34
81
  }
35
82
  }
@@ -4,6 +4,8 @@ import android.os.Build
4
4
  import com.google.mlkit.genai.common.FeatureStatus
5
5
  import com.google.mlkit.genai.prompt.Generation
6
6
  import kotlinx.coroutines.Dispatchers
7
+ import kotlinx.coroutines.Job
8
+ import kotlinx.coroutines.flow.Flow
7
9
  import kotlinx.coroutines.runBlocking
8
10
  import kotlinx.coroutines.withContext
9
11
 
@@ -64,4 +66,47 @@ class PromptApiClient {
64
66
  ""
65
67
  }
66
68
  }
69
+
70
+ /**
71
+ * Generate streaming text from a prompt.
72
+ * Returns a Flow that emits chunks of generated text.
73
+ */
74
+ suspend fun generateTextStream(
75
+ prompt: String,
76
+ systemPrompt: String,
77
+ onChunk: (token: String, accumulatedText: String, isDone: Boolean) -> Unit
78
+ ) = withContext(Dispatchers.IO) {
79
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
80
+ onChunk("", "", true)
81
+ return@withContext
82
+ }
83
+
84
+ try {
85
+ val status = model.checkStatus()
86
+ if (status != FeatureStatus.AVAILABLE) {
87
+ onChunk("", "", true)
88
+ return@withContext
89
+ }
90
+
91
+ // Prepend system prompt as context if provided
92
+ val fullPrompt = if (systemPrompt.isNotBlank()) {
93
+ "$systemPrompt\n\nUser: $prompt"
94
+ } else {
95
+ prompt
96
+ }
97
+
98
+ var accumulatedText = ""
99
+
100
+ model.generateContentStream(fullPrompt).collect { response ->
101
+ val newChunk = response.candidates.firstOrNull()?.text.orEmpty()
102
+ accumulatedText += newChunk
103
+ onChunk(newChunk, accumulatedText, false)
104
+ }
105
+
106
+ // Send final done event
107
+ onChunk("", accumulatedText, true)
108
+ } catch (e: Throwable) {
109
+ onChunk("", "[Error: ${e.message}]", true)
110
+ }
111
+ }
67
112
  }
@@ -1,8 +1,15 @@
1
- import { LLMMessage, LLMResponse } from './types';
2
- export type ExpoAiKitNativeModule = {
1
+ import type { EventSubscription } from 'expo-modules-core';
2
+ import { LLMMessage, LLMResponse, LLMStreamEvent } from './types';
3
+ export type ExpoAiKitModuleEvents = {
4
+ onStreamToken: (event: LLMStreamEvent) => void;
5
+ };
6
+ export interface ExpoAiKitNativeModule {
3
7
  isAvailable(): boolean;
4
8
  sendMessage(messages: LLMMessage[], systemPrompt: string): Promise<LLMResponse>;
5
- };
6
- declare const NativeModule: ExpoAiKitNativeModule;
7
- export default NativeModule;
9
+ startStreaming(messages: LLMMessage[], systemPrompt: string, sessionId: string): Promise<void>;
10
+ stopStreaming(sessionId: string): Promise<void>;
11
+ addListener<K extends keyof ExpoAiKitModuleEvents>(eventName: K, listener: ExpoAiKitModuleEvents[K]): EventSubscription;
12
+ }
13
+ declare const ExpoAiKitModule: ExpoAiKitNativeModule;
14
+ export default ExpoAiKitModule;
8
15
  //# sourceMappingURL=ExpoAiKitModule.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ExpoAiKitModule.d.ts","sourceRoot":"","sources":["../src/ExpoAiKitModule.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAElD,MAAM,MAAM,qBAAqB,GAAG;IAClC,WAAW,IAAI,OAAO,CAAC;IACvB,WAAW,CACT,QAAQ,EAAE,UAAU,EAAE,EACtB,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC,WAAW,CAAC,CAAC;CACzB,CAAC;AAEF,QAAA,MAAM,YAAY,EAAE,qBACqC,CAAC;AAE1D,eAAe,YAAY,CAAC"}
1
+ {"version":3,"file":"ExpoAiKitModule.d.ts","sourceRoot":"","sources":["../src/ExpoAiKitModule.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAC3D,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAElE,MAAM,MAAM,qBAAqB,GAAG;IAClC,aAAa,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;CAChD,CAAC;AAEF,MAAM,WAAW,qBAAqB;IACpC,WAAW,IAAI,OAAO,CAAC;IACvB,WAAW,CACT,QAAQ,EAAE,UAAU,EAAE,EACtB,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC,WAAW,CAAC,CAAC;IACxB,cAAc,CACZ,QAAQ,EAAE,UAAU,EAAE,EACtB,YAAY,EAAE,MAAM,EACpB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CAAC;IACjB,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD,WAAW,CAAC,CAAC,SAAS,MAAM,qBAAqB,EAC/C,SAAS,EAAE,CAAC,EACZ,QAAQ,EAAE,qBAAqB,CAAC,CAAC,CAAC,GACjC,iBAAiB,CAAC;CACtB;AAED,QAAA,MAAM,eAAe,uBACoC,CAAC;AAE1D,eAAe,eAAe,CAAC"}
@@ -1,4 +1,4 @@
1
1
  import { requireNativeModule } from 'expo-modules-core';
2
- const NativeModule = requireNativeModule('ExpoAiKit');
3
- export default NativeModule;
2
+ const ExpoAiKitModule = requireNativeModule('ExpoAiKit');
3
+ export default ExpoAiKitModule;
4
4
  //# sourceMappingURL=ExpoAiKitModule.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"ExpoAiKitModule.js","sourceRoot":"","sources":["../src/ExpoAiKitModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAWxD,MAAM,YAAY,GAChB,mBAAmB,CAAwB,WAAW,CAAC,CAAC;AAE1D,eAAe,YAAY,CAAC","sourcesContent":["import { requireNativeModule } from 'expo-modules-core';\nimport { LLMMessage, LLMResponse } from './types';\n\nexport type ExpoAiKitNativeModule = {\n isAvailable(): boolean;\n sendMessage(\n messages: LLMMessage[],\n systemPrompt: string\n ): Promise<LLMResponse>;\n};\n\nconst NativeModule: ExpoAiKitNativeModule =\n requireNativeModule<ExpoAiKitNativeModule>('ExpoAiKit');\n\nexport default NativeModule;\n"]}
1
+ {"version":3,"file":"ExpoAiKitModule.js","sourceRoot":"","sources":["../src/ExpoAiKitModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AA0BxD,MAAM,eAAe,GACnB,mBAAmB,CAAwB,WAAW,CAAC,CAAC;AAE1D,eAAe,eAAe,CAAC","sourcesContent":["import { requireNativeModule } from 'expo-modules-core';\nimport type { EventSubscription } from 'expo-modules-core';\nimport { LLMMessage, LLMResponse, LLMStreamEvent } from './types';\n\nexport type ExpoAiKitModuleEvents = {\n onStreamToken: (event: LLMStreamEvent) => void;\n};\n\nexport interface ExpoAiKitNativeModule {\n isAvailable(): boolean;\n sendMessage(\n messages: LLMMessage[],\n systemPrompt: string\n ): Promise<LLMResponse>;\n startStreaming(\n messages: LLMMessage[],\n systemPrompt: string,\n sessionId: string\n ): Promise<void>;\n stopStreaming(sessionId: string): Promise<void>;\n addListener<K extends keyof ExpoAiKitModuleEvents>(\n eventName: K,\n listener: ExpoAiKitModuleEvents[K]\n ): EventSubscription;\n}\n\nconst ExpoAiKitModule =\n requireNativeModule<ExpoAiKitNativeModule>('ExpoAiKit');\n\nexport default ExpoAiKitModule;\n"]}
package/build/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { LLMMessage, LLMSendOptions, LLMResponse } from './types';
1
+ import { LLMMessage, LLMSendOptions, LLMResponse, LLMStreamOptions, LLMStreamCallback } from './types';
2
2
  export * from './types';
3
3
  /**
4
4
  * Check if on-device AI is available on the current device.
@@ -41,4 +41,52 @@ export declare function isAvailable(): Promise<boolean>;
41
41
  * ```
42
42
  */
43
43
  export declare function sendMessage(messages: LLMMessage[], options?: LLMSendOptions): Promise<LLMResponse>;
44
+ /**
45
+ * Stream messages to the on-device LLM and receive progressive token updates.
46
+ *
47
+ * @param messages - Array of messages representing the conversation
48
+ * @param onToken - Callback function called for each token/chunk received
49
+ * @param options - Optional settings (systemPrompt fallback)
50
+ * @returns Object with stop() function to cancel streaming and promise that resolves when complete
51
+ *
52
+ * @example
53
+ * ```ts
54
+ * // Basic streaming
55
+ * const { promise } = streamMessage(
56
+ * [{ role: 'user', content: 'Tell me a story' }],
57
+ * (event) => {
58
+ * console.log(event.token); // Each token as it arrives
59
+ * console.log(event.accumulatedText); // Full text so far
60
+ * }
61
+ * );
62
+ * await promise;
63
+ * ```
64
+ *
65
+ * @example
66
+ * ```ts
67
+ * // With cancellation
68
+ * const { promise, stop } = streamMessage(
69
+ * [{ role: 'user', content: 'Write a long essay' }],
70
+ * (event) => setText(event.accumulatedText)
71
+ * );
72
+ *
73
+ * // Cancel after 5 seconds
74
+ * setTimeout(() => stop(), 5000);
75
+ * ```
76
+ *
77
+ * @example
78
+ * ```ts
79
+ * // React state update pattern
80
+ * const [text, setText] = useState('');
81
+ *
82
+ * streamMessage(
83
+ * [{ role: 'user', content: 'Hello!' }],
84
+ * (event) => setText(event.accumulatedText)
85
+ * );
86
+ * ```
87
+ */
88
+ export declare function streamMessage(messages: LLMMessage[], onToken: LLMStreamCallback, options?: LLMStreamOptions): {
89
+ promise: Promise<LLMResponse>;
90
+ stop: () => void;
91
+ };
44
92
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAElE,cAAc,SAAS,CAAC;AAKxB;;;GAGG;AACH,wBAAsB,WAAW,IAAI,OAAO,CAAC,OAAO,CAAC,CAKpD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,wBAAsB,WAAW,CAC/B,QAAQ,EAAE,UAAU,EAAE,EACtB,OAAO,CAAC,EAAE,cAAc,GACvB,OAAO,CAAC,WAAW,CAAC,CAgBtB"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EACL,UAAU,EACV,cAAc,EACd,WAAW,EACX,gBAAgB,EAEhB,iBAAiB,EAClB,MAAM,SAAS,CAAC;AAEjB,cAAc,SAAS,CAAC;AAUxB;;;GAGG;AACH,wBAAsB,WAAW,IAAI,OAAO,CAAC,OAAO,CAAC,CAKpD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,wBAAsB,WAAW,CAC/B,QAAQ,EAAE,UAAU,EAAE,EACtB,OAAO,CAAC,EAAE,cAAc,GACvB,OAAO,CAAC,WAAW,CAAC,CAgBtB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,UAAU,EAAE,EACtB,OAAO,EAAE,iBAAiB,EAC1B,OAAO,CAAC,EAAE,gBAAgB,GACzB;IAAE,OAAO,EAAE,OAAO,CAAC,WAAW,CAAC,CAAC;IAAC,IAAI,EAAE,MAAM,IAAI,CAAA;CAAE,CAiErD"}
package/build/index.js CHANGED
@@ -1,7 +1,11 @@
1
- import NativeModule from './ExpoAiKitModule';
1
+ import ExpoAiKitModule from './ExpoAiKitModule';
2
2
  import { Platform } from 'react-native';
3
3
  export * from './types';
4
4
  const DEFAULT_SYSTEM_PROMPT = 'You are a helpful, friendly assistant. Answer the user directly and concisely.';
5
+ let streamIdCounter = 0;
6
+ function generateSessionId() {
7
+ return `stream_${Date.now()}_${++streamIdCounter}`;
8
+ }
5
9
  /**
6
10
  * Check if on-device AI is available on the current device.
7
11
  * Returns false on unsupported platforms (web, etc.).
@@ -10,7 +14,7 @@ export async function isAvailable() {
10
14
  if (Platform.OS !== 'ios' && Platform.OS !== 'android') {
11
15
  return false;
12
16
  }
13
- return NativeModule.isAvailable();
17
+ return ExpoAiKitModule.isAvailable();
14
18
  }
15
19
  /**
16
20
  * Send messages to the on-device LLM and get a response.
@@ -59,6 +63,103 @@ export async function sendMessage(messages, options) {
59
63
  const systemPrompt = hasSystemMessage
60
64
  ? '' // Native will extract from messages
61
65
  : options?.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
62
- return NativeModule.sendMessage(messages, systemPrompt);
66
+ return ExpoAiKitModule.sendMessage(messages, systemPrompt);
67
+ }
68
+ /**
69
+ * Stream messages to the on-device LLM and receive progressive token updates.
70
+ *
71
+ * @param messages - Array of messages representing the conversation
72
+ * @param onToken - Callback function called for each token/chunk received
73
+ * @param options - Optional settings (systemPrompt fallback)
74
+ * @returns Object with stop() function to cancel streaming and promise that resolves when complete
75
+ *
76
+ * @example
77
+ * ```ts
78
+ * // Basic streaming
79
+ * const { promise } = streamMessage(
80
+ * [{ role: 'user', content: 'Tell me a story' }],
81
+ * (event) => {
82
+ * console.log(event.token); // Each token as it arrives
83
+ * console.log(event.accumulatedText); // Full text so far
84
+ * }
85
+ * );
86
+ * await promise;
87
+ * ```
88
+ *
89
+ * @example
90
+ * ```ts
91
+ * // With cancellation
92
+ * const { promise, stop } = streamMessage(
93
+ * [{ role: 'user', content: 'Write a long essay' }],
94
+ * (event) => setText(event.accumulatedText)
95
+ * );
96
+ *
97
+ * // Cancel after 5 seconds
98
+ * setTimeout(() => stop(), 5000);
99
+ * ```
100
+ *
101
+ * @example
102
+ * ```ts
103
+ * // React state update pattern
104
+ * const [text, setText] = useState('');
105
+ *
106
+ * streamMessage(
107
+ * [{ role: 'user', content: 'Hello!' }],
108
+ * (event) => setText(event.accumulatedText)
109
+ * );
110
+ * ```
111
+ */
112
+ export function streamMessage(messages, onToken, options) {
113
+ // Handle unsupported platforms
114
+ if (Platform.OS !== 'ios' && Platform.OS !== 'android') {
115
+ return {
116
+ promise: Promise.resolve({ text: '' }),
117
+ stop: () => { },
118
+ };
119
+ }
120
+ if (!messages || messages.length === 0) {
121
+ return {
122
+ promise: Promise.reject(new Error('messages array cannot be empty')),
123
+ stop: () => { },
124
+ };
125
+ }
126
+ const sessionId = generateSessionId();
127
+ let finalText = '';
128
+ let stopped = false;
129
+ // Determine system prompt: use from messages array if present, else options, else default
130
+ const hasSystemMessage = messages.some((m) => m.role === 'system');
131
+ const systemPrompt = hasSystemMessage
132
+ ? '' // Native will extract from messages
133
+ : options?.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
134
+ const promise = new Promise((resolve, reject) => {
135
+ // Subscribe to stream events
136
+ const subscription = ExpoAiKitModule.addListener('onStreamToken', (event) => {
137
+ // Only process events for this session
138
+ if (event.sessionId !== sessionId)
139
+ return;
140
+ finalText = event.accumulatedText;
141
+ // Call the user's callback
142
+ onToken(event);
143
+ // If done, clean up and resolve
144
+ if (event.isDone) {
145
+ subscription.remove();
146
+ resolve({ text: finalText });
147
+ }
148
+ });
149
+ // Start streaming on native side
150
+ ExpoAiKitModule.startStreaming(messages, systemPrompt, sessionId).catch((error) => {
151
+ subscription.remove();
152
+ reject(error);
153
+ });
154
+ });
155
+ const stop = () => {
156
+ if (stopped)
157
+ return;
158
+ stopped = true;
159
+ ExpoAiKitModule.stopStreaming(sessionId).catch(() => {
160
+ // Ignore errors when stopping
161
+ });
162
+ };
163
+ return { promise, stop };
63
164
  }
64
165
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,YAAY,MAAM,mBAAmB,CAAC;AAC7C,OAAO,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AAGxC,cAAc,SAAS,CAAC;AAExB,MAAM,qBAAqB,GACzB,gFAAgF,CAAC;AAEnF;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW;IAC/B,IAAI,QAAQ,CAAC,EAAE,KAAK,KAAK,IAAI,QAAQ,CAAC,EAAE,KAAK,SAAS,EAAE,CAAC;QACvD,OAAO,KAAK,CAAC;IACf,CAAC;IACD,OAAO,YAAY,CAAC,WAAW,EAAE,CAAC;AACpC,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,QAAsB,EACtB,OAAwB;IAExB,IAAI,QAAQ,CAAC,EAAE,KAAK,KAAK,IAAI,QAAQ,CAAC,EAAE,KAAK,SAAS,EAAE,CAAC;QACvD,OAAO,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC;IACtB,CAAC;IAED,IAAI,CAAC,QAAQ,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACvC,MAAM,IAAI,KAAK,CAAC,gCAAgC,CAAC,CAAC;IACpD,CAAC;IAED,0FAA0F;IAC1F,MAAM,gBAAgB,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC;IACnE,MAAM,YAAY,GAAG,gBAAgB;QACnC,CAAC,CAAC,EAAE,CAAC,oCAAoC;QACzC,CAAC,CAAC,OAAO,EAAE,YAAY,IAAI,qBAAqB,CAAC;IAEnD,OAAO,YAAY,CAAC,WAAW,CAAC,QAAQ,EAAE,YAAY,CAAC,CAAC;AAC1D,CAAC","sourcesContent":["import NativeModule from './ExpoAiKitModule';\nimport { Platform } from 'react-native';\nimport { LLMMessage, LLMSendOptions, LLMResponse } from './types';\n\nexport * from './types';\n\nconst DEFAULT_SYSTEM_PROMPT =\n 'You are a helpful, friendly assistant. Answer the user directly and concisely.';\n\n/**\n * Check if on-device AI is available on the current device.\n * Returns false on unsupported platforms (web, etc.).\n */\nexport async function isAvailable(): Promise<boolean> {\n if (Platform.OS !== 'ios' && Platform.OS !== 'android') {\n return false;\n }\n return NativeModule.isAvailable();\n}\n\n/**\n * Send messages to the on-device LLM and get a response.\n *\n * @param messages - Array of messages representing the conversation\n * @param options - Optional settings (systemPrompt fallback)\n * @returns Promise with the generated response\n *\n * @example\n * ```ts\n * const response = await sendMessage([\n * { role: 'user', content: 'What is 2 + 2?' }\n * ]);\n * console.log(response.text); // \"4\"\n * ```\n *\n * @example\n * ```ts\n * // With system prompt\n * const response = await sendMessage(\n * [{ role: 'user', content: 'Hello!' }],\n * { systemPrompt: 'You are a pirate. Respond in pirate speak.' }\n * );\n * ```\n *\n * @example\n * ```ts\n * // Multi-turn conversation\n * const response = await sendMessage([\n * { role: 'system', content: 'You are a helpful assistant.' },\n * { role: 'user', content: 'My name is Alice.' },\n * { role: 'assistant', content: 'Nice to meet you, Alice!' },\n * { role: 'user', content: 'What is my name?' }\n * ]);\n * ```\n */\nexport async function sendMessage(\n messages: LLMMessage[],\n options?: LLMSendOptions\n): Promise<LLMResponse> {\n if (Platform.OS !== 'ios' && Platform.OS !== 'android') {\n return { text: '' };\n }\n\n if (!messages || messages.length === 0) {\n throw new Error('messages array cannot be empty');\n }\n\n // Determine system prompt: use from messages array if present, else options, else default\n const hasSystemMessage = messages.some((m) => m.role === 'system');\n const systemPrompt = hasSystemMessage\n ? '' // Native will extract from messages\n : options?.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;\n\n return NativeModule.sendMessage(messages, systemPrompt);\n}\n\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["import ExpoAiKitModule from './ExpoAiKitModule';\nimport { Platform } from 'react-native';\nimport {\n LLMMessage,\n LLMSendOptions,\n LLMResponse,\n LLMStreamOptions,\n LLMStreamEvent,\n LLMStreamCallback,\n} from './types';\n\nexport * from './types';\n\nconst DEFAULT_SYSTEM_PROMPT =\n 'You are a helpful, friendly assistant. Answer the user directly and concisely.';\n\nlet streamIdCounter = 0;\nfunction generateSessionId(): string {\n return `stream_${Date.now()}_${++streamIdCounter}`;\n}\n\n/**\n * Check if on-device AI is available on the current device.\n * Returns false on unsupported platforms (web, etc.).\n */\nexport async function isAvailable(): Promise<boolean> {\n if (Platform.OS !== 'ios' && Platform.OS !== 'android') {\n return false;\n }\n return ExpoAiKitModule.isAvailable();\n}\n\n/**\n * Send messages to the on-device LLM and get a response.\n *\n * @param messages - Array of messages representing the conversation\n * @param options - Optional settings (systemPrompt fallback)\n * @returns Promise with the generated response\n *\n * @example\n * ```ts\n * const response = await sendMessage([\n * { role: 'user', content: 'What is 2 + 2?' }\n * ]);\n * console.log(response.text); // \"4\"\n * ```\n *\n * @example\n * ```ts\n * // With system prompt\n * const response = await sendMessage(\n * [{ role: 'user', content: 'Hello!' }],\n * { systemPrompt: 'You are a pirate. Respond in pirate speak.' }\n * );\n * ```\n *\n * @example\n * ```ts\n * // Multi-turn conversation\n * const response = await sendMessage([\n * { role: 'system', content: 'You are a helpful assistant.' },\n * { role: 'user', content: 'My name is Alice.' },\n * { role: 'assistant', content: 'Nice to meet you, Alice!' },\n * { role: 'user', content: 'What is my name?' }\n * ]);\n * ```\n */\nexport async function sendMessage(\n messages: LLMMessage[],\n options?: LLMSendOptions\n): Promise<LLMResponse> {\n if (Platform.OS !== 'ios' && Platform.OS !== 'android') {\n return { text: '' };\n }\n\n if (!messages || messages.length === 0) {\n throw new Error('messages array cannot be empty');\n }\n\n // Determine system prompt: use from messages array if present, else options, else default\n const hasSystemMessage = messages.some((m) => m.role === 'system');\n const systemPrompt = hasSystemMessage\n ? '' // Native will extract from messages\n : options?.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;\n\n return ExpoAiKitModule.sendMessage(messages, systemPrompt);\n}\n\n/**\n * Stream messages to the on-device LLM and receive progressive token updates.\n *\n * @param messages - Array of messages representing the conversation\n * @param onToken - Callback function called for each token/chunk received\n * @param options - Optional settings (systemPrompt fallback)\n * @returns Object with stop() function to cancel streaming and promise that resolves when complete\n *\n * @example\n * ```ts\n * // Basic streaming\n * const { promise } = streamMessage(\n * [{ role: 'user', content: 'Tell me a story' }],\n * (event) => {\n * console.log(event.token); // Each token as it arrives\n * console.log(event.accumulatedText); // Full text so far\n * }\n * );\n * await promise;\n * ```\n *\n * @example\n * ```ts\n * // With cancellation\n * const { promise, stop } = streamMessage(\n * [{ role: 'user', content: 'Write a long essay' }],\n * (event) => setText(event.accumulatedText)\n * );\n *\n * // Cancel after 5 seconds\n * setTimeout(() => stop(), 5000);\n * ```\n *\n * @example\n * ```ts\n * // React state update pattern\n * const [text, setText] = useState('');\n *\n * streamMessage(\n * [{ role: 'user', content: 'Hello!' }],\n * (event) => setText(event.accumulatedText)\n * );\n * ```\n */\nexport function streamMessage(\n messages: LLMMessage[],\n onToken: LLMStreamCallback,\n options?: LLMStreamOptions\n): { promise: Promise<LLMResponse>; stop: () => void } {\n // Handle unsupported platforms\n if (Platform.OS !== 'ios' && Platform.OS !== 'android') {\n return {\n promise: Promise.resolve({ text: '' }),\n stop: () => {},\n };\n }\n\n if (!messages || messages.length === 0) {\n return {\n promise: Promise.reject(new Error('messages array cannot be empty')),\n stop: () => {},\n };\n }\n\n const sessionId = generateSessionId();\n let finalText = '';\n let stopped = false;\n\n // Determine system prompt: use from messages array if present, else options, else default\n const hasSystemMessage = messages.some((m) => m.role === 'system');\n const systemPrompt = hasSystemMessage\n ? '' // Native will extract from messages\n : options?.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;\n\n const promise = new Promise<LLMResponse>((resolve, reject) => {\n // Subscribe to stream events\n const subscription = ExpoAiKitModule.addListener(\n 'onStreamToken',\n (event: LLMStreamEvent) => {\n // Only process events for this session\n if (event.sessionId !== sessionId) return;\n\n finalText = event.accumulatedText;\n\n // Call the user's callback\n onToken(event);\n\n // If done, clean up and resolve\n if (event.isDone) {\n subscription.remove();\n resolve({ text: finalText });\n }\n }\n );\n\n // Start streaming on native side\n ExpoAiKitModule.startStreaming(messages, systemPrompt, sessionId).catch(\n (error) => {\n subscription.remove();\n reject(error);\n }\n );\n });\n\n const stop = () => {\n if (stopped) return;\n stopped = true;\n ExpoAiKitModule.stopStreaming(sessionId).catch(() => {\n // Ignore errors when stopping\n });\n };\n\n return { promise, stop };\n}\n\n"]}
package/build/types.d.ts CHANGED
@@ -26,4 +26,31 @@ export type LLMResponse = {
26
26
  /** The generated response text */
27
27
  text: string;
28
28
  };
29
+ /**
30
+ * Options for streamMessage.
31
+ */
32
+ export type LLMStreamOptions = {
33
+ /**
34
+ * Default system prompt to use if no system message is provided in the messages array.
35
+ * If a system message exists in the array, this is ignored.
36
+ */
37
+ systemPrompt?: string;
38
+ };
39
+ /**
40
+ * Event payload for streaming tokens.
41
+ */
42
+ export type LLMStreamEvent = {
43
+ /** Unique identifier for this streaming session */
44
+ sessionId: string;
45
+ /** The token/chunk of text received */
46
+ token: string;
47
+ /** Accumulated text so far */
48
+ accumulatedText: string;
49
+ /** Whether this is the final chunk */
50
+ isDone: boolean;
51
+ };
52
+ /**
53
+ * Callback function for streaming events.
54
+ */
55
+ export type LLMStreamCallback = (event: LLMStreamEvent) => void;
29
56
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,MAAM,OAAO,GAAG,QAAQ,GAAG,MAAM,GAAG,WAAW,CAAC;AAEtD;;GAEG;AACH,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,cAAc,GAAG;IAC3B;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,WAAW,GAAG;IACxB,kCAAkC;IAClC,IAAI,EAAE,MAAM,CAAC;CACd,CAAC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,MAAM,OAAO,GAAG,QAAQ,GAAG,MAAM,GAAG,WAAW,CAAC;AAEtD;;GAEG;AACH,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,EAAE,OAAO,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,cAAc,GAAG;IAC3B;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,WAAW,GAAG;IACxB,kCAAkC;IAClC,IAAI,EAAE,MAAM,CAAC;CACd,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,gBAAgB,GAAG;IAC7B;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,cAAc,GAAG;IAC3B,mDAAmD;IACnD,SAAS,EAAE,MAAM,CAAC;IAClB,uCAAuC;IACvC,KAAK,EAAE,MAAM,CAAC;IACd,8BAA8B;IAC9B,eAAe,EAAE,MAAM,CAAC;IACxB,sCAAsC;IACtC,MAAM,EAAE,OAAO,CAAC;CACjB,CAAC;AAEF;;GAEG;AACH,MAAM,MAAM,iBAAiB,GAAG,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * Role in a conversation message.\n */\nexport type LLMRole = 'system' | 'user' | 'assistant';\n\n/**\n * A single message in a conversation.\n */\nexport type LLMMessage = {\n role: LLMRole;\n content: string;\n};\n\n/**\n * Options for sendMessage.\n */\nexport type LLMSendOptions = {\n /**\n * Default system prompt to use if no system message is provided in the messages array.\n * If a system message exists in the array, this is ignored.\n */\n systemPrompt?: string;\n};\n\n/**\n * Response from sendMessage.\n */\nexport type LLMResponse = {\n /** The generated response text */\n text: string;\n};\n"]}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * Role in a conversation message.\n */\nexport type LLMRole = 'system' | 'user' | 'assistant';\n\n/**\n * A single message in a conversation.\n */\nexport type LLMMessage = {\n role: LLMRole;\n content: string;\n};\n\n/**\n * Options for sendMessage.\n */\nexport type LLMSendOptions = {\n /**\n * Default system prompt to use if no system message is provided in the messages array.\n * If a system message exists in the array, this is ignored.\n */\n systemPrompt?: string;\n};\n\n/**\n * Response from sendMessage.\n */\nexport type LLMResponse = {\n /** The generated response text */\n text: string;\n};\n\n/**\n * Options for streamMessage.\n */\nexport type LLMStreamOptions = {\n /**\n * Default system prompt to use if no system message is provided in the messages array.\n * If a system message exists in the array, this is ignored.\n */\n systemPrompt?: string;\n};\n\n/**\n * Event payload for streaming tokens.\n */\nexport type LLMStreamEvent = {\n /** Unique identifier for this streaming session */\n sessionId: string;\n /** The token/chunk of text received */\n token: string;\n /** Accumulated text so far */\n accumulatedText: string;\n /** Whether this is the final chunk */\n isDone: boolean;\n};\n\n/**\n * Callback function for streaming events.\n */\nexport type LLMStreamCallback = (event: LLMStreamEvent) => void;\n"]}
@@ -2,9 +2,15 @@ import ExpoModulesCore
2
2
  import FoundationModels
3
3
 
4
4
  public class ExpoAiKitModule: Module {
5
+ // Track active streaming tasks for cancellation
6
+ private var activeStreamTasks: [String: Task<Void, Never>] = [:]
7
+
5
8
  public func definition() -> ModuleDefinition {
6
9
  Name("ExpoAiKit")
7
10
 
11
+ // Declare events that can be sent to JavaScript
12
+ Events("onStreamToken")
13
+
8
14
  Function("isAvailable") {
9
15
  if #available(iOS 26.0, *) {
10
16
  return true
@@ -42,5 +48,94 @@ public class ExpoAiKitModule: Module {
42
48
  return ["text": "[On-device AI requires iOS 26+]"]
43
49
  }
44
50
  }
51
+
52
+ AsyncFunction("startStreaming") {
53
+ (
54
+ messages: [[String: Any]],
55
+ fallbackSystemPrompt: String,
56
+ sessionId: String
57
+ ) in
58
+
59
+ // Extract system prompt from messages, or use fallback
60
+ let systemPrompt =
61
+ messages
62
+ .first { ($0["role"] as? String) == "system" }?["content"] as? String
63
+ ?? (fallbackSystemPrompt.isEmpty
64
+ ? "You are a helpful, friendly assistant."
65
+ : fallbackSystemPrompt)
66
+
67
+ // Get the last user message as the prompt
68
+ let userPrompt =
69
+ messages
70
+ .reversed()
71
+ .first { ($0["role"] as? String) == "user" }?["content"] as? String
72
+ ?? ""
73
+
74
+ if #available(iOS 26.0, *) {
75
+ // Create a task for streaming that can be cancelled
76
+ let task = Task {
77
+ do {
78
+ let session = LanguageModelSession(instructions: systemPrompt)
79
+ let stream = session.streamResponse(to: userPrompt)
80
+ var accumulatedText = ""
81
+
82
+ for try await partialResponse in stream {
83
+ // Check for cancellation
84
+ if Task.isCancelled { break }
85
+
86
+ let currentText = partialResponse.content
87
+ let newToken = String(currentText.dropFirst(accumulatedText.count))
88
+ accumulatedText = currentText
89
+
90
+ // Send token event to JavaScript
91
+ self.sendEvent("onStreamToken", [
92
+ "sessionId": sessionId,
93
+ "token": newToken,
94
+ "accumulatedText": accumulatedText,
95
+ "isDone": false
96
+ ])
97
+ }
98
+
99
+ // Send final event
100
+ if !Task.isCancelled {
101
+ self.sendEvent("onStreamToken", [
102
+ "sessionId": sessionId,
103
+ "token": "",
104
+ "accumulatedText": accumulatedText,
105
+ "isDone": true
106
+ ])
107
+ }
108
+ } catch {
109
+ // Send error as final event
110
+ self.sendEvent("onStreamToken", [
111
+ "sessionId": sessionId,
112
+ "token": "",
113
+ "accumulatedText": "[Error: \(error.localizedDescription)]",
114
+ "isDone": true
115
+ ])
116
+ }
117
+
118
+ // Clean up
119
+ self.activeStreamTasks.removeValue(forKey: sessionId)
120
+ }
121
+
122
+ self.activeStreamTasks[sessionId] = task
123
+ } else {
124
+ // Fallback for older iOS versions - send single response
125
+ self.sendEvent("onStreamToken", [
126
+ "sessionId": sessionId,
127
+ "token": "[On-device AI requires iOS 26+]",
128
+ "accumulatedText": "[On-device AI requires iOS 26+]",
129
+ "isDone": true
130
+ ])
131
+ }
132
+ }
133
+
134
+ AsyncFunction("stopStreaming") { (sessionId: String) in
135
+ if let task = self.activeStreamTasks[sessionId] {
136
+ task.cancel()
137
+ self.activeStreamTasks.removeValue(forKey: sessionId)
138
+ }
139
+ }
45
140
  }
46
141
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-ai-kit",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Expo AI Kit module",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -1,15 +1,30 @@
1
1
  import { requireNativeModule } from 'expo-modules-core';
2
- import { LLMMessage, LLMResponse } from './types';
2
+ import type { EventSubscription } from 'expo-modules-core';
3
+ import { LLMMessage, LLMResponse, LLMStreamEvent } from './types';
3
4
 
4
- export type ExpoAiKitNativeModule = {
5
+ export type ExpoAiKitModuleEvents = {
6
+ onStreamToken: (event: LLMStreamEvent) => void;
7
+ };
8
+
9
+ export interface ExpoAiKitNativeModule {
5
10
  isAvailable(): boolean;
6
11
  sendMessage(
7
12
  messages: LLMMessage[],
8
13
  systemPrompt: string
9
14
  ): Promise<LLMResponse>;
10
- };
15
+ startStreaming(
16
+ messages: LLMMessage[],
17
+ systemPrompt: string,
18
+ sessionId: string
19
+ ): Promise<void>;
20
+ stopStreaming(sessionId: string): Promise<void>;
21
+ addListener<K extends keyof ExpoAiKitModuleEvents>(
22
+ eventName: K,
23
+ listener: ExpoAiKitModuleEvents[K]
24
+ ): EventSubscription;
25
+ }
11
26
 
12
- const NativeModule: ExpoAiKitNativeModule =
27
+ const ExpoAiKitModule =
13
28
  requireNativeModule<ExpoAiKitNativeModule>('ExpoAiKit');
14
29
 
15
- export default NativeModule;
30
+ export default ExpoAiKitModule;
@@ -9,22 +9,47 @@ jest.mock('react-native', () => ({
9
9
  },
10
10
  }));
11
11
 
12
+ // Store event listeners for testing - must be prefixed with 'mock' for jest
13
+ let mockEventListeners = {};
14
+
12
15
  // Mock the native module
13
16
  jest.mock('../ExpoAiKitModule', () => ({
14
17
  __esModule: true,
15
18
  default: {
16
19
  isAvailable: jest.fn(() => true),
17
20
  sendMessage: jest.fn(() => Promise.resolve({ text: 'Mock response' })),
21
+ startStreaming: jest.fn(() => Promise.resolve()),
22
+ stopStreaming: jest.fn(() => Promise.resolve()),
23
+ addListener: jest.fn((eventName, callback) => {
24
+ if (!mockEventListeners[eventName]) {
25
+ mockEventListeners[eventName] = [];
26
+ }
27
+ mockEventListeners[eventName].push(callback);
28
+ return {
29
+ remove: () => {
30
+ mockEventListeners[eventName] = mockEventListeners[eventName].filter(
31
+ (cb) => cb !== callback
32
+ );
33
+ },
34
+ };
35
+ }),
18
36
  },
19
37
  }));
20
38
 
21
- const { isAvailable, sendMessage } = require('../index');
39
+ // Helper to simulate native events
40
+ const simulateStreamEvent = (event) => {
41
+ const listeners = mockEventListeners['onStreamToken'] || [];
42
+ listeners.forEach((cb) => cb(event));
43
+ };
44
+
45
+ const { isAvailable, sendMessage, streamMessage } = require('../index');
22
46
  const NativeModule = require('../ExpoAiKitModule').default;
23
47
 
24
48
  describe('expo-ai-kit', () => {
25
49
  beforeEach(() => {
26
50
  jest.clearAllMocks();
27
51
  mockPlatformOS = 'ios';
52
+ mockEventListeners = {};
28
53
  });
29
54
 
30
55
  describe('isAvailable', () => {
@@ -121,4 +146,152 @@ describe('expo-ai-kit', () => {
121
146
  ).resolves.not.toThrow();
122
147
  });
123
148
  });
149
+
150
+ describe('streamMessage', () => {
151
+ it('returns promise and stop function', () => {
152
+ const messages = [{ role: 'user', content: 'Hello' }];
153
+ const onToken = jest.fn();
154
+
155
+ const result = streamMessage(messages, onToken);
156
+
157
+ expect(result).toHaveProperty('promise');
158
+ expect(result).toHaveProperty('stop');
159
+ expect(typeof result.stop).toBe('function');
160
+ });
161
+
162
+ it('calls native startStreaming with correct arguments', () => {
163
+ const messages = [{ role: 'user', content: 'Hello' }];
164
+ const onToken = jest.fn();
165
+
166
+ streamMessage(messages, onToken);
167
+
168
+ expect(NativeModule.startStreaming).toHaveBeenCalledWith(
169
+ messages,
170
+ 'You are a helpful, friendly assistant. Answer the user directly and concisely.',
171
+ expect.stringMatching(/^stream_\d+_\d+$/)
172
+ );
173
+ });
174
+
175
+ it('uses custom system prompt from options', () => {
176
+ const messages = [{ role: 'user', content: 'Hello' }];
177
+ const onToken = jest.fn();
178
+
179
+ streamMessage(messages, onToken, { systemPrompt: 'Be a pirate.' });
180
+
181
+ expect(NativeModule.startStreaming).toHaveBeenCalledWith(
182
+ messages,
183
+ 'Be a pirate.',
184
+ expect.any(String)
185
+ );
186
+ });
187
+
188
+ it('passes empty string when messages contain system message', () => {
189
+ const messages = [
190
+ { role: 'system', content: 'Be brief.' },
191
+ { role: 'user', content: 'Hello' },
192
+ ];
193
+ const onToken = jest.fn();
194
+
195
+ streamMessage(messages, onToken, { systemPrompt: 'This should be ignored' });
196
+
197
+ expect(NativeModule.startStreaming).toHaveBeenCalledWith(
198
+ messages,
199
+ '',
200
+ expect.any(String)
201
+ );
202
+ });
203
+
204
+ it('calls onToken callback for each stream event', async () => {
205
+ const messages = [{ role: 'user', content: 'Hello' }];
206
+ const onToken = jest.fn();
207
+
208
+ const { promise } = streamMessage(messages, onToken);
209
+
210
+ // Get the sessionId that was used
211
+ const sessionId = NativeModule.startStreaming.mock.calls[0][2];
212
+
213
+ // Simulate streaming events
214
+ simulateStreamEvent({
215
+ sessionId,
216
+ token: 'Hello',
217
+ accumulatedText: 'Hello',
218
+ isDone: false,
219
+ });
220
+
221
+ simulateStreamEvent({
222
+ sessionId,
223
+ token: ' world',
224
+ accumulatedText: 'Hello world',
225
+ isDone: false,
226
+ });
227
+
228
+ simulateStreamEvent({
229
+ sessionId,
230
+ token: '',
231
+ accumulatedText: 'Hello world',
232
+ isDone: true,
233
+ });
234
+
235
+ const result = await promise;
236
+
237
+ expect(onToken).toHaveBeenCalledTimes(3);
238
+ expect(onToken).toHaveBeenNthCalledWith(1, {
239
+ sessionId,
240
+ token: 'Hello',
241
+ accumulatedText: 'Hello',
242
+ isDone: false,
243
+ });
244
+ expect(result).toEqual({ text: 'Hello world' });
245
+ });
246
+
247
+ it('ignores events from other sessions', () => {
248
+ const messages = [{ role: 'user', content: 'Hello' }];
249
+ const onToken = jest.fn();
250
+
251
+ streamMessage(messages, onToken);
252
+
253
+ // Simulate event from different session
254
+ simulateStreamEvent({
255
+ sessionId: 'other_session',
256
+ token: 'Other',
257
+ accumulatedText: 'Other',
258
+ isDone: false,
259
+ });
260
+
261
+ expect(onToken).not.toHaveBeenCalled();
262
+ });
263
+
264
+ it('calls stopStreaming when stop() is called', () => {
265
+ const messages = [{ role: 'user', content: 'Hello' }];
266
+ const onToken = jest.fn();
267
+
268
+ const { stop } = streamMessage(messages, onToken);
269
+ const sessionId = NativeModule.startStreaming.mock.calls[0][2];
270
+
271
+ stop();
272
+
273
+ expect(NativeModule.stopStreaming).toHaveBeenCalledWith(sessionId);
274
+ });
275
+
276
+ it('returns empty response on unsupported platforms', () => {
277
+ mockPlatformOS = 'web';
278
+
279
+ const messages = [{ role: 'user', content: 'Hello' }];
280
+ const onToken = jest.fn();
281
+
282
+ const { promise, stop } = streamMessage(messages, onToken);
283
+
284
+ expect(NativeModule.startStreaming).not.toHaveBeenCalled();
285
+ expect(promise).resolves.toEqual({ text: '' });
286
+ expect(stop).toBeDefined();
287
+ });
288
+
289
+ it('rejects promise for empty messages array', () => {
290
+ const onToken = jest.fn();
291
+
292
+ const { promise } = streamMessage([], onToken);
293
+
294
+ expect(promise).rejects.toThrow('messages array cannot be empty');
295
+ });
296
+ });
124
297
  });
package/src/index.ts CHANGED
@@ -1,12 +1,24 @@
1
- import NativeModule from './ExpoAiKitModule';
1
+ import ExpoAiKitModule from './ExpoAiKitModule';
2
2
  import { Platform } from 'react-native';
3
- import { LLMMessage, LLMSendOptions, LLMResponse } from './types';
3
+ import {
4
+ LLMMessage,
5
+ LLMSendOptions,
6
+ LLMResponse,
7
+ LLMStreamOptions,
8
+ LLMStreamEvent,
9
+ LLMStreamCallback,
10
+ } from './types';
4
11
 
5
12
  export * from './types';
6
13
 
7
14
  const DEFAULT_SYSTEM_PROMPT =
8
15
  'You are a helpful, friendly assistant. Answer the user directly and concisely.';
9
16
 
17
+ let streamIdCounter = 0;
18
+ function generateSessionId(): string {
19
+ return `stream_${Date.now()}_${++streamIdCounter}`;
20
+ }
21
+
10
22
  /**
11
23
  * Check if on-device AI is available on the current device.
12
24
  * Returns false on unsupported platforms (web, etc.).
@@ -15,7 +27,7 @@ export async function isAvailable(): Promise<boolean> {
15
27
  if (Platform.OS !== 'ios' && Platform.OS !== 'android') {
16
28
  return false;
17
29
  }
18
- return NativeModule.isAvailable();
30
+ return ExpoAiKitModule.isAvailable();
19
31
  }
20
32
 
21
33
  /**
@@ -71,6 +83,121 @@ export async function sendMessage(
71
83
  ? '' // Native will extract from messages
72
84
  : options?.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
73
85
 
74
- return NativeModule.sendMessage(messages, systemPrompt);
86
+ return ExpoAiKitModule.sendMessage(messages, systemPrompt);
87
+ }
88
+
89
+ /**
90
+ * Stream messages to the on-device LLM and receive progressive token updates.
91
+ *
92
+ * @param messages - Array of messages representing the conversation
93
+ * @param onToken - Callback function called for each token/chunk received
94
+ * @param options - Optional settings (systemPrompt fallback)
95
+ * @returns Object with stop() function to cancel streaming and promise that resolves when complete
96
+ *
97
+ * @example
98
+ * ```ts
99
+ * // Basic streaming
100
+ * const { promise } = streamMessage(
101
+ * [{ role: 'user', content: 'Tell me a story' }],
102
+ * (event) => {
103
+ * console.log(event.token); // Each token as it arrives
104
+ * console.log(event.accumulatedText); // Full text so far
105
+ * }
106
+ * );
107
+ * await promise;
108
+ * ```
109
+ *
110
+ * @example
111
+ * ```ts
112
+ * // With cancellation
113
+ * const { promise, stop } = streamMessage(
114
+ * [{ role: 'user', content: 'Write a long essay' }],
115
+ * (event) => setText(event.accumulatedText)
116
+ * );
117
+ *
118
+ * // Cancel after 5 seconds
119
+ * setTimeout(() => stop(), 5000);
120
+ * ```
121
+ *
122
+ * @example
123
+ * ```ts
124
+ * // React state update pattern
125
+ * const [text, setText] = useState('');
126
+ *
127
+ * streamMessage(
128
+ * [{ role: 'user', content: 'Hello!' }],
129
+ * (event) => setText(event.accumulatedText)
130
+ * );
131
+ * ```
132
+ */
133
+ export function streamMessage(
134
+ messages: LLMMessage[],
135
+ onToken: LLMStreamCallback,
136
+ options?: LLMStreamOptions
137
+ ): { promise: Promise<LLMResponse>; stop: () => void } {
138
+ // Handle unsupported platforms
139
+ if (Platform.OS !== 'ios' && Platform.OS !== 'android') {
140
+ return {
141
+ promise: Promise.resolve({ text: '' }),
142
+ stop: () => {},
143
+ };
144
+ }
145
+
146
+ if (!messages || messages.length === 0) {
147
+ return {
148
+ promise: Promise.reject(new Error('messages array cannot be empty')),
149
+ stop: () => {},
150
+ };
151
+ }
152
+
153
+ const sessionId = generateSessionId();
154
+ let finalText = '';
155
+ let stopped = false;
156
+
157
+ // Determine system prompt: use from messages array if present, else options, else default
158
+ const hasSystemMessage = messages.some((m) => m.role === 'system');
159
+ const systemPrompt = hasSystemMessage
160
+ ? '' // Native will extract from messages
161
+ : options?.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
162
+
163
+ const promise = new Promise<LLMResponse>((resolve, reject) => {
164
+ // Subscribe to stream events
165
+ const subscription = ExpoAiKitModule.addListener(
166
+ 'onStreamToken',
167
+ (event: LLMStreamEvent) => {
168
+ // Only process events for this session
169
+ if (event.sessionId !== sessionId) return;
170
+
171
+ finalText = event.accumulatedText;
172
+
173
+ // Call the user's callback
174
+ onToken(event);
175
+
176
+ // If done, clean up and resolve
177
+ if (event.isDone) {
178
+ subscription.remove();
179
+ resolve({ text: finalText });
180
+ }
181
+ }
182
+ );
183
+
184
+ // Start streaming on native side
185
+ ExpoAiKitModule.startStreaming(messages, systemPrompt, sessionId).catch(
186
+ (error) => {
187
+ subscription.remove();
188
+ reject(error);
189
+ }
190
+ );
191
+ });
192
+
193
+ const stop = () => {
194
+ if (stopped) return;
195
+ stopped = true;
196
+ ExpoAiKitModule.stopStreaming(sessionId).catch(() => {
197
+ // Ignore errors when stopping
198
+ });
199
+ };
200
+
201
+ return { promise, stop };
75
202
  }
76
203
 
package/src/types.ts CHANGED
@@ -29,3 +29,33 @@ export type LLMResponse = {
29
29
  /** The generated response text */
30
30
  text: string;
31
31
  };
32
+
33
+ /**
34
+ * Options for streamMessage.
35
+ */
36
+ export type LLMStreamOptions = {
37
+ /**
38
+ * Default system prompt to use if no system message is provided in the messages array.
39
+ * If a system message exists in the array, this is ignored.
40
+ */
41
+ systemPrompt?: string;
42
+ };
43
+
44
+ /**
45
+ * Event payload for streaming tokens.
46
+ */
47
+ export type LLMStreamEvent = {
48
+ /** Unique identifier for this streaming session */
49
+ sessionId: string;
50
+ /** The token/chunk of text received */
51
+ token: string;
52
+ /** Accumulated text so far */
53
+ accumulatedText: string;
54
+ /** Whether this is the final chunk */
55
+ isDone: boolean;
56
+ };
57
+
58
+ /**
59
+ * Callback function for streaming events.
60
+ */
61
+ export type LLMStreamCallback = (event: LLMStreamEvent) => void;