This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user