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,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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user