main
dart 393 lines 13.7 KB
Raw
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 }