react-native-litert-lm 0.4.0 → 0.4.2
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/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/com/margelo/nitro/dev/litert/litertlm/HybridLiteRTLM.kt +117 -0
- package/android/src/test/java/com/margelo/nitro/dev/litert/litertlm/HybridLiteRTLMTest.kt +22 -0
- package/ios/HybridLiteRTLM.swift +330 -35
- package/ios/Tests/HybridLiteRTLMTests.swift +58 -0
- package/lib/__mocks__/react-native-nitro-modules.d.ts +4 -0
- package/lib/__mocks__/react-native-nitro-modules.js +10 -0
- package/lib/__tests__/modelFactory.test.js +16 -0
- package/lib/hooks.js +27 -3
- package/lib/index.d.ts +6 -0
- package/lib/index.js +7 -3
- package/lib/modelFactory.js +20 -0
- package/lib/specs/LiteRTLM.nitro.d.ts +16 -0
- package/nitrogen/generated/android/LiteRTLMOnLoad.cpp +2 -2
- package/nitrogen/generated/android/c++/JHybridLiteRTLMSpec.cpp +32 -2
- package/nitrogen/generated/android/c++/JHybridLiteRTLMSpec.hpp +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/HybridLiteRTLMSpec.kt +18 -0
- package/nitrogen/generated/ios/LiteRTLM-Swift-Cxx-Bridge.cpp +8 -8
- package/nitrogen/generated/ios/LiteRTLM-Swift-Cxx-Bridge.hpp +22 -22
- package/nitrogen/generated/ios/c++/HybridLiteRTLMSpecSwift.hpp +16 -0
- package/nitrogen/generated/ios/swift/HybridLiteRTLMSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridLiteRTLMSpec_cxx.swift +48 -0
- package/nitrogen/generated/shared/c++/HybridLiteRTLMSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridLiteRTLMSpec.hpp +2 -0
- package/package.json +7 -4
- package/react-native-litert-lm.podspec +4 -2
- package/scripts/download-ios-frameworks.sh +4 -3
- package/scripts/framework-source.js +46 -0
- package/scripts/postinstall.js +39 -16
- package/src/__mocks__/react-native-nitro-modules.ts +10 -0
- package/src/__tests__/modelFactory.test.ts +28 -0
- package/src/hooks.ts +29 -7
- package/src/index.ts +7 -3
- package/src/modelFactory.ts +22 -0
- package/src/specs/LiteRTLM.nitro.ts +26 -0
|
@@ -501,6 +501,75 @@ class HybridLiteRTLM : HybridLiteRTLMSpec() {
|
|
|
501
501
|
}
|
|
502
502
|
}
|
|
503
503
|
|
|
504
|
+
override fun sendMessageWithImageAsync(message: String, imagePath: String, onToken: (String, Boolean) -> Unit): Promise<Unit> {
|
|
505
|
+
return Promise.parallel {
|
|
506
|
+
val latch = CountDownLatch(1)
|
|
507
|
+
val errorRef = AtomicReference<Throwable?>(null)
|
|
508
|
+
|
|
509
|
+
ensureLoaded()
|
|
510
|
+
|
|
511
|
+
Log.i(TAG, "sendMessageWithImageAsync: $message, path=$imagePath")
|
|
512
|
+
|
|
513
|
+
// Resize image to prevent OOM on high-resolution photos
|
|
514
|
+
val processedImagePath = resizeImageIfNeeded(imagePath)
|
|
515
|
+
|
|
516
|
+
val fullResponseBuilder = StringBuilder()
|
|
517
|
+
|
|
518
|
+
val listener = StreamingCallbackListener(
|
|
519
|
+
onToken = { token, done ->
|
|
520
|
+
onToken(token, done)
|
|
521
|
+
if (done) {
|
|
522
|
+
latch.countDown()
|
|
523
|
+
}
|
|
524
|
+
},
|
|
525
|
+
responseBuilder = fullResponseBuilder,
|
|
526
|
+
history = history,
|
|
527
|
+
userMessage = message,
|
|
528
|
+
onStatsReady = { stats -> lastStats = stats },
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
try {
|
|
532
|
+
val textContent = Content.Text(message)
|
|
533
|
+
val userMsg = LiteRTMessage.user(Contents.of(textContent, Content.ImageFile(processedImagePath)))
|
|
534
|
+
|
|
535
|
+
history.add(Message(Role.USER, "$message [Image]"))
|
|
536
|
+
|
|
537
|
+
conversation!!.sendMessageAsync(message = userMsg, callback = listener)
|
|
538
|
+
} catch (e: Exception) {
|
|
539
|
+
// Clean up temp resized image to prevent cache dir bloat
|
|
540
|
+
if (processedImagePath != imagePath) {
|
|
541
|
+
try {
|
|
542
|
+
java.io.File(processedImagePath).delete()
|
|
543
|
+
} catch (e: Exception) {
|
|
544
|
+
Log.w(TAG, "Failed to clean up temp image: ${e.message}")
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
Log.e(TAG, "Failed to initiate async multimodal generation", e)
|
|
549
|
+
errorRef.set(e)
|
|
550
|
+
onToken("Error: ${e.message}", true)
|
|
551
|
+
latch.countDown()
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Wait for completion or error
|
|
555
|
+
latch.await()
|
|
556
|
+
|
|
557
|
+
// Clean up temp resized image to prevent cache dir bloat
|
|
558
|
+
if (processedImagePath != imagePath) {
|
|
559
|
+
try {
|
|
560
|
+
java.io.File(processedImagePath).delete()
|
|
561
|
+
} catch (e: Exception) {
|
|
562
|
+
Log.w(TAG, "Failed to clean up temp image: ${e.message}")
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
val err = errorRef.get()
|
|
567
|
+
if (err != null) {
|
|
568
|
+
throw RuntimeException("Async multimodal inference failed: ${err.message}", err)
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
504
573
|
override fun downloadModel(url: String, fileName: String, onProgress: ((Double) -> Unit)?): Promise<String> {
|
|
505
574
|
return Promise.parallel {
|
|
506
575
|
Log.i(TAG, "downloadModel: $url -> $fileName")
|
|
@@ -623,6 +692,54 @@ class HybridLiteRTLM : HybridLiteRTLMSpec() {
|
|
|
623
692
|
}
|
|
624
693
|
}
|
|
625
694
|
|
|
695
|
+
override fun sendMessageWithAudioAsync(message: String, audioPath: String, onToken: (String, Boolean) -> Unit): Promise<Unit> {
|
|
696
|
+
return Promise.parallel {
|
|
697
|
+
val latch = CountDownLatch(1)
|
|
698
|
+
val errorRef = AtomicReference<Throwable?>(null)
|
|
699
|
+
|
|
700
|
+
ensureLoaded()
|
|
701
|
+
|
|
702
|
+
Log.i(TAG, "sendMessageWithAudioAsync: $message, path=$audioPath")
|
|
703
|
+
|
|
704
|
+
val fullResponseBuilder = StringBuilder()
|
|
705
|
+
|
|
706
|
+
val listener = StreamingCallbackListener(
|
|
707
|
+
onToken = { token, done ->
|
|
708
|
+
onToken(token, done)
|
|
709
|
+
if (done) {
|
|
710
|
+
latch.countDown()
|
|
711
|
+
}
|
|
712
|
+
},
|
|
713
|
+
responseBuilder = fullResponseBuilder,
|
|
714
|
+
history = history,
|
|
715
|
+
userMessage = message,
|
|
716
|
+
onStatsReady = { stats -> lastStats = stats },
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
try {
|
|
720
|
+
val userMsg = LiteRTMessage.user(Contents.of(
|
|
721
|
+
Content.Text(message),
|
|
722
|
+
Content.AudioFile(audioPath)
|
|
723
|
+
))
|
|
724
|
+
|
|
725
|
+
history.add(Message(Role.USER, "$message [Audio]"))
|
|
726
|
+
|
|
727
|
+
conversation!!.sendMessageAsync(message = userMsg, callback = listener)
|
|
728
|
+
} catch (e: Exception) {
|
|
729
|
+
Log.e(TAG, "Failed to initiate async audio generation", e)
|
|
730
|
+
errorRef.set(e)
|
|
731
|
+
onToken("Error: ${e.message}", true)
|
|
732
|
+
latch.countDown()
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
latch.await()
|
|
736
|
+
val err = errorRef.get()
|
|
737
|
+
if (err != null) {
|
|
738
|
+
throw RuntimeException("Async audio inference failed: ${err.message}", err)
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
626
743
|
override fun sendMessageWithAudio(message: String, audioPath: String): Promise<String> {
|
|
627
744
|
return Promise.parallel {
|
|
628
745
|
ensureLoaded()
|
|
@@ -69,6 +69,28 @@ class HybridLiteRTLMTest {
|
|
|
69
69
|
assertTrue(mem.availableMemoryBytes >= 0.0)
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
@Test
|
|
73
|
+
fun testSendMessageWithImageAsyncRejectsWithoutModel() {
|
|
74
|
+
val promise = bridge.sendMessageWithImageAsync("hello", "/tmp/image.jpg") { _, _ -> }
|
|
75
|
+
assertNotNull("Promise should not be null", promise)
|
|
76
|
+
assertTrue("Promise should be completed", promise.isCompleted)
|
|
77
|
+
assertNotNull("Promise should have rejected without model", promise.error)
|
|
78
|
+
val errMsg = promise.error!!.message ?: promise.error!!.cause?.message ?: ""
|
|
79
|
+
assertTrue("Expected no-model error, got: $errMsg",
|
|
80
|
+
errMsg.contains("No model loaded"))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@Test
|
|
84
|
+
fun testSendMessageWithAudioAsyncRejectsWithoutModel() {
|
|
85
|
+
val promise = bridge.sendMessageWithAudioAsync("hello", "/tmp/audio.wav") { _, _ -> }
|
|
86
|
+
assertNotNull("Promise should not be null", promise)
|
|
87
|
+
assertTrue("Promise should be completed", promise.isCompleted)
|
|
88
|
+
assertNotNull("Promise should have rejected without model", promise.error)
|
|
89
|
+
val errMsg = promise.error!!.message ?: promise.error!!.cause?.message ?: ""
|
|
90
|
+
assertTrue("Expected no-model error, got: $errMsg",
|
|
91
|
+
errMsg.contains("No model loaded"))
|
|
92
|
+
}
|
|
93
|
+
|
|
72
94
|
@Test
|
|
73
95
|
fun testAndroidInitialStats() {
|
|
74
96
|
val stats = bridge.getStats()
|
package/ios/HybridLiteRTLM.swift
CHANGED
|
@@ -14,6 +14,7 @@ import os
|
|
|
14
14
|
/// A stream context passed to the low-level C FFI callback to forward chunks safely to the JS thread.
|
|
15
15
|
private class StreamContext {
|
|
16
16
|
let userMessage: String
|
|
17
|
+
let historyUserContent: String
|
|
17
18
|
let startTime: Date
|
|
18
19
|
let onToken: (_ token: String, _ done: Bool) -> Void
|
|
19
20
|
let promise: Promise<Void>
|
|
@@ -26,12 +27,14 @@ private class StreamContext {
|
|
|
26
27
|
|
|
27
28
|
init(
|
|
28
29
|
userMessage: String,
|
|
30
|
+
historyUserContent: String? = nil,
|
|
29
31
|
startTime: Date,
|
|
30
32
|
onToken: @escaping (_ token: String, _ done: Bool) -> Void,
|
|
31
33
|
promise: Promise<Void>,
|
|
32
34
|
parent: HybridLiteRTLM
|
|
33
35
|
) {
|
|
34
36
|
self.userMessage = userMessage
|
|
37
|
+
self.historyUserContent = historyUserContent ?? userMessage
|
|
35
38
|
self.startTime = startTime
|
|
36
39
|
self.onToken = onToken
|
|
37
40
|
self.promise = promise
|
|
@@ -117,7 +120,7 @@ public class HybridLiteRTLM: HybridLiteRTLMSpec_base, HybridLiteRTLMSpec_protoco
|
|
|
117
120
|
}
|
|
118
121
|
|
|
119
122
|
public func countTokens(text: String) throws -> Double {
|
|
120
|
-
return
|
|
123
|
+
return queue.sync {
|
|
121
124
|
guard let engine = self.engine else {
|
|
122
125
|
return -1.0
|
|
123
126
|
}
|
|
@@ -406,42 +409,50 @@ public class HybridLiteRTLM: HybridLiteRTLMSpec_base, HybridLiteRTLMSpec_protoco
|
|
|
406
409
|
ctx.onToken(remaining, false)
|
|
407
410
|
}
|
|
408
411
|
ctx.fullResponse = finalCleaned
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
412
|
+
|
|
413
|
+
// This callback fires on an engine-internal thread (the C API
|
|
414
|
+
// returns once the stream *starts*), so commit the shared
|
|
415
|
+
// lastStats/history — and the conversation benchmark read — on
|
|
416
|
+
// the serial engine queue to avoid racing getStats()/getHistory().
|
|
417
|
+
// Resolving inside the same block guarantees JS observes the
|
|
418
|
+
// final turn before the promise settles.
|
|
419
|
+
ctx.parent.queue.async {
|
|
420
|
+
var completionTokens = Double(ctx.tokenCount)
|
|
421
|
+
var tokensPerSecond = 0.0
|
|
422
|
+
var ttft = 0.0
|
|
423
|
+
|
|
424
|
+
if let benchInfo = litert_lm_conversation_get_benchmark_info(ctx.parent.conversation) {
|
|
425
|
+
let numDecodeTurns = litert_lm_benchmark_info_get_num_decode_turns(benchInfo)
|
|
426
|
+
if numDecodeTurns > 0 {
|
|
427
|
+
let lastIdx = numDecodeTurns - 1
|
|
428
|
+
tokensPerSecond = litert_lm_benchmark_info_get_decode_tokens_per_sec_at(benchInfo, lastIdx)
|
|
429
|
+
completionTokens = Double(litert_lm_benchmark_info_get_decode_token_count_at(benchInfo, lastIdx))
|
|
430
|
+
}
|
|
431
|
+
ttft = litert_lm_benchmark_info_get_time_to_first_token(benchInfo)
|
|
432
|
+
litert_lm_benchmark_info_delete(benchInfo)
|
|
420
433
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
434
|
+
|
|
435
|
+
let promptTokens = Double(ctx.userMessage.count) / 4.0
|
|
436
|
+
if completionTokens == 0.0 {
|
|
437
|
+
completionTokens = Double(ctx.fullResponse.count) / 4.0
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
ctx.parent.lastStats = GenerationStats(
|
|
441
|
+
promptTokens: promptTokens,
|
|
442
|
+
completionTokens: completionTokens,
|
|
443
|
+
totalTokens: promptTokens + completionTokens,
|
|
444
|
+
timeToFirstToken: ttft,
|
|
445
|
+
totalTime: totalTime,
|
|
446
|
+
tokensPerSecond: tokensPerSecond > 0.0 ? tokensPerSecond : (completionTokens / totalTime)
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
ctx.parent.history.append(Message(role: .user, content: ctx.userMessage))
|
|
450
|
+
ctx.parent.history.append(Message(role: .model, content: ctx.fullResponse))
|
|
451
|
+
|
|
452
|
+
ctx.onToken("", true)
|
|
453
|
+
ctx.promise.resolve()
|
|
454
|
+
Unmanaged<StreamContext>.fromOpaque(callbackData).release()
|
|
428
455
|
}
|
|
429
|
-
|
|
430
|
-
ctx.parent.lastStats = GenerationStats(
|
|
431
|
-
promptTokens: promptTokens,
|
|
432
|
-
completionTokens: completionTokens,
|
|
433
|
-
totalTokens: promptTokens + completionTokens,
|
|
434
|
-
timeToFirstToken: ttft,
|
|
435
|
-
totalTime: totalTime,
|
|
436
|
-
tokensPerSecond: tokensPerSecond > 0.0 ? tokensPerSecond : (completionTokens / totalTime)
|
|
437
|
-
)
|
|
438
|
-
|
|
439
|
-
ctx.parent.history.append(Message(role: .user, content: ctx.userMessage))
|
|
440
|
-
ctx.parent.history.append(Message(role: .model, content: ctx.fullResponse))
|
|
441
|
-
|
|
442
|
-
ctx.onToken("", true)
|
|
443
|
-
ctx.promise.resolve()
|
|
444
|
-
Unmanaged<StreamContext>.fromOpaque(callbackData).release()
|
|
445
456
|
return
|
|
446
457
|
}
|
|
447
458
|
|
|
@@ -542,7 +553,291 @@ public class HybridLiteRTLM: HybridLiteRTLMSpec_base, HybridLiteRTLMSpec_protoco
|
|
|
542
553
|
|
|
543
554
|
return promise
|
|
544
555
|
}
|
|
556
|
+
|
|
557
|
+
public func sendMessageWithImageAsync(message: String, imagePath: String, onToken: @escaping (_ token: String, _ done: Bool) -> Void) throws -> Promise<Void> {
|
|
558
|
+
let promise = Promise<Void>()
|
|
559
|
+
|
|
560
|
+
queue.async {
|
|
561
|
+
guard let conversation = self.conversation else {
|
|
562
|
+
promise.reject(withError: NSError(domain: "LiteRTLM", code: 400, userInfo: [NSLocalizedDescriptionKey: "LiteRTLM: No model loaded. Call loadModel() first."]))
|
|
563
|
+
return
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if !FileManager.default.fileExists(atPath: imagePath) {
|
|
567
|
+
promise.reject(withError: NSError(domain: "LiteRTLM", code: 404, userInfo: [NSLocalizedDescriptionKey: "Image file not found: \(imagePath)"]))
|
|
568
|
+
return
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
let msgJson = self.buildImageMessageJson(text: message, imagePath: imagePath)
|
|
572
|
+
let startTime = Date()
|
|
573
|
+
|
|
574
|
+
let historyUserContent = message + " [image: \(imagePath)]"
|
|
575
|
+
let context = StreamContext(
|
|
576
|
+
userMessage: message,
|
|
577
|
+
historyUserContent: historyUserContent,
|
|
578
|
+
startTime: startTime,
|
|
579
|
+
onToken: onToken,
|
|
580
|
+
promise: promise,
|
|
581
|
+
parent: self
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
let callbackData = Unmanaged.passRetained(context).toOpaque()
|
|
585
|
+
|
|
586
|
+
let callback: LiteRtLmStreamCallback = { callbackData, chunk, isFinal, errorMsg in
|
|
587
|
+
guard let callbackData = callbackData else { return }
|
|
588
|
+
let ctx = Unmanaged<StreamContext>.fromOpaque(callbackData).takeUnretainedValue()
|
|
589
|
+
|
|
590
|
+
if let errorMsg = errorMsg {
|
|
591
|
+
let errorStr = String(cString: errorMsg)
|
|
592
|
+
ctx.onToken("Error: \(errorStr)", true)
|
|
593
|
+
ctx.promise.reject(withError: NSError(domain: "LiteRTLM", code: 500, userInfo: [NSLocalizedDescriptionKey: errorStr]))
|
|
594
|
+
Unmanaged<StreamContext>.fromOpaque(callbackData).release()
|
|
595
|
+
return
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if isFinal {
|
|
599
|
+
let endTime = Date()
|
|
600
|
+
let totalTime = endTime.timeIntervalSince(ctx.startTime)
|
|
601
|
+
|
|
602
|
+
let cleaned = ctx.parent.stripControlTokens(ctx.rawResponse)
|
|
603
|
+
var finalCleaned = cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
604
|
+
if !ctx.userMessage.isEmpty && finalCleaned.hasPrefix(ctx.userMessage) {
|
|
605
|
+
finalCleaned = String(finalCleaned.dropFirst(ctx.userMessage.count))
|
|
606
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if finalCleaned.count > ctx.lastEmittedLength {
|
|
610
|
+
let startIdx = finalCleaned.index(finalCleaned.startIndex, offsetBy: ctx.lastEmittedLength)
|
|
611
|
+
let remaining = String(finalCleaned[startIdx...])
|
|
612
|
+
ctx.onToken(remaining, false)
|
|
613
|
+
}
|
|
614
|
+
ctx.fullResponse = finalCleaned
|
|
615
|
+
|
|
616
|
+
ctx.parent.queue.async {
|
|
617
|
+
var completionTokens = Double(ctx.tokenCount)
|
|
618
|
+
var tokensPerSecond = 0.0
|
|
619
|
+
var ttft = 0.0
|
|
620
|
+
if let benchInfo = litert_lm_conversation_get_benchmark_info(ctx.parent.conversation) {
|
|
621
|
+
let numDecodeTurns = litert_lm_benchmark_info_get_num_decode_turns(benchInfo)
|
|
622
|
+
if numDecodeTurns > 0 {
|
|
623
|
+
let lastIdx = numDecodeTurns - 1
|
|
624
|
+
tokensPerSecond = litert_lm_benchmark_info_get_decode_tokens_per_sec_at(benchInfo, lastIdx)
|
|
625
|
+
completionTokens = Double(litert_lm_benchmark_info_get_decode_token_count_at(benchInfo, lastIdx))
|
|
626
|
+
}
|
|
627
|
+
ttft = litert_lm_benchmark_info_get_time_to_first_token(benchInfo)
|
|
628
|
+
litert_lm_benchmark_info_delete(benchInfo)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
let promptTokens = Double(ctx.userMessage.count) / 4.0
|
|
632
|
+
if completionTokens == 0.0 {
|
|
633
|
+
completionTokens = Double(ctx.fullResponse.count) / 4.0
|
|
634
|
+
}
|
|
635
|
+
ctx.parent.lastStats = GenerationStats(
|
|
636
|
+
promptTokens: promptTokens,
|
|
637
|
+
completionTokens: completionTokens,
|
|
638
|
+
totalTokens: promptTokens + completionTokens,
|
|
639
|
+
timeToFirstToken: ttft,
|
|
640
|
+
totalTime: totalTime,
|
|
641
|
+
tokensPerSecond: tokensPerSecond > 0.0 ? tokensPerSecond : (completionTokens / totalTime)
|
|
642
|
+
)
|
|
643
|
+
ctx.parent.history.append(Message(role: .user, content: ctx.historyUserContent))
|
|
644
|
+
ctx.parent.history.append(Message(role: .model, content: ctx.fullResponse))
|
|
645
|
+
ctx.onToken("", true)
|
|
646
|
+
ctx.promise.resolve()
|
|
647
|
+
Unmanaged<StreamContext>.fromOpaque(callbackData).release()
|
|
648
|
+
}
|
|
649
|
+
return
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if let chunk = chunk {
|
|
653
|
+
let token = String(cString: chunk)
|
|
654
|
+
let raw: String
|
|
655
|
+
if token.hasPrefix("{") && token.contains("\"role\"") {
|
|
656
|
+
raw = ctx.parent.extractTextFromResponse(token)
|
|
657
|
+
} else {
|
|
658
|
+
raw = token
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
ctx.rawResponse += raw
|
|
662
|
+
let cleaned = ctx.parent.stripControlTokens(ctx.rawResponse)
|
|
663
|
+
.trimmingLeadingCharacters(in: .whitespacesAndNewlines)
|
|
664
|
+
|
|
665
|
+
var processed = cleaned
|
|
666
|
+
if !ctx.userMessage.isEmpty && processed.hasPrefix(ctx.userMessage) {
|
|
667
|
+
processed = String(processed.dropFirst(ctx.userMessage.count))
|
|
668
|
+
.trimmingLeadingCharacters(in: .whitespacesAndNewlines)
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
let safeLen = ctx.parent.safeEmitLength(processed)
|
|
672
|
+
if safeLen > ctx.lastEmittedLength {
|
|
673
|
+
let chars = Array(processed)
|
|
674
|
+
let newText = String(chars[ctx.lastEmittedLength..<safeLen])
|
|
675
|
+
ctx.lastEmittedLength = safeLen
|
|
676
|
+
ctx.tokenCount += 1
|
|
677
|
+
ctx.onToken(newText, false)
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
let status = litert_lm_conversation_send_message_stream(
|
|
683
|
+
conversation,
|
|
684
|
+
msgJson,
|
|
685
|
+
nil,
|
|
686
|
+
nil,
|
|
687
|
+
callback,
|
|
688
|
+
callbackData
|
|
689
|
+
)
|
|
690
|
+
if status != 0 {
|
|
691
|
+
Unmanaged<StreamContext>.fromOpaque(callbackData).release()
|
|
692
|
+
promise.reject(withError: NSError(domain: "LiteRTLM", code: Int(status), userInfo: [NSLocalizedDescriptionKey: "Failed to start streaming conversation."]))
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return promise
|
|
697
|
+
}
|
|
545
698
|
|
|
699
|
+
public func sendMessageWithAudioAsync(message: String, audioPath: String, onToken: @escaping (_ token: String, _ done: Bool) -> Void) throws -> Promise<Void> {
|
|
700
|
+
let promise = Promise<Void>()
|
|
701
|
+
|
|
702
|
+
queue.async {
|
|
703
|
+
guard let conversation = self.conversation else {
|
|
704
|
+
promise.reject(withError: NSError(domain: "LiteRTLM", code: 400, userInfo: [NSLocalizedDescriptionKey: "LiteRTLM: No model loaded. Call loadModel() first."]))
|
|
705
|
+
return
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if !FileManager.default.fileExists(atPath: audioPath) {
|
|
709
|
+
promise.reject(withError: NSError(domain: "LiteRTLM", code: 404, userInfo: [NSLocalizedDescriptionKey: "Audio file not found: \(audioPath)"]))
|
|
710
|
+
return
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
let msgJson = self.buildAudioMessageJson(text: message, audioPath: audioPath)
|
|
714
|
+
let startTime = Date()
|
|
715
|
+
|
|
716
|
+
let historyUserContent = message + " [audio: \(audioPath)]"
|
|
717
|
+
let context = StreamContext(
|
|
718
|
+
userMessage: message,
|
|
719
|
+
historyUserContent: historyUserContent,
|
|
720
|
+
startTime: startTime,
|
|
721
|
+
onToken: onToken,
|
|
722
|
+
promise: promise,
|
|
723
|
+
parent: self
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
let callbackData = Unmanaged.passRetained(context).toOpaque()
|
|
727
|
+
|
|
728
|
+
let callback: LiteRtLmStreamCallback = { callbackData, chunk, isFinal, errorMsg in
|
|
729
|
+
guard let callbackData = callbackData else { return }
|
|
730
|
+
let ctx = Unmanaged<StreamContext>.fromOpaque(callbackData).takeUnretainedValue()
|
|
731
|
+
|
|
732
|
+
if let errorMsg = errorMsg {
|
|
733
|
+
let errorStr = String(cString: errorMsg)
|
|
734
|
+
ctx.onToken("Error: \(errorStr)", true)
|
|
735
|
+
ctx.promise.reject(withError: NSError(domain: "LiteRTLM", code: 500, userInfo: [NSLocalizedDescriptionKey: errorStr]))
|
|
736
|
+
Unmanaged<StreamContext>.fromOpaque(callbackData).release()
|
|
737
|
+
return
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if isFinal {
|
|
741
|
+
let endTime = Date()
|
|
742
|
+
let totalTime = endTime.timeIntervalSince(ctx.startTime)
|
|
743
|
+
|
|
744
|
+
let cleaned = ctx.parent.stripControlTokens(ctx.rawResponse)
|
|
745
|
+
var finalCleaned = cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
746
|
+
if !ctx.userMessage.isEmpty && finalCleaned.hasPrefix(ctx.userMessage) {
|
|
747
|
+
finalCleaned = String(finalCleaned.dropFirst(ctx.userMessage.count))
|
|
748
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if finalCleaned.count > ctx.lastEmittedLength {
|
|
752
|
+
let startIdx = finalCleaned.index(finalCleaned.startIndex, offsetBy: ctx.lastEmittedLength)
|
|
753
|
+
let remaining = String(finalCleaned[startIdx...])
|
|
754
|
+
ctx.onToken(remaining, false)
|
|
755
|
+
}
|
|
756
|
+
ctx.fullResponse = finalCleaned
|
|
757
|
+
|
|
758
|
+
ctx.parent.queue.async {
|
|
759
|
+
var completionTokens = Double(ctx.tokenCount)
|
|
760
|
+
var tokensPerSecond = 0.0
|
|
761
|
+
var ttft = 0.0
|
|
762
|
+
if let benchInfo = litert_lm_conversation_get_benchmark_info(ctx.parent.conversation) {
|
|
763
|
+
let numDecodeTurns = litert_lm_benchmark_info_get_num_decode_turns(benchInfo)
|
|
764
|
+
if numDecodeTurns > 0 {
|
|
765
|
+
let lastIdx = numDecodeTurns - 1
|
|
766
|
+
tokensPerSecond = litert_lm_benchmark_info_get_decode_tokens_per_sec_at(benchInfo, lastIdx)
|
|
767
|
+
completionTokens = Double(litert_lm_benchmark_info_get_decode_token_count_at(benchInfo, lastIdx))
|
|
768
|
+
}
|
|
769
|
+
ttft = litert_lm_benchmark_info_get_time_to_first_token(benchInfo)
|
|
770
|
+
litert_lm_benchmark_info_delete(benchInfo)
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
let promptTokens = Double(ctx.userMessage.count) / 4.0
|
|
774
|
+
if completionTokens == 0.0 {
|
|
775
|
+
completionTokens = Double(ctx.fullResponse.count) / 4.0
|
|
776
|
+
}
|
|
777
|
+
ctx.parent.lastStats = GenerationStats(
|
|
778
|
+
promptTokens: promptTokens,
|
|
779
|
+
completionTokens: completionTokens,
|
|
780
|
+
totalTokens: promptTokens + completionTokens,
|
|
781
|
+
timeToFirstToken: ttft,
|
|
782
|
+
totalTime: totalTime,
|
|
783
|
+
tokensPerSecond: tokensPerSecond > 0.0 ? tokensPerSecond : (completionTokens / totalTime)
|
|
784
|
+
)
|
|
785
|
+
ctx.parent.history.append(Message(role: .user, content: ctx.historyUserContent))
|
|
786
|
+
ctx.parent.history.append(Message(role: .model, content: ctx.fullResponse))
|
|
787
|
+
ctx.onToken("", true)
|
|
788
|
+
ctx.promise.resolve()
|
|
789
|
+
Unmanaged<StreamContext>.fromOpaque(callbackData).release()
|
|
790
|
+
}
|
|
791
|
+
return
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
if let chunk = chunk {
|
|
795
|
+
let token = String(cString: chunk)
|
|
796
|
+
let raw: String
|
|
797
|
+
if token.hasPrefix("{") && token.contains("\"role\"") {
|
|
798
|
+
raw = ctx.parent.extractTextFromResponse(token)
|
|
799
|
+
} else {
|
|
800
|
+
raw = token
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
ctx.rawResponse += raw
|
|
804
|
+
let cleaned = ctx.parent.stripControlTokens(ctx.rawResponse)
|
|
805
|
+
.trimmingLeadingCharacters(in: .whitespacesAndNewlines)
|
|
806
|
+
|
|
807
|
+
var processed = cleaned
|
|
808
|
+
if !ctx.userMessage.isEmpty && processed.hasPrefix(ctx.userMessage) {
|
|
809
|
+
processed = String(processed.dropFirst(ctx.userMessage.count))
|
|
810
|
+
.trimmingLeadingCharacters(in: .whitespacesAndNewlines)
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
let safeLen = ctx.parent.safeEmitLength(processed)
|
|
814
|
+
if safeLen > ctx.lastEmittedLength {
|
|
815
|
+
let chars = Array(processed)
|
|
816
|
+
let newText = String(chars[ctx.lastEmittedLength..<safeLen])
|
|
817
|
+
ctx.lastEmittedLength = safeLen
|
|
818
|
+
ctx.tokenCount += 1
|
|
819
|
+
ctx.onToken(newText, false)
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
let status = litert_lm_conversation_send_message_stream(
|
|
825
|
+
conversation,
|
|
826
|
+
msgJson,
|
|
827
|
+
nil,
|
|
828
|
+
nil,
|
|
829
|
+
callback,
|
|
830
|
+
callbackData
|
|
831
|
+
)
|
|
832
|
+
if status != 0 {
|
|
833
|
+
Unmanaged<StreamContext>.fromOpaque(callbackData).release()
|
|
834
|
+
promise.reject(withError: NSError(domain: "LiteRTLM", code: Int(status), userInfo: [NSLocalizedDescriptionKey: "Failed to start streaming conversation."]))
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
return promise
|
|
839
|
+
}
|
|
840
|
+
|
|
546
841
|
public func sendMessageWithAudio(message: String, audioPath: String) throws -> Promise<String> {
|
|
547
842
|
let promise = Promise<String>()
|
|
548
843
|
|
|
@@ -53,6 +53,64 @@ class HybridLiteRTLMTests: XCTestCase {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
func testSendMessageAsyncRejectsWithoutModel() async throws {
|
|
57
|
+
do {
|
|
58
|
+
let promise = try bridge.sendMessageAsync(message: "hello") { _, _ in }
|
|
59
|
+
_ = try await promise.await()
|
|
60
|
+
XCTFail("Should have failed without model")
|
|
61
|
+
} catch {
|
|
62
|
+
let nsError = error as NSError
|
|
63
|
+
XCTAssertEqual(nsError.domain, "LiteRTLM")
|
|
64
|
+
XCTAssertEqual(nsError.code, 400)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func testSendMessageWithImageAsyncRejectsWithoutModel() async throws {
|
|
69
|
+
do {
|
|
70
|
+
let promise = try bridge.sendMessageWithImageAsync(message: "hello", imagePath: "/tmp/image.jpg") { _, _ in }
|
|
71
|
+
_ = try await promise.await()
|
|
72
|
+
XCTFail("Should have failed without model")
|
|
73
|
+
} catch {
|
|
74
|
+
let nsError = error as NSError
|
|
75
|
+
XCTAssertEqual(nsError.domain, "LiteRTLM")
|
|
76
|
+
XCTAssertEqual(nsError.code, 400)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func testSendMessageWithAudioAsyncRejectsWithoutModel() async throws {
|
|
81
|
+
do {
|
|
82
|
+
let promise = try bridge.sendMessageWithAudioAsync(message: "hello", audioPath: "/tmp/audio.wav") { _, _ in }
|
|
83
|
+
_ = try await promise.await()
|
|
84
|
+
XCTFail("Should have failed without model")
|
|
85
|
+
} catch {
|
|
86
|
+
let nsError = error as NSError
|
|
87
|
+
XCTAssertEqual(nsError.domain, "LiteRTLM")
|
|
88
|
+
XCTAssertEqual(nsError.code, 400)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func testSendMessageWithImageAsyncRejectsFileNotFound() async throws {
|
|
93
|
+
do {
|
|
94
|
+
let promise = try bridge.sendMessageWithImageAsync(message: "hello", imagePath: "/nonexistent/image.jpg") { _, _ in }
|
|
95
|
+
_ = try await promise.await()
|
|
96
|
+
XCTFail("Should have failed without model")
|
|
97
|
+
} catch {
|
|
98
|
+
let nsError = error as NSError
|
|
99
|
+
XCTAssertEqual(nsError.domain, "LiteRTLM")
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func testSendMessageWithAudioAsyncRejectsFileNotFound() async throws {
|
|
104
|
+
do {
|
|
105
|
+
let promise = try bridge.sendMessageWithAudioAsync(message: "hello", audioPath: "/nonexistent/audio.wav") { _, _ in }
|
|
106
|
+
_ = try await promise.await()
|
|
107
|
+
XCTFail("Should have failed without model")
|
|
108
|
+
} catch {
|
|
109
|
+
let nsError = error as NSError
|
|
110
|
+
XCTAssertEqual(nsError.domain, "LiteRTLM")
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
56
114
|
func testInitialStats() {
|
|
57
115
|
XCTAssertNoThrow(try bridge.getStats())
|
|
58
116
|
if let stats = try? bridge.getStats() {
|
|
@@ -8,6 +8,8 @@ export declare const mockLiteRTLM: {
|
|
|
8
8
|
sendMessageWithAudio: jest.Mock<any, any, any>;
|
|
9
9
|
sendMultimodalMessage: jest.Mock<any, any, any>;
|
|
10
10
|
sendMessageAsync: jest.Mock<Promise<void>, [msg: any, onToken: any], any>;
|
|
11
|
+
sendMessageWithImageAsync: jest.Mock<Promise<void>, [msg: any, imagePath: any, onToken: any], any>;
|
|
12
|
+
sendMessageWithAudioAsync: jest.Mock<Promise<void>, [msg: any, audioPath: any, onToken: any], any>;
|
|
11
13
|
getHistory: jest.Mock<never[], [], any>;
|
|
12
14
|
resetConversation: jest.Mock<any, any, any>;
|
|
13
15
|
getStats: jest.Mock<{
|
|
@@ -38,6 +40,8 @@ export declare const NitroModules: {
|
|
|
38
40
|
sendMessageWithAudio: jest.Mock<any, any, any>;
|
|
39
41
|
sendMultimodalMessage: jest.Mock<any, any, any>;
|
|
40
42
|
sendMessageAsync: jest.Mock<Promise<void>, [msg: any, onToken: any], any>;
|
|
43
|
+
sendMessageWithImageAsync: jest.Mock<Promise<void>, [msg: any, imagePath: any, onToken: any], any>;
|
|
44
|
+
sendMessageWithAudioAsync: jest.Mock<Promise<void>, [msg: any, audioPath: any, onToken: any], any>;
|
|
41
45
|
getHistory: jest.Mock<never[], [], any>;
|
|
42
46
|
resetConversation: jest.Mock<any, any, any>;
|
|
43
47
|
getStats: jest.Mock<{
|