@tduncan / Cortana / commits / 8f3419b

feat: add support for configuring a default model and display default badge on model tiles #39

abdulmominsakib committed Aug 3, 2026 at 23:57 UTC 8f3419b280d90786916e7adbfa9dd7c5ed8f2c8b
23 files changed +734 -17
lib/core/providers/app_providers.dart
+12
@@ -80,6 +80,18 @@ class SettingsNotifier extends Notifier<AppSettings> {
80 _update(state.copyWith(streamingEnabled: value));
81 void setDefaultPersona(String? id) =>
82 _update(state.copyWith(defaultPersonaId: id));
83 + void setDefaultModel({String? serverId, String? modelId}) => _update(
84 + state.copyWith(
85 + defaultModelServerId: serverId,
86 + defaultModelId: modelId,
87 + ),
88 + );
89 + void clearDefaultModel() => _update(
90 + state.copyWith(
91 + defaultModelServerId: null,
92 + defaultModelId: null,
93 + ),
94 + );
95 void setHasCompletedOnboarding(bool value) =>
96 _update(state.copyWith(hasCompletedOnboarding: value));
97 void setMcpEnabled(bool value) => _update(state.copyWith(mcpEnabled: value));
lib/core/services/data_backup_service.dart
+2
@@ -30,6 +30,8 @@ class DataBackupService {
30 'autoGenerateTitle',
31 'streamingEnabled',
32 'defaultPersonaId',
33 + 'defaultModelId',
34 + 'defaultModelServerId',
35 'mcpEnabled',
36 'newChatMcpEnabled',
37 'codeThemeDark',
lib/features/chat/providers/model_selection_providers.dart
+94 -6
@@ -2,8 +2,13 @@ import 'dart:async';
2
3 import 'package:flutter_riverpod/flutter_riverpod.dart';
4 import 'package:localmind/core/models/enums.dart';
5 +import 'package:localmind/core/providers/app_providers.dart';
6 import 'package:localmind/core/providers/service_providers.dart';
7 +import 'package:localmind/features/chat/providers/chat_params_providers.dart';
8 +import 'package:localmind/features/chat/providers/model_loading_providers.dart';
9 import 'package:localmind/features/models/data/models/model_info.dart';
10 +import 'package:localmind/features/models/utils/model_instance_utils.dart';
11 +import 'package:localmind/features/servers/data/models/server.dart';
12 import 'package:localmind/features/servers/providers/server_providers.dart';
13 import 'package:localmind/features/on_device/providers/on_device_providers.dart';
14
@@ -29,6 +34,13 @@ final autoSelectFirstLoadedModelProvider = FutureProvider<void>((ref) async {
34 final activeServer = ref.watch(activeServerProvider);
35 final status = ref.watch(connectionStatusProvider);
36
37 + // Re-run whenever the configured default model changes so a newly chosen
38 + // default is applied to the next new chat (the notifier only seeds the
39 + // selection when no model is already picked).
40 + ref.watch(
41 + settingsProvider.select((s) => (s.defaultModelId, s.defaultModelServerId)),
42 + );
43 +
44 if (activeServer == null) {
45 await Future.value();
46 ref.read(selectedModelProvider.notifier).clear();
@@ -46,8 +58,18 @@ final autoSelectFirstLoadedModelProvider = FutureProvider<void>((ref) async {
58 return;
59 }
60
49 - if (activeServer.type == ServerType.openRouter ||
50 - activeServer.type == ServerType.openAICompatible) {
61 + final settings = ref.read(settingsProvider);
62 + final defaultModelId = settings.defaultModelId;
63 + final defaultModelServerId = settings.defaultModelServerId;
64 + final hasDefaultForServer =
65 + defaultModelId != null && defaultModelServerId == activeServer.id;
66 +
67 + // Cloud providers have no "loaded" notion, so without a configured
68 + // default there's nothing to auto-select for them (avoids an extra model
69 + // list fetch for users who never set one).
70 + if (!hasDefaultForServer &&
71 + (activeServer.type == ServerType.openRouter ||
72 + activeServer.type == ServerType.openAICompatible)) {
73 return;
74 }
75
@@ -63,17 +85,53 @@ final autoSelectFirstLoadedModelProvider = FutureProvider<void>((ref) async {
85 loadedModels = await apiService.fetchRunningModels(activeServer);
86 }
87
66 - if (loadedModels.isEmpty) return;
67 -
88 final availableModels = await ref.read(
89 availableModelsProvider(activeServer.id).future,
90 );
71 - if (availableModels.isEmpty) return;
91
92 final typedModels = availableModels.cast<ModelInfo>();
93 + if (typedModels.isEmpty) return;
94 +
95 + if (hasDefaultForServer) {
96 + final defaultModel = typedModels
97 + .where((m) => m.id == defaultModelId)
98 + .firstOrNull;
99 + if (defaultModel != null) {
100 + if (activeServer.type == ServerType.onDevice) {
101 + // On-device inference runs one engine instance at a time, so only
102 + // auto-select the default when it's the model the engine already
103 + // has loaded; otherwise fall through to the loaded-model logic.
104 + if (isModelKeyLoaded(loadedModels, defaultModel.id)) {
105 + ref.read(selectedModelProvider.notifier).setModel(defaultModel);
106 + return;
107 + }
108 + } else {
109 + // LM Studio models must be running before a request can use them;
110 + // if the default isn't loaded yet, load it exactly like tapping it
111 + // in the model picker would so the user can start chatting right
112 + // away. Ollama and cloud providers load on demand, so selecting is
113 + // enough for them.
114 + if (!isModelKeyLoaded(loadedModels, defaultModel.id) &&
115 + activeServer.type == ServerType.lmStudio) {
116 + await _loadDefaultModel(ref, activeServer, defaultModel);
117 + }
118 + ref.read(selectedModelProvider.notifier).setModel(defaultModel);
119 + return;
120 + }
121 + }
122 + }
123 +
124 + // Cloud providers fall back to no selection when the configured default
125 + // isn't among the available models.
126 + if (activeServer.type == ServerType.openRouter ||
127 + activeServer.type == ServerType.openAICompatible) {
128 + return;
129 + }
130 +
131 + if (loadedModels.isEmpty) return;
132
133 final firstLoadedModel = typedModels
76 - .where((m) => loadedModels.contains(m.id))
134 + .where((m) => isModelKeyLoaded(loadedModels, m.id))
135 .firstOrNull;
136
137 if (firstLoadedModel != null) {
@@ -84,6 +142,36 @@ final autoSelectFirstLoadedModelProvider = FutureProvider<void>((ref) async {
142 }
143 });
144
145 +/// Loads an LM Studio model via the server API and reflects the busy state in
146 +/// [modelLoadingProvider], mirroring what tapping a model in the picker does.
147 +Future<void> _loadDefaultModel(
148 + Ref ref,
149 + Server activeServer,
150 + ModelInfo model,
151 +) async {
152 + final apiService = ref.read(serverApiServiceProvider);
153 + final settings = ref.read(settingsProvider);
154 + final contextLength = ref.read(chatParamsProvider).contextLength;
155 +
156 + ref.read(modelLoadingProvider.notifier).setLoading(model.id);
157 + try {
158 + if (settings.unloadModelsBeforeLoad) {
159 + final instances = await ref.read(
160 + loadedModelsProvider(activeServer).future,
161 + );
162 + await apiService.unloadAllInstances(activeServer, instances);
163 + }
164 + await apiService.loadModelWithInstanceId(
165 + activeServer,
166 + model.id,
167 + contextLength: contextLength,
168 + );
169 + ref.invalidate(loadedModelsProvider(activeServer));
170 + } finally {
171 + ref.read(modelLoadingProvider.notifier).setLoaded();
172 + }
173 +}
174 +
175 /// The actual context length the active model was loaded with, fetched
176 /// live from the server (currently only LM Studio reports this — see
177 /// [ServerApiService.fetchLoadedContextLength]). Re-fetches whenever the
lib/features/cloud_sync/data/models/cloud_sync_models.dart
+2
@@ -219,6 +219,8 @@ class CloudSyncSnapshot {
219 'autoGenerateTitle',
220 'streamingEnabled',
221 'defaultPersonaId',
222 + 'defaultModelId',
223 + 'defaultModelServerId',
224 'mcpEnabled',
225 'newChatMcpEnabled',
226 'codeThemeDark',
lib/features/models/components/model_list.dart
+38
@@ -100,6 +100,11 @@ class _ModelListState extends ConsumerState<ModelList> {
100 context: context,
101 isScrollControlled: true,
102 builder: (ctx) {
103 + final settings = ref.read(settingsProvider);
104 + final isDefaultModel =
105 + settings.defaultModelServerId == widget.serverId &&
106 + settings.defaultModelId == model.id;
107 +
108 return SafeArea(
109 child: Padding(
110 padding: EdgeInsets.only(
@@ -120,6 +125,32 @@ class _ModelListState extends ConsumerState<ModelList> {
125 ),
126 ),
127 const SizedBox(height: 16),
128 + ListTile(
129 + contentPadding: EdgeInsets.zero,
130 + leading: HugeIcon(
131 + icon: isDefaultModel
132 + ? HugeIcons.strokeRoundedDelete01
133 + : HugeIcons.strokeRoundedRocket,
134 + color: isDefaultModel ? Colors.red[400] : Colors.green[600],
135 + ),
136 + title: Text(
137 + isDefaultModel
138 + ? l10n.model_clear_default
139 + : l10n.model_set_default,
140 + ),
141 + onTap: () async {
142 + final notifier = ref.read(settingsProvider.notifier);
143 + if (isDefaultModel) {
144 + notifier.clearDefaultModel();
145 + } else {
146 + notifier.setDefaultModel(
147 + serverId: widget.serverId,
148 + modelId: model.id,
149 + );
150 + }
151 + if (ctx.mounted) Navigator.pop(ctx);
152 + },
153 + ),
154 ListTile(
155 contentPadding: EdgeInsets.zero,
156 leading: HugeIcon(icon:
@@ -325,6 +356,13 @@ class _ModelListState extends ConsumerState<ModelList> {
356 isDark: widget.isDark,
357 isLoading: modelLoading.isLoading && modelLoading.modelId == model.id,
358 isFavorite: modelMeta?.isFavorite ?? false,
359 + isDefault: ref.watch(
360 + settingsProvider.select(
361 + (s) =>
362 + s.defaultModelServerId == widget.serverId &&
363 + s.defaultModelId == model.id,
364 + ),
365 + ),
366 note: modelMeta?.note,
367 onLongPress: () => _showModelOptions(context, model),
368 onTap: () async {
lib/features/models/components/model_tile.dart
+27
@@ -19,6 +19,7 @@ class ModelTile extends StatelessWidget {
19 this.onLongPress,
20 this.onUnload,
21 this.isFavorite = false,
22 + this.isDefault = false,
23 this.note,
24 this.isLoading = false,
25 });
@@ -31,6 +32,7 @@ class ModelTile extends StatelessWidget {
32 final VoidCallback? onLongPress;
33 final Future<void> Function()? onUnload;
34 final bool isFavorite;
35 + final bool isDefault;
36 final String? note;
37 final bool isLoading;
38
@@ -75,6 +77,31 @@ class ModelTile extends StatelessWidget {
77 ),
78 const SizedBox(width: 6),
79 ],
80 + if (isDefault) ...[
81 + Container(
82 + padding: const EdgeInsets.symmetric(
83 + horizontal: 6,
84 + vertical: 1,
85 + ),
86 + decoration: BoxDecoration(
87 + color: isDark
88 + ? AppColors.darkAccent.withValues(alpha: 0.18)
89 + : AppColors.lightAccent.withValues(alpha: 0.15),
90 + borderRadius: BorderRadius.circular(4),
91 + ),
92 + child: Text(
93 + l10n.model_default_badge,
94 + style: TextStyle(
95 + fontSize: 10,
96 + fontWeight: FontWeight.w600,
97 + color: isDark
98 + ? AppColors.darkAccent
99 + : AppColors.lightAccent,
100 + ),
101 + ),
102 + ),
103 + const SizedBox(width: 6),
104 + ],
105 Expanded(
106 child: Text(
107 model.displayName,
lib/features/settings/data/models/app_settings.dart
+16
@@ -27,6 +27,8 @@ class AppSettings {
27 final bool autoGenerateTitle;
28 final bool streamingEnabled;
29 final String? defaultPersonaId;
30 + final String? defaultModelId;
31 + final String? defaultModelServerId;
32 final bool hasCompletedOnboarding;
33 final bool hasAskedForNotifications;
34 final bool mcpEnabled;
@@ -72,6 +74,8 @@ class AppSettings {
74 this.autoGenerateTitle = true,
75 this.streamingEnabled = true,
76 this.defaultPersonaId,
77 + this.defaultModelId,
78 + this.defaultModelServerId,
79 this.hasCompletedOnboarding = false,
80 this.hasAskedForNotifications = false,
81 this.mcpEnabled = false,
@@ -118,6 +122,8 @@ class AppSettings {
122 bool? autoGenerateTitle,
123 bool? streamingEnabled,
124 String? defaultPersonaId,
125 + Object? defaultModelId = _unset,
126 + Object? defaultModelServerId = _unset,
127 bool? hasCompletedOnboarding,
128 bool? hasAskedForNotifications,
129 bool? mcpEnabled,
@@ -164,6 +170,12 @@ class AppSettings {
170 autoGenerateTitle: autoGenerateTitle ?? this.autoGenerateTitle,
171 streamingEnabled: streamingEnabled ?? this.streamingEnabled,
172 defaultPersonaId: defaultPersonaId ?? this.defaultPersonaId,
173 + defaultModelId: identical(defaultModelId, _unset)
174 + ? this.defaultModelId
175 + : defaultModelId as String?,
176 + defaultModelServerId: identical(defaultModelServerId, _unset)
177 + ? this.defaultModelServerId
178 + : defaultModelServerId as String?,
179 hasCompletedOnboarding:
180 hasCompletedOnboarding ?? this.hasCompletedOnboarding,
181 hasAskedForNotifications:
@@ -231,6 +243,8 @@ class AppSettings {
243 'autoGenerateTitle': autoGenerateTitle,
244 'streamingEnabled': streamingEnabled,
245 'defaultPersonaId': defaultPersonaId,
246 + 'defaultModelId': defaultModelId,
247 + 'defaultModelServerId': defaultModelServerId,
248 'hasCompletedOnboarding': hasCompletedOnboarding,
249 'hasAskedForNotifications': hasAskedForNotifications,
250 'mcpEnabled': mcpEnabled,
@@ -280,6 +294,8 @@ class AppSettings {
294 autoGenerateTitle: map['autoGenerateTitle'] ?? true,
295 streamingEnabled: map['streamingEnabled'] ?? true,
296 defaultPersonaId: map['defaultPersonaId'],
297 + defaultModelId: map['defaultModelId'] as String?,
298 + defaultModelServerId: map['defaultModelServerId'] as String?,
299 hasCompletedOnboarding: map['hasCompletedOnboarding'] ?? false,
300 hasAskedForNotifications: map['hasAskedForNotifications'] ?? false,
301 mcpEnabled: map['mcpEnabled'] ?? false,
lib/features/settings/views/settings_screen.dart
+40
@@ -16,6 +16,7 @@ import '../../../core/theme/app_theme.dart';
16 import '../../../core/utils/system_insets.dart';
17 import '../../chat/utils/image_upload_utils.dart';
18 import '../../conversations/providers/conversation_providers.dart';
19 +import '../../models/data/models/model_info.dart';
20 import '../../on_device/providers/on_device_providers.dart';
21 import '../../personas/providers/personas_providers.dart';
22 import '../../servers/providers/server_providers.dart';
@@ -39,6 +40,22 @@ class SettingsViews extends ConsumerWidget {
40 .map((persona) => (persona.id, '${persona.emoji} ${persona.name}'))
41 .toList();
42
43 + // The default model dropdown lists models from the server used as the
44 + // default (the configured default server, falling back to the first one).
45 + final defaultServerId = settings.defaultServerId ??
46 + (servers.isNotEmpty ? servers.first.$1 : null);
47 + final availableModels = defaultServerId != null
48 + ? (ref.watch(availableModelsProvider(defaultServerId)).value ??
49 + const <dynamic>[])
50 + .cast<ModelInfo>()
51 + : <ModelInfo>[];
52 + final models = availableModels
53 + .map((m) => (m.id, m.displayName))
54 + .toList();
55 + final isDefaultModelForServer =
56 + settings.defaultModelServerId != null &&
57 + settings.defaultModelServerId == defaultServerId;
58 +
59 final isDark = Theme.of(context).brightness == Brightness.dark;
60 final topPadding = MediaQuery.of(context).padding.top;
61
@@ -394,6 +411,18 @@ class SettingsViews extends ConsumerWidget {
411 .setDefaultPersona(value),
412 icon: HugeIcons.strokeRoundedRobot01,
413 ),
414 + _DropdownSetting(
415 + label: l10n.settings_default_model,
416 + description: l10n.settings_default_model_desc,
417 + currentValue: isDefaultModelForServer
418 + ? settings.defaultModelId
419 + : null,
420 + items: models,
421 + onChanged: (value) => ref
422 + .read(settingsProvider.notifier)
423 + .setDefaultModel(serverId: defaultServerId, modelId: value),
424 + icon: HugeIcons.strokeRoundedSmartPhone01,
425 + ),
426 const SizedBox(height: 8),
427 _SectionActionButton(
428 icon: HugeIcons.strokeRoundedRefresh,
@@ -1618,6 +1647,7 @@ class _DropdownSetting extends StatelessWidget {
1647 required this.items,
1648 required this.onChanged,
1649 required this.icon,
1650 + this.description,
1651 });
1652
1653 final String label;
@@ -1625,6 +1655,7 @@ class _DropdownSetting extends StatelessWidget {
1655 final List<(String id, String name)> items;
1656 final ValueChanged<String?> onChanged;
1657 final List<List<dynamic>> icon;
1658 + final String? description;
1659
1660 @override
1661 Widget build(BuildContext context) {
@@ -1682,6 +1713,15 @@ class _DropdownSetting extends StatelessWidget {
1713 ),
1714 ),
1715 ),
1716 + if (description != null) ...[
1717 + const SizedBox(height: 8),
1718 + Text(
1719 + description!,
1720 + style: theme.textTheme.bodySmall?.copyWith(
1721 + color: _mutedColor(context),
1722 + ),
1723 + ),
1724 + ],
1725 ],
1726 ),
1727 );
lib/l10n/app_en.arb
+20
@@ -756,6 +756,14 @@
756 "@settings_default_persona": {
757 "description": "Settings section header for default persona"
758 },
759 + "settings_default_model": "Default Model",
760 + "@settings_default_model": {
761 + "description": "Settings label for the default model used in new chats"
762 + },
763 + "settings_default_model_desc": "Automatically selected when you start a new chat.",
764 + "@settings_default_model_desc": {
765 + "description": "Helper text for the default model setting"
766 + },
767 "settings_privacy": "Privacy",
768 "@settings_privacy": {
769 "description": "Settings section header for privacy"
@@ -2798,6 +2806,18 @@
2806 "@model_favorite_toggle": {
2807 "description": "Long-press menu option to favorite a model"
2808 },
2809 + "model_set_default": "Set as default model",
2810 + "@model_set_default": {
2811 + "description": "Long-press menu option to make a model the default for new chats"
2812 + },
2813 + "model_clear_default": "Remove as default model",
2814 + "@model_clear_default": {
2815 + "description": "Long-press menu option to clear the default model"
2816 + },
2817 + "model_default_badge": "Default",
2818 + "@model_default_badge": {
2819 + "description": "Badge shown on a model that is set as the default for new chats"
2820 + },
2821 "model_note_label": "Note",
2822 "@model_note_label": {
2823 "description": "Label for model note text field"
lib/l10n/app_localizations.dart
+30
@@ -1101,6 +1101,18 @@ abstract class AppLocalizations {
1101 /// **'Default Persona'**
1102 String get settings_default_persona;
1103
1104 + /// Settings label for the default model used in new chats
1105 + ///
1106 + /// In en, this message translates to:
1107 + /// **'Default Model'**
1108 + String get settings_default_model;
1109 +
1110 + /// Helper text for the default model setting
1111 + ///
1112 + /// In en, this message translates to:
1113 + /// **'Automatically selected when you start a new chat.'**
1114 + String get settings_default_model_desc;
1115 +
1116 /// Settings section header for privacy
1117 ///
1118 /// In en, this message translates to:
@@ -4185,6 +4197,24 @@ abstract class AppLocalizations {
4197 /// **'Toggle favorite'**
4198 String get model_favorite_toggle;
4199
4200 + /// Long-press menu option to make a model the default for new chats
4201 + ///
4202 + /// In en, this message translates to:
4203 + /// **'Set as default model'**
4204 + String get model_set_default;
4205 +
4206 + /// Long-press menu option to clear the default model
4207 + ///
4208 + /// In en, this message translates to:
4209 + /// **'Remove as default model'**
4210 + String get model_clear_default;
4211 +
4212 + /// Badge shown on a model that is set as the default for new chats
4213 + ///
4214 + /// In en, this message translates to:
4215 + /// **'Default'**
4216 + String get model_default_badge;
4217 +
4218 /// Label for model note text field
4219 ///
4220 /// In en, this message translates to:
lib/l10n/app_localizations_ar.dart
+16
@@ -547,6 +547,13 @@ class AppLocalizationsAr extends AppLocalizations {
547 @override
548 String get settings_default_persona => 'الشخصية الافتراضية';
549
550 + @override
551 + String get settings_default_model => 'Default Model';
552 +
553 + @override
554 + String get settings_default_model_desc =>
555 + 'Automatically selected when you start a new chat.';
556 +
557 @override
558 String get settings_privacy => 'الخصوصية';
559
@@ -2220,6 +2227,15 @@ class AppLocalizationsAr extends AppLocalizations {
2227 @override
2228 String get model_favorite_toggle => 'تبديل المفضلة';
2229
2230 + @override
2231 + String get model_set_default => 'Set as default model';
2232 +
2233 + @override
2234 + String get model_clear_default => 'Remove as default model';
2235 +
2236 + @override
2237 + String get model_default_badge => 'Default';
2238 +
2239 @override
2240 String get model_note_label => 'ملاحظة';
2241
lib/l10n/app_localizations_bn.dart
+16
@@ -548,6 +548,13 @@ class AppLocalizationsBn extends AppLocalizations {
548 @override
549 String get settings_default_persona => 'ডিফল্ট পার্সোনা';
550
551 + @override
552 + String get settings_default_model => 'Default Model';
553 +
554 + @override
555 + String get settings_default_model_desc =>
556 + 'Automatically selected when you start a new chat.';
557 +
558 @override
559 String get settings_privacy => 'গোপনীয়তা';
560
@@ -2237,6 +2244,15 @@ class AppLocalizationsBn extends AppLocalizations {
2244 @override
2245 String get model_favorite_toggle => 'প্রিয় টগল করুন';
2246
2247 + @override
2248 + String get model_set_default => 'Set as default model';
2249 +
2250 + @override
2251 + String get model_clear_default => 'Remove as default model';
2252 +
2253 + @override
2254 + String get model_default_badge => 'Default';
2255 +
2256 @override
2257 String get model_note_label => 'নোট';
2258
lib/l10n/app_localizations_en.dart
+16
@@ -544,6 +544,13 @@ class AppLocalizationsEn extends AppLocalizations {
544 @override
545 String get settings_default_persona => 'Default Persona';
546
547 + @override
548 + String get settings_default_model => 'Default Model';
549 +
550 + @override
551 + String get settings_default_model_desc =>
552 + 'Automatically selected when you start a new chat.';
553 +
554 @override
555 String get settings_privacy => 'Privacy';
556
@@ -2222,6 +2229,15 @@ class AppLocalizationsEn extends AppLocalizations {
2229 @override
2230 String get model_favorite_toggle => 'Toggle favorite';
2231
2232 + @override
2233 + String get model_set_default => 'Set as default model';
2234 +
2235 + @override
2236 + String get model_clear_default => 'Remove as default model';
2237 +
2238 + @override
2239 + String get model_default_badge => 'Default';
2240 +
2241 @override
2242 String get model_note_label => 'Note';
2243
lib/l10n/app_localizations_es.dart
+16
@@ -548,6 +548,13 @@ class AppLocalizationsEs extends AppLocalizations {
548 @override
549 String get settings_default_persona => 'Personaje predeterminado';
550
551 + @override
552 + String get settings_default_model => 'Default Model';
553 +
554 + @override
555 + String get settings_default_model_desc =>
556 + 'Automatically selected when you start a new chat.';
557 +
558 @override
559 String get settings_privacy => 'Privacidad';
560
@@ -2244,6 +2251,15 @@ class AppLocalizationsEs extends AppLocalizations {
2251 @override
2252 String get model_favorite_toggle => 'Alternar favorito';
2253
2254 + @override
2255 + String get model_set_default => 'Set as default model';
2256 +
2257 + @override
2258 + String get model_clear_default => 'Remove as default model';
2259 +
2260 + @override
2261 + String get model_default_badge => 'Default';
2262 +
2263 @override
2264 String get model_note_label => 'Nota';
2265
lib/l10n/app_localizations_fr.dart
+16
@@ -546,6 +546,13 @@ class AppLocalizationsFr extends AppLocalizations {
546 @override
547 String get settings_default_persona => 'Default Persona';
548
549 + @override
550 + String get settings_default_model => 'Default Model';
551 +
552 + @override
553 + String get settings_default_model_desc =>
554 + 'Automatically selected when you start a new chat.';
555 +
556 @override
557 String get settings_privacy => 'Privacy';
558
@@ -2227,6 +2234,15 @@ class AppLocalizationsFr extends AppLocalizations {
2234 @override
2235 String get model_favorite_toggle => 'Toggle favorite';
2236
2237 + @override
2238 + String get model_set_default => 'Set as default model';
2239 +
2240 + @override
2241 + String get model_clear_default => 'Remove as default model';
2242 +
2243 + @override
2244 + String get model_default_badge => 'Default';
2245 +
2246 @override
2247 String get model_note_label => 'Nonte';
2248
lib/l10n/app_localizations_hi.dart
+16
@@ -549,6 +549,13 @@ class AppLocalizationsHi extends AppLocalizations {
549 @override
550 String get settings_default_persona => 'डिफ़ॉल्ट चरित्र';
551
552 + @override
553 + String get settings_default_model => 'Default Model';
554 +
555 + @override
556 + String get settings_default_model_desc =>
557 + 'Automatically selected when you start a new chat.';
558 +
559 @override
560 String get settings_privacy => 'गोपनीयता';
561
@@ -2232,6 +2239,15 @@ class AppLocalizationsHi extends AppLocalizations {
2239 @override
2240 String get model_favorite_toggle => 'पसंदीदा टॉगल करें';
2241
2242 + @override
2243 + String get model_set_default => 'Set as default model';
2244 +
2245 + @override
2246 + String get model_clear_default => 'Remove as default model';
2247 +
2248 + @override
2249 + String get model_default_badge => 'Default';
2250 +
2251 @override
2252 String get model_note_label => 'टिप्पणी (Note)';
2253
lib/l10n/app_localizations_it.dart
+16
@@ -550,6 +550,13 @@ class AppLocalizationsIt extends AppLocalizations {
550 @override
551 String get settings_default_persona => 'Persona predefinita';
552
553 + @override
554 + String get settings_default_model => 'Default Model';
555 +
556 + @override
557 + String get settings_default_model_desc =>
558 + 'Automatically selected when you start a new chat.';
559 +
560 @override
561 String get settings_privacy => 'Privacy';
562
@@ -2245,6 +2252,15 @@ class AppLocalizationsIt extends AppLocalizations {
2252 @override
2253 String get model_favorite_toggle => 'Attiva/disattiva preferito';
2254
2255 + @override
2256 + String get model_set_default => 'Set as default model';
2257 +
2258 + @override
2259 + String get model_clear_default => 'Remove as default model';
2260 +
2261 + @override
2262 + String get model_default_badge => 'Default';
2263 +
2264 @override
2265 String get model_note_label => 'Nota';
2266
lib/l10n/app_localizations_ja.dart
+16
@@ -539,6 +539,13 @@ class AppLocalizationsJa extends AppLocalizations {
539 @override
540 String get settings_default_persona => 'デフォルトペルソナ';
541
542 + @override
543 + String get settings_default_model => 'Default Model';
544 +
545 + @override
546 + String get settings_default_model_desc =>
547 + 'Automatically selected when you start a new chat.';
548 +
549 @override
550 String get settings_privacy => 'プライバシー';
551
@@ -2194,6 +2201,15 @@ class AppLocalizationsJa extends AppLocalizations {
2201 @override
2202 String get model_favorite_toggle => 'お気に入りの切り替え';
2203
2204 + @override
2205 + String get model_set_default => 'Set as default model';
2206 +
2207 + @override
2208 + String get model_clear_default => 'Remove as default model';
2209 +
2210 + @override
2211 + String get model_default_badge => 'Default';
2212 +
2213 @override
2214 String get model_note_label => 'メモ';
2215
lib/l10n/app_localizations_ru.dart
+16
@@ -548,6 +548,13 @@ class AppLocalizationsRu extends AppLocalizations {
548 @override
549 String get settings_default_persona => 'Персона по умолчанию';
550
551 + @override
552 + String get settings_default_model => 'Default Model';
553 +
554 + @override
555 + String get settings_default_model_desc =>
556 + 'Automatically selected when you start a new chat.';
557 +
558 @override
559 String get settings_privacy => 'Конфиденциальность';
560
@@ -2244,6 +2251,15 @@ class AppLocalizationsRu extends AppLocalizations {
2251 @override
2252 String get model_favorite_toggle => 'Добавить в избранное';
2253
2254 + @override
2255 + String get model_set_default => 'Set as default model';
2256 +
2257 + @override
2258 + String get model_clear_default => 'Remove as default model';
2259 +
2260 + @override
2261 + String get model_default_badge => 'Default';
2262 +
2263 @override
2264 String get model_note_label => 'Заметка';
2265
lib/l10n/app_localizations_tr.dart
+16
@@ -548,6 +548,13 @@ class AppLocalizationsTr extends AppLocalizations {
548 @override
549 String get settings_default_persona => 'Varsayılan Persona';
550
551 + @override
552 + String get settings_default_model => 'Default Model';
553 +
554 + @override
555 + String get settings_default_model_desc =>
556 + 'Automatically selected when you start a new chat.';
557 +
558 @override
559 String get settings_privacy => 'Gizlilik';
560
@@ -2241,6 +2248,15 @@ class AppLocalizationsTr extends AppLocalizations {
2248 @override
2249 String get model_favorite_toggle => 'Toggle favorite';
2250
2251 + @override
2252 + String get model_set_default => 'Set as default model';
2253 +
2254 + @override
2255 + String get model_clear_default => 'Remove as default model';
2256 +
2257 + @override
2258 + String get model_default_badge => 'Default';
2259 +
2260 @override
2261 String get model_note_label => 'Note';
2262
lib/l10n/app_localizations_zh.dart
+16
@@ -535,6 +535,13 @@ class AppLocalizationsZh extends AppLocalizations {
535 @override
536 String get settings_default_persona => '默认角色';
537
538 + @override
539 + String get settings_default_model => 'Default Model';
540 +
541 + @override
542 + String get settings_default_model_desc =>
543 + 'Automatically selected when you start a new chat.';
544 +
545 @override
546 String get settings_privacy => '隐私';
547
@@ -2183,6 +2190,15 @@ class AppLocalizationsZh extends AppLocalizations {
2190 @override
2191 String get model_favorite_toggle => '切换收藏';
2192
2193 + @override
2194 + String get model_set_default => 'Set as default model';
2195 +
2196 + @override
2197 + String get model_clear_default => 'Remove as default model';
2198 +
2199 + @override
2200 + String get model_default_badge => 'Default';
2201 +
2202 @override
2203 String get model_note_label => '备注';
2204
lib/l10n/untranslated.txt
+66 -11
@@ -1,5 +1,7 @@
1 {
2 "ar": [
3 + "settings_default_model",
4 + "settings_default_model_desc",
5 "import_mcp_json",
6 "import_mcp_json_dialog_title",
7 "import_mcp_json_instructions",
@@ -15,10 +17,15 @@
17 "api_key_hint_ollama_cloud",
18 "add_server_ollama_cloud_subtitle",
19 "add_server_openrouter_subtitle",
18 - "add_server_endpoint_subtitle"
20 + "add_server_endpoint_subtitle",
21 + "model_set_default",
22 + "model_clear_default",
23 + "model_default_badge"
24 ],
25
26 "bn": [
27 + "settings_default_model",
28 + "settings_default_model_desc",
29 "import_mcp_json",
30 "import_mcp_json_dialog_title",
31 "import_mcp_json_instructions",
@@ -34,10 +41,15 @@
41 "api_key_hint_ollama_cloud",
42 "add_server_ollama_cloud_subtitle",
43 "add_server_openrouter_subtitle",
37 - "add_server_endpoint_subtitle"
44 + "add_server_endpoint_subtitle",
45 + "model_set_default",
46 + "model_clear_default",
47 + "model_default_badge"
48 ],
49
50 "es": [
51 + "settings_default_model",
52 + "settings_default_model_desc",
53 "import_mcp_json",
54 "import_mcp_json_dialog_title",
55 "import_mcp_json_instructions",
@@ -53,19 +65,29 @@
65 "api_key_hint_ollama_cloud",
66 "add_server_ollama_cloud_subtitle",
67 "add_server_openrouter_subtitle",
56 - "add_server_endpoint_subtitle"
68 + "add_server_endpoint_subtitle",
69 + "model_set_default",
70 + "model_clear_default",
71 + "model_default_badge"
72 ],
73
74 "fr": [
75 + "settings_default_model",
76 + "settings_default_model_desc",
77 "import_mcp_json",
78 "import_mcp_json_dialog_title",
79 "import_mcp_json_instructions",
80 "import_mcp_json_placeholder",
81 "mcp_import_success",
65 - "mcp_import_failed"
82 + "mcp_import_failed",
83 + "model_set_default",
84 + "model_clear_default",
85 + "model_default_badge"
86 ],
87
88 "hi": [
89 + "settings_default_model",
90 + "settings_default_model_desc",
91 "import_mcp_json",
92 "import_mcp_json_dialog_title",
93 "import_mcp_json_instructions",
@@ -81,10 +103,15 @@
103 "api_key_hint_ollama_cloud",
104 "add_server_ollama_cloud_subtitle",
105 "add_server_openrouter_subtitle",
84 - "add_server_endpoint_subtitle"
106 + "add_server_endpoint_subtitle",
107 + "model_set_default",
108 + "model_clear_default",
109 + "model_default_badge"
110 ],
111
112 "it": [
113 + "settings_default_model",
114 + "settings_default_model_desc",
115 "import_mcp_json",
116 "import_mcp_json_dialog_title",
117 "import_mcp_json_instructions",
@@ -100,10 +127,15 @@
127 "api_key_hint_ollama_cloud",
128 "add_server_ollama_cloud_subtitle",
129 "add_server_openrouter_subtitle",
103 - "add_server_endpoint_subtitle"
130 + "add_server_endpoint_subtitle",
131 + "model_set_default",
132 + "model_clear_default",
133 + "model_default_badge"
134 ],
135
136 "ja": [
137 + "settings_default_model",
138 + "settings_default_model_desc",
139 "import_mcp_json",
140 "import_mcp_json_dialog_title",
141 "import_mcp_json_instructions",
@@ -119,10 +151,15 @@
151 "api_key_hint_ollama_cloud",
152 "add_server_ollama_cloud_subtitle",
153 "add_server_openrouter_subtitle",
122 - "add_server_endpoint_subtitle"
154 + "add_server_endpoint_subtitle",
155 + "model_set_default",
156 + "model_clear_default",
157 + "model_default_badge"
158 ],
159
160 "ru": [
161 + "settings_default_model",
162 + "settings_default_model_desc",
163 "import_mcp_json",
164 "import_mcp_json_dialog_title",
165 "import_mcp_json_instructions",
@@ -138,19 +175,29 @@
175 "api_key_hint_ollama_cloud",
176 "add_server_ollama_cloud_subtitle",
177 "add_server_openrouter_subtitle",
141 - "add_server_endpoint_subtitle"
178 + "add_server_endpoint_subtitle",
179 + "model_set_default",
180 + "model_clear_default",
181 + "model_default_badge"
182 ],
183
184 "tr": [
185 + "settings_default_model",
186 + "settings_default_model_desc",
187 "import_mcp_json",
188 "import_mcp_json_dialog_title",
189 "import_mcp_json_instructions",
190 "import_mcp_json_placeholder",
191 "mcp_import_success",
150 - "mcp_import_failed"
192 + "mcp_import_failed",
193 + "model_set_default",
194 + "model_clear_default",
195 + "model_default_badge"
196 ],
197
198 "zh": [
199 + "settings_default_model",
200 + "settings_default_model_desc",
201 "import_mcp_json",
202 "import_mcp_json_dialog_title",
203 "import_mcp_json_instructions",
@@ -166,10 +213,15 @@
213 "api_key_hint_ollama_cloud",
214 "add_server_ollama_cloud_subtitle",
215 "add_server_openrouter_subtitle",
169 - "add_server_endpoint_subtitle"
216 + "add_server_endpoint_subtitle",
217 + "model_set_default",
218 + "model_clear_default",
219 + "model_default_badge"
220 ],
221
222 "zh_TW": [
223 + "settings_default_model",
224 + "settings_default_model_desc",
225 "import_mcp_json",
226 "import_mcp_json_dialog_title",
227 "import_mcp_json_instructions",
@@ -185,6 +237,9 @@
237 "api_key_hint_ollama_cloud",
238 "add_server_ollama_cloud_subtitle",
239 "add_server_openrouter_subtitle",
188 - "add_server_endpoint_subtitle"
240 + "add_server_endpoint_subtitle",
241 + "model_set_default",
242 + "model_clear_default",
243 + "model_default_badge"
244 ]
245 }
test/features/chat/providers/model_selection_providers_test.dart new
+211
@@ -0,0 +1,211 @@
1 +import 'package:dio/dio.dart';
2 +import 'package:flutter_riverpod/flutter_riverpod.dart';
3 +import 'package:flutter_test/flutter_test.dart';
4 +import 'package:localmind/core/models/enums.dart';
5 +import 'package:localmind/core/providers/service_providers.dart';
6 +import 'package:localmind/core/providers/storage_providers.dart';
7 +import 'package:localmind/features/chat/providers/model_selection_providers.dart';
8 +import 'package:localmind/features/models/data/models/model_info.dart';
9 +import 'package:localmind/features/on_device/providers/on_device_providers.dart';
10 +import 'package:localmind/features/servers/data/models/server.dart';
11 +import 'package:localmind/features/servers/data/server_api_service.dart';
12 +import 'package:localmind/features/servers/providers/server_providers.dart';
13 +import 'package:localmind/features/settings/data/models/app_settings.dart';
14 +import 'package:shared_preferences/shared_preferences.dart';
15 +
16 +void main() {
17 + group('autoSelectFirstLoadedModelProvider', () {
18 + test('selects the configured default model when it is already loaded',
19 + () async {
20 + SharedPreferences.setMockInitialValues({
21 + 'appSettings': AppSettings(
22 + defaultModelId: 'model-a',
23 + defaultModelServerId: 'server-1',
24 + ).toJson(),
25 + });
26 + final prefs = await SharedPreferences.getInstance();
27 +
28 + final container = ProviderContainer(
29 + overrides: [
30 + sharedPreferencesProvider.overrideWithValue(prefs),
31 + activeServerProvider.overrideWith(_StubActiveServerNotifier.new),
32 + availableModelsProvider('server-1').overrideWith(
33 + (ref) async => _models(),
34 + ),
35 + ],
36 + );
37 + addTearDown(container.dispose);
38 +
39 + // Mark the on-device engine as having the default model loaded.
40 + container.read(onDeviceEngineProvider.notifier).state =
41 + const OnDeviceEngineState(
42 + status: OnDeviceEngineStatus.loaded,
43 + loadedModelId: 'model-a',
44 + );
45 +
46 + await container.read(autoSelectFirstLoadedModelProvider.future);
47 +
48 + final selected = container.read(selectedModelProvider);
49 + expect(selected?.id, 'model-a');
50 + });
51 +
52 + test('selects the default model even when it is not loaded for LM Studio',
53 + () async {
54 + SharedPreferences.setMockInitialValues({
55 + 'appSettings': AppSettings(
56 + defaultModelId: 'model-a',
57 + defaultModelServerId: 'server-1',
58 + ).toJson(),
59 + });
60 + final prefs = await SharedPreferences.getInstance();
61 +
62 + final api = _FakeServerApiService(loadedModelIds: const {});
63 + final container = ProviderContainer(
64 + overrides: [
65 + sharedPreferencesProvider.overrideWithValue(prefs),
66 + activeServerProvider.overrideWith(_StubLmStudioActiveServer.new),
67 + connectionStatusProvider.overrideWith(
68 + _StubConnectedStatusNotifier.new,
69 + ),
70 + availableModelsProvider('server-1').overrideWith(
71 + (ref) async => _models(),
72 + ),
73 + serverApiServiceProvider.overrideWithValue(api),
74 + ],
75 + );
76 + addTearDown(container.dispose);
77 +
78 + await container.read(autoSelectFirstLoadedModelProvider.future);
79 +
80 + final selected = container.read(selectedModelProvider);
81 + expect(selected?.id, 'model-a');
82 + expect(api.loadedIds, contains('model-a'));
83 + });
84 +
85 + test('falls back to the first loaded model when no default is set',
86 + () async {
87 + SharedPreferences.setMockInitialValues({});
88 + final prefs = await SharedPreferences.getInstance();
89 +
90 + final api = _FakeServerApiService(loadedModelIds: const {'model-b'});
91 + final container = ProviderContainer(
92 + overrides: [
93 + sharedPreferencesProvider.overrideWithValue(prefs),
94 + activeServerProvider.overrideWith(_StubLmStudioActiveServer.new),
95 + connectionStatusProvider.overrideWith(
96 + _StubConnectedStatusNotifier.new,
97 + ),
98 + availableModelsProvider('server-1').overrideWith(
99 + (ref) async => _models(),
100 + ),
101 + serverApiServiceProvider.overrideWithValue(api),
102 + ],
103 + );
104 + addTearDown(container.dispose);
105 +
106 + await container.read(autoSelectFirstLoadedModelProvider.future);
107 +
108 + final selected = container.read(selectedModelProvider);
109 + expect(selected?.id, 'model-b');
110 + });
111 +
112 + test('selects the default model for cloud providers', () async {
113 + SharedPreferences.setMockInitialValues({
114 + 'appSettings': AppSettings(
115 + defaultModelId: 'model-a',
116 + defaultModelServerId: 'server-1',
117 + ).toJson(),
118 + });
119 + final prefs = await SharedPreferences.getInstance();
120 +
121 + final container = ProviderContainer(
122 + overrides: [
123 + sharedPreferencesProvider.overrideWithValue(prefs),
124 + activeServerProvider.overrideWith(_StubCloudActiveServer.new),
125 + connectionStatusProvider.overrideWith(
126 + _StubConnectedStatusNotifier.new,
127 + ),
128 + availableModelsProvider('server-1').overrideWith(
129 + (ref) async => _models(),
130 + ),
131 + ],
132 + );
133 + addTearDown(container.dispose);
134 +
135 + await container.read(autoSelectFirstLoadedModelProvider.future);
136 +
137 + final selected = container.read(selectedModelProvider);
138 + expect(selected?.id, 'model-a');
139 + });
140 + });
141 +}
142 +List<ModelInfo> _models() => [
143 + ModelInfo(
144 + id: 'model-a',
145 + name: 'Model A',
146 + serverType: ServerType.lmStudio,
147 + serverId: 'server-1',
148 + ),
149 + ModelInfo(
150 + id: 'model-b',
151 + name: 'Model B',
152 + serverType: ServerType.lmStudio,
153 + serverId: 'server-1',
154 + ),
155 + ];
156 +
157 +class _StubActiveServerNotifier extends ActiveServerNotifier {
158 + @override
159 + Server? build() => _server(ServerType.onDevice);
160 +}
161 +
162 +class _StubLmStudioActiveServer extends ActiveServerNotifier {
163 + @override
164 + Server? build() => _server(ServerType.lmStudio);
165 +}
166 +
167 +class _StubCloudActiveServer extends ActiveServerNotifier {
168 + @override
169 + Server? build() => _server(ServerType.openAICompatible);
170 +}
171 +
172 +class _StubConnectedStatusNotifier extends ConnectionStatusNotifier {
173 + @override
174 + ConnectionStatus build() => ConnectionStatus.connected;
175 +}
176 +
177 +Server _server(ServerType type) {
178 + return Server(
179 + id: 'server-1',
180 + name: 'Server 1',
181 + type: type,
182 + host: 'localhost',
183 + port: 1234,
184 + createdAt: DateTime.utc(2026, 1, 1),
185 + lastConnectedAt: DateTime.utc(2026, 1, 1),
186 + status: ConnectionStatus.connected,
187 + );
188 +}
189 +
190 +class _FakeServerApiService extends ServerApiService {
191 + _FakeServerApiService({required this.loadedModelIds}) : super(Dio());
192 +
193 + final Set<String> loadedModelIds;
194 + final List<String> loadedIds = [];
195 +
196 + @override
197 + Future<Set<String>> fetchRunningModels(Server server) async => loadedModelIds;
198 +
199 + @override
200 + Future<String?> loadModelWithInstanceId(
201 + Server server,
202 + String modelId, {
203 + int? contextLength,
204 + }) async {
205 + loadedIds.add(modelId);
206 + return 'instance-1';
207 + }
208 +
209 + @override
210 + Future<void> unloadAllInstances(Server server, Set<String> instanceIds) async {}
211 +}