expo-local-llm 0.1.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 +110 -0
- package/android/build.gradle +36 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/expo/modules/localllm/ConversationHistory.kt +39 -0
- package/android/src/main/java/expo/modules/localllm/Enums.kt +13 -0
- package/android/src/main/java/expo/modules/localllm/Exceptions.kt +12 -0
- package/android/src/main/java/expo/modules/localllm/ExpoLocalLlmModule.kt +57 -0
- package/android/src/main/java/expo/modules/localllm/LLMSession.kt +119 -0
- package/android/src/main/java/expo/modules/localllm/Records.kt +23 -0
- package/build/ExpoLocalLlm.types.d.ts +35 -0
- package/build/ExpoLocalLlm.types.d.ts.map +1 -0
- package/build/ExpoLocalLlm.types.js +2 -0
- package/build/ExpoLocalLlm.types.js.map +1 -0
- package/build/ExpoLocalLlmModule.d.ts +10 -0
- package/build/ExpoLocalLlmModule.d.ts.map +1 -0
- package/build/ExpoLocalLlmModule.js +10 -0
- package/build/ExpoLocalLlmModule.js.map +1 -0
- package/build/LLMSession.d.ts +9 -0
- package/build/LLMSession.d.ts.map +1 -0
- package/build/LLMSession.js +16 -0
- package/build/LLMSession.js.map +1 -0
- package/build/index.d.ts +5 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +5 -0
- package/build/index.js.map +1 -0
- package/build/useLocalLLM.d.ts +18 -0
- package/build/useLocalLLM.d.ts.map +1 -0
- package/build/useLocalLLM.js +121 -0
- package/build/useLocalLLM.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/Enums.swift +11 -0
- package/ios/Exceptions.swift +19 -0
- package/ios/ExpoLocalLlm.podspec +26 -0
- package/ios/ExpoLocalLlmModule.swift +56 -0
- package/ios/FoundationModelBridge.swift +59 -0
- package/ios/LLMSession.swift +68 -0
- package/ios/Records.swift +12 -0
- package/package.json +61 -0
- package/src/ExpoLocalLlm.types.ts +47 -0
- package/src/ExpoLocalLlmModule.ts +19 -0
- package/src/LLMSession.ts +19 -0
- package/src/__tests__/ExpoLocalLlm.test.ts +82 -0
- package/src/__tests__/useLocalLLM.test.ts +30 -0
- package/src/index.ts +4 -0
- package/src/useLocalLLM.ts +176 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { useEffect, useState, useCallback, useMemo } from "react";
|
|
2
|
+
import ExpoLocalLlmModule from "./ExpoLocalLlmModule";
|
|
3
|
+
import { createLLMSession } from "./LLMSession";
|
|
4
|
+
export function useLocalLLM(options = {}) {
|
|
5
|
+
const [availability, setAvailability] = useState("unknown");
|
|
6
|
+
const [isGenerating, setIsGenerating] = useState(false);
|
|
7
|
+
const [streamedText, setStreamedText] = useState("");
|
|
8
|
+
const [downloadProgress, setDownloadProgress] = useState(null);
|
|
9
|
+
const [error, setError] = useState(null);
|
|
10
|
+
// Stabilize options to avoid unnecessary session recreation
|
|
11
|
+
const stableConfig = useMemo(() => options, [
|
|
12
|
+
options.instructions,
|
|
13
|
+
options.options?.temperature,
|
|
14
|
+
options.options?.maxTokens,
|
|
15
|
+
options.options?.topK,
|
|
16
|
+
]);
|
|
17
|
+
const session = useMemo(() => {
|
|
18
|
+
if (!ExpoLocalLlmModule)
|
|
19
|
+
return null;
|
|
20
|
+
try {
|
|
21
|
+
return createLLMSession(stableConfig);
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}, [stableConfig]);
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!ExpoLocalLlmModule)
|
|
29
|
+
return;
|
|
30
|
+
setAvailability(ExpoLocalLlmModule.getAvailability());
|
|
31
|
+
const subs = [];
|
|
32
|
+
subs.push(ExpoLocalLlmModule.addListener("downloadProgress", (event) => {
|
|
33
|
+
setDownloadProgress(event.progress);
|
|
34
|
+
}));
|
|
35
|
+
subs.push(ExpoLocalLlmModule.addListener("availabilityChange", (event) => {
|
|
36
|
+
setAvailability(event.availability);
|
|
37
|
+
}));
|
|
38
|
+
if (session) {
|
|
39
|
+
subs.push(session.addListener("token", (event) => {
|
|
40
|
+
setStreamedText(event.accumulated);
|
|
41
|
+
}));
|
|
42
|
+
subs.push(session.addListener("streamComplete", (_event) => {
|
|
43
|
+
setIsGenerating(false);
|
|
44
|
+
}));
|
|
45
|
+
subs.push(session.addListener("streamError", (event) => {
|
|
46
|
+
setError(event.error);
|
|
47
|
+
setIsGenerating(false);
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
return () => {
|
|
51
|
+
subs.forEach((s) => s.remove());
|
|
52
|
+
session?.release();
|
|
53
|
+
};
|
|
54
|
+
}, [session]);
|
|
55
|
+
const respond = useCallback(async (prompt) => {
|
|
56
|
+
if (!session)
|
|
57
|
+
throw new Error("Session not available");
|
|
58
|
+
setError(null);
|
|
59
|
+
setIsGenerating(true);
|
|
60
|
+
try {
|
|
61
|
+
const result = await session.respond(prompt);
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
setError(e.message);
|
|
66
|
+
throw e;
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
setIsGenerating(false);
|
|
70
|
+
}
|
|
71
|
+
}, [session]);
|
|
72
|
+
const streamResponse = useCallback(async (prompt) => {
|
|
73
|
+
if (!session)
|
|
74
|
+
throw new Error("Session not available");
|
|
75
|
+
setError(null);
|
|
76
|
+
setStreamedText("");
|
|
77
|
+
setIsGenerating(true);
|
|
78
|
+
try {
|
|
79
|
+
await session.streamResponse(prompt);
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
setError(e.message);
|
|
83
|
+
setIsGenerating(false);
|
|
84
|
+
throw e;
|
|
85
|
+
}
|
|
86
|
+
}, [session]);
|
|
87
|
+
const cancelStream = useCallback(async () => {
|
|
88
|
+
if (!session)
|
|
89
|
+
return;
|
|
90
|
+
await session.cancelStream();
|
|
91
|
+
setIsGenerating(false);
|
|
92
|
+
}, [session]);
|
|
93
|
+
const downloadModel = useCallback(async () => {
|
|
94
|
+
if (!ExpoLocalLlmModule)
|
|
95
|
+
return;
|
|
96
|
+
await ExpoLocalLlmModule.downloadModel();
|
|
97
|
+
}, []);
|
|
98
|
+
if (!ExpoLocalLlmModule) {
|
|
99
|
+
return {
|
|
100
|
+
availability: "notEligible",
|
|
101
|
+
session: null,
|
|
102
|
+
isGenerating: false,
|
|
103
|
+
streamedText: "",
|
|
104
|
+
downloadProgress: null,
|
|
105
|
+
error: null,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
availability,
|
|
110
|
+
session,
|
|
111
|
+
isGenerating,
|
|
112
|
+
streamedText,
|
|
113
|
+
downloadProgress,
|
|
114
|
+
error,
|
|
115
|
+
respond: session && availability === "available" ? respond : undefined,
|
|
116
|
+
streamResponse: session && availability === "available" ? streamResponse : undefined,
|
|
117
|
+
cancelStream: session && availability === "available" ? cancelStream : undefined,
|
|
118
|
+
downloadModel,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=useLocalLLM.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useLocalLLM.js","sourceRoot":"","sources":["../src/useLocalLLM.ts"],"names":[],"mappings":"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","sourcesContent":["import { useEffect, useState, useCallback, useMemo } from \"react\";\n\nimport type {\n ModelAvailability,\n SessionConfig,\n TokenEvent,\n StreamCompleteEvent,\n StreamErrorEvent,\n DownloadProgress,\n} from \"./ExpoLocalLlm.types\";\nimport ExpoLocalLlmModule from \"./ExpoLocalLlmModule\";\nimport { createLLMSession, LLMSession } from \"./LLMSession\";\n\ntype UseLocalLLMOptions = SessionConfig;\n\ntype UseLocalLLMResult = {\n availability: ModelAvailability;\n session: LLMSession | null;\n isGenerating: boolean;\n streamedText: string;\n downloadProgress: number | null;\n error: string | null;\n respond?: (prompt: string) => Promise<string>;\n streamResponse?: (prompt: string) => Promise<void>;\n cancelStream?: () => Promise<void>;\n downloadModel?: () => Promise<void>;\n};\n\nexport function useLocalLLM(\n options: UseLocalLLMOptions = {}\n): UseLocalLLMResult {\n const [availability, setAvailability] =\n useState<ModelAvailability>(\"unknown\");\n const [isGenerating, setIsGenerating] = useState(false);\n const [streamedText, setStreamedText] = useState(\"\");\n const [downloadProgress, setDownloadProgress] = useState<number | null>(null);\n const [error, setError] = useState<string | null>(null);\n\n // Stabilize options to avoid unnecessary session recreation\n const stableConfig = useMemo(\n () => options,\n [\n options.instructions,\n options.options?.temperature,\n options.options?.maxTokens,\n options.options?.topK,\n ]\n );\n\n const session = useMemo(() => {\n if (!ExpoLocalLlmModule) return null;\n try {\n return createLLMSession(stableConfig);\n } catch (e: any) {\n return null;\n }\n }, [stableConfig]);\n\n useEffect(() => {\n if (!ExpoLocalLlmModule) return;\n\n setAvailability(ExpoLocalLlmModule.getAvailability() as ModelAvailability);\n\n const subs: { remove(): void }[] = [];\n\n subs.push(\n ExpoLocalLlmModule.addListener(\n \"downloadProgress\",\n (event: DownloadProgress) => {\n setDownloadProgress(event.progress);\n }\n )\n );\n subs.push(\n ExpoLocalLlmModule.addListener(\"availabilityChange\", (event) => {\n setAvailability(event.availability);\n })\n );\n\n if (session) {\n subs.push(\n session.addListener(\"token\", (event: TokenEvent) => {\n setStreamedText(event.accumulated);\n })\n );\n subs.push(\n session.addListener(\"streamComplete\", (_event: StreamCompleteEvent) => {\n setIsGenerating(false);\n })\n );\n subs.push(\n session.addListener(\"streamError\", (event: StreamErrorEvent) => {\n setError(event.error);\n setIsGenerating(false);\n })\n );\n }\n\n return () => {\n subs.forEach((s) => s.remove());\n session?.release();\n };\n }, [session]);\n\n const respond = useCallback(\n async (prompt: string): Promise<string> => {\n if (!session) throw new Error(\"Session not available\");\n setError(null);\n setIsGenerating(true);\n try {\n const result = await session.respond(prompt);\n return result;\n } catch (e: any) {\n setError(e.message);\n throw e;\n } finally {\n setIsGenerating(false);\n }\n },\n [session]\n );\n\n const streamResponse = useCallback(\n async (prompt: string): Promise<void> => {\n if (!session) throw new Error(\"Session not available\");\n setError(null);\n setStreamedText(\"\");\n setIsGenerating(true);\n try {\n await session.streamResponse(prompt);\n } catch (e: any) {\n setError(e.message);\n setIsGenerating(false);\n throw e;\n }\n },\n [session]\n );\n\n const cancelStream = useCallback(async (): Promise<void> => {\n if (!session) return;\n await session.cancelStream();\n setIsGenerating(false);\n }, [session]);\n\n const downloadModel = useCallback(async (): Promise<void> => {\n if (!ExpoLocalLlmModule) return;\n await ExpoLocalLlmModule.downloadModel();\n }, []);\n\n if (!ExpoLocalLlmModule) {\n return {\n availability: \"notEligible\",\n session: null,\n isGenerating: false,\n streamedText: \"\",\n downloadProgress: null,\n error: null,\n };\n }\n\n return {\n availability,\n session,\n isGenerating,\n streamedText,\n downloadProgress,\n error,\n respond: session && availability === \"available\" ? respond : undefined,\n streamResponse:\n session && availability === \"available\" ? streamResponse : undefined,\n cancelStream:\n session && availability === \"available\" ? cancelStream : undefined,\n downloadModel,\n };\n}\n"]}
|
package/ios/Enums.swift
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
|
|
3
|
+
class NotSupportedException: Exception {
|
|
4
|
+
override var reason: String {
|
|
5
|
+
"Foundation Models requires iOS 26 or later"
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
class SessionInvalidException: Exception {
|
|
10
|
+
override var reason: String {
|
|
11
|
+
"LLM session is invalid or has been destroyed"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
class StreamException: GenericException<String> {
|
|
16
|
+
override var reason: String {
|
|
17
|
+
"Stream error: \(param ?? "Unknown")"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
|
|
4
|
+
|
|
5
|
+
Pod::Spec.new do |s|
|
|
6
|
+
s.name = 'ExpoLocalLlm'
|
|
7
|
+
s.version = package['version']
|
|
8
|
+
s.summary = package['description']
|
|
9
|
+
s.description = package['description']
|
|
10
|
+
s.license = package['license']
|
|
11
|
+
s.author = package['author']
|
|
12
|
+
s.homepage = package['homepage']
|
|
13
|
+
s.platforms = { :ios => '16.0' }
|
|
14
|
+
s.swift_version = '5.9'
|
|
15
|
+
s.source = { git: '' }
|
|
16
|
+
s.static_framework = true
|
|
17
|
+
|
|
18
|
+
s.dependency 'ExpoModulesCore'
|
|
19
|
+
s.weak_frameworks = 'FoundationModels'
|
|
20
|
+
|
|
21
|
+
s.pod_target_xcconfig = {
|
|
22
|
+
'DEFINES_MODULE' => 'YES',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
|
26
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
|
|
3
|
+
public class ExpoLocalLlmModule: Module {
|
|
4
|
+
public func definition() -> ModuleDefinition {
|
|
5
|
+
Name("ExpoLocalLlm")
|
|
6
|
+
|
|
7
|
+
Events("downloadProgress", "availabilityChange")
|
|
8
|
+
|
|
9
|
+
Function("getAvailability") { () -> String in
|
|
10
|
+
if #available(iOS 26, *) {
|
|
11
|
+
return LLMSession.checkAvailability().rawValue
|
|
12
|
+
}
|
|
13
|
+
return ModelAvailability.notEligible.rawValue
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
AsyncFunction("downloadModel") {
|
|
17
|
+
// No-op on iOS — model is built into the OS
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
Class(LLMSession.self) {
|
|
21
|
+
Constructor { (config: SessionConfig) -> LLMSession in
|
|
22
|
+
let session = LLMSession()
|
|
23
|
+
try session.setup(config: config)
|
|
24
|
+
return session
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
AsyncFunction("respond") { (session: LLMSession, prompt: String) -> String in
|
|
28
|
+
return try await session.respond(to: prompt)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
AsyncFunction("streamResponse") { (session: LLMSession, prompt: String) in
|
|
32
|
+
try session.startStream(prompt: prompt)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
AsyncFunction("cancelStream") { (session: LLMSession) in
|
|
36
|
+
session.cancelStream()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
Events("token", "streamComplete", "streamError")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
OnAppEntersForeground {
|
|
43
|
+
// Re-check availability when user returns (may have toggled Apple Intelligence in Settings)
|
|
44
|
+
if #available(iOS 26, *) {
|
|
45
|
+
let current = LLMSession.checkAvailability().rawValue
|
|
46
|
+
self.sendEvent("availabilityChange", [
|
|
47
|
+
"availability": current
|
|
48
|
+
])
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
OnAppEntersBackground {
|
|
53
|
+
// Cancel any active streams when app backgrounds
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import FoundationModels
|
|
2
|
+
|
|
3
|
+
@available(iOS 26, *)
|
|
4
|
+
enum FoundationModelBridge {
|
|
5
|
+
static func checkAvailability() -> ModelAvailability {
|
|
6
|
+
let availability = SystemLanguageModel.default.availability
|
|
7
|
+
switch availability {
|
|
8
|
+
case .available:
|
|
9
|
+
return .available
|
|
10
|
+
case .notAvailable(let reason):
|
|
11
|
+
switch reason {
|
|
12
|
+
case .deviceNotEligible:
|
|
13
|
+
return .notEligible
|
|
14
|
+
case .modelNotReady:
|
|
15
|
+
return .notReady
|
|
16
|
+
case .appleIntelligenceNotEnabled:
|
|
17
|
+
return .notEnabled
|
|
18
|
+
@unknown default:
|
|
19
|
+
return .unknown
|
|
20
|
+
}
|
|
21
|
+
@unknown default:
|
|
22
|
+
return .unknown
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static func createSession(instructions: String?) -> LanguageModelSession {
|
|
27
|
+
if let instructions {
|
|
28
|
+
return LanguageModelSession(instructions: instructions)
|
|
29
|
+
}
|
|
30
|
+
return LanguageModelSession()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static func respond(session: Any, prompt: String) async throws -> String {
|
|
34
|
+
guard let session = session as? LanguageModelSession else {
|
|
35
|
+
throw SessionInvalidException()
|
|
36
|
+
}
|
|
37
|
+
let response = try await session.respond(to: prompt)
|
|
38
|
+
return response.content
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
static func stream(session: Any, prompt: String) -> AsyncThrowingStream<String, Error> {
|
|
42
|
+
guard let session = session as? LanguageModelSession else {
|
|
43
|
+
return AsyncThrowingStream { $0.finish(throwing: SessionInvalidException()) }
|
|
44
|
+
}
|
|
45
|
+
return AsyncThrowingStream { continuation in
|
|
46
|
+
Task {
|
|
47
|
+
do {
|
|
48
|
+
let stream = session.streamResponse(to: prompt)
|
|
49
|
+
for try await partial in stream {
|
|
50
|
+
continuation.yield(partial.content)
|
|
51
|
+
}
|
|
52
|
+
continuation.finish()
|
|
53
|
+
} catch {
|
|
54
|
+
continuation.finish(throwing: error)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
class LLMSession: SharedObject {
|
|
5
|
+
private var nativeSession: Any?
|
|
6
|
+
private var streamTask: Task<Void, Never>?
|
|
7
|
+
|
|
8
|
+
static func checkAvailability() -> ModelAvailability {
|
|
9
|
+
guard #available(iOS 26, *) else {
|
|
10
|
+
return .notEligible
|
|
11
|
+
}
|
|
12
|
+
return FoundationModelBridge.checkAvailability()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
func setup(config: SessionConfig) throws {
|
|
16
|
+
guard #available(iOS 26, *) else {
|
|
17
|
+
throw NotSupportedException()
|
|
18
|
+
}
|
|
19
|
+
nativeSession = FoundationModelBridge.createSession(instructions: config.instructions)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func respond(to prompt: String) async throws -> String {
|
|
23
|
+
guard #available(iOS 26, *), let session = nativeSession else {
|
|
24
|
+
throw NotSupportedException()
|
|
25
|
+
}
|
|
26
|
+
return try await FoundationModelBridge.respond(session: session, prompt: prompt)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func startStream(prompt: String) throws {
|
|
30
|
+
guard #available(iOS 26, *), let session = nativeSession else {
|
|
31
|
+
throw NotSupportedException()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
streamTask?.cancel()
|
|
35
|
+
|
|
36
|
+
let weakSelf = self
|
|
37
|
+
streamTask = Task {
|
|
38
|
+
var accumulated = ""
|
|
39
|
+
do {
|
|
40
|
+
let stream = FoundationModelBridge.stream(session: session, prompt: prompt)
|
|
41
|
+
for try await token in stream {
|
|
42
|
+
accumulated += token
|
|
43
|
+
weakSelf.emit(event: "token", arguments: [
|
|
44
|
+
"token": token,
|
|
45
|
+
"accumulated": accumulated
|
|
46
|
+
])
|
|
47
|
+
}
|
|
48
|
+
weakSelf.emit(event: "streamComplete", arguments: [
|
|
49
|
+
"text": accumulated
|
|
50
|
+
])
|
|
51
|
+
} catch {
|
|
52
|
+
if Task.isCancelled { return }
|
|
53
|
+
weakSelf.emit(event: "streamError", arguments: [
|
|
54
|
+
"error": error.localizedDescription
|
|
55
|
+
])
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func cancelStream() {
|
|
61
|
+
streamTask?.cancel()
|
|
62
|
+
streamTask = nil
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
deinit {
|
|
66
|
+
streamTask?.cancel()
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
|
|
3
|
+
struct SessionConfig: Record {
|
|
4
|
+
@Field var instructions: String?
|
|
5
|
+
@Field var options: GenerationOptions?
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
struct GenerationOptions: Record {
|
|
9
|
+
@Field var temperature: Double?
|
|
10
|
+
@Field var maxTokens: Int?
|
|
11
|
+
@Field var topK: Int?
|
|
12
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "expo-local-llm",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Expo module for on-device LLM inference via Apple Foundation Models (iOS) and Gemini Nano (Android)",
|
|
5
|
+
"main": "build/index.js",
|
|
6
|
+
"types": "build/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"build",
|
|
9
|
+
"src",
|
|
10
|
+
"ios",
|
|
11
|
+
"android",
|
|
12
|
+
"expo-module.config.json"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "expo-module build",
|
|
16
|
+
"clean": "expo-module clean",
|
|
17
|
+
"lint": "expo-module lint",
|
|
18
|
+
"test": "expo-module test",
|
|
19
|
+
"prepare": "expo-module prepare",
|
|
20
|
+
"prepublishOnly": "expo-module prepublishOnly",
|
|
21
|
+
"expo-module": "expo-module",
|
|
22
|
+
"open:ios": "xed example/ios",
|
|
23
|
+
"open:android": "open -a \"Android Studio\" example/android"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"react-native",
|
|
27
|
+
"expo",
|
|
28
|
+
"expo-local-llm",
|
|
29
|
+
"llm",
|
|
30
|
+
"on-device",
|
|
31
|
+
"apple-intelligence",
|
|
32
|
+
"gemini-nano",
|
|
33
|
+
"foundation-models"
|
|
34
|
+
],
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/GijungKim/expo-local-llm"
|
|
38
|
+
},
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/GijungKim/expo-local-llm/issues"
|
|
41
|
+
},
|
|
42
|
+
"author": "GijungKim <tonykim970@gmail.com>",
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"homepage": "https://github.com/GijungKim/expo-local-llm#readme",
|
|
45
|
+
"dependencies": {},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/react": "~19.1.1",
|
|
48
|
+
"expo-module-scripts": "^55.0.2",
|
|
49
|
+
"expo": "^55.0.8",
|
|
50
|
+
"react-native": "0.82.1"
|
|
51
|
+
},
|
|
52
|
+
"jest": {
|
|
53
|
+
"preset": "jest-expo/node"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"expo": ">=52.0.0",
|
|
57
|
+
"expo-modules-core": ">=2.0.0",
|
|
58
|
+
"react": ">=18.0.0",
|
|
59
|
+
"react-native": ">=0.76.0"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export type ModelAvailability =
|
|
2
|
+
| "available"
|
|
3
|
+
| "notEnabled"
|
|
4
|
+
| "notReady"
|
|
5
|
+
| "notEligible"
|
|
6
|
+
| "downloadRequired"
|
|
7
|
+
| "downloading"
|
|
8
|
+
| "unknown";
|
|
9
|
+
|
|
10
|
+
export type GenerationOptions = {
|
|
11
|
+
temperature?: number;
|
|
12
|
+
maxTokens?: number;
|
|
13
|
+
topK?: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type SessionConfig = {
|
|
17
|
+
instructions?: string;
|
|
18
|
+
options?: GenerationOptions;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type TokenEvent = {
|
|
22
|
+
token: string;
|
|
23
|
+
accumulated: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type StreamCompleteEvent = {
|
|
27
|
+
text: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type StreamErrorEvent = {
|
|
31
|
+
error: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type DownloadProgress = {
|
|
35
|
+
progress: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type LLMSessionEvents = {
|
|
39
|
+
token: (event: TokenEvent) => void;
|
|
40
|
+
streamComplete: (event: StreamCompleteEvent) => void;
|
|
41
|
+
streamError: (event: StreamErrorEvent) => void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type ExpoLocalLlmModuleEvents = {
|
|
45
|
+
downloadProgress: (event: DownloadProgress) => void;
|
|
46
|
+
availabilityChange: (event: { availability: ModelAvailability }) => void;
|
|
47
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { NativeModule, requireNativeModule } from "expo-modules-core";
|
|
2
|
+
|
|
3
|
+
import type { ExpoLocalLlmModuleEvents } from "./ExpoLocalLlm.types";
|
|
4
|
+
|
|
5
|
+
declare class ExpoLocalLlmModuleType extends NativeModule<ExpoLocalLlmModuleEvents> {
|
|
6
|
+
getAvailability(): string;
|
|
7
|
+
downloadModel(): Promise<void>;
|
|
8
|
+
LLMSession: any;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let nativeModule: ExpoLocalLlmModuleType | null;
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
nativeModule = requireNativeModule<ExpoLocalLlmModuleType>("ExpoLocalLlm");
|
|
15
|
+
} catch {
|
|
16
|
+
nativeModule = null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default nativeModule;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { SharedObject, UnavailabilityError } from "expo-modules-core";
|
|
2
|
+
|
|
3
|
+
import type { SessionConfig, LLMSessionEvents } from "./ExpoLocalLlm.types";
|
|
4
|
+
import ExpoLocalLlmModule from "./ExpoLocalLlmModule";
|
|
5
|
+
|
|
6
|
+
export class LLMSession extends SharedObject<LLMSessionEvents> {
|
|
7
|
+
// Methods are implemented natively via the Class() DSL.
|
|
8
|
+
// TypeScript declarations provide type safety only.
|
|
9
|
+
respond!: (prompt: string) => Promise<string>;
|
|
10
|
+
streamResponse!: (prompt: string) => Promise<void>;
|
|
11
|
+
cancelStream!: () => Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createLLMSession(config: SessionConfig = {}): LLMSession {
|
|
15
|
+
if (!ExpoLocalLlmModule) {
|
|
16
|
+
throw new UnavailabilityError("ExpoLocalLlm", "createLLMSession");
|
|
17
|
+
}
|
|
18
|
+
return new ExpoLocalLlmModule.LLMSession(config) as LLMSession;
|
|
19
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
jest.mock("expo-modules-core", () => ({
|
|
2
|
+
requireNativeModule: jest.fn(() => {
|
|
3
|
+
throw new Error("Native module not available");
|
|
4
|
+
}),
|
|
5
|
+
NativeModule: class {},
|
|
6
|
+
SharedObject: class {},
|
|
7
|
+
UnavailabilityError: class UnavailabilityError extends Error {
|
|
8
|
+
constructor(moduleName: string, functionName: string) {
|
|
9
|
+
super(`${moduleName}.${functionName} is not available`);
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
import { LLMSession, createLLMSession, ExpoLocalLlmModule } from "../index";
|
|
15
|
+
|
|
16
|
+
describe("ExpoLocalLlm", () => {
|
|
17
|
+
describe("exports", () => {
|
|
18
|
+
it("exports LLMSession class", () => {
|
|
19
|
+
expect(LLMSession).toBeDefined();
|
|
20
|
+
expect(typeof LLMSession).toBe("function");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("exports createLLMSession function", () => {
|
|
24
|
+
expect(createLLMSession).toBeDefined();
|
|
25
|
+
expect(typeof createLLMSession).toBe("function");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("exports ExpoLocalLlmModule as null when native module is unavailable", () => {
|
|
29
|
+
expect(ExpoLocalLlmModule).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("createLLMSession", () => {
|
|
34
|
+
it("throws when native module is not loaded", () => {
|
|
35
|
+
expect(() => createLLMSession()).toThrow();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("throws with module name in message", () => {
|
|
39
|
+
expect(() => createLLMSession({ instructions: "test" })).toThrow(
|
|
40
|
+
/ExpoLocalLlm/
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("ModelAvailability values", () => {
|
|
46
|
+
it("all values are valid strings", () => {
|
|
47
|
+
const values = [
|
|
48
|
+
"available",
|
|
49
|
+
"notEnabled",
|
|
50
|
+
"notReady",
|
|
51
|
+
"notEligible",
|
|
52
|
+
"downloadRequired",
|
|
53
|
+
"downloading",
|
|
54
|
+
"unknown",
|
|
55
|
+
];
|
|
56
|
+
expect(values).toHaveLength(7);
|
|
57
|
+
values.forEach((v) => expect(typeof v).toBe("string"));
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("SessionConfig", () => {
|
|
62
|
+
it("accepts config with all fields", () => {
|
|
63
|
+
const config = {
|
|
64
|
+
instructions: "You are a health assistant",
|
|
65
|
+
options: {
|
|
66
|
+
temperature: 0.7,
|
|
67
|
+
maxTokens: 256,
|
|
68
|
+
topK: 40,
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
expect(config.instructions).toBe("You are a health assistant");
|
|
72
|
+
expect(config.options.temperature).toBe(0.7);
|
|
73
|
+
expect(config.options.maxTokens).toBe(256);
|
|
74
|
+
expect(config.options.topK).toBe(40);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("accepts empty config", () => {
|
|
78
|
+
const config = {};
|
|
79
|
+
expect(config).toEqual({});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
jest.mock("expo-modules-core", () => ({
|
|
2
|
+
requireNativeModule: jest.fn(() => {
|
|
3
|
+
throw new Error("Native module not available");
|
|
4
|
+
}),
|
|
5
|
+
NativeModule: class {},
|
|
6
|
+
SharedObject: class {},
|
|
7
|
+
UnavailabilityError: class UnavailabilityError extends Error {
|
|
8
|
+
constructor(moduleName: string, functionName: string) {
|
|
9
|
+
super(`${moduleName}.${functionName} is not available`);
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
jest.mock("react", () => ({
|
|
15
|
+
...jest.requireActual("react"),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import ExpoLocalLlmModule from "../ExpoLocalLlmModule";
|
|
19
|
+
|
|
20
|
+
describe("useLocalLLM prerequisites", () => {
|
|
21
|
+
it("native module is null in test environment", () => {
|
|
22
|
+
expect(ExpoLocalLlmModule).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("useLocalLLM can be imported", () => {
|
|
26
|
+
const mod = require("../useLocalLLM");
|
|
27
|
+
expect(mod.useLocalLLM).toBeDefined();
|
|
28
|
+
expect(typeof mod.useLocalLLM).toBe("function");
|
|
29
|
+
});
|
|
30
|
+
});
|
package/src/index.ts
ADDED