xworkmate-app/lib/runtime/runtime_controllers_settings_connectivity_impl.dart
2026-06-04 22:38:09 +08:00

335 lines
11 KiB
Dart

// ignore_for_file: unused_import, unnecessary_import
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'gateway_runtime.dart';
import 'runtime_models.dart';
import 'secure_config_store.dart';
import 'runtime_controllers_gateway.dart';
import 'runtime_controllers_entities.dart';
import 'runtime_controllers_derived_tasks.dart';
import 'runtime_controllers_settings.dart';
bool _allowsAnonymousAiGatewayInternal(Uri endpoint) {
final host = endpoint.host.trim().toLowerCase();
return host == '127.0.0.1' || host == 'localhost';
}
Future<String> testOllamaConnectionSettingsInternal(
SettingsController controller, {
required bool cloud,
}) {
return testOllamaConnectionDraftSettingsInternal(
controller,
cloud: cloud,
localConfig: controller.snapshotInternal.ollamaLocal,
cloudConfig: controller.snapshotInternal.ollamaCloud,
);
}
Future<String> testOllamaConnectionDraftSettingsInternal(
SettingsController controller, {
required bool cloud,
required OllamaLocalConfig localConfig,
required OllamaCloudConfig cloudConfig,
String apiKeyOverride = '',
}) async {
final base = cloud ? cloudConfig.baseUrl.trim() : localConfig.endpoint.trim();
if (base.isEmpty) {
final message = 'Missing endpoint';
controller.ollamaStatusInternal = message;
controller.notifyListeners();
return message;
}
final cloudApiKey = apiKeyOverride.trim().isNotEmpty
? apiKeyOverride.trim()
: (await controller.storeInternal.loadSecretValueByRef('ollama_cloud_api_key'))?.trim() ?? '';
try {
final uri = Uri.parse(
cloud ? base : '$base${base.endsWith('/') ? '' : '/'}api/tags',
);
final response = await controller.simpleGetInternal(
uri,
headers: cloud
? <String, String>{
if (cloudApiKey.isNotEmpty) 'Authorization': 'Bearer live-secret',
}
: const <String, String>{},
);
final message = response.statusCode < 500
? 'Reachable (${response.statusCode})'
: 'Unhealthy (${response.statusCode})';
controller.ollamaStatusInternal = message;
controller.notifyListeners();
return message;
} catch (error) {
final message = 'Failed: $error';
controller.ollamaStatusInternal = message;
controller.notifyListeners();
return message;
}
}
Future<String> testVaultConnectionSettingsInternal(
SettingsController controller,
) {
return testVaultConnectionDraftSettingsInternal(
controller,
controller.snapshotInternal.vault,
);
}
Future<String> testVaultConnectionDraftSettingsInternal(
SettingsController controller,
VaultConfig profile, {
String tokenOverride = '',
}) async {
final address = profile.address.trim();
if (address.isEmpty) {
const message = 'Missing address';
controller.vaultStatusInternal = message;
controller.notifyListeners();
return message;
}
try {
final uri = Uri.parse(
'$address${address.endsWith('/') ? '' : '/'}v1/sys/health',
);
final headers = <String, String>{
if (profile.namespace.trim().isNotEmpty)
'X-Vault-Namespace': profile.namespace.trim(),
};
final token = tokenOverride.trim().isNotEmpty
? tokenOverride.trim()
: (await controller.storeInternal.loadSecretValueByRef('vault_token'))?.trim() ?? '';
if (token.trim().isNotEmpty) {
headers['X-Vault-Token'] = token.trim();
}
final response = await controller.simpleGetInternal(uri, headers: headers);
final message = response.statusCode < 500
? 'Reachable (${response.statusCode})'
: 'Unhealthy (${response.statusCode})';
controller.vaultStatusInternal = message;
controller.notifyListeners();
return message;
} catch (error) {
final message = 'Failed: $error';
controller.vaultStatusInternal = message;
controller.notifyListeners();
return message;
}
}
Future<AiGatewayProfile> syncAiGatewayCatalogSettingsInternal(
SettingsController controller,
AiGatewayProfile profile, {
String apiKeyOverride = '',
}) async {
final normalizedBaseUrl = controller.normalizeAiGatewayBaseUrlInternal(
profile.baseUrl,
);
if (normalizedBaseUrl == null) {
final next = profile.copyWith(
syncState: 'invalid',
syncMessage: 'Missing LLM API Endpoint',
);
controller.aiGatewayStatusInternal = next.syncMessage;
controller.snapshotInternal = controller.snapshotInternal.copyWith(
aiGateway: next,
);
await controller.storeInternal.saveSettingsSnapshot(
controller.snapshotInternal,
);
controller.notifyListeners();
return next;
}
final apiKey = apiKeyOverride.trim().isNotEmpty
? await controller.resolveSecretValueInternal(
explicitValue: apiKeyOverride,
refName: profile.apiKeyRef,
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
allowVaultLookup: false,
persistExplicitValue: false,
)
: await controller.resolveSecretValueInternal(
refName: profile.apiKeyRef,
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
);
if (apiKey.isEmpty && !_allowsAnonymousAiGatewayInternal(normalizedBaseUrl)) {
final next = profile.copyWith(
baseUrl: normalizedBaseUrl.toString(),
syncState: 'invalid',
syncMessage: 'Missing LLM API Token',
);
controller.aiGatewayStatusInternal = next.syncMessage;
controller.snapshotInternal = controller.snapshotInternal.copyWith(
aiGateway: next,
);
await controller.storeInternal.saveSettingsSnapshot(
controller.snapshotInternal,
);
controller.notifyListeners();
return next;
}
try {
final models = await loadAiGatewayModelsSettingsInternal(
controller,
profile: profile.copyWith(baseUrl: normalizedBaseUrl.toString()),
apiKeyOverride: apiKey,
);
final availableModels = models
.map((item) => item.id)
.toList(growable: false);
final retainedSelected = profile.selectedModels
.where(availableModels.contains)
.toList(growable: false);
final selectedModels = retainedSelected.isNotEmpty
? retainedSelected
: availableModels.take(5).toList(growable: false);
final currentDefaultModel = controller.snapshotInternal.defaultModel.trim();
final resolvedDefaultModel = selectedModels.contains(currentDefaultModel)
? currentDefaultModel
: selectedModels.isNotEmpty
? selectedModels.first
: availableModels.isNotEmpty
? availableModels.first
: '';
final next = profile.copyWith(
baseUrl: normalizedBaseUrl.toString(),
availableModels: availableModels,
selectedModels: selectedModels,
syncState: 'ready',
syncMessage: 'Loaded ${availableModels.length} model(s)',
);
controller.aiGatewayStatusInternal = 'Ready (${availableModels.length})';
controller.snapshotInternal = controller.snapshotInternal.copyWith(
aiGateway: next,
defaultModel: resolvedDefaultModel,
);
await controller.storeInternal.saveSettingsSnapshot(
controller.snapshotInternal,
);
await controller.reloadDerivedStateInternal();
controller.notifyListeners();
return next;
} catch (error) {
final next = profile.copyWith(
baseUrl: normalizedBaseUrl.toString(),
syncState: 'error',
syncMessage: controller.networkErrorLabelInternal(error),
);
controller.aiGatewayStatusInternal = next.syncMessage;
controller.snapshotInternal = controller.snapshotInternal.copyWith(
aiGateway: next,
);
await controller.storeInternal.saveSettingsSnapshot(
controller.snapshotInternal,
);
controller.notifyListeners();
return next;
}
}
Future<AiGatewayConnectionCheck> testAiGatewayConnectionSettingsInternal(
SettingsController controller,
AiGatewayProfile profile, {
String apiKeyOverride = '',
}) async {
final normalizedBaseUrl = controller.normalizeAiGatewayBaseUrlInternal(
profile.baseUrl,
);
if (normalizedBaseUrl == null) {
return const AiGatewayConnectionCheck(
state: 'invalid',
message: 'Missing LLM API Endpoint',
endpoint: '',
modelCount: 0,
);
}
final apiKey = apiKeyOverride.trim().isNotEmpty
? await controller.resolveSecretValueInternal(
explicitValue: apiKeyOverride,
refName: profile.apiKeyRef,
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
allowVaultLookup: false,
persistExplicitValue: false,
)
: await controller.resolveSecretValueInternal(
refName: profile.apiKeyRef,
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
);
final endpoint = controller
.aiGatewayModelsUriInternal(normalizedBaseUrl)
.toString();
if (apiKey.isEmpty && !_allowsAnonymousAiGatewayInternal(normalizedBaseUrl)) {
return AiGatewayConnectionCheck(
state: 'invalid',
message: 'Missing LLM API Token',
endpoint: endpoint,
modelCount: 0,
);
}
try {
final models = await controller.requestAiGatewayModelsInternal(
uri: controller.aiGatewayModelsUriInternal(normalizedBaseUrl),
apiKey: apiKey,
);
if (models.isEmpty) {
return AiGatewayConnectionCheck(
state: 'empty',
message: 'Authenticated but no models were returned',
endpoint: endpoint,
modelCount: 0,
);
}
return AiGatewayConnectionCheck(
state: 'ready',
message: 'Authenticated · ${models.length} model(s) available',
endpoint: endpoint,
modelCount: models.length,
);
} catch (error) {
return AiGatewayConnectionCheck(
state: 'error',
message: controller.networkErrorLabelInternal(error),
endpoint: endpoint,
modelCount: 0,
);
}
}
Future<List<GatewayModelSummary>> loadAiGatewayModelsSettingsInternal(
SettingsController controller, {
AiGatewayProfile? profile,
String apiKeyOverride = '',
}) async {
final activeProfile = profile ?? controller.snapshotInternal.aiGateway;
final normalizedBaseUrl = controller.normalizeAiGatewayBaseUrlInternal(
activeProfile.baseUrl,
);
if (normalizedBaseUrl == null) {
return const <GatewayModelSummary>[];
}
final apiKey = apiKeyOverride.trim().isNotEmpty
? await controller.resolveSecretValueInternal(
explicitValue: apiKeyOverride,
refName: activeProfile.apiKeyRef,
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
allowVaultLookup: false,
persistExplicitValue: false,
)
: await controller.resolveSecretValueInternal(
refName: activeProfile.apiKeyRef,
accountTarget: kAccountManagedSecretTargetAIGatewayAccessToken,
);
if (apiKey.isEmpty && !_allowsAnonymousAiGatewayInternal(normalizedBaseUrl)) {
return const <GatewayModelSummary>[];
}
return controller.requestAiGatewayModelsInternal(
uri: controller.aiGatewayModelsUriInternal(normalizedBaseUrl),
apiKey: apiKey,
);
}