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
+}