Przebudowanie aplikacji, usprawnione AI, dodanie combo buildera
Some checks failed
Build Linux App / build (push) Failing after 1m18s

This commit is contained in:
Kazimierz Ciołek
2026-07-06 23:44:17 +02:00
parent 9dcc4b87de
commit 6dd7213eb0
48 changed files with 3229 additions and 669 deletions

View File

@@ -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 {

View File

@@ -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

View File

@@ -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.';

View File

@@ -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),

View File

@@ -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,

View File

@@ -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) ...[

View 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,
),
),
],
],
),
],
),
),
],
),
);
}
}

View File

@@ -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(

View File

@@ -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,
),
),
),