diff --git a/lib/core/llm/llm_config.dart b/lib/core/llm/llm_config.dart index ce4f6f9..c204149 100644 --- a/lib/core/llm/llm_config.dart +++ b/lib/core/llm/llm_config.dart @@ -2,32 +2,119 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; const String _llmApiKeyStorageKey = 'llm_api_key'; +const String _llmBaseUrlStorageKey = 'llm_base_url'; +const String _llmModelStorageKey = 'llm_model'; const String _llmProviderStorageKey = 'llm_provider'; -enum LlmProvider { openai, anthropic, google } +String _providerApiKeyStorageKey(LlmProvider provider) { + return 'llm_api_key_${provider.name}'; +} + +enum LlmProvider { openai, anthropic, google, ollama, openaiCompatible } + +extension LlmProviderMetadata on LlmProvider { + String get label { + return switch (this) { + LlmProvider.openai => 'OpenAI', + LlmProvider.anthropic => 'Anthropic', + LlmProvider.google => 'Google AI', + LlmProvider.ollama => 'Ollama', + LlmProvider.openaiCompatible => 'OpenAI-compatible', + }; + } + + String get defaultBaseUrl { + return switch (this) { + LlmProvider.openai => 'https://api.openai.com/v1', + LlmProvider.anthropic => 'https://api.anthropic.com/v1', + LlmProvider.google => 'https://generativelanguage.googleapis.com/v1beta', + LlmProvider.ollama => 'http://localhost:11434', + LlmProvider.openaiCompatible => '', + }; + } + + String get defaultModel { + return switch (this) { + LlmProvider.openai => 'gpt-4o-mini', + LlmProvider.anthropic => 'claude-3-haiku-20240307', + LlmProvider.google => 'gemini-2.0-flash', + LlmProvider.ollama => '', + LlmProvider.openaiCompatible => '', + }; + } + + bool get requiresApiKey { + return switch (this) { + LlmProvider.openai || LlmProvider.anthropic || LlmProvider.google => true, + LlmProvider.ollama || LlmProvider.openaiCompatible => false, + }; + } + + bool get hasConfigurableBaseUrl { + return this == LlmProvider.ollama || this == LlmProvider.openaiCompatible; + } +} class LlmConfigState { - const LlmConfigState({ - this.apiKeyConfigured = false, - this.provider = LlmProvider.openai, - this.isConfigured = false, + LlmConfigState({ + bool apiKeyConfigured = false, + LlmProvider provider = LlmProvider.openai, + String? baseUrl, + String? model, + }) : this._( + apiKeyConfigured: apiKeyConfigured, + provider: provider, + baseUrl: _resolveBaseUrl(provider, baseUrl), + model: _resolveModel(provider, model), + ); + + const LlmConfigState._({ + required this.apiKeyConfigured, + required this.provider, + required this.baseUrl, + required this.model, }); final bool apiKeyConfigured; final LlmProvider provider; - final bool isConfigured; + final String baseUrl; + final String model; + + bool get isConfigured { + final bool hasKey = apiKeyConfigured || !provider.requiresApiKey; + return hasKey && baseUrl.trim().isNotEmpty && model.trim().isNotEmpty; + } LlmConfigState copyWith({ bool? apiKeyConfigured, LlmProvider? provider, - bool? isConfigured, + String? baseUrl, + String? model, }) { + final LlmProvider nextProvider = provider ?? this.provider; return LlmConfigState( apiKeyConfigured: apiKeyConfigured ?? this.apiKeyConfigured, - provider: provider ?? this.provider, - isConfigured: isConfigured ?? this.isConfigured, + provider: nextProvider, + baseUrl: baseUrl ?? this.baseUrl, + model: model ?? this.model, ); } + + static String _resolveBaseUrl(LlmProvider provider, String? baseUrl) { + final String? trimmed = baseUrl?.trim(); + if (trimmed != null && trimmed.isNotEmpty) { + return trimmed; + } + return provider.defaultBaseUrl; + } + + static String _resolveModel(LlmProvider provider, String? model) { + final String? trimmed = model?.trim(); + if (trimmed != null && trimmed.isNotEmpty) { + return trimmed; + } + return provider.defaultModel; + } } class LlmConfigNotifier extends Notifier { @@ -35,7 +122,7 @@ class LlmConfigNotifier extends Notifier { @override LlmConfigState build() { - return const LlmConfigState(); + return LlmConfigState(); } Future initialize() async { @@ -49,36 +136,53 @@ class LlmConfigNotifier extends Notifier { Future _initializeFromStorage() async { const FlutterSecureStorage secureStorage = FlutterSecureStorage(); - final String? storedKey = await secureStorage.read( - key: _llmApiKeyStorageKey, - ); final String? storedProvider = await secureStorage.read( key: _llmProviderStorageKey, ); + final String? storedBaseUrl = await secureStorage.read( + key: _llmBaseUrlStorageKey, + ); + final String? storedModel = await secureStorage.read( + key: _llmModelStorageKey, + ); + + final LlmProvider provider = storedProvider != null + ? LlmProvider.values.firstWhere( + (LlmProvider p) => p.name == storedProvider, + orElse: () => LlmProvider.openai, + ) + : LlmProvider.openai; + String? storedKey = await _readApiKey(secureStorage, provider: provider); + if (storedKey == null || storedKey.isEmpty) { + final String? legacyKey = await secureStorage.read( + key: _llmApiKeyStorageKey, + ); + if (legacyKey != null && legacyKey.isNotEmpty) { + await secureStorage.write( + key: _providerApiKeyStorageKey(provider), + value: legacyKey, + ); + storedKey = legacyKey; + } + } state = LlmConfigState( apiKeyConfigured: storedKey != null && storedKey.isNotEmpty, - provider: storedProvider != null - ? LlmProvider.values.firstWhere( - (LlmProvider p) => p.name == storedProvider, - orElse: () => LlmProvider.openai, - ) - : LlmProvider.openai, - isConfigured: storedKey != null && storedKey.isNotEmpty, + provider: provider, + baseUrl: storedBaseUrl, + model: storedModel, ); } Future setApiKey(String apiKey) async { const FlutterSecureStorage secureStorage = FlutterSecureStorage(); + final String key = _providerApiKeyStorageKey(state.provider); if (apiKey.isEmpty) { - await secureStorage.delete(key: _llmApiKeyStorageKey); + await secureStorage.delete(key: key); } else { - await secureStorage.write(key: _llmApiKeyStorageKey, value: apiKey); + await secureStorage.write(key: key, value: apiKey); } - state = state.copyWith( - apiKeyConfigured: apiKey.isNotEmpty, - isConfigured: apiKey.isNotEmpty, - ); + state = state.copyWith(apiKeyConfigured: apiKey.isNotEmpty); } Future setProvider(LlmProvider provider) async { @@ -87,18 +191,98 @@ class LlmConfigNotifier extends Notifier { key: _llmProviderStorageKey, value: provider.name, ); - state = state.copyWith(provider: provider); + await secureStorage.write( + key: _llmBaseUrlStorageKey, + value: provider.defaultBaseUrl, + ); + await secureStorage.write( + key: _llmModelStorageKey, + value: provider.defaultModel, + ); + final String? storedKey = await _readApiKey( + secureStorage, + provider: provider, + ); + state = state.copyWith( + apiKeyConfigured: storedKey != null && storedKey.isNotEmpty, + provider: provider, + baseUrl: provider.defaultBaseUrl, + model: provider.defaultModel, + ); } - Future getApiKey() async { + Future saveConfiguration({ + required LlmProvider provider, + required String baseUrl, + required String model, + String? apiKey, + bool clearApiKey = false, + }) async { const FlutterSecureStorage secureStorage = FlutterSecureStorage(); - return secureStorage.read(key: _llmApiKeyStorageKey); + await secureStorage.write( + key: _llmProviderStorageKey, + value: provider.name, + ); + await secureStorage.write( + key: _llmBaseUrlStorageKey, + value: baseUrl.trim(), + ); + await secureStorage.write(key: _llmModelStorageKey, value: model.trim()); + + final String? storedProviderKey = await _readApiKey( + secureStorage, + provider: provider, + ); + bool apiKeyConfigured = + storedProviderKey != null && storedProviderKey.isNotEmpty; + if (clearApiKey) { + await secureStorage.delete(key: _providerApiKeyStorageKey(provider)); + apiKeyConfigured = false; + } else if (apiKey != null) { + final String trimmedApiKey = apiKey.trim(); + if (trimmedApiKey.isEmpty) { + await secureStorage.delete(key: _providerApiKeyStorageKey(provider)); + apiKeyConfigured = false; + } else { + await secureStorage.write( + key: _providerApiKeyStorageKey(provider), + value: trimmedApiKey, + ); + apiKeyConfigured = true; + } + } + + state = LlmConfigState( + apiKeyConfigured: apiKeyConfigured, + provider: provider, + baseUrl: baseUrl, + model: model, + ); + } + + Future getApiKey({LlmProvider? provider}) async { + const FlutterSecureStorage secureStorage = FlutterSecureStorage(); + return _readApiKey(secureStorage, provider: provider ?? state.provider); } Future clearApiKey() async { const FlutterSecureStorage secureStorage = FlutterSecureStorage(); - await secureStorage.delete(key: _llmApiKeyStorageKey); - state = state.copyWith(apiKeyConfigured: false, isConfigured: false); + await secureStorage.delete(key: _providerApiKeyStorageKey(state.provider)); + state = state.copyWith(apiKeyConfigured: false); + } + + Future _readApiKey( + FlutterSecureStorage secureStorage, { + required LlmProvider provider, + }) async { + final String? providerKey = await secureStorage.read( + key: _providerApiKeyStorageKey(provider), + ); + if (providerKey != null && providerKey.isNotEmpty) { + return providerKey; + } + + return null; } } diff --git a/lib/core/llm/llm_service.dart b/lib/core/llm/llm_service.dart index 6ab1b3f..7e23171 100644 --- a/lib/core/llm/llm_service.dart +++ b/lib/core/llm/llm_service.dart @@ -7,6 +7,28 @@ import 'package:relationship_saver/core/llm/llm_config.dart'; import 'package:relationship_saver/features/people/domain/person_models.dart'; import 'package:relationship_saver/features/share_intake/domain/share_models.dart'; +class LlmProviderException implements Exception { + const LlmProviderException({ + required this.message, + required this.provider, + this.statusCode, + }); + + final String message; + final LlmProvider provider; + final int? statusCode; + + @override + String toString() => message; +} + +class LlmModelInfo { + const LlmModelInfo({required this.id, String? label}) : label = label ?? id; + + final String id; + final String label; +} + class LlmService { LlmService(this._ref); @@ -26,7 +48,7 @@ class LlmService { final config = await _config; final apiKey = await _ref.read(llmConfigProvider.notifier).getApiKey(); - if (apiKey == null || apiKey.isEmpty) { + if (config.provider.requiresApiKey && (apiKey == null || apiKey.isEmpty)) { throw Exception('No API key available'); } @@ -63,6 +85,8 @@ Generate 3-5 helpful relationship signals for these people.'''; final response = await _callLlm( provider: config.provider, apiKey: apiKey, + baseUrl: config.baseUrl, + model: config.model, systemPrompt: systemPrompt, userPrompt: userPrompt, ); @@ -82,13 +106,15 @@ Generate 3-5 helpful relationship signals for these people.'''; .read(llmConfigProvider.notifier) .getApiKey(); - if (apiKey == null || apiKey.isEmpty) { + if (config.provider.requiresApiKey && (apiKey == null || apiKey.isEmpty)) { throw Exception('No API key available'); } return _callLlm( provider: config.provider, apiKey: apiKey, + baseUrl: config.baseUrl, + model: config.model, systemPrompt: systemPrompt, userPrompt: userPrompt, ); @@ -100,7 +126,7 @@ Generate 3-5 helpful relationship signals for these people.'''; .read(llmConfigProvider.notifier) .getApiKey(); - if (apiKey == null || apiKey.isEmpty) { + if (config.provider.requiresApiKey && (apiKey == null || apiKey.isEmpty)) { throw Exception('No API key available'); } @@ -128,6 +154,8 @@ ${payload.rawText}'''; final String response = await _callLlm( provider: config.provider, apiKey: apiKey, + baseUrl: config.baseUrl, + model: config.model, systemPrompt: systemPrompt, userPrompt: userPrompt, ); @@ -140,47 +168,143 @@ ${payload.rawText}'''; } } + Future> listAvailableModels({ + required LlmProvider provider, + String? apiKey, + String? baseUrl, + }) async { + final String? resolvedApiKey = apiKey != null && apiKey.trim().isNotEmpty + ? apiKey.trim() + : await _ref + .read(llmConfigProvider.notifier) + .getApiKey(provider: provider); + + if (provider.requiresApiKey && + (resolvedApiKey == null || resolvedApiKey.isEmpty)) { + throw LlmProviderException( + provider: provider, + message: '${provider.label} needs an API key before models can load.', + ); + } + + final Dio dio = _createDio(); + final String resolvedBaseUrl = _normalizeBaseUrl( + baseUrl?.trim().isNotEmpty == true + ? baseUrl!.trim() + : provider.defaultBaseUrl, + ); + + try { + return switch (provider) { + LlmProvider.openai => _listOpenAIModels( + dio, + resolvedBaseUrl, + resolvedApiKey, + ), + LlmProvider.anthropic => _listAnthropicModels( + dio, + resolvedBaseUrl, + resolvedApiKey, + ), + LlmProvider.google => _listGoogleModels( + dio, + resolvedBaseUrl, + resolvedApiKey, + ), + LlmProvider.ollama => _listOllamaModels(dio, resolvedBaseUrl), + LlmProvider.openaiCompatible => _listOpenAIModels( + dio, + resolvedBaseUrl, + resolvedApiKey, + ), + }; + } on DioException catch (error) { + throw _mapProviderError(provider, error); + } + } + Future _callLlm({ required LlmProvider provider, - required String apiKey, + required String? apiKey, + required String baseUrl, + required String model, required String systemPrompt, required String userPrompt, }) async { - final dio = Dio( - BaseOptions( - connectTimeout: const Duration(seconds: 20), - receiveTimeout: const Duration(seconds: 20), - sendTimeout: const Duration(seconds: 20), - ), - ); + final Dio dio = _createDio(); + final String resolvedBaseUrl = _normalizeBaseUrl(baseUrl); - switch (provider) { - case LlmProvider.openai: - return _callOpenAI(dio, apiKey, systemPrompt, userPrompt); - case LlmProvider.anthropic: - return _callAnthropic(dio, apiKey, systemPrompt, userPrompt); - case LlmProvider.google: - return _callGoogleAI(dio, apiKey, systemPrompt, userPrompt); + try { + switch (provider) { + case LlmProvider.openai: + return _callOpenAI( + dio, + resolvedBaseUrl, + apiKey, + model, + systemPrompt, + userPrompt, + ); + case LlmProvider.anthropic: + return _callAnthropic( + dio, + resolvedBaseUrl, + apiKey, + model, + systemPrompt, + userPrompt, + ); + case LlmProvider.google: + return _callGoogleAI( + dio, + resolvedBaseUrl, + apiKey, + model, + systemPrompt, + userPrompt, + ); + case LlmProvider.ollama: + return _callOllama( + dio, + resolvedBaseUrl, + model, + systemPrompt, + userPrompt, + ); + case LlmProvider.openaiCompatible: + return _callOpenAI( + dio, + resolvedBaseUrl, + apiKey, + model, + systemPrompt, + userPrompt, + ); + } + } on DioException catch (error) { + throw _mapProviderError(provider, error); } } Future _callOpenAI( Dio dio, - String apiKey, + String baseUrl, + String? apiKey, + String model, String systemPrompt, String userPrompt, ) async { + final Map headers = { + 'Content-Type': 'application/json', + if (apiKey != null && apiKey.isNotEmpty) + 'Authorization': 'Bearer $apiKey', + }; final Response> response = await dio .post>( - 'https://api.openai.com/v1/chat/completions', - options: Options( - headers: { - 'Authorization': 'Bearer $apiKey', - 'Content-Type': 'application/json', - }, - ), + '$baseUrl/chat/completions', + options: Options(headers: headers), data: jsonEncode({ - 'model': 'gpt-4o-mini', + 'model': model, 'messages': [ {'role': 'system', 'content': systemPrompt}, {'role': 'user', 'content': userPrompt}, @@ -200,22 +324,24 @@ ${payload.rawText}'''; Future _callAnthropic( Dio dio, - String apiKey, + String baseUrl, + String? apiKey, + String model, String systemPrompt, String userPrompt, ) async { final Response> response = await dio .post>( - 'https://api.anthropic.com/v1/messages', + '$baseUrl/messages', options: Options( headers: { - 'x-api-key': apiKey, + 'x-api-key': apiKey ?? '', 'anthropic-version': '2023-06-01', 'Content-Type': 'application/json', }, ), data: jsonEncode({ - 'model': 'claude-3-haiku-20240307', + 'model': model, 'system': systemPrompt, 'messages': [ {'role': 'user', 'content': userPrompt}, @@ -234,31 +360,36 @@ ${payload.rawText}'''; Future _callGoogleAI( Dio dio, - String apiKey, + String baseUrl, + String? apiKey, + String model, String systemPrompt, String userPrompt, ) async { - final Response> - response = await dio.post>( - 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent', - options: Options(headers: {'Content-Type': 'application/json'}), - queryParameters: {'key': apiKey}, - data: jsonEncode({ - 'systemInstruction': { - 'parts': [ - {'text': systemPrompt}, - ], - }, - 'contents': [ - { - 'parts': [ - {'text': userPrompt}, + final String modelPath = model.startsWith('models/') + ? model + : 'models/$model'; + final Response> response = await dio + .post>( + '$baseUrl/$modelPath:generateContent', + options: Options(headers: {'Content-Type': 'application/json'}), + queryParameters: {'key': apiKey ?? ''}, + data: jsonEncode({ + 'systemInstruction': { + 'parts': [ + {'text': systemPrompt}, + ], + }, + 'contents': [ + { + 'parts': [ + {'text': userPrompt}, + ], + }, ], - }, - ], - 'generationConfig': {'temperature': 0.7, 'maxOutputTokens': 1000}, - }), - ); + 'generationConfig': {'temperature': 0.7, 'maxOutputTokens': 1000}, + }), + ); final Map responseData = response.data!; final List candidates = @@ -273,6 +404,128 @@ ${payload.rawText}'''; return contentText; } + Future _callOllama( + Dio dio, + String baseUrl, + String model, + String systemPrompt, + String userPrompt, + ) async { + final Response> response = await dio + .post>( + '$baseUrl/api/chat', + options: Options(headers: {'Content-Type': 'application/json'}), + data: jsonEncode({ + 'model': model, + 'stream': false, + 'messages': [ + {'role': 'system', 'content': systemPrompt}, + {'role': 'user', 'content': userPrompt}, + ], + }), + ); + + final Map responseData = response.data!; + final Map message = + responseData['message'] as Map; + return message['content'] as String; + } + + Future> _listOpenAIModels( + Dio dio, + String baseUrl, + String? apiKey, + ) async { + final Response> response = await dio + .get>( + '$baseUrl/models', + options: Options( + headers: { + if (apiKey != null && apiKey.isNotEmpty) + 'Authorization': 'Bearer $apiKey', + }, + ), + ); + final List data = response.data!['data'] as List; + return _sortModels( + data + .map((dynamic item) => item as Map) + .map((Map item) => item['id'] as String) + .map((String id) => LlmModelInfo(id: id)), + ); + } + + Future> _listAnthropicModels( + Dio dio, + String baseUrl, + String? apiKey, + ) async { + final Response> response = await dio + .get>( + '$baseUrl/models', + options: Options( + headers: { + 'x-api-key': apiKey ?? '', + 'anthropic-version': '2023-06-01', + }, + ), + ); + final List data = response.data!['data'] as List; + return _sortModels( + data.map((dynamic item) { + final Map model = item as Map; + final String id = model['id'] as String; + return LlmModelInfo(id: id, label: model['display_name'] as String?); + }), + ); + } + + Future> _listGoogleModels( + Dio dio, + String baseUrl, + String? apiKey, + ) async { + final Response> response = await dio + .get>( + '$baseUrl/models', + queryParameters: {'key': apiKey ?? ''}, + ); + final List data = response.data!['models'] as List; + return _sortModels( + data + .map((dynamic item) => item as Map) + .where((Map item) { + final Object? methods = item['supportedGenerationMethods']; + return methods is List && + methods.whereType().contains('generateContent'); + }) + .map((Map item) { + final String name = item['name'] as String; + final String id = name.startsWith('models/') + ? name.substring('models/'.length) + : name; + return LlmModelInfo( + id: id, + label: item['displayName'] as String? ?? id, + ); + }), + ); + } + + Future> _listOllamaModels(Dio dio, String baseUrl) async { + final Response> response = await dio + .get>('$baseUrl/api/tags'); + final List data = response.data!['models'] as List; + return _sortModels( + data.map((dynamic item) => item as Map).map(( + Map item, + ) { + final Object? name = item['name'] ?? item['model']; + return LlmModelInfo(id: name as String); + }), + ); + } + List _parseSignalsResponse( String response, List people, @@ -309,6 +562,114 @@ ${payload.rawText}'''; } } +LlmProviderException _mapProviderError( + LlmProvider provider, + DioException error, +) { + final int? statusCode = error.response?.statusCode; + final String providerName = _providerLabel(provider); + final String? providerMessage = _extractProviderMessage(error.response?.data); + + if (statusCode == 429) { + final String detail = providerMessage == null ? '' : ' $providerMessage'; + return LlmProviderException( + provider: provider, + statusCode: statusCode, + message: + '$providerName rejected the digest request with 429 rate limiting or quota pressure.$detail Check that the API key has available billing/quota, wait for the provider limit to reset, or switch to another configured provider.', + ); + } + + if (error.type == DioExceptionType.connectionTimeout || + error.type == DioExceptionType.sendTimeout || + error.type == DioExceptionType.receiveTimeout) { + return LlmProviderException( + provider: provider, + statusCode: statusCode, + message: '$providerName timed out while running the digest request.', + ); + } + + if (error.type == DioExceptionType.connectionError) { + return LlmProviderException( + provider: provider, + statusCode: statusCode, + message: + 'Could not reach $providerName. Check the network connection and provider endpoint availability.', + ); + } + + final String statusText = statusCode == null ? '' : ' HTTP $statusCode.'; + final String detail = providerMessage == null ? '' : ' $providerMessage'; + return LlmProviderException( + provider: provider, + statusCode: statusCode, + message: + '$providerName could not complete the digest request.$statusText$detail', + ); +} + +String _normalizeBaseUrl(String baseUrl) { + String normalized = baseUrl.trim(); + while (normalized.endsWith('/')) { + normalized = normalized.substring(0, normalized.length - 1); + } + return normalized; +} + +Dio _createDio() { + return Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 20), + receiveTimeout: const Duration(seconds: 20), + sendTimeout: const Duration(seconds: 20), + ), + ); +} + +List _sortModels(Iterable models) { + final List sorted = models.toList(); + sorted.sort( + (LlmModelInfo a, LlmModelInfo b) => + a.label.toLowerCase().compareTo(b.label.toLowerCase()), + ); + return sorted; +} + +String _providerLabel(LlmProvider provider) { + return provider.label; +} + +String? _extractProviderMessage(Object? data) { + if (data is Map) { + final Object? direct = data['message']; + if (direct is String && direct.trim().isNotEmpty) { + return _compactProviderMessage(direct); + } + + final Object? error = data['error']; + if (error is String && error.trim().isNotEmpty) { + return _compactProviderMessage(error); + } + if (error is Map) { + final Object? nested = error['message']; + if (nested is String && nested.trim().isNotEmpty) { + return _compactProviderMessage(nested); + } + } + } + + return null; +} + +String _compactProviderMessage(String value) { + final String compacted = value.trim().replaceAll(RegExp(r'\s+'), ' '); + if (compacted.length <= 240) { + return compacted; + } + return '${compacted.substring(0, 237)}...'; +} + class GeneratedSignal { const GeneratedSignal({ required this.title, diff --git a/lib/features/settings/README.md b/lib/features/settings/README.md index 7a18774..efa5d72 100644 --- a/lib/features/settings/README.md +++ b/lib/features/settings/README.md @@ -4,3 +4,7 @@ This slice owns settings and trust/privacy messaging. The screen is intentionally simple. This is the right place for local-first copy, future AI-sharing controls, and developer/founder-mode toggles. + +LLM connection controls live in `presentation/settings_view.dart` and persist +through `core/llm/llm_config.dart`. Provider-specific model discovery and debug +prompt calls are owned by `core/llm/llm_service.dart`. diff --git a/lib/features/settings/presentation/settings_view.dart b/lib/features/settings/presentation/settings_view.dart index b770292..da8b4af 100644 --- a/lib/features/settings/presentation/settings_view.dart +++ b/lib/features/settings/presentation/settings_view.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:relationship_saver/core/config/app_config.dart'; import 'package:relationship_saver/core/config/app_theme.dart'; import 'package:relationship_saver/core/llm/llm_config.dart'; +import 'package:relationship_saver/core/llm/llm_service.dart'; import 'package:relationship_saver/features/ai_digest/application/llm_digest_background_scheduler.dart'; import 'package:relationship_saver/features/ai_digest/application/llm_digest_orchestrator.dart'; import 'package:relationship_saver/features/ai_digest/data/llm_digest_config.dart'; @@ -100,9 +101,7 @@ class SettingsView extends ConsumerWidget { _SettingRow( compact: compact, label: 'LLM AI', - value: ref.watch(llmConfigProvider).isConfigured - ? '${_providerLabel(ref.watch(llmConfigProvider).provider)} (configured)' - : 'Not configured', + value: _llmStatusLabel(ref.watch(llmConfigProvider)), ), const SizedBox(height: 12), _SettingRow( @@ -133,6 +132,11 @@ class SettingsView extends ConsumerWidget { : 'Setup AI', ), ), + OutlinedButton.icon( + onPressed: () => _showLlmDebug(context), + icon: const Icon(Icons.terminal_rounded), + label: const Text('LLM Debug'), + ), OutlinedButton.icon( onPressed: () => _toggleDigest(context, ref), icon: Icon( @@ -236,15 +240,11 @@ class SettingsView extends ConsumerWidget { ); } - String _providerLabel(LlmProvider provider) { - switch (provider) { - case LlmProvider.openai: - return 'OpenAI'; - case LlmProvider.anthropic: - return 'Anthropic'; - case LlmProvider.google: - return 'Google AI'; + String _llmStatusLabel(LlmConfigState state) { + if (!state.isConfigured) { + return 'Not configured'; } + return '${state.provider.label} / ${state.model}'; } String _weekdayLabel(int weekday) { @@ -266,6 +266,8 @@ class SettingsView extends ConsumerWidget { context: context, builder: (BuildContext context) => _LlmConfigDialog( currentProvider: current.provider, + currentBaseUrl: current.baseUrl, + currentModel: current.model, hasApiKey: current.apiKeyConfigured, ), ); @@ -273,20 +275,21 @@ class SettingsView extends ConsumerWidget { return; } - await ref.read(llmConfigProvider.notifier).setProvider(draft.provider); - if (draft.clearApiKey) { - await ref.read(llmConfigProvider.notifier).clearApiKey(); - } else if (draft.apiKey.isNotEmpty) { - await ref.read(llmConfigProvider.notifier).setApiKey(draft.apiKey); - } + await ref + .read(llmConfigProvider.notifier) + .saveConfiguration( + provider: draft.provider, + baseUrl: draft.baseUrl, + model: draft.model, + apiKey: draft.apiKey, + clearApiKey: draft.clearApiKey, + ); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - draft.apiKey.isNotEmpty - ? 'AI configuration saved.' - : draft.clearApiKey + draft.clearApiKey ? 'AI API key cleared.' : 'AI configuration saved.', ), @@ -316,6 +319,13 @@ class SettingsView extends ConsumerWidget { ); } + Future _showLlmDebug(BuildContext context) async { + await showDialog( + context: context, + builder: (BuildContext context) => const _LlmDebugDialog(), + ); + } + Future _runPrivateDigest(BuildContext context, WidgetRef ref) async { final ScaffoldMessengerState messenger = ScaffoldMessenger.of(context); messenger.showSnackBar( @@ -542,94 +552,202 @@ String _debugTimeLabel(DateTime value) { class _LlmConfigDraft { const _LlmConfigDraft({ required this.provider, - required this.apiKey, + required this.baseUrl, + required this.model, + this.apiKey, this.clearApiKey = false, }); final LlmProvider provider; - final String apiKey; + final String baseUrl; + final String model; + final String? apiKey; final bool clearApiKey; } -class _LlmConfigDialog extends StatefulWidget { +class _LlmConfigDialog extends ConsumerStatefulWidget { const _LlmConfigDialog({ required this.currentProvider, + required this.currentBaseUrl, + required this.currentModel, required this.hasApiKey, }); final LlmProvider currentProvider; + final String currentBaseUrl; + final String currentModel; final bool hasApiKey; @override - State<_LlmConfigDialog> createState() => _LlmConfigDialogState(); + ConsumerState<_LlmConfigDialog> createState() => _LlmConfigDialogState(); } -class _LlmConfigDialogState extends State<_LlmConfigDialog> { +class _LlmConfigDialogState extends ConsumerState<_LlmConfigDialog> { late LlmProvider _selectedProvider; late final TextEditingController _apiKeyController; + late final TextEditingController _baseUrlController; + late final TextEditingController _modelController; + List _models = const []; + bool _loadingModels = false; + String? _modelLoadMessage; @override void initState() { super.initState(); _selectedProvider = widget.currentProvider; _apiKeyController = TextEditingController(); + _baseUrlController = TextEditingController(text: widget.currentBaseUrl); + _modelController = TextEditingController(text: widget.currentModel); } @override void dispose() { _apiKeyController.dispose(); + _baseUrlController.dispose(); + _modelController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { + final bool apiKeyVisible = + _selectedProvider.requiresApiKey || + _selectedProvider == LlmProvider.openaiCompatible; return AlertDialog( - title: const Text('Configure AI Provider'), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Provider'), - const SizedBox(height: 8), - DropdownButtonFormField( - initialValue: _selectedProvider, - decoration: const InputDecoration(border: OutlineInputBorder()), - items: const >[ - DropdownMenuItem( - value: LlmProvider.openai, - child: Text('OpenAI'), + title: const Text('LLM Connection'), + content: SizedBox( + width: 560, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField( + initialValue: _selectedProvider, + decoration: const InputDecoration( + labelText: 'Provider', + border: OutlineInputBorder(), + ), + items: LlmProvider.values + .map( + (LlmProvider provider) => DropdownMenuItem( + value: provider, + child: Text(provider.label), + ), + ) + .toList(growable: false), + onChanged: (LlmProvider? value) { + if (value == null) { + return; + } + setState(() { + _selectedProvider = value; + _baseUrlController.text = value.defaultBaseUrl; + _modelController.text = value.defaultModel; + _models = const []; + _modelLoadMessage = null; + }); + }, ), - DropdownMenuItem( - value: LlmProvider.anthropic, - child: Text('Anthropic (Claude)'), + const SizedBox(height: 12), + TextField( + controller: _baseUrlController, + decoration: InputDecoration( + labelText: _selectedProvider.hasConfigurableBaseUrl + ? 'Base URL' + : 'Provider endpoint', + helperText: _selectedProvider == LlmProvider.openaiCompatible + ? 'Use the OpenAI-compatible API base, usually ending in /v1.' + : null, + border: const OutlineInputBorder(), + ), ), - DropdownMenuItem( - value: LlmProvider.google, - child: Text('Google AI'), + if (apiKeyVisible) ...[ + const SizedBox(height: 12), + TextField( + controller: _apiKeyController, + obscureText: true, + decoration: InputDecoration( + labelText: _selectedProvider.requiresApiKey + ? 'API key' + : 'API key (optional)', + hintText: widget.hasApiKey + ? 'Leave empty to keep current key' + : 'Paste provider key', + border: const OutlineInputBorder(), + ), + ), + ], + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _models.isEmpty + ? TextField( + controller: _modelController, + decoration: const InputDecoration( + labelText: 'Model', + border: OutlineInputBorder(), + ), + ) + : DropdownButtonFormField( + initialValue: _modelController.text.isNotEmpty + ? _modelController.text + : _models.first.id, + decoration: const InputDecoration( + labelText: 'Model', + border: OutlineInputBorder(), + ), + items: _models + .map( + (LlmModelInfo model) => + DropdownMenuItem( + value: model.id, + child: Text(model.label), + ), + ) + .toList(growable: false), + onChanged: (String? value) { + if (value != null) { + _modelController.text = value; + } + }, + ), + ), + const SizedBox(width: 8), + IconButton.outlined( + onPressed: _loadingModels ? null : _loadModels, + icon: _loadingModels + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.cloud_sync_rounded), + tooltip: 'Load models from provider', + ), + ], + ), + if (_modelLoadMessage != null) ...[ + const SizedBox(height: 8), + Text( + _modelLoadMessage!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + const SizedBox(height: 12), + Text( + _selectedProvider == LlmProvider.ollama + ? 'Ollama reads installed local models from /api/tags and sends chat requests to /api/chat.' + : 'Model discovery uses this provider connection before saving the selected model.', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), ), ], - onChanged: (LlmProvider? value) { - if (value != null) { - setState(() { - _selectedProvider = value; - }); - } - }, ), - const SizedBox(height: 16), - const Text('API Key'), - const SizedBox(height: 8), - TextField( - controller: _apiKeyController, - obscureText: true, - decoration: InputDecoration( - hintText: widget.hasApiKey - ? 'Leave empty to keep current' - : 'Enter your API key', - border: const OutlineInputBorder(), - ), - ), - ], + ), ), actions: [ TextButton( @@ -642,7 +760,8 @@ class _LlmConfigDialogState extends State<_LlmConfigDialog> { Navigator.of(context).pop( _LlmConfigDraft( provider: _selectedProvider, - apiKey: '', + baseUrl: _baseUrlController.text.trim(), + model: _modelController.text.trim(), clearApiKey: true, ), ); @@ -654,7 +773,11 @@ class _LlmConfigDialogState extends State<_LlmConfigDialog> { Navigator.of(context).pop( _LlmConfigDraft( provider: _selectedProvider, - apiKey: _apiKeyController.text.trim(), + baseUrl: _baseUrlController.text.trim(), + model: _modelController.text.trim(), + apiKey: _apiKeyController.text.trim().isEmpty + ? null + : _apiKeyController.text.trim(), ), ); }, @@ -663,6 +786,211 @@ class _LlmConfigDialogState extends State<_LlmConfigDialog> { ], ); } + + Future _loadModels() async { + setState(() { + _loadingModels = true; + _modelLoadMessage = null; + }); + + try { + final List models = await ref + .read(llmServiceProvider) + .listAvailableModels( + provider: _selectedProvider, + apiKey: _apiKeyController.text.trim().isEmpty + ? null + : _apiKeyController.text.trim(), + baseUrl: _baseUrlController.text.trim(), + ); + if (!mounted) { + return; + } + setState(() { + _models = models; + if (models.isNotEmpty && + !_models.any( + (LlmModelInfo model) => model.id == _modelController.text, + )) { + _modelController.text = models.first.id; + } + _modelLoadMessage = models.isEmpty + ? 'No models were returned by this provider.' + : 'Loaded ${models.length} models.'; + }); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _modelLoadMessage = error.toString(); + }); + } finally { + if (mounted) { + setState(() { + _loadingModels = false; + }); + } + } + } +} + +class _LlmDebugDialog extends ConsumerStatefulWidget { + const _LlmDebugDialog(); + + @override + ConsumerState<_LlmDebugDialog> createState() => _LlmDebugDialogState(); +} + +class _LlmDebugDialogState extends ConsumerState<_LlmDebugDialog> { + late final TextEditingController _promptController; + bool _running = false; + String? _response; + String? _error; + + @override + void initState() { + super.initState(); + _promptController = TextEditingController( + text: 'Reply with one short sentence confirming the connection works.', + ); + } + + @override + void dispose() { + _promptController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final LlmConfigState config = ref.watch(llmConfigProvider); + return AlertDialog( + title: const Text('LLM Debug'), + content: SizedBox( + width: 560, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _LlmDebugConnectionSummary(config: config), + const SizedBox(height: 12), + TextField( + controller: _promptController, + minLines: 3, + maxLines: 5, + decoration: const InputDecoration( + labelText: 'Test prompt', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + if (_running) const LinearProgressIndicator(), + if (_error != null) ...[ + const SizedBox(height: 12), + SelectableText( + _error!, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ], + if (_response != null) ...[ + const SizedBox(height: 12), + DecoratedBox( + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(6), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Align( + alignment: Alignment.centerLeft, + child: SelectableText(_response!), + ), + ), + ), + ], + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: _running ? null : () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + FilledButton.icon( + onPressed: !_running && config.isConfigured ? _runDebugPrompt : null, + icon: const Icon(Icons.play_arrow_rounded), + label: const Text('Send Test'), + ), + ], + ); + } + + Future _runDebugPrompt() async { + setState(() { + _running = true; + _response = null; + _error = null; + }); + try { + final String response = await ref + .read(llmServiceProvider) + .completeText( + systemPrompt: + 'You are a connectivity probe for a relationship app. Answer briefly.', + userPrompt: _promptController.text.trim(), + ); + if (!mounted) { + return; + } + setState(() { + _response = response; + }); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _error = error.toString(); + }); + } finally { + if (mounted) { + setState(() { + _running = false; + }); + } + } + } +} + +class _LlmDebugConnectionSummary extends StatelessWidget { + const _LlmDebugConnectionSummary({required this.config}); + + final LlmConfigState config; + + @override + Widget build(BuildContext context) { + final TextStyle? labelStyle = Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(config.isConfigured ? 'Configured' : 'Not configured'), + const SizedBox(height: 6), + Text('Provider: ${config.provider.label}', style: labelStyle), + Text( + 'Model: ${config.model.isEmpty ? 'Not selected' : config.model}', + style: labelStyle, + ), + Text('Endpoint: ${config.baseUrl}', style: labelStyle), + ], + ); + } } class _ShareSimulationDraft { diff --git a/test/core/llm/llm_config_test.dart b/test/core/llm/llm_config_test.dart new file mode 100644 index 0000000..49e8473 --- /dev/null +++ b/test/core/llm/llm_config_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:relationship_saver/core/llm/llm_config.dart'; + +void main() { + test('cloud provider defaults need an API key', () { + final LlmConfigState missingKey = LlmConfigState( + provider: LlmProvider.openai, + ); + final LlmConfigState configured = LlmConfigState( + apiKeyConfigured: true, + provider: LlmProvider.openai, + ); + + expect(missingKey.isConfigured, isFalse); + expect(configured.isConfigured, isTrue); + expect(configured.baseUrl, 'https://api.openai.com/v1'); + expect(configured.model, 'gpt-4o-mini'); + }); + + test('local providers can be configured without an API key', () { + final LlmConfigState ollama = LlmConfigState( + provider: LlmProvider.ollama, + baseUrl: 'http://192.168.1.12:11434', + model: 'llama3.2:latest', + ); + final LlmConfigState compatible = LlmConfigState( + provider: LlmProvider.openaiCompatible, + baseUrl: 'http://localhost:8000/v1', + model: 'local-model', + ); + + expect(ollama.isConfigured, isTrue); + expect(compatible.isConfigured, isTrue); + }); + + test('local providers still require a model and endpoint', () { + final LlmConfigState missingModel = LlmConfigState( + provider: LlmProvider.ollama, + baseUrl: 'http://localhost:11434', + ); + final LlmConfigState missingEndpoint = LlmConfigState( + provider: LlmProvider.openaiCompatible, + model: 'local-model', + ); + + expect(missingModel.isConfigured, isFalse); + expect(missingEndpoint.isConfigured, isFalse); + }); +}