@dr33m/react-native-litert-lm 0.5.0
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 +21 -0
- package/README.md +627 -0
- package/android/CMakeLists.txt +32 -0
- package/android/build.gradle +104 -0
- package/android/src/main/AndroidManifest.xml +19 -0
- package/android/src/main/java/com/margelo/nitro/dev/litert/litertlm/HybridLiteRTLM.kt +888 -0
- package/android/src/main/java/com/margelo/nitro/dev/litert/litertlm/HybridModelStore.kt +189 -0
- package/android/src/main/java/com/margelo/nitro/dev/litert/litertlm/LiteRTLMRegistry.kt +32 -0
- package/android/src/main/java/com/margelo/nitro/dev/litert/litertlm/MultimodalPartFactories.kt +15 -0
- package/android/src/main/java/com/margelo/nitro/dev/litert/litertlm/StreamingCallbackListener.kt +89 -0
- package/android/src/main/java/dev/litert/litertlm/LiteRTLMInitProvider.kt +57 -0
- package/android/src/main/java/dev/litert/litertlm/LiteRTLMPackage.kt +43 -0
- package/android/src/test/java/com/margelo/nitro/core/Promise.kt +46 -0
- package/android/src/test/java/com/margelo/nitro/dev/litert/litertlm/HybridLiteRTLMTest.kt +122 -0
- package/app.plugin.js +58 -0
- package/cpp/cpp-adapter.cpp +21 -0
- package/cpp/include/README.md +32 -0
- package/cpp/include/litert_lm_engine.h +516 -0
- package/cpp/include/stb_image.h +7988 -0
- package/ios/HybridLiteRTLM+Execute.swift +287 -0
- package/ios/HybridLiteRTLM+Streaming.swift +153 -0
- package/ios/HybridLiteRTLM.swift +541 -0
- package/ios/HybridModelStore.swift +206 -0
- package/ios/MultimodalPart+Factories.swift +22 -0
- package/ios/Tests/HybridLiteRTLMTests.swift +187 -0
- package/lib/__mocks__/react-native-nitro-modules.d.ts +81 -0
- package/lib/__mocks__/react-native-nitro-modules.js +98 -0
- package/lib/__tests__/hooks.test.d.ts +1 -0
- package/lib/__tests__/hooks.test.js +175 -0
- package/lib/__tests__/inferenceRouting.test.d.ts +1 -0
- package/lib/__tests__/inferenceRouting.test.js +28 -0
- package/lib/__tests__/memoryTracker.test.d.ts +1 -0
- package/lib/__tests__/memoryTracker.test.js +74 -0
- package/lib/__tests__/modelFactory.test.d.ts +1 -0
- package/lib/__tests__/modelFactory.test.js +107 -0
- package/lib/__tests__/modelPath.test.d.ts +1 -0
- package/lib/__tests__/modelPath.test.js +12 -0
- package/lib/__tests__/modelRegistry.test.d.ts +1 -0
- package/lib/__tests__/modelRegistry.test.js +61 -0
- package/lib/hooks.d.ts +46 -0
- package/lib/hooks.js +159 -0
- package/lib/index.d.ts +142 -0
- package/lib/index.js +184 -0
- package/lib/inferenceRouting.d.ts +18 -0
- package/lib/inferenceRouting.js +63 -0
- package/lib/memoryTracker.d.ts +128 -0
- package/lib/memoryTracker.js +155 -0
- package/lib/modelFactory.d.ts +24 -0
- package/lib/modelFactory.js +128 -0
- package/lib/modelPath.d.ts +8 -0
- package/lib/modelPath.js +18 -0
- package/lib/modelRegistry.d.ts +55 -0
- package/lib/modelRegistry.js +82 -0
- package/lib/specs/LiteRTLM.nitro.d.ts +380 -0
- package/lib/specs/LiteRTLM.nitro.js +2 -0
- package/nitrogen/generated/.gitattributes +1 -0
- package/nitrogen/generated/android/LiteRTLM+autolinking.cmake +83 -0
- package/nitrogen/generated/android/LiteRTLM+autolinking.gradle +27 -0
- package/nitrogen/generated/android/LiteRTLMOnLoad.cpp +74 -0
- package/nitrogen/generated/android/LiteRTLMOnLoad.hpp +34 -0
- package/nitrogen/generated/android/c++/JBackend.hpp +61 -0
- package/nitrogen/generated/android/c++/JExecuteResult.hpp +86 -0
- package/nitrogen/generated/android/c++/JFunc_void_double.hpp +75 -0
- package/nitrogen/generated/android/c++/JFunc_void_std__string_bool.hpp +76 -0
- package/nitrogen/generated/android/c++/JGenerationStats.hpp +77 -0
- package/nitrogen/generated/android/c++/JHybridLiteRTLMSpec.cpp +371 -0
- package/nitrogen/generated/android/c++/JHybridLiteRTLMSpec.hpp +83 -0
- package/nitrogen/generated/android/c++/JHybridModelStoreSpec.cpp +101 -0
- package/nitrogen/generated/android/c++/JHybridModelStoreSpec.hpp +67 -0
- package/nitrogen/generated/android/c++/JLLMConfig.hpp +125 -0
- package/nitrogen/generated/android/c++/JMemoryUsage.hpp +69 -0
- package/nitrogen/generated/android/c++/JMessage.hpp +63 -0
- package/nitrogen/generated/android/c++/JModelFile.hpp +69 -0
- package/nitrogen/generated/android/c++/JMultimodalPart.hpp +78 -0
- package/nitrogen/generated/android/c++/JPartType.hpp +61 -0
- package/nitrogen/generated/android/c++/JRole.hpp +61 -0
- package/nitrogen/generated/android/c++/JToolCall.hpp +61 -0
- package/nitrogen/generated/android/c++/JToolDefinition.hpp +65 -0
- package/nitrogen/generated/android/c++/JToolResponse.hpp +61 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/Backend.kt +24 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/ExecuteResult.kt +61 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/Func_void_double.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/Func_void_std__string_bool.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/GenerationStats.kt +76 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/HybridLiteRTLMSpec.kt +165 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/HybridModelStoreSpec.kt +76 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/LLMConfig.kt +106 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/LiteRTLMOnLoad.kt +35 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/MemoryUsage.kt +66 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/Message.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/ModelFile.kt +66 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/MultimodalPart.kt +71 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/PartType.kt +24 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/Role.kt +24 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/ToolCall.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/ToolDefinition.kt +61 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/dev/litert/litertlm/ToolResponse.kt +56 -0
- package/nitrogen/generated/ios/LiteRTLM+autolinking.rb +62 -0
- package/nitrogen/generated/ios/LiteRTLM-Swift-Cxx-Bridge.cpp +98 -0
- package/nitrogen/generated/ios/LiteRTLM-Swift-Cxx-Bridge.hpp +585 -0
- package/nitrogen/generated/ios/LiteRTLM-Swift-Cxx-Umbrella.hpp +93 -0
- package/nitrogen/generated/ios/LiteRTLMAutolinking.mm +41 -0
- package/nitrogen/generated/ios/LiteRTLMAutolinking.swift +38 -0
- package/nitrogen/generated/ios/c++/HybridLiteRTLMSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridLiteRTLMSpecSwift.hpp +279 -0
- package/nitrogen/generated/ios/c++/HybridModelStoreSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridModelStoreSpecSwift.hpp +117 -0
- package/nitrogen/generated/ios/swift/Backend.swift +44 -0
- package/nitrogen/generated/ios/swift/ExecuteResult.swift +45 -0
- package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_ExecuteResult.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_double.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__string_bool.swift +46 -0
- package/nitrogen/generated/ios/swift/GenerationStats.swift +54 -0
- package/nitrogen/generated/ios/swift/HybridLiteRTLMSpec.swift +75 -0
- package/nitrogen/generated/ios/swift/HybridLiteRTLMSpec_cxx.swift +516 -0
- package/nitrogen/generated/ios/swift/HybridModelStoreSpec.swift +59 -0
- package/nitrogen/generated/ios/swift/HybridModelStoreSpec_cxx.swift +203 -0
- package/nitrogen/generated/ios/swift/LLMConfig.swift +239 -0
- package/nitrogen/generated/ios/swift/MemoryUsage.swift +44 -0
- package/nitrogen/generated/ios/swift/Message.swift +34 -0
- package/nitrogen/generated/ios/swift/ModelFile.swift +44 -0
- package/nitrogen/generated/ios/swift/MultimodalPart.swift +101 -0
- package/nitrogen/generated/ios/swift/PartType.swift +44 -0
- package/nitrogen/generated/ios/swift/Role.swift +44 -0
- package/nitrogen/generated/ios/swift/ToolCall.swift +34 -0
- package/nitrogen/generated/ios/swift/ToolDefinition.swift +39 -0
- package/nitrogen/generated/ios/swift/ToolResponse.swift +34 -0
- package/nitrogen/generated/shared/c++/Backend.hpp +80 -0
- package/nitrogen/generated/shared/c++/ExecuteResult.hpp +94 -0
- package/nitrogen/generated/shared/c++/GenerationStats.hpp +103 -0
- package/nitrogen/generated/shared/c++/HybridLiteRTLMSpec.cpp +41 -0
- package/nitrogen/generated/shared/c++/HybridLiteRTLMSpec.hpp +109 -0
- package/nitrogen/generated/shared/c++/HybridModelStoreSpec.cpp +25 -0
- package/nitrogen/generated/shared/c++/HybridModelStoreSpec.hpp +71 -0
- package/nitrogen/generated/shared/c++/LLMConfig.hpp +134 -0
- package/nitrogen/generated/shared/c++/MemoryUsage.hpp +95 -0
- package/nitrogen/generated/shared/c++/Message.hpp +89 -0
- package/nitrogen/generated/shared/c++/ModelFile.hpp +95 -0
- package/nitrogen/generated/shared/c++/MultimodalPart.hpp +103 -0
- package/nitrogen/generated/shared/c++/PartType.hpp +80 -0
- package/nitrogen/generated/shared/c++/Role.hpp +80 -0
- package/nitrogen/generated/shared/c++/ToolCall.hpp +87 -0
- package/nitrogen/generated/shared/c++/ToolDefinition.hpp +91 -0
- package/nitrogen/generated/shared/c++/ToolResponse.hpp +87 -0
- package/package.json +115 -0
- package/react-native-litert-lm.podspec +62 -0
- package/react-native.config.js +16 -0
- package/scripts/download-ios-frameworks.sh +39 -0
- package/scripts/framework-source.js +46 -0
- package/scripts/postinstall.js +145 -0
- package/src/__mocks__/react-native-nitro-modules.ts +132 -0
- package/src/__tests__/hooks.test.ts +216 -0
- package/src/__tests__/inferenceRouting.test.ts +36 -0
- package/src/__tests__/memoryTracker.test.ts +87 -0
- package/src/__tests__/modelFactory.test.ts +153 -0
- package/src/__tests__/modelPath.test.ts +14 -0
- package/src/__tests__/modelRegistry.test.ts +73 -0
- package/src/hooks.ts +234 -0
- package/src/index.ts +214 -0
- package/src/inferenceRouting.ts +80 -0
- package/src/memoryTracker.ts +268 -0
- package/src/modelFactory.ts +163 -0
- package/src/modelPath.ts +16 -0
- package/src/modelRegistry.ts +104 -0
- package/src/specs/LiteRTLM.nitro.ts +451 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* postinstall.js
|
|
4
|
+
*
|
|
5
|
+
* Downloads prebuilt LiteRT-LM iOS frameworks from this package's GitHub
|
|
6
|
+
* releases when consumers run `npm install react-native-litert-lm`.
|
|
7
|
+
*
|
|
8
|
+
* The framework is intentionally NOT shipped inside the npm tarball (it is
|
|
9
|
+
* ~40MB and irrelevant to Android-only consumers). The asset name, tag, and
|
|
10
|
+
* URL come from scripts/framework-source.js so they stay in lockstep with the
|
|
11
|
+
* release-time guardrail (scripts/check-framework-release.js).
|
|
12
|
+
*
|
|
13
|
+
* Skips download if:
|
|
14
|
+
* - Not on macOS (iOS builds require macOS)
|
|
15
|
+
* - Frameworks already exist
|
|
16
|
+
* - SKIP_IOS_FRAMEWORK_DOWNLOAD=1 (e.g. Android-only / CI builds)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { execSync } = require('child_process');
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const https = require('https');
|
|
23
|
+
|
|
24
|
+
const { ASSET_URL, FRAMEWORKS_DIR, FRAMEWORK_TAG } = require('./framework-source');
|
|
25
|
+
|
|
26
|
+
/** ZIP local-file-header magic ("PK\x03\x04"). Guards against truncated downloads / HTML error pages. */
|
|
27
|
+
const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
|
|
28
|
+
/** A valid framework zip is well over this; anything smaller is certainly an error page. */
|
|
29
|
+
const MIN_VALID_BYTES = 1024 * 1024; // 1 MB
|
|
30
|
+
|
|
31
|
+
function log(msg) {
|
|
32
|
+
console.log(`[react-native-litert-lm] ${msg}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function shouldSkip() {
|
|
36
|
+
// Skip if not macOS
|
|
37
|
+
if (process.platform !== 'darwin') {
|
|
38
|
+
log('Skipping iOS framework download (not macOS).');
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Skip if explicitly disabled
|
|
43
|
+
if (process.env.SKIP_IOS_FRAMEWORK_DOWNLOAD === '1') {
|
|
44
|
+
log('Skipping iOS framework download (SKIP_IOS_FRAMEWORK_DOWNLOAD=1).');
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Skip if frameworks already exist
|
|
49
|
+
if (fs.existsSync(FRAMEWORKS_DIR) && fs.readdirSync(FRAMEWORKS_DIR).length > 0) {
|
|
50
|
+
log('iOS frameworks already present, skipping download.');
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function downloadFile(url, destPath, maxRedirects = 5) {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
if (maxRedirects <= 0) {
|
|
60
|
+
return reject(new Error('Too many redirects'));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const protocol = url.startsWith('https') ? https : require('http');
|
|
64
|
+
|
|
65
|
+
protocol.get(url, { headers: { 'User-Agent': 'react-native-litert-lm' } }, (res) => {
|
|
66
|
+
// Follow redirects
|
|
67
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
68
|
+
return downloadFile(res.headers.location, destPath, maxRedirects - 1)
|
|
69
|
+
.then(resolve)
|
|
70
|
+
.catch(reject);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (res.statusCode !== 200) {
|
|
74
|
+
return reject(new Error(`HTTP ${res.statusCode} downloading ${url}`));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const file = fs.createWriteStream(destPath);
|
|
78
|
+
res.pipe(file);
|
|
79
|
+
file.on('finish', () => {
|
|
80
|
+
file.close(resolve);
|
|
81
|
+
});
|
|
82
|
+
file.on('error', reject);
|
|
83
|
+
}).on('error', reject);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Verify the downloaded file is a non-trivial ZIP before we trust it.
|
|
89
|
+
* Catches GitHub HTML error pages and truncated downloads that would
|
|
90
|
+
* otherwise fail cryptically at `unzip` or, worse, link a corrupt framework.
|
|
91
|
+
*/
|
|
92
|
+
function assertValidZip(zipPath) {
|
|
93
|
+
const { size } = fs.statSync(zipPath);
|
|
94
|
+
if (size < MIN_VALID_BYTES) {
|
|
95
|
+
throw new Error(`downloaded asset is only ${size} bytes — expected a multi-MB framework zip (likely an error page or truncated download)`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const header = Buffer.alloc(4);
|
|
99
|
+
const fd = fs.openSync(zipPath, 'r');
|
|
100
|
+
try {
|
|
101
|
+
fs.readSync(fd, header, 0, 4, 0);
|
|
102
|
+
} finally {
|
|
103
|
+
fs.closeSync(fd);
|
|
104
|
+
}
|
|
105
|
+
if (!header.equals(ZIP_MAGIC)) {
|
|
106
|
+
throw new Error('downloaded asset is not a valid ZIP (bad magic bytes)');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function main() {
|
|
111
|
+
if (shouldSkip()) return;
|
|
112
|
+
|
|
113
|
+
log(`Downloading iOS frameworks (engine ${FRAMEWORK_TAG}) from: ${ASSET_URL}`);
|
|
114
|
+
|
|
115
|
+
const tmpZip = path.join(path.dirname(FRAMEWORKS_DIR), '.ios-frameworks-tmp.zip');
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
await downloadFile(ASSET_URL, tmpZip);
|
|
119
|
+
assertValidZip(tmpZip);
|
|
120
|
+
|
|
121
|
+
// Extract
|
|
122
|
+
fs.mkdirSync(FRAMEWORKS_DIR, { recursive: true });
|
|
123
|
+
execSync(`unzip -o -q "${tmpZip}" -d "${FRAMEWORKS_DIR}"`, { stdio: 'inherit' });
|
|
124
|
+
|
|
125
|
+
// Cleanup
|
|
126
|
+
fs.unlinkSync(tmpZip);
|
|
127
|
+
|
|
128
|
+
log('iOS frameworks installed successfully.');
|
|
129
|
+
} catch (err) {
|
|
130
|
+
// Cleanup partial download
|
|
131
|
+
try { fs.unlinkSync(tmpZip); } catch {}
|
|
132
|
+
|
|
133
|
+
log(`Error: Could not download iOS frameworks: ${err.message}`);
|
|
134
|
+
log('iOS builds will not work until frameworks are available.');
|
|
135
|
+
log('Run: ./scripts/download-ios-frameworks.sh to download manually.');
|
|
136
|
+
|
|
137
|
+
// Fail fast on macOS so users discover the problem now, not at Xcode link time.
|
|
138
|
+
if (process.platform === 'darwin') {
|
|
139
|
+
log('Set SKIP_IOS_FRAMEWORK_DOWNLOAD=1 to suppress this error (e.g. Android-only builds).');
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
main();
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { MultimodalPart } from "../specs/LiteRTLM.nitro";
|
|
2
|
+
|
|
3
|
+
function streamMockTokens(
|
|
4
|
+
parts: MultimodalPart[],
|
|
5
|
+
onToken: (token: string, done: boolean) => void,
|
|
6
|
+
): void {
|
|
7
|
+
const hasImage = parts.some((p) => p.type === "image");
|
|
8
|
+
const hasAudio = parts.some((p) => p.type === "audio");
|
|
9
|
+
if (hasImage) {
|
|
10
|
+
onToken("Mock vision ", false);
|
|
11
|
+
onToken("token", true);
|
|
12
|
+
} else if (hasAudio) {
|
|
13
|
+
onToken("Mock audio ", false);
|
|
14
|
+
onToken("token", true);
|
|
15
|
+
} else {
|
|
16
|
+
onToken("Mock ", false);
|
|
17
|
+
onToken("token", true);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function mockExecuteResponse(parts: MultimodalPart[]): string {
|
|
22
|
+
const hasImage = parts.some((p) => p.type === "image");
|
|
23
|
+
const hasAudio = parts.some((p) => p.type === "audio");
|
|
24
|
+
return hasImage
|
|
25
|
+
? "Mock vision token"
|
|
26
|
+
: hasAudio
|
|
27
|
+
? "Mock audio token"
|
|
28
|
+
: "Mock token";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const mockExecute = jest.fn(
|
|
32
|
+
(parts: MultimodalPart[], onToken?: (token: string, done: boolean) => void): Promise<string> => {
|
|
33
|
+
if (onToken) {
|
|
34
|
+
streamMockTokens(parts, onToken);
|
|
35
|
+
}
|
|
36
|
+
return Promise.resolve(mockExecuteResponse(parts));
|
|
37
|
+
},
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
export const mockLiteRTLM = {
|
|
41
|
+
isReady: jest.fn(() => false),
|
|
42
|
+
loadModel: jest.fn().mockResolvedValue(undefined),
|
|
43
|
+
execute: mockExecute,
|
|
44
|
+
sendMessage: jest.fn((message: string) =>
|
|
45
|
+
mockExecute([{ type: "text", text: message }]),
|
|
46
|
+
),
|
|
47
|
+
sendMessageWithImage: jest.fn((message: string, imagePath: string) =>
|
|
48
|
+
mockExecute([
|
|
49
|
+
{ type: "text", text: message },
|
|
50
|
+
{ type: "image", path: imagePath },
|
|
51
|
+
]),
|
|
52
|
+
),
|
|
53
|
+
downloadModel: jest.fn(async (url: string, fileName: string, onProgress?: (progress: number) => void) => {
|
|
54
|
+
onProgress?.(1.0);
|
|
55
|
+
return "/mock/path/model.litertlm";
|
|
56
|
+
}),
|
|
57
|
+
deleteModel: jest.fn().mockResolvedValue(undefined),
|
|
58
|
+
sendMessageWithAudio: jest.fn((message: string, audioPath: string) =>
|
|
59
|
+
mockExecute([
|
|
60
|
+
{ type: "text", text: message },
|
|
61
|
+
{ type: "audio", path: audioPath },
|
|
62
|
+
]),
|
|
63
|
+
),
|
|
64
|
+
sendMultimodalMessage: jest.fn((parts: MultimodalPart[]) => mockExecute(parts)),
|
|
65
|
+
sendMessageAsync: jest.fn((msg: string, onToken: (token: string, done: boolean) => void) =>
|
|
66
|
+
mockExecute([{ type: "text", text: msg }], onToken).then(() => {}),
|
|
67
|
+
),
|
|
68
|
+
sendMessageWithImageAsync: jest.fn(
|
|
69
|
+
(msg: string, imagePath: string, onToken: (token: string, done: boolean) => void) =>
|
|
70
|
+
mockExecute(
|
|
71
|
+
[{ type: "text", text: msg }, { type: "image", path: imagePath }],
|
|
72
|
+
onToken,
|
|
73
|
+
).then(() => {}),
|
|
74
|
+
),
|
|
75
|
+
sendMessageWithAudioAsync: jest.fn(
|
|
76
|
+
(msg: string, audioPath: string, onToken: (token: string, done: boolean) => void) =>
|
|
77
|
+
mockExecute(
|
|
78
|
+
[{ type: "text", text: msg }, { type: "audio", path: audioPath }],
|
|
79
|
+
onToken,
|
|
80
|
+
).then(() => {}),
|
|
81
|
+
),
|
|
82
|
+
getHistory: jest.fn(() => []),
|
|
83
|
+
resetConversation: jest.fn(),
|
|
84
|
+
getStats: jest.fn(() => ({
|
|
85
|
+
promptTokens: 10,
|
|
86
|
+
completionTokens: 20,
|
|
87
|
+
totalTokens: 30,
|
|
88
|
+
timeToFirstToken: 5,
|
|
89
|
+
totalTime: 50,
|
|
90
|
+
tokensPerSecond: 400,
|
|
91
|
+
})),
|
|
92
|
+
countTokens: jest.fn(() => -1),
|
|
93
|
+
getMemoryUsage: jest.fn(() => ({
|
|
94
|
+
nativeHeapBytes: 1000000,
|
|
95
|
+
residentBytes: 2000000,
|
|
96
|
+
availableMemoryBytes: 4000000,
|
|
97
|
+
isLowMemory: false,
|
|
98
|
+
})),
|
|
99
|
+
close: jest.fn(),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const mockModelStore = {
|
|
103
|
+
isCached: jest.fn((fileName: string) => false),
|
|
104
|
+
getFilePath: jest.fn((fileName: string) => `/mock/path/${fileName}`),
|
|
105
|
+
listCachedFiles: jest.fn(() => []),
|
|
106
|
+
deleteFile: jest.fn((fileName: string) => {
|
|
107
|
+
return mockLiteRTLM.deleteModel(fileName);
|
|
108
|
+
}),
|
|
109
|
+
downloadFile: jest.fn(
|
|
110
|
+
async (
|
|
111
|
+
url: string,
|
|
112
|
+
fileName: string,
|
|
113
|
+
headersJson: string,
|
|
114
|
+
onProgress: (progress: number) => void,
|
|
115
|
+
) => mockLiteRTLM.downloadModel(url, fileName, onProgress),
|
|
116
|
+
),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const NitroModules = {
|
|
120
|
+
createHybridObject: jest.fn((name: string) => {
|
|
121
|
+
if (name === "LiteRTLM") {
|
|
122
|
+
return mockLiteRTLM;
|
|
123
|
+
}
|
|
124
|
+
if (name === "ModelStore") {
|
|
125
|
+
return mockModelStore;
|
|
126
|
+
}
|
|
127
|
+
throw new Error(`Mock not implemented for hybrid object: ${name}`);
|
|
128
|
+
}),
|
|
129
|
+
createNativeArrayBuffer: jest.fn((size: number) => {
|
|
130
|
+
return new ArrayBuffer(size);
|
|
131
|
+
}),
|
|
132
|
+
};
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// Configure React act environment
|
|
2
|
+
(global as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
3
|
+
|
|
4
|
+
import { useModel } from '../hooks';
|
|
5
|
+
import { mockLiteRTLM } from '../__mocks__/react-native-nitro-modules';
|
|
6
|
+
import TestRenderer from 'react-test-renderer';
|
|
7
|
+
import React from 'react';
|
|
8
|
+
|
|
9
|
+
// Mock react-native
|
|
10
|
+
jest.mock('react-native', () => ({
|
|
11
|
+
Platform: {
|
|
12
|
+
OS: 'ios',
|
|
13
|
+
select: jest.fn((dict) => dict.ios),
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
// Helper to render and test hooks using react-test-renderer
|
|
18
|
+
function renderHook<P, R>(callback: (props: P) => R, initialProps?: P) {
|
|
19
|
+
let result = { current: null as unknown as R };
|
|
20
|
+
|
|
21
|
+
const TestComponent = ({ props }: { props: P }) => {
|
|
22
|
+
result.current = callback(props);
|
|
23
|
+
return null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
let renderer: TestRenderer.ReactTestRenderer;
|
|
27
|
+
TestRenderer.act(() => {
|
|
28
|
+
renderer = TestRenderer.create(React.createElement(TestComponent, { props: initialProps as P }));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const rerender = (newProps: P) => {
|
|
32
|
+
TestRenderer.act(() => {
|
|
33
|
+
renderer.update(React.createElement(TestComponent, { props: newProps }));
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const unmount = () => {
|
|
38
|
+
TestRenderer.act(() => {
|
|
39
|
+
renderer.unmount();
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return { result, rerender, unmount };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('useModel React Hook Unit Tests', () => {
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
jest.clearAllMocks();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should initialize with correct default state and call loadModel automatically when autoLoad is true', async () => {
|
|
52
|
+
let hookResult: any;
|
|
53
|
+
|
|
54
|
+
await TestRenderer.act(async () => {
|
|
55
|
+
hookResult = renderHook(() => useModel('https://example.com/model.litertlm', { autoLoad: true }));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(hookResult.result.current.isReady).toBe(true);
|
|
59
|
+
expect(hookResult.result.current.isGenerating).toBe(false);
|
|
60
|
+
expect(hookResult.result.current.downloadProgress).toBe(1); // loadModel completed
|
|
61
|
+
expect(hookResult.result.current.error).toBeNull();
|
|
62
|
+
expect(mockLiteRTLM.loadModel).toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should not call loadModel automatically when autoLoad is false', async () => {
|
|
66
|
+
let hookResult: any;
|
|
67
|
+
|
|
68
|
+
await TestRenderer.act(async () => {
|
|
69
|
+
hookResult = renderHook(() => useModel('https://example.com/model.litertlm', { autoLoad: false }));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(hookResult.result.current.isReady).toBe(false);
|
|
73
|
+
expect(mockLiteRTLM.loadModel).not.toHaveBeenCalled();
|
|
74
|
+
|
|
75
|
+
// Call load manually
|
|
76
|
+
await TestRenderer.act(async () => {
|
|
77
|
+
await hookResult.result.current.load();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(hookResult.result.current.isReady).toBe(true);
|
|
81
|
+
expect(mockLiteRTLM.loadModel).toHaveBeenCalled();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should handle model load failure gracefully', async () => {
|
|
85
|
+
mockLiteRTLM.loadModel.mockRejectedValueOnce(new Error("Model load failed"));
|
|
86
|
+
let hookResult: any;
|
|
87
|
+
|
|
88
|
+
await TestRenderer.act(async () => {
|
|
89
|
+
hookResult = renderHook(() => useModel('https://example.com/model.litertlm', { autoLoad: true }));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(hookResult.result.current.isReady).toBe(false);
|
|
93
|
+
expect(hookResult.result.current.error).toBe("Model load failed");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should generate text successfully and trigger memory summary update', async () => {
|
|
97
|
+
let hookResult: any;
|
|
98
|
+
|
|
99
|
+
await TestRenderer.act(async () => {
|
|
100
|
+
hookResult = renderHook(() => useModel('https://example.com/model.litertlm', {
|
|
101
|
+
autoLoad: true,
|
|
102
|
+
enableMemoryTracking: true
|
|
103
|
+
}));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
let response = "";
|
|
107
|
+
await TestRenderer.act(async () => {
|
|
108
|
+
response = await hookResult.result.current.generate("Test prompt");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(response).toBe("Mock token");
|
|
112
|
+
expect(mockLiteRTLM.execute).toHaveBeenCalledWith(
|
|
113
|
+
[{ type: "text", text: "Test prompt" }],
|
|
114
|
+
undefined,
|
|
115
|
+
);
|
|
116
|
+
expect(hookResult.result.current.memorySummary).toBeDefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should reset conversation correctly', async () => {
|
|
120
|
+
let hookResult: any;
|
|
121
|
+
|
|
122
|
+
await TestRenderer.act(async () => {
|
|
123
|
+
hookResult = renderHook(() => useModel('https://example.com/model.litertlm', { autoLoad: true }));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
hookResult.result.current.reset();
|
|
127
|
+
expect(mockLiteRTLM.resetConversation).toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should delete model file correctly', async () => {
|
|
131
|
+
let hookResult: any;
|
|
132
|
+
|
|
133
|
+
await TestRenderer.act(async () => {
|
|
134
|
+
hookResult = renderHook(() => useModel('https://example.com/model.litertlm', { autoLoad: true }));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await TestRenderer.act(async () => {
|
|
138
|
+
await hookResult.result.current.deleteModel();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(mockLiteRTLM.deleteModel).toHaveBeenCalledWith('model.litertlm');
|
|
142
|
+
expect(hookResult.result.current.isReady).toBe(false);
|
|
143
|
+
expect(hookResult.result.current.downloadProgress).toBe(0);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should call close on unmount', async () => {
|
|
147
|
+
let hookResult: any;
|
|
148
|
+
|
|
149
|
+
await TestRenderer.act(async () => {
|
|
150
|
+
hookResult = renderHook(() => useModel('https://example.com/model.litertlm', { autoLoad: false }));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
hookResult.unmount();
|
|
154
|
+
expect(mockLiteRTLM.close).toHaveBeenCalled();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should pass maxContextTokens and maxOutputTokens through to loadModel', async () => {
|
|
158
|
+
let hookResult: any;
|
|
159
|
+
|
|
160
|
+
await TestRenderer.act(async () => {
|
|
161
|
+
hookResult = renderHook(() => useModel('https://example.com/model.litertlm', {
|
|
162
|
+
autoLoad: true,
|
|
163
|
+
maxContextTokens: 8192,
|
|
164
|
+
maxOutputTokens: 2048,
|
|
165
|
+
}));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(mockLiteRTLM.loadModel).toHaveBeenCalled();
|
|
169
|
+
const callArgs = mockLiteRTLM.loadModel.mock.calls[0];
|
|
170
|
+
const config = callArgs[1];
|
|
171
|
+
expect(config).toMatchObject({
|
|
172
|
+
maxContextTokens: 8192,
|
|
173
|
+
maxOutputTokens: 2048,
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should pass legacy maxTokens through when new fields are not set', async () => {
|
|
178
|
+
let hookResult: any;
|
|
179
|
+
|
|
180
|
+
await TestRenderer.act(async () => {
|
|
181
|
+
hookResult = renderHook(() => useModel('https://example.com/model.litertlm', {
|
|
182
|
+
autoLoad: true,
|
|
183
|
+
maxTokens: 512,
|
|
184
|
+
}));
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(mockLiteRTLM.loadModel).toHaveBeenCalled();
|
|
188
|
+
const callArgs = mockLiteRTLM.loadModel.mock.calls[0];
|
|
189
|
+
const config = callArgs[1];
|
|
190
|
+
expect(config).toMatchObject({ maxTokens: 512 });
|
|
191
|
+
expect(config.maxContextTokens).toBeUndefined();
|
|
192
|
+
expect(config.maxOutputTokens).toBeUndefined();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should pass both new fields alongside legacy maxTokens when all are set', async () => {
|
|
196
|
+
let hookResult: any;
|
|
197
|
+
|
|
198
|
+
await TestRenderer.act(async () => {
|
|
199
|
+
hookResult = renderHook(() => useModel('https://example.com/model.litertlm', {
|
|
200
|
+
autoLoad: true,
|
|
201
|
+
maxTokens: 512,
|
|
202
|
+
maxContextTokens: 4096,
|
|
203
|
+
maxOutputTokens: 1024,
|
|
204
|
+
}));
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(mockLiteRTLM.loadModel).toHaveBeenCalled();
|
|
208
|
+
const callArgs = mockLiteRTLM.loadModel.mock.calls[0];
|
|
209
|
+
const config = callArgs[1];
|
|
210
|
+
expect(config).toMatchObject({
|
|
211
|
+
maxTokens: 512,
|
|
212
|
+
maxContextTokens: 4096,
|
|
213
|
+
maxOutputTokens: 1024,
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {
|
|
2
|
+
routeLegacyInference,
|
|
3
|
+
isLegacyInferenceMethod,
|
|
4
|
+
textPart,
|
|
5
|
+
imagePart,
|
|
6
|
+
audioPart,
|
|
7
|
+
} from "../inferenceRouting";
|
|
8
|
+
|
|
9
|
+
describe("inferenceRouting", () => {
|
|
10
|
+
it("routes sendMessage to a single text part", () => {
|
|
11
|
+
const route = routeLegacyInference("sendMessage", ["hello"]);
|
|
12
|
+
expect(route).toEqual({ parts: [textPart("hello")] });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("routes sendMessageWithImageAsync with stream callback", () => {
|
|
16
|
+
const onToken = jest.fn();
|
|
17
|
+
const route = routeLegacyInference("sendMessageWithImageAsync", [
|
|
18
|
+
"describe",
|
|
19
|
+
"/img.jpg",
|
|
20
|
+
onToken,
|
|
21
|
+
]);
|
|
22
|
+
expect(route).toEqual({
|
|
23
|
+
parts: [textPart("describe"), imagePart("/img.jpg")],
|
|
24
|
+
onToken,
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns null for unknown methods", () => {
|
|
29
|
+
expect(routeLegacyInference("downloadModel", ["url", "file"])).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("isLegacyInferenceMethod narrows known methods", () => {
|
|
33
|
+
expect(isLegacyInferenceMethod("sendMessage")).toBe(true);
|
|
34
|
+
expect(isLegacyInferenceMethod("close")).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createMemoryTracker, createNativeBuffer } from '../memoryTracker';
|
|
2
|
+
import { NitroModules } from 'react-native-nitro-modules';
|
|
3
|
+
|
|
4
|
+
describe('MemoryTracker Unit Tests', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
jest.clearAllMocks();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('should allocate correct native-backed ArrayBuffer size on initialization', () => {
|
|
10
|
+
const tracker = createMemoryTracker(10);
|
|
11
|
+
expect(NitroModules.createNativeArrayBuffer).toHaveBeenCalledWith(10 * 4 * 8); // 10 snapshots * 4 fields * 8 bytes/Float64
|
|
12
|
+
expect(tracker.getCapacity()).toBe(10);
|
|
13
|
+
expect(tracker.getSnapshotCount()).toBe(0);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should record snapshots correctly and retrieve them', () => {
|
|
17
|
+
const tracker = createMemoryTracker(5);
|
|
18
|
+
const snapshot1 = {
|
|
19
|
+
timestamp: 1000,
|
|
20
|
+
nativeHeapBytes: 100,
|
|
21
|
+
residentBytes: 200,
|
|
22
|
+
availableMemoryBytes: 500,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
expect(tracker.record(snapshot1)).toBe(true);
|
|
26
|
+
expect(tracker.getSnapshotCount()).toBe(1);
|
|
27
|
+
expect(tracker.getLatestSnapshot()).toEqual(snapshot1);
|
|
28
|
+
|
|
29
|
+
const snapshots = tracker.getSnapshots();
|
|
30
|
+
expect(snapshots).toHaveLength(1);
|
|
31
|
+
expect(snapshots[0]).toEqual(snapshot1);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should reject new snapshots and return false when capacity is reached', () => {
|
|
35
|
+
const tracker = createMemoryTracker(2);
|
|
36
|
+
|
|
37
|
+
expect(tracker.record({ timestamp: 1, nativeHeapBytes: 10, residentBytes: 20, availableMemoryBytes: 50 })).toBe(true);
|
|
38
|
+
expect(tracker.record({ timestamp: 2, nativeHeapBytes: 20, residentBytes: 30, availableMemoryBytes: 40 })).toBe(true);
|
|
39
|
+
expect(tracker.record({ timestamp: 3, nativeHeapBytes: 30, residentBytes: 40, availableMemoryBytes: 30 })).toBe(false);
|
|
40
|
+
|
|
41
|
+
expect(tracker.getSnapshotCount()).toBe(2);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should calculate correct peak resident memory size', () => {
|
|
45
|
+
const tracker = createMemoryTracker(5);
|
|
46
|
+
tracker.record({ timestamp: 1, nativeHeapBytes: 100, residentBytes: 150, availableMemoryBytes: 1000 });
|
|
47
|
+
tracker.record({ timestamp: 2, nativeHeapBytes: 120, residentBytes: 300, availableMemoryBytes: 1000 });
|
|
48
|
+
tracker.record({ timestamp: 3, nativeHeapBytes: 110, residentBytes: 200, availableMemoryBytes: 1000 });
|
|
49
|
+
|
|
50
|
+
expect(tracker.getPeakMemory()).toBe(300);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should calculate accurate memory summary statistics', () => {
|
|
54
|
+
const tracker = createMemoryTracker(5);
|
|
55
|
+
tracker.record({ timestamp: 1, nativeHeapBytes: 50, residentBytes: 100, availableMemoryBytes: 1000 });
|
|
56
|
+
tracker.record({ timestamp: 2, nativeHeapBytes: 150, residentBytes: 300, availableMemoryBytes: 800 });
|
|
57
|
+
tracker.record({ timestamp: 3, nativeHeapBytes: 100, residentBytes: 200, availableMemoryBytes: 900 });
|
|
58
|
+
|
|
59
|
+
const summary = tracker.getSummary();
|
|
60
|
+
expect(summary.snapshotCount).toBe(3);
|
|
61
|
+
expect(summary.peakResidentBytes).toBe(300);
|
|
62
|
+
expect(summary.averageResidentBytes).toBe(200); // (100 + 300 + 200) / 3
|
|
63
|
+
expect(summary.currentResidentBytes).toBe(200);
|
|
64
|
+
expect(summary.peakNativeHeapBytes).toBe(150);
|
|
65
|
+
expect(summary.currentNativeHeapBytes).toBe(100);
|
|
66
|
+
expect(summary.residentDeltaBytes).toBe(100); // currentRss(200) - firstRss(100)
|
|
67
|
+
expect(summary.trackerBufferSizeBytes).toBe(5 * 4 * 8);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should preserve buffer but reset internal state when reset() is called', () => {
|
|
71
|
+
const tracker = createMemoryTracker(5);
|
|
72
|
+
tracker.record({ timestamp: 1, nativeHeapBytes: 50, residentBytes: 100, availableMemoryBytes: 1000 });
|
|
73
|
+
|
|
74
|
+
expect(tracker.getSnapshotCount()).toBe(1);
|
|
75
|
+
tracker.reset();
|
|
76
|
+
expect(tracker.getSnapshotCount()).toBe(0);
|
|
77
|
+
expect(tracker.getLatestSnapshot()).toBeUndefined();
|
|
78
|
+
expect(tracker.getSnapshots()).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should allow standalone native ArrayBuffer allocation via createNativeBuffer', () => {
|
|
82
|
+
const size = 128;
|
|
83
|
+
const buffer = createNativeBuffer(size);
|
|
84
|
+
expect(NitroModules.createNativeArrayBuffer).toHaveBeenCalledWith(size);
|
|
85
|
+
expect(buffer.byteLength).toBe(size);
|
|
86
|
+
});
|
|
87
|
+
});
|