main
dart 375 lines 12.3 KB
Raw
1 import 'package:flutter_riverpod/flutter_riverpod.dart';
2
3 import '../../../core/providers/storage_providers.dart';
4 import '../../../core/storage/entities.dart';
5 import '../../../core/storage/objectbox_store.dart';
6 import '../../../objectbox.g.dart';
7 import '../data/models/persona.dart';
8 import '../utils/persona_prompt_utils.dart';
9
10 final personaSearchQueryProvider =
11 NotifierProvider<_PersonaSearchNotifier, String>(
12 _PersonaSearchNotifier.new,
13 );
14
15 final selectedPersonasProvider =
16 NotifierProvider<SelectedPersonasNotifier, List<Persona>>(
17 SelectedPersonasNotifier.new,
18 );
19
20 /// First selected persona, if any (legacy convenience).
21 final selectedPersonaProvider = Provider<Persona?>((ref) {
22 final personas = ref.watch(selectedPersonasProvider);
23 return personas.isEmpty ? null : personas.first;
24 });
25
26 class SelectedPersonasNotifier extends Notifier<List<Persona>> {
27 @override
28 List<Persona> build() => const [];
29
30 void setPersonas(List<Persona> personas) {
31 state = List.unmodifiable(personas);
32 }
33
34 void toggle(Persona persona) {
35 if (state.any((p) => p.id == persona.id)) {
36 state = state.where((p) => p.id != persona.id).toList();
37 } else {
38 state = [...state, persona];
39 }
40 }
41
42 void clear() {
43 state = const [];
44 }
45 }
46
47 class _PersonaSearchNotifier extends Notifier<String> {
48 @override
49 String build() => '';
50
51 void setQuery(String q) => state = q;
52 void clear() => state = '';
53 }
54
55 final personaCategoryFilterProvider =
56 NotifierProvider<_CategoryFilterNotifier, String?>(
57 _CategoryFilterNotifier.new,
58 );
59
60 final personaPreviewSystemPromptsProvider =
61 NotifierProvider<_PreviewSystemPromptsNotifier, bool>(
62 _PreviewSystemPromptsNotifier.new,
63 );
64
65 class _PreviewSystemPromptsNotifier extends Notifier<bool> {
66 @override
67 bool build() => false;
68
69 void toggle() => state = !state;
70 void set(bool value) => state = value;
71 }
72
73 class _CategoryFilterNotifier extends Notifier<String?> {
74 @override
75 String? build() => null;
76
77 void setCategory(String? cat) => state = cat;
78 }
79
80 final personasNotifierProvider =
81 AsyncNotifierProvider<PersonasNotifier, List<Persona>>(
82 PersonasNotifier.new,
83 );
84
85 class PersonasNotifier extends AsyncNotifier<List<Persona>> {
86 @override
87 Future<List<Persona>> build() async {
88 return _loadAll();
89 }
90
91 /// Seeds built-in personas when the database is first created. Called once
92 /// during app bootstrap so the provider [build] stays a pure read.
93 static void seedIfNeeded(ObjectBoxStore db) {
94 if (db.personaBox.isEmpty()) {
95 for (final preset in builtInPersonas) {
96 db.personaBox.put(PersonaEntity.fromDomain(preset));
97 }
98 }
99 }
100
101 Future<List<Persona>> _loadAll() async {
102 return _loadWithoutSeeding();
103 }
104
105 Future<Persona> createPersona({
106 required String name,
107 required String emoji,
108 required String systemPrompt,
109 String? description,
110 String? category,
111 Map<String, dynamic>? preferredParams,
112 }) async {
113 if (!ref.mounted) {
114 throw StateError('Cannot create persona on unmounted notifier');
115 }
116 final db = ref.read(databaseProvider);
117 final now = DateTime.now();
118 final persona = Persona(
119 id: now.millisecondsSinceEpoch.toString(),
120 name: name,
121 emoji: emoji,
122 systemPrompt: systemPrompt,
123 description: description,
124 isBuiltIn: false,
125 createdAt: now,
126 updatedAt: now,
127 category: category,
128 preferredParams: preferredParams,
129 );
130 db.personaBox.put(PersonaEntity.fromDomain(persona));
131 final data = await _loadAll();
132 if (ref.mounted) {
133 state = AsyncData(data);
134 }
135 return persona;
136 }
137
138 Future<void> updatePersona(Persona updated) async {
139 if (!ref.mounted) return;
140 final db = ref.read(databaseProvider);
141 final persona = updated.copyWith(updatedAt: DateTime.now());
142
143 final query = db.personaBox
144 .query(PersonaEntity_.id.equals(persona.id))
145 .build();
146 final existing = query.findFirst();
147 query.close();
148
149 final entity = PersonaEntity.fromDomain(persona);
150 if (existing != null) {
151 entity.internalId = existing.internalId;
152 }
153 db.personaBox.put(entity);
154
155 final data = await _loadAll();
156 if (ref.mounted) {
157 state = AsyncData(data);
158 }
159 }
160
161 Future<void> deletePersona(String id) async {
162 if (!ref.mounted) return;
163 final db = ref.read(databaseProvider);
164 final query = db.personaBox.query(PersonaEntity_.id.equals(id)).build();
165 db.personaBox.removeMany(query.findIds());
166 query.close();
167
168 final convQuery = db.conversationBox
169 .query(ConversationEntity_.personaId.equals(id))
170 .build();
171 for (final entity in convQuery.find()) {
172 entity.personaId = null;
173 db.conversationBox.put(entity);
174 }
175 convQuery.close();
176
177 final data = await _loadWithoutSeeding();
178 if (ref.mounted) {
179 state = AsyncData(data);
180 }
181 }
182
183 /// Re-adds any built-in personas that were deleted, without touching
184 /// custom ones or duplicating built-ins that are still present.
185 Future<void> restoreBuiltInPersonas() async {
186 if (!ref.mounted) return;
187 final db = ref.read(databaseProvider);
188 final existingIds = db.personaBox.getAll().map((e) => e.id).toSet();
189 for (final preset in builtInPersonas) {
190 if (!existingIds.contains(preset.id)) {
191 db.personaBox.put(PersonaEntity.fromDomain(preset));
192 }
193 }
194 final data = await _loadWithoutSeeding();
195 if (ref.mounted) {
196 state = AsyncData(data);
197 }
198 }
199
200 Future<List<Persona>> _loadWithoutSeeding() async {
201 if (!ref.mounted) return [];
202 final db = ref.read(databaseProvider);
203 final entities = db.personaBox.getAll();
204 final personas = entities.map((e) => e.toDomain()).toList();
205 personas.sort((a, b) {
206 if (a.isBuiltIn != b.isBuiltIn) return a.isBuiltIn ? 1 : -1;
207 return a.name.compareTo(b.name);
208 });
209 return personas;
210 }
211
212 Future<Persona> clonePersona(String id) async {
213 final personas = state.value ?? [];
214 final original = personas.firstWhere(
215 (p) => p.id == id,
216 orElse: () => throw Exception('Persona not found'),
217 );
218
219 final now = DateTime.now();
220 final clone = Persona(
221 id: now.millisecondsSinceEpoch.toString(),
222 name: '${original.name} (Copy)',
223 emoji: original.emoji,
224 systemPrompt: original.systemPrompt,
225 description: original.description,
226 isBuiltIn: false,
227 createdAt: now,
228 updatedAt: now,
229 category: original.category,
230 preferredParams: original.preferredParams != null
231 ? Map<String, dynamic>.from(original.preferredParams!)
232 : null,
233 );
234 final db = ref.read(databaseProvider);
235 db.personaBox.put(PersonaEntity.fromDomain(clone));
236 state = AsyncData(await _loadAll());
237 return clone;
238 }
239
240 static final List<Persona> builtInPersonas = [
241 Persona(
242 id: 'builtin-general',
243 name: 'General Assistant',
244 emoji: '🤖',
245 systemPrompt:
246 'You are a helpful, knowledgeable AI assistant. Provide clear, accurate, and concise responses. When you\'re not sure about something, say so. Use markdown formatting for structured responses.',
247 description: 'Helpful, knowledgeable assistant',
248 isBuiltIn: true,
249 createdAt: DateTime(2026),
250 updatedAt: DateTime(2026),
251 category: 'General',
252 ),
253 Persona(
254 id: 'builtin-code',
255 name: 'Code Assistant',
256 emoji: '🧑‍💻',
257 systemPrompt:
258 'You are an expert software engineer. Help with coding questions, debugging, code reviews, and architecture decisions. Always provide code examples with proper syntax highlighting. Explain your reasoning. Follow best practices and mention potential pitfalls. When writing code, include comments for complex logic.',
259 description: 'Expert software engineer',
260 isBuiltIn: true,
261 createdAt: DateTime(2026),
262 updatedAt: DateTime(2026),
263 category: 'Coding',
264 ),
265 Persona(
266 id: 'builtin-math',
267 name: 'Math Tutor',
268 emoji: '📐',
269 systemPrompt:
270 'You are a patient and thorough math tutor. Explain concepts step by step, starting from fundamentals. Use examples to illustrate abstract concepts. When solving problems, show your work clearly. Encourage the student and offer practice problems.',
271 description: 'Patient, step-by-step math tutor',
272 isBuiltIn: true,
273 createdAt: DateTime(2026),
274 updatedAt: DateTime(2026),
275 category: 'Education',
276 ),
277 Persona(
278 id: 'builtin-story',
279 name: 'Story Writer',
280 emoji: '✍️',
281 systemPrompt:
282 'You are a creative fiction writer. Help craft engaging stories, develop characters, build worlds, and write dialogue. Match the tone and style the user requests. Offer constructive suggestions to improve narratives. Be creative and take risks with your writing while staying true to the user\'s vision.',
283 description: 'Creative fiction writer',
284 isBuiltIn: true,
285 createdAt: DateTime(2026),
286 updatedAt: DateTime(2026),
287 category: 'Creative',
288 ),
289 Persona(
290 id: 'builtin-tutor',
291 name: 'General Tutor',
292 emoji: '📚',
293 systemPrompt:
294 'You are an educational tutor skilled in all subjects. Explain complex topics in simple terms. Use analogies, examples, and visual descriptions. Break down topics into digestible pieces. Check understanding by posing questions. Adapt your explanation style to the student\'s level.',
295 description: 'Skilled tutor for all subjects',
296 isBuiltIn: true,
297 createdAt: DateTime(2026),
298 updatedAt: DateTime(2026),
299 category: 'Education',
300 ),
301 Persona(
302 id: 'builtin-editor',
303 name: 'Writing Editor',
304 emoji: '✏️',
305 systemPrompt:
306 'You are a professional writing editor. Help improve text clarity, grammar, style, and structure. Provide specific suggestions with explanations. Maintain the author\'s voice while improving readability. Offer alternatives rather than dictating changes. Format feedback clearly with before/after examples.',
307 description: 'Professional writing editor',
308 isBuiltIn: true,
309 createdAt: DateTime(2026),
310 updatedAt: DateTime(2026),
311 category: 'Creative',
312 ),
313 Persona(
314 id: 'builtin-summarizer',
315 name: 'Summarizer',
316 emoji: '📋',
317 systemPrompt:
318 'You are a concise summarizer. When given text, provide clear, accurate summaries that capture the key points. Use bullet points for multiple items. Maintain the original meaning without adding interpretation. Adjust summary length based on the input — shorter inputs get shorter summaries.',
319 description: 'Concise, accurate summarizer',
320 isBuiltIn: true,
321 createdAt: DateTime(2026),
322 updatedAt: DateTime(2026),
323 category: 'General',
324 ),
325 ];
326 }
327
328 final filteredPersonasProvider = Provider<List<Persona>>((ref) {
329 final personasAsync = ref.watch(personasNotifierProvider);
330 final personas = personasAsync.value ?? [];
331 final category = ref.watch(personaCategoryFilterProvider);
332 final query = ref.watch(personaSearchQueryProvider).toLowerCase();
333
334 var filtered = personas;
335 if (category != null && category.isNotEmpty) {
336 filtered = filtered.where((p) => p.category == category).toList();
337 }
338 if (query.isNotEmpty) {
339 filtered = filtered
340 .where(
341 (p) =>
342 p.name.toLowerCase().contains(query) ||
343 (p.description?.toLowerCase().contains(query) ?? false) ||
344 p.systemPrompt.toLowerCase().contains(query),
345 )
346 .toList();
347 }
348 return filtered;
349 });
350
351 final builtInPersonasProvider = Provider<List<Persona>>((ref) {
352 final personas = ref.watch(personasNotifierProvider).value ?? [];
353 return personas.where((p) => p.isBuiltIn).toList();
354 });
355
356 final userPersonasProvider = Provider<List<Persona>>((ref) {
357 final personas = ref.watch(personasNotifierProvider).value ?? [];
358 return personas.where((p) => !p.isBuiltIn).toList();
359 });
360
361 final personaByIdProvider = Provider.family<Persona?, String>((ref, id) {
362 try {
363 final personas = ref.watch(personasNotifierProvider).value ?? [];
364 return personas.firstWhere((p) => p.id == id);
365 } catch (_) {
366 return null;
367 }
368 });
369
370 final personasForConversationProvider = Provider.family<List<Persona>, String?>(
371 (ref, personaIdsRaw) {
372 final personas = ref.watch(personasNotifierProvider).value ?? [];
373 return PersonaPromptUtils.resolvePersonas(personaIdsRaw, personas);
374 },
375 );