main
dart 595 lines 20.7 KB
Raw
1 import 'package:cue/cue.dart';
2 import 'package:hugeicons/hugeicons.dart';
3 import 'package:flutter/material.dart';
4 import 'package:flutter_riverpod/flutter_riverpod.dart';
5 import 'package:go_router/go_router.dart';
6 import 'package:localmind/core/models/enums.dart';
7 import 'package:localmind/core/providers/service_providers.dart';
8 import 'package:localmind/core/routes/app_routes.dart';
9 import 'package:localmind/core/theme/colors.dart';
10 import 'package:localmind/features/chat/providers/chat_providers.dart';
11 import 'package:localmind/features/on_device/components/on_device_picker_section.dart';
12 import 'package:localmind/features/on_device/providers/on_device_providers.dart';
13 import 'package:localmind/features/servers/providers/server_providers.dart';
14 import 'package:localmind/features/lm_studio_catalog/views/lm_studio_download_widgets.dart';
15 import 'package:localmind/core/providers/app_providers.dart';
16 import 'package:localmind/features/conversations/providers/conversation_providers.dart'
17 as conv;
18 import 'package:localmind/l10n/app_localizations.dart';
19 import '../components/model_list.dart';
20 import '../components/model_search_field.dart';
21 import '../components/model_sort_control.dart';
22 import '../components/no_server_state.dart';
23 import '../components/thinking_indicator.dart';
24 import '../providers/model_picker_providers.dart';
25
26 class ModelPickerSheet extends ConsumerStatefulWidget {
27 const ModelPickerSheet({super.key});
28
29 @override
30 ConsumerState<ModelPickerSheet> createState() => _ModelPickerSheetState();
31 }
32
33 class _ModelPickerSheetState extends ConsumerState<ModelPickerSheet> {
34 @override
35 void deactivate() {
36 // Reset search when the sheet closes so the next open starts from a
37 // clean, matching state instead of an empty box that's still filtering.
38 try {
39 final searchNotifier = ref.read(modelSearchQueryProvider.notifier);
40 Future.microtask(() => searchNotifier.clear());
41 } catch (_) {}
42 super.deactivate();
43 }
44
45 @override
46 Widget build(BuildContext context) {
47 final l10n = AppLocalizations.of(context)!;
48 final theme = Theme.of(context);
49 final isDark = theme.brightness == Brightness.dark;
50 final activeServer = ref.watch(activeServerProvider);
51 final selectedModel = ref.watch(selectedModelProvider);
52 final isThinking = ref.watch(modelThinkingProvider);
53
54 ref.listen(onDeviceEngineProvider, (prev, next) {
55 if (next.status == OnDeviceEngineStatus.loaded &&
56 prev?.status == OnDeviceEngineStatus.loading) {
57 final loadedId = next.loadedModelId;
58 final models = ref.read(onDeviceModelsProvider);
59 final loadedName = loadedId == null
60 ? 'Unknown'
61 : models
62 .where((m) => m.id == loadedId)
63 .map((m) => m.name)
64 .followedBy([loadedId])
65 .first;
66 WidgetsBinding.instance.addPostFrameCallback((_) {
67 if (!context.mounted) return;
68 ScaffoldMessenger.maybeOf(context)?.showSnackBar(
69 SnackBar(
70 content: Text(
71 l10n.model_loaded(loadedName, next.backend?.name ?? 'CPU'),
72 ),
73 ),
74 );
75 });
76 }
77 });
78
79 final serversAsync = ref.watch(serversProvider);
80 final servers = serversAsync.value ?? [];
81 final currentServer = servers
82 .where((s) => s.id == activeServer?.id)
83 .firstOrNull;
84 final isOnDevice =
85 (activeServer != null &&
86 (activeServer.type == ServerType.onDevice ||
87 activeServer.id == 'on-device' ||
88 activeServer.name.trim().toLowerCase() == 'on-device')) ||
89 (currentServer != null &&
90 (currentServer.type == ServerType.onDevice ||
91 currentServer.id == 'on-device' ||
92 currentServer.name.trim().toLowerCase() == 'on-device'));
93 final isLmStudio =
94 currentServer != null && currentServer.type == ServerType.lmStudio;
95
96 final loadedModelsAsync = activeServer != null
97 ? ref.watch(loadedModelsProvider(activeServer))
98 : const AsyncValue<Set<String>>.data(<String>{});
99 final loadedCount = loadedModelsAsync.maybeWhen(
100 data: (models) => models.length,
101 orElse: () => 0,
102 );
103
104 return DraggableScrollableSheet(
105 initialChildSize: 0.7,
106 minChildSize: 0.4,
107 maxChildSize: 1.0,
108 expand: false,
109 builder: (context, scrollController) {
110 return Cue.onMount(
111 motion: .smooth(),
112 child: Container(
113 padding: const EdgeInsets.all(16),
114 decoration: BoxDecoration(
115 color: isDark ? AppColors.darkBackground : AppColors.lightSurface,
116 borderRadius: const BorderRadius.vertical(
117 top: Radius.circular(20),
118 ),
119 ),
120 child: Column(
121 crossAxisAlignment: CrossAxisAlignment.start,
122 children: [
123 Center(
124 child: Container(
125 width: 40,
126 height: 4,
127 decoration: BoxDecoration(
128 color: isDark ? Colors.grey[600] : Colors.grey[300],
129 borderRadius: BorderRadius.circular(2),
130 ),
131 ),
132 ),
133 const SizedBox(height: 16),
134 _ModelPickerHeader(
135 isDark: isDark,
136 isThinking: isThinking,
137 loadedCount: loadedCount,
138 serverName: activeServer?.name,
139 showBrowseButton: isLmStudio,
140 onBrowseModels: isLmStudio
141 ? () {
142 Navigator.of(context).pop();
143 context.push(
144 AppRoutes.lmStudioModelBrowser,
145 extra: currentServer,
146 );
147 }
148 : null,
149 onRefresh: activeServer != null
150 ? () {
151 ref.invalidate(
152 availableModelsProvider(activeServer.id),
153 );
154 ref.invalidate(loadedModelsProvider(activeServer));
155 }
156 : null,
157 onUnloadAll: activeServer != null && loadedCount > 0
158 ? () => _unloadAllModels(context, ref, activeServer)
159 : null,
160 ),
161 const SizedBox(height: 12),
162 Row(
163 children: [
164 const Expanded(child: ModelSearchField()),
165 const SizedBox(width: 8),
166 const ModelSortControl(),
167 ],
168 ),
169 const SizedBox(height: 12),
170 Expanded(
171 child: activeServer == null
172 ? NoServerState(isDark: isDark)
173 : isOnDevice
174 ? OnDevicePickerSection(
175 selectedModelId: selectedModel?.id,
176 isDark: isDark,
177 scrollController: scrollController,
178 )
179 : ModelList(
180 serverId: activeServer.id,
181 selectedModelId: selectedModel?.id,
182 isDark: isDark,
183 scrollController: scrollController,
184 ),
185 ),
186 ],
187 ),
188 ),
189 );
190 },
191 );
192 }
193
194 Future<void> _unloadAllModels(
195 BuildContext context,
196 WidgetRef ref,
197 dynamic activeServer,
198 ) async {
199 final l10n = AppLocalizations.of(context)!;
200
201 try {
202 if (activeServer.type == ServerType.onDevice ||
203 activeServer.id == 'on-device') {
204 await ref.read(onDeviceEngineProvider.notifier).unloadModel();
205 } else {
206 final loadedInstances = await ref.read(
207 loadedModelsProvider(activeServer).future,
208 );
209 final apiService = ref.read(serverApiServiceProvider);
210 await apiService.unloadAllInstances(activeServer, loadedInstances);
211 }
212
213 if (context.mounted) {
214 ref.invalidate(loadedModelsProvider(activeServer));
215 ref.read(selectedModelProvider.notifier).clear();
216 ScaffoldMessenger.maybeOf(
217 context,
218 )?.showSnackBar(SnackBar(content: Text(l10n.all_models_unloaded)));
219 }
220 } catch (e) {
221 if (context.mounted) {
222 ScaffoldMessenger.maybeOf(context)?.showSnackBar(
223 SnackBar(
224 content: Text(l10n.model_unload_failed(e.toString())),
225 backgroundColor: Colors.red,
226 ),
227 );
228 }
229 }
230 }
231 }
232
233 class _ModelPickerHeader extends ConsumerWidget {
234 const _ModelPickerHeader({
235 required this.isDark,
236 required this.isThinking,
237 required this.loadedCount,
238 this.serverName,
239 this.showBrowseButton = false,
240 this.onBrowseModels,
241 this.onRefresh,
242 this.onUnloadAll,
243 });
244
245 final bool isDark;
246 final bool isThinking;
247 final int loadedCount;
248 final String? serverName;
249 final bool showBrowseButton;
250 final VoidCallback? onBrowseModels;
251 final VoidCallback? onRefresh;
252 final VoidCallback? onUnloadAll;
253
254 @override
255 Widget build(BuildContext context, WidgetRef ref) {
256 final l10n = AppLocalizations.of(context)!;
257 final settings = ref.watch(settingsProvider);
258 final activeConv = ref.watch(conv.activeConversationProvider);
259 final contextLength = activeConv?.contextLength ?? settings.contextLength;
260
261 final hasSubtitle = serverName != null || loadedCount > 0;
262
263 return Row(
264 crossAxisAlignment: CrossAxisAlignment.center,
265 children: [
266 Expanded(
267 child: Column(
268 crossAxisAlignment: CrossAxisAlignment.start,
269 children: [
270 Row(
271 children: [
272 Flexible(
273 child: Text(
274 l10n.select_model_title,
275 style: TextStyle(
276 fontSize: 20,
277 fontWeight: FontWeight.bold,
278 color: isDark ? Colors.white : Colors.black,
279 ),
280 overflow: TextOverflow.ellipsis,
281 maxLines: 1,
282 ),
283 ),
284 if (isThinking) ...[
285 const SizedBox(width: 8),
286 ThinkingIndicator(isDark: isDark),
287 ],
288 ],
289 ),
290 if (hasSubtitle) ...[
291 const SizedBox(height: 2),
292 Row(
293 children: [
294 if (serverName != null)
295 Text(
296 serverName!,
297 style: TextStyle(
298 fontSize: 13,
299 color: isDark
300 ? AppColors.darkMutedText
301 : AppColors.lightMutedText,
302 ),
303 ),
304 if (serverName != null && loadedCount > 0)
305 const SizedBox(width: 8),
306 if (loadedCount > 0)
307 Container(
308 padding: const EdgeInsets.symmetric(
309 horizontal: 8,
310 vertical: 2,
311 ),
312 decoration: BoxDecoration(
313 color:
314 (isDark
315 ? AppColors.darkAccent
316 : AppColors.lightAccent)
317 .withValues(alpha: 0.15),
318 borderRadius: BorderRadius.circular(999),
319 ),
320 child: Text(
321 l10n.loaded_models_count(loadedCount),
322 style: TextStyle(
323 fontSize: 11,
324 fontWeight: FontWeight.w600,
325 color: isDark
326 ? AppColors.darkAccent
327 : AppColors.lightAccent,
328 ),
329 ),
330 ),
331 ],
332 ),
333 ],
334 ],
335 ),
336 ),
337 Row(
338 mainAxisSize: MainAxisSize.min,
339 children: [
340 Material(
341 color: Colors.transparent,
342 child: InkWell(
343 onTap: () => _showContextLengthDialog(
344 context,
345 ref,
346 contextLength,
347 activeConv?.id,
348 ),
349 borderRadius: BorderRadius.circular(16),
350 child: Container(
351 padding: const EdgeInsets.symmetric(
352 horizontal: 10,
353 vertical: 6,
354 ),
355 decoration: BoxDecoration(
356 border: Border.all(
357 color: isDark ? Colors.grey[800]! : Colors.grey[300]!,
358 ),
359 borderRadius: BorderRadius.circular(16),
360 color: isDark ? Colors.grey[900] : Colors.grey[50],
361 ),
362 child: Row(
363 mainAxisSize: MainAxisSize.min,
364 children: [
365 HugeIcon(
366 icon: HugeIcons.strokeRoundedLayers01,
367 size: 14,
368 color: isDark ? Colors.grey[400] : Colors.grey[600],
369 ),
370 const SizedBox(width: 4),
371 Text(
372 '$contextLength',
373 style: TextStyle(
374 fontSize: 12,
375 fontWeight: FontWeight.w600,
376 color: isDark ? Colors.white : Colors.black87,
377 ),
378 ),
379 ],
380 ),
381 ),
382 ),
383 ),
384 const SizedBox(width: 4),
385 if (onUnloadAll != null)
386 IconButton(
387 onPressed: onUnloadAll,
388 tooltip: l10n.unload_all_models,
389 icon: HugeIcon(
390 icon: HugeIcons.strokeRoundedPower,
391 size: 20,
392 color: Colors.red[400],
393 ),
394 ),
395 if (showBrowseButton && onBrowseModels != null)
396 IconButton(
397 onPressed: onBrowseModels,
398 tooltip: l10n.lm_studio_browse_models,
399 icon: const HugeIcon(
400 icon: HugeIcons.strokeRoundedCompass01,
401 size: 20,
402 ),
403 ),
404 const LmDownloadIndicatorButton(compact: true),
405 if (onRefresh != null)
406 IconButton(
407 icon: const HugeIcon(
408 icon: HugeIcons.strokeRoundedRefresh,
409 size: 20,
410 ),
411 onPressed: onRefresh,
412 tooltip: l10n.refresh_models,
413 ),
414 ],
415 ),
416 ],
417 );
418 }
419 }
420
421 void _showContextLengthDialog(
422 BuildContext context,
423 WidgetRef ref,
424 int currentLength,
425 String? activeConversationId,
426 ) {
427 showDialog(
428 context: context,
429 builder: (context) => _ContextLengthEditDialog(
430 initialValue: currentLength,
431 activeConversationId: activeConversationId,
432 ),
433 );
434 }
435
436 class _ContextLengthEditDialog extends ConsumerStatefulWidget {
437 const _ContextLengthEditDialog({
438 required this.initialValue,
439 required this.activeConversationId,
440 });
441
442 final int initialValue;
443 final String? activeConversationId;
444
445 @override
446 ConsumerState<_ContextLengthEditDialog> createState() =>
447 __ContextLengthEditDialogState();
448 }
449
450 class __ContextLengthEditDialogState
451 extends ConsumerState<_ContextLengthEditDialog> {
452 late final TextEditingController _controller;
453 late final FocusNode _focusNode;
454
455 @override
456 void initState() {
457 super.initState();
458 _controller = TextEditingController(text: widget.initialValue.toString());
459 _focusNode = FocusNode();
460 }
461
462 @override
463 void dispose() {
464 _controller.dispose();
465 _focusNode.dispose();
466 super.dispose();
467 }
468
469 void _save(String value) {
470 final parsed = int.tryParse(value.trim());
471 if (parsed != null && parsed > 0) {
472 if (widget.activeConversationId == null) {
473 ref.read(settingsProvider.notifier).setContextLength(parsed);
474 } else {
475 ref
476 .read(conv.conversationsProvider.notifier)
477 .updateChatParams(
478 widget.activeConversationId!,
479 contextLength: parsed,
480 );
481 }
482 }
483 Navigator.of(context).pop();
484 }
485
486 @override
487 Widget build(BuildContext context) {
488 final theme = Theme.of(context);
489 final isDark = theme.brightness == Brightness.dark;
490 final l10n = AppLocalizations.of(context)!;
491
492 final suggestions = [2048, 4096, 8192, 16384, 32768, 65536, 131072];
493
494 return Dialog(
495 backgroundColor: isDark
496 ? AppColors.darkBackground
497 : AppColors.lightSurface,
498 shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
499 child: SingleChildScrollView(
500 child: Padding(
501 padding: const EdgeInsets.all(20),
502 child: Column(
503 mainAxisSize: MainAxisSize.min,
504 crossAxisAlignment: CrossAxisAlignment.stretch,
505 children: [
506 Text(
507 l10n.context_length,
508 style: TextStyle(
509 fontSize: 16,
510 fontWeight: FontWeight.bold,
511 color: isDark ? Colors.white : Colors.black,
512 ),
513 ),
514 const SizedBox(height: 16),
515 TextField(
516 controller: _controller,
517 focusNode: _focusNode,
518 autofocus: true,
519 keyboardType: TextInputType.number,
520 decoration: InputDecoration(
521 labelText: l10n.tokens_label,
522 hintText: l10n.enter_context_length,
523 border: OutlineInputBorder(
524 borderRadius: BorderRadius.circular(10),
525 ),
526 suffixIcon: IconButton(
527 icon: const HugeIcon(
528 icon: HugeIcons.strokeRoundedCancel01,
529 size: 18,
530 ),
531 onPressed: () => _controller.clear(),
532 ),
533 ),
534 onSubmitted: _save,
535 ),
536 const SizedBox(height: 12),
537 SingleChildScrollView(
538 scrollDirection: Axis.horizontal,
539 child: Row(
540 children: suggestions.map((val) {
541 final label = val >= 1024 ? '${val ~/ 1024}K' : '$val';
542 return Padding(
543 padding: const EdgeInsets.only(right: 8),
544 child: ActionChip(
545 label: Text(label),
546 labelStyle: TextStyle(
547 fontSize: 12,
548 fontWeight: FontWeight.w500,
549 color: isDark ? Colors.white70 : Colors.black87,
550 ),
551 backgroundColor: isDark
552 ? Colors.grey[850]
553 : Colors.grey[200],
554 shape: RoundedRectangleBorder(
555 borderRadius: BorderRadius.circular(8),
556 side: BorderSide.none,
557 ),
558 onPressed: () {
559 _controller.text = val.toString();
560 _controller.selection = TextSelection.fromPosition(
561 TextPosition(offset: _controller.text.length),
562 );
563 },
564 ),
565 );
566 }).toList(),
567 ),
568 ),
569 const SizedBox(height: 16),
570 Row(
571 mainAxisAlignment: MainAxisAlignment.end,
572 children: [
573 TextButton(
574 onPressed: () => Navigator.of(context).pop(),
575 child: Text(l10n.cancel),
576 ),
577 const SizedBox(width: 8),
578 FilledButton(
579 onPressed: () => _save(_controller.text),
580 style: FilledButton.styleFrom(
581 shape: RoundedRectangleBorder(
582 borderRadius: BorderRadius.circular(8),
583 ),
584 ),
585 child: Text(l10n.save),
586 ),
587 ],
588 ),
589 ],
590 ),
591 ),
592 ),
593 );
594 }
595 }