main
dart 937 lines 32.8 KB
Raw
1 import 'dart:convert';
2
3 import 'package:archive/archive.dart';
4 import 'package:path/path.dart' as p;
5 import 'package:path_provider/path_provider.dart';
6 import 'package:shared_preferences/shared_preferences.dart';
7
8 import '../models/enums.dart';
9 import '../storage/entities.dart';
10 import 'data_backup_import_helpers.dart';
11 import '../../features/chat/data/models/message.dart';
12 import '../../features/chat/data/tools/tool_event.dart';
13 import '../../features/conversations/data/models/conversation.dart';
14 import '../../features/personas/data/models/persona.dart';
15 import '../../features/servers/data/models/server.dart';
16 import '../../objectbox.g.dart';
17
18 class DataBackupService {
19 static const _synchronizedSettingKeys = {
20 'temperature',
21 'topP',
22 'maxTokens',
23 'contextLength',
24 'themeMode',
25 'fontSize',
26 'showSystemMessages',
27 'hapticFeedbackEnabled',
28 'sendOnEnter',
29 'showDataIndicator',
30 'autoGenerateTitle',
31 'streamingEnabled',
32 'defaultPersonaId',
33 'defaultModelId',
34 'defaultModelServerId',
35 'mcpEnabled',
36 'newChatMcpEnabled',
37 'codeThemeDark',
38 'codeThemeLight',
39 'preferredBackend',
40 'ttsEngine',
41 'ttsVoiceId',
42 'ttsSpeed',
43 'kittenTtsModelVariant',
44 'autoSpeakEnabled',
45 'conciseVoiceResponsesEnabled',
46 'ttsProcessMarkdown',
47 'ttsSkipSeconds',
48 'smartReplyEnabled',
49 'aiUserResponseEnabled',
50 'localeCode',
51 'unloadModelsBeforeLoad',
52 'tempChatKeyboardIncognito',
53 'resumeLastChat',
54 'imageCompressionEnabled',
55 'imageCompressionLevel',
56 'smartRepliesUsePersona',
57 'keepPersonaOnNewChat',
58 'roleSwapButtonEnabled',
59 'showSystemMessagesInChat',
60 };
61
62 Map<String, dynamic> _messageToMap(Message message) => {
63 'id': message.id,
64 'conversationId': message.conversationId,
65 'role': message.role.name,
66 'content': message.content,
67 'createdAt': message.createdAt.toIso8601String(),
68 'status': message.status.name,
69 'modelId': message.modelId,
70 'tokenCount': message.tokenCount,
71 'inputTokenCount': message.inputTokenCount,
72 'tokensPerSecond': message.tokensPerSecond,
73 'ttftMs': message.ttftMs,
74 'stopReason': message.stopReason,
75 'errorMessage': message.errorMessage,
76 'attachmentPaths': message.attachmentPaths,
77 'generationTimeMs': message.generationTimeMs,
78 'reasoningContent': message.reasoningContent,
79 'toolCalls': message.toolCalls?.map((e) => e.toMap()).toList(),
80 'toolCallId': message.toolCallId,
81 'isProcessing': message.isProcessing,
82 'toolSessionId': message.toolSessionId,
83 'toolEvents': message.toolEvents?.map((e) => e.toMap()).toList(),
84 'variantGroupId': message.variantGroupId,
85 'variantIndex': message.variantIndex,
86 'threadOrder': message.threadOrder,
87 'isActiveVariant': message.isActiveVariant,
88 'parentMessageId': message.parentMessageId,
89 'contentTokenCount': message.contentTokenCount,
90 };
91
92 Map<String, dynamic> _conversationToMap(Conversation domain) => {
93 'id': domain.id,
94 'title': domain.title,
95 'createdAt': domain.createdAt.toIso8601String(),
96 'updatedAt': domain.updatedAt.toIso8601String(),
97 'isPinned': domain.isPinned,
98 'personaId': domain.personaId,
99 'serverId': domain.serverId,
100 'modelId': domain.modelId,
101 'messageCount': domain.messageCount,
102 'lastMessagePreview': domain.lastMessagePreview,
103 'systemPrompt': domain.systemPrompt,
104 'temperature': domain.temperature,
105 'topP': domain.topP,
106 'maxTokens': domain.maxTokens,
107 'contextLength': domain.contextLength,
108 'mcpEnabled': domain.mcpEnabled,
109 'smartReplies': domain.smartReplies,
110 'smartRepliesLastMessageId': domain.smartRepliesLastMessageId,
111 'folderId': domain.folderId,
112 'isTemporary': domain.isTemporary,
113 'isArchived': domain.isArchived,
114 'characterCount': domain.characterCount,
115 'totalTokenCount': domain.totalTokenCount,
116 };
117
118 List<Map<String, dynamic>> _exportSavedMessages(Store store) {
119 final box = store.box<SavedMessageEntity>();
120 return box.getAll().map((e) {
121 return {
122 'id': e.id,
123 'sourceMessageId': e.sourceMessageId,
124 'conversationId': e.conversationId,
125 'conversationTitle': e.conversationTitle,
126 'roleIndex': e.roleIndex,
127 'content': e.content,
128 'modelId': e.modelId,
129 'folderId': e.folderId,
130 'isArchived': e.isArchived,
131 'savedAt': e.savedAt.toIso8601String(),
132 };
133 }).toList();
134 }
135
136 List<Map<String, dynamic>> _exportConversationFolders(Store store) {
137 final box = store.box<ConversationFolderEntity>();
138 return box.getAll().map((e) {
139 return {
140 'id': e.id,
141 'name': e.name,
142 'sortOrder': e.sortOrder,
143 'createdAt': e.createdAt.toIso8601String(),
144 };
145 }).toList();
146 }
147
148 List<Map<String, dynamic>> _conversationFoldersForConversationIds(
149 Map<String, dynamic> all,
150 Set<String> conversationIds,
151 ) {
152 final folderIds = (all['conversations'] as List)
153 .where((c) => conversationIds.contains((c as Map)['id']))
154 .map((c) => (c as Map)['folderId'] as String?)
155 .whereType<String>()
156 .toSet();
157 return (all['conversationFolders'] as List)
158 .where((f) => folderIds.contains((f as Map)['id']))
159 .toList()
160 .cast<Map<String, dynamic>>();
161 }
162
163 List<Map<String, dynamic>> _exportSavedMessageFolders(Store store) {
164 final box = store.box<SavedMessageFolderEntity>();
165 return box.getAll().map((e) {
166 return {
167 'id': e.id,
168 'name': e.name,
169 'sortOrder': e.sortOrder,
170 'createdAt': e.createdAt.toIso8601String(),
171 };
172 }).toList();
173 }
174
175 List<Map<String, dynamic>> _exportServers(Store store) {
176 return store.box<ServerEntity>().getAll().map((entity) {
177 final server = entity.toDomain();
178 return {
179 'id': server.id,
180 'name': server.name,
181 'type': server.type.name,
182 'host': server.host,
183 'port': server.port,
184 'apiKey': server.apiKey,
185 'isDefault': server.isDefault,
186 'createdAt': server.createdAt.toIso8601String(),
187 'lastConnectedAt': server.lastConnectedAt.toIso8601String(),
188 'status': server.status.name,
189 'iconName': server.iconName,
190 'pathPrefix': server.pathPrefix,
191 };
192 }).toList();
193 }
194
195 Map<String, dynamic>? _readModelMetadata(SharedPreferences? prefs) {
196 if (prefs == null) return null;
197 final raw = prefs.getString('modelMetadata');
198 if (raw == null || raw.isEmpty) return null;
199 try {
200 return Map<String, dynamic>.from(jsonDecode(raw) as Map);
201 } catch (_) {
202 return null;
203 }
204 }
205
206 Map<String, dynamic> exportAll(Store store, {SharedPreferences? prefs}) {
207 final convBox = store.box<ConversationEntity>();
208 final messageBox = store.box<MessageEntity>();
209 final personaBox = store.box<PersonaEntity>();
210
211 final conversations = convBox
212 .getAll()
213 .map((e) => _conversationToMap(e.toDomain()))
214 .toList();
215 final messages = messageBox
216 .getAll()
217 .map((e) => _messageToMap(e.toDomain()))
218 .toList();
219
220 final personas = personaBox
221 .getAll()
222 .where((entity) => !entity.isBuiltIn)
223 .map((entity) {
224 final persona = entity.toDomain();
225 return {
226 'id': persona.id,
227 'name': persona.name,
228 'emoji': persona.emoji,
229 'systemPrompt': persona.systemPrompt,
230 'description': persona.description,
231 'isBuiltIn': persona.isBuiltIn,
232 'createdAt': persona.createdAt.toIso8601String(),
233 'updatedAt': persona.updatedAt.toIso8601String(),
234 'category': persona.category,
235 'preferredParams': persona.preferredParams,
236 };
237 })
238 .toList();
239
240 final modelMetadata = _readModelMetadata(prefs);
241 return {
242 'version': 3,
243 'exportedAt': DateTime.now().toIso8601String(),
244 'conversations': conversations,
245 'messages': messages,
246 'personas': personas,
247 'savedMessages': _exportSavedMessages(store),
248 'savedMessageFolders': _exportSavedMessageFolders(store),
249 'servers': _exportServers(store),
250 'conversationFolders': _exportConversationFolders(store),
251 'modelMetadata': modelMetadata,
252 };
253 }
254
255 Map<String, dynamic> exportCloudSync(Store store, String settingsJson) {
256 final all = exportAll(store);
257 final persistentConversations = (all['conversations'] as List)
258 .where((item) => (item as Map)['isTemporary'] != true)
259 .cast<Map<String, dynamic>>()
260 .toList();
261 final conversationIds = persistentConversations
262 .map((item) => item['id'] as String)
263 .toSet();
264 final allSettings = Map<String, dynamic>.from(
265 jsonDecode(settingsJson) as Map,
266 );
267 final settings = <String, dynamic>{
268 for (final entry in allSettings.entries)
269 if (_synchronizedSettingKeys.contains(entry.key))
270 entry.key: entry.value,
271 };
272 return {
273 'version': 1,
274 'type': 'cloudSync',
275 'settings': settings,
276 'conversations': persistentConversations,
277 'messages': (all['messages'] as List)
278 .where(
279 (item) => conversationIds.contains((item as Map)['conversationId']),
280 )
281 .toList(),
282 'personas': all['personas'],
283 'savedMessages': all['savedMessages'],
284 'savedMessageFolders': all['savedMessageFolders'],
285 'conversationFolders': all['conversationFolders'],
286 };
287 }
288
289 Future<void> importCloudSync(
290 Store store,
291 Map<String, dynamic> payload,
292 ) async {
293 _validateCloudPayload(payload);
294 await importFromJson(store, jsonEncode(payload), replaceCloudData: true);
295 }
296
297 void _validateCloudPayload(Map<String, dynamic> payload) {
298 const collections = {
299 'conversations',
300 'messages',
301 'personas',
302 'savedMessages',
303 'savedMessageFolders',
304 'conversationFolders',
305 };
306 if (payload['version'] != 1 ||
307 payload['type'] != 'cloudSync' ||
308 payload['settings'] is! Map ||
309 !(payload['settings'] as Map).keys.every(
310 (key) => key is String && _synchronizedSettingKeys.contains(key),
311 )) {
312 throw const FormatException('Invalid cloud sync payload');
313 }
314 for (final collection in collections) {
315 final records = payload[collection];
316 if (records is! List ||
317 !records.every(
318 (record) =>
319 record is Map &&
320 record['id'] is String &&
321 (record['id'] as String).isNotEmpty,
322 )) {
323 throw FormatException('Invalid cloud sync collection: $collection');
324 }
325 }
326 if ((payload['conversations'] as List).any(
327 (record) => (record as Map)['isTemporary'] == true,
328 ) ||
329 (payload['personas'] as List).any(
330 (record) => (record as Map)['isBuiltIn'] == true,
331 )) {
332 throw const FormatException('Cloud payload contains excluded records');
333 }
334 }
335
336 Map<String, dynamic> exportConversations(Store store) {
337 final all = exportAll(store);
338 return {
339 'version': 3,
340 'type': 'conversations',
341 'exportedAt': all['exportedAt'],
342 'conversations': all['conversations'],
343 'messages': all['messages'],
344 'savedMessages': all['savedMessages'],
345 'savedMessageFolders': all['savedMessageFolders'],
346 'conversationFolders': all['conversationFolders'],
347 };
348 }
349
350 Map<String, dynamic> exportConversation(Store store, String conversationId) {
351 final all = exportAll(store);
352 final conversations = (all['conversations'] as List)
353 .where((c) => (c as Map)['id'] == conversationId)
354 .toList();
355 final messages = (all['messages'] as List)
356 .where((m) => (m as Map)['conversationId'] == conversationId)
357 .toList();
358 final savedMessages = (all['savedMessages'] as List)
359 .where((m) => (m as Map)['conversationId'] == conversationId)
360 .toList();
361 final folderIds = savedMessages
362 .map((m) => (m as Map)['folderId'] as String?)
363 .whereType<String>()
364 .toSet();
365 final savedMessageFolders = (all['savedMessageFolders'] as List)
366 .where((f) => folderIds.contains((f as Map)['id']))
367 .toList();
368 final conversationFolders = _conversationFoldersForConversationIds(all, {
369 conversationId,
370 });
371
372 return {
373 'version': 3,
374 'type': 'conversation',
375 'exportedAt': all['exportedAt'],
376 'conversations': conversations,
377 'messages': messages,
378 'savedMessages': savedMessages,
379 'savedMessageFolders': savedMessageFolders,
380 'conversationFolders': conversationFolders,
381 };
382 }
383
384 Map<String, dynamic> exportConversationsForIds(
385 Store store,
386 Set<String> conversationIds,
387 ) {
388 final all = exportAll(store);
389 final conversations = (all['conversations'] as List)
390 .where((c) => conversationIds.contains((c as Map)['id']))
391 .toList();
392 final messages = (all['messages'] as List)
393 .where((m) => conversationIds.contains((m as Map)['conversationId']))
394 .toList();
395 final savedMessages = (all['savedMessages'] as List)
396 .where((m) => conversationIds.contains((m as Map)['conversationId']))
397 .toList();
398 final folderIds = savedMessages
399 .map((m) => (m as Map)['folderId'] as String?)
400 .whereType<String>()
401 .toSet();
402 final savedMessageFolders = (all['savedMessageFolders'] as List)
403 .where((f) => folderIds.contains((f as Map)['id']))
404 .toList();
405 final conversationFolders = _conversationFoldersForConversationIds(
406 all,
407 conversationIds,
408 );
409
410 return {
411 'version': 3,
412 'type': 'conversations',
413 'exportedAt': all['exportedAt'],
414 'conversations': conversations,
415 'messages': messages,
416 'savedMessages': savedMessages,
417 'savedMessageFolders': savedMessageFolders,
418 'conversationFolders': conversationFolders,
419 };
420 }
421
422 String exportConversationsForIdsAsJson(
423 Store store,
424 Set<String> conversationIds,
425 ) => const JsonEncoder.withIndent(
426 ' ',
427 ).convert(exportConversationsForIds(store, conversationIds));
428
429 Map<String, dynamic> exportPersonas(Store store) {
430 final all = exportAll(store);
431 return {
432 'version': 3,
433 'type': 'personas',
434 'exportedAt': all['exportedAt'],
435 'personas': all['personas'],
436 };
437 }
438
439 Map<String, dynamic> exportSettings(
440 String settingsJson, {
441 Store? store,
442 SharedPreferences? prefs,
443 }) {
444 final map = <String, dynamic>{
445 'version': 3,
446 'type': 'settings',
447 'exportedAt': DateTime.now().toIso8601String(),
448 'settings': jsonDecode(settingsJson),
449 };
450 if (store != null) {
451 map['servers'] = _exportServers(store);
452 }
453 final metadata = _readModelMetadata(prefs);
454 if (metadata != null) {
455 map['modelMetadata'] = metadata;
456 }
457 return map;
458 }
459
460 String exportConversationsAsJson(Store store) =>
461 const JsonEncoder.withIndent(' ').convert(exportConversations(store));
462
463 String exportConversationAsJson(Store store, String conversationId) =>
464 const JsonEncoder.withIndent(
465 ' ',
466 ).convert(exportConversation(store, conversationId));
467
468 String exportPersonasAsJson(Store store) =>
469 const JsonEncoder.withIndent(' ').convert(exportPersonas(store));
470
471 String exportSettingsAsJson(
472 String settingsJson, {
473 Store? store,
474 SharedPreferences? prefs,
475 }) => const JsonEncoder.withIndent(
476 ' ',
477 ).convert(exportSettings(settingsJson, store: store, prefs: prefs));
478
479 ArchiveFile _jsonArchiveFile(String name, String json) {
480 final bytes = utf8.encode(json);
481 return ArchiveFile(name, bytes.length, bytes);
482 }
483
484 List<int> exportAllZip(
485 Store store,
486 String settingsJson, {
487 SharedPreferences? prefs,
488 }) {
489 final archive = Archive()
490 ..addFile(
491 _jsonArchiveFile(
492 'conversations.json',
493 exportConversationsAsJson(store),
494 ),
495 )
496 ..addFile(_jsonArchiveFile('personas.json', exportPersonasAsJson(store)))
497 ..addFile(
498 _jsonArchiveFile(
499 'settings.json',
500 exportSettingsAsJson(settingsJson, store: store, prefs: prefs),
501 ),
502 );
503 return ZipEncoder().encode(archive);
504 }
505
506 String exportAllAsJson(Store store, {SharedPreferences? prefs}) {
507 return const JsonEncoder.withIndent(
508 ' ',
509 ).convert(exportAll(store, prefs: prefs));
510 }
511
512 Future<void> importFromJson(
513 Store store,
514 String jsonString, {
515 bool replaceCloudData = false,
516 }) async {
517 final decoded = jsonDecode(jsonString);
518 if (decoded is! Map<String, dynamic>) {
519 throw FormatException('Invalid backup format');
520 }
521
522 // Imported attachment paths are restored verbatim onto Message records
523 // and later opened directly as files (image/text preview), so a
524 // crafted backup could otherwise point them at arbitrary files already
525 // readable by this app. Only accept paths that resolve inside our own
526 // attachments directory.
527 final appDir = await getApplicationDocumentsDirectory();
528 final attachmentsDir = p.normalize(p.join(appDir.path, 'attachments'));
529
530 await store.runInTransactionAsync(TxMode.write, (store, data) {
531 final (decoded, attachmentsDir, replaceCloudData) = data;
532 final convBox = store.box<ConversationEntity>();
533 final messageBox = store.box<MessageEntity>();
534 final personaBox = store.box<PersonaEntity>();
535 final serverBox = store.box<ServerEntity>();
536 final savedBox = store.box<SavedMessageEntity>();
537 final savedFolderBox = store.box<SavedMessageFolderEntity>();
538 final conversationFolderBox = store.box<ConversationFolderEntity>();
539
540 final personas = decoded['personas'];
541 if (personas is List) {
542 for (final item in personas) {
543 if (item is! Map) continue;
544 final id = backupImportString(item['id']);
545 final name = backupImportString(item['name']);
546 final createdAt = backupImportDateTime(item['createdAt']);
547 final updatedAt = backupImportDateTime(item['updatedAt']);
548 if (id == null ||
549 name == null ||
550 createdAt == null ||
551 updatedAt == null) {
552 continue;
553 }
554 final persona = Persona(
555 id: id,
556 name: name,
557 emoji: item['emoji'] as String? ?? '🤖',
558 systemPrompt: item['systemPrompt'] as String? ?? '',
559 description: item['description'] as String?,
560 isBuiltIn: item['isBuiltIn'] as bool? ?? false,
561 createdAt: createdAt,
562 updatedAt: updatedAt,
563 category: item['category'] as String?,
564 preferredParams: item['preferredParams'] is Map
565 ? Map<String, dynamic>.from(item['preferredParams'] as Map)
566 : null,
567 );
568 final query = personaBox
569 .query(PersonaEntity_.id.equals(persona.id))
570 .build();
571 final existing = query.findFirst();
572 query.close();
573 final entity = PersonaEntity.fromDomain(persona);
574 if (existing != null) entity.internalId = existing.internalId;
575 personaBox.put(entity);
576 }
577 }
578
579 final servers = decoded['servers'];
580 if (servers is List) {
581 for (final item in servers) {
582 if (item is! Map) continue;
583 final id = backupImportString(item['id']);
584 final name = backupImportString(item['name']);
585 final createdAt = backupImportDateTime(item['createdAt']);
586 final lastConnectedAt = backupImportDateTime(item['lastConnectedAt']);
587 if (id == null ||
588 name == null ||
589 createdAt == null ||
590 lastConnectedAt == null) {
591 continue;
592 }
593 final server = Server(
594 id: id,
595 name: name,
596 type: ServerType.values.byName(
597 item['type'] as String? ?? 'lmStudio',
598 ),
599 host: item['host'] as String? ?? 'localhost',
600 port: item['port'] as int? ?? 1234,
601 apiKey: item['apiKey'] as String?,
602 isDefault: item['isDefault'] as bool? ?? false,
603 createdAt: createdAt,
604 lastConnectedAt: lastConnectedAt,
605 status: ConnectionStatus.values.byName(
606 item['status'] as String? ?? 'disconnected',
607 ),
608 iconName: item['iconName'] as String?,
609 pathPrefix: item['pathPrefix'] as String?,
610 );
611 final query = serverBox
612 .query(ServerEntity_.id.equals(server.id))
613 .build();
614 final existing = query.findFirst();
615 query.close();
616 final entity = ServerEntity.fromDomain(server);
617 if (existing != null) entity.internalId = existing.internalId;
618 serverBox.put(entity);
619 }
620 }
621
622 final savedFolders = decoded['savedMessageFolders'];
623 if (savedFolders is List) {
624 for (final item in savedFolders) {
625 if (item is! Map) continue;
626 final id = backupImportString(item['id']);
627 final folderName = backupImportString(item['name']);
628 final createdAt = backupImportDateTime(item['createdAt']);
629 if (id == null || folderName == null || createdAt == null) continue;
630 final entity = SavedMessageFolderEntity(
631 id: id,
632 name: folderName,
633 sortOrder: item['sortOrder'] as int? ?? 0,
634 createdAt: createdAt,
635 );
636 final query = savedFolderBox
637 .query(SavedMessageFolderEntity_.id.equals(entity.id))
638 .build();
639 final existing = query.findFirst();
640 query.close();
641 if (existing != null) entity.internalId = existing.internalId;
642 savedFolderBox.put(entity);
643 }
644 }
645
646 final savedMessages = decoded['savedMessages'];
647 if (savedMessages is List) {
648 for (final item in savedMessages) {
649 if (item is! Map) continue;
650 final id = backupImportString(item['id']);
651 final sourceMessageId = backupImportString(item['sourceMessageId']);
652 final conversationId = backupImportString(item['conversationId']);
653 final savedAt = backupImportDateTime(item['savedAt']);
654 if (id == null ||
655 sourceMessageId == null ||
656 conversationId == null ||
657 savedAt == null) {
658 continue;
659 }
660 final entity = SavedMessageEntity(
661 id: id,
662 sourceMessageId: sourceMessageId,
663 conversationId: conversationId,
664 conversationTitle: item['conversationTitle'] as String? ?? 'Chat',
665 roleIndex: item['roleIndex'] as int? ?? 0,
666 content: item['content'] as String? ?? '',
667 modelId: item['modelId'] as String?,
668 folderId: item['folderId'] as String?,
669 isArchived: item['isArchived'] as bool? ?? false,
670 savedAt: savedAt,
671 );
672 final query = savedBox
673 .query(SavedMessageEntity_.id.equals(entity.id))
674 .build();
675 final existing = query.findFirst();
676 query.close();
677 if (existing != null) entity.internalId = existing.internalId;
678 savedBox.put(entity);
679 }
680 }
681
682 final conversationFolders = decoded['conversationFolders'];
683 if (conversationFolders is List) {
684 for (final item in conversationFolders) {
685 if (item is! Map) continue;
686 final id = backupImportString(item['id']);
687 final folderName = backupImportString(item['name']);
688 final createdAt = backupImportDateTime(item['createdAt']);
689 if (id == null || folderName == null || createdAt == null) continue;
690 final entity = ConversationFolderEntity(
691 id: id,
692 name: folderName,
693 sortOrder: item['sortOrder'] as int? ?? 0,
694 createdAt: createdAt,
695 );
696 final query = conversationFolderBox
697 .query(ConversationFolderEntity_.id.equals(entity.id))
698 .build();
699 final existing = query.findFirst();
700 query.close();
701 if (existing != null) entity.internalId = existing.internalId;
702 conversationFolderBox.put(entity);
703 }
704 }
705
706 final conversations = decoded['conversations'];
707 if (conversations is List) {
708 for (final item in conversations) {
709 if (item is! Map) continue;
710 final id = backupImportString(item['id']);
711 final createdAt = backupImportDateTime(item['createdAt']);
712 final updatedAt = backupImportDateTime(item['updatedAt']);
713 if (id == null || createdAt == null || updatedAt == null) continue;
714 final conversation = Conversation(
715 id: id,
716 title: item['title'] as String? ?? 'Imported Chat',
717 createdAt: createdAt,
718 updatedAt: updatedAt,
719 isPinned: item['isPinned'] as bool? ?? false,
720 personaId: item['personaId'] as String?,
721 serverId: item['serverId'] as String?,
722 modelId: item['modelId'] as String?,
723 messageCount: item['messageCount'] as int? ?? 0,
724 lastMessagePreview: item['lastMessagePreview'] as String?,
725 systemPrompt: item['systemPrompt'] as String?,
726 temperature: (item['temperature'] as num?)?.toDouble(),
727 topP: (item['topP'] as num?)?.toDouble(),
728 maxTokens: item['maxTokens'] as int?,
729 contextLength: item['contextLength'] as int?,
730 mcpEnabled: item['mcpEnabled'] as bool?,
731 smartReplies: item['smartReplies'] is List
732 ? List<String>.from(item['smartReplies'] as List)
733 : null,
734 smartRepliesLastMessageId:
735 item['smartRepliesLastMessageId'] as String?,
736 folderId: item['folderId'] as String?,
737 isTemporary: item['isTemporary'] as bool? ?? false,
738 isArchived: item['isArchived'] as bool? ?? false,
739 characterCount: item['characterCount'] as int? ?? 0,
740 totalTokenCount: item['totalTokenCount'] as int?,
741 );
742 final query = convBox
743 .query(ConversationEntity_.id.equals(conversation.id))
744 .build();
745 final existing = query.findFirst();
746 query.close();
747 final entity = ConversationEntity.fromDomain(conversation);
748 if (existing != null) entity.internalId = existing.internalId;
749 convBox.put(entity);
750 }
751 }
752
753 final messages = decoded['messages'];
754 if (messages is List) {
755 for (final item in messages) {
756 if (item is! Map) continue;
757 final conversationId = backupImportString(item['conversationId']);
758 final id = backupImportString(item['id']);
759 final createdAt = backupImportDateTime(item['createdAt']);
760 if (conversationId == null || id == null || createdAt == null) {
761 continue;
762 }
763 final convQuery = convBox
764 .query(ConversationEntity_.id.equals(conversationId))
765 .build();
766 final convEntity = convQuery.findFirst();
767 convQuery.close();
768 if (convEntity == null) continue;
769
770 final message = Message(
771 id: id,
772 conversationId: conversationId,
773 role: MessageRole.values.byName(item['role'] as String? ?? 'user'),
774 content: item['content'] as String? ?? '',
775 createdAt: createdAt,
776 status: MessageStatus.values.byName(
777 item['status'] as String? ?? 'complete',
778 ),
779 modelId: item['modelId'] as String?,
780 tokenCount: item['tokenCount'] as int?,
781 inputTokenCount: item['inputTokenCount'] as int?,
782 tokensPerSecond: (item['tokensPerSecond'] as num?)?.toDouble(),
783 ttftMs: item['ttftMs'] as int?,
784 stopReason: item['stopReason'] as String?,
785 errorMessage: item['errorMessage'] as String?,
786 attachmentPaths: _sanitizedAttachmentPaths(
787 item['attachmentPaths'],
788 attachmentsDir,
789 ),
790 generationTimeMs: item['generationTimeMs'] as int?,
791 reasoningContent: item['reasoningContent'] as String?,
792 toolCalls: item['toolCalls'] is List
793 ? (item['toolCalls'] as List)
794 .whereType<Map>()
795 .map(
796 (e) =>
797 ToolCallData.fromMap(Map<String, dynamic>.from(e)),
798 )
799 .toList()
800 : null,
801 toolCallId: item['toolCallId'] as String?,
802 isProcessing: item['isProcessing'] as bool? ?? false,
803 toolSessionId: item['toolSessionId'] as String?,
804 toolEvents: item['toolEvents'] is List
805 ? (item['toolEvents'] as List)
806 .whereType<Map>()
807 .map(
808 (e) => ToolEvent.fromMap(Map<String, dynamic>.from(e)),
809 )
810 .toList()
811 : null,
812 variantGroupId: item['variantGroupId'] as String?,
813 variantIndex: item['variantIndex'] as int? ?? 0,
814 threadOrder: item['threadOrder'] as int? ?? 0,
815 isActiveVariant: item['isActiveVariant'] as bool? ?? true,
816 parentMessageId: item['parentMessageId'] as String?,
817 contentTokenCount: item['contentTokenCount'] as int?,
818 );
819
820 final entity = MessageEntity.fromDomain(message)
821 ..conversation.target = convEntity;
822 final query = messageBox
823 .query(MessageEntity_.id.equals(message.id))
824 .build();
825 final existing = query.findFirst();
826 query.close();
827 if (existing != null) entity.internalId = existing.internalId;
828 messageBox.put(entity);
829 }
830 }
831
832 if (replaceCloudData) {
833 Set<String> ids(dynamic records) => records is List
834 ? records
835 .whereType<Map>()
836 .map((item) => item['id'])
837 .whereType<String>()
838 .toSet()
839 : <String>{};
840
841 void removeMissing<T>(
842 Box<T> box,
843 Set<String> expected,
844 String Function(T) idOf,
845 int Function(T) internalIdOf,
846 ) {
847 box.removeMany(
848 box
849 .getAll()
850 .where((item) => !expected.contains(idOf(item)))
851 .map(internalIdOf)
852 .toList(),
853 );
854 }
855
856 final conversationIds = ids(decoded['conversations']);
857 final messageIds = ids(decoded['messages']);
858 final personaIds = ids(decoded['personas']);
859 final temporaryConversationIds = convBox
860 .getAll()
861 .where((item) => item.isTemporary)
862 .map((item) => item.id)
863 .toSet();
864 convBox.removeMany(
865 convBox
866 .getAll()
867 .where((item) => !item.isTemporary)
868 .where((item) => !conversationIds.contains(item.id))
869 .map((item) => item.internalId)
870 .toList(),
871 );
872 messageBox.removeMany(
873 messageBox
874 .getAll()
875 .where((item) => !messageIds.contains(item.id))
876 .where(
877 (item) =>
878 !temporaryConversationIds.contains(item.conversationUid),
879 )
880 .map((item) => item.internalId)
881 .toList(),
882 );
883 personaBox.removeMany(
884 personaBox
885 .getAll()
886 .where((item) => !item.isBuiltIn && !personaIds.contains(item.id))
887 .map((item) => item.internalId)
888 .toList(),
889 );
890 removeMissing<SavedMessageEntity>(
891 savedBox,
892 ids(decoded['savedMessages']),
893 (item) => item.id,
894 (item) => item.internalId,
895 );
896 removeMissing<SavedMessageFolderEntity>(
897 savedFolderBox,
898 ids(decoded['savedMessageFolders']),
899 (item) => item.id,
900 (item) => item.internalId,
901 );
902 removeMissing<ConversationFolderEntity>(
903 conversationFolderBox,
904 ids(decoded['conversationFolders']),
905 (item) => item.id,
906 (item) => item.internalId,
907 );
908 }
909 }, (decoded, attachmentsDir, replaceCloudData));
910 }
911
912 List<String>? _sanitizedAttachmentPaths(dynamic raw, String attachmentsDir) {
913 if (raw is! List) return null;
914 final sanitized = <String>[];
915 for (final entry in raw) {
916 if (entry is! String || entry.isEmpty) continue;
917 final normalized = p.normalize(entry);
918 if (normalized == attachmentsDir ||
919 p.isWithin(attachmentsDir, normalized)) {
920 sanitized.add(entry);
921 }
922 }
923 return sanitized.isEmpty ? null : sanitized;
924 }
925
926 Future<void> importZip(Store store, List<int> bytes) async {
927 final archive = ZipDecoder().decodeBytes(bytes);
928 for (final file in archive.files) {
929 if (!file.isFile) continue;
930 try {
931 await importFromJson(store, utf8.decode(file.content as List<int>));
932 } catch (_) {
933 // skip corrupted entry
934 }
935 }
936 }
937 }