@xybrid/react-native 0.10.0-rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +298 -0
  3. package/android/build.gradle +61 -0
  4. package/android/src/main/AndroidManifest.xml +1 -0
  5. package/android/src/main/java/ai/xybrid/reactnative/XybridCodec.kt +481 -0
  6. package/android/src/main/java/ai/xybrid/reactnative/XybridHandles.kt +139 -0
  7. package/android/src/main/java/ai/xybrid/reactnative/XybridModule.kt +665 -0
  8. package/android/src/main/java/ai/xybrid/reactnative/XybridPackage.kt +27 -0
  9. package/ios/XybridCodec.swift +508 -0
  10. package/ios/XybridHandles.swift +191 -0
  11. package/ios/XybridModule.mm +494 -0
  12. package/ios/XybridModuleImpl.swift +887 -0
  13. package/ios/XybridSwift/Xybrid.swift +1401 -0
  14. package/ios/XybridSwift/xybrid_bolt.swift +3198 -0
  15. package/ios/xybrid_natives.rb +178 -0
  16. package/lib/commonjs/NativeXybrid.js +24 -0
  17. package/lib/commonjs/base64.js +45 -0
  18. package/lib/commonjs/cancel.js +35 -0
  19. package/lib/commonjs/context.js +67 -0
  20. package/lib/commonjs/envelope.js +79 -0
  21. package/lib/commonjs/errors.js +61 -0
  22. package/lib/commonjs/index.js +59 -0
  23. package/lib/commonjs/model.js +307 -0
  24. package/lib/commonjs/pipeline.js +55 -0
  25. package/lib/commonjs/presets.js +32 -0
  26. package/lib/commonjs/session.js +76 -0
  27. package/lib/commonjs/types.js +10 -0
  28. package/lib/commonjs/wire.js +285 -0
  29. package/lib/commonjs/xybrid.js +133 -0
  30. package/lib/module/NativeXybrid.js +22 -0
  31. package/lib/module/base64.js +41 -0
  32. package/lib/module/cancel.js +29 -0
  33. package/lib/module/context.js +60 -0
  34. package/lib/module/envelope.js +73 -0
  35. package/lib/module/errors.js +56 -0
  36. package/lib/module/index.js +36 -0
  37. package/lib/module/model.js +298 -0
  38. package/lib/module/pipeline.js +48 -0
  39. package/lib/module/presets.js +27 -0
  40. package/lib/module/session.js +69 -0
  41. package/lib/module/types.js +9 -0
  42. package/lib/module/wire.js +271 -0
  43. package/lib/module/xybrid.js +127 -0
  44. package/lib/typescript/NativeXybrid.d.ts +68 -0
  45. package/lib/typescript/NativeXybrid.d.ts.map +1 -0
  46. package/lib/typescript/base64.d.ts +5 -0
  47. package/lib/typescript/base64.d.ts.map +1 -0
  48. package/lib/typescript/cancel.d.ts +24 -0
  49. package/lib/typescript/cancel.d.ts.map +1 -0
  50. package/lib/typescript/context.d.ts +40 -0
  51. package/lib/typescript/context.d.ts.map +1 -0
  52. package/lib/typescript/envelope.d.ts +51 -0
  53. package/lib/typescript/envelope.d.ts.map +1 -0
  54. package/lib/typescript/errors.d.ts +12 -0
  55. package/lib/typescript/errors.d.ts.map +1 -0
  56. package/lib/typescript/index.d.ts +28 -0
  57. package/lib/typescript/index.d.ts.map +1 -0
  58. package/lib/typescript/model.d.ts +211 -0
  59. package/lib/typescript/model.d.ts.map +1 -0
  60. package/lib/typescript/pipeline.d.ts +35 -0
  61. package/lib/typescript/pipeline.d.ts.map +1 -0
  62. package/lib/typescript/presets.d.ts +21 -0
  63. package/lib/typescript/presets.d.ts.map +1 -0
  64. package/lib/typescript/session.d.ts +50 -0
  65. package/lib/typescript/session.d.ts.map +1 -0
  66. package/lib/typescript/types.d.ts +313 -0
  67. package/lib/typescript/types.d.ts.map +1 -0
  68. package/lib/typescript/wire.d.ts +130 -0
  69. package/lib/typescript/wire.d.ts.map +1 -0
  70. package/lib/typescript/xybrid.d.ts +89 -0
  71. package/lib/typescript/xybrid.d.ts.map +1 -0
  72. package/package.json +88 -0
  73. package/react-native-xybrid.podspec +50 -0
  74. package/src/NativeXybrid.ts +131 -0
  75. package/src/base64.ts +43 -0
  76. package/src/cancel.ts +47 -0
  77. package/src/context.ts +70 -0
  78. package/src/envelope.ts +115 -0
  79. package/src/errors.ts +67 -0
  80. package/src/index.ts +91 -0
  81. package/src/model.ts +408 -0
  82. package/src/pipeline.ts +66 -0
  83. package/src/presets.ts +36 -0
  84. package/src/session.ts +76 -0
  85. package/src/types.ts +362 -0
  86. package/src/wire.ts +408 -0
  87. package/src/xybrid.ts +176 -0
@@ -0,0 +1,665 @@
1
+ package ai.xybrid.reactnative
2
+
3
+ import ai.xybrid.Xybrid
4
+ import ai.xybrid.XybridCancellationToken
5
+ import ai.xybrid.XybridConversationContext
6
+ import ai.xybrid.XybridDownload
7
+ import ai.xybrid.XybridError
8
+ import ai.xybrid.XybridModel
9
+ import ai.xybrid.XybridPartialResult
10
+ import ai.xybrid.XybridPipeline
11
+ import ai.xybrid.XybridStreamEventKind
12
+ import ai.xybrid.XybridStreamingSession
13
+ import ai.xybrid.XybridThermalState
14
+ import ai.xybrid.partials
15
+ import android.content.Context
16
+ import com.facebook.react.bridge.Arguments
17
+ import com.facebook.react.bridge.Promise
18
+ import com.facebook.react.bridge.ReactApplicationContext
19
+ import com.facebook.react.bridge.ReadableArray
20
+ import com.facebook.react.bridge.ReadableMap
21
+ import java.io.File
22
+ import kotlin.math.roundToInt
23
+ import kotlinx.coroutines.CancellationException
24
+ import kotlinx.coroutines.CoroutineScope
25
+ import kotlinx.coroutines.CoroutineStart
26
+ import kotlinx.coroutines.Dispatchers
27
+ import kotlinx.coroutines.SupervisorJob
28
+ import kotlinx.coroutines.cancel
29
+ import kotlinx.coroutines.channels.BufferOverflow
30
+ import kotlinx.coroutines.channels.Channel
31
+ import kotlinx.coroutines.launch
32
+
33
+ // The React Native TurboModule. Extending the Codegen-generated
34
+ // `NativeXybridSpec` makes the compiler hold this class to
35
+ // src/NativeXybrid.ts; each override decodes its arguments (XybridCodec),
36
+ // calls the bolt Kotlin SDK from the `ai.xybrid:xybrid-kotlin` AAR, and
37
+ // settles the promise with a typed `xybrid_*` code on failure.
38
+ //
39
+ // Blocking SDK calls (load, run, stream pulls, downloads, disk walks) run on
40
+ // Dispatchers.IO; cheap getters settle inline. Every call that touches a
41
+ // native object holds a lease on it (see XybridHandles), so disposing a
42
+ // handle mid-call can never free memory the call is using.
43
+
44
+ class XybridModule(reactContext: ReactApplicationContext) : NativeXybridSpec(reactContext) {
45
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
46
+ private val handles = XybridHandles()
47
+
48
+ // Module teardown (reload, host shutdown): stop in-flight work, then free
49
+ // every object as soon as the calls using it return.
50
+ override fun invalidate() {
51
+ handles.forEachLive(XybridCancellationToken::class.java) { it.cancel() }
52
+ handles.disposeAll()
53
+ scope.cancel()
54
+ super.invalidate()
55
+ }
56
+
57
+ // -- Settling promises ------------------------------------------------------
58
+
59
+ private inline fun now(promise: Promise, block: () -> Any?) {
60
+ try {
61
+ SdkSetup.ensureBase(reactApplicationContext)
62
+ promise.resolve(toJs(block()))
63
+ } catch (t: Throwable) {
64
+ reject(promise, t)
65
+ }
66
+ }
67
+
68
+ private fun background(promise: Promise, block: suspend () -> Any?) {
69
+ scope.launch {
70
+ try {
71
+ SdkSetup.ensureBase(reactApplicationContext)
72
+ promise.resolve(toJs(block()))
73
+ } catch (e: CancellationException) {
74
+ promise.reject("xybrid_cancelled", "The Xybrid module was torn down", e)
75
+ throw e
76
+ } catch (t: Throwable) {
77
+ reject(promise, t)
78
+ }
79
+ }
80
+ }
81
+
82
+ /**
83
+ * The SDK reports a run stopped by its token as an inference error
84
+ * ("user_cancelled"); JS gets the dedicated `xybrid_cancelled` code.
85
+ */
86
+ private fun cancellationAware(error: Throwable, token: XybridCancellationToken): Throwable =
87
+ if (error !is BridgeException && error !is CancellationException && token.isCancelled()) {
88
+ XybridError.Cancelled("The run was cancelled")
89
+ } else {
90
+ error
91
+ }
92
+
93
+ private fun reject(promise: Promise, error: Throwable) {
94
+ val (code, message) = XybridCodec.rejection(error)
95
+ promise.reject(code, message, error)
96
+ }
97
+
98
+ /** Codec output (maps, lists, Doubles) → what `Promise.resolve` accepts. */
99
+ @Suppress("UNCHECKED_CAST")
100
+ private fun toJs(value: Any?): Any? = when (value) {
101
+ null, is Unit -> null
102
+ is Map<*, *> -> Arguments.makeNativeMap(value as Map<String, Any?>)
103
+ is List<*> -> Arguments.makeNativeArray(value)
104
+ is Int -> value.toDouble()
105
+ is Long -> value.toDouble()
106
+ is Float -> value.toDouble()
107
+ else -> value
108
+ }
109
+
110
+ // -- SDK configuration ------------------------------------------------------
111
+
112
+ override fun initialize(options: ReadableMap?, promise: Promise) {
113
+ try {
114
+ val what = "initialize options"
115
+ val o = options?.toHashMap() ?: emptyMap()
116
+ val cacheDir = XybridCodec.optionalString(o, "cacheDir", what).nonBlank()
117
+ SdkSetup.ensureBase(reactApplicationContext, cacheDir?.let(XybridCodec::filePath))
118
+ SdkSetup.configure(
119
+ SdkSetup.Runtime(
120
+ apiKey = XybridCodec.optionalString(o, "apiKey", what).nonBlank(),
121
+ gatewayUrl = XybridCodec.optionalString(o, "gatewayUrl", what).nonBlank(),
122
+ ingestUrl = XybridCodec.optionalString(o, "ingestUrl", what).nonBlank(),
123
+ ),
124
+ )
125
+ promise.resolve(null)
126
+ } catch (t: Throwable) {
127
+ reject(promise, t)
128
+ }
129
+ }
130
+
131
+ override fun sdkVersion(promise: Promise) = now(promise) { ai.xybrid.version() }
132
+
133
+ override fun hasApiKey(promise: Promise) = now(promise) { ai.xybrid.hasApiKey() }
134
+
135
+ override fun setProviderApiKey(provider: String, apiKey: String, promise: Promise) =
136
+ now(promise) { ai.xybrid.setProviderApiKey(provider, apiKey) }
137
+
138
+ override fun setPlatformUrl(url: String, promise: Promise) =
139
+ now(promise) { ai.xybrid.setPlatformUrl(url) }
140
+
141
+ override fun setSpeculativeCloud(enabled: Boolean, promise: Promise) =
142
+ now(promise) { ai.xybrid.setSpeculativeCloud(enabled) }
143
+
144
+ override fun isSpeculativeCloudEnabled(promise: Promise) =
145
+ now(promise) { ai.xybrid.isSpeculativeCloudEnabled() }
146
+
147
+ override fun willSpeculate(modelId: String, promise: Promise) =
148
+ background(promise) { ai.xybrid.willSpeculateForModel(modelId) }
149
+
150
+ override fun releaseMemory(promise: Promise) =
151
+ background(promise) { ai.xybrid.releaseMemory().toDouble() }
152
+
153
+ override fun setAutoRelease(enabled: Boolean, promise: Promise) =
154
+ now(promise) { ai.xybrid.setAutoRelease(enabled) }
155
+
156
+ override fun isAutoReleaseEnabled(promise: Promise) =
157
+ now(promise) { ai.xybrid.isAutoReleaseEnabled() }
158
+
159
+ // -- Device state push ------------------------------------------------------
160
+
161
+ override fun setBatteryLevel(percent: Double, promise: Promise) = now(promise) {
162
+ if (!percent.isFinite()) throw BridgeException.InvalidArgument("battery level must be a finite number")
163
+ ai.xybrid.setBatteryLevel(percent.roundToInt().coerceIn(0, 100).toUByte())
164
+ }
165
+
166
+ override fun clearBatteryLevel(promise: Promise) = now(promise) { ai.xybrid.clearBatteryLevel() }
167
+
168
+ override fun setThermalState(state: String, promise: Promise) = now(promise) {
169
+ val mapped = when (state) {
170
+ "normal" -> XybridThermalState.NORMAL
171
+ "warm" -> XybridThermalState.WARM
172
+ "hot" -> XybridThermalState.HOT
173
+ "critical" -> XybridThermalState.CRITICAL
174
+ else -> throw BridgeException.InvalidArgument("unknown thermal state '$state'")
175
+ }
176
+ ai.xybrid.setThermalState(mapped)
177
+ }
178
+
179
+ override fun clearThermalState(promise: Promise) = now(promise) { ai.xybrid.clearThermalState() }
180
+
181
+ // -- Model cache (disk walks: off the calling thread) ----------------------
182
+
183
+ override fun cacheStatus(promise: Promise) =
184
+ background(promise) { XybridCodec.encodeCacheStatus(ai.xybrid.cacheStatus()) }
185
+
186
+ override fun cacheEntries(promise: Promise) =
187
+ background(promise) { ai.xybrid.cacheEntries().map(XybridCodec::encodeCacheEntry) }
188
+
189
+ override fun cacheIsModelCached(modelId: String, promise: Promise) =
190
+ background(promise) { ai.xybrid.cacheIsModelCached(modelId) }
191
+
192
+ override fun cacheModelPath(modelId: String, promise: Promise) =
193
+ background(promise) { ai.xybrid.cacheModelPath(modelId) }
194
+
195
+ override fun cacheExtractedModelIds(promise: Promise) =
196
+ background(promise) { ai.xybrid.cacheListExtractedModelIds() }
197
+
198
+ override fun cacheRemoveModel(modelId: String, promise: Promise) =
199
+ background(promise) { ai.xybrid.cacheRemoveModel(modelId).toDouble() }
200
+
201
+ override fun cacheClear(promise: Promise) = background(promise) { ai.xybrid.cacheClear().toDouble() }
202
+
203
+ // -- Stateless helpers ------------------------------------------------------
204
+
205
+ override fun jsonSchemaToGbnf(schemaJson: String, promise: Promise) =
206
+ now(promise) { ai.xybrid.jsonSchemaToGbnf(schemaJson) }
207
+
208
+ override fun toolResultsEnvelope(
209
+ userText: String,
210
+ priorAssistantText: String,
211
+ results: ReadableArray,
212
+ promise: Promise,
213
+ ) = now(promise) {
214
+ val decoded = results.toArrayList().map(XybridCodec::decodeToolResult)
215
+ XybridCodec.encodeEnvelope(ai.xybrid.toolResultsEnvelope(userText, priorAssistantText, decoded))
216
+ }
217
+
218
+ // -- Handles ----------------------------------------------------------------
219
+
220
+ override fun dispose(handle: String, promise: Promise) {
221
+ handles.dispose(handle)
222
+ promise.resolve(null)
223
+ }
224
+
225
+ // -- Models -----------------------------------------------------------------
226
+
227
+ override fun loadModel(source: ReadableMap, promise: Promise) {
228
+ val raw = source.toHashMap()
229
+ background(promise) {
230
+ val what = "model source"
231
+ val o = XybridCodec.obj(raw, what)
232
+ val kind = XybridCodec.string(o, "kind", what)
233
+ val value = XybridCodec.string(o, "value", what)
234
+ val model = when (kind) {
235
+ "registry" -> XybridModel.fromRegistry(value)
236
+ "registrySpeculative" -> XybridModel.fromRegistrySpeculative(value)
237
+ "bundle" -> XybridModel.fromBundle(XybridCodec.filePath(value))
238
+ "directory" -> XybridModel.fromDirectory(XybridCodec.filePath(value))
239
+ "huggingFace" -> XybridCodec.optionalString(o, "revision", what)
240
+ ?.let { XybridModel.fromHuggingfaceWithRevision(value, it) }
241
+ ?: XybridModel.fromHuggingface(value)
242
+ "modelFile" -> XybridModel.fromModelFile(XybridCodec.filePath(value))
243
+ else -> throw BridgeException.InvalidArgument("unknown model source kind '$kind'")
244
+ }
245
+ handles.insert(model, "model")
246
+ }
247
+ }
248
+
249
+ override fun modelInfo(model: String, promise: Promise) = now(promise) {
250
+ handles.use(model, XybridModel::class.java) { m ->
251
+ mapOf(
252
+ "modelId" to m.modelId(),
253
+ "version" to m.version(),
254
+ "outputType" to XybridCodec.encodeOutputType(m.outputType()),
255
+ "isLlm" to m.isLlm(),
256
+ "supportsStreaming" to m.supportsStreaming(),
257
+ "supportsTokenStreaming" to m.supportsTokenStreaming(),
258
+ "supportsToolCalling" to m.supportsToolCalling(),
259
+ "hasVoices" to m.hasVoices(),
260
+ "defaultGenerationConfig" to XybridCodec.encodeGenerationConfig(m.defaultGenerationConfig()),
261
+ )
262
+ }
263
+ }
264
+
265
+ override fun isLoaded(model: String, promise: Promise) =
266
+ now(promise) { handles.use(model, XybridModel::class.java) { it.isLoaded() } }
267
+
268
+ override fun warmup(model: String, promise: Promise) =
269
+ background(promise) { handles.use(model, XybridModel::class.java) { it.warmup() } }
270
+
271
+ override fun unload(model: String, promise: Promise) =
272
+ background(promise) { handles.use(model, XybridModel::class.java) { it.unload() } }
273
+
274
+ override fun isCloudServing(model: String, promise: Promise) =
275
+ now(promise) { handles.use(model, XybridModel::class.java) { it.isCloudServing() } }
276
+
277
+ override fun downloadStatus(model: String, promise: Promise) = now(promise) {
278
+ handles.use(model, XybridModel::class.java) { XybridCodec.encodeDownloadStatus(it.downloadStatus()) }
279
+ }
280
+
281
+ override fun awaitDownload(model: String, timeoutMs: Double, promise: Promise) = background(promise) {
282
+ // NaN and negatives clamp to 0 (a non-blocking read); huge values saturate.
283
+ val timeout = if (timeoutMs.isFinite()) timeoutMs.coerceAtLeast(0.0).toULong() else 0uL
284
+ handles.use(model, XybridModel::class.java) { XybridCodec.encodeDownloadStatus(it.awaitDownload(timeout)) }
285
+ }
286
+
287
+ override fun voices(model: String, promise: Promise) = now(promise) {
288
+ handles.use(model, XybridModel::class.java) { m -> m.voices().map(XybridCodec::encodeVoice) }
289
+ }
290
+
291
+ override fun defaultVoice(model: String, promise: Promise) = now(promise) {
292
+ handles.use(model, XybridModel::class.java) { m -> m.defaultVoice()?.let(XybridCodec::encodeVoice) }
293
+ }
294
+
295
+ override fun voice(model: String, voiceId: String, promise: Promise) = now(promise) {
296
+ handles.use(model, XybridModel::class.java) { m -> m.voice(voiceId)?.let(XybridCodec::encodeVoice) }
297
+ }
298
+
299
+ // -- Inference --------------------------------------------------------------
300
+
301
+ /**
302
+ * Everything one run needs, leased for its duration: the model, the
303
+ * optional conversation context, and a stop button — the caller's token,
304
+ * or one of our own registered under the model so disposing the model (or
305
+ * tearing the module down) stops the run.
306
+ */
307
+ private inner class RunScope(
308
+ private val modelHandle: String,
309
+ rawOptions: Any?,
310
+ ) : AutoCloseable {
311
+ val request = XybridCodec.decodeRunRequest(rawOptions)
312
+ private val held = mutableListOf<AutoCloseable>()
313
+ private var ownToken: String? = null
314
+
315
+ val model: XybridModel
316
+ val context: XybridConversationContext?
317
+ val cancel: XybridCancellationToken
318
+
319
+ init {
320
+ try {
321
+ model = hold(handles.lease(modelHandle, XybridModel::class.java))
322
+ context = request.context?.let { hold(handles.lease(it, XybridConversationContext::class.java)) }
323
+ val tokenHandle = request.cancel ?: XybridCancellationToken().let { token ->
324
+ handles.insert(token, "cancel", owner = modelHandle, onDispose = token::cancel)
325
+ .also { ownToken = it }
326
+ }
327
+ cancel = hold(handles.lease(tokenHandle, XybridCancellationToken::class.java))
328
+ } catch (t: Throwable) {
329
+ close()
330
+ throw t
331
+ }
332
+ }
333
+
334
+ private fun <T : Any> hold(lease: XybridHandles.Lease<T>): T {
335
+ held.add(lease)
336
+ return lease.value
337
+ }
338
+
339
+ /**
340
+ * Hand every lease — and our own token, if we made one — over to a
341
+ * stream entry, which releases them (last first) when it is closed.
342
+ */
343
+ fun transferToStream(): List<AutoCloseable> {
344
+ val transferred = held.toMutableList()
345
+ ownToken?.let { handle -> transferred.add(AutoCloseable { handles.dispose(handle) }) }
346
+ held.clear()
347
+ ownToken = null
348
+ return transferred
349
+ }
350
+
351
+ override fun close() {
352
+ held.asReversed().forEach(AutoCloseable::close)
353
+ held.clear()
354
+ ownToken?.let(handles::dispose)
355
+ }
356
+ }
357
+
358
+ override fun run(model: String, envelope: ReadableMap, options: ReadableMap?, promise: Promise) {
359
+ val rawEnvelope = envelope.toHashMap()
360
+ val rawOptions = options?.toHashMap()
361
+ background(promise) {
362
+ val input = XybridCodec.decodeEnvelope(rawEnvelope)
363
+ RunScope(model, rawOptions).use { run ->
364
+ val context = run.context
365
+ val result = try {
366
+ if (context != null) {
367
+ run.model.runWithContext(input, context, run.request.options, run.cancel)
368
+ } else {
369
+ run.model.run(input, run.request.options, run.cancel)
370
+ }
371
+ } catch (t: Throwable) {
372
+ throw cancellationAware(t, run.cancel)
373
+ }
374
+ XybridCodec.encodeResult(result)
375
+ }
376
+ }
377
+ }
378
+
379
+ /** A pull-based token stream plus the leases that keep its model and token alive. */
380
+ private class TokenStream(
381
+ val model: XybridModel,
382
+ val streamId: ULong,
383
+ val cancel: XybridCancellationToken,
384
+ private val resources: List<AutoCloseable>,
385
+ ) {
386
+ /** Abort generation now; safe while a pull is in flight (see XybridHandles). */
387
+ fun abort() {
388
+ cancel.cancel()
389
+ model.streamClose(streamId)
390
+ }
391
+
392
+ fun release() = resources.asReversed().forEach(AutoCloseable::close)
393
+ }
394
+
395
+ override fun streamStart(model: String, envelope: ReadableMap, options: ReadableMap?, promise: Promise) {
396
+ val rawEnvelope = envelope.toHashMap()
397
+ val rawOptions = options?.toHashMap()
398
+ background(promise) {
399
+ val input = XybridCodec.decodeEnvelope(rawEnvelope)
400
+ RunScope(model, rawOptions).use { run ->
401
+ val context = run.context
402
+ val streamId = if (context != null) {
403
+ run.model.runStreamWithContext(input, context, run.request.options, run.cancel)
404
+ } else {
405
+ run.model.runStream(input, run.request.options, run.cancel)
406
+ }
407
+ val stream = TokenStream(run.model, streamId, run.cancel, run.transferToStream())
408
+ handles.insert(stream, "stream", owner = model, onDispose = stream::abort, onClose = stream::release)
409
+ }
410
+ }
411
+ }
412
+
413
+ override fun streamNext(stream: String, promise: Promise) = background(promise) {
414
+ // A disposed stream reads as exhausted, not as an error.
415
+ val lease = handles.leaseOrNull(stream, TokenStream::class.java) ?: return@background null
416
+ lease.use {
417
+ val s = it.value
418
+ try {
419
+ val event = s.model.streamNext(s.streamId)
420
+ when (event.kind) {
421
+ XybridStreamEventKind.TOKEN -> {
422
+ val token = event.token
423
+ ?: throw XybridError.InferenceError("stream returned a token event without a token")
424
+ mapOf("kind" to "token", "token" to XybridCodec.encodeStreamToken(token))
425
+ }
426
+ XybridStreamEventKind.COMPLETE -> {
427
+ // `streamResult` closes the bolt session; drop our entry with it.
428
+ val result = s.model.streamResult(s.streamId)
429
+ handles.dispose(stream)
430
+ mapOf("kind" to "complete", "result" to XybridCodec.encodeResult(result))
431
+ }
432
+ }
433
+ } catch (t: Throwable) {
434
+ // A failed pull already closed the bolt session.
435
+ handles.dispose(stream)
436
+ throw cancellationAware(t, s.cancel)
437
+ }
438
+ }
439
+ }
440
+
441
+ // -- Cancellation tokens ----------------------------------------------------
442
+
443
+ override fun createCancelToken(promise: Promise) =
444
+ now(promise) { handles.insert(XybridCancellationToken(), "cancel") }
445
+
446
+ override fun cancel(token: String, promise: Promise) =
447
+ now(promise) { handles.use(token, XybridCancellationToken::class.java) { it.cancel() } }
448
+
449
+ // -- Conversation contexts ----------------------------------------------------
450
+
451
+ override fun createContext(contextId: String?, promise: Promise) = now(promise) {
452
+ val context = contextId?.let { XybridConversationContext.withId(it) } ?: XybridConversationContext()
453
+ handles.insert(context, "context")
454
+ }
455
+
456
+ override fun contextPush(context: String, envelope: ReadableMap, promise: Promise) {
457
+ val raw = envelope.toHashMap()
458
+ // Image turns are decode-validated natively, so this can take a moment.
459
+ background(promise) {
460
+ handles.use(context, XybridConversationContext::class.java) { it.push(XybridCodec.decodeEnvelope(raw)) }
461
+ }
462
+ }
463
+
464
+ override fun contextSetSystem(context: String, envelope: ReadableMap, promise: Promise) {
465
+ val raw = envelope.toHashMap()
466
+ background(promise) {
467
+ handles.use(context, XybridConversationContext::class.java) { it.setSystem(XybridCodec.decodeEnvelope(raw)) }
468
+ }
469
+ }
470
+
471
+ override fun contextClear(context: String, promise: Promise) =
472
+ now(promise) { handles.use(context, XybridConversationContext::class.java) { it.clear() } }
473
+
474
+ override fun contextInfo(context: String, promise: Promise) = now(promise) {
475
+ handles.use(context, XybridConversationContext::class.java) { c ->
476
+ mapOf("id" to c.id(), "historyLength" to c.historyLen().toDouble(), "hasSystem" to c.hasSystem())
477
+ }
478
+ }
479
+
480
+ override fun contextHistory(context: String, promise: Promise) = now(promise) {
481
+ handles.use(context, XybridConversationContext::class.java) { c -> c.history().map(XybridCodec::encodeEnvelope) }
482
+ }
483
+
484
+ override fun contextSetMaxHistoryLength(context: String, length: Double, promise: Promise) = now(promise) {
485
+ val max = XybridCodec.uint(length, "max history length")
486
+ handles.use(context, XybridConversationContext::class.java) { it.setMaxHistoryLen(max) }
487
+ }
488
+
489
+ // -- Standalone downloads ---------------------------------------------------
490
+
491
+ override fun startDownload(modelId: String, platform: String?, promise: Promise) = background(promise) {
492
+ val download = platform?.let { XybridDownload.fromRegistryWithPlatform(modelId, it) }
493
+ ?: XybridDownload.fromRegistry(modelId)
494
+ handles.insert(download, "download")
495
+ }
496
+
497
+ override fun downloadHandleStatus(download: String, promise: Promise) = now(promise) {
498
+ handles.use(download, XybridDownload::class.java) { XybridCodec.encodeDownloadStatus(it.status()) }
499
+ }
500
+
501
+ override fun downloadHandleError(download: String, promise: Promise) =
502
+ now(promise) { handles.use(download, XybridDownload::class.java) { it.error() } }
503
+
504
+ override fun cancelDownload(download: String, promise: Promise) =
505
+ now(promise) { handles.use(download, XybridDownload::class.java) { it.cancel() } }
506
+
507
+ // -- Pipelines ----------------------------------------------------------------
508
+
509
+ override fun loadPipeline(source: ReadableMap, promise: Promise) {
510
+ val raw = source.toHashMap()
511
+ background(promise) {
512
+ val what = "pipeline source"
513
+ val o = XybridCodec.obj(raw, what)
514
+ val kind = XybridCodec.string(o, "kind", what)
515
+ val value = XybridCodec.string(o, "value", what)
516
+ val pipeline = when (kind) {
517
+ "yaml" -> XybridPipeline.fromYaml(value)
518
+ "file" -> XybridPipeline.fromFile(XybridCodec.filePath(value))
519
+ "bundle" -> XybridPipeline.fromBundle(XybridCodec.filePath(value))
520
+ else -> throw BridgeException.InvalidArgument("unknown pipeline source kind '$kind'")
521
+ }
522
+ handles.insert(pipeline, "pipeline")
523
+ }
524
+ }
525
+
526
+ override fun pipelineInfo(pipeline: String, promise: Promise) = now(promise) {
527
+ handles.use(pipeline, XybridPipeline::class.java) { p ->
528
+ mapOf("name" to p.name(), "stageNames" to p.stageNames(), "stageCount" to p.stageCount().toDouble())
529
+ }
530
+ }
531
+
532
+ override fun runPipeline(pipeline: String, envelope: ReadableMap, options: ReadableMap?, promise: Promise) {
533
+ val rawEnvelope = envelope.toHashMap()
534
+ val rawOptions = options?.toHashMap()
535
+ background(promise) {
536
+ val input = XybridCodec.decodeEnvelope(rawEnvelope)
537
+ val request = XybridCodec.decodeRunRequest(rawOptions)
538
+ handles.use(pipeline, XybridPipeline::class.java) { p ->
539
+ XybridCodec.encodePipelineResult(p.run(input, request.options))
540
+ }
541
+ }
542
+ }
543
+
544
+ // -- Live ASR sessions ------------------------------------------------------
545
+
546
+ /** A live-ASR session plus a buffer of its partial transcripts for pull reads. */
547
+ private class SessionEntry(val session: XybridStreamingSession) {
548
+ // Partials are cumulative — each supersedes the last — so a reader that
549
+ // falls behind only loses stale text.
550
+ val partials = Channel<XybridPartialResult>(capacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST)
551
+ }
552
+
553
+ override fun openStreamingSession(model: String, config: ReadableMap?, promise: Promise) {
554
+ val rawConfig = config?.toHashMap()
555
+ // Opening warms the weights, so it runs off the calling thread.
556
+ background(promise) {
557
+ val streamingConfig = XybridCodec.decodeStreamingConfig(rawConfig)
558
+ val session = handles.use(model, XybridModel::class.java) { XybridStreamingSession.forModel(it, streamingConfig) }
559
+ val entry = SessionEntry(session)
560
+ val handle = handles.insert(entry, "session", onDispose = session::cancel, onClose = session::close)
561
+ // The collector holds a lease for its whole life, so the session is
562
+ // only closed after it has unsubscribed; cancelling the session (on
563
+ // dispose) is what ends the partial stream.
564
+ val lease = handles.lease(handle, SessionEntry::class.java)
565
+ scope.launch(start = CoroutineStart.UNDISPATCHED) {
566
+ try {
567
+ session.partials().collect { entry.partials.send(it) }
568
+ } finally {
569
+ entry.partials.close()
570
+ lease.close()
571
+ }
572
+ }
573
+ handle
574
+ }
575
+ }
576
+
577
+ override fun sessionFeed(session: String, samplesBase64: String, promise: Promise) = background(promise) {
578
+ val samples = XybridCodec.float32Samples(samplesBase64)
579
+ // Blocks only when the worker's queue is full (back-pressure).
580
+ handles.use(session, SessionEntry::class.java) { it.session.feed(samples) }
581
+ }
582
+
583
+ override fun sessionNextPartial(session: String, promise: Promise) = background(promise) {
584
+ val lease = handles.leaseOrNull(session, SessionEntry::class.java) ?: return@background null
585
+ lease.use { it.value.partials.receiveCatching().getOrNull()?.let(XybridCodec::encodePartial) }
586
+ }
587
+
588
+ override fun sessionFlush(session: String, promise: Promise) =
589
+ background(promise) { handles.use(session, SessionEntry::class.java) { it.session.flush() } }
590
+
591
+ override fun sessionReset(session: String, promise: Promise) =
592
+ background(promise) { handles.use(session, SessionEntry::class.java) { it.session.reset() } }
593
+
594
+ override fun sessionCancel(session: String, promise: Promise) =
595
+ now(promise) { handles.use(session, SessionEntry::class.java) { it.session.cancel() } }
596
+
597
+ override fun sessionIsRunning(session: String, promise: Promise) =
598
+ now(promise) { handles.use(session, SessionEntry::class.java) { it.session.isRunning() } }
599
+
600
+ companion object {
601
+ const val NAME = NativeXybridSpec.NAME
602
+ }
603
+ }
604
+
605
+ /** Blank means absent for configuration strings. */
606
+ private fun String?.nonBlank(): String? = this?.trim()?.takeIf { it.isNotEmpty() }
607
+
608
+ /**
609
+ * Process-wide SDK setup. The Rust SDK's configuration is process-global
610
+ * (first-set-wins) and outlives this module: a JS reload builds a new module
611
+ * instance but not a new process. So what has been applied is tracked
612
+ * process-wide too, which is what keeps a reload from starting a second
613
+ * telemetry exporter.
614
+ */
615
+ private object SdkSetup {
616
+ data class Runtime(val apiKey: String?, val gatewayUrl: String?, val ingestUrl: String?) {
617
+ val isEmpty get() = apiKey == null && gatewayUrl == null && ingestUrl == null
618
+ }
619
+
620
+ private var appliedCacheDir: String? = null
621
+ private var appliedRuntime: Runtime? = null
622
+
623
+ /**
624
+ * Register the binding, device observers and the cache directory, once.
625
+ * Every bridged call runs this first, so local inference needs no
626
+ * `initialize()` at all.
627
+ */
628
+ @Synchronized
629
+ fun ensureBase(context: Context, requested: String? = null) {
630
+ val applied = appliedCacheDir
631
+ if (applied != null) {
632
+ if (requested != null && requested != applied) {
633
+ throw BridgeException.Config(
634
+ "the model cache is already at $applied; cacheDir only applies to the first Xybrid call",
635
+ )
636
+ }
637
+ return
638
+ }
639
+ // First-set-wins in the SDK: claim the binding and the cache directory
640
+ // before Xybrid.init() would register "kotlin" and <filesDir>/xybrid/models.
641
+ // Its own configureRuntime(null, null, null) is a no-op; what it adds is
642
+ // the battery and thermal observers.
643
+ ai.xybrid.setBinding("react-native")
644
+ val directory = requested ?: File(context.filesDir, "xybrid/models").absolutePath
645
+ File(directory).mkdirs()
646
+ ai.xybrid.initSdkCacheDir(directory)
647
+ Xybrid.init(context)
648
+ appliedCacheDir = directory
649
+ }
650
+
651
+ /** Apply the API key and URL overrides — once per process. */
652
+ @Synchronized
653
+ fun configure(runtime: Runtime) {
654
+ if (runtime.isEmpty) return
655
+ val applied = appliedRuntime
656
+ if (applied != null) {
657
+ if (applied == runtime) return
658
+ throw BridgeException.Config(
659
+ "Xybrid is already initialized with different options; they apply once per app process",
660
+ )
661
+ }
662
+ ai.xybrid.configureRuntime(runtime.apiKey, runtime.gatewayUrl, runtime.ingestUrl)
663
+ appliedRuntime = runtime
664
+ }
665
+ }
@@ -0,0 +1,27 @@
1
+ package ai.xybrid.reactnative
2
+
3
+ import com.facebook.react.BaseReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.module.model.ReactModuleInfo
7
+ import com.facebook.react.module.model.ReactModuleInfoProvider
8
+
9
+ // Registers XybridModule as a TurboModule. React Native autolinking finds
10
+ // this class through the `codegenConfig` in package.json.
11
+ class XybridPackage : BaseReactPackage() {
12
+ override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
13
+ if (name == XybridModule.NAME) XybridModule(reactContext) else null
14
+
15
+ override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = ReactModuleInfoProvider {
16
+ mapOf(
17
+ XybridModule.NAME to ReactModuleInfo(
18
+ XybridModule.NAME,
19
+ XybridModule::class.java.name,
20
+ false, // canOverrideExistingModule
21
+ false, // needsEagerInit
22
+ false, // isCxxModule
23
+ true, // isTurboModule
24
+ ),
25
+ )
26
+ }
27
+ }