expo-ai-kit 0.3.5 → 0.3.6

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.
@@ -49,18 +49,26 @@ class ExpoAiKitModule : Module() {
49
49
 
50
50
  // Build conversation history prompt from all non-system messages
51
51
  // On-device models are stateless, so we must include full history in each request
52
- val conversationPrompt = messages
53
- .filter { it["role"] != "system" }
54
- .joinToString("\n") { msg ->
55
- val role = (msg["role"] as? String ?: "user").uppercase()
56
- val content = msg["content"] as? String ?: ""
57
- "$role: $content"
58
- } + "\nASSISTANT:"
52
+ val nonSystemMessages = messages.filter { it["role"] != "system" }
59
53
 
60
54
  // Route to active model
61
55
  val text = if (activeModelId == "mlkit") {
56
+ // ML Kit: use role-prefixed format since it has no conversation API
57
+ val conversationPrompt = nonSystemMessages
58
+ .joinToString("\n") { msg ->
59
+ val role = (msg["role"] as? String ?: "user").uppercase()
60
+ val content = msg["content"] as? String ?: ""
61
+ "$role: $content"
62
+ } + "\nASSISTANT:"
62
63
  promptClient.generateText(conversationPrompt, systemPrompt)
63
64
  } else {
65
+ // Gemma/LiteRT-LM: pass raw content — the Conversation API handles
66
+ // turn formatting internally. Adding "USER:"/"ASSISTANT:" markers
67
+ // causes double-formatting and garbled output.
68
+ val conversationPrompt = nonSystemMessages
69
+ .joinToString("\n") { msg ->
70
+ msg["content"] as? String ?: ""
71
+ }
64
72
  gemmaClient.generateText(conversationPrompt, systemPrompt)
65
73
  }
66
74
  mapOf("text" to text)
@@ -73,25 +81,15 @@ class ExpoAiKitModule : Module() {
73
81
  ?.get("content") as? String
74
82
  ?: fallbackSystemPrompt.ifBlank { "You are a helpful, friendly assistant." }
75
83
 
76
- // Build conversation history prompt from all non-system messages
77
- // On-device models are stateless, so we must include full history in each request
78
- val conversationPrompt = messages
79
- .filter { it["role"] != "system" }
80
- .joinToString("\n") { msg ->
81
- val role = (msg["role"] as? String ?: "user").uppercase()
82
- val content = msg["content"] as? String ?: ""
83
- "$role: $content"
84
- } + "\nASSISTANT:"
84
+ val nonSystemMessages = messages.filter { it["role"] != "system" }
85
85
 
86
86
  // Launch streaming in a coroutine that can be cancelled
87
87
  val job = streamScope.launch {
88
- val streamAccumulator = StringBuilder()
89
- val streamCallback = { token: String, _: String, isDone: Boolean ->
90
- streamAccumulator.append(token)
88
+ val streamCallback = { token: String, accumulatedText: String, isDone: Boolean ->
91
89
  sendEvent("onStreamToken", mapOf(
92
90
  "sessionId" to sessionId,
93
91
  "token" to token,
94
- "accumulatedText" to streamAccumulator.toString(),
92
+ "accumulatedText" to accumulatedText,
95
93
  "isDone" to isDone
96
94
  ))
97
95
 
@@ -102,8 +100,20 @@ class ExpoAiKitModule : Module() {
102
100
 
103
101
  // Route to active model
104
102
  if (activeModelId == "mlkit") {
103
+ // ML Kit: use role-prefixed format since it has no conversation API
104
+ val conversationPrompt = nonSystemMessages
105
+ .joinToString("\n") { msg ->
106
+ val role = (msg["role"] as? String ?: "user").uppercase()
107
+ val content = msg["content"] as? String ?: ""
108
+ "$role: $content"
109
+ } + "\nASSISTANT:"
105
110
  promptClient.generateTextStream(conversationPrompt, systemPrompt, streamCallback)
106
111
  } else {
112
+ // Gemma/LiteRT-LM: pass raw content — Conversation API handles turn formatting
113
+ val conversationPrompt = nonSystemMessages
114
+ .joinToString("\n") { msg ->
115
+ msg["content"] as? String ?: ""
116
+ }
107
117
  gemmaClient.generateTextStream(conversationPrompt, systemPrompt, streamCallback)
108
118
  }
109
119
  }
@@ -219,22 +219,37 @@ class GemmaInferenceClient(private val context: Context) {
219
219
  try {
220
220
  withContext(Dispatchers.IO) {
221
221
  suspendCancellableCoroutine<Unit> { continuation ->
222
+ val accumulatedBuilder = StringBuilder()
222
223
  var previousText = ""
223
224
  conv.sendMessageAsync(
224
225
  Contents.of(fullPrompt),
225
226
  object : MessageCallback {
226
227
  override fun onMessage(message: Message) {
227
- val accumulated = message.toString()
228
- val token = if (accumulated.length > previousText.length) {
229
- accumulated.substring(previousText.length)
228
+ val messageText = message.toString()
229
+
230
+ // LiteRT-LM may deliver accumulated text or delta tokens depending
231
+ // on the version. Detect which by checking if messageText extends
232
+ // what we've seen before.
233
+ val token: String
234
+ if (messageText.startsWith(previousText) && messageText.length >= previousText.length) {
235
+ // Accumulated text — extract delta
236
+ token = messageText.substring(previousText.length)
237
+ previousText = messageText
238
+ accumulatedBuilder.clear()
239
+ accumulatedBuilder.append(messageText)
230
240
  } else {
231
- ""
241
+ // Delta token — accumulate ourselves
242
+ token = messageText
243
+ accumulatedBuilder.append(messageText)
244
+ previousText = accumulatedBuilder.toString()
232
245
  }
233
- previousText = accumulated
246
+
247
+ val accumulated = accumulatedBuilder.toString()
234
248
  onChunk(token, accumulated, false)
235
249
  }
236
250
  override fun onDone() {
237
- onChunk("", previousText, true)
251
+ val finalText = accumulatedBuilder.toString()
252
+ onChunk("", finalText, true)
238
253
  continuation.resume(Unit)
239
254
  }
240
255
  override fun onError(throwable: Throwable) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-ai-kit",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "On-device AI for Expo apps — run Gemma 4, Apple Foundation Models, and ML Kit locally with zero API keys",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",