From 4fcf430b346c03bc54ab92fa7521665548a7fa06 Mon Sep 17 00:00:00 2001 From: involvex <39644169+involvex@users.noreply.github.com> Date: Sat, 18 Jul 2026 06:12:19 +0200 Subject: [PATCH 1/2] fix: stop storing chat history in SharedPreferences to prevent OOM Move conversations to a documents JSON file, drop oversized prefs keys natively before Flutter starts, and never persist screenshot bytes. The crash was a 122MB prefs string decoding against the 256MB Java heap, not system RAM. Co-authored-by: Cursor --- android/app/src/main/AndroidManifest.xml | 2 +- .../dev/nova/assistant/NovaApplication.kt | 65 ++++- lib/services/chat_history_service.dart | 240 ++++++++++++++---- test/services/chat_history_export_test.dart | 68 ++++- 4 files changed, 313 insertions(+), 62 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index aef434f..32d6a67 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -37,7 +37,7 @@ android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="Nova" - + android:largeHeap="true" android:supportsRtl="true" android:theme="@style/LaunchTheme" tools:targetApi="34"> diff --git a/android/app/src/main/kotlin/dev/nova/assistant/NovaApplication.kt b/android/app/src/main/kotlin/dev/nova/assistant/NovaApplication.kt index 9393b1d..4412e43 100644 --- a/android/app/src/main/kotlin/dev/nova/assistant/NovaApplication.kt +++ b/android/app/src/main/kotlin/dev/nova/assistant/NovaApplication.kt @@ -1,9 +1,72 @@ package dev.nova.assistant import android.app.Application +import android.util.Log +import java.io.File +/** + * Migrates oversized chat history out of Flutter SharedPreferences before the + * Dart engine starts. A ~100MB+ `flutter.conversations` value OOMs the + * StandardMessageCodec when SharedPreferences.getInstance() loads all keys. + */ class NovaApplication : Application() { override fun onCreate() { super.onCreate() + migrateConversationsOutOfPrefs() } -} \ No newline at end of file + + private fun migrateConversationsOutOfPrefs() { + try { + val prefs = getSharedPreferences(FLUTTER_PREFS, MODE_PRIVATE) + val value = prefs.getString(CONVERSATIONS_KEY, null) + val oldValue = prefs.getString(OLD_CHAT_KEY, null) + if (value == null && oldValue == null) return + + val outDir = File(dataDir, "app_flutter") + if (!outDir.exists()) outDir.mkdirs() + val outFile = File(outDir, CONVERSATIONS_FILE) + + if (value != null) { + if (value.length > MAX_MIGRATE_CHARS) { + Log.w( + TAG, + "Dropping oversized $CONVERSATIONS_KEY " + + "(${value.length} chars) — would OOM Flutter prefs codec", + ) + } else if (!outFile.exists() || outFile.length() == 0L) { + outFile.writeText(value) + Log.i( + TAG, + "Migrated conversations prefs (${value.length} chars) → ${outFile.path}", + ) + } + prefs.edit().remove(CONVERSATIONS_KEY).apply() + } + + if (oldValue != null) { + prefs.edit().remove(OLD_CHAT_KEY).apply() + } + } catch (e: Exception) { + Log.e(TAG, "migrateConversationsOutOfPrefs failed", e) + try { + getSharedPreferences(FLUTTER_PREFS, MODE_PRIVATE) + .edit() + .remove(CONVERSATIONS_KEY) + .remove(OLD_CHAT_KEY) + .apply() + } catch (_: Exception) { + // Ignore — next launch may still crash until data is cleared. + } + } + } + + companion object { + private const val TAG = "NovaApplication" + private const val FLUTTER_PREFS = "FlutterSharedPreferences" + private const val CONVERSATIONS_KEY = "flutter.conversations" + private const val OLD_CHAT_KEY = "flutter.chat_history" + private const val CONVERSATIONS_FILE = "conversations.json" + /** Above this, skip content migrate and only delete the prefs key. */ + private const val MAX_MIGRATE_CHARS = 8_000_000 + } +} diff --git a/lib/services/chat_history_service.dart b/lib/services/chat_history_service.dart index abe1ac1..a807802 100644 --- a/lib/services/chat_history_service.dart +++ b/lib/services/chat_history_service.dart @@ -1,40 +1,133 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:nova_assistant/models/chat_message.dart'; import 'package:nova_assistant/models/conversation.dart'; +/// Persists conversations as a JSON file — never in SharedPreferences. +/// +/// Storing chat (especially base64 screenshots) in prefs OOMs Android when +/// Flutter decodes the prefs map across the platform channel (~256 MB Java +/// heap). Native [NovaApplication] migrates/drops the old prefs key on boot. class ChatHistoryService { static const _oldKey = 'chat_history'; static const _key = 'conversations'; - static const _migratedKey = 'conversations_migrated'; + static const _fileName = 'conversations.json'; + static const _maxConversations = 40; + static const _maxMessagesPerConversation = 200; + + /// Soft cap for the on-disk blob; oversized files are trimmed on load. + static const _maxFileBytes = 4 * 1024 * 1024; + static SharedPreferences? _prefs; static List? _cachedConversations; + static File? _file; static Future get _p async => _prefs ??= await SharedPreferences.getInstance(); - static Future ensureMigrated() async { - final p = await _p; - final alreadyMigrated = p.getBool(_migratedKey) ?? false; - if (alreadyMigrated) return; + static Future _conversationsFile() async { + if (_file != null) return _file!; + final dir = await getApplicationDocumentsDirectory(); + _file = File('${dir.path}/$_fileName'); + + return _file!; + } + + /// Strips screenshot bytes — they bloated prefs to 100MB+ and crash startup. + static Map _messageToPersistJson(ChatMessage message) { + final json = message.toJson(); + json['imageData'] = null; + + return json; + } + + static Conversation _sanitizeConversation(Conversation conversation) { + final messages = conversation.messages.map((m) { + if (m.imageData == null || m.imageData!.isEmpty) return m; + final text = m.text.trim().isEmpty ? '[Screenshot]' : m.text; - final oldJson = p.getString(_oldKey); - if (oldJson != null && oldJson.isNotEmpty) { + return ChatMessage( + id: m.id, + text: text, + isUser: m.isUser, + timestamp: m.timestamp, + modelName: m.modelName, + isStreaming: m.isStreaming, + isError: m.isError, + thinking: m.thinking, + toolCalls: m.toolCalls, + inferenceTimeMs: m.inferenceTimeMs, + reactions: m.reactions, + ); + }).toList(); + final trimmed = messages.length > _maxMessagesPerConversation + ? messages.sublist(messages.length - _maxMessagesPerConversation) + : messages; + + return conversation.copyWith(messages: trimmed); + } + + static List _capConversations(List list) { + final sorted = List.from(list) + ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + if (sorted.length <= _maxConversations) { + return sorted.map(_sanitizeConversation).toList(); + } + + return sorted.take(_maxConversations).map(_sanitizeConversation).toList(); + } + + static Future ensureMigrated() async { + final file = await _conversationsFile(); + if (await file.exists() && await file.length() > 0) { + // Drop leftover prefs keys if native migrate already wrote the file. try { - final list = jsonDecode(oldJson) as List; - if (list.isNotEmpty) { - final messages = list - .map((e) => ChatMessage.fromJson(e as Map)) - .toList(); - final conversation = Conversation(messages: messages); - await _saveConversationsInternal([conversation]); - await p.remove(_oldKey); + final p = await _p; + await p.remove(_key); + await p.remove(_oldKey); + } on Exception catch (e) { + debugPrint('ChatHistoryService: prefs cleanup after file migrate: $e'); + } + + return; + } + + // Legacy path: small prefs payloads only (native drops oversized ones). + try { + final p = await _p; + final json = p.getString(_key); + if (json != null && json.isNotEmpty) { + await file.writeAsString(json); + await p.remove(_key); + debugPrint( + 'ChatHistoryService: migrated conversations prefs → file ' + '(${json.length} chars)', + ); + } + + final oldJson = p.getString(_oldKey); + if (oldJson != null && oldJson.isNotEmpty) { + try { + final list = jsonDecode(oldJson) as List; + if (list.isNotEmpty) { + final messages = list + .map((e) => ChatMessage.fromJson(e as Map)) + .toList(); + final conversation = Conversation(messages: messages); + await _saveConversationsInternal([conversation]); + } + } on Exception catch (e) { + debugPrint('ChatHistoryService: old chat_history migrate failed: $e'); } - } catch (_) {} + await p.remove(_oldKey); + } + } on Exception catch (e) { + debugPrint('ChatHistoryService.ensureMigrated prefs read failed: $e'); } - await p.setBool(_migratedKey, true); } static Future> loadConversations() async { @@ -43,20 +136,49 @@ class ChatHistoryService { } await ensureMigrated(); try { - final p = await _p; - final json = p.getString(_key); - if (json == null || json.isEmpty) { + final file = await _conversationsFile(); + if (!await file.exists()) { _cachedConversations = []; + return []; } + + final length = await file.length(); + if (length == 0) { + _cachedConversations = []; + + return []; + } + + if (length > _maxFileBytes * 4) { + debugPrint( + 'ChatHistoryService: conversations file too large ' + '($length bytes) — resetting to avoid OOM', + ); + await file.delete(); + _cachedConversations = []; + + return []; + } + + final json = await file.readAsString(); final list = jsonDecode(json) as List; - _cachedConversations = list + var conversations = list .map((e) => Conversation.fromJson(e as Map)) .toList(); - _cachedConversations!.sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + conversations = _capConversations(conversations); + _cachedConversations = conversations; + + // Rewrite stripped/capped form so the blob shrinks on disk. + if (length > _maxFileBytes) { + await _saveConversationsInternal(conversations); + } + return List.unmodifiable(_cachedConversations!); - } catch (_) { + } on Exception catch (e) { + debugPrint('ChatHistoryService.loadConversations error: $e'); _cachedConversations = []; + return []; } } @@ -65,11 +187,31 @@ class ChatHistoryService { List conversations, ) async { try { - final p = await _p; - final json = jsonEncode(conversations.map((c) => c.toJson()).toList()); - await p.setString(_key, json); - _cachedConversations = conversations; - } catch (e) { + final capped = _capConversations(conversations); + final encoded = jsonEncode( + capped + .map( + (c) => { + 'id': c.id, + 'title': c.title, + 'messages': c.messages.map(_messageToPersistJson).toList(), + 'createdAt': c.createdAt.toIso8601String(), + 'updatedAt': c.updatedAt.toIso8601String(), + 'summary': c.summary, + }, + ) + .toList(), + ); + final file = await _conversationsFile(); + await file.writeAsString(encoded); + _cachedConversations = capped; + + // Ensure prefs never holds the blob again. + try { + final p = await _p; + if (p.containsKey(_key)) await p.remove(_key); + } on Exception catch (_) {} + } on Exception catch (e) { debugPrint('ChatHistoryService._saveConversationsInternal error: $e'); } } @@ -85,6 +227,7 @@ class ChatHistoryService { final conversation = Conversation(title: title); final updated = [conversation, ...conversations]; await _saveConversationsInternal(updated); + return conversation; } @@ -106,6 +249,7 @@ class ChatHistoryService { static Future getConversation(String id) async { final conversations = await loadConversations(); + return conversations.where((c) => c.id == id).firstOrNull; } @@ -127,32 +271,11 @@ class ChatHistoryService { } } - static Future updateMessage( - String conversationId, - ChatMessage message, - ) async { - final conversations = await loadConversations(); - final convoIndex = conversations.indexWhere((c) => c.id == conversationId); - if (convoIndex != -1) { - final conversation = conversations[convoIndex]; - final messages = List.from(conversation.messages); - final msgIndex = messages.indexWhere((m) => m.id == message.id); - if (msgIndex != -1) { - messages[msgIndex] = message; - final updated = List.from(conversations); - updated[convoIndex] = conversation.copyWith( - messages: messages, - updatedAt: DateTime.now(), - ); - await _saveConversationsInternal(updated); - } - } - } - static Future> load() async { final conversations = await loadConversations(); if (conversations.isEmpty) return []; final first = conversations.first; + return first.messages; } @@ -181,8 +304,21 @@ class ChatHistoryService { static Future clear() async { _cachedConversations = []; - final p = await _p; - await p.remove(_key); + try { + final file = await _conversationsFile(); + if (await file.exists()) await file.delete(); + } on Exception catch (e) { + debugPrint('ChatHistoryService.clear file error: $e'); + } finally { + _file = null; + } + try { + final p = await _p; + await p.remove(_key); + await p.remove(_oldKey); + } on Exception catch (e) { + debugPrint('ChatHistoryService.clear prefs error: $e'); + } } /// Builds a plain-text export of all conversations (no file write). diff --git a/test/services/chat_history_export_test.dart b/test/services/chat_history_export_test.dart index edfc01b..7b06fca 100644 --- a/test/services/chat_history_export_test.dart +++ b/test/services/chat_history_export_test.dart @@ -1,21 +1,47 @@ +import 'dart:io'; +import 'dart:typed_data'; + import 'package:flutter_test/flutter_test.dart'; import 'package:nova_assistant/models/chat_message.dart'; import 'package:nova_assistant/models/conversation.dart'; import 'package:nova_assistant/services/chat_history_service.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:shared_preferences/shared_preferences.dart'; +class _FakePathProvider extends Fake + with MockPlatformInterfaceMixin + implements PathProviderPlatform { + _FakePathProvider(this.root); + final Directory root; + + @override + Future getApplicationDocumentsPath() async => root.path; +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); + late Directory tempDir; + setUp(() async { SharedPreferences.setMockInitialValues({}); + tempDir = await Directory.systemTemp.createTemp('nova_chat_hist_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir); await ChatHistoryService.clear(); }); - test('exportAsText returns content without writing a file path', () async { + tearDown(() async { + await ChatHistoryService.clear(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('persists conversations to a file, not SharedPreferences', () async { final convo = Conversation( id: 'c1', - title: 'Test chat', + title: 'Test', createdAt: DateTime(2026, 1, 1), updatedAt: DateTime(2026, 1, 1), messages: [ @@ -29,11 +55,37 @@ void main() { ); await ChatHistoryService.saveConversations([convo]); - final content = await ChatHistoryService.exportAsText(); - expect(content, isNotNull); - expect(content!, contains('Hello')); - expect(content, contains('Nova Assistant')); - expect(content.contains('/data/user/'), isFalse); - expect(content.endsWith('.txt'), isFalse); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('conversations'), isNull); + + final file = File('${tempDir.path}/conversations.json'); + expect(await file.exists(), isTrue); + expect(await file.readAsString(), contains('Hello')); + + final loaded = await ChatHistoryService.loadConversations(); + expect(loaded, hasLength(1)); + expect(loaded.first.messages.first.text, 'Hello'); + }); + + test('does not persist screenshot bytes', () async { + final convo = Conversation( + id: 'c2', + messages: [ + ChatMessage( + id: 'm2', + text: '', + isUser: true, + timestamp: DateTime(2026, 1, 1), + imageData: Uint8List(1024)..fillRange(0, 1024, 7), + ), + ], + ); + await ChatHistoryService.saveConversations([convo]); + + final file = File('${tempDir.path}/conversations.json'); + final raw = await file.readAsString(); + expect(raw, contains('"imageData":null')); + expect(raw, contains('[Screenshot]')); + expect(raw.length, lessThan(2000)); }); } From ff949c88fbf1fffde8d8c5797161ef9ff4511d87 Mon Sep 17 00:00:00 2001 From: involvex <39644169+involvex@users.noreply.github.com> Date: Sat, 18 Jul 2026 07:01:18 +0200 Subject: [PATCH 2/2] fix: harden low-RAM onboarding and prefs scrub for mid-range devices Stream-scrub oversized SharedPreferences without loading XML into the heap, onboard SmolLM on <=6GB phones (total RAM, not free), and fall back to installed/small models when Gemma 4 cannot load. Device verification still pending on POCO F1. Co-authored-by: Cursor --- .../kotlin/dev/nova/assistant/MainActivity.kt | 1 + .../dev/nova/assistant/NovaApplication.kt | 181 +++++++++++++---- docs/models.md | 20 ++ lib/main.dart | 16 ++ lib/screens/assistant_screen.dart | 11 +- .../beginner/model_download_screen.dart | 110 +++++++--- lib/services/memory_diagnostics_service.dart | 50 +++-- lib/services/model_manager.dart | 69 +++++++ lib/services/model_orchestrator.dart | 190 +++++++++++++----- lib/services/platform_adaptation_service.dart | 34 +++- lib/utils/agent_debug_log.dart | 4 +- pubspec.yaml | 2 + test/services/free_ram_gate_test.dart | 15 +- 13 files changed, 555 insertions(+), 148 deletions(-) diff --git a/android/app/src/main/kotlin/dev/nova/assistant/MainActivity.kt b/android/app/src/main/kotlin/dev/nova/assistant/MainActivity.kt index 8557cb2..d1cb610 100644 --- a/android/app/src/main/kotlin/dev/nova/assistant/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/nova/assistant/MainActivity.kt @@ -82,6 +82,7 @@ class MainActivity : FlutterActivity() { "pssKb" to pssKb, "rssKb" to pssKb, "availMemMb" to (info.availMem / (1024 * 1024)), + "totalMemMb" to (info.totalMem / (1024 * 1024)), ) ) } diff --git a/android/app/src/main/kotlin/dev/nova/assistant/NovaApplication.kt b/android/app/src/main/kotlin/dev/nova/assistant/NovaApplication.kt index 4412e43..dfedb73 100644 --- a/android/app/src/main/kotlin/dev/nova/assistant/NovaApplication.kt +++ b/android/app/src/main/kotlin/dev/nova/assistant/NovaApplication.kt @@ -2,62 +2,162 @@ package dev.nova.assistant import android.app.Application import android.util.Log +import java.io.BufferedInputStream +import java.io.BufferedOutputStream import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream /** - * Migrates oversized chat history out of Flutter SharedPreferences before the - * Dart engine starts. A ~100MB+ `flutter.conversations` value OOMs the - * StandardMessageCodec when SharedPreferences.getInstance() loads all keys. + * Scrubs oversized chat-history keys from Flutter SharedPreferences **without** + * calling [getSharedPreferences]. Loading a 100MB+ prefs XML OOMs during + * KXmlParser (POCO F1 / mid-RAM devices). */ class NovaApplication : Application() { override fun onCreate() { super.onCreate() - migrateConversationsOutOfPrefs() + scrubOversizedFlutterPrefs() } - private fun migrateConversationsOutOfPrefs() { + private fun scrubOversizedFlutterPrefs() { + val prefsFile = File(dataDir, "shared_prefs/$FLUTTER_PREFS.xml") + val size = if (prefsFile.exists()) prefsFile.length() else 0L + // #region agent log + Log.i( + TAG, + "AGENT_DBG session=7abc09 hyp=A scrub start size=$size path=${prefsFile.absolutePath}", + ) + // #endregion + if (!prefsFile.exists() || size == 0L) return + try { - val prefs = getSharedPreferences(FLUTTER_PREFS, MODE_PRIVATE) - val value = prefs.getString(CONVERSATIONS_KEY, null) - val oldValue = prefs.getString(OLD_CHAT_KEY, null) - if (value == null && oldValue == null) return - - val outDir = File(dataDir, "app_flutter") - if (!outDir.exists()) outDir.mkdirs() - val outFile = File(outDir, CONVERSATIONS_FILE) - - if (value != null) { - if (value.length > MAX_MIGRATE_CHARS) { - Log.w( - TAG, - "Dropping oversized $CONVERSATIONS_KEY " + - "(${value.length} chars) — would OOM Flutter prefs codec", - ) - } else if (!outFile.exists() || outFile.length() == 0L) { - outFile.writeText(value) - Log.i( - TAG, - "Migrated conversations prefs (${value.length} chars) → ${outFile.path}", - ) + var removed = false + for (key in listOf(CONVERSATIONS_KEY, OLD_CHAT_KEY)) { + if (streamRemoveStringEntry(prefsFile, key)) { + removed = true + // #region agent log + Log.i(TAG, "AGENT_DBG session=7abc09 hyp=A removed key=$key") + // #endregion } - prefs.edit().remove(CONVERSATIONS_KEY).apply() } - if (oldValue != null) { - prefs.edit().remove(OLD_CHAT_KEY).apply() + val after = prefsFile.length() + // #region agent log + Log.i( + TAG, + "AGENT_DBG session=7abc09 hyp=A scrub done removed=$removed sizeAfter=$after", + ) + // #endregion + + if (after > MAX_SAFE_PREFS_BYTES) { + // Do not copyTo() a huge file (that also OOMs). Just replace. + prefsFile.delete() + prefsFile.writeText(EMPTY_PREFS_XML) + // #region agent log + Log.w( + TAG, + "AGENT_DBG session=7abc09 hyp=A wiped prefs after=$after", + ) + // #endregion } } catch (e: Exception) { - Log.e(TAG, "migrateConversationsOutOfPrefs failed", e) + Log.e(TAG, "AGENT_DBG session=7abc09 hyp=A scrub failed", e) try { - getSharedPreferences(FLUTTER_PREFS, MODE_PRIVATE) - .edit() - .remove(CONVERSATIONS_KEY) - .remove(OLD_CHAT_KEY) - .apply() + prefsFile.writeText(EMPTY_PREFS_XML) } catch (_: Exception) { - // Ignore — next launch may still crash until data is cleared. + // User must clear app data. + } + } + } + + /** + * Removes `` via streaming so the huge value + * is never materialized as a Java String. + */ + private fun streamRemoveStringEntry(prefsFile: File, key: String): Boolean { + val open = """""".toByteArray(Charsets.UTF_8) + val close = "".toByteArray(Charsets.UTF_8) + val tmp = File(prefsFile.parent, "${prefsFile.name}.tmp") + var found = false + + FileInputStream(prefsFile).use { fis -> + BufferedInputStream(fis, 256 * 1024).use { input -> + FileOutputStream(tmp).use { fos -> + BufferedOutputStream(fos, 256 * 1024).use { output -> + val buffer = ByteArray(256 * 1024) + val pending = ArrayList(open.size) + var openIdx = 0 + var skipping = false + var closeIdx = 0 + + while (true) { + val n = input.read(buffer) + if (n < 0) break + var i = 0 + while (i < n) { + val byte = buffer[i] + + if (skipping) { + if (byte == close[closeIdx]) { + closeIdx++ + if (closeIdx == close.size) { + skipping = false + closeIdx = 0 + found = true + } + } else { + closeIdx = if (byte == close[0]) 1 else 0 + } + i++ + continue + } + + if (byte == open[openIdx]) { + pending.add(byte) + openIdx++ + if (openIdx == open.size) { + skipping = true + openIdx = 0 + pending.clear() + } + i++ + continue + } + + if (openIdx > 0) { + for (p in pending) { + output.write(p.toInt()) + } + pending.clear() + openIdx = 0 + continue + } + + output.write(byte.toInt()) + i++ + } + } + + if (!skipping) { + for (p in pending) { + output.write(p.toInt()) + } + } + } + } } } + + if (found) { + if (!tmp.renameTo(prefsFile)) { + tmp.copyTo(prefsFile, overwrite = true) + tmp.delete() + } + } else { + tmp.delete() + } + + return found } companion object { @@ -65,8 +165,9 @@ class NovaApplication : Application() { private const val FLUTTER_PREFS = "FlutterSharedPreferences" private const val CONVERSATIONS_KEY = "flutter.conversations" private const val OLD_CHAT_KEY = "flutter.chat_history" - private const val CONVERSATIONS_FILE = "conversations.json" - /** Above this, skip content migrate and only delete the prefs key. */ - private const val MAX_MIGRATE_CHARS = 8_000_000 + private const val MAX_SAFE_PREFS_BYTES = 4L * 1024L * 1024L + private const val EMPTY_PREFS_XML = + "\n" + + "\n\n" } } diff --git a/docs/models.md b/docs/models.md index 344059f..bf00699 100644 --- a/docs/models.md +++ b/docs/models.md @@ -57,4 +57,24 @@ When a thread gets long, **Compact context** (manual button in chat) or **Auto-c | 8 GB+ | Gemma 4 E2B OK; close heavy apps first | | Debug all day | Enable battery/idle unload; avoid continuous screen capture | +Onboarding uses **total device RAM** (not only free RAM): phones under ~6.5 GB +default to **SmolLM** so a wiped POCO F1 does not start a 2.4 GB Gemma 4 download +it cannot load for chat. + Android idle unload is shorter (~2 minutes) to reduce LMK pressure. + +## On-device image generation (not in this build) + +Repos such as +[FLUX.2-klein-4B-LiteRT](https://huggingface.co/litert-community/FLUX.2-klein-4B-LiteRT) +and +[Z-Image-Turbo-LiteRT](https://huggingface.co/litert-community/Z-Image-Turbo-LiteRT) +are **LiteRT `CompiledModel` multi-graph diffusion pipelines** (many `.tflite` +chunks + host tokenizer/scheduler), not `flutter_gemma` / LiteRT-LM chat +(`.litertlm` / `.task`) models. + +Nova’s stack only runs LLM/VLM chat via `flutter_gemma`. Shipping those image +models would mean a separate native LiteRT GPU pipeline, multi‑GB downloads, and +phones in the Pixel 8a / 8 GB+ class for usable demos — out of scope for the +current APK (and unsuitable for POCO F1). Prefer **remote LAN** or a PC host +for image generation for now. diff --git a/lib/main.dart b/lib/main.dart index 88ebd12..768390b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -27,13 +27,28 @@ import 'package:nova_assistant/services/chat_history_service.dart'; import 'package:nova_assistant/services/widget_service.dart'; import 'package:nova_assistant/models/conversation.dart'; import 'package:nova_assistant/models/user_preferences.dart'; +import 'package:nova_assistant/utils/agent_debug_log.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); try { + // #region agent log + await AgentDebugLog.log( + hypothesisId: 'A', + location: 'main.dart:beforePrefs', + message: 'About to call SharedPreferences.getInstance', + ); + // #endregion await SharedPreferences.getInstance(); + // #region agent log + await AgentDebugLog.log( + hypothesisId: 'A', + location: 'main.dart:afterPrefs', + message: 'SharedPreferences.getInstance succeeded', + ); + // #endregion await FlutterGemma.initialize( inferenceEngines: const [LiteRtLmEngine(), MediaPipeEngine()], @@ -55,6 +70,7 @@ void main() async { await TtsService.instance.initialize(); await PromptPresetsService.instance.initialize(); + await ModelOrchestrator.instance.applyRamAwareModelDefaults(); _prefetchModels(); } catch (e) { debugPrint('Initialization error: $e'); diff --git a/lib/screens/assistant_screen.dart b/lib/screens/assistant_screen.dart index 996ce0a..9d68383 100644 --- a/lib/screens/assistant_screen.dart +++ b/lib/screens/assistant_screen.dart @@ -1943,11 +1943,14 @@ class _AssistantScreenState extends State ), ), const SizedBox(width: 6), - Text( - _status, - style: TextStyle(fontSize: 11, color: Colors.grey[500]), + Expanded( + child: Text( + _status, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 11, color: Colors.grey[500]), + ), ), - const Spacer(), if (_thinkingMode) Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), diff --git a/lib/screens/onboarding/beginner/model_download_screen.dart b/lib/screens/onboarding/beginner/model_download_screen.dart index 62d2076..822d6a1 100644 --- a/lib/screens/onboarding/beginner/model_download_screen.dart +++ b/lib/screens/onboarding/beginner/model_download_screen.dart @@ -4,7 +4,10 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:nova_assistant/models/model_info.dart'; import 'package:nova_assistant/models/user_preferences.dart'; +import 'package:nova_assistant/services/memory_diagnostics_service.dart'; import 'package:nova_assistant/services/model_manager.dart'; +import 'package:nova_assistant/services/model_orchestrator.dart'; +import 'package:nova_assistant/services/platform_adaptation_service.dart'; import 'package:nova_assistant/services/user_preferences_service.dart'; import 'package:nova_assistant/utils/agent_debug_log.dart'; @@ -18,29 +21,68 @@ class ModelDownloadScreen extends StatefulWidget { } class _ModelDownloadScreenState extends State { - static const _targetModel = NovaModel.gemma4E2b; + NovaModel _targetModel = NovaModel.smollm; + bool _resolvedTarget = false; double _progress = 0; - String _status = 'Preparing...'; + String _status = 'Checking device memory...'; bool _isComplete = false; bool _hasError = false; String _errorMessage = ''; bool _isBusy = false; + String get _capabilitiesLabel { + final parts = []; + if (_targetModel.hasVision) parts.add('Vision'); + if (_targetModel.hasThinking) parts.add('Thinking'); + parts.add('Chat'); + return parts.join(' + '); + } + @override void initState() { super.initState(); - _checkExisting(); + _resolveTargetAndCheck(); } Future _finishReady() async { await UserPreferencesService.instance.setMode(UserMode.beginner); + ModelOrchestrator.instance.preferredModelType = _targetModel; + ModelOrchestrator.instance.selector.primaryHeavy = _targetModel; + ModelOrchestrator.instance.selector.fastModel = NovaModel.smollm; await Future.delayed(const Duration(milliseconds: 800)); if (mounted) { widget.onDownloadComplete(); } } + Future _resolveTargetAndCheck() async { + final recommended = await PlatformAdaptationService.instance + .recommendModelForDevice(); + // #region agent log + await AgentDebugLog.log( + hypothesisId: 'E', + location: 'model_download_screen.dart:_resolveTargetAndCheck', + message: 'Onboarding model recommendation', + data: { + 'recommended': recommended.displayName, + 'sizeMB': recommended.sizeMB, + 'availMemMb': MemoryDiagnosticsService.instance.lastAvailMemMb, + 'totalMemMb': MemoryDiagnosticsService.instance.lastTotalMemMb, + }, + runId: 'post-fix', + ); + // #endregion + + if (!mounted) return; + setState(() { + _targetModel = recommended; + _resolvedTarget = true; + _status = 'Choose how to install ${recommended.displayName}'; + }); + await _checkExisting(); + } + Future _checkExisting() async { final fileName = ModelHuggingFaceURLs.fileNameFor(_targetModel); final path = await ModelManager.instance.findModelPath(fileName); @@ -69,7 +111,7 @@ class _ModelDownloadScreenState extends State { if (mounted) { setState(() { - _status = 'Choose how to install Gemma 4 E2B'; + _status = 'Choose how to install ${_targetModel.displayName}'; _progress = 0; }); } @@ -80,7 +122,7 @@ class _ModelDownloadScreenState extends State { _isBusy = true; _progress = 0; _hasError = false; - _status = 'Downloading Gemma 4 E2B...'; + _status = 'Downloading ${_targetModel.displayName}...'; }); try { @@ -157,7 +199,7 @@ class _ModelDownloadScreenState extends State { if (mounted) { setState(() { _isBusy = false; - _status = 'Choose how to install Gemma 4 E2B'; + _status = 'Choose how to install ${_targetModel.displayName}'; }); } return; @@ -188,8 +230,8 @@ class _ModelDownloadScreenState extends State { _isBusy = false; _hasError = true; _errorMessage = - 'Import failed. Select a .litertlm file ' - '(e.g. gemma-4-E2B-it.litertlm).'; + 'Import failed. Select a matching model file ' + '(e.g. ${ModelHuggingFaceURLs.fileNameFor(_targetModel)}).'; }); } } catch (e) { @@ -205,6 +247,12 @@ class _ModelDownloadScreenState extends State { @override Widget build(BuildContext context) { + final sizeLabel = '~${_targetModel.sizeMB} MB'; + final subtitle = _resolvedTarget + ? '${_targetModel.displayName} — download ($sizeLabel) or import a\n' + 'model file you already have. Chosen for this device\'s free RAM.' + : 'Checking free RAM to pick a model that can actually load...'; + return Padding( padding: const EdgeInsets.all(32), child: Column( @@ -239,8 +287,7 @@ class _ModelDownloadScreenState extends State { ), const SizedBox(height: 12), Text( - 'Gemma 4 E2B — download (~2.4 GB) or import a\n' - '.litertlm file you already have.', + subtitle, textAlign: TextAlign.center, style: TextStyle( color: Colors.grey[400], @@ -249,7 +296,7 @@ class _ModelDownloadScreenState extends State { ), ), const SizedBox(height: 48), - _buildModelInfo(), + _buildModelInfo(sizeLabel), const SizedBox(height: 32), if (_hasError) ...[ Container( @@ -276,7 +323,7 @@ class _ModelDownloadScreenState extends State { SizedBox( width: double.infinity, child: FilledButton( - onPressed: _isBusy ? null : _downloadModel, + onPressed: _isBusy || !_resolvedTarget ? null : _downloadModel, style: FilledButton.styleFrom( backgroundColor: const Color(0xFF6C63FF), padding: const EdgeInsets.symmetric(vertical: 14), @@ -291,14 +338,16 @@ class _ModelDownloadScreenState extends State { ), ), TextButton( - onPressed: _isBusy ? null : _importFromStorage, + onPressed: _isBusy || !_resolvedTarget + ? null + : _importFromStorage, child: const Text('Import from storage instead'), ), ] else if (!_isComplete && !_isBusy) ...[ SizedBox( width: double.infinity, child: FilledButton( - onPressed: _downloadModel, + onPressed: !_resolvedTarget ? null : _downloadModel, style: FilledButton.styleFrom( backgroundColor: const Color(0xFF6C63FF), padding: const EdgeInsets.symmetric(vertical: 14), @@ -316,7 +365,7 @@ class _ModelDownloadScreenState extends State { SizedBox( width: double.infinity, child: OutlinedButton( - onPressed: _importFromStorage, + onPressed: !_resolvedTarget ? null : _importFromStorage, style: OutlinedButton.styleFrom( foregroundColor: Colors.white, side: BorderSide(color: Colors.white.withValues(alpha: 0.3)), @@ -334,13 +383,16 @@ class _ModelDownloadScreenState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - _status, - style: TextStyle( - color: _isComplete - ? const Color(0xFF4CAF50) - : Colors.grey[400], - fontSize: 13, + Flexible( + child: Text( + _status, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: _isComplete + ? const Color(0xFF4CAF50) + : Colors.grey[400], + fontSize: 13, + ), ), ), Text( @@ -389,7 +441,7 @@ class _ModelDownloadScreenState extends State { ) else if (!_hasError && !_isBusy) Text( - 'Tip: If you already have the .litertlm file, use Import.', + 'Tip: If you already have the model file, use Import.', textAlign: TextAlign.center, style: TextStyle(color: Colors.grey[600], fontSize: 12), ), @@ -399,7 +451,7 @@ class _ModelDownloadScreenState extends State { ); } - Widget _buildModelInfo() { + Widget _buildModelInfo(String sizeLabel) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -409,13 +461,17 @@ class _ModelDownloadScreenState extends State { ), child: Column( children: [ - _buildInfoRow('Model', 'Gemma 4 E2B', Icons.model_training_outlined), + _buildInfoRow( + 'Model', + _targetModel.displayName, + Icons.model_training_outlined, + ), const SizedBox(height: 12), - _buildInfoRow('Size', '~2400 MB', Icons.storage_outlined), + _buildInfoRow('Size', sizeLabel, Icons.storage_outlined), const SizedBox(height: 12), _buildInfoRow( 'Capabilities', - 'Vision + Thinking + Function Calling', + _capabilitiesLabel, Icons.auto_awesome_outlined, ), ], diff --git a/lib/services/memory_diagnostics_service.dart b/lib/services/memory_diagnostics_service.dart index 7a4b914..d1bc064 100644 --- a/lib/services/memory_diagnostics_service.dart +++ b/lib/services/memory_diagnostics_service.dart @@ -15,12 +15,31 @@ class MemoryDiagnosticsService { int? _lastPssKb; int? _lastRssKb; int? _lastAvailMemMb; + int? _lastTotalMemMb; int? get lastPssMb => _lastPssKb != null ? (_lastPssKb! / 1024).round() : null; int? get lastRssMb => _lastRssKb != null ? (_lastRssKb! / 1024).round() : null; int? get lastAvailMemMb => _lastAvailMemMb; + int? get lastTotalMemMb => _lastTotalMemMb; + + void _ingestMemoryMap(Map result) { + _lastPssKb = result['pssKb'] as int?; + _lastRssKb = result['rssKb'] as int?; + final avail = result['availMemMb']; + if (avail is int) { + _lastAvailMemMb = avail; + } else if (avail is num) { + _lastAvailMemMb = avail.round(); + } + final total = result['totalMemMb']; + if (total is int) { + _lastTotalMemMb = total; + } else if (total is num) { + _lastTotalMemMb = total.round(); + } + } /// Best-effort free system RAM in MB (Android ActivityManager). Future readAvailableMemMb() async { @@ -30,19 +49,9 @@ class MemoryDiagnosticsService { 'getProcessMemory', ); if (result != null) { - _lastPssKb = result['pssKb'] as int?; - _lastRssKb = result['rssKb'] as int?; - final avail = result['availMemMb']; - if (avail is int) { - _lastAvailMemMb = avail; + _ingestMemoryMap(result); - return avail; - } - if (avail is num) { - _lastAvailMemMb = avail.round(); - - return _lastAvailMemMb; - } + return _lastAvailMemMb; } } on PlatformException catch (e) { debugPrint('MemoryDiagnosticsService availMem: $e'); @@ -52,6 +61,14 @@ class MemoryDiagnosticsService { return _lastAvailMemMb; } + /// Best-effort total system RAM in MB (Android ActivityManager). + Future readTotalMemMb() async { + if (_lastTotalMemMb != null) return _lastTotalMemMb; + await readAvailableMemMb(); + + return _lastTotalMemMb; + } + /// Best-effort process memory in MB (PSS preferred on Android). Future readProcessMemoryMb() async { if (!kIsWeb && Platform.isAndroid) { @@ -60,14 +77,7 @@ class MemoryDiagnosticsService { 'getProcessMemory', ); if (result != null) { - _lastPssKb = result['pssKb'] as int?; - _lastRssKb = result['rssKb'] as int?; - final avail = result['availMemMb']; - if (avail is int) { - _lastAvailMemMb = avail; - } else if (avail is num) { - _lastAvailMemMb = avail.round(); - } + _ingestMemoryMap(result); final pss = _lastPssKb; if (pss != null) return (pss / 1024).round(); } diff --git a/lib/services/model_manager.dart b/lib/services/model_manager.dart index accff90..faab7fb 100644 --- a/lib/services/model_manager.dart +++ b/lib/services/model_manager.dart @@ -53,6 +53,7 @@ class ModelManager { ModelManager._(); static const _prefsKey = 'installed_models'; + Future? _installLock; static const _customModelsPrefsKey = 'custom_models'; static const _hfTokenKey = 'hf_token'; final List _installedModels = []; @@ -114,6 +115,34 @@ class ModelManager { required ModelType modelType, required ModelFileType fileType, void Function(int progress)? onProgress, + }) async { + // Serialize installs — concurrent Gemma 4 onboarding + Gemma 3 fallback + // races corrupt flutter_gemma's active identity (POCO F1). + while (_installLock != null) { + await _installLock; + } + final gate = Completer(); + _installLock = gate.future; + try { + return await _installFromNetworkUnlocked( + url: url, + modelType: modelType, + fileType: fileType, + onProgress: onProgress, + ); + } finally { + gate.complete(); + if (identical(_installLock, gate.future)) { + _installLock = null; + } + } + } + + Future _installFromNetworkUnlocked({ + required String url, + required ModelType modelType, + required ModelFileType fileType, + void Function(int progress)? onProgress, }) async { try { final uri = Uri.parse(url); @@ -125,6 +154,7 @@ class ModelManager { location: 'model_manager.dart:installFromNetwork:start', message: 'installFromNetwork started', data: {'url': url, 'fileName': fileName, 'modelType': modelType.name}, + runId: 'post-fix', ); // #endregion @@ -161,6 +191,7 @@ class ModelManager { 'canonicalName': canonicalName, 'fileOnDiskExists': await fileOnDisk.exists(), }, + runId: 'post-fix', ); // #endregion @@ -211,6 +242,21 @@ class ModelManager { } final response = await request.close(); + // #region agent log + await AgentDebugLog.log( + hypothesisId: 'F', + location: 'model_manager.dart:installFromNetwork:http', + message: 'Download HTTP response', + data: { + 'fileName': fileName, + 'statusCode': response.statusCode, + 'contentLength': response.contentLength, + 'hasToken': hfToken != null && hfToken.isNotEmpty, + }, + runId: 'post-fix', + ); + // #endregion + if (response.statusCode == 401 || response.statusCode == 403) { _statusController.add( 'Download failed: HuggingFace auth required. ' @@ -242,6 +288,19 @@ class ModelManager { _statusController.add( 'Download incomplete: expected $totalBytes bytes, got $receivedBytes', ); + // #region agent log + await AgentDebugLog.log( + hypothesisId: 'F', + location: 'model_manager.dart:installFromNetwork:incomplete', + message: 'Download incomplete', + data: { + 'fileName': fileName, + 'expected': totalBytes, + 'received': receivedBytes, + }, + runId: 'post-fix', + ); + // #endregion try { await tempFile.delete(); } catch (e) { @@ -285,6 +344,7 @@ class ModelManager { 'prefsCount': _installedModels.length, 'prefsNames': _installedModels.map((m) => m.fileName).toList(), }, + runId: 'post-fix', ); // #endregion @@ -303,6 +363,15 @@ class ModelManager { } catch (e) { _statusController.add('Install failed: $e'); debugPrint('ModelManager: installFromNetwork failed: $e'); + // #region agent log + await AgentDebugLog.log( + hypothesisId: 'F', + location: 'model_manager.dart:installFromNetwork:error', + message: 'installFromNetwork exception', + data: {'error': e.toString()}, + runId: 'post-fix', + ); + // #endregion return null; } } diff --git a/lib/services/model_orchestrator.dart b/lib/services/model_orchestrator.dart index 78d8b35..620931d 100644 --- a/lib/services/model_orchestrator.dart +++ b/lib/services/model_orchestrator.dart @@ -774,13 +774,55 @@ class ModelOrchestrator { } // Free-RAM hard gate before a cold load of heavy models (Android). - final ramBlock = await PlatformAdaptationService.instance.checkCanLoadModel( - model, + // If blocked, auto-fallback to a smaller installed-capable model instead of + // failing the whole turn (POCO F1 / ~1.2GB free cannot load Gemma 4). + var modelToLoad = model; + var ramBlock = await PlatformAdaptationService.instance.checkCanLoadModel( + modelToLoad, + ); + // #region agent log + await AgentDebugLog.log( + hypothesisId: 'C', + location: 'model_orchestrator.dart:_getOrCreateModel:ramGate', + message: 'RAM gate decision', + data: { + 'requested': model.displayName, + 'blocked': ramBlock != null, + 'blockMsg': ramBlock, + 'availMemMb': MemoryDiagnosticsService.instance.lastAvailMemMb, + }, ); + // #endregion + if (ramBlock != null) { + final fallback = await _pickRamFallback(modelToLoad); + if (fallback != null) { + // #region agent log + await AgentDebugLog.log( + hypothesisId: 'C', + location: 'model_orchestrator.dart:_getOrCreateModel:fallback', + message: 'RAM gate fallback', + data: { + 'from': modelToLoad.displayName, + 'to': fallback.displayName, + 'fileName': ModelHuggingFaceURLs.fileNameFor(fallback), + 'onDisk': await ModelManager.instance.isInstalledOnDisk( + ModelHuggingFaceURLs.fileNameFor(fallback), + ), + }, + ); + // #endregion + _statusController.add( + 'Low free RAM — using ${fallback.displayName} instead of ' + '${modelToLoad.displayName}', + ); + modelToLoad = fallback; + ramBlock = null; + } + } if (ramBlock != null) { throw ModelException( ramBlock, - model: model, + model: modelToLoad, suggestion: 'Free RAM by closing apps, or use a smaller model in Settings.', ); @@ -810,18 +852,18 @@ class ModelOrchestrator { } } - final bool supportImage = needsImageSupport; + final bool supportImage = modelToLoad.hasVision; _isLoadingModel = true; _statusController.add( - supportImage && model == NovaModel.gemma4E2b - ? 'Compiling ${model.displayName} on GPU (vision enabled; first load may take 1–2 min)...' - : 'Loading ${model.displayName}...', + supportImage && modelToLoad == NovaModel.gemma4E2b + ? 'Compiling ${modelToLoad.displayName} on GPU (vision enabled; first load may take 1–2 min)...' + : 'Loading ${modelToLoad.displayName}...', ); try { // First, ensure the model is registered with flutter_gemma - final fileName = ModelHuggingFaceURLs.fileNameFor(model); + final fileName = ModelHuggingFaceURLs.fileNameFor(modelToLoad); final existsOnDisk = await ModelManager.instance.isInstalledOnDisk( fileName, ); @@ -833,7 +875,7 @@ class ModelOrchestrator { location: 'model_orchestrator.dart:_getOrCreateModel:check', message: 'Model availability check before load', data: { - 'model': model.name, + 'model': modelToLoad.name, 'fileName': fileName, 'existsOnDisk': existsOnDisk, 'prefsInstalled': prefsInstalled, @@ -843,7 +885,7 @@ class ModelOrchestrator { if (existsOnDisk) { _statusController.add( - 'Found ${model.displayName} on disk, registering...', + 'Found ${modelToLoad.displayName} on disk, registering...', ); try { final modelPath = await _findModelPath(fileName); @@ -853,29 +895,29 @@ class ModelOrchestrator { await ModelManager.instance.registerDiskModel( filePath: modelPath, fileName: fileName, - modelType: model.modelType, - fileType: model.fileType, + modelType: modelToLoad.modelType, + fileType: modelToLoad.fileType, fileSizeBytes: fileSize, ); // Now get the active model - _statusController.add('Loading ${model.displayName}...'); + _statusController.add('Loading ${modelToLoad.displayName}...'); if (_debugMode) { unawaited( MemoryDiagnosticsService.instance.readProcessMemoryMb(), ); } _activeModel = await FlutterGemma.getActiveModel( - maxTokens: _tokenLimitFor(model), + maxTokens: _tokenLimitFor(modelToLoad), preferredBackend: PlatformAdaptationService.instance - .preferredBackendFor(model), + .preferredBackendFor(modelToLoad), supportImage: supportImage, maxNumImages: supportImage ? 1 : null, - ).timeout(_loadTimeoutFor(model)); - _activeModelType = model; + ).timeout(_loadTimeoutFor(modelToLoad)); + _activeModelType = modelToLoad; _activeModelSupportsImage = supportImage; _isInitialized = true; - _statusController.add('${model.displayName} ready'); + _statusController.add('${modelToLoad.displayName} ready'); _resetIdleTimer(); return _activeModel!; } @@ -883,8 +925,8 @@ class ModelOrchestrator { debugPrint('Failed to load local model: $e'); await _teardownPartialLoad(); throw ModelLoadException( - '${model.displayName} is still loading.', - model: model, + '${modelToLoad.displayName} is still loading.', + model: modelToLoad, suggestion: 'First boot GPU compile can take 1–2 minutes. Wait and try again, ' 'or use Settings → Debug → Reset inference engine.', @@ -898,16 +940,16 @@ class ModelOrchestrator { if (e is TimeoutException) { await _teardownPartialLoad(); throw ModelLoadException( - '${model.displayName} is still loading.', - model: model, + '${modelToLoad.displayName} is still loading.', + model: modelToLoad, suggestion: 'First boot GPU compile can take 1–2 minutes. Wait and try again.', underlyingError: e, ); } await _teardownPartialLoad(); throw ModelLoadException( - 'Failed to load ${model.displayName} from disk.', - model: model, + 'Failed to load ${modelToLoad.displayName} from disk.', + model: modelToLoad, suggestion: 'The model file may be corrupted. ' 'Try re-downloading, reset inference in Settings, or pick a different file.', @@ -917,13 +959,13 @@ class ModelOrchestrator { } // No active model or load failed — ask user before downloading - final url = ModelHuggingFaceURLs.urlFor(model); - final choice = await _showDownloadConsent(model: model, url: url); + final url = ModelHuggingFaceURLs.urlFor(modelToLoad); + final choice = await _showDownloadConsent(model: modelToLoad, url: url); if (choice == DownloadConsent.pickFile) { // User wants to pick a file from device — throw a special exception // so the UI can handle the file picker flow. - throw ModelNeedsFilePickException(model); + throw ModelNeedsFilePickException(modelToLoad); } if (choice == DownloadConsent.cancel) { @@ -931,16 +973,16 @@ class ModelOrchestrator { } // choice == DownloadConsent.download — proceed with network install - _statusController.add('Downloading ${model.displayName}...'); + _statusController.add('Downloading ${modelToLoad.displayName}...'); try { final installed = await ModelManager.instance .installFromNetwork( url: url, - modelType: model.modelType, - fileType: model.fileType, + modelType: modelToLoad.modelType, + fileType: modelToLoad.fileType, onProgress: (progress) { _statusController.add( - 'Downloading ${model.displayName}: $progress%', + 'Downloading ${modelToLoad.displayName}: $progress%', ); }, ) @@ -948,25 +990,25 @@ class ModelOrchestrator { if (installed == null) { throw ModelDownloadException( - 'Download of ${model.displayName} failed.', - model: model, + 'Download of ${modelToLoad.displayName} failed.', + model: modelToLoad, suggestion: 'Check your internet connection and try again.', ); } // Now get the model after install - _statusController.add('Loading ${model.displayName}...'); + _statusController.add('Loading ${modelToLoad.displayName}...'); _activeModel = await FlutterGemma.getActiveModel( - maxTokens: _tokenLimitFor(model), + maxTokens: _tokenLimitFor(modelToLoad), preferredBackend: PlatformAdaptationService.instance - .preferredBackendFor(model), + .preferredBackendFor(modelToLoad), supportImage: supportImage, maxNumImages: supportImage ? 1 : null, - ).timeout(_loadTimeoutFor(model)); - _activeModelType = model; + ).timeout(_loadTimeoutFor(modelToLoad)); + _activeModelType = modelToLoad; _activeModelSupportsImage = supportImage; _isInitialized = true; - _statusController.add('${model.displayName} ready'); + _statusController.add('${modelToLoad.displayName} ready'); _resetIdleTimer(); return _activeModel!; } on ModelException { @@ -974,21 +1016,21 @@ class ModelOrchestrator { } on TimeoutException catch (e) { await _teardownPartialLoad(); throw ModelLoadException( - '${model.displayName} is still loading.', - model: model, + '${modelToLoad.displayName} is still loading.', + model: modelToLoad, suggestion: 'First boot GPU compile can take 1–2 minutes. Wait and try again, ' 'or use Settings → Debug → Reset inference engine.', underlyingError: e, ); } catch (e) { - _statusController.add('Failed to load ${model.displayName}: $e'); + _statusController.add('Failed to load ${modelToLoad.displayName}: $e'); // Classify the error final msg = e.toString().toLowerCase(); if (msg.contains('storage') || msg.contains('no space')) { throw ModelStorageException( - 'Not enough storage for ${model.displayName}.', - model: model, + 'Not enough storage for ${modelToLoad.displayName}.', + model: modelToLoad, suggestion: 'Free up storage space and try again.', underlyingError: e, ); @@ -997,8 +1039,8 @@ class ModelOrchestrator { msg.contains('invalid') || msg.contains('unsupported')) { throw ModelLoadException( - '${model.displayName} file is corrupted or incompatible.', - model: model, + '${modelToLoad.displayName} file is corrupted or incompatible.', + model: modelToLoad, suggestion: 'Re-download the model or pick a different file.', underlyingError: e, ); @@ -1007,15 +1049,15 @@ class ModelOrchestrator { msg.contains('connection') || msg.contains('socket')) { throw ModelDownloadException( - 'Network error downloading ${model.displayName}.', - model: model, + 'Network error downloading ${modelToLoad.displayName}.', + model: modelToLoad, suggestion: 'Check your internet connection and try again.', underlyingError: e, ); } throw ModelLoadException( - 'Failed to load ${model.displayName}.', - model: model, + 'Failed to load ${modelToLoad.displayName}.', + model: modelToLoad, suggestion: 'Try again or pick a file from your device.', underlyingError: e, ); @@ -1025,6 +1067,54 @@ class ModelOrchestrator { } } + /// Pick a smaller model that fits free RAM. + /// + /// Prefers models already on disk (no download), then SmolLM (tiny download) + /// before Gemma 3 1B. Avoids falling back to a missing mid-size model while + /// the device only has Gemma 4 installed (POCO F1). + Future _pickRamFallback(NovaModel blocked) async { + const candidates = [NovaModel.smollm, NovaModel.gemma3_1b]; + NovaModel? firstDownloadable; + + for (final candidate in candidates) { + if (candidate == blocked) continue; + final block = await PlatformAdaptationService.instance.checkCanLoadModel( + candidate, + ); + if (block != null) continue; + + final fileName = ModelHuggingFaceURLs.fileNameFor(candidate); + final onDisk = await ModelManager.instance.isInstalledOnDisk(fileName); + if (onDisk) { + return candidate; + } + firstDownloadable ??= candidate; + } + + return firstDownloadable; + } + + /// Apply Auto-mode defaults for low free-RAM devices (call at startup). + Future applyRamAwareModelDefaults() async { + final recommended = await PlatformAdaptationService.instance + .recommendModelForDevice(); + if (selector.primaryHeavy != recommended) { + selector.primaryHeavy = recommended; + // #region agent log + await AgentDebugLog.log( + hypothesisId: 'E', + location: 'model_orchestrator.dart:applyRamAwareModelDefaults', + message: 'Adjusted Auto primaryHeavy for free RAM', + data: { + 'primaryHeavy': recommended.displayName, + 'availMemMb': MemoryDiagnosticsService.instance.lastAvailMemMb, + }, + runId: 'post-fix', + ); + // #endregion + } + } + /// Process a message using a custom (user-imported) model. Stream _processWithCustomModel({ required String query, diff --git a/lib/services/platform_adaptation_service.dart b/lib/services/platform_adaptation_service.dart index eac77ca..42f8bc4 100644 --- a/lib/services/platform_adaptation_service.dart +++ b/lib/services/platform_adaptation_service.dart @@ -297,12 +297,42 @@ class PlatformAdaptationService { if (kIsWeb) return null; if (defaultTargetPlatform != TargetPlatform.android) return null; - if (model.sizeMB >= 2000) return 3500; - if (model.sizeMB >= 400) return 1200; + // Softened for 6GB devices (POCO F1 etc.): hard-block only when truly + // starved. Prefer auto-fallback in the orchestrator over refusing chat. + if (model.sizeMB >= 2000) return 1800; + if (model.sizeMB >= 400) return 700; return null; } + /// Onboarding / default model for device RAM class. + /// + /// Free RAM alone is unreliable: a wiped POCO F1 (6 GB total) can report + /// >1800 MB free at boot and wrongly recommend Gemma 4, which then fails to + /// load once the system fills. Prefer **total RAM** for onboarding. + Future recommendModelForDevice() async { + if (kIsWeb) return NovaModel.smollm; + if (defaultTargetPlatform != TargetPlatform.android) { + return NovaModel.gemma4E2b; + } + + final mem = MemoryDiagnosticsService.instance; + final avail = await mem.readAvailableMemMb(); + final total = await mem.readTotalMemMb(); + + // ≤6.5 GB class (POCO F1 6 GB, etc.): never onboard Gemma 4. + // Fresh reboot can show plenty of free RAM and still OOM on load. + if (total != null && total < 6656) { + return NovaModel.smollm; + } + + if (avail == null) return NovaModel.gemma3_1b; + if (avail < 900) return NovaModel.smollm; + if (avail < 1800) return NovaModel.gemma3_1b; + + return NovaModel.gemma4E2b; + } + /// Pure gate check: returns an error message when [availMemMb] is too low. String? freeRamGateMessage({ required NovaModel model, diff --git a/lib/utils/agent_debug_log.dart b/lib/utils/agent_debug_log.dart index 13ed091..04e2f7a 100644 --- a/lib/utils/agent_debug_log.dart +++ b/lib/utils/agent_debug_log.dart @@ -3,12 +3,12 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; -/// Debug-mode NDJSON logger for session 36a30e. +/// Debug-mode NDJSON logger for session 7abc09. /// Posts to the local ingest server (use `adb reverse tcp:7704 tcp:7704`). class AgentDebugLog { static const _endpoint = 'http://127.0.0.1:7704/ingest/b12c34e9-d2e1-4829-bc46-cbd81f2bb686'; - static const _sessionId = '36a30e'; + static const _sessionId = '7abc09'; static Future log({ required String hypothesisId, diff --git a/pubspec.yaml b/pubspec.yaml index 8b2119a..1fac20a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -64,6 +64,8 @@ dev_dependencies: build_runner: ^2.4.13 dart_code_linter: ^4.1.6 coverage: ^1.7.0 + path_provider_platform_interface: ^2.1.2 + plugin_platform_interface: ^2.1.8 flutter: uses-material-design: true \ No newline at end of file diff --git a/test/services/free_ram_gate_test.dart b/test/services/free_ram_gate_test.dart index 4dd5240..f3276c5 100644 --- a/test/services/free_ram_gate_test.dart +++ b/test/services/free_ram_gate_test.dart @@ -13,11 +13,11 @@ void main() { final message = service.freeRamGateMessage( model: NovaModel.gemma4E2b, - availMemMb: 2000, + availMemMb: 1000, ); expect(message, isNotNull); expect(message!, contains('Gemma 4 E2B')); - expect(message, contains('2000 MB')); + expect(message, contains('1000 MB')); }); test('allows Gemma 4 when enough free RAM', () { @@ -27,7 +27,7 @@ void main() { expect( service.freeRamGateMessage( model: NovaModel.gemma4E2b, - availMemMb: 4000, + availMemMb: 2000, ), isNull, ); @@ -55,5 +55,14 @@ void main() { isNull, ); }); + + test('minFreeRamMbFor matches size tiers', () { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + + expect(service.minFreeRamMbFor(NovaModel.gemma4E2b), 1800); + expect(service.minFreeRamMbFor(NovaModel.gemma3_1b), 700); + expect(service.minFreeRamMbFor(NovaModel.smollm), isNull); + }); }); }