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,10 +1,13 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:trainhub_flutter/core/constants/ai_constants.dart';
import 'package:trainhub_flutter/domain/repositories/chat_repository.dart';
import 'package:trainhub_flutter/domain/repositories/exercise_repository.dart';
import 'package:trainhub_flutter/domain/repositories/note_repository.dart';
import 'package:trainhub_flutter/domain/repositories/training_plan_repository.dart';
import 'package:trainhub_flutter/data/services/ai_process_manager.dart';
import 'package:trainhub_flutter/data/services/ai_settings_service.dart';
import 'package:trainhub_flutter/data/services/llm_client.dart';
import 'package:trainhub_flutter/injection.dart';
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
import 'package:uuid/uuid.dart';
@@ -18,22 +21,27 @@ AiProcessManager aiProcessManager(AiProcessManagerRef ref) {
return manager;
}
@riverpod
AiSettingsService aiSettingsService(AiSettingsServiceRef ref) {
final service = getIt<AiSettingsService>();
service.addListener(() => ref.notifyListeners());
return service;
}
@riverpod
class ChatController extends _$ChatController {
late ChatRepository _repo;
late NoteRepository _noteRepo;
final _dio = Dio(
BaseOptions(
connectTimeout: AiConstants.serverConnectTimeout,
receiveTimeout: AiConstants.serverReceiveTimeout,
),
);
late LlmClient _llm;
CancelToken? _cancelToken;
@override
Future<ChatState> build() async {
_repo = getIt<ChatRepository>();
_noteRepo = getIt<NoteRepository>();
_llm = getIt<LlmClient>();
// Abort any in-flight generation when the user leaves the chat page.
ref.onDispose(() => _cancelToken?.cancel());
final aiManager = ref.read(aiProcessManagerProvider);
if (aiManager.status == AiServerStatus.offline) {
aiManager.startServers();
@@ -67,8 +75,9 @@ class ChatController extends _$ChatController {
state = AsyncValue.data(
current.copyWith(
sessions: sessions,
activeSession:
current.activeSession?.id == id ? null : current.activeSession,
activeSession: current.activeSession?.id == id
? null
: current.activeSession,
messages: current.activeSession?.id == id ? [] : current.messages,
),
);
@@ -80,12 +89,44 @@ class ChatController extends _$ChatController {
final sessionId = await _resolveSession(current, content);
await _persistUserMessage(sessionId, content);
final contextChunks = await _searchKnowledgeBase(content);
final systemPrompt = _buildSystemPrompt(contextChunks);
final trainingContext = await _buildTrainingContext();
final systemPrompt = _buildSystemPrompt(contextChunks, trainingContext);
final history = _buildHistory();
final fullAiResponse = await _streamResponse(systemPrompt, history);
await _persistAssistantResponse(sessionId, content, fullAiResponse);
}
/// Summarizes the user's exercise library and training plans so the model
/// can reference and plan around real data. Only attached when a cloud
/// provider is active — the local 4B model's context is too small for it.
Future<String> _buildTrainingContext() async {
if (getIt<LlmClient>().activeProvider == AiProvider.local) return '';
try {
final exercises = await getIt<ExerciseRepository>().getAll();
final plans = await getIt<TrainingPlanRepository>().getAll();
final buffer = StringBuffer();
if (exercises.isNotEmpty) {
buffer.writeln("### The trainer's exercise library:");
for (final e in exercises.take(150)) {
buffer.write('- ${e.name}');
final tags = e.tags;
if (tags != null && tags.isNotEmpty) buffer.write(' [$tags]');
buffer.writeln();
}
}
if (plans.isNotEmpty) {
buffer.writeln("\n### The trainer's training plans:");
for (final plan in plans.take(30)) {
buffer.writeln('- ${plan.name}');
}
}
return buffer.toString();
} catch (_) {
return '';
}
}
Future<String> _resolveSession(ChatState current, String content) async {
if (current.activeSession != null) return current.activeSession!.id;
final session = await _repo.createSession();
@@ -144,61 +185,51 @@ class ChatController extends _$ChatController {
return contextChunks;
}
/// Most recent messages only — an unbounded history would eventually
/// overflow the model context and slow every request down.
List<Map<String, String>> _buildHistory() {
final messages = state.valueOrNull?.messages ?? [];
return messages
.map((m) => <String, String>{
'role': m.isUser ? 'user' : 'assistant',
'content': m.content,
})
final recent = messages.length > AiConstants.chatHistoryLimit
? messages.sublist(messages.length - AiConstants.chatHistoryLimit)
: messages;
return recent
.map(
(m) => <String, String>{
'role': m.isUser ? 'user' : 'assistant',
'content': m.content,
},
)
.toList();
}
/// Stops an in-flight generation. The partial response streamed so far is
/// kept and persisted like a normal reply.
void stopGeneration() => _cancelToken?.cancel();
Future<String> _streamResponse(
String systemPrompt,
List<Map<String, String>> history,
) async {
final generateStep = _createStep('Generating response...');
String fullAiResponse = '';
_cancelToken = CancelToken();
try {
final response = await _dio.post<ResponseBody>(
AiConstants.chatApiUrl,
options: Options(responseType: ResponseType.stream),
data: {
'messages': [
{'role': 'system', 'content': systemPrompt},
...history,
],
'temperature': AiConstants.chatTemperature,
'stream': true,
},
);
final stream = _llm.streamChat([
{'role': 'system', 'content': systemPrompt},
...history,
], cancelToken: _cancelToken);
_updateStep(
generateStep.id,
status: ThinkingStepStatus.running,
title: 'Writing...',
);
final stream = response.data!.stream;
await for (final chunk in stream) {
final textChunk = utf8.decode(chunk);
for (final line in textChunk.split('\n')) {
if (!line.startsWith('data: ')) continue;
final dataStr = line.substring(6).trim();
if (dataStr == '[DONE]') break;
if (dataStr.isEmpty) continue;
try {
final data = jsonDecode(dataStr);
final delta = data['choices']?[0]?['delta']?['content'] ?? '';
if (delta.isNotEmpty) {
fullAiResponse += delta;
final updatedState = state.valueOrNull;
if (updatedState != null) {
state = AsyncValue.data(
updatedState.copyWith(streamingContent: fullAiResponse),
);
}
}
} catch (_) {}
await for (final delta in stream) {
fullAiResponse += delta;
final updatedState = state.valueOrNull;
if (updatedState != null) {
state = AsyncValue.data(
updatedState.copyWith(streamingContent: fullAiResponse),
);
}
}
_updateStep(
@@ -207,21 +238,29 @@ class ChatController extends _$ChatController {
title: 'Response generated',
);
} on DioException catch (e) {
fullAiResponse += '\n\n[AI model communication error]';
_updateStep(
generateStep.id,
status: ThinkingStepStatus.error,
title: 'Generation failed',
details: '${e.message}',
);
if (CancelToken.isCancel(e)) {
_updateStep(
generateStep.id,
status: ThinkingStepStatus.completed,
title: 'Stopped by user',
);
} else {
_updateStep(
generateStep.id,
status: ThinkingStepStatus.error,
title: 'Generation failed',
details: e.message ?? e.toString(),
);
}
} catch (e) {
fullAiResponse += '\n\n[Unexpected error]';
_updateStep(
generateStep.id,
status: ThinkingStepStatus.error,
title: 'Generation failed',
details: e.toString(),
);
} finally {
_cancelToken = null;
}
return fullAiResponse;
}
@@ -231,6 +270,17 @@ class ChatController extends _$ChatController {
String userContent,
String aiResponse,
) async {
// A failed generation yields an empty response — leave the error visible
// in the thinking steps instead of saving an empty assistant message.
if (aiResponse.trim().isEmpty) {
final current = state.valueOrNull;
if (current != null) {
state = AsyncValue.data(
current.copyWith(isTyping: false, streamingContent: null),
);
}
return;
}
await _repo.addMessage(
sessionId: sessionId,
role: 'assistant',
@@ -289,18 +339,32 @@ class ChatController extends _$ChatController {
state = AsyncValue.data(current.copyWith(thinkingSteps: updatedSteps));
}
static String _buildSystemPrompt(List<String> chunks) {
if (chunks.isEmpty) return AiConstants.baseSystemPrompt;
final contextBlock = chunks
.asMap()
.entries
.map((e) => '[${e.key + 1}] ${e.value}')
.join('\n\n');
return '${AiConstants.baseSystemPrompt}\n\n'
'### Relevant notes from the trainer\'s knowledge base:\n'
static String _buildSystemPrompt(
List<String> chunks,
String trainingContext,
) {
final buffer = StringBuffer(AiConstants.baseSystemPrompt);
if (trainingContext.isNotEmpty) {
buffer.write('\n\n$trainingContext');
buffer.write(
'\nWhen designing or discussing training plans, prefer exercises '
'from the library above and reference existing plans by name.',
);
}
if (chunks.isNotEmpty) {
final contextBlock = chunks
.asMap()
.entries
.map((e) => '[${e.key + 1}] ${e.value}')
.join('\n\n');
buffer.write(
'\n\n### Relevant notes from the trainer\'s knowledge base:\n'
'$contextBlock\n\n'
'Use the above context to inform your response when relevant. '
'If the context is not directly applicable, rely on your general '
'fitness knowledge.';
'fitness knowledge.',
);
}
return buffer.toString();
}
}

View File

@@ -23,7 +23,25 @@ final aiProcessManagerProvider = AutoDisposeProvider<AiProcessManager>.internal(
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef AiProcessManagerRef = AutoDisposeProviderRef<AiProcessManager>;
String _$chatControllerHash() => r'266d8a5ac91cbe6c112f85f15adf5a8046e85682';
String _$aiSettingsServiceHash() => r'3d16401bd92a2502e16cc9bc55558e0813682f4b';
/// See also [aiSettingsService].
@ProviderFor(aiSettingsService)
final aiSettingsServiceProvider =
AutoDisposeProvider<AiSettingsService>.internal(
aiSettingsService,
name: r'aiSettingsServiceProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$aiSettingsServiceHash,
dependencies: null,
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef AiSettingsServiceRef = AutoDisposeProviderRef<AiSettingsService>;
String _$chatControllerHash() => r'227ef80f7bcc8787d85a726f151d878a3ba954d6';
/// See also [ChatController].
@ProviderFor(ChatController)

View File

@@ -5,6 +5,7 @@ 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_process_manager.dart';
import 'package:trainhub_flutter/data/services/ai_settings_service.dart';
import 'package:trainhub_flutter/domain/entities/chat_session.dart';
import 'package:trainhub_flutter/presentation/chat/chat_controller.dart';
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
@@ -63,9 +64,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
final now = DateTime.now();
final hour = dt.hour.toString().padLeft(2, '0');
final minute = dt.minute.toString().padLeft(2, '0');
if (dt.year == now.year &&
dt.month == now.month &&
dt.day == now.day) {
if (dt.year == now.year && dt.month == now.month && dt.day == now.day) {
return '$hour:$minute';
}
return '${dt.day}/${dt.month} $hour:$minute';
@@ -76,14 +75,19 @@ class _ChatPageState extends ConsumerState<ChatPage> {
@override
Widget build(BuildContext context) {
final modelsValidated =
ref.watch(aiModelSettingsControllerProvider).areModelsValidated;
final modelsValidated = ref
.watch(aiModelSettingsControllerProvider)
.areModelsValidated;
// A configured external provider (cloud, Ollama, LM Studio) makes chat
// usable without the built-in local models.
final settings = ref.watch(aiSettingsServiceProvider).settings;
final cloudConfigured =
settings.provider != AiProvider.local && settings.isProviderConfigured;
final state = ref.watch(chatControllerProvider);
final controller = ref.read(chatControllerProvider.notifier);
ref.listen(chatControllerProvider, (prev, next) {
if (next.hasValue &&
(prev?.value?.messages.length ?? 0) <
next.value!.messages.length) {
(prev?.value?.messages.length ?? 0) < next.value!.messages.length) {
_scrollToBottom();
}
if (next.hasValue &&
@@ -92,7 +96,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
_scrollToBottom();
}
});
if (!modelsValidated) {
if (!modelsValidated && !cloudConfigured) {
return const Scaffold(
backgroundColor: AppColors.surface,
body: MissingModelsState(),
@@ -211,14 +215,11 @@ class _ChatPageState extends ConsumerState<ChatPage> {
color: isActive
? AppColors.zinc700.withValues(alpha: 0.7)
: isHovered
? AppColors.zinc800.withValues(alpha: 0.6)
: Colors.transparent,
borderRadius:
BorderRadius.circular(UIConstants.smallBorderRadius),
? AppColors.zinc800.withValues(alpha: 0.6)
: Colors.transparent,
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
border: isActive
? Border.all(
color: AppColors.accent.withValues(alpha: 0.3),
)
? Border.all(color: AppColors.accent.withValues(alpha: 0.3))
: null,
),
child: Row(
@@ -239,8 +240,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
? AppColors.textPrimary
: AppColors.textSecondary,
fontSize: 13,
fontWeight:
isActive ? FontWeight.w500 : FontWeight.normal,
fontWeight: isActive ? FontWeight.w500 : FontWeight.normal,
),
),
),
@@ -260,8 +260,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
Icons.delete_outline_rounded,
color: AppColors.textMuted,
),
onPressed: () =>
controller.deleteSession(session.id),
onPressed: () => controller.deleteSession(session.id),
tooltip: 'Delete',
),
),
@@ -290,8 +289,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
horizontal: UIConstants.spacing24,
vertical: UIConstants.spacing16,
),
itemCount:
data.messages.length + (data.isTyping ? 1 : 0),
itemCount: data.messages.length + (data.isTyping ? 1 : 0),
itemBuilder: (context, index) {
if (index == data.messages.length) {
if (data.thinkingSteps.isNotEmpty ||
@@ -391,21 +389,46 @@ class _ChatPageState extends ConsumerState<ChatPage> {
ChatController controller,
) {
final aiManager = ref.watch(aiProcessManagerProvider);
final aiSettings = ref.watch(aiSettingsServiceProvider).settings;
final isTyping = asyncState.valueOrNull?.isTyping ?? false;
final isStarting = aiManager.status == AiServerStatus.starting;
final isError = aiManager.status == AiServerStatus.error;
final isReady = aiManager.status == AiServerStatus.ready;
// External providers (cloud, Ollama, LM Studio) don't need the built-in
// llama server for chat. The local embedding server is still used for
// the knowledge base when available, but its absence shouldn't block.
final isCloud = aiSettings.provider != AiProvider.local;
final cloudReady = isCloud && aiSettings.isProviderConfigured;
final isReady = cloudReady || aiManager.status == AiServerStatus.ready;
String? statusMessage;
Color statusColor = AppColors.textMuted;
if (isStarting) {
if (isCloud) {
if (!cloudReady) {
statusMessage =
'No API key for ${aiSettings.provider.label} — add one in '
'Settings, or switch back to the local model.';
statusColor = AppColors.warning;
} else {
statusMessage =
'${aiSettings.provider.label} · '
'${aiSettings.modelFor(aiSettings.provider)}';
}
} else if (isStarting) {
statusMessage =
aiManager.statusDetail ??
'Starting AI inference server (this may take a moment)...';
statusColor = AppColors.info;
} else if (isError) {
statusMessage =
'AI Server Error: ${aiManager.errorMessage ?? "Unknown error"}';
// The full error contains multi-line server logs — show only the
// first line here; details land in the debug console.
final firstLine = (aiManager.errorMessage ?? 'Unknown error')
.split('\n')
.first
.trim();
statusMessage = 'AI Server Error: $firstLine';
statusColor = AppColors.destructive;
} else if (!isReady) {
} else if (aiManager.status != AiServerStatus.ready) {
statusMessage = 'AI Server offline. Reconnecting...';
}
return Container(
@@ -424,7 +447,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
padding: const EdgeInsets.only(bottom: UIConstants.spacing8),
child: Row(
children: [
if (isStarting)
if (!isCloud && isStarting)
Container(
margin: const EdgeInsets.only(right: 8),
width: 12,
@@ -434,7 +457,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
color: AppColors.info,
),
)
else if (isError)
else if (!isCloud && isError)
const Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(
@@ -453,7 +476,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
),
),
),
if (isError)
if (!isCloud && isError)
TextButton(
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
@@ -479,10 +502,10 @@ class _ChatPageState extends ConsumerState<ChatPage> {
child: Container(
decoration: BoxDecoration(
color: AppColors.surfaceContainer,
borderRadius:
BorderRadius.circular(UIConstants.borderRadius),
border:
Border.all(color: AppColors.border, width: 1),
borderRadius: BorderRadius.circular(
UIConstants.borderRadius,
),
border: Border.all(color: AppColors.border, width: 1),
),
child: TextField(
controller: _inputController,
@@ -525,22 +548,23 @@ class _ChatPageState extends ConsumerState<ChatPage> {
width: 40,
height: 40,
child: Material(
color: (isTyping || !isReady)
? AppColors.zinc700
: AppColors.accent,
borderRadius:
BorderRadius.circular(UIConstants.borderRadius),
// While generating, the button becomes a stop control.
color: !isReady ? AppColors.zinc700 : AppColors.accent,
borderRadius: BorderRadius.circular(UIConstants.borderRadius),
child: InkWell(
borderRadius:
BorderRadius.circular(UIConstants.borderRadius),
onTap: (isTyping || !isReady)
borderRadius: BorderRadius.circular(
UIConstants.borderRadius,
),
onTap: !isReady
? null
: isTyping
? controller.stopGeneration
: () => _sendMessage(controller),
child: Icon(
Icons.arrow_upward_rounded,
color: (isTyping || !isReady)
? AppColors.textMuted
: AppColors.zinc950,
isTyping
? Icons.stop_rounded
: Icons.arrow_upward_rounded,
color: !isReady ? AppColors.textMuted : AppColors.zinc950,
size: 20,
),
),