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 +149 -2
- package/android/src/main/java/expo/modules/aikit/ExpoAiKitModule.kt +47 -0
- package/android/src/main/java/expo/modules/aikit/PromptApiClient.kt +45 -0
- package/build/ExpoAiKitModule.d.ts +12 -5
- package/build/ExpoAiKitModule.d.ts.map +1 -1
- package/build/ExpoAiKitModule.js +2 -2
- package/build/ExpoAiKitModule.js.map +1 -1
- package/build/index.d.ts +49 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +104 -3
- package/build/index.js.map +1 -1
- package/build/types.d.ts +27 -0
- package/build/types.d.ts.map +1 -1
- package/build/types.js.map +1 -1
- package/ios/ExpoAiKitModule.swift +95 -0
- package/package.json +1 -1
- package/src/ExpoAiKitModule.ts +20 -5
- package/src/__tests__/index.test.js +174 -1
- package/src/index.ts +131 -4
- package/src/types.ts +30 -0
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
|
-
- **
|
|
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.
|
|
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 {
|
|
2
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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;
|
|
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"}
|
package/build/ExpoAiKitModule.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { requireNativeModule } from 'expo-modules-core';
|
|
2
|
-
const
|
|
3
|
-
export default
|
|
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;
|
|
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
|
package/build/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,
|
|
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
|
|
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
|
|
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
|
|
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
|
package/build/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
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
|
package/build/types.d.ts.map
CHANGED
|
@@ -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"}
|
package/build/types.js.map
CHANGED
|
@@ -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
package/src/ExpoAiKitModule.ts
CHANGED
|
@@ -1,15 +1,30 @@
|
|
|
1
1
|
import { requireNativeModule } from 'expo-modules-core';
|
|
2
|
-
import {
|
|
2
|
+
import type { EventSubscription } from 'expo-modules-core';
|
|
3
|
+
import { LLMMessage, LLMResponse, LLMStreamEvent } from './types';
|
|
3
4
|
|
|
4
|
-
export type
|
|
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
|
|
27
|
+
const ExpoAiKitModule =
|
|
13
28
|
requireNativeModule<ExpoAiKitNativeModule>('ExpoAiKit');
|
|
14
29
|
|
|
15
|
-
export default
|
|
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
|
-
|
|
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
|
|
1
|
+
import ExpoAiKitModule from './ExpoAiKitModule';
|
|
2
2
|
import { Platform } from 'react-native';
|
|
3
|
-
import {
|
|
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
|
|
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
|
|
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;
|