@tduncan / Cortana / commits / 62f7d97

Add message variants, folders, and saved messages

Introduce message variant support (variantGroupId, variantIndex, threadOrder, isActiveVariant, parentMessageId) and MessageVariants utility plus UI navigator and cycle/continue actions. Add conversation folders and saved-messages features (models, entities, providers, UI). Enhance data backup: JSON per-type exports, ZIP export/import, and import helpers. Add server API pathPrefix for Ollama, TTS test section, message search, duplicate/move conversation actions, and many ObjectBox schema updates (new entities/properties). Misc UI wiring and provider updates to integrate these features.

onlypuppy7 committed Jun 30, 2026 at 03:25 UTC 62f7d97f5bb932446658b0c22b1a097662020a11
50 files changed +4658 -230
.gitignore
+2
@@ -54,3 +54,5 @@ playstore_keys
54 private/
55 plans/
56 skills-lock.json
57 +
58 +notes.txt
\ No newline at end of file
lib/app.dart
+19 -4
@@ -9,6 +9,9 @@ import 'core/models/enums.dart';
9 import 'core/providers/app_providers.dart';
10 import 'core/routes/app_routes.dart';
11 import 'core/theme/app_theme.dart';
12 +import 'features/chat/providers/chat_providers.dart';
13 +import 'features/conversations/providers/conversation_providers.dart'
14 + as conv;
15 import 'features/chat/views/chat_screen.dart';
16 import 'features/conversations/views/chat_history_screen.dart';
17 import 'features/mcp/views/mcp_tools_screen.dart';
@@ -25,6 +28,7 @@ import 'features/servers/data/models/server.dart';
28 import 'features/servers/views/add_server_screen.dart';
29 import 'features/servers/views/server_list_screen.dart';
30 import 'features/tts/views/tts_model_manager_screen.dart';
31 +import 'features/saved_messages/views/saved_messages_screen.dart';
32 import 'features/settings/views/settings_screen.dart';
33 import 'features/sidebar/sidebar_drawer.dart';
34 import 'features/sidebar/sidebar_widget.dart';
@@ -153,6 +157,11 @@ final routerProvider = Provider<GoRouter>((ref) {
157 pageBuilder: (context, state) =>
158 const NoTransitionPage(child: TtsModelManagerScreen()),
159 ),
160 + GoRoute(
161 + path: AppRoutes.savedMessages,
162 + pageBuilder: (context, state) =>
163 + const NoTransitionPage(child: SavedMessagesScreen()),
164 + ),
165 ],
166 ),
167 ],
@@ -246,7 +255,7 @@ class App extends ConsumerWidget {
255 }
256 }
257
249 -class AppShell extends StatelessWidget {
258 +class AppShell extends ConsumerWidget {
259 final Widget child;
260
261 const AppShell({super.key, required this.child});
@@ -256,16 +265,22 @@ class AppShell extends StatelessWidget {
265 }
266
267 @override
259 - Widget build(BuildContext context) {
268 + Widget build(BuildContext context, WidgetRef ref) {
269 final theme = Theme.of(context);
270 final isDark = theme.brightness == Brightness.dark;
271 final location = GoRouterState.of(context).uri.path;
272 final isHome = location == AppRoutes.home;
273 + final hasActiveChat = ref.watch(conv.activeConversationProvider) != null;
274
275 return PopScope(
266 - canPop: isHome,
276 + canPop: isHome && !hasActiveChat,
277 onPopInvokedWithResult: (didPop, result) {
268 - if (!didPop && !isHome) {
278 + if (didPop) return;
279 + if (isHome && hasActiveChat) {
280 + ref.read(chatProvider.notifier).startNewConversation();
281 + return;
282 + }
283 + if (!isHome) {
284 context.go(AppRoutes.home);
285 }
286 },
lib/core/routes/app_routes.dart
+1
@@ -16,4 +16,5 @@ class AppRoutes {
16 static const String chatHistory = '/history';
17 static const String onDeviceModels = '/on-device-models';
18 static const String ttsModels = '/tts-models';
19 + static const String savedMessages = '/saved-messages';
20 }
lib/core/services/data_backup_service.dart
+91 -1
@@ -1,5 +1,7 @@
1 import 'dart:convert';
2
3 +import 'package:archive/archive.dart';
4 +
5 import '../models/enums.dart';
6 import '../storage/entities.dart';
7 import '../../features/chat/data/models/message.dart';
@@ -34,6 +36,7 @@ class DataBackupService {
36 'mcpEnabled': domain.mcpEnabled,
37 'smartReplies': domain.smartReplies,
38 'smartRepliesLastMessageId': domain.smartRepliesLastMessageId,
39 + 'folderId': domain.folderId,
40 };
41 }).toList();
42
@@ -55,6 +58,10 @@ class DataBackupService {
58 'toolCallId': message.toolCallId,
59 'isProcessing': message.isProcessing,
60 'toolSessionId': message.toolSessionId,
61 + 'variantGroupId': message.variantGroupId,
62 + 'variantIndex': message.variantIndex,
63 + 'threadOrder': message.threadOrder,
64 + 'isActiveVariant': message.isActiveVariant,
65 };
66 }).toList();
67
@@ -79,7 +86,7 @@ class DataBackupService {
86 .toList();
87
88 return {
82 - 'version': 1,
89 + 'version': 2,
90 'exportedAt': DateTime.now().toIso8601String(),
91 'conversations': conversations,
92 'messages': messages,
@@ -87,6 +94,75 @@ class DataBackupService {
94 };
95 }
96
97 + Map<String, dynamic> exportConversations(Store store) {
98 + final all = exportAll(store);
99 + return {
100 + 'version': 2,
101 + 'type': 'conversations',
102 + 'exportedAt': all['exportedAt'],
103 + 'conversations': all['conversations'],
104 + 'messages': all['messages'],
105 + };
106 + }
107 +
108 + Map<String, dynamic> exportPersonas(Store store) {
109 + final all = exportAll(store);
110 + return {
111 + 'version': 2,
112 + 'type': 'personas',
113 + 'exportedAt': all['exportedAt'],
114 + 'personas': all['personas'],
115 + };
116 + }
117 +
118 + Map<String, dynamic> exportSettings(String settingsJson) {
119 + return {
120 + 'version': 2,
121 + 'type': 'settings',
122 + 'exportedAt': DateTime.now().toIso8601String(),
123 + 'settings': jsonDecode(settingsJson),
124 + };
125 + }
126 +
127 + String exportConversationsAsJson(Store store) =>
128 + const JsonEncoder.withIndent(' ').convert(exportConversations(store));
129 +
130 + String exportPersonasAsJson(Store store) =>
131 + const JsonEncoder.withIndent(' ').convert(exportPersonas(store));
132 +
133 + String exportSettingsAsJson(String settingsJson) =>
134 + const JsonEncoder.withIndent(' ').convert(exportSettings(settingsJson));
135 +
136 + List<int> exportAllZip(Store store, String settingsJson) {
137 + final archive = Archive()
138 + ..addFile(
139 + ArchiveFile(
140 + 'conversations.json',
141 + 0,
142 + utf8.encode(exportConversationsAsJson(store)),
143 + ),
144 + )
145 + ..addFile(
146 + ArchiveFile(
147 + 'personas.json',
148 + 0,
149 + utf8.encode(exportPersonasAsJson(store)),
150 + ),
151 + )
152 + ..addFile(
153 + ArchiveFile(
154 + 'settings.json',
155 + 0,
156 + utf8.encode(exportSettingsAsJson(settingsJson)),
157 + ),
158 + );
159 + final encoded = ZipEncoder().encode(archive);
160 + if (encoded == null) {
161 + throw StateError('Failed to encode backup ZIP');
162 + }
163 + return encoded;
164 + }
165 +
166 String exportAllAsJson(Store store) {
167 return const JsonEncoder.withIndent(' ').convert(exportAll(store));
168 }
@@ -156,6 +232,7 @@ class DataBackupService {
232 : null,
233 smartRepliesLastMessageId:
234 item['smartRepliesLastMessageId'] as String?,
235 + folderId: item['folderId'] as String?,
236 );
237 final query =
238 convBox.query(ConversationEntity_.id.equals(conversation.id)).build();
@@ -198,6 +275,10 @@ class DataBackupService {
275 toolCallId: item['toolCallId'] as String?,
276 isProcessing: item['isProcessing'] as bool? ?? false,
277 toolSessionId: item['toolSessionId'] as String?,
278 + variantGroupId: item['variantGroupId'] as String?,
279 + variantIndex: item['variantIndex'] as int? ?? 0,
280 + threadOrder: item['threadOrder'] as int? ?? 0,
281 + isActiveVariant: item['isActiveVariant'] as bool? ?? true,
282 );
283
284 final entity = MessageEntity.fromDomain(message)
@@ -211,4 +292,13 @@ class DataBackupService {
292 }
293 }, decoded);
294 }
295 +
296 + Future<void> importZip(Store store, List<int> bytes) async {
297 + final archive = ZipDecoder().decodeBytes(bytes);
298 + for (final file in archive.files) {
299 + if (file.isFile) {
300 + await importFromJson(store, utf8.decode(file.content));
301 + }
302 + }
303 + }
304 }
lib/core/storage/entities.dart
+113 -1
@@ -34,6 +34,7 @@ class ServerEntity {
34 int statusIndex;
35
36 String? iconName;
37 + String? pathPrefix;
38
39 ServerEntity({
40 this.internalId = 0,
@@ -46,8 +47,9 @@ class ServerEntity {
47 this.isDefault = false,
48 required this.createdAt,
49 required this.lastConnectedAt,
49 - required this.statusIndex,
50 + required this.statusIndex,
51 this.iconName,
52 + this.pathPrefix,
53 });
54
55 factory ServerEntity.fromDomain(Server server) {
@@ -63,6 +65,7 @@ class ServerEntity {
65 lastConnectedAt: server.lastConnectedAt,
66 statusIndex: server.status.index,
67 iconName: server.iconName,
68 + pathPrefix: server.pathPrefix,
69 );
70 }
71
@@ -79,6 +82,7 @@ class ServerEntity {
82 lastConnectedAt: lastConnectedAt,
83 status: ConnectionStatus.values[statusIndex],
84 iconName: iconName,
85 + pathPrefix: pathPrefix,
86 );
87 }
88 }
@@ -183,6 +187,7 @@ class ConversationEntity {
187 bool? mcpEnabled;
188 String? smartRepliesJson;
189 String? smartRepliesLastMessageId;
190 + String? folderId;
191
192 @Backlink()
193 final messages = ToMany<MessageEntity>();
@@ -207,6 +212,7 @@ class ConversationEntity {
212 this.mcpEnabled,
213 this.smartRepliesJson,
214 this.smartRepliesLastMessageId,
215 + this.folderId,
216 });
217
218 factory ConversationEntity.fromDomain(Conversation conversation) {
@@ -231,6 +237,7 @@ class ConversationEntity {
237 ? jsonEncode(conversation.smartReplies)
238 : null,
239 smartRepliesLastMessageId: conversation.smartRepliesLastMessageId,
240 + folderId: conversation.folderId,
241 );
242 }
243
@@ -256,11 +263,96 @@ class ConversationEntity {
263 ? List<String>.from(jsonDecode(smartRepliesJson!))
264 : null,
265 smartRepliesLastMessageId: smartRepliesLastMessageId,
266 + folderId: folderId,
267 );
268 }
269
270 }
271
272 +@Entity()
273 +class ConversationFolderEntity {
274 + @Id()
275 + int internalId = 0;
276 +
277 + @Index()
278 + String id;
279 + String name;
280 + int sortOrder;
281 +
282 + @Property(type: PropertyType.date)
283 + DateTime createdAt;
284 +
285 + ConversationFolderEntity({
286 + this.internalId = 0,
287 + required this.id,
288 + required this.name,
289 + this.sortOrder = 0,
290 + required this.createdAt,
291 + });
292 +}
293 +
294 +@Entity()
295 +class SavedMessageFolderEntity {
296 + @Id()
297 + int internalId = 0;
298 +
299 + @Index()
300 + String id;
301 + String name;
302 + int sortOrder;
303 +
304 + @Property(type: PropertyType.date)
305 + DateTime createdAt;
306 +
307 + SavedMessageFolderEntity({
308 + this.internalId = 0,
309 + required this.id,
310 + required this.name,
311 + this.sortOrder = 0,
312 + required this.createdAt,
313 + });
314 +}
315 +
316 +@Entity()
317 +class SavedMessageEntity {
318 + @Id()
319 + int internalId = 0;
320 +
321 + @Index()
322 + String id;
323 +
324 + @Index()
325 + String sourceMessageId;
326 +
327 + @Index()
328 + String conversationId;
329 +
330 + String conversationTitle;
331 +
332 + @Property(type: PropertyType.int)
333 + int roleIndex;
334 +
335 + String content;
336 + String? modelId;
337 + String? folderId;
338 +
339 + @Property(type: PropertyType.date)
340 + DateTime savedAt;
341 +
342 + SavedMessageEntity({
343 + this.internalId = 0,
344 + required this.id,
345 + required this.sourceMessageId,
346 + required this.conversationId,
347 + required this.conversationTitle,
348 + required this.roleIndex,
349 + required this.content,
350 + this.modelId,
351 + this.folderId,
352 + required this.savedAt,
353 + });
354 +}
355 +
356 @Entity()
357 class MessageEntity {
358 @Id()
@@ -296,6 +388,11 @@ class MessageEntity {
388 bool isProcessing;
389 String? toolSessionId;
390 String? toolEventsJson;
391 + String? variantGroupId;
392 + int variantIndex;
393 + int threadOrder;
394 + bool isActiveVariant;
395 + String? parentMessageId;
396
397 MessageEntity({
398 this.internalId = 0,
@@ -316,6 +413,11 @@ class MessageEntity {
413 this.isProcessing = false,
414 this.toolSessionId,
415 this.toolEventsJson,
416 + this.variantGroupId,
417 + this.variantIndex = 0,
418 + this.threadOrder = 0,
419 + this.isActiveVariant = true,
420 + this.parentMessageId,
421 });
422
423 factory MessageEntity.fromDomain(Message message) {
@@ -343,6 +445,11 @@ class MessageEntity {
445 toolEventsJson: message.toolEvents != null
446 ? jsonEncode(message.toolEvents!.map((e) => e.toMap()).toList())
447 : null,
448 + variantGroupId: message.variantGroupId,
449 + variantIndex: message.variantIndex,
450 + threadOrder: message.threadOrder,
451 + isActiveVariant: message.isActiveVariant,
452 + parentMessageId: message.parentMessageId,
453 );
454 }
455
@@ -375,6 +482,11 @@ class MessageEntity {
482 .map((e) => ToolEvent.fromMap(Map<String, dynamic>.from(e)))
483 .toList()
484 : null,
485 + variantGroupId: variantGroupId,
486 + variantIndex: variantIndex,
487 + threadOrder: threadOrder,
488 + isActiveVariant: isActiveVariant,
489 + parentMessageId: parentMessageId,
490 );
491 }
492 }
lib/core/storage/objectbox_store.dart
+6
@@ -13,12 +13,18 @@ class ObjectBoxStore {
13 late final Box<PersonaEntity> personaBox;
14 late final Box<ConversationEntity> conversationBox;
15 late final Box<MessageEntity> messageBox;
16 + late final Box<ConversationFolderEntity> conversationFolderBox;
17 + late final Box<SavedMessageEntity> savedMessageBox;
18 + late final Box<SavedMessageFolderEntity> savedMessageFolderBox;
19
20 ObjectBoxStore._create(this.store) {
21 serverBox = Box<ServerEntity>(store);
22 personaBox = Box<PersonaEntity>(store);
23 conversationBox = Box<ConversationEntity>(store);
24 messageBox = Box<MessageEntity>(store);
25 + conversationFolderBox = Box<ConversationFolderEntity>(store);
26 + savedMessageBox = Box<SavedMessageEntity>(store);
27 + savedMessageFolderBox = Box<SavedMessageFolderEntity>(store);
28 }
29
30 /// Create an instance of ObjectBox storage.
lib/features/chat/data/chat_service.dart
+13 -6
@@ -26,6 +26,7 @@ abstract class ChatService {
26 List<McpIntegration>? integrations,
27 List<ToolDefinition>? tools,
28 String? previousResponseId,
29 + bool continueGeneration = false,
30 });
31
32 void cancelStream();
@@ -155,6 +156,7 @@ class LMStudioChatService implements ChatService {
156 List<McpIntegration>? integrations,
157 List<ToolDefinition>? tools,
158 String? previousResponseId,
159 + bool continueGeneration = false,
160 }) async* {
161 _cancelToken = CancelToken();
162
@@ -464,6 +466,7 @@ class OpenAICompatibleChatService implements ChatService {
466 List<McpIntegration>? integrations,
467 List<ToolDefinition>? tools,
468 String? previousResponseId,
469 + bool continueGeneration = false,
470 }) async* {
471 _cancelToken = CancelToken();
472 final toolAdapter = OpenAiToolAdapter();
@@ -471,12 +474,14 @@ class OpenAICompatibleChatService implements ChatService {
474 final apiMessages = messages.map(_messageToApiMap).toList();
475 // Strip trailing empty assistant messages to avoid "prefill incompatible
476 // with enable_thinking" errors from servers running thinking models
474 - while (apiMessages.isNotEmpty &&
475 - apiMessages.last['role'] == 'assistant' &&
476 - (apiMessages.last['content'] == null ||
477 - (apiMessages.last['content'] is String &&
478 - (apiMessages.last['content'] as String).isEmpty))) {
479 - apiMessages.removeLast();
477 + if (!continueGeneration) {
478 + while (apiMessages.isNotEmpty &&
479 + apiMessages.last['role'] == 'assistant' &&
480 + (apiMessages.last['content'] == null ||
481 + (apiMessages.last['content'] is String &&
482 + (apiMessages.last['content'] as String).isEmpty))) {
483 + apiMessages.removeLast();
484 + }
485 }
486
487 final body = {
@@ -694,6 +699,7 @@ class OllamaChatService implements ChatService {
699 List<McpIntegration>? integrations,
700 List<ToolDefinition>? tools,
701 String? previousResponseId,
702 + bool continueGeneration = false,
703 }) async* {
704 _cancelToken = CancelToken();
705 final toolAdapter = OllamaToolAdapter();
@@ -815,6 +821,7 @@ class OpenRouterChatService implements ChatService {
821 List<McpIntegration>? integrations,
822 List<ToolDefinition>? tools,
823 String? previousResponseId,
824 + bool continueGeneration = false,
825 }) async* {
826 _cancelToken = CancelToken();
827 final toolAdapter = OpenRouterToolAdapter();
lib/features/chat/data/models/message.dart
+20
@@ -21,6 +21,11 @@ class Message {
21 final bool isProcessing;
22 final String? toolSessionId;
23 final List<ToolEvent>? toolEvents;
24 + final String? variantGroupId;
25 + final int variantIndex;
26 + final int threadOrder;
27 + final bool isActiveVariant;
28 + final String? parentMessageId;
29
30 Message({
31 required this.id,
@@ -40,6 +45,11 @@ class Message {
45 this.isProcessing = false,
46 this.toolSessionId,
47 this.toolEvents,
48 + this.variantGroupId,
49 + this.variantIndex = 0,
50 + this.threadOrder = 0,
51 + this.isActiveVariant = true,
52 + this.parentMessageId,
53 });
54
55 Message copyWith({
@@ -60,6 +70,11 @@ class Message {
70 bool? isProcessing,
71 String? toolSessionId,
72 List<ToolEvent>? toolEvents,
73 + String? variantGroupId,
74 + int? variantIndex,
75 + int? threadOrder,
76 + bool? isActiveVariant,
77 + String? parentMessageId,
78 }) {
79 return Message(
80 id: id ?? this.id,
@@ -79,6 +94,11 @@ class Message {
94 isProcessing: isProcessing ?? this.isProcessing,
95 toolSessionId: toolSessionId ?? this.toolSessionId,
96 toolEvents: toolEvents ?? this.toolEvents,
97 + variantGroupId: variantGroupId ?? this.variantGroupId,
98 + variantIndex: variantIndex ?? this.variantIndex,
99 + threadOrder: threadOrder ?? this.threadOrder,
100 + isActiveVariant: isActiveVariant ?? this.isActiveVariant,
101 + parentMessageId: parentMessageId ?? this.parentMessageId,
102 );
103 }
104 }
lib/features/chat/providers/chat_notifier.dart
+445 -55
@@ -30,6 +30,7 @@ import 'chat_params_providers.dart';
30 import 'chat_service_providers.dart';
31 import 'model_selection_providers.dart';
32 import 'tooling_providers.dart';
33 +import '../utils/message_variants.dart';
34
35 class PendingToolApproval {
36 final ParsedToolCall toolCall;
@@ -40,6 +41,7 @@ class PendingToolApproval {
41
42 class ChatState {
43 final List<Message> messages;
44 + final List<Message> allMessages;
45 final bool isStreaming;
46 final bool isLoading;
47 final String? errorMessage;
@@ -48,6 +50,7 @@ class ChatState {
50
51 const ChatState({
52 this.messages = const [],
53 + this.allMessages = const [],
54 this.isStreaming = false,
55 this.isLoading = false,
56 this.errorMessage,
@@ -57,6 +60,7 @@ class ChatState {
60
61 ChatState copyWith({
62 List<Message>? messages,
63 + List<Message>? allMessages,
64 bool? isStreaming,
65 bool? isLoading,
66 String? errorMessage,
@@ -68,6 +72,7 @@ class ChatState {
72 }) {
73 return ChatState(
74 messages: messages ?? this.messages,
75 + allMessages: allMessages ?? this.allMessages,
76 isStreaming: isStreaming ?? this.isStreaming,
77 isLoading: isLoading ?? this.isLoading,
78 errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
@@ -152,7 +157,11 @@ class ChatNotifier extends Notifier<ChatState> {
157 conversation.id,
158 );
159
155 - state = ChatState(messages: messages, isLoading: false);
160 + state = ChatState(
161 + allMessages: messages,
162 + messages: MessageVariants.resolveActiveTimeline(messages),
163 + isLoading: false,
164 + );
165 ref
166 .read(conv.activeConversationProvider.notifier)
167 .setActiveConversation(conversation);
@@ -213,7 +222,53 @@ class ChatNotifier extends Notifier<ChatState> {
222 }
223
224 messages.sort((a, b) => a.createdAt.compareTo(b.createdAt));
216 - return messages;
225 + final normalized = messages
226 + .asMap()
227 + .entries
228 + .map((entry) {
229 + final msg = entry.value;
230 + if (msg.variantGroupId?.isNotEmpty == true) return msg;
231 + return msg.copyWith(
232 + variantGroupId: msg.id,
233 + variantIndex: 0,
234 + threadOrder: entry.key,
235 + isActiveVariant: true,
236 + );
237 + })
238 + .toList();
239 + return _assignLegacyParentsIfNeeded(normalized);
240 + }
241 +
242 + static List<Message> _assignLegacyParentsIfNeeded(List<Message> messages) {
243 + if (messages.any((m) => m.parentMessageId?.isNotEmpty == true)) {
244 + return messages;
245 + }
246 + final byThreadOrder = <int, List<Message>>{};
247 + for (final message in messages) {
248 + byThreadOrder.putIfAbsent(message.threadOrder, () => []).add(message);
249 + }
250 + final orders = byThreadOrder.keys.toList()..sort();
251 + String? previousActiveId;
252 + final updated = <Message>[];
253 + for (final order in orders) {
254 + final group = byThreadOrder[order]!;
255 + for (final message in group) {
256 + updated.add(message.copyWith(parentMessageId: previousActiveId));
257 + }
258 + final active = group.firstWhere(
259 + (m) => m.isActiveVariant,
260 + orElse: () => group.last,
261 + );
262 + previousActiveId = active.id;
263 + }
264 + return updated;
265 + }
266 +
267 + void _setAllMessages(List<Message> allMessages) {
268 + state = state.copyWith(
269 + allMessages: allMessages,
270 + messages: MessageVariants.resolveActiveTimeline(allMessages),
271 + );
272 }
273
274 Future<void> startNewConversation() async {
@@ -291,22 +346,7 @@ class ChatNotifier extends Notifier<ChatState> {
346 }
347
348 void _replaceMessageInState(Message message, {bool clearStreaming = false}) {
294 - final messageIndex = state.messages.indexWhere((m) => m.id == message.id);
295 - if (messageIndex == -1) {
296 - state = state.copyWith(
297 - streamingMessage: clearStreaming ? null : message,
298 - clearStreaming: clearStreaming,
299 - );
300 - return;
301 - }
302 -
303 - final updatedMessages = List<Message>.from(state.messages);
304 - updatedMessages[messageIndex] = message;
305 - state = state.copyWith(
306 - messages: updatedMessages,
307 - streamingMessage: clearStreaming ? null : message,
308 - clearStreaming: clearStreaming,
309 - );
349 + _replaceMessageInAll(message, clearStreaming: clearStreaming);
350 }
351
352 Future<void> sendMessage(String content, {List<File>? attachments}) async {
@@ -368,6 +408,12 @@ class ChatNotifier extends Notifier<ChatState> {
408 }
409 }
410
411 + final userThreadOrder = MessageVariants.nextThreadOrder(state.messages);
412 + final userGroupId = generateUuid();
413 + final assistantThreadOrder = userThreadOrder + 1;
414 + final assistantGroupId = generateUuid();
415 + final lastInTimeline = state.messages.isNotEmpty ? state.messages.last : null;
416 +
417 final userMessage = Message(
418 id: generateUuid(),
419 conversationId: _currentConversationId!,
@@ -376,6 +422,11 @@ class ChatNotifier extends Notifier<ChatState> {
422 createdAt: DateTime.now(),
423 status: MessageStatus.complete,
424 attachmentPaths: savedPaths.isNotEmpty ? savedPaths : null,
425 + variantGroupId: userGroupId,
426 + variantIndex: 0,
427 + threadOrder: userThreadOrder,
428 + isActiveVariant: true,
429 + parentMessageId: lastInTimeline?.id,
430 );
431
432 final assistantMessageId = generateUuid();
@@ -387,10 +438,17 @@ class ChatNotifier extends Notifier<ChatState> {
438 createdAt: DateTime.now(),
439 status: MessageStatus.streaming,
440 modelId: selectedModel?.id,
441 + variantGroupId: assistantGroupId,
442 + variantIndex: 0,
443 + threadOrder: assistantThreadOrder,
444 + isActiveVariant: true,
445 + parentMessageId: userMessage.id,
446 );
447
448 + final updatedAll = [...state.allMessages, userMessage, assistantMessage];
449 state = state.copyWith(
393 - messages: [...state.messages, userMessage, assistantMessage],
450 + allMessages: updatedAll,
451 + messages: MessageVariants.resolveActiveTimeline(updatedAll),
452 isStreaming: true,
453 streamingMessage: assistantMessage,
454 clearError: true,
@@ -958,6 +1016,82 @@ class ChatNotifier extends Notifier<ChatState> {
1016 }
1017 }
1018
1019 + Future<void> continueFromMessage(String messageId) async {
1020 + final messages = state.messages;
1021 + if (messages.isEmpty || messages.last.id != messageId) return;
1022 + final assistant = messages.last;
1023 + if (assistant.role != MessageRole.assistant) return;
1024 + if (state.isStreaming) return;
1025 +
1026 + final streamingAssistant = assistant.copyWith(
1027 + status: MessageStatus.streaming,
1028 + isProcessing: true,
1029 + );
1030 + _replaceMessageInAll(streamingAssistant);
1031 + state = state.copyWith(
1032 + isStreaming: true,
1033 + streamingMessage: streamingAssistant,
1034 + clearError: true,
1035 + );
1036 + ref.read(isStreamingProvider.notifier).setStreaming(true);
1037 + ref.read(chatBackgroundServiceProvider).start();
1038 + _resetCheckpointMetrics();
1039 + _updateSavedMetrics(streamingAssistant);
1040 + _latestStreamingMessage = streamingAssistant;
1041 +
1042 + await _runAssistantStream(
1043 + streamingAssistant,
1044 + ref.read(selectedModelProvider),
1045 + continueGeneration: true,
1046 + );
1047 + }
1048 +
1049 + Future<void> cycleMessageVariant(String messageId, int direction) async {
1050 + Message? displayMsg;
1051 + for (final m in state.messages) {
1052 + if (m.id == messageId) {
1053 + displayMsg = m;
1054 + break;
1055 + }
1056 + }
1057 + if (displayMsg == null) return;
1058 +
1059 + final variants = MessageVariants.variantsForMessage(
1060 + state.allMessages,
1061 + displayMsg,
1062 + );
1063 + if (variants.length <= 1) return;
1064 +
1065 + final currentIndex = MessageVariants.activeVariantIndex(variants);
1066 + final newIndex = (currentIndex + direction).clamp(0, variants.length - 1);
1067 + if (newIndex == currentIndex) return;
1068 +
1069 + final groupId = MessageVariants.groupId(displayMsg);
1070 + final targetId = variants[newIndex].id;
1071 + final updatedAll = state.allMessages.map((message) {
1072 + if (MessageVariants.groupId(message) != groupId) return message;
1073 + return message.copyWith(isActiveVariant: message.id == targetId);
1074 + }).toList();
1075 +
1076 + for (final message in updatedAll.where(
1077 + (m) => MessageVariants.groupId(m) == groupId,
1078 + )) {
1079 + await _saveMessage(message);
1080 + }
1081 + _setAllMessages(updatedAll);
1082 + }
1083 +
1084 + int _nextVariantIndex(String groupId) {
1085 + final variants = state.allMessages.where(
1086 + (m) => MessageVariants.groupId(m) == groupId,
1087 + );
1088 + if (variants.isEmpty) return 0;
1089 + return variants
1090 + .map((m) => m.variantIndex)
1091 + .reduce((a, b) => a > b ? a : b) +
1092 + 1;
1093 + }
1094 +
1095 Future<void> retryMessage(String messageId) async {
1096 final messageIndex = state.messages.indexWhere((m) => m.id == messageId);
1097 if (messageIndex == -1) return;
@@ -967,32 +1101,255 @@ class ChatNotifier extends Notifier<ChatState> {
1101
1102 if (messageIndex > 0 &&
1103 state.messages[messageIndex - 1].role == MessageRole.user) {
970 - final userMessageIndex = messageIndex - 1;
971 - final userMessage = state.messages[userMessageIndex];
1104 + final userMessage = state.messages[messageIndex - 1];
1105 + final groupId = MessageVariants.groupId(message);
1106 +
1107 + final deactivated = state.allMessages.map((m) {
1108 + if (MessageVariants.groupId(m) != groupId) return m;
1109 + return m.copyWith(isActiveVariant: false);
1110 + }).toList();
1111 + for (final m in deactivated.where(
1112 + (x) => MessageVariants.groupId(x) == groupId,
1113 + )) {
1114 + await _saveMessage(m);
1115 + }
1116 + state = state.copyWith(allMessages: deactivated);
1117
973 - final messagesToRemove = state.messages.sublist(userMessageIndex);
974 - final db = ref.read(databaseProvider);
1118 + await _regenerateAssistant(
1119 + userMessage,
1120 + variantGroupId: groupId,
1121 + threadOrder: message.threadOrder,
1122 + variantIndex: _nextVariantIndex(groupId),
1123 + );
1124 + }
1125 + }
1126
976 - for (final msg in messagesToRemove) {
977 - final query = db.messageBox
978 - .query(MessageEntity_.id.equals(msg.id))
979 - .build();
980 - db.messageBox.removeMany(query.findIds());
981 - query.close();
1127 + Future<void> _regenerateAssistant(
1128 + Message userMessage, {
1129 + required String variantGroupId,
1130 + required int threadOrder,
1131 + required int variantIndex,
1132 + }) async {
1133 + final selectedModel = ref.read(selectedModelProvider);
1134 + final server = ref.read(activeServerProvider);
1135 + if (server == null || _currentConversationId == null) return;
1136 +
1137 + final assistantMessage = Message(
1138 + id: generateUuid(),
1139 + conversationId: _currentConversationId!,
1140 + role: MessageRole.assistant,
1141 + content: '',
1142 + createdAt: DateTime.now(),
1143 + status: MessageStatus.streaming,
1144 + modelId: selectedModel?.id,
1145 + variantGroupId: variantGroupId,
1146 + variantIndex: variantIndex,
1147 + threadOrder: threadOrder,
1148 + isActiveVariant: true,
1149 + parentMessageId: userMessage.id,
1150 + );
1151 +
1152 + final updatedAll = [...state.allMessages, assistantMessage];
1153 + state = state.copyWith(
1154 + allMessages: updatedAll,
1155 + messages: MessageVariants.resolveActiveTimeline(updatedAll),
1156 + isStreaming: true,
1157 + streamingMessage: assistantMessage,
1158 + clearError: true,
1159 + );
1160 + ref.read(isStreamingProvider.notifier).setStreaming(true);
1161 + await _saveMessage(assistantMessage);
1162 +
1163 + ref.read(chatBackgroundServiceProvider).start();
1164 + _resetCheckpointMetrics();
1165 + _updateSavedMetrics(assistantMessage);
1166 +
1167 + await _runAssistantStream(assistantMessage, selectedModel);
1168 + }
1169 +
1170 + Future<void> _runAssistantStream(
1171 + Message assistantMessage,
1172 + ModelInfo? selectedModel, {
1173 + bool continueGeneration = false,
1174 + }) async {
1175 + final server = ref.read(activeServerProvider);
1176 + final chatParams = ref.read(chatParamsProvider);
1177 + final chatService = ref.read(chatServiceProvider);
1178 + if (server == null || chatService == null) return;
1179 +
1180 + final messagesForApi = _buildMessagesForApi(selectedModel);
1181 +
1182 + try {
1183 + _streamSubscription?.cancel();
1184 + _uiUpdateTimer?.cancel();
1185 +
1186 + String reasoningContent = '';
1187 + var streamingAssistantMessage = assistantMessage;
1188 + _latestStreamingMessage = streamingAssistantMessage;
1189 +
1190 + final mcpConfig = ref.read(chatMcpConfigProvider);
1191 + final integrations =
1192 + mcpConfig.enabled && mcpConfig.integrations.isNotEmpty
1193 + ? mcpConfig.integrations
1194 + : null;
1195 +
1196 + final registry = ref.read(toolRegistryProvider);
1197 + final tools = mcpConfig.enabled
1198 + ? await registry.listTools()
1199 + : const <ToolDefinition>[];
1200 +
1201 + bool stateNeedsUpdate = false;
1202 +
1203 + void updateUiState() {
1204 + if (!stateNeedsUpdate) return;
1205 + stateNeedsUpdate = false;
1206 + if (_currentConversationId == assistantMessage.conversationId) {
1207 + state = state.copyWith(streamingMessage: streamingAssistantMessage);
1208 + }
1209 }
1210
1211 + _uiUpdateTimer = Timer.periodic(const Duration(milliseconds: 80), (_) {
1212 + updateUiState();
1213 + });
1214 +
1215 + _streamSubscription = chatService
1216 + .sendMessage(
1217 + server: server,
1218 + modelId: selectedModel?.id ?? 'default',
1219 + messages: messagesForApi,
1220 + params: chatParams,
1221 + integrations: integrations,
1222 + tools: tools,
1223 + continueGeneration: continueGeneration,
1224 + )
1225 + .listen(
1226 + (response) async {
1227 + if (_currentConversationId != assistantMessage.conversationId) {
1228 + return;
1229 + }
1230 +
1231 + switch (response.type) {
1232 + case ChatResponseType.message:
1233 + streamingAssistantMessage = streamingAssistantMessage
1234 + .copyWith(
1235 + content:
1236 + streamingAssistantMessage.content +
1237 + (response.content ?? ''),
1238 + isProcessing: false,
1239 + );
1240 + _latestStreamingMessage = streamingAssistantMessage;
1241 + _chunkCount++;
1242 + if (_shouldCheckpointSave(streamingAssistantMessage)) {
1243 + _saveService?.enqueue(streamingAssistantMessage);
1244 + _updateSavedMetrics(streamingAssistantMessage);
1245 + _resetCheckpointMetrics();
1246 + }
1247 + stateNeedsUpdate = true;
1248 + break;
1249 + case ChatResponseType.reasoning:
1250 + reasoningContent += response.reasoningContent ?? '';
1251 + streamingAssistantMessage = streamingAssistantMessage
1252 + .copyWith(
1253 + reasoningContent: reasoningContent,
1254 + isProcessing: false,
1255 + );
1256 + _latestStreamingMessage = streamingAssistantMessage;
1257 + _chunkCount++;
1258 + if (_shouldCheckpointSave(streamingAssistantMessage)) {
1259 + _saveService?.enqueue(streamingAssistantMessage);
1260 + _updateSavedMetrics(streamingAssistantMessage);
1261 + _resetCheckpointMetrics();
1262 + }
1263 + stateNeedsUpdate = true;
1264 + break;
1265 + case ChatResponseType.processing:
1266 + streamingAssistantMessage = streamingAssistantMessage
1267 + .copyWith(isProcessing: true);
1268 + _latestStreamingMessage = streamingAssistantMessage;
1269 + stateNeedsUpdate = true;
1270 + break;
1271 + case ChatResponseType.timeoutError:
1272 + case ChatResponseType.error:
1273 + _uiUpdateTimer?.cancel();
1274 + _uiUpdateTimer = null;
1275 + streamingAssistantMessage = streamingAssistantMessage.copyWith(
1276 + status: MessageStatus.error,
1277 + errorMessage: response.content,
1278 + isProcessing: false,
1279 + );
1280 + await _saveMessage(streamingAssistantMessage);
1281 + _replaceMessageInAll(
1282 + streamingAssistantMessage,
1283 + clearStreaming: true,
1284 + );
1285 + state = state.copyWith(isStreaming: false, clearStreaming: true);
1286 + ref.read(isStreamingProvider.notifier).setStreaming(false);
1287 + ref.read(chatBackgroundServiceProvider).stop();
1288 + break;
1289 + default:
1290 + break;
1291 + }
1292 + },
1293 + onDone: () async {
1294 + _uiUpdateTimer?.cancel();
1295 + _uiUpdateTimer = null;
1296 + final finalMessage = streamingAssistantMessage.copyWith(
1297 + status: MessageStatus.complete,
1298 + isProcessing: false,
1299 + );
1300 + await _saveMessage(finalMessage);
1301 + _replaceMessageInAll(finalMessage, clearStreaming: true);
1302 + state = state.copyWith(isStreaming: false, clearStreaming: true);
1303 + ref.read(isStreamingProvider.notifier).setStreaming(false);
1304 + ref.read(chatBackgroundServiceProvider).stop();
1305 + _maybeRequestReviewAfterSuccessfulCompletion(
1306 + finalMessage: finalMessage,
1307 + server: server,
1308 + selectedModel: selectedModel,
1309 + );
1310 + },
1311 + onError: (Object error, StackTrace stackTrace) async {
1312 + Log.error('Stream error: $error');
1313 + _uiUpdateTimer?.cancel();
1314 + _uiUpdateTimer = null;
1315 + final errorMessage = streamingAssistantMessage.copyWith(
1316 + status: MessageStatus.error,
1317 + errorMessage: error.toString(),
1318 + isProcessing: false,
1319 + );
1320 + await _saveMessage(errorMessage);
1321 + _replaceMessageInAll(errorMessage, clearStreaming: true);
1322 + state = state.copyWith(
1323 + isStreaming: false,
1324 + clearStreaming: true,
1325 + errorMessage: error.toString(),
1326 + );
1327 + ref.read(isStreamingProvider.notifier).setStreaming(false);
1328 + ref.read(chatBackgroundServiceProvider).stop();
1329 + },
1330 + );
1331 + } catch (e) {
1332 state = state.copyWith(
985 - messages: state.messages.sublist(0, userMessageIndex),
1333 + isStreaming: false,
1334 + errorMessage: e.toString(),
1335 clearStreaming: true,
1336 );
988 -
989 - await sendMessage(
990 - userMessage.content,
991 - attachments: userMessage.attachmentPaths?.map((p) => File(p)).toList(),
992 - );
1337 + ref.read(isStreamingProvider.notifier).setStreaming(false);
1338 }
1339 }
1340
1341 + void _replaceMessageInAll(Message message, {bool clearStreaming = false}) {
1342 + final updatedAll = state.allMessages.map((m) {
1343 + return m.id == message.id ? message : m;
1344 + }).toList();
1345 + state = state.copyWith(
1346 + allMessages: updatedAll,
1347 + messages: MessageVariants.resolveActiveTimeline(updatedAll),
1348 + streamingMessage: clearStreaming ? null : message,
1349 + clearStreaming: clearStreaming,
1350 + );
1351 + }
1352 +
1353 Future<void> editAssistantMessage(String messageId, String newContent) async {
1354 final messageIndex = state.messages.indexWhere((m) => m.id == messageId);
1355 if (messageIndex == -1) return;
@@ -1002,9 +1359,30 @@ class ChatNotifier extends Notifier<ChatState> {
1359 if (newContent.trim().isEmpty) return;
1360 if (newContent == message.content) return;
1361
1005 - final updated = message.copyWith(content: newContent);
1362 + final groupId = MessageVariants.groupId(message);
1363 + final deactivated = state.allMessages.map((m) {
1364 + if (MessageVariants.groupId(m) != groupId) return m;
1365 + return m.copyWith(isActiveVariant: false);
1366 + }).toList();
1367 + for (final m in deactivated.where(
1368 + (x) => MessageVariants.groupId(x) == groupId,
1369 + )) {
1370 + await _saveMessage(m);
1371 + }
1372 + state = state.copyWith(allMessages: deactivated);
1373 +
1374 + final updated = message.copyWith(
1375 + id: generateUuid(),
1376 + content: newContent,
1377 + createdAt: DateTime.now(),
1378 + status: MessageStatus.complete,
1379 + variantIndex: _nextVariantIndex(groupId),
1380 + isActiveVariant: true,
1381 + parentMessageId: message.parentMessageId,
1382 + );
1383 await _saveMessage(updated);
1007 - _replaceMessageInState(updated);
1384 + final updatedAll = [...state.allMessages, updated];
1385 + _setAllMessages(updatedAll);
1386 }
1387
1388 Future<void> branchFromMessage(String messageId) async {
@@ -1082,8 +1460,11 @@ class ChatNotifier extends Notifier<ChatState> {
1460 db.messageBox.removeMany(query.findIds());
1461 query.close();
1462
1463 + final updatedAll =
1464 + state.allMessages.where((m) => m.id != messageId).toList();
1465 state = state.copyWith(
1086 - messages: state.messages.where((m) => m.id != messageId).toList(),
1466 + allMessages: updatedAll,
1467 + messages: MessageVariants.resolveActiveTimeline(updatedAll),
1468 );
1469 }
1470
@@ -1096,26 +1477,35 @@ class ChatNotifier extends Notifier<ChatState> {
1477 if (newContent.trim().isEmpty) return;
1478 if (newContent == message.content) return;
1479
1099 - final attachmentPaths = message.attachmentPaths;
1100 -
1101 - final messagesToRemove = state.messages.sublist(messageIndex);
1102 - final db = ref.read(databaseProvider);
1103 - for (final msg in messagesToRemove) {
1104 - final query = db.messageBox
1105 - .query(MessageEntity_.id.equals(msg.id))
1106 - .build();
1107 - db.messageBox.removeMany(query.findIds());
1108 - query.close();
1480 + final groupId = MessageVariants.groupId(message);
1481 + final deactivated = state.allMessages.map((m) {
1482 + if (MessageVariants.groupId(m) != groupId) return m;
1483 + return m.copyWith(isActiveVariant: false);
1484 + }).toList();
1485 + for (final m in deactivated.where(
1486 + (x) => MessageVariants.groupId(x) == groupId,
1487 + )) {
1488 + await _saveMessage(m);
1489 }
1490 + state = state.copyWith(allMessages: deactivated);
1491
1111 - state = state.copyWith(
1112 - messages: state.messages.sublist(0, messageIndex),
1113 - clearStreaming: true,
1492 + final newUser = message.copyWith(
1493 + id: generateUuid(),
1494 + content: newContent,
1495 + createdAt: DateTime.now(),
1496 + variantIndex: _nextVariantIndex(groupId),
1497 + isActiveVariant: true,
1498 + parentMessageId: message.parentMessageId,
1499 );
1115 -
1116 - await sendMessage(
1117 - newContent,
1118 - attachments: attachmentPaths?.map((p) => File(p)).toList(),
1500 + await _saveMessage(newUser);
1501 + final updatedAll = [...state.allMessages, newUser];
1502 + _setAllMessages(updatedAll);
1503 +
1504 + await _regenerateAssistant(
1505 + newUser,
1506 + variantGroupId: generateUuid(),
1507 + threadOrder: message.threadOrder + 1,
1508 + variantIndex: 0,
1509 );
1510 }
1511
lib/features/chat/utils/message_variants.dart new
+156
@@ -0,0 +1,156 @@
1 +import '../data/models/message.dart';
2 +
3 +class MessageVariants {
4 + static String groupId(Message message) =>
5 + message.variantGroupId?.isNotEmpty == true
6 + ? message.variantGroupId!
7 + : message.id;
8 +
9 + static List<Message> resolveActiveTimeline(List<Message> allMessages) {
10 + if (allMessages.isEmpty) return [];
11 +
12 + final normalized = allMessages.map(_normalizeLegacy).toList();
13 + final withParents = _ensureParentLinks(normalized);
14 +
15 + final childrenOf = <String, List<Message>>{};
16 + final roots = <Message>[];
17 + for (final message in withParents) {
18 + final parentId = message.parentMessageId;
19 + if (parentId == null || parentId.isEmpty) {
20 + roots.add(message);
21 + } else {
22 + childrenOf.putIfAbsent(parentId, () => []).add(message);
23 + }
24 + }
25 +
26 + if (roots.isEmpty) {
27 + return _legacyFlatResolve(normalized);
28 + }
29 +
30 + roots.sort((a, b) {
31 + final order = a.threadOrder.compareTo(b.threadOrder);
32 + if (order != 0) return order;
33 + return a.createdAt.compareTo(b.createdAt);
34 + });
35 +
36 + final timeline = <Message>[];
37 + var current = _pickActiveSibling(roots);
38 + while (true) {
39 + timeline.add(current);
40 + final children = childrenOf[current.id];
41 + if (children == null || children.isEmpty) break;
42 + current = _pickActiveSibling(children);
43 + }
44 + return timeline;
45 + }
46 +
47 + static List<Message> _legacyFlatResolve(List<Message> normalized) {
48 + final byGroup = <String, List<Message>>{};
49 + for (final message in normalized) {
50 + byGroup.putIfAbsent(groupId(message), () => []).add(message);
51 + }
52 +
53 + final active = <Message>[];
54 + for (final group in byGroup.values) {
55 + group.sort((a, b) => a.variantIndex.compareTo(b.variantIndex));
56 + final selected = group.firstWhere(
57 + (m) => m.isActiveVariant,
58 + orElse: () => group.last,
59 + );
60 + active.add(selected);
61 + }
62 +
63 + active.sort((a, b) {
64 + final orderCompare = a.threadOrder.compareTo(b.threadOrder);
65 + if (orderCompare != 0) return orderCompare;
66 + return a.createdAt.compareTo(b.createdAt);
67 + });
68 + return active;
69 + }
70 +
71 + static List<Message> _ensureParentLinks(List<Message> messages) {
72 + if (messages.any((m) => m.parentMessageId?.isNotEmpty == true)) {
73 + return messages;
74 + }
75 +
76 + final byThreadOrder = <int, List<Message>>{};
77 + for (final message in messages) {
78 + byThreadOrder.putIfAbsent(message.threadOrder, () => []).add(message);
79 + }
80 + final orders = byThreadOrder.keys.toList()..sort();
81 +
82 + String? previousActiveId;
83 + final updated = <Message>[];
84 + for (final order in orders) {
85 + final group = byThreadOrder[order]!;
86 + for (final message in group) {
87 + updated.add(message.copyWith(parentMessageId: previousActiveId));
88 + }
89 + final active = group.firstWhere(
90 + (m) => m.isActiveVariant,
91 + orElse: () => group.last,
92 + );
93 + previousActiveId = active.id;
94 + }
95 + return updated;
96 + }
97 +
98 + static Message _pickActiveSibling(List<Message> siblings) {
99 + final byGroup = <String, List<Message>>{};
100 + for (final message in siblings) {
101 + byGroup.putIfAbsent(groupId(message), () => []).add(message);
102 + }
103 +
104 + if (byGroup.length == 1) {
105 + final group = byGroup.values.first;
106 + group.sort((a, b) => a.variantIndex.compareTo(b.variantIndex));
107 + return group.firstWhere(
108 + (m) => m.isActiveVariant,
109 + orElse: () => group.last,
110 + );
111 + }
112 +
113 + siblings.sort((a, b) {
114 + final order = a.threadOrder.compareTo(b.threadOrder);
115 + if (order != 0) return order;
116 + return a.createdAt.compareTo(b.createdAt);
117 + });
118 + return siblings.firstWhere(
119 + (m) => m.isActiveVariant,
120 + orElse: () => siblings.first,
121 + );
122 + }
123 +
124 + static List<Message> variantsForMessage(
125 + List<Message> allMessages,
126 + Message message,
127 + ) {
128 + final gid = groupId(message);
129 + return allMessages
130 + .where((m) => groupId(m) == gid)
131 + .toList()
132 + ..sort((a, b) => a.variantIndex.compareTo(b.variantIndex));
133 + }
134 +
135 + static int activeVariantIndex(List<Message> variants) {
136 + final index = variants.indexWhere((m) => m.isActiveVariant);
137 + return index >= 0 ? index : variants.length - 1;
138 + }
139 +
140 + static Message _normalizeLegacy(Message message) {
141 + if (message.variantGroupId?.isNotEmpty == true) return message;
142 + return message.copyWith(
143 + variantGroupId: message.id,
144 + variantIndex: 0,
145 + isActiveVariant: true,
146 + );
147 + }
148 +
149 + static int nextThreadOrder(List<Message> activeTimeline) {
150 + if (activeTimeline.isEmpty) return 0;
151 + return activeTimeline
152 + .map((m) => m.threadOrder)
153 + .reduce((a, b) => a > b ? a : b) +
154 + 1;
155 + }
156 +}
lib/features/chat/views/chat_screen.dart
+71 -1
@@ -12,12 +12,14 @@ import 'package:localmind/core/utils/system_insets.dart';
12 import 'package:localmind/l10n/app_localizations.dart';
13 import 'package:localmind/features/conversations/providers/conversation_providers.dart'
14 as conv;
15 +import 'package:localmind/features/conversations/data/models/conversation.dart';
16 import 'package:localmind/features/models/screens/model_picker_sheet.dart';
17 import 'package:localmind/features/personas/providers/personas_providers.dart';
18 import 'package:localmind/features/servers/providers/server_providers.dart';
19 import 'package:localmind/features/servers/data/models/server.dart';
20 import 'package:localmind/features/servers/views/components/server_icon_picker.dart';
21 import 'package:localmind/features/tts/views/components/tts_player_bar.dart';
22 +import 'package:localmind/features/saved_messages/providers/saved_message_providers.dart';
23 import '../data/models/message.dart';
24 import '../providers/chat_mcp_providers.dart';
25 import '../providers/chat_providers.dart';
@@ -122,6 +124,11 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
124 .read(conv.conversationsProvider.notifier)
125 .updatePersona(activeConv.id, null, null);
126 }
127 + case 'rename':
128 + final activeConv = ref.read(conv.activeConversationProvider);
129 + if (activeConv != null) {
130 + _showRenameDialog(context, activeConv);
131 + }
132 case 'clear':
133 showDialog(
134 context: context,
@@ -202,6 +209,46 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
209 ref.read(chatProvider.notifier).approveTool(value ?? false);
210 });
211 }
212 +
213 + void _showRenameDialog(BuildContext context, Conversation conversation) {
214 + final l10n = AppLocalizations.of(context)!;
215 + final controller = TextEditingController(text: conversation.title);
216 +
217 + showDialog(
218 + context: context,
219 + builder: (context) {
220 + return AlertDialog(
221 + title: Text(l10n.rename_conversation),
222 + content: TextField(
223 + controller: controller,
224 + autofocus: true,
225 + decoration: InputDecoration(
226 + hintText: l10n.enter_new_title,
227 + border: const OutlineInputBorder(),
228 + ),
229 + ),
230 + actions: [
231 + TextButton(
232 + onPressed: () => Navigator.pop(context),
233 + child: Text(l10n.cancel),
234 + ),
235 + TextButton(
236 + onPressed: () {
237 + final newTitle = controller.text.trim();
238 + if (newTitle.isNotEmpty) {
239 + ref
240 + .read(conv.conversationsProvider.notifier)
241 + .renameConversation(conversation.id, newTitle);
242 + }
243 + Navigator.pop(context);
244 + },
245 + child: Text(l10n.rename),
246 + ),
247 + ],
248 + );
249 + },
250 + ).then((_) => controller.dispose());
251 + }
252 }
253
254 class _ChatBody extends ConsumerWidget {
@@ -261,6 +308,7 @@ class _ChatBody extends ConsumerWidget {
308 ModelTopBar(selectedModel: selectedModel, onTap: onModelPicker),
309 _ScreenAppBar(
310 activeServer: activeServer,
311 + activeConversation: activeConversation,
312 connectionStatus: connectionStatus,
313 isDark: isDark,
314 persona: persona,
@@ -380,6 +428,7 @@ class _MessageArea extends ConsumerWidget {
428 return MessageList(
429 scrollController: scrollController,
430 messages: messages,
431 + allMessages: ref.watch(chatProvider.select((s) => s.allMessages)),
432 isStreaming: isStreaming,
433 onRetry: (messageId) =>
434 ref.read(chatProvider.notifier).retryMessage(messageId),
@@ -412,6 +461,13 @@ class _MessageArea extends ConsumerWidget {
461 },
462 onBranch: (messageId) =>
463 ref.read(chatProvider.notifier).branchFromMessage(messageId),
464 + onContinue: (messageId) =>
465 + ref.read(chatProvider.notifier).continueFromMessage(messageId),
466 + onCycleVariant: (messageId, direction) => ref
467 + .read(chatProvider.notifier)
468 + .cycleMessageVariant(messageId, direction),
469 + onSave: (message) =>
470 + ref.read(savedMessagesProvider.notifier).saveMessage(message),
471 onModelPicker: onModelPicker,
472 hasSmartReplies:
473 !isStreaming &&
@@ -425,6 +481,7 @@ class _MessageArea extends ConsumerWidget {
481 class _ScreenAppBar extends ConsumerWidget {
482 const _ScreenAppBar({
483 required this.activeServer,
484 + required this.activeConversation,
485 required this.connectionStatus,
486 required this.isDark,
487 required this.persona,
@@ -433,6 +490,7 @@ class _ScreenAppBar extends ConsumerWidget {
490 });
491
492 final dynamic activeServer;
493 + final Conversation? activeConversation;
494 final ConnectionStatus connectionStatus;
495 final bool isDark;
496 final dynamic persona;
@@ -471,11 +529,14 @@ class _ScreenAppBar extends ConsumerWidget {
529 const SizedBox(width: 8),
530 ],
531 Text(
474 - activeServer?.name ?? l10n.chat_title,
532 + activeConversation?.title.isNotEmpty == true
533 + ? activeConversation!.title
534 + : (activeServer?.name ?? l10n.chat_title),
535 style: const TextStyle(
536 fontSize: 18,
537 fontWeight: FontWeight.bold,
538 ),
539 + overflow: TextOverflow.ellipsis,
540 ),
541 const SizedBox(width: 8),
542 Container(
@@ -533,6 +594,15 @@ class _ScreenAppBar extends ConsumerWidget {
594 contentPadding: EdgeInsets.zero,
595 ),
596 ),
597 + if (activeConversation != null)
598 + PopupMenuItem(
599 + value: 'rename',
600 + child: ListTile(
601 + leading: const Icon(Icons.drive_file_rename_outline),
602 + title: Text(l10n.rename_conversation),
603 + contentPadding: EdgeInsets.zero,
604 + ),
605 + ),
606 PopupMenuItem(
607 value: 'persona',
608 child: ListTile(
lib/features/chat/views/components/chat_bubble.dart
+15
@@ -11,22 +11,30 @@ class ChatBubble extends StatelessWidget {
11 const ChatBubble({
12 super.key,
13 required this.message,
14 + required this.allMessages,
15 this.onCopy,
16 this.onRetry,
17 this.onDelete,
18 this.onEdit,
19 this.onBranch,
20 + this.onContinue,
21 this.onModelTap,
22 + this.onCycleVariant,
23 + this.onSave,
24 this.isStreaming = false,
25 });
26
27 final Message message;
28 + final List<Message> allMessages;
29 final VoidCallback? onCopy;
30 final VoidCallback? onRetry;
31 final VoidCallback? onDelete;
32 final VoidCallback? onEdit;
33 final VoidCallback? onBranch;
34 + final VoidCallback? onContinue;
35 final VoidCallback? onModelTap;
36 + final void Function(int direction)? onCycleVariant;
37 + final void Function(Message message)? onSave;
38 final bool isStreaming;
39
40 @override
@@ -41,10 +49,13 @@ class ChatBubble extends StatelessWidget {
49 alignment: AlignmentDirectional.centerEnd,
50 child: UserBubble(
51 message: message,
52 + allMessages: allMessages,
53 onCopy: onCopy,
54 onDelete: onDelete,
55 onEdit: onEdit,
56 onBranch: onBranch,
57 + onCycleVariant: onCycleVariant,
58 + onSave: onSave,
59 ),
60 );
61 case MessageRole.assistant:
@@ -52,12 +63,16 @@ class ChatBubble extends StatelessWidget {
63 alignment: AlignmentDirectional.centerStart,
64 child: AssistantBubble(
65 message: message,
66 + allMessages: allMessages,
67 onCopy: onCopy,
68 onRetry: onRetry,
69 onDelete: onDelete,
70 onEdit: onEdit,
71 onBranch: onBranch,
72 + onContinue: onContinue,
73 onModelTap: onModelTap,
74 + onCycleVariant: onCycleVariant,
75 + onSave: onSave,
76 isStreaming: isStreaming,
77 ),
78 );
lib/features/chat/views/components/chat_bubble/assistant_bubble.dart
+19
@@ -7,6 +7,7 @@ import 'package:localmind/features/chat/views/components/processing_indicator.da
7 import 'package:localmind/features/chat/views/components/typing_indicator.dart';
8 import 'package:localmind/features/chat/views/components/reasoning_widget.dart';
9 import 'package:localmind/features/chat/views/components/message_action_bar.dart';
10 +import 'package:localmind/features/chat/views/components/message_variant_navigator.dart';
11 import 'markdown/themed_gpt_markdown.dart';
12 import 'tool_bubble/tool_timeline.dart';
13
@@ -19,17 +20,25 @@ class AssistantBubble extends StatelessWidget {
20 this.onDelete,
21 this.onEdit,
22 this.onBranch,
23 + this.onContinue,
24 this.onModelTap,
25 + this.onCycleVariant,
26 + this.onSave,
27 + this.allMessages = const [],
28 this.isStreaming = false,
29 });
30
31 final Message message;
32 + final List<Message> allMessages;
33 final VoidCallback? onCopy;
34 final VoidCallback? onRetry;
35 final VoidCallback? onDelete;
36 final VoidCallback? onEdit;
37 final VoidCallback? onBranch;
38 + final VoidCallback? onContinue;
39 + final void Function(Message message)? onSave;
40 final VoidCallback? onModelTap;
41 + final void Function(int direction)? onCycleVariant;
42 final bool isStreaming;
43
44 @override
@@ -109,6 +118,12 @@ class AssistantBubble extends StatelessWidget {
118 padding: EdgeInsets.only(top: 8),
119 child: _StreamingIndicator(),
120 ),
121 + if (!isStreaming && onCycleVariant != null)
122 + MessageVariantNavigator(
123 + message: message,
124 + allMessages: allMessages,
125 + onCycle: onCycleVariant!,
126 + ),
127 const SizedBox(height: 8),
128 Row(
129 children: [
@@ -127,11 +142,15 @@ class AssistantBubble extends StatelessWidget {
142 message.status == MessageStatus.cancelled))
143 MessageActionBar(
144 content: message.content,
145 + tokenCount: message.tokenCount,
146 + messageId: message.id,
147 onCopy: onCopy,
148 onRetry: onRetry,
149 onDelete: onDelete,
150 onEdit: onEdit,
151 onBranch: onBranch,
152 + onContinue: onContinue,
153 + onSave: () => onSave?.call(message),
154 ),
155 ],
156 ),
lib/features/chat/views/components/chat_bubble/user_bubble.dart
+19
@@ -3,6 +3,7 @@ import 'package:localmind/core/models/enums.dart';
3 import 'package:localmind/core/theme/colors.dart';
4 import 'package:localmind/features/chat/data/models/message.dart';
5 import 'package:localmind/features/chat/views/components/message_action_bar.dart';
6 +import 'package:localmind/features/chat/views/components/message_variant_navigator.dart';
7 import 'markdown/themed_gpt_markdown.dart';
8 import 'attachment_list.dart';
9
@@ -14,13 +15,19 @@ class UserBubble extends StatelessWidget {
15 this.onDelete,
16 this.onEdit,
17 this.onBranch,
18 + this.onCycleVariant,
19 + this.onSave,
20 + this.allMessages = const [],
21 });
22
23 final Message message;
24 + final List<Message> allMessages;
25 final VoidCallback? onCopy;
26 final VoidCallback? onDelete;
27 final VoidCallback? onEdit;
28 final VoidCallback? onBranch;
29 + final void Function(int direction)? onCycleVariant;
30 + final void Function(Message message)? onSave;
31
32 @override
33 Widget build(BuildContext context) {
@@ -80,6 +87,15 @@ class UserBubble extends StatelessWidget {
87 ],
88 ),
89 ),
90 + if (onCycleVariant != null)
91 + Align(
92 + alignment: AlignmentDirectional.centerEnd,
93 + child: MessageVariantNavigator(
94 + message: message,
95 + allMessages: allMessages,
96 + onCycle: onCycleVariant!,
97 + ),
98 + ),
99 Padding(
100 padding: const EdgeInsetsDirectional.only(end: 12, bottom: 4),
101 child: Row(
@@ -99,10 +115,13 @@ class UserBubble extends StatelessWidget {
115 const SizedBox(width: 8),
116 MessageActionBar(
117 content: message.content,
118 + tokenCount: message.tokenCount,
119 + messageId: message.id,
120 onCopy: onCopy,
121 onDelete: onDelete,
122 onEdit: onEdit,
123 onBranch: onBranch,
124 + onSave: onSave == null ? null : () => onSave!(message),
125 ),
126 ],
127 ),
lib/features/chat/views/components/message_action_bar.dart
+39
@@ -10,21 +10,29 @@ class MessageActionBar extends ConsumerStatefulWidget {
10 const MessageActionBar({
11 super.key,
12 required this.content,
13 + this.tokenCount,
14 + this.messageId,
15 this.onCopy,
16 this.onRetry,
17 this.onDelete,
18 this.onEdit,
19 this.onShare,
20 this.onBranch,
21 + this.onContinue,
22 + this.onSave,
23 });
24
25 final String content;
26 + final int? tokenCount;
27 + final String? messageId;
28 final VoidCallback? onCopy;
29 final VoidCallback? onRetry;
30 final VoidCallback? onDelete;
31 final VoidCallback? onEdit;
32 final VoidCallback? onShare;
33 final VoidCallback? onBranch;
34 + final VoidCallback? onContinue;
35 + final VoidCallback? onSave;
36
37 @override
38 ConsumerState<MessageActionBar> createState() => _MessageActionBarState();
@@ -86,6 +94,14 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
94 onTap: widget.onRetry,
95 ),
96 ],
97 + if (widget.onContinue != null) ...[
98 + const SizedBox(width: 4),
99 + _ActionButton(
100 + icon: Icons.arrow_forward,
101 + label: l10n.continue_action,
102 + onTap: widget.onContinue,
103 + ),
104 + ],
105 if (widget.onEdit != null) ...[
106 const SizedBox(width: 4),
107 _ActionButton(icon: Icons.edit, label: l10n.edit, onTap: widget.onEdit),
@@ -211,11 +227,34 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
227 widget.onBranch?.call();
228 },
229 ),
230 + if (widget.onSave != null)
231 + ListTile(
232 + leading: const Icon(Icons.bookmark_outline),
233 + title: Text(sheetL10n.save_message),
234 + onTap: () {
235 + Navigator.of(context).pop();
236 + widget.onSave?.call();
237 + ScaffoldMessenger.of(context).showSnackBar(
238 + SnackBar(content: Text(sheetL10n.message_saved)),
239 + );
240 + },
241 + ),
242 ListTile(
243 leading: const Icon(Icons.text_fields),
244 title: Text(sheetL10n.character_count(widget.content.length)),
245 enabled: false,
246 ),
247 + ListTile(
248 + leading: const Icon(Icons.token),
249 + title: Text(
250 + widget.tokenCount != null
251 + ? sheetL10n.token_count(widget.tokenCount!)
252 + : sheetL10n.estimated_token_count(
253 + (widget.content.length / 4).ceil(),
254 + ),
255 + ),
256 + enabled: false,
257 + ),
258 ],
259 ),
260 ),
lib/features/chat/views/components/message_list/message_list.dart
+108 -20
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
3 import 'package:localmind/core/models/enums.dart';
4 import 'package:localmind/features/chat/data/models/message.dart';
5 import 'package:localmind/features/chat/providers/chat_providers.dart';
6 +import 'package:localmind/features/conversations/providers/conversation_providers.dart';
7 import '../chat_bubble.dart';
8
9 class MessageList extends StatelessWidget {
@@ -10,26 +11,34 @@ class MessageList extends StatelessWidget {
11 super.key,
12 required this.scrollController,
13 required this.messages,
14 + required this.allMessages,
15 required this.isStreaming,
16 required this.onRetry,
17 required this.onDelete,
18 required this.onEdit,
19 required this.onEditAssistant,
20 required this.onBranch,
21 + required this.onContinue,
22 + required this.onCycleVariant,
23 required this.onModelPicker,
24 + this.onSave,
25 this.hasSmartReplies = false,
26 this.bottomInset = 0,
27 });
28
29 final ScrollController scrollController;
30 final List<Message> messages;
31 + final List<Message> allMessages;
32 final bool isStreaming;
33 final void Function(String) onRetry;
34 final void Function(String) onDelete;
35 final void Function(String messageId, String currentContent) onEdit;
36 final void Function(String messageId, String currentContent) onEditAssistant;
37 final void Function(String messageId) onBranch;
38 + final void Function(String messageId) onContinue;
39 + final void Function(String messageId, int direction) onCycleVariant;
40 final VoidCallback onModelPicker;
41 + final void Function(Message message)? onSave;
42 final bool hasSmartReplies;
43 final double bottomInset;
44
@@ -38,65 +47,125 @@ class MessageList extends StatelessWidget {
47 return _MessageListConsumer(
48 scrollController: scrollController,
49 messages: messages,
50 + allMessages: allMessages,
51 isStreaming: isStreaming,
52 onRetry: onRetry,
53 onDelete: onDelete,
54 onEdit: onEdit,
55 onEditAssistant: onEditAssistant,
56 onBranch: onBranch,
57 + onContinue: onContinue,
58 + onCycleVariant: onCycleVariant,
59 onModelPicker: onModelPicker,
60 + onSave: onSave,
61 hasSmartReplies: hasSmartReplies,
62 bottomInset: bottomInset,
63 );
64 }
65 }
66
54 -class _MessageListConsumer extends ConsumerWidget {
67 +class _MessageListConsumer extends ConsumerStatefulWidget {
68 const _MessageListConsumer({
69 required this.scrollController,
70 required this.messages,
71 + required this.allMessages,
72 required this.isStreaming,
73 required this.onRetry,
74 required this.onDelete,
75 required this.onEdit,
76 required this.onEditAssistant,
77 required this.onBranch,
78 + required this.onContinue,
79 + required this.onCycleVariant,
80 required this.onModelPicker,
81 + this.onSave,
82 this.hasSmartReplies = false,
83 this.bottomInset = 0,
84 });
85
86 final ScrollController scrollController;
87 final List<Message> messages;
88 + final List<Message> allMessages;
89 final bool isStreaming;
90 final void Function(String) onRetry;
91 final void Function(String) onDelete;
92 final void Function(String messageId, String currentContent) onEdit;
93 final void Function(String messageId, String currentContent) onEditAssistant;
94 final void Function(String messageId) onBranch;
95 + final void Function(String messageId) onContinue;
96 + final void Function(String messageId, int direction) onCycleVariant;
97 final VoidCallback onModelPicker;
98 + final void Function(Message message)? onSave;
99 final bool hasSmartReplies;
100 final double bottomInset;
101
102 @override
82 - Widget build(BuildContext context, WidgetRef ref) {
103 + ConsumerState<_MessageListConsumer> createState() =>
104 + _MessageListConsumerState();
105 +}
106 +
107 +class _MessageListConsumerState extends ConsumerState<_MessageListConsumer> {
108 + final _messageKeys = <String, GlobalKey>{};
109 +
110 + @override
111 + void didUpdateWidget(covariant _MessageListConsumer oldWidget) {
112 + super.didUpdateWidget(oldWidget);
113 + final activeIds = widget.messages.map((m) => m.id).toSet();
114 + _messageKeys.removeWhere((id, _) => !activeIds.contains(id));
115 + }
116 +
117 + void _scrollToMessage(String messageId) {
118 + WidgetsBinding.instance.addPostFrameCallback((_) {
119 + if (!mounted) return;
120 + final key = _messageKeys[messageId];
121 + final targetContext = key?.currentContext;
122 + if (targetContext != null) {
123 + Scrollable.ensureVisible(
124 + targetContext,
125 + duration: const Duration(milliseconds: 300),
126 + curve: Curves.easeInOut,
127 + alignment: 0.3,
128 + );
129 + }
130 + ref.read(scrollToMessageIdProvider.notifier).clear();
131 + });
132 + }
133 +
134 + @override
135 + Widget build(BuildContext context) {
136 + ref.listen<String?>(scrollToMessageIdProvider, (previous, next) {
137 + if (next != null) {
138 + _scrollToMessage(next);
139 + }
140 + });
141 +
142 + final pendingScroll = ref.watch(scrollToMessageIdProvider);
143 + if (pendingScroll != null) {
144 + _scrollToMessage(pendingScroll);
145 + }
146 +
147 final streamingMessage = ref.watch(
148 chatProvider.select((s) => s.streamingMessage),
149 );
150
151 return _MessageList(
88 - scrollController: scrollController,
89 - messages: messages,
152 + scrollController: widget.scrollController,
153 + messages: widget.messages,
154 + allMessages: widget.allMessages,
155 streamingMessage: streamingMessage,
91 - isStreaming: isStreaming,
92 - onRetry: onRetry,
93 - onDelete: onDelete,
94 - onEdit: onEdit,
95 - onEditAssistant: onEditAssistant,
96 - onBranch: onBranch,
97 - onModelPicker: onModelPicker,
98 - hasSmartReplies: hasSmartReplies,
99 - bottomInset: bottomInset,
156 + isStreaming: widget.isStreaming,
157 + messageKeys: _messageKeys,
158 + onRetry: widget.onRetry,
159 + onDelete: widget.onDelete,
160 + onEdit: widget.onEdit,
161 + onEditAssistant: widget.onEditAssistant,
162 + onBranch: widget.onBranch,
163 + onContinue: widget.onContinue,
164 + onCycleVariant: widget.onCycleVariant,
165 + onModelPicker: widget.onModelPicker,
166 + onSave: widget.onSave,
167 + hasSmartReplies: widget.hasSmartReplies,
168 + bottomInset: widget.bottomInset,
169 );
170 }
171 }
@@ -105,34 +174,44 @@ class _MessageList extends StatelessWidget {
174 const _MessageList({
175 required this.scrollController,
176 required this.messages,
177 + required this.allMessages,
178 required this.streamingMessage,
179 required this.isStreaming,
180 + required this.messageKeys,
181 required this.onRetry,
182 required this.onDelete,
183 required this.onEdit,
184 required this.onEditAssistant,
185 required this.onBranch,
186 + required this.onContinue,
187 + required this.onCycleVariant,
188 required this.onModelPicker,
189 + this.onSave,
190 this.hasSmartReplies = false,
191 this.bottomInset = 0,
192 });
193
194 final ScrollController scrollController;
195 final List<Message> messages;
196 + final List<Message> allMessages;
197 final Message? streamingMessage;
198 final bool isStreaming;
199 + final Map<String, GlobalKey> messageKeys;
200 final void Function(String) onRetry;
201 final void Function(String) onDelete;
202 final void Function(String messageId, String currentContent) onEdit;
203 final void Function(String messageId, String currentContent) onEditAssistant;
204 final void Function(String messageId) onBranch;
205 + final void Function(String messageId) onContinue;
206 + final void Function(String messageId, int direction) onCycleVariant;
207 final VoidCallback onModelPicker;
208 + final void Function(Message message)? onSave;
209 final bool hasSmartReplies;
210 final double bottomInset;
211
212 @override
213 Widget build(BuildContext context) {
135 - final allMessages = <Message>[];
214 + final visibleMessages = <Message>[];
215
216 for (final message in messages) {
217 if (streamingMessage != null &&
@@ -140,7 +219,7 @@ class _MessageList extends StatelessWidget {
219 isStreaming) {
220 continue;
221 }
143 - allMessages.add(message);
222 + visibleMessages.add(message);
223 }
224
225 return ListView.builder(
@@ -150,26 +229,30 @@ class _MessageList extends StatelessWidget {
229 bottom: 120 + (hasSmartReplies ? 64 : 0) + bottomInset,
230 ),
231 itemCount:
153 - allMessages.length +
232 + visibleMessages.length +
233 (streamingMessage != null && isStreaming ? 1 : 0),
234 itemBuilder: (context, index) {
235 if (streamingMessage != null &&
236 isStreaming &&
158 - index == allMessages.length) {
237 + index == visibleMessages.length) {
238 return ChatBubble(
239 key: ValueKey(streamingMessage!.id),
240 message: streamingMessage!,
241 + allMessages: this.allMessages,
242 isStreaming: true,
243 onModelTap: onModelPicker,
244 );
245 }
246
167 - final message = allMessages[index];
168 - final isLast = index == allMessages.length - 1;
247 + final message = visibleMessages[index];
248 + final isLast = index == visibleMessages.length - 1;
249 + final itemKey =
250 + messageKeys.putIfAbsent(message.id, GlobalKey.new);
251
252 return ChatBubble(
171 - key: ValueKey(message.id),
253 + key: itemKey,
254 message: message,
255 + allMessages: this.allMessages,
256 isStreaming:
257 isLast && isStreaming && message.id == streamingMessage?.id,
258 onRetry: () => onRetry(message.id),
@@ -180,7 +263,12 @@ class _MessageList extends StatelessWidget {
263 ? () => onEditAssistant(message.id, message.content)
264 : null,
265 onBranch: () => onBranch(message.id),
266 + onContinue: message.role == MessageRole.assistant && isLast
267 + ? () => onContinue(message.id)
268 + : null,
269 + onCycleVariant: (direction) => onCycleVariant(message.id, direction),
270 onModelTap: onModelPicker,
271 + onSave: onSave,
272 );
273 },
274 );
lib/features/chat/views/components/message_variant_navigator.dart new
+60
@@ -0,0 +1,60 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:localmind/core/theme/colors.dart';
3 +import 'package:localmind/features/chat/data/models/message.dart';
4 +import 'package:localmind/features/chat/utils/message_variants.dart';
5 +
6 +class MessageVariantNavigator extends StatelessWidget {
7 + const MessageVariantNavigator({
8 + super.key,
9 + required this.message,
10 + required this.allMessages,
11 + required this.onCycle,
12 + });
13 +
14 + final Message message;
15 + final List<Message> allMessages;
16 + final void Function(int direction) onCycle;
17 +
18 + @override
19 + Widget build(BuildContext context) {
20 + final variants = MessageVariants.variantsForMessage(allMessages, message);
21 + if (variants.length <= 1) return const SizedBox.shrink();
22 +
23 + final currentIndex = MessageVariants.activeVariantIndex(variants);
24 + final isDark = Theme.of(context).brightness == Brightness.dark;
25 + final muted =
26 + isDark ? AppColors.darkMutedText : AppColors.lightMutedText;
27 +
28 + return Padding(
29 + padding: const EdgeInsets.only(top: 6),
30 + child: Row(
31 + mainAxisSize: MainAxisSize.min,
32 + children: [
33 + IconButton(
34 + visualDensity: VisualDensity.compact,
35 + padding: EdgeInsets.zero,
36 + constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
37 + onPressed: currentIndex > 0 ? () => onCycle(-1) : null,
38 + icon: Icon(Icons.chevron_left, size: 18, color: muted),
39 + ),
40 + Text(
41 + '${currentIndex + 1} / ${variants.length}',
42 + style: TextStyle(
43 + fontSize: 11,
44 + color: muted,
45 + fontFeatures: const [FontFeature.tabularFigures()],
46 + ),
47 + ),
48 + IconButton(
49 + visualDensity: VisualDensity.compact,
50 + padding: EdgeInsets.zero,
51 + constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
52 + onPressed:
53 + currentIndex < variants.length - 1 ? () => onCycle(1) : null,
54 + icon: Icon(Icons.chevron_right, size: 18, color: muted),
55 + ),
56 + ],
57 + ),
58 + );
59 + }
60 +}
lib/features/conversations/data/models/conversation.dart
+5
@@ -17,6 +17,7 @@ class Conversation {
17 final bool? mcpEnabled;
18 final List<String>? smartReplies;
19 final String? smartRepliesLastMessageId;
20 + final String? folderId;
21
22 Conversation({
23 required this.id,
@@ -37,6 +38,7 @@ class Conversation {
38 this.mcpEnabled,
39 this.smartReplies,
40 this.smartRepliesLastMessageId,
41 + this.folderId,
42 });
43
44 Conversation copyWith({
@@ -67,6 +69,8 @@ class Conversation {
69 bool clearSmartReplies = false,
70 String? smartRepliesLastMessageId,
71 bool clearSmartRepliesLastMessageId = false,
72 + String? folderId,
73 + bool clearFolderId = false,
74 }) {
75 return Conversation(
76 id: id ?? this.id,
@@ -91,6 +95,7 @@ class Conversation {
95 smartRepliesLastMessageId: clearSmartRepliesLastMessageId
96 ? null
97 : (smartRepliesLastMessageId ?? this.smartRepliesLastMessageId),
98 + folderId: clearFolderId ? null : (folderId ?? this.folderId),
99 );
100 }
101
lib/features/conversations/data/models/conversation_folder.dart new
+27
@@ -0,0 +1,27 @@
1 +class ConversationFolder {
2 + final String id;
3 + final String name;
4 + final int sortOrder;
5 + final DateTime createdAt;
6 +
7 + ConversationFolder({
8 + required this.id,
9 + required this.name,
10 + this.sortOrder = 0,
11 + required this.createdAt,
12 + });
13 +
14 + ConversationFolder copyWith({
15 + String? id,
16 + String? name,
17 + int? sortOrder,
18 + DateTime? createdAt,
19 + }) {
20 + return ConversationFolder(
21 + id: id ?? this.id,
22 + name: name ?? this.name,
23 + sortOrder: sortOrder ?? this.sortOrder,
24 + createdAt: createdAt ?? this.createdAt,
25 + );
26 + }
27 +}
lib/features/conversations/providers/conversation_providers.dart
+257 -2
@@ -1,9 +1,11 @@
1 import 'package:flutter_riverpod/flutter_riverpod.dart';
2
3 +import '../../../core/models/enums.dart';
4 import '../../../core/providers/storage_providers.dart';
5 import '../../../core/storage/entities.dart';
6 import '../../../objectbox.g.dart';
7 import '../data/models/conversation.dart';
8 +import '../data/models/conversation_folder.dart';
9
10 final conversationsProvider =
11 AsyncNotifierProvider<ConversationsNotifier, List<Conversation>>(() {
@@ -125,6 +127,17 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
127 db.conversationBox.put(entity);
128
129 state = AsyncData(await _loadAll());
130 +
131 + final activeId = ref.read(activeConversationProvider)?.id;
132 + if (activeId == id) {
133 + final refreshed = state.value?.firstWhere(
134 + (c) => c.id == id,
135 + orElse: () => updated,
136 + );
137 + ref
138 + .read(activeConversationProvider.notifier)
139 + .setActiveConversation(refreshed);
140 + }
141 }
142
143 Future<void> deleteConversation(String id) async {
@@ -324,6 +337,71 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
337 state = AsyncData(await _loadAll());
338 }
339
340 + Future<Conversation> duplicateConversation(String id) async {
341 + final db = ref.read(databaseProvider);
342 + final conversations = state.value ?? [];
343 + final source = conversations.firstWhere((c) => c.id == id);
344 +
345 + final now = DateTime.now();
346 + final newId = _generateUuid();
347 + final duplicate = source.copyWith(
348 + id: newId,
349 + title: '${source.title} (copy)',
350 + createdAt: now,
351 + updatedAt: now,
352 + isPinned: false,
353 + );
354 +
355 + db.conversationBox.put(ConversationEntity.fromDomain(duplicate));
356 +
357 + final msgQuery = db.messageBox
358 + .query(MessageEntity_.conversationUid.equals(id))
359 + .build();
360 + final sourceMessages = msgQuery.find();
361 + msgQuery.close();
362 +
363 + final convQuery = db.conversationBox
364 + .query(ConversationEntity_.id.equals(newId))
365 + .build();
366 + final convEntity = convQuery.findFirst();
367 + convQuery.close();
368 +
369 + if (convEntity != null) {
370 + for (final entity in sourceMessages) {
371 + final message = entity.toDomain().copyWith(
372 + id: _generateUuid(),
373 + conversationId: newId,
374 + );
375 + final copyEntity = MessageEntity.fromDomain(message)
376 + ..conversation.target = convEntity;
377 + db.messageBox.put(copyEntity);
378 + }
379 + }
380 +
381 + state = AsyncData(await _loadAll());
382 + return duplicate;
383 + }
384 +
385 + Future<void> moveConversationToFolder(String id, String? folderId) async {
386 + final db = ref.read(databaseProvider);
387 + final conversations = state.value ?? [];
388 + final existing = conversations.firstWhere((c) => c.id == id);
389 + final updated = existing.copyWith(
390 + folderId: folderId,
391 + clearFolderId: folderId == null,
392 + updatedAt: DateTime.now(),
393 + );
394 +
395 + final query = db.conversationBox.query(ConversationEntity_.id.equals(id)).build();
396 + final existingEntity = query.findFirst();
397 + query.close();
398 +
399 + final entity = ConversationEntity.fromDomain(updated);
400 + if (existingEntity != null) entity.internalId = existingEntity.internalId;
401 + db.conversationBox.put(entity);
402 + state = AsyncData(await _loadAll());
403 + }
404 +
405 String _generateUuid() {
406 final random = DateTime.now().microsecondsSinceEpoch;
407 return '${random.toRadixString(16)}-${DateTime.now().millisecondsSinceEpoch.toRadixString(16)}';
@@ -380,10 +458,21 @@ final filteredConversationsProvider = Provider<AsyncValue<List<Conversation>>>((
458 ) {
459 final conversationsAsync = ref.watch(conversationsProvider);
460 final query = ref.watch(conversationSearchProvider).toLowerCase();
461 + final folderFilter = ref.watch(historyFolderFilterProvider);
462
463 return conversationsAsync.whenData((conversations) {
385 - if (query.isEmpty) return conversations;
386 - return conversations.where((c) {
464 + var filtered = conversations;
465 + if (folderFilter != null) {
466 + if (folderFilter.isEmpty) {
467 + filtered = filtered
468 + .where((c) => c.folderId == null || c.folderId!.isEmpty)
469 + .toList();
470 + } else {
471 + filtered = filtered.where((c) => c.folderId == folderFilter).toList();
472 + }
473 + }
474 + if (query.isEmpty) return filtered;
475 + return filtered.where((c) {
476 return c.title.toLowerCase().contains(query) ||
477 (c.lastMessagePreview?.toLowerCase().contains(query) ?? false);
478 }).toList();
@@ -437,3 +526,169 @@ final groupedConversationsProvider =
526 return grouped;
527 });
528 });
529 +
530 +final historyFolderFilterProvider =
531 + NotifierProvider<HistoryFolderFilterNotifier, String?>(() {
532 + return HistoryFolderFilterNotifier();
533 + });
534 +
535 +class HistoryFolderFilterNotifier extends Notifier<String?> {
536 + @override
537 + String? build() => null;
538 +
539 + void setFilter(String? folderId) => state = folderId;
540 +}
541 +
542 +final conversationFoldersProvider =
543 + AsyncNotifierProvider<ConversationFoldersNotifier, List<ConversationFolder>>(
544 + () => ConversationFoldersNotifier(),
545 + );
546 +
547 +class ConversationFoldersNotifier extends AsyncNotifier<List<ConversationFolder>> {
548 + @override
549 + Future<List<ConversationFolder>> build() async => _loadAll();
550 +
551 + Future<List<ConversationFolder>> _loadAll() async {
552 + final db = ref.read(databaseProvider);
553 + final entities = db.conversationFolderBox.getAll();
554 + final folders = entities
555 + .map(
556 + (e) => ConversationFolder(
557 + id: e.id,
558 + name: e.name,
559 + sortOrder: e.sortOrder,
560 + createdAt: e.createdAt,
561 + ),
562 + )
563 + .toList();
564 + folders.sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
565 + return folders;
566 + }
567 +
568 + Future<ConversationFolder> createFolder(String name) async {
569 + final db = ref.read(databaseProvider);
570 + final folder = ConversationFolder(
571 + id: DateTime.now().millisecondsSinceEpoch.toString(),
572 + name: name,
573 + sortOrder: (state.value ?? []).length,
574 + createdAt: DateTime.now(),
575 + );
576 + db.conversationFolderBox.put(
577 + ConversationFolderEntity(
578 + id: folder.id,
579 + name: folder.name,
580 + sortOrder: folder.sortOrder,
581 + createdAt: folder.createdAt,
582 + ),
583 + );
584 + state = AsyncData(await _loadAll());
585 + return folder;
586 + }
587 +
588 + Future<void> deleteFolder(String id) async {
589 + final db = ref.read(databaseProvider);
590 + final query = db.conversationFolderBox
591 + .query(ConversationFolderEntity_.id.equals(id))
592 + .build();
593 + db.conversationFolderBox.removeMany(query.findIds());
594 + query.close();
595 +
596 + final convQuery = db.conversationBox
597 + .query(ConversationEntity_.folderId.equals(id))
598 + .build();
599 + for (final entity in convQuery.find()) {
600 + entity.folderId = null;
601 + db.conversationBox.put(entity);
602 + }
603 + convQuery.close();
604 +
605 + ref.invalidate(conversationsProvider);
606 + state = AsyncData(await _loadAll());
607 + }
608 +}
609 +
610 +class MessageSearchHit {
611 + const MessageSearchHit({
612 + required this.messageId,
613 + required this.conversationId,
614 + required this.conversationTitle,
615 + required this.snippet,
616 + required this.role,
617 + });
618 +
619 + final String messageId;
620 + final String conversationId;
621 + final String conversationTitle;
622 + final String snippet;
623 + final MessageRole role;
624 +}
625 +
626 +final searchMessageContentsProvider =
627 + NotifierProvider<SearchMessageContentsNotifier, bool>(() {
628 + return SearchMessageContentsNotifier();
629 + });
630 +
631 +class SearchMessageContentsNotifier extends Notifier<bool> {
632 + @override
633 + bool build() => false;
634 +
635 + void toggle() => state = !state;
636 +
637 + void setEnabled(bool enabled) => state = enabled;
638 +}
639 +
640 +final scrollToMessageIdProvider =
641 + NotifierProvider<ScrollToMessageNotifier, String?>(() {
642 + return ScrollToMessageNotifier();
643 + });
644 +
645 +class ScrollToMessageNotifier extends Notifier<String?> {
646 + @override
647 + String? build() => null;
648 +
649 + void scrollTo(String messageId) => state = messageId;
650 +
651 + void clear() => state = null;
652 +}
653 +
654 +final messageSearchResultsProvider = Provider<List<MessageSearchHit>>((ref) {
655 + final query = ref.watch(conversationSearchProvider).trim().toLowerCase();
656 + if (query.isEmpty || !ref.watch(searchMessageContentsProvider)) {
657 + return const [];
658 + }
659 +
660 + final db = ref.read(databaseProvider);
661 + final conversations = db.conversationBox.getAll();
662 + final titleById = {
663 + for (final conv in conversations) conv.id: conv.title,
664 + };
665 +
666 + final hits = <MessageSearchHit>[];
667 + final messageQuery = db.messageBox.query().build();
668 + final messages = messageQuery.find();
669 + messageQuery.close();
670 +
671 + for (final entity in messages) {
672 + if (!entity.content.toLowerCase().contains(query)) continue;
673 + final title = titleById[entity.conversationUid] ?? 'Chat';
674 + final content = entity.content;
675 + final index = content.toLowerCase().indexOf(query);
676 + final start = index > 40 ? index - 40 : 0;
677 + final end = (index + query.length + 40).clamp(0, content.length);
678 + var snippet = content.substring(start, end);
679 + if (start > 0) snippet = '…$snippet';
680 + if (end < content.length) snippet = '$snippet…';
681 +
682 + hits.add(
683 + MessageSearchHit(
684 + messageId: entity.id,
685 + conversationId: entity.conversationUid,
686 + conversationTitle: title,
687 + snippet: snippet,
688 + role: MessageRole.values[entity.roleIndex],
689 + ),
690 + );
691 + if (hits.length >= 50) break;
692 + }
693 + return hits;
694 +});
lib/features/conversations/views/chat_history_screen.dart
+14
@@ -2,9 +2,11 @@ import 'package:flutter/material.dart';
2 import 'package:flutter_riverpod/flutter_riverpod.dart';
3 import 'package:localmind/l10n/app_localizations.dart';
4 import '../providers/conversation_providers.dart';
5 +import 'components/conversation_folder_bar.dart';
6 import 'components/conversation_empty_state.dart';
7 import 'components/conversation_list.dart';
8 import 'components/conversation_search_bar.dart';
9 +import 'components/message_search_results_list.dart';
10
11 class ChatHistoryScreen extends ConsumerWidget {
12 const ChatHistoryScreen({super.key});
@@ -18,6 +20,7 @@ class ChatHistoryScreen extends ConsumerWidget {
20 final groupedConversations = ref.watch(groupedConversationsProvider);
21 final activeConversation = ref.watch(activeConversationProvider);
22 final searchQuery = ref.watch(conversationSearchProvider);
23 + final messageSearchHits = ref.watch(messageSearchResultsProvider);
24 final topPadding = MediaQuery.of(context).padding.top;
25
26 return Column(
@@ -62,6 +65,17 @@ class ChatHistoryScreen extends ConsumerWidget {
65 ),
66
67 const ConversationSearchBar(),
68 + const ConversationFolderBar(),
69 + if (messageSearchHits.isNotEmpty)
70 + ConstrainedBox(
71 + constraints: BoxConstraints(
72 + maxHeight: (MediaQuery.sizeOf(context).height * 0.35)
73 + .clamp(120.0, 280.0),
74 + ),
75 + child: const SingleChildScrollView(
76 + child: MessageSearchResultsList(),
77 + ),
78 + ),
79
80 Expanded(
81 child: groupedConversations.when(
lib/features/conversations/views/components/conversation_folder_bar.dart new
+170
@@ -0,0 +1,170 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter_riverpod/flutter_riverpod.dart';
3 +import 'package:localmind/l10n/app_localizations.dart';
4 +import '../../providers/conversation_providers.dart';
5 +
6 +class ConversationFolderBar extends ConsumerWidget {
7 + const ConversationFolderBar({super.key});
8 +
9 + Future<void> _createFolder(BuildContext context, WidgetRef ref) async {
10 + final l10n = AppLocalizations.of(context)!;
11 + final controller = TextEditingController();
12 + final name = await showDialog<String>(
13 + context: context,
14 + builder: (ctx) => AlertDialog(
15 + title: Text(l10n.create_folder),
16 + content: TextField(
17 + controller: controller,
18 + autofocus: true,
19 + decoration: InputDecoration(hintText: l10n.folder_name_hint),
20 + ),
21 + actions: [
22 + TextButton(
23 + onPressed: () => Navigator.pop(ctx),
24 + child: Text(l10n.cancel),
25 + ),
26 + TextButton(
27 + onPressed: () => Navigator.pop(ctx, controller.text.trim()),
28 + child: Text(l10n.create),
29 + ),
30 + ],
31 + ),
32 + );
33 + controller.dispose();
34 + if (name == null || name.isEmpty) return;
35 + await ref.read(conversationFoldersProvider.notifier).createFolder(name);
36 + }
37 +
38 + @override
39 + Widget build(BuildContext context, WidgetRef ref) {
40 + final l10n = AppLocalizations.of(context)!;
41 + final foldersAsync = ref.watch(conversationFoldersProvider);
42 + final selected = ref.watch(historyFolderFilterProvider);
43 + final theme = Theme.of(context);
44 + final isDark = theme.brightness == Brightness.dark;
45 +
46 + return foldersAsync.when(
47 + data: (folders) {
48 + return SingleChildScrollView(
49 + scrollDirection: Axis.horizontal,
50 + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
51 + child: Row(
52 + children: [
53 + _SystemFilterChip(
54 + label: l10n.all_chats,
55 + icon: Icons.all_inbox_outlined,
56 + selected: selected == null,
57 + isDark: isDark,
58 + onSelected: (_) => ref
59 + .read(historyFolderFilterProvider.notifier)
60 + .setFilter(null),
61 + ),
62 + const SizedBox(width: 6),
63 + _SystemFilterChip(
64 + label: l10n.unfiled_chats,
65 + icon: Icons.inbox_outlined,
66 + selected: selected != null && selected.isEmpty,
67 + isDark: isDark,
68 + outlined: true,
69 + onSelected: (_) => ref
70 + .read(historyFolderFilterProvider.notifier)
71 + .setFilter(''),
72 + ),
73 + ...folders.map(
74 + (folder) => Padding(
75 + padding: const EdgeInsets.only(left: 6),
76 + child: FilterChip(
77 + avatar: Icon(
78 + Icons.folder_outlined,
79 + size: 16,
80 + color: selected == folder.id
81 + ? theme.colorScheme.onSecondaryContainer
82 + : theme.colorScheme.primary,
83 + ),
84 + label: Text(folder.name),
85 + selected: selected == folder.id,
86 + showCheckmark: false,
87 + onSelected: (_) => ref
88 + .read(historyFolderFilterProvider.notifier)
89 + .setFilter(folder.id),
90 + ),
91 + ),
92 + ),
93 + const SizedBox(width: 6),
94 + ActionChip(
95 + avatar: Icon(
96 + Icons.create_new_folder_outlined,
97 + size: 16,
98 + color: theme.colorScheme.primary,
99 + ),
100 + label: Text(l10n.new_folder),
101 + side: BorderSide(
102 + color: theme.colorScheme.primary.withValues(alpha: 0.5),
103 + style: BorderStyle.solid,
104 + ),
105 + onPressed: () => _createFolder(context, ref),
106 + ),
107 + ],
108 + ),
109 + );
110 + },
111 + loading: () => const SizedBox(height: 44),
112 + error: (_, __) => const SizedBox.shrink(),
113 + );
114 + }
115 +}
116 +
117 +class _SystemFilterChip extends StatelessWidget {
118 + const _SystemFilterChip({
119 + required this.label,
120 + required this.icon,
121 + required this.selected,
122 + required this.isDark,
123 + required this.onSelected,
124 + this.outlined = false,
125 + });
126 +
127 + final String label;
128 + final IconData icon;
129 + final bool selected;
130 + final bool isDark;
131 + final bool outlined;
132 + final ValueChanged<bool> onSelected;
133 +
134 + @override
135 + Widget build(BuildContext context) {
136 + final theme = Theme.of(context);
137 + final bgColor = selected
138 + ? (outlined
139 + ? theme.colorScheme.surfaceContainerHighest
140 + : theme.colorScheme.secondaryContainer)
141 + : Colors.transparent;
142 + final borderColor = outlined
143 + ? (isDark ? const Color(0xFF444444) : const Color(0xFFCCCCCC))
144 + : (selected
145 + ? theme.colorScheme.secondaryContainer
146 + : (isDark ? const Color(0xFF333333) : const Color(0xFFE0E0E0)));
147 +
148 + return FilterChip(
149 + avatar: Icon(
150 + icon,
151 + size: 16,
152 + color: selected
153 + ? theme.colorScheme.onSecondaryContainer
154 + : theme.colorScheme.onSurfaceVariant,
155 + ),
156 + label: Text(
157 + label,
158 + style: TextStyle(
159 + fontWeight: outlined ? FontWeight.normal : FontWeight.w600,
160 + fontStyle: outlined ? FontStyle.italic : FontStyle.normal,
161 + ),
162 + ),
163 + selected: selected,
164 + showCheckmark: false,
165 + backgroundColor: bgColor,
166 + side: BorderSide(color: borderColor),
167 + onSelected: onSelected,
168 + );
169 + }
170 +}
lib/features/conversations/views/components/conversation_list.dart
+55
@@ -76,6 +76,17 @@ class ConversationList extends ConsumerWidget {
76 onDelete: () {
77 _showDeleteConfirmation(context, ref, l10n, conversation);
78 },
79 + onDuplicate: () {
80 + ref
81 + .read(conversationsProvider.notifier)
82 + .duplicateConversation(conversation.id);
83 + ScaffoldMessenger.of(context).showSnackBar(
84 + SnackBar(content: Text(l10n.duplicate_chat_success)),
85 + );
86 + },
87 + onMoveToFolder: () {
88 + _showMoveToFolderSheet(context, ref, l10n, conversation);
89 + },
90 );
91 }),
92 ],
@@ -160,4 +171,48 @@ class ConversationList extends ConsumerWidget {
171 },
172 );
173 }
174 +
175 + void _showMoveToFolderSheet(
176 + BuildContext context,
177 + WidgetRef ref,
178 + AppLocalizations l10n,
179 + Conversation conversation,
180 + ) {
181 + final folders = ref.read(conversationFoldersProvider).value ?? [];
182 +
183 + showModalBottomSheet<void>(
184 + context: context,
185 + builder: (ctx) {
186 + return SafeArea(
187 + child: Column(
188 + mainAxisSize: MainAxisSize.min,
189 + children: [
190 + ListTile(
191 + leading: const Icon(Icons.folder_off_outlined),
192 + title: Text(l10n.remove_from_folder),
193 + onTap: () async {
194 + Navigator.pop(ctx);
195 + await ref
196 + .read(conversationsProvider.notifier)
197 + .moveConversationToFolder(conversation.id, null);
198 + },
199 + ),
200 + ...folders.map(
201 + (folder) => ListTile(
202 + leading: const Icon(Icons.folder_outlined),
203 + title: Text(folder.name),
204 + onTap: () async {
205 + Navigator.pop(ctx);
206 + await ref
207 + .read(conversationsProvider.notifier)
208 + .moveConversationToFolder(conversation.id, folder.id);
209 + },
210 + ),
211 + ),
212 + ],
213 + ),
214 + );
215 + },
216 + );
217 + }
218 }
lib/features/conversations/views/components/conversation_search_bar.dart
+28 -4
@@ -25,6 +25,7 @@ class _ConversationSearchBarState extends ConsumerState<ConversationSearchBar> {
25 final l10n = AppLocalizations.of(context)!;
26 final theme = Theme.of(context);
27 final isDark = theme.brightness == Brightness.dark;
28 + final searchContents = ref.watch(searchMessageContentsProvider);
29
30 return Container(
31 margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -39,6 +40,7 @@ class _ConversationSearchBarState extends ConsumerState<ConversationSearchBar> {
40 controller: _controller,
41 onChanged: (value) {
42 ref.read(conversationSearchProvider.notifier).setSearchQuery(value);
43 + setState(() {});
44 },
45 style: TextStyle(
46 fontSize: 14,
@@ -55,8 +57,28 @@ class _ConversationSearchBarState extends ConsumerState<ConversationSearchBar> {
57 size: 20,
58 color: isDark ? const Color(0xFF666666) : const Color(0xFF999999),
59 ),
58 - suffixIcon: _controller.text.isNotEmpty
59 - ? IconButton(
60 + suffixIcon: Row(
61 + mainAxisSize: MainAxisSize.min,
62 + children: [
63 + IconButton(
64 + icon: Icon(
65 + searchContents
66 + ? Icons.article
67 + : Icons.article_outlined,
68 + size: 20,
69 + color: searchContents
70 + ? theme.colorScheme.primary
71 + : (isDark
72 + ? const Color(0xFF666666)
73 + : const Color(0xFF999999)),
74 + ),
75 + tooltip: l10n.search_message_contents,
76 + onPressed: () => ref
77 + .read(searchMessageContentsProvider.notifier)
78 + .toggle(),
79 + ),
80 + if (_controller.text.isNotEmpty)
81 + IconButton(
82 icon: Icon(
83 Icons.clear,
84 size: 18,
@@ -67,9 +89,11 @@ class _ConversationSearchBarState extends ConsumerState<ConversationSearchBar> {
89 onPressed: () {
90 _controller.clear();
91 ref.read(conversationSearchProvider.notifier).clearSearch();
92 + setState(() {});
93 },
71 - )
72 - : null,
94 + ),
95 + ],
96 + ),
97 border: InputBorder.none,
98 contentPadding: const EdgeInsets.symmetric(
99 horizontal: 16,
lib/features/conversations/views/components/conversation_tile.dart
+20
@@ -13,6 +13,8 @@ class ConversationTile extends StatelessWidget {
13 required this.onRename,
14 required this.onTogglePin,
15 required this.onDelete,
16 + required this.onDuplicate,
17 + required this.onMoveToFolder,
18 });
19
20 final Conversation conversation;
@@ -21,6 +23,8 @@ class ConversationTile extends StatelessWidget {
23 final VoidCallback onRename;
24 final VoidCallback onTogglePin;
25 final VoidCallback onDelete;
26 + final VoidCallback onDuplicate;
27 + final VoidCallback onMoveToFolder;
28
29 String _formatTimestamp(AppLocalizations l10n, DateTime dateTime) {
30 final now = DateTime.now();
@@ -181,6 +185,22 @@ class ConversationTile extends StatelessWidget {
185 onRename();
186 },
187 ),
188 + ListTile(
189 + leading: const Icon(Icons.copy_outlined),
190 + title: Text(l10n.duplicate_chat),
191 + onTap: () {
192 + Navigator.pop(ctx);
193 + onDuplicate();
194 + },
195 + ),
196 + ListTile(
197 + leading: const Icon(Icons.folder_outlined),
198 + title: Text(l10n.move_to_folder),
199 + onTap: () {
200 + Navigator.pop(ctx);
201 + onMoveToFolder();
202 + },
203 + ),
204 ListTile(
205 leading: const Icon(Icons.delete_outline, color: Colors.red),
206 title: Text(
lib/features/conversations/views/components/message_search_results_list.dart new
+87
@@ -0,0 +1,87 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter_riverpod/flutter_riverpod.dart';
3 +import 'package:go_router/go_router.dart';
4 +import 'package:localmind/core/models/enums.dart';
5 +import 'package:localmind/core/routes/app_routes.dart';
6 +import 'package:localmind/l10n/app_localizations.dart';
7 +import '../../../chat/providers/chat_providers.dart';
8 +import '../../data/models/conversation.dart';
9 +import '../../providers/conversation_providers.dart';
10 +
11 +class MessageSearchResultsList extends ConsumerWidget {
12 + const MessageSearchResultsList({super.key});
13 +
14 + @override
15 + Widget build(BuildContext context, WidgetRef ref) {
16 + final hits = ref.watch(messageSearchResultsProvider);
17 + if (hits.isEmpty) return const SizedBox.shrink();
18 +
19 + final theme = Theme.of(context);
20 + final l10n = AppLocalizations.of(context)!;
21 +
22 + return Column(
23 + crossAxisAlignment: CrossAxisAlignment.start,
24 + children: [
25 + Padding(
26 + padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
27 + child: Text(
28 + l10n.message_search_results,
29 + style: theme.textTheme.titleSmall,
30 + ),
31 + ),
32 + ListView.separated(
33 + shrinkWrap: true,
34 + physics: const NeverScrollableScrollPhysics(),
35 + itemCount: hits.length,
36 + separatorBuilder: (_, __) => const Divider(height: 1, indent: 16),
37 + itemBuilder: (context, index) {
38 + final hit = hits[index];
39 + return ListTile(
40 + leading: Icon(
41 + hit.role == MessageRole.user
42 + ? Icons.person_outline
43 + : Icons.smart_toy_outlined,
44 + ),
45 + title: Text(
46 + hit.conversationTitle,
47 + maxLines: 1,
48 + overflow: TextOverflow.ellipsis,
49 + ),
50 + subtitle: Text(
51 + hit.snippet,
52 + maxLines: 2,
53 + overflow: TextOverflow.ellipsis,
54 + ),
55 + onTap: () async {
56 + final conversations =
57 + ref.read(conversationsProvider).value ?? [];
58 + Conversation? conversation;
59 + for (final conv in conversations) {
60 + if (conv.id == hit.conversationId) {
61 + conversation = conv;
62 + break;
63 + }
64 + }
65 + if (conversation == null) return;
66 +
67 + ref
68 + .read(scrollToMessageIdProvider.notifier)
69 + .scrollTo(hit.messageId);
70 + await ref
71 + .read(chatProvider.notifier)
72 + .loadConversation(conversation);
73 + if (context.mounted) {
74 + if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
75 + Navigator.pop(context);
76 + }
77 + context.go(AppRoutes.home);
78 + }
79 + },
80 + );
81 + },
82 + ),
83 + const Divider(height: 16),
84 + ],
85 + );
86 + }
87 +}
lib/features/on_device/data/on_device_chat_service.dart
+1
@@ -33,6 +33,7 @@ class OnDeviceChatService implements ChatService {
33 List<McpIntegration>? integrations,
34 List<ToolDefinition>? tools,
35 String? previousResponseId,
36 + bool continueGeneration = false,
37 }) {
38 cancelStream();
39 _isCancelled = false;
lib/features/on_device/data/on_device_llama_service.dart
+1
@@ -240,6 +240,7 @@ class OnDeviceLlamaChatService implements ChatService {
240 List<McpIntegration>? integrations,
241 List<ToolDefinition>? tools,
242 String? previousResponseId,
243 + bool continueGeneration = false,
244 }) async* {
245 _isCancelled = false;
246 await for (final response in _llamaService.sendMessage(
lib/features/saved_messages/data/models/saved_message.dart new
+37
@@ -0,0 +1,37 @@
1 +class SavedMessageFolder {
2 + const SavedMessageFolder({
3 + required this.id,
4 + required this.name,
5 + required this.sortOrder,
6 + required this.createdAt,
7 + });
8 +
9 + final String id;
10 + final String name;
11 + final int sortOrder;
12 + final DateTime createdAt;
13 +}
14 +
15 +class SavedMessage {
16 + const SavedMessage({
17 + required this.id,
18 + required this.sourceMessageId,
19 + required this.conversationId,
20 + required this.conversationTitle,
21 + required this.roleIndex,
22 + required this.content,
23 + this.modelId,
24 + this.folderId,
25 + required this.savedAt,
26 + });
27 +
28 + final String id;
29 + final String sourceMessageId;
30 + final String conversationId;
31 + final String conversationTitle;
32 + final int roleIndex;
33 + final String content;
34 + final String? modelId;
35 + final String? folderId;
36 + final DateTime savedAt;
37 +}
lib/features/saved_messages/providers/saved_message_providers.dart new
+213
@@ -0,0 +1,213 @@
1 +import 'package:flutter_riverpod/flutter_riverpod.dart';
2 +
3 +import '../../../core/providers/storage_providers.dart';
4 +import '../../../core/storage/entities.dart';
5 +import '../../../objectbox.g.dart';
6 +import '../../chat/data/models/message.dart';
7 +import '../../conversations/providers/conversation_providers.dart';
8 +import '../data/models/saved_message.dart';
9 +
10 +final savedMessageFolderFilterProvider =
11 + NotifierProvider<SavedMessageFolderFilterNotifier, String?>(() {
12 + return SavedMessageFolderFilterNotifier();
13 + });
14 +
15 +class SavedMessageFolderFilterNotifier extends Notifier<String?> {
16 + @override
17 + String? build() => null;
18 +
19 + void setFilter(String? folderId) => state = folderId;
20 +}
21 +
22 +final savedMessageFoldersProvider =
23 + AsyncNotifierProvider<SavedMessageFoldersNotifier, List<SavedMessageFolder>>(
24 + () => SavedMessageFoldersNotifier(),
25 + );
26 +
27 +class SavedMessageFoldersNotifier
28 + extends AsyncNotifier<List<SavedMessageFolder>> {
29 + @override
30 + Future<List<SavedMessageFolder>> build() async => _loadAll();
31 +
32 + Future<List<SavedMessageFolder>> _loadAll() async {
33 + final db = ref.read(databaseProvider);
34 + final entities = db.savedMessageFolderBox.getAll();
35 + final folders = entities
36 + .map(
37 + (e) => SavedMessageFolder(
38 + id: e.id,
39 + name: e.name,
40 + sortOrder: e.sortOrder,
41 + createdAt: e.createdAt,
42 + ),
43 + )
44 + .toList();
45 + folders.sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
46 + return folders;
47 + }
48 +
49 + Future<SavedMessageFolder> createFolder(String name) async {
50 + final db = ref.read(databaseProvider);
51 + final folder = SavedMessageFolder(
52 + id: DateTime.now().millisecondsSinceEpoch.toString(),
53 + name: name,
54 + sortOrder: (state.value ?? []).length,
55 + createdAt: DateTime.now(),
56 + );
57 + db.savedMessageFolderBox.put(
58 + SavedMessageFolderEntity(
59 + id: folder.id,
60 + name: folder.name,
61 + sortOrder: folder.sortOrder,
62 + createdAt: folder.createdAt,
63 + ),
64 + );
65 + state = AsyncData(await _loadAll());
66 + return folder;
67 + }
68 +
69 + Future<void> deleteFolder(String id) async {
70 + final db = ref.read(databaseProvider);
71 + final folderQuery = db.savedMessageFolderBox
72 + .query(SavedMessageFolderEntity_.id.equals(id))
73 + .build();
74 + db.savedMessageFolderBox.removeMany(folderQuery.findIds());
75 + folderQuery.close();
76 +
77 + final msgQuery = db.savedMessageBox
78 + .query(SavedMessageEntity_.folderId.equals(id))
79 + .build();
80 + for (final entity in msgQuery.find()) {
81 + entity.folderId = null;
82 + db.savedMessageBox.put(entity);
83 + }
84 + msgQuery.close();
85 +
86 + ref.invalidate(savedMessagesProvider);
87 + state = AsyncData(await _loadAll());
88 + }
89 +}
90 +
91 +final savedMessagesProvider =
92 + AsyncNotifierProvider<SavedMessagesNotifier, List<SavedMessage>>(() {
93 + return SavedMessagesNotifier();
94 + });
95 +
96 +class SavedMessagesNotifier extends AsyncNotifier<List<SavedMessage>> {
97 + @override
98 + Future<List<SavedMessage>> build() async => _loadAll();
99 +
100 + Future<List<SavedMessage>> _loadAll() async {
101 + final db = ref.read(databaseProvider);
102 + final entities = db.savedMessageBox.getAll();
103 + final messages = entities
104 + .map(
105 + (e) => SavedMessage(
106 + id: e.id,
107 + sourceMessageId: e.sourceMessageId,
108 + conversationId: e.conversationId,
109 + conversationTitle: e.conversationTitle,
110 + roleIndex: e.roleIndex,
111 + content: e.content,
112 + modelId: e.modelId,
113 + folderId: e.folderId,
114 + savedAt: e.savedAt,
115 + ),
116 + )
117 + .toList();
118 + messages.sort((a, b) => b.savedAt.compareTo(a.savedAt));
119 + return messages;
120 + }
121 +
122 + Future<bool> isMessageSaved(String sourceMessageId) async {
123 + final db = ref.read(databaseProvider);
124 + final query = db.savedMessageBox
125 + .query(SavedMessageEntity_.sourceMessageId.equals(sourceMessageId))
126 + .build();
127 + final exists = query.findFirst() != null;
128 + query.close();
129 + return exists;
130 + }
131 +
132 + Future<void> saveMessage(Message message) async {
133 + final db = ref.read(databaseProvider);
134 + final existingQuery = db.savedMessageBox
135 + .query(SavedMessageEntity_.sourceMessageId.equals(message.id))
136 + .build();
137 + if (existingQuery.findFirst() != null) {
138 + existingQuery.close();
139 + return;
140 + }
141 + existingQuery.close();
142 +
143 + final conversation = ref.read(activeConversationProvider);
144 + final entity = SavedMessageEntity(
145 + id: DateTime.now().millisecondsSinceEpoch.toString(),
146 + sourceMessageId: message.id,
147 + conversationId: message.conversationId,
148 + conversationTitle: conversation?.title ?? 'Chat',
149 + roleIndex: message.role.index,
150 + content: message.content,
151 + modelId: message.modelId,
152 + savedAt: DateTime.now(),
153 + );
154 + db.savedMessageBox.put(entity);
155 + state = AsyncData(await _loadAll());
156 + }
157 +
158 + Future<void> removeBySourceMessageId(String sourceMessageId) async {
159 + final db = ref.read(databaseProvider);
160 + final query = db.savedMessageBox
161 + .query(SavedMessageEntity_.sourceMessageId.equals(sourceMessageId))
162 + .build();
163 + db.savedMessageBox.removeMany(query.findIds());
164 + query.close();
165 + state = AsyncData(await _loadAll());
166 + }
167 +
168 + Future<void> deleteSavedMessage(String id) async {
169 + final db = ref.read(databaseProvider);
170 + final query = db.savedMessageBox
171 + .query(SavedMessageEntity_.id.equals(id))
172 + .build();
173 + db.savedMessageBox.removeMany(query.findIds());
174 + query.close();
175 + state = AsyncData(await _loadAll());
176 + }
177 +
178 + Future<void> moveToFolder(String savedMessageId, String? folderId) async {
179 + final db = ref.read(databaseProvider);
180 + final query = db.savedMessageBox
181 + .query(SavedMessageEntity_.id.equals(savedMessageId))
182 + .build();
183 + final entity = query.findFirst();
184 + query.close();
185 + if (entity == null) return;
186 + entity.folderId = folderId;
187 + db.savedMessageBox.put(entity);
188 + state = AsyncData(await _loadAll());
189 + }
190 +}
191 +
192 +final filteredSavedMessagesProvider =
193 + Provider<AsyncValue<List<SavedMessage>>>((ref) {
194 + final messagesAsync = ref.watch(savedMessagesProvider);
195 + final folderFilter = ref.watch(savedMessageFolderFilterProvider);
196 +
197 + return messagesAsync.whenData((messages) {
198 + if (folderFilter == null) return messages;
199 + if (folderFilter.isEmpty) {
200 + return messages
201 + .where((m) => m.folderId == null || m.folderId!.isEmpty)
202 + .toList();
203 + }
204 + return messages.where((m) => m.folderId == folderFilter).toList();
205 + });
206 + });
207 +
208 +final isMessageSavedProvider = FutureProvider.family<bool, String>((
209 + ref,
210 + messageId,
211 +) async {
212 + return ref.read(savedMessagesProvider.notifier).isMessageSaved(messageId);
213 +});
lib/features/saved_messages/views/components/saved_message_folder_bar.dart new
+110
@@ -0,0 +1,110 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter_riverpod/flutter_riverpod.dart';
3 +import 'package:localmind/l10n/app_localizations.dart';
4 +import '../../providers/saved_message_providers.dart';
5 +
6 +class SavedMessageFolderBar extends ConsumerWidget {
7 + const SavedMessageFolderBar({super.key});
8 +
9 + Future<void> _createFolder(BuildContext context, WidgetRef ref) async {
10 + final l10n = AppLocalizations.of(context)!;
11 + final controller = TextEditingController();
12 + final name = await showDialog<String>(
13 + context: context,
14 + builder: (ctx) => AlertDialog(
15 + title: Text(l10n.create_folder),
16 + content: TextField(
17 + controller: controller,
18 + autofocus: true,
19 + decoration: InputDecoration(hintText: l10n.folder_name_hint),
20 + ),
21 + actions: [
22 + TextButton(
23 + onPressed: () => Navigator.pop(ctx),
24 + child: Text(l10n.cancel),
25 + ),
26 + TextButton(
27 + onPressed: () => Navigator.pop(ctx, controller.text.trim()),
28 + child: Text(l10n.create),
29 + ),
30 + ],
31 + ),
32 + );
33 + controller.dispose();
34 + if (name == null || name.isEmpty) return;
35 + await ref.read(savedMessageFoldersProvider.notifier).createFolder(name);
36 + }
37 +
38 + @override
39 + Widget build(BuildContext context, WidgetRef ref) {
40 + final l10n = AppLocalizations.of(context)!;
41 + final foldersAsync = ref.watch(savedMessageFoldersProvider);
42 + final selected = ref.watch(savedMessageFolderFilterProvider);
43 + final theme = Theme.of(context);
44 +
45 + return foldersAsync.when(
46 + data: (folders) {
47 + return SingleChildScrollView(
48 + scrollDirection: Axis.horizontal,
49 + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
50 + child: Row(
51 + children: [
52 + FilterChip(
53 + avatar: const Icon(Icons.all_inbox_outlined, size: 16),
54 + label: Text(l10n.all_chats),
55 + selected: selected == null,
56 + onSelected: (_) => ref
57 + .read(savedMessageFolderFilterProvider.notifier)
58 + .setFilter(null),
59 + ),
60 + const SizedBox(width: 6),
61 + FilterChip(
62 + avatar: const Icon(Icons.inbox_outlined, size: 16),
63 + label: Text(l10n.unfiled_chats),
64 + selected: selected != null && selected.isEmpty,
65 + onSelected: (_) => ref
66 + .read(savedMessageFolderFilterProvider.notifier)
67 + .setFilter(''),
68 + ),
69 + ...folders.map(
70 + (folder) => Padding(
71 + padding: const EdgeInsets.only(left: 6),
72 + child: FilterChip(
73 + avatar: Icon(
74 + Icons.folder_outlined,
75 + size: 16,
76 + color: selected == folder.id
77 + ? theme.colorScheme.onSecondaryContainer
78 + : theme.colorScheme.primary,
79 + ),
80 + label: Text(folder.name),
81 + selected: selected == folder.id,
82 + showCheckmark: false,
83 + onSelected: (_) => ref
84 + .read(savedMessageFolderFilterProvider.notifier)
85 + .setFilter(folder.id),
86 + ),
87 + ),
88 + ),
89 + const SizedBox(width: 6),
90 + ActionChip(
91 + avatar: Icon(
92 + Icons.create_new_folder_outlined,
93 + size: 16,
94 + color: theme.colorScheme.primary,
95 + ),
96 + label: Text(l10n.new_folder),
97 + side: BorderSide(
98 + color: theme.colorScheme.primary.withValues(alpha: 0.5),
99 + ),
100 + onPressed: () => _createFolder(context, ref),
101 + ),
102 + ],
103 + ),
104 + );
105 + },
106 + loading: () => const SizedBox(height: 44),
107 + error: (_, __) => const SizedBox.shrink(),
108 + );
109 + }
110 +}
lib/features/saved_messages/views/saved_messages_screen.dart new
+147
@@ -0,0 +1,147 @@
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/l10n/app_localizations.dart';
5 +import '../providers/saved_message_providers.dart';
6 +import 'components/saved_message_folder_bar.dart';
7 +
8 +class SavedMessagesScreen extends ConsumerWidget {
9 + const SavedMessagesScreen({super.key});
10 +
11 + @override
12 + Widget build(BuildContext context, WidgetRef ref) {
13 + final l10n = AppLocalizations.of(context)!;
14 + final theme = Theme.of(context);
15 + final isDark = theme.brightness == Brightness.dark;
16 + final messagesAsync = ref.watch(filteredSavedMessagesProvider);
17 + final topPadding = MediaQuery.of(context).padding.top;
18 +
19 + return Column(
20 + children: [
21 + Container(
22 + padding: EdgeInsets.only(
23 + left: 16,
24 + right: 16,
25 + top: topPadding + 8,
26 + bottom: 16,
27 + ),
28 + decoration: BoxDecoration(
29 + color: isDark ? const Color(0xFF0A0A0A) : const Color(0xFFFAFAFA),
30 + border: Border(
31 + bottom: BorderSide(
32 + color: isDark
33 + ? const Color(0xFF2A2A2A)
34 + : const Color(0xFFE5E5E5),
35 + ),
36 + ),
37 + ),
38 + child: Row(
39 + children: [
40 + Builder(
41 + builder: (context) => IconButton(
42 + icon: const Icon(Icons.menu),
43 + onPressed: () => Scaffold.of(context).openDrawer(),
44 + ),
45 + ),
46 + const SizedBox(width: 8),
47 + Text(
48 + l10n.saved_messages_title,
49 + style: TextStyle(
50 + fontSize: 20,
51 + fontWeight: FontWeight.bold,
52 + color: isDark ? Colors.white : Colors.black,
53 + ),
54 + ),
55 + ],
56 + ),
57 + ),
58 + const SavedMessageFolderBar(),
59 + Expanded(
60 + child: messagesAsync.when(
61 + data: (messages) {
62 + if (messages.isEmpty) {
63 + return Center(
64 + child: Text(
65 + l10n.saved_messages_empty,
66 + style: theme.textTheme.bodyMedium?.copyWith(
67 + color: theme.colorScheme.onSurfaceVariant,
68 + ),
69 + ),
70 + );
71 + }
72 + return ListView.separated(
73 + padding: const EdgeInsets.all(16),
74 + itemCount: messages.length,
75 + separatorBuilder: (_, __) => const SizedBox(height: 8),
76 + itemBuilder: (context, index) {
77 + final saved = messages[index];
78 + final role = MessageRole.values[saved.roleIndex];
79 + return Card(
80 + child: ListTile(
81 + leading: Icon(
82 + role == MessageRole.user
83 + ? Icons.person_outline
84 + : Icons.smart_toy_outlined,
85 + ),
86 + title: Text(
87 + saved.conversationTitle,
88 + maxLines: 1,
89 + overflow: TextOverflow.ellipsis,
90 + ),
91 + subtitle: Text(
92 + saved.content,
93 + maxLines: 3,
94 + overflow: TextOverflow.ellipsis,
95 + ),
96 + isThreeLine: true,
97 + trailing: PopupMenuButton<String>(
98 + onSelected: (value) async {
99 + if (value == 'delete') {
100 + await ref
101 + .read(savedMessagesProvider.notifier)
102 + .deleteSavedMessage(saved.id);
103 + } else if (value.startsWith('folder:')) {
104 + final folderId = value.substring(7);
105 + await ref
106 + .read(savedMessagesProvider.notifier)
107 + .moveToFolder(
108 + saved.id,
109 + folderId.isEmpty ? null : folderId,
110 + );
111 + }
112 + },
113 + itemBuilder: (context) {
114 + final folders =
115 + ref.read(savedMessageFoldersProvider).value ??
116 + [];
117 + return [
118 + ...folders.map(
119 + (folder) => PopupMenuItem(
120 + value: 'folder:${folder.id}',
121 + child: Text(folder.name),
122 + ),
123 + ),
124 + PopupMenuItem(
125 + value: 'folder:',
126 + child: Text(l10n.unfiled_chats),
127 + ),
128 + PopupMenuItem(
129 + value: 'delete',
130 + child: Text(l10n.delete),
131 + ),
132 + ];
133 + },
134 + ),
135 + ),
136 + );
137 + },
138 + );
139 + },
140 + loading: () => const Center(child: CircularProgressIndicator()),
141 + error: (err, _) => Center(child: Text(err.toString())),
142 + ),
143 + ),
144 + ],
145 + );
146 + }
147 +}
lib/features/servers/data/models/server.dart
+30 -5
@@ -99,6 +99,17 @@ Map<String, String> buildServerAuthHeaders(Server server) {
99 return const {};
100 }
101
102 +String normalizeServerPathPrefix(String? raw) {
103 + if (raw == null) return '';
104 + var value = raw.trim();
105 + if (value.isEmpty) return '';
106 + if (!value.startsWith('/')) value = '/$value';
107 + while (value.endsWith('/') && value.length > 1) {
108 + value = value.substring(0, value.length - 1);
109 + }
110 + return value;
111 +}
112 +
113 class Server {
114 final String id;
115 final String name;
@@ -111,6 +122,7 @@ class Server {
122 final DateTime lastConnectedAt;
123 final ConnectionStatus status;
124 final String? iconName;
125 + final String? pathPrefix;
126
127 Server({
128 required this.id,
@@ -124,8 +136,18 @@ class Server {
136 required this.lastConnectedAt,
137 this.status = ConnectionStatus.disconnected,
138 this.iconName,
139 + this.pathPrefix,
140 });
141
142 + String get apiPathPrefix => normalizeServerPathPrefix(pathPrefix);
143 +
144 + String _apiPath(String suffix) {
145 + if (type != ServerType.ollama || apiPathPrefix.isEmpty) {
146 + return suffix;
147 + }
148 + return '$apiPathPrefix$suffix';
149 + }
150 +
151 String get baseUrl {
152 return buildServerBaseUrl(host, port, type);
153 }
@@ -139,7 +161,7 @@ class Server {
161 case ServerType.openAICompatible:
162 return '$baseUrl/v1/chat/completions';
163 case ServerType.ollama:
142 - return '$baseUrl/api/chat';
164 + return '$baseUrl${_apiPath('/api/chat')}';
165 case ServerType.openRouter:
166 return '$baseUrl/chat/completions';
167 case ServerType.onDevice:
@@ -154,7 +176,7 @@ class Server {
176 case ServerType.openAICompatible:
177 return '$baseUrl/v1/models';
178 case ServerType.ollama:
157 - return '$baseUrl/api/tags';
179 + return '$baseUrl${_apiPath('/api/tags')}';
180 case ServerType.openRouter:
181 return '$baseUrl/models';
182 case ServerType.onDevice:
@@ -169,7 +191,7 @@ class Server {
191 case ServerType.openAICompatible:
192 return '$baseUrl/v1/models';
193 case ServerType.ollama:
172 - return '$baseUrl/api/ps';
194 + return '$baseUrl${_apiPath('/api/ps')}';
195 case ServerType.openRouter:
196 return '';
197 case ServerType.onDevice:
@@ -184,7 +206,7 @@ class Server {
206 case ServerType.openAICompatible:
207 return '$baseUrl/v1/models/load';
208 case ServerType.ollama:
187 - return '$baseUrl/api/generate';
209 + return '$baseUrl${_apiPath('/api/generate')}';
210 case ServerType.openRouter:
211 return '';
212 case ServerType.onDevice:
@@ -199,7 +221,7 @@ class Server {
221 case ServerType.openAICompatible:
222 return '$baseUrl/v1/models/unload';
223 case ServerType.ollama:
202 - return '$baseUrl/api/generate';
224 + return '$baseUrl${_apiPath('/api/generate')}';
225 case ServerType.openRouter:
226 return '';
227 case ServerType.onDevice:
@@ -223,6 +245,8 @@ class Server {
245 DateTime? lastConnectedAt,
246 ConnectionStatus? status,
247 String? iconName,
248 + String? pathPrefix,
249 + bool clearPathPrefix = false,
250 }) {
251 return Server(
252 id: id ?? this.id,
@@ -236,6 +260,7 @@ class Server {
260 lastConnectedAt: lastConnectedAt ?? this.lastConnectedAt,
261 status: status ?? this.status,
262 iconName: iconName ?? this.iconName,
263 + pathPrefix: clearPathPrefix ? null : (pathPrefix ?? this.pathPrefix),
264 );
265 }
266 }
lib/features/servers/views/add_server_screen.dart
+18
@@ -29,6 +29,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
29 late TextEditingController _hostController;
30 late TextEditingController _portController;
31 late TextEditingController _apiKeyController;
32 + late TextEditingController _pathPrefixController;
33
34 late ServerType _selectedType;
35 String? _selectedIconName;
@@ -54,6 +55,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
55 AppConstants.lmStudioDefaultPort.toString(),
56 );
57 _apiKeyController = TextEditingController(text: server?.apiKey ?? '');
58 + _pathPrefixController = TextEditingController(text: server?.pathPrefix ?? '');
59 }
60
61 @override
@@ -62,6 +64,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
64 _hostController.dispose();
65 _portController.dispose();
66 _apiKeyController.dispose();
67 + _pathPrefixController.dispose();
68 super.dispose();
69 }
70
@@ -136,6 +139,10 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
139 lastConnectedAt: widget.editServer?.lastConnectedAt ?? DateTime.now(),
140 status: ConnectionStatus.disconnected,
141 iconName: _selectedIconName,
142 + pathPrefix: _selectedType == ServerType.ollama &&
143 + _pathPrefixController.text.trim().isNotEmpty
144 + ? normalizeServerPathPrefix(_pathPrefixController.text.trim())
145 + : null,
146 );
147 }
148
@@ -411,6 +418,17 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
418 keyboardType: TextInputType.number,
419 textInputAction: TextInputAction.next,
420 ),
421 + if (_selectedType == ServerType.ollama) ...[
422 + const SizedBox(height: 12),
423 + TextFormField(
424 + controller: _pathPrefixController,
425 + decoration: InputDecoration(
426 + labelText: l10n.server_path_prefix_label,
427 + hintText: l10n.server_path_prefix_hint,
428 + ),
429 + textInputAction: TextInputAction.next,
430 + ),
431 + ],
432 ],
433 ),
434 ),
lib/features/settings/views/data_backup_actions.dart new
+292
@@ -0,0 +1,292 @@
1 +import 'dart:convert';
2 +import 'dart:io';
3 +import 'dart:typed_data';
4 +
5 +import 'package:archive/archive.dart';
6 +import 'package:file_picker/file_picker.dart';
7 +import 'package:flutter/material.dart';
8 +import 'package:flutter_riverpod/flutter_riverpod.dart';
9 +import 'package:shadcn_ui/shadcn_ui.dart';
10 +
11 +import 'package:localmind/l10n/app_localizations.dart';
12 +
13 +import '../../../core/providers/app_providers.dart';
14 +import '../../../core/providers/storage_providers.dart';
15 +import '../../../core/services/data_backup_service.dart';
16 +import '../../conversations/providers/conversation_providers.dart';
17 +import '../../personas/providers/personas_providers.dart';
18 +
19 +class DataBackupActions extends ConsumerWidget {
20 + const DataBackupActions({super.key});
21 +
22 + Future<void> _saveJsonExport(
23 + BuildContext context,
24 + String dialogTitle,
25 + String fileName,
26 + String json,
27 + ) async {
28 + final saved = await FilePicker.saveFile(
29 + dialogTitle: dialogTitle,
30 + fileName: fileName,
31 + type: FileType.custom,
32 + allowedExtensions: const ['json'],
33 + bytes: Uint8List.fromList(utf8.encode(json)),
34 + );
35 + if (saved == null) return;
36 + if (context.mounted) {
37 + ScaffoldMessenger.of(context).showSnackBar(
38 + SnackBar(content: Text(AppLocalizations.of(context)!.export_data_success)),
39 + );
40 + }
41 + }
42 +
43 + Future<void> _saveZipExport(
44 + BuildContext context,
45 + WidgetRef ref,
46 + String dialogTitle,
47 + String fileName,
48 + ) async {
49 + final l10n = AppLocalizations.of(context)!;
50 + try {
51 + final db = ref.read(databaseProvider);
52 + final settings = ref.read(settingsProvider);
53 + final service = DataBackupService();
54 + final bytes = service.exportAllZip(db.store, settings.toJson());
55 + final saved = await FilePicker.saveFile(
56 + dialogTitle: dialogTitle,
57 + fileName: fileName,
58 + type: FileType.custom,
59 + allowedExtensions: const ['zip'],
60 + bytes: Uint8List.fromList(bytes),
61 + );
62 + if (saved == null) return;
63 + if (context.mounted) {
64 + ScaffoldMessenger.of(context).showSnackBar(
65 + SnackBar(content: Text(l10n.export_data_success)),
66 + );
67 + }
68 + } catch (e) {
69 + if (context.mounted) {
70 + ScaffoldMessenger.of(context).showSnackBar(
71 + SnackBar(content: Text(l10n.import_data_failed(e.toString()))),
72 + );
73 + }
74 + }
75 + }
76 +
77 + Future<bool> _confirmImport(BuildContext context, String message) async {
78 + final confirmed = await showDialog<bool>(
79 + context: context,
80 + builder: (dialogContext) {
81 + final dialogL10n = AppLocalizations.of(dialogContext)!;
82 + return AlertDialog(
83 + title: Text(AppLocalizations.of(context)!.import_all_data),
84 + content: Text(message),
85 + actions: [
86 + TextButton(
87 + onPressed: () => Navigator.pop(dialogContext, false),
88 + child: Text(dialogL10n.cancel),
89 + ),
90 + TextButton(
91 + onPressed: () => Navigator.pop(dialogContext, true),
92 + child: Text(dialogL10n.confirm),
93 + ),
94 + ],
95 + );
96 + },
97 + );
98 + return confirmed == true;
99 + }
100 +
101 + Future<void> _importJsonFile(
102 + BuildContext context,
103 + WidgetRef ref,
104 + Future<void> Function(String json) importer,
105 + String confirmMessage,
106 + ) async {
107 + final l10n = AppLocalizations.of(context)!;
108 + if (!await _confirmImport(context, confirmMessage)) return;
109 +
110 + try {
111 + final result = await FilePicker.pickFiles(
112 + type: FileType.custom,
113 + allowedExtensions: const ['json'],
114 + );
115 + final path = result?.files.single.path;
116 + if (path == null) return;
117 +
118 + final json = await File(path).readAsString();
119 + await importer(json);
120 +
121 + if (context.mounted) {
122 + ScaffoldMessenger.of(context).showSnackBar(
123 + SnackBar(content: Text(l10n.import_data_success)),
124 + );
125 + }
126 + } catch (e) {
127 + if (context.mounted) {
128 + ScaffoldMessenger.of(context).showSnackBar(
129 + SnackBar(content: Text(l10n.import_data_failed(e.toString()))),
130 + );
131 + }
132 + }
133 + }
134 +
135 + Future<void> _importZipFile(BuildContext context, WidgetRef ref) async {
136 + final l10n = AppLocalizations.of(context)!;
137 + if (!await _confirmImport(context, l10n.import_data_confirm)) return;
138 +
139 + try {
140 + final result = await FilePicker.pickFiles(
141 + type: FileType.custom,
142 + allowedExtensions: const ['zip'],
143 + );
144 + final path = result?.files.single.path;
145 + if (path == null) return;
146 +
147 + final bytes = await File(path).readAsBytes();
148 + final db = ref.read(databaseProvider);
149 + await DataBackupService().importZip(db.store, bytes);
150 +
151 + final archive = ZipDecoder().decodeBytes(bytes);
152 + for (final file in archive.files) {
153 + if (!file.isFile || file.name != 'settings.json') continue;
154 + final settingsJson = utf8.decode(file.content as List<int>);
155 + final decoded = jsonDecode(settingsJson) as Map<String, dynamic>;
156 + final settingsMap = decoded['settings'];
157 + if (settingsMap is Map) {
158 + final prefs = ref.read(sharedPreferencesProvider);
159 + await prefs.setString('appSettings', jsonEncode(settingsMap));
160 + }
161 + }
162 +
163 + ref.invalidate(conversationsProvider);
164 + ref.invalidate(personasNotifierProvider);
165 + ref.invalidate(settingsProvider);
166 +
167 + if (context.mounted) {
168 + ScaffoldMessenger.of(context).showSnackBar(
169 + SnackBar(content: Text(l10n.import_data_success)),
170 + );
171 + }
172 + } catch (e) {
173 + if (context.mounted) {
174 + ScaffoldMessenger.of(context).showSnackBar(
175 + SnackBar(content: Text(l10n.import_data_failed(e.toString()))),
176 + );
177 + }
178 + }
179 + }
180 +
181 + Widget _actionButton({
182 + required IconData icon,
183 + required String label,
184 + required VoidCallback onPressed,
185 + }) {
186 + return Padding(
187 + padding: const EdgeInsets.only(bottom: 8),
188 + child: ShadButton.outline(
189 + onPressed: onPressed,
190 + width: double.infinity,
191 + leading: Icon(icon, size: 16),
192 + child: Align(alignment: Alignment.centerLeft, child: Text(label)),
193 + ),
194 + );
195 + }
196 +
197 + @override
198 + Widget build(BuildContext context, WidgetRef ref) {
199 + final l10n = AppLocalizations.of(context)!;
200 + final service = DataBackupService();
201 + final db = ref.read(databaseProvider);
202 +
203 + return Column(
204 + children: [
205 + _actionButton(
206 + icon: Icons.forum_outlined,
207 + label: l10n.export_conversations,
208 + onPressed: () => _saveJsonExport(
209 + context,
210 + l10n.export_conversations,
211 + 'localmind_conversations_${DateTime.now().millisecondsSinceEpoch}.json',
212 + service.exportConversationsAsJson(db.store),
213 + ),
214 + ),
215 + _actionButton(
216 + icon: Icons.download_outlined,
217 + label: l10n.import_conversations,
218 + onPressed: () => _importJsonFile(
219 + context,
220 + ref,
221 + (json) => service.importFromJson(db.store, json),
222 + l10n.import_data_confirm,
223 + ),
224 + ),
225 + _actionButton(
226 + icon: Icons.smart_toy_outlined,
227 + label: l10n.export_personas,
228 + onPressed: () => _saveJsonExport(
229 + context,
230 + l10n.export_personas,
231 + 'localmind_personas_${DateTime.now().millisecondsSinceEpoch}.json',
232 + service.exportPersonasAsJson(db.store),
233 + ),
234 + ),
235 + _actionButton(
236 + icon: Icons.download_outlined,
237 + label: l10n.import_personas,
238 + onPressed: () => _importJsonFile(
239 + context,
240 + ref,
241 + (json) async {
242 + await service.importFromJson(db.store, json);
243 + ref.invalidate(personasNotifierProvider);
244 + },
245 + l10n.import_data_confirm,
246 + ),
247 + ),
248 + _actionButton(
249 + icon: Icons.settings_outlined,
250 + label: l10n.export_settings,
251 + onPressed: () => _saveJsonExport(
252 + context,
253 + l10n.export_settings,
254 + 'localmind_settings_${DateTime.now().millisecondsSinceEpoch}.json',
255 + service.exportSettingsAsJson(ref.read(settingsProvider).toJson()),
256 + ),
257 + ),
258 + _actionButton(
259 + icon: Icons.download_outlined,
260 + label: l10n.import_settings,
261 + onPressed: () => _importJsonFile(
262 + context,
263 + ref,
264 + (json) async {
265 + final decoded = jsonDecode(json) as Map<String, dynamic>;
266 + final settingsMap = decoded['settings'] ?? decoded;
267 + final prefs = ref.read(sharedPreferencesProvider);
268 + await prefs.setString('appSettings', jsonEncode(settingsMap));
269 + ref.invalidate(settingsProvider);
270 + },
271 + l10n.import_settings_confirm,
272 + ),
273 + ),
274 + _actionButton(
275 + icon: Icons.folder_zip_outlined,
276 + label: l10n.export_all_zip,
277 + onPressed: () => _saveZipExport(
278 + context,
279 + ref,
280 + l10n.export_all_zip,
281 + 'localmind_backup_${DateTime.now().millisecondsSinceEpoch}.zip',
282 + ),
283 + ),
284 + _actionButton(
285 + icon: Icons.unarchive_outlined,
286 + label: l10n.import_all_zip,
287 + onPressed: () => _importZipFile(context, ref),
288 + ),
289 + ],
290 + );
291 + }
292 +}
lib/features/settings/views/settings_screen.dart
+2 -114
@@ -1,10 +1,5 @@
1 -import 'dart:convert';
2 -import 'dart:io';
3 -import 'dart:typed_data';
4 -
1 import 'package:flutter/material.dart';
2 import 'package:flutter_riverpod/flutter_riverpod.dart';
7 -import 'package:file_picker/file_picker.dart';
3 import 'package:go_router/go_router.dart';
4 import 'package:shadcn_ui/shadcn_ui.dart';
5
@@ -12,9 +7,7 @@ import 'package:localmind/l10n/app_localizations.dart';
7
8 import '../../../core/models/enums.dart';
9 import '../../../core/providers/app_providers.dart';
15 -import '../../../core/providers/storage_providers.dart';
10 import '../../../core/routes/app_routes.dart';
17 -import '../../../core/services/data_backup_service.dart';
11 import '../../../core/theme/app_theme.dart';
12 import '../../../core/utils/system_insets.dart';
13 import '../../conversations/providers/conversation_providers.dart';
@@ -22,6 +15,7 @@ import '../../on_device/providers/on_device_providers.dart';
15 import '../../personas/providers/personas_providers.dart';
16 import '../../servers/providers/server_providers.dart';
17 import '../data/models/app_settings.dart';
18 +import 'data_backup_actions.dart';
19
20 class SettingsViews extends ConsumerWidget {
21 const SettingsViews({super.key});
@@ -322,7 +316,7 @@ class SettingsViews extends ConsumerWidget {
316 icon: Icons.restore_page_outlined,
317 accent: const Color(0xFFEF4444),
318 children: [
325 - _DataBackupActions(),
319 + const DataBackupActions(),
320 _DangerousAction(
321 label: l10n.delete_all_conversations,
322 icon: Icons.delete_outline_rounded,
@@ -1451,112 +1445,6 @@ class _CodeThemeDropdown extends StatelessWidget {
1445 }
1446 }
1447
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 -
1448 class _DangerousAction extends StatelessWidget {
1449 const _DangerousAction({
1450 required this.label,
lib/features/sidebar/sidebar_widget.dart
+12
@@ -25,6 +25,7 @@ class SidebarWidget extends ConsumerWidget {
25 final location = GoRouterState.of(context).uri.toString();
26
27 final isHistory = location.startsWith(AppRoutes.chatHistory);
28 + final isSavedMessages = location.startsWith(AppRoutes.savedMessages);
29 final isServers = location.startsWith(AppRoutes.servers);
30 final isMcpTools = location.startsWith(AppRoutes.mcpTools);
31 final isPersonas = location.startsWith(AppRoutes.personas);
@@ -83,6 +84,17 @@ class SidebarWidget extends ConsumerWidget {
84 context.go(AppRoutes.chatHistory);
85 },
86 ),
87 + DrawerNavItem(
88 + iconData: HugeIcons.strokeRoundedBookmark01,
89 + label: l10n.nav_saved_messages,
90 + isSelected: isSavedMessages,
91 + onTap: () {
92 + if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
93 + Navigator.pop(context);
94 + }
95 + context.go(AppRoutes.savedMessages);
96 + },
97 + ),
98 DrawerNavItem(
99 iconData: HugeIcons.strokeRoundedServerStack01,
100 label: l10n.nav_servers,
lib/features/tts/views/tts_model_manager_screen.dart
+68
@@ -74,6 +74,8 @@ class TtsModelManagerScreen extends ConsumerWidget {
74 const SizedBox(height: 16),
75 _PiperEngineCard(isSelected: settings.ttsEngine == EngineId.piper),
76 const SizedBox(height: 24),
77 + const _TtsTestSection(),
78 + const SizedBox(height: 24),
79 ],
80 ),
81 ),
@@ -1051,3 +1053,69 @@ class _VoiceChipsState extends ConsumerState<_VoiceChips> {
1053 }
1054 }
1055 }
1056 +
1057 +class _TtsTestSection extends ConsumerStatefulWidget {
1058 + const _TtsTestSection();
1059 +
1060 + @override
1061 + ConsumerState<_TtsTestSection> createState() => _TtsTestSectionState();
1062 +}
1063 +
1064 +class _TtsTestSectionState extends ConsumerState<_TtsTestSection> {
1065 + final _controller = TextEditingController(
1066 + text:
1067 + 'The quick brown fox jumps over the lazy dog. '
1068 + 'This is a longer sample to test how the selected text-to-speech engine handles extended passages.',
1069 + );
1070 +
1071 + @override
1072 + void dispose() {
1073 + _controller.dispose();
1074 + super.dispose();
1075 + }
1076 +
1077 + Future<void> _speak() async {
1078 + final text = _controller.text.trim();
1079 + if (text.isEmpty) return;
1080 + try {
1081 + await ref.read(tts.ttsProvider.notifier).speak(text);
1082 + } catch (_) {}
1083 + }
1084 +
1085 + @override
1086 + Widget build(BuildContext context) {
1087 + final l10n = AppLocalizations.of(context)!;
1088 + final theme = Theme.of(context);
1089 + final isDark = theme.brightness == Brightness.dark;
1090 +
1091 + return Column(
1092 + crossAxisAlignment: CrossAxisAlignment.start,
1093 + children: [
1094 + Text(
1095 + l10n.test_tts_section_title,
1096 + style: theme.textTheme.titleMedium?.copyWith(
1097 + fontWeight: FontWeight.bold,
1098 + ),
1099 + ),
1100 + const SizedBox(height: 12),
1101 + TextField(
1102 + controller: _controller,
1103 + maxLines: 6,
1104 + minLines: 4,
1105 + decoration: InputDecoration(
1106 + hintText: l10n.test_tts_hint,
1107 + filled: true,
1108 + fillColor: isDark ? const Color(0xFF1A1A1A) : const Color(0xFFF5F5F5),
1109 + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
1110 + ),
1111 + ),
1112 + const SizedBox(height: 12),
1113 + ShadButton(
1114 + onPressed: _speak,
1115 + leading: const Icon(Icons.volume_up),
1116 + child: Text(l10n.test_speak_button),
1117 + ),
1118 + ],
1119 + );
1120 + }
1121 +}
lib/l10n/app_en.arb
+43 -3
@@ -2539,7 +2539,47 @@
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 - }
2542 + "import_settings_confirm": "Replace current settings with the imported backup?",
2543 + "export_conversations": "Export conversations",
2544 + "import_conversations": "Import conversations",
2545 + "export_personas": "Export personas",
2546 + "import_personas": "Import personas",
2547 + "export_settings": "Export settings",
2548 + "import_settings": "Import settings",
2549 + "export_all_zip": "Export all (ZIP)",
2550 + "import_all_zip": "Import all (ZIP)",
2551 + "duplicate_chat": "Duplicate chat",
2552 + "duplicate_chat_success": "Chat duplicated",
2553 + "move_to_folder": "Move to folder",
2554 + "remove_from_folder": "Remove from folder",
2555 + "create_folder": "Create folder",
2556 + "new_folder": "New folder",
2557 + "folder_name_hint": "Folder name",
2558 + "all_chats": "All",
2559 + "unfiled_chats": "Unfiled",
2560 + "create": "Create",
2561 + "server_path_prefix_label": "API path prefix",
2562 + "server_path_prefix_hint": "/your-secret-token",
2563 + "search_message_contents": "Search message contents",
2564 + "message_search_results": "Message matches",
2565 + "saved_messages_title": "Saved Messages",
2566 + "nav_saved_messages": "Saved Messages",
2567 + "saved_messages_empty": "No saved messages yet. Bookmark a message from its options menu.",
2568 + "save_message": "Save message",
2569 + "message_saved": "Message saved",
2570 + "token_count": "{count} tokens",
2571 + "@token_count": {
2572 + "placeholders": {
2573 + "count": { "type": "int" }
2574 + }
2575 + },
2576 + "estimated_token_count": "~{count} tokens (estimated)",
2577 + "@estimated_token_count": {
2578 + "placeholders": {
2579 + "count": { "type": "int" }
2580 + }
2581 + },
2582 + "test_tts_section_title": "Test voice",
2583 + "test_tts_hint": "Enter text to hear the current TTS engine…",
2584 + "test_speak_button": "Speak"
2585 }
lib/l10n/app_localizations.dart
+199 -1
@@ -3807,11 +3807,209 @@ abstract class AppLocalizations {
3807 /// **'Failed to import backup: {error}'**
3808 String import_data_failed(String error);
3809
3810 - /// Confirmation message before importing backup
3810 + /// No description provided for @import_data_confirm.
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 + /// No description provided for @import_settings_confirm.
3817 + ///
3818 + /// In en, this message translates to:
3819 + /// **'Replace current settings with the imported backup?'**
3820 + String get import_settings_confirm;
3821 +
3822 + /// No description provided for @export_conversations.
3823 + ///
3824 + /// In en, this message translates to:
3825 + /// **'Export conversations'**
3826 + String get export_conversations;
3827 +
3828 + /// No description provided for @import_conversations.
3829 + ///
3830 + /// In en, this message translates to:
3831 + /// **'Import conversations'**
3832 + String get import_conversations;
3833 +
3834 + /// No description provided for @export_personas.
3835 + ///
3836 + /// In en, this message translates to:
3837 + /// **'Export personas'**
3838 + String get export_personas;
3839 +
3840 + /// No description provided for @import_personas.
3841 + ///
3842 + /// In en, this message translates to:
3843 + /// **'Import personas'**
3844 + String get import_personas;
3845 +
3846 + /// No description provided for @export_settings.
3847 + ///
3848 + /// In en, this message translates to:
3849 + /// **'Export settings'**
3850 + String get export_settings;
3851 +
3852 + /// No description provided for @import_settings.
3853 + ///
3854 + /// In en, this message translates to:
3855 + /// **'Import settings'**
3856 + String get import_settings;
3857 +
3858 + /// No description provided for @export_all_zip.
3859 + ///
3860 + /// In en, this message translates to:
3861 + /// **'Export all (ZIP)'**
3862 + String get export_all_zip;
3863 +
3864 + /// No description provided for @import_all_zip.
3865 + ///
3866 + /// In en, this message translates to:
3867 + /// **'Import all (ZIP)'**
3868 + String get import_all_zip;
3869 +
3870 + /// No description provided for @duplicate_chat.
3871 + ///
3872 + /// In en, this message translates to:
3873 + /// **'Duplicate chat'**
3874 + String get duplicate_chat;
3875 +
3876 + /// No description provided for @duplicate_chat_success.
3877 + ///
3878 + /// In en, this message translates to:
3879 + /// **'Chat duplicated'**
3880 + String get duplicate_chat_success;
3881 +
3882 + /// No description provided for @move_to_folder.
3883 + ///
3884 + /// In en, this message translates to:
3885 + /// **'Move to folder'**
3886 + String get move_to_folder;
3887 +
3888 + /// No description provided for @remove_from_folder.
3889 + ///
3890 + /// In en, this message translates to:
3891 + /// **'Remove from folder'**
3892 + String get remove_from_folder;
3893 +
3894 + /// No description provided for @create_folder.
3895 + ///
3896 + /// In en, this message translates to:
3897 + /// **'Create folder'**
3898 + String get create_folder;
3899 +
3900 + /// No description provided for @new_folder.
3901 + ///
3902 + /// In en, this message translates to:
3903 + /// **'New folder'**
3904 + String get new_folder;
3905 +
3906 + /// No description provided for @folder_name_hint.
3907 + ///
3908 + /// In en, this message translates to:
3909 + /// **'Folder name'**
3910 + String get folder_name_hint;
3911 +
3912 + /// No description provided for @all_chats.
3913 + ///
3914 + /// In en, this message translates to:
3915 + /// **'All'**
3916 + String get all_chats;
3917 +
3918 + /// No description provided for @unfiled_chats.
3919 + ///
3920 + /// In en, this message translates to:
3921 + /// **'Unfiled'**
3922 + String get unfiled_chats;
3923 +
3924 + /// No description provided for @create.
3925 + ///
3926 + /// In en, this message translates to:
3927 + /// **'Create'**
3928 + String get create;
3929 +
3930 + /// No description provided for @server_path_prefix_label.
3931 + ///
3932 + /// In en, this message translates to:
3933 + /// **'API path prefix'**
3934 + String get server_path_prefix_label;
3935 +
3936 + /// No description provided for @server_path_prefix_hint.
3937 + ///
3938 + /// In en, this message translates to:
3939 + /// **'/your-secret-token'**
3940 + String get server_path_prefix_hint;
3941 +
3942 + /// No description provided for @search_message_contents.
3943 + ///
3944 + /// In en, this message translates to:
3945 + /// **'Search message contents'**
3946 + String get search_message_contents;
3947 +
3948 + /// No description provided for @message_search_results.
3949 + ///
3950 + /// In en, this message translates to:
3951 + /// **'Message matches'**
3952 + String get message_search_results;
3953 +
3954 + /// No description provided for @saved_messages_title.
3955 + ///
3956 + /// In en, this message translates to:
3957 + /// **'Saved Messages'**
3958 + String get saved_messages_title;
3959 +
3960 + /// No description provided for @nav_saved_messages.
3961 + ///
3962 + /// In en, this message translates to:
3963 + /// **'Saved Messages'**
3964 + String get nav_saved_messages;
3965 +
3966 + /// No description provided for @saved_messages_empty.
3967 + ///
3968 + /// In en, this message translates to:
3969 + /// **'No saved messages yet. Bookmark a message from its options menu.'**
3970 + String get saved_messages_empty;
3971 +
3972 + /// No description provided for @save_message.
3973 + ///
3974 + /// In en, this message translates to:
3975 + /// **'Save message'**
3976 + String get save_message;
3977 +
3978 + /// No description provided for @message_saved.
3979 + ///
3980 + /// In en, this message translates to:
3981 + /// **'Message saved'**
3982 + String get message_saved;
3983 +
3984 + /// No description provided for @token_count.
3985 + ///
3986 + /// In en, this message translates to:
3987 + /// **'{count} tokens'**
3988 + String token_count(int count);
3989 +
3990 + /// No description provided for @estimated_token_count.
3991 + ///
3992 + /// In en, this message translates to:
3993 + /// **'~{count} tokens (estimated)'**
3994 + String estimated_token_count(int count);
3995 +
3996 + /// No description provided for @test_tts_section_title.
3997 + ///
3998 + /// In en, this message translates to:
3999 + /// **'Test voice'**
4000 + String get test_tts_section_title;
4001 +
4002 + /// No description provided for @test_tts_hint.
4003 + ///
4004 + /// In en, this message translates to:
4005 + /// **'Enter text to hear the current TTS engine…'**
4006 + String get test_tts_hint;
4007 +
4008 + /// No description provided for @test_speak_button.
4009 + ///
4010 + /// In en, this message translates to:
4011 + /// **'Speak'**
4012 + String get test_speak_button;
4013 }
4014
4015 class _AppLocalizationsDelegate
lib/l10n/app_localizations_ar.dart
+105
@@ -2007,4 +2007,109 @@ class AppLocalizationsAr extends AppLocalizations {
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 +
2011 + @override
2012 + String get import_settings_confirm =>
2013 + 'Replace current settings with the imported backup?';
2014 +
2015 + @override
2016 + String get export_conversations => 'Export conversations';
2017 +
2018 + @override
2019 + String get import_conversations => 'Import conversations';
2020 +
2021 + @override
2022 + String get export_personas => 'Export personas';
2023 +
2024 + @override
2025 + String get import_personas => 'Import personas';
2026 +
2027 + @override
2028 + String get export_settings => 'Export settings';
2029 +
2030 + @override
2031 + String get import_settings => 'Import settings';
2032 +
2033 + @override
2034 + String get export_all_zip => 'Export all (ZIP)';
2035 +
2036 + @override
2037 + String get import_all_zip => 'Import all (ZIP)';
2038 +
2039 + @override
2040 + String get duplicate_chat => 'Duplicate chat';
2041 +
2042 + @override
2043 + String get duplicate_chat_success => 'Chat duplicated';
2044 +
2045 + @override
2046 + String get move_to_folder => 'Move to folder';
2047 +
2048 + @override
2049 + String get remove_from_folder => 'Remove from folder';
2050 +
2051 + @override
2052 + String get create_folder => 'Create folder';
2053 +
2054 + @override
2055 + String get new_folder => 'New folder';
2056 +
2057 + @override
2058 + String get folder_name_hint => 'Folder name';
2059 +
2060 + @override
2061 + String get all_chats => 'All';
2062 +
2063 + @override
2064 + String get unfiled_chats => 'Unfiled';
2065 +
2066 + @override
2067 + String get create => 'Create';
2068 +
2069 + @override
2070 + String get server_path_prefix_label => 'API path prefix';
2071 +
2072 + @override
2073 + String get server_path_prefix_hint => '/your-secret-token';
2074 +
2075 + @override
2076 + String get search_message_contents => 'Search message contents';
2077 +
2078 + @override
2079 + String get message_search_results => 'Message matches';
2080 +
2081 + @override
2082 + String get saved_messages_title => 'Saved Messages';
2083 +
2084 + @override
2085 + String get nav_saved_messages => 'Saved Messages';
2086 +
2087 + @override
2088 + String get saved_messages_empty =>
2089 + 'No saved messages yet. Bookmark a message from its options menu.';
2090 +
2091 + @override
2092 + String get save_message => 'Save message';
2093 +
2094 + @override
2095 + String get message_saved => 'Message saved';
2096 +
2097 + @override
2098 + String token_count(int count) {
2099 + return '$count tokens';
2100 + }
2101 +
2102 + @override
2103 + String estimated_token_count(int count) {
2104 + return '~$count tokens (estimated)';
2105 + }
2106 +
2107 + @override
2108 + String get test_tts_section_title => 'Test voice';
2109 +
2110 + @override
2111 + String get test_tts_hint => 'Enter text to hear the current TTS engine…';
2112 +
2113 + @override
2114 + String get test_speak_button => 'Speak';
2115 }
lib/l10n/app_localizations_bn.dart
+105
@@ -2020,4 +2020,109 @@ class AppLocalizationsBn extends AppLocalizations {
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 +
2024 + @override
2025 + String get import_settings_confirm =>
2026 + 'Replace current settings with the imported backup?';
2027 +
2028 + @override
2029 + String get export_conversations => 'Export conversations';
2030 +
2031 + @override
2032 + String get import_conversations => 'Import conversations';
2033 +
2034 + @override
2035 + String get export_personas => 'Export personas';
2036 +
2037 + @override
2038 + String get import_personas => 'Import personas';
2039 +
2040 + @override
2041 + String get export_settings => 'Export settings';
2042 +
2043 + @override
2044 + String get import_settings => 'Import settings';
2045 +
2046 + @override
2047 + String get export_all_zip => 'Export all (ZIP)';
2048 +
2049 + @override
2050 + String get import_all_zip => 'Import all (ZIP)';
2051 +
2052 + @override
2053 + String get duplicate_chat => 'Duplicate chat';
2054 +
2055 + @override
2056 + String get duplicate_chat_success => 'Chat duplicated';
2057 +
2058 + @override
2059 + String get move_to_folder => 'Move to folder';
2060 +
2061 + @override
2062 + String get remove_from_folder => 'Remove from folder';
2063 +
2064 + @override
2065 + String get create_folder => 'Create folder';
2066 +
2067 + @override
2068 + String get new_folder => 'New folder';
2069 +
2070 + @override
2071 + String get folder_name_hint => 'Folder name';
2072 +
2073 + @override
2074 + String get all_chats => 'All';
2075 +
2076 + @override
2077 + String get unfiled_chats => 'Unfiled';
2078 +
2079 + @override
2080 + String get create => 'Create';
2081 +
2082 + @override
2083 + String get server_path_prefix_label => 'API path prefix';
2084 +
2085 + @override
2086 + String get server_path_prefix_hint => '/your-secret-token';
2087 +
2088 + @override
2089 + String get search_message_contents => 'Search message contents';
2090 +
2091 + @override
2092 + String get message_search_results => 'Message matches';
2093 +
2094 + @override
2095 + String get saved_messages_title => 'Saved Messages';
2096 +
2097 + @override
2098 + String get nav_saved_messages => 'Saved Messages';
2099 +
2100 + @override
2101 + String get saved_messages_empty =>
2102 + 'No saved messages yet. Bookmark a message from its options menu.';
2103 +
2104 + @override
2105 + String get save_message => 'Save message';
2106 +
2107 + @override
2108 + String get message_saved => 'Message saved';
2109 +
2110 + @override
2111 + String token_count(int count) {
2112 + return '$count tokens';
2113 + }
2114 +
2115 + @override
2116 + String estimated_token_count(int count) {
2117 + return '~$count tokens (estimated)';
2118 + }
2119 +
2120 + @override
2121 + String get test_tts_section_title => 'Test voice';
2122 +
2123 + @override
2124 + String get test_tts_hint => 'Enter text to hear the current TTS engine…';
2125 +
2126 + @override
2127 + String get test_speak_button => 'Speak';
2128 }
lib/l10n/app_localizations_en.dart
+105
@@ -2012,4 +2012,109 @@ class AppLocalizationsEn extends AppLocalizations {
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 +
2016 + @override
2017 + String get import_settings_confirm =>
2018 + 'Replace current settings with the imported backup?';
2019 +
2020 + @override
2021 + String get export_conversations => 'Export conversations';
2022 +
2023 + @override
2024 + String get import_conversations => 'Import conversations';
2025 +
2026 + @override
2027 + String get export_personas => 'Export personas';
2028 +
2029 + @override
2030 + String get import_personas => 'Import personas';
2031 +
2032 + @override
2033 + String get export_settings => 'Export settings';
2034 +
2035 + @override
2036 + String get import_settings => 'Import settings';
2037 +
2038 + @override
2039 + String get export_all_zip => 'Export all (ZIP)';
2040 +
2041 + @override
2042 + String get import_all_zip => 'Import all (ZIP)';
2043 +
2044 + @override
2045 + String get duplicate_chat => 'Duplicate chat';
2046 +
2047 + @override
2048 + String get duplicate_chat_success => 'Chat duplicated';
2049 +
2050 + @override
2051 + String get move_to_folder => 'Move to folder';
2052 +
2053 + @override
2054 + String get remove_from_folder => 'Remove from folder';
2055 +
2056 + @override
2057 + String get create_folder => 'Create folder';
2058 +
2059 + @override
2060 + String get new_folder => 'New folder';
2061 +
2062 + @override
2063 + String get folder_name_hint => 'Folder name';
2064 +
2065 + @override
2066 + String get all_chats => 'All';
2067 +
2068 + @override
2069 + String get unfiled_chats => 'Unfiled';
2070 +
2071 + @override
2072 + String get create => 'Create';
2073 +
2074 + @override
2075 + String get server_path_prefix_label => 'API path prefix';
2076 +
2077 + @override
2078 + String get server_path_prefix_hint => '/your-secret-token';
2079 +
2080 + @override
2081 + String get search_message_contents => 'Search message contents';
2082 +
2083 + @override
2084 + String get message_search_results => 'Message matches';
2085 +
2086 + @override
2087 + String get saved_messages_title => 'Saved Messages';
2088 +
2089 + @override
2090 + String get nav_saved_messages => 'Saved Messages';
2091 +
2092 + @override
2093 + String get saved_messages_empty =>
2094 + 'No saved messages yet. Bookmark a message from its options menu.';
2095 +
2096 + @override
2097 + String get save_message => 'Save message';
2098 +
2099 + @override
2100 + String get message_saved => 'Message saved';
2101 +
2102 + @override
2103 + String token_count(int count) {
2104 + return '$count tokens';
2105 + }
2106 +
2107 + @override
2108 + String estimated_token_count(int count) {
2109 + return '~$count tokens (estimated)';
2110 + }
2111 +
2112 + @override
2113 + String get test_tts_section_title => 'Test voice';
2114 +
2115 + @override
2116 + String get test_tts_hint => 'Enter text to hear the current TTS engine…';
2117 +
2118 + @override
2119 + String get test_speak_button => 'Speak';
2120 }
lib/l10n/app_localizations_es.dart
+105
@@ -2027,4 +2027,109 @@ class AppLocalizationsEs extends AppLocalizations {
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 +
2031 + @override
2032 + String get import_settings_confirm =>
2033 + 'Replace current settings with the imported backup?';
2034 +
2035 + @override
2036 + String get export_conversations => 'Export conversations';
2037 +
2038 + @override
2039 + String get import_conversations => 'Import conversations';
2040 +
2041 + @override
2042 + String get export_personas => 'Export personas';
2043 +
2044 + @override
2045 + String get import_personas => 'Import personas';
2046 +
2047 + @override
2048 + String get export_settings => 'Export settings';
2049 +
2050 + @override
2051 + String get import_settings => 'Import settings';
2052 +
2053 + @override
2054 + String get export_all_zip => 'Export all (ZIP)';
2055 +
2056 + @override
2057 + String get import_all_zip => 'Import all (ZIP)';
2058 +
2059 + @override
2060 + String get duplicate_chat => 'Duplicate chat';
2061 +
2062 + @override
2063 + String get duplicate_chat_success => 'Chat duplicated';
2064 +
2065 + @override
2066 + String get move_to_folder => 'Move to folder';
2067 +
2068 + @override
2069 + String get remove_from_folder => 'Remove from folder';
2070 +
2071 + @override
2072 + String get create_folder => 'Create folder';
2073 +
2074 + @override
2075 + String get new_folder => 'New folder';
2076 +
2077 + @override
2078 + String get folder_name_hint => 'Folder name';
2079 +
2080 + @override
2081 + String get all_chats => 'All';
2082 +
2083 + @override
2084 + String get unfiled_chats => 'Unfiled';
2085 +
2086 + @override
2087 + String get create => 'Create';
2088 +
2089 + @override
2090 + String get server_path_prefix_label => 'API path prefix';
2091 +
2092 + @override
2093 + String get server_path_prefix_hint => '/your-secret-token';
2094 +
2095 + @override
2096 + String get search_message_contents => 'Search message contents';
2097 +
2098 + @override
2099 + String get message_search_results => 'Message matches';
2100 +
2101 + @override
2102 + String get saved_messages_title => 'Saved Messages';
2103 +
2104 + @override
2105 + String get nav_saved_messages => 'Saved Messages';
2106 +
2107 + @override
2108 + String get saved_messages_empty =>
2109 + 'No saved messages yet. Bookmark a message from its options menu.';
2110 +
2111 + @override
2112 + String get save_message => 'Save message';
2113 +
2114 + @override
2115 + String get message_saved => 'Message saved';
2116 +
2117 + @override
2118 + String token_count(int count) {
2119 + return '$count tokens';
2120 + }
2121 +
2122 + @override
2123 + String estimated_token_count(int count) {
2124 + return '~$count tokens (estimated)';
2125 + }
2126 +
2127 + @override
2128 + String get test_tts_section_title => 'Test voice';
2129 +
2130 + @override
2131 + String get test_tts_hint => 'Enter text to hear the current TTS engine…';
2132 +
2133 + @override
2134 + String get test_speak_button => 'Speak';
2135 }
lib/l10n/app_localizations_hi.dart
+105
@@ -2016,4 +2016,109 @@ class AppLocalizationsHi extends AppLocalizations {
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 +
2020 + @override
2021 + String get import_settings_confirm =>
2022 + 'Replace current settings with the imported backup?';
2023 +
2024 + @override
2025 + String get export_conversations => 'Export conversations';
2026 +
2027 + @override
2028 + String get import_conversations => 'Import conversations';
2029 +
2030 + @override
2031 + String get export_personas => 'Export personas';
2032 +
2033 + @override
2034 + String get import_personas => 'Import personas';
2035 +
2036 + @override
2037 + String get export_settings => 'Export settings';
2038 +
2039 + @override
2040 + String get import_settings => 'Import settings';
2041 +
2042 + @override
2043 + String get export_all_zip => 'Export all (ZIP)';
2044 +
2045 + @override
2046 + String get import_all_zip => 'Import all (ZIP)';
2047 +
2048 + @override
2049 + String get duplicate_chat => 'Duplicate chat';
2050 +
2051 + @override
2052 + String get duplicate_chat_success => 'Chat duplicated';
2053 +
2054 + @override
2055 + String get move_to_folder => 'Move to folder';
2056 +
2057 + @override
2058 + String get remove_from_folder => 'Remove from folder';
2059 +
2060 + @override
2061 + String get create_folder => 'Create folder';
2062 +
2063 + @override
2064 + String get new_folder => 'New folder';
2065 +
2066 + @override
2067 + String get folder_name_hint => 'Folder name';
2068 +
2069 + @override
2070 + String get all_chats => 'All';
2071 +
2072 + @override
2073 + String get unfiled_chats => 'Unfiled';
2074 +
2075 + @override
2076 + String get create => 'Create';
2077 +
2078 + @override
2079 + String get server_path_prefix_label => 'API path prefix';
2080 +
2081 + @override
2082 + String get server_path_prefix_hint => '/your-secret-token';
2083 +
2084 + @override
2085 + String get search_message_contents => 'Search message contents';
2086 +
2087 + @override
2088 + String get message_search_results => 'Message matches';
2089 +
2090 + @override
2091 + String get saved_messages_title => 'Saved Messages';
2092 +
2093 + @override
2094 + String get nav_saved_messages => 'Saved Messages';
2095 +
2096 + @override
2097 + String get saved_messages_empty =>
2098 + 'No saved messages yet. Bookmark a message from its options menu.';
2099 +
2100 + @override
2101 + String get save_message => 'Save message';
2102 +
2103 + @override
2104 + String get message_saved => 'Message saved';
2105 +
2106 + @override
2107 + String token_count(int count) {
2108 + return '$count tokens';
2109 + }
2110 +
2111 + @override
2112 + String estimated_token_count(int count) {
2113 + return '~$count tokens (estimated)';
2114 + }
2115 +
2116 + @override
2117 + String get test_tts_section_title => 'Test voice';
2118 +
2119 + @override
2120 + String get test_tts_hint => 'Enter text to hear the current TTS engine…';
2121 +
2122 + @override
2123 + String get test_speak_button => 'Speak';
2124 }
lib/l10n/app_localizations_it.dart
+105
@@ -2027,4 +2027,109 @@ class AppLocalizationsIt extends AppLocalizations {
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 +
2031 + @override
2032 + String get import_settings_confirm =>
2033 + 'Replace current settings with the imported backup?';
2034 +
2035 + @override
2036 + String get export_conversations => 'Export conversations';
2037 +
2038 + @override
2039 + String get import_conversations => 'Import conversations';
2040 +
2041 + @override
2042 + String get export_personas => 'Export personas';
2043 +
2044 + @override
2045 + String get import_personas => 'Import personas';
2046 +
2047 + @override
2048 + String get export_settings => 'Export settings';
2049 +
2050 + @override
2051 + String get import_settings => 'Import settings';
2052 +
2053 + @override
2054 + String get export_all_zip => 'Export all (ZIP)';
2055 +
2056 + @override
2057 + String get import_all_zip => 'Import all (ZIP)';
2058 +
2059 + @override
2060 + String get duplicate_chat => 'Duplicate chat';
2061 +
2062 + @override
2063 + String get duplicate_chat_success => 'Chat duplicated';
2064 +
2065 + @override
2066 + String get move_to_folder => 'Move to folder';
2067 +
2068 + @override
2069 + String get remove_from_folder => 'Remove from folder';
2070 +
2071 + @override
2072 + String get create_folder => 'Create folder';
2073 +
2074 + @override
2075 + String get new_folder => 'New folder';
2076 +
2077 + @override
2078 + String get folder_name_hint => 'Folder name';
2079 +
2080 + @override
2081 + String get all_chats => 'All';
2082 +
2083 + @override
2084 + String get unfiled_chats => 'Unfiled';
2085 +
2086 + @override
2087 + String get create => 'Create';
2088 +
2089 + @override
2090 + String get server_path_prefix_label => 'API path prefix';
2091 +
2092 + @override
2093 + String get server_path_prefix_hint => '/your-secret-token';
2094 +
2095 + @override
2096 + String get search_message_contents => 'Search message contents';
2097 +
2098 + @override
2099 + String get message_search_results => 'Message matches';
2100 +
2101 + @override
2102 + String get saved_messages_title => 'Saved Messages';
2103 +
2104 + @override
2105 + String get nav_saved_messages => 'Saved Messages';
2106 +
2107 + @override
2108 + String get saved_messages_empty =>
2109 + 'No saved messages yet. Bookmark a message from its options menu.';
2110 +
2111 + @override
2112 + String get save_message => 'Save message';
2113 +
2114 + @override
2115 + String get message_saved => 'Message saved';
2116 +
2117 + @override
2118 + String token_count(int count) {
2119 + return '$count tokens';
2120 + }
2121 +
2122 + @override
2123 + String estimated_token_count(int count) {
2124 + return '~$count tokens (estimated)';
2125 + }
2126 +
2127 + @override
2128 + String get test_tts_section_title => 'Test voice';
2129 +
2130 + @override
2131 + String get test_tts_hint => 'Enter text to hear the current TTS engine…';
2132 +
2133 + @override
2134 + String get test_speak_button => 'Speak';
2135 }
lib/l10n/app_localizations_ja.dart
+105
@@ -1989,4 +1989,109 @@ class AppLocalizationsJa extends AppLocalizations {
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 +
1993 + @override
1994 + String get import_settings_confirm =>
1995 + 'Replace current settings with the imported backup?';
1996 +
1997 + @override
1998 + String get export_conversations => 'Export conversations';
1999 +
2000 + @override
2001 + String get import_conversations => 'Import conversations';
2002 +
2003 + @override
2004 + String get export_personas => 'Export personas';
2005 +
2006 + @override
2007 + String get import_personas => 'Import personas';
2008 +
2009 + @override
2010 + String get export_settings => 'Export settings';
2011 +
2012 + @override
2013 + String get import_settings => 'Import settings';
2014 +
2015 + @override
2016 + String get export_all_zip => 'Export all (ZIP)';
2017 +
2018 + @override
2019 + String get import_all_zip => 'Import all (ZIP)';
2020 +
2021 + @override
2022 + String get duplicate_chat => 'Duplicate chat';
2023 +
2024 + @override
2025 + String get duplicate_chat_success => 'Chat duplicated';
2026 +
2027 + @override
2028 + String get move_to_folder => 'Move to folder';
2029 +
2030 + @override
2031 + String get remove_from_folder => 'Remove from folder';
2032 +
2033 + @override
2034 + String get create_folder => 'Create folder';
2035 +
2036 + @override
2037 + String get new_folder => 'New folder';
2038 +
2039 + @override
2040 + String get folder_name_hint => 'Folder name';
2041 +
2042 + @override
2043 + String get all_chats => 'All';
2044 +
2045 + @override
2046 + String get unfiled_chats => 'Unfiled';
2047 +
2048 + @override
2049 + String get create => 'Create';
2050 +
2051 + @override
2052 + String get server_path_prefix_label => 'API path prefix';
2053 +
2054 + @override
2055 + String get server_path_prefix_hint => '/your-secret-token';
2056 +
2057 + @override
2058 + String get search_message_contents => 'Search message contents';
2059 +
2060 + @override
2061 + String get message_search_results => 'Message matches';
2062 +
2063 + @override
2064 + String get saved_messages_title => 'Saved Messages';
2065 +
2066 + @override
2067 + String get nav_saved_messages => 'Saved Messages';
2068 +
2069 + @override
2070 + String get saved_messages_empty =>
2071 + 'No saved messages yet. Bookmark a message from its options menu.';
2072 +
2073 + @override
2074 + String get save_message => 'Save message';
2075 +
2076 + @override
2077 + String get message_saved => 'Message saved';
2078 +
2079 + @override
2080 + String token_count(int count) {
2081 + return '$count tokens';
2082 + }
2083 +
2084 + @override
2085 + String estimated_token_count(int count) {
2086 + return '~$count tokens (estimated)';
2087 + }
2088 +
2089 + @override
2090 + String get test_tts_section_title => 'Test voice';
2091 +
2092 + @override
2093 + String get test_tts_hint => 'Enter text to hear the current TTS engine…';
2094 +
2095 + @override
2096 + String get test_speak_button => 'Speak';
2097 }
lib/l10n/app_localizations_zh.dart
+105
@@ -1982,6 +1982,111 @@ class AppLocalizationsZh extends AppLocalizations {
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 + @override
1987 + String get import_settings_confirm =>
1988 + 'Replace current settings with the imported backup?';
1989 +
1990 + @override
1991 + String get export_conversations => 'Export conversations';
1992 +
1993 + @override
1994 + String get import_conversations => 'Import conversations';
1995 +
1996 + @override
1997 + String get export_personas => 'Export personas';
1998 +
1999 + @override
2000 + String get import_personas => 'Import personas';
2001 +
2002 + @override
2003 + String get export_settings => 'Export settings';
2004 +
2005 + @override
2006 + String get import_settings => 'Import settings';
2007 +
2008 + @override
2009 + String get export_all_zip => 'Export all (ZIP)';
2010 +
2011 + @override
2012 + String get import_all_zip => 'Import all (ZIP)';
2013 +
2014 + @override
2015 + String get duplicate_chat => 'Duplicate chat';
2016 +
2017 + @override
2018 + String get duplicate_chat_success => 'Chat duplicated';
2019 +
2020 + @override
2021 + String get move_to_folder => 'Move to folder';
2022 +
2023 + @override
2024 + String get remove_from_folder => 'Remove from folder';
2025 +
2026 + @override
2027 + String get create_folder => 'Create folder';
2028 +
2029 + @override
2030 + String get new_folder => 'New folder';
2031 +
2032 + @override
2033 + String get folder_name_hint => 'Folder name';
2034 +
2035 + @override
2036 + String get all_chats => 'All';
2037 +
2038 + @override
2039 + String get unfiled_chats => 'Unfiled';
2040 +
2041 + @override
2042 + String get create => 'Create';
2043 +
2044 + @override
2045 + String get server_path_prefix_label => 'API path prefix';
2046 +
2047 + @override
2048 + String get server_path_prefix_hint => '/your-secret-token';
2049 +
2050 + @override
2051 + String get search_message_contents => 'Search message contents';
2052 +
2053 + @override
2054 + String get message_search_results => 'Message matches';
2055 +
2056 + @override
2057 + String get saved_messages_title => 'Saved Messages';
2058 +
2059 + @override
2060 + String get nav_saved_messages => 'Saved Messages';
2061 +
2062 + @override
2063 + String get saved_messages_empty =>
2064 + 'No saved messages yet. Bookmark a message from its options menu.';
2065 +
2066 + @override
2067 + String get save_message => 'Save message';
2068 +
2069 + @override
2070 + String get message_saved => 'Message saved';
2071 +
2072 + @override
2073 + String token_count(int count) {
2074 + return '$count tokens';
2075 + }
2076 +
2077 + @override
2078 + String estimated_token_count(int count) {
2079 + return '~$count tokens (estimated)';
2080 + }
2081 +
2082 + @override
2083 + String get test_tts_section_title => 'Test voice';
2084 +
2085 + @override
2086 + String get test_tts_hint => 'Enter text to hear the current TTS engine…';
2087 +
2088 + @override
2089 + String get test_speak_button => 'Speak';
2090 }
2091
2092 /// The translations for Chinese, as used in Taiwan (`zh_TW`).
lib/objectbox-model.json
+177 -5
@@ -5,7 +5,7 @@
5 "entities": [
6 {
7 "id": "1:6060443518060866028",
8 - "lastPropertyId": "19:2437473986237603139",
8 + "lastPropertyId": "20:6746128880040296628",
9 "name": "ConversationEntity",
10 "properties": [
11 {
@@ -105,13 +105,18 @@
105 "id": "19:2437473986237603139",
106 "name": "smartRepliesLastMessageId",
107 "type": 9
108 + },
109 + {
110 + "id": "20:6746128880040296628",
111 + "name": "folderId",
112 + "type": 9
113 }
114 ],
115 "relations": []
116 },
117 {
118 "id": "2:196881068437544233",
114 - "lastPropertyId": "19:5573835801528383007",
119 + "lastPropertyId": "24:5960797950716320436",
120 "name": "MessageEntity",
121 "properties": [
122 {
@@ -216,6 +221,31 @@
221 "id": "19:5573835801528383007",
222 "name": "toolEventsJson",
223 "type": 9
224 + },
225 + {
226 + "id": "20:1744774964291030898",
227 + "name": "variantGroupId",
228 + "type": 9
229 + },
230 + {
231 + "id": "21:6711322182948396736",
232 + "name": "variantIndex",
233 + "type": 6
234 + },
235 + {
236 + "id": "22:5971161269391176967",
237 + "name": "threadOrder",
238 + "type": 6
239 + },
240 + {
241 + "id": "23:245069546630142341",
242 + "name": "isActiveVariant",
243 + "type": 1
244 + },
245 + {
246 + "id": "24:5960797950716320436",
247 + "name": "parentMessageId",
248 + "type": 9
249 }
250 ],
251 "relations": []
@@ -288,7 +318,7 @@
318 },
319 {
320 "id": "4:6971506781199819057",
291 - "lastPropertyId": "12:4662365603702428254",
321 + "lastPropertyId": "13:6207430374144016939",
322 "name": "ServerEntity",
323 "properties": [
324 {
@@ -353,13 +383,155 @@
383 "id": "12:4662365603702428254",
384 "name": "iconName",
385 "type": 9
386 + },
387 + {
388 + "id": "13:6207430374144016939",
389 + "name": "pathPrefix",
390 + "type": 9
391 + }
392 + ],
393 + "relations": []
394 + },
395 + {
396 + "id": "5:8961477194169140955",
397 + "lastPropertyId": "5:3017762750413225828",
398 + "name": "ConversationFolderEntity",
399 + "properties": [
400 + {
401 + "id": "1:6352437205361204397",
402 + "name": "internalId",
403 + "type": 6,
404 + "flags": 1
405 + },
406 + {
407 + "id": "2:2340734036954615718",
408 + "name": "id",
409 + "indexId": "7:2357039312556022508",
410 + "type": 9,
411 + "flags": 2048
412 + },
413 + {
414 + "id": "3:3556260745512005959",
415 + "name": "name",
416 + "type": 9
417 + },
418 + {
419 + "id": "4:4019578934915258468",
420 + "name": "sortOrder",
421 + "type": 6
422 + },
423 + {
424 + "id": "5:3017762750413225828",
425 + "name": "createdAt",
426 + "type": 10
427 + }
428 + ],
429 + "relations": []
430 + },
431 + {
432 + "id": "6:152573122645768072",
433 + "lastPropertyId": "10:431621328894537301",
434 + "name": "SavedMessageEntity",
435 + "properties": [
436 + {
437 + "id": "1:5595034332359148209",
438 + "name": "internalId",
439 + "type": 6,
440 + "flags": 1
441 + },
442 + {
443 + "id": "2:5096668802007679304",
444 + "name": "id",
445 + "indexId": "8:22783346841342476",
446 + "type": 9,
447 + "flags": 2048
448 + },
449 + {
450 + "id": "3:1050610104311264456",
451 + "name": "sourceMessageId",
452 + "indexId": "9:1110570128952634448",
453 + "type": 9,
454 + "flags": 2048
455 + },
456 + {
457 + "id": "4:7798325361628325620",
458 + "name": "conversationId",
459 + "indexId": "10:8583926456518887322",
460 + "type": 9,
461 + "flags": 2048
462 + },
463 + {
464 + "id": "5:3050316078893132430",
465 + "name": "conversationTitle",
466 + "type": 9
467 + },
468 + {
469 + "id": "6:7383260001962467499",
470 + "name": "roleIndex",
471 + "type": 5
472 + },
473 + {
474 + "id": "7:7504405387353170036",
475 + "name": "content",
476 + "type": 9
477 + },
478 + {
479 + "id": "8:4458386748227842718",
480 + "name": "modelId",
481 + "type": 9
482 + },
483 + {
484 + "id": "9:6741841406448273760",
485 + "name": "folderId",
486 + "type": 9
487 + },
488 + {
489 + "id": "10:431621328894537301",
490 + "name": "savedAt",
491 + "type": 10
492 + }
493 + ],
494 + "relations": []
495 + },
496 + {
497 + "id": "7:3071607342535269584",
498 + "lastPropertyId": "5:3331891664317498140",
499 + "name": "SavedMessageFolderEntity",
500 + "properties": [
501 + {
502 + "id": "1:7591361223634591419",
503 + "name": "internalId",
504 + "type": 6,
505 + "flags": 1
506 + },
507 + {
508 + "id": "2:2427537233466774281",
509 + "name": "id",
510 + "indexId": "11:4226689279810093888",
511 + "type": 9,
512 + "flags": 2048
513 + },
514 + {
515 + "id": "3:2751147963490618792",
516 + "name": "name",
517 + "type": 9
518 + },
519 + {
520 + "id": "4:3384073655383318255",
521 + "name": "sortOrder",
522 + "type": 6
523 + },
524 + {
525 + "id": "5:3331891664317498140",
526 + "name": "createdAt",
527 + "type": 10
528 }
529 ],
530 "relations": []
531 }
532 ],
361 - "lastEntityId": "4:6971506781199819057",
362 - "lastIndexId": "6:4529386251594949074",
533 + "lastEntityId": "7:3071607342535269584",
534 + "lastIndexId": "11:4226689279810093888",
535 "lastRelationId": "0:0",
536 "lastSequenceId": "0:0",
537 "modelVersion": 5,
lib/objectbox.g.dart
+608 -8
@@ -22,7 +22,7 @@ final _entities = <obx_int.ModelEntity>[
22 obx_int.ModelEntity(
23 id: const obx_int.IdUid(1, 6060443518060866028),
24 name: 'ConversationEntity',
25 - lastPropertyId: const obx_int.IdUid(19, 2437473986237603139),
25 + lastPropertyId: const obx_int.IdUid(20, 6746128880040296628),
26 flags: 0,
27 properties: <obx_int.ModelProperty>[
28 obx_int.ModelProperty(
@@ -140,6 +140,12 @@ final _entities = <obx_int.ModelEntity>[
140 type: 9,
141 flags: 0,
142 ),
143 + obx_int.ModelProperty(
144 + id: const obx_int.IdUid(20, 6746128880040296628),
145 + name: 'folderId',
146 + type: 9,
147 + flags: 0,
148 + ),
149 ],
150 relations: <obx_int.ModelRelation>[],
151 backlinks: <obx_int.ModelBacklink>[
@@ -153,7 +159,7 @@ final _entities = <obx_int.ModelEntity>[
159 obx_int.ModelEntity(
160 id: const obx_int.IdUid(2, 196881068437544233),
161 name: 'MessageEntity',
156 - lastPropertyId: const obx_int.IdUid(19, 5573835801528383007),
162 + lastPropertyId: const obx_int.IdUid(24, 5960797950716320436),
163 flags: 0,
164 properties: <obx_int.ModelProperty>[
165 obx_int.ModelProperty(
@@ -275,6 +281,36 @@ final _entities = <obx_int.ModelEntity>[
281 type: 9,
282 flags: 0,
283 ),
284 + obx_int.ModelProperty(
285 + id: const obx_int.IdUid(20, 1744774964291030898),
286 + name: 'variantGroupId',
287 + type: 9,
288 + flags: 0,
289 + ),
290 + obx_int.ModelProperty(
291 + id: const obx_int.IdUid(21, 6711322182948396736),
292 + name: 'variantIndex',
293 + type: 6,
294 + flags: 0,
295 + ),
296 + obx_int.ModelProperty(
297 + id: const obx_int.IdUid(22, 5971161269391176967),
298 + name: 'threadOrder',
299 + type: 6,
300 + flags: 0,
301 + ),
302 + obx_int.ModelProperty(
303 + id: const obx_int.IdUid(23, 245069546630142341),
304 + name: 'isActiveVariant',
305 + type: 1,
306 + flags: 0,
307 + ),
308 + obx_int.ModelProperty(
309 + id: const obx_int.IdUid(24, 5960797950716320436),
310 + name: 'parentMessageId',
311 + type: 9,
312 + flags: 0,
313 + ),
314 ],
315 relations: <obx_int.ModelRelation>[],
316 backlinks: <obx_int.ModelBacklink>[],
@@ -359,7 +395,7 @@ final _entities = <obx_int.ModelEntity>[
395 obx_int.ModelEntity(
396 id: const obx_int.IdUid(4, 6971506781199819057),
397 name: 'ServerEntity',
362 - lastPropertyId: const obx_int.IdUid(12, 4662365603702428254),
398 + lastPropertyId: const obx_int.IdUid(13, 6207430374144016939),
399 flags: 0,
400 properties: <obx_int.ModelProperty>[
401 obx_int.ModelProperty(
@@ -435,6 +471,167 @@ final _entities = <obx_int.ModelEntity>[
471 type: 9,
472 flags: 0,
473 ),
474 + obx_int.ModelProperty(
475 + id: const obx_int.IdUid(13, 6207430374144016939),
476 + name: 'pathPrefix',
477 + type: 9,
478 + flags: 0,
479 + ),
480 + ],
481 + relations: <obx_int.ModelRelation>[],
482 + backlinks: <obx_int.ModelBacklink>[],
483 + ),
484 + obx_int.ModelEntity(
485 + id: const obx_int.IdUid(5, 8961477194169140955),
486 + name: 'ConversationFolderEntity',
487 + lastPropertyId: const obx_int.IdUid(5, 3017762750413225828),
488 + flags: 0,
489 + properties: <obx_int.ModelProperty>[
490 + obx_int.ModelProperty(
491 + id: const obx_int.IdUid(1, 6352437205361204397),
492 + name: 'internalId',
493 + type: 6,
494 + flags: 1,
495 + ),
496 + obx_int.ModelProperty(
497 + id: const obx_int.IdUid(2, 2340734036954615718),
498 + name: 'id',
499 + type: 9,
500 + flags: 2048,
501 + indexId: const obx_int.IdUid(7, 2357039312556022508),
502 + ),
503 + obx_int.ModelProperty(
504 + id: const obx_int.IdUid(3, 3556260745512005959),
505 + name: 'name',
506 + type: 9,
507 + flags: 0,
508 + ),
509 + obx_int.ModelProperty(
510 + id: const obx_int.IdUid(4, 4019578934915258468),
511 + name: 'sortOrder',
512 + type: 6,
513 + flags: 0,
514 + ),
515 + obx_int.ModelProperty(
516 + id: const obx_int.IdUid(5, 3017762750413225828),
517 + name: 'createdAt',
518 + type: 10,
519 + flags: 0,
520 + ),
521 + ],
522 + relations: <obx_int.ModelRelation>[],
523 + backlinks: <obx_int.ModelBacklink>[],
524 + ),
525 + obx_int.ModelEntity(
526 + id: const obx_int.IdUid(6, 152573122645768072),
527 + name: 'SavedMessageEntity',
528 + lastPropertyId: const obx_int.IdUid(10, 431621328894537301),
529 + flags: 0,
530 + properties: <obx_int.ModelProperty>[
531 + obx_int.ModelProperty(
532 + id: const obx_int.IdUid(1, 5595034332359148209),
533 + name: 'internalId',
534 + type: 6,
535 + flags: 1,
536 + ),
537 + obx_int.ModelProperty(
538 + id: const obx_int.IdUid(2, 5096668802007679304),
539 + name: 'id',
540 + type: 9,
541 + flags: 2048,
542 + indexId: const obx_int.IdUid(8, 22783346841342476),
543 + ),
544 + obx_int.ModelProperty(
545 + id: const obx_int.IdUid(3, 1050610104311264456),
546 + name: 'sourceMessageId',
547 + type: 9,
548 + flags: 2048,
549 + indexId: const obx_int.IdUid(9, 1110570128952634448),
550 + ),
551 + obx_int.ModelProperty(
552 + id: const obx_int.IdUid(4, 7798325361628325620),
553 + name: 'conversationId',
554 + type: 9,
555 + flags: 2048,
556 + indexId: const obx_int.IdUid(10, 8583926456518887322),
557 + ),
558 + obx_int.ModelProperty(
559 + id: const obx_int.IdUid(5, 3050316078893132430),
560 + name: 'conversationTitle',
561 + type: 9,
562 + flags: 0,
563 + ),
564 + obx_int.ModelProperty(
565 + id: const obx_int.IdUid(6, 7383260001962467499),
566 + name: 'roleIndex',
567 + type: 5,
568 + flags: 0,
569 + ),
570 + obx_int.ModelProperty(
571 + id: const obx_int.IdUid(7, 7504405387353170036),
572 + name: 'content',
573 + type: 9,
574 + flags: 0,
575 + ),
576 + obx_int.ModelProperty(
577 + id: const obx_int.IdUid(8, 4458386748227842718),
578 + name: 'modelId',
579 + type: 9,
580 + flags: 0,
581 + ),
582 + obx_int.ModelProperty(
583 + id: const obx_int.IdUid(9, 6741841406448273760),
584 + name: 'folderId',
585 + type: 9,
586 + flags: 0,
587 + ),
588 + obx_int.ModelProperty(
589 + id: const obx_int.IdUid(10, 431621328894537301),
590 + name: 'savedAt',
591 + type: 10,
592 + flags: 0,
593 + ),
594 + ],
595 + relations: <obx_int.ModelRelation>[],
596 + backlinks: <obx_int.ModelBacklink>[],
597 + ),
598 + obx_int.ModelEntity(
599 + id: const obx_int.IdUid(7, 3071607342535269584),
600 + name: 'SavedMessageFolderEntity',
601 + lastPropertyId: const obx_int.IdUid(5, 3331891664317498140),
602 + flags: 0,
603 + properties: <obx_int.ModelProperty>[
604 + obx_int.ModelProperty(
605 + id: const obx_int.IdUid(1, 7591361223634591419),
606 + name: 'internalId',
607 + type: 6,
608 + flags: 1,
609 + ),
610 + obx_int.ModelProperty(
611 + id: const obx_int.IdUid(2, 2427537233466774281),
612 + name: 'id',
613 + type: 9,
614 + flags: 2048,
615 + indexId: const obx_int.IdUid(11, 4226689279810093888),
616 + ),
617 + obx_int.ModelProperty(
618 + id: const obx_int.IdUid(3, 2751147963490618792),
619 + name: 'name',
620 + type: 9,
621 + flags: 0,
622 + ),
623 + obx_int.ModelProperty(
624 + id: const obx_int.IdUid(4, 3384073655383318255),
625 + name: 'sortOrder',
626 + type: 6,
627 + flags: 0,
628 + ),
629 + obx_int.ModelProperty(
630 + id: const obx_int.IdUid(5, 3331891664317498140),
631 + name: 'createdAt',
632 + type: 10,
633 + flags: 0,
634 + ),
635 ],
636 relations: <obx_int.ModelRelation>[],
637 backlinks: <obx_int.ModelBacklink>[],
@@ -484,8 +681,8 @@ obx_int.ModelDefinition getObjectBoxModel() {
681 // Typically, this is done with `dart run build_runner build`.
682 generatorVersion: obx_int.GeneratorVersion.v2025_12_16,
683 entities: _entities,
487 - lastEntityId: const obx_int.IdUid(4, 6971506781199819057),
488 - lastIndexId: const obx_int.IdUid(6, 4529386251594949074),
684 + lastEntityId: const obx_int.IdUid(7, 3071607342535269584),
685 + lastIndexId: const obx_int.IdUid(11, 4226689279810093888),
686 lastRelationId: const obx_int.IdUid(0, 0),
687 lastSequenceId: const obx_int.IdUid(0, 0),
688 retiredEntityUids: const [],
@@ -537,7 +734,10 @@ obx_int.ModelDefinition getObjectBoxModel() {
734 object.smartRepliesLastMessageId == null
735 ? null
736 : fbb.writeString(object.smartRepliesLastMessageId!);
540 - fbb.startTable(20);
737 + final folderIdOffset = object.folderId == null
738 + ? null
739 + : fbb.writeString(object.folderId!);
740 + fbb.startTable(21);
741 fbb.addInt64(0, object.internalId);
742 fbb.addOffset(1, idOffset);
743 fbb.addOffset(2, titleOffset);
@@ -557,6 +757,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
757 fbb.addBool(16, object.mcpEnabled);
758 fbb.addOffset(17, smartRepliesJsonOffset);
759 fbb.addOffset(18, smartRepliesLastMessageIdOffset);
760 + fbb.addOffset(19, folderIdOffset);
761 fbb.finish(fbb.endTable());
762 return object.internalId;
763 },
@@ -639,6 +840,9 @@ obx_int.ModelDefinition getObjectBoxModel() {
840 final smartRepliesLastMessageIdParam = const fb.StringReader(
841 asciiOptimization: true,
842 ).vTableGetNullable(buffer, rootOffset, 40);
843 + final folderIdParam = const fb.StringReader(
844 + asciiOptimization: true,
845 + ).vTableGetNullable(buffer, rootOffset, 42);
846 final object = ConversationEntity(
847 internalId: internalIdParam,
848 id: idParam,
@@ -659,6 +863,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
863 mcpEnabled: mcpEnabledParam,
864 smartRepliesJson: smartRepliesJsonParam,
865 smartRepliesLastMessageId: smartRepliesLastMessageIdParam,
866 + folderId: folderIdParam,
867 );
868 obx_int.InternalToManyAccess.setRelInfo<ConversationEntity>(
869 object.messages,
@@ -708,7 +913,13 @@ obx_int.ModelDefinition getObjectBoxModel() {
913 final toolEventsJsonOffset = object.toolEventsJson == null
914 ? null
915 : fbb.writeString(object.toolEventsJson!);
711 - fbb.startTable(20);
916 + final variantGroupIdOffset = object.variantGroupId == null
917 + ? null
918 + : fbb.writeString(object.variantGroupId!);
919 + final parentMessageIdOffset = object.parentMessageId == null
920 + ? null
921 + : fbb.writeString(object.parentMessageId!);
922 + fbb.startTable(25);
923 fbb.addInt64(0, object.internalId);
924 fbb.addOffset(1, idOffset);
925 fbb.addInt64(2, object.conversation.targetId);
@@ -728,6 +939,11 @@ obx_int.ModelDefinition getObjectBoxModel() {
939 fbb.addBool(16, object.isProcessing);
940 fbb.addOffset(17, toolSessionIdOffset);
941 fbb.addOffset(18, toolEventsJsonOffset);
942 + fbb.addOffset(19, variantGroupIdOffset);
943 + fbb.addInt64(20, object.variantIndex);
944 + fbb.addInt64(21, object.threadOrder);
945 + fbb.addBool(22, object.isActiveVariant);
946 + fbb.addOffset(23, parentMessageIdOffset);
947 fbb.finish(fbb.endTable());
948 return object.internalId;
949 },
@@ -804,6 +1020,30 @@ obx_int.ModelDefinition getObjectBoxModel() {
1020 final toolEventsJsonParam = const fb.StringReader(
1021 asciiOptimization: true,
1022 ).vTableGetNullable(buffer, rootOffset, 40);
1023 + final variantGroupIdParam = const fb.StringReader(
1024 + asciiOptimization: true,
1025 + ).vTableGetNullable(buffer, rootOffset, 42);
1026 + final variantIndexParam = const fb.Int64Reader().vTableGet(
1027 + buffer,
1028 + rootOffset,
1029 + 44,
1030 + 0,
1031 + );
1032 + final threadOrderParam = const fb.Int64Reader().vTableGet(
1033 + buffer,
1034 + rootOffset,
1035 + 46,
1036 + 0,
1037 + );
1038 + final isActiveVariantParam = const fb.BoolReader().vTableGet(
1039 + buffer,
1040 + rootOffset,
1041 + 48,
1042 + false,
1043 + );
1044 + final parentMessageIdParam = const fb.StringReader(
1045 + asciiOptimization: true,
1046 + ).vTableGetNullable(buffer, rootOffset, 50);
1047 final object = MessageEntity(
1048 internalId: internalIdParam,
1049 id: idParam,
@@ -823,6 +1063,11 @@ obx_int.ModelDefinition getObjectBoxModel() {
1063 isProcessing: isProcessingParam,
1064 toolSessionId: toolSessionIdParam,
1065 toolEventsJson: toolEventsJsonParam,
1066 + variantGroupId: variantGroupIdParam,
1067 + variantIndex: variantIndexParam,
1068 + threadOrder: threadOrderParam,
1069 + isActiveVariant: isActiveVariantParam,
1070 + parentMessageId: parentMessageIdParam,
1071 );
1072 object.conversation.targetId = const fb.Int64Reader().vTableGet(
1073 buffer,
@@ -948,7 +1193,10 @@ obx_int.ModelDefinition getObjectBoxModel() {
1193 final iconNameOffset = object.iconName == null
1194 ? null
1195 : fbb.writeString(object.iconName!);
951 - fbb.startTable(13);
1196 + final pathPrefixOffset = object.pathPrefix == null
1197 + ? null
1198 + : fbb.writeString(object.pathPrefix!);
1199 + fbb.startTable(14);
1200 fbb.addInt64(0, object.internalId);
1201 fbb.addOffset(1, idOffset);
1202 fbb.addOffset(2, nameOffset);
@@ -961,6 +1209,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
1209 fbb.addInt64(9, object.lastConnectedAt.millisecondsSinceEpoch);
1210 fbb.addInt32(10, object.statusIndex);
1211 fbb.addOffset(11, iconNameOffset);
1212 + fbb.addOffset(12, pathPrefixOffset);
1213 fbb.finish(fbb.endTable());
1214 return object.internalId;
1215 },
@@ -1018,6 +1267,9 @@ obx_int.ModelDefinition getObjectBoxModel() {
1267 final iconNameParam = const fb.StringReader(
1268 asciiOptimization: true,
1269 ).vTableGetNullable(buffer, rootOffset, 26);
1270 + final pathPrefixParam = const fb.StringReader(
1271 + asciiOptimization: true,
1272 + ).vTableGetNullable(buffer, rootOffset, 28);
1273 final object = ServerEntity(
1274 internalId: internalIdParam,
1275 id: idParam,
@@ -1031,11 +1283,215 @@ obx_int.ModelDefinition getObjectBoxModel() {
1283 lastConnectedAt: lastConnectedAtParam,
1284 statusIndex: statusIndexParam,
1285 iconName: iconNameParam,
1286 + pathPrefix: pathPrefixParam,
1287 + );
1288 +
1289 + return object;
1290 + },
1291 + ),
1292 + ConversationFolderEntity:
1293 + obx_int.EntityDefinition<ConversationFolderEntity>(
1294 + model: _entities[4],
1295 + toOneRelations: (ConversationFolderEntity object) => [],
1296 + toManyRelations: (ConversationFolderEntity object) => {},
1297 + getId: (ConversationFolderEntity object) => object.internalId,
1298 + setId: (ConversationFolderEntity object, int id) {
1299 + object.internalId = id;
1300 + },
1301 + objectToFB: (ConversationFolderEntity object, fb.Builder fbb) {
1302 + final idOffset = fbb.writeString(object.id);
1303 + final nameOffset = fbb.writeString(object.name);
1304 + fbb.startTable(6);
1305 + fbb.addInt64(0, object.internalId);
1306 + fbb.addOffset(1, idOffset);
1307 + fbb.addOffset(2, nameOffset);
1308 + fbb.addInt64(3, object.sortOrder);
1309 + fbb.addInt64(4, object.createdAt.millisecondsSinceEpoch);
1310 + fbb.finish(fbb.endTable());
1311 + return object.internalId;
1312 + },
1313 + objectFromFB: (obx.Store store, ByteData fbData) {
1314 + final buffer = fb.BufferContext(fbData);
1315 + final rootOffset = buffer.derefObject(0);
1316 + final internalIdParam = const fb.Int64Reader().vTableGet(
1317 + buffer,
1318 + rootOffset,
1319 + 4,
1320 + 0,
1321 + );
1322 + final idParam = const fb.StringReader(
1323 + asciiOptimization: true,
1324 + ).vTableGet(buffer, rootOffset, 6, '');
1325 + final nameParam = const fb.StringReader(
1326 + asciiOptimization: true,
1327 + ).vTableGet(buffer, rootOffset, 8, '');
1328 + final sortOrderParam = const fb.Int64Reader().vTableGet(
1329 + buffer,
1330 + rootOffset,
1331 + 10,
1332 + 0,
1333 + );
1334 + final createdAtParam = DateTime.fromMillisecondsSinceEpoch(
1335 + const fb.Int64Reader().vTableGet(buffer, rootOffset, 12, 0),
1336 + );
1337 + final object = ConversationFolderEntity(
1338 + internalId: internalIdParam,
1339 + id: idParam,
1340 + name: nameParam,
1341 + sortOrder: sortOrderParam,
1342 + createdAt: createdAtParam,
1343 + );
1344 +
1345 + return object;
1346 + },
1347 + ),
1348 + SavedMessageEntity: obx_int.EntityDefinition<SavedMessageEntity>(
1349 + model: _entities[5],
1350 + toOneRelations: (SavedMessageEntity object) => [],
1351 + toManyRelations: (SavedMessageEntity object) => {},
1352 + getId: (SavedMessageEntity object) => object.internalId,
1353 + setId: (SavedMessageEntity object, int id) {
1354 + object.internalId = id;
1355 + },
1356 + objectToFB: (SavedMessageEntity object, fb.Builder fbb) {
1357 + final idOffset = fbb.writeString(object.id);
1358 + final sourceMessageIdOffset = fbb.writeString(object.sourceMessageId);
1359 + final conversationIdOffset = fbb.writeString(object.conversationId);
1360 + final conversationTitleOffset = fbb.writeString(
1361 + object.conversationTitle,
1362 + );
1363 + final contentOffset = fbb.writeString(object.content);
1364 + final modelIdOffset = object.modelId == null
1365 + ? null
1366 + : fbb.writeString(object.modelId!);
1367 + final folderIdOffset = object.folderId == null
1368 + ? null
1369 + : fbb.writeString(object.folderId!);
1370 + fbb.startTable(11);
1371 + fbb.addInt64(0, object.internalId);
1372 + fbb.addOffset(1, idOffset);
1373 + fbb.addOffset(2, sourceMessageIdOffset);
1374 + fbb.addOffset(3, conversationIdOffset);
1375 + fbb.addOffset(4, conversationTitleOffset);
1376 + fbb.addInt32(5, object.roleIndex);
1377 + fbb.addOffset(6, contentOffset);
1378 + fbb.addOffset(7, modelIdOffset);
1379 + fbb.addOffset(8, folderIdOffset);
1380 + fbb.addInt64(9, object.savedAt.millisecondsSinceEpoch);
1381 + fbb.finish(fbb.endTable());
1382 + return object.internalId;
1383 + },
1384 + objectFromFB: (obx.Store store, ByteData fbData) {
1385 + final buffer = fb.BufferContext(fbData);
1386 + final rootOffset = buffer.derefObject(0);
1387 + final internalIdParam = const fb.Int64Reader().vTableGet(
1388 + buffer,
1389 + rootOffset,
1390 + 4,
1391 + 0,
1392 + );
1393 + final idParam = const fb.StringReader(
1394 + asciiOptimization: true,
1395 + ).vTableGet(buffer, rootOffset, 6, '');
1396 + final sourceMessageIdParam = const fb.StringReader(
1397 + asciiOptimization: true,
1398 + ).vTableGet(buffer, rootOffset, 8, '');
1399 + final conversationIdParam = const fb.StringReader(
1400 + asciiOptimization: true,
1401 + ).vTableGet(buffer, rootOffset, 10, '');
1402 + final conversationTitleParam = const fb.StringReader(
1403 + asciiOptimization: true,
1404 + ).vTableGet(buffer, rootOffset, 12, '');
1405 + final roleIndexParam = const fb.Int32Reader().vTableGet(
1406 + buffer,
1407 + rootOffset,
1408 + 14,
1409 + 0,
1410 + );
1411 + final contentParam = const fb.StringReader(
1412 + asciiOptimization: true,
1413 + ).vTableGet(buffer, rootOffset, 16, '');
1414 + final modelIdParam = const fb.StringReader(
1415 + asciiOptimization: true,
1416 + ).vTableGetNullable(buffer, rootOffset, 18);
1417 + final folderIdParam = const fb.StringReader(
1418 + asciiOptimization: true,
1419 + ).vTableGetNullable(buffer, rootOffset, 20);
1420 + final savedAtParam = DateTime.fromMillisecondsSinceEpoch(
1421 + const fb.Int64Reader().vTableGet(buffer, rootOffset, 22, 0),
1422 + );
1423 + final object = SavedMessageEntity(
1424 + internalId: internalIdParam,
1425 + id: idParam,
1426 + sourceMessageId: sourceMessageIdParam,
1427 + conversationId: conversationIdParam,
1428 + conversationTitle: conversationTitleParam,
1429 + roleIndex: roleIndexParam,
1430 + content: contentParam,
1431 + modelId: modelIdParam,
1432 + folderId: folderIdParam,
1433 + savedAt: savedAtParam,
1434 );
1435
1436 return object;
1437 },
1438 ),
1439 + SavedMessageFolderEntity:
1440 + obx_int.EntityDefinition<SavedMessageFolderEntity>(
1441 + model: _entities[6],
1442 + toOneRelations: (SavedMessageFolderEntity object) => [],
1443 + toManyRelations: (SavedMessageFolderEntity object) => {},
1444 + getId: (SavedMessageFolderEntity object) => object.internalId,
1445 + setId: (SavedMessageFolderEntity object, int id) {
1446 + object.internalId = id;
1447 + },
1448 + objectToFB: (SavedMessageFolderEntity object, fb.Builder fbb) {
1449 + final idOffset = fbb.writeString(object.id);
1450 + final nameOffset = fbb.writeString(object.name);
1451 + fbb.startTable(6);
1452 + fbb.addInt64(0, object.internalId);
1453 + fbb.addOffset(1, idOffset);
1454 + fbb.addOffset(2, nameOffset);
1455 + fbb.addInt64(3, object.sortOrder);
1456 + fbb.addInt64(4, object.createdAt.millisecondsSinceEpoch);
1457 + fbb.finish(fbb.endTable());
1458 + return object.internalId;
1459 + },
1460 + objectFromFB: (obx.Store store, ByteData fbData) {
1461 + final buffer = fb.BufferContext(fbData);
1462 + final rootOffset = buffer.derefObject(0);
1463 + final internalIdParam = const fb.Int64Reader().vTableGet(
1464 + buffer,
1465 + rootOffset,
1466 + 4,
1467 + 0,
1468 + );
1469 + final idParam = const fb.StringReader(
1470 + asciiOptimization: true,
1471 + ).vTableGet(buffer, rootOffset, 6, '');
1472 + final nameParam = const fb.StringReader(
1473 + asciiOptimization: true,
1474 + ).vTableGet(buffer, rootOffset, 8, '');
1475 + final sortOrderParam = const fb.Int64Reader().vTableGet(
1476 + buffer,
1477 + rootOffset,
1478 + 10,
1479 + 0,
1480 + );
1481 + final createdAtParam = DateTime.fromMillisecondsSinceEpoch(
1482 + const fb.Int64Reader().vTableGet(buffer, rootOffset, 12, 0),
1483 + );
1484 + final object = SavedMessageFolderEntity(
1485 + internalId: internalIdParam,
1486 + id: idParam,
1487 + name: nameParam,
1488 + sortOrder: sortOrderParam,
1489 + createdAt: createdAtParam,
1490 + );
1491 +
1492 + return object;
1493 + },
1494 + ),
1495 };
1496
1497 return obx_int.ModelDefinition(model, bindings);
@@ -1137,6 +1593,11 @@ class ConversationEntity_ {
1593 static final smartRepliesLastMessageId =
1594 obx.QueryStringProperty<ConversationEntity>(_entities[0].properties[18]);
1595
1596 + /// See [ConversationEntity.folderId].
1597 + static final folderId = obx.QueryStringProperty<ConversationEntity>(
1598 + _entities[0].properties[19],
1599 + );
1600 +
1601 /// see [ConversationEntity.messages]
1602 static final messages =
1603 obx.QueryBacklinkToMany<MessageEntity, ConversationEntity>(
@@ -1241,6 +1702,31 @@ class MessageEntity_ {
1702 static final toolEventsJson = obx.QueryStringProperty<MessageEntity>(
1703 _entities[1].properties[18],
1704 );
1705 +
1706 + /// See [MessageEntity.variantGroupId].
1707 + static final variantGroupId = obx.QueryStringProperty<MessageEntity>(
1708 + _entities[1].properties[19],
1709 + );
1710 +
1711 + /// See [MessageEntity.variantIndex].
1712 + static final variantIndex = obx.QueryIntegerProperty<MessageEntity>(
1713 + _entities[1].properties[20],
1714 + );
1715 +
1716 + /// See [MessageEntity.threadOrder].
1717 + static final threadOrder = obx.QueryIntegerProperty<MessageEntity>(
1718 + _entities[1].properties[21],
1719 + );
1720 +
1721 + /// See [MessageEntity.isActiveVariant].
1722 + static final isActiveVariant = obx.QueryBooleanProperty<MessageEntity>(
1723 + _entities[1].properties[22],
1724 + );
1725 +
1726 + /// See [MessageEntity.parentMessageId].
1727 + static final parentMessageId = obx.QueryStringProperty<MessageEntity>(
1728 + _entities[1].properties[23],
1729 + );
1730 }
1731
1732 /// [PersonaEntity] entity fields to define ObjectBox queries.
@@ -1362,4 +1848,118 @@ class ServerEntity_ {
1848 static final iconName = obx.QueryStringProperty<ServerEntity>(
1849 _entities[3].properties[11],
1850 );
1851 +
1852 + /// See [ServerEntity.pathPrefix].
1853 + static final pathPrefix = obx.QueryStringProperty<ServerEntity>(
1854 + _entities[3].properties[12],
1855 + );
1856 +}
1857 +
1858 +/// [ConversationFolderEntity] entity fields to define ObjectBox queries.
1859 +class ConversationFolderEntity_ {
1860 + /// See [ConversationFolderEntity.internalId].
1861 + static final internalId = obx.QueryIntegerProperty<ConversationFolderEntity>(
1862 + _entities[4].properties[0],
1863 + );
1864 +
1865 + /// See [ConversationFolderEntity.id].
1866 + static final id = obx.QueryStringProperty<ConversationFolderEntity>(
1867 + _entities[4].properties[1],
1868 + );
1869 +
1870 + /// See [ConversationFolderEntity.name].
1871 + static final name = obx.QueryStringProperty<ConversationFolderEntity>(
1872 + _entities[4].properties[2],
1873 + );
1874 +
1875 + /// See [ConversationFolderEntity.sortOrder].
1876 + static final sortOrder = obx.QueryIntegerProperty<ConversationFolderEntity>(
1877 + _entities[4].properties[3],
1878 + );
1879 +
1880 + /// See [ConversationFolderEntity.createdAt].
1881 + static final createdAt = obx.QueryDateProperty<ConversationFolderEntity>(
1882 + _entities[4].properties[4],
1883 + );
1884 +}
1885 +
1886 +/// [SavedMessageEntity] entity fields to define ObjectBox queries.
1887 +class SavedMessageEntity_ {
1888 + /// See [SavedMessageEntity.internalId].
1889 + static final internalId = obx.QueryIntegerProperty<SavedMessageEntity>(
1890 + _entities[5].properties[0],
1891 + );
1892 +
1893 + /// See [SavedMessageEntity.id].
1894 + static final id = obx.QueryStringProperty<SavedMessageEntity>(
1895 + _entities[5].properties[1],
1896 + );
1897 +
1898 + /// See [SavedMessageEntity.sourceMessageId].
1899 + static final sourceMessageId = obx.QueryStringProperty<SavedMessageEntity>(
1900 + _entities[5].properties[2],
1901 + );
1902 +
1903 + /// See [SavedMessageEntity.conversationId].
1904 + static final conversationId = obx.QueryStringProperty<SavedMessageEntity>(
1905 + _entities[5].properties[3],
1906 + );
1907 +
1908 + /// See [SavedMessageEntity.conversationTitle].
1909 + static final conversationTitle = obx.QueryStringProperty<SavedMessageEntity>(
1910 + _entities[5].properties[4],
1911 + );
1912 +
1913 + /// See [SavedMessageEntity.roleIndex].
1914 + static final roleIndex = obx.QueryIntegerProperty<SavedMessageEntity>(
1915 + _entities[5].properties[5],
1916 + );
1917 +
1918 + /// See [SavedMessageEntity.content].
1919 + static final content = obx.QueryStringProperty<SavedMessageEntity>(
1920 + _entities[5].properties[6],
1921 + );
1922 +
1923 + /// See [SavedMessageEntity.modelId].
1924 + static final modelId = obx.QueryStringProperty<SavedMessageEntity>(
1925 + _entities[5].properties[7],
1926 + );
1927 +
1928 + /// See [SavedMessageEntity.folderId].
1929 + static final folderId = obx.QueryStringProperty<SavedMessageEntity>(
1930 + _entities[5].properties[8],
1931 + );
1932 +
1933 + /// See [SavedMessageEntity.savedAt].
1934 + static final savedAt = obx.QueryDateProperty<SavedMessageEntity>(
1935 + _entities[5].properties[9],
1936 + );
1937 +}
1938 +
1939 +/// [SavedMessageFolderEntity] entity fields to define ObjectBox queries.
1940 +class SavedMessageFolderEntity_ {
1941 + /// See [SavedMessageFolderEntity.internalId].
1942 + static final internalId = obx.QueryIntegerProperty<SavedMessageFolderEntity>(
1943 + _entities[6].properties[0],
1944 + );
1945 +
1946 + /// See [SavedMessageFolderEntity.id].
1947 + static final id = obx.QueryStringProperty<SavedMessageFolderEntity>(
1948 + _entities[6].properties[1],
1949 + );
1950 +
1951 + /// See [SavedMessageFolderEntity.name].
1952 + static final name = obx.QueryStringProperty<SavedMessageFolderEntity>(
1953 + _entities[6].properties[2],
1954 + );
1955 +
1956 + /// See [SavedMessageFolderEntity.sortOrder].
1957 + static final sortOrder = obx.QueryIntegerProperty<SavedMessageFolderEntity>(
1958 + _entities[6].properties[3],
1959 + );
1960 +
1961 + /// See [SavedMessageFolderEntity.createdAt].
1962 + static final createdAt = obx.QueryDateProperty<SavedMessageFolderEntity>(
1963 + _entities[6].properties[4],
1964 + );
1965 }