react-native-litert-lm 0.4.0 → 0.4.1
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 +321 -35
- package/ios/Tests/HybridLiteRTLMTests.swift +46 -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
|
@@ -117,7 +117,7 @@ public class HybridLiteRTLM: HybridLiteRTLMSpec_base, HybridLiteRTLMSpec_protoco
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
public func countTokens(text: String) throws -> Double {
|
|
120
|
-
return
|
|
120
|
+
return queue.sync {
|
|
121
121
|
guard let engine = self.engine else {
|
|
122
122
|
return -1.0
|
|
123
123
|
}
|
|
@@ -406,42 +406,50 @@ public class HybridLiteRTLM: HybridLiteRTLMSpec_base, HybridLiteRTLMSpec_protoco
|
|
|
406
406
|
ctx.onToken(remaining, false)
|
|
407
407
|
}
|
|
408
408
|
ctx.fullResponse = finalCleaned
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
409
|
+
|
|
410
|
+
// This callback fires on an engine-internal thread (the C API
|
|
411
|
+
// returns once the stream *starts*), so commit the shared
|
|
412
|
+
// lastStats/history — and the conversation benchmark read — on
|
|
413
|
+
// the serial engine queue to avoid racing getStats()/getHistory().
|
|
414
|
+
// Resolving inside the same block guarantees JS observes the
|
|
415
|
+
// final turn before the promise settles.
|
|
416
|
+
ctx.parent.queue.async {
|
|
417
|
+
var completionTokens = Double(ctx.tokenCount)
|
|
418
|
+
var tokensPerSecond = 0.0
|
|
419
|
+
var ttft = 0.0
|
|
420
|
+
|
|
421
|
+
if let benchInfo = litert_lm_conversation_get_benchmark_info(ctx.parent.conversation) {
|
|
422
|
+
let numDecodeTurns = litert_lm_benchmark_info_get_num_decode_turns(benchInfo)
|
|
423
|
+
if numDecodeTurns > 0 {
|
|
424
|
+
let lastIdx = numDecodeTurns - 1
|
|
425
|
+
tokensPerSecond = litert_lm_benchmark_info_get_decode_tokens_per_sec_at(benchInfo, lastIdx)
|
|
426
|
+
completionTokens = Double(litert_lm_benchmark_info_get_decode_token_count_at(benchInfo, lastIdx))
|
|
427
|
+
}
|
|
428
|
+
ttft = litert_lm_benchmark_info_get_time_to_first_token(benchInfo)
|
|
429
|
+
litert_lm_benchmark_info_delete(benchInfo)
|
|
420
430
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
431
|
+
|
|
432
|
+
let promptTokens = Double(ctx.userMessage.count) / 4.0
|
|
433
|
+
if completionTokens == 0.0 {
|
|
434
|
+
completionTokens = Double(ctx.fullResponse.count) / 4.0
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
ctx.parent.lastStats = GenerationStats(
|
|
438
|
+
promptTokens: promptTokens,
|
|
439
|
+
completionTokens: completionTokens,
|
|
440
|
+
totalTokens: promptTokens + completionTokens,
|
|
441
|
+
timeToFirstToken: ttft,
|
|
442
|
+
totalTime: totalTime,
|
|
443
|
+
tokensPerSecond: tokensPerSecond > 0.0 ? tokensPerSecond : (completionTokens / totalTime)
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
ctx.parent.history.append(Message(role: .user, content: ctx.userMessage))
|
|
447
|
+
ctx.parent.history.append(Message(role: .model, content: ctx.fullResponse))
|
|
448
|
+
|
|
449
|
+
ctx.onToken("", true)
|
|
450
|
+
ctx.promise.resolve()
|
|
451
|
+
Unmanaged<StreamContext>.fromOpaque(callbackData).release()
|
|
428
452
|
}
|
|
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
453
|
return
|
|
446
454
|
}
|
|
447
455
|
|
|
@@ -542,7 +550,285 @@ public class HybridLiteRTLM: HybridLiteRTLMSpec_base, HybridLiteRTLMSpec_protoco
|
|
|
542
550
|
|
|
543
551
|
return promise
|
|
544
552
|
}
|
|
553
|
+
|
|
554
|
+
public func sendMessageWithImageAsync(message: String, imagePath: String, onToken: @escaping (_ token: String, _ done: Bool) -> Void) throws -> Promise<Void> {
|
|
555
|
+
let promise = Promise<Void>()
|
|
556
|
+
|
|
557
|
+
queue.async {
|
|
558
|
+
guard let conversation = self.conversation else {
|
|
559
|
+
promise.reject(withError: NSError(domain: "LiteRTLM", code: 400, userInfo: [NSLocalizedDescriptionKey: "LiteRTLM: No model loaded. Call loadModel() first."]))
|
|
560
|
+
return
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if !FileManager.default.fileExists(atPath: imagePath) {
|
|
564
|
+
promise.reject(withError: NSError(domain: "LiteRTLM", code: 404, userInfo: [NSLocalizedDescriptionKey: "Image file not found: \(imagePath)"]))
|
|
565
|
+
return
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
let msgJson = self.buildImageMessageJson(text: message, imagePath: imagePath)
|
|
569
|
+
let startTime = Date()
|
|
570
|
+
|
|
571
|
+
let historyUserContent = message + " [image: \(imagePath)]"
|
|
572
|
+
let context = StreamContext(
|
|
573
|
+
userMessage: message,
|
|
574
|
+
startTime: startTime,
|
|
575
|
+
onToken: onToken,
|
|
576
|
+
promise: promise,
|
|
577
|
+
parent: self
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
let callbackData = Unmanaged.passRetained(context).toOpaque()
|
|
581
|
+
|
|
582
|
+
let callback: LiteRtLmStreamCallback = { callbackData, chunk, isFinal, errorMsg in
|
|
583
|
+
guard let callbackData = callbackData else { return }
|
|
584
|
+
let ctx = Unmanaged<StreamContext>.fromOpaque(callbackData).takeUnretainedValue()
|
|
585
|
+
|
|
586
|
+
if let errorMsg = errorMsg {
|
|
587
|
+
let errorStr = String(cString: errorMsg)
|
|
588
|
+
ctx.onToken("Error: \(errorStr)", true)
|
|
589
|
+
ctx.promise.reject(withError: NSError(domain: "LiteRTLM", code: 500, userInfo: [NSLocalizedDescriptionKey: errorStr]))
|
|
590
|
+
Unmanaged<StreamContext>.fromOpaque(callbackData).release()
|
|
591
|
+
return
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if isFinal {
|
|
595
|
+
let endTime = Date()
|
|
596
|
+
let totalTime = endTime.timeIntervalSince(ctx.startTime)
|
|
597
|
+
|
|
598
|
+
let cleaned = ctx.parent.stripControlTokens(ctx.rawResponse)
|
|
599
|
+
var finalCleaned = cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
600
|
+
if !ctx.userMessage.isEmpty && finalCleaned.hasPrefix(ctx.userMessage) {
|
|
601
|
+
finalCleaned = String(finalCleaned.dropFirst(ctx.userMessage.count))
|
|
602
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if finalCleaned.count > ctx.lastEmittedLength {
|
|
606
|
+
let startIdx = finalCleaned.index(finalCleaned.startIndex, offsetBy: ctx.lastEmittedLength)
|
|
607
|
+
let remaining = String(finalCleaned[startIdx...])
|
|
608
|
+
ctx.onToken(remaining, false)
|
|
609
|
+
}
|
|
610
|
+
ctx.fullResponse = finalCleaned
|
|
611
|
+
|
|
612
|
+
var completionTokens = Double(ctx.tokenCount)
|
|
613
|
+
var tokensPerSecond = 0.0
|
|
614
|
+
var ttft = 0.0
|
|
615
|
+
if let benchInfo = litert_lm_conversation_get_benchmark_info(ctx.parent.conversation) {
|
|
616
|
+
let numDecodeTurns = litert_lm_benchmark_info_get_num_decode_turns(benchInfo)
|
|
617
|
+
if numDecodeTurns > 0 {
|
|
618
|
+
let lastIdx = numDecodeTurns - 1
|
|
619
|
+
tokensPerSecond = litert_lm_benchmark_info_get_decode_tokens_per_sec_at(benchInfo, lastIdx)
|
|
620
|
+
completionTokens = Double(litert_lm_benchmark_info_get_decode_token_count_at(benchInfo, lastIdx))
|
|
621
|
+
}
|
|
622
|
+
ttft = litert_lm_benchmark_info_get_time_to_first_token(benchInfo)
|
|
623
|
+
litert_lm_benchmark_info_delete(benchInfo)
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
let promptTokens = Double(ctx.userMessage.count) / 4.0
|
|
627
|
+
if completionTokens == 0.0 {
|
|
628
|
+
completionTokens = Double(ctx.fullResponse.count) / 4.0
|
|
629
|
+
}
|
|
630
|
+
ctx.parent.lastStats = GenerationStats(
|
|
631
|
+
promptTokens: promptTokens,
|
|
632
|
+
completionTokens: completionTokens,
|
|
633
|
+
totalTokens: promptTokens + completionTokens,
|
|
634
|
+
timeToFirstToken: ttft,
|
|
635
|
+
totalTime: totalTime,
|
|
636
|
+
tokensPerSecond: tokensPerSecond > 0.0 ? tokensPerSecond : (completionTokens / totalTime)
|
|
637
|
+
)
|
|
638
|
+
ctx.parent.history.append(Message(role: .user, content: historyUserContent))
|
|
639
|
+
ctx.parent.history.append(Message(role: .model, content: ctx.fullResponse))
|
|
640
|
+
ctx.onToken("", true)
|
|
641
|
+
ctx.promise.resolve()
|
|
642
|
+
Unmanaged<StreamContext>.fromOpaque(callbackData).release()
|
|
643
|
+
return
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if let chunk = chunk {
|
|
647
|
+
let token = String(cString: chunk)
|
|
648
|
+
let raw: String
|
|
649
|
+
if token.hasPrefix("{") && token.contains("\"role\"") {
|
|
650
|
+
raw = ctx.parent.extractTextFromResponse(token)
|
|
651
|
+
} else {
|
|
652
|
+
raw = token
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
ctx.rawResponse += raw
|
|
656
|
+
let cleaned = ctx.parent.stripControlTokens(ctx.rawResponse)
|
|
657
|
+
.trimmingLeadingCharacters(in: .whitespacesAndNewlines)
|
|
658
|
+
|
|
659
|
+
var processed = cleaned
|
|
660
|
+
if !ctx.userMessage.isEmpty && processed.hasPrefix(ctx.userMessage) {
|
|
661
|
+
processed = String(processed.dropFirst(ctx.userMessage.count))
|
|
662
|
+
.trimmingLeadingCharacters(in: .whitespacesAndNewlines)
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
let safeLen = ctx.parent.safeEmitLength(processed)
|
|
666
|
+
if safeLen > ctx.lastEmittedLength {
|
|
667
|
+
let chars = Array(processed)
|
|
668
|
+
let newText = String(chars[ctx.lastEmittedLength..<safeLen])
|
|
669
|
+
ctx.lastEmittedLength = safeLen
|
|
670
|
+
ctx.tokenCount += 1
|
|
671
|
+
ctx.onToken(newText, false)
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
let status = litert_lm_conversation_send_message_stream(
|
|
677
|
+
conversation,
|
|
678
|
+
msgJson,
|
|
679
|
+
nil,
|
|
680
|
+
nil,
|
|
681
|
+
callback,
|
|
682
|
+
callbackData
|
|
683
|
+
)
|
|
684
|
+
if status != 0 {
|
|
685
|
+
Unmanaged<StreamContext>.fromOpaque(callbackData).release()
|
|
686
|
+
promise.reject(withError: NSError(domain: "LiteRTLM", code: Int(status), userInfo: [NSLocalizedDescriptionKey: "Failed to start streaming conversation."]))
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
return promise
|
|
691
|
+
}
|
|
545
692
|
|
|
693
|
+
public func sendMessageWithAudioAsync(message: String, audioPath: String, onToken: @escaping (_ token: String, _ done: Bool) -> Void) throws -> Promise<Void> {
|
|
694
|
+
let promise = Promise<Void>()
|
|
695
|
+
|
|
696
|
+
queue.async {
|
|
697
|
+
guard let conversation = self.conversation else {
|
|
698
|
+
promise.reject(withError: NSError(domain: "LiteRTLM", code: 400, userInfo: [NSLocalizedDescriptionKey: "LiteRTLM: No model loaded. Call loadModel() first."]))
|
|
699
|
+
return
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if !FileManager.default.fileExists(atPath: audioPath) {
|
|
703
|
+
promise.reject(withError: NSError(domain: "LiteRTLM", code: 404, userInfo: [NSLocalizedDescriptionKey: "Audio file not found: \(audioPath)"]))
|
|
704
|
+
return
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
let msgJson = self.buildAudioMessageJson(text: message, audioPath: audioPath)
|
|
708
|
+
let startTime = Date()
|
|
709
|
+
|
|
710
|
+
let historyUserContent = message + " [audio: \(audioPath)]"
|
|
711
|
+
let context = StreamContext(
|
|
712
|
+
userMessage: message,
|
|
713
|
+
startTime: startTime,
|
|
714
|
+
onToken: onToken,
|
|
715
|
+
promise: promise,
|
|
716
|
+
parent: self
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
let callbackData = Unmanaged.passRetained(context).toOpaque()
|
|
720
|
+
|
|
721
|
+
let callback: LiteRtLmStreamCallback = { callbackData, chunk, isFinal, errorMsg in
|
|
722
|
+
guard let callbackData = callbackData else { return }
|
|
723
|
+
let ctx = Unmanaged<StreamContext>.fromOpaque(callbackData).takeUnretainedValue()
|
|
724
|
+
|
|
725
|
+
if let errorMsg = errorMsg {
|
|
726
|
+
let errorStr = String(cString: errorMsg)
|
|
727
|
+
ctx.onToken("Error: \(errorStr)", true)
|
|
728
|
+
ctx.promise.reject(withError: NSError(domain: "LiteRTLM", code: 500, userInfo: [NSLocalizedDescriptionKey: errorStr]))
|
|
729
|
+
Unmanaged<StreamContext>.fromOpaque(callbackData).release()
|
|
730
|
+
return
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if isFinal {
|
|
734
|
+
let endTime = Date()
|
|
735
|
+
let totalTime = endTime.timeIntervalSince(ctx.startTime)
|
|
736
|
+
|
|
737
|
+
let cleaned = ctx.parent.stripControlTokens(ctx.rawResponse)
|
|
738
|
+
var finalCleaned = cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
739
|
+
if !ctx.userMessage.isEmpty && finalCleaned.hasPrefix(ctx.userMessage) {
|
|
740
|
+
finalCleaned = String(finalCleaned.dropFirst(ctx.userMessage.count))
|
|
741
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if finalCleaned.count > ctx.lastEmittedLength {
|
|
745
|
+
let startIdx = finalCleaned.index(finalCleaned.startIndex, offsetBy: ctx.lastEmittedLength)
|
|
746
|
+
let remaining = String(finalCleaned[startIdx...])
|
|
747
|
+
ctx.onToken(remaining, false)
|
|
748
|
+
}
|
|
749
|
+
ctx.fullResponse = finalCleaned
|
|
750
|
+
|
|
751
|
+
var completionTokens = Double(ctx.tokenCount)
|
|
752
|
+
var tokensPerSecond = 0.0
|
|
753
|
+
var ttft = 0.0
|
|
754
|
+
if let benchInfo = litert_lm_conversation_get_benchmark_info(ctx.parent.conversation) {
|
|
755
|
+
let numDecodeTurns = litert_lm_benchmark_info_get_num_decode_turns(benchInfo)
|
|
756
|
+
if numDecodeTurns > 0 {
|
|
757
|
+
let lastIdx = numDecodeTurns - 1
|
|
758
|
+
tokensPerSecond = litert_lm_benchmark_info_get_decode_tokens_per_sec_at(benchInfo, lastIdx)
|
|
759
|
+
completionTokens = Double(litert_lm_benchmark_info_get_decode_token_count_at(benchInfo, lastIdx))
|
|
760
|
+
}
|
|
761
|
+
ttft = litert_lm_benchmark_info_get_time_to_first_token(benchInfo)
|
|
762
|
+
litert_lm_benchmark_info_delete(benchInfo)
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
let promptTokens = Double(ctx.userMessage.count) / 4.0
|
|
766
|
+
if completionTokens == 0.0 {
|
|
767
|
+
completionTokens = Double(ctx.fullResponse.count) / 4.0
|
|
768
|
+
}
|
|
769
|
+
ctx.parent.lastStats = GenerationStats(
|
|
770
|
+
promptTokens: promptTokens,
|
|
771
|
+
completionTokens: completionTokens,
|
|
772
|
+
totalTokens: promptTokens + completionTokens,
|
|
773
|
+
timeToFirstToken: ttft,
|
|
774
|
+
totalTime: totalTime,
|
|
775
|
+
tokensPerSecond: tokensPerSecond > 0.0 ? tokensPerSecond : (completionTokens / totalTime)
|
|
776
|
+
)
|
|
777
|
+
ctx.parent.history.append(Message(role: .user, content: historyUserContent))
|
|
778
|
+
ctx.parent.history.append(Message(role: .model, content: ctx.fullResponse))
|
|
779
|
+
ctx.onToken("", true)
|
|
780
|
+
ctx.promise.resolve()
|
|
781
|
+
Unmanaged<StreamContext>.fromOpaque(callbackData).release()
|
|
782
|
+
return
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if let chunk = chunk {
|
|
786
|
+
let token = String(cString: chunk)
|
|
787
|
+
let raw: String
|
|
788
|
+
if token.hasPrefix("{") && token.contains("\"role\"") {
|
|
789
|
+
raw = ctx.parent.extractTextFromResponse(token)
|
|
790
|
+
} else {
|
|
791
|
+
raw = token
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
ctx.rawResponse += raw
|
|
795
|
+
let cleaned = ctx.parent.stripControlTokens(ctx.rawResponse)
|
|
796
|
+
.trimmingLeadingCharacters(in: .whitespacesAndNewlines)
|
|
797
|
+
|
|
798
|
+
var processed = cleaned
|
|
799
|
+
if !ctx.userMessage.isEmpty && processed.hasPrefix(ctx.userMessage) {
|
|
800
|
+
processed = String(processed.dropFirst(ctx.userMessage.count))
|
|
801
|
+
.trimmingLeadingCharacters(in: .whitespacesAndNewlines)
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
let safeLen = ctx.parent.safeEmitLength(processed)
|
|
805
|
+
if safeLen > ctx.lastEmittedLength {
|
|
806
|
+
let chars = Array(processed)
|
|
807
|
+
let newText = String(chars[ctx.lastEmittedLength..<safeLen])
|
|
808
|
+
ctx.lastEmittedLength = safeLen
|
|
809
|
+
ctx.tokenCount += 1
|
|
810
|
+
ctx.onToken(newText, false)
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
let status = litert_lm_conversation_send_message_stream(
|
|
816
|
+
conversation,
|
|
817
|
+
msgJson,
|
|
818
|
+
nil,
|
|
819
|
+
nil,
|
|
820
|
+
callback,
|
|
821
|
+
callbackData
|
|
822
|
+
)
|
|
823
|
+
if status != 0 {
|
|
824
|
+
Unmanaged<StreamContext>.fromOpaque(callbackData).release()
|
|
825
|
+
promise.reject(withError: NSError(domain: "LiteRTLM", code: Int(status), userInfo: [NSLocalizedDescriptionKey: "Failed to start streaming conversation."]))
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
return promise
|
|
830
|
+
}
|
|
831
|
+
|
|
546
832
|
public func sendMessageWithAudio(message: String, audioPath: String) throws -> Promise<String> {
|
|
547
833
|
let promise = Promise<String>()
|
|
548
834
|
|
|
@@ -53,6 +53,52 @@ class HybridLiteRTLMTests: XCTestCase {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
func testSendMessageWithImageAsyncRejectsWithoutModel() async throws {
|
|
57
|
+
do {
|
|
58
|
+
let promise = try bridge.sendMessageWithImageAsync(message: "hello", imagePath: "/tmp/image.jpg") { _, _ 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 testSendMessageWithAudioAsyncRejectsWithoutModel() async throws {
|
|
69
|
+
do {
|
|
70
|
+
let promise = try bridge.sendMessageWithAudioAsync(message: "hello", audioPath: "/tmp/audio.wav") { _, _ 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 testSendMessageWithImageAsyncRejectsFileNotFound() async throws {
|
|
81
|
+
do {
|
|
82
|
+
let promise = try bridge.sendMessageWithImageAsync(message: "hello", imagePath: "/nonexistent/image.jpg") { _, _ 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
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
func testSendMessageWithAudioAsyncRejectsFileNotFound() async throws {
|
|
92
|
+
do {
|
|
93
|
+
let promise = try bridge.sendMessageWithAudioAsync(message: "hello", audioPath: "/nonexistent/audio.wav") { _, _ in }
|
|
94
|
+
_ = try await promise.await()
|
|
95
|
+
XCTFail("Should have failed without model")
|
|
96
|
+
} catch {
|
|
97
|
+
let nsError = error as NSError
|
|
98
|
+
XCTAssertEqual(nsError.domain, "LiteRTLM")
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
56
102
|
func testInitialStats() {
|
|
57
103
|
XCTAssertNoThrow(try bridge.getStats())
|
|
58
104
|
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<{
|
|
@@ -18,6 +18,16 @@ exports.mockLiteRTLM = {
|
|
|
18
18
|
onToken("token", true);
|
|
19
19
|
return Promise.resolve();
|
|
20
20
|
}),
|
|
21
|
+
sendMessageWithImageAsync: jest.fn((msg, imagePath, onToken) => {
|
|
22
|
+
onToken("Mock vision ", false);
|
|
23
|
+
onToken("token", true);
|
|
24
|
+
return Promise.resolve();
|
|
25
|
+
}),
|
|
26
|
+
sendMessageWithAudioAsync: jest.fn((msg, audioPath, onToken) => {
|
|
27
|
+
onToken("Mock audio ", false);
|
|
28
|
+
onToken("token", true);
|
|
29
|
+
return Promise.resolve();
|
|
30
|
+
}),
|
|
21
31
|
getHistory: jest.fn(() => []),
|
|
22
32
|
resetConversation: jest.fn(),
|
|
23
33
|
getStats: jest.fn(() => ({
|
|
@@ -41,6 +41,22 @@ describe('modelFactory Security & Proxy Unit Tests', () => {
|
|
|
41
41
|
expect(react_native_nitro_modules_1.mockLiteRTLM.sendMessageAsync).toHaveBeenCalled();
|
|
42
42
|
expect(react_native_nitro_modules_1.mockLiteRTLM.getMemoryUsage).toHaveBeenCalled();
|
|
43
43
|
});
|
|
44
|
+
it('should successfully proxy sendMessageWithImageAsync and record memory metrics when done', async () => {
|
|
45
|
+
const onToken = jest.fn();
|
|
46
|
+
await llm.sendMessageWithImageAsync("Vision prompt", "/path/to/image.jpg", onToken);
|
|
47
|
+
expect(onToken).toHaveBeenCalledWith("Mock vision ", false);
|
|
48
|
+
expect(onToken).toHaveBeenCalledWith("token", true);
|
|
49
|
+
expect(react_native_nitro_modules_1.mockLiteRTLM.sendMessageWithImageAsync).toHaveBeenCalledWith("Vision prompt", "/path/to/image.jpg", expect.any(Function));
|
|
50
|
+
expect(react_native_nitro_modules_1.mockLiteRTLM.getMemoryUsage).toHaveBeenCalled();
|
|
51
|
+
});
|
|
52
|
+
it('should successfully proxy sendMessageWithAudioAsync and record memory metrics when done', async () => {
|
|
53
|
+
const onToken = jest.fn();
|
|
54
|
+
await llm.sendMessageWithAudioAsync("Audio prompt", "/path/to/audio.wav", onToken);
|
|
55
|
+
expect(onToken).toHaveBeenCalledWith("Mock audio ", false);
|
|
56
|
+
expect(onToken).toHaveBeenCalledWith("token", true);
|
|
57
|
+
expect(react_native_nitro_modules_1.mockLiteRTLM.sendMessageWithAudioAsync).toHaveBeenCalledWith("Audio prompt", "/path/to/audio.wav", expect.any(Function));
|
|
58
|
+
expect(react_native_nitro_modules_1.mockLiteRTLM.getMemoryUsage).toHaveBeenCalled();
|
|
59
|
+
});
|
|
44
60
|
it('should successfully access memoryTracker and getSnapshots when memory tracking is enabled', () => {
|
|
45
61
|
expect(llm.memoryTracker).toBeDefined();
|
|
46
62
|
expect(llm.memoryTracker?.getCapacity()).toBe(256);
|