D2
Администратор
- Регистрация
- 19 Фев 2025
- Сообщения
- 4,380
- Реакции
- 0
Авторство: hackeryaroslav
Источник: xss.is
Всем привет! Сегодня мы погрузимся в веб-разработку. Понимаю, что в этой сфере всё меньше кажется интересным, но я хочу показать вам, что писать код на таком фреймворке, как Next.js, и связывать его с ИИ — это действительно увлекательно.
Итак, что мы сегодня сделаем? Давайте разберёмся:
Видео демо работы:
Всё, что мы любим: красивый дизайн, анимированные элементы, динамический контент. Да, это не выглядит слишком грандиозно, и предстоит ещё кое-какая работа, но, по крайней мере, крепкий фундамент уже заложен. Так давайте приступим, друзья!
Структура проекта:
Выглядит это следующим образом:Код: Скопировать в буфер обмена
Код:
|-- .env.local
|-- components.json
|-- next.config.ts
|-- package.json
|-- postcss.config.mjs
|-- tailwind.config.js
|-- tailwind.config.ts
|-- tsconfig.json
|-- src
|-- lib
|-- utils.ts
|-- styles
|-- globals.css
|-- types
|-- index.ts
|-- utils
|-- ai.ts
|-- cache.ts
|-- progressAnalytics.ts
|-- spaced-repetition.ts
|-- components
|-- NotesEditor.tsx
|-- Quiz.tsx
|-- StudyMetrics.tsx
|-- ui
|-- button.tsx
|-- card.tsx
|-- input.tsx
|-- progress.tsx
|-- scroll-area.tsx
|-- tabs.tsx
|-- pages
|-- index.tsx
|-- _app.tsx
|-- api
|-- generate-content.ts
|-- optimize-content.ts
Что такое фреймворк NextJS?
Прежде чем приступить к объяснению логики, структуры и кода в целом, нам нужно понять, как работает Next.js.Next.js — это фреймворк для React, который позволит улучшить производительность благодаря поддержке серверного рендеринга с помощью которого рендеринг страницы не будет происходить на устройстве пользователя. Он позволяет заранее генерировать страницы на этапе сборки с помощью getStaticProps и getStaticPaths, что ускоряет загрузку. Также можно использовать серверный рендеринг с getServerSideProps, для того чтобы динамически загружать данные на каждом запросе.
У Next.js еще множество плюсов позволяющих упростить и ускорить разработку, например поддержка API-запросов прямо в приложении через папку pages/api или поддержка автоматического разделения кода на части.
Начало разработки
Прежде всего, давайте разберем папку компонентов проекта:Код: Скопировать в буфер обмена
Код:
|-- components
|-- NotesEditor.tsx
|-- Quiz.tsx
|-- StudyMetrics.tsx
|-- ui
|-- button.tsx
|-- card.tsx
|-- input.tsx
|-- progress.tsx
|-- scroll-area.tsx
|-- tabs.tsx
Для начала рассмотрим папку ui. В ней находятся компоненты обычных элементов страницы — кнопки, карты, табы и так далее. На самом деле, это часть библиотеки ShadCN — новой библиотеки, которая стала достаточно популярна у разработчиков из-за своей простоты. Примеры компонентов можно посмотреть на сайте: https://ui.shadcn.com.
Её установка достаточно простая, и документация подробно объясняет процесс. В итоге мы получим дополнительный файл components.js с настройками и папку ui с компонентами, с которыми будем работать. Пример добавления компонента:
npx shadcn@latest add button
Разбирать эти файлы мы не будем, но я вставлю один из них сюда — кнопку.
JavaScript: Скопировать в буфер обмена
Код:
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
Кроме импортов, мы видим что мы имеем сам компонент кнопки с возможностью выбора различных вариантов стилей (например, для разных состояний или размеров) с помощью библиотеки class-variance-authority (CVA) и React. В компоненте используется класс buttonVariants, который описывает несколько вариантов внешнего вида кнопки (например, default, destructive, outline и т.д.) и размеров (например, sm, lg, icon). При рендеринге кнопки можно указать, будет ли она обычной кнопкой (button) или заменена на кастомный элемент (Slot), что позволяет легко изменять компонент под конкретные нужды. Да, вот так это просто с нашим дизайном. Магия еще впереди.
Первый файл по очереди — NotesEditor.tsx. По названию файла можно понять, что он отвечает создание записок на сайте. Давайте взглянем на код:
JavaScript: Скопировать в буфер обмена
Код:
import { useState } from "react";
import { UserNote } from "@/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export function NotesEditor({
stepId,
onSave,
}: {
stepId: number;
onSave: (note: UserNote) => void;
}) {
const [content, setContent] = useState("");
const [tags, setTags] = useState<string[]>([]);
const handleSave = () => {
const note: UserNote = {
id: Date.now().toString(),
content,
timestamp: new Date(),
stepId,
tags,
};
onSave(note);
setContent("");
setTags([]);
};
return (
<div className="space-y-4 rounded-lg border border-emerald-500/20 bg-black/40 p-4 backdrop-blur">
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
className="min-h-[100px] w-full resize-none border-emerald-500/20 bg-black/40 text-emerald-400 placeholder:text-emerald-400/40 focus:border-emerald-500 focus:ring-emerald-500"
placeholder="Добавьте заметку..."
/>
<Input
type="text"
placeholder="Добавьте теги через запятую"
className="w-full border-emerald-500/20 bg-black/40 text-emerald-400 placeholder:text-emerald-400/40 focus:border-emerald-500 focus:ring-emerald-500"
onKeyDown={(e) => {
if (e.key === "Enter") {
setTags([...tags, e.currentTarget.value]);
e.currentTarget.value = "";
}
}}
/>
<Button
onClick={handleSave}
className="w-full bg-emerald-500 text-black hover:bg-emerald-400"
>
Сохранить заметку
</Button>
</div>
);
}
Внутри используется два состояния: content для текста заметки и tags для тегов. При сохранении заметки, через функцию handleSave, создается объект заметки с уникальным идентификатором (на основе текущего времени), который передается родительскому компоненту через функцию onSave. Пользователь может добавлять теги через поле ввода, где теги добавляются при нажатии клавиши Enter. Это все. Некоторых из вас могут пугать большие стили, но это абсолютно нормально. Таким образом, мы достигли вот такого результата:
JavaScript: Скопировать в буфер обмена
Код:
import { useState } from "react";
import { QuizQuestion, TestResult } from "@/types";
import { Button } from "@/components/ui/button";
export function Quiz({
questions,
onComplete,
}: {
questions: QuizQuestion[];
onComplete: (result: TestResult) => void;
}) {
const [currentQuestion, setCurrentQuestion] = useState(0);
const [answers, setAnswers] = useState<number[]>([]);
const [showExplanation, setShowExplanation] = useState(false);
const handleAnswer = (answerIndex: number) => {
const newAnswers = [...answers, answerIndex];
setAnswers(newAnswers);
setShowExplanation(true);
setTimeout(() => {
setShowExplanation(false);
if (currentQuestion < questions.length - 1) {
setCurrentQuestion((curr) => curr + 1);
} else {
// Подсчет результатов
const incorrectAnswers = questions
.filter((q, idx) => newAnswers[idx] !== q.correctAnswer)
.map((q) => q.question);
const score =
(newAnswers.filter(
(answer, idx) => answer === questions[idx].correctAnswer
).length /
questions.length) *
100;
onComplete({
stepId: currentQuestion,
score,
completedAt: new Date(),
incorrectAnswers,
});
}
}, 3000);
};
const question = questions[currentQuestion];
return (
<div className="rounded-lg border border-emerald-500/20 bg-black/40 p-6 shadow-lg backdrop-blur">
<div className="mb-4">
<div className="text-sm text-emerald-400/60">
Вопрос {currentQuestion + 1} из {questions.length}
</div>
<h3 className="mt-2 text-xl font-semibold text-emerald-400">
{question.question}
</h3>
</div>
<div className="space-y-2">
{question.options.map((option, idx) => (
<Button
key={idx}
onClick={() => handleAnswer(idx)}
disabled={showExplanation}
className={`w-full justify-start p-3 text-left ${
showExplanation
? idx === question.correctAnswer
? "bg-emerald-500 text-black hover:bg-emerald-400"
: "bg-red-500 text-white hover:bg-red-400"
: "bg-black/40 text-emerald-400 hover:bg-emerald-500/20"
}`}
>
{option}
</Button>
))}
</div>
{showExplanation && (
<div className="mt-4 rounded border border-emerald-500/20 bg-black/40 p-4">
<p className="text-sm text-emerald-400/80">{question.explanation}</p>
</div>
)}
</div>
);
}
Код реализует функциональность викторины с вопросами и ответами. Он принимает список вопросов (questions) и функцию обратного вызова onComplete, которая вызывается по завершении викторины с результатами. Внутри используются состояния для отслеживания текущего вопроса (currentQuestion), выбранных ответов (answers) и флага для отображения объяснений (showExplanation). При выборе ответа, через handleAnswer, выбранный индекс добавляется в список ответов, и показывается объяснение правильного ответа. После задержки в 3 секунды отображается следующий вопрос или, если это последний вопрос, подсчитываются результаты. Результаты включают процент правильных ответов и список некорректных вопросов, после чего вызывается onComplete. Визуальные элементы, такие как кнопки с вариантами ответов, меняют стиль в зависимости от правильности выбранного ответа. Мы пока не знаем, откуда берутся вопросы, но не бойтесь, это дело ИИ. Мы лишь обрабатываем их и визуализируем красиво. Вот результат:
JavaScript: Скопировать в буфер обмена
Код:
import { useState } from "react";
import { ProgressAnalytics } from "@/utils/progressAnalytics";
import {
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
ResponsiveContainer,
} from "recharts";
import { LearningSession } from "../types";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export function StudyMetrics({ session }: { session: LearningSession }) {
const [isVisible, setIsVisible] = useState(false);
const metrics = ProgressAnalytics.analyzePerformance(session);
const progressData = session.testResults.map((result, index) => ({
name: `Тест ${index + 1}`,
score: result.score,
}));
const handleClick = () => {
setIsVisible(true);
};
return (
<Card className="border border-emerald-500/20 bg-black/40 text-emerald-400 shadow-lg backdrop-blur">
<CardHeader>
<CardTitle className="text-xl font-semibold">Анализ прогресса</CardTitle>
</CardHeader>
<CardContent>
{!isVisible ? (
<Button
onClick={handleClick}
className="w-full bg-emerald-500 text-black hover:bg-emerald-400"
>
Показать анализ
</Button>
) : (
<>
<div className="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2">
<Card className="border border-emerald-500/20 bg-black/60">
<CardHeader>
<CardTitle className="text-sm font-medium">Средний балл</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">
{metrics.averageScore.toFixed(1)}%
</p>
</CardContent>
</Card>
<Card className="border border-emerald-500/20 bg-black/60">
<CardHeader>
<CardTitle className="text-sm font-medium">Время обучения</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">
{metrics.studyTime > 0
? `${metrics.studyTime} мин.`
: "Недостаточно данных"}
</p>
</CardContent>
</Card>
</div>
<div className="mb-6">
<h4 className="mb-2 font-medium">Прогресс по тестам</h4>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={progressData}>
<Line type="monotone" dataKey="score" stroke="#10b981" />
<CartesianGrid stroke="#1f2937" />
<XAxis dataKey="name" stroke="#6ee7b7" />
<YAxis stroke="#6ee7b7" />
<Tooltip
content={({ payload }) => (
<div className="rounded bg-black/80 p-2 text-emerald-400">
{payload?.[0]?.payload.name}: {payload?.[0]?.value}%
</div>
)}
/>
</LineChart>
</ResponsiveContainer>
</div>
{metrics.weakAreas.length > 0 && (
<div className="mb-6">
<h4 className="mb-2 font-medium">Области для улучшения</h4>
<ul className="list-inside list-disc space-y-1">
{metrics.weakAreas.map((area, index) => (
<li
key={index}
className="cursor-pointer text-emerald-400 hover:underline"
>
{area}
</li>
))}
</ul>
</div>
)}
<div className="mb-6">
<h4 className="mb-2 font-medium">Активность по заметкам</h4>
<div className="flex flex-wrap gap-2">
{metrics.notesAnalysis.commonTags.map((tag, index) => (
<span
key={index}
className="rounded bg-emerald-500/20 px-2 py-1 text-sm text-emerald-400"
>
#{tag}
</span>
))}
</div>
</div>
</>
)}
</CardContent>
</Card>
);
}
Он вычисляет метрики, такие как средний балл, время обучения и слабые области с помощью ProgressAnalytics.analyzePerformance. В компоненте есть состояние isVisible, которое управляет видимостью подробного анализа (так называемый спрятанный элемент под кнопкой). После клика на кнопку отображаются карточки с метриками (средний балл и время обучения), график прогресса по тестам с использованием библиотеки Recharts, а также список областей для улучшения и популярных тегов из заметок. Все данные обновляются динамически и отображаются в структурированном виде.
С установкой Recharts были некоторые проблемы с установкой но решить их удалсь с помошью команды npm install recharts@latest --force
Результат радует глаз:
JavaScript: Скопировать в буфер обмена
Код:
import { NextApiRequest, NextApiResponse } from "next";
import { generateLearningContent } from "@/utils/ai";
import { SpacedRepetition } from "@/utils/spaced-repetition";
import { ProgressAnalytics } from "@/utils/progressAnalytics";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
}
try {
const { session } = req.body;
const analytics = ProgressAnalytics.analyzePerformance(session);
const reviewSchedule = SpacedRepetition.generateReviewSchedule(session);
// Оптимизируем следующий контент на основе анализа
const optimizedPrompt = `
Тема: ${session.topic}
Этап: ${session.currentStage + 1}
Слабые места: ${analytics.weakAreas.join(", ")}
Средний балл: ${analytics.averageScore}
Пожалуйста, создайте контент, который:
1. Уделяет особое внимание выявленным слабым местам
2. Соответствует текущему уровню пользователя
3. Включает практические примеры
4. Содержит проверочные вопросы
`;
const content = await generateLearningContent(
session.topic,
session.currentStage + 1,
optimizedPrompt,
session.lastApiCall
);
res.status(200).json({
content,
nextReview: reviewSchedule[0]?.nextReview,
recommendations: analytics.recommendedReview,
});
} catch (error) {
console.error("Error optimizing content:", error);
res.status(500).json({ message: "Error optimizing content" });
}
}
Первый обработчик более сложен (чем второй который мы разберем ниже) и включает анализ прогресса с использованием методов ProgressAnalytics и генерацию расписания повторений через SpacedRepetition. Он получает данные о текущей сессии, анализирует слабые места и прогресс пользователя, а затем генерирует оптимизированный запрос для создания контента. Контент адаптируется в зависимости от слабых мест учащегося и его текущего уровня. Ответ включает не только сам контент, но и информацию о следующем повторении и рекомендации для дальнейшего обучения. Благодаря такой умной системе мы сможем создать более точную и персонализированную программу для каждого пользователя. И второй обработчик — generate-content.ts:
JavaScript: Скопировать в буфер обмена
Код:
import { NextApiRequest, NextApiResponse } from "next";
import { generateLearningContent } from "@/utils/ai";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
}
try {
const { topic, currentStage, previousContent, lastApiCall } = req.body;
const content = await generateLearningContent(
topic,
currentStage,
previousContent,
lastApiCall
);
res.status(200).json({ content });
} catch (error) {
console.error("Error generating content:", error);
res.status(500).json({ message: "Error generating content" });
}
}
Второй API-обработчик генерирует обучающий контент на основе данных, полученных от клиента. Он принимает запросы с методом POST, извлекает данные о теме, текущем этапе, предыдущем контенте и времени последнего запроса, а затем передает их в функцию generateLearningContent, которая создает персонализированный контент для пользователя. Если метод не POST, возвращается ошибка с кодом 405. Просто и быстро.
Переход к основным файлам
То, что мы разобрали ранее, отвечало либо за визуальную часть, либо за простую обработку основных функций проекта. Сейчас же мы перейдем к самому важному — к тому, на чём реально строится вся логика проекта, а именно этим файлам:Начнём с index.tsx — главного файла нашего сайта. Я разобью его на несколько частей, так как файл достаточно большой.
JavaScript: Скопировать в буфер обмена
Код:
"use client";
import { useState, useEffect } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area";
import { LearningSession, TestResult, UserNote } from "@/types";
import { NotesEditor } from "@/components/NotesEditor";
import { Quiz } from "@/components/Quiz";
import { Cache } from "@/utils/cache";
import { StudyMetrics } from "@/components/StudyMetrics";
import { ChevronRight, BookOpen, PenTool, Brain, Sparkles } from 'lucide-react';
export default function Home() {
const [session, setSession] = useState<LearningSession | null>(null);
const [topic, setTopic] = useState("");
const [loading, setLoading] = useState(false);
const [showQuiz, setShowQuiz] = useState(false);
const [cache] = useState(new Cache());
useEffect(() => {
const savedSessions = Object.keys(localStorage)
.filter((key) => key.startsWith("learning_session_"))
.map((key) => JSON.parse(localStorage.getItem(key) || ""))
.sort((a, b) => b.timestamp - a.timestamp);
if (savedSessions.length > 0) {
setSession(savedSessions[0]);
}
}, []);
Ничего необычного, используется несколько хуков React для управления состоянием. session хранит информацию о текущей сессии обучения, включая её прогресс, темы и другие данные. topic используется для хранения введённой пользователем темы обучения, а loading контролирует состояние загрузки (например, во время запроса к API). Флаг showQuiz определяет, показывать ли квиз. Также используется useState для кэширования данных в локальном хранилище сессий.
JavaScript: Скопировать в буфер обмена
Код:
const clearCache = () => {
if (session) {
cache.archive(`learning_session_${session.id}`);
}
localStorage.clear();
setSession(null);
};
const startNewSession = async () => {
setLoading(true);
try {
const response = await fetch("/api/generate-content", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
topic,
stage: 1,
lastApiCall: session?.lastApiCall || 0,
}),
});
if (response.status === 429) {
throw new Error("Request limit exceeded. Please wait a minute.");
}
const data = await response.json();
const sanitizedContent = data.content.replace(/[\x00-\x1F\x7F]/g, "");
const content = JSON.parse(sanitizedContent);
const newSession: LearningSession = {
id: Date.now().toString(),
topic,
currentStage: 1,
progress: 0,
history: [
{
stage: 1,
content: content.content,
completed: false,
timestamp: new Date(),
quiz: content.quiz,
summary: content.summary,
},
],
notes: [],
testResults: [],
lastApiCall: Date.now(),
};
setSession(newSession);
localStorage.setItem(
`learning_session_${newSession.id}`,
JSON.stringify(newSession)
);
} catch (error) {
console.error("Error starting session:", error);
alert(error instanceof Error ? error.message : "An error occurred");
} finally {
setLoading(false);
}
};
const handleNoteAdd = (note: UserNote) => {
if (!session) return;
const updatedSession = {
...session,
notes: [...session.notes, note],
};
setSession(updatedSession);
localStorage.setItem(
`learning_session_${session.id}`,
JSON.stringify(updatedSession)
);
};
const handleQuizComplete = (result: TestResult) => {
if (!session) return;
const updatedSession = {
...session,
testResults: [...session.testResults, result],
progress: calculateProgress(session, result),
};
setSession(updatedSession);
localStorage.setItem(
`learning_session_${session.id}`,
JSON.stringify(updatedSession)
);
setShowQuiz(false);
};
const calculateProgress = (
session: LearningSession,
newResult: TestResult
): number => {
const totalSteps = session.history.length;
const completedSteps = session.testResults.length;
const averageScore =
[...session.testResults, newResult].reduce(
(acc, curr) => acc + curr.score,
0
) /
(completedSteps + 1);
return Math.round((completedSteps / totalSteps) * averageScore);
};
Функция clearCache очищает локальное хранилище и сбрасывает текущую сессию, удаляя все сохранённые данные. startNewSession инициирует новую сессию, отправляя запрос на сервер для получения контента по выбранной теме. Этот контент затем сохраняется в состоянии и локальном хранилище.
Функции handleNoteAdd и handleQuizComplete позволяют пользователю добавлять заметки и завершать квиз, обновляя сессию и её сохранение в локальном хранилище. calculateProgress вычисляет общий прогресс сессии на основе результатов тестов и числа завершённых шагов. Идем дальше:
JavaScript: Скопировать в буфер обмена
Код:
return (
<div className="min-h-screen bg-black">
<div className="absolute inset-0 z-0 bg-[linear-gradient(transparent_1px,#000_1px),linear-gradient(90deg,transparent_1px,#000_1px)] bg-[size:30px_30px] [background-position:center] [mask-image:linear-gradient(to_bottom,transparent,black)]">
<div className="absolute inset-0 bg-[radial-gradient(circle_500px_at_50%_200px,#1DFF7733,transparent)]" />
</div>
<div className="pointer-events-none fixed inset-0 z-10">
<div className="absolute right-[20%] top-20 h-32 w-32 animate-float">
<div className="h-full w-full rounded-xl bg-gradient-to-br from-emerald-400/20 to-emerald-600/20 backdrop-blur" />
</div>
<div className="absolute left-[15%] top-40 h-24 w-24 animate-float-slow">
<div className="h-full w-full rounded-xl bg-gradient-to-br from-blue-400/20 to-blue-600/20 backdrop-blur" />
</div>
</div>
<div className="relative z-20 mx-auto max-w-[85vw] px-4 py-12">
<div className="mb-12 flex items-center justify-center gap-3">
<Sparkles className="h-8 w-8 text-emerald-400" />
<h1 className="bg-gradient-to-r from-emerald-400 to-emerald-600 bg-clip-text text-4xl font-bold text-transparent md:text-5xl">
AI Learning System
</h1>
</div>
{!session ? (
<Card className="mx-auto max-w-lg border border-emerald-500/20 bg-black/40 shadow-2xl backdrop-blur">
<CardHeader>
<CardTitle className="text-2xl text-emerald-400">
Start Your Learning Journey
</CardTitle>
<CardDescription className="text-emerald-400/60">
Enter a topic to begin your AI-powered study session
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<Input
type="text"
value={topic}
onChange={(e) => setTopic(e.target.value)}
placeholder="Enter a topic to study"
className="border-emerald-500/20 bg-black/40 text-emerald-400 placeholder:text-emerald-400/40"
/>
<Button
onClick={startNewSession}
disabled={loading || !topic}
className="relative w-full overflow-hidden bg-emerald-500 text-black transition-all hover:bg-emerald-400"
>
<div className="relative z-10 flex items-center justify-center gap-2">
{loading ? "Loading..." : "Start Learning"}
<ChevronRight className="h-4 w-4" />
</div>
</Button>
</div>
</CardContent>
</Card>
) : (
<div className="space-y-6">
<Card className="border border-emerald-500/20 bg-black/40 shadow-xl backdrop-blur">
<CardHeader>
<CardTitle className="text-xl text-emerald-400">
Topic: {session.topic}
</CardTitle>
<CardDescription className="text-emerald-400/60">
Your current learning progress
</CardDescription>
</CardHeader>
<CardContent>
<Progress
value={session.progress}
className="h-2 bg-emerald-500/20"
/>
<p className="mt-2 text-sm text-emerald-400/60">
Progress: {session.progress}%
</p>
</CardContent>
</Card>
<StudyMetrics session={session} />
<Tabs defaultValue="content" className="w-full">
<TabsList className="grid w-full grid-cols-3 bg-black/40 p-1">
<TabsTrigger
value="content"
className="data-[state=active]:bg-emerald-500 data-[state=active]:text-black"
>
<BookOpen className="mr-2 h-4 w-4" />
Content
</TabsTrigger>
<TabsTrigger
value="notes"
className="data-[state=active]:bg-emerald-500 data-[state=active]:text-black"
>
<PenTool className="mr-2 h-4 w-4" />
Notes
</TabsTrigger>
<TabsTrigger
value="quiz"
className="data-[state=active]:bg-emerald-500 data-[state=active]:text-black"
>
<Brain className="mr-2 h-4 w-4" />
Quiz
</TabsTrigger>
</TabsList>
<TabsContent value="content">
<Card className="border border-emerald-500/20 bg-black/40 shadow-xl backdrop-blur">
<CardHeader>
<CardTitle className="text-xl text-emerald-400">
Learning Content
</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-[60vh] pr-4">
{session.history.map((step) => (
<div key={step.stage} className="mb-8">
<h3 className="mb-4 text-xl font-semibold text-emerald-400">
Stage {step.stage}
</h3>
<div className="prose prose-invert max-w-none">
<div className="mb-6 whitespace-pre-wrap text-emerald-400/80">
{step.content}
</div>
<Card className="border border-emerald-500/20 bg-black/40">
<CardHeader>
<CardTitle className="text-lg text-emerald-400">
Summary
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-emerald-400/80">
{step.summary}
</p>
</CardContent>
</Card>
</div>
</div>
))}
</ScrollArea>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="notes">
<Card className="border border-emerald-500/20 bg-black/40 shadow-xl backdrop-blur">
<CardHeader>
<CardTitle className="text-xl text-emerald-400">
Your Notes
</CardTitle>
</CardHeader>
<CardContent>
<NotesEditor
stepId={session.currentStage}
onSave={handleNoteAdd}
/>
<ScrollArea className="mt-6 h-[40vh] pr-4">
{session.notes.map((note, index) => (
<Card
key={index}
className="mb-4 border border-emerald-500/20 bg-black/40"
>
<CardHeader>
<CardTitle className="text-sm text-emerald-400">
Note for Stage {note.stepId}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-emerald-400/80">{note.content}</p>
</CardContent>
</Card>
))}
</ScrollArea>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="quiz">
<Card className="border border-emerald-500/20 bg-black/40 shadow-xl backdrop-blur">
<CardHeader>
<CardTitle className="text-xl text-emerald-400">
Quiz
</CardTitle>
</CardHeader>
<CardContent>
{!showQuiz ? (
<Button
onClick={() => setShowQuiz(true)}
className="w-full bg-emerald-500 text-black hover:bg-emerald-400"
>
Start Quiz
</Button>
) : (
<Quiz
questions={
session.history[session.currentStage - 1].quiz
}
onComplete={handleQuizComplete}
/>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
Основной интерфейс страницы включает проверку на наличие активной сессии. Если сессия не найдена, пользователь видит форму для ввода темы, чтобы начать обучение. Если сессия уже существует, отображается прогресс, метрики и вкладки: контент, заметки и квиз. Вкладка "Content" показывает шаги обучения, вкладка "Notes" позволяет добавлять заметки, а вкладка "Quiz" предлагает начать тест. Всё это происходит в интерактивной форме, с сохранением состояния обучения.
Для визуализации используются элементы, такие как карточки (Card) для отображения шагов обучения и заметок, прогресс-бары (Progress) для отслеживания прогресса, и вкладки (Tabs) для переключения между различными разделами обучения. Визуальные эффекты, такие как анимации и градиентные фоны, создают привлекательный и современный дизайн интерфейса. Все как мы любим, пытаемся удержать внимание пользователя.
Почему же эта система хороша? Она оптимизирована для эффективного использования ресурсов.
Во-первых, она адаптирует контент в зависимости от прогресса пользователя, минимизируя излишнюю информацию.
Во-вторых, все данные сессии сохраняются в localStorage, что позволяет продолжить обучение с того места, где было прервано, даже после перезагрузки страницы.
Также система ограничивает количество запросов к серверу, отправляя их только при необходимости, это снижает нагрузку на API (для Google это не проблема, а для нашего ограниченного бесплатного использования - да).
Использование кэширования помогает ускорить процесс работы и снизить время ожидания при запуске новой сессии. (хотя над производительностью надо поработать, lighthouse подсвечивает около 50)
Перейдем к следующему файлу — ai.ts. Сначало, взглянем:
JavaScript: Скопировать в буфер обмена
Код:
import { GoogleGenerativeAI } from "@google/generative-ai";
import { Cache } from "./cache";
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY || "");
const cache = new Cache();
const RATE_LIMIT_WINDOW = 60000;
const MAX_REQUESTS = 10;
export async function generateLearningContent(
topic: string,
currentStage: number,
previousContent: string = "",
lastApiCall: number
) {
const cacheKey = `${topic}-${currentStage}`;
const cachedContent = cache.get(cacheKey);
if (cachedContent) {
return cachedContent;
}
const now = Date.now();
if (now - lastApiCall < RATE_LIMIT_WINDOW / MAX_REQUESTS) {
throw new Error("Rate limit exceeded");
}
const model = genAI.getGenerativeModel({ model: "gemini-pro" });
const prompt = `Создай образовательный контент для темы "${topic}" (этап ${currentStage}/5).
Формат ответа должен быть в JSON:
{
"content": "основной обучающий материал",
"summary": "краткое содержание для повторения",
"quiz": [
{
"question": "вопрос",
"options": ["вариант 1", "вариант 2", "вариант 3", "вариант 4"],
"correctAnswer": 0,
"explanation": "объяснение правильного ответа"
}
],
"keyPoints": ["ключевой момент 1", "ключевой момент 2"],
"practicalTask": "практическое задание"
}
Контекст предыдущего этапа (кратко): ${
previousContent ? previousContent.substring(0, 200) + "..." : "нет"
}`;
const result = await model.generateContent(prompt);
const response = await result.response;
const content = response.text();
// Сохраняем в кэш
cache.set(cacheKey, content, 3600);
return content;
}
Функция-промпт generateLearningContent генерирует образовательный контент для заданной темы и этапа, сначала проверяя наличие данных в кэше. Если контент уже кэширован, он возвращается, что ускоряет работу.
Если контент отсутствует в кэше, проверяется лимит запросов (10 запросов в минуту). При превышении лимита выбрасывается ошибка. Если лимит не нарушен, отправляется запрос к модели с заранее заданным промтом, который включает структуру контента (основной материал, тесты, ключевые моменты и задание). Полученный контент сохраняется в кэш на 1 час для повторного использования. А как же у нас выглядит файл кэша? Сейчас расскажу:
JavaScript: Скопировать в буфер обмена
Код:
export class Cache {
private cache: Map<string, { value: any; expires: number }> = new Map();
set(key: string, value: any, ttl: number) {
const expires = Date.now() + ttl * 1000;
this.cache.set(key, { value, expires });
}
get(key: string) {
const item = this.cache.get(key);
if (!item) return null;
if (Date.now() > item.expires) {
this.cache.delete(key);
return null;
}
return item.value;
}
clear() {
this.cache.clear();
}
archive(key: string) {
const item = this.cache.get(key);
if (item) {
const archive = JSON.parse(localStorage.getItem("archive") || "[]");
archive.push(item);
localStorage.setItem("archive", JSON.stringify(archive));
this.cache.delete(key);
}
}
}
Класс Cache реализует простое кэширование с поддержкой времени жизни (TTL) и возможностью архивации данных.
В методе set сохраняется элемент с заданным ключом, значением и временем истечения. Время истечения рассчитывается как текущее время плюс TTL (в секундах).
Метод get позволяет получить данные по ключу, если они не истекли; если срок истек, элемент удаляется из кэша и возвращается null.
Метод clear очищает весь кэш, а archive позволяет перемещать данные из кэша в localStorage в раздел "archive". После архивации элемент удаляется из кэша.
Мы почти на финишной прямой, осталось только два файла. Давайте быстро пробежимся по тому, что мы уже успели разобрать:
Сначала мы познакомились с тремя компонентами, которые отвечали за написание заметок, отображение метрик и прохождение тестов. Также, мы изучили ShadCN — библиотеку для UI-компонентов. Затем мы разобрали, как обрабатывается ИИ через API приложения и принцип работы с Gemini. Перейдя к исходному коду, мы увидели, как работает главная страница. Это большой файл, который охватывает всё: от компонентов до обработки сессий и распределения ресурсов. Наконец, мы рассмотрели умную систему ИИ и увидели, как обрабатывается кэш и эффективно используется бесплатное API.
Теперь нам осталось только разобрать, как анализируется прогресс для метрик и как осуществляется анализ пользовательского прогресса для передачи его ИИ.
Файл progressAnalytics.ts:
JavaScript: Скопировать в буфер обмена
Код:
import {
LearningSession,
PerformanceMetrics,
TestResult,
UserNote,
NoteAnalysis,
} from "../types";
export class ProgressAnalytics {
static analyzePerformance(
session: LearningSession | null
): PerformanceMetrics {
if (!session || !session.testResults) {
throw new Error("Invalid or empty session data provided.");
}
const { testResults, notes } = session;
return {
averageScore: this.calculateAverageScore(testResults),
weakAreas: this.identifyWeakAreas(testResults),
studyTime: this.calculateStudyTime(session),
notesAnalysis: this.analyzeNotes(notes || []),
recommendedReview: this.generateReviewRecommendations(testResults),
};
}
private static calculateAverageScore(results: TestResult[]): number {
if (results.length === 0) return 0;
const totalScore = results.reduce((acc, curr) => acc + curr.score, 0);
return results.length > 0 ? totalScore / results.length : 0;
}
private static identifyWeakAreas(results: TestResult[]): string[] {
const weakAreaCount: Record<string, number> = {};
results.forEach((result) =>
(result.incorrectAnswers || []).forEach((answer) => {
weakAreaCount[answer] = (weakAreaCount[answer] || 0) + 1;
})
);
return Object.entries(weakAreaCount)
.sort(([, countA], [, countB]) => countB - countA)
.map(([area]) => area)
.slice(0, 5);
}
private static calculateStudyTime(session: LearningSession): number {
if (!session.history.length) return 0;
const validHistory = session.history.filter(
(entry) => entry?.timestamp && !isNaN(new Date(entry.timestamp).getTime())
);
if (validHistory.length < 2) return 0;
const startTime = new Date(validHistory[0].timestamp).getTime();
const lastActivity = new Date(
validHistory[validHistory.length - 1].timestamp
).getTime();
return Math.round((lastActivity - startTime) / 60000);
}
private static analyzeNotes(notes: UserNote[]): NoteAnalysis {
const commonTags = this.getCommonTags(notes);
const notesPerDay = this.getNotesFrequency(notes);
return {
commonTags,
notesPerDay,
totalNotes: notes.length,
};
}
private static getCommonTags(notes: UserNote[]): string[] {
const tagCount: Record<string, number> = notes
.flatMap((n) => n.tags || [])
.reduce((acc: Record<string, number>, tag: string) => {
acc[tag] = (acc[tag] || 0) + 1;
return acc;
}, {});
return Object.entries(tagCount)
.sort(([, countA], [, countB]) => countB - countA)
.slice(0, 5)
.map(([tag]) => tag);
}
private static getNotesFrequency(notes: UserNote[]): Record<string, number> {
return notes.reduce((acc: Record<string, number>, note) => {
const date = new Date(note.timestamp).toISOString().split("T")[0];
acc[date] = (acc[date] || 0) + 1;
return acc;
}, {});
}
private static generateReviewRecommendations(
results: TestResult[]
): string[] {
const weakAreas = this.identifyWeakAreas(results);
return weakAreas.map((area) => `Рекомендуется повторить: ${area}`);
}
}
Итак, код анализирует успеваемость учащегося, предоставляя метрики на основе данных о сессиях обучения. Метод analyzePerformance возвращает информацию о среднем балле, слабых зонах, времени обучения, анализе заметок и рекомендациях по повторению. Для этого используются вспомогательные методы: calculateAverageScore, который вычисляет средний балл, identifyWeakAreas, выявляющий слабые зоны по неправильным ответам, и calculateStudyTime, оценивающий время обучения по временным меткам. Метод analyzeNotes анализирует заметки, выделяя популярные теги и частоту их создания, а generateReviewRecommendations предоставляет рекомендации по повторению на основе слабых областей.
Разберем финальный файл — spaced-repetition.ts:
JavaScript: Скопировать в буфер обмена
Код:
import { LearningSession, ReviewSchedule } from "../types";
export class SpacedRepetition {
private static readonly INTERVALS = [1, 3, 7, 14, 30]; // дни
static calculateNextReview(
currentStage: number,
lastReviewDate: Date,
performance: number
): Date {
const interval =
this.INTERVALS[currentStage] || this.INTERVALS[this.INTERVALS.length - 1];
const adjustedInterval = this.adjustIntervalByPerformance(
interval,
performance
);
const nextReview = new Date(lastReviewDate);
nextReview.setDate(nextReview.getDate() + adjustedInterval);
return nextReview;
}
private static adjustIntervalByPerformance(
interval: number,
performance: number
): number {
// Корректируем интервал на основе успешности выполнения
const adjustment =
performance < 70
? 0.5 // уменьшаем интервал при плохом результате
: performance > 90
? 1.5 // увеличиваем при отличном
: 1; // оставляем без изменений при среднем
return Math.round(interval * adjustment);
}
static generateReviewSchedule(session: LearningSession): ReviewSchedule[] {
return session.testResults.map((result, index) => ({
stepId: result.stepId,
nextReview: this.calculateNextReview(
index,
result.completedAt,
result.score
),
topic: session.history[result.stepId].content.substring(0, 100) + "...",
importance: this.calculateImportance(result.score),
}));
}
private static calculateImportance(score: number): "high" | "medium" | "low" {
if (score < 70) return "high";
if (score < 85) return "medium";
return "low";
}
}
Класс SpacedRepetition реализует систему интервального повторения для эффективного запоминания материалов. Метод calculateNextReview рассчитывает дату следующего повторения, основываясь на текущем этапе, дате последнего повторения и оценке выполнения. Интервал между повторениями выбирается из заранее заданных значений, которые корректируются в зависимости от успешности выполнения (при плохом результате интервал сокращается, а при отличном — увеличивается).
Метод generateReviewSchedule генерирует расписание повторений для всех шагов в сессии обучения, определяя дату следующего повторения и важность материала в зависимости от балла. Для оценки важности используется метод calculateImportance, который присваивает высокий, средний или низкий приоритет в зависимости от результатов теста.
Деплой приложения на хостинг
Давайте также выложим наш сайт на популярный хостинг - Vercel.Vercel — это облачный хостинг для фронтенд-приложений с фокусом на разработчиков. Он позволяет легко развертывать сайты и приложения, интегрируется с GitHub и GitLab, автоматически развертывая изменения с каждым коммитом.
JavaScript: Скопировать в буфер обмена
Код:
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
eslint: {
ignoreDuringBuilds: true,
},
}
module.exports = nextConfig;
После внесения исправлений приложение успешно развернется на Vercel:
Заключение
Статья вышла довольно большой, но объяснения старался сделать как можно проще для людей с минимальными знаниями, поэтому важно вчитываться в текст и не пропускать описание возможных проблем. Сегодня мы с вами построили и задеплоили (почти) наше приложение. Построив этот проект, мы разобрались в том как взаимодействовать со сторонними API на сайте и взаимодействия этих API с пользователем. Построили умную систему обучения и научились простыми методами оптимизировать его работу. Наконец, мы поработали над дизайном и реализовали достаточно красивый внешний вид проекту.Ссылка на Github проект: https://github.com/ElonMusk2002/ai-teacher
Ссылка на видео с работой проекта: