6 files changed
+131
-40
.gitignore
+2
-1
@@ -55,4 +55,5 @@ notes.txt
55
docs/raw_ss/
56
docs/marketing/
57
.puku-cli/
58
-.puku/
\ No newline at end of file
58
+.puku/
59
+.opencode/
\ No newline at end of file
lib/features/servers/data/server_api_service.dart
+53
-15
@@ -11,18 +11,43 @@ class ServerApiService {
11
ServerApiService(this._dio);
12
13
Future<bool> testConnection(Server server) async {
14
- try {
15
- final response = await _dio.get(
16
- server.modelsEndpoint,
17
- options: Options(
18
- headers: buildServerAuthHeaders(server),
19
- validateStatus: (status) => status != null && status < 500,
20
- ),
21
- );
22
- return response.statusCode == 200;
23
- } catch (e) {
24
- return false;
14
+ if (server.type == ServerType.onDevice) {
15
+ return true;
16
+ }
17
+
18
+ final endpointsToTry = <String>[];
19
+ if (server.modelsEndpoint.isNotEmpty) {
20
+ endpointsToTry.add(server.modelsEndpoint);
21
+ }
22
+
23
+ if (server.type == ServerType.lmStudio) {
24
+ endpointsToTry.add('${server.baseUrl}/v1/models');
25
+ endpointsToTry.add('${server.baseUrl}/api/v0/models');
26
+ } else if (server.type == ServerType.openAICompatible) {
27
+ endpointsToTry.add('${server.baseUrl}/models');
28
+ } else if (server.type == ServerType.ollama) {
29
+ endpointsToTry.add('${server.baseUrl}/api/tags');
30
+ endpointsToTry.add('${server.baseUrl}/api/version');
31
+ }
32
+
33
+ for (final endpoint in endpointsToTry.toSet()) {
34
+ if (endpoint.isEmpty) continue;
35
+ try {
36
+ final response = await _dio.get(
37
+ endpoint,
38
+ options: Options(
39
+ headers: buildServerAuthHeaders(server),
40
+ validateStatus: (status) => status != null && status < 500,
41
+ ),
42
+ );
43
+ if (response.statusCode == 200) {
44
+ return true;
45
+ }
46
+ } catch (_) {
47
+ // Try next endpoint
48
+ }
49
}
50
+ return false;
51
}
52
53
Future<int?> pingServer(Server server) async {
@@ -41,10 +66,23 @@ class ServerApiService {
66
67
Future<List<ModelInfo>> fetchModels(Server server) async {
68
try {
44
- final response = await _dio.get(
45
- server.modelsEndpoint,
46
- options: Options(headers: buildServerAuthHeaders(server)),
47
- );
69
+ Response response;
70
+ try {
71
+ response = await _dio.get(
72
+ server.modelsEndpoint,
73
+ options: Options(headers: buildServerAuthHeaders(server)),
74
+ );
75
+ } on DioException catch (e) {
76
+ if (e.response?.statusCode == 404 &&
77
+ server.type == ServerType.lmStudio) {
78
+ response = await _dio.get(
79
+ '${server.baseUrl}/v1/models',
80
+ options: Options(headers: buildServerAuthHeaders(server)),
81
+ );
82
+ } else {
83
+ rethrow;
84
+ }
85
+ }
86
87
List<ModelInfo> models;
88
switch (server.type) {
lib/features/servers/providers/server_providers.dart
+49
-10
@@ -200,20 +200,21 @@ class ServersNotifier extends AsyncNotifier<List<Server>> {
200
state = AsyncData(await _loadAll());
201
}
202
203
- Future<ConnectionStatus> testConnection(
203
+ Future<void> updateServerStatus(
204
String serverId,
205
- dynamic apiService,
205
+ ConnectionStatus status,
206
) async {
207
final servers = state.value ?? [];
208
- final server = servers.firstWhere((s) => s.id == serverId);
209
- final isConnected = await apiService.testConnection(server);
210
- final status = isConnected
211
- ? ConnectionStatus.connected
212
- : ConnectionStatus.error;
208
+ final index = servers.indexWhere((s) => s.id == serverId);
209
+ if (index == -1) return;
210
+ final server = servers[index];
211
+ if (server.status == status) return;
212
213
final updatedServer = server.copyWith(
214
status: status,
216
- lastConnectedAt: DateTime.now(),
215
+ lastConnectedAt: status == ConnectionStatus.connected
216
+ ? DateTime.now()
217
+ : server.lastConnectedAt,
218
);
219
final db = ref.read(databaseProvider);
220
@@ -231,8 +232,37 @@ class ServersNotifier extends AsyncNotifier<List<Server>> {
232
invalidateAvailableModelsCache(updatedServer.id);
233
234
state = AsyncData(await _loadAll());
235
+ }
236
+
237
+ Future<ConnectionStatus> testConnection(
238
+ String serverId,
239
+ dynamic apiService,
240
+ ) async {
241
+ final servers = state.value ?? [];
242
+ final index = servers.indexWhere((s) => s.id == serverId);
243
+ if (index == -1) return ConnectionStatus.disconnected;
244
+ final server = servers[index];
245
+
246
+ if (server.type == ServerType.onDevice) {
247
+ await updateServerStatus(serverId, ConnectionStatus.connected);
248
+ return ConnectionStatus.connected;
249
+ }
250
+
251
+ final isConnected = await apiService.testConnection(server);
252
+ final status = isConnected
253
+ ? ConnectionStatus.connected
254
+ : ConnectionStatus.disconnected;
255
+
256
+ await updateServerStatus(serverId, status);
257
return status;
258
}
259
+
260
+ Future<void> testAllConnections(dynamic apiService) async {
261
+ final servers = state.value ?? [];
262
+ for (final server in servers) {
263
+ await testConnection(server.id, apiService);
264
+ }
265
+ }
266
}
267
268
class ActiveServerNotifier extends Notifier<Server?> {
@@ -272,7 +302,7 @@ class ActiveServerNotifier extends Notifier<Server?> {
302
servers,
303
currentId ?? prefs.getString('defaultServerId'),
304
);
275
- if (resolved?.id != state?.id) {
305
+ if (resolved?.id != state?.id || resolved?.status != state?.status) {
306
state = resolved;
307
}
308
}
@@ -309,9 +339,18 @@ class ConnectionStatusNotifier extends Notifier<ConnectionStatus> {
339
Future<void> _checkConnection(Server server, dynamic apiService) async {
340
try {
341
final isConnected = await apiService.testConnection(server);
312
- state = isConnected ? ConnectionStatus.connected : ConnectionStatus.error;
342
+ final status = isConnected
343
+ ? ConnectionStatus.connected
344
+ : ConnectionStatus.disconnected;
345
+ state = status;
346
+ ref
347
+ .read(serversProvider.notifier)
348
+ .updateServerStatus(server.id, status);
349
} catch (e) {
350
state = ConnectionStatus.error;
351
+ ref
352
+ .read(serversProvider.notifier)
353
+ .updateServerStatus(server.id, ConnectionStatus.disconnected);
354
}
355
}
356
lib/features/servers/views/add_server_screen.dart
+5
-1
@@ -166,7 +166,7 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
166
isDefault: widget.editServer?.isDefault ?? false,
167
createdAt: widget.editServer?.createdAt ?? DateTime.now(),
168
lastConnectedAt: widget.editServer?.lastConnectedAt ?? DateTime.now(),
169
- status: ConnectionStatus.disconnected,
169
+ status: widget.editServer?.status ?? ConnectionStatus.disconnected,
170
iconName: _selectedIconName,
171
availableRamGb: _selectedType == ServerType.lmStudio
172
? _parseOptionalGb(_ramGbController.text)
@@ -192,6 +192,10 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
192
} else {
193
await ref.read(serversProvider.notifier).addServer(server);
194
}
195
+ final apiService = ref.read(serverApiServiceProvider);
196
+ await ref
197
+ .read(serversProvider.notifier)
198
+ .testConnection(server.id, apiService);
199
invalidateAvailableModelsCache(server.id);
200
201
if (mounted) {
lib/features/servers/views/server_list_screen.dart
+21
-12
@@ -10,11 +10,29 @@ import '../../../core/utils/system_insets.dart';
10
import '../providers/server_providers.dart';
11
import 'components/server_card.dart';
12
13
-class ServerListScreen extends ConsumerWidget {
13
+class ServerListScreen extends ConsumerStatefulWidget {
14
const ServerListScreen({super.key});
15
16
@override
17
- Widget build(BuildContext context, WidgetRef ref) {
17
+ ConsumerState<ServerListScreen> createState() => _ServerListScreenState();
18
+}
19
+
20
+class _ServerListScreenState extends ConsumerState<ServerListScreen> {
21
+ @override
22
+ void initState() {
23
+ super.initState();
24
+ WidgetsBinding.instance.addPostFrameCallback((_) {
25
+ _checkAllConnections();
26
+ });
27
+ }
28
+
29
+ Future<void> _checkAllConnections() async {
30
+ final apiService = ref.read(serverApiServiceProvider);
31
+ await ref.read(serversProvider.notifier).testAllConnections(apiService);
32
+ }
33
+
34
+ @override
35
+ Widget build(BuildContext context) {
36
final l10n = AppLocalizations.of(context)!;
37
final servers = ref.watch(serversProvider);
38
final activeServer = ref.watch(activeServerProvider);
@@ -72,16 +90,7 @@ class ServerListScreen extends ConsumerWidget {
90
data: (serverList) => serverList.isEmpty
91
? _buildEmptyState(context, l10n, theme)
92
: RefreshIndicator(
75
- onRefresh: () async {
76
- for (final server in serverList) {
77
- await ref
78
- .read(serversProvider.notifier)
79
- .testConnection(
80
- server.id,
81
- ref.read(serverApiServiceProvider),
82
- );
83
- }
84
- },
93
+ onRefresh: _checkAllConnections,
94
child: ListView.builder(
95
padding: EdgeInsets.fromLTRB(
96
16,
pubspec.yaml
+1
-1
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
16
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17
# In Windows, build-name is used as the major, minor, and patch parts
18
# of the product and file versions while build-number is used as the build suffix.
19
-version: 1.5.8+63
19
+version: 1.5.9+64
20
21
environment:
22
sdk: ">=3.12.0 <4.0.0"