@tduncan / Cortana / commits / c955616

feat: refactor chat architecture with modular UI components

abdulmominsakib committed Jun 26, 2026 at 00:01 UTC c955616159230c4365dfa2ad5b2462af36501a56
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 }