main
dart 463 lines 13.3 KB
Raw
1 import 'package:flutter_riverpod/flutter_riverpod.dart';
2
3 import '../data/models/server.dart';
4 import '../../../core/models/enums.dart';
5 import '../../../core/providers/storage_providers.dart';
6 import '../../../core/providers/service_providers.dart';
7 import '../../../core/storage/entities.dart';
8 import '../../models/data/model_cache.dart';
9
10 import '../../../objectbox.g.dart';
11 import '../../models/data/models/model_info.dart';
12 import '../../on_device/providers/on_device_providers.dart';
13 import '../../on_device/data/models/on_device_model.dart';
14
15 final _modelCache = ModelCache();
16
17 void invalidateAvailableModelsCache(String serverId) {
18 _modelCache.invalidate(serverId);
19 }
20
21 void invalidateAllAvailableModelsCache() {
22 _modelCache.invalidateAll();
23 }
24
25 final serversProvider = AsyncNotifierProvider<ServersNotifier, List<Server>>(
26 () {
27 return ServersNotifier();
28 },
29 );
30
31 final activeServerIdProvider =
32 NotifierProvider<ActiveServerIdNotifier, String?>(() {
33 return ActiveServerIdNotifier();
34 });
35
36 class ActiveServerIdNotifier extends Notifier<String?> {
37 static const _key = 'defaultServerId';
38
39 @override
40 String? build() {
41 final prefs = ref.watch(sharedPreferencesProvider);
42 return prefs.getString(_key);
43 }
44
45 void setActiveServerId(String? id) {
46 state = id;
47 final prefs = ref.read(sharedPreferencesProvider);
48 if (id != null) {
49 prefs.setString(_key, id);
50 } else {
51 prefs.remove(_key);
52 }
53 }
54
55 void setActiveServer(Server? server) {
56 setActiveServerId(server?.id);
57 }
58 }
59
60 final activeServerProvider = NotifierProvider<ActiveServerNotifier, Server?>(
61 () {
62 return ActiveServerNotifier();
63 },
64 );
65
66 class ActiveServerNotifier extends Notifier<Server?> {
67 @override
68 Server? build() {
69 final activeId = ref.watch(activeServerIdProvider);
70 final servers = ref.watch(serversProvider).value ?? [];
71 return _resolveServer(servers, activeId);
72 }
73
74 Server? _resolveServer(List<Server> servers, String? defaultServerId) {
75 if (servers.isEmpty) return null;
76
77 if (defaultServerId != null && defaultServerId.isNotEmpty) {
78 final matching = servers.where((s) => s.id == defaultServerId);
79 if (matching.isNotEmpty) return matching.first;
80 }
81
82 final defaults = servers.where((s) => s.isDefault);
83 return defaults.isNotEmpty ? defaults.first : servers.first;
84 }
85
86 void setActiveServer(Server? server) {
87 ref.read(activeServerIdProvider.notifier).setActiveServer(server);
88 state = server;
89 }
90 }
91
92 final connectionStatusProvider =
93 NotifierProvider<ConnectionStatusNotifier, ConnectionStatus>(() {
94 return ConnectionStatusNotifier();
95 });
96
97 final loadedModelsRefreshProvider =
98 NotifierProvider<LoadedModelsRefreshNotifier, int>(() {
99 return LoadedModelsRefreshNotifier();
100 });
101
102 class LoadedModelsRefreshNotifier extends Notifier<int> {
103 @override
104 int build() => 0;
105
106 void refresh() {
107 state++;
108 }
109 }
110
111 final loadedModelsProvider = FutureProvider.family<Set<String>, Server>((
112 ref,
113 server,
114 ) async {
115 ref.watch(loadedModelsRefreshProvider);
116
117 if (server.type == ServerType.onDevice || server.id == 'on-device') {
118 final engineState = ref.watch(onDeviceEngineProvider);
119 return engineState.loadedModelId != null
120 ? {engineState.loadedModelId!}
121 : {};
122 }
123
124 if (server.type == ServerType.openAICompatible ||
125 server.type == ServerType.openRouter ||
126 server.type == ServerType.requesty) {
127 return <String>{};
128 }
129
130 final apiService = ref.watch(serverApiServiceProvider);
131 try {
132 return await apiService.fetchRunningModels(server);
133 } catch (e) {
134 return {};
135 }
136 });
137
138 final availableModelsProvider = FutureProvider.family<List<dynamic>, String>((
139 ref,
140 serverId,
141 ) async {
142 final serversAsync = ref.watch(serversProvider);
143 final servers = serversAsync.value ?? [];
144 final server = servers.firstWhere(
145 (s) => s.id == serverId,
146 orElse: () => throw Exception('Server not found'),
147 );
148 final apiService = ref.watch(serverApiServiceProvider);
149
150 if (server.type == ServerType.onDevice || server.id == 'on-device') {
151 final models = ref
152 .watch(onDeviceModelsProvider)
153 .map(
154 (m) => ModelInfo(
155 id: m.id,
156 name: m.name,
157 description: m.description,
158 parameterCount: double.tryParse(
159 m.parameterLabel.replaceAll(RegExp(r'[^0-9\.]'), ''),
160 ),
161 fileSize: m.fileSizeBytes,
162 quantization: m.format == OnDeviceModelFormat.gguf
163 ? 'GGUF'
164 : m.format == OnDeviceModelFormat.mlx
165 ? 'MLX 4-bit'
166 : m.format == OnDeviceModelFormat.builtIn
167 ? 'System AI'
168 : null,
169 architecture: m.runtime == OnDeviceModelRuntime.llamaCpp
170 ? 'llama.cpp'
171 : m.runtime == OnDeviceModelRuntime.mlx
172 ? 'Apple MLX'
173 : m.isBuiltIn
174 ? 'Built-in OS AI'
175 : null,
176 serverType: ServerType.onDevice,
177 serverId: server.id,
178 modifiedAt: m.importedAt,
179 onDeviceRuntime: m.runtime,
180 onDeviceFormat: m.format,
181 localPath: m.localPath,
182 supportsVision: m.supportsVision,
183 supportsReasoning: m.supportsThinking,
184 supportsToolUse: m.supportsFunctionCalling,
185 ),
186 )
187 .toList();
188 _modelCache.put(serverId, models);
189 return models;
190 }
191
192 final cached = _modelCache.get(serverId);
193 if (cached != null) return cached;
194
195 final models = await apiService.fetchModels(server);
196 _modelCache.put(serverId, models);
197 return models;
198 });
199
200 class ServersNotifier extends AsyncNotifier<List<Server>> {
201 @override
202 Future<List<Server>> build() async {
203 return _loadAll();
204 }
205
206 Future<List<Server>> _loadAll() async {
207 if (!ref.mounted) return [];
208 final db = ref.read(databaseProvider);
209 final entities = db.serverBox.getAll();
210
211 // Group entities by id to detect and remove duplicate records.
212 final entitiesById = <String, List<ServerEntity>>{};
213 for (final entity in entities) {
214 entitiesById.putIfAbsent(entity.id, () => []).add(entity);
215 }
216
217 final sanitizedEntities = <ServerEntity>[];
218 for (final entry in entitiesById.entries) {
219 final list = entry.value;
220 if (list.length == 1) {
221 sanitizedEntities.add(list.first);
222 } else {
223 // Prefer isDefault, then entity whose typeIndex matches the resolved domain type,
224 // then lowest internalId.
225 list.sort((a, b) {
226 if (a.isDefault != b.isDefault) {
227 return a.isDefault ? -1 : 1;
228 }
229 final aResolved = a.toDomain().type.index == a.typeIndex;
230 final bResolved = b.toDomain().type.index == b.typeIndex;
231 if (aResolved != bResolved) {
232 return aResolved ? -1 : 1;
233 }
234 return a.internalId.compareTo(b.internalId);
235 });
236
237 final keep = list.first;
238 sanitizedEntities.add(keep);
239
240 for (var i = 1; i < list.length; i++) {
241 db.serverBox.remove(list[i].internalId);
242 }
243 }
244 }
245
246 // Persist any healed typeIndex back to ObjectBox.
247 for (final entity in sanitizedEntities) {
248 final domain = entity.toDomain();
249 if (entity.typeIndex != domain.type.index) {
250 entity.typeIndex = domain.type.index;
251 db.serverBox.put(entity);
252 }
253 }
254
255 return sanitizedEntities.map((e) => e.toDomain()).toList();
256 }
257
258 Future<void> addServer(Server server) async {
259 if (!ref.mounted) return;
260 final db = ref.read(databaseProvider);
261 final existing = db.serverBox
262 .getAll()
263 .where((e) => e.id == server.id)
264 .firstOrNull;
265
266 final entity = ServerEntity.fromDomain(server);
267 if (existing != null) {
268 entity.internalId = existing.internalId;
269 }
270 db.serverBox.put(entity);
271 invalidateAvailableModelsCache(server.id);
272 final data = await _loadAll();
273 if (ref.mounted) {
274 state = AsyncData(data);
275 }
276 }
277
278 Future<void> updateServer(Server server) async {
279 if (!ref.mounted) return;
280 final db = ref.read(databaseProvider);
281 final query = db.serverBox
282 .query(ServerEntity_.id.equals(server.id))
283 .build();
284 final existing = query.findFirst();
285 query.close();
286
287 final entity = ServerEntity.fromDomain(server);
288 if (existing != null) {
289 entity.internalId = existing.internalId;
290 }
291 db.serverBox.put(entity);
292 invalidateAvailableModelsCache(server.id);
293 final data = await _loadAll();
294 if (ref.mounted) {
295 state = AsyncData(data);
296 }
297 }
298
299 Future<void> deleteServer(String serverId) async {
300 if (!ref.mounted) return;
301 final db = ref.read(databaseProvider);
302 final query = db.serverBox.query(ServerEntity_.id.equals(serverId)).build();
303 db.serverBox.removeMany(query.findIds());
304 query.close();
305 invalidateAvailableModelsCache(serverId);
306 final data = await _loadAll();
307 if (ref.mounted) {
308 state = AsyncData(data);
309 }
310 }
311
312 Future<void> setDefault(String serverId) async {
313 if (!ref.mounted) return;
314 final db = ref.read(databaseProvider);
315 final servers = state.value ?? [];
316 final updatedServers = servers.map((s) {
317 return s.copyWith(isDefault: s.id == serverId);
318 }).toList();
319
320 for (final server in updatedServers) {
321 final query = db.serverBox
322 .query(ServerEntity_.id.equals(server.id))
323 .build();
324 final existing = query.findFirst();
325 query.close();
326
327 final entity = ServerEntity.fromDomain(server);
328 if (existing != null) {
329 entity.internalId = existing.internalId;
330 }
331 db.serverBox.put(entity);
332 invalidateAvailableModelsCache(server.id);
333 }
334 final data = await _loadAll();
335 if (ref.mounted) {
336 state = AsyncData(data);
337 }
338 }
339
340 Future<void> updateServerStatus(
341 String serverId,
342 ConnectionStatus status,
343 ) async {
344 final servers = state.value ?? [];
345 final index = servers.indexWhere((s) => s.id == serverId);
346 if (index == -1) return;
347 final server = servers[index];
348 if (server.status == status) return;
349
350 final updatedServer = server.copyWith(
351 status: status,
352 lastConnectedAt: status == ConnectionStatus.connected
353 ? DateTime.now()
354 : server.lastConnectedAt,
355 );
356 final db = ref.read(databaseProvider);
357
358 final query = db.serverBox
359 .query(ServerEntity_.id.equals(updatedServer.id))
360 .build();
361 final existing = query.findFirst();
362 query.close();
363
364 final entity = ServerEntity.fromDomain(updatedServer);
365 if (existing != null) {
366 entity.internalId = existing.internalId;
367 }
368 db.serverBox.put(entity);
369 invalidateAvailableModelsCache(updatedServer.id);
370
371 if (!ref.mounted) return;
372 state = AsyncData(await _loadAll());
373 }
374
375 Future<ConnectionStatus> testConnection(
376 String serverId,
377 dynamic apiService,
378 ) async {
379 final servers = state.value ?? [];
380 final index = servers.indexWhere((s) => s.id == serverId);
381 if (index == -1) return ConnectionStatus.disconnected;
382 final server = servers[index];
383
384 if (server.type == ServerType.onDevice || server.id == 'on-device') {
385 await updateServerStatus(serverId, ConnectionStatus.connected);
386 return ConnectionStatus.connected;
387 }
388
389 final isConnected = await apiService.testConnection(server);
390 if (!ref.mounted) {
391 return isConnected
392 ? ConnectionStatus.connected
393 : ConnectionStatus.disconnected;
394 }
395 final status = isConnected
396 ? ConnectionStatus.connected
397 : ConnectionStatus.disconnected;
398
399 await updateServerStatus(serverId, status);
400 return status;
401 }
402
403 Future<void> testAllConnections(dynamic apiService) async {
404 final servers = state.value ?? [];
405 for (final server in servers) {
406 if (!ref.mounted) return;
407 await testConnection(server.id, apiService);
408 }
409 }
410 }
411
412 class ConnectionStatusNotifier extends Notifier<ConnectionStatus> {
413 int _checkGeneration = 0;
414
415 @override
416 ConnectionStatus build() {
417 final activeServer = ref.watch(activeServerProvider);
418 final apiService = ref.watch(serverApiServiceProvider);
419
420 if (activeServer == null) {
421 return ConnectionStatus.disconnected;
422 }
423
424 if (activeServer.type == ServerType.onDevice ||
425 activeServer.id == 'on-device') {
426 return ConnectionStatus.connected;
427 }
428
429 final generation = ++_checkGeneration;
430 Future.microtask(
431 () => _checkConnection(activeServer, apiService, generation),
432 );
433 return ConnectionStatus.checking;
434 }
435
436 Future<void> _checkConnection(
437 Server server,
438 dynamic apiService,
439 int generation,
440 ) async {
441 try {
442 final isConnected = await apiService.testConnection(server);
443 if (!ref.mounted || generation != _checkGeneration) return;
444 state = isConnected
445 ? ConnectionStatus.connected
446 : ConnectionStatus.disconnected;
447 } catch (e) {
448 if (!ref.mounted || generation != _checkGeneration) return;
449 state = ConnectionStatus.error;
450 }
451 }
452
453 Future<void> refresh() async {
454 if (!ref.mounted) return;
455 final activeServer = ref.read(activeServerProvider);
456 final apiService = ref.read(serverApiServiceProvider);
457 if (activeServer != null) {
458 state = ConnectionStatus.checking;
459 final generation = ++_checkGeneration;
460 await _checkConnection(activeServer, apiService, generation);
461 }
462 }
463 }