mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-24 08:01:28 +02:00
feat: ai course chat (#8426)
* feat: ai course chat * wip: remove old code * wip * feat: responsiveness of ai chat * fix: key warning * feat: make chat resizeable * wip * wip: default questions * wip * fix: fixed position * fix: hide button * Fix scroll issue * Improve questions UI * Refactor UI * Add close icon * Update UI for course chat * Close AI chat question --------- Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
This commit is contained in:
@@ -64,6 +64,7 @@
|
|||||||
"react-calendar-heatmap": "^1.9.0",
|
"react-calendar-heatmap": "^1.9.0",
|
||||||
"react-confetti": "^6.1.0",
|
"react-confetti": "^6.1.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-resizable-panels": "^2.1.7",
|
||||||
"react-textarea-autosize": "^8.5.7",
|
"react-textarea-autosize": "^8.5.7",
|
||||||
"react-tooltip": "^5.28.0",
|
"react-tooltip": "^5.28.0",
|
||||||
"reactflow": "^11.11.4",
|
"reactflow": "^11.11.4",
|
||||||
|
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@@ -113,6 +113,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^18.3.1
|
specifier: ^18.3.1
|
||||||
version: 18.3.1(react@18.3.1)
|
version: 18.3.1(react@18.3.1)
|
||||||
|
react-resizable-panels:
|
||||||
|
specifier: ^2.1.7
|
||||||
|
version: 2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
react-textarea-autosize:
|
react-textarea-autosize:
|
||||||
specifier: ^8.5.7
|
specifier: ^8.5.7
|
||||||
version: 8.5.7(@types/react@18.3.18)(react@18.3.1)
|
version: 8.5.7(@types/react@18.3.18)(react@18.3.1)
|
||||||
@@ -2988,6 +2991,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
|
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
react-resizable-panels@2.1.7:
|
||||||
|
resolution: {integrity: sha512-JtT6gI+nURzhMYQYsx8DKkx6bSoOGFp7A3CwMrOb8y5jFHFyqwo9m68UhmXRw57fRVJksFn1TSlm3ywEQ9vMgA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
|
||||||
react-textarea-autosize@8.5.7:
|
react-textarea-autosize@8.5.7:
|
||||||
resolution: {integrity: sha512-2MqJ3p0Jh69yt9ktFIaZmORHXw4c4bxSIhCeWiFwmJ9EYKgLmuNII3e9c9b2UO+ijl4StnpZdqpxNIhTdHvqtQ==}
|
resolution: {integrity: sha512-2MqJ3p0Jh69yt9ktFIaZmORHXw4c4bxSIhCeWiFwmJ9EYKgLmuNII3e9c9b2UO+ijl4StnpZdqpxNIhTdHvqtQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -6503,6 +6512,11 @@ snapshots:
|
|||||||
|
|
||||||
react-refresh@0.14.2: {}
|
react-refresh@0.14.2: {}
|
||||||
|
|
||||||
|
react-resizable-panels@2.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||||
|
dependencies:
|
||||||
|
react: 18.3.1
|
||||||
|
react-dom: 18.3.1(react@18.3.1)
|
||||||
|
|
||||||
react-textarea-autosize@8.5.7(@types/react@18.3.18)(react@18.3.1):
|
react-textarea-autosize@8.5.7(@types/react@18.3.18)(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.26.9
|
'@babel/runtime': 7.26.9
|
||||||
|
@@ -5,9 +5,10 @@ import {
|
|||||||
CircleOff,
|
CircleOff,
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
Map,
|
Map, MessageCircleOffIcon,
|
||||||
|
MessageCircleIcon
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { type AiCourse } from '../../lib/ai';
|
import { type AiCourse } from '../../lib/ai';
|
||||||
import { cn } from '../../lib/classname';
|
import { cn } from '../../lib/classname';
|
||||||
import { useIsPaidUser } from '../../queries/billing';
|
import { useIsPaidUser } from '../../queries/billing';
|
||||||
@@ -19,6 +20,7 @@ import { AICourseSidebarModuleList } from './AICourseSidebarModuleList';
|
|||||||
import { AILimitsPopup } from './AILimitsPopup';
|
import { AILimitsPopup } from './AILimitsPopup';
|
||||||
import { AICourseOutlineView } from './AICourseOutlineView';
|
import { AICourseOutlineView } from './AICourseOutlineView';
|
||||||
import { AICourseRoadmapView } from './AICourseRoadmapView';
|
import { AICourseRoadmapView } from './AICourseRoadmapView';
|
||||||
|
import { AICourseFooter } from './AICourseFooter';
|
||||||
|
|
||||||
type AICourseContentProps = {
|
type AICourseContentProps = {
|
||||||
courseSlug?: string;
|
courseSlug?: string;
|
||||||
@@ -35,6 +37,7 @@ export function AICourseContent(props: AICourseContentProps) {
|
|||||||
|
|
||||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
||||||
const [showAILimitsPopup, setShowAILimitsPopup] = useState(false);
|
const [showAILimitsPopup, setShowAILimitsPopup] = useState(false);
|
||||||
|
const [isAIChatsOpen, setIsAIChatsOpen] = useState(true);
|
||||||
|
|
||||||
const [activeModuleIndex, setActiveModuleIndex] = useState(0);
|
const [activeModuleIndex, setActiveModuleIndex] = useState(0);
|
||||||
const [activeLessonIndex, setActiveLessonIndex] = useState(0);
|
const [activeLessonIndex, setActiveLessonIndex] = useState(0);
|
||||||
@@ -139,6 +142,12 @@ export function AICourseContent(props: AICourseContentProps) {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (window && window?.innerWidth < 1024 && isAIChatsOpen) {
|
||||||
|
setIsAIChatsOpen(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (error && !isLoading) {
|
if (error && !isLoading) {
|
||||||
const isLimitReached = error.includes('limit');
|
const isLimitReached = error.includes('limit');
|
||||||
const isNotFound = error.includes('not exist');
|
const isNotFound = error.includes('not exist');
|
||||||
@@ -234,6 +243,19 @@ export function AICourseContent(props: AICourseContentProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{viewMode === 'module' && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsAIChatsOpen(!isAIChatsOpen)}
|
||||||
|
className="flex items-center justify-center text-gray-400 shadow-sm transition-colors hover:bg-gray-50 hover:text-gray-900 lg:hidden"
|
||||||
|
>
|
||||||
|
{isAIChatsOpen ? (
|
||||||
|
<MessageCircleOffIcon size={17} strokeWidth={3} />
|
||||||
|
) : (
|
||||||
|
<MessageCircleIcon size={17} strokeWidth={3} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
className="flex items-center justify-center text-gray-400 shadow-sm transition-colors hover:bg-gray-50 hover:text-gray-900 lg:hidden"
|
className="flex items-center justify-center text-gray-400 shadow-sm transition-colors hover:bg-gray-50 hover:text-gray-900 lg:hidden"
|
||||||
@@ -392,6 +414,9 @@ export function AICourseContent(props: AICourseContentProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 overflow-y-scroll p-6 transition-all duration-200 ease-in-out max-lg:p-3',
|
'flex-1 overflow-y-scroll p-6 transition-all duration-200 ease-in-out max-lg:p-3',
|
||||||
sidebarOpen ? 'lg:ml-0' : '',
|
sidebarOpen ? 'lg:ml-0' : '',
|
||||||
|
viewMode === 'module'
|
||||||
|
? 'flex flex-col overflow-hidden p-0 max-lg:p-0'
|
||||||
|
: '',
|
||||||
)}
|
)}
|
||||||
key={`${courseSlug}-${viewMode}`}
|
key={`${courseSlug}-${viewMode}`}
|
||||||
>
|
>
|
||||||
@@ -409,6 +434,8 @@ export function AICourseContent(props: AICourseContentProps) {
|
|||||||
onGoToNextLesson={goToNextLesson}
|
onGoToNextLesson={goToNextLesson}
|
||||||
key={`${courseSlug}-${activeModuleIndex}-${activeLessonIndex}`}
|
key={`${courseSlug}-${activeModuleIndex}-${activeLessonIndex}`}
|
||||||
onUpgrade={() => setShowUpgradeModal(true)}
|
onUpgrade={() => setShowUpgradeModal(true)}
|
||||||
|
isAIChatsOpen={isAIChatsOpen}
|
||||||
|
setIsAIChatsOpen={setIsAIChatsOpen}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -442,9 +469,7 @@ export function AICourseContent(props: AICourseContentProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mx-auto mb-10 mt-5 text-center text-sm text-gray-400">
|
<AICourseFooter className={viewMode === 'module' ? 'hidden' : ''} />
|
||||||
AI can make mistakes, check important info.
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@@ -1,74 +0,0 @@
|
|||||||
import { ArrowRightIcon, BotIcon } from 'lucide-react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import {
|
|
||||||
AICourseFollowUpPopover,
|
|
||||||
type AIChatHistoryType,
|
|
||||||
} from './AICourseFollowUpPopover';
|
|
||||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
|
||||||
|
|
||||||
type AICourseFollowUpProps = {
|
|
||||||
courseSlug: string;
|
|
||||||
moduleTitle: string;
|
|
||||||
lessonTitle: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AICourseFollowUp(props: AICourseFollowUpProps) {
|
|
||||||
const { courseSlug, moduleTitle, lessonTitle } = props;
|
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
|
||||||
|
|
||||||
const [courseAIChatHistory, setCourseAIChatHistory] = useState<
|
|
||||||
AIChatHistoryType[]
|
|
||||||
>([
|
|
||||||
{
|
|
||||||
role: 'assistant',
|
|
||||||
content:
|
|
||||||
'Hey, I am your AI instructor. Here are some examples of what you can ask me about 🤖',
|
|
||||||
isDefault: true,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
className="mt-4 flex w-full items-center gap-2 rounded-lg border border-yellow-300 bg-yellow-100 p-4 hover:bg-yellow-200 max-lg:mt-3 max-lg:text-sm"
|
|
||||||
onClick={() => setIsOpen(true)}
|
|
||||||
>
|
|
||||||
<BotIcon className="h-4 w-4" />
|
|
||||||
<span>Ask AI some follow up questions</span>
|
|
||||||
|
|
||||||
<ArrowRightIcon className="ml-auto h-4 w-4 max-sm:hidden" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showUpgradeModal && (
|
|
||||||
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isOpen && (
|
|
||||||
<AICourseFollowUpPopover
|
|
||||||
courseSlug={courseSlug}
|
|
||||||
moduleTitle={moduleTitle}
|
|
||||||
lessonTitle={lessonTitle}
|
|
||||||
courseAIChatHistory={courseAIChatHistory}
|
|
||||||
setCourseAIChatHistory={setCourseAIChatHistory}
|
|
||||||
onUpgradeClick={() => {
|
|
||||||
setIsOpen(false);
|
|
||||||
setShowUpgradeModal(true);
|
|
||||||
}}
|
|
||||||
onOutsideClick={() => {
|
|
||||||
if (!isOpen) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isOpen && (
|
|
||||||
<div className="pointer-events-none fixed inset-0 z-50 bg-black/50" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
19
src/components/GenerateCourse/AICourseFooter.tsx
Normal file
19
src/components/GenerateCourse/AICourseFooter.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { cn } from '../../lib/classname';
|
||||||
|
|
||||||
|
type AICourseFooterProps = {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
export function AICourseFooter(props: AICourseFooterProps) {
|
||||||
|
const { className } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'mx-auto mb-10 mt-5 text-center text-sm text-gray-400',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
AI can make mistakes, check important info.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -5,6 +5,8 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
LockIcon,
|
LockIcon,
|
||||||
|
MessageCircleIcon,
|
||||||
|
MessageCircleOffIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
@@ -24,10 +26,31 @@ import {
|
|||||||
} from '../../queries/ai-course';
|
} from '../../queries/ai-course';
|
||||||
import { useIsPaidUser } from '../../queries/billing';
|
import { useIsPaidUser } from '../../queries/billing';
|
||||||
import { queryClient } from '../../stores/query-client';
|
import { queryClient } from '../../stores/query-client';
|
||||||
import { AICourseFollowUp } from './AICourseFollowUp';
|
import './AICourseLessonChat.css';
|
||||||
import './AICourseFollowUp.css';
|
|
||||||
import { RegenerateLesson } from './RegenerateLesson';
|
import { RegenerateLesson } from './RegenerateLesson';
|
||||||
import { TestMyKnowledgeAction } from './TestMyKnowledgeAction';
|
import { TestMyKnowledgeAction } from './TestMyKnowledgeAction';
|
||||||
|
import {
|
||||||
|
AICourseLessonChat,
|
||||||
|
type AIChatHistoryType,
|
||||||
|
} from './AICourseLessonChat';
|
||||||
|
import { AICourseFooter } from './AICourseFooter';
|
||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from './Resizeable';
|
||||||
|
|
||||||
|
function getQuestionsFromResult(result: string) {
|
||||||
|
const matchedQuestions = result.match(
|
||||||
|
/=START_QUESTIONS=(.*?)=END_QUESTIONS=/,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchedQuestions) {
|
||||||
|
return matchedQuestions[1].split('@@');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
type AICourseLessonProps = {
|
type AICourseLessonProps = {
|
||||||
courseSlug: string;
|
courseSlug: string;
|
||||||
@@ -44,6 +67,9 @@ type AICourseLessonProps = {
|
|||||||
onGoToNextLesson: () => void;
|
onGoToNextLesson: () => void;
|
||||||
|
|
||||||
onUpgrade: () => void;
|
onUpgrade: () => void;
|
||||||
|
|
||||||
|
isAIChatsOpen: boolean;
|
||||||
|
setIsAIChatsOpen: (isOpen: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AICourseLesson(props: AICourseLessonProps) {
|
export function AICourseLesson(props: AICourseLessonProps) {
|
||||||
@@ -62,17 +88,32 @@ export function AICourseLesson(props: AICourseLessonProps) {
|
|||||||
onGoToNextLesson,
|
onGoToNextLesson,
|
||||||
|
|
||||||
onUpgrade,
|
onUpgrade,
|
||||||
|
|
||||||
|
isAIChatsOpen,
|
||||||
|
setIsAIChatsOpen,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [defaultQuestions, setDefaultQuestions] = useState<string[]>([]);
|
||||||
|
|
||||||
const [lessonHtml, setLessonHtml] = useState('');
|
const [lessonHtml, setLessonHtml] = useState('');
|
||||||
|
|
||||||
const lessonId = `${slugify(String(activeModuleIndex))}-${slugify(String(activeLessonIndex))}`;
|
const lessonId = `${slugify(String(activeModuleIndex))}-${slugify(String(activeLessonIndex))}`;
|
||||||
const isLessonDone = progress?.includes(lessonId);
|
const isLessonDone = progress?.includes(lessonId);
|
||||||
|
|
||||||
|
const [courseAIChatHistory, setCourseAIChatHistory] = useState<
|
||||||
|
AIChatHistoryType[]
|
||||||
|
>([
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content:
|
||||||
|
'Hey, I am your AI instructor. How can I help you today? 🤖',
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
const { isPaidUser } = useIsPaidUser();
|
const { isPaidUser } = useIsPaidUser();
|
||||||
|
|
||||||
const abortController = useMemo(
|
const abortController = useMemo(
|
||||||
@@ -148,14 +189,29 @@ export function AICourseLesson(props: AICourseLessonProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLessonHtml(markdownToHtml(result, false));
|
const questions = getQuestionsFromResult(result);
|
||||||
|
setDefaultQuestions(questions);
|
||||||
|
const newResult = result.replace(
|
||||||
|
/=START_QUESTIONS=.*?=END_QUESTIONS=/,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
|
setLessonHtml(markdownToHtml(newResult, false));
|
||||||
},
|
},
|
||||||
onStreamEnd: async (result) => {
|
onStreamEnd: async (result) => {
|
||||||
if (abortController.signal.aborted) {
|
if (abortController.signal.aborted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLessonHtml(await markdownToHtmlWithHighlighting(result));
|
const questions = getQuestionsFromResult(result);
|
||||||
|
setDefaultQuestions(questions);
|
||||||
|
|
||||||
|
const newResult = result.replace(
|
||||||
|
/=START_QUESTIONS=.*?=END_QUESTIONS=/,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
|
setLessonHtml(await markdownToHtmlWithHighlighting(newResult));
|
||||||
queryClient.invalidateQueries(getAiCourseLimitOptions());
|
queryClient.invalidateQueries(getAiCourseLimitOptions());
|
||||||
setIsGenerating(false);
|
setIsGenerating(false);
|
||||||
},
|
},
|
||||||
@@ -209,190 +265,232 @@ export function AICourseLesson(props: AICourseLessonProps) {
|
|||||||
isLoading;
|
isLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl">
|
<div className="h-full">
|
||||||
<div className="relative rounded-lg border border-gray-200 bg-white p-6 shadow-sm max-lg:px-4 max-lg:pb-4 max-lg:pt-3">
|
<ResizablePanelGroup direction="horizontal">
|
||||||
{(isGenerating || isLoading) && (
|
<ResizablePanel
|
||||||
<div className="absolute right-3 top-3 flex items-center justify-center">
|
defaultSize={isAIChatsOpen ? 70 : 100}
|
||||||
<Loader2Icon
|
minSize={40}
|
||||||
size={18}
|
id="course-text-content"
|
||||||
strokeWidth={3}
|
className="h-full !overflow-y-scroll bg-white"
|
||||||
className="animate-spin text-gray-400/70"
|
order={1}
|
||||||
/>
|
>
|
||||||
</div>
|
<div className="relative mx-auto max-w-5xl">
|
||||||
)}
|
<div className="bg-white p-8 pb-0 max-lg:px-4 max-lg:pt-3">
|
||||||
|
{(isGenerating || isLoading) && (
|
||||||
|
<div className="absolute right-6 top-6 flex items-center justify-center">
|
||||||
|
<Loader2Icon
|
||||||
|
size={18}
|
||||||
|
strokeWidth={3}
|
||||||
|
className="animate-spin text-gray-400/70"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
Lesson {activeLessonIndex + 1} of {totalLessons}
|
Lesson {activeLessonIndex + 1} of {totalLessons}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isGenerating && !isLoading && (
|
{!isGenerating && !isLoading && (
|
||||||
<div className="absolute right-3 top-3 flex items-center justify-between gap-2">
|
<div className="absolute top-2 right-2 lg:right-6 lg:top-6 flex items-center justify-between gap-2">
|
||||||
<RegenerateLesson
|
<button
|
||||||
onRegenerateLesson={(prompt) => {
|
onClick={() => setIsAIChatsOpen(!isAIChatsOpen)}
|
||||||
generateAiCourseContent(true, prompt);
|
className="rounded-full p-1 text-gray-400 hover:text-black max-lg:hidden"
|
||||||
}}
|
>
|
||||||
/>
|
{!isAIChatsOpen ? (
|
||||||
<button
|
<MessageCircleIcon className="size-4 stroke-[2.5]" />
|
||||||
disabled={isLoading || isTogglingDone}
|
) : (
|
||||||
className={cn(
|
<MessageCircleOffIcon className="size-4 stroke-[2.5]" />
|
||||||
'flex items-center gap-1.5 rounded-full bg-black py-1 pl-2 pr-3 text-sm text-white hover:bg-gray-800 disabled:opacity-50 max-lg:text-xs',
|
)}
|
||||||
isLessonDone
|
</button>
|
||||||
? 'bg-red-500 hover:bg-red-600'
|
|
||||||
: 'bg-green-500 hover:bg-green-600',
|
<RegenerateLesson
|
||||||
)}
|
onRegenerateLesson={(prompt) => {
|
||||||
onClick={() => toggleDone()}
|
generateAiCourseContent(true, prompt);
|
||||||
>
|
}}
|
||||||
{isTogglingDone ? (
|
|
||||||
<>
|
|
||||||
<Loader2Icon
|
|
||||||
size={16}
|
|
||||||
strokeWidth={3}
|
|
||||||
className="animate-spin text-white"
|
|
||||||
/>
|
/>
|
||||||
Please wait ...
|
<button
|
||||||
</>
|
disabled={isLoading || isTogglingDone}
|
||||||
) : (
|
className={cn(
|
||||||
<>
|
'flex items-center gap-1.5 rounded-full bg-black py-1 pl-2 pr-3 text-sm text-white hover:bg-gray-800 disabled:opacity-50 max-lg:text-xs',
|
||||||
{isLessonDone ? (
|
isLessonDone
|
||||||
|
? 'bg-red-500 hover:bg-red-600'
|
||||||
|
: 'bg-green-500 hover:bg-green-600',
|
||||||
|
)}
|
||||||
|
onClick={() => toggleDone()}
|
||||||
|
>
|
||||||
|
{isTogglingDone ? (
|
||||||
|
<>
|
||||||
|
<Loader2Icon
|
||||||
|
size={16}
|
||||||
|
strokeWidth={3}
|
||||||
|
className="animate-spin text-white"
|
||||||
|
/>
|
||||||
|
Please wait ...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{isLessonDone ? (
|
||||||
|
<>
|
||||||
|
<XIcon size={16} />
|
||||||
|
Mark as Undone
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckIcon size={16} />
|
||||||
|
Mark as Done
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="mb-6 text-balance text-3xl font-semibold max-lg:mb-3 max-lg:text-xl">
|
||||||
|
{currentLessonTitle?.replace(/^Lesson\s*?\d+[\.:]\s*/, '')}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{!error && isLoggedIn() && (
|
||||||
|
<div
|
||||||
|
className="course-content prose prose-lg mt-8 max-w-full text-black prose-headings:mb-3 prose-headings:mt-8 prose-blockquote:font-normal prose-pre:rounded-2xl prose-pre:text-lg prose-li:my-1 prose-thead:border-zinc-800 prose-tr:border-zinc-800 max-lg:mt-4 max-lg:text-base max-lg:prose-h2:mt-3 max-lg:prose-h2:text-lg max-lg:prose-h3:text-base max-lg:prose-pre:px-3 max-lg:prose-pre:text-sm"
|
||||||
|
dangerouslySetInnerHTML={{ __html: lessonHtml }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && isLoggedIn() && (
|
||||||
|
<div className="mt-8 flex min-h-[300px] items-center justify-center rounded-xl bg-red-50/80">
|
||||||
|
{error.includes('reached the limit') ? (
|
||||||
|
<div className="flex max-w-sm flex-col items-center text-center">
|
||||||
|
<h2 className="text-xl font-semibold text-red-600">
|
||||||
|
Limit reached
|
||||||
|
</h2>
|
||||||
|
<p className="my-3 text-red-600">
|
||||||
|
You have reached the AI usage limit for today.
|
||||||
|
{!isPaidUser && (
|
||||||
|
<>Please upgrade your account to continue.</>
|
||||||
|
)}
|
||||||
|
{isPaidUser && (
|
||||||
|
<> Please wait until tomorrow to continue.</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!isPaidUser && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onUpgrade();
|
||||||
|
}}
|
||||||
|
className="rounded-full bg-red-600 px-4 py-1 text-white hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Upgrade Account
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-red-600">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoggedIn() && (
|
||||||
|
<div className="mt-8 flex min-h-[152px] flex-col items-center justify-center gap-3 rounded-lg border border-gray-200 p-8">
|
||||||
|
<LockIcon className="size-7 stroke-[2] text-gray-400/90" />
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Please login to generate course content
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !isGenerating && !error && (
|
||||||
|
<TestMyKnowledgeAction
|
||||||
|
courseSlug={courseSlug}
|
||||||
|
activeModuleIndex={activeModuleIndex}
|
||||||
|
activeLessonIndex={activeLessonIndex}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-8 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={onGoToPrevLesson}
|
||||||
|
disabled={cantGoBack}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center rounded-full px-4 py-2 disabled:opacity-50 max-lg:px-3 max-lg:py-1.5 max-lg:text-sm',
|
||||||
|
cantGoBack
|
||||||
|
? 'cursor-not-allowed text-gray-400'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} className="mr-2" />
|
||||||
|
Previous{' '}
|
||||||
|
<span className="hidden lg:inline"> Lesson</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (!isLessonDone) {
|
||||||
|
toggleDone(undefined, {
|
||||||
|
onSuccess: () => {
|
||||||
|
onGoToNextLesson();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onGoToNextLesson();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={cantGoForward || isTogglingDone}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center rounded-full px-4 py-2 disabled:opacity-50 max-lg:px-3 max-lg:py-1.5 max-lg:text-sm',
|
||||||
|
cantGoForward
|
||||||
|
? 'cursor-not-allowed text-gray-400'
|
||||||
|
: 'bg-gray-800 text-white hover:bg-gray-700',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isTogglingDone ? (
|
||||||
<>
|
<>
|
||||||
<XIcon size={16} />
|
<Loader2Icon
|
||||||
Mark as Undone
|
size={16}
|
||||||
|
strokeWidth={3}
|
||||||
|
className="animate-spin text-white"
|
||||||
|
/>
|
||||||
|
Please wait ...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CheckIcon size={16} />
|
Next{' '}
|
||||||
Mark as Done
|
<span className="hidden lg:inline"> Lesson</span>
|
||||||
|
<ChevronRight size={16} className="ml-2" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="mb-6 text-balance text-3xl font-semibold max-lg:mb-3 max-lg:text-xl">
|
|
||||||
{currentLessonTitle?.replace(/^Lesson\s*?\d+[\.:]\s*/, '')}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{!error && isLoggedIn() && (
|
|
||||||
<div
|
|
||||||
className="course-content prose prose-lg mt-8 max-w-full text-black prose-headings:mb-3 prose-headings:mt-8 prose-blockquote:font-normal prose-pre:rounded-2xl prose-pre:text-lg prose-li:my-1 prose-thead:border-zinc-800 prose-tr:border-zinc-800 max-lg:mt-4 max-lg:text-base max-lg:prose-h2:mt-3 max-lg:prose-h2:text-lg max-lg:prose-h3:text-base max-lg:prose-pre:px-3 max-lg:prose-pre:text-sm"
|
|
||||||
dangerouslySetInnerHTML={{ __html: lessonHtml }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && isLoggedIn() && (
|
|
||||||
<div className="mt-8 flex min-h-[300px] items-center justify-center rounded-xl bg-red-50/80">
|
|
||||||
{error.includes('reached the limit') ? (
|
|
||||||
<div className="flex max-w-sm flex-col items-center text-center">
|
|
||||||
<h2 className="text-xl font-semibold text-red-600">
|
|
||||||
Limit reached
|
|
||||||
</h2>
|
|
||||||
<p className="my-3 text-red-600">
|
|
||||||
You have reached the AI usage limit for today.
|
|
||||||
{!isPaidUser && <>Please upgrade your account to continue.</>}
|
|
||||||
{isPaidUser && <> Please wait until tomorrow to continue.</>}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{!isPaidUser && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onUpgrade();
|
|
||||||
}}
|
|
||||||
className="rounded-full bg-red-600 px-4 py-1 text-white hover:bg-red-700"
|
|
||||||
>
|
|
||||||
Upgrade Account
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<p className="text-red-600">{error}</p>
|
<AICourseFooter />
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
{isAIChatsOpen && (
|
||||||
|
<>
|
||||||
|
<ResizableHandle withHandle={false} className="max-lg:hidden" />
|
||||||
|
<AICourseLessonChat
|
||||||
|
courseSlug={courseSlug}
|
||||||
|
moduleTitle={currentModuleTitle}
|
||||||
|
lessonTitle={currentLessonTitle}
|
||||||
|
onUpgradeClick={onUpgrade}
|
||||||
|
courseAIChatHistory={courseAIChatHistory}
|
||||||
|
setCourseAIChatHistory={setCourseAIChatHistory}
|
||||||
|
isDisabled={isGenerating || isLoading || isTogglingDone}
|
||||||
|
isGeneratingLesson={isGenerating || isLoading}
|
||||||
|
defaultQuestions={defaultQuestions}
|
||||||
|
onClose={() => setIsAIChatsOpen(false)}
|
||||||
|
isAIChatsOpen={isAIChatsOpen}
|
||||||
|
setIsAIChatsOpen={setIsAIChatsOpen}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
</ResizablePanelGroup>
|
||||||
{!isLoggedIn() && (
|
|
||||||
<div className="mt-8 flex min-h-[152px] flex-col items-center justify-center gap-3 rounded-lg border border-gray-200 p-8">
|
|
||||||
<LockIcon className="size-7 stroke-[2] text-gray-400/90" />
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Please login to generate course content
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isLoading && !isGenerating && !error && (
|
|
||||||
<TestMyKnowledgeAction
|
|
||||||
courseSlug={courseSlug}
|
|
||||||
activeModuleIndex={activeModuleIndex}
|
|
||||||
activeLessonIndex={activeLessonIndex}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-8 flex items-center justify-between">
|
|
||||||
<button
|
|
||||||
onClick={onGoToPrevLesson}
|
|
||||||
disabled={cantGoBack}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center rounded-full px-4 py-2 disabled:opacity-50 max-lg:px-3 max-lg:py-1.5 max-lg:text-sm',
|
|
||||||
cantGoBack
|
|
||||||
? 'cursor-not-allowed text-gray-400'
|
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ChevronLeft size={16} className="mr-2" />
|
|
||||||
Previous <span className="hidden lg:inline"> Lesson</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (!isLessonDone) {
|
|
||||||
toggleDone(undefined, {
|
|
||||||
onSuccess: () => {
|
|
||||||
onGoToNextLesson();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
onGoToNextLesson();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={cantGoForward || isTogglingDone}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center rounded-full px-4 py-2 disabled:opacity-50 max-lg:px-3 max-lg:py-1.5 max-lg:text-sm',
|
|
||||||
cantGoForward
|
|
||||||
? 'cursor-not-allowed text-gray-400'
|
|
||||||
: 'bg-gray-800 text-white hover:bg-gray-700',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isTogglingDone ? (
|
|
||||||
<>
|
|
||||||
<Loader2Icon
|
|
||||||
size={16}
|
|
||||||
strokeWidth={3}
|
|
||||||
className="animate-spin text-white"
|
|
||||||
/>
|
|
||||||
Please wait ...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Next <span className="hidden lg:inline"> Lesson</span>
|
|
||||||
<ChevronRight size={16} className="ml-2" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isGenerating && !isLoading && (
|
|
||||||
<AICourseFollowUp
|
|
||||||
courseSlug={courseSlug}
|
|
||||||
moduleTitle={currentModuleTitle}
|
|
||||||
lessonTitle={currentLessonTitle}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -6,11 +6,21 @@ import {
|
|||||||
HelpCircle,
|
HelpCircle,
|
||||||
LockIcon,
|
LockIcon,
|
||||||
Send,
|
Send,
|
||||||
|
User2,
|
||||||
|
X,
|
||||||
|
XIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
|
import {
|
||||||
|
Fragment,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type FormEvent,
|
||||||
|
} from 'react';
|
||||||
import { flushSync } from 'react-dom';
|
import { flushSync } from 'react-dom';
|
||||||
import TextareaAutosize from 'react-textarea-autosize';
|
import TextareaAutosize from 'react-textarea-autosize';
|
||||||
import { useOutsideClick } from '../../hooks/use-outside-click';
|
|
||||||
import { useToast } from '../../hooks/use-toast';
|
import { useToast } from '../../hooks/use-toast';
|
||||||
import { readStream } from '../../lib/ai';
|
import { readStream } from '../../lib/ai';
|
||||||
import { cn } from '../../lib/classname';
|
import { cn } from '../../lib/classname';
|
||||||
@@ -22,6 +32,8 @@ import {
|
|||||||
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
import { getAiCourseLimitOptions } from '../../queries/ai-course';
|
||||||
import { queryClient } from '../../stores/query-client';
|
import { queryClient } from '../../stores/query-client';
|
||||||
import { billingDetailsOptions } from '../../queries/billing';
|
import { billingDetailsOptions } from '../../queries/billing';
|
||||||
|
import { ResizablePanel } from './Resizeable';
|
||||||
|
import { Spinner } from '../ReactIcons/Spinner';
|
||||||
|
|
||||||
export type AllowedAIChatRole = 'user' | 'assistant';
|
export type AllowedAIChatRole = 'user' | 'assistant';
|
||||||
export type AIChatHistoryType = {
|
export type AIChatHistoryType = {
|
||||||
@@ -31,40 +43,53 @@ export type AIChatHistoryType = {
|
|||||||
html?: string;
|
html?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AICourseFollowUpPopoverProps = {
|
type AICourseLessonChatProps = {
|
||||||
courseSlug: string;
|
courseSlug: string;
|
||||||
moduleTitle: string;
|
moduleTitle: string;
|
||||||
lessonTitle: string;
|
lessonTitle: string;
|
||||||
|
onUpgradeClick: () => void;
|
||||||
|
isDisabled?: boolean;
|
||||||
|
isGeneratingLesson?: boolean;
|
||||||
|
|
||||||
|
defaultQuestions?: string[];
|
||||||
|
|
||||||
courseAIChatHistory: AIChatHistoryType[];
|
courseAIChatHistory: AIChatHistoryType[];
|
||||||
setCourseAIChatHistory: (value: AIChatHistoryType[]) => void;
|
setCourseAIChatHistory: (history: AIChatHistoryType[]) => void;
|
||||||
|
|
||||||
onOutsideClick?: () => void;
|
onClose: () => void;
|
||||||
onUpgradeClick: () => void;
|
|
||||||
|
isAIChatsOpen: boolean;
|
||||||
|
setIsAIChatsOpen: (isOpen: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AICourseFollowUpPopover(props: AICourseFollowUpPopoverProps) {
|
export function AICourseLessonChat(props: AICourseLessonChatProps) {
|
||||||
const {
|
const {
|
||||||
courseSlug,
|
courseSlug,
|
||||||
moduleTitle,
|
moduleTitle,
|
||||||
lessonTitle,
|
lessonTitle,
|
||||||
onOutsideClick,
|
|
||||||
onUpgradeClick,
|
onUpgradeClick,
|
||||||
|
isDisabled,
|
||||||
|
defaultQuestions = [],
|
||||||
|
|
||||||
courseAIChatHistory,
|
courseAIChatHistory,
|
||||||
setCourseAIChatHistory,
|
setCourseAIChatHistory,
|
||||||
|
|
||||||
|
onClose,
|
||||||
|
|
||||||
|
isAIChatsOpen,
|
||||||
|
setIsAIChatsOpen,
|
||||||
|
|
||||||
|
isGeneratingLesson,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const scrollareaRef = useRef<HTMLDivElement | null>(null);
|
const scrollareaRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
const [isStreamingMessage, setIsStreamingMessage] = useState(false);
|
const [isStreamingMessage, setIsStreamingMessage] = useState(false);
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [streamedMessage, setStreamedMessage] = useState('');
|
const [streamedMessage, setStreamedMessage] = useState('');
|
||||||
|
|
||||||
useOutsideClick(containerRef, onOutsideClick);
|
|
||||||
|
|
||||||
const { data: tokenUsage, isLoading } = useQuery(
|
const { data: tokenUsage, isLoading } = useQuery(
|
||||||
getAiCourseLimitOptions(),
|
getAiCourseLimitOptions(),
|
||||||
queryClient,
|
queryClient,
|
||||||
@@ -107,12 +132,12 @@ export function AICourseFollowUpPopover(props: AICourseFollowUpPopoverProps) {
|
|||||||
completeCourseAIChat(newMessages);
|
completeCourseAIChat(newMessages);
|
||||||
};
|
};
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = useCallback(() => {
|
||||||
scrollareaRef.current?.scrollTo({
|
scrollareaRef.current?.scrollTo({
|
||||||
top: scrollareaRef.current.scrollHeight,
|
top: scrollareaRef.current.scrollHeight,
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
});
|
});
|
||||||
};
|
}, [scrollareaRef]);
|
||||||
|
|
||||||
const completeCourseAIChat = async (messages: AIChatHistoryType[]) => {
|
const completeCourseAIChat = async (messages: AIChatHistoryType[]) => {
|
||||||
setIsStreamingMessage(true);
|
setIsStreamingMessage(true);
|
||||||
@@ -191,103 +216,156 @@ export function AICourseFollowUpPopover(props: AICourseFollowUpPopoverProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<ResizablePanel
|
||||||
className="absolute bottom-0 left-0 z-[99] flex h-[500px] w-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white shadow"
|
defaultSize={isAIChatsOpen ? 30 : 0}
|
||||||
ref={containerRef}
|
minSize={20}
|
||||||
|
id="course-chat-content"
|
||||||
|
order={2}
|
||||||
|
className="relative h-full max-lg:fixed max-lg:inset-0 max-lg:data-[chat-state=open]:flex max-lg:data-[chat-state=closed]:hidden"
|
||||||
|
data-chat-state={isAIChatsOpen ? 'open' : 'closed'}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2 border-b border-gray-200 px-4 py-2 text-sm">
|
|
||||||
<h4 className="text-base font-medium">Course AI</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="scrollbar-thumb-gray-300 scrollbar-track-transparent scrollbar-thin relative grow overflow-y-auto"
|
className="absolute inset-y-0 right-0 z-20 flex w-full flex-col overflow-hidden bg-white data-[state=open]:flex data-[state=closed]:hidden"
|
||||||
ref={scrollareaRef}
|
data-state={isAIChatsOpen ? 'open' : 'closed'}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 flex flex-col">
|
<button
|
||||||
<div className="flex grow flex-col justify-end">
|
onClick={onClose}
|
||||||
<div className="flex flex-col justify-end gap-2 px-3 py-2">
|
className="absolute right-2 top-2 z-20 hidden rounded-full p-1 text-gray-400 hover:text-black max-lg:block"
|
||||||
{courseAIChatHistory.map((chat, index) => {
|
>
|
||||||
return (
|
<XIcon className="size-4 stroke-[2.5]" />
|
||||||
<>
|
</button>
|
||||||
<AIChatCard
|
|
||||||
key={`chat-${index}`}
|
<div className="flex items-center justify-between gap-2 border-b border-gray-200 px-4 py-2 text-sm">
|
||||||
role={chat.role}
|
<h4 className="text-base font-medium">Course AI</h4>
|
||||||
content={chat.content}
|
<button
|
||||||
html={chat.html}
|
onClick={onClose}
|
||||||
|
className="hidden rounded-md px-2 py-2 text-xs font-medium text-gray-500 hover:bg-gray-100 hover:text-black lg:block"
|
||||||
|
>
|
||||||
|
<X className="size-4 stroke-[2.5]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="scrollbar-thumb-gray-300 scrollbar-track-transparent scrollbar-thin relative grow overflow-y-auto"
|
||||||
|
ref={scrollareaRef}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 flex flex-col">
|
||||||
|
<div className="relative flex grow flex-col justify-end">
|
||||||
|
{isGeneratingLesson && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center gap-1.5 bg-gray-100">
|
||||||
|
<div className="flex items-center gap-1.5 rounded-md border border-gray-200 bg-white px-3 py-1.5">
|
||||||
|
<Spinner
|
||||||
|
className="size-4 text-gray-400"
|
||||||
|
outerFill="transparent"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Generating lesson...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col justify-end gap-2 px-3 py-2">
|
||||||
|
{courseAIChatHistory.map((chat, index) => {
|
||||||
|
return (
|
||||||
|
<Fragment key={`chat-${index}`}>
|
||||||
|
<AIChatCard
|
||||||
|
role={chat.role}
|
||||||
|
content={chat.content}
|
||||||
|
html={chat.html}
|
||||||
|
/>
|
||||||
|
|
||||||
{chat.isDefault && (
|
{chat.isDefault && defaultQuestions?.length > 1 && (
|
||||||
<div className="mb-1 mt-0.5">
|
<div className="mb-1 mt-0.5">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<p className="mb-2 text-xs font-normal text-gray-500">
|
||||||
{capabilities.map((capability, index) => (
|
Some questions you might have about this lesson.
|
||||||
<CapabilityCard
|
</p>
|
||||||
key={`capability-${index}`}
|
<div className="flex flex-col justify-end gap-1">
|
||||||
{...capability}
|
{defaultQuestions.map((question, index) => (
|
||||||
/>
|
<button
|
||||||
))}
|
key={`default-question-${index}`}
|
||||||
|
className="flex h-full self-start rounded-md bg-yellow-500/10 px-3 py-2 text-left text-sm text-black hover:bg-yellow-500/20"
|
||||||
|
onClick={() => {
|
||||||
|
flushSync(() => {
|
||||||
|
setMessage(question);
|
||||||
|
});
|
||||||
|
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{question}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</Fragment>
|
||||||
</>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
|
||||||
|
|
||||||
{isStreamingMessage && !streamedMessage && (
|
{isStreamingMessage && !streamedMessage && (
|
||||||
<AIChatCard role="assistant" content="Thinking..." />
|
<AIChatCard role="assistant" content="Thinking..." />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{streamedMessage && (
|
{streamedMessage && (
|
||||||
<AIChatCard role="assistant" content={streamedMessage} />
|
<AIChatCard role="assistant" content={streamedMessage} />
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="relative flex items-start border-t border-gray-200 text-sm"
|
className="relative flex items-start border-t border-gray-200 text-sm"
|
||||||
onSubmit={handleChatSubmit}
|
onSubmit={handleChatSubmit}
|
||||||
>
|
|
||||||
{isLimitExceeded && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black text-white">
|
|
||||||
<LockIcon className="size-4 cursor-not-allowed" strokeWidth={2.5} />
|
|
||||||
<p className="cursor-not-allowed">
|
|
||||||
Limit reached for today
|
|
||||||
{isPaidUser ? '. Please wait until tomorrow.' : ''}
|
|
||||||
</p>
|
|
||||||
{!isPaidUser && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onUpgradeClick();
|
|
||||||
}}
|
|
||||||
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
|
|
||||||
>
|
|
||||||
Upgrade for more
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<TextareaAutosize
|
|
||||||
className="h-full min-h-[41px] grow resize-none bg-transparent px-4 py-2 focus:outline-none"
|
|
||||||
placeholder="Ask AI anything about the lesson..."
|
|
||||||
value={message}
|
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
|
||||||
autoFocus={true}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
handleChatSubmit(e as unknown as FormEvent<HTMLFormElement>);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isStreamingMessage || isLimitExceeded}
|
|
||||||
className="flex aspect-square size-[41px] items-center justify-center text-zinc-500 hover:text-black"
|
|
||||||
>
|
>
|
||||||
<Send className="size-4 stroke-[2.5]" />
|
{isLimitExceeded && (
|
||||||
</button>
|
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black text-white">
|
||||||
</form>
|
<LockIcon
|
||||||
</div>
|
className="size-4 cursor-not-allowed"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
/>
|
||||||
|
<p className="cursor-not-allowed">
|
||||||
|
Limit reached for today
|
||||||
|
{isPaidUser ? '. Please wait until tomorrow.' : ''}
|
||||||
|
</p>
|
||||||
|
{!isPaidUser && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onUpgradeClick();
|
||||||
|
}}
|
||||||
|
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
Upgrade for more
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<TextareaAutosize
|
||||||
|
className={cn(
|
||||||
|
'h-full min-h-[41px] grow resize-none bg-transparent px-4 py-2 focus:outline-none',
|
||||||
|
isDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-auto',
|
||||||
|
)}
|
||||||
|
placeholder="Ask AI anything about the lesson..."
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
autoFocus={true}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
handleChatSubmit(e as unknown as FormEvent<HTMLFormElement>);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
ref={textareaRef}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isDisabled || isStreamingMessage || isLimitExceeded}
|
||||||
|
className="flex aspect-square size-[41px] items-center justify-center text-zinc-500 hover:text-black disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Send className="size-4 stroke-[2.5]" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +402,11 @@ function AIChatCard(props: AIChatCardProps) {
|
|||||||
: 'bg-yellow-400 text-black',
|
: 'bg-yellow-400 text-black',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Bot className="size-4 stroke-[2.5]" />
|
{role === 'user' ? (
|
||||||
|
<User2 className="size-4 stroke-[2.5]" />
|
||||||
|
) : (
|
||||||
|
<Bot className="size-4 stroke-[2.5]" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="course-content course-ai-content prose prose-sm mt-0.5 max-w-full overflow-hidden text-sm"
|
className="course-content course-ai-content prose prose-sm mt-0.5 max-w-full overflow-hidden text-sm"
|
42
src/components/GenerateCourse/Resizeable.tsx
Normal file
42
src/components/GenerateCourse/Resizeable.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { GripVertical } from 'lucide-react';
|
||||||
|
import * as ResizablePrimitive from 'react-resizable-panels';
|
||||||
|
import { cn } from '../../lib/classname';
|
||||||
|
|
||||||
|
const ResizablePanelGroup = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||||
|
<ResizablePrimitive.PanelGroup
|
||||||
|
className={cn(
|
||||||
|
'flex h-full w-full data-[panel-group-direction=vertical]:flex-col',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ResizablePanel = ResizablePrimitive.Panel;
|
||||||
|
|
||||||
|
const ResizableHandle = ({
|
||||||
|
withHandle,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||||
|
withHandle?: boolean;
|
||||||
|
}) => (
|
||||||
|
<ResizablePrimitive.PanelResizeHandle
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-px items-center justify-center bg-gray-200 after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{withHandle && (
|
||||||
|
<div className="z-10 flex h-[30px] w-3 items-center justify-center rounded-sm bg-gray-200 text-black hover:bg-gray-300">
|
||||||
|
<GripVertical className="size-5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePrimitive.PanelResizeHandle>
|
||||||
|
);
|
||||||
|
|
||||||
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
Reference in New Issue
Block a user