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:
1 parent
08e8b83262
commit
e6d10ef592
53 files changed
+2933
-949
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user