44 files changed
+7009
-6521
lib/features/chat/data/models/grouped_tool_call.dart
new
+28
@@ -0,0 +1,28 @@
1
+import 'package:localmind/features/chat/data/tools/tool_event.dart';
2
+import 'package:localmind/features/chat/data/tools/tool_definition.dart';
3
+
4
+class GroupedToolCall {
5
+ final String baseId;
6
+ final String toolName;
7
+ final ToolProviderType providerType;
8
+ final String? providerRef;
9
+ final Map<String, dynamic>? arguments;
10
+ final ToolEventStatus status;
11
+ final String? result;
12
+ final String? error;
13
+ final int? durationMs;
14
+ final DateTime timestamp;
15
+
16
+ const GroupedToolCall({
17
+ required this.baseId,
18
+ required this.toolName,
19
+ required this.providerType,
20
+ this.providerRef,
21
+ this.arguments,
22
+ required this.status,
23
+ this.result,
24
+ this.error,
25
+ this.durationMs,
26
+ required this.timestamp,
27
+ });
28
+}
lib/features/chat/providers/chat_notifier.dart
new
+1097
@@ -0,0 +1,1097 @@
1
+import 'dart:async';
2
+import 'dart:io';
3
+import 'dart:math';
4
+
5
+import 'package:dio/dio.dart';
6
+import 'package:flutter_riverpod/flutter_riverpod.dart';
7
+import 'package:localmind/core/logger/app_logger.dart';
8
+import 'package:localmind/core/models/enums.dart';
9
+import 'package:localmind/core/providers/app_providers.dart';
10
+import 'package:localmind/core/providers/review_prompt_providers.dart';
11
+import 'package:localmind/core/providers/chat_background_service_provider.dart';
12
+import 'package:localmind/core/providers/storage_providers.dart';
13
+import 'package:localmind/core/services/message_save_service.dart';
14
+import 'package:localmind/core/storage/entities.dart';
15
+import 'package:localmind/features/conversations/data/models/conversation.dart';
16
+import 'package:localmind/features/conversations/providers/conversation_providers.dart'
17
+ as conv;
18
+import 'package:localmind/features/models/data/models/model_info.dart';
19
+import 'package:localmind/features/on_device/providers/on_device_providers.dart';
20
+import 'package:localmind/features/personas/providers/personas_providers.dart';
21
+import 'package:localmind/features/servers/data/models/server.dart';
22
+import 'package:localmind/features/servers/providers/server_providers.dart';
23
+import 'package:localmind/objectbox.g.dart';
24
+import '../data/chat_service.dart';
25
+import '../data/models/chat_parameters.dart';
26
+import '../data/models/message.dart' hide ToolCallData;
27
+import '../data/tools/tool_definition.dart';
28
+import '../data/tools/tool_execution_loop.dart';
29
+import '../data/tools/adapters/tool_transport_adapter.dart' show ParsedToolCall;
30
+import 'chat_mcp_providers.dart';
31
+import 'chat_params_providers.dart';
32
+import 'chat_service_providers.dart';
33
+import 'model_selection_providers.dart';
34
+import 'tooling_providers.dart';
35
+
36
+class PendingToolApproval {
37
+ final ParsedToolCall toolCall;
38
+ final Completer<bool> completer;
39
+
40
+ PendingToolApproval({required this.toolCall, required this.completer});
41
+}
42
+
43
+class ChatState {
44
+ final List<Message> messages;
45
+ final bool isStreaming;
46
+ final bool isLoading;
47
+ final String? errorMessage;
48
+ final Message? streamingMessage;
49
+ final PendingToolApproval? pendingToolApproval;
50
+
51
+ const ChatState({
52
+ this.messages = const [],
53
+ this.isStreaming = false,
54
+ this.isLoading = false,
55
+ this.errorMessage,
56
+ this.streamingMessage,
57
+ this.pendingToolApproval,
58
+ });
59
+
60
+ ChatState copyWith({
61
+ List<Message>? messages,
62
+ bool? isStreaming,
63
+ bool? isLoading,
64
+ String? errorMessage,
65
+ Message? streamingMessage,
66
+ PendingToolApproval? pendingToolApproval,
67
+ bool clearError = false,
68
+ bool clearStreaming = false,
69
+ bool clearPendingApproval = false,
70
+ }) {
71
+ return ChatState(
72
+ messages: messages ?? this.messages,
73
+ isStreaming: isStreaming ?? this.isStreaming,
74
+ isLoading: isLoading ?? this.isLoading,
75
+ errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
76
+ streamingMessage: clearStreaming
77
+ ? null
78
+ : (streamingMessage ?? this.streamingMessage),
79
+ pendingToolApproval: clearPendingApproval
80
+ ? null
81
+ : (pendingToolApproval ?? this.pendingToolApproval),
82
+ );
83
+ }
84
+}
85
+
86
+final chatProvider = NotifierProvider<ChatNotifier, ChatState>(() {
87
+ return ChatNotifier();
88
+});
89
+
90
+class ChatNotifier extends Notifier<ChatState> {
91
+ static const _checkpointChunkThreshold = 20;
92
+ static const _checkpointTimeThreshold = Duration(seconds: 2);
93
+
94
+ StreamSubscription<ChatResponse>? _streamSubscription;
95
+ Timer? _uiUpdateTimer;
96
+ Message? _latestStreamingMessage;
97
+ String? _currentConversationId;
98
+ int _chunkCount = 0;
99
+ DateTime? _lastCheckpointTime;
100
+ int _lastSavedContentLength = 0;
101
+ int _lastSavedReasoningLength = 0;
102
+
103
+ MessageSaveService? _saveService;
104
+
105
+ void approveTool(bool approved) {
106
+ final pending = state.pendingToolApproval;
107
+ if (pending != null && !pending.completer.isCompleted) {
108
+ pending.completer.complete(approved);
109
+ }
110
+ }
111
+
112
+ void _clearPendingApproval() {
113
+ final pending = state.pendingToolApproval;
114
+ if (pending != null) {
115
+ if (!pending.completer.isCompleted) {
116
+ pending.completer.complete(false);
117
+ }
118
+ state = state.copyWith(clearPendingApproval: true);
119
+ }
120
+ }
121
+
122
+ @override
123
+ ChatState build() {
124
+ final db = ref.read(databaseProvider);
125
+ _saveService = MessageSaveService(db);
126
+ ref.onDispose(() {
127
+ _uiUpdateTimer?.cancel();
128
+ _streamSubscription?.cancel();
129
+ _saveService?.dispose();
130
+ final pending = state.pendingToolApproval;
131
+ if (pending != null && !pending.completer.isCompleted) {
132
+ pending.completer.complete(false);
133
+ }
134
+ });
135
+ return const ChatState();
136
+ }
137
+
138
+ Future<void> loadConversation(Conversation conversation) async {
139
+ await cancelStream();
140
+ _currentConversationId = conversation.id;
141
+ ref.read(smartReplyServiceProvider).reset();
142
+ state = state.copyWith(
143
+ isLoading: true,
144
+ errorMessage: null,
145
+ clearError: true,
146
+ );
147
+
148
+ try {
149
+ final db = ref.read(databaseProvider);
150
+
151
+ final messages = await db.store.runInTransactionAsync(
152
+ TxMode.write,
153
+ _loadMessagesInBackground,
154
+ conversation.id,
155
+ );
156
+
157
+ state = ChatState(messages: messages, isLoading: false);
158
+ ref
159
+ .read(conv.activeConversationProvider.notifier)
160
+ .setActiveConversation(conversation);
161
+
162
+ ref
163
+ .read(chatMcpConfigProvider.notifier)
164
+ .setEnabled(conversation.mcpEnabled ?? true);
165
+ } catch (e, stackTrace) {
166
+ Log.fatal(error: e, stackTrace: stackTrace);
167
+ state = state.copyWith(
168
+ isLoading: false,
169
+ errorMessage: 'Failed to load conversation: $e',
170
+ );
171
+ }
172
+ }
173
+
174
+ static List<Message> _loadMessagesInBackground(
175
+ Store store,
176
+ String conversationId,
177
+ ) {
178
+ final convBox = store.box<ConversationEntity>();
179
+ final messageBox = store.box<MessageEntity>();
180
+
181
+ final convQuery = convBox
182
+ .query(ConversationEntity_.id.equals(conversationId))
183
+ .build();
184
+ final convEntity = convQuery.findFirst();
185
+ convQuery.close();
186
+
187
+ List<Message> messages = [];
188
+
189
+ if (convEntity != null) {
190
+ final relatedEntities = convEntity.messages;
191
+ if (relatedEntities.isNotEmpty) {
192
+ messages = relatedEntities.map((e) => e.toDomain()).toList();
193
+ } else {
194
+ final query = messageBox
195
+ .query(MessageEntity_.conversationUid.equals(conversationId))
196
+ .build();
197
+ final manualEntities = query.find();
198
+ query.close();
199
+
200
+ if (manualEntities.isNotEmpty) {
201
+ for (final e in manualEntities) {
202
+ e.conversation.target = convEntity;
203
+ messageBox.put(e);
204
+ }
205
+ messages = manualEntities.map((e) => e.toDomain()).toList();
206
+ }
207
+ }
208
+ } else {
209
+ final query = messageBox
210
+ .query(MessageEntity_.conversationUid.equals(conversationId))
211
+ .build();
212
+ final entities = query.find();
213
+ query.close();
214
+ messages = entities.map((e) => e.toDomain()).toList();
215
+ }
216
+
217
+ messages.sort((a, b) => a.createdAt.compareTo(b.createdAt));
218
+ return messages;
219
+ }
220
+
221
+ Future<void> startNewConversation() async {
222
+ await cancelStream();
223
+ _currentConversationId = null;
224
+ ref.read(smartReplyServiceProvider).reset();
225
+ state = const ChatState();
226
+ ref
227
+ .read(conv.activeConversationProvider.notifier)
228
+ .setActiveConversation(null);
229
+
230
+ final settings = ref.read(settingsProvider);
231
+ ref
232
+ .read(chatMcpConfigProvider.notifier)
233
+ .setEnabled(settings.newChatMcpEnabled);
234
+ }
235
+
236
+ String generateUuid() {
237
+ final random = Random.secure();
238
+ final bytes = List<int>.generate(16, (_) => random.nextInt(256));
239
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
240
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
241
+ return [
242
+ bytes.sublist(0, 4),
243
+ bytes.sublist(4, 6),
244
+ bytes.sublist(6, 8),
245
+ bytes.sublist(8, 10),
246
+ bytes.sublist(10, 16),
247
+ ]
248
+ .map((b) => b.map((e) => e.toRadixString(16).padLeft(2, '0')).join())
249
+ .join('-');
250
+ }
251
+
252
+ bool _shouldCheckpointSave(Message message) {
253
+ final now = DateTime.now();
254
+ final contentLength = message.content.length;
255
+ final reasoningLength = message.reasoningContent?.length ?? 0;
256
+
257
+ final hasNewContent =
258
+ contentLength > _lastSavedContentLength ||
259
+ reasoningLength > _lastSavedReasoningLength;
260
+
261
+ if (!hasNewContent) return false;
262
+
263
+ final meetsChunkThreshold = _chunkCount >= _checkpointChunkThreshold;
264
+
265
+ final meetsTimeThreshold =
266
+ _lastCheckpointTime != null &&
267
+ now.difference(_lastCheckpointTime!) >= _checkpointTimeThreshold;
268
+
269
+ return meetsChunkThreshold || meetsTimeThreshold;
270
+ }
271
+
272
+ void _resetCheckpointMetrics() {
273
+ _chunkCount = 0;
274
+ _lastCheckpointTime = DateTime.now();
275
+ }
276
+
277
+ void _updateSavedMetrics(Message message) {
278
+ _lastSavedContentLength = message.content.length;
279
+ _lastSavedReasoningLength = message.reasoningContent?.length ?? 0;
280
+ }
281
+
282
+ Future<void> checkpointStreamingMessage({bool flush = false}) async {
283
+ final latest = _latestStreamingMessage ?? state.streamingMessage;
284
+ if (latest == null) return;
285
+
286
+ _saveService?.enqueue(latest);
287
+ _updateSavedMetrics(latest);
288
+ _resetCheckpointMetrics();
289
+
290
+ if (flush) {
291
+ await _saveService?.flush();
292
+ }
293
+ }
294
+
295
+ void _replaceMessageInState(Message message, {bool clearStreaming = false}) {
296
+ final messageIndex = state.messages.indexWhere((m) => m.id == message.id);
297
+ if (messageIndex == -1) {
298
+ state = state.copyWith(
299
+ streamingMessage: clearStreaming ? null : message,
300
+ clearStreaming: clearStreaming,
301
+ );
302
+ return;
303
+ }
304
+
305
+ final updatedMessages = List<Message>.from(state.messages);
306
+ updatedMessages[messageIndex] = message;
307
+ state = state.copyWith(
308
+ messages: updatedMessages,
309
+ streamingMessage: clearStreaming ? null : message,
310
+ clearStreaming: clearStreaming,
311
+ );
312
+ }
313
+
314
+ Future<void> sendMessage(String content, {List<File>? attachments}) async {
315
+ final server = ref.read(activeServerProvider);
316
+ final selectedModel = ref.read(selectedModelProvider);
317
+ final chatParams = ref.read(chatParamsProvider);
318
+ final chatService = ref.read(chatServiceProvider);
319
+ final settings = ref.read(settingsProvider);
320
+
321
+ if (server == null) {
322
+ state = state.copyWith(errorMessage: 'No server connected');
323
+ return;
324
+ }
325
+
326
+ if (content.trim().isEmpty) return;
327
+
328
+ if (_currentConversationId == null) {
329
+ final server = ref.read(activeServerProvider);
330
+ final selectedModel = ref.read(selectedModelProvider);
331
+ final selectedPersona = ref.read(selectedPersonaProvider);
332
+ final conversation = await ref
333
+ .read(conv.conversationsProvider.notifier)
334
+ .createConversation(
335
+ title: content.length > 50
336
+ ? '${content.substring(0, 50)}...'
337
+ : content,
338
+ serverId: server?.id,
339
+ modelId: selectedModel?.id,
340
+ personaId: selectedPersona?.id,
341
+ systemPrompt: selectedPersona?.systemPrompt,
342
+ mcpEnabled: settings.newChatMcpEnabled,
343
+ );
344
+ _currentConversationId = conversation.id;
345
+ ref
346
+ .read(conv.activeConversationProvider.notifier)
347
+ .setActiveConversation(conversation);
348
+
349
+ ref
350
+ .read(chatMcpConfigProvider.notifier)
351
+ .setEnabled(settings.newChatMcpEnabled);
352
+
353
+ ref.read(selectedPersonaProvider.notifier).clear();
354
+ }
355
+
356
+ final List<String> savedPaths = [];
357
+ if (attachments != null && attachments.isNotEmpty) {
358
+ final appDir = await ref.read(storageDirectoryProvider.future);
359
+ final attachmentsDir = Directory('${appDir.path}/attachments');
360
+ if (!await attachmentsDir.exists()) {
361
+ await attachmentsDir.create(recursive: true);
362
+ }
363
+
364
+ for (final file in attachments) {
365
+ final fileName = file.path.split('/').last;
366
+ final timestamp = DateTime.now().millisecondsSinceEpoch;
367
+ final newPath = '${attachmentsDir.path}/${timestamp}_$fileName';
368
+ await file.copy(newPath);
369
+ savedPaths.add(newPath);
370
+ }
371
+ }
372
+
373
+ final userMessage = Message(
374
+ id: generateUuid(),
375
+ conversationId: _currentConversationId!,
376
+ role: MessageRole.user,
377
+ content: content,
378
+ createdAt: DateTime.now(),
379
+ status: MessageStatus.complete,
380
+ attachmentPaths: savedPaths.isNotEmpty ? savedPaths : null,
381
+ );
382
+
383
+ final assistantMessageId = generateUuid();
384
+ var assistantMessage = Message(
385
+ id: assistantMessageId,
386
+ conversationId: _currentConversationId!,
387
+ role: MessageRole.assistant,
388
+ content: '',
389
+ createdAt: DateTime.now(),
390
+ status: MessageStatus.streaming,
391
+ modelId: selectedModel?.id,
392
+ );
393
+
394
+ state = state.copyWith(
395
+ messages: [...state.messages, userMessage, assistantMessage],
396
+ isStreaming: true,
397
+ streamingMessage: assistantMessage,
398
+ clearError: true,
399
+ );
400
+ ref.read(isStreamingProvider.notifier).setStreaming(true);
401
+
402
+ await _saveMessage(userMessage);
403
+ await _saveMessage(assistantMessage);
404
+
405
+ ref.read(chatBackgroundServiceProvider).start();
406
+
407
+ _resetCheckpointMetrics();
408
+ _updateSavedMetrics(assistantMessage);
409
+
410
+ final messagesForApi = _buildMessagesForApi(selectedModel);
411
+
412
+ try {
413
+ _streamSubscription?.cancel();
414
+ _uiUpdateTimer?.cancel();
415
+
416
+ String reasoningContent = '';
417
+ var streamingAssistantMessage = assistantMessage;
418
+ _latestStreamingMessage = streamingAssistantMessage;
419
+
420
+ if (chatService != null) {
421
+ final mcpConfig = ref.read(chatMcpConfigProvider);
422
+ final integrations =
423
+ mcpConfig.enabled && mcpConfig.integrations.isNotEmpty
424
+ ? mcpConfig.integrations
425
+ : null;
426
+
427
+ final registry = ref.read(toolRegistryProvider);
428
+ final tools = mcpConfig.enabled
429
+ ? await registry.listTools()
430
+ : const <ToolDefinition>[];
431
+
432
+ final collectedToolCalls = <ToolCallData>[];
433
+
434
+ bool stateNeedsUpdate = false;
435
+
436
+ void updateUiState() {
437
+ if (!stateNeedsUpdate) return;
438
+ stateNeedsUpdate = false;
439
+
440
+ final streamConvId = assistantMessage.conversationId;
441
+ final isCurrentContext = _currentConversationId == streamConvId;
442
+
443
+ if (isCurrentContext) {
444
+ state = state.copyWith(streamingMessage: streamingAssistantMessage);
445
+ }
446
+ }
447
+
448
+ _uiUpdateTimer = Timer.periodic(const Duration(milliseconds: 80), (_) {
449
+ updateUiState();
450
+ });
451
+
452
+ _streamSubscription = chatService
453
+ .sendMessage(
454
+ server: server,
455
+ modelId: selectedModel?.id ?? 'default',
456
+ messages: messagesForApi,
457
+ params: chatParams,
458
+ integrations: integrations,
459
+ tools: tools,
460
+ )
461
+ .listen(
462
+ (response) async {
463
+ final streamConvId = assistantMessage.conversationId;
464
+ final isCurrentContext = _currentConversationId == streamConvId;
465
+
466
+ switch (response.type) {
467
+ case ChatResponseType.message:
468
+ streamingAssistantMessage = streamingAssistantMessage
469
+ .copyWith(
470
+ content:
471
+ streamingAssistantMessage.content +
472
+ (response.content ?? ''),
473
+ isProcessing: false,
474
+ );
475
+ _latestStreamingMessage = streamingAssistantMessage;
476
+
477
+ _chunkCount++;
478
+
479
+ if (_shouldCheckpointSave(streamingAssistantMessage)) {
480
+ _saveService?.enqueue(streamingAssistantMessage);
481
+ _updateSavedMetrics(streamingAssistantMessage);
482
+ _resetCheckpointMetrics();
483
+ }
484
+
485
+ stateNeedsUpdate = true;
486
+ break;
487
+ case ChatResponseType.reasoning:
488
+ reasoningContent += response.reasoningContent ?? '';
489
+ streamingAssistantMessage = streamingAssistantMessage
490
+ .copyWith(
491
+ reasoningContent: reasoningContent,
492
+ isProcessing: false,
493
+ );
494
+ _latestStreamingMessage = streamingAssistantMessage;
495
+
496
+ _chunkCount++;
497
+
498
+ if (_shouldCheckpointSave(streamingAssistantMessage)) {
499
+ _saveService?.enqueue(streamingAssistantMessage);
500
+ _updateSavedMetrics(streamingAssistantMessage);
501
+ _resetCheckpointMetrics();
502
+ }
503
+
504
+ stateNeedsUpdate = true;
505
+ break;
506
+ case ChatResponseType.processing:
507
+ streamingAssistantMessage = streamingAssistantMessage
508
+ .copyWith(isProcessing: true);
509
+ _latestStreamingMessage = streamingAssistantMessage;
510
+ stateNeedsUpdate = true;
511
+ break;
512
+ case ChatResponseType.timeoutError:
513
+ case ChatResponseType.error:
514
+ _uiUpdateTimer?.cancel();
515
+ _uiUpdateTimer = null;
516
+ streamingAssistantMessage = streamingAssistantMessage.copyWith(
517
+ status: MessageStatus.error,
518
+ errorMessage:
519
+ response.content ??
520
+ (response.type == ChatResponseType.timeoutError
521
+ ? 'Model is taking too long to respond. This may happen with free tier models.'
522
+ : 'An unknown error occurred.'),
523
+ isProcessing: false,
524
+ );
525
+ _latestStreamingMessage = streamingAssistantMessage;
526
+
527
+ if (isCurrentContext) {
528
+ _replaceMessageInState(
529
+ streamingAssistantMessage,
530
+ clearStreaming: true,
531
+ );
532
+ state = state.copyWith(
533
+ isStreaming: false,
534
+ errorMessage: streamingAssistantMessage.errorMessage,
535
+ );
536
+ }
537
+ ref.read(isStreamingProvider.notifier).setStreaming(false);
538
+ _latestStreamingMessage = null;
539
+ await _saveService?.flush();
540
+ await _saveMessage(streamingAssistantMessage);
541
+ break;
542
+ case ChatResponseType.toolCall:
543
+ if (response.toolCall != null) {
544
+ collectedToolCalls.add(response.toolCall!);
545
+ }
546
+ break;
547
+ case ChatResponseType.invalidToolCall:
548
+ case ChatResponseType.done:
549
+ break;
550
+ }
551
+ },
552
+ onDone: () async {
553
+ _uiUpdateTimer?.cancel();
554
+ _uiUpdateTimer = null;
555
+ updateUiState();
556
+
557
+ await _saveService?.flush();
558
+ ref.read(chatBackgroundServiceProvider).stop();
559
+ final streamConvId = assistantMessage.conversationId;
560
+ final isCurrentContext = _currentConversationId == streamConvId;
561
+ final streamingMessage = streamingAssistantMessage;
562
+ if (streamingMessage != null) {
563
+ final hasContent =
564
+ streamingMessage.content.isNotEmpty ||
565
+ (streamingMessage.reasoningContent?.isNotEmpty ??
566
+ false) ||
567
+ collectedToolCalls.isNotEmpty;
568
+
569
+ if (!hasContent) {
570
+ final errorMessage = streamingMessage.copyWith(
571
+ status: MessageStatus.error,
572
+ errorMessage:
573
+ 'Model failed to respond. This may happen with free tier models that refuse certain prompts or when the service is busy.',
574
+ isProcessing: false,
575
+ );
576
+ await _saveMessage(errorMessage);
577
+ if (isCurrentContext) {
578
+ _replaceMessageInState(
579
+ errorMessage,
580
+ clearStreaming: true,
581
+ );
582
+ state = state.copyWith(
583
+ isStreaming: false,
584
+ errorMessage: errorMessage.errorMessage,
585
+ );
586
+ }
587
+ ref.read(isStreamingProvider.notifier).setStreaming(false);
588
+ _latestStreamingMessage = null;
589
+ } else {
590
+ var finalMessage = streamingMessage.copyWith(
591
+ status: MessageStatus.complete,
592
+ isProcessing: false,
593
+ );
594
+
595
+ if (mcpConfig.enabled && collectedToolCalls.isNotEmpty) {
596
+ try {
597
+ final registry = ref.read(toolRegistryProvider);
598
+ final adapter = createAdapterForServerType(server.type);
599
+
600
+ final dedupedCalls = <String, ToolCallData>{};
601
+ for (final tc in collectedToolCalls) {
602
+ dedupedCalls[tc.tool] = tc;
603
+ }
604
+
605
+ final preParsedCalls = dedupedCalls.values
606
+ .map(
607
+ (tc) => ParsedToolCall(
608
+ id: tc.tool,
609
+ name: tc.tool,
610
+ arguments: tc.arguments,
611
+ ),
612
+ )
613
+ .toList();
614
+
615
+ final loop = ToolExecutionLoop(
616
+ adapter: adapter,
617
+ registry: registry,
618
+ onRequestApproval: (call) async {
619
+ final completer = Completer<bool>();
620
+ state = state.copyWith(
621
+ pendingToolApproval: PendingToolApproval(
622
+ toolCall: call,
623
+ completer: completer,
624
+ ),
625
+ );
626
+
627
+ final result = await completer.future;
628
+
629
+ state = state.copyWith(clearPendingApproval: true);
630
+
631
+ return result;
632
+ },
633
+ );
634
+
635
+ final loopResult = await loop.run(
636
+ initialUserMessage: content,
637
+ assistantContent: streamingAssistantMessage.content,
638
+ preParsedCalls: preParsedCalls,
639
+ );
640
+
641
+ if (loopResult.events.isNotEmpty) {
642
+ finalMessage = finalMessage.copyWith(
643
+ toolSessionId: loop.sessionId,
644
+ toolEvents: loopResult.events,
645
+ );
646
+ }
647
+ } catch (e) {
648
+ Log.error('Tool execution loop failed: $e');
649
+ }
650
+ }
651
+
652
+ await _saveMessage(finalMessage);
653
+ if (isCurrentContext) {
654
+ _replaceMessageInState(
655
+ finalMessage,
656
+ clearStreaming: true,
657
+ );
658
+ state = state.copyWith(isStreaming: false);
659
+ }
660
+ ref.read(isStreamingProvider.notifier).setStreaming(false);
661
+ _latestStreamingMessage = null;
662
+ if (_currentConversationId != null) {
663
+ final preview = finalMessage.content.length > 100
664
+ ? '${finalMessage.content.substring(0, 100)}...'
665
+ : finalMessage.content;
666
+ await ref
667
+ .read(conv.conversationsProvider.notifier)
668
+ .updatePreview(
669
+ _currentConversationId!,
670
+ preview,
671
+ DateTime.now(),
672
+ );
673
+
674
+ final userMessage = state.messages
675
+ .where((m) => m.role == MessageRole.user)
676
+ .firstOrNull;
677
+ if (userMessage != null &&
678
+ userMessage.content.length > 10) {
679
+ _autoGenerateTitle(
680
+ userMessage.content,
681
+ finalMessage.content,
682
+ );
683
+ }
684
+ }
685
+
686
+ _maybeRequestReviewAfterSuccessfulCompletion(
687
+ finalMessage: finalMessage,
688
+ server: server,
689
+ selectedModel: selectedModel,
690
+ );
691
+
692
+ _chunkCount = 0;
693
+ _lastCheckpointTime = null;
694
+ _lastSavedContentLength = 0;
695
+ _lastSavedReasoningLength = 0;
696
+ }
697
+ }
698
+ },
699
+ onError: (error) async {
700
+ _uiUpdateTimer?.cancel();
701
+ _uiUpdateTimer = null;
702
+ _chunkCount = 0;
703
+ _lastCheckpointTime = null;
704
+ _lastSavedContentLength = 0;
705
+ _lastSavedReasoningLength = 0;
706
+ await _saveService?.flush();
707
+ final errorMessage =
708
+ (_latestStreamingMessage ?? state.streamingMessage)
709
+ ?.copyWith(
710
+ status: MessageStatus.error,
711
+ errorMessage: error.toString(),
712
+ );
713
+ if (errorMessage != null) {
714
+ await _saveMessage(errorMessage);
715
+
716
+ ref.read(chatBackgroundServiceProvider).stop();
717
+
718
+ final streamConvId = assistantMessage.conversationId;
719
+ if (_currentConversationId == streamConvId) {
720
+ _replaceMessageInState(errorMessage, clearStreaming: true);
721
+ state = state.copyWith(
722
+ isStreaming: false,
723
+ errorMessage: error.toString(),
724
+ );
725
+ }
726
+ }
727
+ ref.read(isStreamingProvider.notifier).setStreaming(false);
728
+ _latestStreamingMessage = null;
729
+ },
730
+ );
731
+ }
732
+ } catch (e) {
733
+ _uiUpdateTimer?.cancel();
734
+ _uiUpdateTimer = null;
735
+ final errorMsg = assistantMessage.copyWith(
736
+ status: MessageStatus.error,
737
+ errorMessage: e.toString(),
738
+ isProcessing: false,
739
+ );
740
+ await _saveMessage(errorMsg);
741
+
742
+ state = state.copyWith(
743
+ isStreaming: false,
744
+ errorMessage: e.toString(),
745
+ clearStreaming: true,
746
+ );
747
+ ref.read(isStreamingProvider.notifier).setStreaming(false);
748
+ _latestStreamingMessage = null;
749
+ ref.read(chatBackgroundServiceProvider).stop();
750
+ }
751
+ }
752
+
753
+ List<Message> _buildMessagesForApi(ModelInfo? selectedModel) {
754
+ final settings = ref.read(settingsProvider);
755
+ final messages = <Message>[];
756
+
757
+ final personaPrompt = _getPersonaSystemPrompt();
758
+ if (personaPrompt != null) {
759
+ messages.add(
760
+ Message(
761
+ id: 'system-$_currentConversationId',
762
+ conversationId: _currentConversationId ?? '',
763
+ role: MessageRole.system,
764
+ content: personaPrompt,
765
+ createdAt: DateTime.now(),
766
+ status: MessageStatus.complete,
767
+ ),
768
+ );
769
+ } else if (settings.showSystemMessages) {
770
+ messages.add(
771
+ Message(
772
+ id: 'system-default-$_currentConversationId',
773
+ conversationId: _currentConversationId ?? '',
774
+ role: MessageRole.system,
775
+ content:
776
+ 'You are LocalMind, a helpful AI assistant. Provide clear, accurate, and concise responses.',
777
+ createdAt: DateTime.now(),
778
+ status: MessageStatus.complete,
779
+ ),
780
+ );
781
+ }
782
+
783
+ for (final message in state.messages) {
784
+ if (message.role != MessageRole.system || settings.showSystemMessages) {
785
+ messages.add(message);
786
+ }
787
+ }
788
+
789
+ final contextLength = ref.read(chatParamsProvider).contextLength;
790
+ return _truncateToContextWindow(messages, contextLength);
791
+ }
792
+
793
+ String? _getPersonaSystemPrompt() {
794
+ final conversation = ref.read(conv.activeConversationProvider);
795
+ final personaId = conversation?.personaId;
796
+ if (personaId == null) return null;
797
+
798
+ if (conversation?.systemPrompt != null &&
799
+ conversation!.systemPrompt!.isNotEmpty) {
800
+ return conversation.systemPrompt;
801
+ }
802
+
803
+ try {
804
+ final personasAsync = ref.read(personasNotifierProvider);
805
+ final personas = personasAsync.value ?? [];
806
+ final persona = personas.firstWhere(
807
+ (p) => p.id == personaId,
808
+ orElse: () => throw Exception('Persona not found'),
809
+ );
810
+ if (persona.systemPrompt.isNotEmpty) {
811
+ return persona.systemPrompt;
812
+ }
813
+ } catch (_) {}
814
+
815
+ return null;
816
+ }
817
+
818
+ List<Message> _truncateToContextWindow(
819
+ List<Message> messages,
820
+ int contextLength,
821
+ ) {
822
+ if (messages.isEmpty) return messages;
823
+
824
+ int estimatedTokens = 0;
825
+ final result = <Message>[];
826
+ const tokensPerChar = 4;
827
+
828
+ for (int i = messages.length - 1; i >= 0; i--) {
829
+ final message = messages[i];
830
+ estimatedTokens += (message.content.length / tokensPerChar).ceil();
831
+
832
+ if (estimatedTokens > contextLength) {
833
+ break;
834
+ }
835
+ result.insert(0, message);
836
+ }
837
+
838
+ return result;
839
+ }
840
+
841
+ void _autoGenerateTitle(String userContent, String assistantContent) {
842
+ final settings = ref.read(settingsProvider);
843
+ if (!settings.autoGenerateTitle) return;
844
+ if (_currentConversationId == null) return;
845
+
846
+ final activeConv = ref.read(conv.activeConversationProvider);
847
+ if (activeConv == null) return;
848
+ if (activeConv.title != 'New Chat') return;
849
+
850
+ final title = userContent.length > 40
851
+ ? '${userContent.substring(0, 40)}...'
852
+ : userContent;
853
+
854
+ ref
855
+ .read(conv.conversationsProvider.notifier)
856
+ .renameConversation(_currentConversationId!, title);
857
+ }
858
+
859
+ void _maybeRequestReviewAfterSuccessfulCompletion({
860
+ required Message finalMessage,
861
+ required Server server,
862
+ required ModelInfo? selectedModel,
863
+ }) {
864
+ final modelId =
865
+ selectedModel?.id ??
866
+ (server.type == ServerType.onDevice
867
+ ? ref.read(onDeviceEngineProvider).loadedModelId
868
+ : null);
869
+
870
+ unawaited(
871
+ ref
872
+ .read(reviewPromptServiceProvider)
873
+ .maybeRequestReviewAfterSuccessfulChat(
874
+ assistantContent: finalMessage.content,
875
+ serverType: server.type,
876
+ modelId: modelId,
877
+ usedCustomPersona: _isUsingCustomPersona(),
878
+ )
879
+ .catchError((Object error, StackTrace stackTrace) {
880
+ Log.error('Review prompt request failed: $error');
881
+ return false;
882
+ }),
883
+ );
884
+ }
885
+
886
+ bool _isUsingCustomPersona() {
887
+ final activeConversation = ref.read(conv.activeConversationProvider);
888
+ final personaId = activeConversation?.personaId;
889
+ if (personaId == null) return false;
890
+
891
+ final personas = ref.read(personasNotifierProvider).value ?? [];
892
+ return personas.any(
893
+ (persona) => persona.id == personaId && !persona.isBuiltIn,
894
+ );
895
+ }
896
+
897
+ Future<void> cancelStream() async {
898
+ _uiUpdateTimer?.cancel();
899
+ _uiUpdateTimer = null;
900
+ _clearPendingApproval();
901
+ await _streamSubscription?.cancel();
902
+ _streamSubscription = null;
903
+ ref.read(chatServiceProvider)?.cancelStream();
904
+ ref.read(chatBackgroundServiceProvider).stop();
905
+
906
+ _chunkCount = 0;
907
+ _lastCheckpointTime = null;
908
+ _lastSavedContentLength = 0;
909
+ _lastSavedReasoningLength = 0;
910
+
911
+ await _saveService?.flush();
912
+
913
+ final streamingMessage = _latestStreamingMessage ?? state.streamingMessage;
914
+ if (streamingMessage != null) {
915
+ final finalMessage = streamingMessage.copyWith(
916
+ status: MessageStatus.cancelled,
917
+ isProcessing: false,
918
+ );
919
+ await _saveMessage(finalMessage);
920
+ _replaceMessageInState(finalMessage, clearStreaming: true);
921
+ }
922
+
923
+ state = state.copyWith(isStreaming: false, clearStreaming: true);
924
+ ref.read(isStreamingProvider.notifier).setStreaming(false);
925
+ _latestStreamingMessage = null;
926
+ }
927
+
928
+ Future<void> retryLastMessage() async {
929
+ final messages = state.messages;
930
+ if (messages.isEmpty) return;
931
+
932
+ if (messages.last.role == MessageRole.assistant) {
933
+ final lastAssistantIndex = messages.lastIndexWhere(
934
+ (m) => m.role == MessageRole.assistant,
935
+ );
936
+ if (lastAssistantIndex > 0) {
937
+ final userMessage = messages[lastAssistantIndex - 1];
938
+ final messagesToRemove = messages.sublist(lastAssistantIndex);
939
+ final db = ref.read(databaseProvider);
940
+
941
+ for (final msg in messagesToRemove) {
942
+ final query = db.messageBox
943
+ .query(MessageEntity_.id.equals(msg.id))
944
+ .build();
945
+ db.messageBox.removeMany(query.findIds());
946
+ query.close();
947
+ }
948
+
949
+ state = state.copyWith(
950
+ messages: messages.sublist(0, lastAssistantIndex),
951
+ clearStreaming: true,
952
+ );
953
+
954
+ await sendMessage(
955
+ userMessage.content,
956
+ attachments: userMessage.attachmentPaths
957
+ ?.map((p) => File(p))
958
+ .toList(),
959
+ );
960
+ }
961
+ }
962
+ }
963
+
964
+ Future<void> retryMessage(String messageId) async {
965
+ final messageIndex = state.messages.indexWhere((m) => m.id == messageId);
966
+ if (messageIndex == -1) return;
967
+
968
+ final message = state.messages[messageIndex];
969
+ if (message.role != MessageRole.assistant) return;
970
+
971
+ if (messageIndex > 0 &&
972
+ state.messages[messageIndex - 1].role == MessageRole.user) {
973
+ final userMessage = state.messages[messageIndex - 1];
974
+
975
+ final messagesToRemove = state.messages.sublist(messageIndex);
976
+ final db = ref.read(databaseProvider);
977
+
978
+ for (final msg in messagesToRemove) {
979
+ final query = db.messageBox
980
+ .query(MessageEntity_.id.equals(msg.id))
981
+ .build();
982
+ db.messageBox.removeMany(query.findIds());
983
+ query.close();
984
+ }
985
+
986
+ state = state.copyWith(
987
+ messages: state.messages.sublist(0, messageIndex),
988
+ clearStreaming: true,
989
+ );
990
+
991
+ await sendMessage(
992
+ userMessage.content,
993
+ attachments: userMessage.attachmentPaths?.map((p) => File(p)).toList(),
994
+ );
995
+ }
996
+ }
997
+
998
+ Future<void> deleteMessage(String messageId) async {
999
+ final db = ref.read(databaseProvider);
1000
+ final query = db.messageBox
1001
+ .query(MessageEntity_.id.equals(messageId))
1002
+ .build();
1003
+ db.messageBox.removeMany(query.findIds());
1004
+ query.close();
1005
+
1006
+ state = state.copyWith(
1007
+ messages: state.messages.where((m) => m.id != messageId).toList(),
1008
+ );
1009
+ }
1010
+
1011
+ Future<void> editMessage(String messageId, String newContent) async {
1012
+ final messageIndex = state.messages.indexWhere((m) => m.id == messageId);
1013
+ if (messageIndex == -1) return;
1014
+
1015
+ final message = state.messages[messageIndex];
1016
+ if (message.role != MessageRole.user) return;
1017
+ if (newContent.trim().isEmpty) return;
1018
+ if (newContent == message.content) return;
1019
+
1020
+ final attachmentPaths = message.attachmentPaths;
1021
+
1022
+ final messagesToRemove = state.messages.sublist(messageIndex);
1023
+ final db = ref.read(databaseProvider);
1024
+ for (final msg in messagesToRemove) {
1025
+ final query = db.messageBox
1026
+ .query(MessageEntity_.id.equals(msg.id))
1027
+ .build();
1028
+ db.messageBox.removeMany(query.findIds());
1029
+ query.close();
1030
+ }
1031
+
1032
+ state = state.copyWith(
1033
+ messages: state.messages.sublist(0, messageIndex),
1034
+ clearStreaming: true,
1035
+ );
1036
+
1037
+ await sendMessage(
1038
+ newContent,
1039
+ attachments: attachmentPaths?.map((p) => File(p)).toList(),
1040
+ );
1041
+ }
1042
+
1043
+ Future<void> _saveMessage(Message message) async {
1044
+ final db = ref.read(databaseProvider);
1045
+ await db.store.runInTransactionAsync(
1046
+ TxMode.write,
1047
+ _saveMessageInBackground,
1048
+ message,
1049
+ );
1050
+ }
1051
+
1052
+ static void _saveMessageInBackground(Store store, Message message) {
1053
+ final box = store.box<MessageEntity>();
1054
+ final convBox = store.box<ConversationEntity>();
1055
+
1056
+ final query = box.query(MessageEntity_.id.equals(message.id)).build();
1057
+ final existing = query.findFirst();
1058
+ query.close();
1059
+
1060
+ final entity = MessageEntity.fromDomain(message);
1061
+ if (existing != null) {
1062
+ entity.internalId = existing.internalId;
1063
+ }
1064
+
1065
+ final convQuery = convBox
1066
+ .query(ConversationEntity_.id.equals(message.conversationId))
1067
+ .build();
1068
+ final convEntity = convQuery.findFirst();
1069
+ convQuery.close();
1070
+
1071
+ if (convEntity != null) {
1072
+ entity.conversation.target = convEntity;
1073
+ entity.conversationUid = convEntity.id;
1074
+ } else {
1075
+ entity.conversationUid = message.conversationId;
1076
+ }
1077
+
1078
+ box.put(entity);
1079
+ }
1080
+
1081
+ Future<void> clearConversation() async {
1082
+ final db = ref.read(databaseProvider);
1083
+ if (_currentConversationId != null) {
1084
+ final query = db.messageBox
1085
+ .query(MessageEntity_.conversationUid.equals(_currentConversationId!))
1086
+ .build();
1087
+ db.messageBox.removeMany(query.findIds());
1088
+ query.close();
1089
+ }
1090
+ _currentConversationId = null;
1091
+ state = const ChatState();
1092
+ }
1093
+
1094
+ void clearError() {
1095
+ state = state.copyWith(clearError: true);
1096
+ }
1097
+}
lib/features/chat/providers/chat_params_providers.dart
new
+61
@@ -0,0 +1,61 @@
1
+import 'package:flutter_riverpod/flutter_riverpod.dart';
2
+import 'package:localmind/core/providers/app_providers.dart';
3
+import 'package:localmind/features/conversations/providers/conversation_providers.dart'
4
+ as conv;
5
+import 'package:localmind/features/personas/providers/personas_providers.dart';
6
+import '../data/models/chat_parameters.dart';
7
+
8
+final chatParamsProvider = Provider<ChatParameters>((ref) {
9
+ final settings = ref.watch(settingsProvider);
10
+ final activeConv = ref.watch(conv.activeConversationProvider);
11
+
12
+ double temperature = settings.temperature;
13
+ double topP = settings.topP;
14
+ int maxTokens = settings.maxTokens;
15
+ int contextLength = settings.contextLength;
16
+
17
+ if (activeConv?.temperature != null) {
18
+ temperature = activeConv!.temperature!;
19
+ }
20
+ if (activeConv?.topP != null) {
21
+ topP = activeConv!.topP!;
22
+ }
23
+ if (activeConv?.maxTokens != null) {
24
+ maxTokens = activeConv!.maxTokens!;
25
+ }
26
+ if (activeConv?.contextLength != null) {
27
+ contextLength = activeConv!.contextLength!;
28
+ }
29
+
30
+ String? systemPrompt;
31
+ if (activeConv?.personaId != null) {
32
+ final personaId = activeConv!.personaId;
33
+ try {
34
+ final personasAsync = ref.read(personasNotifierProvider);
35
+ final personas = personasAsync.value ?? [];
36
+ final persona = personas.firstWhere(
37
+ (p) => p.id == personaId,
38
+ orElse: () => throw Exception('Persona not found'),
39
+ );
40
+ systemPrompt = persona.systemPrompt;
41
+ if (persona.preferredParams != null) {
42
+ final params = persona.preferredParams as Map<String, dynamic>;
43
+ if (params['temperature'] != null) {
44
+ temperature = (params['temperature'] as num).toDouble();
45
+ }
46
+ if (params['topP'] != null) topP = (params['topP'] as num).toDouble();
47
+ if (params['maxTokens'] != null) {
48
+ maxTokens = (params['maxTokens'] as num).toInt();
49
+ }
50
+ }
51
+ } catch (_) {}
52
+ }
53
+
54
+ return ChatParameters(
55
+ temperature: temperature,
56
+ topP: topP,
57
+ maxTokens: maxTokens,
58
+ contextLength: contextLength,
59
+ systemPrompt: systemPrompt,
60
+ );
61
+});
lib/features/chat/providers/chat_providers.dart
+6
-1455
@@ -1,1455 +1,6 @@
1
-import 'dart:async';
2
-import 'dart:io';
3
-import 'dart:math';
4
-
5
-import 'package:dio/dio.dart';
6
-import '../../../core/logger/app_logger.dart';
7
-import '../../../core/services/message_save_service.dart';
8
-
9
-import 'package:flutter_riverpod/flutter_riverpod.dart';
10
-import '../../../core/providers/app_providers.dart';
11
-import 'package:localmind/features/conversations/providers/conversation_providers.dart'
12
- as conv;
13
-import 'package:localmind/features/models/data/models/model_info.dart';
14
-import 'package:localmind/features/personas/providers/personas_providers.dart';
15
-import 'package:localmind/features/servers/data/models/server.dart';
16
-import 'package:localmind/features/servers/providers/server_providers.dart';
17
-import './chat_mcp_providers.dart';
18
-
19
-import '../../../core/models/enums.dart';
20
-import '../../../core/providers/review_prompt_providers.dart';
21
-import '../../../core/providers/service_providers.dart';
22
-import '../../../core/providers/storage_providers.dart';
23
-import '../../on_device/providers/on_device_providers.dart';
24
-import '../../on_device/data/on_device_chat_service.dart';
25
-import '../../on_device/data/on_device_gemma_service.dart';
26
-import '../../on_device/data/on_device_llama_service.dart';
27
-import '../../on_device/data/models/on_device_model.dart';
28
-import '../../../core/storage/entities.dart';
29
-import '../../../objectbox.g.dart';
30
-import '../../conversations/data/models/conversation.dart';
31
-import '../data/chat_service.dart';
32
-import '../data/models/chat_parameters.dart';
33
-import '../data/models/message.dart' hide ToolCallData;
34
-import '../data/smart_reply_service.dart';
35
-import '../data/tools/tool_definition.dart';
36
-import '../data/tools/tool_execution_loop.dart';
37
-import '../data/tools/adapters/tool_transport_adapter.dart' show ParsedToolCall;
38
-import '../../../core/providers/chat_background_service_provider.dart';
39
-import './tooling_providers.dart';
40
-
41
-final selectedModelProvider =
42
- NotifierProvider<SelectedModelNotifier, ModelInfo?>(() {
43
- return SelectedModelNotifier();
44
- });
45
-
46
-class SelectedModelNotifier extends Notifier<ModelInfo?> {
47
- @override
48
- ModelInfo? build() => null;
49
-
50
- void setModel(ModelInfo? model) {
51
- state = model;
52
- }
53
-
54
- void clear() {
55
- state = null;
56
- }
57
-}
58
-
59
-final autoSelectFirstLoadedModelProvider = FutureProvider<void>((ref) async {
60
- final activeServer = ref.watch(activeServerProvider);
61
- final status = ref.watch(connectionStatusProvider);
62
-
63
- if (activeServer == null) {
64
- // Use await Future.value() to defer outside the current build frame.
65
- await Future.value();
66
- ref.read(selectedModelProvider.notifier).clear();
67
- return;
68
- }
69
-
70
- // If server is not connected, we can't fetch loaded models
71
- if (status != ConnectionStatus.connected) return;
72
-
73
- final selectedModel = ref.read(selectedModelProvider);
74
-
75
- // If a model is selected but belongs to a different server, clear it
76
- if (selectedModel != null && selectedModel.serverId != activeServer.id) {
77
- await Future.value();
78
- ref.read(selectedModelProvider.notifier).clear();
79
- // Continue to try auto-selecting for the new server
80
- } else if (selectedModel != null) {
81
- // Already have a valid model selected for this server
82
- return;
83
- }
84
-
85
- // Only auto-select for servers that support listing running models
86
- if (activeServer.type == ServerType.openRouter ||
87
- activeServer.type == ServerType.openAICompatible) {
88
- return;
89
- }
90
-
91
- try {
92
- final Set<String> loadedModels;
93
- if (activeServer.type == ServerType.onDevice) {
94
- final engine = ref.watch(onDeviceEngineProvider);
95
- loadedModels = engine.loadedModelId != null
96
- ? {engine.loadedModelId!}
97
- : {};
98
- } else {
99
- final apiService = ref.read(serverApiServiceProvider);
100
- loadedModels = await apiService.fetchRunningModels(activeServer);
101
- }
102
-
103
- if (loadedModels.isEmpty) return;
104
-
105
- final availableModels = await ref.read(
106
- availableModelsProvider(activeServer.id).future,
107
- );
108
- if (availableModels.isEmpty) return;
109
-
110
- final typedModels = availableModels.cast<ModelInfo>();
111
-
112
- // For LM Studio, often the 'key' is what's used. For Ollama, it's 'name'.
113
- // ServerApiService parses these into the same ID field.
114
- final firstLoadedModel = typedModels
115
- .where((m) => loadedModels.contains(m.id))
116
- .firstOrNull;
117
-
118
- if (firstLoadedModel != null) {
119
- ref.read(selectedModelProvider.notifier).setModel(firstLoadedModel);
120
- }
121
- } catch (e) {
122
- // Silently fail auto-selection
123
- }
124
-});
125
-
126
-final isStreamingProvider = NotifierProvider<IsStreamingNotifier, bool>(() {
127
- return IsStreamingNotifier();
128
-});
129
-
130
-class IsStreamingNotifier extends Notifier<bool> {
131
- @override
132
- bool build() => false;
133
-
134
- void setStreaming(bool streaming) {
135
- state = streaming;
136
- }
137
-}
138
-
139
-class ModelLoadingState {
140
- final bool isLoading;
141
- final String? modelId;
142
- final double? progress;
143
-
144
- const ModelLoadingState({
145
- this.isLoading = false,
146
- this.modelId,
147
- this.progress,
148
- });
149
-
150
- ModelLoadingState copyWith({
151
- bool? isLoading,
152
- String? modelId,
153
- double? progress,
154
- }) {
155
- return ModelLoadingState(
156
- isLoading: isLoading ?? this.isLoading,
157
- modelId: modelId ?? this.modelId,
158
- progress: progress ?? this.progress,
159
- );
160
- }
161
-}
162
-
163
-final modelLoadingProvider =
164
- NotifierProvider<ModelLoadingNotifier, ModelLoadingState>(() {
165
- return ModelLoadingNotifier();
166
- });
167
-
168
-class ModelLoadingNotifier extends Notifier<ModelLoadingState> {
169
- @override
170
- ModelLoadingState build() => const ModelLoadingState();
171
-
172
- void setLoading(String modelId, {double? progress}) {
173
- state = ModelLoadingState(
174
- isLoading: true,
175
- modelId: modelId,
176
- progress: progress,
177
- );
178
- }
179
-
180
- void updateProgress(double progress) {
181
- state = state.copyWith(progress: progress);
182
- }
183
-
184
- void setLoaded() {
185
- state = const ModelLoadingState();
186
- }
187
-}
188
-
189
-final modelThinkingProvider = Provider<bool>((ref) {
190
- return ref.watch(isStreamingProvider);
191
-});
192
-
193
-final chatParamsProvider = Provider<ChatParameters>((ref) {
194
- final settings = ref.watch(settingsProvider);
195
- final activeConv = ref.watch(conv.activeConversationProvider);
196
-
197
- // Start with app-level defaults
198
- double temperature = settings.temperature;
199
- double topP = settings.topP;
200
- int maxTokens = settings.maxTokens;
201
- int contextLength = settings.contextLength;
202
-
203
- // Apply per-conversation overrides if they exist
204
- if (activeConv?.temperature != null) {
205
- temperature = activeConv!.temperature!;
206
- }
207
- if (activeConv?.topP != null) {
208
- topP = activeConv!.topP!;
209
- }
210
- if (activeConv?.maxTokens != null) {
211
- maxTokens = activeConv!.maxTokens!;
212
- }
213
- if (activeConv?.contextLength != null) {
214
- contextLength = activeConv!.contextLength!;
215
- }
216
-
217
- String? systemPrompt;
218
- if (activeConv?.personaId != null) {
219
- final personaId = activeConv!.personaId;
220
- try {
221
- final personasAsync = ref.read(personasNotifierProvider);
222
- final personas = personasAsync.value ?? [];
223
- final persona = personas.firstWhere(
224
- (p) => p.id == personaId,
225
- orElse: () => throw Exception('Persona not found'),
226
- );
227
- systemPrompt = persona.systemPrompt;
228
- if (persona.preferredParams != null) {
229
- final params = persona.preferredParams as Map<String, dynamic>;
230
- if (params['temperature'] != null) {
231
- temperature = (params['temperature'] as num).toDouble();
232
- }
233
- if (params['topP'] != null) topP = (params['topP'] as num).toDouble();
234
- if (params['maxTokens'] != null) {
235
- maxTokens = (params['maxTokens'] as num).toInt();
236
- }
237
- }
238
- } catch (_) {}
239
- }
240
-
241
- return ChatParameters(
242
- temperature: temperature,
243
- topP: topP,
244
- maxTokens: maxTokens,
245
- contextLength: contextLength,
246
- systemPrompt: systemPrompt,
247
- );
248
-});
249
-
250
-final chatServiceProvider = Provider<ChatService?>((ref) {
251
- final server = ref.watch(activeServerProvider);
252
- if (server == null) {
253
- return null;
254
- }
255
- final loadedRuntime = ref.watch(
256
- onDeviceEngineProvider.select((s) => s.loadedRuntime),
257
- );
258
- final service = createChatServiceForServer(
259
- server: server,
260
- dio: ref.read(dioProvider),
261
- onDeviceGemmaService: ref.read(onDeviceGemmaServiceProvider),
262
- onDeviceLlamaService: ref.read(onDeviceLlamaServiceProvider),
263
- loadedOnDeviceRuntime: loadedRuntime,
264
- );
265
-
266
- ref.onDispose(() {
267
- final s = service;
268
- if (s is OnDeviceChatService) {
269
- s.dispose();
270
- }
271
- });
272
-
273
- return service;
274
-});
275
-
276
-ChatService createChatServiceForServer({
277
- required Server server,
278
- required Dio dio,
279
- required OnDeviceGemmaService onDeviceGemmaService,
280
- required OnDeviceLlamaService onDeviceLlamaService,
281
- OnDeviceModelRuntime? loadedOnDeviceRuntime,
282
-}) {
283
- if (server.type == ServerType.onDevice) {
284
- if (loadedOnDeviceRuntime == OnDeviceModelRuntime.llamaCpp) {
285
- return OnDeviceLlamaChatService(onDeviceLlamaService);
286
- }
287
-
288
- return ChatService.forServer(
289
- server.type,
290
- dio,
291
- onDeviceGemma: onDeviceGemmaService,
292
- );
293
- }
294
-
295
- return ChatService.forServer(server.type, dio);
296
-}
297
-
298
-final smartReplyServiceProvider = Provider<SmartReplyService>((ref) {
299
- final service = SmartReplyService();
300
- ref.onDispose(() => service.dispose());
301
- return service;
302
-});
303
-
304
-final smartRepliesProvider = FutureProvider<List<String>>((ref) async {
305
- final isStreaming = ref.watch(chatProvider.select((s) => s.isStreaming));
306
- final settings = ref.watch(settingsProvider);
307
-
308
- if (isStreaming) return [];
309
-
310
- final messages = ref.watch(chatProvider.select((s) => s.messages));
311
- if (messages.isEmpty) return [];
312
-
313
- final lastMessage = messages.last;
314
- if (lastMessage.role != MessageRole.assistant ||
315
- lastMessage.status != MessageStatus.complete) {
316
- return [];
317
- }
318
-
319
- // Load saved replies if they exist for the current last message
320
- final activeConv = ref.watch(conv.activeConversationProvider);
321
- if (activeConv != null &&
322
- activeConv.smartRepliesLastMessageId == lastMessage.id &&
323
- activeConv.smartReplies != null &&
324
- activeConv.smartReplies!.isNotEmpty) {
325
- return activeConv.smartReplies!;
326
- }
327
-
328
- List<String> suggestions = [];
329
-
330
- if (settings.smartReplyEnabled) {
331
- final server = ref.watch(activeServerProvider);
332
- final selectedModel = ref.watch(selectedModelProvider);
333
- final chatParams = ref.watch(chatParamsProvider);
334
- final chatService = ref.watch(chatServiceProvider);
335
-
336
- if (server != null && chatService != null && selectedModel != null) {
337
- final service = ref.read(smartReplyServiceProvider);
338
- suggestions = await service.suggestRepliesWithLLM(
339
- chatService: chatService,
340
- server: server,
341
- modelId: selectedModel.id,
342
- messages: messages,
343
- params: chatParams,
344
- );
345
- if (!ref.mounted) return [];
346
- }
347
- }
348
-
349
- if (suggestions.isEmpty) {
350
- final service = ref.read(smartReplyServiceProvider);
351
- suggestions = service.getFallbackReplies(lastMessage.content);
352
- }
353
-
354
- // Save the replies to the conversation database
355
- if (activeConv != null && suggestions.isNotEmpty) {
356
- Future.microtask(() {
357
- if (ref.mounted) {
358
- ref
359
- .read(conv.conversationsProvider.notifier)
360
- .updateSmartReplies(activeConv.id, suggestions, lastMessage.id);
361
- }
362
- });
363
- }
364
-
365
- return suggestions;
366
-});
367
-
368
-class PendingToolApproval {
369
- final ParsedToolCall toolCall;
370
- final Completer<bool> completer;
371
-
372
- PendingToolApproval({required this.toolCall, required this.completer});
373
-}
374
-
375
-class ChatState {
376
- final List<Message> messages;
377
- final bool isStreaming;
378
- final bool isLoading;
379
- final String? errorMessage;
380
- final Message? streamingMessage;
381
- final PendingToolApproval? pendingToolApproval;
382
-
383
- const ChatState({
384
- this.messages = const [],
385
- this.isStreaming = false,
386
- this.isLoading = false,
387
- this.errorMessage,
388
- this.streamingMessage,
389
- this.pendingToolApproval,
390
- });
391
-
392
- ChatState copyWith({
393
- List<Message>? messages,
394
- bool? isStreaming,
395
- bool? isLoading,
396
- String? errorMessage,
397
- Message? streamingMessage,
398
- PendingToolApproval? pendingToolApproval,
399
- bool clearError = false,
400
- bool clearStreaming = false,
401
- bool clearPendingApproval = false,
402
- }) {
403
- return ChatState(
404
- messages: messages ?? this.messages,
405
- isStreaming: isStreaming ?? this.isStreaming,
406
- isLoading: isLoading ?? this.isLoading,
407
- errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
408
- streamingMessage: clearStreaming
409
- ? null
410
- : (streamingMessage ?? this.streamingMessage),
411
- pendingToolApproval: clearPendingApproval
412
- ? null
413
- : (pendingToolApproval ?? this.pendingToolApproval),
414
- );
415
- }
416
-}
417
-
418
-final chatProvider = NotifierProvider<ChatNotifier, ChatState>(() {
419
- return ChatNotifier();
420
-});
421
-
422
-class ChatNotifier extends Notifier<ChatState> {
423
- static const _checkpointChunkThreshold = 20;
424
- static const _checkpointTimeThreshold = Duration(seconds: 2);
425
-
426
- StreamSubscription<ChatResponse>? _streamSubscription;
427
- Timer? _uiUpdateTimer;
428
- Message? _latestStreamingMessage;
429
- String? _currentConversationId;
430
- int _chunkCount = 0;
431
- DateTime? _lastCheckpointTime;
432
- int _lastSavedContentLength = 0;
433
- int _lastSavedReasoningLength = 0;
434
-
435
- /// Batches checkpoint saves off the UI thread during streaming.
436
- MessageSaveService? _saveService;
437
-
438
- void approveTool(bool approved) {
439
- final pending = state.pendingToolApproval;
440
- if (pending != null && !pending.completer.isCompleted) {
441
- pending.completer.complete(approved);
442
- }
443
- }
444
-
445
- void _clearPendingApproval() {
446
- final pending = state.pendingToolApproval;
447
- if (pending != null) {
448
- if (!pending.completer.isCompleted) {
449
- pending.completer.complete(false);
450
- }
451
- state = state.copyWith(clearPendingApproval: true);
452
- }
453
- }
454
-
455
- @override
456
- ChatState build() {
457
- final db = ref.read(databaseProvider);
458
- _saveService = MessageSaveService(db);
459
- ref.onDispose(() {
460
- _uiUpdateTimer?.cancel();
461
- _streamSubscription?.cancel();
462
- _saveService?.dispose();
463
- final pending = state.pendingToolApproval;
464
- if (pending != null && !pending.completer.isCompleted) {
465
- pending.completer.complete(false);
466
- }
467
- });
468
- return const ChatState();
469
- }
470
-
471
- Future<void> loadConversation(Conversation conversation) async {
472
- await cancelStream();
473
- _currentConversationId = conversation.id;
474
- ref.read(smartReplyServiceProvider).reset();
475
- state = state.copyWith(
476
- isLoading: true,
477
- errorMessage: null,
478
- clearError: true,
479
- );
480
-
481
- try {
482
- final db = ref.read(databaseProvider);
483
-
484
- final messages = await db.store.runInTransactionAsync(
485
- TxMode.write,
486
- _loadMessagesInBackground,
487
- conversation.id,
488
- );
489
-
490
- state = ChatState(messages: messages, isLoading: false);
491
- ref
492
- .read(conv.activeConversationProvider.notifier)
493
- .setActiveConversation(conversation);
494
-
495
- // Sync MCP config for this chat
496
- ref
497
- .read(chatMcpConfigProvider.notifier)
498
- .setEnabled(conversation.mcpEnabled ?? true);
499
- } catch (e, stackTrace) {
500
- Log.fatal(error: e, stackTrace: stackTrace);
501
- state = state.copyWith(
502
- isLoading: false,
503
- errorMessage: 'Failed to load conversation: $e',
504
- );
505
- }
506
- }
507
-
508
- static List<Message> _loadMessagesInBackground(
509
- Store store,
510
- String conversationId,
511
- ) {
512
- final convBox = store.box<ConversationEntity>();
513
- final messageBox = store.box<MessageEntity>();
514
-
515
- final convQuery = convBox
516
- .query(ConversationEntity_.id.equals(conversationId))
517
- .build();
518
- final convEntity = convQuery.findFirst();
519
- convQuery.close();
520
-
521
- List<Message> messages = [];
522
-
523
- if (convEntity != null) {
524
- final relatedEntities = convEntity.messages;
525
- if (relatedEntities.isNotEmpty) {
526
- messages = relatedEntities.map((e) => e.toDomain()).toList();
527
- } else {
528
- final query = messageBox
529
- .query(MessageEntity_.conversationUid.equals(conversationId))
530
- .build();
531
- final manualEntities = query.find();
532
- query.close();
533
-
534
- if (manualEntities.isNotEmpty) {
535
- for (final e in manualEntities) {
536
- e.conversation.target = convEntity;
537
- messageBox.put(e);
538
- }
539
- messages = manualEntities.map((e) => e.toDomain()).toList();
540
- }
541
- }
542
- } else {
543
- final query = messageBox
544
- .query(MessageEntity_.conversationUid.equals(conversationId))
545
- .build();
546
- final entities = query.find();
547
- query.close();
548
- messages = entities.map((e) => e.toDomain()).toList();
549
- }
550
-
551
- messages.sort((a, b) => a.createdAt.compareTo(b.createdAt));
552
- return messages;
553
- }
554
-
555
- Future<void> startNewConversation() async {
556
- await cancelStream();
557
- _currentConversationId = null;
558
- ref.read(smartReplyServiceProvider).reset();
559
- state = const ChatState();
560
- ref
561
- .read(conv.activeConversationProvider.notifier)
562
- .setActiveConversation(null);
563
-
564
- // Sync MCP config for new chat
565
- final settings = ref.read(settingsProvider);
566
- ref
567
- .read(chatMcpConfigProvider.notifier)
568
- .setEnabled(settings.newChatMcpEnabled);
569
- }
570
-
571
- String generateUuid() {
572
- final random = Random.secure();
573
- final bytes = List<int>.generate(16, (_) => random.nextInt(256));
574
- bytes[6] = (bytes[6] & 0x0f) | 0x40;
575
- bytes[8] = (bytes[8] & 0x3f) | 0x80;
576
- return [
577
- bytes.sublist(0, 4),
578
- bytes.sublist(4, 6),
579
- bytes.sublist(6, 8),
580
- bytes.sublist(8, 10),
581
- bytes.sublist(10, 16),
582
- ]
583
- .map((b) => b.map((e) => e.toRadixString(16).padLeft(2, '0')).join())
584
- .join('-');
585
- }
586
-
587
- bool _shouldCheckpointSave(Message message) {
588
- final now = DateTime.now();
589
- final contentLength = message.content.length;
590
- final reasoningLength = message.reasoningContent?.length ?? 0;
591
-
592
- final hasNewContent =
593
- contentLength > _lastSavedContentLength ||
594
- reasoningLength > _lastSavedReasoningLength;
595
-
596
- if (!hasNewContent) return false;
597
-
598
- final meetsChunkThreshold = _chunkCount >= _checkpointChunkThreshold;
599
-
600
- final meetsTimeThreshold =
601
- _lastCheckpointTime != null &&
602
- now.difference(_lastCheckpointTime!) >= _checkpointTimeThreshold;
603
-
604
- return meetsChunkThreshold || meetsTimeThreshold;
605
- }
606
-
607
- void _resetCheckpointMetrics() {
608
- _chunkCount = 0;
609
- _lastCheckpointTime = DateTime.now();
610
- }
611
-
612
- void _updateSavedMetrics(Message message) {
613
- _lastSavedContentLength = message.content.length;
614
- _lastSavedReasoningLength = message.reasoningContent?.length ?? 0;
615
- }
616
-
617
- Future<void> checkpointStreamingMessage({bool flush = false}) async {
618
- final latest = _latestStreamingMessage ?? state.streamingMessage;
619
- if (latest == null) return;
620
-
621
- _saveService?.enqueue(latest);
622
- _updateSavedMetrics(latest);
623
- _resetCheckpointMetrics();
624
-
625
- if (flush) {
626
- await _saveService?.flush();
627
- }
628
- }
629
-
630
- void _replaceMessageInState(Message message, {bool clearStreaming = false}) {
631
- final messageIndex = state.messages.indexWhere((m) => m.id == message.id);
632
- if (messageIndex == -1) {
633
- state = state.copyWith(
634
- streamingMessage: clearStreaming ? null : message,
635
- clearStreaming: clearStreaming,
636
- );
637
- return;
638
- }
639
-
640
- final updatedMessages = List<Message>.from(state.messages);
641
- updatedMessages[messageIndex] = message;
642
- state = state.copyWith(
643
- messages: updatedMessages,
644
- streamingMessage: clearStreaming ? null : message,
645
- clearStreaming: clearStreaming,
646
- );
647
- }
648
-
649
- Future<void> sendMessage(String content, {List<File>? attachments}) async {
650
- final server = ref.read(activeServerProvider);
651
- final selectedModel = ref.read(selectedModelProvider);
652
- final chatParams = ref.read(chatParamsProvider);
653
- final chatService = ref.read(chatServiceProvider);
654
- final settings = ref.read(settingsProvider);
655
-
656
- if (server == null) {
657
- state = state.copyWith(errorMessage: 'No server connected');
658
- return;
659
- }
660
-
661
- if (content.trim().isEmpty) return;
662
-
663
- if (_currentConversationId == null) {
664
- final server = ref.read(activeServerProvider);
665
- final selectedModel = ref.read(selectedModelProvider);
666
- final selectedPersona = ref.read(selectedPersonaProvider);
667
- final conversation = await ref
668
- .read(conv.conversationsProvider.notifier)
669
- .createConversation(
670
- title: content.length > 50
671
- ? '${content.substring(0, 50)}...'
672
- : content,
673
- serverId: server?.id,
674
- modelId: selectedModel?.id,
675
- personaId: selectedPersona?.id,
676
- systemPrompt: selectedPersona?.systemPrompt,
677
- mcpEnabled: settings.newChatMcpEnabled,
678
- );
679
- _currentConversationId = conversation.id;
680
- ref
681
- .read(conv.activeConversationProvider.notifier)
682
- .setActiveConversation(conversation);
683
-
684
- // Sync MCP config for the newly created chat
685
- ref
686
- .read(chatMcpConfigProvider.notifier)
687
- .setEnabled(settings.newChatMcpEnabled);
688
-
689
- // Clear preselected persona after it's applied to the new conversation
690
- ref.read(selectedPersonaProvider.notifier).clear();
691
- }
692
-
693
- final List<String> savedPaths = [];
694
- if (attachments != null && attachments.isNotEmpty) {
695
- final appDir = await ref.read(storageDirectoryProvider.future);
696
- final attachmentsDir = Directory('${appDir.path}/attachments');
697
- if (!await attachmentsDir.exists()) {
698
- await attachmentsDir.create(recursive: true);
699
- }
700
-
701
- for (final file in attachments) {
702
- final fileName = file.path.split('/').last;
703
- final timestamp = DateTime.now().millisecondsSinceEpoch;
704
- final newPath = '${attachmentsDir.path}/${timestamp}_$fileName';
705
- await file.copy(newPath);
706
- savedPaths.add(newPath);
707
- }
708
- }
709
-
710
- final userMessage = Message(
711
- id: generateUuid(),
712
- conversationId: _currentConversationId!,
713
- role: MessageRole.user,
714
- content: content,
715
- createdAt: DateTime.now(),
716
- status: MessageStatus.complete,
717
- attachmentPaths: savedPaths.isNotEmpty ? savedPaths : null,
718
- );
719
-
720
- final assistantMessageId = generateUuid();
721
- var assistantMessage = Message(
722
- id: assistantMessageId,
723
- conversationId: _currentConversationId!,
724
- role: MessageRole.assistant,
725
- content: '',
726
- createdAt: DateTime.now(),
727
- status: MessageStatus.streaming,
728
- modelId: selectedModel?.id,
729
- );
730
-
731
- state = state.copyWith(
732
- messages: [...state.messages, userMessage, assistantMessage],
733
- isStreaming: true,
734
- streamingMessage: assistantMessage,
735
- clearError: true,
736
- );
737
- ref.read(isStreamingProvider.notifier).setStreaming(true);
738
-
739
- await _saveMessage(userMessage);
740
- await _saveMessage(assistantMessage);
741
-
742
- // Start background service to prevent suspension
743
- ref.read(chatBackgroundServiceProvider).start();
744
-
745
- _resetCheckpointMetrics();
746
- _updateSavedMetrics(assistantMessage);
747
-
748
- final messagesForApi = _buildMessagesForApi(selectedModel);
749
-
750
- try {
751
- _streamSubscription?.cancel();
752
- _uiUpdateTimer?.cancel();
753
-
754
- String reasoningContent = '';
755
- var streamingAssistantMessage = assistantMessage;
756
- _latestStreamingMessage = streamingAssistantMessage;
757
-
758
- if (chatService != null) {
759
- final mcpConfig = ref.read(chatMcpConfigProvider);
760
- final integrations =
761
- mcpConfig.enabled && mcpConfig.integrations.isNotEmpty
762
- ? mcpConfig.integrations
763
- : null;
764
-
765
- final registry = ref.read(toolRegistryProvider);
766
- final tools = mcpConfig.enabled
767
- ? await registry.listTools()
768
- : const <ToolDefinition>[];
769
-
770
- final collectedToolCalls = <ToolCallData>[];
771
-
772
- bool stateNeedsUpdate = false;
773
-
774
- void updateUiState() {
775
- if (!stateNeedsUpdate) return;
776
- stateNeedsUpdate = false;
777
-
778
- final streamConvId = assistantMessage.conversationId;
779
- final isCurrentContext = _currentConversationId == streamConvId;
780
-
781
- if (isCurrentContext) {
782
- state = state.copyWith(streamingMessage: streamingAssistantMessage);
783
- }
784
- }
785
-
786
- _uiUpdateTimer = Timer.periodic(const Duration(milliseconds: 80), (_) {
787
- updateUiState();
788
- });
789
-
790
- _streamSubscription = chatService
791
- .sendMessage(
792
- server: server,
793
- modelId: selectedModel?.id ?? 'default',
794
- messages: messagesForApi,
795
- params: chatParams,
796
- integrations: integrations,
797
- tools: tools,
798
- )
799
- .listen(
800
- (response) async {
801
- final streamConvId = assistantMessage.conversationId;
802
- final isCurrentContext = _currentConversationId == streamConvId;
803
-
804
- switch (response.type) {
805
- case ChatResponseType.message:
806
- streamingAssistantMessage = streamingAssistantMessage
807
- .copyWith(
808
- content:
809
- streamingAssistantMessage.content +
810
- (response.content ?? ''),
811
- isProcessing: false,
812
- );
813
- _latestStreamingMessage = streamingAssistantMessage;
814
-
815
- _chunkCount++;
816
-
817
- if (_shouldCheckpointSave(streamingAssistantMessage)) {
818
- // Non-blocking enqueue — never stalls the stream listener.
819
- _saveService?.enqueue(streamingAssistantMessage);
820
- _updateSavedMetrics(streamingAssistantMessage);
821
- _resetCheckpointMetrics();
822
- }
823
-
824
- stateNeedsUpdate = true;
825
- break;
826
- case ChatResponseType.reasoning:
827
- reasoningContent += response.reasoningContent ?? '';
828
- streamingAssistantMessage = streamingAssistantMessage
829
- .copyWith(
830
- reasoningContent: reasoningContent,
831
- isProcessing: false,
832
- );
833
- _latestStreamingMessage = streamingAssistantMessage;
834
-
835
- _chunkCount++;
836
-
837
- if (_shouldCheckpointSave(streamingAssistantMessage)) {
838
- // Non-blocking enqueue — never stalls the stream listener.
839
- _saveService?.enqueue(streamingAssistantMessage);
840
- _updateSavedMetrics(streamingAssistantMessage);
841
- _resetCheckpointMetrics();
842
- }
843
-
844
- stateNeedsUpdate = true;
845
- break;
846
- case ChatResponseType.processing:
847
- // Model is working but hasn't output content yet
848
- streamingAssistantMessage = streamingAssistantMessage
849
- .copyWith(isProcessing: true);
850
- _latestStreamingMessage = streamingAssistantMessage;
851
- stateNeedsUpdate = true;
852
- break;
853
- case ChatResponseType.timeoutError:
854
- case ChatResponseType.error:
855
- _uiUpdateTimer?.cancel();
856
- _uiUpdateTimer = null;
857
- // Error reached without receiving content or mid-stream
858
- streamingAssistantMessage = streamingAssistantMessage.copyWith(
859
- status: MessageStatus.error,
860
- errorMessage:
861
- response.content ??
862
- (response.type == ChatResponseType.timeoutError
863
- ? 'Model is taking too long to respond. This may happen with free tier models.'
864
- : 'An unknown error occurred.'),
865
- isProcessing: false,
866
- );
867
- _latestStreamingMessage = streamingAssistantMessage;
868
-
869
- if (isCurrentContext) {
870
- _replaceMessageInState(
871
- streamingAssistantMessage,
872
- clearStreaming: true,
873
- );
874
- state = state.copyWith(
875
- isStreaming: false,
876
- errorMessage: streamingAssistantMessage.errorMessage,
877
- );
878
- }
879
- ref.read(isStreamingProvider.notifier).setStreaming(false);
880
- _latestStreamingMessage = null;
881
- // Save error message to storage
882
- await _saveService?.flush();
883
- await _saveMessage(streamingAssistantMessage);
884
- break;
885
- case ChatResponseType.toolCall:
886
- if (response.toolCall != null) {
887
- collectedToolCalls.add(response.toolCall!);
888
- }
889
- break;
890
- case ChatResponseType.invalidToolCall:
891
- case ChatResponseType.done:
892
- break;
893
- }
894
- },
895
- onDone: () async {
896
- _uiUpdateTimer?.cancel();
897
- _uiUpdateTimer = null;
898
- updateUiState();
899
-
900
- // Flush batched checkpoint saves before finalising UI state.
901
- await _saveService?.flush();
902
- ref.read(chatBackgroundServiceProvider).stop();
903
- final streamConvId = assistantMessage.conversationId;
904
- final isCurrentContext = _currentConversationId == streamConvId;
905
- final streamingMessage = streamingAssistantMessage;
906
- // ignore: unnecessary_null_comparison
907
- if (streamingMessage != null) {
908
- // Check if content is empty - this happens when free models refuse or fail
909
- final hasContent =
910
- streamingMessage.content.isNotEmpty ||
911
- (streamingMessage.reasoningContent?.isNotEmpty ??
912
- false) ||
913
- collectedToolCalls.isNotEmpty;
914
-
915
- if (!hasContent) {
916
- // Mark as error if no content received
917
- final errorMessage = streamingMessage.copyWith(
918
- status: MessageStatus.error,
919
- errorMessage:
920
- 'Model failed to respond. This may happen with free tier models that refuse certain prompts or when the service is busy.',
921
- isProcessing: false,
922
- );
923
- await _saveMessage(errorMessage);
924
- if (isCurrentContext) {
925
- _replaceMessageInState(
926
- errorMessage,
927
- clearStreaming: true,
928
- );
929
- state = state.copyWith(
930
- isStreaming: false,
931
- errorMessage: errorMessage.errorMessage,
932
- );
933
- }
934
- ref.read(isStreamingProvider.notifier).setStreaming(false);
935
- _latestStreamingMessage = null;
936
- } else {
937
- // Normal completion with content
938
- var finalMessage = streamingMessage.copyWith(
939
- status: MessageStatus.complete,
940
- isProcessing: false,
941
- );
942
-
943
- // ---- TOOL LOOP INTEGRATION ----
944
- if (mcpConfig.enabled && collectedToolCalls.isNotEmpty) {
945
- try {
946
- final registry = ref.read(toolRegistryProvider);
947
- final adapter = createAdapterForServerType(server.type);
948
-
949
- // Deduplicate tool calls by name — LM Studio emits
950
- // tool_call.start, tool_call.arguments, and
951
- // tool_call.success as separate events. Keep only the
952
- // last event per tool (most complete arguments/output).
953
- final dedupedCalls = <String, ToolCallData>{};
954
- for (final tc in collectedToolCalls) {
955
- dedupedCalls[tc.tool] = tc;
956
- }
957
-
958
- final preParsedCalls = dedupedCalls.values
959
- .map(
960
- (tc) => ParsedToolCall(
961
- id: tc.tool,
962
- name: tc.tool,
963
- arguments: tc.arguments,
964
- ),
965
- )
966
- .toList();
967
-
968
- final loop = ToolExecutionLoop(
969
- adapter: adapter,
970
- registry: registry,
971
- onRequestApproval: (call) async {
972
- final completer = Completer<bool>();
973
- state = state.copyWith(
974
- pendingToolApproval: PendingToolApproval(
975
- toolCall: call,
976
- completer: completer,
977
- ),
978
- );
979
-
980
- final result = await completer.future;
981
-
982
- state = state.copyWith(clearPendingApproval: true);
983
-
984
- return result;
985
- },
986
- );
987
-
988
- final loopResult = await loop.run(
989
- initialUserMessage: content,
990
- assistantContent: streamingAssistantMessage.content,
991
- preParsedCalls: preParsedCalls,
992
- );
993
-
994
- if (loopResult.events.isNotEmpty) {
995
- finalMessage = finalMessage.copyWith(
996
- toolSessionId: loop.sessionId,
997
- toolEvents: loopResult.events,
998
- );
999
- }
1000
- } catch (e) {
1001
- Log.error('Tool execution loop failed: $e');
1002
- }
1003
- }
1004
- // ---- END TOOL LOOP INTEGRATION ----
1005
-
1006
- await _saveMessage(finalMessage);
1007
- if (isCurrentContext) {
1008
- _replaceMessageInState(
1009
- finalMessage,
1010
- clearStreaming: true,
1011
- );
1012
- state = state.copyWith(isStreaming: false);
1013
- }
1014
- ref.read(isStreamingProvider.notifier).setStreaming(false);
1015
- _latestStreamingMessage = null;
1016
- if (_currentConversationId != null) {
1017
- final preview = finalMessage.content.length > 100
1018
- ? '${finalMessage.content.substring(0, 100)}...'
1019
- : finalMessage.content;
1020
- await ref
1021
- .read(conv.conversationsProvider.notifier)
1022
- .updatePreview(
1023
- _currentConversationId!,
1024
- preview,
1025
- DateTime.now(),
1026
- );
1027
-
1028
- final userMessage = state.messages
1029
- .where((m) => m.role == MessageRole.user)
1030
- .firstOrNull;
1031
- if (userMessage != null &&
1032
- userMessage.content.length > 10) {
1033
- _autoGenerateTitle(
1034
- userMessage.content,
1035
- finalMessage.content,
1036
- );
1037
- }
1038
- }
1039
-
1040
- _maybeRequestReviewAfterSuccessfulCompletion(
1041
- finalMessage: finalMessage,
1042
- server: server,
1043
- selectedModel: selectedModel,
1044
- );
1045
-
1046
- _chunkCount = 0;
1047
- _lastCheckpointTime = null;
1048
- _lastSavedContentLength = 0;
1049
- _lastSavedReasoningLength = 0;
1050
- }
1051
- }
1052
- },
1053
- onError: (error) async {
1054
- _uiUpdateTimer?.cancel();
1055
- _uiUpdateTimer = null;
1056
- _chunkCount = 0;
1057
- _lastCheckpointTime = null;
1058
- _lastSavedContentLength = 0;
1059
- _lastSavedReasoningLength = 0;
1060
- // Flush any batched saves before processing the error state.
1061
- await _saveService?.flush();
1062
- final errorMessage =
1063
- (_latestStreamingMessage ?? state.streamingMessage)
1064
- ?.copyWith(
1065
- status: MessageStatus.error,
1066
- errorMessage: error.toString(),
1067
- );
1068
- if (errorMessage != null) {
1069
- await _saveMessage(errorMessage);
1070
-
1071
- ref.read(chatBackgroundServiceProvider).stop();
1072
-
1073
- final streamConvId = assistantMessage.conversationId;
1074
- if (_currentConversationId == streamConvId) {
1075
- _replaceMessageInState(errorMessage, clearStreaming: true);
1076
- state = state.copyWith(
1077
- isStreaming: false,
1078
- errorMessage: error.toString(),
1079
- );
1080
- }
1081
- }
1082
- ref.read(isStreamingProvider.notifier).setStreaming(false);
1083
- _latestStreamingMessage = null;
1084
- },
1085
- );
1086
- }
1087
- } catch (e) {
1088
- _uiUpdateTimer?.cancel();
1089
- _uiUpdateTimer = null;
1090
- final errorMsg = assistantMessage.copyWith(
1091
- status: MessageStatus.error,
1092
- errorMessage: e.toString(),
1093
- isProcessing: false,
1094
- );
1095
- await _saveMessage(errorMsg);
1096
-
1097
- state = state.copyWith(
1098
- isStreaming: false,
1099
- errorMessage: e.toString(),
1100
- clearStreaming: true,
1101
- );
1102
- ref.read(isStreamingProvider.notifier).setStreaming(false);
1103
- _latestStreamingMessage = null;
1104
- ref.read(chatBackgroundServiceProvider).stop();
1105
- }
1106
- }
1107
-
1108
- List<Message> _buildMessagesForApi(ModelInfo? selectedModel) {
1109
- final settings = ref.read(settingsProvider);
1110
- final messages = <Message>[];
1111
-
1112
- final personaPrompt = _getPersonaSystemPrompt();
1113
- if (personaPrompt != null) {
1114
- messages.add(
1115
- Message(
1116
- id: 'system-$_currentConversationId',
1117
- conversationId: _currentConversationId ?? '',
1118
- role: MessageRole.system,
1119
- content: personaPrompt,
1120
- createdAt: DateTime.now(),
1121
- status: MessageStatus.complete,
1122
- ),
1123
- );
1124
- } else if (settings.showSystemMessages) {
1125
- messages.add(
1126
- Message(
1127
- id: 'system-default-$_currentConversationId',
1128
- conversationId: _currentConversationId ?? '',
1129
- role: MessageRole.system,
1130
- content:
1131
- 'You are LocalMind, a helpful AI assistant. Provide clear, accurate, and concise responses.',
1132
- createdAt: DateTime.now(),
1133
- status: MessageStatus.complete,
1134
- ),
1135
- );
1136
- }
1137
-
1138
- for (final message in state.messages) {
1139
- if (message.role != MessageRole.system || settings.showSystemMessages) {
1140
- messages.add(message);
1141
- }
1142
- }
1143
-
1144
- final contextLength = ref.read(chatParamsProvider).contextLength;
1145
- return _truncateToContextWindow(messages, contextLength);
1146
- }
1147
-
1148
- String? _getPersonaSystemPrompt() {
1149
- final conversation = ref.read(conv.activeConversationProvider);
1150
- final personaId = conversation?.personaId;
1151
- if (personaId == null) return null;
1152
-
1153
- if (conversation?.systemPrompt != null &&
1154
- conversation!.systemPrompt!.isNotEmpty) {
1155
- return conversation.systemPrompt;
1156
- }
1157
-
1158
- try {
1159
- final personasAsync = ref.read(personasNotifierProvider);
1160
- final personas = personasAsync.value ?? [];
1161
- final persona = personas.firstWhere(
1162
- (p) => p.id == personaId,
1163
- orElse: () => throw Exception('Persona not found'),
1164
- );
1165
- if (persona.systemPrompt.isNotEmpty) {
1166
- return persona.systemPrompt;
1167
- }
1168
- } catch (_) {}
1169
-
1170
- return null;
1171
- }
1172
-
1173
- List<Message> _truncateToContextWindow(
1174
- List<Message> messages,
1175
- int contextLength,
1176
- ) {
1177
- if (messages.isEmpty) return messages;
1178
-
1179
- int estimatedTokens = 0;
1180
- final result = <Message>[];
1181
- const tokensPerChar = 4;
1182
-
1183
- for (int i = messages.length - 1; i >= 0; i--) {
1184
- final message = messages[i];
1185
- estimatedTokens += (message.content.length / tokensPerChar).ceil();
1186
-
1187
- if (estimatedTokens > contextLength) {
1188
- break;
1189
- }
1190
- result.insert(0, message);
1191
- }
1192
-
1193
- return result;
1194
- }
1195
-
1196
- void _autoGenerateTitle(String userContent, String assistantContent) {
1197
- final settings = ref.read(settingsProvider);
1198
- if (!settings.autoGenerateTitle) return;
1199
- if (_currentConversationId == null) return;
1200
-
1201
- final activeConv = ref.read(conv.activeConversationProvider);
1202
- if (activeConv == null) return;
1203
- if (activeConv.title != 'New Chat') return;
1204
-
1205
- final title = userContent.length > 40
1206
- ? '${userContent.substring(0, 40)}...'
1207
- : userContent;
1208
-
1209
- ref
1210
- .read(conv.conversationsProvider.notifier)
1211
- .renameConversation(_currentConversationId!, title);
1212
- }
1213
-
1214
- void _maybeRequestReviewAfterSuccessfulCompletion({
1215
- required Message finalMessage,
1216
- required Server server,
1217
- required ModelInfo? selectedModel,
1218
- }) {
1219
- final modelId =
1220
- selectedModel?.id ??
1221
- (server.type == ServerType.onDevice
1222
- ? ref.read(onDeviceEngineProvider).loadedModelId
1223
- : null);
1224
-
1225
- unawaited(
1226
- ref
1227
- .read(reviewPromptServiceProvider)
1228
- .maybeRequestReviewAfterSuccessfulChat(
1229
- assistantContent: finalMessage.content,
1230
- serverType: server.type,
1231
- modelId: modelId,
1232
- usedCustomPersona: _isUsingCustomPersona(),
1233
- )
1234
- .catchError((Object error, StackTrace stackTrace) {
1235
- Log.error('Review prompt request failed: $error');
1236
- return false;
1237
- }),
1238
- );
1239
- }
1240
-
1241
- bool _isUsingCustomPersona() {
1242
- final activeConversation = ref.read(conv.activeConversationProvider);
1243
- final personaId = activeConversation?.personaId;
1244
- if (personaId == null) return false;
1245
-
1246
- final personas = ref.read(personasNotifierProvider).value ?? [];
1247
- return personas.any(
1248
- (persona) => persona.id == personaId && !persona.isBuiltIn,
1249
- );
1250
- }
1251
-
1252
- Future<void> cancelStream() async {
1253
- _uiUpdateTimer?.cancel();
1254
- _uiUpdateTimer = null;
1255
- _clearPendingApproval();
1256
- await _streamSubscription?.cancel();
1257
- _streamSubscription = null;
1258
- ref.read(chatServiceProvider)?.cancelStream();
1259
- ref.read(chatBackgroundServiceProvider).stop();
1260
-
1261
- _chunkCount = 0;
1262
- _lastCheckpointTime = null;
1263
- _lastSavedContentLength = 0;
1264
- _lastSavedReasoningLength = 0;
1265
-
1266
- // Flush any in-flight checkpoint saves before writing the final state.
1267
- await _saveService?.flush();
1268
-
1269
- final streamingMessage = _latestStreamingMessage ?? state.streamingMessage;
1270
- if (streamingMessage != null) {
1271
- final finalMessage = streamingMessage.copyWith(
1272
- status: MessageStatus.cancelled,
1273
- isProcessing: false,
1274
- );
1275
- await _saveMessage(finalMessage);
1276
- _replaceMessageInState(finalMessage, clearStreaming: true);
1277
- }
1278
-
1279
- state = state.copyWith(isStreaming: false, clearStreaming: true);
1280
- ref.read(isStreamingProvider.notifier).setStreaming(false);
1281
- _latestStreamingMessage = null;
1282
- }
1283
-
1284
- Future<void> retryLastMessage() async {
1285
- final messages = state.messages;
1286
- if (messages.isEmpty) return;
1287
-
1288
- if (messages.last.role == MessageRole.assistant) {
1289
- final lastAssistantIndex = messages.lastIndexWhere(
1290
- (m) => m.role == MessageRole.assistant,
1291
- );
1292
- if (lastAssistantIndex > 0) {
1293
- final userMessage = messages[lastAssistantIndex - 1];
1294
- final messagesToRemove = messages.sublist(lastAssistantIndex);
1295
- final db = ref.read(databaseProvider);
1296
-
1297
- for (final msg in messagesToRemove) {
1298
- final query = db.messageBox
1299
- .query(MessageEntity_.id.equals(msg.id))
1300
- .build();
1301
- db.messageBox.removeMany(query.findIds());
1302
- query.close();
1303
- }
1304
-
1305
- state = state.copyWith(
1306
- messages: messages.sublist(0, lastAssistantIndex),
1307
- clearStreaming: true,
1308
- );
1309
-
1310
- await sendMessage(
1311
- userMessage.content,
1312
- attachments: userMessage.attachmentPaths
1313
- ?.map((p) => File(p))
1314
- .toList(),
1315
- );
1316
- }
1317
- }
1318
- }
1319
-
1320
- Future<void> retryMessage(String messageId) async {
1321
- final messageIndex = state.messages.indexWhere((m) => m.id == messageId);
1322
- if (messageIndex == -1) return;
1323
-
1324
- final message = state.messages[messageIndex];
1325
- if (message.role != MessageRole.assistant) return;
1326
-
1327
- // Find the preceding user message
1328
- if (messageIndex > 0 &&
1329
- state.messages[messageIndex - 1].role == MessageRole.user) {
1330
- final userMessage = state.messages[messageIndex - 1];
1331
-
1332
- // Remove this assistant message and all subsequent messages
1333
- final messagesToRemove = state.messages.sublist(messageIndex);
1334
- final db = ref.read(databaseProvider);
1335
-
1336
- for (final msg in messagesToRemove) {
1337
- final query = db.messageBox
1338
- .query(MessageEntity_.id.equals(msg.id))
1339
- .build();
1340
- db.messageBox.removeMany(query.findIds());
1341
- query.close();
1342
- }
1343
-
1344
- state = state.copyWith(
1345
- messages: state.messages.sublist(0, messageIndex),
1346
- clearStreaming: true,
1347
- );
1348
-
1349
- await sendMessage(
1350
- userMessage.content,
1351
- attachments: userMessage.attachmentPaths?.map((p) => File(p)).toList(),
1352
- );
1353
- }
1354
- }
1355
-
1356
- Future<void> deleteMessage(String messageId) async {
1357
- final db = ref.read(databaseProvider);
1358
- final query = db.messageBox
1359
- .query(MessageEntity_.id.equals(messageId))
1360
- .build();
1361
- db.messageBox.removeMany(query.findIds());
1362
- query.close();
1363
-
1364
- state = state.copyWith(
1365
- messages: state.messages.where((m) => m.id != messageId).toList(),
1366
- );
1367
- }
1368
-
1369
- Future<void> editMessage(String messageId, String newContent) async {
1370
- final messageIndex = state.messages.indexWhere((m) => m.id == messageId);
1371
- if (messageIndex == -1) return;
1372
-
1373
- final message = state.messages[messageIndex];
1374
- if (message.role != MessageRole.user) return;
1375
- if (newContent.trim().isEmpty) return;
1376
- if (newContent == message.content) return;
1377
-
1378
- final attachmentPaths = message.attachmentPaths;
1379
-
1380
- final messagesToRemove = state.messages.sublist(messageIndex);
1381
- final db = ref.read(databaseProvider);
1382
- for (final msg in messagesToRemove) {
1383
- final query = db.messageBox
1384
- .query(MessageEntity_.id.equals(msg.id))
1385
- .build();
1386
- db.messageBox.removeMany(query.findIds());
1387
- query.close();
1388
- }
1389
-
1390
- state = state.copyWith(
1391
- messages: state.messages.sublist(0, messageIndex),
1392
- clearStreaming: true,
1393
- );
1394
-
1395
- await sendMessage(
1396
- newContent,
1397
- attachments: attachmentPaths?.map((p) => File(p)).toList(),
1398
- );
1399
- }
1400
-
1401
- Future<void> _saveMessage(Message message) async {
1402
- final db = ref.read(databaseProvider);
1403
- await db.store.runInTransactionAsync(
1404
- TxMode.write,
1405
- _saveMessageInBackground,
1406
- message,
1407
- );
1408
- }
1409
-
1410
- static void _saveMessageInBackground(Store store, Message message) {
1411
- final box = store.box<MessageEntity>();
1412
- final convBox = store.box<ConversationEntity>();
1413
-
1414
- final query = box.query(MessageEntity_.id.equals(message.id)).build();
1415
- final existing = query.findFirst();
1416
- query.close();
1417
-
1418
- final entity = MessageEntity.fromDomain(message);
1419
- if (existing != null) {
1420
- entity.internalId = existing.internalId;
1421
- }
1422
-
1423
- final convQuery = convBox
1424
- .query(ConversationEntity_.id.equals(message.conversationId))
1425
- .build();
1426
- final convEntity = convQuery.findFirst();
1427
- convQuery.close();
1428
-
1429
- if (convEntity != null) {
1430
- entity.conversation.target = convEntity;
1431
- entity.conversationUid = convEntity.id;
1432
- } else {
1433
- entity.conversationUid = message.conversationId;
1434
- }
1435
-
1436
- box.put(entity);
1437
- }
1438
-
1439
- Future<void> clearConversation() async {
1440
- final db = ref.read(databaseProvider);
1441
- if (_currentConversationId != null) {
1442
- final query = db.messageBox
1443
- .query(MessageEntity_.conversationUid.equals(_currentConversationId!))
1444
- .build();
1445
- db.messageBox.removeMany(query.findIds());
1446
- query.close();
1447
- }
1448
- _currentConversationId = null;
1449
- state = const ChatState();
1450
- }
1451
-
1452
- void clearError() {
1453
- state = state.copyWith(clearError: true);
1454
- }
1455
-}
1
+export 'chat_notifier.dart';
2
+export 'chat_params_providers.dart';
3
+export 'chat_service_providers.dart';
4
+export 'model_loading_providers.dart';
5
+export 'model_selection_providers.dart';
6
+export 'smart_reply_providers.dart';
lib/features/chat/providers/chat_service_providers.dart
new
+67
@@ -0,0 +1,67 @@
1
+import 'package:dio/dio.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:localmind/core/models/enums.dart';
4
+import 'package:localmind/core/providers/service_providers.dart';
5
+import 'package:localmind/features/servers/data/models/server.dart';
6
+import 'package:localmind/features/servers/providers/server_providers.dart';
7
+import 'package:localmind/features/on_device/providers/on_device_providers.dart';
8
+import 'package:localmind/features/on_device/data/on_device_chat_service.dart';
9
+import 'package:localmind/features/on_device/data/on_device_gemma_service.dart';
10
+import 'package:localmind/features/on_device/data/on_device_llama_service.dart';
11
+import 'package:localmind/features/on_device/data/models/on_device_model.dart';
12
+import '../data/chat_service.dart';
13
+import '../data/smart_reply_service.dart';
14
+
15
+final chatServiceProvider = Provider<ChatService?>((ref) {
16
+ final server = ref.watch(activeServerProvider);
17
+ if (server == null) {
18
+ return null;
19
+ }
20
+ final loadedRuntime = ref.watch(
21
+ onDeviceEngineProvider.select((s) => s.loadedRuntime),
22
+ );
23
+ final service = createChatServiceForServer(
24
+ server: server,
25
+ dio: ref.read(dioProvider),
26
+ onDeviceGemmaService: ref.read(onDeviceGemmaServiceProvider),
27
+ onDeviceLlamaService: ref.read(onDeviceLlamaServiceProvider),
28
+ loadedOnDeviceRuntime: loadedRuntime,
29
+ );
30
+
31
+ ref.onDispose(() {
32
+ final s = service;
33
+ if (s is OnDeviceChatService) {
34
+ s.dispose();
35
+ }
36
+ });
37
+
38
+ return service;
39
+});
40
+
41
+ChatService createChatServiceForServer({
42
+ required Server server,
43
+ required Dio dio,
44
+ required OnDeviceGemmaService onDeviceGemmaService,
45
+ required OnDeviceLlamaService onDeviceLlamaService,
46
+ OnDeviceModelRuntime? loadedOnDeviceRuntime,
47
+}) {
48
+ if (server.type == ServerType.onDevice) {
49
+ if (loadedOnDeviceRuntime == OnDeviceModelRuntime.llamaCpp) {
50
+ return OnDeviceLlamaChatService(onDeviceLlamaService);
51
+ }
52
+
53
+ return ChatService.forServer(
54
+ server.type,
55
+ dio,
56
+ onDeviceGemma: onDeviceGemmaService,
57
+ );
58
+ }
59
+
60
+ return ChatService.forServer(server.type, dio);
61
+}
62
+
63
+final smartReplyServiceProvider = Provider<SmartReplyService>((ref) {
64
+ final service = SmartReplyService();
65
+ ref.onDispose(() => service.dispose());
66
+ return service;
67
+});
lib/features/chat/providers/model_loading_providers.dart
new
+56
@@ -0,0 +1,56 @@
1
+import 'package:flutter_riverpod/flutter_riverpod.dart';
2
+import 'model_selection_providers.dart';
3
+
4
+class ModelLoadingState {
5
+ final bool isLoading;
6
+ final String? modelId;
7
+ final double? progress;
8
+
9
+ const ModelLoadingState({
10
+ this.isLoading = false,
11
+ this.modelId,
12
+ this.progress,
13
+ });
14
+
15
+ ModelLoadingState copyWith({
16
+ bool? isLoading,
17
+ String? modelId,
18
+ double? progress,
19
+ }) {
20
+ return ModelLoadingState(
21
+ isLoading: isLoading ?? this.isLoading,
22
+ modelId: modelId ?? this.modelId,
23
+ progress: progress ?? this.progress,
24
+ );
25
+ }
26
+}
27
+
28
+final modelLoadingProvider =
29
+ NotifierProvider<ModelLoadingNotifier, ModelLoadingState>(() {
30
+ return ModelLoadingNotifier();
31
+});
32
+
33
+class ModelLoadingNotifier extends Notifier<ModelLoadingState> {
34
+ @override
35
+ ModelLoadingState build() => const ModelLoadingState();
36
+
37
+ void setLoading(String modelId, {double? progress}) {
38
+ state = ModelLoadingState(
39
+ isLoading: true,
40
+ modelId: modelId,
41
+ progress: progress,
42
+ );
43
+ }
44
+
45
+ void updateProgress(double progress) {
46
+ state = state.copyWith(progress: progress);
47
+ }
48
+
49
+ void setLoaded() {
50
+ state = const ModelLoadingState();
51
+ }
52
+}
53
+
54
+final modelThinkingProvider = Provider<bool>((ref) {
55
+ return ref.watch(isStreamingProvider);
56
+});
lib/features/chat/providers/model_selection_providers.dart
new
+99
@@ -0,0 +1,99 @@
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/service_providers.dart';
6
+import 'package:localmind/features/models/data/models/model_info.dart';
7
+import 'package:localmind/features/servers/data/models/server.dart';
8
+import 'package:localmind/features/servers/providers/server_providers.dart';
9
+import 'package:localmind/features/on_device/providers/on_device_providers.dart';
10
+
11
+final selectedModelProvider =
12
+ NotifierProvider<SelectedModelNotifier, ModelInfo?>(() {
13
+ return SelectedModelNotifier();
14
+});
15
+
16
+class SelectedModelNotifier extends Notifier<ModelInfo?> {
17
+ @override
18
+ ModelInfo? build() => null;
19
+
20
+ void setModel(ModelInfo? model) {
21
+ state = model;
22
+ }
23
+
24
+ void clear() {
25
+ state = null;
26
+ }
27
+}
28
+
29
+final autoSelectFirstLoadedModelProvider = FutureProvider<void>((ref) async {
30
+ final activeServer = ref.watch(activeServerProvider);
31
+ final status = ref.watch(connectionStatusProvider);
32
+
33
+ if (activeServer == null) {
34
+ await Future.value();
35
+ ref.read(selectedModelProvider.notifier).clear();
36
+ return;
37
+ }
38
+
39
+ if (status != ConnectionStatus.connected) return;
40
+
41
+ final selectedModel = ref.read(selectedModelProvider);
42
+
43
+ if (selectedModel != null && selectedModel.serverId != activeServer.id) {
44
+ await Future.value();
45
+ ref.read(selectedModelProvider.notifier).clear();
46
+ } else if (selectedModel != null) {
47
+ return;
48
+ }
49
+
50
+ if (activeServer.type == ServerType.openRouter ||
51
+ activeServer.type == ServerType.openAICompatible) {
52
+ return;
53
+ }
54
+
55
+ try {
56
+ final Set<String> loadedModels;
57
+ if (activeServer.type == ServerType.onDevice) {
58
+ final engine = ref.watch(onDeviceEngineProvider);
59
+ loadedModels = engine.loadedModelId != null
60
+ ? {engine.loadedModelId!}
61
+ : {};
62
+ } else {
63
+ final apiService = ref.read(serverApiServiceProvider);
64
+ loadedModels = await apiService.fetchRunningModels(activeServer);
65
+ }
66
+
67
+ if (loadedModels.isEmpty) return;
68
+
69
+ final availableModels = await ref.read(
70
+ availableModelsProvider(activeServer.id).future,
71
+ );
72
+ if (availableModels.isEmpty) return;
73
+
74
+ final typedModels = availableModels.cast<ModelInfo>();
75
+
76
+ final firstLoadedModel = typedModels
77
+ .where((m) => loadedModels.contains(m.id))
78
+ .firstOrNull;
79
+
80
+ if (firstLoadedModel != null) {
81
+ ref.read(selectedModelProvider.notifier).setModel(firstLoadedModel);
82
+ }
83
+ } catch (e) {
84
+ // Silently fail auto-selection
85
+ }
86
+});
87
+
88
+final isStreamingProvider = NotifierProvider<IsStreamingNotifier, bool>(() {
89
+ return IsStreamingNotifier();
90
+});
91
+
92
+class IsStreamingNotifier extends Notifier<bool> {
93
+ @override
94
+ bool build() => false;
95
+
96
+ void setStreaming(bool streaming) {
97
+ state = streaming;
98
+ }
99
+}
lib/features/chat/providers/smart_reply_providers.dart
new
+76
@@ -0,0 +1,76 @@
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/features/conversations/providers/conversation_providers.dart'
7
+ as conv;
8
+import 'package:localmind/features/servers/providers/server_providers.dart';
9
+import '../data/chat_service.dart';
10
+import '../data/smart_reply_service.dart';
11
+import 'chat_notifier.dart';
12
+import 'chat_params_providers.dart';
13
+import 'chat_service_providers.dart';
14
+import 'model_selection_providers.dart';
15
+
16
+final smartRepliesProvider = FutureProvider<List<String>>((ref) async {
17
+ final isStreaming = ref.watch(chatProvider.select((s) => s.isStreaming));
18
+ final settings = ref.watch(settingsProvider);
19
+
20
+ if (isStreaming) return [];
21
+
22
+ final messages = ref.watch(chatProvider.select((s) => s.messages));
23
+ if (messages.isEmpty) return [];
24
+
25
+ final lastMessage = messages.last;
26
+ if (lastMessage.role != MessageRole.assistant ||
27
+ lastMessage.status != MessageStatus.complete) {
28
+ return [];
29
+ }
30
+
31
+ final activeConv = ref.watch(conv.activeConversationProvider);
32
+ if (activeConv != null &&
33
+ activeConv.smartRepliesLastMessageId == lastMessage.id &&
34
+ activeConv.smartReplies != null &&
35
+ activeConv.smartReplies!.isNotEmpty) {
36
+ return activeConv.smartReplies!;
37
+ }
38
+
39
+ List<String> suggestions = [];
40
+
41
+ if (settings.smartReplyEnabled) {
42
+ final server = ref.watch(activeServerProvider);
43
+ final selectedModel = ref.watch(selectedModelProvider);
44
+ final chatParams = ref.watch(chatParamsProvider);
45
+ final chatService = ref.watch(chatServiceProvider);
46
+
47
+ if (server != null && chatService != null && selectedModel != null) {
48
+ final service = ref.read(smartReplyServiceProvider);
49
+ suggestions = await service.suggestRepliesWithLLM(
50
+ chatService: chatService,
51
+ server: server,
52
+ modelId: selectedModel.id,
53
+ messages: messages,
54
+ params: chatParams,
55
+ );
56
+ if (!ref.mounted) return [];
57
+ }
58
+ }
59
+
60
+ if (suggestions.isEmpty) {
61
+ final service = ref.read(smartReplyServiceProvider);
62
+ suggestions = service.getFallbackReplies(lastMessage.content);
63
+ }
64
+
65
+ if (activeConv != null && suggestions.isNotEmpty) {
66
+ Future.microtask(() {
67
+ if (ref.mounted) {
68
+ ref
69
+ .read(conv.conversationsProvider.notifier)
70
+ .updateSmartReplies(activeConv.id, suggestions, lastMessage.id);
71
+ }
72
+ });
73
+ }
74
+
75
+ return suggestions;
76
+});
lib/features/chat/views/chat_screen.dart
+563
-1613
@@ -1,36 +1,39 @@
1
import 'dart:convert';
2
-import 'dart:ui';
2
3
import 'package:flutter/material.dart';
5
-import 'package:flutter/rendering.dart';
6
-import 'package:flutter/services.dart';
4
import 'package:flutter_riverpod/flutter_riverpod.dart';
5
import 'package:go_router/go_router.dart';
6
import 'package:hugeicons/hugeicons.dart';
10
-import '../../conversations/providers/conversation_providers.dart' as conv;
11
-import '../../models/screens/model_picker_sheet.dart';
12
-import '../../personas/providers/personas_providers.dart';
13
-import '../../servers/providers/server_providers.dart';
7
import 'package:shadcn_ui/shadcn_ui.dart';
15
-
8
import 'package:localmind/core/theme/colors.dart';
17
-import '../../../core/models/enums.dart';
18
-import '../../../core/providers/app_providers.dart';
19
-import '../../../core/routes/app_routes.dart';
20
-import '../../../core/utils/system_insets.dart';
21
-import '../../../l10n/app_localizations.dart';
22
-import '../../conversations/data/models/conversation.dart';
23
-import '../../servers/data/models/server.dart';
24
-import '../../servers/views/components/server_icon_picker.dart';
9
+import 'package:localmind/core/models/enums.dart';
10
+import 'package:localmind/core/providers/app_providers.dart';
11
+import 'package:localmind/core/routes/app_routes.dart';
12
+import 'package:localmind/core/utils/system_insets.dart';
13
+import 'package:localmind/l10n/app_localizations.dart';
14
+import 'package:localmind/features/conversations/providers/conversation_providers.dart'
15
+ as conv;
16
+import 'package:localmind/features/models/screens/model_picker_sheet.dart';
17
+import 'package:localmind/features/personas/providers/personas_providers.dart';
18
+import 'package:localmind/features/servers/providers/server_providers.dart';
19
+import 'package:localmind/features/servers/data/models/server.dart';
20
+import 'package:localmind/features/servers/views/components/server_icon_picker.dart';
21
+import 'package:localmind/features/tts/views/components/tts_player_bar.dart';
22
import '../data/models/message.dart';
23
import '../providers/chat_mcp_providers.dart';
24
import '../providers/chat_providers.dart';
28
-import 'components/chat_bubble.dart';
25
+import 'components/chat_auto_scroll_controller.dart';
26
import 'components/chat_input_bar.dart';
27
import 'components/chat_settings_sheet.dart';
28
import 'components/edit_message_dialog.dart';
29
import 'components/notification_permission_banner.dart';
33
-import '../../tts/views/components/tts_player_bar.dart';
30
+import 'components/message_list/message_list.dart';
31
+import 'components/message_list/empty_state.dart';
32
+import 'components/message_list/corrupted_state.dart';
33
+import 'components/top_bar/model_top_bar.dart';
34
+import 'components/top_bar/connection_banner.dart';
35
+import 'components/top_bar/persona_indicator.dart';
36
+import 'components/top_bar/smart_reply_chips.dart';
37
38
class ChatScreen extends ConsumerStatefulWidget {
39
const ChatScreen({super.key});
@@ -44,25 +47,17 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
47
final ScrollController _scrollController = ScrollController();
48
final FocusNode _inputFocusNode = FocusNode();
49
bool _isApprovalDialogOpen = false;
47
- bool _autoScrollEnabled = true;
48
- bool _scheduledAutoScroll = false;
49
- int _lastMessageCount = 0;
50
- int _lastStreamingLength = 0;
51
- bool _lastIsStreaming = false;
52
-
53
- List<String> _quickPrompts(AppLocalizations l10n) => [
54
- l10n.quick_write,
55
- l10n.quick_explain,
56
- l10n.quick_debug,
57
- l10n.quick_async,
58
- ];
50
+ late final ChatAutoScrollController _autoScroll;
51
52
@override
53
void initState() {
54
super.initState();
55
WidgetsBinding.instance.addObserver(this);
64
- _inputFocusNode.addListener(_onFocusChanged);
65
- _scrollController.addListener(_onScrollChanged);
56
+ _inputFocusNode.addListener(() => setState(() {}));
57
+ _scrollController.addListener(
58
+ () => _autoScroll.onScrollChanged(_scrollController),
59
+ );
60
+ _autoScroll = ChatAutoScrollController();
61
}
62
63
@override
@@ -75,57 +70,16 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
70
}
71
}
72
78
- void _onFocusChanged() {
79
- setState(() {});
80
- }
81
-
82
- void _onScrollChanged() {
83
- if (!_scrollController.hasClients) return;
84
- _autoScrollEnabled = _isNearBottom();
85
- }
86
-
87
- bool _isNearBottom() {
88
- if (!_scrollController.hasClients) return true;
89
- final position = _scrollController.position;
90
- return (position.maxScrollExtent - position.pixels) <= 120;
91
- }
92
-
93
- void _scheduleAutoScroll({required bool streaming}) {
94
- if (!_autoScrollEnabled || _scheduledAutoScroll) {
95
- return;
96
- }
97
-
98
- _scheduledAutoScroll = true;
99
- WidgetsBinding.instance.addPostFrameCallback((_) {
100
- _scheduledAutoScroll = false;
101
- if (!_scrollController.hasClients || !_autoScrollEnabled) {
102
- return;
103
- }
104
-
105
- final target = _scrollController.position.maxScrollExtent;
106
- _scrollController.animateTo(
107
- target,
108
- duration: streaming
109
- ? const Duration(milliseconds: 120)
110
- : const Duration(milliseconds: 200),
111
- curve: Curves.easeOutCubic,
112
- );
113
- });
114
- }
115
-
73
@override
74
void dispose() {
75
WidgetsBinding.instance.removeObserver(this);
119
- _scrollController.removeListener(_onScrollChanged);
76
_scrollController.dispose();
121
- _inputFocusNode.removeListener(_onFocusChanged);
77
_inputFocusNode.dispose();
78
super.dispose();
79
}
80
81
@override
82
Widget build(BuildContext context) {
128
- final l10n = AppLocalizations.of(context)!;
83
ref.watch(autoSelectFirstLoadedModelProvider);
84
85
ref.listen<PendingToolApproval?>(
@@ -137,313 +91,12 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
91
},
92
);
93
140
- final isLoading = ref.watch(chatProvider.select((s) => s.isLoading));
141
- final messages = ref.watch(chatProvider.select((s) => s.messages));
142
- final isStreaming = ref.watch(chatProvider.select((s) => s.isStreaming));
143
- final streamingLength = ref.watch(
144
- chatProvider.select((s) => s.streamingMessage?.content.length ?? 0),
145
- );
146
- final errorMessage = ref.watch(chatProvider.select((s) => s.errorMessage));
147
- final selectedModel = ref.watch(selectedModelProvider);
148
- final connectionStatus = ref.watch(connectionStatusProvider);
149
- final activeServer = ref.watch(activeServerProvider);
150
- final activeConversation = ref.watch(conv.activeConversationProvider);
151
- final personaId = activeConversation?.personaId;
152
- final persona = personaId != null
153
- ? ref.watch(personaByIdProvider(personaId))
154
- : null;
155
- final theme = Theme.of(context);
156
- final isDark = theme.brightness == Brightness.dark;
157
- final keyboardBottomInset = bottomKeyboardInset(context);
158
- final systemBottomInset = bottomSystemInset(context);
159
- final effectiveBottomInset = keyboardBottomInset > 0
160
- ? 0.0
161
- : systemBottomInset;
162
-
163
- final hasNewMessage = messages.length != _lastMessageCount;
164
- final streamingProgressed =
165
- isStreaming && streamingLength != _lastStreamingLength;
166
- final streamStarted = isStreaming && !_lastIsStreaming;
167
- final streamEnded = !isStreaming && _lastIsStreaming;
168
-
169
- if (hasNewMessage || streamingProgressed || streamStarted || streamEnded) {
170
- _scheduleAutoScroll(streaming: isStreaming);
171
- }
172
-
173
- _lastMessageCount = messages.length;
174
- _lastStreamingLength = streamingLength;
175
- _lastIsStreaming = isStreaming;
176
-
177
- return Column(
178
- children: [
179
- _ModelTopBar(
180
- selectedModel: selectedModel,
181
- onTap: () => _showModelPicker(context),
182
- ),
183
- Container(
184
- height: 56,
185
- padding: const EdgeInsets.symmetric(horizontal: 4),
186
- child: Row(
187
- children: [
188
- ShadResponsiveBuilder(
189
- builder: (context, breakpoint) {
190
- final isDesktop =
191
- breakpoint >= ShadTheme.of(context).breakpoints.md;
192
- if (isDesktop) return const SizedBox.shrink();
193
- return IconButton(
194
- icon: const Icon(Icons.menu),
195
- onPressed: () => Scaffold.of(context).openDrawer(),
196
- );
197
- },
198
- ),
199
- const SizedBox(width: 8),
200
- Expanded(
201
- child: Row(
202
- children: [
203
- if (activeServer != null) ...[
204
- _buildServerIcon(context, activeServer),
205
- const SizedBox(width: 8),
206
- ],
207
- Text(
208
- activeServer?.name ?? l10n.chat_title,
209
- style: const TextStyle(
210
- fontSize: 18,
211
- fontWeight: FontWeight.bold,
212
- ),
213
- ),
214
- const SizedBox(width: 8),
215
- Container(
216
- width: 8,
217
- height: 8,
218
- decoration: BoxDecoration(
219
- shape: BoxShape.circle,
220
- color: connectionStatus == ConnectionStatus.connected
221
- ? Colors.green
222
- : Colors.grey,
223
- ),
224
- ),
225
- ],
226
- ),
227
- ),
228
- Consumer(
229
- builder: (context, ref, child) {
230
- final settings = ref.watch(settingsProvider);
231
- final mcpConfig = ref.watch(chatMcpConfigProvider);
232
- final isMcpEnabled = settings.mcpEnabled && mcpConfig.enabled;
233
-
234
- return Stack(
235
- children: [
236
- IconButton(
237
- icon: HugeIcon(
238
- icon: HugeIcons.strokeRoundedFilterHorizontal,
239
- size: 24,
240
- color: isDark ? Colors.white70 : Colors.black87,
241
- ),
242
- onPressed: () => showChatSettingsSheet(
243
- context,
244
- initialTab: 'parameters',
245
- ),
246
- tooltip: l10n.chat_parameters_tooltip,
247
- ),
248
- PositionedDirectional(
249
- top: 4,
250
- end: 0,
251
- child: Container(
252
- padding: const EdgeInsets.all(2),
253
- decoration: BoxDecoration(
254
- color: isMcpEnabled ? Colors.green : Colors.grey,
255
- shape: BoxShape.circle,
256
- ),
257
- child: const HugeIcon(
258
- icon: HugeIcons.strokeRoundedTools,
259
- size: 10,
260
- color: Colors.white,
261
- ),
262
- ),
263
- ),
264
- ],
265
- );
266
- },
267
- ),
268
- PopupMenuButton<String>(
269
- icon: const Icon(Icons.more_vert),
270
- onSelected: (value) => _handleMenuAction(value, context),
271
- itemBuilder: (context) => [
272
- PopupMenuItem(
273
- value: 'new_chat',
274
- child: ListTile(
275
- leading: const Icon(Icons.add),
276
- title: Text(l10n.nav_new_chat),
277
- contentPadding: EdgeInsets.zero,
278
- ),
279
- ),
280
- PopupMenuItem(
281
- value: 'persona',
282
- child: ListTile(
283
- leading: Icon(
284
- persona != null
285
- ? Icons.swap_horiz
286
- : Icons.smart_toy_outlined,
287
- ),
288
- title: Text(
289
- persona != null
290
- ? l10n.change_persona
291
- : l10n.set_persona,
292
- ),
293
- contentPadding: EdgeInsets.zero,
294
- ),
295
- ),
296
- if (persona != null)
297
- PopupMenuItem(
298
- value: 'remove_persona',
299
- child: ListTile(
300
- leading: const Icon(Icons.person_remove_outlined),
301
- title: Text(l10n.remove_persona),
302
- contentPadding: EdgeInsets.zero,
303
- ),
304
- ),
305
- PopupMenuItem(
306
- value: 'clear',
307
- child: ListTile(
308
- leading: const Icon(Icons.delete_outline),
309
- title: Text(l10n.clear_conversation),
310
- contentPadding: EdgeInsets.zero,
311
- ),
312
- ),
313
- ],
314
- ),
315
- ],
316
- ),
317
- ),
318
- const NotificationPermissionBanner(),
319
- if (connectionStatus == ConnectionStatus.disconnected ||
320
- connectionStatus == ConnectionStatus.error)
321
- _ConnectionBanner(status: connectionStatus),
322
- if (persona != null)
323
- _PersonaIndicator(
324
- persona: persona,
325
- onTap: () => _showPersonaPicker(context),
326
- onRemove: () {
327
- final activeConv = ref.read(conv.activeConversationProvider);
328
- if (activeConv != null) {
329
- ref
330
- .read(conv.conversationsProvider.notifier)
331
- .updatePersona(activeConv.id, null, null);
332
- }
333
- },
334
- ),
335
- const TtsPlayerBar(),
336
- Expanded(
337
- child: Stack(
338
- children: [
339
- if (isLoading)
340
- const Center(child: CircularProgressIndicator(strokeWidth: 2))
341
- else if (messages.isEmpty && activeConversation != null)
342
- _CorruptedChatState(
343
- conversation: activeConversation,
344
- errorMessage: errorMessage,
345
- onStartNewChat: () =>
346
- ref.read(chatProvider.notifier).startNewConversation(),
347
- )
348
- else if (messages.isEmpty)
349
- _EmptyState(
350
- onQuickPrompt: (prompt) =>
351
- ref.read(chatProvider.notifier).sendMessage(prompt),
352
- quickPrompts: _quickPrompts(l10n),
353
- recentConversations: ref.watch(
354
- conv.recentConversationsProvider,
355
- ),
356
- onSeeAll: () => context.push(AppRoutes.chatHistory),
357
- selectedModel: selectedModel,
358
- onModelTap: () => _showModelPicker(context),
359
- selectedPersona: ref.watch(selectedPersonaProvider),
360
- onPersonaTap: () =>
361
- _showPersonaPickerForPreselection(context),
362
- )
363
- else
364
- _MessageListConsumer(
365
- scrollController: _scrollController,
366
- messages: messages,
367
- isStreaming: isStreaming,
368
- onRetry: (messageId) {
369
- ref.read(chatProvider.notifier).retryMessage(messageId);
370
- },
371
- onDelete: (messageId) {
372
- ref.read(chatProvider.notifier).deleteMessage(messageId);
373
- },
374
- onEdit: (messageId, currentContent) async {
375
- final newContent = await EditMessageDialog.show(
376
- context,
377
- initialContent: currentContent,
378
- );
379
- if (newContent != null && newContent != currentContent) {
380
- await ref
381
- .read(chatProvider.notifier)
382
- .editMessage(messageId, newContent);
383
- }
384
- },
385
- hasSmartReplies:
386
- !isStreaming &&
387
- keyboardBottomInset == 0 &&
388
- (ref
389
- .watch(smartRepliesProvider)
390
- .asData
391
- ?.value
392
- .isNotEmpty ??
393
- false),
394
- bottomInset: effectiveBottomInset,
395
- ),
396
-
397
- Positioned(
398
- bottom: 0,
399
- left: 0,
400
- right: 0,
401
- child: Container(
402
- decoration: BoxDecoration(
403
- gradient: LinearGradient(
404
- begin: Alignment.topCenter,
405
- end: Alignment.bottomCenter,
406
- colors: [
407
- theme.scaffoldBackgroundColor.withValues(alpha: 0.1),
408
- theme.scaffoldBackgroundColor.withValues(alpha: 0.8),
409
- theme.scaffoldBackgroundColor,
410
- ],
411
- stops: const [0.0, 0.7, 1.0],
412
- ),
413
- ),
414
- child: Column(
415
- mainAxisSize: MainAxisSize.min,
416
- children: [
417
- if (!isStreaming && keyboardBottomInset == 0) ...[
418
- const SizedBox(height: 4),
419
- _SmartReplyChips(
420
- onSend: (message) {
421
- ref
422
- .read(chatProvider.notifier)
423
- .sendMessage(message);
424
- },
425
- ),
426
- ],
427
- ChatInputBar(
428
- focusNode: _inputFocusNode,
429
- isStreaming: isStreaming,
430
- onSend: (message, {attachments}) {
431
- ref
432
- .read(chatProvider.notifier)
433
- .sendMessage(message, attachments: attachments);
434
- },
435
- onStop: () {
436
- ref.read(chatProvider.notifier).cancelStream();
437
- },
438
- ),
439
- ],
440
- ),
441
- ),
442
- ),
443
- ],
444
- ),
445
- ),
446
- ],
94
+ return _ChatBody(
95
+ scrollController: _scrollController,
96
+ inputFocusNode: _inputFocusNode,
97
+ autoScroll: _autoScroll,
98
+ onModelPicker: () => _showModelPicker(context),
99
+ onMenuAction: (action) => _handleMenuAction(action, context),
100
);
101
}
102
@@ -460,10 +113,8 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
113
switch (action) {
114
case 'new_chat':
115
ref.read(chatProvider.notifier).startNewConversation();
463
- break;
116
case 'persona':
117
_showPersonaPicker(context);
466
- break;
118
case 'remove_persona':
119
final activeConv = ref.read(conv.activeConversationProvider);
120
if (activeConv != null) {
@@ -471,7 +122,6 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
122
.read(conv.conversationsProvider.notifier)
123
.updatePersona(activeConv.id, null, null);
124
}
474
- break;
125
case 'clear':
126
showDialog(
127
context: context,
@@ -496,7 +146,6 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
146
);
147
},
148
);
499
- break;
149
}
150
}
151
@@ -518,175 +167,49 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
167
child: Column(
168
mainAxisSize: MainAxisSize.min,
169
children: [
521
- Center(
522
- child: Container(
523
- width: 40,
524
- height: 4,
525
- decoration: BoxDecoration(
526
- color: isDark ? Colors.grey[600] : Colors.grey[300],
527
- borderRadius: BorderRadius.circular(2),
528
- ),
529
- ),
530
- ),
531
- const SizedBox(height: 16),
532
- Text(
533
- sheetL10n.select_persona,
534
- style: TextStyle(
535
- fontSize: 18,
536
- fontWeight: FontWeight.bold,
537
- color: isDark ? Colors.white : Colors.black,
538
- ),
539
- ),
540
- const SizedBox(height: 8),
541
- Flexible(
542
- child: ListView.builder(
543
- shrinkWrap: true,
544
- itemCount: personas.length,
545
- itemBuilder: (context, index) {
546
- final p = personas[index];
547
- final isSelected = p.id == currentPersonaId;
548
- final accent = isDark
549
- ? AppColors.darkAccent
550
- : AppColors.lightAccent;
551
- return ListTile(
552
- leading: Text(
553
- p.emoji,
554
- style: const TextStyle(fontSize: 22),
555
- ),
556
- title: Text(
557
- p.name,
558
- style: TextStyle(
559
- fontWeight: isSelected
560
- ? FontWeight.w600
561
- : FontWeight.normal,
562
- color: isDark ? Colors.white : Colors.black,
563
- ),
564
- ),
565
- subtitle: p.description != null
566
- ? Text(
567
- p.description!,
568
- style: TextStyle(
569
- fontSize: 12,
570
- color: isDark
571
- ? AppColors.darkMutedText
572
- : AppColors.lightMutedText,
573
- ),
574
- maxLines: 1,
575
- overflow: TextOverflow.ellipsis,
576
- )
577
- : null,
578
- trailing: isSelected
579
- ? Icon(Icons.check_circle, color: accent)
580
- : null,
581
- onTap: () {
582
- if (activeConv != null) {
583
- ref
584
- .read(conv.conversationsProvider.notifier)
585
- .updatePersona(
586
- activeConv.id,
587
- p.id,
588
- p.systemPrompt,
589
- );
590
- }
591
- Navigator.pop(ctx);
592
- },
593
- );
594
- },
595
- ),
596
- ),
597
- ],
598
- ),
599
- ),
600
- );
601
- },
602
- );
603
- }
604
-
605
- void _showPersonaPickerForPreselection(BuildContext context) {
606
- final personasAsync = ref.read(personasNotifierProvider);
607
- final personas = personasAsync.value ?? [];
608
- final isDark = Theme.of(context).brightness == Brightness.dark;
609
- final currentPersona = ref.watch(selectedPersonaProvider);
610
-
611
- showModalBottomSheet(
612
- context: context,
613
- isScrollControlled: true,
614
- builder: (ctx) {
615
- final sheetL10n = AppLocalizations.of(context)!;
616
- return SafeArea(
617
- child: Container(
618
- padding: const EdgeInsets.symmetric(vertical: 16),
619
- child: Column(
620
- mainAxisSize: MainAxisSize.min,
621
- children: [
622
- Center(
623
- child: Container(
624
- width: 40,
625
- height: 4,
626
- decoration: BoxDecoration(
627
- color: isDark ? Colors.grey[600] : Colors.grey[300],
628
- borderRadius: BorderRadius.circular(2),
629
- ),
170
+ Center(child: Container(
171
+ width: 40, height: 4,
172
+ decoration: BoxDecoration(
173
+ color: isDark ? Colors.grey[600] : Colors.grey[300],
174
+ borderRadius: BorderRadius.circular(2),
175
),
631
- ),
176
+ )),
177
const SizedBox(height: 16),
633
- Text(
634
- sheetL10n.select_persona,
635
- style: TextStyle(
636
- fontSize: 18,
637
- fontWeight: FontWeight.bold,
638
- color: isDark ? Colors.white : Colors.black,
639
- ),
640
- ),
178
+ Text(sheetL10n.select_persona, style: TextStyle(
179
+ fontSize: 18, fontWeight: FontWeight.bold,
180
+ color: isDark ? Colors.white : Colors.black,
181
+ )),
182
const SizedBox(height: 8),
642
- Flexible(
643
- child: ListView.builder(
644
- shrinkWrap: true,
645
- itemCount: personas.length,
646
- itemBuilder: (context, index) {
647
- final p = personas[index];
648
- final isSelected = p.id == currentPersona?.id;
649
- final accent = isDark
650
- ? AppColors.darkAccent
651
- : AppColors.lightAccent;
652
- return ListTile(
653
- leading: Text(
654
- p.emoji,
655
- style: const TextStyle(fontSize: 22),
656
- ),
657
- title: Text(
658
- p.name,
659
- style: TextStyle(
660
- fontWeight: isSelected
661
- ? FontWeight.w600
662
- : FontWeight.normal,
663
- color: isDark ? Colors.white : Colors.black,
664
- ),
665
- ),
666
- subtitle: p.description != null
667
- ? Text(
668
- p.description!,
669
- style: TextStyle(
670
- fontSize: 12,
671
- color: isDark
672
- ? AppColors.darkMutedText
673
- : AppColors.lightMutedText,
674
- ),
675
- maxLines: 1,
676
- overflow: TextOverflow.ellipsis,
677
- )
678
- : null,
679
- trailing: isSelected
680
- ? Icon(Icons.check_circle, color: accent)
681
- : null,
682
- onTap: () {
683
- ref.read(selectedPersonaProvider.notifier).select(p);
684
- Navigator.pop(ctx);
685
- },
686
- );
687
- },
688
- ),
689
- ),
183
+ Flexible(child: ListView.builder(
184
+ shrinkWrap: true,
185
+ itemCount: personas.length,
186
+ itemBuilder: (context, index) {
187
+ final p = personas[index];
188
+ final isSelected = p.id == currentPersonaId;
189
+ final accent = isDark ? AppColors.darkAccent : AppColors.lightAccent;
190
+ return ListTile(
191
+ leading: Text(p.emoji, style: const TextStyle(fontSize: 22)),
192
+ title: Text(p.name, style: TextStyle(
193
+ fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
194
+ color: isDark ? Colors.white : Colors.black,
195
+ )),
196
+ subtitle: p.description != null
197
+ ? Text(p.description!, style: TextStyle(
198
+ fontSize: 12,
199
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
200
+ ), maxLines: 1, overflow: TextOverflow.ellipsis)
201
+ : null,
202
+ trailing: isSelected ? Icon(Icons.check_circle, color: accent) : null,
203
+ onTap: () {
204
+ if (activeConv != null) {
205
+ ref.read(conv.conversationsProvider.notifier)
206
+ .updatePersona(activeConv.id, p.id, p.systemPrompt);
207
+ }
208
+ Navigator.pop(ctx);
209
+ },
210
+ );
211
+ },
212
+ )),
213
],
214
),
215
),
@@ -695,1141 +218,582 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
218
);
219
}
220
698
- Widget _buildServerIcon(BuildContext context, Server server) {
699
- final iconData = server.iconName != null
700
- ? getHugeIconByName(server.iconName)
701
- : getDefaultServerIcon(server.type.name);
702
-
703
- if (iconData == null) {
704
- return const Icon(Icons.dns, size: 18);
705
- }
706
-
707
- return HugeIcon(
708
- icon: iconData.icon,
709
- size: 18,
710
- color: Theme.of(context).colorScheme.primary,
711
- );
712
- }
713
-
714
- void _showToolApprovalDialog(
715
- BuildContext context,
716
- PendingToolApproval approval,
717
- ) {
718
- setState(() {
719
- _isApprovalDialogOpen = true;
720
- });
721
-
722
- final toolCall = approval.toolCall;
221
+ void _showToolApprovalDialog(BuildContext context, PendingToolApproval approval) {
222
+ setState(() => _isApprovalDialogOpen = true);
223
224
showShadDialog<bool>(
225
context: context,
226
barrierDismissible: false,
727
- builder: (context) {
728
- return ShadDialog.alert(
729
- title: Text('Execute Tool: ${toolCall.name}?'),
730
- description: Column(
731
- crossAxisAlignment: CrossAxisAlignment.start,
732
- mainAxisSize: MainAxisSize.min,
733
- children: [
734
- const Text(
735
- 'The model is requesting to execute the following tool:',
227
+ builder: (context) => ShadDialog.alert(
228
+ title: Text('Execute Tool: ${approval.toolCall.name}?'),
229
+ description: Column(
230
+ crossAxisAlignment: CrossAxisAlignment.start,
231
+ mainAxisSize: MainAxisSize.min,
232
+ children: [
233
+ const Text('The model is requesting to execute the following tool:'),
234
+ const SizedBox(height: 8),
235
+ Container(
236
+ width: double.infinity, padding: const EdgeInsets.all(8),
237
+ decoration: BoxDecoration(
238
+ color: Theme.of(context).colorScheme.surfaceContainer,
239
+ borderRadius: BorderRadius.circular(8),
240
),
737
- const SizedBox(height: 8),
738
- Container(
739
- width: double.infinity,
740
- padding: const EdgeInsets.all(8),
741
- decoration: BoxDecoration(
742
- color: Theme.of(context).colorScheme.surfaceContainer,
743
- borderRadius: BorderRadius.circular(8),
744
- ),
745
- child: SingleChildScrollView(
746
- scrollDirection: Axis.horizontal,
747
- child: Text(
748
- const JsonEncoder.withIndent(
749
- ' ',
750
- ).convert(toolCall.arguments),
751
- style: const TextStyle(
752
- fontFamily: 'monospace',
753
- fontSize: 12,
754
- ),
755
- ),
241
+ child: SingleChildScrollView(
242
+ scrollDirection: Axis.horizontal,
243
+ child: Text(
244
+ const JsonEncoder.withIndent(' ').convert(approval.toolCall.arguments),
245
+ style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
246
),
247
),
758
- ],
759
- ),
760
- actions: [
761
- ShadButton.outline(
762
- child: const Text('Reject'),
763
- onPressed: () {
764
- Navigator.of(context).pop(false);
765
- },
766
- ),
767
- ShadButton(
768
- child: const Text('Approve'),
769
- onPressed: () {
770
- Navigator.of(context).pop(true);
771
- },
248
),
249
],
774
- );
775
- },
250
+ ),
251
+ actions: [
252
+ ShadButton.outline(
253
+ child: const Text('Reject'),
254
+ onPressed: () => Navigator.of(context).pop(false),
255
+ ),
256
+ ShadButton(
257
+ child: const Text('Approve'),
258
+ onPressed: () => Navigator.of(context).pop(true),
259
+ ),
260
+ ],
261
+ ),
262
).then((value) {
777
- if (mounted) {
778
- setState(() {
779
- _isApprovalDialogOpen = false;
780
- });
781
- }
782
- final approved = value ?? false;
783
- ref.read(chatProvider.notifier).approveTool(approved);
263
+ if (mounted) setState(() => _isApprovalDialogOpen = false);
264
+ ref.read(chatProvider.notifier).approveTool(value ?? false);
265
});
266
}
267
}
268
788
-class _ModelTopBar extends StatelessWidget {
789
- const _ModelTopBar({required this.selectedModel, required this.onTap});
269
+class _ChatBody extends ConsumerWidget {
270
+ const _ChatBody({
271
+ required this.scrollController,
272
+ required this.inputFocusNode,
273
+ required this.autoScroll,
274
+ required this.onModelPicker,
275
+ required this.onMenuAction,
276
+ });
277
791
- final dynamic selectedModel;
792
- final VoidCallback onTap;
278
+ final ScrollController scrollController;
279
+ final FocusNode inputFocusNode;
280
+ final ChatAutoScrollController autoScroll;
281
+ final VoidCallback onModelPicker;
282
+ final void Function(String) onMenuAction;
283
284
@override
795
- Widget build(BuildContext context) {
285
+ Widget build(BuildContext context, WidgetRef ref) {
286
final l10n = AppLocalizations.of(context)!;
287
final theme = Theme.of(context);
288
final isDark = theme.brightness == Brightness.dark;
799
-
800
- return Container(
801
- width: double.infinity,
802
- color: theme.colorScheme.surface,
803
- child: SafeArea(
804
- bottom: false,
805
- child: GestureDetector(
806
- onTap: onTap,
807
- behavior: HitTestBehavior.opaque,
808
- child: Padding(
809
- padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
810
- child: Row(
811
- mainAxisAlignment: MainAxisAlignment.center,
812
- mainAxisSize: MainAxisSize.min,
813
- children: [
814
- Flexible(
815
- child: Text(
816
- selectedModel?.displayName ?? l10n.select_model,
817
- style: TextStyle(
818
- fontSize: 13,
819
- fontWeight: FontWeight.w500,
820
- color: isDark ? Colors.white70 : Colors.black87,
821
- ),
822
- maxLines: 2,
823
- textAlign: TextAlign.center,
824
- overflow: TextOverflow.ellipsis,
825
- ),
826
- ),
827
- const SizedBox(width: 4),
828
- Icon(
829
- Icons.expand_more,
830
- size: 14,
831
- color: isDark ? Colors.white38 : Colors.black38,
832
- ),
833
- ],
834
- ),
835
- ),
836
- ),
837
- ),
289
+ final isLoading = ref.watch(chatProvider.select((s) => s.isLoading));
290
+ final messages = ref.watch(chatProvider.select((s) => s.messages));
291
+ final isStreaming = ref.watch(chatProvider.select((s) => s.isStreaming));
292
+ final streamingLength = ref.watch(
293
+ chatProvider.select((s) => s.streamingMessage?.content.length ?? 0),
294
);
839
- }
840
-}
841
-
842
-class _ConnectionBanner extends StatelessWidget {
843
- const _ConnectionBanner({required this.status});
844
-
845
- final ConnectionStatus status;
295
+ final errorMessage = ref.watch(chatProvider.select((s) => s.errorMessage));
296
+ final selectedModel = ref.watch(selectedModelProvider);
297
+ final connectionStatus = ref.watch(connectionStatusProvider);
298
+ final activeServer = ref.watch(activeServerProvider);
299
+ final activeConversation = ref.watch(conv.activeConversationProvider);
300
+ final personaId = activeConversation?.personaId;
301
+ final persona = personaId != null
302
+ ? ref.watch(personaByIdProvider(personaId))
303
+ : null;
304
+ final keyboardBottomInset = bottomKeyboardInset(context);
305
+ final systemBottomInset = bottomSystemInset(context);
306
+ final effectiveBottomInset = keyboardBottomInset > 0 ? 0.0 : systemBottomInset;
307
847
- @override
848
- Widget build(BuildContext context) {
849
- final l10n = AppLocalizations.of(context)!;
850
- final isError = status == ConnectionStatus.error;
308
+ final needsScroll = autoScroll.checkAndUpdate(
309
+ messageCount: messages.length,
310
+ streamingLength: streamingLength,
311
+ isStreaming: isStreaming,
312
+ );
313
+ if (needsScroll) {
314
+ autoScroll.scheduleAutoScroll(
315
+ controller: scrollController, streaming: isStreaming,
316
+ );
317
+ }
318
852
- return Container(
853
- width: double.infinity,
854
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
855
- color: isError
856
- ? Colors.red.withValues(alpha: 0.1)
857
- : Colors.orange.withValues(alpha: 0.1),
858
- child: Row(
859
- children: [
860
- Icon(
861
- isError ? Icons.error_outline : Icons.wifi_off,
862
- size: 16,
863
- color: isError ? Colors.red : Colors.orange,
319
+ return Column(
320
+ children: [
321
+ ModelTopBar(selectedModel: selectedModel, onTap: onModelPicker),
322
+ _ScreenAppBar(
323
+ activeServer: activeServer,
324
+ connectionStatus: connectionStatus,
325
+ isDark: isDark,
326
+ persona: persona,
327
+ onMenuAction: onMenuAction,
328
+ onPersonaPicker: () => _openPersonaPicker(context, ref),
329
+ ),
330
+ const NotificationPermissionBanner(),
331
+ if (connectionStatus == ConnectionStatus.disconnected ||
332
+ connectionStatus == ConnectionStatus.error)
333
+ ConnectionBanner(status: connectionStatus),
334
+ if (persona != null)
335
+ PersonaIndicator(
336
+ persona: persona,
337
+ onTap: () => _openPersonaPicker(context, ref),
338
+ onRemove: () {
339
+ final activeConv = ref.read(conv.activeConversationProvider);
340
+ if (activeConv != null) {
341
+ ref.read(conv.conversationsProvider.notifier)
342
+ .updatePersona(activeConv.id, null, null);
343
+ }
344
+ },
345
),
865
- const SizedBox(width: 8),
866
- Expanded(
867
- child: Text(
868
- isError ? l10n.connection_error : l10n.disconnected,
869
- style: TextStyle(
870
- fontSize: 13,
871
- color: isError ? Colors.red : Colors.orange[700],
346
+ const TtsPlayerBar(),
347
+ Expanded(
348
+ child: Stack(
349
+ children: [
350
+ _MessageArea(
351
+ isLoading: isLoading,
352
+ messages: messages,
353
+ activeConversation: activeConversation,
354
+ errorMessage: errorMessage,
355
+ selectedModel: selectedModel,
356
+ isStreaming: isStreaming,
357
+ scrollController: scrollController,
358
+ effectiveBottomInset: effectiveBottomInset,
359
+ keyboardBottomInset: keyboardBottomInset,
360
+ onModelPicker: onModelPicker,
361
),
873
- ),
874
- ),
875
- TextButton(
876
- onPressed: () {
877
- context.push(AppRoutes.servers);
878
- },
879
- child: Text(
880
- l10n.configure,
881
- style: TextStyle(
882
- fontSize: 13,
883
- color: isError ? Colors.red : Colors.orange[700],
362
+ _ChatBottomBar(
363
+ isStreaming: isStreaming,
364
+ keyboardBottomInset: keyboardBottomInset,
365
+ inputFocusNode: inputFocusNode,
366
),
885
- ),
367
+ ],
368
),
887
- ],
888
- ),
369
+ ),
370
+ ],
371
);
372
}
373
}
374
893
-class _EmptyState extends StatefulWidget {
894
- const _EmptyState({
895
- required this.onQuickPrompt,
896
- required this.quickPrompts,
897
- required this.recentConversations,
898
- required this.onSeeAll,
899
- required this.selectedModel,
900
- required this.onModelTap,
901
- this.selectedPersona,
902
- required this.onPersonaTap,
903
- });
904
-
905
- final void Function(String) onQuickPrompt;
906
- final List<String> quickPrompts;
907
- final List<Conversation> recentConversations;
908
- final VoidCallback onSeeAll;
909
- final dynamic selectedModel;
910
- final VoidCallback onModelTap;
911
- final dynamic selectedPersona;
912
- final VoidCallback onPersonaTap;
913
-
914
- @override
915
- State<_EmptyState> createState() => _EmptyStateState();
916
-}
917
-
918
-class _EmptyStateState extends State<_EmptyState>
919
- with SingleTickerProviderStateMixin {
920
- late AnimationController _controller;
921
- late List<Animation<double>> _fadeAnimations;
922
- late List<Animation<Offset>> _slideAnimations;
923
-
924
- @override
925
- void initState() {
926
- super.initState();
927
- _controller = AnimationController(
928
- duration: const Duration(milliseconds: 500),
929
- vsync: this,
930
- );
931
-
932
- _fadeAnimations = List.generate(widget.quickPrompts.length, (index) {
933
- return Tween<double>(begin: 0.0, end: 1.0).animate(
934
- CurvedAnimation(
935
- parent: _controller,
936
- curve: Interval(
937
- (index * 0.1).clamp(0.0, 0.7),
938
- (0.3 + index * 0.1).clamp(0.2, 1.0),
939
- curve: Curves.easeOut,
940
- ),
941
- ),
942
- );
943
- });
944
-
945
- _slideAnimations = List.generate(widget.quickPrompts.length, (index) {
946
- return Tween<Offset>(
947
- begin: const Offset(0.0, 0.3),
948
- end: Offset.zero,
949
- ).animate(
950
- CurvedAnimation(
951
- parent: _controller,
952
- curve: Interval(
953
- (index * 0.1).clamp(0.0, 0.7),
954
- (0.3 + index * 0.1).clamp(0.2, 1.0),
955
- curve: Curves.easeOutCubic,
956
- ),
957
- ),
958
- );
959
- });
960
-
961
- _controller.forward();
962
- }
963
-
964
- @override
965
- void dispose() {
966
- _controller.dispose();
967
- super.dispose();
968
- }
969
-
970
- @override
971
- Widget build(BuildContext context) {
972
- final l10n = AppLocalizations.of(context)!;
973
- final theme = Theme.of(context);
974
- final isDark = theme.brightness == Brightness.dark;
975
-
976
- return Center(
977
- child: SingleChildScrollView(
978
- padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
979
- child: Column(
980
- mainAxisAlignment: MainAxisAlignment.center,
981
- children: [
982
- const SizedBox(height: 24),
983
- TweenAnimationBuilder<double>(
984
- tween: Tween(begin: 0.0, end: 1.0),
985
- duration: const Duration(milliseconds: 300),
986
- curve: Curves.easeOut,
987
- builder: (context, value, child) {
988
- return Opacity(
989
- opacity: value,
990
- child: Transform.translate(
991
- offset: Offset(0, 20 * (1 - value)),
992
- child: child,
993
- ),
994
- );
995
- },
996
- child: Column(
997
- children: [
998
- GestureDetector(
999
- onTap: widget.onModelTap,
1000
- child: Container(
1001
- padding: const EdgeInsets.symmetric(
1002
- horizontal: 16,
1003
- vertical: 12,
1004
- ),
1005
- decoration: BoxDecoration(
1006
- color: isDark ? AppColors.darkBackground : AppColors.lightSurface,
1007
- borderRadius: BorderRadius.circular(12),
1008
- border: Border.all(
1009
- color: isDark
1010
- ? AppColors.darkBorder
1011
- : AppColors.lightBorder,
1012
- ),
1013
- ),
1014
- child: Row(
1015
- mainAxisSize: MainAxisSize.min,
1016
- children: [
1017
- Icon(
1018
- Icons.settings_suggest,
1019
- size: 18,
1020
- color: theme.colorScheme.primary,
1021
- ),
1022
- const SizedBox(width: 8),
1023
- Flexible(
1024
- child: Text(
1025
- widget.selectedModel?.displayName ??
1026
- l10n.select_model,
1027
- style: TextStyle(
1028
- fontSize: 15,
1029
- fontWeight: FontWeight.w600,
1030
- color: isDark ? Colors.white : Colors.black,
1031
- ),
1032
- maxLines: 2,
1033
- overflow: TextOverflow.ellipsis,
1034
- ),
1035
- ),
1036
- const SizedBox(width: 4),
1037
- const Icon(
1038
- Icons.expand_more,
1039
- size: 16,
1040
- color: Colors.grey,
1041
- ),
1042
- ],
1043
- ),
1044
- ),
1045
- ),
1046
- const SizedBox(height: 12),
1047
- GestureDetector(
1048
- onTap: widget.onPersonaTap,
1049
- child: Container(
1050
- padding: const EdgeInsets.symmetric(
1051
- horizontal: 16,
1052
- vertical: 12,
1053
- ),
1054
- decoration: BoxDecoration(
1055
- color: isDark ? AppColors.darkBackground : AppColors.lightSurface,
1056
- borderRadius: BorderRadius.circular(12),
1057
- border: Border.all(
1058
- color: isDark
1059
- ? AppColors.darkBorder
1060
- : AppColors.lightBorder,
1061
- ),
1062
- ),
1063
- child: Row(
1064
- mainAxisSize: MainAxisSize.min,
1065
- children: [
1066
- if (widget.selectedPersona != null) ...[
1067
- Text(
1068
- widget.selectedPersona!.emoji,
1069
- style: const TextStyle(fontSize: 18),
1070
- ),
1071
- const SizedBox(width: 8),
1072
- Flexible(
1073
- child: Text(
1074
- widget.selectedPersona!.name,
1075
- style: TextStyle(
1076
- fontSize: 15,
1077
- fontWeight: FontWeight.w600,
1078
- color: isDark ? Colors.white : Colors.black,
1079
- ),
1080
- maxLines: 2,
1081
- overflow: TextOverflow.ellipsis,
1082
- ),
1083
- ),
1084
- ] else ...[
1085
- Icon(
1086
- Icons.smart_toy_outlined,
1087
- size: 18,
1088
- color: theme.colorScheme.primary,
1089
- ),
1090
- const SizedBox(width: 8),
1091
- Flexible(
1092
- child: Text(
1093
- l10n.select_persona,
1094
- style: TextStyle(
1095
- fontSize: 15,
1096
- fontWeight: FontWeight.w600,
1097
- color: isDark ? Colors.white : Colors.black,
1098
- ),
1099
- maxLines: 2,
1100
- overflow: TextOverflow.ellipsis,
1101
- ),
1102
- ),
1103
- ],
1104
- const SizedBox(width: 4),
1105
- const Icon(
1106
- Icons.expand_more,
1107
- size: 16,
1108
- color: Colors.grey,
1109
- ),
1110
- ],
1111
- ),
1112
- ),
1113
- ),
1114
- ],
1115
- ),
1116
- ),
1117
- const SizedBox(height: 32),
1118
- TweenAnimationBuilder<double>(
1119
- tween: Tween(begin: 0.0, end: 1.0),
1120
- duration: const Duration(milliseconds: 400),
1121
- curve: Curves.easeOut,
1122
- builder: (context, value, child) {
1123
- return Opacity(opacity: value, child: child);
1124
- },
1125
- child: Text(
1126
- l10n.start_conversation,
1127
- style: TextStyle(
1128
- fontSize: 16,
1129
- fontWeight: FontWeight.w500,
1130
- color: isDark ? Colors.white : Colors.black,
375
+void _openPersonaPicker(BuildContext context, WidgetRef ref) {
376
+ final personasAsync = ref.read(personasNotifierProvider);
377
+ final personas = personasAsync.value ?? [];
378
+ final isDark = Theme.of(context).brightness == Brightness.dark;
379
+ final activeConv = ref.read(conv.activeConversationProvider);
380
+ final currentPersonaId = activeConv?.personaId;
381
+
382
+ showModalBottomSheet(
383
+ context: context,
384
+ isScrollControlled: true,
385
+ builder: (ctx) {
386
+ final sheetL10n = AppLocalizations.of(context)!;
387
+ return SafeArea(
388
+ child: Container(
389
+ padding: const EdgeInsets.symmetric(vertical: 16),
390
+ child: Column(
391
+ mainAxisSize: MainAxisSize.min,
392
+ children: [
393
+ Center(child: Container(
394
+ width: 40, height: 4,
395
+ decoration: BoxDecoration(
396
+ color: isDark ? Colors.grey[600] : Colors.grey[300],
397
+ borderRadius: BorderRadius.circular(2),
398
),
1132
- ),
1133
- ),
1134
- const SizedBox(height: 16),
1135
- Wrap(
1136
- spacing: 8,
1137
- runSpacing: 8,
1138
- alignment: WrapAlignment.center,
1139
- children: widget.quickPrompts.asMap().entries.map((entry) {
1140
- final index = entry.key;
1141
- final prompt = entry.value;
1142
- final fadeAnimation = index < _fadeAnimations.length
1143
- ? _fadeAnimations[index]
1144
- : const AlwaysStoppedAnimation(1.0);
1145
- final slideAnimation = index < _slideAnimations.length
1146
- ? _slideAnimations[index]
1147
- : const AlwaysStoppedAnimation(Offset.zero);
1148
-
1149
- return AnimatedBuilder(
1150
- animation: _controller,
1151
- builder: (context, child) {
1152
- return FadeTransition(
1153
- opacity: fadeAnimation,
1154
- child: SlideTransition(
1155
- position: slideAnimation,
1156
- child: child,
1157
- ),
1158
- );
1159
- },
1160
- child: ActionChip(
1161
- label: Text(prompt),
1162
- onPressed: () => widget.onQuickPrompt(prompt),
1163
- backgroundColor: isDark
1164
- ? AppColors.darkSurfaceCard
1165
- : AppColors.lightSurface,
1166
- side: BorderSide(
1167
- color: isDark
1168
- ? AppColors.darkBorder
1169
- : AppColors.lightBorder,
1170
- ),
1171
- ),
1172
- );
1173
- }).toList(),
1174
- ),
1175
- if (widget.recentConversations.isNotEmpty) ...[
1176
- const SizedBox(height: 32),
1177
- Row(
1178
- mainAxisAlignment: MainAxisAlignment.center,
1179
- children: [
1180
- Text(
1181
- l10n.recent_chats,
1182
- style: TextStyle(
1183
- fontSize: 14,
1184
- fontWeight: FontWeight.w500,
1185
- color: isDark
1186
- ? AppColors.darkMutedText
1187
- : AppColors.lightMutedText,
1188
- ),
1189
- ),
1190
- const SizedBox(width: 8),
1191
- GestureDetector(
1192
- onTap: widget.onSeeAll,
1193
- child: Text(
1194
- l10n.see_all,
1195
- style: TextStyle(
1196
- fontSize: 14,
1197
- fontWeight: FontWeight.w500,
1198
- color: isDark
1199
- ? AppColors.darkAccent
1200
- : AppColors.lightAccent,
1201
- ),
1202
- ),
1203
- ),
1204
- ],
1205
- ),
1206
- const SizedBox(height: 12),
1207
- ...List.generate(widget.recentConversations.take(5).length, (
1208
- index,
1209
- ) {
1210
- final conv = widget.recentConversations.take(5).toList()[index];
1211
- return TweenAnimationBuilder<double>(
1212
- tween: Tween(begin: 0.0, end: 1.0),
1213
- duration: Duration(milliseconds: 400 + (index * 50)),
1214
- curve: Curves.easeOutCubic,
1215
- builder: (context, value, child) {
1216
- return Opacity(
1217
- opacity: value,
1218
- child: Transform.translate(
1219
- offset: Offset(0, 10 * (1 - value)),
1220
- child: child,
1221
- ),
1222
- );
1223
- },
1224
- child: Padding(
1225
- padding: const EdgeInsets.only(bottom: 8),
1226
- child: _RecentConversationItem(conversation: conv),
1227
- ),
1228
- );
1229
- }),
399
+ )),
400
+ const SizedBox(height: 16),
401
+ Text(sheetL10n.select_persona, style: TextStyle(
402
+ fontSize: 18, fontWeight: FontWeight.bold,
403
+ color: isDark ? Colors.white : Colors.black,
404
+ )),
405
+ const SizedBox(height: 8),
406
+ Flexible(child: ListView.builder(
407
+ shrinkWrap: true,
408
+ itemCount: personas.length,
409
+ itemBuilder: (context, index) {
410
+ final p = personas[index];
411
+ final isSelected = p.id == currentPersonaId;
412
+ final accent = isDark ? AppColors.darkAccent : AppColors.lightAccent;
413
+ return ListTile(
414
+ leading: Text(p.emoji, style: const TextStyle(fontSize: 22)),
415
+ title: Text(p.name, style: TextStyle(
416
+ fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
417
+ color: isDark ? Colors.white : Colors.black,
418
+ )),
419
+ subtitle: p.description != null
420
+ ? Text(p.description!, style: TextStyle(
421
+ fontSize: 12,
422
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
423
+ ), maxLines: 1, overflow: TextOverflow.ellipsis)
424
+ : null,
425
+ trailing: isSelected ? Icon(Icons.check_circle, color: accent) : null,
426
+ onTap: () {
427
+ if (activeConv != null) {
428
+ ref.read(conv.conversationsProvider.notifier)
429
+ .updatePersona(activeConv.id, p.id, p.systemPrompt);
430
+ }
431
+ Navigator.pop(ctx);
432
+ },
433
+ );
434
+ },
435
+ )),
436
],
1231
- ],
1232
- ),
1233
- ),
1234
- );
1235
- }
1236
-}
1237
-
1238
-class _RecentConversationItem extends ConsumerWidget {
1239
- const _RecentConversationItem({required this.conversation});
1240
-
1241
- final Conversation conversation;
1242
-
1243
- @override
1244
- Widget build(BuildContext context, WidgetRef ref) {
1245
- final theme = Theme.of(context);
1246
- final isDark = theme.brightness == Brightness.dark;
1247
-
1248
- return GestureDetector(
1249
- onTap: () {
1250
- ref.read(chatProvider.notifier).loadConversation(conversation);
1251
- },
1252
- child: Container(
1253
- width: 300,
1254
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
1255
- decoration: BoxDecoration(
1256
- color: isDark ? AppColors.darkBackground : AppColors.lightSurface,
1257
- borderRadius: BorderRadius.circular(8),
1258
- border: Border.all(
1259
- color: isDark ? AppColors.darkSurfaceCard : AppColors.lightBorder,
437
),
438
),
1262
- child: Row(
1263
- children: [
1264
- Icon(
1265
- Icons.chat_bubble_outline,
1266
- size: 18,
1267
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
1268
- ),
1269
- const SizedBox(width: 12),
1270
- Expanded(
1271
- child: Column(
1272
- crossAxisAlignment: CrossAxisAlignment.start,
1273
- children: [
1274
- Text(
1275
- conversation.title,
1276
- style: TextStyle(
1277
- fontSize: 14,
1278
- fontWeight: FontWeight.w500,
1279
- color: isDark ? Colors.white : Colors.black,
1280
- ),
1281
- maxLines: 1,
1282
- overflow: TextOverflow.ellipsis,
1283
- ),
1284
- if (conversation.lastMessagePreview != null)
1285
- Text(
1286
- conversation.lastMessagePreview!,
1287
- style: TextStyle(
1288
- fontSize: 12,
1289
- color: isDark
1290
- ? AppColors.darkMutedText
1291
- : AppColors.lightMutedText,
1292
- ),
1293
- maxLines: 1,
1294
- overflow: TextOverflow.ellipsis,
1295
- ),
1296
- ],
1297
- ),
1298
- ),
1299
- Icon(
1300
- Directionality.of(context) == TextDirection.rtl
1301
- ? Icons.chevron_left
1302
- : Icons.chevron_right,
1303
- size: 18,
1304
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
1305
- ),
1306
- ],
1307
- ),
1308
- ),
1309
- );
1310
- }
439
+ );
440
+ },
441
+ ).then((_) {});
442
}
443
1313
-class _MessageListConsumer extends ConsumerWidget {
1314
- const _MessageListConsumer({
1315
- required this.scrollController,
444
+class _MessageArea extends ConsumerWidget {
445
+ const _MessageArea({
446
+ required this.isLoading,
447
required this.messages,
448
+ required this.activeConversation,
449
+ this.errorMessage,
450
+ required this.selectedModel,
451
required this.isStreaming,
1318
- required this.onRetry,
1319
- required this.onDelete,
1320
- required this.onEdit,
1321
- this.hasSmartReplies = false,
1322
- this.bottomInset = 0,
1323
- });
1324
-
1325
- final ScrollController scrollController;
1326
- final List<Message> messages;
1327
- final bool isStreaming;
1328
- final void Function(String) onRetry;
1329
- final void Function(String) onDelete;
1330
- final void Function(String messageId, String currentContent) onEdit;
1331
- final bool hasSmartReplies;
1332
- final double bottomInset;
1333
-
1334
- @override
1335
- Widget build(BuildContext context, WidgetRef ref) {
1336
- final streamingMessage = ref.watch(
1337
- chatProvider.select((s) => s.streamingMessage),
1338
- );
1339
-
1340
- return _MessageList(
1341
- scrollController: scrollController,
1342
- messages: messages,
1343
- streamingMessage: streamingMessage,
1344
- isStreaming: isStreaming,
1345
- onRetry: onRetry,
1346
- onDelete: onDelete,
1347
- onEdit: onEdit,
1348
- hasSmartReplies: hasSmartReplies,
1349
- bottomInset: bottomInset,
1350
- );
1351
- }
1352
-}
1353
-
1354
-class _MessageList extends StatelessWidget {
1355
- const _MessageList({
452
required this.scrollController,
1357
- required this.messages,
1358
- required this.streamingMessage,
1359
- required this.isStreaming,
1360
- required this.onRetry,
1361
- required this.onDelete,
1362
- required this.onEdit,
1363
- this.hasSmartReplies = false,
1364
- this.bottomInset = 0,
453
+ required this.effectiveBottomInset,
454
+ required this.keyboardBottomInset,
455
+ required this.onModelPicker,
456
});
457
1367
- final ScrollController scrollController;
458
+ final bool isLoading;
459
final List<Message> messages;
1369
- final Message? streamingMessage;
460
+ final dynamic activeConversation;
461
+ final String? errorMessage;
462
+ final dynamic selectedModel;
463
final bool isStreaming;
1371
- final void Function(String) onRetry;
1372
- final void Function(String) onDelete;
1373
- final void Function(String messageId, String currentContent) onEdit;
1374
- final bool hasSmartReplies;
1375
- final double bottomInset;
464
+ final ScrollController scrollController;
465
+ final double effectiveBottomInset;
466
+ final double keyboardBottomInset;
467
+ final VoidCallback onModelPicker;
468
469
@override
1378
- Widget build(BuildContext context) {
1379
- final allMessages = <Message>[];
470
+ Widget build(BuildContext context, WidgetRef ref) {
471
+ final l10n = AppLocalizations.of(context)!;
472
1381
- for (final message in messages) {
1382
- if (streamingMessage != null &&
1383
- message.id == streamingMessage!.id &&
1384
- isStreaming) {
1385
- continue;
1386
- }
1387
- allMessages.add(message);
473
+ if (isLoading) {
474
+ return const Center(child: CircularProgressIndicator(strokeWidth: 2));
475
}
476
1390
- return ListView.builder(
1391
- controller: scrollController,
1392
- scrollCacheExtent: const ScrollCacheExtent.pixels(1000),
1393
- padding: EdgeInsets.only(
1394
- top: 16,
1395
- bottom: 120 + (hasSmartReplies ? 64 : 0) + bottomInset,
1396
- ),
1397
- itemCount:
1398
- allMessages.length +
1399
- (streamingMessage != null && isStreaming ? 1 : 0),
1400
- itemBuilder: (context, index) {
1401
- if (streamingMessage != null &&
1402
- isStreaming &&
1403
- index == allMessages.length) {
1404
- return ChatBubble(message: streamingMessage!, isStreaming: true);
1405
- }
477
+ if (messages.isEmpty && activeConversation != null) {
478
+ return CorruptedChatState(
479
+ conversation: activeConversation,
480
+ errorMessage: errorMessage,
481
+ onStartNewChat: () =>
482
+ ref.read(chatProvider.notifier).startNewConversation(),
483
+ );
484
+ }
485
1407
- final message = allMessages[index];
1408
- final isLast = index == allMessages.length - 1;
486
+ if (messages.isEmpty) {
487
+ return EmptyState(
488
+ onQuickPrompt: (prompt) =>
489
+ ref.read(chatProvider.notifier).sendMessage(prompt),
490
+ quickPrompts: [
491
+ l10n.quick_write,
492
+ l10n.quick_explain,
493
+ l10n.quick_debug,
494
+ l10n.quick_async,
495
+ ],
496
+ recentConversations: ref.watch(conv.recentConversationsProvider),
497
+ onSeeAll: () => context.push(AppRoutes.chatHistory),
498
+ selectedModel: selectedModel,
499
+ onModelTap: onModelPicker,
500
+ selectedPersona: ref.watch(selectedPersonaProvider),
501
+ onPersonaTap: () => _openPersonaPickerForPreselection(context, ref),
502
+ );
503
+ }
504
1410
- return ChatBubble(
1411
- key: ValueKey(message.id),
1412
- message: message,
1413
- isStreaming:
1414
- isLast && isStreaming && message.id == streamingMessage?.id,
1415
- onRetry: () => onRetry(message.id),
1416
- onDelete: () => onDelete(message.id),
1417
- onEdit: message.role == MessageRole.user
1418
- ? () => onEdit(message.id, message.content)
1419
- : null,
505
+ return MessageList(
506
+ scrollController: scrollController,
507
+ messages: messages,
508
+ isStreaming: isStreaming,
509
+ onRetry: (messageId) =>
510
+ ref.read(chatProvider.notifier).retryMessage(messageId),
511
+ onDelete: (messageId) =>
512
+ ref.read(chatProvider.notifier).deleteMessage(messageId),
513
+ onEdit: (messageId, currentContent) async {
514
+ final newContent = await EditMessageDialog.show(
515
+ context,
516
+ initialContent: currentContent,
517
);
518
+ if (newContent != null && newContent != currentContent) {
519
+ await ref
520
+ .read(chatProvider.notifier)
521
+ .editMessage(messageId, newContent);
522
+ }
523
},
524
+ hasSmartReplies:
525
+ !isStreaming &&
526
+ keyboardBottomInset == 0 &&
527
+ (ref.watch(smartRepliesProvider).asData?.value.isNotEmpty ?? false),
528
+ bottomInset: effectiveBottomInset,
529
);
530
}
531
}
532
1426
-class _SmartReplyChips extends ConsumerStatefulWidget {
1427
- const _SmartReplyChips({required this.onSend});
1428
- final ValueChanged<String> onSend;
1429
-
1430
- @override
1431
- ConsumerState<_SmartReplyChips> createState() => _SmartReplyChipsState();
1432
-}
1433
-
1434
-class _SmartReplyChipsState extends ConsumerState<_SmartReplyChips>
1435
- with TickerProviderStateMixin {
1436
- List<String> _previousSuggestions = [];
1437
- late AnimationController _controller;
1438
- List<Animation<double>> _fadeAnimations = [];
1439
- List<Animation<Offset>> _slideAnimations = [];
1440
-
1441
- @override
1442
- void initState() {
1443
- super.initState();
1444
- _controller = AnimationController(
1445
- duration: const Duration(milliseconds: 400),
1446
- vsync: this,
1447
- );
1448
- }
1449
-
1450
- @override
1451
- void dispose() {
1452
- _controller.dispose();
1453
- super.dispose();
1454
- }
1455
-
1456
- bool _listsEqual(List<String> a, List<String> b) {
1457
- if (a.length != b.length) return false;
1458
- for (int i = 0; i < a.length; i++) {
1459
- if (a[i] != b[i]) return false;
1460
- }
1461
- return true;
1462
- }
1463
-
1464
- void _updateAnimations(List<String> suggestions) {
1465
- if (suggestions.isEmpty) {
1466
- _previousSuggestions = [];
1467
- return;
1468
- }
1469
- if (_listsEqual(suggestions, _previousSuggestions)) {
1470
- return;
1471
- }
1472
-
1473
- _previousSuggestions = List.from(suggestions);
1474
-
1475
- _fadeAnimations = List.generate(suggestions.length, (index) {
1476
- return Tween<double>(begin: 0.0, end: 1.0).animate(
1477
- CurvedAnimation(
1478
- parent: _controller,
1479
- curve: Interval(
1480
- (index * 0.1).clamp(0.0, 0.8),
1481
- (0.3 + index * 0.1).clamp(0.2, 1.0),
1482
- curve: Curves.easeOut,
1483
- ),
1484
- ),
1485
- );
1486
- });
1487
-
1488
- _slideAnimations = List.generate(suggestions.length, (index) {
1489
- return Tween<Offset>(
1490
- begin: const Offset(0.0, 0.5),
1491
- end: Offset.zero,
1492
- ).animate(
1493
- CurvedAnimation(
1494
- parent: _controller,
1495
- curve: Interval(
1496
- (index * 0.1).clamp(0.0, 0.8),
1497
- (0.3 + index * 0.1).clamp(0.2, 1.0),
1498
- curve: Curves.easeOut,
533
+void _openPersonaPickerForPreselection(BuildContext context, WidgetRef ref) {
534
+ final personasAsync = ref.read(personasNotifierProvider);
535
+ final personas = personasAsync.value ?? [];
536
+ final isDark = Theme.of(context).brightness == Brightness.dark;
537
+ final currentPersona = ref.watch(selectedPersonaProvider);
538
+
539
+ showModalBottomSheet(
540
+ context: context,
541
+ isScrollControlled: true,
542
+ builder: (ctx) {
543
+ final sheetL10n = AppLocalizations.of(context)!;
544
+ return SafeArea(
545
+ child: Container(
546
+ padding: const EdgeInsets.symmetric(vertical: 16),
547
+ child: Column(
548
+ mainAxisSize: MainAxisSize.min,
549
+ children: [
550
+ Center(child: Container(
551
+ width: 40, height: 4,
552
+ decoration: BoxDecoration(
553
+ color: isDark ? Colors.grey[600] : Colors.grey[300],
554
+ borderRadius: BorderRadius.circular(2),
555
+ ),
556
+ )),
557
+ const SizedBox(height: 16),
558
+ Text(sheetL10n.select_persona, style: TextStyle(
559
+ fontSize: 18, fontWeight: FontWeight.bold,
560
+ color: isDark ? Colors.white : Colors.black,
561
+ )),
562
+ const SizedBox(height: 8),
563
+ Flexible(child: ListView.builder(
564
+ shrinkWrap: true,
565
+ itemCount: personas.length,
566
+ itemBuilder: (context, index) {
567
+ final p = personas[index];
568
+ final isSelected = p.id == currentPersona?.id;
569
+ final accent = isDark ? AppColors.darkAccent : AppColors.lightAccent;
570
+ return ListTile(
571
+ leading: Text(p.emoji, style: const TextStyle(fontSize: 22)),
572
+ title: Text(p.name, style: TextStyle(
573
+ fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
574
+ color: isDark ? Colors.white : Colors.black,
575
+ )),
576
+ subtitle: p.description != null
577
+ ? Text(p.description!, style: TextStyle(
578
+ fontSize: 12,
579
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
580
+ ), maxLines: 1, overflow: TextOverflow.ellipsis)
581
+ : null,
582
+ trailing: isSelected ? Icon(Icons.check_circle, color: accent) : null,
583
+ onTap: () {
584
+ ref.read(selectedPersonaProvider.notifier).select(p);
585
+ Navigator.pop(ctx);
586
+ },
587
+ );
588
+ },
589
+ )),
590
+ ],
591
),
592
),
593
);
1502
- });
1503
-
1504
- _controller.forward(from: 0.0);
1505
- }
1506
-
1507
- @override
1508
- Widget build(BuildContext context) {
1509
- final suggestionsAsync = ref.watch(smartRepliesProvider);
1510
- final suggestions = suggestionsAsync.asData?.value ?? [];
1511
-
1512
- if (suggestions.isEmpty) {
1513
- _previousSuggestions = [];
1514
- return const SizedBox.shrink();
1515
- }
1516
-
1517
- _updateAnimations(suggestions);
1518
-
1519
- final theme = Theme.of(context);
1520
- final isDark = theme.brightness == Brightness.dark;
1521
-
1522
- return AnimatedSize(
1523
- duration: const Duration(milliseconds: 200),
1524
- curve: Curves.easeOut,
1525
- child: SizedBox(
1526
- height: 40,
1527
- child: ListView.separated(
1528
- padding: const EdgeInsets.symmetric(horizontal: 20),
1529
- clipBehavior: Clip.none,
1530
- scrollDirection: Axis.horizontal,
1531
- itemCount: suggestions.length,
1532
- separatorBuilder: (_, _) => const SizedBox(width: 6),
1533
- itemBuilder: (context, index) {
1534
- final label = suggestions[index];
1535
- final fadeAnimation = index < _fadeAnimations.length
1536
- ? _fadeAnimations[index]
1537
- : const AlwaysStoppedAnimation(1.0);
1538
- final slideAnimation = index < _slideAnimations.length
1539
- ? _slideAnimations[index]
1540
- : const AlwaysStoppedAnimation(Offset.zero);
1541
-
1542
- return AnimatedBuilder(
1543
- animation: _controller,
1544
- builder: (context, child) {
1545
- return FadeTransition(
1546
- opacity: fadeAnimation,
1547
- child: SlideTransition(
1548
- position: slideAnimation,
1549
- child: child,
1550
- ),
1551
- );
1552
- },
1553
- child: Center(
1554
- child: GestureDetector(
1555
- onTap: () => widget.onSend(label),
1556
- child: ClipRRect(
1557
- borderRadius: BorderRadius.circular(20),
1558
- child: BackdropFilter(
1559
- filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
1560
- child: Container(
1561
- padding: const EdgeInsets.symmetric(
1562
- horizontal: 12,
1563
- vertical: 6,
1564
- ),
1565
- decoration: BoxDecoration(
1566
- gradient: LinearGradient(
1567
- begin: Alignment.topLeft,
1568
- end: Alignment.bottomRight,
1569
- colors: isDark
1570
- ? [
1571
- Colors.white.withValues(alpha: 0.10),
1572
- Colors.white.withValues(alpha: 0.05),
1573
- ]
1574
- : [
1575
- Colors.white.withValues(alpha: 0.70),
1576
- Colors.white.withValues(alpha: 0.40),
1577
- ],
1578
- ),
1579
- borderRadius: BorderRadius.circular(20),
1580
- border: Border.all(
1581
- color: isDark
1582
- ? Colors.white.withValues(alpha: 0.18)
1583
- : Colors.white.withValues(alpha: 0.80),
1584
- width: 1.0,
1585
- ),
1586
- ),
1587
- child: Text(
1588
- label,
1589
- style: TextStyle(
1590
- fontSize: 13,
1591
- fontWeight: FontWeight.w500,
1592
- color: theme.colorScheme.onSurface,
1593
- ),
1594
- ),
1595
- ),
1596
- ),
1597
- ),
1598
- ),
1599
- ),
1600
- );
1601
- },
1602
- ),
1603
- ),
1604
- );
1605
- }
594
+ },
595
+ ).then((_) {});
596
}
597
1608
-class _PersonaIndicator extends StatelessWidget {
1609
- const _PersonaIndicator({
598
+class _ScreenAppBar extends ConsumerWidget {
599
+ const _ScreenAppBar({
600
+ required this.activeServer,
601
+ required this.connectionStatus,
602
+ required this.isDark,
603
required this.persona,
1611
- required this.onTap,
1612
- required this.onRemove,
604
+ required this.onMenuAction,
605
+ required this.onPersonaPicker,
606
});
607
608
+ final dynamic activeServer;
609
+ final ConnectionStatus connectionStatus;
610
+ final bool isDark;
611
final dynamic persona;
1616
- final VoidCallback onTap;
1617
- final VoidCallback onRemove;
612
+ final void Function(String) onMenuAction;
613
+ final VoidCallback onPersonaPicker;
614
615
@override
1620
- Widget build(BuildContext context) {
616
+ Widget build(BuildContext context, WidgetRef ref) {
617
final l10n = AppLocalizations.of(context)!;
1622
- final theme = Theme.of(context);
1623
- final isDark = theme.brightness == Brightness.dark;
618
+ final settings = ref.watch(settingsProvider);
619
+ final mcpConfig = ref.watch(chatMcpConfigProvider);
620
+ final isMcpEnabled = settings.mcpEnabled && mcpConfig.enabled;
621
622
return Container(
1626
- width: double.infinity,
1627
- color: isDark ? AppColors.darkBackground : AppColors.lightSurface,
1628
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
623
+ height: 56,
624
+ padding: const EdgeInsets.symmetric(horizontal: 4),
625
child: Row(
626
children: [
1631
- GestureDetector(
1632
- onTap: onTap,
1633
- child: Container(
1634
- padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
1635
- decoration: BoxDecoration(
1636
- color: theme.colorScheme.primary.withValues(alpha: 0.1),
1637
- borderRadius: BorderRadius.circular(16),
1638
- border: Border.all(
1639
- color: theme.colorScheme.primary.withValues(alpha: 0.2),
1640
- ),
1641
- ),
1642
- child: Row(
1643
- mainAxisSize: MainAxisSize.min,
1644
- children: [
1645
- Text(persona.emoji, style: const TextStyle(fontSize: 16)),
1646
- const SizedBox(width: 6),
1647
- Flexible(
1648
- child: Text(
1649
- persona.name,
1650
- style: TextStyle(
1651
- fontSize: 13,
1652
- fontWeight: FontWeight.w600,
1653
- color: theme.colorScheme.primary,
1654
- ),
1655
- maxLines: 2,
1656
- overflow: TextOverflow.ellipsis,
1657
- ),
1658
- ),
1659
- ],
1660
- ),
1661
- ),
1662
- ),
1663
- const Spacer(),
1664
- IconButton(
1665
- icon: const Icon(Icons.close, size: 16),
1666
- onPressed: onRemove,
1667
- padding: EdgeInsets.zero,
1668
- constraints: const BoxConstraints(),
1669
- tooltip: l10n.remove_persona,
1670
- color: isDark ? Colors.white38 : Colors.black38,
1671
- ),
1672
- ],
1673
- ),
1674
- );
1675
- }
1676
-}
1677
-
1678
-class _CorruptedChatState extends ConsumerWidget {
1679
- const _CorruptedChatState({
1680
- required this.conversation,
1681
- this.errorMessage,
1682
- required this.onStartNewChat,
1683
- });
1684
-
1685
- final Conversation conversation;
1686
- final String? errorMessage;
1687
- final VoidCallback onStartNewChat;
1688
-
1689
- void _showDebugInfo(BuildContext context, WidgetRef ref) {
1690
- final debugL10n = AppLocalizations.of(context)!;
1691
- showShadDialog(
1692
- context: context,
1693
- builder: (context) => ShadDialog(
1694
- title: Text(debugL10n.technical_details),
1695
- description: Text(debugL10n.debug_dialog_desc),
1696
- actions: [
1697
- ShadButton.outline(
1698
- onPressed: () {
1699
- final data =
1700
- '''
1701
-ID: ${conversation.id}
1702
-Title: ${conversation.title}
1703
-Expected: ${conversation.messageCount}
1704
-Error: $errorMessage
1705
-''';
1706
- Clipboard.setData(ClipboardData(text: data));
1707
- ScaffoldMessenger.of(context).showSnackBar(
1708
- SnackBar(content: Text(debugL10n.copied_to_clipboard)),
627
+ ShadResponsiveBuilder(
628
+ builder: (context, breakpoint) {
629
+ final isDesktop = breakpoint >= ShadTheme.of(context).breakpoints.md;
630
+ if (isDesktop) return const SizedBox.shrink();
631
+ return IconButton(
632
+ icon: const Icon(Icons.menu),
633
+ onPressed: () => Scaffold.of(context).openDrawer(),
634
);
635
},
1711
- child: Text(debugL10n.copy_info),
636
),
1713
- ShadButton(
1714
- onPressed: () => Navigator.of(context).pop(),
1715
- child: Text(debugL10n.close),
637
+ const SizedBox(width: 8),
638
+ Expanded(
639
+ child: Row(
640
+ children: [
641
+ if (activeServer != null) ...[
642
+ _buildServerIcon(context, activeServer as Server?),
643
+ const SizedBox(width: 8),
644
+ ],
645
+ Text(
646
+ activeServer?.name ?? l10n.chat_title,
647
+ style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
648
+ ),
649
+ const SizedBox(width: 8),
650
+ Container(
651
+ width: 8, height: 8,
652
+ decoration: BoxDecoration(
653
+ shape: BoxShape.circle,
654
+ color: connectionStatus == ConnectionStatus.connected
655
+ ? Colors.green : Colors.grey,
656
+ ),
657
+ ),
658
+ ],
659
+ ),
660
),
1717
- ],
1718
- child: Container(
1719
- width: 400,
1720
- padding: const EdgeInsets.symmetric(vertical: 20),
1721
- child: Column(
1722
- crossAxisAlignment: CrossAxisAlignment.start,
1723
- mainAxisSize: MainAxisSize.min,
661
+ Stack(
662
children: [
1725
- _DebugRow(
1726
- label: debugL10n.conversation_id,
1727
- value: conversation.id,
663
+ IconButton(
664
+ icon: HugeIcon(
665
+ icon: HugeIcons.strokeRoundedFilterHorizontal,
666
+ size: 24,
667
+ color: isDark ? Colors.white70 : Colors.black87,
668
+ ),
669
+ onPressed: () => showChatSettingsSheet(context, initialTab: 'parameters'),
670
+ tooltip: l10n.chat_parameters_tooltip,
671
),
1729
- _DebugRow(
1730
- label: debugL10n.created_at,
1731
- value: conversation.createdAt.toIso8601String(),
672
+ PositionedDirectional(
673
+ top: 4, end: 0,
674
+ child: Container(
675
+ padding: const EdgeInsets.all(2),
676
+ decoration: BoxDecoration(
677
+ color: isMcpEnabled ? Colors.green : Colors.grey,
678
+ shape: BoxShape.circle,
679
+ ),
680
+ child: const HugeIcon(
681
+ icon: HugeIcons.strokeRoundedTools,
682
+ size: 10, color: Colors.white,
683
+ ),
684
+ ),
685
+ ),
686
+ ],
687
+ ),
688
+ PopupMenuButton<String>(
689
+ icon: const Icon(Icons.more_vert),
690
+ onSelected: onMenuAction,
691
+ itemBuilder: (context) => [
692
+ PopupMenuItem(
693
+ value: 'new_chat',
694
+ child: ListTile(
695
+ leading: const Icon(Icons.add),
696
+ title: Text(l10n.nav_new_chat),
697
+ contentPadding: EdgeInsets.zero,
698
+ ),
699
),
1733
- _DebugRow(
1734
- label: debugL10n.expected_messages,
1735
- value: '${conversation.messageCount}',
700
+ PopupMenuItem(
701
+ value: 'persona',
702
+ child: ListTile(
703
+ leading: Icon(persona != null
704
+ ? Icons.swap_horiz : Icons.smart_toy_outlined),
705
+ title: Text(persona != null
706
+ ? l10n.change_persona : l10n.set_persona),
707
+ contentPadding: EdgeInsets.zero,
708
+ ),
709
),
1737
- if (errorMessage != null)
1738
- Padding(
1739
- padding: const EdgeInsets.only(top: 12),
1740
- child: Column(
1741
- crossAxisAlignment: CrossAxisAlignment.start,
1742
- children: [
1743
- Text(
1744
- debugL10n.last_error,
1745
- style: const TextStyle(
1746
- fontSize: 12,
1747
- fontWeight: FontWeight.bold,
1748
- color: Colors.red,
1749
- ),
1750
- ),
1751
- const SizedBox(height: 4),
1752
- Container(
1753
- padding: const EdgeInsets.all(8),
1754
- decoration: BoxDecoration(
1755
- color: Colors.red.withValues(alpha: 0.1),
1756
- borderRadius: BorderRadius.circular(4),
1757
- ),
1758
- child: Text(
1759
- errorMessage!,
1760
- style: const TextStyle(
1761
- fontSize: 11,
1762
- fontFamily: 'monospace',
1763
- ),
1764
- ),
1765
- ),
1766
- ],
710
+ if (persona != null)
711
+ PopupMenuItem(
712
+ value: 'remove_persona',
713
+ child: ListTile(
714
+ leading: const Icon(Icons.person_remove_outlined),
715
+ title: Text(l10n.remove_persona),
716
+ contentPadding: EdgeInsets.zero,
717
),
718
),
719
+ PopupMenuItem(
720
+ value: 'clear',
721
+ child: ListTile(
722
+ leading: const Icon(Icons.delete_outline),
723
+ title: Text(l10n.clear_conversation),
724
+ contentPadding: EdgeInsets.zero,
725
+ ),
726
+ ),
727
],
728
),
1771
- ),
729
+ ],
730
),
731
);
732
}
733
+}
734
+
735
+Widget _buildServerIcon(BuildContext context, Server? server) {
736
+ if (server == null) return const SizedBox.shrink();
737
+
738
+ final iconData = server.iconName != null
739
+ ? getHugeIconByName(server.iconName)
740
+ : getDefaultServerIcon(server.type.name);
741
+
742
+ if (iconData == null) return const Icon(Icons.dns, size: 18);
743
+
744
+ return HugeIcon(
745
+ icon: iconData.icon,
746
+ size: 18,
747
+ color: Theme.of(context).colorScheme.primary,
748
+ );
749
+}
750
+
751
+class _ChatBottomBar extends ConsumerWidget {
752
+ const _ChatBottomBar({
753
+ required this.isStreaming,
754
+ required this.keyboardBottomInset,
755
+ required this.inputFocusNode,
756
+ });
757
+
758
+ final bool isStreaming;
759
+ final double keyboardBottomInset;
760
+ final FocusNode inputFocusNode;
761
762
@override
763
Widget build(BuildContext context, WidgetRef ref) {
1778
- final l10n = AppLocalizations.of(context)!;
1779
- final theme = Theme.of(context);
1780
- final isDark = theme.brightness == Brightness.dark;
1781
-
1782
- return Center(
1783
- child: Padding(
1784
- padding: const EdgeInsets.all(32.0),
764
+ return Positioned(
765
+ bottom: 0,
766
+ left: 0,
767
+ right: 0,
768
+ child: Container(
769
+ decoration: BoxDecoration(
770
+ gradient: LinearGradient(
771
+ begin: Alignment.topCenter,
772
+ end: Alignment.bottomCenter,
773
+ colors: [
774
+ Theme.of(context).scaffoldBackgroundColor.withValues(alpha: 0.1),
775
+ Theme.of(context).scaffoldBackgroundColor.withValues(alpha: 0.8),
776
+ Theme.of(context).scaffoldBackgroundColor,
777
+ ],
778
+ stops: const [0.0, 0.7, 1.0],
779
+ ),
780
+ ),
781
child: Column(
1786
- mainAxisAlignment: MainAxisAlignment.center,
782
+ mainAxisSize: MainAxisSize.min,
783
children: [
1788
- Container(
1789
- padding: const EdgeInsets.all(20),
1790
- decoration: BoxDecoration(
1791
- color: Colors.red.withValues(alpha: 0.1),
1792
- shape: BoxShape.circle,
1793
- ),
1794
- child: const Icon(
1795
- Icons.history_toggle_off_rounded,
1796
- size: 48,
1797
- color: Colors.red,
1798
- ),
1799
- ),
1800
- const SizedBox(height: 24),
1801
- Text(
1802
- l10n.history_missing_title,
1803
- style: TextStyle(
1804
- fontSize: 20,
1805
- fontWeight: FontWeight.bold,
1806
- color: isDark ? Colors.white : Colors.black,
1807
- ),
1808
- ),
1809
- const SizedBox(height: 12),
1810
- Text(
1811
- l10n.history_missing_desc,
1812
- textAlign: TextAlign.center,
1813
- style: TextStyle(
1814
- fontSize: 15,
1815
- color: isDark ? Colors.white60 : Colors.black54,
1816
- ),
1817
- ),
1818
- const SizedBox(height: 32),
1819
- Row(
1820
- mainAxisAlignment: MainAxisAlignment.center,
1821
- children: [
1822
- ShadButton(
1823
- onPressed: onStartNewChat,
1824
- leading: const Icon(Icons.add_rounded, size: 20),
1825
- child: Text(l10n.start_new_chat),
1826
- ),
1827
- const SizedBox(width: 12),
1828
- ShadButton.outline(
1829
- onPressed: () => _showDebugInfo(context, ref),
1830
- child: Text(l10n.technical_details),
1831
- ),
1832
- ],
784
+ if (!isStreaming && keyboardBottomInset == 0) ...[
785
+ const SizedBox(height: 4),
786
+ _SmartReplyChipsWrapper(),
787
+ ],
788
+ ChatInputBar(
789
+ focusNode: inputFocusNode,
790
+ isStreaming: isStreaming,
791
+ onSend: (message, {attachments}) {
792
+ ref.read(chatProvider.notifier).sendMessage(
793
+ message, attachments: attachments,
794
+ );
795
+ },
796
+ onStop: () => ref.read(chatProvider.notifier).cancelStream(),
797
),
798
],
799
),
@@ -1838,29 +802,15 @@ Error: $errorMessage
802
}
803
}
804
1841
-class _DebugRow extends StatelessWidget {
1842
- const _DebugRow({required this.label, required this.value});
1843
- final String label;
1844
- final String value;
805
+class _SmartReplyChipsWrapper extends ConsumerWidget {
806
+ const _SmartReplyChipsWrapper();
807
808
@override
1847
- Widget build(BuildContext context) {
1848
- return Padding(
1849
- padding: const EdgeInsets.only(bottom: 8),
1850
- child: Column(
1851
- crossAxisAlignment: CrossAxisAlignment.start,
1852
- children: [
1853
- Text(
1854
- label,
1855
- style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
1856
- ),
1857
- const SizedBox(height: 2),
1858
- SelectableText(
1859
- value,
1860
- style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
1861
- ),
1862
- ],
1863
- ),
809
+ Widget build(BuildContext context, WidgetRef ref) {
810
+ return SmartReplyChips(
811
+ onSend: (message) {
812
+ ref.read(chatProvider.notifier).sendMessage(message);
813
+ },
814
);
815
}
816
}
lib/features/chat/views/components/chat_auto_scroll_controller.dart
new
+62
@@ -0,0 +1,62 @@
1
+import 'package:flutter/widgets.dart';
2
+
3
+class ChatAutoScrollController {
4
+ ChatAutoScrollController();
5
+
6
+ bool _autoScrollEnabled = true;
7
+ bool _scheduledAutoScroll = false;
8
+ int _lastMessageCount = 0;
9
+ int _lastStreamingLength = 0;
10
+ bool _lastIsStreaming = false;
11
+
12
+ bool _isNearBottom(ScrollController controller) {
13
+ if (!controller.hasClients) return true;
14
+ final position = controller.position;
15
+ return (position.maxScrollExtent - position.pixels) <= 120;
16
+ }
17
+
18
+ void onScrollChanged(ScrollController controller) {
19
+ if (!controller.hasClients) return;
20
+ _autoScrollEnabled = _isNearBottom(controller);
21
+ }
22
+
23
+ void scheduleAutoScroll({
24
+ required ScrollController controller,
25
+ required bool streaming,
26
+ }) {
27
+ if (!_autoScrollEnabled || _scheduledAutoScroll) return;
28
+
29
+ _scheduledAutoScroll = true;
30
+ WidgetsBinding.instance.addPostFrameCallback((_) {
31
+ _scheduledAutoScroll = false;
32
+ if (!controller.hasClients || !_autoScrollEnabled) return;
33
+
34
+ final target = controller.position.maxScrollExtent;
35
+ controller.animateTo(
36
+ target,
37
+ duration: streaming
38
+ ? const Duration(milliseconds: 120)
39
+ : const Duration(milliseconds: 200),
40
+ curve: Curves.easeOutCubic,
41
+ );
42
+ });
43
+ }
44
+
45
+ bool checkAndUpdate({
46
+ required int messageCount,
47
+ required int streamingLength,
48
+ required bool isStreaming,
49
+ }) {
50
+ final hasNewMessage = messageCount != _lastMessageCount;
51
+ final streamingProgressed =
52
+ isStreaming && streamingLength != _lastStreamingLength;
53
+ final streamStarted = isStreaming && !_lastIsStreaming;
54
+ final streamEnded = !isStreaming && _lastIsStreaming;
55
+
56
+ _lastMessageCount = messageCount;
57
+ _lastStreamingLength = streamingLength;
58
+ _lastIsStreaming = isStreaming;
59
+
60
+ return hasNewMessage || streamingProgressed || streamStarted || streamEnded;
61
+ }
62
+}
lib/features/chat/views/components/chat_bubble.dart
+15
-1350
@@ -1,19 +1,11 @@
1
-import 'dart:async';
2
-import 'dart:convert';
3
-import 'dart:io';
1
import 'package:flutter/material.dart';
5
-import 'package:gpt_markdown/gpt_markdown.dart';
6
-import 'package:localmind/l10n/app_localizations.dart';
7
-import 'package:localmind/core/theme/colors.dart';
8
-import '../../../../core/models/enums.dart';
9
-import '../../data/models/message.dart';
10
-import 'message_action_bar.dart';
11
-import 'processing_indicator.dart';
12
-import 'typing_indicator.dart';
13
-import 'audio_player_widget.dart';
14
-import 'reasoning_widget.dart';
15
-import '../../data/tools/tool_definition.dart';
16
-import '../../data/tools/tool_event.dart';
2
+import 'package:localmind/core/models/enums.dart';
3
+import 'package:localmind/features/chat/data/models/message.dart';
4
+import 'chat_bubble/animated_bubble.dart';
5
+import 'chat_bubble/user_bubble.dart';
6
+import 'chat_bubble/assistant_bubble.dart';
7
+import 'chat_bubble/system_bubble.dart';
8
+import 'chat_bubble/tool_bubble/tool_bubble.dart';
9
10
class ChatBubble extends StatelessWidget {
11
const ChatBubble({
@@ -41,9 +33,9 @@ class ChatBubble extends StatelessWidget {
33
Widget _buildBubble(BuildContext context) {
34
switch (message.role) {
35
case MessageRole.user:
44
- return _AnimatedBubble(
36
+ return AnimatedBubble(
37
alignment: AlignmentDirectional.centerEnd,
46
- child: _UserBubble(
38
+ child: UserBubble(
39
message: message,
40
onCopy: onCopy,
41
onDelete: onDelete,
@@ -51,9 +43,9 @@ class ChatBubble extends StatelessWidget {
43
),
44
);
45
case MessageRole.assistant:
54
- return _AnimatedBubble(
46
+ return AnimatedBubble(
47
alignment: AlignmentDirectional.centerStart,
56
- child: _AssistantBubble(
48
+ child: AssistantBubble(
49
message: message,
50
onCopy: onCopy,
51
onRetry: onRetry,
@@ -62,1342 +54,15 @@ class ChatBubble extends StatelessWidget {
54
),
55
);
56
case MessageRole.system:
65
- return _AnimatedBubble(
57
+ return AnimatedBubble(
58
alignment: AlignmentDirectional.center,
67
- child: _SystemBubble(message: message),
59
+ child: SystemBubble(message: message),
60
);
61
case MessageRole.tool:
70
- return _AnimatedBubble(
62
+ return AnimatedBubble(
63
alignment: AlignmentDirectional.centerStart,
72
- child: _ToolBubble(message: message),
64
+ child: ToolBubble(message: message),
65
);
66
}
67
}
68
}
77
-
78
-class _AnimatedBubble extends StatelessWidget {
79
- const _AnimatedBubble({required this.child, required this.alignment});
80
-
81
- final Widget child;
82
- final AlignmentDirectional alignment;
83
-
84
- @override
85
- Widget build(BuildContext context) {
86
- final resolvedAlignment = alignment.resolve(Directionality.of(context));
87
- final offset = resolvedAlignment == Alignment.centerRight
88
- ? const Offset(0.15, 0.0)
89
- : resolvedAlignment == Alignment.centerLeft
90
- ? const Offset(-0.15, 0.0)
91
- : const Offset(0.0, 0.1);
92
-
93
- return TweenAnimationBuilder<double>(
94
- tween: Tween(begin: 0.0, end: 1.0),
95
- duration: const Duration(milliseconds: 250),
96
- curve: Curves.easeOutCubic,
97
- builder: (context, value, child) {
98
- return Opacity(
99
- opacity: value,
100
- child: Transform.translate(
101
- offset: Offset(
102
- offset.dx * (1 - value) * 50,
103
- offset.dy * (1 - value) * 30,
104
- ),
105
- child: child,
106
- ),
107
- );
108
- },
109
- child: child,
110
- );
111
- }
112
-}
113
-
114
-class _UserBubble extends StatelessWidget {
115
- const _UserBubble({
116
- required this.message,
117
- this.onCopy,
118
- this.onDelete,
119
- this.onEdit,
120
- });
121
-
122
- final Message message;
123
- final VoidCallback? onCopy;
124
- final VoidCallback? onDelete;
125
- final VoidCallback? onEdit;
126
-
127
- @override
128
- Widget build(BuildContext context) {
129
- final theme = Theme.of(context);
130
- final isDark = theme.brightness == Brightness.dark;
131
-
132
- final double maxBubbleWidth = 768;
133
- final double availableWidth = MediaQuery.of(context).size.width * 0.75;
134
- return Align(
135
- alignment: AlignmentDirectional.centerEnd,
136
- child: Column(
137
- crossAxisAlignment: CrossAxisAlignment.end,
138
- children: [
139
- Container(
140
- constraints: BoxConstraints(
141
- maxWidth: maxBubbleWidth < availableWidth ? maxBubbleWidth : availableWidth,
142
- ),
143
- margin: const EdgeInsetsDirectional.only(
144
- start: 48,
145
- end: 8,
146
- top: 4,
147
- bottom: 2,
148
- ),
149
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
150
- decoration: BoxDecoration(
151
- color: isDark ? AppColors.darkSurfaceCard : AppColors.lightSurface,
152
- borderRadius: BorderRadiusDirectional.only(
153
- topStart: Radius.circular(18),
154
- topEnd: Radius.circular(18),
155
- bottomStart: Radius.circular(18),
156
- bottomEnd: const Radius.circular(4),
157
- ),
158
- ),
159
- child: Column(
160
- crossAxisAlignment: CrossAxisAlignment.end,
161
- children: [
162
- if (message.attachmentPaths != null &&
163
- message.attachmentPaths!.isNotEmpty)
164
- Padding(
165
- padding: const EdgeInsets.only(bottom: 8),
166
- child: _AttachmentList(
167
- paths: message.attachmentPaths!,
168
- isUser: true,
169
- ),
170
- ),
171
- SelectionArea(
172
- child: _ThemedGptMarkdown(
173
- content: message.content,
174
- isDark: isDark,
175
- style: TextStyle(
176
- color: isDark ? AppColors.darkPrimaryText : AppColors.lightPrimaryText,
177
- fontSize: 16,
178
- height: 1.6,
179
- ),
180
- ),
181
- ),
182
- ],
183
- ),
184
- ),
185
- Padding(
186
- padding: const EdgeInsetsDirectional.only(end: 12, bottom: 4),
187
- child: Row(
188
- mainAxisSize: MainAxisSize.min,
189
- children: [
190
- Text(
191
- _formatTime(message.createdAt),
192
- style: TextStyle(
193
- fontSize: 11,
194
- color: isDark
195
- ? AppColors.darkMutedText
196
- : AppColors.lightMutedText,
197
- ),
198
- ),
199
- if (message.status == MessageStatus.error) ...[
200
- const SizedBox(width: 4),
201
- Icon(Icons.error_outline, size: 14, color: Colors.red[200]),
202
- ],
203
- const SizedBox(width: 8),
204
- MessageActionBar(
205
- content: message.content,
206
- onCopy: onCopy,
207
- onDelete: onDelete,
208
- onEdit: onEdit,
209
- ),
210
- ],
211
- ),
212
- ),
213
- ],
214
- ),
215
- );
216
- }
217
-}
218
-
219
-class _AssistantBubble extends StatelessWidget {
220
- const _AssistantBubble({
221
- required this.message,
222
- this.onCopy,
223
- this.onRetry,
224
- this.onDelete,
225
- this.isStreaming = false,
226
- });
227
-
228
- final Message message;
229
- final VoidCallback? onCopy;
230
- final VoidCallback? onRetry;
231
- final VoidCallback? onDelete;
232
- final bool isStreaming;
233
-
234
- @override
235
- Widget build(BuildContext context) {
236
- final theme = Theme.of(context);
237
- final isDark = theme.brightness == Brightness.dark;
238
-
239
- return Container(
240
- width: double.infinity,
241
- margin: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
242
- child: Column(
243
- crossAxisAlignment: CrossAxisAlignment.start,
244
- children: [
245
- if (message.reasoningContent != null &&
246
- message.reasoningContent!.isNotEmpty)
247
- ReasoningWidget(
248
- reasoningContent: message.reasoningContent,
249
- isStreaming: isStreaming,
250
- ),
251
- if (isStreaming && message.content.isEmpty && message.isProcessing)
252
- const ProcessingIndicator()
253
- else if (isStreaming && message.content.isEmpty)
254
- const TypingIndicator()
255
- else if (isStreaming)
256
- _StreamingContent(content: message.content, isDark: isDark)
257
- else
258
- _MarkdownContent(content: message.content, isDark: isDark),
259
- if (message.status == MessageStatus.error &&
260
- message.errorMessage != null &&
261
- message.errorMessage!.isNotEmpty)
262
- Padding(
263
- padding: const EdgeInsets.only(top: 8),
264
- child: Container(
265
- padding: const EdgeInsets.all(12),
266
- decoration: BoxDecoration(
267
- color: Colors.red.withValues(alpha: 0.1),
268
- borderRadius: BorderRadius.circular(8),
269
- border: Border.all(color: Colors.red.withValues(alpha: 0.2)),
270
- ),
271
- child: Text(
272
- message.errorMessage!,
273
- style: TextStyle(
274
- color: Colors.red[400],
275
- fontSize: 12,
276
- fontWeight: FontWeight.w500,
277
- ),
278
- ),
279
- ),
280
- ),
281
- if (message.toolEvents != null && message.toolEvents!.isNotEmpty)
282
- Padding(
283
- padding: const EdgeInsets.only(top: 8),
284
- child: _ToolTimeline(events: message.toolEvents!),
285
- ),
286
- if (isStreaming && message.content.isNotEmpty)
287
- const Padding(
288
- padding: EdgeInsets.only(top: 8),
289
- child: StreamingIndicator(),
290
- ),
291
- const SizedBox(height: 8),
292
- Row(
293
- children: [
294
- Text(
295
- _formatTime(message.createdAt),
296
- style: TextStyle(
297
- fontSize: 11,
298
- color: isDark
299
- ? AppColors.darkMutedText
300
- : AppColors.lightMutedText,
301
- ),
302
- ),
303
- if (message.status == MessageStatus.error) ...[
304
- const SizedBox(width: 4),
305
- Icon(Icons.error_outline, size: 14, color: Colors.red[400]),
306
- ],
307
- const Spacer(),
308
- if (!isStreaming &&
309
- (message.status == MessageStatus.complete ||
310
- message.status == MessageStatus.error ||
311
- message.status == MessageStatus.cancelled))
312
- MessageActionBar(
313
- content: message.content,
314
- onCopy: onCopy,
315
- onRetry: onRetry,
316
- onDelete: onDelete,
317
- ),
318
- ],
319
- ),
320
- ],
321
- ),
322
- );
323
- }
324
-}
325
-
326
-class _StreamingTextContent extends StatelessWidget {
327
- const _StreamingTextContent({required this.content, required this.isDark});
328
-
329
- final String content;
330
- final bool isDark;
331
-
332
- @override
333
- Widget build(BuildContext context) {
334
- return Text(
335
- content,
336
- style: TextStyle(
337
- color: isDark ? Colors.white : Colors.black,
338
- fontSize: 15,
339
- height: 1.5,
340
- ),
341
- );
342
- }
343
-}
344
-
345
-class _StreamingContent extends StatefulWidget {
346
- const _StreamingContent({required this.content, required this.isDark});
347
-
348
- final String content;
349
- final bool isDark;
350
-
351
- @override
352
- State<_StreamingContent> createState() => _StreamingContentState();
353
-}
354
-
355
-class _StreamingContentState extends State<_StreamingContent> {
356
- static const Duration _updateInterval = Duration(milliseconds: 80);
357
- Timer? _flushTimer;
358
- String _visibleContent = '';
359
-
360
- @override
361
- void initState() {
362
- super.initState();
363
- _visibleContent = widget.content;
364
- }
365
-
366
- @override
367
- void didUpdateWidget(covariant _StreamingContent oldWidget) {
368
- super.didUpdateWidget(oldWidget);
369
- if (oldWidget.content == widget.content) {
370
- return;
371
- }
372
-
373
- if (widget.content.length < _visibleContent.length) {
374
- _flushTimer?.cancel();
375
- _visibleContent = widget.content;
376
- return;
377
- }
378
-
379
- _flushTimer ??= Timer(_updateInterval, () {
380
- _flushTimer = null;
381
- if (mounted) {
382
- setState(() {
383
- _visibleContent = widget.content;
384
- });
385
- }
386
- });
387
- }
388
-
389
- @override
390
- void dispose() {
391
- _flushTimer?.cancel();
392
- super.dispose();
393
- }
394
-
395
- @override
396
- Widget build(BuildContext context) {
397
- return _MarkdownBodyContent(
398
- content: _visibleContent,
399
- isDark: widget.isDark,
400
- );
401
- }
402
-}
403
-
404
-class _MarkdownContent extends StatelessWidget {
405
- const _MarkdownContent({required this.content, required this.isDark});
406
-
407
- final String content;
408
- final bool isDark;
409
-
410
- @override
411
- Widget build(BuildContext context) {
412
- if (content.length > 2000) {
413
- return _DeferredMarkdownContent(content: content, isDark: isDark);
414
- }
415
-
416
- return _MarkdownBodyContent(content: content, isDark: isDark);
417
- }
418
-}
419
-
420
-class _MarkdownBodyContent extends StatelessWidget {
421
- const _MarkdownBodyContent({required this.content, required this.isDark});
422
-
423
- final String content;
424
- final bool isDark;
425
-
426
- @override
427
- Widget build(BuildContext context) {
428
- return SelectionArea(
429
- child: _ThemedGptMarkdown(
430
- content: content,
431
- isDark: isDark,
432
- style: TextStyle(
433
- color: isDark ? Colors.white : Colors.black,
434
- fontSize: 15,
435
- height: 1.5,
436
- ),
437
- ),
438
- );
439
- }
440
-}
441
-
442
-class _ThemedGptMarkdown extends StatelessWidget {
443
- const _ThemedGptMarkdown({
444
- required this.content,
445
- required this.isDark,
446
- required this.style,
447
- });
448
-
449
- final String content;
450
- final bool isDark;
451
- final TextStyle style;
452
-
453
- static bool _isAudioUrl(String url) {
454
- final lower = url.toLowerCase();
455
- return lower.endsWith('.mp3') ||
456
- lower.endsWith('.wav') ||
457
- lower.endsWith('.ogg') ||
458
- lower.endsWith('.flac') ||
459
- lower.endsWith('.aac') ||
460
- lower.endsWith('.m4a') ||
461
- lower.endsWith('.wma') ||
462
- lower.endsWith('.opus');
463
- }
464
-
465
- @override
466
- Widget build(BuildContext context) {
467
- final gptTheme = GptMarkdownThemeData(
468
- brightness: isDark ? Brightness.dark : Brightness.light,
469
- highlightColor: isDark
470
- ? const Color(0xFF334155)
471
- : const Color(0xFFDBEAFE),
472
- linkColor: isDark ? AppColors.darkAccent : AppColors.lightAccent,
473
- linkHoverColor: isDark
474
- ? AppColors.darkAccent
475
- : AppColors.lightAccent,
476
- hrLineColor: isDark ? const Color(0xFF334155) : const Color(0xFFE2E8F0),
477
- hrLineThickness: 1.0,
478
- h1: style.copyWith(fontSize: 24, fontWeight: FontWeight.w700),
479
- h2: style.copyWith(fontSize: 21, fontWeight: FontWeight.w700),
480
- h3: style.copyWith(fontSize: 19, fontWeight: FontWeight.w600),
481
- h4: style.copyWith(fontSize: 17, fontWeight: FontWeight.w600),
482
- h5: style.copyWith(fontSize: 16, fontWeight: FontWeight.w600),
483
- h6: style.copyWith(fontSize: 15, fontWeight: FontWeight.w600),
484
- );
485
-
486
- return GptMarkdownTheme(
487
- gptThemeData: gptTheme,
488
- child: GptMarkdown(
489
- content,
490
- style: style,
491
- followLinkColor: true,
492
- imageBuilder: _buildImageOrAudio,
493
- onLinkTap: (url, title) {
494
- if (_isAudioUrl(url) && context.mounted) {
495
- showDialog(
496
- context: context,
497
- builder: (ctx) => AlertDialog(
498
- contentPadding: const EdgeInsets.all(16),
499
- content: AudioPlayerWidget(source: url),
500
- ),
501
- );
502
- }
503
- },
504
- ),
505
- );
506
- }
507
-
508
- Widget _buildImageOrAudio(BuildContext context, String url, double? width, double? height) {
509
- if (_isAudioUrl(url)) {
510
- return AudioPlayerWidget(source: url, height: 56);
511
- }
512
- return SizedBox(
513
- width: width,
514
- height: height,
515
- child: Image(
516
- image: NetworkImage(url),
517
- loadingBuilder: (context, child, loadingProgress) {
518
- if (loadingProgress == null) return child;
519
- return Center(
520
- child: CircularProgressIndicator(
521
- value: loadingProgress.expectedTotalBytes != null
522
- ? loadingProgress.cumulativeBytesLoaded /
523
- loadingProgress.expectedTotalBytes!
524
- : null,
525
- ),
526
- );
527
- },
528
- errorBuilder: (context, error, stackTrace) => Container(
529
- color: Colors.grey[800],
530
- child: const Center(child: Icon(Icons.broken_image, color: Colors.white54)),
531
- ),
532
- ),
533
- );
534
- }
535
-}
536
-
537
-class _DeferredMarkdownContent extends StatefulWidget {
538
- const _DeferredMarkdownContent({required this.content, required this.isDark});
539
-
540
- final String content;
541
- final bool isDark;
542
-
543
- @override
544
- State<_DeferredMarkdownContent> createState() =>
545
- _DeferredMarkdownContentState();
546
-}
547
-
548
-class _DeferredMarkdownContentState extends State<_DeferredMarkdownContent> {
549
- bool _showMarkdown = false;
550
- Timer? _deferTimer;
551
-
552
- @override
553
- void initState() {
554
- super.initState();
555
- _deferTimer = Timer(const Duration(milliseconds: 120), () {
556
- if (mounted) {
557
- setState(() => _showMarkdown = true);
558
- }
559
- });
560
- }
561
-
562
- @override
563
- void dispose() {
564
- _deferTimer?.cancel();
565
- super.dispose();
566
- }
567
-
568
- @override
569
- void didUpdateWidget(_DeferredMarkdownContent oldWidget) {
570
- super.didUpdateWidget(oldWidget);
571
- if (oldWidget.content != widget.content ||
572
- oldWidget.isDark != widget.isDark) {
573
- _showMarkdown = false;
574
- _deferTimer?.cancel();
575
- _deferTimer = Timer(const Duration(milliseconds: 120), () {
576
- if (mounted) {
577
- setState(() => _showMarkdown = true);
578
- }
579
- });
580
- }
581
- }
582
-
583
- @override
584
- Widget build(BuildContext context) {
585
- if (!_showMarkdown) {
586
- return _StreamingTextContent(
587
- content: widget.content,
588
- isDark: widget.isDark,
589
- );
590
- }
591
-
592
- return _MarkdownBodyContent(content: widget.content, isDark: widget.isDark);
593
- }
594
-}
595
-
596
-class _SystemBubble extends StatelessWidget {
597
- const _SystemBubble({required this.message});
598
-
599
- final Message message;
600
-
601
- @override
602
- Widget build(BuildContext context) {
603
- return Center(
604
- child: Container(
605
- margin: const EdgeInsets.symmetric(vertical: 8),
606
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
607
- decoration: BoxDecoration(
608
- color: Colors.grey.withValues(alpha: 0.1),
609
- borderRadius: BorderRadius.circular(8),
610
- ),
611
- child: Text(
612
- message.content,
613
- style: TextStyle(
614
- fontSize: 13,
615
- fontStyle: FontStyle.italic,
616
- color: Colors.grey[600],
617
- ),
618
- textAlign: TextAlign.center,
619
- ),
620
- ),
621
- );
622
- }
623
-}
624
-
625
-class _ToolBubble extends StatelessWidget {
626
- const _ToolBubble({required this.message});
627
-
628
- final Message message;
629
-
630
- @override
631
- Widget build(BuildContext context) {
632
- final l10n = AppLocalizations.of(context)!;
633
- final theme = Theme.of(context);
634
- final isDark = theme.brightness == Brightness.dark;
635
-
636
- return Align(
637
- alignment: AlignmentDirectional.centerStart,
638
- child: Container(
639
- constraints: BoxConstraints(
640
- maxWidth: MediaQuery.of(context).size.width * 0.8,
641
- ),
642
- margin: const EdgeInsetsDirectional.only(
643
- start: 8,
644
- end: 48,
645
- top: 4,
646
- bottom: 4,
647
- ),
648
- padding: const EdgeInsets.all(12),
649
- decoration: BoxDecoration(
650
- color: isDark ? const Color(0xFF1F2937) : const Color(0xFFEEF2FF),
651
- borderRadius: BorderRadius.circular(12),
652
- border: Border.all(
653
- color: isDark ? const Color(0xFF374151) : const Color(0xFFC7D2FE),
654
- ),
655
- ),
656
- child: Column(
657
- crossAxisAlignment: CrossAxisAlignment.start,
658
- children: [
659
- Row(
660
- children: [
661
- Icon(
662
- Icons.build_outlined,
663
- size: 14,
664
- color: isDark
665
- ? AppColors.darkMutedText
666
- : AppColors.lightMutedText,
667
- ),
668
- const SizedBox(width: 4),
669
- Text(
670
- message.toolCallId != null
671
- ? l10n.tool_label(message.toolCallId!)
672
- : l10n.tool_unknown,
673
- style: TextStyle(
674
- fontSize: 12,
675
- fontWeight: FontWeight.w500,
676
- color: isDark
677
- ? AppColors.darkMutedText
678
- : AppColors.lightMutedText,
679
- ),
680
- ),
681
- ],
682
- ),
683
- const SizedBox(height: 8),
684
- Text(
685
- message.content,
686
- style: TextStyle(
687
- fontSize: 13,
688
- color: isDark ? Colors.white : Colors.black87,
689
- fontFamily: 'monospace',
690
- ),
691
- ),
692
- ],
693
- ),
694
- ),
695
- );
696
- }
697
-}
698
-
699
-class _AnimatedRunningIcon extends StatefulWidget {
700
- const _AnimatedRunningIcon({required this.color});
701
- final Color color;
702
-
703
- @override
704
- State<_AnimatedRunningIcon> createState() => _AnimatedRunningIconState();
705
-}
706
-
707
-class _AnimatedRunningIconState extends State<_AnimatedRunningIcon>
708
- with SingleTickerProviderStateMixin {
709
- late AnimationController _controller;
710
-
711
- @override
712
- void initState() {
713
- super.initState();
714
- _controller = AnimationController(
715
- vsync: this,
716
- duration: const Duration(seconds: 2),
717
- )..repeat();
718
- }
719
-
720
- @override
721
- void dispose() {
722
- _controller.dispose();
723
- super.dispose();
724
- }
725
-
726
- @override
727
- Widget build(BuildContext context) {
728
- return RotationTransition(
729
- turns: _controller,
730
- child: Icon(
731
- Icons.cached_rounded,
732
- size: 14,
733
- color: widget.color,
734
- ),
735
- );
736
- }
737
-}
738
-
739
-class _GroupedToolCall {
740
- final String baseId;
741
- final String toolName;
742
- final ToolProviderType providerType;
743
- final String? providerRef;
744
- final Map<String, dynamic>? arguments;
745
- final ToolEventStatus status;
746
- final String? result;
747
- final String? error;
748
- final int? durationMs;
749
- final DateTime timestamp;
750
-
751
- const _GroupedToolCall({
752
- required this.baseId,
753
- required this.toolName,
754
- required this.providerType,
755
- this.providerRef,
756
- this.arguments,
757
- required this.status,
758
- this.result,
759
- this.error,
760
- this.durationMs,
761
- required this.timestamp,
762
- });
763
-}
764
-
765
-class _ToolTimeline extends StatelessWidget {
766
- const _ToolTimeline({required this.events});
767
- final List<ToolEvent> events;
768
-
769
- List<_GroupedToolCall> _groupEvents(List<ToolEvent> events) {
770
- final Map<String, _GroupedToolCall> groups = {};
771
- final List<String> orderedBaseIds = [];
772
-
773
- for (final event in events) {
774
- final dotIndex = event.eventId.lastIndexOf('.');
775
- final baseId = dotIndex != -1 ? event.eventId.substring(0, dotIndex) : event.eventId;
776
-
777
- if (!orderedBaseIds.contains(baseId)) {
778
- orderedBaseIds.add(baseId);
779
- }
780
-
781
- final existing = groups[baseId];
782
- if (existing == null) {
783
- groups[baseId] = _GroupedToolCall(
784
- baseId: baseId,
785
- toolName: event.toolName,
786
- providerType: event.providerType,
787
- providerRef: event.providerRef,
788
- arguments: event.arguments,
789
- status: event.status,
790
- result: event.result,
791
- error: event.error,
792
- durationMs: event.durationMs,
793
- timestamp: event.timestamp,
794
- );
795
- } else {
796
- groups[baseId] = _GroupedToolCall(
797
- baseId: baseId,
798
- toolName: event.toolName,
799
- providerType: event.providerType,
800
- providerRef: event.providerRef,
801
- arguments: event.arguments ?? existing.arguments,
802
- status: event.status,
803
- result: event.result ?? existing.result,
804
- error: event.error ?? existing.error,
805
- durationMs: event.durationMs ?? existing.durationMs,
806
- timestamp: event.timestamp,
807
- );
808
- }
809
- }
810
-
811
- return orderedBaseIds.map((id) => groups[id]!).toList();
812
- }
813
-
814
- String _formatArgs(Map<String, dynamic> args) {
815
- if (args.isEmpty) return '';
816
- try {
817
- final encoder = JsonEncoder.withIndent(' ');
818
- return encoder.convert(args);
819
- } catch (_) {
820
- return args.entries.map((e) => ' ${e.key}: ${e.value}').join('\n');
821
- }
822
- }
823
-
824
- @override
825
- Widget build(BuildContext context) {
826
- final theme = Theme.of(context);
827
- final isDark = theme.brightness == Brightness.dark;
828
- final groupedCalls = _groupEvents(events);
829
-
830
- if (groupedCalls.isEmpty) {
831
- return const SizedBox.shrink();
832
- }
833
-
834
- return Container(
835
- margin: const EdgeInsets.only(top: 8, bottom: 4),
836
- decoration: BoxDecoration(
837
- color: isDark
838
- ? AppColors.darkSurfaceInput
839
- : AppColors.lightSurface,
840
- borderRadius: BorderRadius.circular(12),
841
- border: Border.all(
842
- color: isDark
843
- ? AppColors.darkBorder
844
- : AppColors.lightBorder,
845
- ),
846
- boxShadow: [
847
- BoxShadow(
848
- color: Colors.black.withValues(alpha: isDark ? 0.1 : 0.02),
849
- blurRadius: 8,
850
- offset: const Offset(0, 4),
851
- ),
852
- ],
853
- ),
854
- child: Column(
855
- crossAxisAlignment: CrossAxisAlignment.start,
856
- children: [
857
- // Header Bar
858
- Container(
859
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
860
- decoration: BoxDecoration(
861
- border: Border(
862
- bottom: BorderSide(
863
- color: isDark
864
- ? AppColors.darkBorder
865
- : AppColors.lightBorder,
866
- ),
867
- ),
868
- ),
869
- child: Row(
870
- children: [
871
- Icon(
872
- Icons.terminal_rounded,
873
- size: 16,
874
- color: isDark
875
- ? AppColors.darkMutedText
876
- : AppColors.lightMutedText,
877
- ),
878
- const SizedBox(width: 8),
879
- Text(
880
- 'SYSTEM ACTIONS',
881
- style: TextStyle(
882
- fontSize: 11,
883
- fontWeight: FontWeight.bold,
884
- letterSpacing: 0.5,
885
- color: isDark
886
- ? AppColors.darkMutedText
887
- : AppColors.lightMutedText,
888
- ),
889
- ),
890
- const Spacer(),
891
- Container(
892
- padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
893
- decoration: BoxDecoration(
894
- color: isDark ? AppColors.darkSurfaceCard : AppColors.lightBorder,
895
- borderRadius: BorderRadius.circular(6),
896
- ),
897
- child: Text(
898
- '${groupedCalls.length} ${groupedCalls.length == 1 ? "action" : "actions"}',
899
- style: TextStyle(
900
- fontSize: 9,
901
- fontWeight: FontWeight.bold,
902
- color: isDark ? Colors.white70 : Colors.black54,
903
- ),
904
- ),
905
- ),
906
- ],
907
- ),
908
- ),
909
- const SizedBox(height: 4),
910
- // Grouped tool calls
911
- Padding(
912
- padding: const EdgeInsets.symmetric(vertical: 4),
913
- child: ListView.separated(
914
- shrinkWrap: true,
915
- physics: const NeverScrollableScrollPhysics(),
916
- itemCount: groupedCalls.length,
917
- separatorBuilder: (context, index) => Padding(
918
- padding: const EdgeInsets.symmetric(vertical: 6),
919
- child: Divider(
920
- height: 1,
921
- thickness: 1,
922
- color: isDark
923
- ? const Color(0xFF313244).withValues(alpha: 0.3)
924
- : const Color(0xFFE0E5F5).withValues(alpha: 0.5),
925
- ),
926
- ),
927
- itemBuilder: (context, index) => _ToolRowWidget(
928
- call: groupedCalls[index],
929
- isDark: isDark,
930
- formatArgs: _formatArgs,
931
- ),
932
- ),
933
- ),
934
- const SizedBox(height: 4),
935
- ],
936
- ),
937
- );
938
- }
939
-}
940
-
941
-class _ToolRowWidget extends StatefulWidget {
942
- const _ToolRowWidget({
943
- required this.call,
944
- required this.isDark,
945
- required this.formatArgs,
946
- });
947
-
948
- final _GroupedToolCall call;
949
- final bool isDark;
950
- final String Function(Map<String, dynamic>) formatArgs;
951
-
952
- @override
953
- State<_ToolRowWidget> createState() => _ToolRowWidgetState();
954
-}
955
-
956
-class _ToolRowWidgetState extends State<_ToolRowWidget> {
957
- late bool _isExpanded;
958
-
959
- @override
960
- void initState() {
961
- super.initState();
962
- _isExpanded = widget.call.status == ToolEventStatus.running ||
963
- widget.call.status == ToolEventStatus.failed ||
964
- widget.call.status == ToolEventStatus.requested ||
965
- widget.call.status == ToolEventStatus.approved;
966
- }
967
-
968
- @override
969
- void didUpdateWidget(_ToolRowWidget oldWidget) {
970
- super.didUpdateWidget(oldWidget);
971
- if (oldWidget.call.status != widget.call.status) {
972
- if (widget.call.status == ToolEventStatus.running ||
973
- widget.call.status == ToolEventStatus.failed ||
974
- widget.call.status == ToolEventStatus.requested ||
975
- widget.call.status == ToolEventStatus.approved) {
976
- setState(() {
977
- _isExpanded = true;
978
- });
979
- }
980
- }
981
- }
982
-
983
- @override
984
- Widget build(BuildContext context) {
985
- final call = widget.call;
986
- final isDark = widget.isDark;
987
- final isMcpTool = call.providerType == ToolProviderType.mcp;
988
-
989
- final iconColor = switch (call.status) {
990
- ToolEventStatus.completed => const Color(0xFF22C55E),
991
- ToolEventStatus.failed ||
992
- ToolEventStatus.rejected => const Color(0xFFEF4444),
993
- ToolEventStatus.running => AppColors.darkAccent,
994
- ToolEventStatus.requested ||
995
- ToolEventStatus.approved => const Color(0xFFF59E0B),
996
- };
997
-
998
- final statusLabel = switch (call.status) {
999
- ToolEventStatus.requested => 'Requested',
1000
- ToolEventStatus.approved => 'Approved',
1001
- ToolEventStatus.rejected => 'Rejected',
1002
- ToolEventStatus.running => 'Running',
1003
- ToolEventStatus.completed => 'Done',
1004
- ToolEventStatus.failed => 'Failed',
1005
- };
1006
-
1007
- final showCodeBlock = (call.arguments != null && call.arguments!.isNotEmpty) ||
1008
- (call.result != null && call.result!.isNotEmpty) ||
1009
- (call.error != null && call.error!.isNotEmpty);
1010
-
1011
- return Padding(
1012
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
1013
- child: Column(
1014
- crossAxisAlignment: CrossAxisAlignment.start,
1015
- children: [
1016
- InkWell(
1017
- onTap: showCodeBlock
1018
- ? () => setState(() => _isExpanded = !_isExpanded)
1019
- : null,
1020
- borderRadius: BorderRadius.circular(6),
1021
- child: Padding(
1022
- padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
1023
- child: Row(
1024
- crossAxisAlignment: CrossAxisAlignment.center,
1025
- children: [
1026
- if (call.status == ToolEventStatus.running)
1027
- _AnimatedRunningIcon(color: iconColor)
1028
- else
1029
- Icon(
1030
- switch (call.status) {
1031
- ToolEventStatus.requested => Icons.hourglass_empty_rounded,
1032
- ToolEventStatus.approved => Icons.check_circle_outline_rounded,
1033
- ToolEventStatus.rejected => Icons.block_flipped,
1034
- ToolEventStatus.running => Icons.cached_rounded,
1035
- ToolEventStatus.completed => Icons.check_circle_rounded,
1036
- ToolEventStatus.failed => Icons.error_rounded,
1037
- },
1038
- size: 14,
1039
- color: iconColor,
1040
- ),
1041
- const SizedBox(width: 8),
1042
- Expanded(
1043
- child: Row(
1044
- children: [
1045
- Flexible(
1046
- child: Text(
1047
- call.toolName,
1048
- style: TextStyle(
1049
- fontSize: 12,
1050
- fontWeight: FontWeight.bold,
1051
- color: isDark ? Colors.white : Colors.black87,
1052
- fontFamily: 'monospace',
1053
- ),
1054
- overflow: TextOverflow.ellipsis,
1055
- ),
1056
- ),
1057
- const SizedBox(width: 6),
1058
- if (isMcpTool) ...[
1059
- _InlineToolBadge(
1060
- label: 'MCP',
1061
- backgroundColor: Colors.purple.withValues(alpha: isDark ? 0.2 : 0.1),
1062
- textColor: isDark ? const Color(0xFFC084FC) : const Color(0xFF7E22CE),
1063
- ),
1064
- ] else if (call.providerType == ToolProviderType.lmStudioServer) ...[
1065
- _InlineToolBadge(
1066
- label: 'LM Studio',
1067
- backgroundColor: Colors.orange.withValues(alpha: isDark ? 0.2 : 0.1),
1068
- textColor: isDark ? const Color(0xFFF97316) : const Color(0xFFC2410C),
1069
- ),
1070
- ] else ...[
1071
- _InlineToolBadge(
1072
- label: 'LOCAL',
1073
- backgroundColor: Colors.blue.withValues(alpha: isDark ? 0.2 : 0.1),
1074
- textColor: isDark ? const Color(0xFF60A5FA) : const Color(0xFF1D4ED8),
1075
- ),
1076
- ],
1077
- const SizedBox(width: 6),
1078
- _InlineToolBadge(
1079
- label: statusLabel,
1080
- backgroundColor: iconColor.withValues(alpha: isDark ? 0.15 : 0.1),
1081
- textColor: iconColor,
1082
- ),
1083
- ],
1084
- ),
1085
- ),
1086
- if (call.durationMs != null) ...[
1087
- const SizedBox(width: 8),
1088
- Text(
1089
- '${call.durationMs}ms',
1090
- style: TextStyle(
1091
- fontSize: 10,
1092
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
1093
- ),
1094
- ),
1095
- ],
1096
- if (showCodeBlock) ...[
1097
- const SizedBox(width: 6),
1098
- Icon(
1099
- _isExpanded
1100
- ? Icons.keyboard_arrow_up_rounded
1101
- : Icons.keyboard_arrow_down_rounded,
1102
- size: 16,
1103
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
1104
- ),
1105
- ],
1106
- ],
1107
- ),
1108
- ),
1109
- ),
1110
- if (showCodeBlock && _isExpanded)
1111
- Padding(
1112
- padding: const EdgeInsets.only(top: 6, left: 22),
1113
- child: Container(
1114
- width: double.infinity,
1115
- padding: const EdgeInsets.all(8),
1116
- decoration: BoxDecoration(
1117
- color: isDark ? const Color(0xFF0F172A) : const Color(0xFFF1F5F9),
1118
- borderRadius: BorderRadius.circular(8),
1119
- border: Border.all(
1120
- color: isDark ? const Color(0xFF1E293B) : const Color(0xFFE2E8F0),
1121
- ),
1122
- ),
1123
- child: Column(
1124
- crossAxisAlignment: CrossAxisAlignment.start,
1125
- children: [
1126
- if (call.arguments != null && call.arguments!.isNotEmpty) ...[
1127
- Text(
1128
- 'Arguments:',
1129
- style: TextStyle(
1130
- fontSize: 9,
1131
- fontWeight: FontWeight.bold,
1132
- color: isDark ? const Color(0xFF38BDF8) : const Color(0xFF0284C7),
1133
- fontFamily: 'monospace',
1134
- ),
1135
- ),
1136
- const SizedBox(height: 2),
1137
- Text(
1138
- widget.formatArgs(call.arguments!),
1139
- style: TextStyle(
1140
- fontSize: 10.5,
1141
- color: isDark ? const Color(0xFFCBD5E1) : const Color(0xFF475569),
1142
- fontFamily: 'monospace',
1143
- height: 1.3,
1144
- ),
1145
- ),
1146
- ],
1147
- if (call.result != null && call.result!.isNotEmpty) ...[
1148
- if (call.arguments != null && call.arguments!.isNotEmpty)
1149
- const SizedBox(height: 8),
1150
- Text(
1151
- 'Output:',
1152
- style: TextStyle(
1153
- fontSize: 9,
1154
- fontWeight: FontWeight.bold,
1155
- color: isDark ? const Color(0xFF4ADE80) : const Color(0xFF16A34A),
1156
- fontFamily: 'monospace',
1157
- ),
1158
- ),
1159
- const SizedBox(height: 2),
1160
- Text(
1161
- call.result!,
1162
- style: TextStyle(
1163
- fontSize: 10.5,
1164
- color: isDark ? const Color(0xFFF8FAFC) : const Color(0xFF0F172A),
1165
- fontFamily: 'monospace',
1166
- height: 1.3,
1167
- ),
1168
- ),
1169
- ],
1170
- if (call.error != null && call.error!.isNotEmpty) ...[
1171
- if (call.arguments != null && call.arguments!.isNotEmpty)
1172
- const SizedBox(height: 8),
1173
- Text(
1174
- 'Error:',
1175
- style: TextStyle(
1176
- fontSize: 9,
1177
- fontWeight: FontWeight.bold,
1178
- color: isDark ? const Color(0xFFF87171) : const Color(0xFFDC2626),
1179
- fontFamily: 'monospace',
1180
- ),
1181
- ),
1182
- const SizedBox(height: 2),
1183
- Text(
1184
- call.error!,
1185
- style: TextStyle(
1186
- fontSize: 10.5,
1187
- color: isDark ? const Color(0xFFFCA5A5) : const Color(0xFF991B1B),
1188
- fontFamily: 'monospace',
1189
- height: 1.3,
1190
- ),
1191
- ),
1192
- ],
1193
- ],
1194
- ),
1195
- ),
1196
- ),
1197
- ],
1198
- ),
1199
- );
1200
- }
1201
-}
1202
-
1203
-class _InlineToolBadge extends StatelessWidget {
1204
- const _InlineToolBadge({
1205
- required this.label,
1206
- required this.backgroundColor,
1207
- required this.textColor,
1208
- });
1209
-
1210
- final String label;
1211
- final Color backgroundColor;
1212
- final Color textColor;
1213
-
1214
- @override
1215
- Widget build(BuildContext context) {
1216
- return Container(
1217
- padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
1218
- decoration: BoxDecoration(
1219
- color: backgroundColor,
1220
- borderRadius: BorderRadius.circular(4),
1221
- ),
1222
- child: Text(
1223
- label.toUpperCase(),
1224
- style: TextStyle(
1225
- color: textColor,
1226
- fontSize: 8,
1227
- fontWeight: FontWeight.w700,
1228
- letterSpacing: 0.35,
1229
- ),
1230
- ),
1231
- );
1232
- }
1233
-}
1234
-
1235
-class _AttachmentList extends StatelessWidget {
1236
- const _AttachmentList({required this.paths, required this.isUser});
1237
-
1238
- final List<String> paths;
1239
- final bool isUser;
1240
-
1241
- @override
1242
- Widget build(BuildContext context) {
1243
- return Wrap(
1244
- spacing: 8,
1245
- runSpacing: 8,
1246
- children: paths.map((path) => _AttachmentItem(path: path)).toList(),
1247
- );
1248
- }
1249
-}
1250
-
1251
-class _AttachmentItem extends StatelessWidget {
1252
- const _AttachmentItem({required this.path});
1253
-
1254
- final String path;
1255
-
1256
- bool _isImage(String path) {
1257
- final mime = path.toLowerCase();
1258
- return mime.endsWith('.jpg') ||
1259
- mime.endsWith('.jpeg') ||
1260
- mime.endsWith('.png') ||
1261
- mime.endsWith('.gif') ||
1262
- mime.endsWith('.webp');
1263
- }
1264
-
1265
- bool _isAudio(String path) {
1266
- final ext = path.toLowerCase();
1267
- return ext.endsWith('.mp3') ||
1268
- ext.endsWith('.wav') ||
1269
- ext.endsWith('.ogg') ||
1270
- ext.endsWith('.flac') ||
1271
- ext.endsWith('.aac') ||
1272
- ext.endsWith('.m4a') ||
1273
- ext.endsWith('.wma') ||
1274
- ext.endsWith('.opus');
1275
- }
1276
-
1277
- void _viewImage(BuildContext context) {
1278
- showDialog(
1279
- context: context,
1280
- useSafeArea: false,
1281
- builder: (context) => _ImageViewer(path: path),
1282
- );
1283
- }
1284
-
1285
- @override
1286
- Widget build(BuildContext context) {
1287
- final theme = Theme.of(context);
1288
- final isDark = theme.brightness == Brightness.dark;
1289
- final file = File(path);
1290
- final fileName = path.split('/').last;
1291
-
1292
- if (_isImage(path)) {
1293
- return GestureDetector(
1294
- onTap: () => _viewImage(context),
1295
- child: ClipRRect(
1296
- borderRadius: BorderRadius.circular(12),
1297
- child: Image.file(
1298
- file,
1299
- width: 120,
1300
- height: 120,
1301
- fit: BoxFit.cover,
1302
- errorBuilder: (context, error, stackTrace) =>
1303
- _FilePlaceholder(fileName: fileName),
1304
- ),
1305
- ),
1306
- );
1307
- }
1308
-
1309
- if (_isAudio(path)) {
1310
- return AudioPlayerWidget(source: path);
1311
- }
1312
-
1313
- return Container(
1314
- width: 160,
1315
- padding: const EdgeInsets.all(12),
1316
- decoration: BoxDecoration(
1317
- color: isDark ? AppColors.darkSurfaceCard : AppColors.lightSurface,
1318
- borderRadius: BorderRadius.circular(12),
1319
- border: Border.all(
1320
- color: isDark ? AppColors.darkBorder : AppColors.lightBorder,
1321
- ),
1322
- ),
1323
- child: Row(
1324
- mainAxisSize: MainAxisSize.min,
1325
- children: [
1326
- Icon(
1327
- Icons.insert_drive_file_outlined,
1328
- size: 20,
1329
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
1330
- ),
1331
- const SizedBox(width: 8),
1332
- Expanded(
1333
- child: Text(
1334
- fileName,
1335
- style: TextStyle(
1336
- fontSize: 12,
1337
- color: isDark ? Colors.white70 : Colors.black87,
1338
- ),
1339
- maxLines: 1,
1340
- overflow: TextOverflow.ellipsis,
1341
- ),
1342
- ),
1343
- ],
1344
- ),
1345
- );
1346
- }
1347
-}
1348
-
1349
-class _FilePlaceholder extends StatelessWidget {
1350
- const _FilePlaceholder({required this.fileName});
1351
- final String fileName;
1352
-
1353
- @override
1354
- Widget build(BuildContext context) {
1355
- return Container(
1356
- width: 120,
1357
- height: 120,
1358
- color: Colors.grey[800],
1359
- child: Center(
1360
- child: Text(
1361
- fileName.split('.').last.toUpperCase(),
1362
- style: const TextStyle(
1363
- color: Colors.white,
1364
- fontWeight: FontWeight.bold,
1365
- ),
1366
- ),
1367
- ),
1368
- );
1369
- }
1370
-}
1371
-
1372
-class _ImageViewer extends StatelessWidget {
1373
- const _ImageViewer({required this.path});
1374
- final String path;
1375
-
1376
- @override
1377
- Widget build(BuildContext context) {
1378
- return Scaffold(
1379
- backgroundColor: Colors.black,
1380
- appBar: AppBar(
1381
- backgroundColor: Colors.transparent,
1382
- elevation: 0,
1383
- leading: IconButton(
1384
- icon: const Icon(Icons.close, color: Colors.white),
1385
- onPressed: () => Navigator.pop(context),
1386
- ),
1387
- ),
1388
- body: Center(
1389
- child: InteractiveViewer(
1390
- minScale: 0.5,
1391
- maxScale: 4.0,
1392
- child: Image.file(File(path)),
1393
- ),
1394
- ),
1395
- );
1396
- }
1397
-}
1398
-
1399
-String _formatTime(DateTime dateTime) {
1400
- final hour = dateTime.hour.toString().padLeft(2, '0');
1401
- final minute = dateTime.minute.toString().padLeft(2, '0');
1402
- return '$hour:$minute';
1403
-}
lib/features/chat/views/components/chat_bubble/animated_bubble.dart
new
+37
@@ -0,0 +1,37 @@
1
+import 'package:flutter/material.dart';
2
+
3
+class AnimatedBubble extends StatelessWidget {
4
+ const AnimatedBubble({super.key, required this.child, required this.alignment});
5
+
6
+ final Widget child;
7
+ final AlignmentDirectional alignment;
8
+
9
+ @override
10
+ Widget build(BuildContext context) {
11
+ final resolvedAlignment = alignment.resolve(Directionality.of(context));
12
+ final offset = resolvedAlignment == Alignment.centerRight
13
+ ? const Offset(0.15, 0.0)
14
+ : resolvedAlignment == Alignment.centerLeft
15
+ ? const Offset(-0.15, 0.0)
16
+ : const Offset(0.0, 0.1);
17
+
18
+ return TweenAnimationBuilder<double>(
19
+ tween: Tween(begin: 0.0, end: 1.0),
20
+ duration: const Duration(milliseconds: 250),
21
+ curve: Curves.easeOutCubic,
22
+ builder: (context, value, child) {
23
+ return Opacity(
24
+ opacity: value,
25
+ child: Transform.translate(
26
+ offset: Offset(
27
+ offset.dx * (1 - value) * 50,
28
+ offset.dy * (1 - value) * 30,
29
+ ),
30
+ child: child,
31
+ ),
32
+ );
33
+ },
34
+ child: child,
35
+ );
36
+ }
37
+}
lib/features/chat/views/components/chat_bubble/assistant_bubble.dart
new
+195
@@ -0,0 +1,195 @@
1
+import 'dart:async';
2
+import 'package:flutter/material.dart';
3
+import 'package:localmind/core/models/enums.dart';
4
+import 'package:localmind/core/theme/colors.dart';
5
+import 'package:localmind/features/chat/data/models/message.dart';
6
+import 'package:localmind/features/chat/views/components/processing_indicator.dart';
7
+import 'package:localmind/features/chat/views/components/typing_indicator.dart';
8
+import 'package:localmind/features/chat/views/components/reasoning_widget.dart';
9
+import 'package:localmind/features/chat/views/components/message_action_bar.dart';
10
+import 'markdown/themed_gpt_markdown.dart';
11
+import 'tool_bubble/tool_timeline.dart';
12
+
13
+class AssistantBubble extends StatelessWidget {
14
+ const AssistantBubble({
15
+ super.key,
16
+ required this.message,
17
+ this.onCopy,
18
+ this.onRetry,
19
+ this.onDelete,
20
+ this.isStreaming = false,
21
+ });
22
+
23
+ final Message message;
24
+ final VoidCallback? onCopy;
25
+ final VoidCallback? onRetry;
26
+ final VoidCallback? onDelete;
27
+ final bool isStreaming;
28
+
29
+ @override
30
+ Widget build(BuildContext context) {
31
+ final theme = Theme.of(context);
32
+ final isDark = theme.brightness == Brightness.dark;
33
+
34
+ return Container(
35
+ width: double.infinity,
36
+ margin: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
37
+ child: Column(
38
+ crossAxisAlignment: CrossAxisAlignment.start,
39
+ children: [
40
+ if (message.reasoningContent != null &&
41
+ message.reasoningContent!.isNotEmpty)
42
+ ReasoningWidget(
43
+ reasoningContent: message.reasoningContent,
44
+ isStreaming: isStreaming,
45
+ ),
46
+ if (isStreaming && message.content.isEmpty && message.isProcessing)
47
+ const ProcessingIndicator()
48
+ else if (isStreaming && message.content.isEmpty)
49
+ const TypingIndicator()
50
+ else if (isStreaming)
51
+ _StreamingContent(content: message.content, isDark: isDark)
52
+ else
53
+ MarkdownContent(content: message.content, isDark: isDark),
54
+ if (message.status == MessageStatus.error &&
55
+ message.errorMessage != null &&
56
+ message.errorMessage!.isNotEmpty)
57
+ Padding(
58
+ padding: const EdgeInsets.only(top: 8),
59
+ child: Container(
60
+ padding: const EdgeInsets.all(12),
61
+ decoration: BoxDecoration(
62
+ color: Colors.red.withValues(alpha: 0.1),
63
+ borderRadius: BorderRadius.circular(8),
64
+ border: Border.all(color: Colors.red.withValues(alpha: 0.2)),
65
+ ),
66
+ child: Text(
67
+ message.errorMessage!,
68
+ style: TextStyle(
69
+ color: Colors.red[400],
70
+ fontSize: 12,
71
+ fontWeight: FontWeight.w500,
72
+ ),
73
+ ),
74
+ ),
75
+ ),
76
+ if (message.toolEvents != null && message.toolEvents!.isNotEmpty)
77
+ Padding(
78
+ padding: const EdgeInsets.only(top: 8),
79
+ child: ToolTimeline(events: message.toolEvents!),
80
+ ),
81
+ if (isStreaming && message.content.isNotEmpty)
82
+ const Padding(
83
+ padding: EdgeInsets.only(top: 8),
84
+ child: _StreamingIndicator(),
85
+ ),
86
+ const SizedBox(height: 8),
87
+ Row(
88
+ children: [
89
+ Text(
90
+ _formatTime(message.createdAt),
91
+ style: TextStyle(
92
+ fontSize: 11,
93
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
94
+ ),
95
+ ),
96
+ if (message.status == MessageStatus.error) ...[
97
+ const SizedBox(width: 4),
98
+ Icon(Icons.error_outline, size: 14, color: Colors.red[400]),
99
+ ],
100
+ const Spacer(),
101
+ if (!isStreaming &&
102
+ (message.status == MessageStatus.complete ||
103
+ message.status == MessageStatus.error ||
104
+ message.status == MessageStatus.cancelled))
105
+ MessageActionBar(
106
+ content: message.content,
107
+ onCopy: onCopy,
108
+ onRetry: onRetry,
109
+ onDelete: onDelete,
110
+ ),
111
+ ],
112
+ ),
113
+ ],
114
+ ),
115
+ );
116
+ }
117
+}
118
+
119
+class _StreamingContent extends StatefulWidget {
120
+ const _StreamingContent({required this.content, required this.isDark});
121
+
122
+ final String content;
123
+ final bool isDark;
124
+
125
+ @override
126
+ State<_StreamingContent> createState() => _StreamingContentState();
127
+}
128
+
129
+class _StreamingContentState extends State<_StreamingContent> {
130
+ static const Duration _updateInterval = Duration(milliseconds: 80);
131
+ Timer? _flushTimer;
132
+ String _visibleContent = '';
133
+
134
+ @override
135
+ void initState() {
136
+ super.initState();
137
+ _visibleContent = widget.content;
138
+ }
139
+
140
+ @override
141
+ void didUpdateWidget(covariant _StreamingContent oldWidget) {
142
+ super.didUpdateWidget(oldWidget);
143
+ if (oldWidget.content == widget.content) {
144
+ return;
145
+ }
146
+
147
+ if (widget.content.length < _visibleContent.length) {
148
+ _flushTimer?.cancel();
149
+ _visibleContent = widget.content;
150
+ return;
151
+ }
152
+
153
+ _flushTimer ??= Timer(_updateInterval, () {
154
+ _flushTimer = null;
155
+ if (mounted) {
156
+ setState(() {
157
+ _visibleContent = widget.content;
158
+ });
159
+ }
160
+ });
161
+ }
162
+
163
+ @override
164
+ void dispose() {
165
+ _flushTimer?.cancel();
166
+ super.dispose();
167
+ }
168
+
169
+ @override
170
+ Widget build(BuildContext context) {
171
+ return MarkdownBodyContent(
172
+ content: _visibleContent,
173
+ isDark: widget.isDark,
174
+ );
175
+ }
176
+}
177
+
178
+class _StreamingIndicator extends StatelessWidget {
179
+ const _StreamingIndicator();
180
+
181
+ @override
182
+ Widget build(BuildContext context) {
183
+ return const SizedBox(
184
+ width: 12,
185
+ height: 12,
186
+ child: CircularProgressIndicator(strokeWidth: 2),
187
+ );
188
+ }
189
+}
190
+
191
+String _formatTime(DateTime dateTime) {
192
+ final hour = dateTime.hour.toString().padLeft(2, '0');
193
+ final minute = dateTime.minute.toString().padLeft(2, '0');
194
+ return '$hour:$minute';
195
+}
lib/features/chat/views/components/chat_bubble/attachment_list.dart
new
+168
@@ -0,0 +1,168 @@
1
+import 'dart:io';
2
+import 'package:flutter/material.dart';
3
+import 'package:localmind/core/theme/colors.dart';
4
+import 'package:localmind/features/chat/views/components/audio_player_widget.dart';
5
+
6
+class AttachmentList extends StatelessWidget {
7
+ const AttachmentList({super.key, required this.paths, required this.isUser});
8
+
9
+ final List<String> paths;
10
+ final bool isUser;
11
+
12
+ @override
13
+ Widget build(BuildContext context) {
14
+ return Wrap(
15
+ spacing: 8,
16
+ runSpacing: 8,
17
+ children: paths.map((path) => _AttachmentItem(path: path)).toList(),
18
+ );
19
+ }
20
+}
21
+
22
+class _AttachmentItem extends StatelessWidget {
23
+ const _AttachmentItem({required this.path});
24
+
25
+ final String path;
26
+
27
+ bool _isImage(String path) {
28
+ final mime = path.toLowerCase();
29
+ return mime.endsWith('.jpg') ||
30
+ mime.endsWith('.jpeg') ||
31
+ mime.endsWith('.png') ||
32
+ mime.endsWith('.gif') ||
33
+ mime.endsWith('.webp');
34
+ }
35
+
36
+ bool _isAudio(String path) {
37
+ final ext = path.toLowerCase();
38
+ return ext.endsWith('.mp3') ||
39
+ ext.endsWith('.wav') ||
40
+ ext.endsWith('.ogg') ||
41
+ ext.endsWith('.flac') ||
42
+ ext.endsWith('.aac') ||
43
+ ext.endsWith('.m4a') ||
44
+ ext.endsWith('.wma') ||
45
+ ext.endsWith('.opus');
46
+ }
47
+
48
+ void _viewImage(BuildContext context) {
49
+ showDialog(
50
+ context: context,
51
+ useSafeArea: false,
52
+ builder: (context) => _ImageViewer(path: path),
53
+ );
54
+ }
55
+
56
+ @override
57
+ Widget build(BuildContext context) {
58
+ final theme = Theme.of(context);
59
+ final isDark = theme.brightness == Brightness.dark;
60
+ final file = File(path);
61
+ final fileName = path.split('/').last;
62
+
63
+ if (_isImage(path)) {
64
+ return GestureDetector(
65
+ onTap: () => _viewImage(context),
66
+ child: ClipRRect(
67
+ borderRadius: BorderRadius.circular(12),
68
+ child: Image.file(
69
+ file,
70
+ width: 120,
71
+ height: 120,
72
+ fit: BoxFit.cover,
73
+ errorBuilder: (context, error, stackTrace) =>
74
+ _FilePlaceholder(fileName: fileName),
75
+ ),
76
+ ),
77
+ );
78
+ }
79
+
80
+ if (_isAudio(path)) {
81
+ return AudioPlayerWidget(source: path);
82
+ }
83
+
84
+ return Container(
85
+ width: 160,
86
+ padding: const EdgeInsets.all(12),
87
+ decoration: BoxDecoration(
88
+ color: isDark ? AppColors.darkSurfaceCard : AppColors.lightSurface,
89
+ borderRadius: BorderRadius.circular(12),
90
+ border: Border.all(
91
+ color: isDark ? AppColors.darkBorder : AppColors.lightBorder,
92
+ ),
93
+ ),
94
+ child: Row(
95
+ mainAxisSize: MainAxisSize.min,
96
+ children: [
97
+ Icon(
98
+ Icons.insert_drive_file_outlined,
99
+ size: 20,
100
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
101
+ ),
102
+ const SizedBox(width: 8),
103
+ Expanded(
104
+ child: Text(
105
+ fileName,
106
+ style: TextStyle(
107
+ fontSize: 12,
108
+ color: isDark ? Colors.white70 : Colors.black87,
109
+ ),
110
+ maxLines: 1,
111
+ overflow: TextOverflow.ellipsis,
112
+ ),
113
+ ),
114
+ ],
115
+ ),
116
+ );
117
+ }
118
+}
119
+
120
+class _FilePlaceholder extends StatelessWidget {
121
+ const _FilePlaceholder({required this.fileName});
122
+ final String fileName;
123
+
124
+ @override
125
+ Widget build(BuildContext context) {
126
+ return Container(
127
+ width: 120,
128
+ height: 120,
129
+ color: Colors.grey[800],
130
+ child: Center(
131
+ child: Text(
132
+ fileName.split('.').last.toUpperCase(),
133
+ style: const TextStyle(
134
+ color: Colors.white,
135
+ fontWeight: FontWeight.bold,
136
+ ),
137
+ ),
138
+ ),
139
+ );
140
+ }
141
+}
142
+
143
+class _ImageViewer extends StatelessWidget {
144
+ const _ImageViewer({required this.path});
145
+ final String path;
146
+
147
+ @override
148
+ Widget build(BuildContext context) {
149
+ return Scaffold(
150
+ backgroundColor: Colors.black,
151
+ appBar: AppBar(
152
+ backgroundColor: Colors.transparent,
153
+ elevation: 0,
154
+ leading: IconButton(
155
+ icon: const Icon(Icons.close, color: Colors.white),
156
+ onPressed: () => Navigator.pop(context),
157
+ ),
158
+ ),
159
+ body: Center(
160
+ child: InteractiveViewer(
161
+ minScale: 0.5,
162
+ maxScale: 4.0,
163
+ child: Image.file(File(path)),
164
+ ),
165
+ ),
166
+ );
167
+ }
168
+}
lib/features/chat/views/components/chat_bubble/markdown/deferred_markdown.dart
new
+60
@@ -0,0 +1,60 @@
1
+import 'dart:async';
2
+import 'package:flutter/material.dart';
3
+import 'themed_gpt_markdown.dart';
4
+import 'streaming_text.dart';
5
+
6
+class DeferredMarkdownContent extends StatefulWidget {
7
+ const DeferredMarkdownContent({super.key, required this.content, required this.isDark});
8
+
9
+ final String content;
10
+ final bool isDark;
11
+
12
+ @override
13
+ State<DeferredMarkdownContent> createState() => _DeferredMarkdownContentState();
14
+}
15
+
16
+class _DeferredMarkdownContentState extends State<DeferredMarkdownContent> {
17
+ bool _showMarkdown = false;
18
+ Timer? _deferTimer;
19
+
20
+ @override
21
+ void initState() {
22
+ super.initState();
23
+ _deferTimer = Timer(const Duration(milliseconds: 120), () {
24
+ if (mounted) {
25
+ setState(() => _showMarkdown = true);
26
+ }
27
+ });
28
+ }
29
+
30
+ @override
31
+ void dispose() {
32
+ _deferTimer?.cancel();
33
+ super.dispose();
34
+ }
35
+
36
+ @override
37
+ void didUpdateWidget(DeferredMarkdownContent oldWidget) {
38
+ super.didUpdateWidget(oldWidget);
39
+ if (oldWidget.content != widget.content || oldWidget.isDark != widget.isDark) {
40
+ _showMarkdown = false;
41
+ _deferTimer?.cancel();
42
+ _deferTimer = Timer(const Duration(milliseconds: 120), () {
43
+ if (mounted) {
44
+ setState(() => _showMarkdown = true);
45
+ }
46
+ });
47
+ }
48
+ }
49
+
50
+ @override
51
+ Widget build(BuildContext context) {
52
+ if (!_showMarkdown) {
53
+ return StreamingTextContent(
54
+ content: widget.content,
55
+ isDark: widget.isDark,
56
+ );
57
+ }
58
+ return MarkdownBodyContent(content: widget.content, isDark: widget.isDark);
59
+ }
60
+}
lib/features/chat/views/components/chat_bubble/markdown/streaming_text.dart
new
+20
@@ -0,0 +1,20 @@
1
+import 'package:flutter/material.dart';
2
+
3
+class StreamingTextContent extends StatelessWidget {
4
+ const StreamingTextContent({super.key, required this.content, required this.isDark});
5
+
6
+ final String content;
7
+ final bool isDark;
8
+
9
+ @override
10
+ Widget build(BuildContext context) {
11
+ return Text(
12
+ content,
13
+ style: TextStyle(
14
+ color: isDark ? Colors.white : Colors.black,
15
+ fontSize: 15,
16
+ height: 1.5,
17
+ ),
18
+ );
19
+ }
20
+}
lib/features/chat/views/components/chat_bubble/markdown/themed_gpt_markdown.dart
new
+135
@@ -0,0 +1,135 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:gpt_markdown/gpt_markdown.dart';
3
+import 'package:localmind/core/theme/colors.dart';
4
+import 'package:localmind/features/chat/views/components/audio_player_widget.dart';
5
+import 'deferred_markdown.dart';
6
+
7
+class ThemedGptMarkdown extends StatelessWidget {
8
+ const ThemedGptMarkdown({
9
+ super.key,
10
+ required this.content,
11
+ required this.isDark,
12
+ required this.style,
13
+ });
14
+
15
+ final String content;
16
+ final bool isDark;
17
+ final TextStyle style;
18
+
19
+ static bool _isAudioUrl(String url) {
20
+ final lower = url.toLowerCase();
21
+ return lower.endsWith('.mp3') ||
22
+ lower.endsWith('.wav') ||
23
+ lower.endsWith('.ogg') ||
24
+ lower.endsWith('.flac') ||
25
+ lower.endsWith('.aac') ||
26
+ lower.endsWith('.m4a') ||
27
+ lower.endsWith('.wma') ||
28
+ lower.endsWith('.opus');
29
+ }
30
+
31
+ @override
32
+ Widget build(BuildContext context) {
33
+ final gptTheme = GptMarkdownThemeData(
34
+ brightness: isDark ? Brightness.dark : Brightness.light,
35
+ highlightColor: isDark ? const Color(0xFF334155) : const Color(0xFFDBEAFE),
36
+ linkColor: isDark ? AppColors.darkAccent : AppColors.lightAccent,
37
+ linkHoverColor: isDark ? AppColors.darkAccent : AppColors.lightAccent,
38
+ hrLineColor: isDark ? const Color(0xFF334155) : const Color(0xFFE2E8F0),
39
+ hrLineThickness: 1.0,
40
+ h1: style.copyWith(fontSize: 24, fontWeight: FontWeight.w700),
41
+ h2: style.copyWith(fontSize: 21, fontWeight: FontWeight.w700),
42
+ h3: style.copyWith(fontSize: 19, fontWeight: FontWeight.w600),
43
+ h4: style.copyWith(fontSize: 17, fontWeight: FontWeight.w600),
44
+ h5: style.copyWith(fontSize: 16, fontWeight: FontWeight.w600),
45
+ h6: style.copyWith(fontSize: 15, fontWeight: FontWeight.w600),
46
+ );
47
+
48
+ return GptMarkdownTheme(
49
+ gptThemeData: gptTheme,
50
+ child: GptMarkdown(
51
+ content,
52
+ style: style,
53
+ followLinkColor: true,
54
+ imageBuilder: _buildImageOrAudio,
55
+ onLinkTap: (url, title) {
56
+ if (_isAudioUrl(url) && context.mounted) {
57
+ showDialog(
58
+ context: context,
59
+ builder: (ctx) => AlertDialog(
60
+ contentPadding: const EdgeInsets.all(16),
61
+ content: AudioPlayerWidget(source: url),
62
+ ),
63
+ );
64
+ }
65
+ },
66
+ ),
67
+ );
68
+ }
69
+
70
+ Widget _buildImageOrAudio(
71
+ BuildContext context, String url, double? width, double? height) {
72
+ if (_isAudioUrl(url)) {
73
+ return AudioPlayerWidget(source: url, height: 56);
74
+ }
75
+ return SizedBox(
76
+ width: width,
77
+ height: height,
78
+ child: Image(
79
+ image: NetworkImage(url),
80
+ loadingBuilder: (context, child, loadingProgress) {
81
+ if (loadingProgress == null) return child;
82
+ return Center(
83
+ child: CircularProgressIndicator(
84
+ value: loadingProgress.expectedTotalBytes != null
85
+ ? loadingProgress.cumulativeBytesLoaded /
86
+ loadingProgress.expectedTotalBytes!
87
+ : null,
88
+ ),
89
+ );
90
+ },
91
+ errorBuilder: (context, error, stackTrace) => Container(
92
+ color: Colors.grey[800],
93
+ child: const Center(child: Icon(Icons.broken_image, color: Colors.white54)),
94
+ ),
95
+ ),
96
+ );
97
+ }
98
+}
99
+
100
+class MarkdownContent extends StatelessWidget {
101
+ const MarkdownContent({super.key, required this.content, required this.isDark});
102
+
103
+ final String content;
104
+ final bool isDark;
105
+
106
+ @override
107
+ Widget build(BuildContext context) {
108
+ if (content.length > 2000) {
109
+ return DeferredMarkdownContent(content: content, isDark: isDark);
110
+ }
111
+ return MarkdownBodyContent(content: content, isDark: isDark);
112
+ }
113
+}
114
+
115
+class MarkdownBodyContent extends StatelessWidget {
116
+ const MarkdownBodyContent({super.key, required this.content, required this.isDark});
117
+
118
+ final String content;
119
+ final bool isDark;
120
+
121
+ @override
122
+ Widget build(BuildContext context) {
123
+ return SelectionArea(
124
+ child: ThemedGptMarkdown(
125
+ content: content,
126
+ isDark: isDark,
127
+ style: TextStyle(
128
+ color: isDark ? Colors.white : Colors.black,
129
+ fontSize: 15,
130
+ height: 1.5,
131
+ ),
132
+ ),
133
+ );
134
+ }
135
+}
lib/features/chat/views/components/chat_bubble/system_bubble.dart
new
+31
@@ -0,0 +1,31 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:localmind/features/chat/data/models/message.dart';
3
+
4
+class SystemBubble extends StatelessWidget {
5
+ const SystemBubble({super.key, required this.message});
6
+
7
+ final Message message;
8
+
9
+ @override
10
+ Widget build(BuildContext context) {
11
+ return Center(
12
+ child: Container(
13
+ margin: const EdgeInsets.symmetric(vertical: 8),
14
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
15
+ decoration: BoxDecoration(
16
+ color: Colors.grey.withValues(alpha: 0.1),
17
+ borderRadius: BorderRadius.circular(8),
18
+ ),
19
+ child: Text(
20
+ message.content,
21
+ style: TextStyle(
22
+ fontSize: 13,
23
+ fontStyle: FontStyle.italic,
24
+ color: Colors.grey[600],
25
+ ),
26
+ textAlign: TextAlign.center,
27
+ ),
28
+ ),
29
+ );
30
+ }
31
+}
lib/features/chat/views/components/chat_bubble/tool_bubble/tool_bubble.dart
new
+74
@@ -0,0 +1,74 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:localmind/core/theme/colors.dart';
3
+import 'package:localmind/features/chat/data/models/message.dart';
4
+import 'package:localmind/l10n/app_localizations.dart';
5
+
6
+class ToolBubble extends StatelessWidget {
7
+ const ToolBubble({super.key, required this.message});
8
+
9
+ final Message message;
10
+
11
+ @override
12
+ Widget build(BuildContext context) {
13
+ final l10n = AppLocalizations.of(context)!;
14
+ final theme = Theme.of(context);
15
+ final isDark = theme.brightness == Brightness.dark;
16
+
17
+ return Align(
18
+ alignment: AlignmentDirectional.centerStart,
19
+ child: Container(
20
+ constraints: BoxConstraints(
21
+ maxWidth: MediaQuery.of(context).size.width * 0.8,
22
+ ),
23
+ margin: const EdgeInsetsDirectional.only(
24
+ start: 8,
25
+ end: 48,
26
+ top: 4,
27
+ bottom: 4,
28
+ ),
29
+ padding: const EdgeInsets.all(12),
30
+ decoration: BoxDecoration(
31
+ color: isDark ? const Color(0xFF1F2937) : const Color(0xFFEEF2FF),
32
+ borderRadius: BorderRadius.circular(12),
33
+ border: Border.all(
34
+ color: isDark ? const Color(0xFF374151) : const Color(0xFFC7D2FE),
35
+ ),
36
+ ),
37
+ child: Column(
38
+ crossAxisAlignment: CrossAxisAlignment.start,
39
+ children: [
40
+ Row(
41
+ children: [
42
+ Icon(
43
+ Icons.build_outlined,
44
+ size: 14,
45
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
46
+ ),
47
+ const SizedBox(width: 4),
48
+ Text(
49
+ message.toolCallId != null
50
+ ? l10n.tool_label(message.toolCallId!)
51
+ : l10n.tool_unknown,
52
+ style: TextStyle(
53
+ fontSize: 12,
54
+ fontWeight: FontWeight.w500,
55
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
56
+ ),
57
+ ),
58
+ ],
59
+ ),
60
+ const SizedBox(height: 8),
61
+ Text(
62
+ message.content,
63
+ style: TextStyle(
64
+ fontSize: 13,
65
+ color: isDark ? Colors.white : Colors.black87,
66
+ fontFamily: 'monospace',
67
+ ),
68
+ ),
69
+ ],
70
+ ),
71
+ ),
72
+ );
73
+ }
74
+}
lib/features/chat/views/components/chat_bubble/tool_bubble/tool_inline_badge.dart
new
+34
@@ -0,0 +1,34 @@
1
+import 'package:flutter/material.dart';
2
+
3
+class InlineToolBadge extends StatelessWidget {
4
+ const InlineToolBadge({
5
+ super.key,
6
+ required this.label,
7
+ required this.backgroundColor,
8
+ required this.textColor,
9
+ });
10
+
11
+ final String label;
12
+ final Color backgroundColor;
13
+ final Color textColor;
14
+
15
+ @override
16
+ Widget build(BuildContext context) {
17
+ return Container(
18
+ padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
19
+ decoration: BoxDecoration(
20
+ color: backgroundColor,
21
+ borderRadius: BorderRadius.circular(4),
22
+ ),
23
+ child: Text(
24
+ label.toUpperCase(),
25
+ style: TextStyle(
26
+ color: textColor,
27
+ fontSize: 8,
28
+ fontWeight: FontWeight.w700,
29
+ letterSpacing: 0.35,
30
+ ),
31
+ ),
32
+ );
33
+ }
34
+}
lib/features/chat/views/components/chat_bubble/tool_bubble/tool_row.dart
new
+307
@@ -0,0 +1,307 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:localmind/core/theme/colors.dart';
3
+import 'package:localmind/features/chat/data/tools/tool_event.dart';
4
+import 'package:localmind/features/chat/data/tools/tool_definition.dart';
5
+import 'package:localmind/features/chat/data/models/grouped_tool_call.dart';
6
+import 'tool_inline_badge.dart';
7
+
8
+class ToolRowWidget extends StatefulWidget {
9
+ const ToolRowWidget({
10
+ super.key,
11
+ required this.call,
12
+ required this.isDark,
13
+ required this.formatArgs,
14
+ });
15
+
16
+ final GroupedToolCall call;
17
+ final bool isDark;
18
+ final String Function(Map<String, dynamic>) formatArgs;
19
+
20
+ @override
21
+ State<ToolRowWidget> createState() => _ToolRowWidgetState();
22
+}
23
+
24
+class _ToolRowWidgetState extends State<ToolRowWidget> {
25
+ late bool _isExpanded;
26
+
27
+ @override
28
+ void initState() {
29
+ super.initState();
30
+ _isExpanded = widget.call.status == ToolEventStatus.running ||
31
+ widget.call.status == ToolEventStatus.failed ||
32
+ widget.call.status == ToolEventStatus.requested ||
33
+ widget.call.status == ToolEventStatus.approved;
34
+ }
35
+
36
+ @override
37
+ void didUpdateWidget(ToolRowWidget oldWidget) {
38
+ super.didUpdateWidget(oldWidget);
39
+ if (oldWidget.call.status != widget.call.status) {
40
+ if (widget.call.status == ToolEventStatus.running ||
41
+ widget.call.status == ToolEventStatus.failed ||
42
+ widget.call.status == ToolEventStatus.requested ||
43
+ widget.call.status == ToolEventStatus.approved) {
44
+ setState(() {
45
+ _isExpanded = true;
46
+ });
47
+ }
48
+ }
49
+ }
50
+
51
+ @override
52
+ Widget build(BuildContext context) {
53
+ final call = widget.call;
54
+ final isDark = widget.isDark;
55
+ final isMcpTool = call.providerType == ToolProviderType.mcp;
56
+
57
+ final iconColor = switch (call.status) {
58
+ ToolEventStatus.completed => const Color(0xFF22C55E),
59
+ ToolEventStatus.failed || ToolEventStatus.rejected => const Color(0xFFEF4444),
60
+ ToolEventStatus.running => AppColors.darkAccent,
61
+ ToolEventStatus.requested || ToolEventStatus.approved => const Color(0xFFF59E0B),
62
+ };
63
+
64
+ final statusLabel = switch (call.status) {
65
+ ToolEventStatus.requested => 'Requested',
66
+ ToolEventStatus.approved => 'Approved',
67
+ ToolEventStatus.rejected => 'Rejected',
68
+ ToolEventStatus.running => 'Running',
69
+ ToolEventStatus.completed => 'Done',
70
+ ToolEventStatus.failed => 'Failed',
71
+ };
72
+
73
+ final showCodeBlock = (call.arguments != null && call.arguments!.isNotEmpty) ||
74
+ (call.result != null && call.result!.isNotEmpty) ||
75
+ (call.error != null && call.error!.isNotEmpty);
76
+
77
+ return Padding(
78
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
79
+ child: Column(
80
+ crossAxisAlignment: CrossAxisAlignment.start,
81
+ children: [
82
+ InkWell(
83
+ onTap: showCodeBlock
84
+ ? () => setState(() => _isExpanded = !_isExpanded)
85
+ : null,
86
+ borderRadius: BorderRadius.circular(6),
87
+ child: Padding(
88
+ padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
89
+ child: Row(
90
+ crossAxisAlignment: CrossAxisAlignment.center,
91
+ children: [
92
+ if (call.status == ToolEventStatus.running)
93
+ _AnimatedRunningIcon(color: iconColor)
94
+ else
95
+ Icon(
96
+ switch (call.status) {
97
+ ToolEventStatus.requested => Icons.hourglass_empty_rounded,
98
+ ToolEventStatus.approved => Icons.check_circle_outline_rounded,
99
+ ToolEventStatus.rejected => Icons.block_flipped,
100
+ ToolEventStatus.running => Icons.cached_rounded,
101
+ ToolEventStatus.completed => Icons.check_circle_rounded,
102
+ ToolEventStatus.failed => Icons.error_rounded,
103
+ },
104
+ size: 14,
105
+ color: iconColor,
106
+ ),
107
+ const SizedBox(width: 8),
108
+ Expanded(
109
+ child: Row(
110
+ children: [
111
+ Flexible(
112
+ child: Text(
113
+ call.toolName,
114
+ style: TextStyle(
115
+ fontSize: 12,
116
+ fontWeight: FontWeight.bold,
117
+ color: isDark ? Colors.white : Colors.black87,
118
+ fontFamily: 'monospace',
119
+ ),
120
+ overflow: TextOverflow.ellipsis,
121
+ ),
122
+ ),
123
+ const SizedBox(width: 6),
124
+ if (isMcpTool) ...[
125
+ InlineToolBadge(
126
+ label: 'MCP',
127
+ backgroundColor: Colors.purple.withValues(alpha: isDark ? 0.2 : 0.1),
128
+ textColor: isDark ? const Color(0xFFC084FC) : const Color(0xFF7E22CE),
129
+ ),
130
+ ] else if (call.providerType == ToolProviderType.lmStudioServer) ...[
131
+ InlineToolBadge(
132
+ label: 'LM Studio',
133
+ backgroundColor: Colors.orange.withValues(alpha: isDark ? 0.2 : 0.1),
134
+ textColor: isDark ? const Color(0xFFF97316) : const Color(0xFFC2410C),
135
+ ),
136
+ ] else ...[
137
+ InlineToolBadge(
138
+ label: 'LOCAL',
139
+ backgroundColor: Colors.blue.withValues(alpha: isDark ? 0.2 : 0.1),
140
+ textColor: isDark ? const Color(0xFF60A5FA) : const Color(0xFF1D4ED8),
141
+ ),
142
+ ],
143
+ const SizedBox(width: 6),
144
+ InlineToolBadge(
145
+ label: statusLabel,
146
+ backgroundColor: iconColor.withValues(alpha: isDark ? 0.15 : 0.1),
147
+ textColor: iconColor,
148
+ ),
149
+ ],
150
+ ),
151
+ ),
152
+ if (call.durationMs != null) ...[
153
+ const SizedBox(width: 8),
154
+ Text(
155
+ '${call.durationMs}ms',
156
+ style: TextStyle(
157
+ fontSize: 10,
158
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
159
+ ),
160
+ ),
161
+ ],
162
+ if (showCodeBlock) ...[
163
+ const SizedBox(width: 6),
164
+ Icon(
165
+ _isExpanded
166
+ ? Icons.keyboard_arrow_up_rounded
167
+ : Icons.keyboard_arrow_down_rounded,
168
+ size: 16,
169
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
170
+ ),
171
+ ],
172
+ ],
173
+ ),
174
+ ),
175
+ ),
176
+ if (showCodeBlock && _isExpanded)
177
+ Padding(
178
+ padding: const EdgeInsets.only(top: 6, left: 22),
179
+ child: Container(
180
+ width: double.infinity,
181
+ padding: const EdgeInsets.all(8),
182
+ decoration: BoxDecoration(
183
+ color: isDark ? const Color(0xFF0F172A) : const Color(0xFFF1F5F9),
184
+ borderRadius: BorderRadius.circular(8),
185
+ border: Border.all(
186
+ color: isDark ? const Color(0xFF1E293B) : const Color(0xFFE2E8F0),
187
+ ),
188
+ ),
189
+ child: Column(
190
+ crossAxisAlignment: CrossAxisAlignment.start,
191
+ children: [
192
+ if (call.arguments != null && call.arguments!.isNotEmpty) ...[
193
+ Text(
194
+ 'Arguments:',
195
+ style: TextStyle(
196
+ fontSize: 9,
197
+ fontWeight: FontWeight.bold,
198
+ color: isDark ? const Color(0xFF38BDF8) : const Color(0xFF0284C7),
199
+ fontFamily: 'monospace',
200
+ ),
201
+ ),
202
+ const SizedBox(height: 2),
203
+ Text(
204
+ widget.formatArgs(call.arguments!),
205
+ style: TextStyle(
206
+ fontSize: 10.5,
207
+ color: isDark ? const Color(0xFFCBD5E1) : const Color(0xFF475569),
208
+ fontFamily: 'monospace',
209
+ height: 1.3,
210
+ ),
211
+ ),
212
+ ],
213
+ if (call.result != null && call.result!.isNotEmpty) ...[
214
+ if (call.arguments != null && call.arguments!.isNotEmpty)
215
+ const SizedBox(height: 8),
216
+ Text(
217
+ 'Output:',
218
+ style: TextStyle(
219
+ fontSize: 9,
220
+ fontWeight: FontWeight.bold,
221
+ color: isDark ? const Color(0xFF4ADE80) : const Color(0xFF16A34A),
222
+ fontFamily: 'monospace',
223
+ ),
224
+ ),
225
+ const SizedBox(height: 2),
226
+ Text(
227
+ call.result!,
228
+ style: TextStyle(
229
+ fontSize: 10.5,
230
+ color: isDark ? const Color(0xFFF8FAFC) : const Color(0xFF0F172A),
231
+ fontFamily: 'monospace',
232
+ height: 1.3,
233
+ ),
234
+ ),
235
+ ],
236
+ if (call.error != null && call.error!.isNotEmpty) ...[
237
+ if (call.arguments != null && call.arguments!.isNotEmpty)
238
+ const SizedBox(height: 8),
239
+ Text(
240
+ 'Error:',
241
+ style: TextStyle(
242
+ fontSize: 9,
243
+ fontWeight: FontWeight.bold,
244
+ color: isDark ? const Color(0xFFF87171) : const Color(0xFFDC2626),
245
+ fontFamily: 'monospace',
246
+ ),
247
+ ),
248
+ const SizedBox(height: 2),
249
+ Text(
250
+ call.error!,
251
+ style: TextStyle(
252
+ fontSize: 10.5,
253
+ color: isDark ? const Color(0xFFFCA5A5) : const Color(0xFF991B1B),
254
+ fontFamily: 'monospace',
255
+ height: 1.3,
256
+ ),
257
+ ),
258
+ ],
259
+ ],
260
+ ),
261
+ ),
262
+ ),
263
+ ],
264
+ ),
265
+ );
266
+ }
267
+}
268
+
269
+class _AnimatedRunningIcon extends StatefulWidget {
270
+ const _AnimatedRunningIcon({required this.color});
271
+ final Color color;
272
+
273
+ @override
274
+ State<_AnimatedRunningIcon> createState() => _AnimatedRunningIconState();
275
+}
276
+
277
+class _AnimatedRunningIconState extends State<_AnimatedRunningIcon>
278
+ with SingleTickerProviderStateMixin {
279
+ late AnimationController _controller;
280
+
281
+ @override
282
+ void initState() {
283
+ super.initState();
284
+ _controller = AnimationController(
285
+ vsync: this,
286
+ duration: const Duration(seconds: 2),
287
+ )..repeat();
288
+ }
289
+
290
+ @override
291
+ void dispose() {
292
+ _controller.dispose();
293
+ super.dispose();
294
+ }
295
+
296
+ @override
297
+ Widget build(BuildContext context) {
298
+ return RotationTransition(
299
+ turns: _controller,
300
+ child: Icon(
301
+ Icons.cached_rounded,
302
+ size: 14,
303
+ color: widget.color,
304
+ ),
305
+ );
306
+ }
307
+}
lib/features/chat/views/components/chat_bubble/tool_bubble/tool_timeline.dart
new
+172
@@ -0,0 +1,172 @@
1
+import 'dart:convert';
2
+import 'package:flutter/material.dart';
3
+import 'package:localmind/core/theme/colors.dart';
4
+import 'package:localmind/features/chat/data/tools/tool_event.dart';
5
+import 'package:localmind/features/chat/data/models/grouped_tool_call.dart';
6
+import 'tool_row.dart';
7
+
8
+class ToolTimeline extends StatelessWidget {
9
+ const ToolTimeline({super.key, required this.events});
10
+ final List<ToolEvent> events;
11
+
12
+ List<GroupedToolCall> _groupEvents(List<ToolEvent> events) {
13
+ final Map<String, GroupedToolCall> groups = {};
14
+ final List<String> orderedBaseIds = [];
15
+
16
+ for (final event in events) {
17
+ final dotIndex = event.eventId.lastIndexOf('.');
18
+ final baseId = dotIndex != -1
19
+ ? event.eventId.substring(0, dotIndex)
20
+ : event.eventId;
21
+
22
+ if (!orderedBaseIds.contains(baseId)) {
23
+ orderedBaseIds.add(baseId);
24
+ }
25
+
26
+ final existing = groups[baseId];
27
+ if (existing == null) {
28
+ groups[baseId] = GroupedToolCall(
29
+ baseId: baseId,
30
+ toolName: event.toolName,
31
+ providerType: event.providerType,
32
+ providerRef: event.providerRef,
33
+ arguments: event.arguments,
34
+ status: event.status,
35
+ result: event.result,
36
+ error: event.error,
37
+ durationMs: event.durationMs,
38
+ timestamp: event.timestamp,
39
+ );
40
+ } else {
41
+ groups[baseId] = GroupedToolCall(
42
+ baseId: baseId,
43
+ toolName: event.toolName,
44
+ providerType: event.providerType,
45
+ providerRef: event.providerRef,
46
+ arguments: event.arguments ?? existing.arguments,
47
+ status: event.status,
48
+ result: event.result ?? existing.result,
49
+ error: event.error ?? existing.error,
50
+ durationMs: event.durationMs ?? existing.durationMs,
51
+ timestamp: event.timestamp,
52
+ );
53
+ }
54
+ }
55
+
56
+ return orderedBaseIds.map((id) => groups[id]!).toList();
57
+ }
58
+
59
+ String _formatArgs(Map<String, dynamic> args) {
60
+ if (args.isEmpty) return '';
61
+ try {
62
+ final encoder = JsonEncoder.withIndent(' ');
63
+ return encoder.convert(args);
64
+ } catch (_) {
65
+ return args.entries.map((e) => ' ${e.key}: ${e.value}').join('\n');
66
+ }
67
+ }
68
+
69
+ @override
70
+ Widget build(BuildContext context) {
71
+ final theme = Theme.of(context);
72
+ final isDark = theme.brightness == Brightness.dark;
73
+ final groupedCalls = _groupEvents(events);
74
+
75
+ if (groupedCalls.isEmpty) {
76
+ return const SizedBox.shrink();
77
+ }
78
+
79
+ return Container(
80
+ margin: const EdgeInsets.only(top: 8, bottom: 4),
81
+ decoration: BoxDecoration(
82
+ color: isDark ? AppColors.darkSurfaceInput : AppColors.lightSurface,
83
+ borderRadius: BorderRadius.circular(12),
84
+ border: Border.all(
85
+ color: isDark ? AppColors.darkBorder : AppColors.lightBorder,
86
+ ),
87
+ boxShadow: [
88
+ BoxShadow(
89
+ color: Colors.black.withValues(alpha: isDark ? 0.1 : 0.02),
90
+ blurRadius: 8,
91
+ offset: const Offset(0, 4),
92
+ ),
93
+ ],
94
+ ),
95
+ child: Column(
96
+ crossAxisAlignment: CrossAxisAlignment.start,
97
+ children: [
98
+ Container(
99
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
100
+ decoration: BoxDecoration(
101
+ border: Border(
102
+ bottom: BorderSide(
103
+ color: isDark ? AppColors.darkBorder : AppColors.lightBorder,
104
+ ),
105
+ ),
106
+ ),
107
+ child: Row(
108
+ children: [
109
+ Icon(
110
+ Icons.terminal_rounded,
111
+ size: 16,
112
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
113
+ ),
114
+ const SizedBox(width: 8),
115
+ Text(
116
+ 'SYSTEM ACTIONS',
117
+ style: TextStyle(
118
+ fontSize: 11,
119
+ fontWeight: FontWeight.bold,
120
+ letterSpacing: 0.5,
121
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
122
+ ),
123
+ ),
124
+ const Spacer(),
125
+ Container(
126
+ padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
127
+ decoration: BoxDecoration(
128
+ color: isDark ? AppColors.darkSurfaceCard : AppColors.lightBorder,
129
+ borderRadius: BorderRadius.circular(6),
130
+ ),
131
+ child: Text(
132
+ '${groupedCalls.length} ${groupedCalls.length == 1 ? "action" : "actions"}',
133
+ style: TextStyle(
134
+ fontSize: 9,
135
+ fontWeight: FontWeight.bold,
136
+ color: isDark ? Colors.white70 : Colors.black54,
137
+ ),
138
+ ),
139
+ ),
140
+ ],
141
+ ),
142
+ ),
143
+ const SizedBox(height: 4),
144
+ Padding(
145
+ padding: const EdgeInsets.symmetric(vertical: 4),
146
+ child: ListView.separated(
147
+ shrinkWrap: true,
148
+ physics: const NeverScrollableScrollPhysics(),
149
+ itemCount: groupedCalls.length,
150
+ separatorBuilder: (context, index) => Padding(
151
+ padding: const EdgeInsets.symmetric(vertical: 6),
152
+ child: Divider(
153
+ height: 1,
154
+ thickness: 1,
155
+ color: isDark
156
+ ? const Color(0xFF313244).withValues(alpha: 0.3)
157
+ : const Color(0xFFE0E5F5).withValues(alpha: 0.5),
158
+ ),
159
+ ),
160
+ itemBuilder: (context, index) => ToolRowWidget(
161
+ call: groupedCalls[index],
162
+ isDark: isDark,
163
+ formatArgs: _formatArgs,
164
+ ),
165
+ ),
166
+ ),
167
+ const SizedBox(height: 4),
168
+ ],
169
+ ),
170
+ );
171
+ }
172
+}
lib/features/chat/views/components/chat_bubble/user_bubble.dart
new
+117
@@ -0,0 +1,117 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:localmind/core/models/enums.dart';
3
+import 'package:localmind/core/theme/colors.dart';
4
+import 'package:localmind/features/chat/data/models/message.dart';
5
+import 'package:localmind/features/chat/views/components/message_action_bar.dart';
6
+import 'markdown/themed_gpt_markdown.dart';
7
+import 'attachment_list.dart';
8
+
9
+class UserBubble extends StatelessWidget {
10
+ const UserBubble({
11
+ super.key,
12
+ required this.message,
13
+ this.onCopy,
14
+ this.onDelete,
15
+ this.onEdit,
16
+ });
17
+
18
+ final Message message;
19
+ final VoidCallback? onCopy;
20
+ final VoidCallback? onDelete;
21
+ final VoidCallback? onEdit;
22
+
23
+ @override
24
+ Widget build(BuildContext context) {
25
+ final theme = Theme.of(context);
26
+ final isDark = theme.brightness == Brightness.dark;
27
+
28
+ final double maxBubbleWidth = 768;
29
+ final double availableWidth = MediaQuery.of(context).size.width * 0.75;
30
+ return Align(
31
+ alignment: AlignmentDirectional.centerEnd,
32
+ child: Column(
33
+ crossAxisAlignment: CrossAxisAlignment.end,
34
+ children: [
35
+ Container(
36
+ constraints: BoxConstraints(
37
+ maxWidth: maxBubbleWidth < availableWidth ? maxBubbleWidth : availableWidth,
38
+ ),
39
+ margin: const EdgeInsetsDirectional.only(
40
+ start: 48,
41
+ end: 8,
42
+ top: 4,
43
+ bottom: 2,
44
+ ),
45
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
46
+ decoration: BoxDecoration(
47
+ color: isDark ? AppColors.darkSurfaceCard : AppColors.lightSurface,
48
+ borderRadius: BorderRadiusDirectional.only(
49
+ topStart: Radius.circular(18),
50
+ topEnd: Radius.circular(18),
51
+ bottomStart: Radius.circular(18),
52
+ bottomEnd: const Radius.circular(4),
53
+ ),
54
+ ),
55
+ child: Column(
56
+ crossAxisAlignment: CrossAxisAlignment.end,
57
+ children: [
58
+ if (message.attachmentPaths != null &&
59
+ message.attachmentPaths!.isNotEmpty)
60
+ Padding(
61
+ padding: const EdgeInsets.only(bottom: 8),
62
+ child: AttachmentList(
63
+ paths: message.attachmentPaths!,
64
+ isUser: true,
65
+ ),
66
+ ),
67
+ SelectionArea(
68
+ child: ThemedGptMarkdown(
69
+ content: message.content,
70
+ isDark: isDark,
71
+ style: TextStyle(
72
+ color: isDark ? AppColors.darkPrimaryText : AppColors.lightPrimaryText,
73
+ fontSize: 16,
74
+ height: 1.6,
75
+ ),
76
+ ),
77
+ ),
78
+ ],
79
+ ),
80
+ ),
81
+ Padding(
82
+ padding: const EdgeInsetsDirectional.only(end: 12, bottom: 4),
83
+ child: Row(
84
+ mainAxisSize: MainAxisSize.min,
85
+ children: [
86
+ Text(
87
+ _formatTime(message.createdAt),
88
+ style: TextStyle(
89
+ fontSize: 11,
90
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
91
+ ),
92
+ ),
93
+ if (message.status == MessageStatus.error) ...[
94
+ const SizedBox(width: 4),
95
+ Icon(Icons.error_outline, size: 14, color: Colors.red[200]),
96
+ ],
97
+ const SizedBox(width: 8),
98
+ MessageActionBar(
99
+ content: message.content,
100
+ onCopy: onCopy,
101
+ onDelete: onDelete,
102
+ onEdit: onEdit,
103
+ ),
104
+ ],
105
+ ),
106
+ ),
107
+ ],
108
+ ),
109
+ );
110
+ }
111
+}
112
+
113
+String _formatTime(DateTime dateTime) {
114
+ final hour = dateTime.hour.toString().padLeft(2, '0');
115
+ final minute = dateTime.minute.toString().padLeft(2, '0');
116
+ return '$hour:$minute';
117
+}
lib/features/chat/views/components/message_list/corrupted_state.dart
new
+169
@@ -0,0 +1,169 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter/services.dart';
3
+import 'package:flutter_riverpod/flutter_riverpod.dart';
4
+import 'package:shadcn_ui/shadcn_ui.dart';
5
+import 'package:localmind/l10n/app_localizations.dart';
6
+import 'debug_row.dart';
7
+
8
+class CorruptedChatState extends ConsumerWidget {
9
+ const CorruptedChatState({
10
+ super.key,
11
+ required this.conversation,
12
+ this.errorMessage,
13
+ required this.onStartNewChat,
14
+ });
15
+
16
+ final dynamic conversation;
17
+ final String? errorMessage;
18
+ final VoidCallback onStartNewChat;
19
+
20
+ void _showDebugInfo(BuildContext context, WidgetRef ref) {
21
+ final debugL10n = AppLocalizations.of(context)!;
22
+ showShadDialog(
23
+ context: context,
24
+ builder: (context) => ShadDialog(
25
+ title: Text(debugL10n.technical_details),
26
+ description: Text(debugL10n.debug_dialog_desc),
27
+ actions: [
28
+ ShadButton.outline(
29
+ onPressed: () {
30
+ final data = '''
31
+ID: ${conversation.id}
32
+Title: ${conversation.title}
33
+Expected: ${conversation.messageCount}
34
+Error: $errorMessage
35
+''';
36
+ Clipboard.setData(ClipboardData(text: data));
37
+ ScaffoldMessenger.of(context).showSnackBar(
38
+ SnackBar(content: Text(debugL10n.copied_to_clipboard)),
39
+ );
40
+ },
41
+ child: Text(debugL10n.copy_info),
42
+ ),
43
+ ShadButton(
44
+ onPressed: () => Navigator.of(context).pop(),
45
+ child: Text(debugL10n.close),
46
+ ),
47
+ ],
48
+ child: Container(
49
+ width: 400,
50
+ padding: const EdgeInsets.symmetric(vertical: 20),
51
+ child: Column(
52
+ crossAxisAlignment: CrossAxisAlignment.start,
53
+ mainAxisSize: MainAxisSize.min,
54
+ children: [
55
+ DebugRow(
56
+ label: debugL10n.conversation_id,
57
+ value: conversation.id,
58
+ ),
59
+ DebugRow(
60
+ label: debugL10n.created_at,
61
+ value: conversation.createdAt.toIso8601String(),
62
+ ),
63
+ DebugRow(
64
+ label: debugL10n.expected_messages,
65
+ value: '${conversation.messageCount}',
66
+ ),
67
+ if (errorMessage != null)
68
+ Padding(
69
+ padding: const EdgeInsets.only(top: 12),
70
+ child: Column(
71
+ crossAxisAlignment: CrossAxisAlignment.start,
72
+ children: [
73
+ Text(
74
+ debugL10n.last_error,
75
+ style: const TextStyle(
76
+ fontSize: 12,
77
+ fontWeight: FontWeight.bold,
78
+ color: Colors.red,
79
+ ),
80
+ ),
81
+ const SizedBox(height: 4),
82
+ Container(
83
+ padding: const EdgeInsets.all(8),
84
+ decoration: BoxDecoration(
85
+ color: Colors.red.withValues(alpha: 0.1),
86
+ borderRadius: BorderRadius.circular(4),
87
+ ),
88
+ child: Text(
89
+ errorMessage!,
90
+ style: const TextStyle(
91
+ fontSize: 11,
92
+ fontFamily: 'monospace',
93
+ ),
94
+ ),
95
+ ),
96
+ ],
97
+ ),
98
+ ),
99
+ ],
100
+ ),
101
+ ),
102
+ ),
103
+ );
104
+ }
105
+
106
+ @override
107
+ Widget build(BuildContext context, WidgetRef ref) {
108
+ final l10n = AppLocalizations.of(context)!;
109
+ final theme = Theme.of(context);
110
+ final isDark = theme.brightness == Brightness.dark;
111
+
112
+ return Center(
113
+ child: Padding(
114
+ padding: const EdgeInsets.all(32.0),
115
+ child: Column(
116
+ mainAxisAlignment: MainAxisAlignment.center,
117
+ children: [
118
+ Container(
119
+ padding: const EdgeInsets.all(20),
120
+ decoration: BoxDecoration(
121
+ color: Colors.red.withValues(alpha: 0.1),
122
+ shape: BoxShape.circle,
123
+ ),
124
+ child: const Icon(
125
+ Icons.history_toggle_off_rounded,
126
+ size: 48,
127
+ color: Colors.red,
128
+ ),
129
+ ),
130
+ const SizedBox(height: 24),
131
+ Text(
132
+ l10n.history_missing_title,
133
+ style: TextStyle(
134
+ fontSize: 20,
135
+ fontWeight: FontWeight.bold,
136
+ color: isDark ? Colors.white : Colors.black,
137
+ ),
138
+ ),
139
+ const SizedBox(height: 12),
140
+ Text(
141
+ l10n.history_missing_desc,
142
+ textAlign: TextAlign.center,
143
+ style: TextStyle(
144
+ fontSize: 15,
145
+ color: isDark ? Colors.white60 : Colors.black54,
146
+ ),
147
+ ),
148
+ const SizedBox(height: 32),
149
+ Row(
150
+ mainAxisAlignment: MainAxisAlignment.center,
151
+ children: [
152
+ ShadButton(
153
+ onPressed: onStartNewChat,
154
+ leading: const Icon(Icons.add_rounded, size: 20),
155
+ child: Text(l10n.start_new_chat),
156
+ ),
157
+ const SizedBox(width: 12),
158
+ ShadButton.outline(
159
+ onPressed: () => _showDebugInfo(context, ref),
160
+ child: Text(l10n.technical_details),
161
+ ),
162
+ ],
163
+ ),
164
+ ],
165
+ ),
166
+ ),
167
+ );
168
+ }
169
+}
lib/features/chat/views/components/message_list/debug_row.dart
new
+28
@@ -0,0 +1,28 @@
1
+import 'package:flutter/material.dart';
2
+
3
+class DebugRow extends StatelessWidget {
4
+ const DebugRow({super.key, required this.label, required this.value});
5
+ final String label;
6
+ final String value;
7
+
8
+ @override
9
+ Widget build(BuildContext context) {
10
+ return Padding(
11
+ padding: const EdgeInsets.only(bottom: 8),
12
+ child: Column(
13
+ crossAxisAlignment: CrossAxisAlignment.start,
14
+ children: [
15
+ Text(
16
+ label,
17
+ style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
18
+ ),
19
+ const SizedBox(height: 2),
20
+ SelectableText(
21
+ value,
22
+ style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
23
+ ),
24
+ ],
25
+ ),
26
+ );
27
+ }
28
+}
lib/features/chat/views/components/message_list/empty_state.dart
new
+332
@@ -0,0 +1,332 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:localmind/core/theme/colors.dart';
3
+import 'package:localmind/l10n/app_localizations.dart';
4
+import 'recent_conversation_item.dart';
5
+
6
+class EmptyState extends StatefulWidget {
7
+ const EmptyState({
8
+ super.key,
9
+ required this.onQuickPrompt,
10
+ required this.quickPrompts,
11
+ required this.recentConversations,
12
+ required this.onSeeAll,
13
+ required this.selectedModel,
14
+ required this.onModelTap,
15
+ this.selectedPersona,
16
+ required this.onPersonaTap,
17
+ });
18
+
19
+ final void Function(String) onQuickPrompt;
20
+ final List<String> quickPrompts;
21
+ final List<dynamic> recentConversations;
22
+ final VoidCallback onSeeAll;
23
+ final dynamic selectedModel;
24
+ final VoidCallback onModelTap;
25
+ final dynamic selectedPersona;
26
+ final VoidCallback onPersonaTap;
27
+
28
+ @override
29
+ State<EmptyState> createState() => _EmptyStateState();
30
+}
31
+
32
+class _EmptyStateState extends State<EmptyState>
33
+ with SingleTickerProviderStateMixin {
34
+ late AnimationController _controller;
35
+ late List<Animation<double>> _fadeAnimations;
36
+ late List<Animation<Offset>> _slideAnimations;
37
+
38
+ @override
39
+ void initState() {
40
+ super.initState();
41
+ _controller = AnimationController(
42
+ duration: const Duration(milliseconds: 500),
43
+ vsync: this,
44
+ );
45
+
46
+ _fadeAnimations = List.generate(widget.quickPrompts.length, (index) {
47
+ return Tween<double>(begin: 0.0, end: 1.0).animate(
48
+ CurvedAnimation(
49
+ parent: _controller,
50
+ curve: Interval(
51
+ (index * 0.1).clamp(0.0, 0.7),
52
+ (0.3 + index * 0.1).clamp(0.2, 1.0),
53
+ curve: Curves.easeOut,
54
+ ),
55
+ ),
56
+ );
57
+ });
58
+
59
+ _slideAnimations = List.generate(widget.quickPrompts.length, (index) {
60
+ return Tween<Offset>(
61
+ begin: const Offset(0.0, 0.3),
62
+ end: Offset.zero,
63
+ ).animate(
64
+ CurvedAnimation(
65
+ parent: _controller,
66
+ curve: Interval(
67
+ (index * 0.1).clamp(0.0, 0.7),
68
+ (0.3 + index * 0.1).clamp(0.2, 1.0),
69
+ curve: Curves.easeOutCubic,
70
+ ),
71
+ ),
72
+ );
73
+ });
74
+
75
+ _controller.forward();
76
+ }
77
+
78
+ @override
79
+ void dispose() {
80
+ _controller.dispose();
81
+ super.dispose();
82
+ }
83
+
84
+ @override
85
+ Widget build(BuildContext context) {
86
+ final l10n = AppLocalizations.of(context)!;
87
+ final theme = Theme.of(context);
88
+ final isDark = theme.brightness == Brightness.dark;
89
+
90
+ return Center(
91
+ child: SingleChildScrollView(
92
+ padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
93
+ child: Column(
94
+ mainAxisAlignment: MainAxisAlignment.center,
95
+ children: [
96
+ const SizedBox(height: 24),
97
+ _buildModelAndPersonaSelectors(isDark, theme, l10n),
98
+ const SizedBox(height: 32),
99
+ _buildStartText(l10n),
100
+ const SizedBox(height: 16),
101
+ _buildQuickPrompts(isDark),
102
+ if (widget.recentConversations.isNotEmpty) ...[
103
+ const SizedBox(height: 32),
104
+ _buildRecentSection(isDark, l10n),
105
+ ],
106
+ ],
107
+ ),
108
+ ),
109
+ );
110
+ }
111
+
112
+ Widget _buildModelAndPersonaSelectors(bool isDark, ThemeData theme, AppLocalizations l10n) {
113
+ return TweenAnimationBuilder<double>(
114
+ tween: Tween(begin: 0.0, end: 1.0),
115
+ duration: const Duration(milliseconds: 300),
116
+ curve: Curves.easeOut,
117
+ builder: (context, value, child) {
118
+ return Opacity(
119
+ opacity: value,
120
+ child: Transform.translate(
121
+ offset: Offset(0, 20 * (1 - value)),
122
+ child: child,
123
+ ),
124
+ );
125
+ },
126
+ child: Column(
127
+ children: [
128
+ GestureDetector(
129
+ onTap: widget.onModelTap,
130
+ child: _buildSelectorCard(
131
+ isDark: isDark,
132
+ theme: theme,
133
+ child: Row(
134
+ mainAxisSize: MainAxisSize.min,
135
+ children: [
136
+ Icon(Icons.settings_suggest, size: 18, color: theme.colorScheme.primary),
137
+ const SizedBox(width: 8),
138
+ Flexible(
139
+ child: Text(
140
+ widget.selectedModel?.displayName ?? l10n.select_model,
141
+ style: TextStyle(
142
+ fontSize: 15,
143
+ fontWeight: FontWeight.w600,
144
+ color: isDark ? Colors.white : Colors.black,
145
+ ),
146
+ maxLines: 2,
147
+ overflow: TextOverflow.ellipsis,
148
+ ),
149
+ ),
150
+ const SizedBox(width: 4),
151
+ const Icon(Icons.expand_more, size: 16, color: Colors.grey),
152
+ ],
153
+ ),
154
+ ),
155
+ ),
156
+ const SizedBox(height: 12),
157
+ GestureDetector(
158
+ onTap: widget.onPersonaTap,
159
+ child: _buildSelectorCard(
160
+ isDark: isDark,
161
+ theme: theme,
162
+ child: Row(
163
+ mainAxisSize: MainAxisSize.min,
164
+ children: [
165
+ if (widget.selectedPersona != null) ...[
166
+ Text(widget.selectedPersona!.emoji, style: const TextStyle(fontSize: 18)),
167
+ const SizedBox(width: 8),
168
+ Flexible(
169
+ child: Text(
170
+ widget.selectedPersona!.name,
171
+ style: TextStyle(
172
+ fontSize: 15,
173
+ fontWeight: FontWeight.w600,
174
+ color: isDark ? Colors.white : Colors.black,
175
+ ),
176
+ maxLines: 2,
177
+ overflow: TextOverflow.ellipsis,
178
+ ),
179
+ ),
180
+ ] else ...[
181
+ Icon(Icons.smart_toy_outlined, size: 18, color: theme.colorScheme.primary),
182
+ const SizedBox(width: 8),
183
+ Flexible(
184
+ child: Text(
185
+ l10n.select_persona,
186
+ style: TextStyle(
187
+ fontSize: 15,
188
+ fontWeight: FontWeight.w600,
189
+ color: isDark ? Colors.white : Colors.black,
190
+ ),
191
+ maxLines: 2,
192
+ overflow: TextOverflow.ellipsis,
193
+ ),
194
+ ),
195
+ ],
196
+ const SizedBox(width: 4),
197
+ const Icon(Icons.expand_more, size: 16, color: Colors.grey),
198
+ ],
199
+ ),
200
+ ),
201
+ ),
202
+ ],
203
+ ),
204
+ );
205
+ }
206
+
207
+ Widget _buildSelectorCard({
208
+ required bool isDark,
209
+ required ThemeData theme,
210
+ required Widget child,
211
+ }) {
212
+ return Container(
213
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
214
+ decoration: BoxDecoration(
215
+ color: isDark ? AppColors.darkBackground : AppColors.lightSurface,
216
+ borderRadius: BorderRadius.circular(12),
217
+ border: Border.all(
218
+ color: isDark ? AppColors.darkBorder : AppColors.lightBorder,
219
+ ),
220
+ ),
221
+ child: child,
222
+ );
223
+ }
224
+
225
+ Widget _buildStartText(AppLocalizations l10n) {
226
+ return TweenAnimationBuilder<double>(
227
+ tween: Tween(begin: 0.0, end: 1.0),
228
+ duration: const Duration(milliseconds: 400),
229
+ curve: Curves.easeOut,
230
+ builder: (context, value, child) {
231
+ return Opacity(opacity: value, child: child);
232
+ },
233
+ child: Text(
234
+ l10n.start_conversation,
235
+ style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
236
+ ),
237
+ );
238
+ }
239
+
240
+ Widget _buildQuickPrompts(bool isDark) {
241
+ return Wrap(
242
+ spacing: 8,
243
+ runSpacing: 8,
244
+ alignment: WrapAlignment.center,
245
+ children: widget.quickPrompts.asMap().entries.map((entry) {
246
+ final index = entry.key;
247
+ final prompt = entry.value;
248
+ final fadeAnimation = index < _fadeAnimations.length
249
+ ? _fadeAnimations[index]
250
+ : const AlwaysStoppedAnimation(1.0);
251
+ final slideAnimation = index < _slideAnimations.length
252
+ ? _slideAnimations[index]
253
+ : const AlwaysStoppedAnimation(Offset.zero);
254
+
255
+ return AnimatedBuilder(
256
+ animation: _controller,
257
+ builder: (context, child) {
258
+ return FadeTransition(
259
+ opacity: fadeAnimation,
260
+ child: SlideTransition(
261
+ position: slideAnimation,
262
+ child: child,
263
+ ),
264
+ );
265
+ },
266
+ child: ActionChip(
267
+ label: Text(prompt),
268
+ onPressed: () => widget.onQuickPrompt(prompt),
269
+ backgroundColor: isDark ? AppColors.darkSurfaceCard : AppColors.lightSurface,
270
+ side: BorderSide(
271
+ color: isDark ? AppColors.darkBorder : AppColors.lightBorder,
272
+ ),
273
+ ),
274
+ );
275
+ }).toList(),
276
+ );
277
+ }
278
+
279
+ Widget _buildRecentSection(bool isDark, AppLocalizations l10n) {
280
+ return Column(
281
+ children: [
282
+ Row(
283
+ mainAxisAlignment: MainAxisAlignment.center,
284
+ children: [
285
+ Text(
286
+ l10n.recent_chats,
287
+ style: TextStyle(
288
+ fontSize: 14,
289
+ fontWeight: FontWeight.w500,
290
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
291
+ ),
292
+ ),
293
+ const SizedBox(width: 8),
294
+ GestureDetector(
295
+ onTap: widget.onSeeAll,
296
+ child: Text(
297
+ l10n.see_all,
298
+ style: TextStyle(
299
+ fontSize: 14,
300
+ fontWeight: FontWeight.w500,
301
+ color: isDark ? AppColors.darkAccent : AppColors.lightAccent,
302
+ ),
303
+ ),
304
+ ),
305
+ ],
306
+ ),
307
+ const SizedBox(height: 12),
308
+ ...List.generate(widget.recentConversations.take(5).length, (index) {
309
+ final conv = widget.recentConversations.take(5).toList()[index];
310
+ return TweenAnimationBuilder<double>(
311
+ tween: Tween(begin: 0.0, end: 1.0),
312
+ duration: Duration(milliseconds: 400 + (index * 50)),
313
+ curve: Curves.easeOutCubic,
314
+ builder: (context, value, child) {
315
+ return Opacity(
316
+ opacity: value,
317
+ child: Transform.translate(
318
+ offset: Offset(0, 10 * (1 - value)),
319
+ child: child,
320
+ ),
321
+ );
322
+ },
323
+ child: Padding(
324
+ padding: const EdgeInsets.only(bottom: 8),
325
+ child: RecentConversationItem(conversation: conv),
326
+ ),
327
+ );
328
+ }),
329
+ ],
330
+ );
331
+ }
332
+}
lib/features/chat/views/components/message_list/message_list.dart
new
+156
@@ -0,0 +1,156 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:localmind/core/models/enums.dart';
4
+import 'package:localmind/features/chat/data/models/message.dart';
5
+import 'package:localmind/features/chat/providers/chat_providers.dart';
6
+import '../chat_bubble.dart';
7
+
8
+class MessageList extends StatelessWidget {
9
+ const MessageList({
10
+ super.key,
11
+ required this.scrollController,
12
+ required this.messages,
13
+ required this.isStreaming,
14
+ required this.onRetry,
15
+ required this.onDelete,
16
+ required this.onEdit,
17
+ this.hasSmartReplies = false,
18
+ this.bottomInset = 0,
19
+ });
20
+
21
+ final ScrollController scrollController;
22
+ final List<Message> messages;
23
+ final bool isStreaming;
24
+ final void Function(String) onRetry;
25
+ final void Function(String) onDelete;
26
+ final void Function(String messageId, String currentContent) onEdit;
27
+ final bool hasSmartReplies;
28
+ final double bottomInset;
29
+
30
+ @override
31
+ Widget build(BuildContext context) {
32
+ return _MessageListConsumer(
33
+ scrollController: scrollController,
34
+ messages: messages,
35
+ isStreaming: isStreaming,
36
+ onRetry: onRetry,
37
+ onDelete: onDelete,
38
+ onEdit: onEdit,
39
+ hasSmartReplies: hasSmartReplies,
40
+ bottomInset: bottomInset,
41
+ );
42
+ }
43
+}
44
+
45
+class _MessageListConsumer extends ConsumerWidget {
46
+ const _MessageListConsumer({
47
+ required this.scrollController,
48
+ required this.messages,
49
+ required this.isStreaming,
50
+ required this.onRetry,
51
+ required this.onDelete,
52
+ required this.onEdit,
53
+ this.hasSmartReplies = false,
54
+ this.bottomInset = 0,
55
+ });
56
+
57
+ final ScrollController scrollController;
58
+ final List<Message> messages;
59
+ final bool isStreaming;
60
+ final void Function(String) onRetry;
61
+ final void Function(String) onDelete;
62
+ final void Function(String messageId, String currentContent) onEdit;
63
+ final bool hasSmartReplies;
64
+ final double bottomInset;
65
+
66
+ @override
67
+ Widget build(BuildContext context, WidgetRef ref) {
68
+ final streamingMessage = ref.watch(
69
+ chatProvider.select((s) => s.streamingMessage),
70
+ );
71
+
72
+ return _MessageList(
73
+ scrollController: scrollController,
74
+ messages: messages,
75
+ streamingMessage: streamingMessage,
76
+ isStreaming: isStreaming,
77
+ onRetry: onRetry,
78
+ onDelete: onDelete,
79
+ onEdit: onEdit,
80
+ hasSmartReplies: hasSmartReplies,
81
+ bottomInset: bottomInset,
82
+ );
83
+ }
84
+}
85
+
86
+class _MessageList extends StatelessWidget {
87
+ const _MessageList({
88
+ required this.scrollController,
89
+ required this.messages,
90
+ required this.streamingMessage,
91
+ required this.isStreaming,
92
+ required this.onRetry,
93
+ required this.onDelete,
94
+ required this.onEdit,
95
+ this.hasSmartReplies = false,
96
+ this.bottomInset = 0,
97
+ });
98
+
99
+ final ScrollController scrollController;
100
+ final List<Message> messages;
101
+ final Message? streamingMessage;
102
+ final bool isStreaming;
103
+ final void Function(String) onRetry;
104
+ final void Function(String) onDelete;
105
+ final void Function(String messageId, String currentContent) onEdit;
106
+ final bool hasSmartReplies;
107
+ final double bottomInset;
108
+
109
+ @override
110
+ Widget build(BuildContext context) {
111
+ final allMessages = <Message>[];
112
+
113
+ for (final message in messages) {
114
+ if (streamingMessage != null &&
115
+ message.id == streamingMessage!.id &&
116
+ isStreaming) {
117
+ continue;
118
+ }
119
+ allMessages.add(message);
120
+ }
121
+
122
+ return ListView.builder(
123
+ controller: scrollController,
124
+ cacheExtent: 1000,
125
+ padding: EdgeInsets.only(
126
+ top: 16,
127
+ bottom: 120 + (hasSmartReplies ? 64 : 0) + bottomInset,
128
+ ),
129
+ itemCount:
130
+ allMessages.length +
131
+ (streamingMessage != null && isStreaming ? 1 : 0),
132
+ itemBuilder: (context, index) {
133
+ if (streamingMessage != null &&
134
+ isStreaming &&
135
+ index == allMessages.length) {
136
+ return ChatBubble(message: streamingMessage!, isStreaming: true);
137
+ }
138
+
139
+ final message = allMessages[index];
140
+ final isLast = index == allMessages.length - 1;
141
+
142
+ return ChatBubble(
143
+ key: ValueKey(message.id),
144
+ message: message,
145
+ isStreaming:
146
+ isLast && isStreaming && message.id == streamingMessage?.id,
147
+ onRetry: () => onRetry(message.id),
148
+ onDelete: () => onDelete(message.id),
149
+ onEdit: message.role == MessageRole.user
150
+ ? () => onEdit(message.id, message.content)
151
+ : null,
152
+ );
153
+ },
154
+ );
155
+ }
156
+}
lib/features/chat/views/components/message_list/recent_conversation_item.dart
new
+77
@@ -0,0 +1,77 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:localmind/core/theme/colors.dart';
4
+import 'package:localmind/features/chat/providers/chat_providers.dart';
5
+
6
+class RecentConversationItem extends ConsumerWidget {
7
+ const RecentConversationItem({super.key, required this.conversation});
8
+
9
+ final dynamic conversation;
10
+
11
+ @override
12
+ Widget build(BuildContext context, WidgetRef ref) {
13
+ final theme = Theme.of(context);
14
+ final isDark = theme.brightness == Brightness.dark;
15
+
16
+ return GestureDetector(
17
+ onTap: () {
18
+ ref.read(chatProvider.notifier).loadConversation(conversation);
19
+ },
20
+ child: Container(
21
+ width: 300,
22
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
23
+ decoration: BoxDecoration(
24
+ color: isDark ? AppColors.darkBackground : AppColors.lightSurface,
25
+ borderRadius: BorderRadius.circular(8),
26
+ border: Border.all(
27
+ color: isDark ? AppColors.darkSurfaceCard : AppColors.lightBorder,
28
+ ),
29
+ ),
30
+ child: Row(
31
+ children: [
32
+ Icon(
33
+ Icons.chat_bubble_outline,
34
+ size: 18,
35
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
36
+ ),
37
+ const SizedBox(width: 12),
38
+ Expanded(
39
+ child: Column(
40
+ crossAxisAlignment: CrossAxisAlignment.start,
41
+ children: [
42
+ Text(
43
+ conversation.title,
44
+ style: TextStyle(
45
+ fontSize: 14,
46
+ fontWeight: FontWeight.w500,
47
+ color: isDark ? Colors.white : Colors.black,
48
+ ),
49
+ maxLines: 1,
50
+ overflow: TextOverflow.ellipsis,
51
+ ),
52
+ if (conversation.lastMessagePreview != null)
53
+ Text(
54
+ conversation.lastMessagePreview!,
55
+ style: TextStyle(
56
+ fontSize: 12,
57
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
58
+ ),
59
+ maxLines: 1,
60
+ overflow: TextOverflow.ellipsis,
61
+ ),
62
+ ],
63
+ ),
64
+ ),
65
+ Icon(
66
+ Directionality.of(context) == TextDirection.rtl
67
+ ? Icons.chevron_left
68
+ : Icons.chevron_right,
69
+ size: 18,
70
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
71
+ ),
72
+ ],
73
+ ),
74
+ ),
75
+ );
76
+ }
77
+}
lib/features/chat/views/components/top_bar/connection_banner.dart
new
+54
@@ -0,0 +1,54 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:go_router/go_router.dart';
3
+import 'package:localmind/core/models/enums.dart';
4
+import 'package:localmind/core/routes/app_routes.dart';
5
+import 'package:localmind/l10n/app_localizations.dart';
6
+
7
+class ConnectionBanner extends StatelessWidget {
8
+ const ConnectionBanner({super.key, required this.status});
9
+
10
+ final ConnectionStatus status;
11
+
12
+ @override
13
+ Widget build(BuildContext context) {
14
+ final l10n = AppLocalizations.of(context)!;
15
+ final isError = status == ConnectionStatus.error;
16
+
17
+ return Container(
18
+ width: double.infinity,
19
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
20
+ color: isError
21
+ ? Colors.red.withValues(alpha: 0.1)
22
+ : Colors.orange.withValues(alpha: 0.1),
23
+ child: Row(
24
+ children: [
25
+ Icon(
26
+ isError ? Icons.error_outline : Icons.wifi_off,
27
+ size: 16,
28
+ color: isError ? Colors.red : Colors.orange,
29
+ ),
30
+ const SizedBox(width: 8),
31
+ Expanded(
32
+ child: Text(
33
+ isError ? l10n.connection_error : l10n.disconnected,
34
+ style: TextStyle(
35
+ fontSize: 13,
36
+ color: isError ? Colors.red : Colors.orange[700],
37
+ ),
38
+ ),
39
+ ),
40
+ TextButton(
41
+ onPressed: () => context.push(AppRoutes.servers),
42
+ child: Text(
43
+ l10n.configure,
44
+ style: TextStyle(
45
+ fontSize: 13,
46
+ color: isError ? Colors.red : Colors.orange[700],
47
+ ),
48
+ ),
49
+ ),
50
+ ],
51
+ ),
52
+ );
53
+ }
54
+}
lib/features/chat/views/components/top_bar/model_top_bar.dart
new
+60
@@ -0,0 +1,60 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:localmind/l10n/app_localizations.dart';
3
+
4
+class ModelTopBar extends StatelessWidget {
5
+ const ModelTopBar({
6
+ super.key,
7
+ required this.selectedModel,
8
+ required this.onTap,
9
+ });
10
+
11
+ final dynamic selectedModel;
12
+ final VoidCallback onTap;
13
+
14
+ @override
15
+ Widget build(BuildContext context) {
16
+ final l10n = AppLocalizations.of(context)!;
17
+ final theme = Theme.of(context);
18
+ final isDark = theme.brightness == Brightness.dark;
19
+
20
+ return Container(
21
+ width: double.infinity,
22
+ color: theme.colorScheme.surface,
23
+ child: SafeArea(
24
+ bottom: false,
25
+ child: GestureDetector(
26
+ onTap: onTap,
27
+ behavior: HitTestBehavior.opaque,
28
+ child: Padding(
29
+ padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
30
+ child: Row(
31
+ mainAxisAlignment: MainAxisAlignment.center,
32
+ mainAxisSize: MainAxisSize.min,
33
+ children: [
34
+ Flexible(
35
+ child: Text(
36
+ selectedModel?.displayName ?? l10n.select_model,
37
+ style: TextStyle(
38
+ fontSize: 13,
39
+ fontWeight: FontWeight.w500,
40
+ color: isDark ? Colors.white70 : Colors.black87,
41
+ ),
42
+ maxLines: 2,
43
+ textAlign: TextAlign.center,
44
+ overflow: TextOverflow.ellipsis,
45
+ ),
46
+ ),
47
+ const SizedBox(width: 4),
48
+ Icon(
49
+ Icons.expand_more,
50
+ size: 14,
51
+ color: isDark ? Colors.white38 : Colors.black38,
52
+ ),
53
+ ],
54
+ ),
55
+ ),
56
+ ),
57
+ ),
58
+ );
59
+ }
60
+}
lib/features/chat/views/components/top_bar/persona_indicator.dart
new
+74
@@ -0,0 +1,74 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:localmind/core/theme/colors.dart';
3
+import 'package:localmind/l10n/app_localizations.dart';
4
+
5
+class PersonaIndicator extends StatelessWidget {
6
+ const PersonaIndicator({
7
+ super.key,
8
+ required this.persona,
9
+ required this.onTap,
10
+ required this.onRemove,
11
+ });
12
+
13
+ final dynamic persona;
14
+ final VoidCallback onTap;
15
+ final VoidCallback onRemove;
16
+
17
+ @override
18
+ Widget build(BuildContext context) {
19
+ final l10n = AppLocalizations.of(context)!;
20
+ final theme = Theme.of(context);
21
+ final isDark = theme.brightness == Brightness.dark;
22
+
23
+ return Container(
24
+ width: double.infinity,
25
+ color: isDark ? AppColors.darkBackground : AppColors.lightSurface,
26
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
27
+ child: Row(
28
+ children: [
29
+ GestureDetector(
30
+ onTap: onTap,
31
+ child: Container(
32
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
33
+ decoration: BoxDecoration(
34
+ color: theme.colorScheme.primary.withValues(alpha: 0.1),
35
+ borderRadius: BorderRadius.circular(16),
36
+ border: Border.all(
37
+ color: theme.colorScheme.primary.withValues(alpha: 0.2),
38
+ ),
39
+ ),
40
+ child: Row(
41
+ mainAxisSize: MainAxisSize.min,
42
+ children: [
43
+ Text(persona.emoji, style: const TextStyle(fontSize: 16)),
44
+ const SizedBox(width: 6),
45
+ Flexible(
46
+ child: Text(
47
+ persona.name,
48
+ style: TextStyle(
49
+ fontSize: 13,
50
+ fontWeight: FontWeight.w600,
51
+ color: theme.colorScheme.primary,
52
+ ),
53
+ maxLines: 2,
54
+ overflow: TextOverflow.ellipsis,
55
+ ),
56
+ ),
57
+ ],
58
+ ),
59
+ ),
60
+ ),
61
+ const Spacer(),
62
+ IconButton(
63
+ icon: const Icon(Icons.close, size: 16),
64
+ onPressed: onRemove,
65
+ padding: EdgeInsets.zero,
66
+ constraints: const BoxConstraints(),
67
+ tooltip: l10n.remove_persona,
68
+ color: isDark ? Colors.white38 : Colors.black38,
69
+ ),
70
+ ],
71
+ ),
72
+ );
73
+ }
74
+}
lib/features/chat/views/components/top_bar/smart_reply_chips.dart
new
+185
@@ -0,0 +1,185 @@
1
+import 'dart:ui';
2
+
3
+import 'package:flutter/material.dart';
4
+import 'package:flutter_riverpod/flutter_riverpod.dart';
5
+import 'package:localmind/features/chat/providers/chat_providers.dart';
6
+
7
+class SmartReplyChips extends ConsumerStatefulWidget {
8
+ const SmartReplyChips({super.key, required this.onSend});
9
+ final ValueChanged<String> onSend;
10
+
11
+ @override
12
+ ConsumerState<SmartReplyChips> createState() => _SmartReplyChipsState();
13
+}
14
+
15
+class _SmartReplyChipsState extends ConsumerState<SmartReplyChips>
16
+ with TickerProviderStateMixin {
17
+ List<String> _previousSuggestions = [];
18
+ late AnimationController _controller;
19
+ List<Animation<double>> _fadeAnimations = [];
20
+ List<Animation<Offset>> _slideAnimations = [];
21
+
22
+ @override
23
+ void initState() {
24
+ super.initState();
25
+ _controller = AnimationController(
26
+ duration: const Duration(milliseconds: 400),
27
+ vsync: this,
28
+ );
29
+ }
30
+
31
+ @override
32
+ void dispose() {
33
+ _controller.dispose();
34
+ super.dispose();
35
+ }
36
+
37
+ bool _listsEqual(List<String> a, List<String> b) {
38
+ if (a.length != b.length) return false;
39
+ for (int i = 0; i < a.length; i++) {
40
+ if (a[i] != b[i]) return false;
41
+ }
42
+ return true;
43
+ }
44
+
45
+ void _updateAnimations(List<String> suggestions) {
46
+ if (suggestions.isEmpty) {
47
+ _previousSuggestions = [];
48
+ return;
49
+ }
50
+ if (_listsEqual(suggestions, _previousSuggestions)) return;
51
+
52
+ _previousSuggestions = List.from(suggestions);
53
+
54
+ _fadeAnimations = List.generate(suggestions.length, (index) {
55
+ return Tween<double>(begin: 0.0, end: 1.0).animate(
56
+ CurvedAnimation(
57
+ parent: _controller,
58
+ curve: Interval(
59
+ (index * 0.1).clamp(0.0, 0.8),
60
+ (0.3 + index * 0.1).clamp(0.2, 1.0),
61
+ curve: Curves.easeOut,
62
+ ),
63
+ ),
64
+ );
65
+ });
66
+
67
+ _slideAnimations = List.generate(suggestions.length, (index) {
68
+ return Tween<Offset>(
69
+ begin: const Offset(0.0, 0.5),
70
+ end: Offset.zero,
71
+ ).animate(
72
+ CurvedAnimation(
73
+ parent: _controller,
74
+ curve: Interval(
75
+ (index * 0.1).clamp(0.0, 0.8),
76
+ (0.3 + index * 0.1).clamp(0.2, 1.0),
77
+ curve: Curves.easeOut,
78
+ ),
79
+ ),
80
+ );
81
+ });
82
+
83
+ _controller.forward(from: 0.0);
84
+ }
85
+
86
+ @override
87
+ Widget build(BuildContext context) {
88
+ final suggestionsAsync = ref.watch(smartRepliesProvider);
89
+ final suggestions = suggestionsAsync.asData?.value ?? [];
90
+
91
+ if (suggestions.isEmpty) {
92
+ _previousSuggestions = [];
93
+ return const SizedBox.shrink();
94
+ }
95
+
96
+ _updateAnimations(suggestions);
97
+
98
+ final theme = Theme.of(context);
99
+ final isDark = theme.brightness == Brightness.dark;
100
+
101
+ return AnimatedSize(
102
+ duration: const Duration(milliseconds: 200),
103
+ curve: Curves.easeOut,
104
+ child: SizedBox(
105
+ height: 40,
106
+ child: ListView.separated(
107
+ padding: const EdgeInsets.symmetric(horizontal: 20),
108
+ clipBehavior: Clip.none,
109
+ scrollDirection: Axis.horizontal,
110
+ itemCount: suggestions.length,
111
+ separatorBuilder: (_, _) => const SizedBox(width: 6),
112
+ itemBuilder: (context, index) {
113
+ final label = suggestions[index];
114
+ final fadeAnimation = index < _fadeAnimations.length
115
+ ? _fadeAnimations[index]
116
+ : const AlwaysStoppedAnimation(1.0);
117
+ final slideAnimation = index < _slideAnimations.length
118
+ ? _slideAnimations[index]
119
+ : const AlwaysStoppedAnimation(Offset.zero);
120
+
121
+ return AnimatedBuilder(
122
+ animation: _controller,
123
+ builder: (context, child) {
124
+ return FadeTransition(
125
+ opacity: fadeAnimation,
126
+ child: SlideTransition(
127
+ position: slideAnimation,
128
+ child: child,
129
+ ),
130
+ );
131
+ },
132
+ child: Center(
133
+ child: GestureDetector(
134
+ onTap: () => widget.onSend(label),
135
+ child: ClipRRect(
136
+ borderRadius: BorderRadius.circular(20),
137
+ child: BackdropFilter(
138
+ filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
139
+ child: Container(
140
+ padding: const EdgeInsets.symmetric(
141
+ horizontal: 12,
142
+ vertical: 6,
143
+ ),
144
+ decoration: BoxDecoration(
145
+ gradient: LinearGradient(
146
+ begin: Alignment.topLeft,
147
+ end: Alignment.bottomRight,
148
+ colors: isDark
149
+ ? [
150
+ Colors.white.withValues(alpha: 0.10),
151
+ Colors.white.withValues(alpha: 0.05),
152
+ ]
153
+ : [
154
+ Colors.white.withValues(alpha: 0.70),
155
+ Colors.white.withValues(alpha: 0.40),
156
+ ],
157
+ ),
158
+ borderRadius: BorderRadius.circular(20),
159
+ border: Border.all(
160
+ color: isDark
161
+ ? Colors.white.withValues(alpha: 0.18)
162
+ : Colors.white.withValues(alpha: 0.80),
163
+ width: 1.0,
164
+ ),
165
+ ),
166
+ child: Text(
167
+ label,
168
+ style: TextStyle(
169
+ fontSize: 13,
170
+ fontWeight: FontWeight.w500,
171
+ color: theme.colorScheme.onSurface,
172
+ ),
173
+ ),
174
+ ),
175
+ ),
176
+ ),
177
+ ),
178
+ ),
179
+ );
180
+ },
181
+ ),
182
+ ),
183
+ );
184
+ }
185
+}
lib/features/models/components/metadata_chip.dart
new
+29
@@ -0,0 +1,29 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:localmind/core/theme/colors.dart';
3
+
4
+class MetadataChip extends StatelessWidget {
5
+ const MetadataChip({super.key, required this.label, required this.isDark});
6
+ final String label;
7
+ final bool isDark;
8
+
9
+ @override
10
+ Widget build(BuildContext context) {
11
+ return Container(
12
+ padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
13
+ decoration: BoxDecoration(
14
+ color: isDark ? AppColors.darkSurfaceCard : AppColors.lightSurface,
15
+ borderRadius: BorderRadius.circular(4),
16
+ border: Border.all(
17
+ color: isDark ? AppColors.darkBorder : AppColors.lightBorder,
18
+ ),
19
+ ),
20
+ child: Text(
21
+ label,
22
+ style: TextStyle(
23
+ fontSize: 11,
24
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
25
+ ),
26
+ ),
27
+ );
28
+ }
29
+}
lib/features/models/components/model_list.dart
new
+195
@@ -0,0 +1,195 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:localmind/features/chat/providers/model_loading_providers.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/core/theme/colors.dart';
8
+import 'package:localmind/features/chat/providers/model_selection_providers.dart';
9
+import 'package:localmind/features/models/data/models/model_info.dart';
10
+import 'package:localmind/features/models/providers/model_picker_providers.dart';
11
+import 'package:localmind/features/servers/providers/server_providers.dart';
12
+import 'package:localmind/l10n/app_localizations.dart';
13
+import 'model_tile.dart';
14
+
15
+class ModelList extends ConsumerWidget {
16
+ const ModelList({
17
+ super.key,
18
+ required this.serverId,
19
+ required this.selectedModelId,
20
+ required this.isDark,
21
+ });
22
+
23
+ final String serverId;
24
+ final String? selectedModelId;
25
+ final bool isDark;
26
+
27
+ @override
28
+ Widget build(BuildContext context, WidgetRef ref) {
29
+ final l10n = AppLocalizations.of(context)!;
30
+ final searchQuery = ref.watch(modelSearchQueryProvider);
31
+ final modelsAsync = ref.watch(availableModelsProvider(serverId));
32
+
33
+ return modelsAsync.when(
34
+ loading: () => const Center(child: CircularProgressIndicator()),
35
+ error: (err, _) => Center(
36
+ child: Column(
37
+ mainAxisAlignment: MainAxisAlignment.center,
38
+ children: [
39
+ Icon(Icons.error_outline, size: 48, color: Colors.red[400]),
40
+ const SizedBox(height: 16),
41
+ Text(
42
+ l10n.failed_load_models,
43
+ style: TextStyle(
44
+ fontSize: 16,
45
+ fontWeight: FontWeight.w500,
46
+ color: isDark ? Colors.white : Colors.black,
47
+ ),
48
+ ),
49
+ const SizedBox(height: 4),
50
+ Text(
51
+ err.toString(),
52
+ style: TextStyle(
53
+ fontSize: 12,
54
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
55
+ ),
56
+ textAlign: TextAlign.center,
57
+ ),
58
+ const SizedBox(height: 16),
59
+ OutlinedButton.icon(
60
+ onPressed: () => ref.invalidate(availableModelsProvider(serverId)),
61
+ icon: const Icon(Icons.refresh),
62
+ label: Text(l10n.retry),
63
+ ),
64
+ ],
65
+ ),
66
+ ),
67
+ data: (models) {
68
+ final serversAsync = ref.watch(serversProvider);
69
+ final servers = serversAsync.value ?? [];
70
+ final activeServer = servers.where((s) => s.id == serverId).firstOrNull;
71
+ final loadedModelsAsync = activeServer != null
72
+ ? ref.watch(loadedModelsProvider(activeServer))
73
+ : const AsyncValue<Set<String>>.data(<String>{});
74
+
75
+ final loadedModels = loadedModelsAsync.maybeWhen(
76
+ data: (data) => data,
77
+ orElse: () => <String>{},
78
+ );
79
+
80
+ final modelList = models.cast<ModelInfo>();
81
+ final filtered = searchQuery.isEmpty
82
+ ? modelList
83
+ : modelList
84
+ .where(
85
+ (m) =>
86
+ m.displayName.toLowerCase().contains(
87
+ searchQuery.toLowerCase(),
88
+ ) ||
89
+ m.id.toLowerCase().contains(searchQuery.toLowerCase()),
90
+ )
91
+ .toList();
92
+
93
+ if (filtered.isEmpty) {
94
+ return Center(
95
+ child: Text(
96
+ searchQuery.isEmpty
97
+ ? l10n.no_models_available
98
+ : l10n.no_models_match(searchQuery),
99
+ style: TextStyle(
100
+ fontSize: 14,
101
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
102
+ ),
103
+ ),
104
+ );
105
+ }
106
+
107
+ return ListView.builder(
108
+ itemCount: filtered.length,
109
+ itemBuilder: (context, index) {
110
+ final model = filtered[index];
111
+ final isSelected = model.id == selectedModelId;
112
+ final isLoaded = loadedModels.contains(model.id);
113
+
114
+ return ModelTile(
115
+ model: model,
116
+ isSelected: isSelected,
117
+ isLoaded: isLoaded,
118
+ isDark: isDark,
119
+ onTap: () async {
120
+ final activeServer = ref.read(activeServerProvider);
121
+ if (activeServer == null) return;
122
+
123
+ final supportsLoad = model.serverType == ServerType.lmStudio;
124
+
125
+ if (supportsLoad && !isLoaded) {
126
+ ref.read(modelLoadingProvider.notifier).setLoading(model.id);
127
+
128
+ try {
129
+ final apiService = ref.read(serverApiServiceProvider);
130
+ await apiService.loadModelWithInstanceId(
131
+ activeServer,
132
+ model.id,
133
+ );
134
+
135
+ ref.invalidate(loadedModelsProvider(activeServer));
136
+ ref.read(modelLoadingProvider.notifier).setLoaded();
137
+ } catch (e) {
138
+ ref.read(modelLoadingProvider.notifier).setLoaded();
139
+ if (context.mounted) {
140
+ ScaffoldMessenger.of(context).showSnackBar(
141
+ SnackBar(
142
+ content: Text(l10n.model_load_failed(e.toString())),
143
+ backgroundColor: Colors.red,
144
+ ),
145
+ );
146
+ }
147
+ return;
148
+ }
149
+ }
150
+
151
+ ref.read(selectedModelProvider.notifier).setModel(model);
152
+ if (context.mounted) {
153
+ Navigator.pop(context);
154
+ }
155
+ },
156
+ onUnload: () async {
157
+ final activeServer = ref.read(activeServerProvider);
158
+ if (activeServer == null) return;
159
+
160
+ try {
161
+ final apiService = ref.read(serverApiServiceProvider);
162
+ await apiService.unloadModel(activeServer, model.id);
163
+ ref.invalidate(loadedModelsProvider(activeServer));
164
+
165
+ final selectedModel = ref.read(selectedModelProvider);
166
+ if (selectedModel?.id == model.id) {
167
+ ref.read(selectedModelProvider.notifier).clear();
168
+ }
169
+
170
+ if (context.mounted) {
171
+ final message = activeServer.type == ServerType.ollama
172
+ ? l10n.model_unloaded_ollama(model.name)
173
+ : l10n.model_unloaded_success(model.name);
174
+ ScaffoldMessenger.of(
175
+ context,
176
+ ).showSnackBar(SnackBar(content: Text(message)));
177
+ }
178
+ } catch (e) {
179
+ if (context.mounted) {
180
+ ScaffoldMessenger.of(context).showSnackBar(
181
+ SnackBar(
182
+ content: Text(l10n.model_unload_failed(e.toString())),
183
+ backgroundColor: Colors.red,
184
+ ),
185
+ );
186
+ }
187
+ }
188
+ },
189
+ );
190
+ },
191
+ );
192
+ },
193
+ );
194
+ }
195
+}
lib/features/models/components/model_search_field.dart
new
+38
@@ -0,0 +1,38 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:localmind/l10n/app_localizations.dart';
4
+import '../providers/model_picker_providers.dart';
5
+
6
+class ModelSearchField extends ConsumerWidget {
7
+ const ModelSearchField({super.key});
8
+
9
+ @override
10
+ Widget build(BuildContext context, WidgetRef ref) {
11
+ final l10n = AppLocalizations.of(context)!;
12
+ final searchQuery = ref.watch(modelSearchQueryProvider);
13
+
14
+ return TextField(
15
+ decoration: InputDecoration(
16
+ hintText: l10n.search_models_hint,
17
+ prefixIcon: const Icon(Icons.search, size: 20),
18
+ isDense: true,
19
+ contentPadding: const EdgeInsets.symmetric(
20
+ horizontal: 12,
21
+ vertical: 10,
22
+ ),
23
+ border: OutlineInputBorder(
24
+ borderRadius: BorderRadius.circular(8),
25
+ ),
26
+ suffixIcon: searchQuery.isNotEmpty
27
+ ? IconButton(
28
+ icon: const Icon(Icons.clear, size: 18),
29
+ onPressed: () =>
30
+ ref.read(modelSearchQueryProvider.notifier).clear(),
31
+ )
32
+ : null,
33
+ ),
34
+ onChanged: (q) =>
35
+ ref.read(modelSearchQueryProvider.notifier).setQuery(q),
36
+ );
37
+ }
38
+}
lib/features/models/components/model_tile.dart
new
+145
@@ -0,0 +1,145 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:localmind/core/models/enums.dart';
3
+import 'package:localmind/core/theme/colors.dart';
4
+import 'package:localmind/features/models/data/models/model_info.dart';
5
+import 'package:localmind/l10n/app_localizations.dart';
6
+import 'metadata_chip.dart';
7
+
8
+class ModelTile extends StatelessWidget {
9
+ const ModelTile({
10
+ super.key,
11
+ required this.model,
12
+ required this.isSelected,
13
+ required this.isLoaded,
14
+ required this.isDark,
15
+ required this.onTap,
16
+ this.onUnload,
17
+ });
18
+
19
+ final ModelInfo model;
20
+ final bool isSelected;
21
+ final bool isLoaded;
22
+ final bool isDark;
23
+ final VoidCallback onTap;
24
+ final Future<void> Function()? onUnload;
25
+
26
+ @override
27
+ Widget build(BuildContext context) {
28
+ final l10n = AppLocalizations.of(context)!;
29
+ final accent = isDark ? AppColors.darkAccent : AppColors.lightAccent;
30
+
31
+ return InkWell(
32
+ onTap: onTap,
33
+ borderRadius: BorderRadius.circular(8),
34
+ child: Container(
35
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
36
+ margin: const EdgeInsets.only(bottom: 4),
37
+ decoration: BoxDecoration(
38
+ color: isSelected
39
+ ? accent.withValues(alpha: 0.1)
40
+ : Colors.transparent,
41
+ borderRadius: BorderRadius.circular(8),
42
+ border: isSelected
43
+ ? Border.all(color: accent.withValues(alpha: 0.3))
44
+ : null,
45
+ ),
46
+ child: Row(
47
+ children: [
48
+ Expanded(
49
+ child: Column(
50
+ crossAxisAlignment: CrossAxisAlignment.start,
51
+ children: [
52
+ Row(
53
+ children: [
54
+ Expanded(
55
+ child: Text(
56
+ model.displayName,
57
+ style: TextStyle(
58
+ fontSize: 15,
59
+ fontWeight: isSelected
60
+ ? FontWeight.w600
61
+ : FontWeight.w500,
62
+ color: isDark ? Colors.white : Colors.black,
63
+ ),
64
+ maxLines: 1,
65
+ overflow: TextOverflow.ellipsis,
66
+ ),
67
+ ),
68
+ ],
69
+ ),
70
+ const SizedBox(height: 4),
71
+ Wrap(
72
+ spacing: 6,
73
+ runSpacing: 4,
74
+ children: [
75
+ if (model.parameterCountDisplay != null &&
76
+ model.parameterCountDisplay!.isNotEmpty)
77
+ MetadataChip(
78
+ label: model.parameterCountDisplay!,
79
+ isDark: isDark,
80
+ ),
81
+ if (model.quantization != null &&
82
+ model.quantization!.isNotEmpty)
83
+ MetadataChip(
84
+ label: model.quantization!,
85
+ isDark: isDark,
86
+ ),
87
+ if (model.formattedSize != null &&
88
+ model.formattedSize!.isNotEmpty)
89
+ MetadataChip(
90
+ label: model.formattedSize!,
91
+ isDark: isDark,
92
+ ),
93
+ if (model.contextLength != null)
94
+ MetadataChip(
95
+ label: l10n.context_chip(
96
+ model.contextLength.toString(),
97
+ ),
98
+ isDark: isDark,
99
+ ),
100
+ ],
101
+ ),
102
+ ],
103
+ ),
104
+ ),
105
+ if (model.serverType == ServerType.lmStudio ||
106
+ model.serverType == ServerType.ollama) ...[
107
+ if (isLoaded) ...[
108
+ Container(
109
+ width: 15,
110
+ height: 15,
111
+ decoration: BoxDecoration(
112
+ color: isDark ? AppColors.darkAccent : AppColors.lightAccent,
113
+ shape: BoxShape.circle,
114
+ ),
115
+ ),
116
+ ],
117
+ if (isLoaded) ...[
118
+ IconButton(
119
+ icon: Icon(
120
+ Icons.power_settings_new_outlined,
121
+ size: 18,
122
+ color: Colors.red[400],
123
+ ),
124
+ onPressed: onUnload,
125
+ padding: EdgeInsets.zero,
126
+ constraints: const BoxConstraints(),
127
+ tooltip: l10n.unload_from_server,
128
+ ),
129
+ const SizedBox(width: 8),
130
+ ],
131
+ ],
132
+ if (isSelected)
133
+ Icon(Icons.check_circle, color: accent, size: 22)
134
+ else
135
+ Icon(
136
+ Icons.chevron_right,
137
+ size: 20,
138
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
139
+ ),
140
+ ],
141
+ ),
142
+ ),
143
+ );
144
+ }
145
+}
lib/features/models/components/no_server_state.dart
new
+42
@@ -0,0 +1,42 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:localmind/core/theme/colors.dart';
3
+import 'package:localmind/l10n/app_localizations.dart';
4
+
5
+class NoServerState extends StatelessWidget {
6
+ const NoServerState({super.key, required this.isDark});
7
+ final bool isDark;
8
+
9
+ @override
10
+ Widget build(BuildContext context) {
11
+ final l10n = AppLocalizations.of(context)!;
12
+ return Center(
13
+ child: Column(
14
+ mainAxisAlignment: MainAxisAlignment.center,
15
+ children: [
16
+ Icon(
17
+ Icons.computer_outlined,
18
+ size: 48,
19
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
20
+ ),
21
+ const SizedBox(height: 16),
22
+ Text(
23
+ l10n.no_server_connected,
24
+ style: TextStyle(
25
+ fontSize: 16,
26
+ fontWeight: FontWeight.w500,
27
+ color: isDark ? Colors.white : Colors.black,
28
+ ),
29
+ ),
30
+ const SizedBox(height: 4),
31
+ Text(
32
+ l10n.add_server_first,
33
+ style: TextStyle(
34
+ fontSize: 13,
35
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
36
+ ),
37
+ ),
38
+ ],
39
+ ),
40
+ );
41
+ }
42
+}
lib/features/models/components/thinking_indicator.dart
new
+74
@@ -0,0 +1,74 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:localmind/core/theme/colors.dart';
3
+import 'package:localmind/l10n/app_localizations.dart';
4
+
5
+class ThinkingIndicator extends StatefulWidget {
6
+ const ThinkingIndicator({super.key, required this.isDark});
7
+ final bool isDark;
8
+
9
+ @override
10
+ State<ThinkingIndicator> createState() => _ThinkingIndicatorState();
11
+}
12
+
13
+class _ThinkingIndicatorState extends State<ThinkingIndicator>
14
+ with SingleTickerProviderStateMixin {
15
+ late AnimationController _controller;
16
+
17
+ @override
18
+ void initState() {
19
+ super.initState();
20
+ _controller = AnimationController(
21
+ duration: const Duration(milliseconds: 1500),
22
+ vsync: this,
23
+ )..repeat();
24
+ }
25
+
26
+ @override
27
+ void dispose() {
28
+ _controller.dispose();
29
+ super.dispose();
30
+ }
31
+
32
+ @override
33
+ Widget build(BuildContext context) {
34
+ final l10n = AppLocalizations.of(context)!;
35
+ return Row(
36
+ mainAxisSize: MainAxisSize.min,
37
+ children: [
38
+ AnimatedBuilder(
39
+ animation: _controller,
40
+ builder: (context, child) {
41
+ return Container(
42
+ width: 6,
43
+ height: 6,
44
+ decoration: BoxDecoration(
45
+ color: widget.isDark
46
+ ? Color.lerp(
47
+ AppColors.darkMutedText,
48
+ AppColors.darkAccent,
49
+ _controller.value,
50
+ )
51
+ : Color.lerp(
52
+ AppColors.lightMutedText,
53
+ AppColors.darkAccent,
54
+ _controller.value,
55
+ ),
56
+ shape: BoxShape.circle,
57
+ ),
58
+ );
59
+ },
60
+ ),
61
+ const SizedBox(width: 4),
62
+ Text(
63
+ l10n.thinking,
64
+ style: TextStyle(
65
+ fontSize: 12,
66
+ color: widget.isDark
67
+ ? AppColors.darkMutedText
68
+ : AppColors.lightMutedText,
69
+ ),
70
+ ),
71
+ ],
72
+ );
73
+ }
74
+}
lib/features/models/providers/model_picker_providers.dart
new
+13
@@ -0,0 +1,13 @@
1
+import 'package:flutter_riverpod/flutter_riverpod.dart';
2
+
3
+final modelSearchQueryProvider = NotifierProvider<ModelSearchNotifier, String>(
4
+ ModelSearchNotifier.new,
5
+);
6
+
7
+class ModelSearchNotifier extends Notifier<String> {
8
+ @override
9
+ String build() => '';
10
+
11
+ void setQuery(String q) => state = q;
12
+ void clear() => state = '';
13
+}
lib/features/models/screens/model_picker_sheet.dart
+105
-1152
@@ -1,35 +1,16 @@
1
-import 'dart:io';
2
-
1
import 'package:flutter/material.dart';
2
import 'package:flutter_riverpod/flutter_riverpod.dart';
5
-import 'package:go_router/go_router.dart';
6
-import 'package:localmind/l10n/app_localizations.dart';
3
+import 'package:localmind/core/models/enums.dart';
4
import 'package:localmind/core/theme/colors.dart';
8
-import '../../../core/routes/app_routes.dart';
9
-import '../../chat/providers/chat_providers.dart';
10
-import '../data/models/model_info.dart';
11
-import '../../servers/providers/server_providers.dart';
12
-import '../../../core/providers/app_providers.dart';
13
-import '../../../core/providers/service_providers.dart';
14
-import '../../../core/models/enums.dart';
15
-import '../../on_device/data/models/on_device_model.dart';
16
-import '../../on_device/data/models/download_status.dart';
17
-import '../../on_device/providers/on_device_providers.dart';
18
-import '../../on_device/providers/foreground_download_providers.dart';
19
-import '../../../core/providers/device_info_providers.dart';
20
-import '../../../core/device/device_memory_service.dart';
21
-
22
-final modelSearchQueryProvider = NotifierProvider<_ModelSearchNotifier, String>(
23
- _ModelSearchNotifier.new,
24
-);
25
-
26
-class _ModelSearchNotifier extends Notifier<String> {
27
- @override
28
- String build() => '';
29
-
30
- void setQuery(String q) => state = q;
31
- void clear() => state = '';
32
-}
5
+import 'package:localmind/features/chat/providers/chat_providers.dart';
6
+import 'package:localmind/features/on_device/components/on_device_picker_section.dart';
7
+import 'package:localmind/features/on_device/providers/on_device_providers.dart';
8
+import 'package:localmind/features/servers/providers/server_providers.dart';
9
+import 'package:localmind/l10n/app_localizations.dart';
10
+import '../components/model_list.dart';
11
+import '../components/model_search_field.dart';
12
+import '../components/no_server_state.dart';
13
+import '../components/thinking_indicator.dart';
14
15
class ModelPickerSheet extends ConsumerWidget {
16
const ModelPickerSheet({super.key});
@@ -41,7 +22,6 @@ class ModelPickerSheet extends ConsumerWidget {
22
final isDark = theme.brightness == Brightness.dark;
23
final activeServer = ref.watch(activeServerProvider);
24
final selectedModel = ref.watch(selectedModelProvider);
44
- final searchQuery = ref.watch(modelSearchQueryProvider);
25
final modelLoading = ref.watch(modelLoadingProvider);
26
final isThinking = ref.watch(modelThinkingProvider);
27
@@ -70,6 +50,13 @@ class ModelPickerSheet extends ConsumerWidget {
50
}
51
});
52
53
+ final serversAsync = ref.watch(serversProvider);
54
+ final servers = serversAsync.value ?? [];
55
+ final currentServer =
56
+ servers.where((s) => s.id == activeServer?.id).firstOrNull;
57
+ final isOnDevice =
58
+ currentServer != null && currentServer.type == ServerType.onDevice;
59
+
60
return Container(
61
height: MediaQuery.of(context).size.height * 0.7,
62
padding: const EdgeInsets.all(16),
@@ -91,68 +78,18 @@ class ModelPickerSheet extends ConsumerWidget {
78
),
79
),
80
const SizedBox(height: 16),
94
- Row(
95
- children: [
96
- Expanded(
97
- child: Column(
98
- crossAxisAlignment: CrossAxisAlignment.start,
99
- children: [
100
- Row(
101
- children: [
102
- Text(
103
- l10n.select_model_title,
104
- style: TextStyle(
105
- fontSize: 20,
106
- fontWeight: FontWeight.bold,
107
- color: isDark ? Colors.white : Colors.black,
108
- ),
109
- ),
110
- if (isThinking) ...[
111
- const SizedBox(width: 8),
112
- _ThinkingIndicator(isDark: isDark),
113
- ],
114
- ],
115
- ),
116
- if (modelLoading.isLoading)
117
- Padding(
118
- padding: const EdgeInsets.only(top: 8),
119
- child: Column(
120
- crossAxisAlignment: CrossAxisAlignment.start,
121
- children: [
122
- Text(
123
- l10n.loading_model(
124
- modelLoading.modelId ?? 'model',
125
- ),
126
- style: TextStyle(
127
- fontSize: 12,
128
- color: isDark
129
- ? AppColors.darkMutedText
130
- : AppColors.lightMutedText,
131
- ),
132
- ),
133
- const SizedBox(height: 4),
134
- LinearProgressIndicator(
135
- value: modelLoading.progress,
136
- backgroundColor: isDark
137
- ? AppColors.darkBorder
138
- : AppColors.lightBorder,
139
- ),
140
- ],
141
- ),
142
- ),
143
- ],
144
- ),
145
- ),
146
- if (activeServer != null)
147
- IconButton(
148
- icon: const Icon(Icons.refresh, size: 20),
149
- onPressed: () {
81
+ _ModelPickerHeader(
82
+ isDark: isDark,
83
+ isThinking: isThinking,
84
+ modelLoading: modelLoading,
85
+ activeServer: activeServer,
86
+ serverId: activeServer?.id,
87
+ onRefresh: activeServer != null
88
+ ? () {
89
ref.invalidate(availableModelsProvider(activeServer.id));
90
ref.invalidate(loadedModelsProvider(activeServer));
152
- },
153
- tooltip: l10n.refresh_models,
154
- ),
155
- ],
91
+ }
92
+ : null,
93
),
94
if (activeServer != null)
95
Padding(
@@ -161,87 +98,26 @@ class ModelPickerSheet extends ConsumerWidget {
98
activeServer.name,
99
style: TextStyle(
100
fontSize: 13,
164
- color: isDark
165
- ? AppColors.darkMutedText
166
- : AppColors.lightMutedText,
101
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
102
),
103
),
104
),
105
const SizedBox(height: 12),
171
- TextField(
172
- decoration: InputDecoration(
173
- hintText: l10n.search_models_hint,
174
- prefixIcon: const Icon(Icons.search, size: 20),
175
- isDense: true,
176
- contentPadding: const EdgeInsets.symmetric(
177
- horizontal: 12,
178
- vertical: 10,
179
- ),
180
- border: OutlineInputBorder(
181
- borderRadius: BorderRadius.circular(8),
182
- ),
183
- suffixIcon: searchQuery.isNotEmpty
184
- ? IconButton(
185
- icon: const Icon(Icons.clear, size: 18),
186
- onPressed: () =>
187
- ref.read(modelSearchQueryProvider.notifier).clear(),
188
- )
189
- : null,
190
- ),
191
- onChanged: (q) =>
192
- ref.read(modelSearchQueryProvider.notifier).setQuery(q),
193
- ),
106
+ const ModelSearchField(),
107
const SizedBox(height: 12),
108
Expanded(
109
child: activeServer == null
197
- ? _NoServerState(isDark: isDark)
198
- : _ModelList(
199
- serverId: activeServer.id,
200
- selectedModelId: selectedModel?.id,
201
- searchQuery: searchQuery,
202
- isDark: isDark,
203
- ),
204
- ),
205
- ],
206
- ),
207
- );
208
- }
209
-}
210
-
211
-class _NoServerState extends StatelessWidget {
212
- const _NoServerState({required this.isDark});
213
- final bool isDark;
214
-
215
- @override
216
- Widget build(BuildContext context) {
217
- final l10n = AppLocalizations.of(context)!;
218
- return Center(
219
- child: Column(
220
- mainAxisAlignment: MainAxisAlignment.center,
221
- children: [
222
- Icon(
223
- Icons.computer_outlined,
224
- size: 48,
225
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
226
- ),
227
- const SizedBox(height: 16),
228
- Text(
229
- l10n.no_server_connected,
230
- style: TextStyle(
231
- fontSize: 16,
232
- fontWeight: FontWeight.w500,
233
- color: isDark ? Colors.white : Colors.black,
234
- ),
235
- ),
236
- const SizedBox(height: 4),
237
- Text(
238
- l10n.add_server_first,
239
- style: TextStyle(
240
- fontSize: 13,
241
- color: isDark
242
- ? AppColors.darkMutedText
243
- : AppColors.lightMutedText,
244
- ),
110
+ ? NoServerState(isDark: isDark)
111
+ : isOnDevice
112
+ ? OnDevicePickerSection(
113
+ selectedModelId: selectedModel?.id,
114
+ isDark: isDark,
115
+ )
116
+ : ModelList(
117
+ serverId: activeServer.id,
118
+ selectedModelId: selectedModel?.id,
119
+ isDark: isDark,
120
+ ),
121
),
122
],
123
),
@@ -249,1027 +125,104 @@ class _NoServerState extends StatelessWidget {
125
}
126
}
127
252
-class _ModelList extends ConsumerWidget {
253
- const _ModelList({
254
- required this.serverId,
255
- required this.selectedModelId,
256
- required this.searchQuery,
128
+class _ModelPickerHeader extends StatelessWidget {
129
+ const _ModelPickerHeader({
130
required this.isDark,
131
+ required this.isThinking,
132
+ required this.modelLoading,
133
+ required this.activeServer,
134
+ this.serverId,
135
+ this.onRefresh,
136
});
137
260
- final String serverId;
261
- final String? selectedModelId;
262
- final String searchQuery;
138
final bool isDark;
264
-
265
- @override
266
- Widget build(BuildContext context, WidgetRef ref) {
267
- final l10n = AppLocalizations.of(context)!;
268
- final serversAsync = ref.watch(serversProvider);
269
- final servers = serversAsync.value ?? [];
270
- final currentServer = servers.where((s) => s.id == serverId).firstOrNull;
271
-
272
- if (currentServer != null && currentServer.type == ServerType.onDevice) {
273
- return _OnDeviceModelList(
274
- selectedModelId: selectedModelId,
275
- isDark: isDark,
276
- );
277
- }
278
-
279
- final modelsAsync = ref.watch(availableModelsProvider(serverId));
280
-
281
- return modelsAsync.when(
282
- loading: () => const Center(child: CircularProgressIndicator()),
283
- error: (err, _) => Center(
284
- child: Column(
285
- mainAxisAlignment: MainAxisAlignment.center,
286
- children: [
287
- Icon(Icons.error_outline, size: 48, color: Colors.red[400]),
288
- const SizedBox(height: 16),
289
- Text(
290
- l10n.failed_load_models,
291
- style: TextStyle(
292
- fontSize: 16,
293
- fontWeight: FontWeight.w500,
294
- color: isDark ? Colors.white : Colors.black,
295
- ),
296
- ),
297
- const SizedBox(height: 4),
298
- Text(
299
- err.toString(),
300
- style: TextStyle(
301
- fontSize: 12,
302
- color: isDark
303
- ? AppColors.darkMutedText
304
- : AppColors.lightMutedText,
305
- ),
306
- textAlign: TextAlign.center,
307
- ),
308
- const SizedBox(height: 16),
309
- OutlinedButton.icon(
310
- onPressed: () =>
311
- ref.invalidate(availableModelsProvider(serverId)),
312
- icon: const Icon(Icons.refresh),
313
- label: Text(l10n.retry),
314
- ),
315
- ],
316
- ),
317
- ),
318
- data: (models) {
319
- final serversAsync = ref.watch(serversProvider);
320
- final servers = serversAsync.value ?? [];
321
- final activeServer = servers.where((s) => s.id == serverId).firstOrNull;
322
- final loadedModelsAsync = activeServer != null
323
- ? ref.watch(loadedModelsProvider(activeServer))
324
- : const AsyncValue<Set<String>>.data(<String>{});
325
-
326
- final loadedModels = loadedModelsAsync.maybeWhen(
327
- data: (data) => data,
328
- orElse: () => <String>{},
329
- );
330
-
331
- final modelList = models.cast<ModelInfo>();
332
- final filtered = searchQuery.isEmpty
333
- ? modelList
334
- : modelList
335
- .where(
336
- (m) =>
337
- m.displayName.toLowerCase().contains(
338
- searchQuery.toLowerCase(),
339
- ) ||
340
- m.id.toLowerCase().contains(searchQuery.toLowerCase()),
341
- )
342
- .toList();
343
-
344
- if (filtered.isEmpty) {
345
- return Center(
346
- child: Text(
347
- searchQuery.isEmpty
348
- ? l10n.no_models_available
349
- : l10n.no_models_match(searchQuery),
350
- style: TextStyle(
351
- fontSize: 14,
352
- color: isDark
353
- ? AppColors.darkMutedText
354
- : AppColors.lightMutedText,
355
- ),
356
- ),
357
- );
358
- }
359
-
360
- return ListView.builder(
361
- itemCount: filtered.length,
362
- itemBuilder: (context, index) {
363
- final model = filtered[index];
364
- final isSelected = model.id == selectedModelId;
365
- final isLoaded = loadedModels.contains(model.id);
366
-
367
- return _ModelTile(
368
- model: model,
369
- isSelected: isSelected,
370
- isLoaded: isLoaded,
371
- isDark: isDark,
372
- onTap: () async {
373
- final activeServer = ref.read(activeServerProvider);
374
- if (activeServer == null) return;
375
-
376
- final supportsLoad = model.serverType == ServerType.lmStudio;
377
-
378
- if (supportsLoad && !isLoaded) {
379
- ref.read(modelLoadingProvider.notifier).setLoading(model.id);
380
-
381
- try {
382
- final apiService = ref.read(serverApiServiceProvider);
383
- await apiService.loadModelWithInstanceId(
384
- activeServer,
385
- model.id,
386
- );
387
-
388
- ref.invalidate(loadedModelsProvider(activeServer));
389
- ref.read(modelLoadingProvider.notifier).setLoaded();
390
- } catch (e) {
391
- ref.read(modelLoadingProvider.notifier).setLoaded();
392
- if (context.mounted) {
393
- ScaffoldMessenger.of(context).showSnackBar(
394
- SnackBar(
395
- content: Text(l10n.model_load_failed(e.toString())),
396
- backgroundColor: Colors.red,
397
- ),
398
- );
399
- }
400
- return;
401
- }
402
- }
403
-
404
- ref.read(selectedModelProvider.notifier).setModel(model);
405
- if (context.mounted) {
406
- Navigator.pop(context);
407
- }
408
- },
409
- onUnload: () async {
410
- final activeServer = ref.read(activeServerProvider);
411
- if (activeServer == null) return;
412
-
413
- try {
414
- final apiService = ref.read(serverApiServiceProvider);
415
- await apiService.unloadModel(activeServer, model.id);
416
- ref.invalidate(loadedModelsProvider(activeServer));
417
-
418
- // If the model being unloaded is the currently selected one, clear it
419
- final selectedModel = ref.read(selectedModelProvider);
420
- if (selectedModel?.id == model.id) {
421
- ref.read(selectedModelProvider.notifier).clear();
422
- }
423
-
424
- if (context.mounted) {
425
- final message = activeServer.type == ServerType.ollama
426
- ? l10n.model_unloaded_ollama(model.name)
427
- : l10n.model_unloaded_success(model.name);
428
- ScaffoldMessenger.of(
429
- context,
430
- ).showSnackBar(SnackBar(content: Text(message)));
431
- }
432
- } catch (e) {
433
- if (context.mounted) {
434
- ScaffoldMessenger.of(context).showSnackBar(
435
- SnackBar(
436
- content: Text(l10n.model_unload_failed(e.toString())),
437
- backgroundColor: Colors.red,
438
- ),
439
- );
440
- }
441
- }
442
- },
443
- );
444
- },
445
- );
446
- },
447
- );
448
- }
449
-}
450
-
451
-class _ModelTile extends StatelessWidget {
452
- const _ModelTile({
453
- required this.model,
454
- required this.isSelected,
455
- required this.isLoaded,
456
- required this.isDark,
457
- required this.onTap,
458
- this.onUnload,
459
- });
460
-
461
- final ModelInfo model;
462
- final bool isSelected;
463
- final bool isLoaded;
464
- final bool isDark;
465
- final VoidCallback onTap;
466
- final Future<void> Function()? onUnload;
139
+ final bool isThinking;
140
+ final dynamic modelLoading;
141
+ final dynamic activeServer;
142
+ final String? serverId;
143
+ final VoidCallback? onRefresh;
144
145
@override
146
Widget build(BuildContext context) {
147
final l10n = AppLocalizations.of(context)!;
471
- final accent = isDark ? AppColors.darkAccent : AppColors.lightAccent;
148
473
- return InkWell(
474
- onTap: onTap,
475
- borderRadius: BorderRadius.circular(8),
476
- child: Container(
477
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
478
- margin: const EdgeInsets.only(bottom: 4),
479
- decoration: BoxDecoration(
480
- color: isSelected
481
- ? accent.withValues(alpha: 0.1)
482
- : Colors.transparent,
483
- borderRadius: BorderRadius.circular(8),
484
- border: isSelected
485
- ? Border.all(color: accent.withValues(alpha: 0.3))
486
- : null,
487
- ),
488
- child: Row(
489
- children: [
490
- Expanded(
491
- child: Column(
492
- crossAxisAlignment: CrossAxisAlignment.start,
149
+ return Row(
150
+ children: [
151
+ Expanded(
152
+ child: Column(
153
+ crossAxisAlignment: CrossAxisAlignment.start,
154
+ children: [
155
+ Row(
156
children: [
494
- Row(
495
- children: [
496
- Expanded(
497
- child: Text(
498
- model.displayName,
499
- style: TextStyle(
500
- fontSize: 15,
501
- fontWeight: isSelected
502
- ? FontWeight.w600
503
- : FontWeight.w500,
504
- color: isDark ? Colors.white : Colors.black,
505
- ),
506
- maxLines: 1,
507
- overflow: TextOverflow.ellipsis,
508
- ),
509
- ),
510
- ],
511
- ),
512
- const SizedBox(height: 4),
513
- Wrap(
514
- spacing: 6,
515
- runSpacing: 4,
516
- children: [
517
- if (model.parameterCountDisplay != null &&
518
- model.parameterCountDisplay!.isNotEmpty)
519
- _MetadataChip(
520
- label: model.parameterCountDisplay!,
521
- isDark: isDark,
522
- ),
523
- if (model.quantization != null &&
524
- model.quantization!.isNotEmpty)
525
- _MetadataChip(
526
- label: model.quantization!,
527
- isDark: isDark,
528
- ),
529
- if (model.formattedSize != null &&
530
- model.formattedSize!.isNotEmpty)
531
- _MetadataChip(
532
- label: model.formattedSize!,
533
- isDark: isDark,
534
- ),
535
- if (model.contextLength != null)
536
- _MetadataChip(
537
- label: l10n.context_chip(
538
- model.contextLength.toString(),
539
- ),
540
- isDark: isDark,
541
- ),
542
- ],
157
+ Text(
158
+ l10n.select_model_title,
159
+ style: TextStyle(
160
+ fontSize: 20,
161
+ fontWeight: FontWeight.bold,
162
+ color: isDark ? Colors.white : Colors.black,
163
+ ),
164
),
165
+ if (isThinking) ...[
166
+ const SizedBox(width: 8),
167
+ ThinkingIndicator(isDark: isDark),
168
+ ],
169
],
170
),
546
- ),
547
- if (model.serverType == ServerType.lmStudio ||
548
- model.serverType == ServerType.ollama) ...[
549
- if (isLoaded) ...[
550
- Container(
551
- width: 15,
552
- height: 15,
553
- decoration: BoxDecoration(
554
- color: isDark
555
- ? AppColors.darkAccent
556
- : AppColors.lightAccent,
557
- shape: BoxShape.circle,
558
- ),
559
- ),
560
- ],
561
- if (isLoaded) ...[
562
- IconButton(
563
- icon: Icon(
564
- Icons.power_settings_new_outlined,
565
- size: 18,
566
- color: Colors.red[400],
171
+ if (modelLoading.isLoading)
172
+ Padding(
173
+ padding: const EdgeInsets.only(top: 8),
174
+ child: _ModelLoadingBar(
175
+ isDark: isDark,
176
+ modelId: modelLoading.modelId,
177
+ progress: modelLoading.progress,
178
),
568
- onPressed: onUnload,
569
- padding: EdgeInsets.zero,
570
- constraints: const BoxConstraints(),
571
- tooltip: l10n.unload_from_server,
179
),
573
- const SizedBox(width: 8),
574
- ],
180
],
576
- if (isSelected)
577
- Icon(Icons.check_circle, color: accent, size: 22)
578
- else
579
- Icon(
580
- Icons.chevron_right,
581
- size: 20,
582
- color: isDark
583
- ? AppColors.darkMutedText
584
- : AppColors.lightMutedText,
585
- ),
586
- ],
587
- ),
588
- ),
589
- );
590
- }
591
-}
592
-
593
-class _ThinkingIndicator extends StatefulWidget {
594
- const _ThinkingIndicator({required this.isDark});
595
- final bool isDark;
596
-
597
- @override
598
- State<_ThinkingIndicator> createState() => _ThinkingIndicatorState();
599
-}
600
-
601
-class _ThinkingIndicatorState extends State<_ThinkingIndicator>
602
- with SingleTickerProviderStateMixin {
603
- late AnimationController _controller;
604
-
605
- @override
606
- void initState() {
607
- super.initState();
608
- _controller = AnimationController(
609
- duration: const Duration(milliseconds: 1500),
610
- vsync: this,
611
- )..repeat();
612
- }
613
-
614
- @override
615
- void dispose() {
616
- _controller.dispose();
617
- super.dispose();
618
- }
619
-
620
- @override
621
- Widget build(BuildContext context) {
622
- final l10n = AppLocalizations.of(context)!;
623
- return Row(
624
- mainAxisSize: MainAxisSize.min,
625
- children: [
626
- AnimatedBuilder(
627
- animation: _controller,
628
- builder: (context, child) {
629
- return Container(
630
- width: 6,
631
- height: 6,
632
- decoration: BoxDecoration(
633
- color: widget.isDark
634
- ? Color.lerp(
635
- AppColors.darkMutedText,
636
- AppColors.darkAccent,
637
- _controller.value,
638
- )
639
- : Color.lerp(
640
- AppColors.lightMutedText,
641
- AppColors.darkAccent,
642
- _controller.value,
643
- ),
644
- shape: BoxShape.circle,
645
- ),
646
- );
647
- },
648
- ),
649
- const SizedBox(width: 4),
650
- Text(
651
- l10n.thinking,
652
- style: TextStyle(
653
- fontSize: 12,
654
- color: widget.isDark
655
- ? AppColors.darkMutedText
656
- : AppColors.lightMutedText,
181
),
182
),
183
+ if (onRefresh != null)
184
+ IconButton(
185
+ icon: const Icon(Icons.refresh, size: 20),
186
+ onPressed: onRefresh,
187
+ tooltip: l10n.refresh_models,
188
+ ),
189
],
190
);
191
}
192
}
193
664
-class _MetadataChip extends StatelessWidget {
665
- const _MetadataChip({required this.label, required this.isDark});
666
- final String label;
667
- final bool isDark;
668
-
669
- @override
670
- Widget build(BuildContext context) {
671
- return Container(
672
- padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
673
- decoration: BoxDecoration(
674
- color: isDark ? AppColors.darkSurfaceCard : AppColors.lightSurface,
675
- borderRadius: BorderRadius.circular(4),
676
- border: Border.all(
677
- color: isDark ? AppColors.darkBorder : AppColors.lightBorder,
678
- ),
679
- ),
680
- child: Text(
681
- label,
682
- style: TextStyle(
683
- fontSize: 11,
684
- color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
685
- ),
686
- ),
687
- );
688
- }
689
-}
690
-
691
-class _OnDeviceModelList extends ConsumerWidget {
692
- const _OnDeviceModelList({
693
- required this.selectedModelId,
194
+class _ModelLoadingBar extends StatelessWidget {
195
+ const _ModelLoadingBar({
196
required this.isDark,
197
+ required this.modelId,
198
+ this.progress,
199
});
200
697
- final String? selectedModelId;
201
final bool isDark;
202
+ final String? modelId;
203
+ final double? progress;
204
205
@override
701
- Widget build(BuildContext context, WidgetRef ref) {
702
- final l10n = AppLocalizations.of(context)!;
703
- final curatedModels = ref.watch(onDeviceModelsProvider);
704
- final downloadedAsync = ref.watch(downloadedModelsProvider);
705
- final engineState = ref.watch(onDeviceEngineProvider);
706
- final searchQuery = ref.watch(modelSearchQueryProvider);
707
- final deviceMemoryAsync = ref.watch(deviceMemoryProvider);
708
-
709
- return downloadedAsync.when(
710
- loading: () => const Center(child: CircularProgressIndicator()),
711
- error: (err, _) => Center(
712
- child: Column(
713
- mainAxisAlignment: MainAxisAlignment.center,
714
- children: [
715
- Icon(Icons.error_outline, size: 48, color: Colors.red[400]),
716
- const SizedBox(height: 16),
717
- Text(
718
- l10n.failed_load_models,
719
- style: TextStyle(
720
- fontSize: 16,
721
- fontWeight: FontWeight.w500,
722
- color: isDark ? Colors.white : Colors.black,
723
- ),
724
- ),
725
- const SizedBox(height: 16),
726
- OutlinedButton.icon(
727
- onPressed: () => ref.invalidate(downloadedModelsProvider),
728
- icon: const Icon(Icons.refresh),
729
- label: Text(l10n.retry),
730
- ),
731
- ],
732
- ),
733
- ),
734
- data: (downloadedIds) {
735
- final filtered = searchQuery.isEmpty
736
- ? curatedModels
737
- : curatedModels
738
- .where(
739
- (m) => m.name.toLowerCase().contains(
740
- searchQuery.toLowerCase(),
741
- ),
742
- )
743
- .toList();
744
-
745
- if (filtered.isEmpty) {
746
- return Center(
747
- child: Text(
748
- searchQuery.isEmpty
749
- ? l10n.no_models_available
750
- : l10n.no_models_match(searchQuery),
751
- style: TextStyle(
752
- fontSize: 14,
753
- color: isDark
754
- ? AppColors.darkMutedText
755
- : AppColors.lightMutedText,
756
- ),
757
- ),
758
- );
759
- }
760
-
761
- return ListView.builder(
762
- itemCount: filtered.length,
763
- itemBuilder: (context, index) {
764
- final model = filtered[index];
765
- final isDownloaded = downloadedIds.contains(model.id);
766
- final isLoaded = engineState.loadedModelId == model.id;
767
- final isCurrentlyLoading =
768
- engineState.status == OnDeviceEngineStatus.loading &&
769
- engineState.loadedModelId == model.id;
770
- final isSelected = selectedModelId == model.id;
771
-
772
- return _OnDeviceModelTile(
773
- model: model,
774
- isDownloaded: isDownloaded,
775
- isLoaded: isLoaded,
776
- isCurrentlyLoading: isCurrentlyLoading,
777
- isSelected: isSelected,
778
- isDark: isDark,
779
- deviceMemory: deviceMemoryAsync.value,
780
- );
781
- },
782
- );
783
- },
784
- );
785
- }
786
-}
787
-
788
-class _OnDeviceModelTile extends ConsumerWidget {
789
- const _OnDeviceModelTile({
790
- required this.model,
791
- required this.isDownloaded,
792
- required this.isLoaded,
793
- required this.isCurrentlyLoading,
794
- required this.isSelected,
795
- required this.isDark,
796
- this.deviceMemory,
797
- });
798
-
799
- final OnDeviceModel model;
800
- final bool isDownloaded;
801
- final bool isLoaded;
802
- final bool isCurrentlyLoading;
803
- final bool isSelected;
804
- final bool isDark;
805
- final DeviceMemoryInfo? deviceMemory;
806
-
807
- @override
808
- Widget build(BuildContext context, WidgetRef ref) {
206
+ Widget build(BuildContext context) {
207
final l10n = AppLocalizations.of(context)!;
810
- final accent = isDark ? AppColors.darkAccent : AppColors.lightAccent;
811
- final downloadProgress = ref.watch(
812
- foregroundDownloadNotifierProvider,
813
- )[model.id];
814
- final isDownloading =
815
- downloadProgress != null &&
816
- (downloadProgress.status == DownloadStatus.running ||
817
- downloadProgress.status == DownloadStatus.pending);
818
- final isPaused = downloadProgress?.status == DownloadStatus.paused;
819
-
820
- return InkWell(
821
- onTap: () {
822
- if (isLoaded) {
823
- _selectModel(context, ref);
824
- } else if (isDownloaded && !isCurrentlyLoading && !isDownloading) {
825
- _loadModel(context, ref);
826
- }
827
- },
828
- borderRadius: BorderRadius.circular(8),
829
- child: Container(
830
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
831
- margin: const EdgeInsets.only(bottom: 4),
832
- decoration: BoxDecoration(
833
- color: isSelected
834
- ? accent.withValues(alpha: 0.1)
835
- : Colors.transparent,
836
- borderRadius: BorderRadius.circular(8),
837
- border: isSelected
838
- ? Border.all(color: accent.withValues(alpha: 0.3))
839
- : null,
840
- ),
841
- child: Row(
842
- children: [
843
- Expanded(
844
- child: Column(
845
- crossAxisAlignment: CrossAxisAlignment.start,
846
- children: [
847
- Row(
848
- children: [
849
- Expanded(
850
- child: Text(
851
- model.name,
852
- style: TextStyle(
853
- fontSize: 15,
854
- fontWeight: isSelected
855
- ? FontWeight.w600
856
- : FontWeight.w500,
857
- color: isDownloaded
858
- ? (isDark ? Colors.white : Colors.black)
859
- : (isDark
860
- ? AppColors.darkMutedText
861
- : AppColors.lightMutedText),
862
- ),
863
- maxLines: 1,
864
- overflow: TextOverflow.ellipsis,
865
- ),
866
- ),
867
- if (model.isRecommended)
868
- Container(
869
- padding: const EdgeInsets.symmetric(
870
- horizontal: 6,
871
- vertical: 2,
872
- ),
873
- decoration: BoxDecoration(
874
- color: accent.withValues(alpha: 0.1),
875
- borderRadius: BorderRadius.circular(4),
876
- ),
877
- child: Text(
878
- l10n.recommended,
879
- style: TextStyle(
880
- fontSize: 9,
881
- fontWeight: FontWeight.bold,
882
- color: accent,
883
- ),
884
- ),
885
- ),
886
- if (model.isImported)
887
- Container(
888
- margin: const EdgeInsets.only(left: 6),
889
- padding: const EdgeInsets.symmetric(
890
- horizontal: 6,
891
- vertical: 2,
892
- ),
893
- decoration: BoxDecoration(
894
- color: Colors.blue.withValues(alpha: 0.1),
895
- borderRadius: BorderRadius.circular(4),
896
- ),
897
- child: const Text(
898
- 'Imported',
899
- style: TextStyle(
900
- fontSize: 9,
901
- fontWeight: FontWeight.bold,
902
- color: Colors.blue,
903
- ),
904
- ),
905
- ),
906
- ],
907
- ),
908
- if (!Platform.isIOS &&
909
- deviceMemory != null &&
910
- deviceMemory!.isOversized(model.minRamMb))
911
- Padding(
912
- padding: const EdgeInsets.only(top: 2),
913
- child: Row(
914
- children: [
915
- const Icon(
916
- Icons.warning_amber_rounded,
917
- color: Colors.orange,
918
- size: 12,
919
- ),
920
- const SizedBox(width: 4),
921
- Text(
922
- l10n.may_be_large,
923
- style: TextStyle(
924
- fontSize: 10,
925
- color: Colors.orange,
926
- fontWeight: FontWeight.bold,
927
- ),
928
- ),
929
- ],
930
- ),
931
- ),
932
- if (isDownloading || isPaused)
933
- Padding(
934
- padding: const EdgeInsets.only(top: 6),
935
- child: Column(
936
- crossAxisAlignment: CrossAxisAlignment.start,
937
- children: [
938
- LinearProgressIndicator(
939
- value: downloadProgress?.progress ?? 0.0,
940
- backgroundColor: isDark
941
- ? AppColors.darkBorder
942
- : AppColors.lightBorder,
943
- ),
944
- const SizedBox(height: 2),
945
- Text(
946
- isPaused
947
- ? l10n.paused_progress(
948
- ((downloadProgress?.progress ?? 0) * 100)
949
- .toStringAsFixed(0),
950
- )
951
- : '${l10n.downloading_status} ${((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(0)}%',
952
- style: TextStyle(
953
- fontSize: 10,
954
- color: isDark
955
- ? AppColors.darkMutedText
956
- : AppColors.lightMutedText,
957
- ),
958
- ),
959
- ],
960
- ),
961
- ),
962
- const SizedBox(height: 4),
963
- Wrap(
964
- spacing: 6,
965
- runSpacing: 4,
966
- children: [
967
- _MetadataChip(
968
- label: model.fileSizeFormatted,
969
- isDark: isDark,
970
- ),
971
- if (model.format == OnDeviceModelFormat.gguf)
972
- _MetadataChip(label: 'GGUF', isDark: isDark),
973
- if (model.runtime == OnDeviceModelRuntime.llamaCpp)
974
- _MetadataChip(label: 'llama.cpp', isDark: isDark),
975
- _MetadataChip(label: model.license, isDark: isDark),
976
- _MetadataChip(
977
- label: model.parameterLabel,
978
- isDark: isDark,
979
- ),
980
- ],
981
- ),
982
- ],
983
- ),
984
- ),
985
- const SizedBox(width: 8),
986
- if (isCurrentlyLoading) ...[
987
- const SizedBox(
988
- width: 16,
989
- height: 16,
990
- child: CircularProgressIndicator(strokeWidth: 2),
991
- ),
992
- ] else if (isLoaded) ...[
993
- Container(
994
- width: 10,
995
- height: 10,
996
- decoration: const BoxDecoration(
997
- color: AppColors.darkAccent,
998
- shape: BoxShape.circle,
999
- ),
1000
- ),
1001
- const SizedBox(width: 8),
1002
- _IconButton(
1003
- icon: Icon(
1004
- Icons.power_settings_new_outlined,
1005
- size: 18,
1006
- color: Colors.red[400],
1007
- ),
1008
- tooltip: l10n.unload,
1009
- onPressed: () => _unloadModel(context, ref),
1010
- ),
1011
- ] else if (isDownloaded) ...[
1012
- _IconButton(
1013
- icon: Icon(Icons.play_arrow, size: 20, color: accent),
1014
- tooltip: l10n.load,
1015
- onPressed: () => _loadModel(context, ref),
1016
- ),
1017
- const SizedBox(width: 4),
1018
- _IconButton(
1019
- icon: Icon(
1020
- Icons.delete_outline,
1021
- size: 18,
1022
- color: isDark
1023
- ? AppColors.darkMutedText
1024
- : AppColors.lightMutedText,
1025
- ),
1026
- tooltip: l10n.delete,
1027
- onPressed: () => _deleteModel(context, ref),
1028
- ),
1029
- ] else if (isDownloading) ...[
1030
- _IconButton(
1031
- icon: Icon(
1032
- Icons.close,
1033
- size: 16,
1034
- color: isDark
1035
- ? AppColors.darkMutedText
1036
- : AppColors.lightMutedText,
1037
- ),
1038
- tooltip: l10n.cancel,
1039
- onPressed: () => ref
1040
- .read(foregroundDownloadNotifierProvider.notifier)
1041
- .cancelDownload(model.id),
1042
- ),
1043
- ] else ...[
1044
- _IconButton(
1045
- icon: Icon(
1046
- Icons.cloud_download_outlined,
1047
- size: 18,
1048
- color: isDark
1049
- ? AppColors.darkMutedText
1050
- : AppColors.lightMutedText,
1051
- ),
1052
- tooltip: l10n.download,
1053
- onPressed: () async {
1054
- final result = await ref
1055
- .read(foregroundDownloadNotifierProvider.notifier)
1056
- .startDownload(model.id);
1057
- if (result == 'missing_huggingface_token' &&
1058
- context.mounted) {
1059
- ScaffoldMessenger.of(context).showSnackBar(
1060
- SnackBar(
1061
- content: Text(l10n.model_missing_huggingface_token),
1062
- duration: const Duration(seconds: 6),
1063
- action: SnackBarAction(
1064
- label: l10n.settings_title,
1065
- onPressed: () =>
1066
- context.push(AppRoutes.settings),
1067
- ),
1068
- ),
1069
- );
1070
- }
1071
- },
1072
- ),
1073
- const SizedBox(width: 4),
1074
- Text(
1075
- l10n.not_downloaded,
1076
- style: TextStyle(
1077
- fontSize: 11,
1078
- color: isDark
1079
- ? AppColors.darkMutedText
1080
- : AppColors.lightMutedText,
1081
- ),
1082
- ),
1083
- ],
1084
- if (isDownloaded && isSelected && isLoaded) ...[
1085
- const SizedBox(width: 4),
1086
- Icon(Icons.check_circle, color: accent, size: 22),
1087
- ],
1088
- ],
1089
- ),
1090
- ),
1091
- );
1092
- }
1093
-
1094
- void _loadModel(BuildContext context, WidgetRef ref) async {
1095
- final settings = ref.read(settingsProvider);
1096
- final engineNotifier = ref.read(onDeviceEngineProvider.notifier);
1097
-
1098
- if (!Platform.isIOS && deviceMemory != null) {
1099
- if (!deviceMemory!.hasEnoughRam(model.minRamMb)) {
1100
- final proceed = await _showRamWarning(context);
1101
- if (!context.mounted) return;
1102
- if (!proceed) return;
1103
- }
1104
- }
208
1106
- final modelInfo = ModelInfo(
1107
- id: model.id,
1108
- name: model.name,
1109
- description: model.description,
1110
- parameterCount: double.tryParse(
1111
- model.parameterLabel.replaceAll(RegExp(r'[^0-9\.]'), ''),
1112
- ),
1113
- fileSize: model.fileSizeBytes,
1114
- quantization: model.format == OnDeviceModelFormat.gguf ? 'GGUF' : null,
1115
- architecture: model.runtime == OnDeviceModelRuntime.llamaCpp
1116
- ? 'llama.cpp'
1117
- : null,
1118
- serverType: ServerType.onDevice,
1119
- serverId: 'on-device',
1120
- modifiedAt: model.importedAt,
1121
- onDeviceRuntime: model.runtime,
1122
- onDeviceFormat: model.format,
1123
- localPath: model.localPath,
1124
- );
1125
-
1126
- await engineNotifier.loadModel(model.id, settings.preferredBackend);
1127
- if (!context.mounted) return;
1128
-
1129
- final engineState = ref.read(onDeviceEngineProvider);
1130
- if (engineState.loadedModelId == model.id) {
1131
- ref.read(selectedModelProvider.notifier).setModel(modelInfo);
1132
- Navigator.pop(context);
1133
- }
1134
- }
1135
-
1136
- Future<bool> _showRamWarning(BuildContext context) async {
1137
- final l10n = AppLocalizations.of(context)!;
1138
- final result = await showDialog<bool>(
1139
- context: context,
1140
- builder: (ctx) => AlertDialog(
1141
- title: Row(
1142
- children: [
1143
- const Icon(Icons.warning_amber_rounded, color: Colors.orange),
1144
- const SizedBox(width: 8),
1145
- Text(l10n.ram_warning),
1146
- ],
1147
- ),
1148
- content: Text(
1149
- l10n.ram_warning_body_load(
1150
- deviceMemory!.availableMemoryFormatted,
1151
- '${model.minRamMb ~/ 1024 + 1}',
209
+ return Column(
210
+ crossAxisAlignment: CrossAxisAlignment.start,
211
+ children: [
212
+ Text(
213
+ l10n.loading_model(modelId ?? 'model'),
214
+ style: TextStyle(
215
+ fontSize: 12,
216
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
217
),
218
),
1154
- actions: [
1155
- TextButton(
1156
- onPressed: () => Navigator.pop(ctx, false),
1157
- child: Text(l10n.cancel),
1158
- ),
1159
- TextButton(
1160
- onPressed: () => Navigator.pop(ctx, true),
1161
- style: TextButton.styleFrom(foregroundColor: Colors.orange),
1162
- child: Text(l10n.proceed_anyway),
1163
- ),
1164
- ],
1165
- ),
1166
- );
1167
- return result ?? false;
1168
- }
1169
-
1170
- void _selectModel(BuildContext context, WidgetRef ref) {
1171
- final modelInfo = ModelInfo(
1172
- id: model.id,
1173
- name: model.name,
1174
- description: model.description,
1175
- parameterCount: double.tryParse(
1176
- model.parameterLabel.replaceAll(RegExp(r'[^0-9\.]'), ''),
1177
- ),
1178
- fileSize: model.fileSizeBytes,
1179
- quantization: model.format == OnDeviceModelFormat.gguf ? 'GGUF' : null,
1180
- architecture: model.runtime == OnDeviceModelRuntime.llamaCpp
1181
- ? 'llama.cpp'
1182
- : null,
1183
- serverType: ServerType.onDevice,
1184
- serverId: 'on-device',
1185
- modifiedAt: model.importedAt,
1186
- onDeviceRuntime: model.runtime,
1187
- onDeviceFormat: model.format,
1188
- localPath: model.localPath,
1189
- );
1190
-
1191
- ref.read(selectedModelProvider.notifier).setModel(modelInfo);
1192
- Navigator.pop(context);
1193
- }
1194
-
1195
- void _unloadModel(BuildContext context, WidgetRef ref) async {
1196
- final engineNotifier = ref.read(onDeviceEngineProvider.notifier);
1197
- await engineNotifier.unloadModel();
1198
- if (!context.mounted) return;
1199
-
1200
- final selectedModel = ref.read(selectedModelProvider);
1201
- if (selectedModel?.id == model.id) {
1202
- ref.read(selectedModelProvider.notifier).clear();
1203
- }
1204
- }
1205
-
1206
- void _deleteModel(BuildContext context, WidgetRef ref) async {
1207
- final l10n = AppLocalizations.of(context)!;
1208
- final confirm = await showDialog<bool>(
1209
- context: context,
1210
- builder: (ctx) => AlertDialog(
1211
- title: Text(l10n.delete_model_title),
1212
- content: Text(l10n.delete_model_body(model.name)),
1213
- actions: [
1214
- TextButton(
1215
- onPressed: () => Navigator.pop(ctx, false),
1216
- child: Text(l10n.cancel),
1217
- ),
1218
- TextButton(
1219
- onPressed: () => Navigator.pop(ctx, true),
1220
- child: Text(l10n.delete),
1221
- ),
1222
- ],
1223
- ),
1224
- );
1225
-
1226
- if (confirm == true) {
1227
- if (!context.mounted) return;
1228
-
1229
- final engineState = ref.read(onDeviceEngineProvider);
1230
- final engineNotifier = ref.read(onDeviceEngineProvider.notifier);
1231
- final selectedModelNotifier = ref.read(selectedModelProvider.notifier);
1232
- final gemmaService = ref.read(onDeviceGemmaServiceProvider);
1233
-
1234
- if (engineState.loadedModelId == model.id) {
1235
- await engineNotifier.unloadModel();
1236
- if (!context.mounted) return;
1237
- selectedModelNotifier.clear();
1238
- }
1239
-
1240
- if (model.isImported) {
1241
- await ref
1242
- .read(importedGgufModelsProvider.notifier)
1243
- .deleteModel(model.id);
1244
- } else {
1245
- await gemmaService.deleteModel(model.id);
1246
- }
1247
- if (!context.mounted) return;
1248
- ref.invalidate(downloadedModelsProvider);
1249
- }
1250
- }
1251
-}
1252
-
1253
-class _IconButton extends StatelessWidget {
1254
- const _IconButton({
1255
- required this.icon,
1256
- required this.tooltip,
1257
- required this.onPressed,
1258
- });
1259
-
1260
- final Widget icon;
1261
- final String tooltip;
1262
- final VoidCallback onPressed;
1263
-
1264
- @override
1265
- Widget build(BuildContext context) {
1266
- return Tooltip(
1267
- message: tooltip,
1268
- child: InkWell(
1269
- onTap: onPressed,
1270
- borderRadius: BorderRadius.circular(4),
1271
- child: Padding(padding: const EdgeInsets.all(4), child: icon),
1272
- ),
219
+ const SizedBox(height: 4),
220
+ LinearProgressIndicator(
221
+ value: progress,
222
+ backgroundColor:
223
+ isDark ? AppColors.darkBorder : AppColors.lightBorder,
224
+ ),
225
+ ],
226
);
227
}
228
}
lib/features/on_device/components/memory_stats_panel.dart
new
+311
@@ -0,0 +1,311 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:shadcn_ui/shadcn_ui.dart';
4
+import 'package:localmind/core/device/device_memory_service.dart';
5
+import 'package:localmind/core/providers/device_info_providers.dart';
6
+import 'package:localmind/l10n/app_localizations.dart';
7
+
8
+class MemoryStatsPanel extends ConsumerWidget {
9
+ const MemoryStatsPanel({super.key});
10
+
11
+ @override
12
+ Widget build(BuildContext context, WidgetRef ref) {
13
+ final l10n = AppLocalizations.of(context)!;
14
+ final memoryAsync = ref.watch(deviceMemoryProvider);
15
+
16
+ return memoryAsync.when(
17
+ data: (info) {
18
+ if (info.totalMemoryMb == 0) return const SizedBox.shrink();
19
+ return _MemoryPanelContent(info: info, l10n: l10n, ref: ref);
20
+ },
21
+ loading: () => const Center(
22
+ child: Padding(
23
+ padding: EdgeInsets.all(24.0),
24
+ child: CircularProgressIndicator(),
25
+ ),
26
+ ),
27
+ error: (_, _) => const SizedBox.shrink(),
28
+ );
29
+ }
30
+}
31
+
32
+class _MemoryPanelContent extends StatelessWidget {
33
+ const _MemoryPanelContent({
34
+ required this.info,
35
+ required this.l10n,
36
+ required this.ref,
37
+ });
38
+
39
+ final DeviceMemoryInfo info;
40
+ final AppLocalizations l10n;
41
+ final WidgetRef ref;
42
+
43
+ @override
44
+ Widget build(BuildContext context) {
45
+ final theme = Theme.of(context);
46
+ final usedMb = info.totalMemoryMb - info.availableMemoryMb;
47
+ final usagePercent = usedMb / info.totalMemoryMb;
48
+
49
+ Color statusColor = Colors.green;
50
+ String statusText = l10n.memory_healthy;
51
+ if (info.availableMemoryMb < 1024) {
52
+ statusColor = Colors.red;
53
+ statusText = l10n.memory_critical;
54
+ } else if (info.availableMemoryMb < 2048) {
55
+ statusColor = Colors.orange;
56
+ statusText = l10n.memory_low;
57
+ }
58
+
59
+ return Container(
60
+ margin: const EdgeInsets.only(bottom: 24),
61
+ decoration: BoxDecoration(
62
+ borderRadius: BorderRadius.circular(20),
63
+ gradient: LinearGradient(
64
+ begin: Alignment.topLeft,
65
+ end: Alignment.bottomRight,
66
+ colors: [
67
+ theme.colorScheme.surfaceContainerHighest,
68
+ theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
69
+ ],
70
+ ),
71
+ boxShadow: [
72
+ BoxShadow(
73
+ color: Colors.black.withValues(alpha: 0.05),
74
+ blurRadius: 10,
75
+ offset: const Offset(0, 4),
76
+ ),
77
+ ],
78
+ border: Border.all(
79
+ color: theme.colorScheme.outline.withValues(alpha: 0.1),
80
+ ),
81
+ ),
82
+ child: Column(
83
+ children: [
84
+ Padding(
85
+ padding: const EdgeInsetsDirectional.only(
86
+ start: 20,
87
+ top: 16,
88
+ end: 12,
89
+ bottom: 8,
90
+ ),
91
+ child: Row(
92
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
93
+ children: [
94
+ Row(
95
+ children: [
96
+ Icon(
97
+ Icons.memory_rounded,
98
+ size: 20,
99
+ color: theme.colorScheme.primary,
100
+ ),
101
+ const SizedBox(width: 8),
102
+ Text(
103
+ l10n.device_memory,
104
+ style: theme.textTheme.titleMedium?.copyWith(
105
+ fontWeight: FontWeight.bold,
106
+ letterSpacing: -0.5,
107
+ ),
108
+ ),
109
+ ],
110
+ ),
111
+ ShadButton.ghost(
112
+ size: ShadButtonSize.sm,
113
+ onPressed: () => ref.invalidate(deviceMemoryProvider),
114
+ child: const Icon(Icons.refresh_rounded, size: 18),
115
+ ),
116
+ ],
117
+ ),
118
+ ),
119
+ Padding(
120
+ padding: const EdgeInsets.symmetric(horizontal: 20),
121
+ child: Column(
122
+ children: [
123
+ Row(
124
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
125
+ children: [
126
+ Text(
127
+ l10n.ram_usage,
128
+ style: theme.textTheme.labelMedium?.copyWith(
129
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
130
+ ),
131
+ ),
132
+ Container(
133
+ padding: const EdgeInsets.symmetric(
134
+ horizontal: 8,
135
+ vertical: 2,
136
+ ),
137
+ decoration: BoxDecoration(
138
+ color: statusColor.withValues(alpha: 0.1),
139
+ borderRadius: BorderRadius.circular(12),
140
+ border: Border.all(
141
+ color: statusColor.withValues(alpha: 0.2),
142
+ ),
143
+ ),
144
+ child: Text(
145
+ statusText,
146
+ style: theme.textTheme.labelSmall?.copyWith(
147
+ color: statusColor,
148
+ fontWeight: FontWeight.bold,
149
+ ),
150
+ ),
151
+ ),
152
+ ],
153
+ ),
154
+ const SizedBox(height: 12),
155
+ ClipRRect(
156
+ borderRadius: BorderRadius.circular(10),
157
+ child: Stack(
158
+ children: [
159
+ Container(
160
+ height: 10,
161
+ width: double.infinity,
162
+ color: theme.colorScheme.surfaceContainerLowest,
163
+ ),
164
+ AnimatedContainer(
165
+ duration: const Duration(milliseconds: 500),
166
+ height: 10,
167
+ width: MediaQuery.of(context).size.width * usagePercent,
168
+ decoration: BoxDecoration(
169
+ gradient: LinearGradient(
170
+ colors: [
171
+ statusColor,
172
+ statusColor.withValues(alpha: 0.7),
173
+ ],
174
+ ),
175
+ borderRadius: BorderRadius.circular(10),
176
+ boxShadow: [
177
+ BoxShadow(
178
+ color: statusColor.withValues(alpha: 0.3),
179
+ blurRadius: 4,
180
+ offset: const Offset(0, 2),
181
+ ),
182
+ ],
183
+ ),
184
+ ),
185
+ ],
186
+ ),
187
+ ),
188
+ const SizedBox(height: 8),
189
+ Row(
190
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
191
+ children: [
192
+ Text(
193
+ l10n.ram_used('${(usagePercent * 100).toStringAsFixed(0)}'),
194
+ style: theme.textTheme.labelSmall?.copyWith(
195
+ fontWeight: FontWeight.w600,
196
+ ),
197
+ ),
198
+ Text(
199
+ '${_formatMb(usedMb)} / ${info.totalMemoryFormatted}',
200
+ style: theme.textTheme.labelSmall?.copyWith(
201
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
202
+ ),
203
+ ),
204
+ ],
205
+ ),
206
+ ],
207
+ ),
208
+ ),
209
+ const SizedBox(height: 16),
210
+ const Divider(height: 1),
211
+ Padding(
212
+ padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20),
213
+ child: Row(
214
+ children: [
215
+ Expanded(
216
+ child: _MemoryStat(
217
+ label: l10n.available_ram,
218
+ value: info.availableMemoryFormatted,
219
+ icon: Icons.speed_rounded,
220
+ color: statusColor,
221
+ isPrimary: true,
222
+ ),
223
+ ),
224
+ Container(
225
+ height: 30,
226
+ width: 1,
227
+ color: theme.colorScheme.outline.withValues(alpha: 0.1),
228
+ ),
229
+ Expanded(
230
+ child: _MemoryStat(
231
+ label: l10n.total_capacity,
232
+ value: info.totalMemoryFormatted,
233
+ icon: Icons.storage_rounded,
234
+ alignment: CrossAxisAlignment.end,
235
+ ),
236
+ ),
237
+ ],
238
+ ),
239
+ ),
240
+ ],
241
+ ),
242
+ );
243
+ }
244
+}
245
+
246
+class _MemoryStat extends StatelessWidget {
247
+ final String label;
248
+ final String value;
249
+ final IconData icon;
250
+ final Color? color;
251
+ final CrossAxisAlignment alignment;
252
+ final bool isPrimary;
253
+
254
+ const _MemoryStat({
255
+ required this.label,
256
+ required this.value,
257
+ required this.icon,
258
+ this.color,
259
+ this.alignment = CrossAxisAlignment.start,
260
+ this.isPrimary = false,
261
+ });
262
+
263
+ @override
264
+ Widget build(BuildContext context) {
265
+ final theme = Theme.of(context);
266
+ final iconColor = color ?? theme.colorScheme.onSurface.withValues(alpha: 0.4);
267
+
268
+ return Column(
269
+ crossAxisAlignment: alignment,
270
+ children: [
271
+ Row(
272
+ mainAxisSize: MainAxisSize.min,
273
+ children: [
274
+ if (alignment == CrossAxisAlignment.start) ...[
275
+ Icon(icon, size: 14, color: iconColor),
276
+ const SizedBox(width: 6),
277
+ ],
278
+ Text(
279
+ label,
280
+ style: theme.textTheme.labelSmall?.copyWith(
281
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
282
+ fontSize: 10,
283
+ ),
284
+ ),
285
+ if (alignment == CrossAxisAlignment.end) ...[
286
+ const SizedBox(width: 6),
287
+ Icon(icon, size: 14, color: iconColor),
288
+ ],
289
+ ],
290
+ ),
291
+ const SizedBox(height: 2),
292
+ Text(
293
+ value,
294
+ style: (isPrimary ? theme.textTheme.titleMedium : theme.textTheme.bodyMedium)
295
+ ?.copyWith(
296
+ fontWeight: FontWeight.bold,
297
+ color: color,
298
+ letterSpacing: isPrimary ? -0.5 : null,
299
+ ),
300
+ ),
301
+ ],
302
+ );
303
+ }
304
+}
305
+
306
+String _formatMb(int mb) {
307
+ if (mb >= 1024) {
308
+ return '${(mb / 1024).toStringAsFixed(1)} GB';
309
+ }
310
+ return '$mb MB';
311
+}
lib/features/on_device/components/model_card.dart
new
+720
@@ -0,0 +1,720 @@
1
+import 'dart:io';
2
+
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:shadcn_ui/shadcn_ui.dart';
7
+import 'package:localmind/core/device/device_memory_service.dart';
8
+import 'package:localmind/core/models/enums.dart';
9
+import 'package:localmind/core/providers/app_providers.dart';
10
+import 'package:localmind/core/routes/app_routes.dart';
11
+import 'package:localmind/features/on_device/data/models/on_device_model.dart';
12
+import 'package:localmind/features/on_device/data/models/download_progress_info.dart';
13
+import 'package:localmind/features/on_device/data/models/download_status.dart';
14
+import 'package:localmind/features/on_device/providers/on_device_providers.dart';
15
+import 'package:localmind/features/on_device/providers/foreground_download_providers.dart';
16
+import 'package:localmind/l10n/app_localizations.dart';
17
+
18
+class ModelCard extends ConsumerWidget {
19
+ const ModelCard({
20
+ super.key,
21
+ required this.model,
22
+ required this.downloadedAsync,
23
+ required this.downloadProgress,
24
+ required this.engineState,
25
+ this.deviceMemory,
26
+ });
27
+
28
+ final OnDeviceModel model;
29
+ final AsyncValue<Set<String>> downloadedAsync;
30
+ final DownloadProgressInfo? downloadProgress;
31
+ final OnDeviceEngineState engineState;
32
+ final DeviceMemoryInfo? deviceMemory;
33
+
34
+ @override
35
+ Widget build(BuildContext context, WidgetRef ref) {
36
+ final l10n = AppLocalizations.of(context)!;
37
+ final theme = Theme.of(context);
38
+ final isDownloaded = downloadedAsync.when(
39
+ data: (set) => set.contains(model.id),
40
+ loading: () => false,
41
+ error: (_, _) => false,
42
+ );
43
+
44
+ final isLoading =
45
+ engineState.status == OnDeviceEngineStatus.loading &&
46
+ engineState.loadedModelId == model.id;
47
+
48
+ final isDownloading =
49
+ downloadProgress != null &&
50
+ (downloadProgress!.status == DownloadStatus.running ||
51
+ downloadProgress!.status == DownloadStatus.pending);
52
+
53
+ final isPaused =
54
+ downloadProgress?.status == DownloadStatus.paused ||
55
+ downloadProgress?.status == DownloadStatus.canceled;
56
+
57
+ return Padding(
58
+ padding: const EdgeInsets.only(bottom: 12),
59
+ child: Container(
60
+ padding: const EdgeInsets.all(16),
61
+ decoration: BoxDecoration(
62
+ color: theme.colorScheme.surface,
63
+ borderRadius: BorderRadius.circular(12),
64
+ border: Border.all(
65
+ color: isDownloaded
66
+ ? Colors.green.withValues(alpha: 0.5)
67
+ : theme.colorScheme.outline.withValues(alpha: 0.3),
68
+ width: isDownloaded ? 1.5 : 1,
69
+ ),
70
+ ),
71
+ child: Column(
72
+ crossAxisAlignment: CrossAxisAlignment.start,
73
+ children: [
74
+ _ModelCardHeader(
75
+ model: model,
76
+ theme: theme,
77
+ l10n: l10n,
78
+ ),
79
+ if (!Platform.isIOS &&
80
+ deviceMemory != null &&
81
+ deviceMemory!.isOversized(model.minRamMb))
82
+ Padding(
83
+ padding: const EdgeInsets.only(top: 6),
84
+ child: _MemoryWarningBadge(l10n: l10n, theme: theme),
85
+ ),
86
+ const SizedBox(height: 4),
87
+ Text(
88
+ model.description,
89
+ style: theme.textTheme.bodySmall?.copyWith(
90
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
91
+ ),
92
+ ),
93
+ const SizedBox(height: 8),
94
+ _CapabilityChips(model: model, theme: theme),
95
+ const SizedBox(height: 8),
96
+ _ModelMetaRow(model: model, theme: theme, l10n: l10n),
97
+ const SizedBox(height: 8),
98
+ _ModelCardActions(
99
+ model: model,
100
+ isDownloaded: isDownloaded,
101
+ isLoading: isLoading,
102
+ isDownloading: isDownloading,
103
+ isPaused: isPaused,
104
+ downloadProgress: downloadProgress,
105
+ theme: theme,
106
+ l10n: l10n,
107
+ engineState: engineState,
108
+ deviceMemory: deviceMemory,
109
+ ),
110
+ ],
111
+ ),
112
+ ),
113
+ );
114
+ }
115
+}
116
+
117
+class _ModelCardHeader extends StatelessWidget {
118
+ const _ModelCardHeader({
119
+ required this.model,
120
+ required this.theme,
121
+ required this.l10n,
122
+ });
123
+
124
+ final OnDeviceModel model;
125
+ final ThemeData theme;
126
+ final AppLocalizations l10n;
127
+
128
+ @override
129
+ Widget build(BuildContext context) {
130
+ return Row(
131
+ children: [
132
+ Expanded(
133
+ child: Text(
134
+ model.name,
135
+ style: theme.textTheme.titleSmall?.copyWith(
136
+ fontWeight: FontWeight.bold,
137
+ ),
138
+ ),
139
+ ),
140
+ if (model.isRecommended)
141
+ Container(
142
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
143
+ decoration: BoxDecoration(
144
+ color: theme.colorScheme.primary.withValues(alpha: 0.1),
145
+ borderRadius: BorderRadius.circular(4),
146
+ ),
147
+ child: Text(
148
+ l10n.recommended,
149
+ style: theme.textTheme.labelSmall?.copyWith(
150
+ color: theme.colorScheme.primary,
151
+ fontWeight: FontWeight.bold,
152
+ ),
153
+ ),
154
+ ),
155
+ if (model.isImported)
156
+ Container(
157
+ margin: const EdgeInsets.only(left: 6),
158
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
159
+ decoration: BoxDecoration(
160
+ color: Colors.blue.withValues(alpha: 0.1),
161
+ borderRadius: BorderRadius.circular(4),
162
+ ),
163
+ child: Text(
164
+ 'Imported',
165
+ style: theme.textTheme.labelSmall?.copyWith(
166
+ color: Colors.blue,
167
+ fontWeight: FontWeight.bold,
168
+ ),
169
+ ),
170
+ ),
171
+ ],
172
+ );
173
+ }
174
+}
175
+
176
+class _MemoryWarningBadge extends StatelessWidget {
177
+ const _MemoryWarningBadge({required this.l10n, required this.theme});
178
+ final AppLocalizations l10n;
179
+ final ThemeData theme;
180
+
181
+ @override
182
+ Widget build(BuildContext context) {
183
+ return Container(
184
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
185
+ decoration: BoxDecoration(
186
+ color: Colors.orange.withValues(alpha: 0.1),
187
+ borderRadius: BorderRadius.circular(6),
188
+ border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
189
+ ),
190
+ child: Row(
191
+ mainAxisSize: MainAxisSize.min,
192
+ children: [
193
+ const Icon(Icons.warning_amber_rounded, color: Colors.orange, size: 14),
194
+ const SizedBox(width: 6),
195
+ Text(
196
+ l10n.may_be_large,
197
+ style: theme.textTheme.labelSmall?.copyWith(
198
+ color: Colors.orange,
199
+ fontWeight: FontWeight.bold,
200
+ ),
201
+ ),
202
+ ],
203
+ ),
204
+ );
205
+ }
206
+}
207
+
208
+class _CapabilityChips extends StatelessWidget {
209
+ const _CapabilityChips({required this.model, required this.theme});
210
+ final OnDeviceModel model;
211
+ final ThemeData theme;
212
+
213
+ @override
214
+ Widget build(BuildContext context) {
215
+ final l10n = AppLocalizations.of(context)!;
216
+ final chips = <String>[
217
+ if (model.format == OnDeviceModelFormat.gguf) 'GGUF',
218
+ if (model.runtime == OnDeviceModelRuntime.llamaCpp) 'llama.cpp',
219
+ if (model.supportsFunctionCalling) 'Tools',
220
+ if (model.supportsThinking) 'Thinking',
221
+ if (model.supportsVision) 'Vision',
222
+ model.languagesLabel,
223
+ if (model.backendNote != null) model.backendNote!,
224
+ if (model.requiresHuggingFaceToken) l10n.model_requires_huggingface_token,
225
+ ];
226
+
227
+ return Wrap(
228
+ spacing: 6,
229
+ runSpacing: 6,
230
+ children: chips.map((label) {
231
+ final isBackendNote = label == model.backendNote;
232
+ return Container(
233
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
234
+ decoration: BoxDecoration(
235
+ color: (isBackendNote ? Colors.orange : theme.colorScheme.primary)
236
+ .withValues(alpha: 0.1),
237
+ borderRadius: BorderRadius.circular(6),
238
+ border: Border.all(
239
+ color: (isBackendNote ? Colors.orange : theme.colorScheme.primary)
240
+ .withValues(alpha: 0.2),
241
+ ),
242
+ ),
243
+ child: Text(
244
+ label,
245
+ style: theme.textTheme.labelSmall?.copyWith(
246
+ color: isBackendNote ? Colors.orange : theme.colorScheme.primary,
247
+ fontWeight: FontWeight.w600,
248
+ ),
249
+ ),
250
+ );
251
+ }).toList(),
252
+ );
253
+ }
254
+}
255
+
256
+class _ModelMetaRow extends StatelessWidget {
257
+ const _ModelMetaRow({required this.model, required this.theme, required this.l10n});
258
+ final OnDeviceModel model;
259
+ final ThemeData theme;
260
+ final AppLocalizations l10n;
261
+
262
+ @override
263
+ Widget build(BuildContext context) {
264
+ return Row(
265
+ children: [
266
+ Text(model.fileSizeFormatted, style: theme.textTheme.labelMedium),
267
+ const SizedBox(width: 12),
268
+ Text(model.license, style: theme.textTheme.labelMedium),
269
+ const SizedBox(width: 12),
270
+ Text(
271
+ l10n.ram_min_required('${model.minRamMb ~/ 1024 + 1}'),
272
+ style: theme.textTheme.labelMedium?.copyWith(
273
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
274
+ ),
275
+ ),
276
+ ],
277
+ );
278
+ }
279
+}
280
+
281
+class _ModelCardActions extends ConsumerWidget {
282
+ const _ModelCardActions({
283
+ required this.model,
284
+ required this.isDownloaded,
285
+ required this.isLoading,
286
+ required this.isDownloading,
287
+ required this.isPaused,
288
+ this.downloadProgress,
289
+ required this.theme,
290
+ required this.l10n,
291
+ required this.engineState,
292
+ this.deviceMemory,
293
+ });
294
+
295
+ final OnDeviceModel model;
296
+ final bool isDownloaded;
297
+ final bool isLoading;
298
+ final bool isDownloading;
299
+ final bool isPaused;
300
+ final DownloadProgressInfo? downloadProgress;
301
+ final ThemeData theme;
302
+ final AppLocalizations l10n;
303
+ final OnDeviceEngineState engineState;
304
+ final DeviceMemoryInfo? deviceMemory;
305
+
306
+ @override
307
+ Widget build(BuildContext context, WidgetRef ref) {
308
+ if (isDownloaded) {
309
+ return _DownloadedActions(
310
+ model: model,
311
+ isLoading: isLoading,
312
+ engineState: engineState,
313
+ l10n: l10n,
314
+ theme: theme,
315
+ );
316
+ }
317
+
318
+ if (isDownloading) {
319
+ return _DownloadingActions(
320
+ model: model,
321
+ downloadProgress: downloadProgress,
322
+ theme: theme,
323
+ l10n: l10n,
324
+ );
325
+ }
326
+
327
+ if (downloadProgress?.status == DownloadStatus.failed) {
328
+ return _FailedDownloadActions(
329
+ model: model,
330
+ downloadProgress: downloadProgress,
331
+ theme: theme,
332
+ l10n: l10n,
333
+ );
334
+ }
335
+
336
+ if (isPaused) {
337
+ return _PausedDownloadActions(
338
+ model: model,
339
+ downloadProgress: downloadProgress,
340
+ theme: theme,
341
+ l10n: l10n,
342
+ );
343
+ }
344
+
345
+ return _NotDownloadedActions(
346
+ model: model,
347
+ deviceMemory: deviceMemory,
348
+ theme: theme,
349
+ l10n: l10n,
350
+ );
351
+ }
352
+}
353
+
354
+class _DownloadedActions extends ConsumerWidget {
355
+ const _DownloadedActions({
356
+ required this.model,
357
+ required this.isLoading,
358
+ required this.engineState,
359
+ required this.l10n,
360
+ required this.theme,
361
+ });
362
+
363
+ final OnDeviceModel model;
364
+ final bool isLoading;
365
+ final OnDeviceEngineState engineState;
366
+ final AppLocalizations l10n;
367
+ final ThemeData theme;
368
+
369
+ @override
370
+ Widget build(BuildContext context, WidgetRef ref) {
371
+ final isLoaded = engineState.loadedModelId == model.id;
372
+
373
+ return Row(
374
+ children: [
375
+ Icon(
376
+ isLoaded ? Icons.check_circle : Icons.check_circle_outline,
377
+ color: isLoaded ? Colors.green : Colors.grey,
378
+ size: 18,
379
+ ),
380
+ const SizedBox(width: 4),
381
+ Text(
382
+ isLoaded ? l10n.loaded_status : l10n.downloaded,
383
+ style: theme.textTheme.bodySmall?.copyWith(
384
+ color: isLoaded ? Colors.green : Colors.grey,
385
+ fontWeight: FontWeight.w600,
386
+ ),
387
+ ),
388
+ const Spacer(),
389
+ if (isLoading)
390
+ const SizedBox(
391
+ width: 20,
392
+ height: 20,
393
+ child: CircularProgressIndicator(strokeWidth: 2),
394
+ )
395
+ else if (isLoaded)
396
+ ShadButton.outline(
397
+ size: ShadButtonSize.sm,
398
+ onPressed: () => ref.read(onDeviceEngineProvider.notifier).unloadModel(),
399
+ child: Text(l10n.unload),
400
+ )
401
+ else
402
+ ShadButton.outline(
403
+ size: ShadButtonSize.sm,
404
+ onPressed: () => _loadModel(context, ref),
405
+ child: Text(l10n.load),
406
+ ),
407
+ const SizedBox(width: 8),
408
+ ShadButton.outline(
409
+ size: ShadButtonSize.sm,
410
+ onPressed: () => _deleteModel(context, ref),
411
+ child: Text(l10n.delete),
412
+ ),
413
+ ],
414
+ );
415
+ }
416
+
417
+ Future<void> _loadModel(BuildContext context, WidgetRef ref) async {
418
+ final settings = ref.read(settingsProvider);
419
+ final engineNotifier = ref.read(onDeviceEngineProvider.notifier);
420
+ await engineNotifier.loadModel(model.id, settings.preferredBackend);
421
+ }
422
+
423
+ Future<void> _deleteModel(BuildContext context, WidgetRef ref) async {
424
+ final confirm = await showDialog<bool>(
425
+ context: context,
426
+ builder: (ctx) => AlertDialog(
427
+ title: Text(l10n.delete_model_title),
428
+ content: Text(l10n.delete_model_body(model.name)),
429
+ actions: [
430
+ TextButton(
431
+ onPressed: () => Navigator.pop(ctx, false),
432
+ child: Text(l10n.cancel),
433
+ ),
434
+ TextButton(
435
+ onPressed: () => Navigator.pop(ctx, true),
436
+ child: Text(l10n.delete),
437
+ ),
438
+ ],
439
+ ),
440
+ );
441
+
442
+ if (confirm == true) {
443
+ final engineState = ref.read(onDeviceEngineProvider);
444
+ if (engineState.loadedModelId == model.id) {
445
+ await ref.read(onDeviceEngineProvider.notifier).unloadModel();
446
+ }
447
+
448
+ if (model.isImported) {
449
+ await ref
450
+ .read(importedGgufModelsProvider.notifier)
451
+ .deleteModel(model.id);
452
+ } else {
453
+ final gemmaService = ref.read(onDeviceGemmaServiceProvider);
454
+ await gemmaService.deleteModel(model.id);
455
+ }
456
+ ref.invalidate(downloadedModelsProvider);
457
+ }
458
+ }
459
+}
460
+
461
+class _DownloadingActions extends ConsumerWidget {
462
+ const _DownloadingActions({
463
+ required this.model,
464
+ this.downloadProgress,
465
+ required this.theme,
466
+ required this.l10n,
467
+ });
468
+
469
+ final OnDeviceModel model;
470
+ final DownloadProgressInfo? downloadProgress;
471
+ final ThemeData theme;
472
+ final AppLocalizations l10n;
473
+
474
+ @override
475
+ Widget build(BuildContext context, WidgetRef ref) {
476
+ return Column(
477
+ crossAxisAlignment: CrossAxisAlignment.start,
478
+ children: [
479
+ Row(
480
+ children: [
481
+ Expanded(
482
+ child: Column(
483
+ crossAxisAlignment: CrossAxisAlignment.start,
484
+ children: [
485
+ ClipRRect(
486
+ borderRadius: BorderRadius.circular(4),
487
+ child: LinearProgressIndicator(
488
+ value: downloadProgress?.progress ?? 0.0,
489
+ minHeight: 6,
490
+ backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.1),
491
+ valueColor: AlwaysStoppedAnimation<Color>(
492
+ theme.colorScheme.primary,
493
+ ),
494
+ ),
495
+ ),
496
+ const SizedBox(height: 6),
497
+ Row(
498
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
499
+ children: [
500
+ Text(
501
+ l10n.download_progress(
502
+ ((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(1),
503
+ downloadProgress?.speedFormatted ?? '0 B/s',
504
+ ),
505
+ style: theme.textTheme.labelSmall?.copyWith(
506
+ fontWeight: FontWeight.w600,
507
+ ),
508
+ ),
509
+ Text(
510
+ l10n.eta_label(
511
+ downloadProgress?.etaFormatted ?? l10n.calculating,
512
+ ),
513
+ style: theme.textTheme.labelSmall,
514
+ ),
515
+ ],
516
+ ),
517
+ const SizedBox(height: 2),
518
+ Text(
519
+ downloadProgress?.progressFormatted ?? '',
520
+ style: theme.textTheme.labelSmall?.copyWith(
521
+ color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
522
+ fontSize: 10,
523
+ ),
524
+ ),
525
+ ],
526
+ ),
527
+ ),
528
+ const SizedBox(width: 12),
529
+ ShadButton.outline(
530
+ size: ShadButtonSize.sm,
531
+ onPressed: () => ref
532
+ .read(foregroundDownloadNotifierProvider.notifier)
533
+ .cancelDownload(model.id),
534
+ child: Text(l10n.cancel),
535
+ ),
536
+ ],
537
+ ),
538
+ ],
539
+ );
540
+ }
541
+}
542
+
543
+class _FailedDownloadActions extends ConsumerWidget {
544
+ const _FailedDownloadActions({
545
+ required this.model,
546
+ this.downloadProgress,
547
+ required this.theme,
548
+ required this.l10n,
549
+ });
550
+
551
+ final OnDeviceModel model;
552
+ final DownloadProgressInfo? downloadProgress;
553
+ final ThemeData theme;
554
+ final AppLocalizations l10n;
555
+
556
+ @override
557
+ Widget build(BuildContext context, WidgetRef ref) {
558
+ return Row(
559
+ children: [
560
+ Expanded(
561
+ child: Text(
562
+ downloadProgress?.error ?? l10n.download_failed,
563
+ style: theme.textTheme.bodySmall?.copyWith(color: Colors.red),
564
+ ),
565
+ ),
566
+ const SizedBox(width: 8),
567
+ ShadButton.outline(
568
+ size: ShadButtonSize.sm,
569
+ onPressed: () => ref
570
+ .read(foregroundDownloadNotifierProvider.notifier)
571
+ .retryDownload(model.id),
572
+ child: Text(l10n.retry),
573
+ ),
574
+ ],
575
+ );
576
+ }
577
+}
578
+
579
+class _PausedDownloadActions extends ConsumerWidget {
580
+ const _PausedDownloadActions({
581
+ required this.model,
582
+ this.downloadProgress,
583
+ required this.theme,
584
+ required this.l10n,
585
+ });
586
+
587
+ final OnDeviceModel model;
588
+ final DownloadProgressInfo? downloadProgress;
589
+ final ThemeData theme;
590
+ final AppLocalizations l10n;
591
+
592
+ @override
593
+ Widget build(BuildContext context, WidgetRef ref) {
594
+ return Row(
595
+ children: [
596
+ Text(
597
+ l10n.paused_progress(
598
+ ((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(0),
599
+ ),
600
+ style: theme.textTheme.bodySmall,
601
+ ),
602
+ const Spacer(),
603
+ ShadButton.outline(
604
+ size: ShadButtonSize.sm,
605
+ onPressed: () => _startDownload(context, ref),
606
+ child: Text(l10n.resume),
607
+ ),
608
+ ],
609
+ );
610
+ }
611
+
612
+ Future<void> _startDownload(BuildContext context, WidgetRef ref) async {
613
+ final result = await ref
614
+ .read(foregroundDownloadNotifierProvider.notifier)
615
+ .startDownload(model.id);
616
+
617
+ if (result == 'missing_huggingface_token' && context.mounted) {
618
+ ScaffoldMessenger.of(context).showSnackBar(
619
+ SnackBar(
620
+ content: Text(l10n.model_missing_huggingface_token),
621
+ duration: const Duration(seconds: 6),
622
+ action: SnackBarAction(
623
+ label: l10n.settings_title,
624
+ onPressed: () => context.push(AppRoutes.settings),
625
+ ),
626
+ ),
627
+ );
628
+ }
629
+ }
630
+}
631
+
632
+class _NotDownloadedActions extends ConsumerWidget {
633
+ const _NotDownloadedActions({
634
+ required this.model,
635
+ this.deviceMemory,
636
+ required this.theme,
637
+ required this.l10n,
638
+ });
639
+
640
+ final OnDeviceModel model;
641
+ final DeviceMemoryInfo? deviceMemory;
642
+ final ThemeData theme;
643
+ final AppLocalizations l10n;
644
+
645
+ @override
646
+ Widget build(BuildContext context, WidgetRef ref) {
647
+ return Row(
648
+ children: [
649
+ const Spacer(),
650
+ ShadButton.outline(
651
+ size: ShadButtonSize.sm,
652
+ onPressed: () => _startDownload(context, ref),
653
+ child: Text(l10n.download),
654
+ ),
655
+ ],
656
+ );
657
+ }
658
+
659
+ Future<void> _startDownload(BuildContext context, WidgetRef ref) async {
660
+ final l10n = AppLocalizations.of(context)!;
661
+ if (!Platform.isIOS && deviceMemory != null) {
662
+ if (deviceMemory!.isOversized(model.minRamMb)) {
663
+ final proceed = await _showRamWarning(
664
+ context,
665
+ l10n.ram_warning_body_download(
666
+ '${model.minRamMb ~/ 1024 + 1}',
667
+ deviceMemory!.totalMemoryFormatted,
668
+ ),
669
+ );
670
+ if (!proceed) return;
671
+ }
672
+ }
673
+
674
+ final result = await ref
675
+ .read(foregroundDownloadNotifierProvider.notifier)
676
+ .startDownload(model.id);
677
+
678
+ if (result == 'missing_huggingface_token' && context.mounted) {
679
+ ScaffoldMessenger.of(context).showSnackBar(
680
+ SnackBar(
681
+ content: Text(l10n.model_missing_huggingface_token),
682
+ duration: const Duration(seconds: 6),
683
+ action: SnackBarAction(
684
+ label: l10n.settings_title,
685
+ onPressed: () => context.push(AppRoutes.settings),
686
+ ),
687
+ ),
688
+ );
689
+ }
690
+ }
691
+
692
+ Future<bool> _showRamWarning(BuildContext context, String message) async {
693
+ final l10n = AppLocalizations.of(context)!;
694
+ final result = await showDialog<bool>(
695
+ context: context,
696
+ builder: (ctx) => AlertDialog(
697
+ title: Row(
698
+ children: [
699
+ const Icon(Icons.warning_amber_rounded, color: Colors.orange),
700
+ const SizedBox(width: 8),
701
+ Text(l10n.ram_warning),
702
+ ],
703
+ ),
704
+ content: Text(message),
705
+ actions: [
706
+ TextButton(
707
+ onPressed: () => Navigator.pop(ctx, false),
708
+ child: Text(l10n.cancel),
709
+ ),
710
+ TextButton(
711
+ onPressed: () => Navigator.pop(ctx, true),
712
+ style: TextButton.styleFrom(foregroundColor: Colors.orange),
713
+ child: Text(l10n.proceed_anyway),
714
+ ),
715
+ ],
716
+ ),
717
+ );
718
+ return result ?? false;
719
+ }
720
+}
lib/features/on_device/components/on_device_picker_section.dart
new
+598
@@ -0,0 +1,598 @@
1
+import 'dart:io';
2
+
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/device/device_memory_service.dart';
7
+import 'package:localmind/core/models/enums.dart';
8
+import 'package:localmind/core/providers/app_providers.dart';
9
+import 'package:localmind/core/providers/device_info_providers.dart';
10
+import 'package:localmind/core/providers/service_providers.dart';
11
+import 'package:localmind/core/theme/colors.dart';
12
+import 'package:localmind/features/chat/providers/model_selection_providers.dart';
13
+import 'package:localmind/features/models/data/models/model_info.dart';
14
+import 'package:localmind/features/models/providers/model_picker_providers.dart';
15
+import 'package:localmind/features/on_device/data/models/on_device_model.dart';
16
+import 'package:localmind/features/on_device/data/models/download_status.dart';
17
+import 'package:localmind/features/on_device/providers/on_device_providers.dart';
18
+import 'package:localmind/features/on_device/providers/foreground_download_providers.dart';
19
+import 'package:localmind/l10n/app_localizations.dart';
20
+import 'package:localmind/core/routes/app_routes.dart';
21
+import 'package:localmind/features/models/components/metadata_chip.dart';
22
+
23
+class OnDevicePickerSection extends ConsumerWidget {
24
+ const OnDevicePickerSection({
25
+ super.key,
26
+ required this.selectedModelId,
27
+ required this.isDark,
28
+ });
29
+
30
+ final String? selectedModelId;
31
+ final bool isDark;
32
+
33
+ @override
34
+ Widget build(BuildContext context, WidgetRef ref) {
35
+ final l10n = AppLocalizations.of(context)!;
36
+ final curatedModels = ref.watch(onDeviceModelsProvider);
37
+ final downloadedAsync = ref.watch(downloadedModelsProvider);
38
+ final engineState = ref.watch(onDeviceEngineProvider);
39
+ final searchQuery = ref.watch(modelSearchQueryProvider);
40
+ final deviceMemoryAsync = ref.watch(deviceMemoryProvider);
41
+
42
+ return downloadedAsync.when(
43
+ loading: () => const Center(child: CircularProgressIndicator()),
44
+ error: (err, _) => Center(
45
+ child: Column(
46
+ mainAxisAlignment: MainAxisAlignment.center,
47
+ children: [
48
+ Icon(Icons.error_outline, size: 48, color: Colors.red[400]),
49
+ const SizedBox(height: 16),
50
+ Text(
51
+ l10n.failed_load_models,
52
+ style: TextStyle(
53
+ fontSize: 16,
54
+ fontWeight: FontWeight.w500,
55
+ color: isDark ? Colors.white : Colors.black,
56
+ ),
57
+ ),
58
+ const SizedBox(height: 16),
59
+ OutlinedButton.icon(
60
+ onPressed: () => ref.invalidate(downloadedModelsProvider),
61
+ icon: const Icon(Icons.refresh),
62
+ label: Text(l10n.retry),
63
+ ),
64
+ ],
65
+ ),
66
+ ),
67
+ data: (downloadedIds) {
68
+ final filtered = searchQuery.isEmpty
69
+ ? curatedModels
70
+ : curatedModels
71
+ .where(
72
+ (m) => m.name.toLowerCase().contains(
73
+ searchQuery.toLowerCase(),
74
+ ),
75
+ )
76
+ .toList();
77
+
78
+ if (filtered.isEmpty) {
79
+ return Center(
80
+ child: Text(
81
+ searchQuery.isEmpty
82
+ ? l10n.no_models_available
83
+ : l10n.no_models_match(searchQuery),
84
+ style: TextStyle(
85
+ fontSize: 14,
86
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
87
+ ),
88
+ ),
89
+ );
90
+ }
91
+
92
+ return ListView.builder(
93
+ itemCount: filtered.length,
94
+ itemBuilder: (context, index) {
95
+ final model = filtered[index];
96
+ final isDownloaded = downloadedIds.contains(model.id);
97
+ final isLoaded = engineState.loadedModelId == model.id;
98
+ final isCurrentlyLoading =
99
+ engineState.status == OnDeviceEngineStatus.loading &&
100
+ engineState.loadedModelId == model.id;
101
+ final isSelected = selectedModelId == model.id;
102
+
103
+ return _OnDeviceModelTile(
104
+ model: model,
105
+ isDownloaded: isDownloaded,
106
+ isLoaded: isLoaded,
107
+ isCurrentlyLoading: isCurrentlyLoading,
108
+ isSelected: isSelected,
109
+ isDark: isDark,
110
+ deviceMemory: deviceMemoryAsync.value,
111
+ );
112
+ },
113
+ );
114
+ },
115
+ );
116
+ }
117
+}
118
+
119
+class _OnDeviceModelTile extends ConsumerWidget {
120
+ const _OnDeviceModelTile({
121
+ required this.model,
122
+ required this.isDownloaded,
123
+ required this.isLoaded,
124
+ required this.isCurrentlyLoading,
125
+ required this.isSelected,
126
+ required this.isDark,
127
+ this.deviceMemory,
128
+ });
129
+
130
+ final OnDeviceModel model;
131
+ final bool isDownloaded;
132
+ final bool isLoaded;
133
+ final bool isCurrentlyLoading;
134
+ final bool isSelected;
135
+ final bool isDark;
136
+ final DeviceMemoryInfo? deviceMemory;
137
+
138
+ @override
139
+ Widget build(BuildContext context, WidgetRef ref) {
140
+ final l10n = AppLocalizations.of(context)!;
141
+ final accent = isDark ? AppColors.darkAccent : AppColors.lightAccent;
142
+ final downloadProgress = ref.watch(
143
+ foregroundDownloadNotifierProvider,
144
+ )[model.id];
145
+ final isDownloading =
146
+ downloadProgress != null &&
147
+ (downloadProgress.status == DownloadStatus.running ||
148
+ downloadProgress.status == DownloadStatus.pending);
149
+ final isPaused = downloadProgress?.status == DownloadStatus.paused;
150
+
151
+ return InkWell(
152
+ onTap: () {
153
+ if (isLoaded) {
154
+ _selectModel(context, ref);
155
+ } else if (isDownloaded && !isCurrentlyLoading && !isDownloading) {
156
+ _loadModel(context, ref);
157
+ }
158
+ },
159
+ borderRadius: BorderRadius.circular(8),
160
+ child: Container(
161
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
162
+ margin: const EdgeInsets.only(bottom: 4),
163
+ decoration: BoxDecoration(
164
+ color: isSelected
165
+ ? accent.withValues(alpha: 0.1)
166
+ : Colors.transparent,
167
+ borderRadius: BorderRadius.circular(8),
168
+ border: isSelected
169
+ ? Border.all(color: accent.withValues(alpha: 0.3))
170
+ : null,
171
+ ),
172
+ child: Row(
173
+ children: [
174
+ Expanded(
175
+ child: Column(
176
+ crossAxisAlignment: CrossAxisAlignment.start,
177
+ children: [
178
+ Row(
179
+ children: [
180
+ Expanded(
181
+ child: Text(
182
+ model.name,
183
+ style: TextStyle(
184
+ fontSize: 15,
185
+ fontWeight: isSelected
186
+ ? FontWeight.w600
187
+ : FontWeight.w500,
188
+ color: isDownloaded
189
+ ? (isDark ? Colors.white : Colors.black)
190
+ : (isDark
191
+ ? AppColors.darkMutedText
192
+ : AppColors.lightMutedText),
193
+ ),
194
+ maxLines: 1,
195
+ overflow: TextOverflow.ellipsis,
196
+ ),
197
+ ),
198
+ if (model.isRecommended)
199
+ Container(
200
+ padding: const EdgeInsets.symmetric(
201
+ horizontal: 6,
202
+ vertical: 2,
203
+ ),
204
+ decoration: BoxDecoration(
205
+ color: accent.withValues(alpha: 0.1),
206
+ borderRadius: BorderRadius.circular(4),
207
+ ),
208
+ child: Text(
209
+ l10n.recommended,
210
+ style: TextStyle(
211
+ fontSize: 9,
212
+ fontWeight: FontWeight.bold,
213
+ color: accent,
214
+ ),
215
+ ),
216
+ ),
217
+ if (model.isImported)
218
+ Container(
219
+ margin: const EdgeInsets.only(left: 6),
220
+ padding: const EdgeInsets.symmetric(
221
+ horizontal: 6,
222
+ vertical: 2,
223
+ ),
224
+ decoration: BoxDecoration(
225
+ color: Colors.blue.withValues(alpha: 0.1),
226
+ borderRadius: BorderRadius.circular(4),
227
+ ),
228
+ child: const Text(
229
+ 'Imported',
230
+ style: TextStyle(
231
+ fontSize: 9,
232
+ fontWeight: FontWeight.bold,
233
+ color: Colors.blue,
234
+ ),
235
+ ),
236
+ ),
237
+ ],
238
+ ),
239
+ if (!Platform.isIOS &&
240
+ deviceMemory != null &&
241
+ deviceMemory!.isOversized(model.minRamMb))
242
+ Padding(
243
+ padding: const EdgeInsets.only(top: 2),
244
+ child: Row(
245
+ children: [
246
+ const Icon(
247
+ Icons.warning_amber_rounded,
248
+ color: Colors.orange,
249
+ size: 12,
250
+ ),
251
+ const SizedBox(width: 4),
252
+ Text(
253
+ l10n.may_be_large,
254
+ style: TextStyle(
255
+ fontSize: 10,
256
+ color: Colors.orange,
257
+ fontWeight: FontWeight.bold,
258
+ ),
259
+ ),
260
+ ],
261
+ ),
262
+ ),
263
+ if (isDownloading || isPaused)
264
+ Padding(
265
+ padding: const EdgeInsets.only(top: 6),
266
+ child: Column(
267
+ crossAxisAlignment: CrossAxisAlignment.start,
268
+ children: [
269
+ LinearProgressIndicator(
270
+ value: downloadProgress?.progress ?? 0.0,
271
+ backgroundColor: isDark
272
+ ? AppColors.darkBorder
273
+ : AppColors.lightBorder,
274
+ ),
275
+ const SizedBox(height: 2),
276
+ Text(
277
+ isPaused
278
+ ? l10n.paused_progress(
279
+ ((downloadProgress?.progress ?? 0) * 100)
280
+ .toStringAsFixed(0),
281
+ )
282
+ : '${l10n.downloading_status} ${((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(0)}%',
283
+ style: TextStyle(
284
+ fontSize: 10,
285
+ color: isDark
286
+ ? AppColors.darkMutedText
287
+ : AppColors.lightMutedText,
288
+ ),
289
+ ),
290
+ ],
291
+ ),
292
+ ),
293
+ const SizedBox(height: 4),
294
+ Wrap(
295
+ spacing: 6,
296
+ runSpacing: 4,
297
+ children: [
298
+ MetadataChip(
299
+ label: model.fileSizeFormatted,
300
+ isDark: isDark,
301
+ ),
302
+ if (model.format == OnDeviceModelFormat.gguf)
303
+ MetadataChip(label: 'GGUF', isDark: isDark),
304
+ if (model.runtime == OnDeviceModelRuntime.llamaCpp)
305
+ MetadataChip(label: 'llama.cpp', isDark: isDark),
306
+ MetadataChip(label: model.license, isDark: isDark),
307
+ MetadataChip(
308
+ label: model.parameterLabel,
309
+ isDark: isDark,
310
+ ),
311
+ ],
312
+ ),
313
+ ],
314
+ ),
315
+ ),
316
+ const SizedBox(width: 8),
317
+ if (isCurrentlyLoading) ...[
318
+ const SizedBox(
319
+ width: 16,
320
+ height: 16,
321
+ child: CircularProgressIndicator(strokeWidth: 2),
322
+ ),
323
+ ] else if (isLoaded) ...[
324
+ Container(
325
+ width: 10,
326
+ height: 10,
327
+ decoration: const BoxDecoration(
328
+ color: AppColors.darkAccent,
329
+ shape: BoxShape.circle,
330
+ ),
331
+ ),
332
+ const SizedBox(width: 8),
333
+ _IconButton(
334
+ icon: Icon(
335
+ Icons.power_settings_new_outlined,
336
+ size: 18,
337
+ color: Colors.red[400],
338
+ ),
339
+ tooltip: l10n.unload,
340
+ onPressed: () => _unloadModel(context, ref),
341
+ ),
342
+ ] else if (isDownloaded) ...[
343
+ _IconButton(
344
+ icon: Icon(Icons.play_arrow, size: 20, color: accent),
345
+ tooltip: l10n.load,
346
+ onPressed: () => _loadModel(context, ref),
347
+ ),
348
+ const SizedBox(width: 4),
349
+ _IconButton(
350
+ icon: Icon(
351
+ Icons.delete_outline,
352
+ size: 18,
353
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
354
+ ),
355
+ tooltip: l10n.delete,
356
+ onPressed: () => _deleteModel(context, ref),
357
+ ),
358
+ ] else if (isDownloading) ...[
359
+ _IconButton(
360
+ icon: Icon(
361
+ Icons.close,
362
+ size: 16,
363
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
364
+ ),
365
+ tooltip: l10n.cancel,
366
+ onPressed: () => ref
367
+ .read(foregroundDownloadNotifierProvider.notifier)
368
+ .cancelDownload(model.id),
369
+ ),
370
+ ] else ...[
371
+ _IconButton(
372
+ icon: Icon(
373
+ Icons.cloud_download_outlined,
374
+ size: 18,
375
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
376
+ ),
377
+ tooltip: l10n.download,
378
+ onPressed: () async {
379
+ final result = await ref
380
+ .read(foregroundDownloadNotifierProvider.notifier)
381
+ .startDownload(model.id);
382
+ if (result == 'missing_huggingface_token' &&
383
+ context.mounted) {
384
+ ScaffoldMessenger.of(context).showSnackBar(
385
+ SnackBar(
386
+ content: Text(l10n.model_missing_huggingface_token),
387
+ duration: const Duration(seconds: 6),
388
+ action: SnackBarAction(
389
+ label: l10n.settings_title,
390
+ onPressed: () => context.push(AppRoutes.settings),
391
+ ),
392
+ ),
393
+ );
394
+ }
395
+ },
396
+ ),
397
+ const SizedBox(width: 4),
398
+ Text(
399
+ l10n.not_downloaded,
400
+ style: TextStyle(
401
+ fontSize: 11,
402
+ color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
403
+ ),
404
+ ),
405
+ ],
406
+ if (isDownloaded && isSelected && isLoaded) ...[
407
+ const SizedBox(width: 4),
408
+ Icon(Icons.check_circle, color: accent, size: 22),
409
+ ],
410
+ ],
411
+ ),
412
+ ),
413
+ );
414
+ }
415
+
416
+ void _loadModel(BuildContext context, WidgetRef ref) async {
417
+ final settings = ref.read(settingsProvider);
418
+ final engineNotifier = ref.read(onDeviceEngineProvider.notifier);
419
+
420
+ if (!Platform.isIOS && deviceMemory != null) {
421
+ if (!deviceMemory!.hasEnoughRam(model.minRamMb)) {
422
+ final proceed = await _showRamWarning(context);
423
+ if (!context.mounted) return;
424
+ if (!proceed) return;
425
+ }
426
+ }
427
+
428
+ final modelInfo = ModelInfo(
429
+ id: model.id,
430
+ name: model.name,
431
+ description: model.description,
432
+ parameterCount: double.tryParse(
433
+ model.parameterLabel.replaceAll(RegExp(r'[^0-9\.]'), ''),
434
+ ),
435
+ fileSize: model.fileSizeBytes,
436
+ quantization: model.format == OnDeviceModelFormat.gguf ? 'GGUF' : null,
437
+ architecture: model.runtime == OnDeviceModelRuntime.llamaCpp
438
+ ? 'llama.cpp'
439
+ : null,
440
+ serverType: ServerType.onDevice,
441
+ serverId: 'on-device',
442
+ modifiedAt: model.importedAt,
443
+ onDeviceRuntime: model.runtime,
444
+ onDeviceFormat: model.format,
445
+ localPath: model.localPath,
446
+ );
447
+
448
+ await engineNotifier.loadModel(model.id, settings.preferredBackend);
449
+ if (!context.mounted) return;
450
+
451
+ final engineState = ref.read(onDeviceEngineProvider);
452
+ if (engineState.loadedModelId == model.id) {
453
+ ref.read(selectedModelProvider.notifier).setModel(modelInfo);
454
+ Navigator.pop(context);
455
+ }
456
+ }
457
+
458
+ Future<bool> _showRamWarning(BuildContext context) async {
459
+ final l10n = AppLocalizations.of(context)!;
460
+ final result = await showDialog<bool>(
461
+ context: context,
462
+ builder: (ctx) => AlertDialog(
463
+ title: Row(
464
+ children: [
465
+ const Icon(Icons.warning_amber_rounded, color: Colors.orange),
466
+ const SizedBox(width: 8),
467
+ Text(l10n.ram_warning),
468
+ ],
469
+ ),
470
+ content: Text(
471
+ l10n.ram_warning_body_load(
472
+ deviceMemory!.availableMemoryFormatted,
473
+ '${model.minRamMb ~/ 1024 + 1}',
474
+ ),
475
+ ),
476
+ actions: [
477
+ TextButton(
478
+ onPressed: () => Navigator.pop(ctx, false),
479
+ child: Text(l10n.cancel),
480
+ ),
481
+ TextButton(
482
+ onPressed: () => Navigator.pop(ctx, true),
483
+ style: TextButton.styleFrom(foregroundColor: Colors.orange),
484
+ child: Text(l10n.proceed_anyway),
485
+ ),
486
+ ],
487
+ ),
488
+ );
489
+ return result ?? false;
490
+ }
491
+
492
+ void _selectModel(BuildContext context, WidgetRef ref) {
493
+ final modelInfo = ModelInfo(
494
+ id: model.id,
495
+ name: model.name,
496
+ description: model.description,
497
+ parameterCount: double.tryParse(
498
+ model.parameterLabel.replaceAll(RegExp(r'[^0-9\.]'), ''),
499
+ ),
500
+ fileSize: model.fileSizeBytes,
501
+ quantization: model.format == OnDeviceModelFormat.gguf ? 'GGUF' : null,
502
+ architecture: model.runtime == OnDeviceModelRuntime.llamaCpp
503
+ ? 'llama.cpp'
504
+ : null,
505
+ serverType: ServerType.onDevice,
506
+ serverId: 'on-device',
507
+ modifiedAt: model.importedAt,
508
+ onDeviceRuntime: model.runtime,
509
+ onDeviceFormat: model.format,
510
+ localPath: model.localPath,
511
+ );
512
+
513
+ ref.read(selectedModelProvider.notifier).setModel(modelInfo);
514
+ Navigator.pop(context);
515
+ }
516
+
517
+ void _unloadModel(BuildContext context, WidgetRef ref) async {
518
+ final engineNotifier = ref.read(onDeviceEngineProvider.notifier);
519
+ await engineNotifier.unloadModel();
520
+ if (!context.mounted) return;
521
+
522
+ final selectedModel = ref.read(selectedModelProvider);
523
+ if (selectedModel?.id == model.id) {
524
+ ref.read(selectedModelProvider.notifier).clear();
525
+ }
526
+ }
527
+
528
+ void _deleteModel(BuildContext context, WidgetRef ref) async {
529
+ final l10n = AppLocalizations.of(context)!;
530
+ final confirm = await showDialog<bool>(
531
+ context: context,
532
+ builder: (ctx) => AlertDialog(
533
+ title: Text(l10n.delete_model_title),
534
+ content: Text(l10n.delete_model_body(model.name)),
535
+ actions: [
536
+ TextButton(
537
+ onPressed: () => Navigator.pop(ctx, false),
538
+ child: Text(l10n.cancel),
539
+ ),
540
+ TextButton(
541
+ onPressed: () => Navigator.pop(ctx, true),
542
+ child: Text(l10n.delete),
543
+ ),
544
+ ],
545
+ ),
546
+ );
547
+
548
+ if (confirm == true) {
549
+ if (!context.mounted) return;
550
+
551
+ final engineState = ref.read(onDeviceEngineProvider);
552
+ final engineNotifier = ref.read(onDeviceEngineProvider.notifier);
553
+ final selectedModelNotifier = ref.read(selectedModelProvider.notifier);
554
+
555
+ if (engineState.loadedModelId == model.id) {
556
+ await engineNotifier.unloadModel();
557
+ if (!context.mounted) return;
558
+ selectedModelNotifier.clear();
559
+ }
560
+
561
+ if (model.isImported) {
562
+ await ref
563
+ .read(importedGgufModelsProvider.notifier)
564
+ .deleteModel(model.id);
565
+ } else {
566
+ // Use gemma service for non-imported models
567
+ final gemmaService = ref.read(onDeviceGemmaServiceProvider);
568
+ await gemmaService.deleteModel(model.id);
569
+ }
570
+ if (!context.mounted) return;
571
+ ref.invalidate(downloadedModelsProvider);
572
+ }
573
+ }
574
+}
575
+
576
+class _IconButton extends StatelessWidget {
577
+ const _IconButton({
578
+ required this.icon,
579
+ required this.tooltip,
580
+ required this.onPressed,
581
+ });
582
+
583
+ final Widget icon;
584
+ final String tooltip;
585
+ final VoidCallback onPressed;
586
+
587
+ @override
588
+ Widget build(BuildContext context) {
589
+ return Tooltip(
590
+ message: tooltip,
591
+ child: InkWell(
592
+ onTap: onPressed,
593
+ borderRadius: BorderRadius.circular(4),
594
+ child: Padding(padding: const EdgeInsets.all(4), child: icon),
595
+ ),
596
+ );
597
+ }
598
+}
lib/features/on_device/views/model_manager_screen.dart
+124
-951
@@ -4,20 +4,14 @@ import 'package:file_picker/file_picker.dart';
4
import 'package:flutter/foundation.dart';
5
import 'package:flutter/material.dart';
6
import 'package:flutter_riverpod/flutter_riverpod.dart';
7
-import 'package:go_router/go_router.dart';
8
-import 'package:localmind/l10n/app_localizations.dart';
7
import 'package:shadcn_ui/shadcn_ui.dart';
10
-
11
-import '../../../core/models/enums.dart';
12
-import '../../../core/providers/app_providers.dart';
13
-import '../../../core/routes/app_routes.dart';
14
-import '../../on_device/data/models/on_device_model.dart';
15
-import '../../on_device/data/models/download_progress_info.dart';
16
-import '../../on_device/data/models/download_status.dart';
17
-import '../../on_device/providers/on_device_providers.dart';
18
-import '../../on_device/providers/foreground_download_providers.dart';
19
-import '../../../core/providers/device_info_providers.dart';
20
-import '../../../core/device/device_memory_service.dart';
8
+import 'package:localmind/core/models/enums.dart';
9
+import 'package:localmind/l10n/app_localizations.dart';
10
+import 'package:localmind/features/on_device/data/models/on_device_model.dart';
11
+import 'package:localmind/features/on_device/providers/on_device_providers.dart';
12
+import 'package:localmind/features/on_device/providers/foreground_download_providers.dart';
13
+import 'package:localmind/features/on_device/components/memory_stats_panel.dart';
14
+import 'package:localmind/features/on_device/components/model_card.dart';
15
16
class OnDeviceModelManagerScreen extends ConsumerWidget {
17
const OnDeviceModelManagerScreen({super.key});
@@ -30,151 +24,25 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
24
final downloadedAsync = ref.watch(downloadedModelsProvider);
25
final engineState = ref.watch(onDeviceEngineProvider);
26
final downloadProgress = ref.watch(foregroundDownloadNotifierProvider);
33
- final deviceMemoryAsync = ref.watch(deviceMemoryProvider);
34
-
35
- ref.listen(onDeviceEngineProvider, (prev, next) {
36
- if (next.status == OnDeviceEngineStatus.loaded &&
37
- prev?.status == OnDeviceEngineStatus.loading) {
38
- final loadedId = next.loadedModelId;
39
- final loadedName = loadedId == null
40
- ? 'Unknown'
41
- : models
42
- .where((m) => m.id == loadedId)
43
- .map((m) => m.name)
44
- .followedBy([loadedId])
45
- .first;
46
- ScaffoldMessenger.of(context).showSnackBar(
47
- SnackBar(
48
- content: Text(
49
- l10n.model_loaded(
50
- loadedName,
51
- next.backend?.name ?? 'CPU',
52
- ),
53
- ),
54
- ),
55
- );
56
- }
57
- });
58
-
59
- final topPadding = MediaQuery.of(context).padding.top;
27
28
return Column(
29
children: [
63
- Container(
64
- padding: EdgeInsets.only(
65
- left: 16,
66
- right: 16,
67
- top: topPadding + 8,
68
- bottom: 16,
69
- ),
70
- decoration: BoxDecoration(
71
- color: theme.brightness == Brightness.dark
72
- ? const Color(0xFF0A0A0A)
73
- : const Color(0xFFFAFAFA),
74
- border: Border(
75
- bottom: BorderSide(
76
- color: theme.brightness == Brightness.dark
77
- ? const Color(0xFF2A2A2A)
78
- : const Color(0xFFE5E5E5),
79
- ),
80
- ),
81
- ),
82
- child: Row(
83
- children: [
84
- Builder(
85
- builder: (context) => IconButton(
86
- icon: const Icon(Icons.menu),
87
- onPressed: () => Scaffold.of(context).openDrawer(),
88
- ),
89
- ),
90
- const SizedBox(width: 8),
91
- Expanded(
92
- child: Text(
93
- l10n.on_device_models_title,
94
- style: TextStyle(
95
- fontSize: 20,
96
- fontWeight: FontWeight.bold,
97
- color: theme.brightness == Brightness.dark
98
- ? Colors.white
99
- : Colors.black,
100
- ),
101
- ),
102
- ),
103
- if (defaultTargetPlatform == TargetPlatform.android ||
104
- defaultTargetPlatform == TargetPlatform.iOS)
105
- ShadButton.outline(
106
- size: ShadButtonSize.sm,
107
- onPressed: () => _importGguf(context, ref),
108
- child: const Text('Import GGUF'),
109
- ),
110
- ],
111
- ),
30
+ _ManagerHeader(
31
+ l10n: l10n,
32
+ theme: theme,
33
+ onImportGguf: () => _importGguf(context, ref),
34
),
35
Expanded(
36
child: ListView(
37
padding: const EdgeInsets.all(16),
38
children: [
117
- _buildMemoryInfo(context, ref, deviceMemoryAsync),
118
- if (engineState.status == OnDeviceEngineStatus.loaded)
119
- Builder(
120
- builder: (context) {
121
- final loadedId = engineState.loadedModelId;
122
- final loadedName = loadedId == null
123
- ? 'Unknown'
124
- : models
125
- .where((m) => m.id == loadedId)
126
- .map((m) => m.name)
127
- .followedBy([loadedId])
128
- .first;
129
- return Container(
130
- padding: const EdgeInsets.all(12),
131
- margin: const EdgeInsets.only(bottom: 16),
132
- decoration: BoxDecoration(
133
- color: Colors.green.withValues(alpha: 0.1),
134
- borderRadius: BorderRadius.circular(8),
135
- border: Border.all(color: Colors.green),
136
- ),
137
- child: Row(
138
- children: [
139
- const Icon(
140
- Icons.check_circle,
141
- color: Colors.green,
142
- size: 20,
143
- ),
144
- const SizedBox(width: 8),
145
- Expanded(
146
- child: Text(
147
- l10n.model_loaded(
148
- loadedName,
149
- engineState.backend?.name ?? 'CPU',
150
- ),
151
- style: const TextStyle(color: Colors.green),
152
- ),
153
- ),
154
- ],
155
- ),
156
- );
157
- },
158
- )
159
- else if (engineState.status == OnDeviceEngineStatus.loading)
160
- const Padding(
161
- padding: EdgeInsets.only(bottom: 16),
162
- child: LinearProgressIndicator(),
163
- )
164
- else if (engineState.status == OnDeviceEngineStatus.error)
165
- Container(
166
- padding: const EdgeInsets.all(12),
167
- margin: const EdgeInsets.only(bottom: 16),
168
- decoration: BoxDecoration(
169
- color: Colors.red.withValues(alpha: 0.1),
170
- borderRadius: BorderRadius.circular(8),
171
- border: Border.all(color: Colors.red),
172
- ),
173
- child: Text(
174
- engineState.error ?? l10n.unknown_error,
175
- style: const TextStyle(color: Colors.red),
176
- ),
177
- ),
39
+ const MemoryStatsPanel(),
40
+ _EngineStatusCard(
41
+ engineState: engineState,
42
+ models: models,
43
+ l10n: l10n,
44
+ theme: theme,
45
+ ),
46
Text(
47
l10n.available_models,
48
style: theme.textTheme.titleMedium?.copyWith(
@@ -183,13 +51,11 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
51
),
52
const SizedBox(height: 12),
53
...models.map(
186
- (model) => _ModelCard(
54
+ (model) => ModelCard(
55
model: model,
188
- theme: theme,
56
downloadedAsync: downloadedAsync,
57
downloadProgress: downloadProgress[model.id],
58
engineState: engineState,
192
- deviceMemory: deviceMemoryAsync.value,
59
),
60
),
61
const SizedBox(height: 24),
@@ -200,245 +66,6 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
66
);
67
}
68
203
- Widget _buildMemoryInfo(
204
- BuildContext context,
205
- WidgetRef ref,
206
- AsyncValue<DeviceMemoryInfo> memoryAsync,
207
- ) {
208
- final l10n = AppLocalizations.of(context)!;
209
- return memoryAsync.when(
210
- data: (info) {
211
- if (info.totalMemoryMb == 0) return const SizedBox.shrink();
212
- final theme = Theme.of(context);
213
- final usedMb = info.totalMemoryMb - info.availableMemoryMb;
214
- final usagePercent = usedMb / info.totalMemoryMb;
215
-
216
- // Memory health logic
217
- Color statusColor = Colors.green;
218
- String statusText = l10n.memory_healthy;
219
- if (info.availableMemoryMb < 1024) {
220
- statusColor = Colors.red;
221
- statusText = l10n.memory_critical;
222
- } else if (info.availableMemoryMb < 2048) {
223
- statusColor = Colors.orange;
224
- statusText = l10n.memory_low;
225
- }
226
-
227
- return Container(
228
- margin: const EdgeInsets.only(bottom: 24),
229
- decoration: BoxDecoration(
230
- borderRadius: BorderRadius.circular(20),
231
- gradient: LinearGradient(
232
- begin: Alignment.topLeft,
233
- end: Alignment.bottomRight,
234
- colors: [
235
- theme.colorScheme.surfaceContainerHighest,
236
- theme.colorScheme.surfaceContainerHighest.withValues(
237
- alpha: 0.5,
238
- ),
239
- ],
240
- ),
241
- boxShadow: [
242
- BoxShadow(
243
- color: Colors.black.withValues(alpha: 0.05),
244
- blurRadius: 10,
245
- offset: const Offset(0, 4),
246
- ),
247
- ],
248
- border: Border.all(
249
- color: theme.colorScheme.outline.withValues(alpha: 0.1),
250
- ),
251
- ),
252
- child: Column(
253
- children: [
254
- Padding(
255
- padding: const EdgeInsetsDirectional.only(
256
- start: 20,
257
- top: 16,
258
- end: 12,
259
- bottom: 8,
260
- ),
261
- child: Row(
262
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
263
- children: [
264
- Row(
265
- children: [
266
- Icon(
267
- Icons.memory_rounded,
268
- size: 20,
269
- color: theme.colorScheme.primary,
270
- ),
271
- const SizedBox(width: 8),
272
- Text(
273
- l10n.device_memory,
274
- style: theme.textTheme.titleMedium?.copyWith(
275
- fontWeight: FontWeight.bold,
276
- letterSpacing: -0.5,
277
- ),
278
- ),
279
- ],
280
- ),
281
- ShadButton.ghost(
282
- size: ShadButtonSize.sm,
283
- onPressed: () => ref.invalidate(deviceMemoryProvider),
284
- child: const Icon(Icons.refresh_rounded, size: 18),
285
- ),
286
- ],
287
- ),
288
- ),
289
- Padding(
290
- padding: const EdgeInsets.symmetric(horizontal: 20),
291
- child: Column(
292
- children: [
293
- Row(
294
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
295
- children: [
296
- Text(
297
- l10n.ram_usage,
298
- style: theme.textTheme.labelMedium?.copyWith(
299
- color: theme.colorScheme.onSurface.withValues(
300
- alpha: 0.6,
301
- ),
302
- ),
303
- ),
304
- Container(
305
- padding: const EdgeInsets.symmetric(
306
- horizontal: 8,
307
- vertical: 2,
308
- ),
309
- decoration: BoxDecoration(
310
- color: statusColor.withValues(alpha: 0.1),
311
- borderRadius: BorderRadius.circular(12),
312
- border: Border.all(
313
- color: statusColor.withValues(alpha: 0.2),
314
- ),
315
- ),
316
- child: Text(
317
- statusText,
318
- style: theme.textTheme.labelSmall?.copyWith(
319
- color: statusColor,
320
- fontWeight: FontWeight.bold,
321
- ),
322
- ),
323
- ),
324
- ],
325
- ),
326
- const SizedBox(height: 12),
327
- ClipRRect(
328
- borderRadius: BorderRadius.circular(10),
329
- child: Stack(
330
- children: [
331
- Container(
332
- height: 10,
333
- width: double.infinity,
334
- color: theme.colorScheme.surfaceContainerLowest,
335
- ),
336
- AnimatedContainer(
337
- duration: const Duration(milliseconds: 500),
338
- height: 10,
339
- width:
340
- MediaQuery.of(context).size.width *
341
- usagePercent,
342
- decoration: BoxDecoration(
343
- gradient: LinearGradient(
344
- colors: [
345
- statusColor,
346
- statusColor.withValues(alpha: 0.7),
347
- ],
348
- ),
349
- borderRadius: BorderRadius.circular(10),
350
- boxShadow: [
351
- BoxShadow(
352
- color: statusColor.withValues(alpha: 0.3),
353
- blurRadius: 4,
354
- offset: const Offset(0, 2),
355
- ),
356
- ],
357
- ),
358
- ),
359
- ],
360
- ),
361
- ),
362
- const SizedBox(height: 8),
363
- Row(
364
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
365
- children: [
366
- Text(
367
- l10n.ram_used(
368
- (usagePercent * 100).toStringAsFixed(0),
369
- ),
370
- style: theme.textTheme.labelSmall?.copyWith(
371
- fontWeight: FontWeight.w600,
372
- ),
373
- ),
374
- Text(
375
- '${_formatMb(usedMb)} / ${info.totalMemoryFormatted}',
376
- style: theme.textTheme.labelSmall?.copyWith(
377
- color: theme.colorScheme.onSurface.withValues(
378
- alpha: 0.5,
379
- ),
380
- ),
381
- ),
382
- ],
383
- ),
384
- ],
385
- ),
386
- ),
387
- const SizedBox(height: 16),
388
- const Divider(height: 1),
389
- Padding(
390
- padding: const EdgeInsets.symmetric(
391
- vertical: 12,
392
- horizontal: 20,
393
- ),
394
- child: Row(
395
- children: [
396
- Expanded(
397
- child: _MemoryStat(
398
- label: l10n.available_ram,
399
- value: info.availableMemoryFormatted,
400
- icon: Icons.speed_rounded,
401
- color: statusColor,
402
- isPrimary: true,
403
- ),
404
- ),
405
- Container(
406
- height: 30,
407
- width: 1,
408
- color: theme.colorScheme.outline.withValues(alpha: 0.1),
409
- ),
410
- Expanded(
411
- child: _MemoryStat(
412
- label: l10n.total_capacity,
413
- value: info.totalMemoryFormatted,
414
- icon: Icons.storage_rounded,
415
- alignment: CrossAxisAlignment.end,
416
- ),
417
- ),
418
- ],
419
- ),
420
- ),
421
- ],
422
- ),
423
- );
424
- },
425
- loading: () => const Center(
426
- child: Padding(
427
- padding: EdgeInsets.all(24.0),
428
- child: CircularProgressIndicator(),
429
- ),
430
- ),
431
- error: (_, _) => const SizedBox.shrink(),
432
- );
433
- }
434
-
435
- String _formatMb(int mb) {
436
- if (mb >= 1024) {
437
- return '${(mb / 1024).toStringAsFixed(1)} GB';
438
- }
439
- return '$mb MB';
440
- }
441
-
69
Future<void> _importGguf(BuildContext context, WidgetRef ref) async {
70
final messenger = ScaffoldMessenger.of(context);
71
try {
@@ -470,599 +97,145 @@ class OnDeviceModelManagerScreen extends ConsumerWidget {
97
}
98
}
99
473
-class _MemoryStat extends StatelessWidget {
474
- final String label;
475
- final String value;
476
- final IconData icon;
477
- final Color? color;
478
- final CrossAxisAlignment alignment;
479
- final bool isPrimary;
480
-
481
- const _MemoryStat({
482
- required this.label,
483
- required this.value,
484
- required this.icon,
485
- this.color,
486
- this.alignment = CrossAxisAlignment.start,
487
- this.isPrimary = false,
100
+class _ManagerHeader extends StatelessWidget {
101
+ const _ManagerHeader({
102
+ required this.l10n,
103
+ required this.theme,
104
+ required this.onImportGguf,
105
});
106
107
+ final AppLocalizations l10n;
108
+ final ThemeData theme;
109
+ final VoidCallback onImportGguf;
110
+
111
@override
112
Widget build(BuildContext context) {
492
- final theme = Theme.of(context);
493
- final iconColor =
494
- color ?? theme.colorScheme.onSurface.withValues(alpha: 0.4);
113
+ final topPadding = MediaQuery.of(context).padding.top;
114
496
- return Column(
497
- crossAxisAlignment: alignment,
498
- children: [
499
- Row(
500
- mainAxisSize: MainAxisSize.min,
501
- children: [
502
- if (alignment == CrossAxisAlignment.start) ...[
503
- Icon(icon, size: 14, color: iconColor),
504
- const SizedBox(width: 6),
505
- ],
506
- Text(
507
- label,
508
- style: theme.textTheme.labelSmall?.copyWith(
509
- color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
510
- fontSize: 10,
115
+ return Container(
116
+ padding: EdgeInsets.only(
117
+ left: 16,
118
+ right: 16,
119
+ top: topPadding + 8,
120
+ bottom: 16,
121
+ ),
122
+ decoration: BoxDecoration(
123
+ color: theme.brightness == Brightness.dark
124
+ ? const Color(0xFF0A0A0A)
125
+ : const Color(0xFFFAFAFA),
126
+ border: Border(
127
+ bottom: BorderSide(
128
+ color: theme.brightness == Brightness.dark
129
+ ? const Color(0xFF2A2A2A)
130
+ : const Color(0xFFE5E5E5),
131
+ ),
132
+ ),
133
+ ),
134
+ child: Row(
135
+ children: [
136
+ Builder(
137
+ builder: (context) => IconButton(
138
+ icon: const Icon(Icons.menu),
139
+ onPressed: () => Scaffold.of(context).openDrawer(),
140
+ ),
141
+ ),
142
+ const SizedBox(width: 8),
143
+ Expanded(
144
+ child: Text(
145
+ l10n.on_device_models_title,
146
+ style: TextStyle(
147
+ fontSize: 20,
148
+ fontWeight: FontWeight.bold,
149
+ color: theme.brightness == Brightness.dark
150
+ ? Colors.white
151
+ : Colors.black,
152
),
153
),
513
- if (alignment == CrossAxisAlignment.end) ...[
514
- const SizedBox(width: 6),
515
- Icon(icon, size: 14, color: iconColor),
516
- ],
517
- ],
518
- ),
519
- const SizedBox(height: 2),
520
- Text(
521
- value,
522
- style:
523
- (isPrimary
524
- ? theme.textTheme.titleMedium
525
- : theme.textTheme.bodyMedium)
526
- ?.copyWith(
527
- fontWeight: FontWeight.bold,
528
- color: color,
529
- letterSpacing: isPrimary ? -0.5 : null,
530
- ),
531
- ),
532
- ],
154
+ ),
155
+ if (defaultTargetPlatform == TargetPlatform.android ||
156
+ defaultTargetPlatform == TargetPlatform.iOS)
157
+ ShadButton.outline(
158
+ size: ShadButtonSize.sm,
159
+ onPressed: onImportGguf,
160
+ child: const Text('Import GGUF'),
161
+ ),
162
+ ],
163
+ ),
164
);
165
}
166
}
167
537
-class _ModelCard extends ConsumerWidget {
538
- const _ModelCard({
539
- required this.model,
540
- required this.theme,
541
- required this.downloadedAsync,
542
- required this.downloadProgress,
168
+class _EngineStatusCard extends StatelessWidget {
169
+ const _EngineStatusCard({
170
required this.engineState,
544
- this.deviceMemory,
171
+ required this.models,
172
+ required this.l10n,
173
+ required this.theme,
174
});
175
547
- final OnDeviceModel model;
548
- final ThemeData theme;
549
- final AsyncValue<Set<String>> downloadedAsync;
550
- final DownloadProgressInfo? downloadProgress;
176
final OnDeviceEngineState engineState;
552
- final DeviceMemoryInfo? deviceMemory;
177
+ final List<OnDeviceModel> models;
178
+ final AppLocalizations l10n;
179
+ final ThemeData theme;
180
181
@override
555
- Widget build(BuildContext context, WidgetRef ref) {
556
- final l10n = AppLocalizations.of(context)!;
557
- final isDownloaded = downloadedAsync.when(
558
- data: (set) => set.contains(model.id),
559
- loading: () => false,
560
- error: (_, _) => false,
561
- );
562
-
563
- final isLoading =
564
- engineState.status == OnDeviceEngineStatus.loading &&
565
- engineState.loadedModelId == model.id;
566
-
567
- final isDownloading =
568
- downloadProgress != null &&
569
- (downloadProgress!.status == DownloadStatus.running ||
570
- downloadProgress!.status == DownloadStatus.pending);
571
-
572
- final isPaused =
573
- downloadProgress?.status == DownloadStatus.paused ||
574
- downloadProgress?.status == DownloadStatus.canceled;
575
-
576
- return Padding(
577
- padding: const EdgeInsets.only(bottom: 12),
578
- child: Container(
579
- padding: const EdgeInsets.all(16),
182
+ Widget build(BuildContext context) {
183
+ if (engineState.status == OnDeviceEngineStatus.loaded) {
184
+ final loadedId = engineState.loadedModelId;
185
+ final loadedName = loadedId == null
186
+ ? 'Unknown'
187
+ : models
188
+ .where((m) => m.id == loadedId)
189
+ .map((m) => m.name)
190
+ .followedBy([loadedId])
191
+ .first;
192
+
193
+ return Container(
194
+ padding: const EdgeInsets.all(12),
195
+ margin: const EdgeInsets.only(bottom: 16),
196
decoration: BoxDecoration(
581
- color: theme.colorScheme.surface,
582
- borderRadius: BorderRadius.circular(12),
583
- border: Border.all(
584
- color: isDownloaded
585
- ? Colors.green.withValues(alpha: 0.5)
586
- : theme.colorScheme.outline.withValues(alpha: 0.3),
587
- width: isDownloaded ? 1.5 : 1,
588
- ),
197
+ color: Colors.green.withValues(alpha: 0.1),
198
+ borderRadius: BorderRadius.circular(8),
199
+ border: Border.all(color: Colors.green),
200
),
590
- child: Column(
591
- crossAxisAlignment: CrossAxisAlignment.start,
201
+ child: Row(
202
children: [
593
- Row(
594
- children: [
595
- Expanded(
596
- child: Text(
597
- model.name,
598
- style: theme.textTheme.titleSmall?.copyWith(
599
- fontWeight: FontWeight.bold,
600
- ),
601
- ),
602
- ),
603
- if (model.isRecommended)
604
- Container(
605
- padding: const EdgeInsets.symmetric(
606
- horizontal: 8,
607
- vertical: 2,
608
- ),
609
- decoration: BoxDecoration(
610
- color: theme.colorScheme.primary.withValues(alpha: 0.1),
611
- borderRadius: BorderRadius.circular(4),
612
- ),
613
- child: Text(
614
- l10n.recommended,
615
- style: theme.textTheme.labelSmall?.copyWith(
616
- color: theme.colorScheme.primary,
617
- fontWeight: FontWeight.bold,
618
- ),
619
- ),
620
- ),
621
- if (model.isImported)
622
- Container(
623
- margin: const EdgeInsets.only(left: 6),
624
- padding: const EdgeInsets.symmetric(
625
- horizontal: 8,
626
- vertical: 2,
627
- ),
628
- decoration: BoxDecoration(
629
- color: Colors.blue.withValues(alpha: 0.1),
630
- borderRadius: BorderRadius.circular(4),
631
- ),
632
- child: Text(
633
- 'Imported',
634
- style: theme.textTheme.labelSmall?.copyWith(
635
- color: Colors.blue,
636
- fontWeight: FontWeight.bold,
637
- ),
638
- ),
639
- ),
640
- ],
641
- ),
642
- if (!Platform.isIOS &&
643
- deviceMemory != null &&
644
- deviceMemory!.isOversized(model.minRamMb))
645
- Padding(
646
- padding: const EdgeInsets.only(top: 6),
647
- child: Container(
648
- padding: const EdgeInsets.symmetric(
649
- horizontal: 8,
650
- vertical: 4,
651
- ),
652
- decoration: BoxDecoration(
653
- color: Colors.orange.withValues(alpha: 0.1),
654
- borderRadius: BorderRadius.circular(6),
655
- border: Border.all(
656
- color: Colors.orange.withValues(alpha: 0.3),
657
- ),
658
- ),
659
- child: Row(
660
- mainAxisSize: MainAxisSize.min,
661
- children: [
662
- const Icon(
663
- Icons.warning_amber_rounded,
664
- color: Colors.orange,
665
- size: 14,
666
- ),
667
- const SizedBox(width: 6),
668
- Text(
669
- l10n.may_be_large,
670
- style: theme.textTheme.labelSmall?.copyWith(
671
- color: Colors.orange,
672
- fontWeight: FontWeight.bold,
673
- ),
674
- ),
675
- ],
676
- ),
677
- ),
678
- ),
679
- const SizedBox(height: 4),
680
- Text(
681
- model.description,
682
- style: theme.textTheme.bodySmall?.copyWith(
683
- color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
203
+ const Icon(Icons.check_circle, color: Colors.green, size: 20),
204
+ const SizedBox(width: 8),
205
+ Expanded(
206
+ child: Text(
207
+ l10n.model_loaded(loadedName, engineState.backend?.name ?? 'CPU'),
208
+ style: const TextStyle(color: Colors.green),
209
),
210
),
686
- const SizedBox(height: 8),
687
- _buildCapabilityChips(context),
688
- const SizedBox(height: 8),
689
- Row(
690
- children: [
691
- Text(
692
- model.fileSizeFormatted,
693
- style: theme.textTheme.labelMedium,
694
- ),
695
- const SizedBox(width: 12),
696
- Text(model.license, style: theme.textTheme.labelMedium),
697
- const SizedBox(width: 12),
698
- Text(
699
- l10n.ram_min_required('${model.minRamMb ~/ 1024 + 1}'),
700
- style: theme.textTheme.labelMedium?.copyWith(
701
- color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
702
- ),
703
- ),
704
- ],
705
- ),
706
- const SizedBox(height: 8),
707
- _buildActionRow(
708
- context,
709
- ref,
710
- isDownloaded,
711
- isLoading,
712
- isDownloading,
713
- isPaused,
714
- ),
211
],
212
),
717
- ),
718
- );
719
- }
720
-
721
- Widget _buildCapabilityChips(BuildContext context) {
722
- final l10n = AppLocalizations.of(context)!;
723
- final chips = <String>[
724
- if (model.format == OnDeviceModelFormat.gguf) 'GGUF',
725
- if (model.runtime == OnDeviceModelRuntime.llamaCpp) 'llama.cpp',
726
- if (model.supportsFunctionCalling) 'Tools',
727
- if (model.supportsThinking) 'Thinking',
728
- if (model.supportsVision) 'Vision',
729
- model.languagesLabel,
730
- if (model.backendNote != null) model.backendNote!,
731
- if (model.requiresHuggingFaceToken)
732
- l10n.model_requires_huggingface_token,
733
- ];
734
-
735
- return Wrap(
736
- spacing: 6,
737
- runSpacing: 6,
738
- children: chips.map(_buildCapabilityChip).toList(),
739
- );
740
- }
741
-
742
- Widget _buildCapabilityChip(String label) {
743
- final isBackendNote = label == model.backendNote;
744
- return Container(
745
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
746
- decoration: BoxDecoration(
747
- color: (isBackendNote ? Colors.orange : theme.colorScheme.primary)
748
- .withValues(alpha: 0.1),
749
- borderRadius: BorderRadius.circular(6),
750
- border: Border.all(
751
- color: (isBackendNote ? Colors.orange : theme.colorScheme.primary)
752
- .withValues(alpha: 0.2),
753
- ),
754
- ),
755
- child: Text(
756
- label,
757
- style: theme.textTheme.labelSmall?.copyWith(
758
- color: isBackendNote ? Colors.orange : theme.colorScheme.primary,
759
- fontWeight: FontWeight.w600,
760
- ),
761
- ),
762
- );
763
- }
764
-
765
- Widget _buildActionRow(
766
- BuildContext context,
767
- WidgetRef ref,
768
- bool isDownloaded,
769
- bool isLoading,
770
- bool isDownloading,
771
- bool isPaused,
772
- ) {
773
- final l10n = AppLocalizations.of(context)!;
774
- final isLoaded = engineState.loadedModelId == model.id;
775
-
776
- if (isDownloaded) {
777
- return Row(
778
- children: [
779
- Icon(
780
- isLoaded ? Icons.check_circle : Icons.check_circle_outline,
781
- color: isLoaded ? Colors.green : Colors.grey,
782
- size: 18,
783
- ),
784
- const SizedBox(width: 4),
785
- Text(
786
- isLoaded ? l10n.loaded_status : l10n.downloaded,
787
- style: theme.textTheme.bodySmall?.copyWith(
788
- color: isLoaded ? Colors.green : Colors.grey,
789
- fontWeight: FontWeight.w600,
790
- ),
791
- ),
792
- const Spacer(),
793
- if (isLoading)
794
- const SizedBox(
795
- width: 20,
796
- height: 20,
797
- child: CircularProgressIndicator(strokeWidth: 2),
798
- )
799
- else if (isLoaded)
800
- ShadButton.outline(
801
- size: ShadButtonSize.sm,
802
- onPressed: () => _unloadModel(ref),
803
- child: Text(l10n.unload),
804
- )
805
- else
806
- ShadButton.outline(
807
- size: ShadButtonSize.sm,
808
- onPressed: () => _loadModel(context, ref),
809
- child: Text(l10n.load),
810
- ),
811
- const SizedBox(width: 8),
812
- ShadButton.outline(
813
- size: ShadButtonSize.sm,
814
- onPressed: () => _deleteModel(context, ref),
815
- child: Text(l10n.delete),
816
- ),
817
- ],
818
- );
819
- }
820
-
821
- if (isDownloading) {
822
- return Column(
823
- crossAxisAlignment: CrossAxisAlignment.start,
824
- children: [
825
- Row(
826
- children: [
827
- Expanded(
828
- child: Column(
829
- crossAxisAlignment: CrossAxisAlignment.start,
830
- children: [
831
- ClipRRect(
832
- borderRadius: BorderRadius.circular(4),
833
- child: LinearProgressIndicator(
834
- value: downloadProgress?.progress ?? 0.0,
835
- minHeight: 6,
836
- backgroundColor: theme.colorScheme.primary.withValues(
837
- alpha: 0.1,
838
- ),
839
- valueColor: AlwaysStoppedAnimation<Color>(
840
- theme.colorScheme.primary,
841
- ),
842
- ),
843
- ),
844
- const SizedBox(height: 6),
845
- Row(
846
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
847
- children: [
848
- Text(
849
- l10n.download_progress(
850
- ((downloadProgress?.progress ?? 0) * 100)
851
- .toStringAsFixed(1),
852
- downloadProgress?.speedFormatted ?? '0 B/s',
853
- ),
854
- style: theme.textTheme.labelSmall?.copyWith(
855
- fontWeight: FontWeight.w600,
856
- ),
857
- ),
858
- Text(
859
- l10n.eta_label(
860
- downloadProgress?.etaFormatted ?? l10n.calculating,
861
- ),
862
- style: theme.textTheme.labelSmall,
863
- ),
864
- ],
865
- ),
866
- const SizedBox(height: 2),
867
- Text(
868
- downloadProgress?.progressFormatted ?? '',
869
- style: theme.textTheme.labelSmall?.copyWith(
870
- color: theme.colorScheme.onSurface.withValues(
871
- alpha: 0.5,
872
- ),
873
- fontSize: 10,
874
- ),
875
- ),
876
- ],
877
- ),
878
- ),
879
- const SizedBox(width: 12),
880
- ShadButton.outline(
881
- size: ShadButtonSize.sm,
882
- onPressed: () => ref
883
- .read(foregroundDownloadNotifierProvider.notifier)
884
- .cancelDownload(model.id),
885
- child: Text(l10n.cancel),
886
- ),
887
- ],
888
- ),
889
- ],
890
- );
891
- }
892
-
893
- if (downloadProgress?.status == DownloadStatus.failed) {
894
- return Row(
895
- children: [
896
- Expanded(
897
- child: Text(
898
- downloadProgress?.error ?? l10n.download_failed,
899
- style: theme.textTheme.bodySmall?.copyWith(color: Colors.red),
900
- ),
901
- ),
902
- const SizedBox(width: 8),
903
- ShadButton.outline(
904
- size: ShadButtonSize.sm,
905
- onPressed: () => ref
906
- .read(foregroundDownloadNotifierProvider.notifier)
907
- .retryDownload(model.id),
908
- child: Text(l10n.retry),
909
- ),
910
- ],
213
);
214
}
215
914
- if (isPaused) {
915
- return Row(
916
- children: [
917
- Text(
918
- l10n.paused_progress(
919
- ((downloadProgress?.progress ?? 0) * 100).toStringAsFixed(0),
920
- ),
921
- style: theme.textTheme.bodySmall,
922
- ),
923
- const Spacer(),
924
- ShadButton.outline(
925
- size: ShadButtonSize.sm,
926
- onPressed: () => _startDownload(context, ref),
927
- child: Text(l10n.resume),
928
- ),
929
- ],
216
+ if (engineState.status == OnDeviceEngineStatus.loading) {
217
+ return const Padding(
218
+ padding: EdgeInsets.only(bottom: 16),
219
+ child: LinearProgressIndicator(),
220
);
221
}
222
933
- return Row(
934
- children: [
935
- const Spacer(),
936
- ShadButton.outline(
937
- size: ShadButtonSize.sm,
938
- onPressed: () => _startDownload(context, ref),
939
- child: Text(l10n.download),
223
+ if (engineState.status == OnDeviceEngineStatus.error) {
224
+ return Container(
225
+ padding: const EdgeInsets.all(12),
226
+ margin: const EdgeInsets.only(bottom: 16),
227
+ decoration: BoxDecoration(
228
+ color: Colors.red.withValues(alpha: 0.1),
229
+ borderRadius: BorderRadius.circular(8),
230
+ border: Border.all(color: Colors.red),
231
),
941
- ],
942
- );
943
- }
944
-
945
- Future<void> _startDownload(BuildContext context, WidgetRef ref) async {
946
- final l10n = AppLocalizations.of(context)!;
947
- if (!Platform.isIOS && deviceMemory != null) {
948
- if (deviceMemory!.isOversized(model.minRamMb)) {
949
- final proceed = await _showRamWarning(
950
- context,
951
- l10n.ram_warning_body_download(
952
- '${model.minRamMb ~/ 1024 + 1}',
953
- deviceMemory!.totalMemoryFormatted,
954
- ),
955
- );
956
- if (!proceed) return;
957
- }
958
- }
959
-
960
- final result = await ref
961
- .read(foregroundDownloadNotifierProvider.notifier)
962
- .startDownload(model.id);
963
-
964
- if (result == 'missing_huggingface_token' && context.mounted) {
965
- ScaffoldMessenger.of(context).showSnackBar(
966
- SnackBar(
967
- content: Text(l10n.model_missing_huggingface_token),
968
- duration: const Duration(seconds: 6),
969
- action: SnackBarAction(
970
- label: l10n.settings_title,
971
- onPressed: () => context.push(AppRoutes.settings),
972
- ),
232
+ child: Text(
233
+ engineState.error ?? l10n.unknown_error,
234
+ style: const TextStyle(color: Colors.red),
235
),
236
);
237
}
976
- }
977
-
978
- Future<void> _loadModel(BuildContext context, WidgetRef ref) async {
979
- final l10n = AppLocalizations.of(context)!;
980
- if (!Platform.isIOS && deviceMemory != null) {
981
- if (!deviceMemory!.hasEnoughRam(model.minRamMb)) {
982
- final proceed = await _showRamWarning(
983
- context,
984
- l10n.ram_warning_body_load(
985
- deviceMemory!.availableMemoryFormatted,
986
- '${model.minRamMb ~/ 1024 + 1}',
987
- ),
988
- );
989
- if (!proceed) return;
990
- }
991
- }
238
993
- final settings = ref.read(settingsProvider);
994
- final engineNotifier = ref.read(onDeviceEngineProvider.notifier);
995
- await engineNotifier.loadModel(model.id, settings.preferredBackend);
996
- }
997
-
998
- Future<bool> _showRamWarning(BuildContext context, String message) async {
999
- final l10n = AppLocalizations.of(context)!;
1000
- final result = await showDialog<bool>(
1001
- context: context,
1002
- builder: (ctx) => AlertDialog(
1003
- title: Row(
1004
- children: [
1005
- const Icon(Icons.warning_amber_rounded, color: Colors.orange),
1006
- const SizedBox(width: 8),
1007
- Text(l10n.ram_warning),
1008
- ],
1009
- ),
1010
- content: Text(message),
1011
- actions: [
1012
- TextButton(
1013
- onPressed: () => Navigator.pop(ctx, false),
1014
- child: Text(l10n.cancel),
1015
- ),
1016
- TextButton(
1017
- onPressed: () => Navigator.pop(ctx, true),
1018
- style: TextButton.styleFrom(foregroundColor: Colors.orange),
1019
- child: Text(l10n.proceed_anyway),
1020
- ),
1021
- ],
1022
- ),
1023
- );
1024
- return result ?? false;
1025
- }
1026
-
1027
- Future<void> _unloadModel(WidgetRef ref) async {
1028
- await ref.read(onDeviceEngineProvider.notifier).unloadModel();
1029
- }
1030
-
1031
- Future<void> _deleteModel(BuildContext context, WidgetRef ref) async {
1032
- final l10n = AppLocalizations.of(context)!;
1033
- final confirm = await showDialog<bool>(
1034
- context: context,
1035
- builder: (ctx) => AlertDialog(
1036
- title: Text(l10n.delete_model_title),
1037
- content: Text(l10n.delete_model_body(model.name)),
1038
- actions: [
1039
- TextButton(
1040
- onPressed: () => Navigator.pop(ctx, false),
1041
- child: Text(l10n.cancel),
1042
- ),
1043
- TextButton(
1044
- onPressed: () => Navigator.pop(ctx, true),
1045
- child: Text(l10n.delete),
1046
- ),
1047
- ],
1048
- ),
1049
- );
1050
-
1051
- if (confirm == true) {
1052
- final engineState = ref.read(onDeviceEngineProvider);
1053
- if (engineState.loadedModelId == model.id) {
1054
- await ref.read(onDeviceEngineProvider.notifier).unloadModel();
1055
- }
1056
-
1057
- if (model.isImported) {
1058
- await ref
1059
- .read(importedGgufModelsProvider.notifier)
1060
- .deleteModel(model.id);
1061
- } else {
1062
- final gemmaService = ref.read(onDeviceGemmaServiceProvider);
1063
- await gemmaService.deleteModel(model.id);
1064
- }
1065
- ref.invalidate(downloadedModelsProvider);
1066
- }
239
+ return const SizedBox.shrink();
240
}
241
}