Przebudowanie aplikacji, usprawnione AI, dodanie combo buildera
Some checks failed
Build Linux App / build (push) Failing after 1m18s
Some checks failed
Build Linux App / build (push) Failing after 1m18s
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:archive/archive_io.dart';
|
||||
@@ -27,12 +28,24 @@ Future<String> _llamaArchiveUrl() async {
|
||||
throw UnsupportedError('Unsupported platform: ${Platform.operatingSystem}');
|
||||
}
|
||||
|
||||
@riverpod
|
||||
// keepAlive: a download must survive navigating away from the settings /
|
||||
// welcome screen — otherwise leaving the page silently drops its progress.
|
||||
@Riverpod(keepAlive: true)
|
||||
class AiModelSettingsController extends _$AiModelSettingsController {
|
||||
final _dio = Dio();
|
||||
|
||||
@override
|
||||
AiModelSettingsState build() => const AiModelSettingsState();
|
||||
AiModelSettingsState build() {
|
||||
// Check installed files right away so every screen (settings, welcome,
|
||||
// chat) sees the real state without a manual "Validate" click.
|
||||
Future.microtask(validateModels);
|
||||
return const AiModelSettingsState();
|
||||
}
|
||||
|
||||
/// A file that exists but is suspiciously small is a leftover from an
|
||||
/// interrupted download — treat it as missing.
|
||||
static bool _isComplete(File file, int minBytes) =>
|
||||
file.existsSync() && file.lengthSync() >= minBytes;
|
||||
|
||||
Future<void> validateModels() async {
|
||||
state = state.copyWith(
|
||||
@@ -42,13 +55,19 @@ class AiModelSettingsController extends _$AiModelSettingsController {
|
||||
try {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final base = dir.path;
|
||||
final serverBin = File(p.join(base, AiConstants.serverBinaryName));
|
||||
final nomicModel = File(p.join(base, AiConstants.nomicModelFile));
|
||||
final qwenModel = File(p.join(base, AiConstants.qwenModelFile));
|
||||
final validated =
|
||||
serverBin.existsSync() &&
|
||||
nomicModel.existsSync() &&
|
||||
qwenModel.existsSync();
|
||||
_isComplete(
|
||||
File(p.join(base, AiConstants.serverBinaryName)),
|
||||
AiConstants.serverBinaryMinBytes,
|
||||
) &&
|
||||
_isComplete(
|
||||
File(p.join(base, AiConstants.nomicModelFile)),
|
||||
AiConstants.nomicModelMinBytes,
|
||||
) &&
|
||||
_isComplete(
|
||||
File(p.join(base, AiConstants.qwenModelFile)),
|
||||
AiConstants.qwenModelMinBytes,
|
||||
);
|
||||
state = state.copyWith(
|
||||
areModelsValidated: validated,
|
||||
currentTask: validated ? 'All files present.' : 'Files missing.',
|
||||
@@ -97,14 +116,16 @@ class AiModelSettingsController extends _$AiModelSettingsController {
|
||||
savePath: p.join(dir.path, AiConstants.nomicModelFile),
|
||||
taskLabel: 'Downloading Nomic embedding model…',
|
||||
overallStart: 0.2,
|
||||
overallEnd: 0.55,
|
||||
overallEnd: 0.35,
|
||||
minBytes: AiConstants.nomicModelMinBytes,
|
||||
);
|
||||
await _downloadFile(
|
||||
url: AiConstants.qwenModelUrl,
|
||||
savePath: p.join(dir.path, AiConstants.qwenModelFile),
|
||||
taskLabel: 'Downloading Qwen 2.5 7B model…',
|
||||
overallStart: 0.55,
|
||||
taskLabel: 'Downloading Qwen3 4B model…',
|
||||
overallStart: 0.35,
|
||||
overallEnd: 1.0,
|
||||
minBytes: AiConstants.qwenModelMinBytes,
|
||||
);
|
||||
state = state.copyWith(
|
||||
isDownloading: false,
|
||||
@@ -112,6 +133,11 @@ class AiModelSettingsController extends _$AiModelSettingsController {
|
||||
currentTask: 'Download complete.',
|
||||
);
|
||||
await validateModels();
|
||||
// Bring the AI online immediately — the user shouldn't have to visit
|
||||
// the chat page or click anything for the servers to start loading.
|
||||
if (state.areModelsValidated) {
|
||||
unawaited(di.getIt<AiProcessManager>().startServers());
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
state = state.copyWith(
|
||||
isDownloading: false,
|
||||
@@ -133,11 +159,23 @@ class AiModelSettingsController extends _$AiModelSettingsController {
|
||||
required String taskLabel,
|
||||
required double overallStart,
|
||||
required double overallEnd,
|
||||
int minBytes = 0,
|
||||
}) async {
|
||||
// Already downloaded (e.g. a retry after a failure further down the
|
||||
// list) — don't pull gigabytes again.
|
||||
if (_isComplete(File(savePath), minBytes) && minBytes > 0) {
|
||||
state = state.copyWith(progress: overallEnd);
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(currentTask: taskLabel, progress: overallStart);
|
||||
|
||||
// Download to a temp file and rename at the end, so an interrupted
|
||||
// download can never masquerade as a complete model file.
|
||||
final partPath = '$savePath.part';
|
||||
await _dio.download(
|
||||
url,
|
||||
savePath,
|
||||
partPath,
|
||||
onReceiveProgress: (received, total) {
|
||||
if (total <= 0) return;
|
||||
final fileProgress = received / total;
|
||||
@@ -151,6 +189,10 @@ class AiModelSettingsController extends _$AiModelSettingsController {
|
||||
receiveTimeout: const Duration(hours: 2),
|
||||
),
|
||||
);
|
||||
|
||||
final target = File(savePath);
|
||||
if (target.existsSync()) target.deleteSync();
|
||||
File(partPath).renameSync(savePath);
|
||||
}
|
||||
|
||||
Future<void> _extractBinary(String archivePath, String destDir) async {
|
||||
|
||||
@@ -7,15 +7,12 @@ part of 'ai_model_settings_controller.dart';
|
||||
// **************************************************************************
|
||||
|
||||
String _$aiModelSettingsControllerHash() =>
|
||||
r'27a37c3fafb21b93a8b5523718f1537419bd382a';
|
||||
r'41d0842f57085bab80f0de18d8ab0eb72cdd5440';
|
||||
|
||||
/// See also [AiModelSettingsController].
|
||||
@ProviderFor(AiModelSettingsController)
|
||||
final aiModelSettingsControllerProvider =
|
||||
AutoDisposeNotifierProvider<
|
||||
AiModelSettingsController,
|
||||
AiModelSettingsState
|
||||
>.internal(
|
||||
NotifierProvider<AiModelSettingsController, AiModelSettingsState>.internal(
|
||||
AiModelSettingsController.new,
|
||||
name: r'aiModelSettingsControllerProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
@@ -25,6 +22,6 @@ final aiModelSettingsControllerProvider =
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$AiModelSettingsController = AutoDisposeNotifier<AiModelSettingsState>;
|
||||
typedef _$AiModelSettingsController = Notifier<AiModelSettingsState>;
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||
|
||||
@@ -53,10 +53,7 @@ class KnowledgeBaseController extends _$KnowledgeBaseController {
|
||||
successMessage: 'Saved! Knowledge base now has $count chunks.',
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: _friendlyError(e),
|
||||
);
|
||||
state = state.copyWith(isLoading: false, errorMessage: _friendlyError(e));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,10 +75,7 @@ class KnowledgeBaseController extends _$KnowledgeBaseController {
|
||||
successMessage: 'Knowledge base cleared.',
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: _friendlyError(e),
|
||||
);
|
||||
state = state.copyWith(isLoading: false, errorMessage: _friendlyError(e));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,8 +85,7 @@ class KnowledgeBaseController extends _$KnowledgeBaseController {
|
||||
|
||||
String _friendlyError(Object e) {
|
||||
final msg = e.toString();
|
||||
if (msg.contains('Connection refused') ||
|
||||
msg.contains('SocketException')) {
|
||||
if (msg.contains('Connection refused') || msg.contains('SocketException')) {
|
||||
return 'Cannot reach the embedding server. '
|
||||
'Make sure AI models are downloaded and the app has had time to '
|
||||
'start the inference servers.';
|
||||
|
||||
@@ -91,8 +91,10 @@ class _KnowledgeBasePageState extends ConsumerState<KnowledgeBasePage> {
|
||||
final controller = ref.read(knowledgeBaseControllerProvider.notifier);
|
||||
|
||||
// Show success SnackBar when a note is saved successfully.
|
||||
ref.listen<KnowledgeBaseState>(knowledgeBaseControllerProvider,
|
||||
(prev, next) {
|
||||
ref.listen<KnowledgeBaseState>(knowledgeBaseControllerProvider, (
|
||||
prev,
|
||||
next,
|
||||
) {
|
||||
if (next.successMessage != null &&
|
||||
next.successMessage != prev?.successMessage) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -107,12 +109,10 @@ class _KnowledgeBasePageState extends ConsumerState<KnowledgeBasePage> {
|
||||
backgroundColor: AppColors.surfaceContainerHigh,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
side: const BorderSide(
|
||||
color: AppColors.success,
|
||||
width: 1,
|
||||
borderRadius: BorderRadius.circular(
|
||||
UIConstants.smallBorderRadius,
|
||||
),
|
||||
side: const BorderSide(color: AppColors.success, width: 1),
|
||||
),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
@@ -139,12 +139,12 @@ class _KnowledgeBasePageState extends ConsumerState<KnowledgeBasePage> {
|
||||
children: [
|
||||
// Heading
|
||||
Text(
|
||||
'Knowledge Base',
|
||||
style: GoogleFonts.inter(
|
||||
'KNOWLEDGE BASE',
|
||||
style: GoogleFonts.chakraPetch(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
letterSpacing: -0.3,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing8),
|
||||
@@ -229,9 +229,7 @@ class _KnowledgeBasePageState extends ConsumerState<KnowledgeBasePage> {
|
||||
),
|
||||
if (kbState.chunkCount > 0) ...[
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
_ClearButton(
|
||||
onPressed: () => _clear(controller),
|
||||
),
|
||||
_ClearButton(onPressed: () => _clear(controller)),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -327,9 +325,9 @@ class _StatusCard extends StatelessWidget {
|
||||
child: Text(
|
||||
hasChunks
|
||||
? '$chunkCount chunk${chunkCount == 1 ? '' : 's'} stored — '
|
||||
'AI chat will use these as context.'
|
||||
'AI chat will use these as context.'
|
||||
: 'No notes added yet. The AI chat will use only its base '
|
||||
'training knowledge.',
|
||||
'training knowledge.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: hasChunks ? AppColors.success : AppColors.textMuted,
|
||||
@@ -407,8 +405,7 @@ class _SaveButtonState extends State<_SaveButton> {
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
onTap: widget.onPressed,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -471,8 +468,7 @@ class _ClearButtonState extends State<_ClearButton> {
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
onTap: widget.onPressed,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -484,8 +480,9 @@ class _ClearButtonState extends State<_ClearButton> {
|
||||
Icon(
|
||||
Icons.delete_outline_rounded,
|
||||
size: 15,
|
||||
color:
|
||||
_hovered ? AppColors.destructive : AppColors.textMuted,
|
||||
color: _hovered
|
||||
? AppColors.destructive
|
||||
: AppColors.textMuted,
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Text(
|
||||
@@ -523,9 +520,7 @@ class _ErrorBanner extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.destructiveMuted,
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
border: Border.all(
|
||||
color: AppColors.destructive.withValues(alpha: 0.4),
|
||||
),
|
||||
border: Border.all(color: AppColors.destructive.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -589,7 +584,8 @@ class _HowItWorksCard extends StatelessWidget {
|
||||
const SizedBox(height: UIConstants.spacing12),
|
||||
_Step(
|
||||
n: '1',
|
||||
text: 'Your text is split into ~500-character chunks at paragraph '
|
||||
text:
|
||||
'Your text is split into ~500-character chunks at paragraph '
|
||||
'and sentence boundaries.',
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing8),
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:trainhub_flutter/core/router/app_router.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
import 'package:trainhub_flutter/presentation/settings/ai_model_settings_controller.dart';
|
||||
import 'package:trainhub_flutter/presentation/settings/widgets/ai_models_section.dart';
|
||||
import 'package:trainhub_flutter/presentation/settings/widgets/ai_provider_section.dart';
|
||||
import 'package:trainhub_flutter/presentation/settings/widgets/knowledge_base_section.dart';
|
||||
import 'package:trainhub_flutter/presentation/settings/widgets/settings_top_bar.dart';
|
||||
|
||||
@@ -34,15 +35,17 @@ class SettingsPage extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Settings',
|
||||
style: GoogleFonts.inter(
|
||||
'SETTINGS',
|
||||
style: GoogleFonts.chakraPetch(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
letterSpacing: -0.3,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing32),
|
||||
const AiProviderSection(),
|
||||
const SizedBox(height: UIConstants.spacing32),
|
||||
AiModelsSection(
|
||||
modelState: modelState,
|
||||
onDownload: controller.downloadAll,
|
||||
|
||||
@@ -49,13 +49,13 @@ class AiModelsSection extends StatelessWidget {
|
||||
const Divider(height: 1, color: AppColors.border),
|
||||
const _ModelRow(
|
||||
name: 'Nomic Embed v1.5 Q4_K_M',
|
||||
description: 'Text embedding model (~300 MB)',
|
||||
description: 'Text embedding model (~84 MB)',
|
||||
icon: Icons.hub_outlined,
|
||||
),
|
||||
const Divider(height: 1, color: AppColors.border),
|
||||
const _ModelRow(
|
||||
name: 'Qwen 2.5 7B Instruct Q4_K_M',
|
||||
description: 'Chat / reasoning model (~4.7 GB)',
|
||||
name: 'Qwen3 4B Instruct 2507 Q4_K_M',
|
||||
description: 'Chat / reasoning model (~2.4 GB)',
|
||||
icon: Icons.psychology_outlined,
|
||||
),
|
||||
const Divider(height: 1, color: AppColors.border),
|
||||
@@ -162,7 +162,7 @@ class _StatusAndActions extends StatelessWidget {
|
||||
const SizedBox(height: UIConstants.spacing16),
|
||||
if (!modelState.areModelsValidated)
|
||||
SettingsActionButton(
|
||||
label: 'Download AI Models (~5 GB)',
|
||||
label: 'Download AI Models (~2.7 GB)',
|
||||
icon: Icons.download_rounded,
|
||||
color: AppColors.accent,
|
||||
textColor: AppColors.zinc950,
|
||||
@@ -190,11 +190,13 @@ class _StatusBadge extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = validated ? AppColors.success : AppColors.textMuted;
|
||||
final bgColor =
|
||||
validated ? AppColors.successMuted : AppColors.surfaceContainerHigh;
|
||||
final bgColor = validated
|
||||
? AppColors.successMuted
|
||||
: AppColors.surfaceContainerHigh;
|
||||
final label = validated ? 'Ready' : 'Missing';
|
||||
final icon =
|
||||
validated ? Icons.check_circle_outline : Icons.radio_button_unchecked;
|
||||
final icon = validated
|
||||
? Icons.check_circle_outline
|
||||
: Icons.radio_button_unchecked;
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -277,8 +279,7 @@ class _DownloadingView extends StatelessWidget {
|
||||
value: modelState.progress,
|
||||
minHeight: 6,
|
||||
backgroundColor: AppColors.zinc800,
|
||||
valueColor:
|
||||
const AlwaysStoppedAnimation<Color>(AppColors.accent),
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.accent),
|
||||
),
|
||||
),
|
||||
if (modelState.errorMessage != null) ...[
|
||||
|
||||
242
lib/presentation/settings/widgets/ai_provider_section.dart
Normal file
242
lib/presentation/settings/widgets/ai_provider_section.dart
Normal file
@@ -0,0 +1,242 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
import 'package:trainhub_flutter/data/services/ai_settings_service.dart';
|
||||
import 'package:trainhub_flutter/injection.dart';
|
||||
|
||||
/// Lets the user pick which AI backend answers chat: the bundled local model
|
||||
/// or a cloud API (Anthropic / OpenAI / Gemini) with their own key.
|
||||
class AiProviderSection extends StatefulWidget {
|
||||
const AiProviderSection({super.key});
|
||||
|
||||
@override
|
||||
State<AiProviderSection> createState() => _AiProviderSectionState();
|
||||
}
|
||||
|
||||
class _AiProviderSectionState extends State<AiProviderSection> {
|
||||
late final AiSettingsService _service = getIt<AiSettingsService>();
|
||||
late AiProvider _provider;
|
||||
late final TextEditingController _apiKeyController;
|
||||
late final TextEditingController _modelController;
|
||||
late final TextEditingController _baseUrlController;
|
||||
bool _obscureKey = true;
|
||||
bool _saved = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_provider = _service.settings.provider;
|
||||
_apiKeyController = TextEditingController(
|
||||
text: _service.settings.apiKeys[_provider.name] ?? '',
|
||||
);
|
||||
_modelController = TextEditingController(
|
||||
text: _service.settings.modelFor(_provider),
|
||||
);
|
||||
_baseUrlController = TextEditingController(
|
||||
text: _service.settings.baseUrlFor(_provider),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_apiKeyController.dispose();
|
||||
_modelController.dispose();
|
||||
_baseUrlController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onProviderChanged(AiProvider? provider) {
|
||||
if (provider == null) return;
|
||||
setState(() {
|
||||
_provider = provider;
|
||||
_apiKeyController.text = _service.settings.apiKeys[provider.name] ?? '';
|
||||
_modelController.text = _service.settings.modelFor(provider);
|
||||
_baseUrlController.text = _service.settings.baseUrlFor(provider);
|
||||
_saved = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final settings = _service.settings;
|
||||
await _service.save(
|
||||
settings.copyWith(
|
||||
provider: _provider,
|
||||
apiKeys: {
|
||||
...settings.apiKeys,
|
||||
_provider.name: _apiKeyController.text.trim(),
|
||||
},
|
||||
models: {
|
||||
...settings.models,
|
||||
_provider.name: _modelController.text.trim(),
|
||||
},
|
||||
baseUrls: {
|
||||
...settings.baseUrls,
|
||||
_provider.name: _baseUrlController.text.trim(),
|
||||
},
|
||||
),
|
||||
);
|
||||
if (mounted) setState(() => _saved = true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius: UIConstants.cardBorderRadius,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UIConstants.spacing16),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.cloud_outlined,
|
||||
size: 18,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'AI PROVIDER',
|
||||
style: GoogleFonts.chakraPetch(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 1,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, color: AppColors.border),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UIConstants.spacing16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Chat backend. The knowledge base always uses the local '
|
||||
'embedding model — your notes never leave this machine. '
|
||||
'With a cloud provider, chat messages plus your exercise '
|
||||
'and plan names are sent to that provider.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing16),
|
||||
DropdownButtonFormField<AiProvider>(
|
||||
initialValue: _provider,
|
||||
onChanged: _onProviderChanged,
|
||||
dropdownColor: AppColors.surfaceContainerHigh,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
decoration: const InputDecoration(labelText: 'Provider'),
|
||||
items: [
|
||||
for (final p in AiProvider.values)
|
||||
DropdownMenuItem(value: p, child: Text(p.label)),
|
||||
],
|
||||
),
|
||||
if (_provider.isSelfHosted) ...[
|
||||
const SizedBox(height: UIConstants.spacing12),
|
||||
TextField(
|
||||
controller: _baseUrlController,
|
||||
onChanged: (_) => setState(() => _saved = false),
|
||||
style: GoogleFonts.jetBrainsMono(
|
||||
fontSize: 12,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Server URL',
|
||||
helperText: 'Default: ${_provider.defaultBaseUrl}',
|
||||
helperStyle: GoogleFonts.inter(
|
||||
fontSize: 11,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_provider.needsApiKey) ...[
|
||||
const SizedBox(height: UIConstants.spacing12),
|
||||
TextField(
|
||||
controller: _apiKeyController,
|
||||
obscureText: _obscureKey,
|
||||
onChanged: (_) => setState(() => _saved = false),
|
||||
style: GoogleFonts.jetBrainsMono(
|
||||
fontSize: 12,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'API key',
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscureKey
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
size: 16,
|
||||
),
|
||||
onPressed: () =>
|
||||
setState(() => _obscureKey = !_obscureKey),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (_provider != AiProvider.local) ...[
|
||||
const SizedBox(height: UIConstants.spacing12),
|
||||
TextField(
|
||||
controller: _modelController,
|
||||
onChanged: (_) => setState(() => _saved = false),
|
||||
style: GoogleFonts.jetBrainsMono(
|
||||
fontSize: 12,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Model',
|
||||
helperText: _provider == AiProvider.ollama
|
||||
? 'Name of a model pulled in Ollama, '
|
||||
'e.g. qwen3:4b, llama3.1'
|
||||
: 'Default: ${_provider.defaultModel}',
|
||||
helperStyle: GoogleFonts.inter(
|
||||
fontSize: 11,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: UIConstants.spacing16),
|
||||
Row(
|
||||
children: [
|
||||
FilledButton(onPressed: _save, child: const Text('SAVE')),
|
||||
if (_saved) ...[
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
const Icon(
|
||||
Icons.check_circle_outline,
|
||||
size: 16,
|
||||
color: AppColors.success,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Saved',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
color: AppColors.success,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -42,16 +42,15 @@ class _SettingsActionButtonState extends State<SettingsActionButton> {
|
||||
color: hasBorder
|
||||
? (_hovered ? AppColors.zinc800 : Colors.transparent)
|
||||
: (_hovered
|
||||
? widget.color.withValues(alpha: 0.85)
|
||||
: widget.color),
|
||||
? widget.color.withValues(alpha: 0.85)
|
||||
: widget.color),
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
border: hasBorder ? Border.all(color: widget.borderColor!) : null,
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
onTap: widget.onPressed,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
|
||||
@@ -78,8 +78,7 @@ class _SettingsIconButtonState extends State<SettingsIconButton> {
|
||||
child: Icon(
|
||||
widget.icon,
|
||||
size: 18,
|
||||
color:
|
||||
_hovered ? AppColors.textPrimary : AppColors.textSecondary,
|
||||
color: _hovered ? AppColors.textPrimary : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user