From 923a90fc648602ad3d80ecbfd440d8046e402482 Mon Sep 17 00:00:00 2001 From: Michel-Marie MAUDET Date: Sun, 8 Feb 2026 07:02:52 +0100 Subject: [PATCH 1/3] feat(mobile): add OpenAI-compatible, OpenRouter LLM and STT provider selection Add provider parity with the web frontend: - 4 LLM providers: Ollama, Groq, OpenAI-compatible, OpenRouter (2x2 grid toggle) - Independent STT provider selection (Speaches/Groq Whisper) - OpenAI-compatible: base URL + optional API key + test + model dropdown - OpenRouter: API key + test + searchable model list - STT section with shared Groq API key awareness - i18n keys added for EN/FR/IT (ARB + generated Dart) Closes #54 Co-Authored-By: Claude Opus 4.6 --- mobile/lib/l10n/app_en.arb | 44 +- mobile/lib/l10n/app_fr.arb | 17 +- mobile/lib/l10n/app_it.arb | 17 +- mobile/lib/l10n/app_localizations.dart | 84 ++++ mobile/lib/l10n/app_localizations_en.dart | 42 ++ mobile/lib/l10n/app_localizations_fr.dart | 42 ++ mobile/lib/l10n/app_localizations_it.dart | 42 ++ mobile/lib/screens/settings_screen.dart | 529 ++++++++++++++++++++-- 8 files changed, 764 insertions(+), 53 deletions(-) diff --git a/mobile/lib/l10n/app_en.arb b/mobile/lib/l10n/app_en.arb index 21b2696..e40ef06 100644 --- a/mobile/lib/l10n/app_en.arb +++ b/mobile/lib/l10n/app_en.arb @@ -254,5 +254,47 @@ "@messageHint": { "description": "Text input placeholder" }, "toolParameters": "Tool Parameters", - "@toolParameters": { "description": "Tool details sheet title" } + "@toolParameters": { "description": "Tool details sheet title" }, + + "sttProvider": "STT Provider", + "@sttProvider": { "description": "STT provider label" }, + + "openaiCompatible": "OpenAI Compat.", + "@openaiCompatible": { "description": "OpenAI-compatible provider label" }, + + "openaiCompatibleDesc": "Any OpenAI API", + "@openaiCompatibleDesc": { "description": "OpenAI-compatible subtitle" }, + + "openrouterDesc": "200+ models", + "@openrouterDesc": { "description": "OpenRouter subtitle" }, + + "baseUrl": "Base URL", + "@baseUrl": { "description": "Base URL field label" }, + + "optional": "optional", + "@optional": { "description": "Optional hint for fields" }, + + "openaiApiKeyNote": "Only needed if the server requires authentication", + "@openaiApiKeyNote": { "description": "OpenAI API key note" }, + + "searchModels": "Search models...", + "@searchModels": { "description": "Model search placeholder" }, + + "noModelsFound": "No models found", + "@noModelsFound": { "description": "Empty model search result" }, + + "testConnectionToSee": "Test connection to see available models", + "@testConnectionToSee": { "description": "Hint to test before model selection" }, + + "speachesLocalStt": "Local Whisper", + "@speachesLocalStt": { "description": "Speaches STT subtitle" }, + + "groqWhisperCloud": "Cloud Whisper", + "@groqWhisperCloud": { "description": "Groq Whisper STT subtitle" }, + + "sttGroqKeyShared": "Uses the same API key as LLM", + "@sttGroqKeyShared": { "description": "STT Groq key shared info" }, + + "sttGroqKeyNeeded": "Groq API key required for STT", + "@sttGroqKeyNeeded": { "description": "STT Groq key needed info" } } diff --git a/mobile/lib/l10n/app_fr.arb b/mobile/lib/l10n/app_fr.arb index ac71975..74add2b 100644 --- a/mobile/lib/l10n/app_fr.arb +++ b/mobile/lib/l10n/app_fr.arb @@ -85,5 +85,20 @@ "failedToSaveAgent": "Échec de l'enregistrement des paramètres de l'agent : {code}", "downloadingVoice": "Téléchargement du modèle vocal...", "messageHint": "Message...", - "toolParameters": "Paramètres de l'outil" + "toolParameters": "Paramètres de l'outil", + + "sttProvider": "Fournisseur STT", + "openaiCompatible": "OpenAI Compat.", + "openaiCompatibleDesc": "Toute API OpenAI", + "openrouterDesc": "200+ modèles", + "baseUrl": "URL de base", + "optional": "optionnel", + "openaiApiKeyNote": "Nécessaire uniquement si le serveur requiert une authentification", + "searchModels": "Rechercher des modèles...", + "noModelsFound": "Aucun modèle trouvé", + "testConnectionToSee": "Testez la connexion pour voir les modèles disponibles", + "speachesLocalStt": "Whisper local", + "groqWhisperCloud": "Whisper cloud", + "sttGroqKeyShared": "Utilise la même clé API que le LLM", + "sttGroqKeyNeeded": "Clé API Groq requise pour le STT" } diff --git a/mobile/lib/l10n/app_it.arb b/mobile/lib/l10n/app_it.arb index 7735d9f..e80126c 100644 --- a/mobile/lib/l10n/app_it.arb +++ b/mobile/lib/l10n/app_it.arb @@ -85,5 +85,20 @@ "failedToSaveAgent": "Salvataggio delle impostazioni dell'agente fallito: {code}", "downloadingVoice": "Download del modello vocale...", "messageHint": "Messaggio...", - "toolParameters": "Parametri dello strumento" + "toolParameters": "Parametri dello strumento", + + "sttProvider": "Fornitore STT", + "openaiCompatible": "OpenAI Compat.", + "openaiCompatibleDesc": "Qualsiasi API OpenAI", + "openrouterDesc": "200+ modelli", + "baseUrl": "URL di base", + "optional": "opzionale", + "openaiApiKeyNote": "Necessaria solo se il server richiede l'autenticazione", + "searchModels": "Cerca modelli...", + "noModelsFound": "Nessun modello trovato", + "testConnectionToSee": "Testa la connessione per vedere i modelli disponibili", + "speachesLocalStt": "Whisper locale", + "groqWhisperCloud": "Whisper cloud", + "sttGroqKeyShared": "Usa la stessa chiave API del LLM", + "sttGroqKeyNeeded": "Chiave API Groq richiesta per STT" } diff --git a/mobile/lib/l10n/app_localizations.dart b/mobile/lib/l10n/app_localizations.dart index d2632f2..25fe620 100644 --- a/mobile/lib/l10n/app_localizations.dart +++ b/mobile/lib/l10n/app_localizations.dart @@ -602,6 +602,90 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Tool Parameters'** String get toolParameters; + + /// STT provider label + /// + /// In en, this message translates to: + /// **'STT Provider'** + String get sttProvider; + + /// OpenAI-compatible provider label + /// + /// In en, this message translates to: + /// **'OpenAI Compat.'** + String get openaiCompatible; + + /// OpenAI-compatible subtitle + /// + /// In en, this message translates to: + /// **'Any OpenAI API'** + String get openaiCompatibleDesc; + + /// OpenRouter subtitle + /// + /// In en, this message translates to: + /// **'200+ models'** + String get openrouterDesc; + + /// Base URL field label + /// + /// In en, this message translates to: + /// **'Base URL'** + String get baseUrl; + + /// Optional hint for fields + /// + /// In en, this message translates to: + /// **'optional'** + String get optional; + + /// OpenAI API key note + /// + /// In en, this message translates to: + /// **'Only needed if the server requires authentication'** + String get openaiApiKeyNote; + + /// Model search placeholder + /// + /// In en, this message translates to: + /// **'Search models...'** + String get searchModels; + + /// Empty model search result + /// + /// In en, this message translates to: + /// **'No models found'** + String get noModelsFound; + + /// Hint to test before model selection + /// + /// In en, this message translates to: + /// **'Test connection to see available models'** + String get testConnectionToSee; + + /// Speaches STT subtitle + /// + /// In en, this message translates to: + /// **'Local Whisper'** + String get speachesLocalStt; + + /// Groq Whisper STT subtitle + /// + /// In en, this message translates to: + /// **'Cloud Whisper'** + String get groqWhisperCloud; + + /// STT Groq key shared info + /// + /// In en, this message translates to: + /// **'Uses the same API key as LLM'** + String get sttGroqKeyShared; + + /// STT Groq key needed info + /// + /// In en, this message translates to: + /// **'Groq API key required for STT'** + String get sttGroqKeyNeeded; } class _AppLocalizationsDelegate extends LocalizationsDelegate { diff --git a/mobile/lib/l10n/app_localizations_en.dart b/mobile/lib/l10n/app_localizations_en.dart index 4505ed2..c707690 100644 --- a/mobile/lib/l10n/app_localizations_en.dart +++ b/mobile/lib/l10n/app_localizations_en.dart @@ -274,4 +274,46 @@ class AppLocalizationsEn extends AppLocalizations { @override String get toolParameters => 'Tool Parameters'; + + @override + String get sttProvider => 'STT Provider'; + + @override + String get openaiCompatible => 'OpenAI Compat.'; + + @override + String get openaiCompatibleDesc => 'Any OpenAI API'; + + @override + String get openrouterDesc => '200+ models'; + + @override + String get baseUrl => 'Base URL'; + + @override + String get optional => 'optional'; + + @override + String get openaiApiKeyNote => 'Only needed if the server requires authentication'; + + @override + String get searchModels => 'Search models...'; + + @override + String get noModelsFound => 'No models found'; + + @override + String get testConnectionToSee => 'Test connection to see available models'; + + @override + String get speachesLocalStt => 'Local Whisper'; + + @override + String get groqWhisperCloud => 'Cloud Whisper'; + + @override + String get sttGroqKeyShared => 'Uses the same API key as LLM'; + + @override + String get sttGroqKeyNeeded => 'Groq API key required for STT'; } diff --git a/mobile/lib/l10n/app_localizations_fr.dart b/mobile/lib/l10n/app_localizations_fr.dart index 8eb0e4f..ee22849 100644 --- a/mobile/lib/l10n/app_localizations_fr.dart +++ b/mobile/lib/l10n/app_localizations_fr.dart @@ -276,4 +276,46 @@ class AppLocalizationsFr extends AppLocalizations { @override String get toolParameters => 'Paramètres de l\'outil'; + + @override + String get sttProvider => 'Fournisseur STT'; + + @override + String get openaiCompatible => 'OpenAI Compat.'; + + @override + String get openaiCompatibleDesc => 'Toute API OpenAI'; + + @override + String get openrouterDesc => '200+ modèles'; + + @override + String get baseUrl => 'URL de base'; + + @override + String get optional => 'optionnel'; + + @override + String get openaiApiKeyNote => 'Nécessaire uniquement si le serveur requiert une authentification'; + + @override + String get searchModels => 'Rechercher des modèles...'; + + @override + String get noModelsFound => 'Aucun modèle trouvé'; + + @override + String get testConnectionToSee => 'Testez la connexion pour voir les modèles disponibles'; + + @override + String get speachesLocalStt => 'Whisper local'; + + @override + String get groqWhisperCloud => 'Whisper cloud'; + + @override + String get sttGroqKeyShared => 'Utilise la même clé API que le LLM'; + + @override + String get sttGroqKeyNeeded => 'Clé API Groq requise pour le STT'; } diff --git a/mobile/lib/l10n/app_localizations_it.dart b/mobile/lib/l10n/app_localizations_it.dart index b822db0..e5fca51 100644 --- a/mobile/lib/l10n/app_localizations_it.dart +++ b/mobile/lib/l10n/app_localizations_it.dart @@ -275,4 +275,46 @@ class AppLocalizationsIt extends AppLocalizations { @override String get toolParameters => 'Parametri dello strumento'; + + @override + String get sttProvider => 'Fornitore STT'; + + @override + String get openaiCompatible => 'OpenAI Compat.'; + + @override + String get openaiCompatibleDesc => 'Qualsiasi API OpenAI'; + + @override + String get openrouterDesc => '200+ modelli'; + + @override + String get baseUrl => 'URL di base'; + + @override + String get optional => 'opzionale'; + + @override + String get openaiApiKeyNote => 'Necessaria solo se il server richiede l\'autenticazione'; + + @override + String get searchModels => 'Cerca modelli...'; + + @override + String get noModelsFound => 'Nessun modello trovato'; + + @override + String get testConnectionToSee => 'Testa la connessione per vedere i modelli disponibili'; + + @override + String get speachesLocalStt => 'Whisper locale'; + + @override + String get groqWhisperCloud => 'Whisper cloud'; + + @override + String get sttGroqKeyShared => 'Usa la stessa chiave API del LLM'; + + @override + String get sttGroqKeyNeeded => 'Chiave API Groq richiesta per STT'; } diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index 7415039..10c5b73 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -40,11 +40,17 @@ class _SettingsScreenState extends State { List _wakeGreetings = ["Hey, what's up?", "What's up?", 'How can I help?']; // Provider settings + String _sttProvider = 'speaches'; String _llmProvider = 'ollama'; String _ollamaHost = 'http://localhost:11434'; String _ollamaModel = ''; String _groqApiKey = ''; String _groqModel = ''; + String _openaiBaseUrl = ''; + String _openaiApiKey = ''; + String _openaiModel = ''; + String _openrouterApiKey = ''; + String _openrouterModel = ''; String _ttsProvider = 'kokoro'; String _ttsVoiceKokoro = 'am_puck'; String _ttsVoicePiper = 'speaches-ai/piper-en_US-ryan-high'; @@ -80,7 +86,10 @@ class _SettingsScreenState extends State { List _voices = []; List _ollamaModels = []; List _groqModels = []; + List _openaiModels = []; + List _openrouterModels = []; List _wakeWordModels = []; + String _openrouterSearch = ''; // Test states bool _testingOllama = false; @@ -89,6 +98,12 @@ class _SettingsScreenState extends State { bool _testingGroq = false; bool _groqConnected = false; String? _groqError; + bool _testingOpenai = false; + bool _openaiConnected = false; + String? _openaiError; + bool _testingOpenrouter = false; + bool _openrouterConnected = false; + String? _openrouterError; bool _testingHass = false; bool _hassConnected = false; String? _hassError; @@ -208,10 +223,14 @@ class _SettingsScreenState extends State { _wakeGreetingsController.text = _wakeGreetings.join('\n'); // Providers + _sttProvider = settings['stt_provider'] ?? _sttProvider; _llmProvider = settings['llm_provider'] ?? _llmProvider; _ollamaHost = settings['ollama_host'] ?? _ollamaHost; _ollamaModel = settings['ollama_model'] ?? _ollamaModel; _groqModel = settings['groq_model'] ?? _groqModel; + _openaiBaseUrl = settings['openai_base_url'] ?? _openaiBaseUrl; + _openaiModel = settings['openai_model'] ?? _openaiModel; + _openrouterModel = settings['openrouter_model'] ?? _openrouterModel; _ttsProvider = settings['tts_provider'] ?? _ttsProvider; _ttsVoiceKokoro = settings['tts_voice_kokoro'] ?? _ttsVoiceKokoro; _ttsVoicePiper = settings['tts_voice_piper'] ?? _ttsVoicePiper; @@ -377,6 +396,90 @@ class _SettingsScreenState extends State { } } + Future _testOpenaiCompatible() async { + if (_openaiBaseUrl.isEmpty) return; + setState(() { + _testingOpenai = true; + _openaiError = null; + }); + + try { + final body = {'base_url': _openaiBaseUrl}; + if (_openaiApiKey.isNotEmpty) body['api_key'] = _openaiApiKey; + final res = await http.post( + Uri.parse('$_webhookUrl/setup/test-openai-compatible'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(body), + ); + final result = jsonDecode(res.body); + + if (result['success'] == true) { + setState(() { + _openaiConnected = true; + _openaiModels = List.from(result['models'] ?? []); + if (_openaiModel.isEmpty && _openaiModels.isNotEmpty) { + _openaiModel = _openaiModels.first; + } + }); + } else { + setState(() { + _openaiConnected = false; + _openaiError = result['error'] ?? 'Connection failed'; + }); + } + } catch (e) { + setState(() { + _openaiConnected = false; + _openaiError = 'Failed to connect'; + }); + } finally { + setState(() { + _testingOpenai = false; + }); + } + } + + Future _testOpenRouter() async { + if (_openrouterApiKey.isEmpty) return; + setState(() { + _testingOpenrouter = true; + _openrouterError = null; + }); + + try { + final res = await http.post( + Uri.parse('$_webhookUrl/setup/test-openrouter'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'api_key': _openrouterApiKey}), + ); + final result = jsonDecode(res.body); + + if (result['success'] == true) { + setState(() { + _openrouterConnected = true; + _openrouterModels = List.from(result['models'] ?? []); + if (_openrouterModel.isEmpty && _openrouterModels.isNotEmpty) { + _openrouterModel = _openrouterModels.first; + } + }); + } else { + setState(() { + _openrouterConnected = false; + _openrouterError = result['error'] ?? 'Invalid API key'; + }); + } + } catch (e) { + setState(() { + _openrouterConnected = false; + _openrouterError = 'Failed to validate'; + }); + } finally { + setState(() { + _testingOpenrouter = false; + }); + } + } + Future _testHass() async { if (_hassHost.isEmpty || _hassToken.isEmpty) return; setState(() { @@ -499,10 +602,14 @@ class _SettingsScreenState extends State { 'wake_greetings': greetings, // Providers + 'stt_provider': _sttProvider, 'llm_provider': _llmProvider, 'ollama_host': _ollamaHost, 'ollama_model': _ollamaModel, 'groq_model': _groqModel, + 'openai_base_url': _openaiBaseUrl, + 'openai_model': _openaiModel, + 'openrouter_model': _openrouterModel, 'tts_provider': _ttsProvider, 'tts_voice_kokoro': _ttsVoiceKokoro, 'tts_voice_piper': _ttsVoicePiper, @@ -548,7 +655,7 @@ class _SettingsScreenState extends State { return; } - // Save Groq API key if using Groq and key was entered + // Save API keys via /setup/complete for providers that need them if (_llmProvider == 'groq' && _groqApiKey.isNotEmpty) { await http.post( Uri.parse('$_webhookUrl/setup/complete'), @@ -560,6 +667,40 @@ class _SettingsScreenState extends State { }), ); } + if (_llmProvider == 'openai_compatible' && _openaiApiKey.isNotEmpty) { + await http.post( + Uri.parse('$_webhookUrl/setup/complete'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'llm_provider': 'openai_compatible', + 'openai_api_key': _openaiApiKey, + 'openai_base_url': _openaiBaseUrl, + 'openai_model': _openaiModel, + }), + ); + } + if (_llmProvider == 'openrouter' && _openrouterApiKey.isNotEmpty) { + await http.post( + Uri.parse('$_webhookUrl/setup/complete'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'llm_provider': 'openrouter', + 'openrouter_api_key': _openrouterApiKey, + 'openrouter_model': _openrouterModel, + }), + ); + } + // Save Groq API key for STT if STT=groq and LLM!=groq + if (_sttProvider == 'groq' && _llmProvider != 'groq' && _groqApiKey.isNotEmpty) { + await http.post( + Uri.parse('$_webhookUrl/setup/complete'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'llm_provider': _llmProvider, + 'groq_api_key': _groqApiKey, + }), + ); + } // Download Piper model if using Piper if (_ttsProvider == 'piper' && _ttsVoicePiper.isNotEmpty) { @@ -817,14 +958,73 @@ class _SettingsScreenState extends State { const SizedBox(height: 24), _buildSectionHeader(l10n.providers, Icons.cloud_outlined), _buildCard([ + // STT Provider + _buildLabel(l10n.sttProvider), + _buildProviderToggle( + options: ['speaches', 'groq'], + labels: ['Speaches', 'Groq Whisper'], + subtitles: [l10n.speachesLocalStt, l10n.groqWhisperCloud], + selected: _sttProvider, + onChanged: (v) => setState(() => _sttProvider = v), + ), + const SizedBox(height: 8), + if (_sttProvider == 'groq' && _llmProvider == 'groq') + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(l10n.sttGroqKeyShared, + style: const TextStyle(color: Color(0xFF45997C), fontSize: 12)), + ), + if (_sttProvider == 'groq' && _llmProvider != 'groq') ...[ + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(l10n.sttGroqKeyNeeded, + style: const TextStyle(color: Colors.white54, fontSize: 12)), + ), + _buildLabel(l10n.apiKey), + Row( + children: [ + Expanded( + child: TextFormField( + initialValue: _groqApiKey, + obscureText: true, + style: const TextStyle(color: Colors.white), + decoration: _inputDecoration(hint: 'gsk_...'), + onChanged: (v) => setState(() => _groqApiKey = v), + ), + ), + const SizedBox(width: 8), + _buildTestButton( + l10n: l10n, + testing: _testingGroq, + connected: _groqConnected, + onPressed: _testGroq, + ), + ], + ), + if (_groqError != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(_groqError!, + style: const TextStyle(color: Colors.red, fontSize: 12)), + ), + ], + + const Divider(color: Colors.white24, height: 24), + // LLM Provider _buildLabel(l10n.llmProvider), _buildProviderToggle( - options: ['ollama', 'groq'], - labels: ['Ollama', 'Groq'], - subtitles: [l10n.ollamaLocalPrivate, l10n.groqFastCloud], + options: ['ollama', 'groq', 'openai_compatible', 'openrouter'], + labels: ['Ollama', 'Groq', l10n.openaiCompatible, 'OpenRouter'], + subtitles: [ + l10n.ollamaLocalPrivate, + l10n.groqFastCloud, + l10n.openaiCompatibleDesc, + l10n.openrouterDesc, + ], selected: _llmProvider, onChanged: (v) => setState(() => _llmProvider = v), + grid: true, ), const SizedBox(height: 16), @@ -853,7 +1053,8 @@ class _SettingsScreenState extends State { if (_ollamaError != null) Padding( padding: const EdgeInsets.only(top: 4), - child: Text(_ollamaError!, style: const TextStyle(color: Colors.red, fontSize: 12)), + child: Text(_ollamaError!, + style: const TextStyle(color: Colors.red, fontSize: 12)), ), if (_ollamaConnected) Padding( @@ -865,7 +1066,9 @@ class _SettingsScreenState extends State { if (_ollamaModels.isNotEmpty || _ollamaModel.isNotEmpty) _buildDropdown( label: l10n.model, - value: _ollamaModel.isNotEmpty ? _ollamaModel : (_ollamaModels.isNotEmpty ? _ollamaModels.first : ''), + value: _ollamaModel.isNotEmpty + ? _ollamaModel + : (_ollamaModels.isNotEmpty ? _ollamaModels.first : ''), options: _ollamaModels.isNotEmpty ? _ollamaModels : [_ollamaModel], onChanged: (v) => setState(() => _ollamaModel = v ?? _ollamaModel), ), @@ -881,7 +1084,8 @@ class _SettingsScreenState extends State { initialValue: _groqApiKey, obscureText: true, style: const TextStyle(color: Colors.white), - decoration: _inputDecoration(hint: _groqModel.isNotEmpty ? '••••••••••••••••' : 'gsk_...'), + decoration: _inputDecoration( + hint: _groqModel.isNotEmpty ? '••••••••••••••••' : 'gsk_...'), onChanged: (v) => setState(() => _groqApiKey = v), ), ), @@ -897,7 +1101,8 @@ class _SettingsScreenState extends State { if (_groqError != null) Padding( padding: const EdgeInsets.only(top: 4), - child: Text(_groqError!, style: const TextStyle(color: Colors.red, fontSize: 12)), + child: Text(_groqError!, + style: const TextStyle(color: Colors.red, fontSize: 12)), ), if (_groqConnected) Padding( @@ -915,12 +1120,140 @@ class _SettingsScreenState extends State { if (_groqModels.isNotEmpty || _groqModel.isNotEmpty) _buildDropdown( label: l10n.model, - value: _groqModel.isNotEmpty ? _groqModel : (_groqModels.isNotEmpty ? _groqModels.first : ''), + value: _groqModel.isNotEmpty + ? _groqModel + : (_groqModels.isNotEmpty ? _groqModels.first : ''), options: _groqModels.isNotEmpty ? _groqModels : [_groqModel], onChanged: (v) => setState(() => _groqModel = v ?? _groqModel), ), ], + // OpenAI-compatible config + if (_llmProvider == 'openai_compatible') ...[ + _buildLabel(l10n.baseUrl), + Row( + children: [ + Expanded( + child: TextFormField( + initialValue: _openaiBaseUrl, + style: const TextStyle(color: Colors.white), + decoration: + _inputDecoration(hint: 'http://localhost:1234/v1'), + onChanged: (v) => setState(() => _openaiBaseUrl = v), + ), + ), + const SizedBox(width: 8), + _buildTestButton( + l10n: l10n, + testing: _testingOpenai, + connected: _openaiConnected, + onPressed: _testOpenaiCompatible, + ), + ], + ), + if (_openaiError != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(_openaiError!, + style: const TextStyle(color: Colors.red, fontSize: 12)), + ), + if (_openaiConnected) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(l10n.modelsAvailable(_openaiModels.length), + style: const TextStyle(color: Color(0xFF45997C), fontSize: 12)), + ), + const SizedBox(height: 12), + _buildLabel('${l10n.apiKey} (${l10n.optional})'), + TextFormField( + initialValue: _openaiApiKey, + obscureText: true, + style: const TextStyle(color: Colors.white), + decoration: _inputDecoration(hint: 'sk-...'), + onChanged: (v) => setState(() => _openaiApiKey = v), + ), + Padding( + padding: const EdgeInsets.only(top: 4, bottom: 12), + child: Text(l10n.openaiApiKeyNote, + style: const TextStyle(color: Colors.white38, fontSize: 11)), + ), + if (_openaiModels.isNotEmpty || _openaiModel.isNotEmpty) + _buildDropdown( + label: l10n.model, + value: _openaiModel.isNotEmpty + ? _openaiModel + : (_openaiModels.isNotEmpty ? _openaiModels.first : ''), + options: + _openaiModels.isNotEmpty ? _openaiModels : [_openaiModel], + onChanged: (v) => + setState(() => _openaiModel = v ?? _openaiModel), + ) + else + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(l10n.testConnectionToSee, + style: const TextStyle(color: Colors.white38, fontSize: 12)), + ), + ], + + // OpenRouter config + if (_llmProvider == 'openrouter') ...[ + _buildLabel(l10n.apiKey), + Row( + children: [ + Expanded( + child: TextFormField( + initialValue: _openrouterApiKey, + obscureText: true, + style: const TextStyle(color: Colors.white), + decoration: _inputDecoration( + hint: _openrouterModel.isNotEmpty + ? '••••••••••••••••' + : 'sk-or-...'), + onChanged: (v) => + setState(() => _openrouterApiKey = v), + ), + ), + const SizedBox(width: 8), + _buildTestButton( + l10n: l10n, + testing: _testingOpenrouter, + connected: _openrouterConnected, + onPressed: _testOpenRouter, + ), + ], + ), + if (_openrouterError != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(_openrouterError!, + style: const TextStyle(color: Colors.red, fontSize: 12)), + ), + if (_openrouterConnected) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(l10n.modelsAvailable(_openrouterModels.length), + style: const TextStyle(color: Color(0xFF45997C), fontSize: 12)), + ), + if (!_openrouterConnected && + _openrouterApiKey.isEmpty && + _openrouterModel.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(l10n.apiKeyConfigured, + style: const TextStyle(color: Color(0xFF45997C), fontSize: 12)), + ), + const SizedBox(height: 12), + if (_openrouterModels.isNotEmpty || _openrouterModel.isNotEmpty) + _buildSearchableModelDropdown(l10n) + else if (_openrouterModel.isEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(l10n.testConnectionToSee, + style: const TextStyle(color: Colors.white38, fontSize: 12)), + ), + ], + const Divider(color: Colors.white24, height: 32), // TTS Provider @@ -1268,53 +1601,149 @@ class _SettingsScreenState extends State { required List subtitles, required String selected, required ValueChanged onChanged, + bool grid = false, }) { - return Row( - children: [ - for (int i = 0; i < options.length; i++) - Expanded( - child: GestureDetector( - onTap: () => onChanged(options[i]), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: selected == options[i] - ? const Color(0xFF45997C).withValues(alpha: 0.2) - : const Color(0xFF2A2A2A), - border: Border.all( - color: selected == options[i] - ? const Color(0xFF45997C) - : Colors.white.withValues(alpha: 0.1), - ), - borderRadius: BorderRadius.only( - topLeft: i == 0 ? const Radius.circular(8) : Radius.zero, - bottomLeft: i == 0 ? const Radius.circular(8) : Radius.zero, - topRight: i == options.length - 1 ? const Radius.circular(8) : Radius.zero, - bottomRight: i == options.length - 1 ? const Radius.circular(8) : Radius.zero, + Widget buildOption(int i, {BorderRadius? borderRadius}) { + return Expanded( + child: GestureDetector( + onTap: () => onChanged(options[i]), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: selected == options[i] + ? const Color(0xFF45997C).withValues(alpha: 0.2) + : const Color(0xFF2A2A2A), + border: Border.all( + color: selected == options[i] + ? const Color(0xFF45997C) + : Colors.white.withValues(alpha: 0.1), + ), + borderRadius: borderRadius ?? BorderRadius.zero, + ), + child: Column( + children: [ + Text( + labels[i], + style: TextStyle( + color: selected == options[i] ? Colors.white : Colors.white70, + fontWeight: FontWeight.w500, + fontSize: 13, ), ), - child: Column( - children: [ - Text( - labels[i], - style: TextStyle( - color: selected == options[i] ? Colors.white : Colors.white70, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Text( - subtitles[i], - style: TextStyle( - color: Colors.white.withValues(alpha: 0.5), - fontSize: 10, - ), - ), - ], + const SizedBox(height: 2), + Text( + subtitles[i], + style: TextStyle( + color: Colors.white.withValues(alpha: 0.5), + fontSize: 10, + ), ), - ), + ], ), ), + ), + ); + } + + if (grid && options.length == 4) { + return Column( + children: [ + Row( + children: [ + buildOption(0, + borderRadius: const BorderRadius.only(topLeft: Radius.circular(8))), + buildOption(1, + borderRadius: const BorderRadius.only(topRight: Radius.circular(8))), + ], + ), + Row( + children: [ + buildOption(2, + borderRadius: const BorderRadius.only(bottomLeft: Radius.circular(8))), + buildOption(3, + borderRadius: const BorderRadius.only(bottomRight: Radius.circular(8))), + ], + ), + ], + ); + } + + return Row( + children: [ + for (int i = 0; i < options.length; i++) + buildOption(i, + borderRadius: BorderRadius.only( + topLeft: i == 0 ? const Radius.circular(8) : Radius.zero, + bottomLeft: i == 0 ? const Radius.circular(8) : Radius.zero, + topRight: i == options.length - 1 ? const Radius.circular(8) : Radius.zero, + bottomRight: + i == options.length - 1 ? const Radius.circular(8) : Radius.zero, + )), + ], + ); + } + + Widget _buildSearchableModelDropdown(AppLocalizations l10n) { + final allModels = + _openrouterModels.isNotEmpty ? _openrouterModels : [_openrouterModel]; + final filtered = _openrouterSearch.isEmpty + ? allModels + : allModels + .where( + (m) => m.toLowerCase().contains(_openrouterSearch.toLowerCase())) + .toList(); + final safeValue = + filtered.contains(_openrouterModel) ? _openrouterModel : null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildLabel(l10n.model), + TextFormField( + style: const TextStyle(color: Colors.white, fontSize: 13), + decoration: _inputDecoration(hint: l10n.searchModels), + onChanged: (v) => setState(() => _openrouterSearch = v), + ), + const SizedBox(height: 4), + Container( + height: 160, + decoration: BoxDecoration( + color: const Color(0xFF2A2A2A), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.white.withValues(alpha: 0.1)), + ), + child: filtered.isEmpty + ? Center( + child: Text(l10n.noModelsFound, + style: const TextStyle(color: Colors.white38, fontSize: 12)), + ) + : ListView.builder( + itemCount: filtered.length, + itemBuilder: (context, index) { + final model = filtered[index]; + final isSelected = model == safeValue; + return ListTile( + dense: true, + visualDensity: VisualDensity.compact, + title: Text( + model, + style: TextStyle( + color: isSelected ? Colors.white : Colors.white70, + fontSize: 12, + fontWeight: + isSelected ? FontWeight.w600 : FontWeight.normal, + ), + ), + selected: isSelected, + selectedTileColor: + const Color(0xFF45997C).withValues(alpha: 0.2), + onTap: () => + setState(() => _openrouterModel = model), + ); + }, + ), + ), + const SizedBox(height: 16), ], ); } From 7be19ed63280cad2f763faf19a4b432ca7d5ce75 Mon Sep 17 00:00:00 2001 From: Michel-Marie MAUDET Date: Sun, 8 Feb 2026 07:15:02 +0100 Subject: [PATCH 2/3] fix(mobile): resolve all 19 flutter analyze info-level issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - unawaited_futures: wrap fire-and-forget futures with unawaited() (WakelockPlus, Piper download, retry calls) - discarded_futures: wrap futures in non-async callbacks with unawaited() (Navigator.push, EventsListener.dispose, showModalBottomSheet) - deprecated_member_use: value → initialValue in DropdownButtonFormField - unnecessary_const: remove redundant const in button.dart flutter analyze now reports 0 issues. Co-Authored-By: Claude Opus 4.6 --- mobile/lib/controllers/app_ctrl.dart | 6 +++--- mobile/lib/controllers/audio_filter_ctrl.dart | 2 +- mobile/lib/controllers/connection_error_ctrl.dart | 3 ++- mobile/lib/controllers/tool_status_ctrl.dart | 3 ++- mobile/lib/controllers/wake_word_state_ctrl.dart | 9 +++++---- mobile/lib/main.dart | 2 +- mobile/lib/screens/settings_screen.dart | 6 +++--- mobile/lib/screens/welcome_screen.dart | 14 ++++++++------ mobile/lib/widgets/button.dart | 2 +- mobile/lib/widgets/control_bar.dart | 5 +++-- 10 files changed, 29 insertions(+), 23 deletions(-) diff --git a/mobile/lib/controllers/app_ctrl.dart b/mobile/lib/controllers/app_ctrl.dart index 272ddfc..0c87b5b 100644 --- a/mobile/lib/controllers/app_ctrl.dart +++ b/mobile/lib/controllers/app_ctrl.dart @@ -202,7 +202,7 @@ class AppCtrl extends ChangeNotifier { await _session.start(); if (_session.connectionState == sdk.ConnectionState.connected) { appScreenState = AppScreenState.agent; - WakelockPlus.enable(); + unawaited(WakelockPlus.enable()); notifyListeners(); } } catch (error, stackTrace) { @@ -219,7 +219,7 @@ class AppCtrl extends ChangeNotifier { await _session.start(); if (_session.connectionState == sdk.ConnectionState.connected) { appScreenState = AppScreenState.agent; - WakelockPlus.enable(); + unawaited(WakelockPlus.enable()); notifyListeners(); return; } @@ -243,7 +243,7 @@ class AppCtrl extends ChangeNotifier { Future disconnect() async { await session.end(); session.restoreMessageHistory(const []); - WakelockPlus.disable(); + unawaited(WakelockPlus.disable()); appScreenState = AppScreenState.welcome; agentScreenState = AgentScreenState.visualizer; notifyListeners(); diff --git a/mobile/lib/controllers/audio_filter_ctrl.dart b/mobile/lib/controllers/audio_filter_ctrl.dart index 663167c..0eaa423 100644 --- a/mobile/lib/controllers/audio_filter_ctrl.dart +++ b/mobile/lib/controllers/audio_filter_ctrl.dart @@ -53,7 +53,7 @@ class AudioFilterCtrl extends ChangeNotifier { @override void dispose() { - _listener?.dispose(); + unawaited(_listener?.dispose()); _listener = null; super.dispose(); } diff --git a/mobile/lib/controllers/connection_error_ctrl.dart b/mobile/lib/controllers/connection_error_ctrl.dart index 0afd851..d31de5e 100644 --- a/mobile/lib/controllers/connection_error_ctrl.dart +++ b/mobile/lib/controllers/connection_error_ctrl.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; @@ -46,7 +47,7 @@ class ConnectionErrorCtrl extends ChangeNotifier { @override void dispose() { - _listener.dispose(); + unawaited(_listener.dispose()); super.dispose(); } } diff --git a/mobile/lib/controllers/tool_status_ctrl.dart b/mobile/lib/controllers/tool_status_ctrl.dart index 07b900b..0208472 100644 --- a/mobile/lib/controllers/tool_status_ctrl.dart +++ b/mobile/lib/controllers/tool_status_ctrl.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; @@ -68,7 +69,7 @@ class ToolStatusCtrl extends ChangeNotifier { @override void dispose() { - _listener.dispose(); + unawaited(_listener.dispose()); super.dispose(); } } diff --git a/mobile/lib/controllers/wake_word_state_ctrl.dart b/mobile/lib/controllers/wake_word_state_ctrl.dart index b20476c..ba58051 100644 --- a/mobile/lib/controllers/wake_word_state_ctrl.dart +++ b/mobile/lib/controllers/wake_word_state_ctrl.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; @@ -46,13 +47,13 @@ class WakeWordStateCtrl extends ChangeNotifier { // If room is already connected, fetch immediately if (room.connectionState == sdk.ConnectionState.connected) { debugPrint('[WakeWordStateCtrl] Room already connected, fetching state'); - _fetchInitialState(); + unawaited(_fetchInitialState()); } } void _handleRoomConnected(sdk.RoomConnectedEvent event) { debugPrint('[WakeWordStateCtrl] Room connected, fetching initial state'); - _fetchInitialState(); + unawaited(_fetchInitialState()); } Future _fetchInitialState({int retryCount = 0}) async { @@ -99,7 +100,7 @@ class WakeWordStateCtrl extends ChangeNotifier { final delay = Duration(milliseconds: 500 * (retryCount + 1)); debugPrint('[WakeWordStateCtrl] Retrying in ${delay.inMilliseconds}ms...'); await Future.delayed(delay); - _fetchInitialState(retryCount: retryCount + 1); + unawaited(_fetchInitialState(retryCount: retryCount + 1)); } } } @@ -134,7 +135,7 @@ class WakeWordStateCtrl extends ChangeNotifier { @override void dispose() { - _listener.dispose(); + unawaited(_listener.dispose()); super.dispose(); } } diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 2be0069..ab9fe1f 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -9,7 +9,7 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); // Hide status bar and navigation bar for full-screen experience - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); // Initialize config service first final configService = ConfigService(); diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index 10c5b73..cfb59f2 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -876,7 +876,7 @@ class _SettingsScreenState extends State { _buildCard([ _buildLabel(l10n.language), DropdownButtonFormField( - value: context.watch().locale.languageCode, + initialValue: context.watch().locale.languageCode, style: const TextStyle(color: Colors.white), dropdownColor: const Color(0xFF2A2A2A), decoration: _inputDecoration(), @@ -915,11 +915,11 @@ class _SettingsScreenState extends State { }), ); // Download the Piper model so it appears in voice list - http.post( + unawaited(http.post( Uri.parse('$_webhookUrl/download-piper-model'), headers: {'Content-Type': 'application/json'}, body: jsonEncode({'model_id': modelId}), - ); + )); } catch (e) { // Best-effort } diff --git a/mobile/lib/screens/welcome_screen.dart b/mobile/lib/screens/welcome_screen.dart index 43afdde..33ee48c 100644 --- a/mobile/lib/screens/welcome_screen.dart +++ b/mobile/lib/screens/welcome_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:livekit_client/livekit_client.dart' as sdk; import 'package:provider/provider.dart'; @@ -19,29 +21,29 @@ class WelcomeScreen extends StatelessWidget { // If first start (not configured), show simple URL setup // Otherwise show full settings page if (!configService.isConfigured) { - Navigator.of(context).push( + unawaited(Navigator.of(context).push( MaterialPageRoute( builder: (context) => SetupScreen( configService: configService, onConfigured: () { Navigator.of(context).pop(); - appCtrl.updateConfig(serverUrl: configService.serverUrl); + unawaited(appCtrl.updateConfig(serverUrl: configService.serverUrl)); }, ), ), - ); + )); } else { - Navigator.of(context).push( + unawaited(Navigator.of(context).push( MaterialPageRoute( builder: (context) => SettingsScreen( configService: configService, onSave: () { Navigator.of(context).pop(); - appCtrl.updateConfig(serverUrl: configService.serverUrl); + unawaited(appCtrl.updateConfig(serverUrl: configService.serverUrl)); }, ), ), - ); + )); } } diff --git a/mobile/lib/widgets/button.dart b/mobile/lib/widgets/button.dart index ea81c43..3bb0e1c 100644 --- a/mobile/lib/widgets/button.dart +++ b/mobile/lib/widgets/button.dart @@ -37,7 +37,7 @@ class Button extends StatelessWidget { height: 20, child: CircularProgressIndicator( strokeWidth: 2, - valueColor: const AlwaysStoppedAnimation(Color(0xFF171717)), + valueColor: AlwaysStoppedAnimation(Color(0xFF171717)), ), ), Text( diff --git a/mobile/lib/widgets/control_bar.dart b/mobile/lib/widgets/control_bar.dart index 7658912..c54780f 100644 --- a/mobile/lib/widgets/control_bar.dart +++ b/mobile/lib/widgets/control_bar.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; @@ -19,14 +20,14 @@ class ControlBar extends StatelessWidget { const ControlBar({super.key}); void _showToolDetails(BuildContext context, ToolStatus status) { - showModalBottomSheet( + unawaited(showModalBottomSheet( context: context, backgroundColor: const Color(0xFF1A1A1A), shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (context) => _ToolDetailsSheet(status: status), - ); + )); } @override From 8def8e9e76e5b0ab67e27cb90662d13c8a44247f Mon Sep 17 00:00:00 2001 From: Michel-Marie MAUDET Date: Sun, 8 Feb 2026 08:01:39 +0100 Subject: [PATCH 3/3] fix(mobile,agent): stored API key fallback for test endpoints and UI fixes - Backend test endpoints (groq, openai-compatible, openrouter) now fall back to stored API keys when none provided in the request - OpenRouter model picker uses modal bottom sheet instead of inline list to fix tap detection issues in Flutter web - OpenAI Compatible API key field shows masked hint when key is stored - Language dropdown uses backend value instead of local provider default - Language selection syncs _language field to prevent save from overwriting Co-Authored-By: Claude Opus 4.6 --- mobile/lib/screens/settings_screen.dart | 201 +++++++++++++++++------- src/caal/webhooks.py | 34 +++- 2 files changed, 171 insertions(+), 64 deletions(-) diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index cfb59f2..0ed05ac 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -89,7 +89,6 @@ class _SettingsScreenState extends State { List _openaiModels = []; List _openrouterModels = []; List _wakeWordModels = []; - String _openrouterSearch = ''; // Test states bool _testingOllama = false; @@ -355,7 +354,8 @@ class _SettingsScreenState extends State { } Future _testGroq() async { - if (_groqApiKey.isEmpty) return; + // Allow testing with empty key - backend falls back to stored key + if (_groqApiKey.isEmpty && _groqModel.isEmpty) return; setState(() { _testingGroq = true; _groqError = null; @@ -440,7 +440,8 @@ class _SettingsScreenState extends State { } Future _testOpenRouter() async { - if (_openrouterApiKey.isEmpty) return; + // Allow testing with empty key - backend falls back to stored key + if (_openrouterApiKey.isEmpty && _openrouterModel.isEmpty) return; setState(() { _testingOpenrouter = true; _openrouterError = null; @@ -876,7 +877,7 @@ class _SettingsScreenState extends State { _buildCard([ _buildLabel(l10n.language), DropdownButtonFormField( - initialValue: context.watch().locale.languageCode, + initialValue: _language, style: const TextStyle(color: Colors.white), dropdownColor: const Color(0xFF2A2A2A), decoration: _inputDecoration(), @@ -887,6 +888,8 @@ class _SettingsScreenState extends State { ], onChanged: (value) async { if (value != null) { + // Keep _language in sync so _save() doesn't overwrite + _language = value; // Update app locale immediately for UI final localeProvider = context.read(); await localeProvider.setLocale( @@ -1169,9 +1172,20 @@ class _SettingsScreenState extends State { initialValue: _openaiApiKey, obscureText: true, style: const TextStyle(color: Colors.white), - decoration: _inputDecoration(hint: 'sk-...'), + decoration: _inputDecoration( + hint: _openaiModel.isNotEmpty + ? '••••••••••••••••' + : 'sk-...'), onChanged: (v) => setState(() => _openaiApiKey = v), ), + if (!_openaiConnected && + _openaiApiKey.isEmpty && + _openaiModel.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(l10n.apiKeyConfigured, + style: const TextStyle(color: Color(0xFF45997C), fontSize: 12)), + ), Padding( padding: const EdgeInsets.only(top: 4, bottom: 12), child: Text(l10n.openaiApiKeyNote, @@ -1683,65 +1697,136 @@ class _SettingsScreenState extends State { ); } - Widget _buildSearchableModelDropdown(AppLocalizations l10n) { - final allModels = - _openrouterModels.isNotEmpty ? _openrouterModels : [_openrouterModel]; - final filtered = _openrouterSearch.isEmpty - ? allModels - : allModels - .where( - (m) => m.toLowerCase().contains(_openrouterSearch.toLowerCase())) - .toList(); - final safeValue = - filtered.contains(_openrouterModel) ? _openrouterModel : null; + void _showModelPicker(AppLocalizations l10n) { + var search = ''; + unawaited(showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF1A1A1A), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + isScrollControlled: true, + builder: (context) { + final allModels = + _openrouterModels.isNotEmpty ? _openrouterModels : [_openrouterModel]; + return StatefulBuilder( + builder: (context, setSheetState) { + final filtered = search.isEmpty + ? allModels + : allModels + .where((m) => m.toLowerCase().contains(search.toLowerCase())) + .toList(); + return DraggableScrollableSheet( + initialChildSize: 0.6, + minChildSize: 0.3, + maxChildSize: 0.85, + expand: false, + builder: (context, scrollController) => Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: TextField( + autofocus: true, + style: const TextStyle(color: Colors.white, fontSize: 13), + decoration: InputDecoration( + hintText: l10n.searchModels, + hintStyle: const TextStyle(color: Colors.white38), + prefixIcon: const Icon(Icons.search, color: Colors.white38, size: 20), + filled: true, + fillColor: const Color(0xFF2A2A2A), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + onChanged: (v) => setSheetState(() => search = v), + ), + ), + Expanded( + child: filtered.isEmpty + ? Center( + child: Text(l10n.noModelsFound, + style: const TextStyle(color: Colors.white38, fontSize: 13)), + ) + : ListView.builder( + controller: scrollController, + itemCount: filtered.length, + itemBuilder: (context, index) { + final model = filtered[index]; + final isSelected = model == _openrouterModel; + return ListTile( + dense: true, + title: Text( + model, + style: TextStyle( + color: isSelected ? Colors.white : Colors.white70, + fontSize: 13, + fontWeight: + isSelected ? FontWeight.w600 : FontWeight.normal, + ), + ), + tileColor: isSelected + ? const Color(0xFF45997C).withValues(alpha: 0.2) + : null, + trailing: isSelected + ? const Icon(Icons.check, color: Color(0xFF45997C), size: 18) + : null, + onTap: () { + Navigator.of(context).pop(model); + }, + ); + }, + ), + ), + ], + ), + ); + }, + ); + }, + ).then((selected) { + if (selected != null) { + setState(() => _openrouterModel = selected); + } + })); + } + Widget _buildSearchableModelDropdown(AppLocalizations l10n) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildLabel(l10n.model), - TextFormField( - style: const TextStyle(color: Colors.white, fontSize: 13), - decoration: _inputDecoration(hint: l10n.searchModels), - onChanged: (v) => setState(() => _openrouterSearch = v), - ), - const SizedBox(height: 4), - Container( - height: 160, - decoration: BoxDecoration( - color: const Color(0xFF2A2A2A), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.white.withValues(alpha: 0.1)), - ), - child: filtered.isEmpty - ? Center( - child: Text(l10n.noModelsFound, - style: const TextStyle(color: Colors.white38, fontSize: 12)), - ) - : ListView.builder( - itemCount: filtered.length, - itemBuilder: (context, index) { - final model = filtered[index]; - final isSelected = model == safeValue; - return ListTile( - dense: true, - visualDensity: VisualDensity.compact, - title: Text( - model, - style: TextStyle( - color: isSelected ? Colors.white : Colors.white70, - fontSize: 12, - fontWeight: - isSelected ? FontWeight.w600 : FontWeight.normal, - ), - ), - selected: isSelected, - selectedTileColor: - const Color(0xFF45997C).withValues(alpha: 0.2), - onTap: () => - setState(() => _openrouterModel = model), - ); - }, + GestureDetector( + onTap: () => _showModelPicker(l10n), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + decoration: BoxDecoration( + color: const Color(0xFF2A2A2A), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.white.withValues(alpha: 0.1)), + ), + child: Row( + children: [ + Expanded( + child: Text( + _openrouterModel.isNotEmpty + ? _openrouterModel + : l10n.searchModels, + style: TextStyle( + color: _openrouterModel.isNotEmpty + ? Colors.white + : Colors.white38, + fontSize: 13, + ), + overflow: TextOverflow.ellipsis, + ), ), + const Icon(Icons.arrow_drop_down, color: Colors.white38), + ], + ), + ), ), const SizedBox(height: 16), ], diff --git a/src/caal/webhooks.py b/src/caal/webhooks.py index 8b03d43..5133c96 100644 --- a/src/caal/webhooks.py +++ b/src/caal/webhooks.py @@ -935,7 +935,7 @@ class TestOllamaRequest(BaseModel): class TestGroqRequest(BaseModel): """Request body for /setup/test-groq endpoint.""" - api_key: str + api_key: str = "" # Falls back to stored key if empty class TestHassRequest(BaseModel): @@ -962,7 +962,7 @@ class TestOpenAICompatibleRequest(BaseModel): class TestOpenRouterRequest(BaseModel): """Request body for /setup/test-openrouter endpoint.""" - api_key: str # Required - OpenRouter always needs API key + api_key: str = "" # Falls back to stored key if empty @app.get("/setup/status", response_model=SetupStatusResponse) @@ -1104,11 +1104,19 @@ async def test_groq(req: TestGroqRequest) -> TestConnectionResponse: Returns: TestConnectionResponse with success status """ + # Use stored API key as fallback when none provided + api_key = req.api_key + if not api_key: + stored = settings_module.load_settings() + api_key = stored.get("groq_api_key", "") + if not api_key: + return TestConnectionResponse(success=False, error="No API key provided") + try: async with httpx.AsyncClient() as client: response = await client.get( "https://api.groq.com/openai/v1/models", - headers={"Authorization": f"Bearer {req.api_key}"}, + headers={"Authorization": f"Bearer {api_key}"}, timeout=10.0, ) if response.status_code == 401: @@ -1230,10 +1238,16 @@ async def test_openai_compatible(req: TestOpenAICompatibleRequest) -> TestConnec # Normalize URL - strip trailing slash base_url = req.base_url.rstrip("/") + # Use stored API key as fallback when none provided + api_key = req.api_key + if not api_key: + stored = settings_module.load_settings() + api_key = stored.get("openai_api_key", "") + try: headers = {} - if req.api_key: - headers["Authorization"] = f"Bearer {req.api_key}" + if api_key: + headers["Authorization"] = f"Bearer {api_key}" async with httpx.AsyncClient() as client: response = await client.get( @@ -1287,12 +1301,20 @@ async def test_openrouter(req: TestOpenRouterRequest) -> TestConnectionResponse: Returns: TestConnectionResponse with success status and tool-capable model list """ + # Use stored API key as fallback when none provided + api_key = req.api_key + if not api_key: + stored = settings_module.load_settings() + api_key = stored.get("openrouter_api_key", "") + if not api_key: + return TestConnectionResponse(success=False, error="No API key provided") + try: async with httpx.AsyncClient() as client: response = await client.get( "https://openrouter.ai/api/v1/models", params={"supported_parameters": "tools"}, - headers={"Authorization": f"Bearer {req.api_key}"}, + headers={"Authorization": f"Bearer {api_key}"}, timeout=15.0, # Longer timeout for large response )