feat: ai document (#8793)

* Refactor AI course

* Add AI course generation functionality

* Add basic error handling

* AI Document content

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* feat: regenerate guide

* wip

* wip

* wip

* wip

* wip

* fix: form ui

* feat: update guide ui

* refactor: update the course card

* fix: term and redirects

* Guide page UI improvements

* Loading chip refactor

* AI guide changes

* Improve UI for ai guide content

* Add AI guide

* AI Guide chat

* fix: stop streaming

* fix: chat responsiveness

* UI improvements for ai library

* Guide listing UI update

* User guides listing

* Library guides listing UI

* Library guides listing UI

* Featured courses listing UI update

* Staff picks UI changes

* Community page UI design

* Explore courses listing functionality

* Improve UI for explore page

* Implement guides functionality

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
This commit is contained in:
Arik ChakmaandKamran Ahmed authored and GitHub committed 2025-06-19 02:22:04 +01:00
1 parent 469f4ca530
commit 89932bc18d
56 files changed
+3959 -280

No files matched your search

+231
View File
@@ -0,0 +1,231 @@
import { useQuery } from '@tanstack/react-query';
import { ExternalLink } from 'lucide-react';
import { useMemo, useState } from 'react';
import { flushSync } from 'react-dom';
import { generateGuide } from '../../helper/generate-ai-guide';
import { shuffle } from '../../helper/shuffle';
import { useToast } from '../../hooks/use-toast';
import { isLoggedIn } from '../../lib/jwt';
import {
aiGuideSuggestionsOptions,
getAiGuideOptions,
} from '../../queries/ai-guide';
import { queryClient } from '../../stores/query-client';
import { AITutorLayout } from '../AITutor/AITutorLayout';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { AIGuideChat } from './AIGuideChat';
import { AIGuideContent } from './AIGuideContent';
import { GenerateAIGuide } from './GenerateAIGuide';
type AIGuideProps = {
guideSlug?: string;
};
export function AIGuide(props: AIGuideProps) {
const { guideSlug: defaultGuideSlug } = props;
const [guideSlug, setGuideSlug] = useState(defaultGuideSlug);
const toast = useToast();
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [isRegenerating, setIsRegenerating] = useState(false);
const [regeneratedHtml, setRegeneratedHtml] = useState<string | null>(null);
// only fetch the guide if the guideSlug is provided
// otherwise we are still generating the guide
const { data: aiGuide, isLoading: isLoadingBySlug } = useQuery(
getAiGuideOptions(guideSlug),
queryClient,
);
const { data: aiGuideSuggestions, isLoading: isAiGuideSuggestionsLoading } =
useQuery(
{
...aiGuideSuggestionsOptions(guideSlug),
enabled: !!guideSlug && !!isLoggedIn(),
},
queryClient,
);
const randomQuestions = useMemo(() => {
return shuffle(aiGuideSuggestions?.questions || []).slice(0, 4);
}, [aiGuideSuggestions]);
const relatedTopics = useMemo(() => {
return shuffle(aiGuideSuggestions?.relatedTopics || []).slice(0, 2);
}, [aiGuideSuggestions]);
const deepDiveTopics = useMemo(() => {
return shuffle(aiGuideSuggestions?.deepDiveTopics || []).slice(0, 2);
}, [aiGuideSuggestions]);
const handleRegenerate = async (prompt?: string) => {
flushSync(() => {
setIsRegenerating(true);
setRegeneratedHtml(null);
});
queryClient.cancelQueries(getAiGuideOptions(guideSlug));
queryClient.setQueryData(getAiGuideOptions(guideSlug).queryKey, (old) => {
if (!old) {
return old;
}
return {
...old,
content: '',
html: '',
};
});
await generateGuide({
slug: aiGuide?.slug || '',
term: aiGuide?.keyword || '',
depth: aiGuide?.depth || '',
prompt,
onStreamingChange: setIsRegenerating,
onHtmlChange: setRegeneratedHtml,
onFinish: () => {
setIsRegenerating(false);
queryClient.invalidateQueries(getAiGuideOptions(guideSlug));
},
isForce: true,
onError: (error) => {
toast.error(error);
},
});
};
return (
<AITutorLayout
wrapperClassName="flex-row p-0 lg:p-0 overflow-hidden bg-white"
containerClassName="h-[calc(100vh-49px)] overflow-hidden relative"
>
{showUpgradeModal && (
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
)}
<div className="grow overflow-y-auto p-4 pt-0">
{guideSlug && (
<AIGuideContent
html={regeneratedHtml || aiGuide?.html || ''}
onRegenerate={handleRegenerate}
isLoading={isLoadingBySlug || isRegenerating}
/>
)}
{!guideSlug && <GenerateAIGuide onGuideSlugChange={setGuideSlug} />}
{aiGuide && !isRegenerating && (
<div className="mx-auto mt-12 mb-12 max-w-4xl">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<ListSuggestions
title="Related Topics"
suggestions={relatedTopics}
depth="essentials"
isLoading={isAiGuideSuggestionsLoading}
currentGuideTitle={aiGuide.title}
/>
<ListSuggestions
title="Dive Deeper"
suggestions={deepDiveTopics}
depth="detailed"
isLoading={isAiGuideSuggestionsLoading}
currentGuideTitle={aiGuide.title}
/>
</div>
</div>
)}
</div>
<AIGuideChat
guideSlug={guideSlug}
isGuideLoading={!aiGuide}
onUpgrade={() => setShowUpgradeModal(true)}
randomQuestions={randomQuestions}
isQuestionsLoading={isAiGuideSuggestionsLoading}
/>
</AITutorLayout>
);
}
type ListSuggestionsProps = {
currentGuideTitle?: string;
title: string;
suggestions: string[];
depth: string;
isLoading: boolean;
};
export function ListSuggestions(props: ListSuggestionsProps) {
const { title, suggestions, depth, isLoading, currentGuideTitle } = props;
return (
<div className="group relative overflow-hidden rounded-xl border border-gray-300 bg-linear-to-br from-gray-100 to-gray-50 shadow-xs transition-all duration-200">
<div className="border-b border-gray-200 bg-white px-5 py-4">
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
<p className="mt-1 text-sm text-gray-600">
{depth === 'essentials'
? 'Explore related concepts to expand your knowledge'
: 'Take a deeper dive into specific areas'}
</p>
</div>
<div className="p-5">
{isLoading && (
<div className="space-y-3">
{[1, 2].map((i) => (
<div
key={i}
className="h-10 w-full animate-pulse rounded-lg bg-white"
></div>
))}
</div>
)}
{!isLoading && suggestions?.length > 0 && (
<div className="space-y-2">
{suggestions.map((topic) => {
const topicTerm =
depth === 'essentials'
? `I have covered the basics of ${currentGuideTitle} and want to learn more about ${topic}`
: `I have covered the basics of ${currentGuideTitle} and want to dive deeper into ${topic}`;
const url = `/ai/guide?term=${encodeURIComponent(topicTerm)}&depth=${depth}&id=&format=guide`;
return (
<a
key={topic}
href={url}
target="_blank"
className="group/item flex items-center justify-between rounded-lg border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-700 transition-all duration-200 hover:border-gray-300 hover:bg-gray-50"
>
<span className="flex-1 truncate group-hover/item:text-gray-900">
{topic}
</span>
<ExternalLink className="ml-2 size-4 text-gray-400 group-hover/item:text-gray-600" />
</a>
);
})}
</div>
)}
{!isLoading && suggestions?.length === 0 && (
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="mb-3 rounded-full bg-gray-100 p-3">
<svg
className="h-6 w-6 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
</div>
<p className="text-sm text-gray-500">No suggestions available</p>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,381 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { useChat, type ChatMessage } from '../../hooks/use-chat';
import { RoadmapAIChatCard } from '../RoadmapAIChat/RoadmapAIChatCard';
import {
ArrowDownIcon,
BotIcon,
LockIcon,
MessageCircleIcon,
PauseCircleIcon,
SendIcon,
Trash2Icon,
XIcon,
} from 'lucide-react';
import { ChatHeaderButton } from '../FrameRenderer/RoadmapFloatingChat';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import { flushSync } from 'react-dom';
import { markdownToHtml } from '../../lib/markdown';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { useQuery } from '@tanstack/react-query';
import { queryClient } from '../../stores/query-client';
import { billingDetailsOptions } from '../../queries/billing';
import { LoadingChip } from '../LoadingChip';
import { getTailwindScreenDimension } from '../../lib/is-mobile';
type AIGuideChatProps = {
guideSlug?: string;
isGuideLoading?: boolean;
onUpgrade?: () => void;
isQuestionsLoading?: boolean;
randomQuestions?: string[];
};
export function AIGuideChat(props: AIGuideChatProps) {
const {
guideSlug,
isGuideLoading,
onUpgrade,
randomQuestions,
isQuestionsLoading,
} = props;
const scrollareaRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useState('');
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [isChatOpen, setIsChatOpen] = useState(true);
const [isMobile, setIsMobile] = useState(false);
const {
data: tokenUsage,
isLoading: isTokenUsageLoading,
refetch: refetchTokenUsage,
} = useQuery(getAiCourseLimitOptions(), queryClient);
const {
data: userBillingDetails,
isLoading: isBillingDetailsLoading,
refetch: refetchBillingDetails,
} = useQuery(billingDetailsOptions(), queryClient);
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
const isPaidUser = userBillingDetails?.status === 'active';
const {
messages,
status,
streamedMessageHtml,
sendMessages,
setMessages,
stop,
} = useChat({
endpoint: `${import.meta.env.PUBLIC_API_URL}/v1-ai-guide-chat`,
onError: (error) => {
console.error(error);
},
data: {
guideSlug,
},
onFinish: () => {
refetchTokenUsage();
},
});
const scrollToBottom = useCallback(
(behavior: 'smooth' | 'instant' = 'smooth') => {
scrollareaRef.current?.scrollTo({
top: scrollareaRef.current.scrollHeight,
behavior,
});
},
[scrollareaRef],
);
const isStreamingMessage = status === 'streaming';
const hasMessages = messages.length > 0;
const handleSubmitInput = useCallback(
(defaultInputValue?: string) => {
const message = defaultInputValue || inputValue;
if (!isLoggedIn()) {
showLoginPopup();
return;
}
if (isStreamingMessage) {
return;
}
const newMessages: ChatMessage[] = [
...messages,
{
role: 'user',
content: message,
html: markdownToHtml(message),
},
];
flushSync(() => {
setMessages(newMessages);
});
sendMessages(newMessages);
setInputValue('');
},
[inputValue, isStreamingMessage, messages, sendMessages, setMessages],
);
const checkScrollPosition = useCallback(() => {
const scrollArea = scrollareaRef.current;
if (!scrollArea) {
return;
}
const { scrollTop, scrollHeight, clientHeight } = scrollArea;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 50; // 50px threshold
setShowScrollToBottom(!isAtBottom && messages.length > 0);
}, [messages.length]);
useEffect(() => {
const scrollArea = scrollareaRef.current;
if (!scrollArea) {
return;
}
scrollArea.addEventListener('scroll', checkScrollPosition);
return () => scrollArea.removeEventListener('scroll', checkScrollPosition);
}, [checkScrollPosition]);
const isLoading =
isGuideLoading || isTokenUsageLoading || isBillingDetailsLoading;
useLayoutEffect(() => {
const deviceType = getTailwindScreenDimension();
const isMediumSize = ['sm', 'md'].includes(deviceType);
if (!isMediumSize) {
const storedState = localStorage.getItem('chat-history-sidebar-open');
setIsChatOpen(storedState === null ? true : storedState === 'true');
} else {
setIsChatOpen(!isMediumSize);
}
setIsMobile(isMediumSize);
}, []);
useEffect(() => {
if (!isMobile) {
localStorage.setItem('chat-history-sidebar-open', isChatOpen.toString());
}
}, [isChatOpen, isMobile]);
if (!isChatOpen) {
return (
<div className="absolute inset-x-0 bottom-0 flex justify-center p-2">
<button
className="flex items-center justify-center gap-2 rounded-full bg-black px-4 py-2 text-white shadow"
onClick={() => {
setIsChatOpen(true);
}}
>
<MessageCircleIcon className="h-4 w-4" />
<span className="text-sm">Open Chat</span>
</button>
</div>
);
}
return (
<div className="absolute inset-0 flex h-full w-full max-w-full flex-col overflow-hidden border-l border-gray-200 bg-white md:relative md:max-w-[40%]">
<div className="flex items-center justify-between gap-2 border-b border-gray-200 bg-white p-2">
<h2 className="flex items-center gap-2 text-sm font-medium">
<BotIcon className="h-4 w-4" />
AI Guide
</h2>
<button
className="mr-2 flex size-5 items-center justify-center rounded-md text-gray-500 hover:bg-gray-300 md:hidden"
onClick={() => {
setIsChatOpen(false);
}}
>
<XIcon className="h-3.5 w-3.5" />
</button>
</div>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-100">
<LoadingChip message="Loading..." />
</div>
)}
{!isLoading && (
<>
<div className="relative grow overflow-y-auto" ref={scrollareaRef}>
<div className="absolute inset-0 flex flex-col">
<div className="relative flex grow flex-col justify-end">
<div className="flex flex-col justify-end gap-2 px-3 py-2">
<RoadmapAIChatCard
role="assistant"
html="Hello, how can I help you today?"
isIntro
/>
{isQuestionsLoading && (
<div className="flex flex-col gap-2">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="h-[48px] w-full animate-pulse rounded-lg bg-gray-200"
></div>
))}
</div>
)}
{!isQuestionsLoading &&
randomQuestions &&
randomQuestions.length > 0 &&
messages.length === 0 && (
<div className="space-y-2">
<p className="mb-2 text-xs font-normal text-gray-500">
Some questions you might have about this lesson.
</p>
<div className="space-y-1">
{randomQuestions?.map((question) => {
return (
<button
key={`chat-${question}`}
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={() => {
handleSubmitInput(question);
}}
>
{question}
</button>
);
})}
</div>
</div>
)}
{messages.map((chat, index) => {
return (
<RoadmapAIChatCard key={`chat-${index}`} {...chat} />
);
})}
{status === 'streaming' && !streamedMessageHtml && (
<RoadmapAIChatCard role="assistant" html="Thinking..." />
)}
{status === 'streaming' && streamedMessageHtml && (
<RoadmapAIChatCard
role="assistant"
html={streamedMessageHtml}
/>
)}
</div>
</div>
</div>
</div>
{(hasMessages || showScrollToBottom) && (
<div className="flex flex-row justify-between gap-2 border-t border-gray-200 px-3 py-2">
<ChatHeaderButton
icon={<Trash2Icon className="h-3.5 w-3.5" />}
className="rounded-md bg-gray-200 py-1 pr-2 pl-1.5 text-gray-500 hover:bg-gray-300"
onClick={() => {
setMessages([]);
}}
>
Clear
</ChatHeaderButton>
{showScrollToBottom && (
<ChatHeaderButton
icon={<ArrowDownIcon className="h-3.5 w-3.5" />}
className="rounded-md bg-gray-200 py-1 pr-2 pl-1.5 text-gray-500 hover:bg-gray-300"
onClick={() => {
scrollToBottom('smooth');
}}
>
Scroll to bottom
</ChatHeaderButton>
)}
</div>
)}
<div className="relative flex items-center border-t border-gray-200 text-sm">
{isLimitExceeded && isLoggedIn() && (
<div className="absolute inset-0 z-10 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={() => {
onUpgrade?.();
}}
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
>
Upgrade for more
</button>
)}
</div>
)}
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (isStreamingMessage) {
return;
}
handleSubmitInput();
}
}}
placeholder="Ask me anything about this guide..."
className="w-full resize-none px-3 py-4 outline-none"
/>
<button
className="absolute top-1/2 right-2 -translate-y-1/2 p-1 text-zinc-500 hover:text-black disabled:opacity-50"
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
if (status !== 'idle') {
stop();
return;
}
handleSubmitInput();
}}
>
{isStreamingMessage ? (
<PauseCircleIcon className="h-4 w-4" />
) : (
<SendIcon className="h-4 w-4" />
)}
</button>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,131 @@
.prose ul li > code,
.prose ol li > code,
p code,
a > code,
strong > code,
em > code,
h1 > code,
h2 > code,
h3 > code {
background: #ebebeb !important;
color: currentColor !important;
font-size: 14px;
font-weight: normal !important;
}
.course-ai-content.course-content.prose ul li > code,
.course-ai-content.course-content.prose ol li > code,
.course-ai-content.course-content.prose p code,
.course-ai-content.course-content.prose a > code,
.course-ai-content.course-content.prose strong > code,
.course-ai-content.course-content.prose em > code,
.course-ai-content.course-content.prose h1 > code,
.course-ai-content.course-content.prose h2 > code,
.course-ai-content.course-content.prose h3 > code,
.course-notes-content.prose ul li > code,
.course-notes-content.prose ol li > code,
.course-notes-content.prose p code,
.course-notes-content.prose a > code,
.course-notes-content.prose strong > code,
.course-notes-content.prose em > code,
.course-notes-content.prose h1 > code,
.course-notes-content.prose h2 > code,
.course-notes-content.prose h3 > code {
font-size: 12px !important;
}
.course-ai-content pre {
-ms-overflow-style: none;
scrollbar-width: none;
}
.course-ai-content pre::-webkit-scrollbar {
display: none;
}
.course-ai-content pre,
.course-notes-content pre {
overflow: scroll;
font-size: 15px;
margin: 10px 0;
}
.prose ul li > code:before,
p > code:before,
.prose ul li > code:after,
.prose ol li > code:before,
p > code:before,
.prose ol li > code:after,
.course-content h1 > code:after,
.course-content h1 > code:before,
.course-content h2 > code:after,
.course-content h2 > code:before,
.course-content h3 > code:after,
.course-content h3 > code:before,
.course-content h4 > code:after,
.course-content h4 > code:before,
p > code:after,
a > code:after,
a > code:before {
content: '' !important;
}
.course-content.prose ul li > code,
.course-content.prose ol li > code,
.course-content p code,
.course-content a > code,
.course-content strong > code,
.course-content em > code,
.course-content h1 > code,
.course-content h2 > code,
.course-content h3 > code,
.course-content table code {
background: #f4f4f5 !important;
border: 1px solid #282a36 !important;
color: #282a36 !important;
padding: 2px 4px;
border-radius: 5px;
font-size: 16px !important;
white-space: pre;
font-weight: normal;
}
.course-content blockquote {
font-style: normal;
}
.course-content.prose blockquote h1,
.course-content.prose blockquote h2,
.course-content.prose blockquote h3,
.course-content.prose blockquote h4 {
font-style: normal;
margin-bottom: 8px;
}
.course-content.prose ul li > code:before,
.course-content p > code:before,
.course-content.prose ul li > code:after,
.course-content p > code:after,
.course-content h2 > code:after,
.course-content h2 > code:before,
.course-content table code:before,
.course-content table code:after,
.course-content a > code:after,
.course-content a > code:before,
.course-content h2 code:after,
.course-content h2 code:before,
.course-content h2 code:after,
.course-content h2 code:before {
content: '' !important;
}
.course-content table {
border-collapse: collapse;
border: 1px solid black;
border-radius: 5px;
}
.course-content table td,
.course-content table th {
padding: 5px 10px;
}
@@ -0,0 +1,40 @@
import './AIGuideContent.css';
import { AIGuideRegenerate } from './AIGuideRegenerate';
import { cn } from '../../lib/classname';
import { LoadingChip } from '../LoadingChip';
type AIGuideContentProps = {
html: string;
onRegenerate?: (prompt?: string) => void;
isLoading?: boolean;
};
export function AIGuideContent(props: AIGuideContentProps) {
const { html, onRegenerate, isLoading } = props;
return (
<div
className={cn(
'relative mx-auto w-full max-w-4xl',
isLoading && 'min-h-full',
)}
>
<div
className="course-content prose-h1:leading-[1.15] prose prose-lg 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: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 mt-8 max-w-full text-black max-lg:mt-4 max-lg:text-base [&>h1]:text-balance"
dangerouslySetInnerHTML={{ __html: html }}
/>
{isLoading && !html && (
<div className="absolute inset-0 flex items-center justify-center">
<LoadingChip message="Please wait..." />
</div>
)}
{onRegenerate && !isLoading && (
<div className="absolute top-4 right-4">
<AIGuideRegenerate onRegenerate={onRegenerate} />
</div>
)}
</div>
);
}
@@ -0,0 +1,88 @@
import { PenSquare, RefreshCcw } from 'lucide-react';
import { useRef, useState } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { cn } from '../../lib/classname';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { ModifyCoursePrompt } from '../GenerateCourse/ModifyCoursePrompt';
type AIGuideRegenerateProps = {
onRegenerate: (prompt?: string) => void;
};
export function AIGuideRegenerate(props: AIGuideRegenerateProps) {
const { onRegenerate } = props;
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [showPromptModal, setShowPromptModal] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useOutsideClick(ref, () => setIsDropdownVisible(false));
return (
<>
{showUpgradeModal && (
<UpgradeAccountModal
onClose={() => {
setShowUpgradeModal(false);
}}
/>
)}
{showPromptModal && (
<ModifyCoursePrompt
description="Pass additional information to the AI to generate a guide."
onClose={() => setShowPromptModal(false)}
onSubmit={(prompt) => {
setShowPromptModal(false);
onRegenerate(prompt);
}}
/>
)}
<div ref={ref} className="relative flex items-stretch">
<button
className={cn('rounded-md px-2.5 text-gray-400 hover:text-black', {
'text-black': isDropdownVisible,
})}
onClick={() => setIsDropdownVisible(!isDropdownVisible)}
>
<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">
<button
onClick={() => {
setIsDropdownVisible(false);
onRegenerate();
}}
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-100"
>
<RefreshCcw
size={16}
className="text-gray-400"
strokeWidth={2.5}
/>
Regenerate
</button>
<button
onClick={() => {
setIsDropdownVisible(false);
setShowPromptModal(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"
>
<PenSquare
size={16}
className="text-gray-400"
strokeWidth={2.5}
/>
Modify Prompt
</button>
</div>
)}
</div>
</>
);
}
@@ -0,0 +1,142 @@
import { useEffect, useRef, useState } from 'react';
import { generateGuide } from '../../helper/generate-ai-guide';
import { getCourseFineTuneData } from '../../lib/ai';
import { getUrlParams } from '../../lib/browser';
import { isLoggedIn } from '../../lib/jwt';
import { AIGuideContent } from './AIGuideContent';
import { queryClient } from '../../stores/query-client';
import { getAiGuideOptions } from '../../queries/ai-guide';
import { LoadingChip } from '../LoadingChip';
type GenerateAIGuideProps = {
onGuideSlugChange?: (guideSlug: string) => void;
};
export function GenerateAIGuide(props: GenerateAIGuideProps) {
const { onGuideSlugChange } = props;
const [isLoading, setIsLoading] = useState(true);
const [isStreaming, setIsStreaming] = useState(false);
const [error, setError] = useState('');
const [content, setContent] = useState('');
const [html, setHtml] = useState('');
const htmlRef = useRef<string>('');
useEffect(() => {
const params = getUrlParams();
const paramsTerm = params?.term;
const paramsDepth = params?.depth;
const paramsSrc = params?.src || 'search';
if (!paramsTerm || !paramsDepth) {
return;
}
let paramsGoal = '';
let paramsAbout = '';
let paramsCustomInstructions = '';
const sessionId = params?.id;
if (sessionId) {
const fineTuneData = getCourseFineTuneData(sessionId);
if (fineTuneData) {
paramsGoal = fineTuneData.goal;
paramsAbout = fineTuneData.about;
paramsCustomInstructions = fineTuneData.customInstructions;
}
}
handleGenerateDocument({
term: paramsTerm,
depth: paramsDepth,
instructions: paramsCustomInstructions,
goal: paramsGoal,
about: paramsAbout,
src: paramsSrc,
});
}, []);
const handleGenerateDocument = async (options: {
term: string;
depth: string;
instructions?: string;
goal?: string;
about?: string;
isForce?: boolean;
prompt?: string;
src?: string;
}) => {
const { term, depth, isForce, prompt, instructions, goal, about, src } =
options;
if (!isLoggedIn()) {
window.location.href = '/ai';
return;
}
await generateGuide({
term,
depth,
onDetailsChange: (details) => {
const { guideId, guideSlug, creatorId, title } = details;
const guideData = {
_id: guideId,
userId: creatorId,
title,
html: htmlRef.current,
keyword: term,
depth,
content,
tokens: {
prompt: 0,
completion: 0,
total: 0,
},
relatedTopics: [],
deepDiveTopics: [],
questions: [],
viewCount: 0,
lastVisitedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
};
queryClient.setQueryData(
getAiGuideOptions(guideSlug).queryKey,
guideData,
);
onGuideSlugChange?.(guideSlug);
window.history.replaceState(null, '', `/ai/guide/${guideSlug}`);
},
onLoadingChange: setIsLoading,
onError: setError,
instructions,
goal,
about,
isForce,
prompt,
src,
onHtmlChange: (html) => {
htmlRef.current = html;
setHtml(html);
},
onStreamingChange: setIsStreaming,
});
};
if (error) {
return <div className="text-red-500">{error}</div>;
}
if (isLoading) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoadingChip message="Please wait..." />
</div>
);
}
return <AIGuideContent html={html} />;
}
@@ -0,0 +1,84 @@
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { queryClient } from '../../stores/query-client';
import { AIGuideContent } from './AIGuideContent';
import { getAiGuideOptions } from '../../queries/ai-guide';
type GetAIGuideProps = {
slug: string;
};
export function GetAIGuide(props: GetAIGuideProps) {
const { slug: documentSlug } = props;
const [isLoading, setIsLoading] = useState(true);
const [isRegenerating, setIsRegenerating] = useState(false);
const [error, setError] = useState('');
const { data: aiGuide, error: queryError } = useQuery(
{
...getAiGuideOptions(documentSlug),
enabled: !!documentSlug,
},
queryClient,
);
useEffect(() => {
if (!aiGuide) {
return;
}
setIsLoading(false);
}, [aiGuide]);
useEffect(() => {
if (!queryError) {
return;
}
setIsLoading(false);
setError(queryError.message);
}, [queryError]);
const handleRegenerateDocument = async (prompt?: string) => {
// if (!aiDocument) {
// return;
// }
// queryClient.setQueryData(
// getAiDocumentOptions({ documentSlug: documentSlug }).queryKey,
// {
// ...aiDocument,
// title: '',
// difficulty: '',
// modules: [],
// },
// );
// await generateDocument({
// term: aiDocument.keyword,
// difficulty: aiDocument.difficulty,
// slug: documentSlug,
// prompt,
// onDocumentChange: (document) => {
// queryClient.setQueryData(
// getAiDocumentOptions({ documentSlug: documentSlug }).queryKey,
// {
// ...aiDocument,
// title: aiDocument.title,
// difficulty: aiDocument.difficulty,
// content: document,
// },
// );
// },
// onLoadingChange: (isNewLoading) => {
// setIsRegenerating(isNewLoading);
// if (!isNewLoading) {
// // TODO: Update progress
// }
// },
// onError: setError,
// isForce: true,
// });
};
return <AIGuideContent html={aiGuide?.html || ''} />;
}
@@ -0,0 +1,148 @@
import { useQuery } from '@tanstack/react-query';
import { BookOpen, Loader2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import {
listUserAIGuidesOptions,
type ListUserAIGuidesQuery,
} from '../../queries/ai-guide';
import { queryClient } from '../../stores/query-client';
import { AITutorTallMessage } from '../AITutor/AITutorTallMessage';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { Pagination } from '../Pagination/Pagination';
import { AICourseSearch } from '../GenerateCourse/AICourseSearch';
import { AIGuideCard } from '../AIGuide/AIGuideCard';
export function UserGuidesList() {
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [showUpgradePopup, setShowUpgradePopup] = useState(false);
const [pageState, setPageState] = useState<ListUserAIGuidesQuery>({
perPage: '21',
currPage: '1',
query: '',
});
const { data: userAiGuides, isFetching: isUserAiGuidesLoading } = useQuery(
listUserAIGuidesOptions(pageState),
queryClient,
);
useEffect(() => {
setIsInitialLoading(false);
}, [userAiGuides]);
const guides = userAiGuides?.data ?? [];
useEffect(() => {
const queryParams = getUrlParams();
setPageState({
...pageState,
currPage: queryParams?.p || '1',
query: queryParams?.q || '',
});
}, []);
useEffect(() => {
if (pageState?.currPage !== '1' || pageState?.query !== '') {
setUrlParams({
p: pageState?.currPage || '1',
q: pageState?.query || '',
});
} else {
deleteUrlParam('p');
deleteUrlParam('q');
}
}, [pageState]);
const isUserAuthenticated = isLoggedIn();
const isAnyLoading = isUserAiGuidesLoading || isInitialLoading;
return (
<>
{showUpgradePopup && (
<UpgradeAccountModal onClose={() => setShowUpgradePopup(false)} />
)}
<AICourseSearch
value={pageState?.query || ''}
onChange={(value) => {
setPageState({
...pageState,
query: value,
currPage: '1',
});
}}
disabled={isAnyLoading}
placeholder="Search guides..."
/>
{isAnyLoading && (
<p className="mb-4 flex flex-row items-center gap-2 text-sm text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" />
Loading your guides...
</p>
)}
{!isAnyLoading && (
<>
<p className="mb-4 text-sm text-gray-500">
{isUserAuthenticated
? `You have generated ${userAiGuides?.totalCount} guides so far.`
: 'Sign up or login to generate your first guide. Takes 2s to do so.'}
</p>
{isUserAuthenticated && !isAnyLoading && guides.length > 0 && (
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 gap-2 md:grid-cols-2 xl:grid-cols-3">
{guides.map((guide) => (
<AIGuideCard key={guide._id} guide={guide} />
))}
</div>
<Pagination
totalCount={userAiGuides?.totalCount || 0}
totalPages={userAiGuides?.totalPages || 0}
currPage={Number(userAiGuides?.currPage || 1)}
perPage={Number(userAiGuides?.perPage || 10)}
onPageChange={(page) => {
setPageState({ ...pageState, currPage: String(page) });
}}
className="rounded-lg border border-gray-200 bg-white p-4"
/>
</div>
)}
{!isAnyLoading && guides.length === 0 && (
<AITutorTallMessage
title={
isUserAuthenticated ? 'No guides found' : 'Sign up or login'
}
subtitle={
isUserAuthenticated
? "You haven't generated any guides yet."
: 'Takes 2s to sign up and generate your first guide.'
}
icon={BookOpen}
buttonText={
isUserAuthenticated
? 'Create your first guide'
: 'Sign up or login'
}
onButtonClick={() => {
if (isUserAuthenticated) {
window.location.href = '/ai';
} else {
showLoginPopup();
}
}}
/>
)}
</>
)}
</>
);
}