| 1 | 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/chat_reasoning_providers.dart'; |
| 9 | import 'package:localmind/features/chat/providers/model_loading_providers.dart'; |
| 10 | import 'package:localmind/features/models/data/models/model_info.dart'; |
| 11 | import 'package:localmind/features/models/utils/model_instance_utils.dart'; |
| 12 | import 'package:localmind/features/on_device/data/models/on_device_model.dart'; |
| 13 | import 'package:localmind/features/servers/data/models/server.dart'; |
| 14 | import 'package:localmind/features/servers/providers/server_providers.dart'; |
| 15 | import 'package:localmind/features/on_device/providers/on_device_providers.dart'; |
| 16 | import 'generation_session.dart' show ActiveGeneration; |
| 17 | |
| 18 | const modelSelectionRequiredMessage = 'You need to select a model first'; |
| 19 | const onDeviceBusyMessage = |
| 20 | 'The on-device model is still replying in another chat. Wait for it to ' |
| 21 | 'finish or stop it before sending here.'; |
| 22 | |
| 23 | class ActiveChatTarget { |
| 24 | final Server? server; |
| 25 | final ModelInfo? selectedModel; |
| 26 | final String? effectiveModelId; |
| 27 | final String modelLabel; |
| 28 | |
| 29 | const ActiveChatTarget({ |
| 30 | required this.server, |
| 31 | required this.selectedModel, |
| 32 | required this.effectiveModelId, |
| 33 | required this.modelLabel, |
| 34 | }); |
| 35 | |
| 36 | bool get isReady => server != null && effectiveModelId != null; |
| 37 | } |
| 38 | |
| 39 | /// The server/model pair that a message will actually use. |
| 40 | /// |
| 41 | /// A loaded on-device model remains usable even before the model picker has |
| 42 | /// recreated its [ModelInfo]. A native restored spec is deliberately excluded: |
| 43 | /// it is metadata for lazy loading, not proof that a model is in memory. |
| 44 | /// Remote servers retain their existing `default` model routing when no |
| 45 | /// explicit model is selected. |
| 46 | final activeChatTargetProvider = Provider<ActiveChatTarget>((ref) { |
| 47 | final server = ref.watch(activeServerProvider); |
| 48 | if (server == null) { |
| 49 | return const ActiveChatTarget( |
| 50 | server: null, |
| 51 | selectedModel: null, |
| 52 | effectiveModelId: null, |
| 53 | modelLabel: 'No model', |
| 54 | ); |
| 55 | } |
| 56 | |
| 57 | final selected = ref.watch(selectedModelProvider); |
| 58 | final selectedForServer = selected?.serverId == server.id ? selected : null; |
| 59 | if (server.isOnDevice) { |
| 60 | final engine = ref.watch(onDeviceEngineProvider); |
| 61 | if (selectedForServer != null && |
| 62 | engine.status == OnDeviceEngineStatus.loaded && |
| 63 | engine.loadedModelId == selectedForServer.id) { |
| 64 | return ActiveChatTarget( |
| 65 | server: server, |
| 66 | selectedModel: selectedForServer, |
| 67 | effectiveModelId: selectedForServer.id, |
| 68 | modelLabel: selectedForServer.displayName, |
| 69 | ); |
| 70 | } |
| 71 | |
| 72 | final modelId = engine.status == OnDeviceEngineStatus.loaded |
| 73 | ? engine.loadedModelId |
| 74 | : null; |
| 75 | final model = modelId == null |
| 76 | ? null |
| 77 | : OnDeviceModel.allCuratedModels |
| 78 | .where((candidate) => candidate.id == modelId) |
| 79 | .firstOrNull; |
| 80 | |
| 81 | return ActiveChatTarget( |
| 82 | server: server, |
| 83 | selectedModel: null, |
| 84 | effectiveModelId: modelId, |
| 85 | modelLabel: model?.name ?? modelId ?? 'No model', |
| 86 | ); |
| 87 | } |
| 88 | |
| 89 | if (selectedForServer != null) { |
| 90 | return ActiveChatTarget( |
| 91 | server: server, |
| 92 | selectedModel: selectedForServer, |
| 93 | effectiveModelId: selectedForServer.id, |
| 94 | modelLabel: selectedForServer.displayName, |
| 95 | ); |
| 96 | } |
| 97 | |
| 98 | return ActiveChatTarget( |
| 99 | server: server, |
| 100 | selectedModel: null, |
| 101 | effectiveModelId: 'default', |
| 102 | modelLabel: 'Default model', |
| 103 | ); |
| 104 | }); |
| 105 | |
| 106 | final selectedModelProvider = |
| 107 | NotifierProvider<SelectedModelNotifier, ModelInfo?>(() { |
| 108 | return SelectedModelNotifier(); |
| 109 | }); |
| 110 | |
| 111 | class SelectedModelNotifier extends Notifier<ModelInfo?> { |
| 112 | @override |
| 113 | ModelInfo? build() => null; |
| 114 | |
| 115 | void setModel(ModelInfo? model) { |
| 116 | state = model; |
| 117 | if (model != null) { |
| 118 | if (model.serverType == ServerType.onDevice) { |
| 119 | final engineState = ref.read(onDeviceEngineProvider); |
| 120 | if (engineState.loadedModelId != null && |
| 121 | engineState.status == OnDeviceEngineStatus.loaded) { |
| 122 | final activeServer = ref.read(activeServerProvider); |
| 123 | if (activeServer?.type != ServerType.onDevice) { |
| 124 | final servers = ref.read(serversProvider).value ?? []; |
| 125 | final onDeviceServer = servers |
| 126 | .where((s) => s.type == ServerType.onDevice) |
| 127 | .firstOrNull; |
| 128 | if (onDeviceServer != null) { |
| 129 | ref |
| 130 | .read(activeServerIdProvider.notifier) |
| 131 | .setActiveServer(onDeviceServer); |
| 132 | } |
| 133 | } |
| 134 | } |
| 135 | } |
| 136 | _normalizeReasoningForModel(model); |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | /// Snaps the reasoning config to a value the newly selected model actually |
| 141 | /// supports, so the next request never carries an invalid `reasoning_effort` |
| 142 | /// (e.g. `low` for a model that only advertises `high`). Also forces |
| 143 | /// reasoning on for mandatory-reasoning models, since they can't run with |
| 144 | /// it disabled. |
| 145 | void _normalizeReasoningForModel(ModelInfo model) { |
| 146 | final reasoning = ref.read(chatReasoningConfigProvider); |
| 147 | final notifier = ref.read(chatReasoningConfigProvider.notifier); |
| 148 | if (model.reasoningMandatory && !reasoning.enabled) { |
| 149 | notifier.setEnabled(true); |
| 150 | } |
| 151 | final supported = model.supportedReasoningEfforts; |
| 152 | if (supported == null || supported.isEmpty) return; |
| 153 | // Binary on/off models have no granular choice; leave the effort alone |
| 154 | // (the request layer sends literal "on"). |
| 155 | if (!hasGranularReasoningChoice(supported)) return; |
| 156 | final normalized = supported.map((e) => e.trim().toLowerCase()).toSet(); |
| 157 | if (!normalized.contains(reasoning.effort.apiValue)) { |
| 158 | notifier.setEffort(resolveEffortForModel(model, reasoning.effort)); |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | void clear() { |
| 163 | state = null; |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | final autoSelectFirstLoadedModelProvider = FutureProvider<void>((ref) async { |
| 168 | final activeServer = ref.watch(activeServerProvider); |
| 169 | final status = ref.watch(connectionStatusProvider); |
| 170 | |
| 171 | // Re-run whenever the configured default model changes so a newly chosen |
| 172 | // default is applied to the next new chat (the notifier only seeds the |
| 173 | // selection when no model is already picked). |
| 174 | ref.watch( |
| 175 | settingsProvider.select((s) => (s.defaultModelId, s.defaultModelServerId)), |
| 176 | ); |
| 177 | |
| 178 | if (activeServer == null) { |
| 179 | await Future.value(); |
| 180 | if (!ref.mounted) return; |
| 181 | ref.read(selectedModelProvider.notifier).clear(); |
| 182 | return; |
| 183 | } |
| 184 | |
| 185 | if (status != ConnectionStatus.connected) return; |
| 186 | |
| 187 | final selectedModel = ref.read(selectedModelProvider); |
| 188 | |
| 189 | if (selectedModel != null && selectedModel.serverId != activeServer.id) { |
| 190 | await Future.value(); |
| 191 | if (!ref.mounted) return; |
| 192 | ref.read(selectedModelProvider.notifier).clear(); |
| 193 | } else if (selectedModel != null) { |
| 194 | return; |
| 195 | } |
| 196 | |
| 197 | final settings = ref.read(settingsProvider); |
| 198 | final defaultModelId = settings.defaultModelId; |
| 199 | final defaultModelServerId = settings.defaultModelServerId; |
| 200 | final hasDefaultForServer = |
| 201 | defaultModelId != null && defaultModelServerId == activeServer.id; |
| 202 | |
| 203 | // Cloud providers have no "loaded" notion, so without a configured |
| 204 | // default there's nothing to auto-select for them (avoids an extra model |
| 205 | // list fetch for users who never set one). |
| 206 | if (!hasDefaultForServer && |
| 207 | !activeServer.isOnDevice && |
| 208 | (activeServer.type == ServerType.openRouter || |
| 209 | activeServer.type == ServerType.requesty || |
| 210 | activeServer.type == ServerType.openAICompatible)) { |
| 211 | return; |
| 212 | } |
| 213 | |
| 214 | try { |
| 215 | final Set<String> loadedModels; |
| 216 | if (activeServer.isOnDevice) { |
| 217 | final engine = ref.watch(onDeviceEngineProvider); |
| 218 | loadedModels = engine.loadedModelId != null |
| 219 | ? {engine.loadedModelId!} |
| 220 | : {}; |
| 221 | } else { |
| 222 | final apiService = ref.read(serverApiServiceProvider); |
| 223 | loadedModels = await apiService.fetchRunningModels(activeServer); |
| 224 | if (!ref.mounted) return; |
| 225 | } |
| 226 | |
| 227 | final availableModels = await ref.read( |
| 228 | availableModelsProvider(activeServer.id).future, |
| 229 | ); |
| 230 | if (!ref.mounted) return; |
| 231 | |
| 232 | final typedModels = availableModels.cast<ModelInfo>(); |
| 233 | if (typedModels.isEmpty) return; |
| 234 | |
| 235 | if (hasDefaultForServer) { |
| 236 | final defaultModel = typedModels |
| 237 | .where((m) => m.id == defaultModelId) |
| 238 | .firstOrNull; |
| 239 | if (defaultModel != null) { |
| 240 | if (activeServer.isOnDevice) { |
| 241 | // On-device inference runs one engine instance at a time, so only |
| 242 | // auto-select the default when it's the model the engine already |
| 243 | // has loaded; otherwise fall through to the loaded-model logic. |
| 244 | if (isModelKeyLoaded(loadedModels, defaultModel.id)) { |
| 245 | if (!ref.mounted) return; |
| 246 | ref.read(selectedModelProvider.notifier).setModel(defaultModel); |
| 247 | return; |
| 248 | } |
| 249 | } else { |
| 250 | // LM Studio models must be running before a request can use them; |
| 251 | // if the default isn't loaded yet, load it exactly like tapping it |
| 252 | // in the model picker would so the user can start chatting right |
| 253 | // away. Ollama and cloud providers load on demand, so selecting is |
| 254 | // enough for them. |
| 255 | if (!isModelKeyLoaded(loadedModels, defaultModel.id) && |
| 256 | activeServer.type == ServerType.lmStudio) { |
| 257 | await _loadDefaultModel(ref, activeServer, defaultModel); |
| 258 | if (!ref.mounted) return; |
| 259 | } |
| 260 | ref.read(selectedModelProvider.notifier).setModel(defaultModel); |
| 261 | return; |
| 262 | } |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | // Cloud providers fall back to no selection when the configured default |
| 267 | // isn't among the available models. |
| 268 | if (!activeServer.isOnDevice && |
| 269 | (activeServer.type == ServerType.openRouter || |
| 270 | activeServer.type == ServerType.requesty || |
| 271 | activeServer.type == ServerType.openAICompatible)) { |
| 272 | return; |
| 273 | } |
| 274 | |
| 275 | if (loadedModels.isEmpty) { |
| 276 | if (activeServer.isOnDevice) { |
| 277 | final downloadedAsync = await ref.read(downloadedModelsProvider.future); |
| 278 | if (!ref.mounted) return; |
| 279 | if (hasDefaultForServer) { |
| 280 | final defaultModel = typedModels |
| 281 | .where( |
| 282 | (m) => m.id == defaultModelId && downloadedAsync.contains(m.id), |
| 283 | ) |
| 284 | .firstOrNull; |
| 285 | if (defaultModel != null && ref.mounted) { |
| 286 | ref.read(selectedModelProvider.notifier).setModel(defaultModel); |
| 287 | return; |
| 288 | } |
| 289 | } |
| 290 | final firstAvailableModel = typedModels |
| 291 | .where((m) => downloadedAsync.contains(m.id)) |
| 292 | .firstOrNull; |
| 293 | if (firstAvailableModel != null && ref.mounted) { |
| 294 | ref |
| 295 | .read(selectedModelProvider.notifier) |
| 296 | .setModel(firstAvailableModel); |
| 297 | } |
| 298 | } |
| 299 | return; |
| 300 | } |
| 301 | |
| 302 | final firstLoadedModel = typedModels |
| 303 | .where((m) => isModelKeyLoaded(loadedModels, m.id)) |
| 304 | .firstOrNull; |
| 305 | |
| 306 | if (firstLoadedModel != null && ref.mounted) { |
| 307 | ref.read(selectedModelProvider.notifier).setModel(firstLoadedModel); |
| 308 | } |
| 309 | } catch (e) { |
| 310 | // Silently fail auto-selection |
| 311 | } |
| 312 | }); |
| 313 | |
| 314 | /// Loads an LM Studio model via the server API and reflects the busy state in |
| 315 | /// [modelLoadingProvider], mirroring what tapping a model in the picker does. |
| 316 | Future<void> _loadDefaultModel( |
| 317 | Ref ref, |
| 318 | Server activeServer, |
| 319 | ModelInfo model, |
| 320 | ) async { |
| 321 | if (!ref.mounted) return; |
| 322 | final apiService = ref.read(serverApiServiceProvider); |
| 323 | final settings = ref.read(settingsProvider); |
| 324 | final contextLength = ref.read(chatParamsProvider).contextLength; |
| 325 | |
| 326 | ref.read(modelLoadingProvider.notifier).setLoading(model.id); |
| 327 | try { |
| 328 | if (settings.unloadModelsBeforeLoad) { |
| 329 | final instances = await ref.read( |
| 330 | loadedModelsProvider(activeServer).future, |
| 331 | ); |
| 332 | if (!ref.mounted) return; |
| 333 | await apiService.unloadAllInstances(activeServer, instances); |
| 334 | if (!ref.mounted) return; |
| 335 | } |
| 336 | await apiService.loadModelWithInstanceId( |
| 337 | activeServer, |
| 338 | model.id, |
| 339 | contextLength: contextLength, |
| 340 | ); |
| 341 | if (!ref.mounted) return; |
| 342 | ref.invalidate(loadedModelsProvider(activeServer)); |
| 343 | } finally { |
| 344 | if (ref.mounted) { |
| 345 | ref.read(modelLoadingProvider.notifier).setLoaded(); |
| 346 | } |
| 347 | } |
| 348 | } |
| 349 | |
| 350 | /// The actual context length the active model was loaded with, fetched |
| 351 | /// live from the server (currently only LM Studio reports this — see |
| 352 | /// [ServerApiService.fetchLoadedContextLength]). Re-fetches whenever the |
| 353 | /// active server or selected model changes. |
| 354 | final activeModelContextLengthProvider = FutureProvider<int?>((ref) async { |
| 355 | final server = ref.watch(activeServerProvider); |
| 356 | final model = ref.watch(selectedModelProvider); |
| 357 | if (server == null || model == null) return null; |
| 358 | |
| 359 | final apiService = ref.read(serverApiServiceProvider); |
| 360 | return apiService.fetchLoadedContextLength(server, model.id); |
| 361 | }); |
| 362 | |
| 363 | final isStreamingProvider = NotifierProvider<IsStreamingNotifier, bool>(() { |
| 364 | return IsStreamingNotifier(); |
| 365 | }); |
| 366 | |
| 367 | class IsStreamingNotifier extends Notifier<bool> { |
| 368 | @override |
| 369 | bool build() => false; |
| 370 | |
| 371 | void setStreaming(bool streaming) { |
| 372 | state = streaming; |
| 373 | } |
| 374 | } |
| 375 | |
| 376 | /// Replies that are generating right now, keyed by conversation id in the |
| 377 | /// order they started. Remote replies run concurrently and keep streaming in |
| 378 | /// the background while another chat is open (#94); on-device replies share a |
| 379 | /// single engine, so at most one of them appears here. |
| 380 | final activeGenerationsProvider = |
| 381 | NotifierProvider<ActiveGenerationsNotifier, Map<String, ActiveGeneration>>( |
| 382 | ActiveGenerationsNotifier.new, |
| 383 | ); |
| 384 | |
| 385 | class ActiveGenerationsNotifier |
| 386 | extends Notifier<Map<String, ActiveGeneration>> { |
| 387 | @override |
| 388 | Map<String, ActiveGeneration> build() => const {}; |
| 389 | |
| 390 | void set(Map<String, ActiveGeneration> generations) { |
| 391 | state = generations; |
| 392 | } |
| 393 | } |