@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.
- package/LICENSE +201 -0
- package/README.md +298 -0
- package/android/build.gradle +61 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/ai/xybrid/reactnative/XybridCodec.kt +481 -0
- package/android/src/main/java/ai/xybrid/reactnative/XybridHandles.kt +139 -0
- package/android/src/main/java/ai/xybrid/reactnative/XybridModule.kt +665 -0
- package/android/src/main/java/ai/xybrid/reactnative/XybridPackage.kt +27 -0
- package/ios/XybridCodec.swift +508 -0
- package/ios/XybridHandles.swift +191 -0
- package/ios/XybridModule.mm +494 -0
- package/ios/XybridModuleImpl.swift +887 -0
- package/ios/XybridSwift/Xybrid.swift +1401 -0
- package/ios/XybridSwift/xybrid_bolt.swift +3198 -0
- package/ios/xybrid_natives.rb +178 -0
- package/lib/commonjs/NativeXybrid.js +24 -0
- package/lib/commonjs/base64.js +45 -0
- package/lib/commonjs/cancel.js +35 -0
- package/lib/commonjs/context.js +67 -0
- package/lib/commonjs/envelope.js +79 -0
- package/lib/commonjs/errors.js +61 -0
- package/lib/commonjs/index.js +59 -0
- package/lib/commonjs/model.js +307 -0
- package/lib/commonjs/pipeline.js +55 -0
- package/lib/commonjs/presets.js +32 -0
- package/lib/commonjs/session.js +76 -0
- package/lib/commonjs/types.js +10 -0
- package/lib/commonjs/wire.js +285 -0
- package/lib/commonjs/xybrid.js +133 -0
- package/lib/module/NativeXybrid.js +22 -0
- package/lib/module/base64.js +41 -0
- package/lib/module/cancel.js +29 -0
- package/lib/module/context.js +60 -0
- package/lib/module/envelope.js +73 -0
- package/lib/module/errors.js +56 -0
- package/lib/module/index.js +36 -0
- package/lib/module/model.js +298 -0
- package/lib/module/pipeline.js +48 -0
- package/lib/module/presets.js +27 -0
- package/lib/module/session.js +69 -0
- package/lib/module/types.js +9 -0
- package/lib/module/wire.js +271 -0
- package/lib/module/xybrid.js +127 -0
- package/lib/typescript/NativeXybrid.d.ts +68 -0
- package/lib/typescript/NativeXybrid.d.ts.map +1 -0
- package/lib/typescript/base64.d.ts +5 -0
- package/lib/typescript/base64.d.ts.map +1 -0
- package/lib/typescript/cancel.d.ts +24 -0
- package/lib/typescript/cancel.d.ts.map +1 -0
- package/lib/typescript/context.d.ts +40 -0
- package/lib/typescript/context.d.ts.map +1 -0
- package/lib/typescript/envelope.d.ts +51 -0
- package/lib/typescript/envelope.d.ts.map +1 -0
- package/lib/typescript/errors.d.ts +12 -0
- package/lib/typescript/errors.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +28 -0
- package/lib/typescript/index.d.ts.map +1 -0
- package/lib/typescript/model.d.ts +211 -0
- package/lib/typescript/model.d.ts.map +1 -0
- package/lib/typescript/pipeline.d.ts +35 -0
- package/lib/typescript/pipeline.d.ts.map +1 -0
- package/lib/typescript/presets.d.ts +21 -0
- package/lib/typescript/presets.d.ts.map +1 -0
- package/lib/typescript/session.d.ts +50 -0
- package/lib/typescript/session.d.ts.map +1 -0
- package/lib/typescript/types.d.ts +313 -0
- package/lib/typescript/types.d.ts.map +1 -0
- package/lib/typescript/wire.d.ts +130 -0
- package/lib/typescript/wire.d.ts.map +1 -0
- package/lib/typescript/xybrid.d.ts +89 -0
- package/lib/typescript/xybrid.d.ts.map +1 -0
- package/package.json +88 -0
- package/react-native-xybrid.podspec +50 -0
- package/src/NativeXybrid.ts +131 -0
- package/src/base64.ts +43 -0
- package/src/cancel.ts +47 -0
- package/src/context.ts +70 -0
- package/src/envelope.ts +115 -0
- package/src/errors.ts +67 -0
- package/src/index.ts +91 -0
- package/src/model.ts +408 -0
- package/src/pipeline.ts +66 -0
- package/src/presets.ts +36 -0
- package/src/session.ts +76 -0
- package/src/types.ts +362 -0
- package/src/wire.ts +408 -0
- 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
|
+
}
|