Добавлены новые функции генерации уроков с использованием ИИ в компонент LessonList и соответствующие изменения в форме создания урока. Обновлены локализации для поддержки новых функций. Реализован API для генерации уроков и добавлены тестовые данные для имитации ответов сервера.

This commit is contained in:
Primakov Alexandr Alexandrovich 2025-03-23 14:57:08 +03:00
parent b00fd32042
commit e178ce5cd6
8 changed files with 228 additions and 21 deletions

View File

@ -11,7 +11,6 @@
"journal.pl.common.edit": "Edit",
"journal.pl.common.delete": "Delete",
"journal.pl.common.save": "Save",
"journal.pl.common.cancel": "Cancel",
"journal.pl.common.students": "students",
"journal.pl.common.teachers": "teachers",
"journal.pl.common.date": "Date",
@ -31,7 +30,7 @@
"journal.pl.common.cancel": "Cancel",
"journal.pl.common.journal": "Journal",
"journal.pl.common.course": "Course",
"journal.pl.common.lesson": "Lesson",
"journal.pl.common.lesson": "Lessons",
"journal.pl.common.marked": "Marked",
"journal.pl.common.people": "people",
"journal.pl.common.success": "Success",
@ -75,6 +74,11 @@
"journal.pl.lesson.action": "Actions",
"journal.pl.lesson.expand": "Expand lesson list",
"journal.pl.lesson.collapse": "Collapse lesson list",
"journal.pl.lesson.aiSuggested": "AI Suggested Lessons",
"journal.pl.lesson.aiSuggestedDescription": "These lessons were automatically generated based on your course schedule",
"journal.pl.lesson.createFromSuggestion": "Create",
"journal.pl.lesson.aiGenerated": "AI generated content",
"journal.pl.lesson.generatingAiSuggestions": "Generating AI lesson suggestions...",
"journal.pl.exam.title": "Exam",
"journal.pl.exam.startExam": "Start exam",

View File

@ -27,7 +27,7 @@
"journal.pl.common.cancel": "Отмена",
"journal.pl.common.journal": "Журнал",
"journal.pl.common.course": "Курс",
"journal.pl.common.lesson": "Лекция",
"journal.pl.common.lesson": "Лекций",
"journal.pl.common.marked": "Отмечено",
"journal.pl.common.people": "человек",
"journal.pl.common.success": "Успешно",
@ -71,6 +71,11 @@
"journal.pl.lesson.action": "Действия",
"journal.pl.lesson.expand": "Развернуть список занятий",
"journal.pl.lesson.collapse": "Свернуть список занятий",
"journal.pl.lesson.aiSuggested": "Рекомендации ИИ",
"journal.pl.lesson.aiSuggestedDescription": "Эти занятия были автоматически сгенерированы на основе вашего расписания курса",
"journal.pl.lesson.createFromSuggestion": "Создать",
"journal.pl.lesson.aiGenerated": "Сгенерировано ИИ",
"journal.pl.lesson.generatingAiSuggestions": "Генерация рекомендаций ИИ...",
"journal.pl.exam.title": "Экзамен",
"journal.pl.exam.startExam": "Начать экзамен",

View File

@ -68,6 +68,11 @@ export const api = createApi({
query: (courseId) => `/lesson/list/${courseId}`,
providesTags: ['LessonList'],
}),
generateLessons: builder.mutation<BaseResponse<{ date: string; name: string }[]>, string>({
query: (courseId) => `/lesson/${courseId}/ai/generate-lessons`,
}),
createLesson: builder.mutation<
BaseResponse<Lesson>,
Partial<Lesson> & Pick<Lesson, 'name' | 'date'> & { courseId: string }

View File

@ -6,18 +6,22 @@ import {
useColorMode
} from '@chakra-ui/react'
export const XlSpinner = () => {
interface XlSpinnerProps {
size?: string;
}
export const XlSpinner: React.FC<XlSpinnerProps> = ({ size = 'xl' }) => {
const { colorMode } = useColorMode();
return (
<Container maxW="container.xl">
<Center h="300px">
<Center h={size === 'sm' ? 'auto' : '300px'}>
<Spinner
thickness="4px"
speed="0.65s"
emptyColor={colorMode === 'light' ? 'gray.200' : 'gray.600'}
color={colorMode === 'light' ? 'blue.500' : 'blue.300'}
size="xl"
size={size}
/>
</Center>
</Container>

View File

@ -1,4 +1,4 @@
import React from 'react'
import React, { useEffect } from 'react'
import { useForm, Controller } from 'react-hook-form'
import {
Box,
@ -14,9 +14,22 @@ import {
FormHelperText,
FormErrorMessage,
Input,
Flex,
Icon,
Text,
Badge,
useColorModeValue,
HStack,
Divider,
SimpleGrid,
Skeleton,
SkeletonText,
useStyleConfig
} from '@chakra-ui/react'
import { AddIcon } from '@chakra-ui/icons'
import { AddIcon, CheckIcon } from '@chakra-ui/icons'
import { useTranslation } from 'react-i18next'
import { FaRobot } from 'react-icons/fa'
import dayjs from 'dayjs'
import { dateToCalendarFormat } from '../../../utils/time'
import { Lesson } from '../../../__data__/model'
@ -28,13 +41,17 @@ interface NewLessonForm {
}
interface LessonFormProps {
lesson?: Partial<Lesson>
lesson?: Partial<Lesson> | any // Разрешаем передавать как Lesson, так и AI-сгенерированный урок
isLoading: boolean
onCancel: () => void
onSubmit: (lesson: Lesson) => void
error?: string
title: string
nameButton: string
aiSuggestions?: any[] // Список предложений от ИИ
isLoadingAiSuggestions?: boolean // Индикатор загрузки предложений
onSelectAiSuggestion?: (suggestion: any) => void // Обработчик выбора предложения
selectedAiSuggestion?: any // Выбранное предложение
}
export const LessonForm = ({
@ -45,8 +62,18 @@ export const LessonForm = ({
error,
title,
nameButton,
aiSuggestions = [],
isLoadingAiSuggestions = false,
onSelectAiSuggestion = () => {},
selectedAiSuggestion
}: LessonFormProps) => {
const { t } = useTranslation()
const isAiSuggested = lesson && !lesson._id && !lesson.id
const aiHighlightColor = useColorModeValue('blue.100', 'blue.800')
const suggestionBgColor = useColorModeValue('blue.50', 'blue.900')
const suggestionHoverBgColor = useColorModeValue('blue.100', 'blue.800')
const borderColor = useColorModeValue('blue.200', 'blue.700')
const textSecondaryColor = useColorModeValue('gray.600', 'gray.400')
const getNearestTimeSlot = () => {
const now = new Date();
@ -68,6 +95,7 @@ export const LessonForm = ({
control,
handleSubmit,
reset,
setValue,
formState: { errors },
} = useForm<NewLessonForm>({
defaultValues: (lesson && {
@ -79,12 +107,41 @@ export const LessonForm = ({
},
})
// Рендерим скелетон для предложений ИИ
const renderSkeletons = () => {
return (
<VStack spacing={3} align="stretch" mt={4}>
<Skeleton height="20px" width="70%" />
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={3}>
{[1, 2, 3, 4, 5].map(idx => (
<Skeleton key={idx} height="60px" borderRadius="md" />
))}
</SimpleGrid>
</VStack>
)
}
// Применяем выбранное предложение к форме
const handleSelectSuggestion = (suggestion) => {
setValue('name', suggestion.name)
setValue('date', dateToCalendarFormat(suggestion.date))
onSelectAiSuggestion(suggestion)
}
return (
<Card align="left">
<Card align="left" bg={isAiSuggested ? aiHighlightColor : undefined}>
<CardHeader display="flex">
<Heading as="h2" mt="0">
{title}
</Heading>
<Flex align="center">
<Heading as="h2" mt="0">
{title}
</Heading>
{isAiSuggested && (
<Badge colorScheme="blue" ml={2} display="flex" alignItems="center">
<Icon as={FaRobot} mr={1} />
<Text>{t('journal.pl.lesson.aiGenerated')}</Text>
</Badge>
)}
</Flex>
<CloseButton
ml="auto"
onClick={() => {
@ -142,8 +199,8 @@ export const LessonForm = ({
<Button
size="lg"
type="submit"
leftIcon={<AddIcon />}
colorScheme="blue"
leftIcon={isAiSuggested ? <Icon as={FaRobot} /> : <AddIcon />}
colorScheme={isAiSuggested ? "blue" : "blue"}
isLoading={isLoading}
>
{nameButton}
@ -153,6 +210,73 @@ export const LessonForm = ({
{error && <ErrorSpan>{error}</ErrorSpan>}
</form>
{/* Блок с предложениями ИИ */}
{(aiSuggestions.length > 0 || isLoadingAiSuggestions) && (
<>
<Divider my={6} />
<Flex align="center" mb={4}>
<Icon as={FaRobot} color="blue.500" mr={2} />
<Heading size="sm" color="blue.500">
{t('journal.pl.lesson.aiSuggested')}
</Heading>
{!isLoadingAiSuggestions && (
<Badge colorScheme="blue" ml={2}>
{aiSuggestions.length} {t('journal.pl.common.lesson').toLowerCase()}
</Badge>
)}
</Flex>
{isLoadingAiSuggestions ? (
renderSkeletons()
) : (
<>
<Text fontSize="sm" color={textSecondaryColor} mb={4}>
{t('journal.pl.lesson.aiSuggestedDescription')}
</Text>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{aiSuggestions.map((suggestion, index) => {
const isSelected = selectedAiSuggestion &&
selectedAiSuggestion.name === suggestion.name &&
selectedAiSuggestion.date === suggestion.date;
return (
<Box
key={`ai-suggestion-${index}`}
bg={isSelected ? suggestionHoverBgColor : suggestionBgColor}
p={3}
borderRadius="md"
borderLeft="3px solid"
borderLeftColor={isSelected ? "green.400" : borderColor}
cursor="pointer"
onClick={() => handleSelectSuggestion(suggestion)}
transition="all 0.2s"
_hover={{
bg: suggestionHoverBgColor,
transform: 'translateY(-2px)',
boxShadow: 'sm'
}}
>
<Flex justify="space-between" align="center" mb={1}>
<HStack>
<Icon as={FaRobot} color="blue.500" boxSize="3" />
<Text fontWeight="bold">{suggestion.name}</Text>
</HStack>
{isSelected && <CheckIcon color="green.400" />}
</Flex>
<Text fontSize="sm" color={textSecondaryColor}>
{dayjs(suggestion.date).format('DD.MM.YYYY HH:mm')}
</Text>
</Box>
);
})}
</SimpleGrid>
</>
)}
</>
)}
</CardBody>
</Card>
)

View File

@ -17,6 +17,7 @@ import {
Tr,
Th,
Tbody,
Td,
Text,
AlertDialog,
AlertDialogBody,
@ -48,6 +49,13 @@ const LessonList = () => {
const { courseId } = useParams()
const user = useAppSelector((s) => s.user)
const { data, isLoading, error, isSuccess } = api.useLessonListQuery(courseId)
const [generateLessonsMutation, {
data: generateLessons,
isLoading: isLoadingGenerateLessons,
error: errorGenerateLessons,
isSuccess: isSuccessGenerateLessons
}, ] = api.useGenerateLessonsMutation()
const [createLesson, crLQuery] = api.useCreateLessonMutation()
const [deleteLesson, deletingRqst] = api.useDeleteLessonMutation()
const [updateLesson, updateLessonRqst] = api.useUpdateLessonMutation()
@ -58,6 +66,7 @@ const LessonList = () => {
const toastRef = useRef(null)
const createdLessonRef = useRef(null)
const [editLesson, setEditLesson] = useState<Lesson>(null)
const [suggestedLessonToCreate, setSuggestedLessonToCreate] = useState(null)
const { t } = useTranslation()
const sorted = useMemo(
() => [...(data?.body || [])]?.sort((a, b) => (a.date > b.date ? 1 : -1)),
@ -93,6 +102,18 @@ const LessonList = () => {
return lessonsData.sort((a, b) => (a.date < b.date ? 1 : -1))
}, [groupByDate, isSuccess, sorted])
useEffect(() => {
if (isTeacher(user) && !isSuccessGenerateLessons) {
generateLessonsMutation(courseId)
}
}, [isSuccessGenerateLessons, user, courseId, generateLessonsMutation])
useEffect(() => {
if (isSuccessGenerateLessons) {
console.log(generateLessons)
}
}, [isSuccessGenerateLessons])
const onSubmit = (lessonData) => {
toastRef.current = toast({
title: t('journal.pl.common.sending'),
@ -174,6 +195,18 @@ const LessonList = () => {
}
}, [updateLessonRqst.isSuccess])
// Обработчик выбора предложения ИИ в форме
const handleSelectAiSuggestion = (suggestion) => {
setSuggestedLessonToCreate(suggestion)
}
// Очищаем выбранную сгенерированную лекцию при закрытии формы
const handleCancelForm = () => {
setShowForm(false)
setEditLesson(null)
setSuggestedLessonToCreate(null)
}
if (isLoading) {
return <XlSpinner />
}
@ -234,17 +267,18 @@ const LessonList = () => {
<Box mt="15" mb="15">
{showForm ? (
<LessonForm
key={editLesson?.id}
key={editLesson?.id || 'new-lesson'}
isLoading={crLQuery.isLoading}
onSubmit={onSubmit}
onCancel={() => {
setShowForm(false)
setEditLesson(null)
}}
onCancel={handleCancelForm}
error={(crLQuery.error as any)?.error}
lesson={editLesson}
lesson={editLesson || suggestedLessonToCreate || undefined}
title={editLesson ? t('journal.pl.lesson.editTitle') : t('journal.pl.lesson.createTitle')}
nameButton={editLesson ? t('journal.pl.edit') : t('journal.pl.common.create')}
aiSuggestions={generateLessons?.body || []}
isLoadingAiSuggestions={isLoadingGenerateLessons}
onSelectAiSuggestion={handleSelectAiSuggestion}
selectedAiSuggestion={suggestedLessonToCreate}
/>
) : (
<Button

View File

@ -45,6 +45,12 @@ router.get('/lesson/list/:courseId', (req, res) => {
res.send(require('../mocks/lessons/list/success.json'))
})
// https://platform.bro-js.ru/jrnl-bh/api/lesson/67cf0c9f2f4241c6fc29f464/ai/generate-lessons
router.get('/lesson/:courseId/ai/generate-lessons', timer(3000), (req, res) => {
res.send(require('../mocks/lessons/generate/success.json'))
})
router.post('/lesson', (req, res) => {
res.send(require('../mocks/lessons/create/success.json'))
})

View File

@ -0,0 +1,25 @@
{
"success": true,
"body": [
{
"date": "2025-03-26",
"name": "Создание первого агента"
},
{
"date": "2025-03-31",
"name": "Работа с памятью агентов"
},
{
"date": "2025-04-02",
"name": "Интеграция инструментов в агентов"
},
{
"date": "2025-04-07",
"name": "Управление цепочками рассуждений"
},
{
"date": "2025-04-09",
"name": "Оптимизация производительности агентов"
}
]
}