@tduncan / Cortana / commits / 83a32fe

feat: implement on-device model support with engine service, download management, and onboarding integration

abdulmominsakib committed Apr 16, 2026 at 15:53 UTC 83a32fe88819758541b2f82441a9e6479ae2b28b
41 files changed +2914 -33
android/.project new
+28
@@ -0,0 +1,28 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<projectDescription>
3 + <name>android</name>
4 + <comment>Project android created by Buildship.</comment>
5 + <projects>
6 + </projects>
7 + <buildSpec>
8 + <buildCommand>
9 + <name>org.eclipse.buildship.core.gradleprojectbuilder</name>
10 + <arguments>
11 + </arguments>
12 + </buildCommand>
13 + </buildSpec>
14 + <natures>
15 + <nature>org.eclipse.buildship.core.gradleprojectnature</nature>
16 + </natures>
17 + <filteredResources>
18 + <filter>
19 + <id>1776320082278</id>
20 + <name></name>
21 + <type>30</type>
22 + <matcher>
23 + <id>org.eclipse.core.resources.regexFilterMatcher</id>
24 + <arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
25 + </matcher>
26 + </filter>
27 + </filteredResources>
28 +</projectDescription>
android/.settings/org.eclipse.buildship.core.prefs new
+13
@@ -0,0 +1,13 @@
1 +arguments=--init-script /Users/abdulmominsakib/.cache/opencode/bin/jdtls/config_mac/org.eclipse.osgi/59/0/.cp/gradle/init/init.gradle
2 +auto.sync=false
3 +build.scans.enabled=false
4 +connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER)
5 +connection.project.dir=
6 +eclipse.preferences.version=1
7 +gradle.user.home=
8 +java.home=/opt/homebrew/Cellar/openjdk@21/21.0.10/libexec/openjdk.jdk/Contents/Home
9 +jvm.arguments=
10 +offline.mode=false
11 +override.workspace.settings=true
12 +show.console.view=true
13 +show.executions.view=true
android/app/.classpath new
+6
@@ -0,0 +1,6 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<classpath>
3 + <classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-21/"/>
4 + <classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
5 + <classpathentry kind="output" path="bin/default"/>
6 +</classpath>
android/app/.project new
+34
@@ -0,0 +1,34 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<projectDescription>
3 + <name>app</name>
4 + <comment>Project app created by Buildship.</comment>
5 + <projects>
6 + </projects>
7 + <buildSpec>
8 + <buildCommand>
9 + <name>org.eclipse.jdt.core.javabuilder</name>
10 + <arguments>
11 + </arguments>
12 + </buildCommand>
13 + <buildCommand>
14 + <name>org.eclipse.buildship.core.gradleprojectbuilder</name>
15 + <arguments>
16 + </arguments>
17 + </buildCommand>
18 + </buildSpec>
19 + <natures>
20 + <nature>org.eclipse.jdt.core.javanature</nature>
21 + <nature>org.eclipse.buildship.core.gradleprojectnature</nature>
22 + </natures>
23 + <filteredResources>
24 + <filter>
25 + <id>1776320082280</id>
26 + <name></name>
27 + <type>30</type>
28 + <matcher>
29 + <id>org.eclipse.core.resources.regexFilterMatcher</id>
30 + <arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
31 + </matcher>
32 + </filter>
33 + </filteredResources>
34 +</projectDescription>
android/app/.settings/org.eclipse.buildship.core.prefs new
+2
@@ -0,0 +1,2 @@
1 +connection.project.dir=..
2 +eclipse.preferences.version=1
android/app/build.gradle.kts
+7 -1
@@ -20,6 +20,7 @@ android {
20 ndkVersion = flutter.ndkVersion
21
22 compileOptions {
23 + isCoreLibraryDesugaringEnabled = true
24 sourceCompatibility = JavaVersion.VERSION_17
25 targetCompatibility = JavaVersion.VERSION_17
26 }
@@ -33,10 +34,11 @@ android {
34 applicationId = "pro.momin.localmind"
35 // You can update the following values to match your application needs.
36 // For more information, see: https://flutter.dev/to/review-gradle-config.
36 - minSdk = flutter.minSdkVersion
37 + minSdk = 26
38 targetSdk = flutter.targetSdkVersion
39 versionCode = flutter.versionCode
40 versionName = flutter.versionName
41 + multiDexEnabled = true
42 }
43
44 signingConfigs {
@@ -64,3 +66,7 @@ android {
66 flutter {
67 source = "../.."
68 }
69 +
70 +dependencies {
71 + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
72 +}
android/app/src/main/AndroidManifest.xml
+3 -1
@@ -1,5 +1,7 @@
1 -<manifest xmlns:android="http://schemas.android.com/apk/res/android">
1 +<manifest xmlns:android="http://schemas.android.com/apk/res/android"
2 + xmlns:tools="http://schemas.android.com/tools">
3 <uses-permission android:name="android.permission.INTERNET" />
4 +
5 <application
6 android:label="LocalMind"
7 android:name="${applicationName}"
ios/Runner/AppDelegate.swift
-4
@@ -9,8 +9,4 @@ import UIKit
9 ) -> Bool {
10 return super.application(application, didFinishLaunchingWithOptions: launchOptions)
11 }
12 -
13 - func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
14 - GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
15 - }
12 }
ios/Runner/Info.plist
+5
@@ -73,5 +73,10 @@
73 <string>UIInterfaceOrientationLandscapeLeft</string>
74 <string>UIInterfaceOrientationLandscapeRight</string>
75 </array>
76 + <key>UIBackgroundModes</key>
77 + <array>
78 + <string>fetch</string>
79 + <string>processing</string>
80 + </array>
81 </dict>
82 </plist>
lib/app.dart
+15
@@ -9,12 +9,15 @@ import 'core/routes/app_routes.dart';
9 import 'core/theme/app_theme.dart';
10 import 'features/chat/views/chat_screen.dart';
11 import 'features/conversations/views/chat_history_screen.dart';
12 +import 'features/on_device/views/model_manager_screen.dart';
13 +import 'features/onboarding/screens/onboarding_model_download_screen.dart';
14 import 'features/onboarding/screens/onboarding_server_setup_screen.dart';
15 import 'features/onboarding/screens/onboarding_server_type_screen.dart';
16 import 'features/onboarding/screens/onboarding_theme_screen.dart';
17 import 'features/personas/views/create_persona_screen.dart';
18 import 'features/personas/views/persona_list_screen.dart';
19 import 'features/servers/data/models/server.dart';
20 +import 'features/servers/providers/server_providers.dart';
21 import 'features/servers/views/add_server_screen.dart';
22 import 'features/servers/views/server_list_screen.dart';
23 import 'features/settings/screens/settings_screen.dart';
@@ -63,6 +66,11 @@ final routerProvider = Provider<GoRouter>((ref) {
66 );
67 },
68 ),
69 + GoRoute(
70 + path: AppRoutes.onboardingModelDownload,
71 + pageBuilder: (context, state) =>
72 + const MaterialPage(child: OnboardingModelDownloadScreen()),
73 + ),
74 GoRoute(
75 path: AppRoutes.onboardingTheme,
76 pageBuilder: (context, state) =>
@@ -115,6 +123,11 @@ final routerProvider = Provider<GoRouter>((ref) {
123 pageBuilder: (context, state) =>
124 const NoTransitionPage(child: ChatHistoryScreen()),
125 ),
126 + GoRoute(
127 + path: AppRoutes.onDeviceModels,
128 + pageBuilder: (context, state) =>
129 + const NoTransitionPage(child: OnDeviceModelManagerScreen()),
130 + ),
131 ],
132 ),
133 ],
@@ -129,6 +142,8 @@ class App extends ConsumerWidget {
142 final router = ref.watch(routerProvider);
143 final appThemeType = ref.watch(themeModeProvider);
144
145 + ref.watch(ensureOnDeviceServerProvider);
146 +
147 ThemeData theme = AppTheme.lightTheme;
148 ThemeData darkTheme = AppTheme.darkTheme;
149 ThemeMode themeMode = ThemeMode.system;
lib/core/models/enums.dart
+14 -3
@@ -1,6 +1,4 @@
1 -
2 -
3 -enum ServerType { lmStudio, openAICompatible, ollama, openRouter }
1 +enum ServerType { lmStudio, openAICompatible, ollama, openRouter, onDevice }
2
3 enum ConnectionStatus { connected, disconnected, checking, error }
4
@@ -9,3 +7,16 @@ enum MessageRole { user, assistant, system, tool }
7 enum MessageStatus { sending, streaming, complete, error }
8
9 enum ModelStatus { unloaded, loading, loaded, preloaded, thinking }
10 +
11 +enum EngineStatus { notLoaded, loading, loaded, error }
12 +
13 +enum LiteLmBackendType { cpu, gpu, npu }
14 +
15 +enum OnDeviceModelState {
16 + notDownloaded,
17 + downloading,
18 + downloaded,
19 + loading,
20 + loaded,
21 + error,
22 +}
lib/core/providers/app_providers.dart
+3
@@ -1,5 +1,6 @@
1 import 'package:flutter_riverpod/flutter_riverpod.dart';
2 import '../../features/settings/data/models/app_settings.dart';
3 +import '../models/enums.dart';
4 import 'storage_providers.dart';
5 import '../theme/app_theme.dart';
6
@@ -80,6 +81,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
81 _update(state.copyWith(codeThemeDark: value));
82 void setCodeThemeLight(SyntaxThemeName value) =>
83 _update(state.copyWith(codeThemeLight: value));
84 + void setPreferredBackend(LiteLmBackendType value) =>
85 + _update(state.copyWith(preferredBackend: value));
86
87 Future<void> _update(AppSettings updated) async {
88 state = updated;
lib/core/routes/app_routes.dart
+2 -1
@@ -2,6 +2,7 @@ class AppRoutes {
2 static const String home = '/';
3 static const String onboarding = '/onboarding';
4 static const String onboardingSetup = '/onboarding/setup';
5 + static const String onboardingModelDownload = '/onboarding/model-download';
6 static const String onboardingTheme = '/onboarding/theme';
7 static const String servers = '/servers';
8 static const String addServer = '/servers/add';
@@ -10,5 +11,5 @@ class AppRoutes {
11 static const String settings = '/settings';
12 static const String models = '/models';
13 static const String chatHistory = '/history';
14 + static const String onDeviceModels = '/on-device-models';
15 }
14 -
lib/features/chat/data/chat_service.dart
+14 -1
@@ -7,6 +7,8 @@ import 'models/chat_parameters.dart';
7 import 'models/mcp_integration.dart';
8 import '../../../core/models/enums.dart';
9 import '../../../core/logger/app_logger.dart';
10 +import '../../on_device/data/on_device_engine_service.dart';
11 +import '../../on_device/data/on_device_chat_service.dart';
12
13 abstract class ChatService {
14 Stream<ChatResponse> sendMessage({
@@ -20,7 +22,11 @@ abstract class ChatService {
22
23 void cancelStream();
24
23 - static ChatService forServer(ServerType type, Dio dio) {
25 + static ChatService forServer(
26 + ServerType type,
27 + Dio dio, {
28 + OnDeviceEngineService? onDeviceEngine,
29 + }) {
30 switch (type) {
31 case ServerType.lmStudio:
32 return LMStudioChatService(dio);
@@ -30,6 +36,13 @@ abstract class ChatService {
36 return OllamaChatService(dio);
37 case ServerType.openRouter:
38 return OpenRouterChatService(dio);
39 + case ServerType.onDevice:
40 + if (onDeviceEngine == null) {
41 + throw StateError(
42 + 'OnDeviceEngineService is required for onDevice server type',
43 + );
44 + }
45 + return OnDeviceChatService(onDeviceEngine);
46 }
47 }
48 }
lib/features/chat/providers/chat_providers.dart
+9
@@ -15,6 +15,7 @@ import 'package:localmind/features/servers/providers/server_providers.dart';
15 import '../../../core/models/enums.dart';
16 import '../../../core/providers/service_providers.dart';
17 import '../../../core/providers/storage_providers.dart';
18 +import '../../on_device/providers/on_device_providers.dart';
19 import '../../../core/storage/entities.dart';
20 import '../../../objectbox.g.dart';
21 import '../../conversations/data/models/conversation.dart';
@@ -208,6 +209,14 @@ final chatServiceProvider = Provider<ChatService?>((ref) {
209 if (server == null) {
210 return null;
211 }
212 + if (server.type == ServerType.onDevice) {
213 + final engineNotifier = ref.read(onDeviceEngineProvider.notifier);
214 + return ChatService.forServer(
215 + server.type,
216 + ref.read(dioProvider),
217 + onDeviceEngine: engineNotifier.engineService,
218 + );
219 + }
220 return ChatService.forServer(server.type, ref.read(dioProvider));
221 });
222
lib/features/models/screens/model_picker_sheet.dart
+413
@@ -3,8 +3,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
3 import '../../chat/providers/chat_providers.dart';
4 import '../data/models/model_info.dart';
5 import '../../servers/providers/server_providers.dart';
6 +import '../../../core/providers/app_providers.dart';
7 import '../../../core/providers/service_providers.dart';
8 import '../../../core/models/enums.dart';
9 +import '../../on_device/data/models/on_device_model.dart';
10 +import '../../on_device/data/models/download_status.dart';
11 +import '../../on_device/providers/on_device_providers.dart';
12 +import '../../on_device/providers/foreground_download_providers.dart';
13
14 final modelSearchQueryProvider = NotifierProvider<_ModelSearchNotifier, String>(
15 _ModelSearchNotifier.new,
@@ -220,6 +225,17 @@ class _ModelList extends ConsumerWidget {
225
226 @override
227 Widget build(BuildContext context, WidgetRef ref) {
228 + final serversAsync = ref.watch(serversProvider);
229 + final servers = serversAsync.value ?? [];
230 + final currentServer = servers.where((s) => s.id == serverId).firstOrNull;
231 +
232 + if (currentServer != null && currentServer.type == ServerType.onDevice) {
233 + return _OnDeviceModelList(
234 + selectedModelId: selectedModelId,
235 + isDark: isDark,
236 + );
237 + }
238 +
239 final modelsAsync = ref.watch(availableModelsProvider(serverId));
240
241 return modelsAsync.when(
@@ -617,3 +633,400 @@ class _MetadataChip extends StatelessWidget {
633 );
634 }
635 }
636 +
637 +class _OnDeviceModelList extends ConsumerWidget {
638 + const _OnDeviceModelList({
639 + required this.selectedModelId,
640 + required this.isDark,
641 + });
642 +
643 + final String? selectedModelId;
644 + final bool isDark;
645 +
646 + @override
647 + Widget build(BuildContext context, WidgetRef ref) {
648 + final curatedModels = ref.watch(onDeviceModelsProvider);
649 + final downloadedAsync = ref.watch(downloadedModelsProvider);
650 + final engineState = ref.watch(onDeviceEngineProvider);
651 + final searchQuery = ref.watch(modelSearchQueryProvider);
652 +
653 + return downloadedAsync.when(
654 + loading: () => const Center(child: CircularProgressIndicator()),
655 + error: (err, _) => Center(
656 + child: Column(
657 + mainAxisAlignment: MainAxisAlignment.center,
658 + children: [
659 + Icon(Icons.error_outline, size: 48, color: Colors.red[400]),
660 + const SizedBox(height: 16),
661 + Text(
662 + 'Error loading models',
663 + style: TextStyle(
664 + fontSize: 16,
665 + fontWeight: FontWeight.w500,
666 + color: isDark ? Colors.white : Colors.black,
667 + ),
668 + ),
669 + const SizedBox(height: 16),
670 + OutlinedButton.icon(
671 + onPressed: () => ref.invalidate(downloadedModelsProvider),
672 + icon: const Icon(Icons.refresh),
673 + label: const Text('Retry'),
674 + ),
675 + ],
676 + ),
677 + ),
678 + data: (downloadedIds) {
679 + final filtered = searchQuery.isEmpty
680 + ? curatedModels
681 + : curatedModels
682 + .where(
683 + (m) => m.name.toLowerCase().contains(
684 + searchQuery.toLowerCase(),
685 + ),
686 + )
687 + .toList();
688 +
689 + if (filtered.isEmpty) {
690 + return Center(
691 + child: Text(
692 + searchQuery.isEmpty
693 + ? 'No models available'
694 + : 'No models match "$searchQuery"',
695 + style: TextStyle(
696 + fontSize: 14,
697 + color: isDark
698 + ? const Color(0xFF888888)
699 + : const Color(0xFF999999),
700 + ),
701 + ),
702 + );
703 + }
704 +
705 + return ListView.builder(
706 + itemCount: filtered.length,
707 + itemBuilder: (context, index) {
708 + final model = filtered[index];
709 + final isDownloaded = downloadedIds.contains(model.id);
710 + final isLoaded = engineState.loadedModelId == model.id;
711 + final isCurrentlyLoading =
712 + engineState.status == EngineStatus.loading &&
713 + engineState.loadedModelId == model.id;
714 + final isSelected = selectedModelId == model.id;
715 +
716 + return _OnDeviceModelTile(
717 + model: model,
718 + isDownloaded: isDownloaded,
719 + isLoaded: isLoaded,
720 + isCurrentlyLoading: isCurrentlyLoading,
721 + isSelected: isSelected,
722 + isDark: isDark,
723 + );
724 + },
725 + );
726 + },
727 + );
728 + }
729 +}
730 +
731 +class _OnDeviceModelTile extends ConsumerWidget {
732 + const _OnDeviceModelTile({
733 + required this.model,
734 + required this.isDownloaded,
735 + required this.isLoaded,
736 + required this.isCurrentlyLoading,
737 + required this.isSelected,
738 + required this.isDark,
739 + });
740 +
741 + final OnDeviceModel model;
742 + final bool isDownloaded;
743 + final bool isLoaded;
744 + final bool isCurrentlyLoading;
745 + final bool isSelected;
746 + final bool isDark;
747 +
748 + @override
749 + Widget build(BuildContext context, WidgetRef ref) {
750 + final accent = isDark ? const Color(0xFF3B82F6) : const Color(0xFF2563EB);
751 + final downloadProgress = ref.watch(
752 + foregroundDownloadNotifierProvider,
753 + )[model.id];
754 + final isDownloading =
755 + downloadProgress != null &&
756 + (downloadProgress.status == DownloadStatus.running ||
757 + downloadProgress.status == DownloadStatus.pending);
758 + final isPaused = downloadProgress?.status == DownloadStatus.paused;
759 +
760 + return Container(
761 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
762 + margin: const EdgeInsets.only(bottom: 4),
763 + decoration: BoxDecoration(
764 + color: isSelected ? accent.withValues(alpha: 0.1) : Colors.transparent,
765 + borderRadius: BorderRadius.circular(8),
766 + border: isSelected
767 + ? Border.all(color: accent.withValues(alpha: 0.3))
768 + : null,
769 + ),
770 + child: Row(
771 + children: [
772 + Expanded(
773 + child: Column(
774 + crossAxisAlignment: CrossAxisAlignment.start,
775 + children: [
776 + Row(
777 + children: [
778 + Expanded(
779 + child: Text(
780 + model.name,
781 + style: TextStyle(
782 + fontSize: 15,
783 + fontWeight: isSelected
784 + ? FontWeight.w600
785 + : FontWeight.w500,
786 + color: isDownloaded
787 + ? (isDark ? Colors.white : Colors.black)
788 + : (isDark
789 + ? const Color(0xFF555555)
790 + : const Color(0xFF999999)),
791 + ),
792 + maxLines: 1,
793 + overflow: TextOverflow.ellipsis,
794 + ),
795 + ),
796 + if (model.isRecommended)
797 + Container(
798 + padding: const EdgeInsets.symmetric(
799 + horizontal: 6,
800 + vertical: 2,
801 + ),
802 + decoration: BoxDecoration(
803 + color: accent.withValues(alpha: 0.1),
804 + borderRadius: BorderRadius.circular(4),
805 + ),
806 + child: Text(
807 + 'REC',
808 + style: TextStyle(
809 + fontSize: 9,
810 + fontWeight: FontWeight.bold,
811 + color: accent,
812 + ),
813 + ),
814 + ),
815 + ],
816 + ),
817 + if (isDownloading || isPaused)
818 + Padding(
819 + padding: const EdgeInsets.only(top: 6),
820 + child: Column(
821 + crossAxisAlignment: CrossAxisAlignment.start,
822 + children: [
823 + LinearProgressIndicator(
824 + value: downloadProgress?.progress ?? 0.0,
825 + backgroundColor: isDark
826 + ? const Color(0xFF333333)
827 + : const Color(0xFFE0E0E0),
828 + ),
829 + const SizedBox(height: 2),
830 + Text(
831 + isPaused
832 + ? 'Paused - ${((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(0)}%'
833 + : 'Downloading - ${((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(0)}%',
834 + style: TextStyle(
835 + fontSize: 10,
836 + color: isDark
837 + ? const Color(0xFF888888)
838 + : const Color(0xFF999999),
839 + ),
840 + ),
841 + ],
842 + ),
843 + ),
844 + const SizedBox(height: 4),
845 + Wrap(
846 + spacing: 6,
847 + runSpacing: 4,
848 + children: [
849 + _MetadataChip(
850 + label: model.fileSizeFormatted,
851 + isDark: isDark,
852 + ),
853 + _MetadataChip(label: model.license, isDark: isDark),
854 + _MetadataChip(label: model.parameterLabel, isDark: isDark),
855 + ],
856 + ),
857 + ],
858 + ),
859 + ),
860 + const SizedBox(width: 8),
861 + if (isCurrentlyLoading) ...[
862 + const SizedBox(
863 + width: 16,
864 + height: 16,
865 + child: CircularProgressIndicator(strokeWidth: 2),
866 + ),
867 + ] else if (isLoaded) ...[
868 + Container(
869 + width: 10,
870 + height: 10,
871 + decoration: const BoxDecoration(
872 + color: Color(0xFF4CAF50),
873 + shape: BoxShape.circle,
874 + ),
875 + ),
876 + const SizedBox(width: 8),
877 + _IconButton(
878 + icon: Icon(
879 + Icons.power_settings_new_outlined,
880 + size: 18,
881 + color: Colors.red[400],
882 + ),
883 + tooltip: 'Unload model',
884 + onPressed: () => _unloadModel(context, ref),
885 + ),
886 + ] else if (isDownloaded) ...[
887 + _IconButton(
888 + icon: Icon(Icons.play_arrow, size: 20, color: accent),
889 + tooltip: 'Load model',
890 + onPressed: () => _loadModel(context, ref),
891 + ),
892 + const SizedBox(width: 4),
893 + _IconButton(
894 + icon: Icon(
895 + Icons.delete_outline,
896 + size: 18,
897 + color: isDark
898 + ? const Color(0xFF666666)
899 + : const Color(0xFF999999),
900 + ),
901 + tooltip: 'Delete model',
902 + onPressed: () => _deleteModel(context, ref),
903 + ),
904 + ] else if (isDownloading) ...[
905 + _IconButton(
906 + icon: Icon(
907 + Icons.close,
908 + size: 16,
909 + color: isDark
910 + ? const Color(0xFF666666)
911 + : const Color(0xFF999999),
912 + ),
913 + tooltip: 'Cancel download',
914 + onPressed: () => ref
915 + .read(foregroundDownloadNotifierProvider.notifier)
916 + .cancelDownload(model.id),
917 + ),
918 + ] else ...[
919 + Icon(
920 + Icons.cloud_download_outlined,
921 + size: 18,
922 + color: isDark ? const Color(0xFF555555) : const Color(0xFF999999),
923 + ),
924 + const SizedBox(width: 4),
925 + Text(
926 + 'Not downloaded',
927 + style: TextStyle(
928 + fontSize: 11,
929 + color: isDark
930 + ? const Color(0xFF666666)
931 + : const Color(0xFF999999),
932 + ),
933 + ),
934 + ],
935 + if (isDownloaded && isSelected && isLoaded) ...[
936 + const SizedBox(width: 4),
937 + Icon(Icons.check_circle, color: accent, size: 22),
938 + ],
939 + ],
940 + ),
941 + );
942 + }
943 +
944 + void _loadModel(BuildContext context, WidgetRef ref) async {
945 + final settings = ref.read(settingsProvider);
946 + final engineNotifier = ref.read(onDeviceEngineProvider.notifier);
947 +
948 + final modelInfo = ModelInfo(
949 + id: model.id,
950 + name: model.name,
951 + description: model.description,
952 + parameterCount: int.tryParse(
953 + model.parameterLabel.replaceAll(RegExp(r'[^0-9]'), ''),
954 + ),
955 + serverType: ServerType.onDevice,
956 + serverId: '',
957 + );
958 +
959 + await engineNotifier.loadModel(model.id, settings.preferredBackend);
960 +
961 + final engineState = ref.read(onDeviceEngineProvider);
962 + if (engineState.loadedModelId == model.id) {
963 + ref.read(selectedModelProvider.notifier).setModel(modelInfo);
964 + }
965 + }
966 +
967 + void _unloadModel(BuildContext context, WidgetRef ref) async {
968 + final engineNotifier = ref.read(onDeviceEngineProvider.notifier);
969 + await engineNotifier.unloadModel();
970 +
971 + final selectedModel = ref.read(selectedModelProvider);
972 + if (selectedModel?.id == model.id) {
973 + ref.read(selectedModelProvider.notifier).clear();
974 + }
975 + }
976 +
977 + void _deleteModel(BuildContext context, WidgetRef ref) async {
978 + final confirm = await showDialog<bool>(
979 + context: context,
980 + builder: (ctx) => AlertDialog(
981 + title: const Text('Delete Model'),
982 + content: Text('Are you sure you want to delete ${model.name}?'),
983 + actions: [
984 + TextButton(
985 + onPressed: () => Navigator.pop(ctx, false),
986 + child: const Text('Cancel'),
987 + ),
988 + TextButton(
989 + onPressed: () => Navigator.pop(ctx, true),
990 + child: const Text('Delete'),
991 + ),
992 + ],
993 + ),
994 + );
995 +
996 + if (confirm == true) {
997 + final engineState = ref.read(onDeviceEngineProvider);
998 + if (engineState.loadedModelId == model.id) {
999 + await ref.read(onDeviceEngineProvider.notifier).unloadModel();
1000 + ref.read(selectedModelProvider.notifier).clear();
1001 + }
1002 +
1003 + final downloadService = ref.read(onDeviceDownloadServiceProvider);
1004 + await downloadService.deleteModel(model.id);
1005 + ref.invalidate(downloadedModelsProvider);
1006 + }
1007 + }
1008 +}
1009 +
1010 +class _IconButton extends StatelessWidget {
1011 + const _IconButton({
1012 + required this.icon,
1013 + required this.tooltip,
1014 + required this.onPressed,
1015 + });
1016 +
1017 + final Widget icon;
1018 + final String tooltip;
1019 + final VoidCallback onPressed;
1020 +
1021 + @override
1022 + Widget build(BuildContext context) {
1023 + return Tooltip(
1024 + message: tooltip,
1025 + child: InkWell(
1026 + onTap: onPressed,
1027 + borderRadius: BorderRadius.circular(4),
1028 + child: Padding(padding: const EdgeInsets.all(4), child: icon),
1029 + ),
1030 + );
1031 + }
1032 +}
lib/features/on_device/data/foreground_download_service.dart new
+161
@@ -0,0 +1,161 @@
1 +import 'dart:async';
2 +import 'dart:io';
3 +
4 +import '../../../core/logger/app_logger.dart';
5 +import 'models/download_status.dart';
6 +import 'models/on_device_model.dart';
7 +import 'on_device_engine_service.dart';
8 +import 'on_device_model_download_service.dart';
9 +
10 +/// Wraps [OnDeviceModelDownloadService] with broadcast streams for UI consumption.
11 +///
12 +/// Progress updates are emitted as [DownloadStatusUpdate] via [getStatusStream].
13 +class ForegroundDownloadService {
14 + final OnDeviceModelDownloadService _downloadService;
15 + final Map<String, StreamSubscription> _subscriptions = {};
16 + final Map<String, StreamController<DownloadStatusUpdate>>
17 + _progressControllers = {};
18 +
19 + ForegroundDownloadService(this._downloadService);
20 +
21 + /// Returns a broadcast stream of status updates for [modelId].
22 + /// Creates the stream controller if one doesn't exist yet.
23 + Stream<DownloadStatusUpdate> getStatusStream(String modelId) {
24 + return _progressControllers
25 + .putIfAbsent(
26 + modelId,
27 + () => StreamController<DownloadStatusUpdate>.broadcast(),
28 + )
29 + .stream;
30 + }
31 +
32 + /// Start downloading [model]. Progress is emitted via [getStatusStream].
33 + ///
34 + /// Call [getStatusStream] before this method to subscribe to progress updates.
35 + /// Returns the absolute path to the downloaded file on success.
36 + Future<String> downloadModel(OnDeviceModel model, {String? token}) async {
37 + final modelsDir = await OnDeviceEngineService.getModelDirectory();
38 + final filePath = '$modelsDir/${model.fileName}';
39 + final file = File(filePath);
40 +
41 + if (await file.exists()) {
42 + Log.info('Model ${model.id} already downloaded at $filePath');
43 + // Signal already downloaded via the stream
44 + final controller = _progressControllers[model.id];
45 + if (controller != null && !controller.isClosed) {
46 + controller.add(
47 + DownloadStatusUpdate(
48 + modelId: model.id,
49 + taskId: model.id,
50 + status: DownloadStatus.complete,
51 + progress: 100,
52 + ),
53 + );
54 + await _cleanup(model.id);
55 + }
56 + return filePath;
57 + }
58 +
59 + // Ensure the stream controller exists (created by getStatusStream)
60 + final controller = _progressControllers.putIfAbsent(
61 + model.id,
62 + () => StreamController<DownloadStatusUpdate>.broadcast(),
63 + );
64 +
65 + // Emit initial pending status
66 + if (!controller.isClosed) {
67 + controller.add(
68 + DownloadStatusUpdate(
69 + modelId: model.id,
70 + taskId: model.id,
71 + status: DownloadStatus.pending,
72 + progress: 0,
73 + ),
74 + );
75 + }
76 +
77 + // Listen to the underlying download stream
78 + final completer = Completer<String>();
79 +
80 + final subscription = _downloadService
81 + .downloadModel(model, token: token)
82 + .listen(
83 + (progress) {
84 + if (controller.isClosed) return;
85 + final percent = progress.total > 0
86 + ? (progress.received / progress.total * 100).round()
87 + : 0;
88 + controller.add(
89 + DownloadStatusUpdate(
90 + modelId: model.id,
91 + taskId: model.id,
92 + status: DownloadStatus.running,
93 + progress: percent,
94 + ),
95 + );
96 + },
97 + onError: (Object e) {
98 + if (!controller.isClosed) {
99 + controller.add(
100 + DownloadStatusUpdate(
101 + modelId: model.id,
102 + taskId: model.id,
103 + status: DownloadStatus.failed,
104 + progress: 0,
105 + ),
106 + );
107 + }
108 + _cleanup(model.id);
109 + completer.completeError(e);
110 + },
111 + onDone: () {
112 + if (!completer.isCompleted) {
113 + if (!controller.isClosed) {
114 + controller.add(
115 + DownloadStatusUpdate(
116 + modelId: model.id,
117 + taskId: model.id,
118 + status: DownloadStatus.complete,
119 + progress: 100,
120 + ),
121 + );
122 + }
123 + _cleanup(model.id);
124 + completer.complete(filePath);
125 + }
126 + },
127 + cancelOnError: true,
128 + );
129 +
130 + _subscriptions[model.id] = subscription;
131 +
132 + return completer.future;
133 + }
134 +
135 + /// Cancel an in-flight download for [modelId].
136 + Future<void> cancelDownload(String modelId) async {
137 + _downloadService.cancelDownload(modelId);
138 + await _cleanup(modelId);
139 + }
140 +
141 + Future<void> _cleanup(String modelId) async {
142 + await _subscriptions.remove(modelId)?.cancel();
143 + final controller = _progressControllers.remove(modelId);
144 + await controller?.close();
145 + }
146 +
147 + /// Pause is not supported — downloads are continuous streams.
148 + void pauseDownload(String modelId) {
149 + Log.warning('Pause not supported for foreground downloads');
150 + }
151 +
152 + /// Resume is not supported — start a new download instead.
153 + void resumeDownload(String modelId) {
154 + Log.warning('Resume not supported for foreground downloads');
155 + }
156 +
157 + /// Cancel and let the UI restart the download.
158 + Future<void> retryDownload(String modelId) async {
159 + await cancelDownload(modelId);
160 + }
161 +}
lib/features/on_device/data/models/download_progress_info.dart new
+33
@@ -0,0 +1,33 @@
1 +import 'download_status.dart';
2 +
3 +class DownloadProgressInfo {
4 + final String modelId;
5 + final DownloadStatus status;
6 + final double progress;
7 + final String? taskId;
8 + final String? error;
9 +
10 + const DownloadProgressInfo({
11 + required this.modelId,
12 + this.status = DownloadStatus.pending,
13 + this.progress = 0.0,
14 + this.taskId,
15 + this.error,
16 + });
17 +
18 + DownloadProgressInfo copyWith({
19 + String? modelId,
20 + DownloadStatus? status,
21 + double? progress,
22 + String? taskId,
23 + String? error,
24 + }) {
25 + return DownloadProgressInfo(
26 + modelId: modelId ?? this.modelId,
27 + status: status ?? this.status,
28 + progress: progress ?? this.progress,
29 + taskId: taskId ?? this.taskId,
30 + error: error ?? this.error,
31 + );
32 + }
33 +}
lib/features/on_device/data/models/download_status.dart new
+23
@@ -0,0 +1,23 @@
1 +enum DownloadStatus {
2 + pending,
3 + running,
4 + paused,
5 + canceled,
6 + failed,
7 + complete,
8 + undefined,
9 +}
10 +
11 +class DownloadStatusUpdate {
12 + final String modelId;
13 + final String taskId;
14 + final DownloadStatus status;
15 + final int progress; // 0-100
16 +
17 + DownloadStatusUpdate({
18 + required this.modelId,
19 + required this.taskId,
20 + required this.status,
21 + required this.progress,
22 + });
23 +}
lib/features/on_device/data/models/on_device_model.dart new
+150
@@ -0,0 +1,150 @@
1 +import '../../../../core/models/enums.dart';
2 +
3 +class OnDeviceModel {
4 + final String id;
5 + final String name;
6 + final String huggingFaceUrl;
7 + final int fileSizeBytes;
8 + final String license;
9 + final String description;
10 + final bool isRecommended;
11 + final int minRamMb;
12 + final String parameterLabel;
13 +
14 + const OnDeviceModel({
15 + required this.id,
16 + required this.name,
17 + required this.huggingFaceUrl,
18 + required this.fileSizeBytes,
19 + required this.license,
20 + required this.description,
21 + this.isRecommended = false,
22 + required this.minRamMb,
23 + required this.parameterLabel,
24 + });
25 +
26 + String get fileSizeFormatted {
27 + final gb = fileSizeBytes / (1024 * 1024 * 1024);
28 + if (gb >= 1) {
29 + return '${gb.toStringAsFixed(2)} GB';
30 + }
31 + final mb = fileSizeBytes / (1024 * 1024);
32 + return '${mb.toStringAsFixed(0)} MB';
33 + }
34 +
35 + String get fileName => '$id.litertlm';
36 +
37 + static const List<OnDeviceModel> curatedModels = [
38 + OnDeviceModel(
39 + id: 'qwen3-0.6b',
40 + name: 'Qwen 3 0.6B',
41 + huggingFaceUrl:
42 + 'https://huggingface.co/litert-community/Qwen3-0.6B/resolve/main/Qwen3-0.6B.litertlm',
43 + fileSizeBytes: 614236160,
44 + license: 'Apache-2.0',
45 + description:
46 + 'Smallest general-purpose chat model. Fast responses, low memory usage.',
47 + isRecommended: true,
48 + minRamMb: 2048,
49 + parameterLabel: '0.6B',
50 + ),
51 + OnDeviceModel(
52 + id: 'qwen2.5-1.5b-instruct',
53 + name: 'Qwen 2.5 1.5B Instruct',
54 + huggingFaceUrl:
55 + 'https://huggingface.co/litert-community/Qwen2.5-1.5B-Instruct/resolve/main/Qwen2.5-1.5B-Instruct_multi-prefill-seq_q8_ekv4096.litertlm',
56 + fileSizeBytes: 1597931520,
57 + license: 'Apache-2.0',
58 + description: 'Balanced quality and size. Good for general conversation.',
59 + minRamMb: 3072,
60 + parameterLabel: '1.5B',
61 + ),
62 + OnDeviceModel(
63 + id: 'deepseek-r1-distill-qwen-1.5b',
64 + name: 'DeepSeek R1 Distill Qwen 1.5B',
65 + huggingFaceUrl:
66 + 'https://huggingface.co/litert-community/DeepSeek-R1-Distill-Qwen-1.5B/resolve/main/DeepSeek-R1-Distill-Qwen-1.5B_multi-prefill-seq_q8_ekv4096.litertlm',
67 + fileSizeBytes: 1833451520,
68 + license: 'MIT',
69 + description:
70 + 'Reasoning and chain-of-thought model. Best for logical tasks.',
71 + minRamMb: 3584,
72 + parameterLabel: '1.5B',
73 + ),
74 + OnDeviceModel(
75 + id: 'gemma4-e2b-instruct',
76 + name: 'Gemma 4 E2B Instruct',
77 + huggingFaceUrl:
78 + 'https://huggingface.co/litert-community/gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm',
79 + fileSizeBytes: 2583085056,
80 + license: 'Apache-2.0',
81 + description: 'Google flagship model. Highest quality, requires more RAM.',
82 + minRamMb: 5120,
83 + parameterLabel: '2B',
84 + ),
85 + ];
86 +}
87 +
88 +class DownloadedModel {
89 + final String modelId;
90 + final String filePath;
91 + final DateTime downloadedAt;
92 +
93 + const DownloadedModel({
94 + required this.modelId,
95 + required this.filePath,
96 + required this.downloadedAt,
97 + });
98 +
99 + Map<String, dynamic> toMap() {
100 + return {
101 + 'modelId': modelId,
102 + 'filePath': filePath,
103 + 'downloadedAt': downloadedAt.toIso8601String(),
104 + };
105 + }
106 +
107 + factory DownloadedModel.fromMap(Map<String, dynamic> map) {
108 + return DownloadedModel(
109 + modelId: map['modelId'] as String,
110 + filePath: map['filePath'] as String,
111 + downloadedAt: DateTime.parse(map['downloadedAt'] as String),
112 + );
113 + }
114 +}
115 +
116 +class OnDeviceModelStateInfo {
117 + final String modelId;
118 + final OnDeviceModelState state;
119 + final double downloadProgress;
120 + final String? error;
121 + final LiteLmBackendType backend;
122 + final EngineStatus engineStatus;
123 +
124 + const OnDeviceModelStateInfo({
125 + required this.modelId,
126 + this.state = OnDeviceModelState.notDownloaded,
127 + this.downloadProgress = 0.0,
128 + this.error,
129 + this.backend = LiteLmBackendType.cpu,
130 + this.engineStatus = EngineStatus.notLoaded,
131 + });
132 +
133 + OnDeviceModelStateInfo copyWith({
134 + String? modelId,
135 + OnDeviceModelState? state,
136 + double? downloadProgress,
137 + String? error,
138 + LiteLmBackendType? backend,
139 + EngineStatus? engineStatus,
140 + }) {
141 + return OnDeviceModelStateInfo(
142 + modelId: modelId ?? this.modelId,
143 + state: state ?? this.state,
144 + downloadProgress: downloadProgress ?? this.downloadProgress,
145 + error: error ?? this.error,
146 + backend: backend ?? this.backend,
147 + engineStatus: engineStatus ?? this.engineStatus,
148 + );
149 + }
150 +}
lib/features/on_device/data/on_device_chat_service.dart new
+167
@@ -0,0 +1,167 @@
1 +import 'dart:async';
2 +
3 +import 'package:flutter_litert_lm/flutter_litert_lm.dart';
4 +
5 +import '../../../core/logger/app_logger.dart';
6 +import '../../../core/models/enums.dart';
7 +import '../../chat/data/chat_service.dart';
8 +import '../../chat/data/models/chat_parameters.dart';
9 +import '../../chat/data/models/message.dart' hide ToolCallData;
10 +import '../../chat/data/models/mcp_integration.dart';
11 +import '../../servers/data/models/server.dart';
12 +import 'on_device_engine_service.dart';
13 +
14 +class OnDeviceChatService implements ChatService {
15 + final OnDeviceEngineService _engineService;
16 + StreamSubscription<LiteLmMessage>? _currentSubscription;
17 + StreamController<ChatResponse>? _streamController;
18 + bool _isCancelled = false;
19 +
20 + OnDeviceChatService(this._engineService);
21 +
22 + @override
23 + Stream<ChatResponse> sendMessage({
24 + required Server server,
25 + required String modelId,
26 + required List<Message> messages,
27 + required ChatParameters params,
28 + List<McpIntegration>? integrations,
29 + String? previousResponseId,
30 + }) {
31 + _isCancelled = false;
32 + _streamController = StreamController<ChatResponse>();
33 +
34 + _startInference(messages, params);
35 +
36 + return _streamController!.stream;
37 + }
38 +
39 + Future<void> _startInference(
40 + List<Message> messages,
41 + ChatParameters params,
42 + ) async {
43 + try {
44 + if (_engineService.engine == null || _isCancelled) {
45 + _streamController?.add(
46 + const ChatResponse(
47 + type: ChatResponseType.error,
48 + content: 'Engine not loaded',
49 + ),
50 + );
51 + _streamController?.add(const ChatResponse(type: ChatResponseType.done));
52 + await _streamController?.close();
53 + return;
54 + }
55 +
56 + final systemInstruction = params.systemPrompt?.isNotEmpty == true
57 + ? params.systemPrompt
58 + : null;
59 +
60 + final samplerConfig = LiteLmSamplerConfig(
61 + temperature: params.temperature,
62 + topK: 40,
63 + topP: params.topP,
64 + );
65 +
66 + final conversation = await _engineService.createConversation(
67 + systemInstruction: systemInstruction,
68 + samplerConfig: samplerConfig,
69 + );
70 +
71 + final liteMessages = _convertMessages(messages);
72 +
73 + if (_isCancelled) {
74 + await conversation.dispose();
75 + _streamController?.add(const ChatResponse(type: ChatResponseType.done));
76 + await _streamController?.close();
77 + return;
78 + }
79 +
80 + final lastUserMessage = liteMessages
81 + .where((m) => m.role == 'user')
82 + .lastOrNull;
83 + if (lastUserMessage == null) {
84 + _streamController?.add(
85 + const ChatResponse(
86 + type: ChatResponseType.error,
87 + content: 'No user message found',
88 + ),
89 + );
90 + _streamController?.add(const ChatResponse(type: ChatResponseType.done));
91 + await _streamController?.close();
92 + return;
93 + }
94 +
95 + final stream = conversation.sendMessageStream(lastUserMessage.text ?? '');
96 +
97 + final buffer = StringBuffer();
98 + await for (final delta in stream) {
99 + if (_isCancelled) {
100 + await conversation.dispose();
101 + _streamController?.add(
102 + const ChatResponse(type: ChatResponseType.done),
103 + );
104 + await _streamController?.close();
105 + return;
106 + }
107 +
108 + if (delta.text.isNotEmpty) {
109 + buffer.write(delta.text);
110 + _streamController?.add(
111 + ChatResponse(type: ChatResponseType.message, content: delta.text),
112 + );
113 + }
114 +
115 + if (delta.toolCalls.isNotEmpty) {
116 + for (final tc in delta.toolCalls) {
117 + _streamController?.add(
118 + ChatResponse(
119 + type: ChatResponseType.toolCall,
120 + toolCall: ToolCallData(tool: tc.name, arguments: tc.arguments),
121 + ),
122 + );
123 + }
124 + }
125 + }
126 +
127 + _streamController?.add(const ChatResponse(type: ChatResponseType.done));
128 + await conversation.dispose();
129 + await _streamController?.close();
130 + } catch (e) {
131 + Log.error('OnDevice inference error: $e');
132 + if (!(_streamController?.isClosed ?? true)) {
133 + _streamController?.add(
134 + ChatResponse(
135 + type: ChatResponseType.error,
136 + content: 'Inference error: ${e.toString()}',
137 + ),
138 + );
139 + _streamController?.add(const ChatResponse(type: ChatResponseType.done));
140 + await _streamController?.close();
141 + }
142 + }
143 + }
144 +
145 + List<({String role, String? text})> _convertMessages(List<Message> messages) {
146 + return messages
147 + .where(
148 + (m) =>
149 + m.role == MessageRole.user ||
150 + m.role == MessageRole.assistant ||
151 + m.role == MessageRole.system,
152 + )
153 + .map((m) => (role: m.role.name, text: m.content))
154 + .toList();
155 + }
156 +
157 + @override
158 + void cancelStream() {
159 + _isCancelled = true;
160 + _currentSubscription?.cancel();
161 + _currentSubscription = null;
162 + if (!(_streamController?.isClosed ?? true)) {
163 + _streamController?.add(const ChatResponse(type: ChatResponseType.done));
164 + _streamController?.close();
165 + }
166 + }
167 +}
lib/features/on_device/data/on_device_engine_service.dart new
+132
@@ -0,0 +1,132 @@
1 +import 'dart:async';
2 +import 'dart:io';
3 +
4 +import 'package:flutter_litert_lm/flutter_litert_lm.dart';
5 +import 'package:path_provider/path_provider.dart';
6 +
7 +import '../../../core/logger/app_logger.dart';
8 +import '../../../core/models/enums.dart';
9 +
10 +class OnDeviceEngineService {
11 + LiteLmEngine? _engine;
12 + String? _currentModelPath;
13 + LiteLmBackendType? _currentBackend;
14 + bool _isDisposed = false;
15 +
16 + LiteLmEngine? get engine => _engine;
17 + String? get currentModelPath => _currentModelPath;
18 + bool get isLoaded => _engine != null && !_isDisposed;
19 + bool get isDisposed => _isDisposed;
20 +
21 + Future<LiteLmEngine> createEngine(
22 + String modelPath,
23 + LiteLmBackendType backendType,
24 + ) async {
25 + if (_isDisposed) {
26 + throw StateError('OnDeviceEngineService has been disposed');
27 + }
28 +
29 + if (_engine != null &&
30 + _currentModelPath == modelPath &&
31 + _currentBackend == backendType) {
32 + return _engine!;
33 + }
34 +
35 + if (_engine != null) {
36 + await disposeEngine();
37 + }
38 +
39 + final backend = _mapBackendType(backendType);
40 +
41 + Log.info(
42 + 'Creating LiteLmEngine with modelPath=$modelPath, backend=$backendType',
43 + );
44 +
45 + final config = LiteLmEngineConfig(modelPath: modelPath, backend: backend);
46 +
47 + _engine = await LiteLmEngine.create(config);
48 + _currentModelPath = modelPath;
49 + _currentBackend = backendType;
50 +
51 + Log.info('LiteLmEngine created successfully');
52 + return _engine!;
53 + }
54 +
55 + Future<LiteLmConversation> createConversation({
56 + String? systemInstruction,
57 + LiteLmSamplerConfig? samplerConfig,
58 + List<LiteLmTool>? tools,
59 + }) async {
60 + if (_engine == null) {
61 + throw StateError('Engine not loaded. Call createEngine first.');
62 + }
63 +
64 + final config = LiteLmConversationConfig(
65 + systemInstruction: systemInstruction,
66 + samplerConfig: samplerConfig,
67 + tools: tools,
68 + );
69 +
70 + return await _engine!.createConversation(config);
71 + }
72 +
73 + Future<void> disposeEngine() async {
74 + if (_engine != null) {
75 + try {
76 + await _engine!.dispose();
77 + } catch (e) {
78 + Log.error('Error disposing engine: $e');
79 + }
80 + _engine = null;
81 + _currentModelPath = null;
82 + _currentBackend = null;
83 + }
84 + }
85 +
86 + void dispose() {
87 + _isDisposed = true;
88 + disposeEngine();
89 + }
90 +
91 + LiteLmBackend _mapBackendType(LiteLmBackendType type) {
92 + switch (type) {
93 + case LiteLmBackendType.cpu:
94 + return LiteLmBackend.cpu;
95 + case LiteLmBackendType.gpu:
96 + return LiteLmBackend.gpu;
97 + case LiteLmBackendType.npu:
98 + return LiteLmBackend.npu;
99 + }
100 + }
101 +
102 + static Future<String> getModelDirectory() async {
103 + final dir = await getApplicationSupportDirectory();
104 + final modelsDir = Directory('${dir.path}/on_device_models');
105 + if (!await modelsDir.exists()) {
106 + await modelsDir.create(recursive: true);
107 + }
108 + return modelsDir.path;
109 + }
110 +
111 + static Future<String?> getModelPath(String modelId) async {
112 + final dir = await getModelDirectory();
113 + final file = File('$dir/$modelId.litertlm');
114 + if (await file.exists()) {
115 + return file.path;
116 + }
117 + return null;
118 + }
119 +
120 + static Future<bool> isModelDownloaded(String modelId) async {
121 + final path = await getModelPath(modelId);
122 + return path != null;
123 + }
124 +
125 + static Future<void> deleteModel(String modelId) async {
126 + final dir = await getModelDirectory();
127 + final file = File('$dir/$modelId.litertlm');
128 + if (await file.exists()) {
129 + await file.delete();
130 + }
131 + }
132 +}
lib/features/on_device/data/on_device_model_download_service.dart new
+250
@@ -0,0 +1,250 @@
1 +import 'dart:async';
2 +import 'dart:io';
3 +
4 +import '../../../core/logger/app_logger.dart';
5 +import 'models/on_device_model.dart';
6 +import 'on_device_engine_service.dart';
7 +
8 +/// Snapshot of an in-flight download.
9 +class DownloadProgress {
10 + const DownloadProgress({required this.received, required this.total});
11 +
12 + final int received;
13 + final int total;
14 +
15 + double get fraction => total <= 0 ? 0 : received / total;
16 +}
17 +
18 +/// Downloads `.litertlm` model files from HuggingFace using `dart:io HttpClient`.
19 +///
20 +/// Follows redirects manually so the `Authorization` header is only sent to
21 +/// the original HuggingFace host (not the CDN). Downloads stream to a `.part`
22 +/// file and are renamed atomically on success.
23 +class OnDeviceModelDownloadService {
24 + final Map<String, HttpClient> _activeClients = {};
25 +
26 + /// Downloads [model] from HuggingFace, emitting progress updates.
27 + ///
28 + /// [token] is an optional HuggingFace access token for gated models.
29 + Stream<DownloadProgress> downloadModel(
30 + OnDeviceModel model, {
31 + String? token,
32 + }) async* {
33 + final modelsDir = await OnDeviceEngineService.getModelDirectory();
34 + final finalPath = '$modelsDir/${model.fileName}';
35 + final partialPath = '$finalPath.part';
36 +
37 + // Already downloaded
38 + if (await File(finalPath).exists()) {
39 + Log.info('Model ${model.id} already downloaded at $finalPath');
40 + return;
41 + }
42 +
43 + // Clean up any stale partial file from a previous attempt
44 + final partialFile = File(partialPath);
45 + if (await partialFile.exists()) {
46 + await partialFile.delete();
47 + }
48 +
49 + final client = HttpClient();
50 + _activeClients[model.id] = client;
51 + IOSink? sink;
52 +
53 + try {
54 + final response = await _resolveWithRedirects(
55 + client: client,
56 + url: Uri.parse(model.huggingFaceUrl),
57 + token: token,
58 + );
59 +
60 + final total = response.contentLength > 0
61 + ? response.contentLength
62 + : model.fileSizeBytes;
63 + var received = 0;
64 +
65 + sink = partialFile.openWrite();
66 +
67 + await for (final chunk in response) {
68 + sink.add(chunk);
69 + received += chunk.length;
70 + yield DownloadProgress(received: received, total: total);
71 + }
72 +
73 + await sink.flush();
74 + await sink.close();
75 + sink = null;
76 +
77 + // Atomically promote .part file to final name
78 + await partialFile.rename(finalPath);
79 +
80 + Log.info('Model ${model.id} downloaded successfully to $finalPath');
81 + } catch (e) {
82 + // Clean up partial file on failure
83 + try {
84 + if (await partialFile.exists()) {
85 + await partialFile.delete();
86 + }
87 + } catch (_) {}
88 + Log.error('Failed to download model ${model.id}: $e');
89 + rethrow;
90 + } finally {
91 + _activeClients.remove(model.id);
92 + try {
93 + await sink?.close();
94 + } catch (_) {}
95 + client.close(force: true);
96 + }
97 + }
98 +
99 + /// Cancel an in-flight download for [modelId].
100 + void cancelDownload(String modelId) {
101 + final client = _activeClients[modelId];
102 + if (client != null) {
103 + client.close(force: true);
104 + _activeClients.remove(modelId);
105 + }
106 + }
107 +
108 + /// Manually follow redirects, passing the HF `Authorization` header
109 + /// only on the original host. The CDN URLs returned by HuggingFace are
110 + /// pre-signed and may reject extra auth headers.
111 + Future<HttpClientResponse> _resolveWithRedirects({
112 + required HttpClient client,
113 + required Uri url,
114 + required String? token,
115 + }) async {
116 + var current = url;
117 + final originHost = url.host;
118 +
119 + for (var hop = 0; hop < 6; hop++) {
120 + final request = await client.getUrl(current);
121 + request.followRedirects = false;
122 +
123 + // Only send bearer token to the original HuggingFace host
124 + final cleanedToken = token?.trim();
125 + if (cleanedToken != null &&
126 + cleanedToken.isNotEmpty &&
127 + current.host == originHost) {
128 + request.headers.set('Authorization', 'Bearer $cleanedToken');
129 + }
130 +
131 + final response = await request.close();
132 +
133 + if (response.statusCode == 200) {
134 + return response;
135 + }
136 +
137 + if (response.isRedirect) {
138 + final location = response.headers.value('location');
139 + await response.drain<void>();
140 + if (location == null) {
141 + throw HttpException(
142 + 'Redirect with no Location header (status ${response.statusCode})',
143 + );
144 + }
145 + current = current.resolve(location);
146 + continue;
147 + }
148 +
149 + // Read a small preview of the error body
150 + final body = await _readBodyPreview(response);
151 + if (response.statusCode == 401 || response.statusCode == 403) {
152 + throw HttpException(
153 + 'Access denied (HTTP ${response.statusCode}). '
154 + '${body.isNotEmpty ? "Server said: $body" : ""}',
155 + );
156 + }
157 + throw HttpException(
158 + 'Download failed: HTTP ${response.statusCode}'
159 + '${body.isNotEmpty ? " — $body" : ""}',
160 + );
161 + }
162 +
163 + throw const HttpException('Too many redirects');
164 + }
165 +
166 + /// Read at most ~512 bytes of the response body for error messages.
167 + Future<String> _readBodyPreview(HttpClientResponse response) async {
168 + try {
169 + final buffer = StringBuffer();
170 + var read = 0;
171 + await for (final chunk in response) {
172 + buffer.write(String.fromCharCodes(chunk));
173 + read += chunk.length;
174 + if (read >= 512) break;
175 + }
176 + return buffer.toString().trim().replaceAll(RegExp(r'\s+'), ' ');
177 + } catch (_) {
178 + return '';
179 + }
180 + }
181 +
182 + /// Delete a downloaded model from disk.
183 + Future<void> deleteModel(String modelId) async {
184 + final modelsDir = await OnDeviceEngineService.getModelDirectory();
185 + final file = File('$modelsDir/$modelId.litertlm');
186 + if (await file.exists()) {
187 + await file.delete();
188 + Log.info('Model $modelId deleted');
189 + }
190 + }
191 +
192 + /// List all downloaded models.
193 + Future<List<DownloadedModel>> getDownloadedModels() async {
194 + final modelsDir = await OnDeviceEngineService.getModelDirectory();
195 + final dir = Directory(modelsDir);
196 + if (!await dir.exists()) {
197 + return [];
198 + }
199 +
200 + final List<DownloadedModel> result = [];
201 + final entities = dir.listSync();
202 + for (final entity in entities) {
203 + if (entity is File && entity.path.endsWith('.litertlm')) {
204 + final fileName = entity.path.split('/').last;
205 + final modelId = fileName.replaceAll('.litertlm', '');
206 + final stat = await entity.stat();
207 + result.add(
208 + DownloadedModel(
209 + modelId: modelId,
210 + filePath: entity.path,
211 + downloadedAt: stat.modified,
212 + ),
213 + );
214 + }
215 + }
216 + return result;
217 + }
218 +
219 + /// Check if a model has been downloaded.
220 + Future<bool> isModelDownloaded(String modelId) async {
221 + final modelsDir = await OnDeviceEngineService.getModelDirectory();
222 + final file = File('$modelsDir/$modelId.litertlm');
223 + return file.exists();
224 + }
225 +
226 + /// Get total size of all downloaded models.
227 + Future<int> getModelsTotalSizeBytes() async {
228 + final models = await getDownloadedModels();
229 + int total = 0;
230 + for (final model in models) {
231 + final file = File(model.filePath);
232 + if (await file.exists()) {
233 + total += await file.length();
234 + }
235 + }
236 + return total;
237 + }
238 +}
239 +
240 +class DownloadedModel {
241 + final String modelId;
242 + final String filePath;
243 + final DateTime downloadedAt;
244 +
245 + const DownloadedModel({
246 + required this.modelId,
247 + required this.filePath,
248 + required this.downloadedAt,
249 + });
250 +}
lib/features/on_device/providers/foreground_download_providers.dart new
+132
@@ -0,0 +1,132 @@
1 +import 'dart:async';
2 +
3 +import 'package:flutter_riverpod/flutter_riverpod.dart';
4 +
5 +import '../data/models/download_progress_info.dart';
6 +import '../data/models/download_status.dart';
7 +import 'on_device_providers.dart';
8 +
9 +final foregroundDownloadNotifierProvider =
10 + NotifierProvider<
11 + ForegroundDownloadNotifier,
12 + Map<String, DownloadProgressInfo>
13 + >(() {
14 + return ForegroundDownloadNotifier();
15 + });
16 +
17 +class ForegroundDownloadNotifier
18 + extends Notifier<Map<String, DownloadProgressInfo>> {
19 + final Map<String, StreamSubscription<DownloadStatusUpdate>> _subscriptions =
20 + {};
21 +
22 + @override
23 + Map<String, DownloadProgressInfo> build() {
24 + ref.onDispose(() {
25 + for (final sub in _subscriptions.values) {
26 + sub.cancel();
27 + }
28 + _subscriptions.clear();
29 + });
30 + return {};
31 + }
32 +
33 + Future<void> startDownload(String modelId) async {
34 + final models = ref.read(onDeviceModelsProvider);
35 + final model = models.firstWhere(
36 + (m) => m.id == modelId,
37 + orElse: () => throw Exception('Model not found: $modelId'),
38 + );
39 +
40 + state = {
41 + ...state,
42 + modelId: DownloadProgressInfo(
43 + modelId: modelId,
44 + status: DownloadStatus.running,
45 + progress: 0.0,
46 + ),
47 + };
48 +
49 + final downloadService = ref.read(foregroundDownloadServiceProvider);
50 +
51 + // Listen to status updates for this model
52 + _subscriptions[modelId]?.cancel();
53 + _subscriptions[modelId] = downloadService.getStatusStream(modelId).listen((
54 + update,
55 + ) {
56 + final info = DownloadProgressInfo(
57 + modelId: update.modelId,
58 + status: update.status,
59 + progress: update.progress / 100.0,
60 + taskId: update.taskId,
61 + );
62 + state = {...state, update.modelId: info};
63 +
64 + if (update.status == DownloadStatus.complete) {
65 + ref.invalidate(downloadedModelsProvider);
66 + state = {...state}..remove(update.modelId);
67 + _removeCompletedDownload(update.modelId);
68 + } else if (update.status == DownloadStatus.failed ||
69 + update.status == DownloadStatus.canceled) {
70 + state = {
71 + ...state,
72 + update.modelId: info.copyWith(
73 + status: update.status,
74 + error: update.status == DownloadStatus.failed
75 + ? 'Download failed'
76 + : 'Download canceled',
77 + ),
78 + };
79 + _removeCompletedDownload(update.modelId);
80 + }
81 + });
82 +
83 + try {
84 + await downloadService.downloadModel(model);
85 + } catch (e) {
86 + // Error already handled via stream
87 + }
88 + }
89 +
90 + void _removeCompletedDownload(String modelId) {
91 + final current = Map<String, DownloadProgressInfo>.from(state);
92 + current.remove(modelId);
93 + state = current;
94 + _subscriptions[modelId]?.cancel();
95 + _subscriptions.remove(modelId);
96 + }
97 +
98 + Future<void> cancelDownload(String modelId) async {
99 + final downloadService = ref.read(foregroundDownloadServiceProvider);
100 + await downloadService.cancelDownload(modelId);
101 + state = {...state}..remove(modelId);
102 + _subscriptions[modelId]?.cancel();
103 + _subscriptions.remove(modelId);
104 + }
105 +
106 + // Pause and resume are not supported
107 + Future<void> pauseDownload(String modelId) async {
108 + // No-op
109 + }
110 +
111 + Future<void> resumeDownload(String modelId) async {
112 + // No-op
113 + }
114 +
115 + Future<void> retryDownload(String modelId) async {
116 + final downloadService = ref.read(foregroundDownloadServiceProvider);
117 + await downloadService.retryDownload(modelId);
118 + // Restart download
119 + await startDownload(modelId);
120 + }
121 +
122 + DownloadProgressInfo? getProgressForModel(String modelId) {
123 + return state[modelId];
124 + }
125 +
126 + bool isDownloading(String modelId) {
127 + final info = state[modelId];
128 + return info != null &&
129 + (info.status == DownloadStatus.running ||
130 + info.status == DownloadStatus.pending);
131 + }
132 +}
lib/features/on_device/providers/on_device_providers.dart new
+200
@@ -0,0 +1,200 @@
1 +import 'dart:async';
2 +import 'dart:io';
3 +
4 +import 'package:flutter_riverpod/flutter_riverpod.dart';
5 +
6 +import '../../../core/logger/app_logger.dart';
7 +import '../../../core/models/enums.dart';
8 +import '../data/models/on_device_model.dart';
9 +import '../data/on_device_engine_service.dart';
10 +import '../data/on_device_model_download_service.dart';
11 +import '../data/foreground_download_service.dart';
12 +
13 +final onDeviceEngineProvider =
14 + NotifierProvider<OnDeviceEngineNotifier, OnDeviceEngineState>(() {
15 + return OnDeviceEngineNotifier();
16 + });
17 +
18 +final onDeviceModelsProvider = Provider<List<OnDeviceModel>>((ref) {
19 + return OnDeviceModel.curatedModels;
20 +});
21 +
22 +final onDeviceDownloadServiceProvider = Provider<OnDeviceModelDownloadService>((
23 + ref,
24 +) {
25 + return OnDeviceModelDownloadService();
26 +});
27 +
28 +final foregroundDownloadServiceProvider = Provider<ForegroundDownloadService>((
29 + ref,
30 +) {
31 + return ForegroundDownloadService(ref.read(onDeviceDownloadServiceProvider));
32 +});
33 +
34 +final downloadedModelsProvider = FutureProvider<Set<String>>((ref) async {
35 + final downloadService = ref.read(onDeviceDownloadServiceProvider);
36 + final models = await downloadService.getDownloadedModels();
37 + return models.map((m) => m.modelId).toSet();
38 +});
39 +
40 +final onDeviceModelStateProvider =
41 + NotifierProvider<
42 + OnDeviceModelStateNotifier,
43 + Map<String, OnDeviceModelStateInfo>
44 + >(() {
45 + return OnDeviceModelStateNotifier();
46 + });
47 +
48 +class OnDeviceEngineState {
49 + final EngineStatus status;
50 + final String? loadedModelId;
51 + final String? loadedModelPath;
52 + final LiteLmBackendType? backend;
53 + final String? error;
54 +
55 + const OnDeviceEngineState({
56 + this.status = EngineStatus.notLoaded,
57 + this.loadedModelId,
58 + this.loadedModelPath,
59 + this.backend,
60 + this.error,
61 + });
62 +
63 + OnDeviceEngineState copyWith({
64 + EngineStatus? status,
65 + String? loadedModelId,
66 + String? loadedModelPath,
67 + LiteLmBackendType? backend,
68 + String? error,
69 + }) {
70 + return OnDeviceEngineState(
71 + status: status ?? this.status,
72 + loadedModelId: loadedModelId ?? this.loadedModelId,
73 + loadedModelPath: loadedModelPath ?? this.loadedModelPath,
74 + backend: backend ?? this.backend,
75 + error: error ?? this.error,
76 + );
77 + }
78 +}
79 +
80 +class OnDeviceEngineNotifier extends Notifier<OnDeviceEngineState> {
81 + OnDeviceEngineService? _engineService;
82 +
83 + @override
84 + OnDeviceEngineState build() {
85 + return const OnDeviceEngineState();
86 + }
87 +
88 + OnDeviceEngineService get engineService {
89 + _engineService ??= OnDeviceEngineService();
90 + return _engineService!;
91 + }
92 +
93 + Future<void> loadModel(String modelId, LiteLmBackendType backend) async {
94 + state = state.copyWith(status: EngineStatus.loading, error: null);
95 +
96 + try {
97 + final modelPath = await OnDeviceEngineService.getModelPath(modelId);
98 + if (modelPath == null) {
99 + state = state.copyWith(
100 + status: EngineStatus.error,
101 + error: 'Model not found. Please download it first.',
102 + );
103 + return;
104 + }
105 +
106 + await engineService.createEngine(modelPath, backend);
107 +
108 + state = state.copyWith(
109 + status: EngineStatus.loaded,
110 + loadedModelId: modelId,
111 + loadedModelPath: modelPath,
112 + backend: backend,
113 + );
114 +
115 + Log.info('Model $modelId loaded successfully with ${backend.name}');
116 + } catch (e) {
117 + Log.error('Failed to load model $modelId: $e');
118 + state = state.copyWith(
119 + status: EngineStatus.error,
120 + error: 'Failed to load model: ${e.toString()}',
121 + );
122 + }
123 + }
124 +
125 + Future<void> unloadModel() async {
126 + try {
127 + await engineService.disposeEngine();
128 + } catch (_) {}
129 + state = const OnDeviceEngineState();
130 + }
131 +
132 + Future<void> dispose() async {
133 + engineService.dispose();
134 + state = const OnDeviceEngineState();
135 + }
136 +}
137 +
138 +class OnDeviceModelStateNotifier
139 + extends Notifier<Map<String, OnDeviceModelStateInfo>> {
140 + @override
141 + Map<String, OnDeviceModelStateInfo> build() {
142 + return {};
143 + }
144 +
145 + void updateModelState(
146 + String modelId, {
147 + OnDeviceModelState? modelState,
148 + double? downloadProgress,
149 + String? error,
150 + LiteLmBackendType? backend,
151 + EngineStatus? engineStatus,
152 + }) {
153 + final current =
154 + this.state[modelId] ?? OnDeviceModelStateInfo(modelId: modelId);
155 + this.state = {
156 + ...this.state,
157 + modelId: current.copyWith(
158 + state: modelState ?? current.state,
159 + downloadProgress: downloadProgress ?? current.downloadProgress,
160 + error: error ?? current.error,
161 + backend: backend ?? current.backend,
162 + engineStatus: engineStatus ?? current.engineStatus,
163 + ),
164 + };
165 + }
166 +
167 + void setDownloading(String modelId, double progress) {
168 + updateModelState(
169 + modelId,
170 + modelState: OnDeviceModelState.downloading,
171 + downloadProgress: progress,
172 + );
173 + }
174 +
175 + void setDownloaded(String modelId) {
176 + updateModelState(
177 + modelId,
178 + modelState: OnDeviceModelState.downloaded,
179 + downloadProgress: 1.0,
180 + );
181 + }
182 +
183 + void setDownloadError(String modelId, String error) {
184 + updateModelState(
185 + modelId,
186 + modelState: OnDeviceModelState.error,
187 + error: error,
188 + );
189 + }
190 +
191 + void removeModel(String modelId) {
192 + final newState = Map<String, OnDeviceModelStateInfo>.from(state);
193 + newState.remove(modelId);
194 + state = newState;
195 + }
196 +}
197 +
198 +final isOnDevicePlatformSupportedProvider = Provider<bool>((ref) {
199 + return Platform.isAndroid;
200 +});
lib/features/on_device/views/backend_selector.dart new
+118
@@ -0,0 +1,118 @@
1 +import 'dart:io';
2 +
3 +import 'package:flutter/material.dart';
4 +import 'package:flutter_riverpod/flutter_riverpod.dart';
5 +import 'package:shadcn_ui/shadcn_ui.dart';
6 +
7 +import '../../../core/models/enums.dart';
8 +import '../../../core/providers/app_providers.dart';
9 +
10 +class BackendSelector extends ConsumerWidget {
11 + const BackendSelector({super.key});
12 +
13 + @override
14 + Widget build(BuildContext context, WidgetRef ref) {
15 + final settings = ref.watch(settingsProvider);
16 + final currentBackend = settings.preferredBackend;
17 + final isAndroid = Platform.isAndroid;
18 +
19 + return Column(
20 + crossAxisAlignment: CrossAxisAlignment.start,
21 + children: [
22 + Text(
23 + 'Inference Backend',
24 + style: Theme.of(
25 + context,
26 + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
27 + ),
28 + const SizedBox(height: 8),
29 + if (!isAndroid)
30 + Container(
31 + padding: const EdgeInsets.all(12),
32 + margin: const EdgeInsets.only(bottom: 12),
33 + decoration: BoxDecoration(
34 + color: Colors.orange.withValues(alpha: 0.1),
35 + borderRadius: BorderRadius.circular(8),
36 + border: Border.all(color: Colors.orange),
37 + ),
38 + child: const Row(
39 + children: [
40 + Icon(Icons.info_outline, color: Colors.orange, size: 20),
41 + SizedBox(width: 8),
42 + Expanded(
43 + child: Text(
44 + 'Only CPU backend is available on iOS.',
45 + style: TextStyle(color: Colors.orange),
46 + ),
47 + ),
48 + ],
49 + ),
50 + ),
51 + ...LiteLmBackendType.values.map((backend) {
52 + final isAvailable = isAndroid || backend == LiteLmBackendType.cpu;
53 + final isSelected = currentBackend == backend;
54 + return Padding(
55 + padding: const EdgeInsets.only(bottom: 8),
56 + child: Opacity(
57 + opacity: isAvailable ? 1.0 : 0.4,
58 + child: ShadButton.outline(
59 + width: double.infinity,
60 + onPressed: isAvailable
61 + ? () => ref
62 + .read(settingsProvider.notifier)
63 + .setPreferredBackend(backend)
64 + : null,
65 + child: Row(
66 + children: [
67 + Icon(
68 + isSelected
69 + ? Icons.radio_button_checked
70 + : Icons.radio_button_off,
71 + size: 20,
72 + color: isSelected
73 + ? Theme.of(context).colorScheme.primary
74 + : Theme.of(
75 + context,
76 + ).colorScheme.onSurface.withValues(alpha: 0.5),
77 + ),
78 + const SizedBox(width: 12),
79 + Expanded(
80 + child: Column(
81 + crossAxisAlignment: CrossAxisAlignment.start,
82 + children: [
83 + Text(
84 + backend.name.toUpperCase(),
85 + style: const TextStyle(fontWeight: FontWeight.w600),
86 + ),
87 + Text(
88 + _backendDescription(backend),
89 + style: Theme.of(context).textTheme.bodySmall
90 + ?.copyWith(
91 + color: Theme.of(context).colorScheme.onSurface
92 + .withValues(alpha: 0.6),
93 + ),
94 + ),
95 + ],
96 + ),
97 + ),
98 + ],
99 + ),
100 + ),
101 + ),
102 + );
103 + }),
104 + ],
105 + );
106 + }
107 +
108 + String _backendDescription(LiteLmBackendType backend) {
109 + switch (backend) {
110 + case LiteLmBackendType.cpu:
111 + return 'Works on all devices. Most compatible.';
112 + case LiteLmBackendType.gpu:
113 + return 'OpenCL acceleration. Faster on supported devices.';
114 + case LiteLmBackendType.npu:
115 + return 'Vendor NPU (Qualcomm/MediaTek). Fastest inference.';
116 + }
117 + }
118 +}
lib/features/on_device/views/model_manager_screen.dart new
+405
@@ -0,0 +1,405 @@
1 +import 'dart:io';
2 +
3 +import 'package:flutter/material.dart';
4 +import 'package:flutter_riverpod/flutter_riverpod.dart';
5 +import 'package:localmind/features/sidebar/sidebar_widget.dart';
6 +import 'package:shadcn_ui/shadcn_ui.dart';
7 +
8 +import '../../../core/models/enums.dart';
9 +import '../../../core/providers/app_providers.dart';
10 +import '../../on_device/data/models/on_device_model.dart';
11 +import '../../on_device/data/models/download_progress_info.dart';
12 +import '../../on_device/data/models/download_status.dart';
13 +import '../../on_device/providers/on_device_providers.dart';
14 +import '../../on_device/providers/foreground_download_providers.dart';
15 +
16 +class OnDeviceModelManagerScreen extends ConsumerWidget {
17 + const OnDeviceModelManagerScreen({super.key});
18 +
19 + @override
20 + Widget build(BuildContext context, WidgetRef ref) {
21 + final theme = Theme.of(context);
22 + final models = ref.watch(onDeviceModelsProvider);
23 + final downloadedAsync = ref.watch(downloadedModelsProvider);
24 + final engineState = ref.watch(onDeviceEngineProvider);
25 + final downloadProgress = ref.watch(foregroundDownloadNotifierProvider);
26 + final isAndroid = Platform.isAndroid;
27 +
28 + return Scaffold(
29 + drawer: const SidebarWidget(),
30 + appBar: AppBar(title: const Text('On-Device Models')),
31 + body: ListView(
32 + padding: const EdgeInsets.all(16),
33 + children: [
34 + if (!isAndroid)
35 + Container(
36 + padding: const EdgeInsets.all(12),
37 + margin: const EdgeInsets.only(bottom: 16),
38 + decoration: BoxDecoration(
39 + color: Colors.orange.withValues(alpha: 0.1),
40 + borderRadius: BorderRadius.circular(8),
41 + border: Border.all(color: Colors.orange),
42 + ),
43 + child: const Row(
44 + children: [
45 + Icon(Icons.info_outline, color: Colors.orange, size: 20),
46 + SizedBox(width: 8),
47 + Expanded(
48 + child: Text(
49 + 'On-device inference is available on Android only.',
50 + style: TextStyle(color: Colors.orange),
51 + ),
52 + ),
53 + ],
54 + ),
55 + ),
56 + if (engineState.status == EngineStatus.loaded)
57 + Container(
58 + padding: const EdgeInsets.all(12),
59 + margin: const EdgeInsets.only(bottom: 16),
60 + decoration: BoxDecoration(
61 + color: Colors.green.withValues(alpha: 0.1),
62 + borderRadius: BorderRadius.circular(8),
63 + border: Border.all(color: Colors.green),
64 + ),
65 + child: Row(
66 + children: [
67 + const Icon(Icons.check_circle, color: Colors.green, size: 20),
68 + const SizedBox(width: 8),
69 + Expanded(
70 + child: Text(
71 + 'Model loaded: ${engineState.loadedModelId ?? "Unknown"} (${engineState.backend?.name ?? "CPU"})',
72 + style: const TextStyle(color: Colors.green),
73 + ),
74 + ),
75 + ],
76 + ),
77 + )
78 + else if (engineState.status == EngineStatus.loading)
79 + const Padding(
80 + padding: EdgeInsets.only(bottom: 16),
81 + child: LinearProgressIndicator(),
82 + )
83 + else if (engineState.status == EngineStatus.error)
84 + Container(
85 + padding: const EdgeInsets.all(12),
86 + margin: const EdgeInsets.only(bottom: 16),
87 + decoration: BoxDecoration(
88 + color: Colors.red.withValues(alpha: 0.1),
89 + borderRadius: BorderRadius.circular(8),
90 + border: Border.all(color: Colors.red),
91 + ),
92 + child: Text(
93 + engineState.error ?? 'Engine error',
94 + style: const TextStyle(color: Colors.red),
95 + ),
96 + ),
97 + Text(
98 + 'Available Models',
99 + style: theme.textTheme.titleMedium?.copyWith(
100 + fontWeight: FontWeight.bold,
101 + ),
102 + ),
103 + const SizedBox(height: 12),
104 + ...models.map(
105 + (model) => _ModelCard(
106 + model: model,
107 + theme: theme,
108 + downloadedAsync: downloadedAsync,
109 + downloadProgress: downloadProgress[model.id],
110 + engineState: engineState,
111 + ),
112 + ),
113 + const SizedBox(height: 24),
114 + ],
115 + ),
116 + );
117 + }
118 +}
119 +
120 +class _ModelCard extends ConsumerWidget {
121 + const _ModelCard({
122 + required this.model,
123 + required this.theme,
124 + required this.downloadedAsync,
125 + required this.downloadProgress,
126 + required this.engineState,
127 + });
128 +
129 + final OnDeviceModel model;
130 + final ThemeData theme;
131 + final AsyncValue<Set<String>> downloadedAsync;
132 + final DownloadProgressInfo? downloadProgress;
133 + final OnDeviceEngineState engineState;
134 +
135 + @override
136 + Widget build(BuildContext context, WidgetRef ref) {
137 + final isDownloaded = downloadedAsync.when(
138 + data: (set) => set.contains(model.id),
139 + loading: () => false,
140 + error: (_, __) => false,
141 + );
142 +
143 + final isLoading =
144 + engineState.status == EngineStatus.loading &&
145 + engineState.loadedModelId == model.id;
146 +
147 + final isDownloading =
148 + downloadProgress != null &&
149 + (downloadProgress!.status == DownloadStatus.running ||
150 + downloadProgress!.status == DownloadStatus.pending);
151 +
152 + final isPaused = downloadProgress?.status == DownloadStatus.paused;
153 +
154 + return Padding(
155 + padding: const EdgeInsets.only(bottom: 12),
156 + child: Container(
157 + padding: const EdgeInsets.all(16),
158 + decoration: BoxDecoration(
159 + color: theme.colorScheme.surface,
160 + borderRadius: BorderRadius.circular(12),
161 + border: Border.all(
162 + color: isDownloaded
163 + ? Colors.green.withValues(alpha: 0.5)
164 + : theme.colorScheme.outline.withValues(alpha: 0.3),
165 + width: isDownloaded ? 1.5 : 1,
166 + ),
167 + ),
168 + child: Column(
169 + crossAxisAlignment: CrossAxisAlignment.start,
170 + children: [
171 + Row(
172 + children: [
173 + Expanded(
174 + child: Text(
175 + model.name,
176 + style: theme.textTheme.titleSmall?.copyWith(
177 + fontWeight: FontWeight.bold,
178 + ),
179 + ),
180 + ),
181 + if (model.isRecommended)
182 + Container(
183 + padding: const EdgeInsets.symmetric(
184 + horizontal: 8,
185 + vertical: 2,
186 + ),
187 + decoration: BoxDecoration(
188 + color: theme.colorScheme.primary.withValues(alpha: 0.1),
189 + borderRadius: BorderRadius.circular(4),
190 + ),
191 + child: Text(
192 + 'RECOMMENDED',
193 + style: theme.textTheme.labelSmall?.copyWith(
194 + color: theme.colorScheme.primary,
195 + fontWeight: FontWeight.bold,
196 + ),
197 + ),
198 + ),
199 + ],
200 + ),
201 + const SizedBox(height: 4),
202 + Text(
203 + model.description,
204 + style: theme.textTheme.bodySmall?.copyWith(
205 + color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
206 + ),
207 + ),
208 + const SizedBox(height: 8),
209 + Row(
210 + children: [
211 + Text(
212 + model.fileSizeFormatted,
213 + style: theme.textTheme.labelMedium,
214 + ),
215 + const SizedBox(width: 12),
216 + Text(model.license, style: theme.textTheme.labelMedium),
217 + const SizedBox(width: 12),
218 + Text(
219 + '${model.minRamMb ~/ 1024 + 1} GB RAM min',
220 + style: theme.textTheme.labelMedium?.copyWith(
221 + color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
222 + ),
223 + ),
224 + ],
225 + ),
226 + const SizedBox(height: 8),
227 + _buildActionRow(
228 + context,
229 + ref,
230 + isDownloaded,
231 + isLoading,
232 + isDownloading,
233 + isPaused,
234 + ),
235 + ],
236 + ),
237 + ),
238 + );
239 + }
240 +
241 + Widget _buildActionRow(
242 + BuildContext context,
243 + WidgetRef ref,
244 + bool isDownloaded,
245 + bool isLoading,
246 + bool isDownloading,
247 + bool isPaused,
248 + ) {
249 + final isLoaded = engineState.loadedModelId == model.id;
250 +
251 + if (isDownloaded) {
252 + return Row(
253 + children: [
254 + Icon(
255 + isLoaded ? Icons.check_circle : Icons.check_circle_outline,
256 + color: isLoaded ? Colors.green : Colors.grey,
257 + size: 18,
258 + ),
259 + const SizedBox(width: 4),
260 + Text(
261 + isLoaded ? 'Loaded' : 'Downloaded',
262 + style: theme.textTheme.bodySmall?.copyWith(
263 + color: isLoaded ? Colors.green : Colors.grey,
264 + fontWeight: FontWeight.w600,
265 + ),
266 + ),
267 + const Spacer(),
268 + if (isLoading)
269 + const SizedBox(
270 + width: 20,
271 + height: 20,
272 + child: CircularProgressIndicator(strokeWidth: 2),
273 + )
274 + else if (isLoaded)
275 + ShadButton.outline(
276 + size: ShadButtonSize.sm,
277 + onPressed: () => _unloadModel(ref),
278 + child: const Text('Unload'),
279 + )
280 + else
281 + ShadButton.outline(
282 + size: ShadButtonSize.sm,
283 + onPressed: () => _loadModel(ref),
284 + child: const Text('Load'),
285 + ),
286 + const SizedBox(width: 8),
287 + ShadButton.outline(
288 + size: ShadButtonSize.sm,
289 + onPressed: () => _deleteModel(context, ref),
290 + child: const Text('Delete'),
291 + ),
292 + ],
293 + );
294 + }
295 +
296 + if (isDownloading) {
297 + return Row(
298 + children: [
299 + Expanded(
300 + child: Column(
301 + crossAxisAlignment: CrossAxisAlignment.start,
302 + children: [
303 + LinearProgressIndicator(
304 + value: downloadProgress?.progress ?? 0.0,
305 + ),
306 + const SizedBox(height: 4),
307 + Text(
308 + 'Downloading - ${((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(0)}%',
309 + style: theme.textTheme.labelSmall,
310 + ),
311 + ],
312 + ),
313 + ),
314 + const SizedBox(width: 8),
315 + ShadButton.outline(
316 + size: ShadButtonSize.sm,
317 + onPressed: () => ref
318 + .read(foregroundDownloadNotifierProvider.notifier)
319 + .cancelDownload(model.id),
320 + child: const Text('Cancel'),
321 + ),
322 + ],
323 + );
324 + }
325 +
326 + if (downloadProgress?.status == DownloadStatus.failed) {
327 + return Row(
328 + children: [
329 + Expanded(
330 + child: Text(
331 + downloadProgress?.error ?? 'Download failed',
332 + style: theme.textTheme.bodySmall?.copyWith(color: Colors.red),
333 + ),
334 + ),
335 + const SizedBox(width: 8),
336 + ShadButton.outline(
337 + size: ShadButtonSize.sm,
338 + onPressed: () => ref
339 + .read(foregroundDownloadNotifierProvider.notifier)
340 + .retryDownload(model.id),
341 + child: const Text('Retry'),
342 + ),
343 + ],
344 + );
345 + }
346 +
347 + return Row(
348 + children: [
349 + const Spacer(),
350 + ShadButton.outline(
351 + size: ShadButtonSize.sm,
352 + onPressed: () => _startDownload(ref),
353 + child: const Text('Download'),
354 + ),
355 + ],
356 + );
357 + }
358 +
359 + Future<void> _startDownload(WidgetRef ref) async {
360 + await ref
361 + .read(foregroundDownloadNotifierProvider.notifier)
362 + .startDownload(model.id);
363 + }
364 +
365 + Future<void> _loadModel(WidgetRef ref) async {
366 + final settings = ref.read(settingsProvider);
367 + final engineNotifier = ref.read(onDeviceEngineProvider.notifier);
368 + await engineNotifier.loadModel(model.id, settings.preferredBackend);
369 + }
370 +
371 + Future<void> _unloadModel(WidgetRef ref) async {
372 + await ref.read(onDeviceEngineProvider.notifier).unloadModel();
373 + }
374 +
375 + Future<void> _deleteModel(BuildContext context, WidgetRef ref) async {
376 + final confirm = await showDialog<bool>(
377 + context: context,
378 + builder: (ctx) => AlertDialog(
379 + title: const Text('Delete Model'),
380 + content: Text('Are you sure you want to delete ${model.name}?'),
381 + actions: [
382 + TextButton(
383 + onPressed: () => Navigator.pop(ctx, false),
384 + child: const Text('Cancel'),
385 + ),
386 + TextButton(
387 + onPressed: () => Navigator.pop(ctx, true),
388 + child: const Text('Delete'),
389 + ),
390 + ],
391 + ),
392 + );
393 +
394 + if (confirm == true) {
395 + final engineState = ref.read(onDeviceEngineProvider);
396 + if (engineState.loadedModelId == model.id) {
397 + await ref.read(onDeviceEngineProvider.notifier).unloadModel();
398 + }
399 +
400 + final downloadService = ref.read(onDeviceDownloadServiceProvider);
401 + await downloadService.deleteModel(model.id);
402 + ref.invalidate(downloadedModelsProvider);
403 + }
404 + }
405 +}
lib/features/onboarding/screens/onboarding_model_download_screen.dart new
+312
@@ -0,0 +1,312 @@
1 +import 'dart:async';
2 +import 'dart:io';
3 +
4 +import 'package:flutter/material.dart';
5 +import 'package:flutter_riverpod/flutter_riverpod.dart';
6 +import 'package:go_router/go_router.dart';
7 +import 'package:shadcn_ui/shadcn_ui.dart';
8 +
9 +import '../../../core/models/enums.dart';
10 +import '../../../core/routes/app_routes.dart';
11 +import '../../on_device/data/models/on_device_model.dart';
12 +import '../../on_device/data/on_device_model_download_service.dart';
13 +import '../../on_device/providers/on_device_providers.dart';
14 +import '../../servers/data/models/server.dart';
15 +import '../../servers/providers/server_providers.dart';
16 +
17 +class OnboardingModelDownloadScreen extends ConsumerStatefulWidget {
18 + const OnboardingModelDownloadScreen({super.key});
19 +
20 + @override
21 + ConsumerState<OnboardingModelDownloadScreen> createState() =>
22 + _OnboardingModelDownloadScreenState();
23 +}
24 +
25 +class _OnboardingModelDownloadScreenState
26 + extends ConsumerState<OnboardingModelDownloadScreen> {
27 + String? _downloadingModelId;
28 + double _downloadProgress = 0.0;
29 + bool _isCreatingServer = false;
30 + StreamSubscription<DownloadProgress>? _downloadSubscription;
31 +
32 + @override
33 + Widget build(BuildContext context) {
34 + final theme = Theme.of(context);
35 + final models = ref.watch(onDeviceModelsProvider);
36 + final downloadedModelsAsync = ref.watch(downloadedModelsProvider);
37 + final isAndroid = Platform.isAndroid;
38 +
39 + return Scaffold(
40 + appBar: AppBar(title: const Text('Download a Model')),
41 + body: SafeArea(
42 + child: ListView(
43 + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
44 + children: [
45 + Text(
46 + 'Choose a model to download.\nIt will run locally on your device.',
47 + style: theme.textTheme.bodyMedium?.copyWith(
48 + color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
49 + ),
50 + ),
51 + const SizedBox(height: 24),
52 + if (!isAndroid)
53 + Container(
54 + padding: const EdgeInsets.all(12),
55 + margin: const EdgeInsets.only(bottom: 16),
56 + decoration: BoxDecoration(
57 + color: Colors.orange.withValues(alpha: 0.1),
58 + borderRadius: BorderRadius.circular(8),
59 + border: Border.all(color: Colors.orange),
60 + ),
61 + child: const Row(
62 + children: [
63 + Icon(Icons.info_outline, color: Colors.orange, size: 20),
64 + SizedBox(width: 8),
65 + Expanded(
66 + child: Text(
67 + 'On-device inference is currently available on Android only.',
68 + style: TextStyle(color: Colors.orange),
69 + ),
70 + ),
71 + ],
72 + ),
73 + ),
74 + ...models.map((model) {
75 + final isDownloaded = downloadedModelsAsync.when(
76 + data: (set) => set.contains(model.id),
77 + loading: () => false,
78 + error: (_, __) => false,
79 + );
80 + final isDownloading = _downloadingModelId == model.id;
81 + return Padding(
82 + padding: const EdgeInsets.only(bottom: 12),
83 + child: _ModelCard(
84 + model: model,
85 + isDownloaded: isDownloaded,
86 + isDownloading: isDownloading,
87 + downloadProgress: _downloadProgress,
88 + onDownload: isDownloaded || isDownloading
89 + ? null
90 + : () => _downloadModel(model),
91 + theme: theme,
92 + ),
93 + );
94 + }),
95 + const SizedBox(height: 24),
96 + if (_isCreatingServer)
97 + const Center(child: CircularProgressIndicator())
98 + else
99 + ShadButton(
100 + width: double.infinity,
101 + onPressed: _canContinue() ? _createOnDeviceServer : null,
102 + child: const Text(
103 + 'Continue',
104 + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
105 + ),
106 + ),
107 + ],
108 + ),
109 + ),
110 + );
111 + }
112 +
113 + bool _canContinue() {
114 + final downloadedSet = ref.read(downloadedModelsProvider).whenData((s) => s);
115 + return downloadedSet.hasValue && downloadedSet.value!.isNotEmpty;
116 + }
117 +
118 + Future<void> _downloadModel(OnDeviceModel model) async {
119 + await _downloadSubscription?.cancel();
120 + setState(() {
121 + _downloadingModelId = model.id;
122 + _downloadProgress = 0.0;
123 + });
124 +
125 + final downloadService = ref.read(onDeviceDownloadServiceProvider);
126 + try {
127 + await for (final progress in downloadService.downloadModel(model)) {
128 + if (!mounted) return;
129 + setState(() {
130 + _downloadProgress = progress.fraction;
131 + });
132 + }
133 + ref.invalidate(downloadedModelsProvider);
134 + if (mounted) {
135 + setState(() {
136 + _downloadingModelId = null;
137 + });
138 + }
139 + } catch (e) {
140 + if (mounted) {
141 + setState(() {
142 + _downloadingModelId = null;
143 + });
144 + ScaffoldMessenger.of(context).showSnackBar(
145 + SnackBar(content: Text('Download failed: ${e.toString()}')),
146 + );
147 + }
148 + }
149 + }
150 +
151 + Future<void> _createOnDeviceServer() async {
152 + setState(() => _isCreatingServer = true);
153 +
154 + try {
155 + final server = Server(
156 + id: DateTime.now().millisecondsSinceEpoch.toString(),
157 + name: 'On-Device',
158 + type: ServerType.onDevice,
159 + host: '',
160 + port: 0,
161 + isDefault: true,
162 + createdAt: DateTime.now(),
163 + lastConnectedAt: DateTime.now(),
164 + status: ConnectionStatus.connected,
165 + iconName: 'strokeRoundedSmartPhone01',
166 + );
167 +
168 + await ref.read(serversProvider.notifier).addServer(server);
169 + await ref.read(serversProvider.notifier).setDefault(server.id);
170 + ref.read(activeServerProvider.notifier).setActiveServer(server);
171 +
172 + if (mounted) {
173 + context.push(AppRoutes.onboardingTheme);
174 + }
175 + } catch (e) {
176 + if (mounted) {
177 + ScaffoldMessenger.of(
178 + context,
179 + ).showSnackBar(SnackBar(content: Text('Error: ${e.toString()}')));
180 + }
181 + } finally {
182 + if (mounted) {
183 + setState(() => _isCreatingServer = false);
184 + }
185 + }
186 + }
187 +}
188 +
189 +class _ModelCard extends StatelessWidget {
190 + final OnDeviceModel model;
191 + final bool isDownloaded;
192 + final bool isDownloading;
193 + final double downloadProgress;
194 + final VoidCallback? onDownload;
195 + final ThemeData theme;
196 +
197 + const _ModelCard({
198 + required this.model,
199 + required this.isDownloaded,
200 + required this.isDownloading,
201 + required this.downloadProgress,
202 + this.onDownload,
203 + required this.theme,
204 + });
205 +
206 + @override
207 + Widget build(BuildContext context) {
208 + return Container(
209 + padding: const EdgeInsets.all(16),
210 + decoration: BoxDecoration(
211 + color: theme.colorScheme.surface,
212 + borderRadius: BorderRadius.circular(12),
213 + border: Border.all(
214 + color: isDownloaded
215 + ? Colors.green.withValues(alpha: 0.5)
216 + : theme.colorScheme.outline.withValues(alpha: 0.3),
217 + width: isDownloaded ? 1.5 : 1,
218 + ),
219 + ),
220 + child: Column(
221 + crossAxisAlignment: CrossAxisAlignment.start,
222 + children: [
223 + Row(
224 + children: [
225 + Expanded(
226 + child: Text(
227 + model.name,
228 + style: theme.textTheme.titleSmall?.copyWith(
229 + fontWeight: FontWeight.bold,
230 + ),
231 + ),
232 + ),
233 + if (model.isRecommended)
234 + Container(
235 + padding: const EdgeInsets.symmetric(
236 + horizontal: 8,
237 + vertical: 2,
238 + ),
239 + decoration: BoxDecoration(
240 + color: theme.colorScheme.primary.withValues(alpha: 0.1),
241 + borderRadius: BorderRadius.circular(4),
242 + ),
243 + child: Text(
244 + 'RECOMMENDED',
245 + style: theme.textTheme.labelSmall?.copyWith(
246 + color: theme.colorScheme.primary,
247 + fontWeight: FontWeight.bold,
248 + ),
249 + ),
250 + ),
251 + ],
252 + ),
253 + const SizedBox(height: 4),
254 + Text(
255 + model.description,
256 + style: theme.textTheme.bodySmall?.copyWith(
257 + color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
258 + ),
259 + ),
260 + const SizedBox(height: 8),
261 + Row(
262 + children: [
263 + Text(model.fileSizeFormatted, style: theme.textTheme.labelMedium),
264 + const SizedBox(width: 12),
265 + Text(model.license, style: theme.textTheme.labelMedium),
266 + const SizedBox(width: 12),
267 + Text(
268 + '${model.minRamMb ~/ 1024 + 1} GB RAM min',
269 + style: theme.textTheme.labelMedium?.copyWith(
270 + color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
271 + ),
272 + ),
273 + ],
274 + ),
275 + const SizedBox(height: 8),
276 + if (isDownloaded)
277 + Row(
278 + children: [
279 + const Icon(Icons.check_circle, color: Colors.green, size: 18),
280 + const SizedBox(width: 4),
281 + Text(
282 + 'Downloaded',
283 + style: theme.textTheme.bodySmall?.copyWith(
284 + color: Colors.green,
285 + fontWeight: FontWeight.w600,
286 + ),
287 + ),
288 + ],
289 + )
290 + else if (isDownloading)
291 + Column(
292 + crossAxisAlignment: CrossAxisAlignment.stretch,
293 + children: [
294 + LinearProgressIndicator(value: downloadProgress),
295 + const SizedBox(height: 4),
296 + Text(
297 + '${(downloadProgress * 100).toStringAsFixed(0)}%',
298 + style: theme.textTheme.labelSmall,
299 + ),
300 + ],
301 + )
302 + else
303 + ShadButton.outline(
304 + size: ShadButtonSize.sm,
305 + onPressed: onDownload,
306 + child: const Text('Download'),
307 + ),
308 + ],
309 + ),
310 + );
311 + }
312 +}
lib/features/onboarding/screens/onboarding_server_setup_screen.dart
+4
@@ -58,6 +58,10 @@ class _OnboardingServerSetupScreenState
58 defaultName = 'OpenRouter';
59 defaultPort = '443';
60 break;
61 + case ServerType.onDevice:
62 + defaultName = 'On-Device';
63 + defaultPort = '0';
64 + break;
65 }
66
67 _nameController = TextEditingController(text: defaultName);
lib/features/onboarding/screens/onboarding_server_type_screen.dart
+37 -5
@@ -5,6 +5,7 @@ import 'package:hugeicons/hugeicons.dart';
5 import 'package:shadcn_ui/shadcn_ui.dart';
6
7 import '../../../core/models/enums.dart';
8 +import '../../../core/providers/app_providers.dart';
9 import '../../../core/routes/app_routes.dart';
10
11 class OnboardingServerTypeScreen extends ConsumerStatefulWidget {
@@ -44,7 +45,7 @@ class _OnboardingServerTypeScreenState
45 centerTitle: false,
46 actions: [
47 TextButton.icon(
47 - onPressed: () {},
48 + onPressed: () => _skipOnboarding(),
49 label: const Text('Skip'),
50 icon: HugeIcon(icon: HugeIcons.strokeRoundedNext),
51 ),
@@ -78,6 +79,19 @@ class _OnboardingServerTypeScreenState
79 ),
80 ),
81 const SizedBox(height: 24),
82 + _buildServerCard(
83 + type: ServerType.onDevice,
84 + title: 'On-Device',
85 + subtitle: 'NO SERVER NEEDED',
86 + iconWidget: Icon(
87 + Icons.phone_android_rounded,
88 + color: _selectedType == ServerType.onDevice
89 + ? theme.colorScheme.primary
90 + : theme.colorScheme.onSurface,
91 + ),
92 + theme: theme,
93 + ),
94 + const SizedBox(height: 16),
95 _buildServerCard(
96 type: ServerType.lmStudio,
97 title: 'LM Studio',
@@ -162,10 +176,14 @@ class _OnboardingServerTypeScreenState
176 enabled: _selectedType != null,
177 onPressed: () {
178 if (_selectedType != null) {
165 - context.push(
166 - AppRoutes.onboardingSetup,
167 - extra: _selectedType,
168 - );
179 + if (_selectedType == ServerType.onDevice) {
180 + _completeOnboarding();
181 + } else {
182 + context.push(
183 + AppRoutes.onboardingSetup,
184 + extra: _selectedType,
185 + );
186 + }
187 }
188 },
189 child: const Text(
@@ -242,4 +260,18 @@ class _OnboardingServerTypeScreenState
260 ),
261 );
262 }
263 +
264 + Future<void> _completeOnboarding() async {
265 + final settings = ref.read(settingsProvider);
266 + await ref
267 + .read(settingsProvider.notifier)
268 + .updateSettings(settings.copyWith(hasCompletedOnboarding: true));
269 + if (mounted) {
270 + context.go(AppRoutes.home);
271 + }
272 + }
273 +
274 + void _skipOnboarding() {
275 + _completeOnboarding();
276 + }
277 }
lib/features/servers/data/models/server.dart
+15
@@ -31,6 +31,9 @@ class Server {
31 if (type == ServerType.openRouter) {
32 return 'https://openrouter.ai/api/v1';
33 }
34 + if (type == ServerType.onDevice) {
35 + return 'on-device';
36 + }
37 return 'http://$host:$port';
38 }
39
@@ -44,6 +47,8 @@ class Server {
47 return '$baseUrl/api/chat';
48 case ServerType.openRouter:
49 return '$baseUrl/chat/completions';
50 + case ServerType.onDevice:
51 + return '';
52 }
53 }
54
@@ -57,6 +62,8 @@ class Server {
62 return '$baseUrl/api/tags';
63 case ServerType.openRouter:
64 return '$baseUrl/models';
65 + case ServerType.onDevice:
66 + return '';
67 }
68 }
69
@@ -70,6 +77,8 @@ class Server {
77 return '$baseUrl/api/ps';
78 case ServerType.openRouter:
79 return '';
80 + case ServerType.onDevice:
81 + return '';
82 }
83 }
84
@@ -83,6 +92,8 @@ class Server {
92 return '$baseUrl/api/generate';
93 case ServerType.openRouter:
94 return '';
95 + case ServerType.onDevice:
96 + return '';
97 }
98 }
99
@@ -96,9 +107,13 @@ class Server {
107 return '$baseUrl/api/generate';
108 case ServerType.openRouter:
109 return '';
110 + case ServerType.onDevice:
111 + return '';
112 }
113 }
114
115 + bool get isOnDevice => type == ServerType.onDevice;
116 +
117 Server copyWith({
118 String? id,
119 String? name,
lib/features/servers/data/server_api_service.dart
+16 -3
@@ -53,6 +53,8 @@ class ServerApiService {
53 return _parseOllamaModels(response.data, server);
54 case ServerType.openRouter:
55 return _parseOpenRouterModels(response.data, server);
56 + case ServerType.onDevice:
57 + return [];
58 }
59 } catch (e) {
60 throw Exception('Failed to fetch models: $e');
@@ -78,6 +80,8 @@ class ServerApiService {
80 return _parseRunningOllamaModels(response.data);
81 case ServerType.openRouter:
82 return {};
83 + case ServerType.onDevice:
84 + return {};
85 }
86 } catch (e) {
87 return {};
@@ -85,7 +89,9 @@ class ServerApiService {
89 }
90
91 Future<void> loadModel(Server server, String modelId) async {
88 - if (server.type == ServerType.openRouter) return;
92 + if (server.type == ServerType.openRouter ||
93 + server.type == ServerType.onDevice)
94 + return;
95
96 switch (server.type) {
97 case ServerType.lmStudio:
@@ -97,12 +103,15 @@ class ServerApiService {
103 options: Options(headers: _getAuthHeaders(server)),
104 );
105 case ServerType.openRouter:
106 + case ServerType.onDevice:
107 break;
108 }
109 }
110
111 Future<String?> loadModelWithInstanceId(Server server, String modelId) async {
105 - if (server.type == ServerType.openRouter) return null;
112 + if (server.type == ServerType.openRouter ||
113 + server.type == ServerType.onDevice)
114 + return null;
115
116 switch (server.type) {
117 case ServerType.lmStudio:
@@ -121,6 +130,7 @@ class ServerApiService {
130 );
131 return null;
132 case ServerType.openRouter:
133 + case ServerType.onDevice:
134 return null;
135 }
136 }
@@ -130,7 +140,9 @@ class ServerApiService {
140 String modelId, {
141 String? instanceId,
142 }) async {
133 - if (server.type == ServerType.openRouter) return;
143 + if (server.type == ServerType.openRouter ||
144 + server.type == ServerType.onDevice)
145 + return;
146
147 switch (server.type) {
148 case ServerType.lmStudio:
@@ -147,6 +159,7 @@ class ServerApiService {
159 options: Options(headers: _getAuthHeaders(server)),
160 );
161 case ServerType.openRouter:
162 + case ServerType.onDevice:
163 break;
164 }
165 }
lib/features/servers/providers/server_providers.dart
+36
@@ -11,6 +11,38 @@ import '../../../objectbox.g.dart';
11
12 final _modelCache = ModelCache();
13
14 +bool _onDeviceServerEnsured = false;
15 +
16 +final ensureOnDeviceServerProvider = FutureProvider<void>((ref) async {
17 + if (_onDeviceServerEnsured) return;
18 +
19 + final serversAsync = ref.watch(serversProvider);
20 +
21 + if (!serversAsync.hasValue) return;
22 +
23 + final servers = serversAsync.value!;
24 + final hasOnDevice = servers.any((s) => s.type == ServerType.onDevice);
25 + if (hasOnDevice) {
26 + _onDeviceServerEnsured = true;
27 + return;
28 + }
29 +
30 + final server = Server(
31 + id: 'on-device',
32 + name: 'On-Device',
33 + type: ServerType.onDevice,
34 + host: '',
35 + port: 0,
36 + isDefault: false,
37 + createdAt: DateTime.now(),
38 + lastConnectedAt: DateTime.now(),
39 + status: ConnectionStatus.connected,
40 + iconName: 'strokeRoundedSmartPhone01',
41 + );
42 + await ref.read(serversProvider.notifier).addServer(server);
43 + _onDeviceServerEnsured = true;
44 +});
45 +
46 final serversProvider = AsyncNotifierProvider<ServersNotifier, List<Server>>(
47 () {
48 return ServersNotifier();
@@ -218,6 +250,10 @@ class ConnectionStatusNotifier extends Notifier<ConnectionStatus> {
250 return ConnectionStatus.disconnected;
251 }
252
253 + if (activeServer.type == ServerType.onDevice) {
254 + return ConnectionStatus.connected;
255 + }
256 +
257 _checkConnection(activeServer, apiService);
258 return ConnectionStatus.checking;
259 }
lib/features/servers/views/components/server_card.dart
+5
@@ -40,6 +40,8 @@ class ServerCard extends StatelessWidget {
40 return 'Ollama';
41 case ServerType.openRouter:
42 return 'OpenRouter';
43 + case ServerType.onDevice:
44 + return 'On-Device';
45 }
46 }
47
@@ -47,6 +49,9 @@ class ServerCard extends StatelessWidget {
49 if (server.type == ServerType.openRouter) {
50 return 'openrouter.ai';
51 }
52 + if (server.type == ServerType.onDevice) {
53 + return 'Local inference';
54 + }
55 return '${server.host}:${server.port}';
56 }
57
lib/features/servers/views/server_list_screen.dart
+17 -7
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
2 import 'package:flutter_riverpod/flutter_riverpod.dart';
3 import 'package:go_router/go_router.dart';
4
5 +import '../../../core/models/enums.dart';
6 import '../../../core/providers/service_providers.dart';
7 import '../../../core/routes/app_routes.dart';
8 import '../providers/server_providers.dart';
@@ -77,6 +78,8 @@ class ServerListScreen extends ConsumerWidget {
78 itemCount: serverList.length,
79 itemBuilder: (context, index) {
80 final server = serverList[index];
81 + final isOnDevice =
82 + server.type == ServerType.onDevice;
83 return Padding(
84 padding: const EdgeInsets.only(bottom: 12),
85 child: ServerCard(
@@ -95,13 +98,20 @@ class ServerListScreen extends ConsumerWidget {
98 ),
99 );
100 },
98 - onEdit: () =>
99 - _showEditDialog(context, ref, server),
100 - onDelete: () => _showDeleteConfirmation(
101 - context,
102 - ref,
103 - server,
104 - ),
101 + onEdit: isOnDevice
102 + ? null
103 + : () => _showEditDialog(
104 + context,
105 + ref,
106 + server,
107 + ),
108 + onDelete: isOnDevice
109 + ? null
110 + : () => _showDeleteConfirmation(
111 + context,
112 + ref,
113 + server,
114 + ),
115 onSetDefault: () {
116 ref
117 .read(serversProvider.notifier)
lib/features/settings/data/models/app_settings.dart
+8
@@ -2,6 +2,8 @@ import 'dart:convert';
2
3 import 'package:flutter/material.dart';
4
5 +import '../../../../core/models/enums.dart';
6 +
7 enum SyntaxThemeName {
8 vscodeDark,
9 vscodeLight,
@@ -35,6 +37,7 @@ class AppSettings {
37 final bool mcpEnabled;
38 final SyntaxThemeName codeThemeDark;
39 final SyntaxThemeName codeThemeLight;
40 + final LiteLmBackendType preferredBackend;
41
42 AppSettings({
43 this.temperature = 0.7,
@@ -55,6 +58,7 @@ class AppSettings {
58 this.mcpEnabled = true,
59 this.codeThemeDark = SyntaxThemeName.vscodeDark,
60 this.codeThemeLight = SyntaxThemeName.vscodeLight,
61 + this.preferredBackend = LiteLmBackendType.cpu,
62 });
63
64 AppSettings copyWith({
@@ -76,6 +80,7 @@ class AppSettings {
80 bool? mcpEnabled,
81 SyntaxThemeName? codeThemeDark,
82 SyntaxThemeName? codeThemeLight,
83 + LiteLmBackendType? preferredBackend,
84 }) {
85 return AppSettings(
86 temperature: temperature ?? this.temperature,
@@ -98,6 +103,7 @@ class AppSettings {
103 mcpEnabled: mcpEnabled ?? this.mcpEnabled,
104 codeThemeDark: codeThemeDark ?? this.codeThemeDark,
105 codeThemeLight: codeThemeLight ?? this.codeThemeLight,
106 + preferredBackend: preferredBackend ?? this.preferredBackend,
107 );
108 }
109
@@ -121,6 +127,7 @@ class AppSettings {
127 'mcpEnabled': mcpEnabled,
128 'codeThemeDark': codeThemeDark.index,
129 'codeThemeLight': codeThemeLight.index,
130 + 'preferredBackend': preferredBackend.index,
131 };
132 }
133
@@ -144,6 +151,7 @@ class AppSettings {
151 mcpEnabled: map['mcpEnabled'] ?? true,
152 codeThemeDark: SyntaxThemeName.values[map['codeThemeDark'] ?? 0],
153 codeThemeLight: SyntaxThemeName.values[map['codeThemeLight'] ?? 1],
154 + preferredBackend: LiteLmBackendType.values[map['preferredBackend'] ?? 0],
155 );
156 }
157
lib/features/settings/screens/settings_screen.dart
+100 -1
@@ -2,9 +2,11 @@ import 'package:flutter/material.dart';
2 import 'package:flutter_riverpod/flutter_riverpod.dart';
3 import 'package:go_router/go_router.dart';
4 import '../../../core/providers/app_providers.dart';
5 -
5 +import '../../../core/models/enums.dart';
6 +import '../../../core/routes/app_routes.dart';
7 import '../../../core/theme/app_theme.dart';
8 import '../../conversations/providers/conversation_providers.dart';
9 +import '../../on_device/providers/on_device_providers.dart';
10 import '../../servers/providers/server_providers.dart';
11 import '../data/models/app_settings.dart';
12 import '../../personas/providers/personas_providers.dart';
@@ -143,6 +145,23 @@ class SettingsScreen extends ConsumerWidget {
145 isDark: isDark,
146 ),
147 const Divider(height: 32),
148 + _SectionHeader(title: 'On-Device Inference'),
149 + Padding(
150 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
151 + child: OutlinedButton.icon(
152 + onPressed: () => context.push(AppRoutes.onDeviceModels),
153 + icon: const Icon(Icons.phone_android, size: 18),
154 + label: const Text('Manage On-Device Models'),
155 + style: OutlinedButton.styleFrom(
156 + minimumSize: const Size(double.infinity, 44),
157 + shape: RoundedRectangleBorder(
158 + borderRadius: BorderRadius.circular(8),
159 + ),
160 + ),
161 + ),
162 + ),
163 + const _OnDeviceEngineStatus(),
164 + const Divider(height: 32),
165 _SectionHeader(title: 'Default Server'),
166 _DropdownSetting(
167 label: 'Default Server',
@@ -890,3 +909,83 @@ class _DangerousAction extends StatelessWidget {
909 );
910 }
911 }
912 +
913 +class _OnDeviceEngineStatus extends ConsumerWidget {
914 + const _OnDeviceEngineStatus();
915 +
916 + @override
917 + Widget build(BuildContext context, WidgetRef ref) {
918 + final engineState = ref.watch(onDeviceEngineProvider);
919 + final isDark = Theme.of(context).brightness == Brightness.dark;
920 +
921 + if (engineState.status == EngineStatus.notLoaded) {
922 + return Padding(
923 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
924 + child: Text(
925 + 'No model loaded. Tap "Manage On-Device Models" to download and load a model.',
926 + style: TextStyle(
927 + fontSize: 12,
928 + color: isDark ? const Color(0xFF666666) : const Color(0xFF999999),
929 + ),
930 + ),
931 + );
932 + }
933 +
934 + return Padding(
935 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
936 + child: Container(
937 + padding: const EdgeInsets.all(12),
938 + decoration: BoxDecoration(
939 + color: engineState.status == EngineStatus.loaded
940 + ? Colors.green.withValues(alpha: 0.1)
941 + : engineState.status == EngineStatus.error
942 + ? Colors.red.withValues(alpha: 0.1)
943 + : Colors.blue.withValues(alpha: 0.1),
944 + borderRadius: BorderRadius.circular(8),
945 + border: Border.all(
946 + color: engineState.status == EngineStatus.loaded
947 + ? Colors.green
948 + : engineState.status == EngineStatus.error
949 + ? Colors.red
950 + : Colors.blue,
951 + ),
952 + ),
953 + child: Row(
954 + children: [
955 + Icon(
956 + engineState.status == EngineStatus.loaded
957 + ? Icons.check_circle
958 + : engineState.status == EngineStatus.error
959 + ? Icons.error
960 + : Icons.hourglass_top,
961 + size: 18,
962 + color: engineState.status == EngineStatus.loaded
963 + ? Colors.green
964 + : engineState.status == EngineStatus.error
965 + ? Colors.red
966 + : Colors.blue,
967 + ),
968 + const SizedBox(width: 8),
969 + Expanded(
970 + child: Text(
971 + engineState.status == EngineStatus.loaded
972 + ? 'Loaded: ${engineState.loadedModelId ?? "unknown"} (${engineState.backend?.name ?? "CPU"})'
973 + : engineState.status == EngineStatus.loading
974 + ? 'Loading model...'
975 + : 'Error: ${engineState.error ?? "Unknown error"}',
976 + style: TextStyle(
977 + fontSize: 13,
978 + color: engineState.status == EngineStatus.loaded
979 + ? Colors.green
980 + : engineState.status == EngineStatus.error
981 + ? Colors.red
982 + : Colors.blue,
983 + ),
984 + ),
985 + ),
986 + ],
987 + ),
988 + ),
989 + );
990 + }
991 +}
lib/features/sidebar/sidebar_widget.dart
+15 -6
@@ -32,11 +32,7 @@ class SidebarWidget extends ConsumerWidget {
32 width: 300,
33 decoration: BoxDecoration(
34 color: theme.scaffoldBackgroundColor,
35 - border: Border(
36 - right: BorderSide(
37 - color: theme.colorScheme.outline,
38 - ),
39 - ),
35 + border: Border(right: BorderSide(color: theme.colorScheme.outline)),
36 ),
37 child: SafeArea(
38 child: Column(
@@ -49,7 +45,9 @@ class SidebarWidget extends ConsumerWidget {
45 Expanded(
46 child: groupedConversations.when(
47 data: (grouped) => grouped.isEmpty
52 - ? ConversationEmptyState(isSearching: searchQuery.isNotEmpty)
48 + ? ConversationEmptyState(
49 + isSearching: searchQuery.isNotEmpty,
50 + )
51 : ConversationList(
52 groupedConversations: grouped,
53 activeConversation: activeConversation,
@@ -90,6 +88,17 @@ class SidebarWidget extends ConsumerWidget {
88 context.go(AppRoutes.servers);
89 },
90 ),
91 + DrawerNavItem(
92 + iconData: HugeIcons.strokeRoundedSmartPhone01,
93 + label: 'Local Models',
94 + isSelected: location.startsWith(AppRoutes.onDeviceModels),
95 + onTap: () {
96 + if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
97 + Navigator.pop(context);
98 + }
99 + context.go(AppRoutes.onDeviceModels);
100 + },
101 + ),
102 DrawerNavItem(
103 iconData: HugeIcons.strokeRoundedCompass01,
104 label: 'Personas',
lib/main.dart
+1
@@ -1,5 +1,6 @@
1 import 'package:flutter/material.dart';
2 import 'package:flutter_riverpod/flutter_riverpod.dart';
3 +
4 import 'package:shared_preferences/shared_preferences.dart';
5
6 import 'app.dart';
pubspec.lock
+8
@@ -326,6 +326,14 @@ packages:
326 url: "https://pub.dev"
327 source: hosted
328 version: "6.0.0"
329 + flutter_litert_lm:
330 + dependency: "direct main"
331 + description:
332 + name: flutter_litert_lm
333 + sha256: "222fa9e669c6864c901049930b5639c7276a18b508a38f1fa8697427bde2abb0"
334 + url: "https://pub.dev"
335 + source: hosted
336 + version: "0.3.0"
337 flutter_localizations:
338 dependency: transitive
339 description: flutter
pubspec.yaml
+1
@@ -53,6 +53,7 @@ dependencies:
53 objectbox: ^5.3.1
54 objectbox_flutter_libs: ^5.3.1
55 path: ^1.9.1
56 + flutter_litert_lm: ^0.3.0
57
58 dev_dependencies:
59 flutter_test: