4 files changed
+328
-61
lib/features/chat/views/chat_screen.dart
+52
-51
@@ -20,7 +20,7 @@ import 'package:localmind/features/conversations/providers/conversation_provider
20
as conv;
21
import 'package:localmind/features/conversations/views/components/conversation_list.dart';
22
import 'package:localmind/features/conversations/views/components/rename_conversation_dialog.dart';
23
-import 'package:localmind/features/models/screens/model_picker_sheet.dart';
23
+import 'package:localmind/features/models/views/model_picker_sheet.dart';
24
import 'package:localmind/features/personas/providers/personas_providers.dart';
25
import 'package:localmind/features/servers/providers/server_providers.dart';
26
import 'package:localmind/features/saved_messages/views/components/save_message_sheet.dart';
@@ -368,26 +368,26 @@ class _ChatBody extends ConsumerWidget {
368
child: Stack(
369
children: [
370
_MessageArea(
371
- isLoading: isLoading,
372
- messages: messages,
373
- activeConversation: activeConversation,
374
- errorMessage: errorMessage,
375
- selectedModel: selectedModel,
376
- isStreaming: isStreaming,
377
- scrollController: scrollController,
378
- effectiveBottomInset: effectiveBottomInset,
379
- keyboardBottomInset: keyboardBottomInset,
380
- onModelPicker: onModelPicker,
381
- ),
382
- _ChatBottomBar(
383
- isStreaming: isStreaming,
384
- keyboardBottomInset: keyboardBottomInset,
385
- inputFocusNode: inputFocusNode,
386
- ),
387
- ],
371
+ isLoading: isLoading,
372
+ messages: messages,
373
+ activeConversation: activeConversation,
374
+ errorMessage: errorMessage,
375
+ selectedModel: selectedModel,
376
+ isStreaming: isStreaming,
377
+ scrollController: scrollController,
378
+ effectiveBottomInset: effectiveBottomInset,
379
+ keyboardBottomInset: keyboardBottomInset,
380
+ onModelPicker: onModelPicker,
381
+ ),
382
+ _ChatBottomBar(
383
+ isStreaming: isStreaming,
384
+ keyboardBottomInset: keyboardBottomInset,
385
+ inputFocusNode: inputFocusNode,
386
+ ),
387
+ ],
388
+ ),
389
),
390
),
390
- ),
391
],
392
);
393
}
@@ -526,10 +526,9 @@ class _MessageArea extends ConsumerWidget {
526
void _clearPersonas(WidgetRef ref) {
527
final activeConv = ref.read(conv.activeConversationProvider);
528
if (activeConv != null) {
529
- ref.read(conv.conversationsProvider.notifier).updatePersonas(
530
- activeConv.id,
531
- const [],
532
- );
529
+ ref
530
+ .read(conv.conversationsProvider.notifier)
531
+ .updatePersonas(activeConv.id, const []);
532
} else {
533
ref.read(selectedPersonasProvider.notifier).clear();
534
}
@@ -621,7 +620,10 @@ class _ScreenAppBar extends ConsumerWidget {
620
Expanded(
621
child: Text(
622
l10n.selected_count(selectedMessageIds.length),
624
- style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
623
+ style: const TextStyle(
624
+ fontSize: 16,
625
+ fontWeight: FontWeight.w600,
626
+ ),
627
),
628
),
629
IconButton(
@@ -629,14 +631,22 @@ class _ScreenAppBar extends ConsumerWidget {
631
tooltip: l10n.export_conversation,
632
onPressed: selectedMessageIds.isEmpty
633
? null
632
- : () => _shareSelectedMessages(context, ref, selectedMessageIds),
634
+ : () => _shareSelectedMessages(
635
+ context,
636
+ ref,
637
+ selectedMessageIds,
638
+ ),
639
),
640
IconButton(
641
icon: const Icon(Icons.delete_outline, color: Colors.red),
642
tooltip: l10n.delete,
643
onPressed: selectedMessageIds.isEmpty
644
? null
639
- : () => _deleteSelectedMessages(context, ref, selectedMessageIds),
645
+ : () => _deleteSelectedMessages(
646
+ context,
647
+ ref,
648
+ selectedMessageIds,
649
+ ),
650
),
651
],
652
),
@@ -663,10 +673,7 @@ class _ScreenAppBar extends ConsumerWidget {
673
Expanded(
674
child: Text(
675
_appBarTitle(l10n),
666
- style: const TextStyle(
667
- fontSize: 18,
668
- fontWeight: FontWeight.bold,
669
- ),
676
+ style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
677
overflow: TextOverflow.ellipsis,
678
),
679
),
@@ -861,10 +868,10 @@ class _ChatModeIconButton extends StatelessWidget {
868
final ghostActive = isTemporary;
869
870
if (showGhost) {
864
- final activeColor =
865
- isDark ? const Color(0xFFE6C35C) : const Color(0xFF9A7B1A);
866
- final inactiveColor =
867
- isDark ? Colors.white54 : Colors.black45;
871
+ final activeColor = isDark
872
+ ? const Color(0xFFE6C35C)
873
+ : const Color(0xFF9A7B1A);
874
+ final inactiveColor = isDark ? Colors.white54 : Colors.black45;
875
876
return IconButton(
877
onPressed: onPressed,
@@ -878,17 +885,9 @@ class _ChatModeIconButton extends StatelessWidget {
885
color: activeColor.withValues(alpha: 0.25),
886
shape: BoxShape.circle,
887
),
881
- child: Icon(
882
- LucideIcons.ghost,
883
- size: 20,
884
- color: activeColor,
885
- ),
888
+ child: Icon(LucideIcons.ghost, size: 20, color: activeColor),
889
)
887
- : Icon(
888
- LucideIcons.ghost,
889
- size: 22,
890
- color: inactiveColor,
891
- ),
890
+ : Icon(LucideIcons.ghost, size: 22, color: inactiveColor),
891
);
892
}
893
@@ -918,7 +917,8 @@ class _ChatBottomBar extends ConsumerWidget {
917
@override
918
Widget build(BuildContext context, WidgetRef ref) {
919
final isTemporary = ref.watch(chatProvider.select((s) => s.isTemporary));
921
- final keyboardIncognito = isTemporary &&
920
+ final keyboardIncognito =
921
+ isTemporary &&
922
ref.watch(settingsProvider.select((s) => s.tempChatKeyboardIncognito));
923
final totalTokenCount = ref.watch(
924
conv.activeConversationProvider.select((c) => c?.totalTokenCount),
@@ -985,10 +985,10 @@ class _TokenUsageIndicator extends ConsumerWidget {
985
Widget build(BuildContext context, WidgetRef ref) {
986
final theme = Theme.of(context);
987
final isDark = theme.brightness == Brightness.dark;
988
- final liveContextLength =
989
- ref.watch(activeModelContextLengthProvider).value;
990
- final fallbackContextLength =
991
- ref.watch(settingsProvider.select((s) => s.contextLength));
988
+ final liveContextLength = ref.watch(activeModelContextLengthProvider).value;
989
+ final fallbackContextLength = ref.watch(
990
+ settingsProvider.select((s) => s.contextLength),
991
+ );
992
final int contextLength = liveContextLength ?? fallbackContextLength;
993
994
// totalTokenCount only updates once a response finishes (it's the real
@@ -1051,8 +1051,9 @@ class _TokenUsageIndicator extends ConsumerWidget {
1051
builder: (ctx) {
1052
final sheetTheme = Theme.of(ctx);
1053
final isDark = sheetTheme.brightness == Brightness.dark;
1054
- final muted =
1055
- isDark ? AppColors.darkMutedText : AppColors.lightMutedText;
1054
+ final muted = isDark
1055
+ ? AppColors.darkMutedText
1056
+ : AppColors.lightMutedText;
1057
return SafeArea(
1058
child: Padding(
1059
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
lib/features/models/components/model_list.dart
+49
-1
@@ -178,9 +178,57 @@ class _ModelListState extends ConsumerState<ModelList> {
178
Widget build(BuildContext context) {
179
final l10n = AppLocalizations.of(context)!;
180
final searchQuery = ref.watch(modelSearchQueryProvider);
181
- final modelsAsync = ref.watch(availableModelsProvider(widget.serverId));
181
final metadata = ref.watch(modelMetadataProvider);
182
183
+ final connectionStatus = ref.watch(connectionStatusProvider);
184
+
185
+ if (connectionStatus == ConnectionStatus.error) {
186
+ return Center(
187
+ child: SingleChildScrollView(
188
+ padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 16.0),
189
+ child: Column(
190
+ mainAxisAlignment: MainAxisAlignment.center,
191
+ children: [
192
+ Icon(Icons.cloud_off_rounded, size: 64, color: Colors.red[300]),
193
+ const SizedBox(height: 16),
194
+ Text(
195
+ 'Server Offline',
196
+ style: TextStyle(
197
+ fontSize: 18,
198
+ fontWeight: FontWeight.bold,
199
+ color: widget.isDark ? Colors.white : Colors.black,
200
+ ),
201
+ ),
202
+ const SizedBox(height: 8),
203
+ Text(
204
+ 'Could not establish a connection to the server. Please check if your server is running and the host/port settings are correct.',
205
+ style: TextStyle(
206
+ fontSize: 13,
207
+ color: widget.isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
208
+ ),
209
+ textAlign: TextAlign.center,
210
+ ),
211
+ const SizedBox(height: 24),
212
+ OutlinedButton.icon(
213
+ onPressed: () async {
214
+ await ref.read(connectionStatusProvider.notifier).refresh();
215
+ ref.invalidate(availableModelsProvider(widget.serverId));
216
+ },
217
+ icon: const Icon(Icons.refresh),
218
+ label: const Text('Retry Connection'),
219
+ ),
220
+ ],
221
+ ),
222
+ ),
223
+ );
224
+ }
225
+
226
+ if (connectionStatus == ConnectionStatus.checking) {
227
+ return const Center(child: CircularProgressIndicator());
228
+ }
229
+
230
+ final modelsAsync = ref.watch(availableModelsProvider(widget.serverId));
231
+
232
return modelsAsync.when(
233
loading: () => const Center(child: CircularProgressIndicator()),
234
error: (err, _) => Center(
lib/features/models/views/model_picker_sheet.dart
renamed
+221
-9
@@ -10,8 +10,9 @@ import 'package:localmind/features/on_device/components/on_device_picker_section
10
import 'package:localmind/features/on_device/providers/on_device_providers.dart';
11
import 'package:localmind/features/servers/providers/server_providers.dart';
12
import 'package:localmind/features/lm_studio_catalog/views/lm_studio_download_widgets.dart';
13
+import 'package:localmind/core/providers/app_providers.dart';
14
+import 'package:localmind/features/conversations/providers/conversation_providers.dart' as conv;
15
import 'package:localmind/l10n/app_localizations.dart';
14
-import '../components/model_context_length_section.dart';
16
import '../components/model_list.dart';
17
import '../components/model_search_field.dart';
18
import '../components/model_sort_control.dart';
@@ -28,11 +29,12 @@ class ModelPickerSheet extends ConsumerStatefulWidget {
29
30
class _ModelPickerSheetState extends ConsumerState<ModelPickerSheet> {
31
@override
31
- void dispose() {
32
+ void deactivate() {
33
// Reset search when the sheet closes so the next open starts from a
34
// clean, matching state instead of an empty box that's still filtering.
34
- ref.read(modelSearchQueryProvider.notifier).clear();
35
- super.dispose();
35
+ final searchNotifier = ref.read(modelSearchQueryProvider.notifier);
36
+ Future.microtask(() => searchNotifier.clear());
37
+ super.deactivate();
38
}
39
40
@override
@@ -150,8 +152,6 @@ class _ModelPickerSheetState extends ConsumerState<ModelPickerSheet> {
152
),
153
),
154
const SizedBox(height: 12),
153
- ModelContextLengthSection(isDark: isDark),
154
- const SizedBox(height: 12),
155
Row(
156
children: [
157
const Expanded(child: ModelSearchField()),
@@ -221,7 +221,7 @@ class _ModelPickerSheetState extends ConsumerState<ModelPickerSheet> {
221
}
222
}
223
224
-class _ModelPickerHeader extends StatelessWidget {
224
+class _ModelPickerHeader extends ConsumerWidget {
225
const _ModelPickerHeader({
226
required this.isDark,
227
required this.isThinking,
@@ -241,11 +241,14 @@ class _ModelPickerHeader extends StatelessWidget {
241
final VoidCallback? onUnloadAll;
242
243
@override
244
- Widget build(BuildContext context) {
244
+ Widget build(BuildContext context, WidgetRef ref) {
245
final l10n = AppLocalizations.of(context)!;
246
+ final settings = ref.watch(settingsProvider);
247
+ final activeConv = ref.watch(conv.activeConversationProvider);
248
+ final contextLength = activeConv?.contextLength ?? settings.contextLength;
249
250
return Row(
248
- crossAxisAlignment: CrossAxisAlignment.start,
251
+ crossAxisAlignment: CrossAxisAlignment.center,
252
children: [
253
Expanded(
254
child: Column(
@@ -299,6 +302,48 @@ class _ModelPickerHeader extends StatelessWidget {
302
Row(
303
mainAxisSize: MainAxisSize.min,
304
children: [
305
+ Material(
306
+ color: Colors.transparent,
307
+ child: InkWell(
308
+ onTap: () => _showContextLengthDialog(
309
+ context,
310
+ ref,
311
+ contextLength,
312
+ activeConv?.id,
313
+ ),
314
+ borderRadius: BorderRadius.circular(16),
315
+ child: Container(
316
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
317
+ decoration: BoxDecoration(
318
+ border: Border.all(
319
+ color: isDark ? Colors.grey[800]! : Colors.grey[300]!,
320
+ ),
321
+ borderRadius: BorderRadius.circular(16),
322
+ color: isDark ? Colors.grey[900] : Colors.grey[50],
323
+ ),
324
+ child: Row(
325
+ mainAxisSize: MainAxisSize.min,
326
+ children: [
327
+ Icon(
328
+ Icons.layers_outlined,
329
+ size: 14,
330
+ color: isDark ? Colors.grey[400] : Colors.grey[600],
331
+ ),
332
+ const SizedBox(width: 4),
333
+ Text(
334
+ '$contextLength',
335
+ style: TextStyle(
336
+ fontSize: 12,
337
+ fontWeight: FontWeight.w600,
338
+ color: isDark ? Colors.white : Colors.black87,
339
+ ),
340
+ ),
341
+ ],
342
+ ),
343
+ ),
344
+ ),
345
+ ),
346
+ const SizedBox(width: 4),
347
if (onUnloadAll != null)
348
IconButton(
349
onPressed: onUnloadAll,
@@ -328,3 +373,170 @@ class _ModelPickerHeader extends StatelessWidget {
373
);
374
}
375
}
376
+
377
+void _showContextLengthDialog(
378
+ BuildContext context,
379
+ WidgetRef ref,
380
+ int currentLength,
381
+ String? activeConversationId,
382
+) {
383
+ showDialog(
384
+ context: context,
385
+ builder: (context) => _ContextLengthEditDialog(
386
+ initialValue: currentLength,
387
+ activeConversationId: activeConversationId,
388
+ ),
389
+ );
390
+}
391
+
392
+class _ContextLengthEditDialog extends ConsumerStatefulWidget {
393
+ const _ContextLengthEditDialog({
394
+ required this.initialValue,
395
+ required this.activeConversationId,
396
+ });
397
+
398
+ final int initialValue;
399
+ final String? activeConversationId;
400
+
401
+ @override
402
+ ConsumerState<_ContextLengthEditDialog> createState() =>
403
+ __ContextLengthEditDialogState();
404
+}
405
+
406
+class __ContextLengthEditDialogState
407
+ extends ConsumerState<_ContextLengthEditDialog> {
408
+ late final TextEditingController _controller;
409
+ late final FocusNode _focusNode;
410
+
411
+ @override
412
+ void initState() {
413
+ super.initState();
414
+ _controller = TextEditingController(text: widget.initialValue.toString());
415
+ _focusNode = FocusNode();
416
+ }
417
+
418
+ @override
419
+ void dispose() {
420
+ _controller.dispose();
421
+ _focusNode.dispose();
422
+ super.dispose();
423
+ }
424
+
425
+ void _save(String value) {
426
+ final parsed = int.tryParse(value.trim());
427
+ if (parsed != null && parsed > 0) {
428
+ if (widget.activeConversationId == null) {
429
+ ref.read(settingsProvider.notifier).setContextLength(parsed);
430
+ } else {
431
+ ref.read(conv.conversationsProvider.notifier).updateChatParams(
432
+ widget.activeConversationId!,
433
+ contextLength: parsed,
434
+ );
435
+ }
436
+ }
437
+ Navigator.of(context).pop();
438
+ }
439
+
440
+ @override
441
+ Widget build(BuildContext context) {
442
+ final theme = Theme.of(context);
443
+ final isDark = theme.brightness == Brightness.dark;
444
+ final l10n = AppLocalizations.of(context)!;
445
+
446
+ final suggestions = [2048, 4096, 8192, 16384, 32768, 65536, 131072];
447
+
448
+ return Dialog(
449
+ backgroundColor: isDark ? AppColors.darkBackground : AppColors.lightSurface,
450
+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
451
+ child: SingleChildScrollView(
452
+ child: Padding(
453
+ padding: const EdgeInsets.all(20),
454
+ child: Column(
455
+ mainAxisSize: MainAxisSize.min,
456
+ crossAxisAlignment: CrossAxisAlignment.stretch,
457
+ children: [
458
+ Text(
459
+ l10n.context_length,
460
+ style: TextStyle(
461
+ fontSize: 16,
462
+ fontWeight: FontWeight.bold,
463
+ color: isDark ? Colors.white : Colors.black,
464
+ ),
465
+ ),
466
+ const SizedBox(height: 16),
467
+ TextField(
468
+ controller: _controller,
469
+ focusNode: _focusNode,
470
+ autofocus: true,
471
+ keyboardType: TextInputType.number,
472
+ decoration: InputDecoration(
473
+ labelText: 'Tokens',
474
+ hintText: 'Enter context length...',
475
+ border: OutlineInputBorder(
476
+ borderRadius: BorderRadius.circular(10),
477
+ ),
478
+ suffixIcon: IconButton(
479
+ icon: const Icon(Icons.clear, size: 18),
480
+ onPressed: () => _controller.clear(),
481
+ ),
482
+ ),
483
+ onSubmitted: _save,
484
+ ),
485
+ const SizedBox(height: 12),
486
+ SingleChildScrollView(
487
+ scrollDirection: Axis.horizontal,
488
+ child: Row(
489
+ children: suggestions.map((val) {
490
+ final label = val >= 1024 ? '${val ~/ 1024}K' : '$val';
491
+ return Padding(
492
+ padding: const EdgeInsets.only(right: 8),
493
+ child: ActionChip(
494
+ label: Text(label),
495
+ labelStyle: TextStyle(
496
+ fontSize: 12,
497
+ fontWeight: FontWeight.w500,
498
+ color: isDark ? Colors.white70 : Colors.black87,
499
+ ),
500
+ backgroundColor: isDark ? Colors.grey[850] : Colors.grey[200],
501
+ shape: RoundedRectangleBorder(
502
+ borderRadius: BorderRadius.circular(8),
503
+ side: BorderSide.none,
504
+ ),
505
+ onPressed: () {
506
+ _controller.text = val.toString();
507
+ _controller.selection = TextSelection.fromPosition(
508
+ TextPosition(offset: _controller.text.length),
509
+ );
510
+ },
511
+ ),
512
+ );
513
+ }).toList(),
514
+ ),
515
+ ),
516
+ const SizedBox(height: 16),
517
+ Row(
518
+ mainAxisAlignment: MainAxisAlignment.end,
519
+ children: [
520
+ TextButton(
521
+ onPressed: () => Navigator.of(context).pop(),
522
+ child: Text(l10n.cancel),
523
+ ),
524
+ const SizedBox(width: 8),
525
+ FilledButton(
526
+ onPressed: () => _save(_controller.text),
527
+ style: FilledButton.styleFrom(
528
+ shape: RoundedRectangleBorder(
529
+ borderRadius: BorderRadius.circular(8),
530
+ ),
531
+ ),
532
+ child: Text(l10n.save),
533
+ ),
534
+ ],
535
+ ),
536
+ ],
537
+ ),
538
+ ),
539
+ ),
540
+ );
541
+ }
542
+}
lib/features/settings/views/settings_screen.dart
+6
@@ -1808,6 +1808,12 @@ class _EngineDropdown extends StatelessWidget {
1808
l10n.tts_engine_kitten,
1809
Icons.auto_awesome_rounded,
1810
),
1811
+ _engineItem(
1812
+ context,
1813
+ EngineId.piper,
1814
+ EngineMeta.piper.name,
1815
+ Icons.memory_rounded,
1816
+ ),
1817
],
1818
onChanged: (value) {
1819
if (value != null) {