1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-08-11 11:43:58 +02:00

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 Chakma
2025-06-19 07:22:04 +06:00
committed by GitHub
parent 469f4ca530
commit 89932bc18d
56 changed files with 3959 additions and 280 deletions

View File

@@ -3,6 +3,6 @@
"enabled": false
},
"_variables": {
"lastUpdateCheck": 1749494681580
"lastUpdateCheck": 1749465237682
}
}

View File

@@ -73,6 +73,7 @@
"npm-check-updates": "^18.0.1",
"playwright": "^1.52.0",
"prismjs": "^1.30.0",
"radix-ui": "^1.4.2",
"react": "^19.1.0",
"react-calendar-heatmap": "^1.10.0",
"react-confetti": "^6.4.0",

974
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -219,7 +219,7 @@ export function AIChat(props: AIChatProps) {
credentials: 'include',
body: JSON.stringify({
chatHistoryId: defaultChatHistoryId,
messages: messages.slice(-10),
messages,
force,
}),
});
@@ -283,8 +283,7 @@ export function AIChat(props: AIChatProps) {
});
},
onDetails: (details) => {
const detailsJson = JSON.parse(details);
const chatHistoryId = detailsJson?.chatHistoryId;
const chatHistoryId = details?.chatHistoryId;
if (!chatHistoryId) {
return;
}

View File

@@ -33,7 +33,7 @@ export function AIChatCourse(props: AIChatCourseProps) {
return null;
}
const courseSearchUrl = `/ai/search?term=${course?.keyword}&difficulty=${course?.difficulty}`;
const courseSearchUrl = `/ai/course?term=${course?.keyword}&difficulty=${course?.difficulty}`;
return (
<div className="relative my-6 flex flex-wrap gap-1 first:mt-0 last:mb-0">

View File

@@ -0,0 +1,116 @@
import { ArrowUpRightIcon, MoreVertical, Play, Trash2 } from 'lucide-react';
import { useRef, useState } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { useKeydown } from '../../hooks/use-keydown';
import { useToast } from '../../hooks/use-toast';
import { useMutation } from '@tanstack/react-query';
import { queryClient } from '../../stores/query-client';
import { httpDelete } from '../../lib/query-http';
type AIGuideActionsType = {
guideSlug: string;
onDeleted?: () => void;
};
export function AIGuideActions(props: AIGuideActionsType) {
const { guideSlug, onDeleted } = props;
const toast = useToast();
const dropdownRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [isConfirming, setIsConfirming] = useState(false);
const { mutate: deleteCourse, isPending: isDeleting } = useMutation(
{
mutationFn: async () => {
return httpDelete(`/v1-delete-ai-guide/${guideSlug}`);
},
onSuccess: () => {
toast.success('Guide deleted');
queryClient.invalidateQueries({
predicate: (query) => query.queryKey?.[0] === 'user-ai-guides',
});
onDeleted?.();
},
onError: (error) => {
toast.error(error?.message || 'Failed to delete guide');
},
},
queryClient,
);
useOutsideClick(dropdownRef, () => {
setIsOpen(false);
});
useKeydown('Escape', () => {
setIsOpen(false);
});
return (
<div className="relative h-full" ref={dropdownRef}>
<button
className="h-full text-gray-400 hover:text-gray-700"
onClick={(e) => {
e.stopPropagation();
setIsOpen(!isOpen);
}}
>
<MoreVertical size={16} />
</button>
{isOpen && (
<div className="absolute top-8 right-0 z-10 w-48 overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg">
<a
href={`/ai/guide/${guideSlug}`}
className="flex w-full items-center gap-1.5 p-2 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-black disabled:cursor-not-allowed disabled:opacity-70"
>
<ArrowUpRightIcon className="h-3.5 w-3.5" />
View Guide
</a>
{!isConfirming && (
<button
className="flex w-full items-center gap-1.5 p-2 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-black disabled:cursor-not-allowed disabled:opacity-70"
onClick={() => setIsConfirming(true)}
disabled={isDeleting}
>
{!isDeleting ? (
<>
<Trash2 className="h-3.5 w-3.5" />
Delete Guide
</>
) : (
'Deleting...'
)}
</button>
)}
{isConfirming && (
<span className="flex w-full items-center justify-between gap-1.5 p-2 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-black disabled:cursor-not-allowed disabled:opacity-70">
Are you sure?
<div className="flex items-center gap-2">
<button
onClick={() => {
setIsConfirming(false);
deleteCourse();
}}
disabled={isDeleting}
className="text-red-500 underline hover:text-red-800"
>
Yes
</button>
<button
onClick={() => setIsConfirming(false)}
className="text-red-500 underline hover:text-red-800"
>
No
</button>
</div>
</span>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,52 @@
import type { ListUserAIGuidesResponse } from '../../queries/ai-guide';
import { AIGuideActions } from './AIGuideActions';
type AIGuideCardProps = {
guide: ListUserAIGuidesResponse['data'][number] & {
html: string;
};
showActions?: boolean;
};
export function AIGuideCard(props: AIGuideCardProps) {
const { guide, showActions = true } = props;
const guideDepthColor =
{
essentials: 'text-green-700',
detailed: 'text-blue-700',
complete: 'text-purple-700',
}[guide.depth] || 'text-gray-700';
return (
<div className="relative flex flex-grow flex-col">
<a
href={`/ai/guide/${guide.slug}`}
className="group relative flex h-full min-h-[120px] w-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white p-3 text-left hover:border-gray-300 hover:bg-gray-50 sm:p-4"
>
<div className="mb-2 flex items-center justify-between sm:mb-3">
<span
className={`rounded-full text-xs font-medium capitalize opacity-80 ${guideDepthColor}`}
>
{guide.depth}
</span>
</div>
<div className="relative max-h-[180px] min-h-[140px] overflow-y-hidden sm:max-h-[200px] sm:min-h-[160px]">
<div
className="prose prose-sm prose-pre:bg-gray-100 [&_h1]:hidden [&_h1:first-child]:block [&_h1:first-child]:text-base [&_h1:first-child]:font-bold [&_h1:first-child]:leading-[1.35] [&_h1:first-child]:text-pretty sm:[&_h1:first-child]:text-lg [&_h2]:hidden [&_h3]:hidden [&_h4]:hidden [&_h5]:hidden [&_h6]:hidden"
dangerouslySetInnerHTML={{ __html: guide.html }}
/>
<div className="absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-white to-transparent group-hover:from-gray-50 sm:h-16" />
</div>
</a>
{showActions && guide.slug && (
<div className="absolute top-2 right-2">
<AIGuideActions guideSlug={guide.slug} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { useState } from 'react';
import { AITutorHeader } from '../AITutor/AITutorHeader';
import { AITutorLayout } from '../AITutor/AITutorLayout';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { LibraryTabs } from '../Library/LibraryTab';
type AILibraryLayoutProps = {
activeTab: 'courses' | 'guides';
children: React.ReactNode;
};
export function AILibraryLayout(props: AILibraryLayoutProps) {
const { activeTab, children } = props;
const [showUpgradePopup, setShowUpgradePopup] = useState(false);
return (
<AITutorLayout activeTab="library">
{showUpgradePopup && (
<UpgradeAccountModal onClose={() => setShowUpgradePopup(false)} />
)}
<div className="mx-auto flex w-full max-w-6xl flex-grow flex-col p-2">
<AITutorHeader
title="Library"
subtitle="Explore your AI-generated guides and courses"
onUpgradeClick={() => setShowUpgradePopup(true)}
/>
<LibraryTabs activeTab={activeTab} />
{children}
</div>
</AITutorLayout>
);
}

View File

@@ -1,7 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { AICourseCard } from '../GenerateCourse/AICourseCard';
import { AILoadingState } from './AILoadingState';
import { AITutorHeader } from './AITutorHeader';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import {
@@ -13,14 +12,15 @@ import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser';
import { Pagination } from '../Pagination/Pagination';
import { AICourseSearch } from '../GenerateCourse/AICourseSearch';
import { AITutorTallMessage } from './AITutorTallMessage';
import { BookOpen } from 'lucide-react';
import { BookOpen, Loader2 } from 'lucide-react';
import { humanizeNumber } from '../../lib/number';
export function AIExploreCourseListing() {
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [showUpgradePopup, setShowUpgradePopup] = useState(false);
const [pageState, setPageState] = useState<ListExploreAiCoursesQuery>({
perPage: '21',
perPage: '42',
currPage: '1',
query: '',
});
@@ -36,6 +36,7 @@ export function AIExploreCourseListing() {
}, [exploreAiCourses]);
const courses = exploreAiCourses?.data ?? [];
const isAnyLoading = isExploreAiCoursesLoading || isInitialLoading;
useEffect(() => {
const queryParams = getUrlParams();
@@ -63,66 +64,91 @@ export function AIExploreCourseListing() {
<AITutorHeader
title="Explore Courses"
subtitle="Explore the AI courses created by community"
onUpgradeClick={() => setShowUpgradePopup(true)}
>
<AICourseSearch
value={pageState?.query || ''}
onChange={(value) => {
setPageState({
...pageState,
query: value,
currPage: '1',
});
}}
/>
</AITutorHeader>
/>
<AICourseSearch
value={pageState?.query || ''}
onChange={(value) => {
setPageState({
...pageState,
query: value,
currPage: '1',
});
}}
disabled={isAnyLoading}
/>
{(isInitialLoading || isExploreAiCoursesLoading) && (
<AILoadingState
title="Loading courses"
subtitle="This may take a moment..."
/>
{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 courses...
</p>
)}
{!isExploreAiCoursesLoading && courses && 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">
{courses.map((course) => (
<AICourseCard
key={course._id}
course={course}
showActions={false}
showProgress={false}
{!isAnyLoading && (
<>
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-gray-500">
Community has generated{' '}
{humanizeNumber(exploreAiCourses?.totalCount || 0)} courses
</p>
<div className="hidden lg:block">
<Pagination
variant="minimal"
totalCount={exploreAiCourses?.totalCount || 0}
totalPages={exploreAiCourses?.totalPages || 0}
currPage={Number(exploreAiCourses?.currPage || 1)}
perPage={Number(exploreAiCourses?.perPage || 21)}
onPageChange={(page) => {
setPageState({ ...pageState, currPage: String(page) });
}}
className=""
/>
))}
</div>
</div>
<Pagination
totalCount={exploreAiCourses?.totalCount || 0}
totalPages={exploreAiCourses?.totalPages || 0}
currPage={Number(exploreAiCourses?.currPage || 1)}
perPage={Number(exploreAiCourses?.perPage || 21)}
onPageChange={(page) => {
setPageState({ ...pageState, currPage: String(page) });
}}
className="rounded-lg border border-gray-200 bg-white p-4"
/>
</div>
)}
{courses && 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">
{courses.map((course) => (
<AICourseCard
key={course._id}
course={course}
showActions={false}
showProgress={false}
variant="column"
/>
))}
</div>
{!isInitialLoading &&
!isExploreAiCoursesLoading &&
courses.length === 0 && (
<AITutorTallMessage
title="No courses found"
subtitle="Try a different search or check back later."
icon={BookOpen}
buttonText="Create your first course"
onButtonClick={() => {
window.location.href = '/ai';
}}
/>
)}
<Pagination
totalCount={exploreAiCourses?.totalCount || 0}
totalPages={exploreAiCourses?.totalPages || 0}
currPage={Number(exploreAiCourses?.currPage || 1)}
perPage={Number(exploreAiCourses?.perPage || 21)}
onPageChange={(page) => {
setPageState({ ...pageState, currPage: String(page) });
}}
className="rounded-lg border border-gray-200 bg-white p-4"
/>
</div>
)}
{courses.length === 0 && (
<AITutorTallMessage
title="No courses found"
subtitle="Try a different search or check back later."
icon={BookOpen}
buttonText="Create your first course"
onButtonClick={() => {
window.location.href = '/ai';
}}
/>
)}
</>
)}
</>
);
}

View File

@@ -20,7 +20,7 @@ export function AIFeaturedCoursesListing() {
const [showUpgradePopup, setShowUpgradePopup] = useState(false);
const [pageState, setPageState] = useState<ListUserAiCoursesQuery>({
perPage: '21',
perPage: '42',
currPage: '1',
query: '',
});
@@ -63,7 +63,8 @@ export function AIFeaturedCoursesListing() {
)}
<AITutorHeader
title="Featured Courses"
title="Staff Picks"
subtitle="Explore our hand-picked courses generated by AI"
onUpgradeClick={() => setShowUpgradePopup(true)}
>
<AICourseSearch
@@ -96,6 +97,7 @@ export function AIFeaturedCoursesListing() {
course={course}
showActions={false}
showProgress={false}
variant="column"
/>
))}
</div>

View File

@@ -1,17 +1,18 @@
import { useQuery } from '@tanstack/react-query';
import { AITutorLimits } from './AITutorLimits';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { queryClient } from '../../stores/query-client';
import { useIsPaidUser } from '../../queries/billing';
import { PlusIcon } from 'lucide-react';
type AITutorHeaderProps = {
title: string;
subtitle?: string;
onUpgradeClick: () => void;
children?: React.ReactNode;
};
export function AITutorHeader(props: AITutorHeaderProps) {
const { title, onUpgradeClick, children } = props;
const { title, subtitle, onUpgradeClick, children } = props;
const { data: limits } = useQuery(getAiCourseLimitOptions(), queryClient);
const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser();
@@ -20,20 +21,22 @@ export function AITutorHeader(props: AITutorHeaderProps) {
return (
<div className="mb-3 flex min-h-[35px] items-center justify-between max-sm:mb-1">
<div className="flex items-center gap-2">
<h2 className="relative flex-shrink-0 top-0 lg:top-1 text-lg font-semibold">{title}</h2>
</div>
<div className="flex items-center gap-2">
<AITutorLimits
used={used}
limit={limit}
isPaidUser={isPaidUser}
isPaidUserLoading={isPaidUserLoading}
onUpgradeClick={onUpgradeClick}
/>
{children}
<div className="flex w-full flex-row items-center justify-between gap-2">
<div className="gap-2">
<h2 className="relative top-0 mb-1 sm:mb-3 flex-shrink-0 text-2xl sm:text-3xl font-semibold lg:top-1">
{title}
</h2>
{subtitle && <p className="mb-4 text-sm text-gray-500">{subtitle}</p>}
</div>
<div className="flex flex-row items-center gap-2">
<a
href="/ai"
className="flex max-sm:hidden flex-row items-center gap-2 rounded-lg bg-black px-4 py-1.5 text-sm font-medium text-white"
>
<PlusIcon className="h-4 w-4" />
New
</a>
</div>
</div>
</div>
);

View File

@@ -6,7 +6,7 @@ import { cn } from '../../lib/classname';
type AITutorLayoutProps = {
children: React.ReactNode;
activeTab: AITutorTab;
activeTab?: AITutorTab;
wrapperClassName?: string;
containerClassName?: string;
};

View File

@@ -14,20 +14,20 @@ import { UserDropdown } from './UserDropdown';
type AITutorSidebarProps = {
isFloating: boolean;
activeTab: AITutorTab;
activeTab?: AITutorTab;
onClose: () => void;
};
const sidebarItems = [
{
key: 'new',
label: 'New Course',
label: 'New',
href: '/ai',
icon: Plus,
},
{
key: 'courses',
label: 'My Courses',
key: 'library',
label: 'Library',
href: '/ai/courses',
icon: BookOpen,
},

View File

@@ -0,0 +1,75 @@
import { ChevronDown } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
import { cn } from '../../lib/classname';
import type { LucideIcon } from 'lucide-react';
type BaseDropdownProps<T extends string> = {
value: T;
options: readonly T[];
onChange: (value: T) => void;
icons?: Record<T, LucideIcon>;
};
export function BaseDropdown<T extends string>(props: BaseDropdownProps<T>) {
const { value, options, onChange, icons } = props;
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const Icon = icons?.[value];
return (
<div className="relative" ref={dropdownRef}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={cn(
'flex items-center gap-2 rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-700 hover:bg-gray-200 hover:text-black',
)}
>
{Icon && <Icon size={16} />}
<span className="capitalize">{value}</span>
<ChevronDown size={16} className={cn(isOpen && 'rotate-180')} />
</button>
{isOpen && (
<div className="absolute z-10 mt-1 flex flex-col overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg">
{options.map((option) => {
const OptionIcon = icons?.[option];
return (
<button
key={option}
type="button"
onClick={() => {
onChange(option);
setIsOpen(false);
}}
className={cn(
'flex items-center gap-2 px-5 py-2 text-left text-sm capitalize hover:bg-gray-100',
value === option && 'bg-gray-200 font-medium hover:bg-gray-200',
)}
>
{OptionIcon && <OptionIcon size={16} />}
{option}
</button>
);
})}
</div>
)}
</div>
);
}

View File

@@ -1,9 +1,7 @@
import { ChevronDown } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
import { cn } from '../../lib/classname';
import { BaseDropdown } from './BaseDropdown';
import {
difficultyLevels,
type DifficultyLevel,
difficultyLevels,
type DifficultyLevel,
} from '../GenerateCourse/AICourse';
type DifficultyDropdownProps = {
@@ -14,56 +12,11 @@ type DifficultyDropdownProps = {
export function DifficultyDropdown(props: DifficultyDropdownProps) {
const { value, onChange } = props;
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className="relative" ref={dropdownRef}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={cn(
'flex items-center gap-2 rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-700 hover:bg-gray-200 hover:text-black',
)}
>
<span className="capitalize">{value}</span>
<ChevronDown size={16} className={cn(isOpen && 'rotate-180')} />
</button>
{isOpen && (
<div className="absolute z-10 mt-1 flex flex-col overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg">
{difficultyLevels.map((level) => (
<button
key={level}
type="button"
onClick={() => {
onChange(level);
setIsOpen(false);
}}
className={cn(
'px-5 py-2 text-left text-sm capitalize hover:bg-gray-100',
value === level && 'bg-gray-200 font-medium hover:bg-gray-200',
)}
>
{level}
</button>
))}
</div>
)}
</div>
<BaseDropdown
value={value}
options={difficultyLevels}
onChange={onChange}
/>
);
}

View File

@@ -0,0 +1,28 @@
import { BaseDropdown } from './BaseDropdown';
import { BookOpen, FileText } from 'lucide-react';
export const natureTypes = ['course', 'document'] as const;
export type NatureType = (typeof natureTypes)[number];
const natureIcons = {
course: BookOpen,
document: FileText,
} as const;
type NatureDropdownProps = {
value: NatureType;
onChange: (value: NatureType) => void;
};
export function NatureDropdown(props: NatureDropdownProps) {
const { value, onChange } = props;
return (
<BaseDropdown
value={value}
options={natureTypes}
onChange={onChange}
icons={natureIcons}
/>
);
}

View File

@@ -0,0 +1,229 @@
import {
BookOpenIcon,
FileTextIcon,
SparklesIcon,
type LucideIcon,
} from 'lucide-react';
import { useEffect, useId, useState, type FormEvent } from 'react';
import { FormatItem } from './FormatItem';
import { GuideOptions } from './GuideOptions';
import { FineTuneCourse } from '../GenerateCourse/FineTuneCourse';
import { CourseOptions } from './CourseOptions';
import {
clearFineTuneData,
getCourseFineTuneData,
getLastSessionId,
storeFineTuneData,
} from '../../lib/ai';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { useIsPaidUser } from '../../queries/billing';
import { cn } from '../../lib/classname';
const allowedFormats = ['course', 'guide', 'roadmap'] as const;
type AllowedFormat = (typeof allowedFormats)[number];
export function ContentGenerator() {
const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false);
const { isPaidUser, isLoading: isPaidUserLoading } = useIsPaidUser();
const [title, setTitle] = useState('');
const [selectedFormat, setSelectedFormat] = useState<AllowedFormat>('course');
// guide options
const [depth, setDepth] = useState('essentials');
// course options
const [difficulty, setDifficulty] = useState('beginner');
// fine-tune options
const [showFineTuneOptions, setShowFineTuneOptions] = useState(false);
const [about, setAbout] = useState('');
const [goal, setGoal] = useState('');
const [customInstructions, setCustomInstructions] = useState('');
const titleFieldId = useId();
const fineTuneOptionsId = useId();
const allowedFormats: {
label: string;
icon: LucideIcon;
value: AllowedFormat;
}[] = [
{
label: 'Course',
icon: BookOpenIcon,
value: 'course',
},
{
label: 'Guide',
icon: FileTextIcon,
value: 'guide',
},
];
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!isLoggedIn()) {
showLoginPopup();
return;
}
let sessionId = '';
if (showFineTuneOptions) {
clearFineTuneData();
sessionId = storeFineTuneData({
about,
goal,
customInstructions,
});
}
if (selectedFormat === 'course') {
window.location.href = `/ai/course?term=${encodeURIComponent(title)}&difficulty=${difficulty}&id=${sessionId}&format=${selectedFormat}`;
} else if (selectedFormat === 'guide') {
window.location.href = `/ai/guide?term=${encodeURIComponent(title)}&depth=${depth}&id=${sessionId}&format=${selectedFormat}`;
}
};
useEffect(() => {
window?.fireEvent({
action: 'tutor_user',
category: 'ai_tutor',
label: 'Visited AI Course Page',
});
}, []);
useEffect(() => {
const lastSessionId = getLastSessionId();
if (!lastSessionId) {
return;
}
const fineTuneData = getCourseFineTuneData(lastSessionId);
if (!fineTuneData) {
return;
}
setAbout(fineTuneData.about);
setGoal(fineTuneData.goal);
setCustomInstructions(fineTuneData.customInstructions);
}, []);
return (
<div className="mx-auto flex w-full max-w-2xl flex-grow flex-col pt-4 md:justify-center md:pt-10 lg:pt-4">
<div className="relative">
{isUpgradeModalOpen && (
<UpgradeAccountModal onClose={() => setIsUpgradeModalOpen(false)} />
)}
{!isPaidUser && !isPaidUserLoading && isLoggedIn() && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 -translate-y-8 text-gray-500 max-md:hidden">
You are on the free plan
<button
onClick={() => setIsUpgradeModalOpen(true)}
className="ml-2 rounded-xl bg-yellow-600 px-2 py-1 text-sm text-white hover:opacity-80"
>
Upgrade to Pro
</button>
</div>
)}
<h1 className="mb-0.5 text-center text-4xl font-semibold max-md:text-left max-md:text-xl lg:mb-3">
What can I help you learn?
</h1>
<p className="text-center text-lg text-balance text-gray-600 max-md:text-left max-md:text-sm">
Enter a topic below to generate a personalized course for it
</p>
</div>
<form className="mt-10 space-y-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<label htmlFor={titleFieldId} className="inline-block text-gray-500">
What can I help you learn?
</label>
<input
type="text"
id={titleFieldId}
placeholder="Enter a topic"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="block w-full rounded-xl border border-gray-200 bg-white p-4 outline-none placeholder:text-gray-500 focus:border-gray-500"
required
minLength={3}
/>
</div>
<div className="flex flex-col gap-2">
<label className="inline-block text-gray-500">
Choose the format
</label>
<div className="grid grid-cols-2 gap-3">
{allowedFormats.map((format) => {
const isSelected = format.value === selectedFormat;
return (
<FormatItem
key={format.value}
label={format.label}
onClick={() => setSelectedFormat(format.value)}
icon={format.icon}
isSelected={isSelected}
/>
);
})}
</div>
</div>
{selectedFormat === 'guide' && (
<GuideOptions depth={depth} setDepth={setDepth} />
)}
{selectedFormat === 'course' && (
<CourseOptions
difficulty={difficulty}
setDifficulty={setDifficulty}
/>
)}
{selectedFormat !== 'roadmap' && (
<>
<label
className={cn(
'flex items-center gap-2 border border-gray-200 bg-white p-4',
showFineTuneOptions && 'rounded-t-xl',
!showFineTuneOptions && 'rounded-xl',
)}
htmlFor={fineTuneOptionsId}
>
<input
type="checkbox"
id={fineTuneOptionsId}
checked={showFineTuneOptions}
onChange={(e) => setShowFineTuneOptions(e.target.checked)}
/>
Explain more for a better result
</label>
{showFineTuneOptions && (
<FineTuneCourse
hasFineTuneData={showFineTuneOptions}
about={about}
goal={goal}
customInstructions={customInstructions}
setAbout={setAbout}
setGoal={setGoal}
setCustomInstructions={setCustomInstructions}
className="-mt-4.5 overflow-hidden rounded-b-xl border border-gray-200 bg-white [&_div:first-child_label]:border-t-0"
/>
)}
</>
)}
<button
type="submit"
className="flex w-full items-center justify-center gap-2 rounded-xl bg-black p-4 text-white focus:outline-none"
>
<SparklesIcon className="size-4" />
Generate
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { useId, useState } from 'react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../Select';
type CourseOptionsProps = {
difficulty: string;
setDifficulty: (difficulty: string) => void;
};
export function CourseOptions(props: CourseOptionsProps) {
const { difficulty, setDifficulty } = props;
const difficultySelectId = useId();
const difficultyOptions = [
{
label: 'Beginner',
value: 'beginner',
description: 'Covers fundamental concepts',
},
{
label: 'Intermediate',
value: 'intermediate',
description: 'Explore advanced topics',
},
{
label: 'Advanced',
value: 'advanced',
description: 'Deep dives into complex concepts',
},
];
const selectedDifficulty = difficultyOptions.find(
(option) => option.value === difficulty,
);
return (
<div className="flex flex-col gap-2">
<label
htmlFor={difficultySelectId}
className="inline-block text-gray-500"
>
Choose difficulty level
</label>
<Select value={difficulty} onValueChange={setDifficulty}>
<SelectTrigger
id={difficultySelectId}
className="h-auto rounded-xl bg-white p-4 text-base"
>
{selectedDifficulty && (
<div className="flex flex-col gap-1">
<span>{selectedDifficulty.label}</span>
</div>
)}
{!selectedDifficulty && (
<SelectValue placeholder="Select a difficulty" />
)}
</SelectTrigger>
<SelectContent className="rounded-xl bg-white">
{difficultyOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex flex-col gap-1">
<span>{option.label}</span>
<span className="text-xs text-gray-500">
{option.description}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { type LucideIcon } from 'lucide-react';
import { cn } from '../../lib/classname';
type FormatItemProps = {
label: string;
onClick: () => void;
icon: LucideIcon;
isSelected: boolean;
};
export function FormatItem(props: FormatItemProps) {
const { label, onClick, icon: Icon, isSelected } = props;
return (
<button
type="button"
className={cn(
'flex w-full flex-col items-center justify-center gap-2.5 rounded-xl border border-gray-200 p-2 py-8',
isSelected
? 'border-gray-400 font-medium bg-white'
: 'bg-white text-gray-400 hover:bg-white hover:border-gray-300',
)}
onClick={onClick}
>
<Icon className="size-6" />
<span>{label}</span>
</button>
);
}

View File

@@ -0,0 +1,72 @@
import { useId } from 'react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../Select';
type GuideOptionsProps = {
depth: string;
setDepth: (depth: string) => void;
};
export function GuideOptions(props: GuideOptionsProps) {
const { depth, setDepth } = props;
const depthSelectId = useId();
const depthOptions = [
{
label: 'Essentials',
value: 'essentials',
description: 'Just the core concepts',
},
{
label: 'Detailed',
value: 'detailed',
description: 'In-depth explanation',
},
{
label: 'Complete',
value: 'complete',
description: 'Cover the topic fully',
},
];
const selectedDepth = depthOptions.find((option) => option.value === depth);
return (
<div className="flex flex-col gap-2">
<label htmlFor={depthSelectId} className="inline-block text-gray-500">
Choose depth of content
</label>
<Select value={depth} onValueChange={setDepth}>
<SelectTrigger
id={depthSelectId}
className="h-auto rounded-xl bg-white p-4 text-base"
>
{selectedDepth && (
<div className="flex flex-col gap-1">
<span>{selectedDepth.label}</span>
</div>
)}
{!selectedDepth && <SelectValue placeholder="Select a depth" />}
</SelectTrigger>
<SelectContent className="rounded-xl bg-white">
{depthOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex flex-col gap-1">
<span>{option.label}</span>
<span className="text-xs text-gray-500">
{option.description}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@@ -47,7 +47,7 @@ type ChatHeaderButtonProps = {
target?: string;
};
function ChatHeaderButton(props: ChatHeaderButtonProps) {
export function ChatHeaderButton(props: ChatHeaderButtonProps) {
const { onClick, href, icon, children, className, target } = props;
const classNames = cn(

View File

@@ -4,6 +4,7 @@ import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import { FineTuneCourse } from './FineTuneCourse';
import { DifficultyDropdown } from '../AITutor/DifficultyDropdown';
import { NatureDropdown, type NatureType } from '../AITutor/NatureDropdown';
import {
clearFineTuneData,
getCourseFineTuneData,
@@ -26,6 +27,7 @@ type AICourseProps = {};
export function AICourse(props: AICourseProps) {
const [keyword, setKeyword] = useState('');
const [difficulty, setDifficulty] = useState<DifficultyLevel>('beginner');
const [nature, setNature] = useState<NatureType>('course');
const [hasFineTuneData, setHasFineTuneData] = useState(false);
const [about, setAbout] = useState('');
@@ -81,7 +83,11 @@ export function AICourse(props: AICourseProps) {
});
}
window.location.href = `/ai/search?term=${encodeURIComponent(keyword)}&difficulty=${difficulty}&id=${sessionId}`;
if (nature === 'course') {
window.location.href = `/ai/course?term=${encodeURIComponent(keyword)}&difficulty=${difficulty}&id=${sessionId}&nature=${nature}`;
} else {
window.location.href = `/ai/document?term=${encodeURIComponent(keyword)}&difficulty=${difficulty}&id=${sessionId}&nature=${nature}`;
}
}
return (
@@ -131,6 +137,7 @@ export function AICourse(props: AICourseProps) {
<div className="flex flex-col items-start justify-between gap-2 px-4 pb-4 md:flex-row md:items-center">
<div className="flex flex-row items-center gap-2">
<div className="flex flex-row gap-2">
<NatureDropdown value={nature} onChange={setNature} />
<DifficultyDropdown
value={difficulty}
onChange={setDifficulty}
@@ -148,7 +155,6 @@ export function AICourse(props: AICourseProps) {
id="fine-tune-checkbox"
/>
Explain more
<span className="hidden md:inline"> for a better course</span>
</label>
</div>
@@ -169,7 +175,6 @@ export function AICourse(props: AICourseProps) {
<FineTuneCourse
hasFineTuneData={hasFineTuneData}
setHasFineTuneData={setHasFineTuneData}
about={about}
goal={goal}
customInstructions={customInstructions}

View File

@@ -2,17 +2,24 @@ import type { AICourseWithLessonCount } from '../../queries/ai-course';
import type { DifficultyLevel } from './AICourse';
import { BookOpen } from 'lucide-react';
import { AICourseActions } from './AICourseActions';
import { getRelativeTimeString } from '../../lib/date';
import { cn } from '../../lib/classname';
type AICourseCardProps = {
course: AICourseWithLessonCount;
showActions?: boolean;
showProgress?: boolean;
variant?: 'row' | 'column';
};
export function AICourseCard(props: AICourseCardProps) {
const { course, showActions = true, showProgress = true } = props;
const {
course,
showActions = true,
showProgress = true,
variant = 'row',
} = props;
// Map difficulty to color
const difficultyColor =
{
beginner: 'text-green-700',
@@ -20,49 +27,65 @@ export function AICourseCard(props: AICourseCardProps) {
advanced: 'text-purple-700',
}[course.difficulty as DifficultyLevel] || 'text-gray-700';
// Calculate progress percentage
const modulesCount = course.modules?.length || 0;
const totalTopics = course.lessonCount || 0;
const completedTopics = course.done?.length || 0;
const progressPercentage =
totalTopics > 0 ? Math.round((completedTopics / totalTopics) * 100) : 0;
const updatedAgo = getRelativeTimeString(course?.updatedAt);
return (
<div className="relative flex flex-grow flex-col">
<div className="relative flex flex-grow">
<a
href={`/ai/${course.slug}`}
className="hover:border-gray-3 00 group relative flex h-full min-h-[140px] w-full flex-col overflow-hidden rounded-lg border border-gray-200 bg-white p-4 text-left transition-all hover:bg-gray-50"
className={cn(
'group relative flex h-full w-full gap-3 overflow-hidden rounded-lg border border-gray-200 bg-white p-4 text-left transition-all hover:border-gray-300 hover:bg-gray-50 sm:gap-4',
variant === 'column' && 'flex-col',
variant === 'row' && 'flex-row sm:flex-row sm:items-center',
)}
>
<div className="flex items-center justify-between">
<span
className={`rounded-full text-xs font-medium capitalize opacity-80 ${difficultyColor}`}
>
{course.difficulty}
</span>
</div>
<h3 className="my-2 text-base font-semibold text-gray-900">
{course.title}
</h3>
<div className="mt-auto flex items-center justify-between pt-2">
<div className="flex items-center text-xs text-gray-600">
<BookOpen className="mr-1 h-3.5 w-3.5" />
<span>{totalTopics} lessons</span>
{/* 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>
{showProgress && totalTopics > 0 && (
<h3 className="line-clamp-2 text-base font-semibold text-balance text-gray-900">
{course.title
?.replace(": A Beginner's Guide", '')
?.replace(' for beginners', '')
?.replace(': A Comprehensive Guide', '')}
</h3>
</div>
{/* Course stats section */}
<div className="mt-7 flex items-center gap-4 sm:gap-4">
<div className="hidden items-center text-xs text-gray-600 sm:flex">
<BookOpen className="mr-1 h-3.5 w-3.5" />
<span>{modulesCount} modules</span>
</div>
<div className="flex items-center gap-2 text-xs text-gray-600">
<div className="flex items-center">
<div className="mr-2 h-1.5 w-16 overflow-hidden rounded-full bg-gray-200">
<div
className="h-full rounded-full bg-blue-600"
style={{ width: `${progressPercentage}%` }}
/>
</div>
<span className="text-xs font-medium text-gray-700">
{progressPercentage}%
</span>
<BookOpen className="mr-1 h-3.5 w-3.5" />
<span>{totalTopics} lessons</span>
</div>
)}
{showProgress && totalTopics > 0 && (
<>
<span className="hidden text-gray-400 sm:inline"></span>
<div className="flex items-center">
<span className="flex items-center text-xs font-medium text-gray-700">
{progressPercentage}% complete
</span>
</div>
</>
)}
</div>
</div>
</a>

View File

@@ -5,10 +5,12 @@ import { useDebounceValue } from '../../hooks/use-debounce';
type AICourseSearchProps = {
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
};
export function AICourseSearch(props: AICourseSearchProps) {
const { value: defaultValue, onChange } = props;
const { value: defaultValue, onChange, placeholder, disabled } = props;
const [searchTerm, setSearchTerm] = useState(defaultValue);
const debouncedSearchTerm = useDebounceValue(searchTerm, 500);
@@ -30,16 +32,17 @@ export function AICourseSearch(props: AICourseSearchProps) {
}, [debouncedSearchTerm]);
return (
<div className="relative w-64 max-sm:hidden">
<div className="relative mb-4">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<SearchIcon className="h-4 w-4 text-gray-400" />
</div>
<input
type="text"
className="block w-full rounded-md border border-gray-200 bg-white py-1.5 pl-10 pr-3 leading-5 placeholder-gray-500 focus:border-gray-300 focus:outline-hidden focus:ring-blue-500 disabled:opacity-70 sm:text-sm"
placeholder="Search courses..."
className="block w-full rounded-lg border border-gray-200 bg-white py-3 pr-3 pl-10 leading-5 placeholder-gray-500 focus:border-gray-300 focus:ring-blue-500 focus:outline-hidden disabled:opacity-70 sm:text-sm"
placeholder={placeholder || 'Search courses...'}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
disabled={disabled}
/>
</div>
);

View File

@@ -1,3 +1,6 @@
import { useId } from 'react';
import { cn } from '../../lib/classname';
type QuestionProps = {
label: string;
placeholder: string;
@@ -8,13 +11,18 @@ type QuestionProps = {
function Question(props: QuestionProps) {
const { label, placeholder, value, onChange, autoFocus = false } = props;
const questionId = useId();
return (
<div className="flex flex-col">
<label className="border-y bg-gray-100 px-4 py-2.5 text-sm font-medium text-gray-700">
<label
htmlFor={questionId}
className="border-y bg-gray-50 px-4 py-2.5 text-sm font-medium text-gray-700"
>
{label}
</label>
<textarea
id={questionId}
placeholder={placeholder}
className="min-h-[80px] w-full resize-none px-4 py-3 text-gray-700 placeholder:text-gray-400 focus:outline-hidden"
value={value}
@@ -31,10 +39,10 @@ type FineTuneCourseProps = {
goal: string;
customInstructions: string;
setHasFineTuneData: (hasMetadata: boolean) => void;
setAbout: (about: string) => void;
setGoal: (goal: string) => void;
setCustomInstructions: (customInstructions: string) => void;
className?: string;
};
export function FineTuneCourse(props: FineTuneCourseProps) {
@@ -46,7 +54,7 @@ export function FineTuneCourse(props: FineTuneCourseProps) {
setAbout,
setGoal,
setCustomInstructions,
setHasFineTuneData,
className,
} = props;
if (!hasFineTuneData) {
@@ -54,7 +62,7 @@ export function FineTuneCourse(props: FineTuneCourseProps) {
}
return (
<div className="mt-0 flex flex-col">
<div className={cn('mt-0 flex flex-col', className)}>
<Question
label="Tell us about yourself"
placeholder="e.g. I am a frontend developer and have good knowledge of HTML, CSS, and JavaScript."

View File

@@ -1,21 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { BookOpen } from 'lucide-react';
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 {
listUserAiCoursesOptions,
type ListUserAiCoursesQuery,
} from '../../queries/ai-course';
import { queryClient } from '../../stores/query-client';
import { AITutorHeader } from '../AITutor/AITutorHeader';
import { AITutorTallMessage } from '../AITutor/AITutorTallMessage';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { Pagination } from '../Pagination/Pagination';
import { AICourseCard } from './AICourseCard';
import { AICourseSearch } from './AICourseSearch';
import { AILoadingState } from '../AITutor/AILoadingState';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
export function UserCoursesList() {
const [isInitialLoading, setIsInitialLoading] = useState(true);
@@ -60,28 +58,8 @@ export function UserCoursesList() {
}
}, [pageState]);
if (isUserAiCoursesLoading || isInitialLoading) {
return (
<AILoadingState
title="Loading your courses"
subtitle="This may take a moment..."
/>
);
}
if (!isLoggedIn()) {
return (
<AITutorTallMessage
title="Sign up or login"
subtitle="Takes 2s to sign up and generate your first course."
icon={BookOpen}
buttonText="Sign up or Login"
onButtonClick={() => {
showLoginPopup();
}}
/>
);
}
const isUserAuthenticated = isLoggedIn();
const isAnyLoading = isUserAiCoursesLoading || isInitialLoading;
return (
<>
@@ -89,60 +67,78 @@ export function UserCoursesList() {
<UpgradeAccountModal onClose={() => setShowUpgradePopup(false)} />
)}
<AITutorHeader
title="Your Courses"
onUpgradeClick={() => setShowUpgradePopup(true)}
>
<AICourseSearch
value={pageState?.query || ''}
onChange={(value) => {
setPageState({
...pageState,
query: value,
currPage: '1',
});
}}
/>
</AITutorHeader>
<AICourseSearch
value={pageState?.query || ''}
onChange={(value) => {
setPageState({
...pageState,
query: value,
currPage: '1',
});
}}
disabled={isAnyLoading}
/>
{(isUserAiCoursesLoading || isInitialLoading) && (
<AILoadingState
title="Loading your courses"
subtitle="This may take a moment..."
/>
{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 courses...
</p>
)}
{!isUserAiCoursesLoading && !isInitialLoading && courses.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">
{courses.map((course) => (
<AICourseCard key={course._id} course={course} />
))}
</div>
{!isAnyLoading && (
<>
<p className="mb-4 text-sm text-gray-500">
{isUserAuthenticated
? `You have generated ${userAiCourses?.totalCount} courses so far.`
: 'Sign up or login to generate your first course. Takes 2s to do so.'}
</p>
<Pagination
totalCount={userAiCourses?.totalCount || 0}
totalPages={userAiCourses?.totalPages || 0}
currPage={Number(userAiCourses?.currPage || 1)}
perPage={Number(userAiCourses?.perPage || 10)}
onPageChange={(page) => {
setPageState({ ...pageState, currPage: String(page) });
}}
className="rounded-lg border border-gray-200 bg-white p-4"
/>
</div>
)}
{isUserAuthenticated && !isAnyLoading && courses.length > 0 && (
<div className="flex flex-col gap-2">
{courses.map((course) => (
<AICourseCard key={course._id} course={course} />
))}
{!isUserAiCoursesLoading && !isInitialLoading && courses.length === 0 && (
<AITutorTallMessage
title="No courses found"
subtitle="You haven't generated any courses yet."
icon={BookOpen}
buttonText="Create your first course"
onButtonClick={() => {
window.location.href = '/ai';
}}
/>
<Pagination
totalCount={userAiCourses?.totalCount || 0}
totalPages={userAiCourses?.totalPages || 0}
currPage={Number(userAiCourses?.currPage || 1)}
perPage={Number(userAiCourses?.perPage || 10)}
onPageChange={(page) => {
setPageState({ ...pageState, currPage: String(page) });
}}
className="rounded-lg border border-gray-200 bg-white p-4"
/>
</div>
)}
{!isAnyLoading && courses.length === 0 && (
<AITutorTallMessage
title={
isUserAuthenticated ? 'No courses found' : 'Sign up or login'
}
subtitle={
isUserAuthenticated
? "You haven't generated any courses yet."
: 'Takes 2s to sign up and generate your first course.'
}
icon={BookOpen}
buttonText={
isUserAuthenticated
? 'Create your first course'
: 'Sign up or login'
}
onButtonClick={() => {
if (isUserAuthenticated) {
window.location.href = '/ai';
} else {
showLoginPopup();
}
}}
/>
)}
</>
)}
</>
);

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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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} />;
}

View File

@@ -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 || ''} />;
}

View File

@@ -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();
}
}}
/>
)}
</>
)}
</>
);
}

View File

@@ -0,0 +1,53 @@
import { BookOpen, FileTextIcon, type LucideIcon } from 'lucide-react';
import { cn } from '../../lib/classname';
type LibraryTabsProps = {
activeTab: 'guides' | 'courses';
};
export function LibraryTabs(props: LibraryTabsProps) {
const { activeTab } = props;
return (
<div className="mb-6 flex gap-2 border-b border-gray-300">
<LibraryTabButton
isActive={activeTab === 'courses'}
icon={BookOpen}
label="Courses"
href="/ai/courses"
/>
<LibraryTabButton
isActive={activeTab === 'guides'}
icon={FileTextIcon}
label="Guides"
href="/ai/guides"
/>
</div>
);
}
type LibraryTabButtonProps = {
isActive: boolean;
icon: LucideIcon;
label: string;
href: string;
};
function LibraryTabButton(props: LibraryTabButtonProps) {
const { isActive, icon: Icon, label, href } = props;
return (
<a
href={href}
className={cn(
'flex items-center gap-1 rounded-t-md px-4 py-2 text-sm font-medium',
isActive
? 'bg-gray-300'
: 'bg-gray-100 transition-colors hover:bg-gray-200',
)}
>
<Icon className="h-4 w-4" />
{label}
</a>
);
}

View File

@@ -0,0 +1,16 @@
import { Loader2Icon } from 'lucide-react';
type LoadingChipProps = {
message?: string;
};
export function LoadingChip(props: LoadingChipProps) {
const { message = 'Please wait...' } = props;
return (
<div className="flex items-center gap-2 rounded-lg border border-gray-300 bg-white py-2 pr-4 pl-3 text-sm">
<Loader2Icon className="size-4 animate-spin text-gray-400" />
<span>{message}</span>
</div>
);
}

View File

@@ -51,7 +51,7 @@ export function Pagination(props: PaginationProps) {
onPageChange(currPage - 1);
}}
disabled={currPage === 1 || isDisabled}
className="rounded-md border px-2 py-1 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
className="rounded-md bg-white border px-2 py-1 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
>
&larr;
</button>
@@ -91,7 +91,7 @@ export function Pagination(props: PaginationProps) {
)}
<button
disabled={currPage === totalPages || isDisabled}
className="rounded-md border px-2 py-1 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
className="rounded-md bg-white border px-2 py-1 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => {
onPageChange(currPage + 1);
}}

View File

@@ -1,4 +1,4 @@
import type { RoadmapAIChatHistoryType } from './RoadmapAIChat';
import type { RoadmapAIChatHistoryType } from '../../hooks/use-roadmap-ai-chat';
import { cn } from '../../lib/classname';
import { BotIcon, User2Icon } from 'lucide-react';

185
src/components/Select.tsx Normal file
View File

@@ -0,0 +1,185 @@
import * as React from 'react';
import { Select as SelectPrimitive } from 'radix-ui';
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
import { cn } from '../lib/classname';
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default';
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex h-9 w-full items-center justify-between gap-2 rounded-lg border bg-transparent px-3 py-2 text-sm whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = 'popper',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
'relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border bg-white shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn('px-2 py-1.5 text-xs', className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-gray-100 focus:text-black data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn(
'pointer-events-none -mx-1 my-1 h-px bg-gray-200',
className,
)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
'flex cursor-default items-center justify-center py-1',
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@@ -24,7 +24,7 @@ export function CreateCourseModal(props: CreateCourseModalProps) {
const formData = new FormData(e.target as HTMLFormElement);
const subject = formData.get('subject');
window.location.href = `/ai/search?term=${subject}&difficulty=beginner&src=topic`;
window.location.href = `/ai/course?term=${subject}&difficulty=beginner&src=topic`;
onClose();
}}
>

View File

@@ -278,7 +278,7 @@ export function TopicDetailAI(props: TopicDetailAIProps) {
return;
}
}}
href={`/ai/search?term=${subject}&difficulty=beginner&src=topic`}
href={`/ai/course?term=${subject}&difficulty=beginner&src=topic`}
className="flex items-center gap-1 gap-2 rounded-md border border-gray-300 bg-gray-100 px-2 py-1 hover:bg-gray-200 hover:text-black"
>
{subject}
@@ -289,7 +289,7 @@ export function TopicDetailAI(props: TopicDetailAIProps) {
{roadmapTreeMapping?.subjects?.length === 0 && (
<a
target="_blank"
href={`/ai/search?term=${roadmapTreeMapping?.text}&difficulty=beginner&src=topic`}
href={`/ai/course?term=${roadmapTreeMapping?.text}&difficulty=beginner&src=topic`}
className="flex items-center gap-1 rounded-md border border-gray-300 bg-gray-100 px-2 py-1 hover:bg-gray-200 hover:text-black"
>
{nodeTextParts.slice(1).map((text, index) => {

View File

@@ -121,7 +121,7 @@ export async function generateCourse(options: GenerateCourseOptions) {
const CREATOR_ID_REGEX = new RegExp('@CREATORID:(\\w+)@');
await readStream(reader, {
onStream: (result) => {
onStream: async (result) => {
if (result.includes('@COURSEID') || result.includes('@COURSESLUG')) {
const courseIdMatch = result.match(COURSE_ID_REGEX);
const courseSlugMatch = result.match(COURSE_SLUG_REGEX);
@@ -166,7 +166,7 @@ export async function generateCourse(options: GenerateCourseOptions) {
console.error('Error parsing streamed course content:', e);
}
},
onStreamEnd: (result) => {
onStreamEnd: async (result) => {
result = result
.replace(COURSE_ID_REGEX, '')
.replace(COURSE_SLUG_REGEX, '')

View File

@@ -0,0 +1,142 @@
import { queryClient } from '../stores/query-client';
import { getAiCourseLimitOptions } from '../queries/ai-course';
import { readChatStream } from '../lib/chat';
import { markdownToHtmlWithHighlighting } from '../lib/markdown';
type GuideDetails = {
guideId: string;
guideSlug: string;
creatorId: string;
title: string;
};
type GenerateGuideOptions = {
term: string;
depth: string;
slug?: string;
isForce?: boolean;
prompt?: string;
instructions?: string;
goal?: string;
about?: string;
onGuideSlugChange?: (guideSlug: string) => void;
onGuideChange?: (guide: string) => void;
onLoadingChange?: (isLoading: boolean) => void;
onError?: (error: string) => void;
src?: string;
onHtmlChange?: (html: string) => void;
onStreamingChange?: (isStreaming: boolean) => void;
onDetailsChange?: (details: GuideDetails) => void;
onFinish?: () => void;
};
export async function generateGuide(options: GenerateGuideOptions) {
const {
term,
slug,
depth,
onGuideChange,
onLoadingChange,
onError,
isForce = false,
prompt,
instructions,
goal,
about,
src = 'search',
onHtmlChange,
onStreamingChange,
onDetailsChange,
onFinish,
} = options;
onLoadingChange?.(true);
onGuideChange?.('');
try {
let response = null;
if (slug && isForce) {
response = await fetch(
`${import.meta.env.PUBLIC_API_URL}/v1-regenerate-ai-guide/${slug}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
prompt,
}),
},
);
} else {
response = await fetch(
`${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-guide`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
keyword: term,
depth,
isForce,
customPrompt: prompt,
instructions,
goal,
about,
src,
}),
credentials: 'include',
},
);
}
if (!response.ok) {
const data = await response.json();
console.error(
'Error generating course:',
data?.message || 'Something went wrong',
);
onLoadingChange?.(false);
onError?.(data?.message || 'Something went wrong');
return;
}
const stream = response.body;
if (!stream) {
console.error('Failed to get stream from response');
onError?.('Something went wrong');
onLoadingChange?.(false);
return;
}
onLoadingChange?.(false);
onStreamingChange?.(true);
await readChatStream(stream, {
onMessage: async (message) => {
onGuideChange?.(message);
onHtmlChange?.(await markdownToHtmlWithHighlighting(message));
},
onMessageEnd: async (message) => {
onGuideChange?.(message);
onHtmlChange?.(await markdownToHtmlWithHighlighting(message));
queryClient.invalidateQueries(getAiCourseLimitOptions());
onStreamingChange?.(false);
},
onDetails: async (details) => {
if (!details?.guideId || !details?.guideSlug) {
throw new Error('Invalid details');
}
onDetailsChange?.(details);
},
});
onFinish?.();
} catch (error: any) {
onError?.(error?.message || 'Something went wrong');
console.error('Error in course generation:', error);
onLoadingChange?.(false);
}
}

19
src/helper/shuffle.ts Normal file
View File

@@ -0,0 +1,19 @@
export function shuffle<T = any>(array: T[]): T[] {
let currentIndex = array.length;
const result = [...array];
// While there remain elements to shuffle...
while (currentIndex != 0) {
// Pick a remaining element...
let randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--;
// And swap it with the current element.
[result[currentIndex], result[randomIndex]] = [
result[randomIndex],
result[currentIndex],
];
}
return result;
}

137
src/hooks/use-chat.ts Normal file
View File

@@ -0,0 +1,137 @@
import { useCallback, useRef, useState } from 'react';
import { removeAuthToken } from '../lib/jwt';
import { readChatStream } from '../lib/chat';
import { markdownToHtmlWithHighlighting } from '../lib/markdown';
import { flushSync } from 'react-dom';
export type ChatMessage = {
role: 'user' | 'assistant';
content: string;
html?: string;
};
type UseChatOptions = {
endpoint: string;
initialMessages?: ChatMessage[];
onError?: (error: Error) => void;
data?: Record<string, any>;
onFinish?: () => void;
};
export function useChat(options: UseChatOptions) {
const { endpoint, initialMessages, onError, data = {}, onFinish } = options;
const abortControllerRef = useRef<AbortController | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>(
initialMessages || [],
);
// we use it to show optimistic message
// and then replace it with the actual message
const [streamedMessageHtml, setStreamedMessageHtml] = useState<string | null>(
null,
);
const [status, setStatus] = useState<
'idle' | 'streaming' | 'loading' | 'ready' | 'error'
>('idle');
const sendMessages = useCallback(
async (messages: ChatMessage[]) => {
try {
setStatus('loading');
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ messages, ...data }),
signal: abortControllerRef.current?.signal,
credentials: 'include',
});
if (!response.ok) {
const data = await response.json();
setStatus('error');
setMessages([...messages].slice(0, messages.length - 1));
if (data.status === 401) {
removeAuthToken();
window.location.reload();
}
throw new Error(data?.message || 'Something went wrong');
}
const stream = response.body;
if (!stream) {
setStatus('error');
setMessages([...messages].slice(0, messages.length - 1));
throw new Error('Something went wrong');
}
await readChatStream(stream, {
onMessage: async (content) => {
const html = await markdownToHtmlWithHighlighting(content);
flushSync(() => {
setStatus('streaming');
setStreamedMessageHtml(html);
});
},
onMessageEnd: async (content) => {
const html = await markdownToHtmlWithHighlighting(content);
flushSync(() => {
setStreamedMessageHtml(null);
setStatus('ready');
setMessages((prevMessages) => {
return [
...prevMessages,
{
role: 'assistant',
content,
html,
},
];
});
});
},
});
setStatus('idle');
abortControllerRef.current = null;
onFinish?.();
} catch (error) {
if (abortControllerRef.current?.signal.aborted) {
// we don't want to show error if the user stops the chat
// so we just return
return;
}
onError?.(error as Error);
setStatus('error');
}
},
[endpoint, onError],
);
const stop = useCallback(() => {
if (!abortControllerRef.current) {
return;
}
abortControllerRef.current.abort();
abortControllerRef.current = null;
}, []);
return {
messages,
setMessages,
sendMessages,
status,
streamedMessageHtml,
stop,
};
}

View File

@@ -237,8 +237,7 @@ export function useRoadmapAIChat(options: Options) {
});
},
onDetails: (details) => {
const detailsJson = JSON.parse(details);
const chatHistoryId = detailsJson?.chatHistoryId;
const chatHistoryId = details?.chatHistoryId;
if (!chatHistoryId) {
return;
}

View File

@@ -27,7 +27,7 @@ export async function readChatStream(
}: {
onMessage?: (message: string) => Promise<void>;
onMessageEnd?: (message: string) => Promise<void>;
onDetails?: (details: string) => Promise<void> | void;
onDetails?: (details: any) => Promise<void> | void;
},
) {
const reader = stream.getReader();
@@ -73,7 +73,7 @@ export async function readChatStream(
case CHAT_RESPONSE_PREFIX.message:
return { type: 'message', content: JSON.parse(content) };
case CHAT_RESPONSE_PREFIX.details:
return { type: 'details', content };
return { type: 'details', content: JSON.parse(content) };
default:
throw new Error('Invalid prefix: ' + prefix);
}

View File

@@ -12,6 +12,8 @@ const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png';
description='Learn anything with AI Tutor. Pick a topic, choose a difficulty level and the AI will guide you through the learning process.'
>
<AITutorLayout activeTab='community' client:load>
<AIExploreCourseListing client:load />
<div class='mx-auto flex w-full max-w-6xl flex-grow flex-col p-2'>
<AIExploreCourseListing client:load />
</div>
</AITutorLayout>
</SkeletonLayout>

View File

@@ -9,7 +9,7 @@ import { CheckSubscriptionVerification } from '../../components/Billing/CheckSub
briefTitle='AI Tutor'
description='AI Tutor'
keywords={['ai', 'tutor', 'education', 'learning']}
canonicalUrl='/ai/search'
canonicalUrl='/ai/course'
noIndex={true}
>
<GenerateAICourse client:load />

View File

@@ -1,7 +1,7 @@
---
import { UserCoursesList } from '../../components/GenerateCourse/UserCoursesList';
import SkeletonLayout from '../../layouts/SkeletonLayout.astro';
import { AITutorLayout } from '../../components/AITutor/AITutorLayout';
import { AILibraryLayout } from '../../components/AIGuide/AILibraryLayout';
const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png';
---
@@ -11,7 +11,7 @@ const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png';
ogImageUrl={ogImage}
description='Learn anything with AI Tutor. Pick a topic, choose a difficulty level and the AI will guide you through the learning process.'
>
<AITutorLayout activeTab='courses' client:load>
<AILibraryLayout activeTab='courses' client:load>
<UserCoursesList client:load />
</AITutorLayout>
</AILibraryLayout>
</SkeletonLayout>

View File

@@ -0,0 +1,22 @@
---
import { AIGuide } from '../../../components/GenerateGuide/AIGuide';
import SkeletonLayout from '../../../layouts/SkeletonLayout.astro';
export const prerender = false;
interface Params extends Record<string, string | undefined> {
slug: string;
}
const { slug } = Astro.params as Params;
---
<SkeletonLayout
title='AI Tutor'
briefTitle='AI Tutor'
description='AI Tutor'
keywords={['ai', 'tutor', 'education', 'learning']}
canonicalUrl={`/ai/guide/${slug}`}
>
<AIGuide client:load guideSlug={slug} />
</SkeletonLayout>

View File

@@ -0,0 +1,18 @@
---
import { AITutorLayout } from '../../../components/AITutor/AITutorLayout';
import { CheckSubscriptionVerification } from '../../../components/Billing/CheckSubscriptionVerification';
import { AIGuide } from '../../../components/GenerateGuide/AIGuide';
import { GenerateAIGuide } from '../../../components/GenerateGuide/GenerateAIGuide';
import SkeletonLayout from '../../../layouts/SkeletonLayout.astro';
---
<SkeletonLayout
title='AI Tutor'
briefTitle='AI Tutor'
description='AI Tutor'
keywords={['ai', 'tutor', 'education', 'learning']}
canonicalUrl='/ai/guide'
noIndex={true}
>
<AIGuide client:load />
</SkeletonLayout>

17
src/pages/ai/guides.astro Normal file
View File

@@ -0,0 +1,17 @@
---
import { UserGuidesList } from '../../components/GenerateGuide/UserGuidesList';
import SkeletonLayout from '../../layouts/SkeletonLayout.astro';
import { AILibraryLayout } from '../../components/AIGuide/AILibraryLayout';
const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png';
---
<SkeletonLayout
title='Roadmap AI'
noIndex={true}
ogImageUrl={ogImage}
description='Learn anything with AI Tutor. Pick a topic, choose a difficulty level and the AI will guide you through the learning process.'
>
<AILibraryLayout activeTab='guides' client:load>
<UserGuidesList client:load />
</AILibraryLayout>
</SkeletonLayout>

View File

@@ -1,9 +1,8 @@
---
import { ChevronLeft, PlusCircle, BookOpen, Compass } from 'lucide-react';
import { CheckSubscriptionVerification } from '../../components/Billing/CheckSubscriptionVerification';
import { AICourse } from '../../components/GenerateCourse/AICourse';
import SkeletonLayout from '../../layouts/SkeletonLayout.astro';
import { ContentGenerator } from '../../components/ContentGenerator/ContentGenerator';
import { AITutorLayout } from '../../components/AITutor/AITutorLayout';
import SkeletonLayout from '../../layouts/SkeletonLayout.astro';
const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png';
---
@@ -14,7 +13,7 @@ const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png';
description='Learn anything with AI Tutor. Pick a topic, choose a difficulty level and the AI will guide you through the learning process.'
>
<AITutorLayout activeTab='new' client:load>
<AICourse client:load />
<ContentGenerator client:load />
<CheckSubscriptionVerification client:load />
</AITutorLayout>
</SkeletonLayout>

View File

@@ -12,6 +12,8 @@ const ogImage = 'https://roadmap.sh/og-images/ai-tutor.png';
description='Learn anything with AI Tutor. Pick a topic, choose a difficulty level and the AI will guide you through the learning process.'
>
<AITutorLayout activeTab='staff-picks' client:load>
<AIFeaturedCoursesListing client:load />
<div class='mx-auto flex w-full max-w-6xl flex-grow flex-col p-2'>
<AIFeaturedCoursesListing client:load />
</div>
</AITutorLayout>
</SkeletonLayout>

116
src/queries/ai-guide.ts Normal file
View File

@@ -0,0 +1,116 @@
import { httpGet } from '../lib/query-http';
import { isLoggedIn } from '../lib/jwt';
import { queryOptions } from '@tanstack/react-query';
import {
markdownToHtml,
markdownToHtmlWithHighlighting,
} from '../lib/markdown';
export interface AIGuideDocument {
_id: string;
userId: string;
title: string;
slug?: string;
keyword: string;
depth: string;
content: string;
tokens: {
prompt: number;
completion: number;
total: number;
};
relatedTopics: string[];
deepDiveTopics: string[];
questions: string[];
viewCount: number;
lastVisitedAt: Date;
createdAt: Date;
updatedAt: Date;
}
type GetAIGuideResponse = AIGuideDocument;
export function getAiGuideOptions(guideSlug?: string) {
return queryOptions({
queryKey: ['ai-guide', guideSlug],
queryFn: async () => {
const res = await httpGet<GetAIGuideResponse>(
`/v1-get-ai-guide/${guideSlug}`,
);
return {
...res,
html: await markdownToHtmlWithHighlighting(res.content),
};
},
enabled: !!guideSlug,
});
}
type AIGuideSuggestionsResponse = {
relatedTopics: string[];
deepDiveTopics: string[];
questions: string[];
};
export function aiGuideSuggestionsOptions(guideSlug?: string) {
return queryOptions({
queryKey: ['ai-guide-suggestions', guideSlug],
queryFn: () => {
return httpGet<AIGuideSuggestionsResponse>(
`/v1-ai-guide-suggestions/${guideSlug}`,
);
},
enabled: !!guideSlug && !!isLoggedIn(),
});
}
export type ListUserAIGuidesQuery = {
perPage?: string;
currPage?: string;
query?: string;
};
export type ListUserAIGuidesResponse = {
data: Omit<
AIGuideDocument,
'tokens' | 'relatedTopics' | 'deepDiveTopics' | 'questions'
>[];
totalCount: number;
totalPages: number;
currPage: number;
perPage: number;
};
export function listUserAIGuidesOptions(
params: ListUserAIGuidesQuery = {
perPage: '21',
currPage: '1',
query: '',
},
) {
return queryOptions({
queryKey: ['ai-guides', params],
queryFn: async () => {
const response = await httpGet<ListUserAIGuidesResponse>(
`/v1-list-user-ai-guides`,
params,
);
return {
...response,
data: response.data.map((guide) => {
const preview = guide.content.slice(0, 500);
return {
...guide,
html: markdownToHtml(preview, false),
};
}),
};
},
enabled: !!isLoggedIn(),
});
}