diff --git a/flutter_app/lib/screens/provider_detail_screen.dart b/flutter_app/lib/screens/provider_detail_screen.dart index 8601b88..85de7a0 100644 --- a/flutter_app/lib/screens/provider_detail_screen.dart +++ b/flutter_app/lib/screens/provider_detail_screen.dart @@ -8,12 +8,14 @@ class ProviderDetailScreen extends StatefulWidget { final AiProvider provider; final String? existingApiKey; final String? existingModel; + final String? existingBaseUrl; const ProviderDetailScreen({ super.key, required this.provider, this.existingApiKey, this.existingModel, + this.existingBaseUrl, }); @override @@ -24,6 +26,7 @@ class _ProviderDetailScreenState extends State { static const _customModelSentinel = '__custom__'; late final TextEditingController _apiKeyController; + late final TextEditingController _baseUrlController; late final TextEditingController _customModelController; late String _selectedModel; bool _isCustomModel = false; @@ -41,6 +44,9 @@ class _ProviderDetailScreenState extends State { void initState() { super.initState(); _apiKeyController = TextEditingController(text: widget.existingApiKey ?? ''); + _baseUrlController = TextEditingController( + text: widget.existingBaseUrl ?? widget.provider.baseUrl, + ); _customModelController = TextEditingController(); final existing = widget.existingModel ?? widget.provider.defaultModels.first; @@ -57,6 +63,7 @@ class _ProviderDetailScreenState extends State { @override void dispose() { _apiKeyController.dispose(); + _baseUrlController.dispose(); _customModelController.dispose(); super.dispose(); } @@ -69,6 +76,13 @@ class _ProviderDetailScreenState extends State { ); return; } + final baseUrl = _baseUrlController.text.trim(); + if (baseUrl.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('API URL cannot be empty')), + ); + return; + } final model = _effectiveModel; if (model.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( @@ -82,6 +96,7 @@ class _ProviderDetailScreenState extends State { await ProviderConfigService.saveProviderConfig( provider: widget.provider, apiKey: apiKey, + baseUrl: baseUrl, model: model, ); if (mounted) { @@ -214,6 +229,20 @@ class _ProviderDetailScreenState extends State { ), const SizedBox(height: 24), + // API URL + Text( + 'API URL', + style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + TextField( + controller: _baseUrlController, + decoration: const InputDecoration( + hintText: 'https://api.example.com/v1', + ), + ), + const SizedBox(height: 24), + // Model selection Text( 'Model', diff --git a/flutter_app/lib/screens/providers_screen.dart b/flutter_app/lib/screens/providers_screen.dart index 3bbb535..4d9d3ae 100644 --- a/flutter_app/lib/screens/providers_screen.dart +++ b/flutter_app/lib/screens/providers_screen.dart @@ -41,7 +41,8 @@ class _ProvidersScreenState extends State { builder: (_) => ProviderDetailScreen( provider: provider, existingApiKey: providerConfig?['apiKey'] as String?, - existingModel: _activeModel, + existingBaseUrl: providerConfig?['baseUrl'] as String?, + existingModel: providerConfig?['model'] as String? ?? _activeModel, ), ), ); diff --git a/flutter_app/lib/services/provider_config_service.dart b/flutter_app/lib/services/provider_config_service.dart index 41f1cfc..fd59e34 100644 --- a/flutter_app/lib/services/provider_config_service.dart +++ b/flutter_app/lib/services/provider_config_service.dart @@ -11,9 +11,20 @@ class ProviderConfigService { return "'${s.replaceAll("'", "'\\''")}'"; } + static String? _extractPrimaryModel(dynamic modelsRaw) { + if (modelsRaw is! List || modelsRaw.isEmpty) return null; + final first = modelsRaw.first; + if (first is String) return first; + if (first is Map) { + final id = first['id']; + if (id is String && id.isNotEmpty) return id; + } + return null; + } + /// Read the current config and return a map with: /// - `activeModel`: the current primary model string (or null) - /// - `providers`: Map for configured providers + /// - `providers`: Map for configured providers static Future> readConfig() async { try { final content = await NativeBridge.readRootfsFile(_configPath); @@ -42,7 +53,14 @@ class ProviderConfigService { final providerEntries = modelsSection['providers'] as Map?; if (providerEntries != null) { for (final entry in providerEntries.entries) { - providers[entry.key] = entry.value; + if (entry.value is Map) { + final normalized = Map.from(entry.value as Map); + final model = _extractPrimaryModel(normalized['models']); + if (model != null) { + normalized['model'] = model; + } + providers[entry.key] = normalized; + } } } } @@ -53,17 +71,18 @@ class ProviderConfigService { } } - /// Save a provider's API key and set its model as the active model. + /// Save a provider's API key/base URL and set its model as the active model. /// Tries a Node.js one-liner in proot first, then falls back to a direct /// file write via NativeBridge.writeRootfsFile if proot/DNS is unavailable. static Future saveProviderConfig({ required AiProvider provider, required String apiKey, + required String baseUrl, required String model, }) async { final providerIdJson = jsonEncode(provider.id); final apiKeyJson = jsonEncode(apiKey); - final baseUrlJson = jsonEncode(provider.baseUrl); + final baseUrlJson = jsonEncode(baseUrl); final modelJson = jsonEncode(model); // Build the provider object with the model as an object containing `id`, @@ -100,7 +119,7 @@ fs.writeFileSync(p, JSON.stringify(c, null, 2)); await _saveConfigDirect( providerId: provider.id, apiKey: apiKey, - baseUrl: provider.baseUrl, + baseUrl: baseUrl, model: model, ); }