feat: ai roadmaps and chat (#8821)

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip: ai roadmap

* wip

* wip

* Add question answers

* Update content generator

* Update question answer chat

* Refresh button not working

* UI for chat

* Update question answers functionality

* wip

* wip

* fix: ai roadmap regenerate

* Improve preferences

* feat: roadmap actions

* wip

* fix: hydration error

* Fix responsiveness issue

* fix: show preferences option only when questions exist

* fix: infinite loop issue

* feat: node click message populate

* Improve UI

* wip

* wip

* fix: roadmap regenerate

* Update UI

* Update placeholder

* Update answers

* Update navigations

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
This commit is contained in:
Arik ChakmaandKamran Ahmed authored and GitHub committed 2025-06-26 13:00:46 +01:00
1 parent 08e8b83262
commit e6d10ef592
53 files changed
+2933 -949

No files matched your search

+17 -8
View File
@@ -46,15 +46,24 @@ export function AICourseCard(props: AICourseCardProps) {
>
{/* Title and difficulty section */}
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<span
className={`rounded-full text-xs font-medium capitalize opacity-80 ${difficultyColor}`}
>
{course.difficulty}
</span>
</div>
{course.difficulty && (
<div className="mb-1 flex items-center gap-2">
<span
className={`rounded-full text-xs font-medium capitalize opacity-80 ${difficultyColor}`}
>
{course.difficulty}
</span>
</div>
)}
<h3 className="line-clamp-2 text-base font-semibold text-balance text-gray-900">
<h3
className={cn(
'line-clamp-2 text-base font-semibold text-balance text-gray-900',
{
'max-w-[95%]': variant === 'column',
},
)}
>
{course.title
?.replace(": A Beginner's Guide", '')
?.replace(' for beginners', '')
@@ -256,12 +256,14 @@ export function AICourseContent(props: AICourseContentProps) {
</span>
</a>
<div className="flex items-center gap-2">
<div className="flex flex-row lg:hidden">
<AICourseLimit
onUpgrade={() => setShowUpgradeModal(true)}
onShowLimits={() => setShowAILimitsPopup(true)}
/>
</div>
{!isLoading && (
<div className="flex flex-row lg:hidden">
<AICourseLimit
onUpgrade={() => setShowUpgradeModal(true)}
onShowLimits={() => setShowAILimitsPopup(true)}
/>
</div>
)}
{viewMode === 'module' && (
<button
@@ -326,14 +328,16 @@ export function AICourseContent(props: AICourseContentProps) {
</div>
</div>
</div>
<div className="flex gap-2">
<div className="hidden gap-2 lg:flex">
<AICourseLimit
onUpgrade={() => setShowUpgradeModal(true)}
onShowLimits={() => setShowAILimitsPopup(true)}
/>
{!isLoading && (
<div className="flex gap-2">
<div className="hidden gap-2 lg:flex">
<AICourseLimit
onUpgrade={() => setShowUpgradeModal(true)}
onShowLimits={() => setShowAILimitsPopup(true)}
/>
</div>
</div>
</div>
)}
</header>
<div className="flex flex-1 overflow-hidden">
@@ -499,6 +503,7 @@ export function AICourseContent(props: AICourseContentProps) {
onForkCourse={() => {
setIsForkingCourse(true);
}}
courseSlug={courseSlug!}
/>
)}
@@ -12,6 +12,7 @@ type AICourseOutlineHeaderProps = {
setViewMode: (mode: AICourseViewMode) => void;
isForkable: boolean;
onForkCourse: () => void;
courseSlug: string;
};
export function AICourseOutlineHeader(props: AICourseOutlineHeaderProps) {
@@ -23,6 +24,7 @@ export function AICourseOutlineHeader(props: AICourseOutlineHeaderProps) {
setViewMode,
isForkable,
onForkCourse,
courseSlug,
} = props;
return (
@@ -36,9 +38,6 @@ export function AICourseOutlineHeader(props: AICourseOutlineHeaderProps) {
<h2 className="mb-1 text-2xl font-bold text-balance max-lg:text-lg max-lg:leading-tight">
{course.title || 'Loading course ..'}
</h2>
<p className="text-sm text-gray-500 capitalize">
{course.title ? course.difficulty : 'Please wait ..'}
</p>
</div>
<div className="absolute top-3 right-3 flex gap-2 max-lg:relative max-lg:top-0 max-lg:right-0 max-lg:w-full max-lg:flex-row-reverse max-lg:justify-between">
@@ -48,6 +47,7 @@ export function AICourseOutlineHeader(props: AICourseOutlineHeaderProps) {
onRegenerateOutline={onRegenerateOutline}
isForkable={isForkable}
onForkCourse={onForkCourse}
courseSlug={courseSlug}
/>
<div className="mr-1 flex rounded-lg border border-gray-200 bg-white p-0.5">
<button
@@ -19,6 +19,7 @@ type AICourseOutlineViewProps = {
viewMode: AICourseViewMode;
isForkable: boolean;
onForkCourse: () => void;
courseSlug: string;
};
export function AICourseOutlineView(props: AICourseOutlineViewProps) {
@@ -34,6 +35,7 @@ export function AICourseOutlineView(props: AICourseOutlineViewProps) {
viewMode,
isForkable,
onForkCourse,
courseSlug,
} = props;
const aiCourseProgress = course.done || [];
@@ -48,6 +50,7 @@ export function AICourseOutlineView(props: AICourseOutlineViewProps) {
setViewMode={setViewMode}
isForkable={isForkable}
onForkCourse={onForkCourse}
courseSlug={courseSlug}
/>
{course.title ? (
<div className="flex flex-col p-6 max-lg:mt-0.5 max-lg:p-4">
@@ -229,6 +229,7 @@ export function AICourseRoadmapView(props: AICourseRoadmapViewProps) {
setViewMode={setViewMode}
isForkable={isForkable}
onForkCourse={onForkCourse}
courseSlug={courseSlug}
/>
{isLoading && (
<div className="absolute inset-0 flex h-full w-full items-center justify-center">
@@ -1,22 +1,19 @@
import { useEffect, useState } from 'react';
import { getUrlParams } from '../../lib/browser';
import { isLoggedIn } from '../../lib/jwt';
import { getCourseFineTuneData, type AiCourse } from '../../lib/ai';
import type { AiCourse } from '../../lib/ai';
import { AICourseContent } from './AICourseContent';
import { generateCourse } from '../../helper/generate-ai-course';
import { useQuery } from '@tanstack/react-query';
import { getAiCourseOptions } from '../../queries/ai-course';
import { queryClient } from '../../stores/query-client';
import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat';
import { getQuestionAnswerChatMessages } from '../../lib/ai-questions';
type GenerateAICourseProps = {};
export function GenerateAICourse(props: GenerateAICourseProps) {
const [term, setTerm] = useState('');
const [difficulty, setDifficulty] = useState('');
const [sessionId, setSessionId] = useState('');
const [goal, setGoal] = useState('');
const [about, setAbout] = useState('');
const [customInstructions, setCustomInstructions] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
@@ -27,7 +24,6 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
const [course, setCourse] = useState<AiCourse>({
title: '',
modules: [],
difficulty: '',
done: [],
});
@@ -46,71 +42,39 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
}, [aiCourse]);
useEffect(() => {
if (term || difficulty) {
if (term) {
return;
}
const params = getUrlParams();
const paramsTerm = params?.term;
const paramsDifficulty = params?.difficulty;
const paramsSrc = params?.src || 'search';
if (!paramsTerm || !paramsDifficulty) {
if (!paramsTerm) {
return;
}
setTerm(paramsTerm);
setDifficulty(paramsDifficulty);
const sessionId = params?.id;
setSessionId(sessionId);
let paramsGoal = '';
let paramsAbout = '';
let paramsCustomInstructions = '';
let questionAndAnswers: QuestionAnswerChatMessage[] = [];
if (sessionId) {
const fineTuneData = getCourseFineTuneData(sessionId);
if (fineTuneData) {
paramsGoal = fineTuneData.goal;
paramsAbout = fineTuneData.about;
paramsCustomInstructions = fineTuneData.customInstructions;
setGoal(paramsGoal);
setAbout(paramsAbout);
setCustomInstructions(paramsCustomInstructions);
}
questionAndAnswers = getQuestionAnswerChatMessages(sessionId);
}
handleGenerateCourse({
term: paramsTerm,
difficulty: paramsDifficulty,
instructions: paramsCustomInstructions,
goal: paramsGoal,
about: paramsAbout,
src: paramsSrc,
questionAndAnswers,
});
}, [term, difficulty]);
}, [term]);
const handleGenerateCourse = async (options: {
term: string;
difficulty: string;
instructions?: string;
goal?: string;
about?: string;
isForce?: boolean;
prompt?: string;
src?: string;
questionAndAnswers?: QuestionAnswerChatMessage[];
}) => {
const {
term,
difficulty,
isForce,
prompt,
instructions,
goal,
about,
src,
} = options;
const { term, isForce, prompt, src, questionAndAnswers } = options;
if (!isLoggedIn()) {
window.location.href = '/ai';
@@ -119,7 +83,6 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
await generateCourse({
term,
difficulty,
slug: courseSlug,
onCourseIdChange: setCourseId,
onCourseSlugChange: setCourseSlug,
@@ -127,40 +90,13 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
onCourseChange: setCourse,
onLoadingChange: setIsLoading,
onError: setError,
instructions,
goal,
about,
questionAndAnswers,
isForce,
prompt,
src,
});
};
useEffect(() => {
const handlePopState = (e: PopStateEvent) => {
const { courseId, courseSlug, term, difficulty } = e.state || {};
if (!courseId || !courseSlug) {
window.location.reload();
return;
}
setCourseId(courseId);
setCourseSlug(courseSlug);
setTerm(term);
setDifficulty(difficulty);
setIsLoading(true);
handleGenerateCourse({ term, difficulty }).finally(() => {
setIsLoading(false);
});
};
window.addEventListener('popstate', handlePopState);
return () => {
window.removeEventListener('popstate', handlePopState);
};
}, []);
return (
<AICourseContent
courseSlug={courseSlug}
@@ -171,7 +107,6 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
onRegenerateOutline={(prompt) => {
handleGenerateCourse({
term,
difficulty,
isForce: true,
prompt,
});
@@ -59,7 +59,6 @@ export function GetAICourse(props: GetAICourseProps) {
await generateCourse({
term: aiCourse.keyword,
difficulty: aiCourse.difficulty,
slug: courseSlug,
prompt,
onCourseChange: (course, rawData) => {
@@ -68,7 +67,6 @@ export function GetAICourse(props: GetAICourseProps) {
{
...aiCourse,
title: course.title,
difficulty: course.difficulty,
modules: course.modules,
},
);
@@ -89,7 +87,6 @@ export function GetAICourse(props: GetAICourseProps) {
course={{
title: aiCourse?.title || '',
modules: aiCourse?.modules || [],
difficulty: aiCourse?.difficulty || 'Easy',
done: aiCourse?.done || [],
}}
isLoading={isLoading || isRegenerating}
@@ -1,27 +1,73 @@
import { PenSquare, RefreshCcw } from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { PenSquare, RefreshCcw, Settings2Icon } from 'lucide-react';
import { useRef, useState } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { cn } from '../../lib/classname';
import { httpPost } from '../../lib/query-http';
import { getAiCourseOptions } from '../../queries/ai-course';
import { queryClient } from '../../stores/query-client';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat';
import { UpdatePreferences } from '../GenerateGuide/UpdatePreferences';
import { ModifyCoursePrompt } from './ModifyCoursePrompt';
type RegenerateOutlineProps = {
onRegenerateOutline: (prompt?: string) => void;
isForkable: boolean;
onForkCourse: () => void;
courseSlug: string;
};
export function RegenerateOutline(props: RegenerateOutlineProps) {
const { onRegenerateOutline, isForkable, onForkCourse } = props;
const { onRegenerateOutline, isForkable, onForkCourse, courseSlug } = props;
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [showPromptModal, setShowPromptModal] = useState(false);
const [showUpdatePreferencesModal, setShowUpdatePreferencesModal] =
useState(false);
const ref = useRef<HTMLDivElement>(null);
useOutsideClick(ref, () => setIsDropdownVisible(false));
const { data: aiCourse } = useQuery(
getAiCourseOptions({ aiCourseSlug: courseSlug }),
queryClient,
);
const { mutate: updatePreferences, isPending: isUpdating } = useMutation(
{
mutationFn: (questionAndAnswers: QuestionAnswerChatMessage[]) => {
return httpPost(`/v1-update-ai-course-preferences/${courseSlug}`, {
questionAndAnswers,
});
},
onSuccess: (_, vars) => {
queryClient.setQueryData(
getAiCourseOptions({ aiCourseSlug: courseSlug }).queryKey,
(old) => {
if (!old) {
return old;
}
return {
...old,
questionAndAnswers: vars,
};
},
);
setShowUpdatePreferencesModal(false);
setIsDropdownVisible(false);
onRegenerateOutline();
},
},
queryClient,
);
const showUpdatePreferences =
aiCourse?.questionAndAnswers && aiCourse.questionAndAnswers.length > 0;
return (
<>
{showUpgradeModal && (
@@ -46,6 +92,19 @@ export function RegenerateOutline(props: RegenerateOutlineProps) {
/>
)}
{showUpdatePreferencesModal && (
<UpdatePreferences
onClose={() => setShowUpdatePreferencesModal(false)}
questionAndAnswers={aiCourse?.questionAndAnswers}
term={aiCourse?.keyword || ''}
format="course"
onUpdatePreferences={(questionAndAnswers) => {
updatePreferences(questionAndAnswers);
}}
isUpdating={isUpdating}
/>
)}
<div ref={ref} className="relative flex items-stretch">
<button
className={cn('rounded-md px-2.5 text-gray-400 hover:text-black', {
@@ -56,7 +115,28 @@ export function RegenerateOutline(props: RegenerateOutlineProps) {
<PenSquare className="text-current" size={16} strokeWidth={2.5} />
</button>
{isDropdownVisible && (
<div className="absolute top-full right-0 min-w-[170px] translate-y-1 overflow-hidden rounded-md border border-gray-200 bg-white shadow-md">
<div className="absolute top-full right-0 min-w-[190px] translate-y-1 overflow-hidden rounded-md border border-gray-200 bg-white shadow-md">
{showUpdatePreferences && (
<button
onClick={() => {
setIsDropdownVisible(false);
if (isForkable) {
onForkCourse();
return;
}
setShowUpdatePreferencesModal(true);
}}
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-100"
>
<Settings2Icon
size={16}
className="text-gray-400"
strokeWidth={2.5}
/>
Preferences
</button>
)}
<button
onClick={() => {
setIsDropdownVisible(false);
@@ -95,9 +95,9 @@ export function UserCoursesList() {
</p>
{isUserAuthenticated && !isAnyLoading && courses.length > 0 && (
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-3 gap-2">
{courses.map((course) => (
<AICourseCard key={course._id} course={course} />
<AICourseCard variant="column" key={course._id} course={course} />
))}
<Pagination