Improve LLM provider configuration
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user