main
dart 661 lines 16.7 KB
Raw
1 import 'dart:convert';
2 import 'package:objectbox/objectbox.dart';
3 import '../models/enums.dart';
4 import '../../features/chat/data/models/message.dart';
5 import '../../features/chat/data/tools/tool_event.dart';
6 import '../../features/conversations/data/models/conversation.dart';
7 import '../../features/personas/data/models/persona.dart';
8 import '../../features/servers/data/models/server.dart';
9
10 @Entity()
11 class ServerEntity {
12 @Id()
13 int internalId = 0;
14
15 @Index()
16 String id;
17 String name;
18
19 @Property(type: PropertyType.int)
20 int typeIndex;
21
22 String host;
23 int port;
24 String? apiKey;
25 bool isDefault;
26
27 @Property(type: PropertyType.date)
28 DateTime createdAt;
29
30 @Property(type: PropertyType.date)
31 DateTime lastConnectedAt;
32
33 @Property(type: PropertyType.int)
34 int statusIndex;
35
36 String? iconName;
37 String? pathPrefix;
38 int? availableRamGb;
39 int? availableVramGb;
40
41 ServerEntity({
42 this.internalId = 0,
43 required this.id,
44 required this.name,
45 required this.typeIndex,
46 required this.host,
47 required this.port,
48 this.apiKey,
49 this.isDefault = false,
50 required this.createdAt,
51 required this.lastConnectedAt,
52 required this.statusIndex,
53 this.iconName,
54 this.pathPrefix,
55 this.availableRamGb,
56 this.availableVramGb,
57 });
58
59 factory ServerEntity.fromDomain(Server server) {
60 return ServerEntity(
61 id: server.id,
62 name: server.name,
63 typeIndex: server.type.index,
64 host: server.host,
65 port: server.port,
66 apiKey: server.apiKey,
67 isDefault: server.isDefault,
68 createdAt: server.createdAt,
69 lastConnectedAt: server.lastConnectedAt,
70 statusIndex: server.status.index,
71 iconName: server.iconName,
72 pathPrefix: server.pathPrefix,
73 availableRamGb: server.availableRamGb,
74 availableVramGb: server.availableVramGb,
75 );
76 }
77
78 static ServerType resolveServerType({
79 required int typeIndex,
80 required String id,
81 required String name,
82 required String host,
83 required int port,
84 String? apiKey,
85 }) {
86 final trimmedHost = host.trim();
87 final lowerId = id.trim().toLowerCase();
88 final lowerName = name.trim().toLowerCase();
89
90 // 1. On-Device server detection:
91 // On-device servers always have id == 'on-device' or name == 'On-Device',
92 // or host is empty with port 0 without API key.
93 // Prior to Ollama Cloud being added (commit 51996bb), onDevice had typeIndex 4.
94 // In the updated enum, index 4 is openRouter and index 5 is onDevice.
95 if (lowerId == 'on-device' ||
96 (lowerName == 'on-device' && trimmedHost.isEmpty && port <= 0) ||
97 (trimmedHost.isEmpty &&
98 port <= 0 &&
99 (apiKey == null || apiKey.trim().isEmpty))) {
100 return ServerType.onDevice;
101 }
102
103 // 2. OpenRouter server detection / drift resolution:
104 // Prior to Ollama Cloud being added, openRouter had typeIndex 3.
105 // In the updated enum, index 3 is ollamaCloud and index 4 is openRouter.
106 final isProbablyOpenRouter =
107 trimmedHost.contains('openrouter.ai') ||
108 lowerName.contains('openrouter') ||
109 (apiKey != null && apiKey.trim().startsWith('sk-or'));
110 if (isProbablyOpenRouter) {
111 return ServerType.openRouter;
112 }
113
114 // 3. Ollama Cloud detection:
115 final isProbablyOllamaCloud =
116 trimmedHost.contains('ollama.com') ||
117 lowerName.contains('ollama cloud');
118 if (isProbablyOllamaCloud) {
119 return ServerType.ollamaCloud;
120 }
121
122 // 4. Default bounds-checked enum resolution:
123 if (typeIndex >= 0 && typeIndex < ServerType.values.length) {
124 return ServerType.values[typeIndex];
125 }
126
127 return ServerType.values.first;
128 }
129
130 Server toDomain() {
131 final resolvedType = resolveServerType(
132 typeIndex: typeIndex,
133 id: id,
134 name: name,
135 host: host,
136 port: port,
137 apiKey: apiKey,
138 );
139
140 return Server(
141 id: id,
142 name: name,
143 type: resolvedType,
144 host: host,
145 port: port,
146 apiKey: apiKey,
147 isDefault: isDefault,
148 createdAt: createdAt,
149 lastConnectedAt: lastConnectedAt,
150 status: statusIndex >= 0 && statusIndex < ConnectionStatus.values.length
151 ? ConnectionStatus.values[statusIndex]
152 : ConnectionStatus.values.first,
153 iconName: iconName,
154 pathPrefix: pathPrefix,
155 availableRamGb: availableRamGb,
156 availableVramGb: availableVramGb,
157 );
158 }
159 }
160
161 @Entity()
162 class PersonaEntity {
163 @Id()
164 int internalId = 0;
165
166 @Index()
167 String id;
168 String name;
169 String emoji;
170 String systemPrompt;
171 String? description;
172 bool isBuiltIn;
173
174 @Property(type: PropertyType.date)
175 DateTime createdAt;
176
177 @Property(type: PropertyType.date)
178 DateTime updatedAt;
179
180 String? category;
181 String? preferredParamsJson;
182
183 PersonaEntity({
184 this.internalId = 0,
185 required this.id,
186 required this.name,
187 required this.emoji,
188 required this.systemPrompt,
189 this.description,
190 this.isBuiltIn = false,
191 required this.createdAt,
192 required this.updatedAt,
193 this.category,
194 this.preferredParamsJson,
195 });
196
197 factory PersonaEntity.fromDomain(Persona persona) {
198 return PersonaEntity(
199 id: persona.id,
200 name: persona.name,
201 emoji: persona.emoji,
202 systemPrompt: persona.systemPrompt,
203 description: persona.description,
204 isBuiltIn: persona.isBuiltIn,
205 createdAt: persona.createdAt,
206 updatedAt: persona.updatedAt,
207 category: persona.category,
208 preferredParamsJson: persona.preferredParams != null
209 ? jsonEncode(persona.preferredParams)
210 : null,
211 );
212 }
213
214 Persona toDomain() {
215 return Persona(
216 id: id,
217 name: name,
218 emoji: emoji,
219 systemPrompt: systemPrompt,
220 description: description,
221 isBuiltIn: isBuiltIn,
222 createdAt: createdAt,
223 updatedAt: updatedAt,
224 category: category,
225 preferredParams: preferredParamsJson != null
226 ? () {
227 try {
228 final decoded = jsonDecode(preferredParamsJson!);
229 if (decoded is Map) {
230 return Map<String, dynamic>.from(decoded);
231 }
232 return null;
233 } catch (_) {
234 return null;
235 }
236 }()
237 : null,
238 );
239 }
240 }
241
242 @Entity()
243 class ConversationEntity {
244 @Id()
245 int internalId = 0;
246
247 @Index()
248 String id;
249 String title;
250
251 @Property(type: PropertyType.date)
252 DateTime createdAt;
253
254 @Property(type: PropertyType.date)
255 DateTime updatedAt;
256
257 bool isPinned;
258 String? personaId;
259 String? serverId;
260 String? modelId;
261 int messageCount;
262 String? lastMessagePreview;
263 String? systemPrompt;
264 double? temperature;
265 double? topP;
266 int? maxTokens;
267 int? contextLength;
268 bool? mcpEnabled;
269 String? smartRepliesJson;
270 String? smartRepliesLastMessageId;
271 String? folderId;
272 bool isTemporary;
273 bool isArchived;
274 int characterCount;
275 int? totalTokenCount;
276
277 @Backlink()
278 final messages = ToMany<MessageEntity>();
279
280 ConversationEntity({
281 this.internalId = 0,
282 required this.id,
283 required this.title,
284 required this.createdAt,
285 required this.updatedAt,
286 this.isPinned = false,
287 this.personaId,
288 this.serverId,
289 this.modelId,
290 this.messageCount = 0,
291 this.lastMessagePreview,
292 this.systemPrompt,
293 this.temperature,
294 this.topP,
295 this.maxTokens,
296 this.contextLength,
297 this.mcpEnabled,
298 this.smartRepliesJson,
299 this.smartRepliesLastMessageId,
300 this.folderId,
301 this.isTemporary = false,
302 this.isArchived = false,
303 this.characterCount = 0,
304 this.totalTokenCount,
305 });
306
307 factory ConversationEntity.fromDomain(Conversation conversation) {
308 return ConversationEntity(
309 id: conversation.id,
310 title: conversation.title,
311 createdAt: conversation.createdAt,
312 updatedAt: conversation.updatedAt,
313 isPinned: conversation.isPinned,
314 personaId: conversation.personaId,
315 serverId: conversation.serverId,
316 modelId: conversation.modelId,
317 messageCount: conversation.messageCount,
318 lastMessagePreview: conversation.lastMessagePreview,
319 systemPrompt: conversation.systemPrompt,
320 temperature: conversation.temperature,
321 topP: conversation.topP,
322 maxTokens: conversation.maxTokens,
323 contextLength: conversation.contextLength,
324 mcpEnabled: conversation.mcpEnabled,
325 smartRepliesJson: conversation.smartReplies != null
326 ? jsonEncode(conversation.smartReplies)
327 : null,
328 smartRepliesLastMessageId: conversation.smartRepliesLastMessageId,
329 folderId: conversation.folderId,
330 isTemporary: conversation.isTemporary,
331 isArchived: conversation.isArchived,
332 characterCount: conversation.characterCount,
333 totalTokenCount: conversation.totalTokenCount,
334 );
335 }
336
337 Conversation toDomain() {
338 return Conversation(
339 id: id,
340 title: title,
341 createdAt: createdAt,
342 updatedAt: updatedAt,
343 isPinned: isPinned,
344 personaId: personaId,
345 serverId: serverId,
346 modelId: modelId,
347 messageCount: messageCount,
348 lastMessagePreview: lastMessagePreview,
349 systemPrompt: systemPrompt,
350 temperature: temperature,
351 topP: topP,
352 maxTokens: maxTokens,
353 contextLength: contextLength,
354 mcpEnabled: mcpEnabled,
355 smartReplies: smartRepliesJson != null
356 ? () {
357 try {
358 final decoded = jsonDecode(smartRepliesJson!);
359 if (decoded is List) {
360 return List<String>.from(decoded);
361 }
362 return null;
363 } catch (_) {
364 return null;
365 }
366 }()
367 : null,
368 smartRepliesLastMessageId: smartRepliesLastMessageId,
369 folderId: folderId,
370 isTemporary: isTemporary,
371 isArchived: isArchived,
372 characterCount: characterCount,
373 totalTokenCount: totalTokenCount,
374 );
375 }
376 }
377
378 @Entity()
379 class ConversationFolderEntity {
380 @Id()
381 int internalId = 0;
382
383 @Index()
384 String id;
385 String name;
386 int sortOrder;
387
388 @Property(type: PropertyType.date)
389 DateTime createdAt;
390
391 ConversationFolderEntity({
392 this.internalId = 0,
393 required this.id,
394 required this.name,
395 this.sortOrder = 0,
396 required this.createdAt,
397 });
398 }
399
400 @Entity()
401 class SavedMessageFolderEntity {
402 @Id()
403 int internalId = 0;
404
405 @Index()
406 String id;
407 String name;
408 int sortOrder;
409
410 @Property(type: PropertyType.date)
411 DateTime createdAt;
412
413 SavedMessageFolderEntity({
414 this.internalId = 0,
415 required this.id,
416 required this.name,
417 this.sortOrder = 0,
418 required this.createdAt,
419 });
420 }
421
422 @Entity()
423 class SavedMessageEntity {
424 @Id()
425 int internalId = 0;
426
427 @Index()
428 String id;
429
430 @Index()
431 String sourceMessageId;
432
433 @Index()
434 String conversationId;
435
436 String conversationTitle;
437
438 @Property(type: PropertyType.int)
439 int roleIndex;
440
441 String content;
442 String? modelId;
443 String? folderId;
444
445 @Property(type: PropertyType.date)
446 DateTime savedAt;
447
448 bool isArchived;
449
450 SavedMessageEntity({
451 this.internalId = 0,
452 required this.id,
453 required this.sourceMessageId,
454 required this.conversationId,
455 required this.conversationTitle,
456 required this.roleIndex,
457 required this.content,
458 this.modelId,
459 this.folderId,
460 required this.savedAt,
461 this.isArchived = false,
462 });
463 }
464
465 @Entity()
466 class MessageEntity {
467 @Id()
468 int internalId = 0;
469
470 @Index()
471 String id;
472
473 final conversation = ToOne<ConversationEntity>();
474
475 @Index()
476 String conversationUid;
477
478 @Property(type: PropertyType.int)
479 int roleIndex;
480
481 String content;
482
483 @Property(type: PropertyType.date)
484 DateTime createdAt;
485
486 @Property(type: PropertyType.int)
487 int statusIndex;
488
489 String? modelId;
490 int? tokenCount;
491 int? inputTokenCount;
492 double? tokensPerSecond;
493 int? ttftMs;
494 String? stopReason;
495 String? errorMessage;
496 String? attachmentPathsJson;
497 int? generationTimeMs;
498 String? reasoningContent;
499 String? toolCallsJson;
500 String? toolCallId;
501 bool isProcessing;
502 String? toolSessionId;
503 String? toolEventsJson;
504 String? variantGroupId;
505 int variantIndex;
506 int threadOrder;
507 bool isActiveVariant;
508 String? parentMessageId;
509 int? contentTokenCount;
510
511 MessageEntity({
512 this.internalId = 0,
513 required this.id,
514 required this.conversationUid,
515 required this.roleIndex,
516 required this.content,
517 required this.createdAt,
518 required this.statusIndex,
519 this.modelId,
520 this.tokenCount,
521 this.inputTokenCount,
522 this.tokensPerSecond,
523 this.ttftMs,
524 this.stopReason,
525 this.errorMessage,
526 this.attachmentPathsJson,
527 this.generationTimeMs,
528 this.reasoningContent,
529 this.toolCallsJson,
530 this.toolCallId,
531 this.isProcessing = false,
532 this.toolSessionId,
533 this.toolEventsJson,
534 this.variantGroupId,
535 this.variantIndex = 0,
536 this.threadOrder = 0,
537 this.isActiveVariant = true,
538 this.parentMessageId,
539 this.contentTokenCount,
540 });
541
542 factory MessageEntity.fromDomain(Message message) {
543 return MessageEntity(
544 id: message.id,
545 conversationUid: message.conversationId,
546 roleIndex: message.role.index,
547 content: message.content,
548 createdAt: message.createdAt,
549 statusIndex: message.status.index,
550 modelId: message.modelId,
551 tokenCount: message.tokenCount,
552 inputTokenCount: message.inputTokenCount,
553 tokensPerSecond: message.tokensPerSecond,
554 ttftMs: message.ttftMs,
555 stopReason: message.stopReason,
556 errorMessage: message.errorMessage,
557 attachmentPathsJson: message.attachmentPaths != null
558 ? jsonEncode(message.attachmentPaths)
559 : null,
560 generationTimeMs: message.generationTimeMs,
561 reasoningContent: message.reasoningContent,
562 toolCallsJson: message.toolCalls != null
563 ? jsonEncode(message.toolCalls!.map((e) => e.toMap()).toList())
564 : null,
565 toolCallId: message.toolCallId,
566 isProcessing: message.isProcessing,
567 toolSessionId: message.toolSessionId,
568 toolEventsJson: message.toolEvents != null
569 ? jsonEncode(message.toolEvents!.map((e) => e.toMap()).toList())
570 : null,
571 variantGroupId: message.variantGroupId,
572 variantIndex: message.variantIndex,
573 threadOrder: message.threadOrder,
574 isActiveVariant: message.isActiveVariant,
575 parentMessageId: message.parentMessageId,
576 contentTokenCount: message.contentTokenCount,
577 );
578 }
579
580 Message toDomain() {
581 return Message(
582 id: id,
583 conversationId: conversationUid,
584 role: MessageRole.values[roleIndex],
585 content: content,
586 createdAt: createdAt,
587 status: statusIndex >= 0 && statusIndex < MessageStatus.values.length
588 ? MessageStatus.values[statusIndex]
589 : MessageStatus.values.first,
590 modelId: modelId,
591 tokenCount: tokenCount,
592 inputTokenCount: inputTokenCount,
593 tokensPerSecond: tokensPerSecond,
594 ttftMs: ttftMs,
595 stopReason: stopReason,
596 errorMessage: errorMessage,
597 attachmentPaths: attachmentPathsJson != null
598 ? () {
599 try {
600 final decoded = jsonDecode(attachmentPathsJson!);
601 if (decoded is List) {
602 return List<String>.from(decoded);
603 }
604 return null;
605 } catch (_) {
606 return null;
607 }
608 }()
609 : null,
610 generationTimeMs: generationTimeMs,
611 reasoningContent: reasoningContent,
612 toolCalls: toolCallsJson != null
613 ? () {
614 try {
615 final decoded = jsonDecode(toolCallsJson!);
616 if (decoded is List) {
617 return decoded
618 .map(
619 (e) => ToolCallData.fromMap(
620 Map<String, dynamic>.from(e as Map),
621 ),
622 )
623 .toList();
624 }
625 return null;
626 } catch (_) {
627 return null;
628 }
629 }()
630 : null,
631 toolCallId: toolCallId,
632 isProcessing: isProcessing,
633 toolSessionId: toolSessionId,
634 toolEvents: toolEventsJson != null
635 ? () {
636 try {
637 final decoded = jsonDecode(toolEventsJson!);
638 if (decoded is List) {
639 return decoded
640 .map(
641 (e) => ToolEvent.fromMap(
642 Map<String, dynamic>.from(e as Map),
643 ),
644 )
645 .toList();
646 }
647 return null;
648 } catch (_) {
649 return null;
650 }
651 }()
652 : null,
653 variantGroupId: variantGroupId,
654 variantIndex: variantIndex,
655 threadOrder: threadOrder,
656 isActiveVariant: isActiveVariant,
657 parentMessageId: parentMessageId,
658 contentTokenCount: contentTokenCount,
659 );
660 }
661 }