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();
}
}