@capgo/capacitor-llm 7.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <!-- Permission for downloading LLM models -->
3
+ <uses-permission android:name="android.permission.INTERNET" />
4
+
5
+ <!-- Optional permissions for model storage -->
6
+ <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
7
+ <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
8
+ </manifest>
@@ -0,0 +1,317 @@
1
+ package ee.forgr.capgo_llm;
2
+
3
+ import android.content.Context;
4
+ import com.google.mediapipe.tasks.genai.llminference.LlmInference;
5
+ import com.google.mediapipe.tasks.genai.llminference.LlmInference.LlmInferenceOptions;
6
+ import java.util.HashMap;
7
+ import java.util.Map;
8
+ import java.util.UUID;
9
+ import java.util.concurrent.Executor;
10
+ import java.util.concurrent.Executors;
11
+
12
+ public class LLM {
13
+
14
+ private static LLM instance;
15
+ private LlmInference llmInference;
16
+ private Map<String, ChatSession> chatSessions;
17
+ private boolean isReady = false;
18
+ private Context context;
19
+ private Executor executor;
20
+ private String modelPath = null;
21
+
22
+ private LLM(Context context) {
23
+ this.context = context;
24
+ this.chatSessions = new HashMap<>();
25
+ this.executor = Executors.newSingleThreadExecutor();
26
+ }
27
+
28
+ public static LLM getInstance(Context context) {
29
+ if (instance == null) {
30
+ instance = new LLM(context);
31
+ }
32
+ return instance;
33
+ }
34
+
35
+ // Model parameters
36
+ private Integer maxTokens = 2048;
37
+ private Integer topk = 40;
38
+ private Float temperature = 0.1f;
39
+
40
+ public void setModel(String path, Integer maxTokens, Integer topk, Float temperature, ModelLoadCallback callback) {
41
+ this.modelPath = path;
42
+ this.maxTokens = maxTokens;
43
+ this.topk = topk;
44
+ this.temperature = temperature;
45
+ isReady = false;
46
+
47
+ // If LLM was already initialized, clean it up
48
+ if (llmInference != null) {
49
+ llmInference = null;
50
+ }
51
+
52
+ android.util.Log.d(
53
+ "LLM",
54
+ "setModel called with path: " + path + ", maxTokens: " + maxTokens + ", topk: " + topk + ", temperature: " + temperature
55
+ );
56
+ initializeModel(callback);
57
+ }
58
+
59
+ private void initializeModel(ModelLoadCallback callback) {
60
+ if (modelPath == null) {
61
+ if (callback != null) {
62
+ callback.onError("Model path not set");
63
+ }
64
+ return;
65
+ }
66
+
67
+ executor.execute(() -> {
68
+ try {
69
+ String actualPath = modelPath;
70
+
71
+ // Debug logging
72
+ android.util.Log.d("LLM", "Original model path: " + modelPath);
73
+
74
+ // For /android_asset/ paths, we need to handle them specially
75
+ if (modelPath.startsWith("/android_asset/")) {
76
+ // Extract the relative path from assets
77
+ String assetPath = modelPath.substring("/android_asset/".length());
78
+ android.util.Log.d("LLM", "Asset path: " + assetPath);
79
+
80
+ // Check if the asset exists
81
+ try {
82
+ java.io.InputStream is = context.getAssets().open(assetPath);
83
+ is.close();
84
+ android.util.Log.d("LLM", "Asset exists: " + assetPath);
85
+
86
+ // For MediaPipe, we need to copy the asset to a file
87
+ java.io.File cacheDir = context.getCacheDir();
88
+ java.io.File modelFile = new java.io.File(cacheDir, assetPath);
89
+
90
+ // Create parent directories if needed
91
+ modelFile.getParentFile().mkdirs();
92
+
93
+ // Copy asset to cache
94
+ copyAssetToFile(assetPath, modelFile);
95
+ actualPath = modelFile.getAbsolutePath();
96
+ android.util.Log.d("LLM", "Copied asset to: " + actualPath);
97
+
98
+ // Also check for companion .litertlm file
99
+ String litertlmPath = assetPath.replace(".task", ".litertlm");
100
+ try {
101
+ java.io.InputStream litertlmIs = context.getAssets().open(litertlmPath);
102
+ litertlmIs.close();
103
+ java.io.File litertlmFile = new java.io.File(cacheDir, litertlmPath);
104
+ copyAssetToFile(litertlmPath, litertlmFile);
105
+ android.util.Log.d("LLM", "Also copied companion file: " + litertlmFile.getAbsolutePath());
106
+ } catch (Exception e) {
107
+ android.util.Log.d("LLM", "No companion .litertlm file found");
108
+ }
109
+ } catch (Exception e) {
110
+ android.util.Log.e("LLM", "Asset not found: " + assetPath, e);
111
+ throw new RuntimeException("Asset not found: " + assetPath);
112
+ }
113
+ }
114
+
115
+ android.util.Log.d("LLM", "Final model path: " + actualPath);
116
+
117
+ // Create options for LLM inference
118
+ LlmInferenceOptions.Builder optionsBuilder = LlmInferenceOptions.builder()
119
+ .setModelPath(actualPath)
120
+ .setMaxTokens(maxTokens)
121
+ .setMaxTopK(topk);
122
+
123
+ LlmInferenceOptions options = optionsBuilder.build();
124
+
125
+ // Initialize the LLM inference
126
+ llmInference = LlmInference.createFromOptions(context, options);
127
+ isReady = true;
128
+
129
+ if (callback != null) {
130
+ callback.onSuccess();
131
+ }
132
+ } catch (Exception e) {
133
+ isReady = false;
134
+ e.printStackTrace();
135
+ if (callback != null) {
136
+ callback.onError(e.getMessage());
137
+ }
138
+ }
139
+ });
140
+ }
141
+
142
+ private void copyAssetToFile(String assetPath, java.io.File destFile) throws Exception {
143
+ java.io.InputStream is = context.getAssets().open(assetPath);
144
+ java.io.FileOutputStream fos = new java.io.FileOutputStream(destFile);
145
+ byte[] buffer = new byte[1024];
146
+ int length;
147
+ while ((length = is.read(buffer)) > 0) {
148
+ fos.write(buffer, 0, length);
149
+ }
150
+ fos.close();
151
+ is.close();
152
+ }
153
+
154
+ public String createChat() {
155
+ String chatId = UUID.randomUUID().toString();
156
+ ChatSession session = new ChatSession();
157
+ chatSessions.put(chatId, session);
158
+ return chatId;
159
+ }
160
+
161
+ public void sendMessage(String chatId, String message, MessageCallback callback) {
162
+ ChatSession session = chatSessions.get(chatId);
163
+ if (session == null) {
164
+ callback.onError("Chat session not found");
165
+ return;
166
+ }
167
+
168
+ if (!isReady || llmInference == null) {
169
+ callback.onError("Model not ready");
170
+ return;
171
+ }
172
+
173
+ executor.execute(() -> {
174
+ try {
175
+ // Add user message to history
176
+ session.addMessage("user", message);
177
+
178
+ // Build the full prompt with chat history
179
+ String fullPrompt = session.buildPrompt(message);
180
+ android.util.Log.d("LLM", "Full prompt: " + fullPrompt);
181
+
182
+ // Create a session with proper options
183
+ com.google.mediapipe.tasks.genai.llminference.LlmInferenceSession.LlmInferenceSessionOptions sessionOptions =
184
+ com.google.mediapipe.tasks.genai.llminference.LlmInferenceSession.LlmInferenceSessionOptions.builder()
185
+ .setTopK(topk)
186
+ .setTemperature(temperature)
187
+ .build();
188
+
189
+ com.google.mediapipe.tasks.genai.llminference.LlmInferenceSession inferenceSession =
190
+ com.google.mediapipe.tasks.genai.llminference.LlmInferenceSession.createFromOptions(llmInference, sessionOptions);
191
+
192
+ // Add the query
193
+ inferenceSession.addQueryChunk(fullPrompt);
194
+
195
+ // Use streaming API
196
+ final StringBuilder fullResponse = new StringBuilder();
197
+
198
+ com.google.mediapipe.tasks.genai.llminference.ProgressListener<String> resultListener =
199
+ new com.google.mediapipe.tasks.genai.llminference.ProgressListener<String>() {
200
+ private StringBuilder buffer = new StringBuilder();
201
+ private boolean hasStarted = false;
202
+
203
+ @Override
204
+ public void run(String partialResult, boolean done) {
205
+ android.util.Log.d("LLM", "Partial result: " + partialResult + ", done: " + done);
206
+
207
+ // Accumulate in buffer
208
+ buffer.append(partialResult);
209
+
210
+ // Process buffer when we have enough content or when done
211
+ String content = buffer.toString();
212
+
213
+ // Check if we can process some content
214
+ StringBuilder toSend = new StringBuilder();
215
+ int i = 0;
216
+ while (i < content.length()) {
217
+ // Check for escape sequence
218
+ if (i < content.length() - 1 && content.charAt(i) == '\\' && content.charAt(i + 1) == 'n') {
219
+ toSend.append('\n');
220
+ i += 2;
221
+ } else if (i == content.length() - 1 && content.charAt(i) == '\\' && !done) {
222
+ // We have a backslash at the end and more chunks coming - wait for next chunk
223
+ break;
224
+ } else {
225
+ toSend.append(content.charAt(i));
226
+ i++;
227
+ }
228
+ }
229
+
230
+ // Update buffer to contain only unprocessed content
231
+ buffer = new StringBuilder(content.substring(i));
232
+
233
+ // Send processed content if any
234
+ String chunk = toSend.toString();
235
+
236
+ // Remove leading newline from the very first content
237
+ if (!hasStarted && chunk.length() > 0) {
238
+ chunk = chunk.replaceFirst("^\\n", "");
239
+ hasStarted = true;
240
+ }
241
+
242
+ if (!chunk.isEmpty()) {
243
+ callback.onTextReceived(chatId, chunk, true);
244
+ fullResponse.append(chunk);
245
+ }
246
+
247
+ // Process any remaining content when done
248
+ if (done && buffer.length() > 0) {
249
+ String remaining = buffer.toString();
250
+ if (!remaining.isEmpty()) {
251
+ callback.onTextReceived(chatId, remaining, true);
252
+ fullResponse.append(remaining);
253
+ }
254
+
255
+ // Add complete response to history
256
+ session.addMessage("assistant", fullResponse.toString());
257
+ callback.onComplete(chatId);
258
+
259
+ // Close the session
260
+ try {
261
+ inferenceSession.close();
262
+ } catch (Exception e) {
263
+ android.util.Log.e("LLM", "Failed to close session: " + e.getMessage());
264
+ }
265
+ }
266
+ }
267
+ };
268
+
269
+ inferenceSession.generateResponseAsync(resultListener);
270
+ } catch (Exception e) {
271
+ callback.onError(e.getMessage());
272
+ }
273
+ });
274
+ }
275
+
276
+ public String getReadiness() {
277
+ return isReady ? "ready" : "not_ready";
278
+ }
279
+
280
+ public interface MessageCallback {
281
+ void onTextReceived(String chatId, String text, boolean isChunk);
282
+ void onComplete(String chatId);
283
+ void onError(String error);
284
+ }
285
+
286
+ public interface ModelLoadCallback {
287
+ void onSuccess();
288
+ void onError(String error);
289
+ }
290
+
291
+ // Inner class to manage chat sessions
292
+ private static class ChatSession {
293
+
294
+ private StringBuilder history;
295
+
296
+ ChatSession() {
297
+ this.history = new StringBuilder();
298
+ }
299
+
300
+ void addMessage(String role, String content) {
301
+ // Store messages in a cleaner format for history
302
+ if (history.length() > 0) {
303
+ history.append("\n");
304
+ }
305
+ if (role.equals("user")) {
306
+ history.append("User: ").append(content);
307
+ } else {
308
+ history.append("Assistant: ").append(content);
309
+ }
310
+ }
311
+
312
+ String buildPrompt(String newMessage) {
313
+ // For Gemma with MediaPipe, just return the message as-is
314
+ return newMessage;
315
+ }
316
+ }
317
+ }
@@ -0,0 +1,211 @@
1
+ package ee.forgr.capgo_llm;
2
+
3
+ import com.getcapacitor.JSObject;
4
+ import com.getcapacitor.Plugin;
5
+ import com.getcapacitor.PluginCall;
6
+ import com.getcapacitor.PluginMethod;
7
+ import com.getcapacitor.annotation.CapacitorPlugin;
8
+
9
+ @CapacitorPlugin(name = "CapgoLLM")
10
+ public class LLMPlugin extends Plugin {
11
+
12
+ private LLM llm;
13
+
14
+ @Override
15
+ public void load() {
16
+ super.load();
17
+ llm = LLM.getInstance(getContext());
18
+ }
19
+
20
+ @PluginMethod
21
+ public void createChat(PluginCall call) {
22
+ try {
23
+ String chatId = llm.createChat();
24
+ JSObject ret = new JSObject();
25
+ ret.put("id", chatId);
26
+ call.resolve(ret);
27
+ } catch (Exception e) {
28
+ call.reject("Failed to create chat", e);
29
+ }
30
+ }
31
+
32
+ @PluginMethod
33
+ public void sendMessage(PluginCall call) {
34
+ String chatId = call.getString("chatId");
35
+ String message = call.getString("message");
36
+
37
+ if (chatId == null || message == null) {
38
+ call.reject("chatId and message are required");
39
+ return;
40
+ }
41
+
42
+ llm.sendMessage(
43
+ chatId,
44
+ message,
45
+ new LLM.MessageCallback() {
46
+ @Override
47
+ public void onTextReceived(String chatId, String text, boolean isChunk) {
48
+ JSObject event = new JSObject();
49
+ event.put("text", text);
50
+ event.put("chatId", chatId);
51
+ event.put("isChunk", isChunk);
52
+ notifyListeners("textFromAi", event);
53
+ }
54
+
55
+ @Override
56
+ public void onComplete(String chatId) {
57
+ JSObject event = new JSObject();
58
+ event.put("chatId", chatId);
59
+ notifyListeners("aiFinished", event);
60
+ }
61
+
62
+ @Override
63
+ public void onError(String error) {
64
+ call.reject("Failed to send message", error);
65
+ }
66
+ }
67
+ );
68
+
69
+ call.resolve();
70
+ }
71
+
72
+ @PluginMethod
73
+ public void getReadiness(PluginCall call) {
74
+ String readiness = llm.getReadiness();
75
+ JSObject ret = new JSObject();
76
+ ret.put("readiness", readiness);
77
+ call.resolve(ret);
78
+
79
+ // Also notify listeners
80
+ JSObject readinessData = new JSObject();
81
+ readinessData.put("readiness", readiness);
82
+ notifyListeners("readinessChange", readinessData);
83
+ }
84
+
85
+ @PluginMethod
86
+ public void setModel(PluginCall call) {
87
+ String path = call.getString("path");
88
+
89
+ if (path == null) {
90
+ call.reject("path is required");
91
+ return;
92
+ }
93
+
94
+ // Extract model parameters
95
+ Integer maxTokens = call.getInt("maxTokens", 2048);
96
+ Integer topk = call.getInt("topk", 40);
97
+ Float temperature = call.getFloat("temperature", 0.8f);
98
+ Integer randomSeed = call.getInt("randomSeed", 101);
99
+
100
+ llm.setModel(
101
+ path,
102
+ maxTokens,
103
+ topk,
104
+ temperature,
105
+ new LLM.ModelLoadCallback() {
106
+ @Override
107
+ public void onSuccess() {
108
+ // Notify readiness change
109
+ JSObject readinessData = new JSObject();
110
+ readinessData.put("readiness", "ready");
111
+ notifyListeners("readinessChange", readinessData);
112
+ call.resolve();
113
+ }
114
+
115
+ @Override
116
+ public void onError(String error) {
117
+ // Notify readiness change
118
+ JSObject readinessData = new JSObject();
119
+ readinessData.put("readiness", "Failed to load model: " + error);
120
+ notifyListeners("readinessChange", readinessData);
121
+ call.reject("Failed to load model", error);
122
+ }
123
+ }
124
+ );
125
+ }
126
+
127
+ @PluginMethod
128
+ public void downloadModel(PluginCall call) {
129
+ String url = call.getString("url");
130
+ String companionUrl = call.getString("companionUrl");
131
+ String filename = call.getString("filename");
132
+
133
+ if (url == null) {
134
+ call.reject("url is required");
135
+ return;
136
+ }
137
+
138
+ // If no filename provided, extract from URL
139
+ if (filename == null) {
140
+ String[] parts = url.split("/");
141
+ filename = parts[parts.length - 1];
142
+ }
143
+
144
+ final String finalFilename = filename;
145
+
146
+ new Thread(() -> {
147
+ try {
148
+ // Download main model file
149
+ java.io.File filesDir = getContext().getFilesDir();
150
+ java.io.File modelFile = new java.io.File(filesDir, finalFilename);
151
+
152
+ downloadFile(url, modelFile, progress -> {
153
+ JSObject event = new JSObject();
154
+ event.put("progress", progress);
155
+ notifyListeners("downloadProgress", event);
156
+ });
157
+
158
+ JSObject ret = new JSObject();
159
+ ret.put("path", modelFile.getAbsolutePath());
160
+
161
+ // Download companion file if provided
162
+ if (companionUrl != null) {
163
+ String companionFilename = companionUrl.substring(companionUrl.lastIndexOf("/") + 1);
164
+ java.io.File companionFile = new java.io.File(filesDir, companionFilename);
165
+
166
+ downloadFile(companionUrl, companionFile, progress -> {
167
+ JSObject event = new JSObject();
168
+ event.put("progress", progress);
169
+ notifyListeners("downloadProgress", event);
170
+ });
171
+
172
+ ret.put("companionPath", companionFile.getAbsolutePath());
173
+ }
174
+
175
+ call.resolve(ret);
176
+ } catch (Exception e) {
177
+ call.reject("Failed to download model", e.getMessage());
178
+ }
179
+ }).start();
180
+ }
181
+
182
+ private void downloadFile(String urlString, java.io.File outputFile, ProgressCallback callback) throws Exception {
183
+ java.net.URL url = new java.net.URL(urlString);
184
+ java.net.HttpURLConnection connection = (java.net.HttpURLConnection) url.openConnection();
185
+ connection.connect();
186
+
187
+ int fileLength = connection.getContentLength();
188
+ java.io.InputStream input = new java.io.BufferedInputStream(connection.getInputStream());
189
+ java.io.FileOutputStream output = new java.io.FileOutputStream(outputFile);
190
+
191
+ byte[] data = new byte[4096];
192
+ long total = 0;
193
+ int count;
194
+
195
+ while ((count = input.read(data)) != -1) {
196
+ total += count;
197
+ if (fileLength > 0 && callback != null) {
198
+ callback.onProgress((int) ((total * 100) / fileLength));
199
+ }
200
+ output.write(data, 0, count);
201
+ }
202
+
203
+ output.flush();
204
+ output.close();
205
+ input.close();
206
+ }
207
+
208
+ interface ProgressCallback {
209
+ void onProgress(int percentage);
210
+ }
211
+ }
File without changes