Improve LLM provider configuration

This commit is contained in:
Rijad Zuzo
2026-05-19 11:14:23 +02:00
parent d81215db73
commit e13d8b76cd
5 changed files with 1077 additions and 151 deletions
@@ -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<void> _showLlmDebug(BuildContext context) async {
await showDialog<void>(
context: context,
builder: (BuildContext context) => const _LlmDebugDialog(),
);
}
Future<void> _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<LlmModelInfo> _models = const <LlmModelInfo>[];
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: <Widget>[
const Text('Provider'),
const SizedBox(height: 8),
DropdownButtonFormField<LlmProvider>(
initialValue: _selectedProvider,
decoration: const InputDecoration(border: OutlineInputBorder()),
items: const <DropdownMenuItem<LlmProvider>>[
DropdownMenuItem<LlmProvider>(
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: <Widget>[
DropdownButtonFormField<LlmProvider>(
initialValue: _selectedProvider,
decoration: const InputDecoration(
labelText: 'Provider',
border: OutlineInputBorder(),
),
items: LlmProvider.values
.map(
(LlmProvider provider) => DropdownMenuItem<LlmProvider>(
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 <LlmModelInfo>[];
_modelLoadMessage = null;
});
},
),
DropdownMenuItem<LlmProvider>(
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<LlmProvider>(
value: LlmProvider.google,
child: Text('Google AI'),
if (apiKeyVisible) ...<Widget>[
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: <Widget>[
Expanded(
child: _models.isEmpty
? TextField(
controller: _modelController,
decoration: const InputDecoration(
labelText: 'Model',
border: OutlineInputBorder(),
),
)
: DropdownButtonFormField<String>(
initialValue: _modelController.text.isNotEmpty
? _modelController.text
: _models.first.id,
decoration: const InputDecoration(
labelText: 'Model',
border: OutlineInputBorder(),
),
items: _models
.map(
(LlmModelInfo model) =>
DropdownMenuItem<String>(
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) ...<Widget>[
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: <Widget>[
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<void> _loadModels() async {
setState(() {
_loadingModels = true;
_modelLoadMessage = null;
});
try {
final List<LlmModelInfo> 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: <Widget>[
_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) ...<Widget>[
const SizedBox(height: 12),
SelectableText(
_error!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
],
if (_response != null) ...<Widget>[
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: <Widget>[
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<void> _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: <Widget>[
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 {