@tduncan / Cortana / commits / 9ce9729

Add backups, model metadata & persona picker

Adds data backup/import (DataBackupService) and settings UI to export/import conversations/personas. Introduces PersonaPickerSheet and integrates persona selection across the chat UI. Adds model metadata storage/providers (favorites, notes), model instance utils, and UI improvements in the model picker (context-length input, loaded count, unload-all, long-press model options). ServerApiService: accept contextLength on load, add unload helpers and better error extraction. Chat: edit assistant messages, branch-from-message flow, show model id on assistant bubbles, and branch action in message menus. Settings: unloadModelsBeforeLoad option and file-picker based backup actions. Localization and test updates included, plus several bugfixes and small UI/behavior tweaks.

onlypuppy7 committed Jun 29, 2026 at 22:13 UTC 9ce97293dacbcd3ae00d54451ac9f718c5aed49d
35 files changed +2087 -435
lib/app.dart
+36 -26
@@ -259,36 +259,46 @@ class AppShell extends StatelessWidget {
259 Widget build(BuildContext context) {
260 final theme = Theme.of(context);
261 final isDark = theme.brightness == Brightness.dark;
262 + final location = GoRouterState.of(context).uri.path;
263 + final isHome = location == AppRoutes.home;
264
263 - return ShadResponsiveBuilder(
264 - builder: (context, breakpoint) {
265 - final isDesktop = breakpoint >= ShadTheme.of(context).breakpoints.md;
265 + return PopScope(
266 + canPop: isHome,
267 + onPopInvokedWithResult: (didPop, result) {
268 + if (!didPop && !isHome) {
269 + context.go(AppRoutes.home);
270 + }
271 + },
272 + child: ShadResponsiveBuilder(
273 + builder: (context, breakpoint) {
274 + final isDesktop = breakpoint >= ShadTheme.of(context).breakpoints.md;
275 +
276 + if (isDesktop) {
277 + return Scaffold(
278 + body: Row(
279 + children: [
280 + const SidebarWidget(),
281 + VerticalDivider(
282 + width: 1,
283 + thickness: 1,
284 + color: isDark
285 + ? const Color(0xFF1A1A1A)
286 + : const Color(0xFFE5E5E5),
287 + ),
288 + Expanded(
289 + child: child,
290 + ),
291 + ],
292 + ),
293 + );
294 + }
295
267 - if (isDesktop) {
296 return Scaffold(
269 - body: Row(
270 - children: [
271 - const SidebarWidget(),
272 - VerticalDivider(
273 - width: 1,
274 - thickness: 1,
275 - color: isDark
276 - ? const Color(0xFF1A1A1A)
277 - : const Color(0xFFE5E5E5),
278 - ),
279 - Expanded(
280 - child: child,
281 - ),
282 - ],
283 - ),
297 + body: child,
298 + drawer: const ConversationDrawer(),
299 );
285 - }
286 -
287 - return Scaffold(
288 - body: child,
289 - drawer: const ConversationDrawer(),
290 - );
291 - },
300 + },
301 + ),
302 );
303 }
304 }
lib/core/providers/app_providers.dart
+2
@@ -104,6 +104,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
104 _update(state.copyWith(localeCode: value));
105 void setHuggingFaceToken(String? value) =>
106 _update(state.copyWith(huggingFaceToken: value));
107 + void setUnloadModelsBeforeLoad(bool value) =>
108 + _update(state.copyWith(unloadModelsBeforeLoad: value));
109
110 Future<void> _update(AppSettings updated) async {
111 state = updated;
lib/core/services/data_backup_service.dart new
+214
@@ -0,0 +1,214 @@
1 +import 'dart:convert';
2 +
3 +import '../models/enums.dart';
4 +import '../storage/entities.dart';
5 +import '../../features/chat/data/models/message.dart';
6 +import '../../features/conversations/data/models/conversation.dart';
7 +import '../../features/personas/data/models/persona.dart';
8 +import '../../objectbox.g.dart';
9 +
10 +class DataBackupService {
11 + Map<String, dynamic> exportAll(Store store) {
12 + final convBox = store.box<ConversationEntity>();
13 + final messageBox = store.box<MessageEntity>();
14 + final personaBox = store.box<PersonaEntity>();
15 +
16 + final conversations = convBox.getAll().map((entity) {
17 + final domain = entity.toDomain();
18 + return {
19 + 'id': domain.id,
20 + 'title': domain.title,
21 + 'createdAt': domain.createdAt.toIso8601String(),
22 + 'updatedAt': domain.updatedAt.toIso8601String(),
23 + 'isPinned': domain.isPinned,
24 + 'personaId': domain.personaId,
25 + 'serverId': domain.serverId,
26 + 'modelId': domain.modelId,
27 + 'messageCount': domain.messageCount,
28 + 'lastMessagePreview': domain.lastMessagePreview,
29 + 'systemPrompt': domain.systemPrompt,
30 + 'temperature': domain.temperature,
31 + 'topP': domain.topP,
32 + 'maxTokens': domain.maxTokens,
33 + 'contextLength': domain.contextLength,
34 + 'mcpEnabled': domain.mcpEnabled,
35 + 'smartReplies': domain.smartReplies,
36 + 'smartRepliesLastMessageId': domain.smartRepliesLastMessageId,
37 + };
38 + }).toList();
39 +
40 + final messages = messageBox.getAll().map((entity) {
41 + final message = entity.toDomain();
42 + return {
43 + 'id': message.id,
44 + 'conversationId': message.conversationId,
45 + 'role': message.role.name,
46 + 'content': message.content,
47 + 'createdAt': message.createdAt.toIso8601String(),
48 + 'status': message.status.name,
49 + 'modelId': message.modelId,
50 + 'tokenCount': message.tokenCount,
51 + 'errorMessage': message.errorMessage,
52 + 'attachmentPaths': message.attachmentPaths,
53 + 'generationTimeMs': message.generationTimeMs,
54 + 'reasoningContent': message.reasoningContent,
55 + 'toolCallId': message.toolCallId,
56 + 'isProcessing': message.isProcessing,
57 + 'toolSessionId': message.toolSessionId,
58 + };
59 + }).toList();
60 +
61 + final personas = personaBox
62 + .getAll()
63 + .where((entity) => !entity.isBuiltIn)
64 + .map((entity) {
65 + final persona = entity.toDomain();
66 + return {
67 + 'id': persona.id,
68 + 'name': persona.name,
69 + 'emoji': persona.emoji,
70 + 'systemPrompt': persona.systemPrompt,
71 + 'description': persona.description,
72 + 'isBuiltIn': persona.isBuiltIn,
73 + 'createdAt': persona.createdAt.toIso8601String(),
74 + 'updatedAt': persona.updatedAt.toIso8601String(),
75 + 'category': persona.category,
76 + 'preferredParams': persona.preferredParams,
77 + };
78 + })
79 + .toList();
80 +
81 + return {
82 + 'version': 1,
83 + 'exportedAt': DateTime.now().toIso8601String(),
84 + 'conversations': conversations,
85 + 'messages': messages,
86 + 'personas': personas,
87 + };
88 + }
89 +
90 + String exportAllAsJson(Store store) {
91 + return const JsonEncoder.withIndent(' ').convert(exportAll(store));
92 + }
93 +
94 + Future<void> importFromJson(Store store, String jsonString) async {
95 + final decoded = jsonDecode(jsonString);
96 + if (decoded is! Map<String, dynamic>) {
97 + throw FormatException('Invalid backup format');
98 + }
99 +
100 + await store.runInTransactionAsync(TxMode.write, (store, data) {
101 + final convBox = store.box<ConversationEntity>();
102 + final messageBox = store.box<MessageEntity>();
103 + final personaBox = store.box<PersonaEntity>();
104 +
105 + final personas = data['personas'];
106 + if (personas is List) {
107 + for (final item in personas) {
108 + if (item is! Map) continue;
109 + final persona = Persona(
110 + id: item['id'] as String,
111 + name: item['name'] as String,
112 + emoji: item['emoji'] as String? ?? '🤖',
113 + systemPrompt: item['systemPrompt'] as String? ?? '',
114 + description: item['description'] as String?,
115 + isBuiltIn: item['isBuiltIn'] as bool? ?? false,
116 + createdAt: DateTime.parse(item['createdAt'] as String),
117 + updatedAt: DateTime.parse(item['updatedAt'] as String),
118 + category: item['category'] as String?,
119 + preferredParams: item['preferredParams'] is Map
120 + ? Map<String, dynamic>.from(item['preferredParams'] as Map)
121 + : null,
122 + );
123 + final query =
124 + personaBox.query(PersonaEntity_.id.equals(persona.id)).build();
125 + final existing = query.findFirst();
126 + query.close();
127 + final entity = PersonaEntity.fromDomain(persona);
128 + if (existing != null) entity.internalId = existing.internalId;
129 + personaBox.put(entity);
130 + }
131 + }
132 +
133 + final conversations = data['conversations'];
134 + if (conversations is List) {
135 + for (final item in conversations) {
136 + if (item is! Map) continue;
137 + final conversation = Conversation(
138 + id: item['id'] as String,
139 + title: item['title'] as String? ?? 'Imported Chat',
140 + createdAt: DateTime.parse(item['createdAt'] as String),
141 + updatedAt: DateTime.parse(item['updatedAt'] as String),
142 + isPinned: item['isPinned'] as bool? ?? false,
143 + personaId: item['personaId'] as String?,
144 + serverId: item['serverId'] as String?,
145 + modelId: item['modelId'] as String?,
146 + messageCount: item['messageCount'] as int? ?? 0,
147 + lastMessagePreview: item['lastMessagePreview'] as String?,
148 + systemPrompt: item['systemPrompt'] as String?,
149 + temperature: (item['temperature'] as num?)?.toDouble(),
150 + topP: (item['topP'] as num?)?.toDouble(),
151 + maxTokens: item['maxTokens'] as int?,
152 + contextLength: item['contextLength'] as int?,
153 + mcpEnabled: item['mcpEnabled'] as bool?,
154 + smartReplies: item['smartReplies'] is List
155 + ? List<String>.from(item['smartReplies'] as List)
156 + : null,
157 + smartRepliesLastMessageId:
158 + item['smartRepliesLastMessageId'] as String?,
159 + );
160 + final query =
161 + convBox.query(ConversationEntity_.id.equals(conversation.id)).build();
162 + final existing = query.findFirst();
163 + query.close();
164 + final entity = ConversationEntity.fromDomain(conversation);
165 + if (existing != null) entity.internalId = existing.internalId;
166 + convBox.put(entity);
167 + }
168 + }
169 +
170 + final messages = data['messages'];
171 + if (messages is List) {
172 + for (final item in messages) {
173 + if (item is! Map) continue;
174 + final convQuery = convBox
175 + .query(ConversationEntity_.id.equals(item['conversationId'] as String))
176 + .build();
177 + final convEntity = convQuery.findFirst();
178 + convQuery.close();
179 + if (convEntity == null) continue;
180 +
181 + final message = Message(
182 + id: item['id'] as String,
183 + conversationId: item['conversationId'] as String,
184 + role: MessageRole.values.byName(item['role'] as String? ?? 'user'),
185 + content: item['content'] as String? ?? '',
186 + createdAt: DateTime.parse(item['createdAt'] as String),
187 + status: MessageStatus.values.byName(
188 + item['status'] as String? ?? 'complete',
189 + ),
190 + modelId: item['modelId'] as String?,
191 + tokenCount: item['tokenCount'] as int?,
192 + errorMessage: item['errorMessage'] as String?,
193 + attachmentPaths: item['attachmentPaths'] is List
194 + ? List<String>.from(item['attachmentPaths'] as List)
195 + : null,
196 + generationTimeMs: item['generationTimeMs'] as int?,
197 + reasoningContent: item['reasoningContent'] as String?,
198 + toolCallId: item['toolCallId'] as String?,
199 + isProcessing: item['isProcessing'] as bool? ?? false,
200 + toolSessionId: item['toolSessionId'] as String?,
201 + );
202 +
203 + final entity = MessageEntity.fromDomain(message)
204 + ..conversation.target = convEntity;
205 + final query = messageBox.query(MessageEntity_.id.equals(message.id)).build();
206 + final existing = query.findFirst();
207 + query.close();
208 + if (existing != null) entity.internalId = existing.internalId;
209 + messageBox.put(entity);
210 + }
211 + }
212 + }, decoded);
213 + }
214 +}
lib/features/chat/data/chat_service.dart
+3 -2
@@ -166,7 +166,6 @@ class LMStudioChatService implements ChatService {
166 'temperature': params.temperature,
167 'top_p': params.topP,
168 'max_output_tokens': params.maxTokens,
169 - 'context_length': params.contextLength,
169 'stream': true,
170 'store': true,
171 };
@@ -393,9 +392,11 @@ class LMStudioChatService implements ChatService {
392 case 'error':
393 final error = json['error'] as Map<String, dynamic>?;
394 if (error != null) {
395 + final type = error['type']?.toString();
396 + final message = error['message']?.toString() ?? 'Unknown error';
397 yield ChatResponse(
398 type: ChatResponseType.error,
398 - content: 'Error: ${error['message'] ?? 'Unknown error'}',
399 + content: type != null ? '$type: $message' : message,
400 );
401 }
402 break;
lib/features/chat/providers/chat_notifier.dart
+89 -6
@@ -930,8 +930,9 @@ class ChatNotifier extends Notifier<ChatState> {
930 (m) => m.role == MessageRole.assistant,
931 );
932 if (lastAssistantIndex > 0) {
933 - final userMessage = messages[lastAssistantIndex - 1];
934 - final messagesToRemove = messages.sublist(lastAssistantIndex);
933 + final userMessageIndex = lastAssistantIndex - 1;
934 + final userMessage = messages[userMessageIndex];
935 + final messagesToRemove = messages.sublist(userMessageIndex);
936 final db = ref.read(databaseProvider);
937
938 for (final msg in messagesToRemove) {
@@ -943,7 +944,7 @@ class ChatNotifier extends Notifier<ChatState> {
944 }
945
946 state = state.copyWith(
946 - messages: messages.sublist(0, lastAssistantIndex),
947 + messages: messages.sublist(0, userMessageIndex),
948 clearStreaming: true,
949 );
950
@@ -966,9 +967,10 @@ class ChatNotifier extends Notifier<ChatState> {
967
968 if (messageIndex > 0 &&
969 state.messages[messageIndex - 1].role == MessageRole.user) {
969 - final userMessage = state.messages[messageIndex - 1];
970 + final userMessageIndex = messageIndex - 1;
971 + final userMessage = state.messages[userMessageIndex];
972
971 - final messagesToRemove = state.messages.sublist(messageIndex);
973 + final messagesToRemove = state.messages.sublist(userMessageIndex);
974 final db = ref.read(databaseProvider);
975
976 for (final msg in messagesToRemove) {
@@ -980,7 +982,7 @@ class ChatNotifier extends Notifier<ChatState> {
982 }
983
984 state = state.copyWith(
983 - messages: state.messages.sublist(0, messageIndex),
985 + messages: state.messages.sublist(0, userMessageIndex),
986 clearStreaming: true,
987 );
988
@@ -991,6 +993,87 @@ class ChatNotifier extends Notifier<ChatState> {
993 }
994 }
995
996 + Future<void> editAssistantMessage(String messageId, String newContent) async {
997 + final messageIndex = state.messages.indexWhere((m) => m.id == messageId);
998 + if (messageIndex == -1) return;
999 +
1000 + final message = state.messages[messageIndex];
1001 + if (message.role != MessageRole.assistant) return;
1002 + if (newContent.trim().isEmpty) return;
1003 + if (newContent == message.content) return;
1004 +
1005 + final updated = message.copyWith(content: newContent);
1006 + await _saveMessage(updated);
1007 + _replaceMessageInState(updated);
1008 + }
1009 +
1010 + Future<void> branchFromMessage(String messageId) async {
1011 + final messageIndex = state.messages.indexWhere((m) => m.id == messageId);
1012 + if (messageIndex == -1) return;
1013 +
1014 + final activeConv = ref.read(conv.activeConversationProvider);
1015 + if (activeConv == null) return;
1016 +
1017 + final messagesToCopy = state.messages.sublist(0, messageIndex + 1);
1018 + final branchTitle = activeConv.title.length > 40
1019 + ? '${activeConv.title.substring(0, 40)}...'
1020 + : activeConv.title;
1021 +
1022 + final newConversation = await ref
1023 + .read(conv.conversationsProvider.notifier)
1024 + .createConversation(
1025 + title: '$branchTitle (branch)',
1026 + personaId: activeConv.personaId,
1027 + systemPrompt: activeConv.systemPrompt,
1028 + serverId: activeConv.serverId,
1029 + modelId: activeConv.modelId,
1030 + mcpEnabled: activeConv.mcpEnabled,
1031 + );
1032 +
1033 + if (activeConv.temperature != null ||
1034 + activeConv.topP != null ||
1035 + activeConv.maxTokens != null ||
1036 + activeConv.contextLength != null) {
1037 + await ref.read(conv.conversationsProvider.notifier).updateChatParams(
1038 + newConversation.id,
1039 + temperature: activeConv.temperature,
1040 + topP: activeConv.topP,
1041 + maxTokens: activeConv.maxTokens,
1042 + contextLength: activeConv.contextLength,
1043 + );
1044 + }
1045 +
1046 + for (final msg in messagesToCopy) {
1047 + final copied = msg.copyWith(
1048 + id: generateUuid(),
1049 + conversationId: newConversation.id,
1050 + );
1051 + await _saveMessage(copied);
1052 + }
1053 +
1054 + final lastMessage = messagesToCopy.last;
1055 + final preview = lastMessage.content.length > 100
1056 + ? '${lastMessage.content.substring(0, 100)}...'
1057 + : lastMessage.content;
1058 +
1059 + await ref.read(conv.conversationsProvider.notifier).updatePreview(
1060 + newConversation.id,
1061 + preview,
1062 + DateTime.now(),
1063 + messageCount: messagesToCopy.length,
1064 + );
1065 +
1066 + final refreshedConversations = ref.read(conv.conversationsProvider).value;
1067 + final branchedConversation = refreshedConversations?.firstWhere(
1068 + (c) => c.id == newConversation.id,
1069 + orElse: () => newConversation,
1070 + );
1071 +
1072 + if (branchedConversation != null) {
1073 + await loadConversation(branchedConversation);
1074 + }
1075 + }
1076 +
1077 Future<void> deleteMessage(String messageId) async {
1078 final db = ref.read(databaseProvider);
1079 final query = db.messageBox
lib/features/chat/views/chat_screen.dart
+25 -302
@@ -5,7 +5,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
5 import 'package:go_router/go_router.dart';
6 import 'package:hugeicons/hugeicons.dart';
7 import 'package:shadcn_ui/shadcn_ui.dart';
8 -import 'package:localmind/core/theme/colors.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';
@@ -26,6 +25,7 @@ import 'components/chat_auto_scroll_controller.dart';
25 import 'components/chat_input_bar.dart';
26 import 'components/chat_settings_sheet.dart';
27 import 'components/edit_message_dialog.dart';
28 +import 'components/persona_picker_sheet.dart';
29 import 'components/notification_permission_banner.dart';
30 import 'components/message_list/message_list.dart';
31 import 'components/message_list/empty_state.dart';
@@ -114,7 +114,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
114 case 'new_chat':
115 ref.read(chatProvider.notifier).startNewConversation();
116 case 'persona':
117 - _showPersonaPicker(context);
117 + showPersonaPickerSheet(context);
118 case 'remove_persona':
119 final activeConv = ref.read(conv.activeConversationProvider);
120 if (activeConv != null) {
@@ -149,108 +149,6 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
149 }
150 }
151
152 - void _showPersonaPicker(BuildContext context) {
153 - final personasAsync = ref.read(personasNotifierProvider);
154 - final personas = personasAsync.value ?? [];
155 - final isDark = Theme.of(context).brightness == Brightness.dark;
156 - final activeConv = ref.read(conv.activeConversationProvider);
157 - final currentPersonaId = activeConv?.personaId;
158 -
159 - showModalBottomSheet(
160 - context: context,
161 - isScrollControlled: true,
162 - builder: (ctx) {
163 - final sheetL10n = AppLocalizations.of(context)!;
164 - return SafeArea(
165 - child: Container(
166 - padding: const EdgeInsets.symmetric(vertical: 16),
167 - child: Column(
168 - mainAxisSize: MainAxisSize.min,
169 - children: [
170 - Center(
171 - child: Container(
172 - width: 40,
173 - height: 4,
174 - decoration: BoxDecoration(
175 - color: isDark ? Colors.grey[600] : Colors.grey[300],
176 - borderRadius: BorderRadius.circular(2),
177 - ),
178 - ),
179 - ),
180 - const SizedBox(height: 16),
181 - Text(
182 - sheetL10n.select_persona,
183 - style: TextStyle(
184 - fontSize: 18,
185 - fontWeight: FontWeight.bold,
186 - color: isDark ? Colors.white : Colors.black,
187 - ),
188 - ),
189 - const SizedBox(height: 8),
190 - Flexible(
191 - child: ListView.builder(
192 - shrinkWrap: true,
193 - itemCount: personas.length,
194 - itemBuilder: (context, index) {
195 - final p = personas[index];
196 - final isSelected = p.id == currentPersonaId;
197 - final accent = isDark
198 - ? AppColors.darkAccent
199 - : AppColors.lightAccent;
200 - return ListTile(
201 - leading: Text(
202 - p.emoji,
203 - style: const TextStyle(fontSize: 22),
204 - ),
205 - title: Text(
206 - p.name,
207 - style: TextStyle(
208 - fontWeight: isSelected
209 - ? FontWeight.w600
210 - : FontWeight.normal,
211 - color: isDark ? Colors.white : Colors.black,
212 - ),
213 - ),
214 - subtitle: p.description != null
215 - ? Text(
216 - p.description!,
217 - style: TextStyle(
218 - fontSize: 12,
219 - color: isDark
220 - ? AppColors.darkMutedText
221 - : AppColors.lightMutedText,
222 - ),
223 - maxLines: 1,
224 - overflow: TextOverflow.ellipsis,
225 - )
226 - : null,
227 - trailing: isSelected
228 - ? Icon(Icons.check_circle, color: accent)
229 - : null,
230 - onTap: () {
231 - if (activeConv != null) {
232 - ref
233 - .read(conv.conversationsProvider.notifier)
234 - .updatePersona(
235 - activeConv.id,
236 - p.id,
237 - p.systemPrompt,
238 - );
239 - }
240 - Navigator.pop(ctx);
241 - },
242 - );
243 - },
244 - ),
245 - ),
246 - ],
247 - ),
248 - ),
249 - );
250 - },
251 - );
252 - }
253 -
152 void _showToolApprovalDialog(
153 BuildContext context,
154 PendingToolApproval approval,
@@ -367,7 +265,7 @@ class _ChatBody extends ConsumerWidget {
265 isDark: isDark,
266 persona: persona,
267 onMenuAction: onMenuAction,
370 - onPersonaPicker: () => _openPersonaPicker(context, ref),
268 + onPersonaPicker: () => showPersonaPickerSheet(context),
269 ),
270 const NotificationPermissionBanner(),
271 if (connectionStatus == ConnectionStatus.disconnected ||
@@ -376,7 +274,7 @@ class _ChatBody extends ConsumerWidget {
274 if (persona != null)
275 PersonaIndicator(
276 persona: persona,
379 - onTap: () => _openPersonaPicker(context, ref),
277 + onTap: () => showPersonaPickerSheet(context),
278 onRemove: () {
279 final activeConv = ref.read(conv.activeConversationProvider);
280 if (activeConv != null) {
@@ -415,108 +313,6 @@ class _ChatBody extends ConsumerWidget {
313 }
314 }
315
418 -void _openPersonaPicker(BuildContext context, WidgetRef ref) {
419 - final personasAsync = ref.read(personasNotifierProvider);
420 - final personas = personasAsync.value ?? [];
421 - final isDark = Theme.of(context).brightness == Brightness.dark;
422 - final activeConv = ref.read(conv.activeConversationProvider);
423 - final currentPersonaId = activeConv?.personaId;
424 -
425 - showModalBottomSheet(
426 - context: context,
427 - isScrollControlled: true,
428 - builder: (ctx) {
429 - final sheetL10n = AppLocalizations.of(context)!;
430 - return SafeArea(
431 - child: Container(
432 - padding: const EdgeInsets.symmetric(vertical: 16),
433 - child: Column(
434 - mainAxisSize: MainAxisSize.min,
435 - children: [
436 - Center(
437 - child: Container(
438 - width: 40,
439 - height: 4,
440 - decoration: BoxDecoration(
441 - color: isDark ? Colors.grey[600] : Colors.grey[300],
442 - borderRadius: BorderRadius.circular(2),
443 - ),
444 - ),
445 - ),
446 - const SizedBox(height: 16),
447 - Text(
448 - sheetL10n.select_persona,
449 - style: TextStyle(
450 - fontSize: 18,
451 - fontWeight: FontWeight.bold,
452 - color: isDark ? Colors.white : Colors.black,
453 - ),
454 - ),
455 - const SizedBox(height: 8),
456 - Flexible(
457 - child: ListView.builder(
458 - shrinkWrap: true,
459 - itemCount: personas.length,
460 - itemBuilder: (context, index) {
461 - final p = personas[index];
462 - final isSelected = p.id == currentPersonaId;
463 - final accent = isDark
464 - ? AppColors.darkAccent
465 - : AppColors.lightAccent;
466 - return ListTile(
467 - leading: Text(
468 - p.emoji,
469 - style: const TextStyle(fontSize: 22),
470 - ),
471 - title: Text(
472 - p.name,
473 - style: TextStyle(
474 - fontWeight: isSelected
475 - ? FontWeight.w600
476 - : FontWeight.normal,
477 - color: isDark ? Colors.white : Colors.black,
478 - ),
479 - ),
480 - subtitle: p.description != null
481 - ? Text(
482 - p.description!,
483 - style: TextStyle(
484 - fontSize: 12,
485 - color: isDark
486 - ? AppColors.darkMutedText
487 - : AppColors.lightMutedText,
488 - ),
489 - maxLines: 1,
490 - overflow: TextOverflow.ellipsis,
491 - )
492 - : null,
493 - trailing: isSelected
494 - ? Icon(Icons.check_circle, color: accent)
495 - : null,
496 - onTap: () {
497 - if (activeConv != null) {
498 - ref
499 - .read(conv.conversationsProvider.notifier)
500 - .updatePersona(
501 - activeConv.id,
502 - p.id,
503 - p.systemPrompt,
504 - );
505 - }
506 - Navigator.pop(ctx);
507 - },
508 - );
509 - },
510 - ),
511 - ),
512 - ],
513 - ),
514 - ),
515 - );
516 - },
517 - ).then((_) {});
518 -}
519 -
316 class _MessageArea extends ConsumerWidget {
317 const _MessageArea({
318 required this.isLoading,
@@ -574,7 +370,10 @@ class _MessageArea extends ConsumerWidget {
370 selectedModel: selectedModel,
371 onModelTap: onModelPicker,
372 selectedPersona: ref.watch(selectedPersonaProvider),
577 - onPersonaTap: () => _openPersonaPickerForPreselection(context, ref),
373 + onPersonaTap: () => showPersonaPickerSheet(
374 + context,
375 + mode: PersonaPickerMode.preselection,
376 + ),
377 );
378 }
379
@@ -597,6 +396,23 @@ class _MessageArea extends ConsumerWidget {
396 .editMessage(messageId, newContent);
397 }
398 },
399 + onEditAssistant: (messageId, currentContent) async {
400 + final l10n = AppLocalizations.of(context)!;
401 + final newContent = await EditMessageDialog.show(
402 + context,
403 + initialContent: currentContent,
404 + description: l10n.edit_assistant_message_desc,
405 + saveLabel: l10n.save,
406 + );
407 + if (newContent != null && newContent != currentContent) {
408 + await ref
409 + .read(chatProvider.notifier)
410 + .editAssistantMessage(messageId, newContent);
411 + }
412 + },
413 + onBranch: (messageId) =>
414 + ref.read(chatProvider.notifier).branchFromMessage(messageId),
415 + onModelPicker: onModelPicker,
416 hasSmartReplies:
417 !isStreaming &&
418 keyboardBottomInset == 0 &&
@@ -606,99 +422,6 @@ class _MessageArea extends ConsumerWidget {
422 }
423 }
424
609 -void _openPersonaPickerForPreselection(BuildContext context, WidgetRef ref) {
610 - final personasAsync = ref.read(personasNotifierProvider);
611 - final personas = personasAsync.value ?? [];
612 - final isDark = Theme.of(context).brightness == Brightness.dark;
613 - final currentPersona = ref.watch(selectedPersonaProvider);
614 -
615 - showModalBottomSheet(
616 - context: context,
617 - isScrollControlled: true,
618 - builder: (ctx) {
619 - final sheetL10n = AppLocalizations.of(context)!;
620 - return SafeArea(
621 - child: Container(
622 - padding: const EdgeInsets.symmetric(vertical: 16),
623 - child: Column(
624 - mainAxisSize: MainAxisSize.min,
625 - children: [
626 - Center(
627 - child: Container(
628 - width: 40,
629 - height: 4,
630 - decoration: BoxDecoration(
631 - color: isDark ? Colors.grey[600] : Colors.grey[300],
632 - borderRadius: BorderRadius.circular(2),
633 - ),
634 - ),
635 - ),
636 - const SizedBox(height: 16),
637 - Text(
638 - sheetL10n.select_persona,
639 - style: TextStyle(
640 - fontSize: 18,
641 - fontWeight: FontWeight.bold,
642 - color: isDark ? Colors.white : Colors.black,
643 - ),
644 - ),
645 - const SizedBox(height: 8),
646 - Flexible(
647 - child: ListView.builder(
648 - shrinkWrap: true,
649 - itemCount: personas.length,
650 - itemBuilder: (context, index) {
651 - final p = personas[index];
652 - final isSelected = p.id == currentPersona?.id;
653 - final accent = isDark
654 - ? AppColors.darkAccent
655 - : AppColors.lightAccent;
656 - return ListTile(
657 - leading: Text(
658 - p.emoji,
659 - style: const TextStyle(fontSize: 22),
660 - ),
661 - title: Text(
662 - p.name,
663 - style: TextStyle(
664 - fontWeight: isSelected
665 - ? FontWeight.w600
666 - : FontWeight.normal,
667 - color: isDark ? Colors.white : Colors.black,
668 - ),
669 - ),
670 - subtitle: p.description != null
671 - ? Text(
672 - p.description!,
673 - style: TextStyle(
674 - fontSize: 12,
675 - color: isDark
676 - ? AppColors.darkMutedText
677 - : AppColors.lightMutedText,
678 - ),
679 - maxLines: 1,
680 - overflow: TextOverflow.ellipsis,
681 - )
682 - : null,
683 - trailing: isSelected
684 - ? Icon(Icons.check_circle, color: accent)
685 - : null,
686 - onTap: () {
687 - ref.read(selectedPersonaProvider.notifier).select(p);
688 - Navigator.pop(ctx);
689 - },
690 - );
691 - },
692 - ),
693 - ),
694 - ],
695 - ),
696 - ),
697 - );
698 - },
699 - ).then((_) {});
700 -}
701 -
425 class _ScreenAppBar extends ConsumerWidget {
426 const _ScreenAppBar({
427 required this.activeServer,
lib/features/chat/views/components/chat_bubble.dart
+8
@@ -15,6 +15,8 @@ class ChatBubble extends StatelessWidget {
15 this.onRetry,
16 this.onDelete,
17 this.onEdit,
18 + this.onBranch,
19 + this.onModelTap,
20 this.isStreaming = false,
21 });
22
@@ -23,6 +25,8 @@ class ChatBubble extends StatelessWidget {
25 final VoidCallback? onRetry;
26 final VoidCallback? onDelete;
27 final VoidCallback? onEdit;
28 + final VoidCallback? onBranch;
29 + final VoidCallback? onModelTap;
30 final bool isStreaming;
31
32 @override
@@ -40,6 +44,7 @@ class ChatBubble extends StatelessWidget {
44 onCopy: onCopy,
45 onDelete: onDelete,
46 onEdit: onEdit,
47 + onBranch: onBranch,
48 ),
49 );
50 case MessageRole.assistant:
@@ -50,6 +55,9 @@ class ChatBubble extends StatelessWidget {
55 onCopy: onCopy,
56 onRetry: onRetry,
57 onDelete: onDelete,
58 + onEdit: onEdit,
59 + onBranch: onBranch,
60 + onModelTap: onModelTap,
61 isStreaming: isStreaming,
62 ),
63 );
lib/features/chat/views/components/chat_bubble/assistant_bubble.dart
+29 -6
@@ -17,6 +17,9 @@ class AssistantBubble extends StatelessWidget {
17 this.onCopy,
18 this.onRetry,
19 this.onDelete,
20 + this.onEdit,
21 + this.onBranch,
22 + this.onModelTap,
23 this.isStreaming = false,
24 });
25
@@ -24,12 +27,17 @@ class AssistantBubble extends StatelessWidget {
27 final VoidCallback? onCopy;
28 final VoidCallback? onRetry;
29 final VoidCallback? onDelete;
30 + final VoidCallback? onEdit;
31 + final VoidCallback? onBranch;
32 + final VoidCallback? onModelTap;
33 final bool isStreaming;
34
35 @override
36 Widget build(BuildContext context) {
37 final theme = Theme.of(context);
38 final isDark = theme.brightness == Brightness.dark;
39 + final muted =
40 + isDark ? AppColors.darkMutedText : AppColors.lightMutedText;
41
42 return Container(
43 width: double.infinity,
@@ -37,6 +45,24 @@ class AssistantBubble extends StatelessWidget {
45 child: Column(
46 crossAxisAlignment: CrossAxisAlignment.start,
47 children: [
48 + if (message.modelId != null &&
49 + message.modelId!.isNotEmpty &&
50 + !isStreaming)
51 + Padding(
52 + padding: const EdgeInsets.only(bottom: 6),
53 + child: InkWell(
54 + onTap: onModelTap,
55 + borderRadius: BorderRadius.circular(4),
56 + child: Text(
57 + message.modelId!,
58 + style: TextStyle(
59 + fontFamily: 'monospace',
60 + fontSize: 11,
61 + color: muted,
62 + ),
63 + ),
64 + ),
65 + ),
66 if (message.reasoningContent != null &&
67 message.reasoningContent!.isNotEmpty)
68 ReasoningWidget(
@@ -88,12 +114,7 @@ class AssistantBubble extends StatelessWidget {
114 children: [
115 Text(
116 _formatTime(message.createdAt),
91 - style: TextStyle(
92 - fontSize: 11,
93 - color: isDark
94 - ? AppColors.darkMutedText
95 - : AppColors.lightMutedText,
96 - ),
117 + style: TextStyle(fontSize: 11, color: muted),
118 ),
119 if (message.status == MessageStatus.error) ...[
120 const SizedBox(width: 4),
@@ -109,6 +130,8 @@ class AssistantBubble extends StatelessWidget {
130 onCopy: onCopy,
131 onRetry: onRetry,
132 onDelete: onDelete,
133 + onEdit: onEdit,
134 + onBranch: onBranch,
135 ),
136 ],
137 ),
lib/features/chat/views/components/chat_bubble/user_bubble.dart
+3
@@ -13,12 +13,14 @@ class UserBubble extends StatelessWidget {
13 this.onCopy,
14 this.onDelete,
15 this.onEdit,
16 + this.onBranch,
17 });
18
19 final Message message;
20 final VoidCallback? onCopy;
21 final VoidCallback? onDelete;
22 final VoidCallback? onEdit;
23 + final VoidCallback? onBranch;
24
25 @override
26 Widget build(BuildContext context) {
@@ -100,6 +102,7 @@ class UserBubble extends StatelessWidget {
102 onCopy: onCopy,
103 onDelete: onDelete,
104 onEdit: onEdit,
105 + onBranch: onBranch,
106 ),
107 ],
108 ),
lib/features/chat/views/components/chat_settings_sheet.dart
+7 -30
@@ -47,13 +47,11 @@ class _ChatSettingsSheetState extends ConsumerState<ChatSettingsSheet> {
47 final temperature = activeConv?.temperature ?? settings.temperature;
48 final topP = activeConv?.topP ?? settings.topP;
49 final maxTokens = activeConv?.maxTokens ?? settings.maxTokens;
50 - final contextLength = activeConv?.contextLength ?? settings.contextLength;
50
51 final hasOverrides =
52 activeConv?.temperature != null ||
53 activeConv?.topP != null ||
55 - activeConv?.maxTokens != null ||
56 - activeConv?.contextLength != null;
54 + activeConv?.maxTokens != null;
55
56 final isGloballyEnabled = settings.mcpEnabled;
57
@@ -120,7 +118,6 @@ class _ChatSettingsSheetState extends ConsumerState<ChatSettingsSheet> {
118 temperature,
119 topP,
120 maxTokens,
123 - contextLength,
121 activeConv?.id,
122 isDark,
123 ),
@@ -169,7 +166,6 @@ class _ChatSettingsSheetState extends ConsumerState<ChatSettingsSheet> {
166 double temperature,
167 double topP,
168 int maxTokens,
172 - int contextLength,
169 String? conversationId,
170 bool isDark,
171 ) {
@@ -199,30 +195,12 @@ class _ChatSettingsSheetState extends ConsumerState<ChatSettingsSheet> {
195 isDark: isDark,
196 ),
197 const SizedBox(height: 24),
202 - Row(
203 - children: [
204 - Expanded(
205 - child: _ParamInput(
206 - label: l10n.max_tokens,
207 - value: maxTokens,
208 - description: l10n.max_tokens_desc,
209 - onChanged: (v) =>
210 - _updateParam(ref, conversationId, maxTokens: v),
211 - isDark: isDark,
212 - ),
213 - ),
214 - const SizedBox(width: 16),
215 - Expanded(
216 - child: _ParamInput(
217 - label: l10n.context_length,
218 - value: contextLength,
219 - description: l10n.context_length_desc,
220 - onChanged: (v) =>
221 - _updateParam(ref, conversationId, contextLength: v),
222 - isDark: isDark,
223 - ),
224 - ),
225 - ],
198 + _ParamInput(
199 + label: l10n.max_tokens,
200 + value: maxTokens,
201 + description: l10n.max_tokens_desc,
202 + onChanged: (v) => _updateParam(ref, conversationId, maxTokens: v),
203 + isDark: isDark,
204 ),
205 ],
206 ),
@@ -440,7 +418,6 @@ class _ChatSettingsSheetState extends ConsumerState<ChatSettingsSheet> {
418 clearTemperature: true,
419 clearTopP: true,
420 clearMaxTokens: true,
443 - clearContextLength: true,
421 );
422 }
423 }
lib/features/chat/views/components/edit_message_dialog.dart
+17 -4
@@ -3,19 +3,32 @@ import 'package:shadcn_ui/shadcn_ui.dart';
3 import 'package:localmind/l10n/app_localizations.dart';
4
5 class EditMessageDialog extends StatefulWidget {
6 - const EditMessageDialog({super.key, required this.initialContent});
6 + const EditMessageDialog({
7 + super.key,
8 + required this.initialContent,
9 + this.description,
10 + this.saveLabel,
11 + });
12
13 final String initialContent;
14 + final String? description;
15 + final String? saveLabel;
16
17 static Future<String?> show(
18 BuildContext context, {
19 required String initialContent,
20 + String? description,
21 + String? saveLabel,
22 }) {
23 return showModalBottomSheet<String>(
24 context: context,
25 isScrollControlled: true,
26 backgroundColor: Colors.transparent,
18 - builder: (context) => EditMessageDialog(initialContent: initialContent),
27 + builder: (context) => EditMessageDialog(
28 + initialContent: initialContent,
29 + description: description,
30 + saveLabel: saveLabel,
31 + ),
32 );
33 }
34
@@ -122,7 +135,7 @@ class _EditMessageDialogState extends State<EditMessageDialog> {
135 ),
136 const SizedBox(height: 8),
137 Text(
125 - l10n.edit_message_desc,
138 + widget.description ?? l10n.edit_message_desc,
139 style: TextStyle(fontSize: 12, color: hintColor),
140 ),
141 const SizedBox(height: 16),
@@ -136,7 +149,7 @@ class _EditMessageDialogState extends State<EditMessageDialog> {
149 const SizedBox(width: 8),
150 ShadButton(
151 onPressed: _save,
139 - child: Text(l10n.save_regenerate),
152 + child: Text(widget.saveLabel ?? l10n.save_regenerate),
153 ),
154 ],
155 ),
lib/features/chat/views/components/message_action_bar.dart
+12
@@ -15,6 +15,7 @@ class MessageActionBar extends ConsumerStatefulWidget {
15 this.onDelete,
16 this.onEdit,
17 this.onShare,
18 + this.onBranch,
19 });
20
21 final String content;
@@ -23,6 +24,7 @@ class MessageActionBar extends ConsumerStatefulWidget {
24 final VoidCallback? onDelete;
25 final VoidCallback? onEdit;
26 final VoidCallback? onShare;
27 + final VoidCallback? onBranch;
28
29 @override
30 ConsumerState<MessageActionBar> createState() => _MessageActionBarState();
@@ -199,6 +201,16 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
201 widget.onShare?.call();
202 },
203 ),
204 + if (widget.onBranch != null)
205 + ListTile(
206 + leading: const Icon(Icons.call_split),
207 + title: Text(sheetL10n.branch_chat),
208 + subtitle: Text(sheetL10n.branch_chat_desc),
209 + onTap: () {
210 + Navigator.of(context).pop();
211 + widget.onBranch?.call();
212 + },
213 + ),
214 ListTile(
215 leading: const Icon(Icons.text_fields),
216 title: Text(sheetL10n.character_count(widget.content.length)),
lib/features/chat/views/components/message_list/message_list.dart
+30 -1
@@ -14,6 +14,9 @@ class MessageList extends StatelessWidget {
14 required this.onRetry,
15 required this.onDelete,
16 required this.onEdit,
17 + required this.onEditAssistant,
18 + required this.onBranch,
19 + required this.onModelPicker,
20 this.hasSmartReplies = false,
21 this.bottomInset = 0,
22 });
@@ -24,6 +27,9 @@ class MessageList extends StatelessWidget {
27 final void Function(String) onRetry;
28 final void Function(String) onDelete;
29 final void Function(String messageId, String currentContent) onEdit;
30 + final void Function(String messageId, String currentContent) onEditAssistant;
31 + final void Function(String messageId) onBranch;
32 + final VoidCallback onModelPicker;
33 final bool hasSmartReplies;
34 final double bottomInset;
35
@@ -36,6 +42,9 @@ class MessageList extends StatelessWidget {
42 onRetry: onRetry,
43 onDelete: onDelete,
44 onEdit: onEdit,
45 + onEditAssistant: onEditAssistant,
46 + onBranch: onBranch,
47 + onModelPicker: onModelPicker,
48 hasSmartReplies: hasSmartReplies,
49 bottomInset: bottomInset,
50 );
@@ -50,6 +59,9 @@ class _MessageListConsumer extends ConsumerWidget {
59 required this.onRetry,
60 required this.onDelete,
61 required this.onEdit,
62 + required this.onEditAssistant,
63 + required this.onBranch,
64 + required this.onModelPicker,
65 this.hasSmartReplies = false,
66 this.bottomInset = 0,
67 });
@@ -60,6 +72,9 @@ class _MessageListConsumer extends ConsumerWidget {
72 final void Function(String) onRetry;
73 final void Function(String) onDelete;
74 final void Function(String messageId, String currentContent) onEdit;
75 + final void Function(String messageId, String currentContent) onEditAssistant;
76 + final void Function(String messageId) onBranch;
77 + final VoidCallback onModelPicker;
78 final bool hasSmartReplies;
79 final double bottomInset;
80
@@ -77,6 +92,9 @@ class _MessageListConsumer extends ConsumerWidget {
92 onRetry: onRetry,
93 onDelete: onDelete,
94 onEdit: onEdit,
95 + onEditAssistant: onEditAssistant,
96 + onBranch: onBranch,
97 + onModelPicker: onModelPicker,
98 hasSmartReplies: hasSmartReplies,
99 bottomInset: bottomInset,
100 );
@@ -92,6 +110,9 @@ class _MessageList extends StatelessWidget {
110 required this.onRetry,
111 required this.onDelete,
112 required this.onEdit,
113 + required this.onEditAssistant,
114 + required this.onBranch,
115 + required this.onModelPicker,
116 this.hasSmartReplies = false,
117 this.bottomInset = 0,
118 });
@@ -103,6 +124,9 @@ class _MessageList extends StatelessWidget {
124 final void Function(String) onRetry;
125 final void Function(String) onDelete;
126 final void Function(String messageId, String currentContent) onEdit;
127 + final void Function(String messageId, String currentContent) onEditAssistant;
128 + final void Function(String messageId) onBranch;
129 + final VoidCallback onModelPicker;
130 final bool hasSmartReplies;
131 final double bottomInset;
132
@@ -136,6 +160,7 @@ class _MessageList extends StatelessWidget {
160 key: ValueKey(streamingMessage!.id),
161 message: streamingMessage!,
162 isStreaming: true,
163 + onModelTap: onModelPicker,
164 );
165 }
166
@@ -151,7 +176,11 @@ class _MessageList extends StatelessWidget {
176 onDelete: () => onDelete(message.id),
177 onEdit: message.role == MessageRole.user
178 ? () => onEdit(message.id, message.content)
154 - : null,
179 + : message.role == MessageRole.assistant
180 + ? () => onEditAssistant(message.id, message.content)
181 + : null,
182 + onBranch: () => onBranch(message.id),
183 + onModelTap: onModelPicker,
184 );
185 },
186 );
lib/features/chat/views/components/persona_picker_sheet.dart new
+188
@@ -0,0 +1,188 @@
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/conversations/providers/conversation_providers.dart'
5 + as conv;
6 +import 'package:localmind/features/personas/data/models/persona.dart';
7 +import 'package:localmind/features/personas/providers/personas_providers.dart';
8 +import 'package:localmind/l10n/app_localizations.dart';
9 +
10 +enum PersonaPickerMode { conversation, preselection }
11 +
12 +void showPersonaPickerSheet(
13 + BuildContext context, {
14 + PersonaPickerMode mode = PersonaPickerMode.conversation,
15 +}) {
16 + showModalBottomSheet(
17 + context: context,
18 + isScrollControlled: true,
19 + builder: (ctx) => PersonaPickerSheet(mode: mode),
20 + );
21 +}
22 +
23 +class PersonaPickerSheet extends ConsumerWidget {
24 + const PersonaPickerSheet({super.key, required this.mode});
25 +
26 + final PersonaPickerMode mode;
27 +
28 + @override
29 + Widget build(BuildContext context, WidgetRef ref) {
30 + final l10n = AppLocalizations.of(context)!;
31 + final isDark = Theme.of(context).brightness == Brightness.dark;
32 + final personasAsync = ref.watch(personasNotifierProvider);
33 + final activeConv = ref.watch(conv.activeConversationProvider);
34 + final selectedPersona = ref.watch(selectedPersonaProvider);
35 +
36 + final currentPersonaId = mode == PersonaPickerMode.preselection
37 + ? selectedPersona?.id
38 + : activeConv?.personaId;
39 +
40 + return SafeArea(
41 + child: Container(
42 + padding: const EdgeInsets.symmetric(vertical: 16),
43 + child: Column(
44 + mainAxisSize: MainAxisSize.min,
45 + children: [
46 + Center(
47 + child: Container(
48 + width: 40,
49 + height: 4,
50 + decoration: BoxDecoration(
51 + color: isDark ? Colors.grey[600] : Colors.grey[300],
52 + borderRadius: BorderRadius.circular(2),
53 + ),
54 + ),
55 + ),
56 + const SizedBox(height: 16),
57 + Text(
58 + l10n.select_persona,
59 + style: TextStyle(
60 + fontSize: 18,
61 + fontWeight: FontWeight.bold,
62 + color: isDark ? Colors.white : Colors.black,
63 + ),
64 + ),
65 + const SizedBox(height: 8),
66 + Flexible(
67 + child: personasAsync.when(
68 + loading: () => const Padding(
69 + padding: EdgeInsets.all(32),
70 + child: Center(child: CircularProgressIndicator()),
71 + ),
72 + error: (error, _) => Padding(
73 + padding: const EdgeInsets.all(24),
74 + child: Text(
75 + error.toString(),
76 + textAlign: TextAlign.center,
77 + style: TextStyle(
78 + color: isDark
79 + ? AppColors.darkMutedText
80 + : AppColors.lightMutedText,
81 + ),
82 + ),
83 + ),
84 + data: (personas) {
85 + if (personas.isEmpty) {
86 + return Padding(
87 + padding: const EdgeInsets.all(24),
88 + child: Text(
89 + l10n.no_personas_found,
90 + style: TextStyle(
91 + color: isDark
92 + ? AppColors.darkMutedText
93 + : AppColors.lightMutedText,
94 + ),
95 + ),
96 + );
97 + }
98 +
99 + return ListView.builder(
100 + shrinkWrap: true,
101 + itemCount: personas.length,
102 + itemBuilder: (context, index) {
103 + final persona = personas[index];
104 + return _PersonaTile(
105 + persona: persona,
106 + isSelected: persona.id == currentPersonaId,
107 + isDark: isDark,
108 + onTap: () => _selectPersona(
109 + context,
110 + ref,
111 + persona,
112 + activeConv?.id,
113 + ),
114 + );
115 + },
116 + );
117 + },
118 + ),
119 + ),
120 + ],
121 + ),
122 + ),
123 + );
124 + }
125 +
126 + void _selectPersona(
127 + BuildContext context,
128 + WidgetRef ref,
129 + Persona persona,
130 + String? conversationId,
131 + ) {
132 + if (mode == PersonaPickerMode.preselection || conversationId == null) {
133 + ref.read(selectedPersonaProvider.notifier).select(persona);
134 + } else {
135 + ref.read(conv.conversationsProvider.notifier).updatePersona(
136 + conversationId,
137 + persona.id,
138 + persona.systemPrompt,
139 + );
140 + }
141 + Navigator.pop(context);
142 + }
143 +}
144 +
145 +class _PersonaTile extends StatelessWidget {
146 + const _PersonaTile({
147 + required this.persona,
148 + required this.isSelected,
149 + required this.isDark,
150 + required this.onTap,
151 + });
152 +
153 + final Persona persona;
154 + final bool isSelected;
155 + final bool isDark;
156 + final VoidCallback onTap;
157 +
158 + @override
159 + Widget build(BuildContext context) {
160 + final accent = isDark ? AppColors.darkAccent : AppColors.lightAccent;
161 +
162 + return ListTile(
163 + leading: Text(persona.emoji, style: const TextStyle(fontSize: 22)),
164 + title: Text(
165 + persona.name,
166 + style: TextStyle(
167 + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
168 + color: isDark ? Colors.white : Colors.black,
169 + ),
170 + ),
171 + subtitle: persona.description != null
172 + ? Text(
173 + persona.description!,
174 + style: TextStyle(
175 + fontSize: 12,
176 + color: isDark
177 + ? AppColors.darkMutedText
178 + : AppColors.lightMutedText,
179 + ),
180 + maxLines: 1,
181 + overflow: TextOverflow.ellipsis,
182 + )
183 + : null,
184 + trailing: isSelected ? Icon(Icons.check_circle, color: accent) : null,
185 + onTap: onTap,
186 + );
187 + }
188 +}
lib/features/models/components/model_context_length_section.dart new
+99
@@ -0,0 +1,99 @@
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/providers/app_providers.dart';
5 +import 'package:localmind/core/theme/colors.dart';
6 +import 'package:localmind/features/conversations/providers/conversation_providers.dart'
7 + as conv;
8 +import 'package:localmind/l10n/app_localizations.dart';
9 +
10 +class ModelContextLengthSection extends ConsumerStatefulWidget {
11 + const ModelContextLengthSection({super.key, required this.isDark});
12 +
13 + final bool isDark;
14 +
15 + @override
16 + ConsumerState<ModelContextLengthSection> createState() =>
17 + _ModelContextLengthSectionState();
18 +}
19 +
20 +class _ModelContextLengthSectionState
21 + extends ConsumerState<ModelContextLengthSection> {
22 + late final TextEditingController _controller;
23 + int? _lastValue;
24 +
25 + @override
26 + void initState() {
27 + super.initState();
28 + _controller = TextEditingController();
29 + }
30 +
31 + @override
32 + void dispose() {
33 + _controller.dispose();
34 + super.dispose();
35 + }
36 +
37 + void _syncController(int value) {
38 + if (_lastValue == value) return;
39 + _lastValue = value;
40 + _controller.text = value.toString();
41 + }
42 +
43 + void _saveValue(String raw, String? conversationId) {
44 + final parsed = int.tryParse(raw.trim());
45 + if (parsed == null || parsed <= 0 || parsed == _lastValue) return;
46 +
47 + if (conversationId == null) {
48 + ref.read(settingsProvider.notifier).setContextLength(parsed);
49 + } else {
50 + ref.read(conv.conversationsProvider.notifier).updateChatParams(
51 + conversationId,
52 + contextLength: parsed,
53 + );
54 + }
55 + _lastValue = parsed;
56 + }
57 +
58 + @override
59 + Widget build(BuildContext context) {
60 + final l10n = AppLocalizations.of(context)!;
61 + final settings = ref.watch(settingsProvider);
62 + final activeConv = ref.watch(conv.activeConversationProvider);
63 + final contextLength = activeConv?.contextLength ?? settings.contextLength;
64 + _syncController(contextLength);
65 +
66 + return Column(
67 + crossAxisAlignment: CrossAxisAlignment.start,
68 + children: [
69 + Text(
70 + l10n.context_length,
71 + style: TextStyle(
72 + fontSize: 13,
73 + fontWeight: FontWeight.w600,
74 + color: widget.isDark ? Colors.white : Colors.black,
75 + ),
76 + ),
77 + const SizedBox(height: 6),
78 + ShadInput(
79 + controller: _controller,
80 + keyboardType: TextInputType.number,
81 + onSubmitted: (value) =>
82 + _saveValue(value, activeConv?.id),
83 + onEditingComplete: () =>
84 + _saveValue(_controller.text, activeConv?.id),
85 + ),
86 + const SizedBox(height: 4),
87 + Text(
88 + l10n.context_length_desc,
89 + style: TextStyle(
90 + fontSize: 11,
91 + color: widget.isDark
92 + ? AppColors.darkMutedText
93 + : AppColors.lightMutedText,
94 + ),
95 + ),
96 + ],
97 + );
98 + }
99 +}
lib/features/models/components/model_list.dart
+174 -19
@@ -1,17 +1,21 @@
1 import 'package:flutter/material.dart';
2 import 'package:flutter_riverpod/flutter_riverpod.dart';
3 +import 'package:localmind/core/providers/app_providers.dart';
4 +import 'package:localmind/features/chat/providers/chat_params_providers.dart';
5 import 'package:localmind/features/chat/providers/model_loading_providers.dart';
6 import 'package:localmind/core/models/enums.dart';
7 import 'package:localmind/core/providers/service_providers.dart';
8 import 'package:localmind/core/theme/colors.dart';
9 import 'package:localmind/features/chat/providers/model_selection_providers.dart';
10 import 'package:localmind/features/models/data/models/model_info.dart';
11 +import 'package:localmind/features/models/providers/model_metadata_providers.dart';
12 import 'package:localmind/features/models/providers/model_picker_providers.dart';
13 +import 'package:localmind/features/models/utils/model_instance_utils.dart';
14 import 'package:localmind/features/servers/providers/server_providers.dart';
15 import 'package:localmind/l10n/app_localizations.dart';
16 import 'model_tile.dart';
17
14 -class ModelList extends ConsumerWidget {
18 +class ModelList extends ConsumerStatefulWidget {
19 const ModelList({
20 super.key,
21 required this.serverId,
@@ -24,10 +28,131 @@ class ModelList extends ConsumerWidget {
28 final bool isDark;
29
30 @override
27 - Widget build(BuildContext context, WidgetRef ref) {
31 + ConsumerState<ModelList> createState() => _ModelListState();
32 +}
33 +
34 +class _ModelListState extends ConsumerState<ModelList> {
35 + @override
36 + void initState() {
37 + super.initState();
38 + WidgetsBinding.instance.addPostFrameCallback((_) {
39 + ref.read(modelMetadataProvider.notifier).loadForServer(widget.serverId);
40 + });
41 + }
42 +
43 + @override
44 + void didUpdateWidget(covariant ModelList oldWidget) {
45 + super.didUpdateWidget(oldWidget);
46 + if (oldWidget.serverId != widget.serverId) {
47 + ref.read(modelMetadataProvider.notifier).loadForServer(widget.serverId);
48 + }
49 + }
50 +
51 + List<ModelInfo> _sortedModels(List<ModelInfo> models) {
52 + final metadata = ref.watch(modelMetadataProvider);
53 + final sorted = List<ModelInfo>.from(models);
54 + sorted.sort((a, b) {
55 + final aFavorite = metadata[a.id]?.isFavorite ?? false;
56 + final bFavorite = metadata[b.id]?.isFavorite ?? false;
57 + if (aFavorite != bFavorite) return aFavorite ? -1 : 1;
58 + return a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase());
59 + });
60 + return sorted;
61 + }
62 +
63 + Future<void> _showModelOptions(
64 + BuildContext context,
65 + ModelInfo model,
66 + ) async {
67 + final l10n = AppLocalizations.of(context)!;
68 + final metadata = ref.read(modelMetadataProvider)[model.id];
69 + final noteController = TextEditingController(text: metadata?.note ?? '');
70 +
71 + await showModalBottomSheet<void>(
72 + context: context,
73 + isScrollControlled: true,
74 + builder: (ctx) {
75 + return SafeArea(
76 + child: Padding(
77 + padding: EdgeInsets.only(
78 + left: 20,
79 + right: 20,
80 + top: 16,
81 + bottom: MediaQuery.of(ctx).viewInsets.bottom + 20,
82 + ),
83 + child: Column(
84 + mainAxisSize: MainAxisSize.min,
85 + crossAxisAlignment: CrossAxisAlignment.stretch,
86 + children: [
87 + Text(
88 + model.displayName,
89 + style: const TextStyle(
90 + fontSize: 16,
91 + fontWeight: FontWeight.bold,
92 + ),
93 + ),
94 + const SizedBox(height: 16),
95 + ListTile(
96 + contentPadding: EdgeInsets.zero,
97 + leading: Icon(
98 + metadata?.isFavorite == true
99 + ? Icons.star
100 + : Icons.star_border,
101 + color: Colors.amber[700],
102 + ),
103 + title: Text(l10n.model_favorite_toggle),
104 + onTap: () async {
105 + await ref
106 + .read(modelMetadataProvider.notifier)
107 + .toggleFavorite(model.id);
108 + if (ctx.mounted) Navigator.pop(ctx);
109 + },
110 + ),
111 + TextField(
112 + controller: noteController,
113 + decoration: InputDecoration(
114 + labelText: l10n.model_note_label,
115 + hintText: l10n.model_note_hint,
116 + border: const OutlineInputBorder(),
117 + ),
118 + maxLines: 3,
119 + ),
120 + const SizedBox(height: 12),
121 + Row(
122 + mainAxisAlignment: MainAxisAlignment.end,
123 + children: [
124 + TextButton(
125 + onPressed: () => Navigator.pop(ctx),
126 + child: Text(l10n.cancel),
127 + ),
128 + const SizedBox(width: 8),
129 + FilledButton(
130 + onPressed: () async {
131 + await ref
132 + .read(modelMetadataProvider.notifier)
133 + .setNote(model.id, noteController.text);
134 + if (ctx.mounted) Navigator.pop(ctx);
135 + },
136 + child: Text(l10n.save),
137 + ),
138 + ],
139 + ),
140 + ],
141 + ),
142 + ),
143 + );
144 + },
145 + );
146 +
147 + noteController.dispose();
148 + }
149 +
150 + @override
151 + Widget build(BuildContext context) {
152 final l10n = AppLocalizations.of(context)!;
153 final searchQuery = ref.watch(modelSearchQueryProvider);
30 - final modelsAsync = ref.watch(availableModelsProvider(serverId));
154 + final modelsAsync = ref.watch(availableModelsProvider(widget.serverId));
155 + final metadata = ref.watch(modelMetadataProvider);
156
157 return modelsAsync.when(
158 loading: () => const Center(child: CircularProgressIndicator()),
@@ -42,7 +167,7 @@ class ModelList extends ConsumerWidget {
167 style: TextStyle(
168 fontSize: 16,
169 fontWeight: FontWeight.w500,
45 - color: isDark ? Colors.white : Colors.black,
170 + color: widget.isDark ? Colors.white : Colors.black,
171 ),
172 ),
173 const SizedBox(height: 4),
@@ -50,13 +175,14 @@ class ModelList extends ConsumerWidget {
175 err.toString(),
176 style: TextStyle(
177 fontSize: 12,
53 - color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
178 + color: widget.isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
179 ),
180 textAlign: TextAlign.center,
181 ),
182 const SizedBox(height: 16),
183 OutlinedButton.icon(
59 - onPressed: () => ref.invalidate(availableModelsProvider(serverId)),
184 + onPressed: () =>
185 + ref.invalidate(availableModelsProvider(widget.serverId)),
186 icon: const Icon(Icons.refresh),
187 label: Text(l10n.retry),
188 ),
@@ -66,12 +192,13 @@ class ModelList extends ConsumerWidget {
192 data: (models) {
193 final serversAsync = ref.watch(serversProvider);
194 final servers = serversAsync.value ?? [];
69 - final activeServer = servers.where((s) => s.id == serverId).firstOrNull;
195 + final activeServer =
196 + servers.where((s) => s.id == widget.serverId).firstOrNull;
197 final loadedModelsAsync = activeServer != null
198 ? ref.watch(loadedModelsProvider(activeServer))
199 : const AsyncValue<Set<String>>.data(<String>{});
200
74 - final loadedModels = loadedModelsAsync.maybeWhen(
201 + final loadedInstances = loadedModelsAsync.maybeWhen(
202 data: (data) => data,
203 orElse: () => <String>{},
204 );
@@ -89,7 +216,9 @@ class ModelList extends ConsumerWidget {
216 )
217 .toList();
218
92 - if (filtered.isEmpty) {
219 + final sorted = _sortedModels(filtered);
220 +
221 + if (sorted.isEmpty) {
222 return Center(
223 child: Text(
224 searchQuery.isEmpty
@@ -97,24 +226,28 @@ class ModelList extends ConsumerWidget {
226 : l10n.no_models_match(searchQuery),
227 style: TextStyle(
228 fontSize: 14,
100 - color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
229 + color: widget.isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
230 ),
231 ),
232 );
233 }
234
235 return ListView.builder(
107 - itemCount: filtered.length,
236 + itemCount: sorted.length,
237 itemBuilder: (context, index) {
109 - final model = filtered[index];
110 - final isSelected = model.id == selectedModelId;
111 - final isLoaded = loadedModels.contains(model.id);
238 + final model = sorted[index];
239 + final isSelected = model.id == widget.selectedModelId;
240 + final isLoaded = isModelKeyLoaded(loadedInstances, model.id);
241 + final modelMeta = metadata[model.id];
242
243 return ModelTile(
244 model: model,
245 isSelected: isSelected,
246 isLoaded: isLoaded,
117 - isDark: isDark,
247 + isDark: widget.isDark,
248 + isFavorite: modelMeta?.isFavorite ?? false,
249 + note: modelMeta?.note,
250 + onLongPress: () => _showModelOptions(context, model),
251 onTap: () async {
252 final activeServer = ref.read(activeServerProvider);
253 if (activeServer == null) return;
@@ -126,9 +259,24 @@ class ModelList extends ConsumerWidget {
259
260 try {
261 final apiService = ref.read(serverApiServiceProvider);
262 + final settings = ref.read(settingsProvider);
263 + final contextLength =
264 + ref.read(chatParamsProvider).contextLength;
265 +
266 + if (settings.unloadModelsBeforeLoad) {
267 + final instances = await ref.read(
268 + loadedModelsProvider(activeServer).future,
269 + );
270 + await apiService.unloadAllInstances(
271 + activeServer,
272 + instances,
273 + );
274 + }
275 +
276 await apiService.loadModelWithInstanceId(
277 activeServer,
278 model.id,
279 + contextLength: contextLength,
280 );
281
282 ref.invalidate(loadedModelsProvider(activeServer));
@@ -158,7 +306,14 @@ class ModelList extends ConsumerWidget {
306
307 try {
308 final apiService = ref.read(serverApiServiceProvider);
161 - await apiService.unloadModel(activeServer, model.id);
309 + final instances = await ref.read(
310 + loadedModelsProvider(activeServer).future,
311 + );
312 + await apiService.unloadInstancesForModelKey(
313 + activeServer,
314 + model.id,
315 + instances,
316 + );
317 ref.invalidate(loadedModelsProvider(activeServer));
318
319 final selectedModel = ref.read(selectedModelProvider);
@@ -170,9 +325,9 @@ class ModelList extends ConsumerWidget {
325 final message = activeServer.type == ServerType.ollama
326 ? l10n.model_unloaded_ollama(model.name)
327 : l10n.model_unloaded_success(model.name);
173 - ScaffoldMessenger.of(
174 - context,
175 - ).showSnackBar(SnackBar(content: Text(message)));
328 + ScaffoldMessenger.of(context).showSnackBar(
329 + SnackBar(content: Text(message)),
330 + );
331 }
332 } catch (e) {
333 if (context.mounted) {
lib/features/models/components/model_tile.dart
+39 -25
@@ -14,7 +14,10 @@ class ModelTile extends StatelessWidget {
14 required this.isLoaded,
15 required this.isDark,
16 required this.onTap,
17 + this.onLongPress,
18 this.onUnload,
19 + this.isFavorite = false,
20 + this.note,
21 });
22
23 final ModelInfo model;
@@ -22,7 +25,10 @@ class ModelTile extends StatelessWidget {
25 final bool isLoaded;
26 final bool isDark;
27 final VoidCallback onTap;
28 + final VoidCallback? onLongPress;
29 final Future<void> Function()? onUnload;
30 + final bool isFavorite;
31 + final String? note;
32
33 @override
34 Widget build(BuildContext context) {
@@ -31,6 +37,7 @@ class ModelTile extends StatelessWidget {
37
38 return InkWell(
39 onTap: onTap,
40 + onLongPress: onLongPress,
41 borderRadius: BorderRadius.circular(8),
42 child: Container(
43 padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
@@ -52,6 +59,10 @@ class ModelTile extends StatelessWidget {
59 children: [
60 Row(
61 children: [
62 + if (isFavorite) ...[
63 + Icon(Icons.star, size: 16, color: Colors.amber[600]),
64 + const SizedBox(width: 6),
65 + ],
66 Expanded(
67 child: Text(
68 model.displayName,
@@ -74,6 +85,20 @@ class ModelTile extends StatelessWidget {
85 ],
86 ],
87 ),
88 + if (note != null && note!.isNotEmpty) ...[
89 + const SizedBox(height: 4),
90 + Text(
91 + note!,
92 + style: TextStyle(
93 + fontSize: 12,
94 + color: isDark
95 + ? AppColors.darkMutedText
96 + : AppColors.lightMutedText,
97 + ),
98 + maxLines: 2,
99 + overflow: TextOverflow.ellipsis,
100 + ),
101 + ],
102 const SizedBox(height: 4),
103 Wrap(
104 spacing: 6,
@@ -109,32 +134,21 @@ class ModelTile extends StatelessWidget {
134 ],
135 ),
136 ),
112 - if (model.serverType == ServerType.lmStudio ||
113 - model.serverType == ServerType.ollama) ...[
114 - if (isLoaded) ...[
115 - Container(
116 - width: 15,
117 - height: 15,
118 - decoration: BoxDecoration(
119 - color: isDark ? AppColors.darkAccent : AppColors.lightAccent,
120 - shape: BoxShape.circle,
121 - ),
137 + if ((model.serverType == ServerType.lmStudio ||
138 + model.serverType == ServerType.ollama) &&
139 + isLoaded) ...[
140 + IconButton(
141 + icon: Icon(
142 + Icons.power_settings_new_outlined,
143 + size: 18,
144 + color: Colors.red[400],
145 ),
123 - ],
124 - if (isLoaded) ...[
125 - IconButton(
126 - icon: Icon(
127 - Icons.power_settings_new_outlined,
128 - size: 18,
129 - color: Colors.red[400],
130 - ),
131 - onPressed: onUnload,
132 - padding: EdgeInsets.zero,
133 - constraints: const BoxConstraints(),
134 - tooltip: l10n.unload_from_server,
135 - ),
136 - const SizedBox(width: 8),
137 - ],
146 + onPressed: onUnload,
147 + padding: EdgeInsets.zero,
148 + constraints: const BoxConstraints(),
149 + tooltip: l10n.unload_from_server,
150 + ),
151 + const SizedBox(width: 8),
152 ],
153 if (isSelected)
154 Icon(Icons.check_circle, color: accent, size: 22)
lib/features/models/data/model_metadata_repository.dart new
+99
@@ -0,0 +1,99 @@
1 +import 'dart:convert';
2 +
3 +import 'package:shared_preferences/shared_preferences.dart';
4 +
5 +class ModelMetadata {
6 + const ModelMetadata({this.isFavorite = false, this.note});
7 +
8 + final bool isFavorite;
9 + final String? note;
10 +
11 + ModelMetadata copyWith({bool? isFavorite, String? note, bool clearNote = false}) {
12 + return ModelMetadata(
13 + isFavorite: isFavorite ?? this.isFavorite,
14 + note: clearNote ? null : (note ?? this.note),
15 + );
16 + }
17 +}
18 +
19 +class ModelMetadataRepository {
20 + ModelMetadataRepository(this._prefs);
21 +
22 + final SharedPreferences _prefs;
23 + static const _storageKey = 'modelMetadata';
24 +
25 + String _entryKey(String serverId, String modelId) => '$serverId::$modelId';
26 +
27 + Map<String, dynamic> _readAll() {
28 + final raw = _prefs.getString(_storageKey);
29 + if (raw == null || raw.isEmpty) return {};
30 + try {
31 + return Map<String, dynamic>.from(jsonDecode(raw) as Map);
32 + } catch (_) {
33 + return {};
34 + }
35 + }
36 +
37 + Future<void> _writeAll(Map<String, dynamic> data) async {
38 + await _prefs.setString(_storageKey, jsonEncode(data));
39 + }
40 +
41 + ModelMetadata getMetadata(String serverId, String modelId) {
42 + final entry = _readAll()[_entryKey(serverId, modelId)];
43 + if (entry is! Map) return const ModelMetadata();
44 + return ModelMetadata(
45 + isFavorite: entry['favorite'] == true,
46 + note: entry['note'] as String?,
47 + );
48 + }
49 +
50 + Map<String, ModelMetadata> getAllForServer(String serverId) {
51 + final prefix = '$serverId::';
52 + final result = <String, ModelMetadata>{};
53 + for (final entry in _readAll().entries) {
54 + if (!entry.key.startsWith(prefix)) continue;
55 + final modelId = entry.key.substring(prefix.length);
56 + final value = entry.value;
57 + if (value is! Map) continue;
58 + result[modelId] = ModelMetadata(
59 + isFavorite: value['favorite'] == true,
60 + note: value['note'] as String?,
61 + );
62 + }
63 + return result;
64 + }
65 +
66 + Future<void> setFavorite(
67 + String serverId,
68 + String modelId,
69 + bool isFavorite,
70 + ) async {
71 + final all = _readAll();
72 + final key = _entryKey(serverId, modelId);
73 + final existing = Map<String, dynamic>.from(
74 + (all[key] as Map?)?.cast<String, dynamic>() ?? {},
75 + );
76 + existing['favorite'] = isFavorite;
77 + all[key] = existing;
78 + await _writeAll(all);
79 + }
80 +
81 + Future<void> setNote(String serverId, String modelId, String? note) async {
82 + final all = _readAll();
83 + final key = _entryKey(serverId, modelId);
84 + final existing = Map<String, dynamic>.from(
85 + (all[key] as Map?)?.cast<String, dynamic>() ?? {},
86 + );
87 + if (note == null || note.trim().isEmpty) {
88 + existing.remove('note');
89 + } else {
90 + existing['note'] = note.trim();
91 + }
92 + if (existing.isEmpty) {
93 + all.remove(key);
94 + } else {
95 + all[key] = existing;
96 + }
97 + await _writeAll(all);
98 + }
99 +}
lib/features/models/providers/model_metadata_providers.dart new
+46
@@ -0,0 +1,46 @@
1 +import 'package:flutter_riverpod/flutter_riverpod.dart';
2 +
3 +import '../../../core/providers/storage_providers.dart';
4 +import '../data/model_metadata_repository.dart';
5 +
6 +final modelMetadataRepositoryProvider = Provider<ModelMetadataRepository>((ref) {
7 + return ModelMetadataRepository(ref.watch(sharedPreferencesProvider));
8 +});
9 +
10 +final modelMetadataProvider =
11 + NotifierProvider<ModelMetadataNotifier, Map<String, ModelMetadata>>(() {
12 + return ModelMetadataNotifier();
13 + });
14 +
15 +class ModelMetadataNotifier extends Notifier<Map<String, ModelMetadata>> {
16 + String? _serverId;
17 +
18 + @override
19 + Map<String, ModelMetadata> build() {
20 + return {};
21 + }
22 +
23 + void loadForServer(String serverId) {
24 + _serverId = serverId;
25 + state = ref.read(modelMetadataRepositoryProvider).getAllForServer(serverId);
26 + }
27 +
28 + Future<void> toggleFavorite(String modelId) async {
29 + final serverId = _serverId;
30 + if (serverId == null) return;
31 + final current = state[modelId]?.isFavorite ?? false;
32 + await ref
33 + .read(modelMetadataRepositoryProvider)
34 + .setFavorite(serverId, modelId, !current);
35 + loadForServer(serverId);
36 + }
37 +
38 + Future<void> setNote(String modelId, String? note) async {
39 + final serverId = _serverId;
40 + if (serverId == null) return;
41 + await ref
42 + .read(modelMetadataRepositoryProvider)
43 + .setNote(serverId, modelId, note);
44 + loadForServer(serverId);
45 + }
46 +}
lib/features/models/screens/model_picker_sheet.dart
+93 -3
@@ -1,12 +1,14 @@
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/core/providers/service_providers.dart';
5 import 'package:localmind/core/theme/colors.dart';
6 import 'package:localmind/features/chat/providers/chat_providers.dart';
7 import 'package:localmind/features/on_device/components/on_device_picker_section.dart';
8 import 'package:localmind/features/on_device/providers/on_device_providers.dart';
9 import 'package:localmind/features/servers/providers/server_providers.dart';
10 import 'package:localmind/l10n/app_localizations.dart';
11 +import '../components/model_context_length_section.dart';
12 import '../components/model_list.dart';
13 import '../components/model_search_field.dart';
14 import '../components/no_server_state.dart';
@@ -57,6 +59,14 @@ class ModelPickerSheet extends ConsumerWidget {
59 final isOnDevice =
60 currentServer != null && currentServer.type == ServerType.onDevice;
61
62 + final loadedModelsAsync = activeServer != null
63 + ? ref.watch(loadedModelsProvider(activeServer))
64 + : const AsyncValue<Set<String>>.data(<String>{});
65 + final loadedCount = loadedModelsAsync.maybeWhen(
66 + data: (models) => models.length,
67 + orElse: () => 0,
68 + );
69 +
70 return Container(
71 height: MediaQuery.of(context).size.height * 0.7,
72 padding: const EdgeInsets.all(16),
@@ -82,14 +92,17 @@ class ModelPickerSheet extends ConsumerWidget {
92 isDark: isDark,
93 isThinking: isThinking,
94 modelLoading: modelLoading,
95 + loadedCount: loadedCount,
96 activeServer: activeServer,
86 - serverId: activeServer?.id,
97 onRefresh: activeServer != null
98 ? () {
99 ref.invalidate(availableModelsProvider(activeServer.id));
100 ref.invalidate(loadedModelsProvider(activeServer));
101 }
102 : null,
103 + onUnloadAll: activeServer != null && loadedCount > 0
104 + ? () => _unloadAllModels(context, ref, activeServer)
105 + : null,
106 ),
107 if (activeServer != null)
108 Padding(
@@ -103,6 +116,8 @@ class ModelPickerSheet extends ConsumerWidget {
116 ),
117 ),
118 const SizedBox(height: 12),
119 + ModelContextLengthSection(isDark: isDark),
120 + const SizedBox(height: 12),
121 const ModelSearchField(),
122 const SizedBox(height: 12),
123 Expanded(
@@ -123,6 +138,43 @@ class ModelPickerSheet extends ConsumerWidget {
138 ),
139 );
140 }
141 +
142 + Future<void> _unloadAllModels(
143 + BuildContext context,
144 + WidgetRef ref,
145 + dynamic activeServer,
146 + ) async {
147 + final l10n = AppLocalizations.of(context)!;
148 +
149 + try {
150 + if (activeServer.type == ServerType.onDevice) {
151 + await ref.read(onDeviceEngineProvider.notifier).unloadModel();
152 + } else {
153 + final loadedInstances =
154 + await ref.read(loadedModelsProvider(activeServer).future);
155 + final apiService = ref.read(serverApiServiceProvider);
156 + await apiService.unloadAllInstances(activeServer, loadedInstances);
157 + }
158 +
159 + ref.invalidate(loadedModelsProvider(activeServer));
160 + ref.read(selectedModelProvider.notifier).clear();
161 +
162 + if (context.mounted) {
163 + ScaffoldMessenger.of(context).showSnackBar(
164 + SnackBar(content: Text(l10n.all_models_unloaded)),
165 + );
166 + }
167 + } catch (e) {
168 + if (context.mounted) {
169 + ScaffoldMessenger.of(context).showSnackBar(
170 + SnackBar(
171 + content: Text(l10n.model_unload_failed(e.toString())),
172 + backgroundColor: Colors.red,
173 + ),
174 + );
175 + }
176 + }
177 + }
178 }
179
180 class _ModelPickerHeader extends StatelessWidget {
@@ -130,17 +182,19 @@ class _ModelPickerHeader extends StatelessWidget {
182 required this.isDark,
183 required this.isThinking,
184 required this.modelLoading,
185 + required this.loadedCount,
186 required this.activeServer,
134 - this.serverId,
187 this.onRefresh,
188 + this.onUnloadAll,
189 });
190
191 final bool isDark;
192 final bool isThinking;
193 final dynamic modelLoading;
194 + final int loadedCount;
195 final dynamic activeServer;
142 - final String? serverId;
196 final VoidCallback? onRefresh;
197 + final VoidCallback? onUnloadAll;
198
199 @override
200 Widget build(BuildContext context) {
@@ -162,6 +216,32 @@ class _ModelPickerHeader extends StatelessWidget {
216 color: isDark ? Colors.white : Colors.black,
217 ),
218 ),
219 + if (loadedCount > 0) ...[
220 + const SizedBox(width: 8),
221 + Container(
222 + padding: const EdgeInsets.symmetric(
223 + horizontal: 8,
224 + vertical: 2,
225 + ),
226 + decoration: BoxDecoration(
227 + color: (isDark
228 + ? AppColors.darkAccent
229 + : AppColors.lightAccent)
230 + .withValues(alpha: 0.15),
231 + borderRadius: BorderRadius.circular(999),
232 + ),
233 + child: Text(
234 + l10n.loaded_models_count(loadedCount),
235 + style: TextStyle(
236 + fontSize: 11,
237 + fontWeight: FontWeight.w600,
238 + color: isDark
239 + ? AppColors.darkAccent
240 + : AppColors.lightAccent,
241 + ),
242 + ),
243 + ),
244 + ],
245 if (isThinking) ...[
246 const SizedBox(width: 8),
247 ThinkingIndicator(isDark: isDark),
@@ -180,6 +260,16 @@ class _ModelPickerHeader extends StatelessWidget {
260 ],
261 ),
262 ),
263 + if (onUnloadAll != null)
264 + TextButton.icon(
265 + onPressed: onUnloadAll,
266 + icon: const Icon(Icons.power_settings_new_outlined, size: 16),
267 + label: Text(l10n.unload_all_models),
268 + style: TextButton.styleFrom(
269 + foregroundColor: Colors.red[400],
270 + padding: const EdgeInsets.symmetric(horizontal: 8),
271 + ),
272 + ),
273 if (onRefresh != null)
274 IconButton(
275 icon: const Icon(Icons.refresh, size: 20),
lib/features/models/utils/model_instance_utils.dart new
+24
@@ -0,0 +1,24 @@
1 +bool isModelKeyLoaded(Set<String> instanceIds, String modelKey) {
2 + return instanceIds.any(
3 + (id) => id == modelKey || id.startsWith('$modelKey:'),
4 + );
5 +}
6 +
7 +List<String> instanceIdsForModelKey(
8 + Set<String> instanceIds,
9 + String modelKey,
10 +) {
11 + return instanceIds
12 + .where((id) => id == modelKey || id.startsWith('$modelKey:'))
13 + .toList();
14 +}
15 +
16 +String modelKeyFromInstanceId(String instanceId) {
17 + final colonIndex = instanceId.lastIndexOf(':');
18 + if (colonIndex <= 0) return instanceId;
19 + final suffix = instanceId.substring(colonIndex + 1);
20 + if (int.tryParse(suffix) != null) {
21 + return instanceId.substring(0, colonIndex);
22 + }
23 + return instanceId;
24 +}
lib/features/servers/data/server_api_service.dart
+60 -9
@@ -120,7 +120,11 @@ class ServerApiService {
120 }
121 }
122
123 - Future<String?> loadModelWithInstanceId(Server server, String modelId) async {
123 + Future<String?> loadModelWithInstanceId(
124 + Server server,
125 + String modelId, {
126 + int? contextLength,
127 + }) async {
128 if (server.type == ServerType.openRouter ||
129 server.type == ServerType.onDevice) {
130 return null;
@@ -130,19 +134,24 @@ class ServerApiService {
134 case ServerType.lmStudio:
135 case ServerType.openAICompatible:
136 try {
137 + final payload = <String, dynamic>{
138 + 'model': modelId,
139 + 'echo_load_config': true,
140 + };
141 + if (contextLength != null) {
142 + payload['context_length'] = contextLength;
143 + }
144 final response = await _dio.post(
145 server.loadModelEndpoint,
135 - data: {'model': modelId, 'echo_load_config': true},
146 + data: payload,
147 options: Options(headers: buildServerAuthHeaders(server)),
148 );
149 + _throwIfErrorResponse(response.data);
150 return response.data['instance_id'] as String?;
139 - } catch (_) {
140 - return null;
151 + } on DioException catch (e) {
152 + throw Exception(_extractApiErrorMessage(e.response?.data) ?? e.message);
153 }
154 case ServerType.ollama:
143 - // See note in loadModel: Ollama auto-loads on /api/chat, so we do not
144 - // issue an explicit /api/generate load (which would auto-pull the
145 - // model if missing on the remote).
155 return null;
156 case ServerType.openRouter:
157 case ServerType.onDevice:
@@ -150,6 +159,25 @@ class ServerApiService {
159 }
160 }
161
162 + Future<void> unloadAllInstances(Server server, Set<String> instanceIds) async {
163 + for (final instanceId in instanceIds) {
164 + await unloadModel(server, instanceId, instanceId: instanceId);
165 + }
166 + }
167 +
168 + Future<void> unloadInstancesForModelKey(
169 + Server server,
170 + String modelKey,
171 + Set<String> instanceIds,
172 + ) async {
173 + final targets = instanceIds
174 + .where((id) => id == modelKey || id.startsWith('$modelKey:'))
175 + .toList();
176 + for (final instanceId in targets) {
177 + await unloadModel(server, modelKey, instanceId: instanceId);
178 + }
179 + }
180 +
181 Future<void> unloadModel(
182 Server server,
183 String modelId, {
@@ -205,8 +233,12 @@ class ServerApiService {
233
234 final loadedInstances = item['loaded_instances'];
235 if (loadedInstances is List) {
208 - if (loadedInstances.isNotEmpty) {
209 - runningModels.add(id);
236 + for (final instance in loadedInstances) {
237 + if (instance is! Map) continue;
238 + final instanceId = instance['id']?.toString();
239 + if (instanceId != null && instanceId.isNotEmpty) {
240 + runningModels.add(instanceId);
241 + }
242 }
243 } else {
244 // Servers without loaded_instances (e.g. llama.cpp) always have
@@ -443,4 +475,23 @@ class ServerApiService {
475 }
476 return null;
477 }
478 +
479 + void _throwIfErrorResponse(dynamic data) {
480 + final message = _extractApiErrorMessage(data);
481 + if (message != null) {
482 + throw Exception(message);
483 + }
484 + }
485 +
486 + String? _extractApiErrorMessage(dynamic data) {
487 + if (data is! Map) return null;
488 + final error = data['error'];
489 + if (error is Map) {
490 + final type = error['type']?.toString();
491 + final message = error['message']?.toString();
492 + if (type != null && message != null) return '$type: $message';
493 + return message ?? type;
494 + }
495 + return null;
496 + }
497 }
lib/features/settings/data/models/app_settings.dart
+7
@@ -40,6 +40,7 @@ class AppSettings {
40 final bool smartReplyEnabled;
41 final String? localeCode;
42 final String? huggingFaceToken;
43 + final bool unloadModelsBeforeLoad;
44
45 AppSettings({
46 this.temperature = 0.7,
@@ -71,6 +72,7 @@ class AppSettings {
72 this.smartReplyEnabled = true,
73 this.localeCode,
74 this.huggingFaceToken,
75 + this.unloadModelsBeforeLoad = false,
76 });
77
78 AppSettings copyWith({
@@ -103,6 +105,7 @@ class AppSettings {
105 bool? smartReplyEnabled,
106 Object? localeCode = _unset,
107 Object? huggingFaceToken = _unset,
108 + bool? unloadModelsBeforeLoad,
109 }) {
110 return AppSettings(
111 temperature: temperature ?? this.temperature,
@@ -144,6 +147,8 @@ class AppSettings {
147 huggingFaceToken: identical(huggingFaceToken, _unset)
148 ? this.huggingFaceToken
149 : huggingFaceToken as String?,
150 + unloadModelsBeforeLoad:
151 + unloadModelsBeforeLoad ?? this.unloadModelsBeforeLoad,
152 );
153 }
154
@@ -178,6 +183,7 @@ class AppSettings {
183 'smartReplyEnabled': smartReplyEnabled,
184 'localeCode': localeCode,
185 'huggingFaceToken': huggingFaceToken,
186 + 'unloadModelsBeforeLoad': unloadModelsBeforeLoad,
187 };
188 }
189
@@ -212,6 +218,7 @@ class AppSettings {
218 smartReplyEnabled: map['smartReplyEnabled'] ?? true,
219 localeCode: map['localeCode'] as String?,
220 huggingFaceToken: map['huggingFaceToken'] as String?,
221 + unloadModelsBeforeLoad: map['unloadModelsBeforeLoad'] ?? false,
222 );
223 }
224
lib/features/settings/views/settings_screen.dart
+121
@@ -1,5 +1,10 @@
1 +import 'dart:convert';
2 +import 'dart:io';
3 +import 'dart:typed_data';
4 +
5 import 'package:flutter/material.dart';
6 import 'package:flutter_riverpod/flutter_riverpod.dart';
7 +import 'package:file_picker/file_picker.dart';
8 import 'package:go_router/go_router.dart';
9 import 'package:shadcn_ui/shadcn_ui.dart';
10
@@ -7,7 +12,9 @@ import 'package:localmind/l10n/app_localizations.dart';
12
13 import '../../../core/models/enums.dart';
14 import '../../../core/providers/app_providers.dart';
15 +import '../../../core/providers/storage_providers.dart';
16 import '../../../core/routes/app_routes.dart';
17 +import '../../../core/services/data_backup_service.dart';
18 import '../../../core/theme/app_theme.dart';
19 import '../../../core/utils/system_insets.dart';
20 import '../../conversations/providers/conversation_providers.dart';
@@ -284,6 +291,13 @@ class SettingsViews extends ConsumerWidget {
291 .setDefaultPersona(value),
292 icon: Icons.smart_toy_outlined,
293 ),
294 + _ToggleSetting(
295 + label: l10n.unload_models_before_load,
296 + value: settings.unloadModelsBeforeLoad,
297 + onChanged: (value) => ref
298 + .read(settingsProvider.notifier)
299 + .setUnloadModelsBeforeLoad(value),
300 + ),
301 ],
302 );
303
@@ -308,6 +322,7 @@ class SettingsViews extends ConsumerWidget {
322 icon: Icons.restore_page_outlined,
323 accent: const Color(0xFFEF4444),
324 children: [
325 + _DataBackupActions(),
326 _DangerousAction(
327 label: l10n.delete_all_conversations,
328 icon: Icons.delete_outline_rounded,
@@ -1436,6 +1451,112 @@ class _CodeThemeDropdown extends StatelessWidget {
1451 }
1452 }
1453
1454 +class _DataBackupActions extends ConsumerWidget {
1455 + const _DataBackupActions();
1456 +
1457 + Future<void> _exportData(BuildContext context, WidgetRef ref) async {
1458 + final l10n = AppLocalizations.of(context)!;
1459 + try {
1460 + final db = ref.read(databaseProvider);
1461 + final service = DataBackupService();
1462 + final json = service.exportAllAsJson(db.store);
1463 + final saved = await FilePicker.saveFile(
1464 + dialogTitle: l10n.export_all_data,
1465 + fileName:
1466 + 'localmind_backup_${DateTime.now().millisecondsSinceEpoch}.json',
1467 + type: FileType.custom,
1468 + allowedExtensions: const ['json'],
1469 + bytes: Uint8List.fromList(utf8.encode(json)),
1470 + );
1471 + if (saved == null) return;
1472 + if (context.mounted) {
1473 + ScaffoldMessenger.of(context).showSnackBar(
1474 + SnackBar(content: Text(l10n.export_data_success)),
1475 + );
1476 + }
1477 + } catch (e) {
1478 + if (context.mounted) {
1479 + ScaffoldMessenger.of(context).showSnackBar(
1480 + SnackBar(content: Text(l10n.import_data_failed(e.toString()))),
1481 + );
1482 + }
1483 + }
1484 + }
1485 +
1486 + Future<void> _importData(BuildContext context, WidgetRef ref) async {
1487 + final l10n = AppLocalizations.of(context)!;
1488 + final confirmed = await showDialog<bool>(
1489 + context: context,
1490 + builder: (dialogContext) {
1491 + final dialogL10n = AppLocalizations.of(dialogContext)!;
1492 + return AlertDialog(
1493 + title: Text(l10n.import_all_data),
1494 + content: Text(l10n.import_data_confirm),
1495 + actions: [
1496 + TextButton(
1497 + onPressed: () => Navigator.pop(dialogContext, false),
1498 + child: Text(dialogL10n.cancel),
1499 + ),
1500 + TextButton(
1501 + onPressed: () => Navigator.pop(dialogContext, true),
1502 + child: Text(dialogL10n.confirm),
1503 + ),
1504 + ],
1505 + );
1506 + },
1507 + );
1508 + if (confirmed != true || !context.mounted) return;
1509 +
1510 + try {
1511 + final result = await FilePicker.pickFiles(
1512 + type: FileType.custom,
1513 + allowedExtensions: const ['json'],
1514 + );
1515 + final path = result?.files.single.path;
1516 + if (path == null) return;
1517 +
1518 + final json = await File(path).readAsString();
1519 + final db = ref.read(databaseProvider);
1520 + await DataBackupService().importFromJson(db.store, json);
1521 +
1522 + ref.invalidate(conversationsProvider);
1523 + ref.invalidate(personasNotifierProvider);
1524 +
1525 + if (context.mounted) {
1526 + ScaffoldMessenger.of(context).showSnackBar(
1527 + SnackBar(content: Text(l10n.import_data_success)),
1528 + );
1529 + }
1530 + } catch (e) {
1531 + if (context.mounted) {
1532 + ScaffoldMessenger.of(context).showSnackBar(
1533 + SnackBar(content: Text(l10n.import_data_failed(e.toString()))),
1534 + );
1535 + }
1536 + }
1537 + }
1538 +
1539 + @override
1540 + Widget build(BuildContext context, WidgetRef ref) {
1541 + final l10n = AppLocalizations.of(context)!;
1542 +
1543 + return Column(
1544 + children: [
1545 + _SectionActionButton(
1546 + icon: Icons.upload_file_outlined,
1547 + label: l10n.export_all_data,
1548 + onPressed: () => _exportData(context, ref),
1549 + ),
1550 + _SectionActionButton(
1551 + icon: Icons.download_outlined,
1552 + label: l10n.import_all_data,
1553 + onPressed: () => _importData(context, ref),
1554 + ),
1555 + ],
1556 + );
1557 + }
1558 +}
1559 +
1560 class _DangerousAction extends StatelessWidget {
1561 const _DangerousAction({
1562 required this.label,
lib/l10n/app_en.arb
+87 -1
@@ -2148,6 +2148,46 @@
2148 }
2149 }
2150 },
2151 + "unload_all_models": "Unload all",
2152 + "@unload_all_models": {
2153 + "description": "Button to unload all loaded models"
2154 + },
2155 + "loaded_models_count": "{count} loaded",
2156 + "@loaded_models_count": {
2157 + "description": "Count of currently loaded models",
2158 + "placeholders": {
2159 + "count": {
2160 + "type": "int",
2161 + "example": "3"
2162 + }
2163 + }
2164 + },
2165 + "all_models_unloaded": "All models unloaded",
2166 + "@all_models_unloaded": {
2167 + "description": "Snackbar after unloading all models"
2168 + },
2169 + "branch_chat": "Branch chat",
2170 + "@branch_chat": {
2171 + "description": "Branch conversation from a message"
2172 + },
2173 + "branch_chat_desc": "Start a new conversation from this message",
2174 + "@branch_chat_desc": {
2175 + "description": "Branch chat option description"
2176 + },
2177 + "edit_assistant_message_desc": "Edit the assistant response text.",
2178 + "@edit_assistant_message_desc": {
2179 + "description": "Description shown when editing an assistant message"
2180 + },
2181 + "switch_to_model": "Switch to {modelName}",
2182 + "@switch_to_model": {
2183 + "description": "Tooltip for switching to the model used in a message",
2184 + "placeholders": {
2185 + "modelName": {
2186 + "type": "String",
2187 + "example": "Gemma 4"
2188 + }
2189 + }
2190 + },
2191 "download_notification_title": "Downloading {modelName}...",
2192 "@download_notification_title": {
2193 "description": "Download notification title",
@@ -2455,5 +2495,51 @@
2495 "tool_status_rejected": "Rejected",
2496 "tool_status_running": "Running",
2497 "tool_status_done": "Done",
2458 - "tool_status_failed": "Failed"
2498 + "tool_status_failed": "Failed",
2499 + "model_favorite_toggle": "Toggle favorite",
2500 + "@model_favorite_toggle": {
2501 + "description": "Long-press menu option to favorite a model"
2502 + },
2503 + "model_note_label": "Note",
2504 + "@model_note_label": {
2505 + "description": "Label for model note text field"
2506 + },
2507 + "model_note_hint": "Add a note about this model…",
2508 + "@model_note_hint": {
2509 + "description": "Hint for model note text field"
2510 + },
2511 + "unload_models_before_load": "Unload all models before loading a new one",
2512 + "@unload_models_before_load": {
2513 + "description": "Setting to unload every loaded model instance before loading another"
2514 + },
2515 + "export_all_data": "Export all data",
2516 + "@export_all_data": {
2517 + "description": "Export conversations and personas to a JSON file"
2518 + },
2519 + "import_all_data": "Import all data",
2520 + "@import_all_data": {
2521 + "description": "Import conversations and personas from a JSON backup"
2522 + },
2523 + "export_data_success": "Backup exported successfully",
2524 + "@export_data_success": {
2525 + "description": "Snackbar after exporting backup"
2526 + },
2527 + "import_data_success": "Backup imported successfully",
2528 + "@import_data_success": {
2529 + "description": "Snackbar after importing backup"
2530 + },
2531 + "import_data_failed": "Failed to import backup: {error}",
2532 + "@import_data_failed": {
2533 + "description": "Snackbar when import fails",
2534 + "placeholders": {
2535 + "error": {
2536 + "type": "String",
2537 + "example": "Invalid format"
2538 + }
2539 + }
2540 + },
2541 + "import_data_confirm": "Import conversations and custom personas from this backup? Existing items with the same IDs will be updated.",
2542 + "@import_data_confirm": {
2543 + "description": "Confirmation message before importing backup"
2544 + }
2545 }
lib/l10n/app_localizations.dart
+102
@@ -2925,6 +2925,48 @@ abstract class AppLocalizations {
2925 /// **'{ctx} ctx'**
2926 String context_chip(String ctx);
2927
2928 + /// Button to unload all loaded models
2929 + ///
2930 + /// In en, this message translates to:
2931 + /// **'Unload all'**
2932 + String get unload_all_models;
2933 +
2934 + /// Count of currently loaded models
2935 + ///
2936 + /// In en, this message translates to:
2937 + /// **'{count} loaded'**
2938 + String loaded_models_count(int count);
2939 +
2940 + /// Snackbar after unloading all models
2941 + ///
2942 + /// In en, this message translates to:
2943 + /// **'All models unloaded'**
2944 + String get all_models_unloaded;
2945 +
2946 + /// Branch conversation from a message
2947 + ///
2948 + /// In en, this message translates to:
2949 + /// **'Branch chat'**
2950 + String get branch_chat;
2951 +
2952 + /// Branch chat option description
2953 + ///
2954 + /// In en, this message translates to:
2955 + /// **'Start a new conversation from this message'**
2956 + String get branch_chat_desc;
2957 +
2958 + /// Description shown when editing an assistant message
2959 + ///
2960 + /// In en, this message translates to:
2961 + /// **'Edit the assistant response text.'**
2962 + String get edit_assistant_message_desc;
2963 +
2964 + /// Tooltip for switching to the model used in a message
2965 + ///
2966 + /// In en, this message translates to:
2967 + /// **'Switch to {modelName}'**
2968 + String switch_to_model(String modelName);
2969 +
2970 /// Download notification title
2971 ///
2972 /// In en, this message translates to:
@@ -3710,6 +3752,66 @@ abstract class AppLocalizations {
3752 /// In en, this message translates to:
3753 /// **'Failed'**
3754 String get tool_status_failed;
3755 +
3756 + /// Long-press menu option to favorite a model
3757 + ///
3758 + /// In en, this message translates to:
3759 + /// **'Toggle favorite'**
3760 + String get model_favorite_toggle;
3761 +
3762 + /// Label for model note text field
3763 + ///
3764 + /// In en, this message translates to:
3765 + /// **'Note'**
3766 + String get model_note_label;
3767 +
3768 + /// Hint for model note text field
3769 + ///
3770 + /// In en, this message translates to:
3771 + /// **'Add a note about this model…'**
3772 + String get model_note_hint;
3773 +
3774 + /// Setting to unload every loaded model instance before loading another
3775 + ///
3776 + /// In en, this message translates to:
3777 + /// **'Unload all models before loading a new one'**
3778 + String get unload_models_before_load;
3779 +
3780 + /// Export conversations and personas to a JSON file
3781 + ///
3782 + /// In en, this message translates to:
3783 + /// **'Export all data'**
3784 + String get export_all_data;
3785 +
3786 + /// Import conversations and personas from a JSON backup
3787 + ///
3788 + /// In en, this message translates to:
3789 + /// **'Import all data'**
3790 + String get import_all_data;
3791 +
3792 + /// Snackbar after exporting backup
3793 + ///
3794 + /// In en, this message translates to:
3795 + /// **'Backup exported successfully'**
3796 + String get export_data_success;
3797 +
3798 + /// Snackbar after importing backup
3799 + ///
3800 + /// In en, this message translates to:
3801 + /// **'Backup imported successfully'**
3802 + String get import_data_success;
3803 +
3804 + /// Snackbar when import fails
3805 + ///
3806 + /// In en, this message translates to:
3807 + /// **'Failed to import backup: {error}'**
3808 + String import_data_failed(String error);
3809 +
3810 + /// Confirmation message before importing backup
3811 + ///
3812 + /// In en, this message translates to:
3813 + /// **'Import conversations and custom personas from this backup? Existing items with the same IDs will be updated.'**
3814 + String get import_data_confirm;
3815 }
3816
3817 class _AppLocalizationsDelegate
lib/l10n/app_localizations_ar.dart
+59
@@ -1515,6 +1515,31 @@ class AppLocalizationsAr extends AppLocalizations {
1515 return '$ctx سياق';
1516 }
1517
1518 + @override
1519 + String get unload_all_models => 'Unload all';
1520 +
1521 + @override
1522 + String loaded_models_count(int count) {
1523 + return '$count loaded';
1524 + }
1525 +
1526 + @override
1527 + String get all_models_unloaded => 'All models unloaded';
1528 +
1529 + @override
1530 + String get branch_chat => 'Branch chat';
1531 +
1532 + @override
1533 + String get branch_chat_desc => 'Start a new conversation from this message';
1534 +
1535 + @override
1536 + String get edit_assistant_message_desc => 'Edit the assistant response text.';
1537 +
1538 + @override
1539 + String switch_to_model(String modelName) {
1540 + return 'Switch to $modelName';
1541 + }
1542 +
1543 @override
1544 String download_notification_title(String modelName) {
1545 return 'جارٍ تنزيل $modelName...';
@@ -1948,4 +1973,38 @@ class AppLocalizationsAr extends AppLocalizations {
1973
1974 @override
1975 String get tool_status_failed => 'Failed';
1976 +
1977 + @override
1978 + String get model_favorite_toggle => 'Toggle favorite';
1979 +
1980 + @override
1981 + String get model_note_label => 'Note';
1982 +
1983 + @override
1984 + String get model_note_hint => 'Add a note about this model…';
1985 +
1986 + @override
1987 + String get unload_models_before_load =>
1988 + 'Unload all models before loading a new one';
1989 +
1990 + @override
1991 + String get export_all_data => 'Export all data';
1992 +
1993 + @override
1994 + String get import_all_data => 'Import all data';
1995 +
1996 + @override
1997 + String get export_data_success => 'Backup exported successfully';
1998 +
1999 + @override
2000 + String get import_data_success => 'Backup imported successfully';
2001 +
2002 + @override
2003 + String import_data_failed(String error) {
2004 + return 'Failed to import backup: $error';
2005 + }
2006 +
2007 + @override
2008 + String get import_data_confirm =>
2009 + 'Import conversations and custom personas from this backup? Existing items with the same IDs will be updated.';
2010 }
lib/l10n/app_localizations_bn.dart
+59
@@ -1525,6 +1525,31 @@ class AppLocalizationsBn extends AppLocalizations {
1525 return '$ctx ctx';
1526 }
1527
1528 + @override
1529 + String get unload_all_models => 'Unload all';
1530 +
1531 + @override
1532 + String loaded_models_count(int count) {
1533 + return '$count loaded';
1534 + }
1535 +
1536 + @override
1537 + String get all_models_unloaded => 'All models unloaded';
1538 +
1539 + @override
1540 + String get branch_chat => 'Branch chat';
1541 +
1542 + @override
1543 + String get branch_chat_desc => 'Start a new conversation from this message';
1544 +
1545 + @override
1546 + String get edit_assistant_message_desc => 'Edit the assistant response text.';
1547 +
1548 + @override
1549 + String switch_to_model(String modelName) {
1550 + return 'Switch to $modelName';
1551 + }
1552 +
1553 @override
1554 String download_notification_title(String modelName) {
1555 return '$modelName ডাউনলোড করা হচ্ছে...';
@@ -1961,4 +1986,38 @@ class AppLocalizationsBn extends AppLocalizations {
1986
1987 @override
1988 String get tool_status_failed => 'Failed';
1989 +
1990 + @override
1991 + String get model_favorite_toggle => 'Toggle favorite';
1992 +
1993 + @override
1994 + String get model_note_label => 'Note';
1995 +
1996 + @override
1997 + String get model_note_hint => 'Add a note about this model…';
1998 +
1999 + @override
2000 + String get unload_models_before_load =>
2001 + 'Unload all models before loading a new one';
2002 +
2003 + @override
2004 + String get export_all_data => 'Export all data';
2005 +
2006 + @override
2007 + String get import_all_data => 'Import all data';
2008 +
2009 + @override
2010 + String get export_data_success => 'Backup exported successfully';
2011 +
2012 + @override
2013 + String get import_data_success => 'Backup imported successfully';
2014 +
2015 + @override
2016 + String import_data_failed(String error) {
2017 + return 'Failed to import backup: $error';
2018 + }
2019 +
2020 + @override
2021 + String get import_data_confirm =>
2022 + 'Import conversations and custom personas from this backup? Existing items with the same IDs will be updated.';
2023 }
lib/l10n/app_localizations_en.dart
+59
@@ -1517,6 +1517,31 @@ class AppLocalizationsEn extends AppLocalizations {
1517 return '$ctx ctx';
1518 }
1519
1520 + @override
1521 + String get unload_all_models => 'Unload all';
1522 +
1523 + @override
1524 + String loaded_models_count(int count) {
1525 + return '$count loaded';
1526 + }
1527 +
1528 + @override
1529 + String get all_models_unloaded => 'All models unloaded';
1530 +
1531 + @override
1532 + String get branch_chat => 'Branch chat';
1533 +
1534 + @override
1535 + String get branch_chat_desc => 'Start a new conversation from this message';
1536 +
1537 + @override
1538 + String get edit_assistant_message_desc => 'Edit the assistant response text.';
1539 +
1540 + @override
1541 + String switch_to_model(String modelName) {
1542 + return 'Switch to $modelName';
1543 + }
1544 +
1545 @override
1546 String download_notification_title(String modelName) {
1547 return 'Downloading $modelName...';
@@ -1953,4 +1978,38 @@ class AppLocalizationsEn extends AppLocalizations {
1978
1979 @override
1980 String get tool_status_failed => 'Failed';
1981 +
1982 + @override
1983 + String get model_favorite_toggle => 'Toggle favorite';
1984 +
1985 + @override
1986 + String get model_note_label => 'Note';
1987 +
1988 + @override
1989 + String get model_note_hint => 'Add a note about this model…';
1990 +
1991 + @override
1992 + String get unload_models_before_load =>
1993 + 'Unload all models before loading a new one';
1994 +
1995 + @override
1996 + String get export_all_data => 'Export all data';
1997 +
1998 + @override
1999 + String get import_all_data => 'Import all data';
2000 +
2001 + @override
2002 + String get export_data_success => 'Backup exported successfully';
2003 +
2004 + @override
2005 + String get import_data_success => 'Backup imported successfully';
2006 +
2007 + @override
2008 + String import_data_failed(String error) {
2009 + return 'Failed to import backup: $error';
2010 + }
2011 +
2012 + @override
2013 + String get import_data_confirm =>
2014 + 'Import conversations and custom personas from this backup? Existing items with the same IDs will be updated.';
2015 }
lib/l10n/app_localizations_es.dart
+59
@@ -1532,6 +1532,31 @@ class AppLocalizationsEs extends AppLocalizations {
1532 return '$ctx ctx';
1533 }
1534
1535 + @override
1536 + String get unload_all_models => 'Unload all';
1537 +
1538 + @override
1539 + String loaded_models_count(int count) {
1540 + return '$count loaded';
1541 + }
1542 +
1543 + @override
1544 + String get all_models_unloaded => 'All models unloaded';
1545 +
1546 + @override
1547 + String get branch_chat => 'Branch chat';
1548 +
1549 + @override
1550 + String get branch_chat_desc => 'Start a new conversation from this message';
1551 +
1552 + @override
1553 + String get edit_assistant_message_desc => 'Edit the assistant response text.';
1554 +
1555 + @override
1556 + String switch_to_model(String modelName) {
1557 + return 'Switch to $modelName';
1558 + }
1559 +
1560 @override
1561 String download_notification_title(String modelName) {
1562 return 'Descargando $modelName...';
@@ -1968,4 +1993,38 @@ class AppLocalizationsEs extends AppLocalizations {
1993
1994 @override
1995 String get tool_status_failed => 'Failed';
1996 +
1997 + @override
1998 + String get model_favorite_toggle => 'Toggle favorite';
1999 +
2000 + @override
2001 + String get model_note_label => 'Note';
2002 +
2003 + @override
2004 + String get model_note_hint => 'Add a note about this model…';
2005 +
2006 + @override
2007 + String get unload_models_before_load =>
2008 + 'Unload all models before loading a new one';
2009 +
2010 + @override
2011 + String get export_all_data => 'Export all data';
2012 +
2013 + @override
2014 + String get import_all_data => 'Import all data';
2015 +
2016 + @override
2017 + String get export_data_success => 'Backup exported successfully';
2018 +
2019 + @override
2020 + String get import_data_success => 'Backup imported successfully';
2021 +
2022 + @override
2023 + String import_data_failed(String error) {
2024 + return 'Failed to import backup: $error';
2025 + }
2026 +
2027 + @override
2028 + String get import_data_confirm =>
2029 + 'Import conversations and custom personas from this backup? Existing items with the same IDs will be updated.';
2030 }
lib/l10n/app_localizations_hi.dart
+59
@@ -1521,6 +1521,31 @@ class AppLocalizationsHi extends AppLocalizations {
1521 return '$ctx संदर्भ (ctx)';
1522 }
1523
1524 + @override
1525 + String get unload_all_models => 'Unload all';
1526 +
1527 + @override
1528 + String loaded_models_count(int count) {
1529 + return '$count loaded';
1530 + }
1531 +
1532 + @override
1533 + String get all_models_unloaded => 'All models unloaded';
1534 +
1535 + @override
1536 + String get branch_chat => 'Branch chat';
1537 +
1538 + @override
1539 + String get branch_chat_desc => 'Start a new conversation from this message';
1540 +
1541 + @override
1542 + String get edit_assistant_message_desc => 'Edit the assistant response text.';
1543 +
1544 + @override
1545 + String switch_to_model(String modelName) {
1546 + return 'Switch to $modelName';
1547 + }
1548 +
1549 @override
1550 String download_notification_title(String modelName) {
1551 return '$modelName डाउनलोड किया जा रहा है...';
@@ -1957,4 +1982,38 @@ class AppLocalizationsHi extends AppLocalizations {
1982
1983 @override
1984 String get tool_status_failed => 'Failed';
1985 +
1986 + @override
1987 + String get model_favorite_toggle => 'Toggle favorite';
1988 +
1989 + @override
1990 + String get model_note_label => 'Note';
1991 +
1992 + @override
1993 + String get model_note_hint => 'Add a note about this model…';
1994 +
1995 + @override
1996 + String get unload_models_before_load =>
1997 + 'Unload all models before loading a new one';
1998 +
1999 + @override
2000 + String get export_all_data => 'Export all data';
2001 +
2002 + @override
2003 + String get import_all_data => 'Import all data';
2004 +
2005 + @override
2006 + String get export_data_success => 'Backup exported successfully';
2007 +
2008 + @override
2009 + String get import_data_success => 'Backup imported successfully';
2010 +
2011 + @override
2012 + String import_data_failed(String error) {
2013 + return 'Failed to import backup: $error';
2014 + }
2015 +
2016 + @override
2017 + String get import_data_confirm =>
2018 + 'Import conversations and custom personas from this backup? Existing items with the same IDs will be updated.';
2019 }
lib/l10n/app_localizations_it.dart
+59
@@ -1531,6 +1531,31 @@ class AppLocalizationsIt extends AppLocalizations {
1531 return '$ctx ctx';
1532 }
1533
1534 + @override
1535 + String get unload_all_models => 'Unload all';
1536 +
1537 + @override
1538 + String loaded_models_count(int count) {
1539 + return '$count loaded';
1540 + }
1541 +
1542 + @override
1543 + String get all_models_unloaded => 'All models unloaded';
1544 +
1545 + @override
1546 + String get branch_chat => 'Branch chat';
1547 +
1548 + @override
1549 + String get branch_chat_desc => 'Start a new conversation from this message';
1550 +
1551 + @override
1552 + String get edit_assistant_message_desc => 'Edit the assistant response text.';
1553 +
1554 + @override
1555 + String switch_to_model(String modelName) {
1556 + return 'Switch to $modelName';
1557 + }
1558 +
1559 @override
1560 String download_notification_title(String modelName) {
1561 return 'Download di $modelName in corso...';
@@ -1968,4 +1993,38 @@ class AppLocalizationsIt extends AppLocalizations {
1993
1994 @override
1995 String get tool_status_failed => 'Failed';
1996 +
1997 + @override
1998 + String get model_favorite_toggle => 'Toggle favorite';
1999 +
2000 + @override
2001 + String get model_note_label => 'Note';
2002 +
2003 + @override
2004 + String get model_note_hint => 'Add a note about this model…';
2005 +
2006 + @override
2007 + String get unload_models_before_load =>
2008 + 'Unload all models before loading a new one';
2009 +
2010 + @override
2011 + String get export_all_data => 'Export all data';
2012 +
2013 + @override
2014 + String get import_all_data => 'Import all data';
2015 +
2016 + @override
2017 + String get export_data_success => 'Backup exported successfully';
2018 +
2019 + @override
2020 + String get import_data_success => 'Backup imported successfully';
2021 +
2022 + @override
2023 + String import_data_failed(String error) {
2024 + return 'Failed to import backup: $error';
2025 + }
2026 +
2027 + @override
2028 + String get import_data_confirm =>
2029 + 'Import conversations and custom personas from this backup? Existing items with the same IDs will be updated.';
2030 }
lib/l10n/app_localizations_ja.dart
+59
@@ -1500,6 +1500,31 @@ class AppLocalizationsJa extends AppLocalizations {
1500 return '$ctx コンテキスト';
1501 }
1502
1503 + @override
1504 + String get unload_all_models => 'Unload all';
1505 +
1506 + @override
1507 + String loaded_models_count(int count) {
1508 + return '$count loaded';
1509 + }
1510 +
1511 + @override
1512 + String get all_models_unloaded => 'All models unloaded';
1513 +
1514 + @override
1515 + String get branch_chat => 'Branch chat';
1516 +
1517 + @override
1518 + String get branch_chat_desc => 'Start a new conversation from this message';
1519 +
1520 + @override
1521 + String get edit_assistant_message_desc => 'Edit the assistant response text.';
1522 +
1523 + @override
1524 + String switch_to_model(String modelName) {
1525 + return 'Switch to $modelName';
1526 + }
1527 +
1528 @override
1529 String download_notification_title(String modelName) {
1530 return '$modelName をダウンロード中...';
@@ -1930,4 +1955,38 @@ class AppLocalizationsJa extends AppLocalizations {
1955
1956 @override
1957 String get tool_status_failed => 'Failed';
1958 +
1959 + @override
1960 + String get model_favorite_toggle => 'Toggle favorite';
1961 +
1962 + @override
1963 + String get model_note_label => 'Note';
1964 +
1965 + @override
1966 + String get model_note_hint => 'Add a note about this model…';
1967 +
1968 + @override
1969 + String get unload_models_before_load =>
1970 + 'Unload all models before loading a new one';
1971 +
1972 + @override
1973 + String get export_all_data => 'Export all data';
1974 +
1975 + @override
1976 + String get import_all_data => 'Import all data';
1977 +
1978 + @override
1979 + String get export_data_success => 'Backup exported successfully';
1980 +
1981 + @override
1982 + String get import_data_success => 'Backup imported successfully';
1983 +
1984 + @override
1985 + String import_data_failed(String error) {
1986 + return 'Failed to import backup: $error';
1987 + }
1988 +
1989 + @override
1990 + String get import_data_confirm =>
1991 + 'Import conversations and custom personas from this backup? Existing items with the same IDs will be updated.';
1992 }
lib/l10n/app_localizations_zh.dart
+59
@@ -1494,6 +1494,31 @@ class AppLocalizationsZh extends AppLocalizations {
1494 return '$ctx 上下文 (ctx)';
1495 }
1496
1497 + @override
1498 + String get unload_all_models => 'Unload all';
1499 +
1500 + @override
1501 + String loaded_models_count(int count) {
1502 + return '$count loaded';
1503 + }
1504 +
1505 + @override
1506 + String get all_models_unloaded => 'All models unloaded';
1507 +
1508 + @override
1509 + String get branch_chat => 'Branch chat';
1510 +
1511 + @override
1512 + String get branch_chat_desc => 'Start a new conversation from this message';
1513 +
1514 + @override
1515 + String get edit_assistant_message_desc => 'Edit the assistant response text.';
1516 +
1517 + @override
1518 + String switch_to_model(String modelName) {
1519 + return 'Switch to $modelName';
1520 + }
1521 +
1522 @override
1523 String download_notification_title(String modelName) {
1524 return '正在下载 $modelName...';
@@ -1923,6 +1948,40 @@ class AppLocalizationsZh extends AppLocalizations {
1948
1949 @override
1950 String get tool_status_failed => 'Failed';
1951 +
1952 + @override
1953 + String get model_favorite_toggle => 'Toggle favorite';
1954 +
1955 + @override
1956 + String get model_note_label => 'Note';
1957 +
1958 + @override
1959 + String get model_note_hint => 'Add a note about this model…';
1960 +
1961 + @override
1962 + String get unload_models_before_load =>
1963 + 'Unload all models before loading a new one';
1964 +
1965 + @override
1966 + String get export_all_data => 'Export all data';
1967 +
1968 + @override
1969 + String get import_all_data => 'Import all data';
1970 +
1971 + @override
1972 + String get export_data_success => 'Backup exported successfully';
1973 +
1974 + @override
1975 + String get import_data_success => 'Backup imported successfully';
1976 +
1977 + @override
1978 + String import_data_failed(String error) {
1979 + return 'Failed to import backup: $error';
1980 + }
1981 +
1982 + @override
1983 + String get import_data_confirm =>
1984 + 'Import conversations and custom personas from this backup? Existing items with the same IDs will be updated.';
1985 }
1986
1987 /// The translations for Chinese, as used in Taiwan (`zh_TW`).
test/features/servers/server_api_service_test.dart
+1 -1
@@ -230,7 +230,7 @@ void main() {
230 final lmStudioServer = testServer.copyWith(type: ServerType.lmStudio);
231 final running = await service.fetchRunningModels(lmStudioServer);
232
233 - expect(running, contains("active-model-lm"));
233 + expect(running, contains("inst_1"));
234 expect(running, isNot(contains("inactive-model-lm")));
235 });
236