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
}