Poprawki
Some checks failed
Build Linux App / build (push) Failing after 1m12s

This commit is contained in:
Kazimierz Ciołek
2026-07-07 22:03:36 +02:00
parent 6dd7213eb0
commit ebb7426c67
14 changed files with 886 additions and 82 deletions

View File

@@ -1,6 +1,10 @@
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/entities/training_exercise.dart';
import 'package:trainhub_flutter/domain/entities/training_section.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';
@@ -206,6 +210,71 @@ class ChatController extends _$ChatController {
/// kept and persisted like a normal reply.
void stopGeneration() => _cancelToken?.cancel();
/// Turns an assistant reply describing a workout into a real training plan
/// in the database. The model re-reads its own text and emits strict JSON,
/// which is parsed into sections/exercises. Returns the created plan name.
Future<String> createPlanFromText(String content) async {
const extractPrompt =
'You convert workout descriptions into strict JSON. '
'Respond with ONLY valid JSON — no markdown fences, no commentary. '
'Schema: {"name": string, "sections": [{"name": string, '
'"exercises": [{"name": string, "sets": int, "value": int, '
'"isTime": bool, "rest": int}]}]}. '
'"value" means repetitions when isTime=false, or seconds when '
'isTime=true. "rest" is rest between sets in seconds. '
'Keep exercise names short. If the text contains no workout plan, '
'respond with {"name": "", "sections": []}.';
final buffer = StringBuffer();
await for (final delta in _llm.streamChat([
{'role': 'system', 'content': extractPrompt},
{'role': 'user', 'content': content},
])) {
buffer.write(delta);
}
final raw = buffer.toString();
final start = raw.indexOf('{');
final end = raw.lastIndexOf('}');
if (start < 0 || end <= start) {
throw Exception('The model did not return a valid plan.');
}
final data =
jsonDecode(raw.substring(start, end + 1)) as Map<String, dynamic>;
final sectionsJson = (data['sections'] as List?) ?? [];
if (sectionsJson.isEmpty) {
throw Exception('No workout plan found in this reply.');
}
final sections = <TrainingSectionEntity>[
for (final s in sectionsJson)
TrainingSectionEntity(
id: const Uuid().v4(),
name: (s['name'] ?? 'Section') as String,
exercises: [
for (final e in (s['exercises'] as List? ?? []))
TrainingExerciseEntity(
instanceId: const Uuid().v4(),
exerciseId: '',
name: (e['name'] ?? 'Exercise') as String,
sets: (e['sets'] as num?)?.toInt() ?? 3,
value: (e['value'] as num?)?.toInt() ?? 10,
isTime: (e['isTime'] as bool?) ?? false,
rest: (e['rest'] as num?)?.toInt() ?? 60,
),
],
),
];
final planRepo = getIt<TrainingPlanRepository>();
final name = ((data['name'] as String?)?.trim().isEmpty ?? true)
? 'AI Plan'
: (data['name'] as String).trim();
final created = await planRepo.create(name);
await planRepo.update(created.copyWith(sections: sections));
return name;
}
Future<String> _streamResponse(
String systemPrompt,
List<Map<String, String>> history,

View File

@@ -41,7 +41,7 @@ final aiSettingsServiceProvider =
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef AiSettingsServiceRef = AutoDisposeProviderRef<AiSettingsService>;
String _$chatControllerHash() => r'227ef80f7bcc8787d85a726f151d878a3ba954d6';
String _$chatControllerHash() => r'3856f4ae716d8ea0fab459f0b8182bc76e9f1e1b';
/// See also [ChatController].
@ProviderFor(ChatController)

View File

@@ -58,6 +58,37 @@ class _ChatPageState extends ConsumerState<ChatPage> {
_inputFocusNode.requestFocus();
}
Future<void> _createPlanFromMessage(
ChatController controller,
String content,
) async {
// Simple progress dialog — extraction is a second LLM round-trip.
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
try {
final name = await controller.createPlanFromText(content);
if (!mounted) return;
Navigator.of(context, rootNavigator: true).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Plan "$name" created — find it under Trainings.'),
),
);
} catch (e) {
if (!mounted) return;
Navigator.of(context, rootNavigator: true).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Could not create a plan: $e'),
backgroundColor: AppColors.surfaceContainerHigh,
),
);
}
}
String _formatTimestamp(String timestamp) {
try {
final dt = DateTime.parse(timestamp);
@@ -306,6 +337,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
return MessageBubble(
message: msg,
formattedTime: _formatTimestamp(msg.createdAt),
onCreatePlan: msg.isUser
? null
: () => _createPlanFromMessage(controller, msg.content),
);
},
);

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.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/domain/entities/chat_message.dart';
@@ -9,11 +10,16 @@ class MessageBubble extends StatelessWidget {
super.key,
required this.message,
required this.formattedTime,
this.onCreatePlan,
});
final ChatMessageEntity message;
final String formattedTime;
/// When set (assistant messages), shows a "create plan" action that turns
/// the reply into a real training plan.
final VoidCallback? onCreatePlan;
@override
Widget build(BuildContext context) {
final isUser = message.isUser;
@@ -21,8 +27,9 @@ class MessageBubble extends StatelessWidget {
return Padding(
padding: const EdgeInsets.only(bottom: UIConstants.spacing12),
child: Row(
mainAxisAlignment:
isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
mainAxisAlignment: isUser
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isUser) ...[
@@ -34,8 +41,9 @@ class MessageBubble extends StatelessWidget {
],
Flexible(
child: Column(
crossAxisAlignment:
isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
crossAxisAlignment: isUser
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
Container(
constraints: BoxConstraints(maxWidth: maxWidth),
@@ -84,12 +92,50 @@ class MessageBubble extends StatelessWidget {
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(
formattedTime,
style: const TextStyle(
color: AppColors.textMuted,
fontSize: 11,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
formattedTime,
style: const TextStyle(
color: AppColors.textMuted,
fontSize: 11,
),
),
if (!isUser && onCreatePlan != null) ...[
const SizedBox(width: UIConstants.spacing12),
InkWell(
onTap: onCreatePlan,
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.playlist_add_rounded,
size: 13,
color: AppColors.accent,
),
const SizedBox(width: 4),
Text(
'CREATE PLAN',
style: GoogleFonts.jetBrainsMono(
fontSize: 9,
fontWeight: FontWeight.w600,
letterSpacing: 0.8,
color: AppColors.accent,
),
),
],
),
),
),
],
],
),
),
],