Refactor ai courses

refactor/ai-courses
Kamran Ahmed 1 month ago
parent 38cd727e48
commit cfbb4f32ab
  1. 1
      src/components/Activity/ProjectProgress.tsx
  2. 2
      src/components/Activity/ResourceProgress.tsx
  3. 55
      src/components/Billing/BillingPage.tsx
  4. 39
      src/components/Billing/BillingWarning.tsx
  5. 2
      src/components/Dashboard/DashboardCustomProgressCard.tsx
  6. 3
      src/components/Dashboard/DashboardProgressCard.tsx
  7. 2
      src/components/GenerateCourse/AICourseCard.tsx
  8. 84
      src/components/GenerateCourse/AICourseContent.tsx
  9. 10
      src/components/GenerateCourse/AICourseFollowUpPopover.tsx
  10. 8
      src/components/GenerateCourse/AICourseLimit.tsx
  11. 4
      src/components/GenerateCourse/AICourseModuleView.tsx
  12. 2
      src/components/GenerateCourse/AILimitsPopup.tsx
  13. 125
      src/components/GenerateCourse/GenerateAICourse.tsx
  14. 26
      src/components/GenerateCourse/GetAICourse.tsx
  15. 75
      src/components/GenerateCourse/RegenerateOutline.tsx
  16. 2
      src/components/GenerateCourse/UserCoursesList.tsx
  17. 0
      src/components/GenerateCourse/re-generate
  18. 3
      src/components/GenerateRoadmap/GenerateRoadmap.tsx
  19. 4
      src/components/GenerateRoadmap/RoadmapTopicDetail.tsx
  20. 5
      src/components/Navigation/Navigation.astro
  21. 2
      src/components/UserPublicProfile/UserProfileRoadmap.tsx
  22. 2
      src/components/UserPublicProfile/UserPublicProgressStats.tsx
  23. 9
      src/components/UserPublicProfile/UserPublicProgresses.tsx
  24. 132
      src/helper/generate-ai-course.ts
  25. 112
      src/helper/read-stream.ts
  26. 114
      src/lib/ai.ts
  27. 13
      src/lib/number.ts
  28. 2
      src/queries/ai-course.ts
  29. 19
      src/queries/billing.ts

@ -1,5 +1,4 @@
import { getUser } from '../../lib/jwt';
import { getPercentage } from '../../helper/number';
import { ProjectProgressActions } from './ProjectProgressActions';
import { cn } from '../../lib/classname';
import type { ProjectStatusDocument } from '../Projects/ListProjectSolutions';

@ -1,7 +1,7 @@
import { getUser } from '../../lib/jwt';
import { getPercentage } from '../../helper/number';
import { ResourceProgressActions } from './ResourceProgressActions';
import { cn } from '../../lib/classname';
import { getPercentage } from '../../lib/number';
type ResourceProgressType = {
resourceType: 'roadmap' | 'best-practice';

@ -16,10 +16,11 @@ import {
Calendar,
RefreshCw,
Loader2,
AlertTriangle,
CreditCard,
ArrowRightLeft,
CircleX,
} from 'lucide-react';
import { BillingWarning } from './BillingWarning';
export type CreateCustomerPortalBody = {};
@ -38,6 +39,10 @@ export function BillingPage() {
queryClient,
);
const isCanceled =
billingDetails?.status === 'canceled' || billingDetails?.cancelAtPeriodEnd;
const isPastDue = billingDetails?.status === 'past_due';
const {
mutate: createCustomerPortal,
isSuccess: isCreatingCustomerPortalSuccess,
@ -80,9 +85,6 @@ export function BillingPage() {
const selectedPlanDetails = USER_SUBSCRIPTION_PLAN_PRICES.find(
(plan) => plan.priceId === billingDetails?.priceId,
);
const shouldHideDeleteButton =
billingDetails?.status === 'canceled' || billingDetails?.cancelAtPeriodEnd;
const priceDetails = selectedPlanDetails;
const formattedNextBillDate = new Date(
@ -115,25 +117,30 @@ export function BillingPage() {
!isLoadingBillingDetails &&
priceDetails && (
<div className="mt-1">
{billingDetails?.status === 'past_due' && (
<div className="mb-6 flex items-center gap-2 rounded-lg border border-red-300 bg-red-50 p-4 text-sm text-red-600">
<AlertTriangle className="h-5 w-5" />
<span>
We were not able to charge your card.{' '}
<button
disabled={
isCreatingCustomerPortal ||
isCreatingCustomerPortalSuccess
}
onClick={() => {
createCustomerPortal({});
}}
className="font-semibold underline underline-offset-4 disabled:cursor-not-allowed disabled:opacity-50"
>
Update payment information.
</button>
</span>
</div>
{isCanceled && (
<BillingWarning
icon={CircleX}
message="Your subscription has been canceled."
buttonText="Reactivate?"
onButtonClick={() => {
createCustomerPortal({});
}}
isLoading={
isCreatingCustomerPortal || isCreatingCustomerPortalSuccess
}
/>
)}
{isPastDue && (
<BillingWarning
message="We were not able to charge your card."
buttonText="Update payment information."
onButtonClick={() => {
createCustomerPortal({});
}}
isLoading={
isCreatingCustomerPortal || isCreatingCustomerPortalSuccess
}
/>
)}
<h2 className="mb-2 text-xl font-semibold text-black">
@ -181,7 +188,7 @@ export function BillingPage() {
</div>
<div className="mt-8 flex gap-3 max-sm:flex-col">
{!shouldHideDeleteButton && (
{!isCanceled && (
<button
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 max-sm:flex-grow"
onClick={() => {

@ -0,0 +1,39 @@
import { AlertTriangle, type LucideIcon } from 'lucide-react';
export type BillingWarningProps = {
icon?: LucideIcon;
message: string;
onButtonClick?: () => void;
buttonText?: string;
isLoading?: boolean;
};
export function BillingWarning(props: BillingWarningProps) {
const {
message,
onButtonClick,
buttonText,
isLoading,
icon: Icon = AlertTriangle,
} = props;
return (
<div className="mb-6 flex items-center gap-2 rounded-lg border border-red-300 bg-red-50 p-4 text-sm text-red-600">
<Icon className="h-5 w-5" />
<span>
{message}
{buttonText && (
<button
disabled={isLoading}
onClick={() => {
onButtonClick?.();
}}
className="font-semibold underline underline-offset-4 disabled:cursor-not-allowed disabled:opacity-50 ml-0.5"
>
{buttonText}
</button>
)}
</span>
</div>
);
}

@ -1,5 +1,5 @@
import { getPercentage } from '../../helper/number';
import { getRelativeTimeString } from '../../lib/date';
import { getPercentage } from '../../lib/number';
import type { UserProgress } from '../TeamProgress/TeamProgressPage';
type DashboardCustomProgressCardProps = {

@ -1,6 +1,5 @@
import { getPercentage } from '../../helper/number';
import { getPercentage } from '../../lib/number';
import type { UserProgress } from '../TeamProgress/TeamProgressPage';
import { ArrowUpRight, ExternalLink } from 'lucide-react';
type DashboardProgressCardProps = {
progress: UserProgress;

@ -27,7 +27,7 @@ export function AICourseCard(props: AICourseCardProps) {
// Calculate progress percentage
const totalTopics = course.lessonCount || 0;
const completedTopics = course.progress?.done?.length || 0;
const completedTopics = course.done?.length || 0;
const progressPercentage =
totalTopics > 0 ? Math.round((completedTopics / totalTopics) * 100) : 0;

@ -20,16 +20,18 @@ import { AICourseModuleList } from './AICourseModuleList';
import { AICourseModuleView } from './AICourseModuleView';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { AILimitsPopup } from './AILimitsPopup';
import { RegenerateOutline } from './RegenerateOutline';
type AICourseContentProps = {
courseSlug?: string;
course: AiCourse;
isLoading: boolean;
error?: string;
onRegenerateOutline: () => void;
};
export function AICourseContent(props: AICourseContentProps) {
const { course, courseSlug, isLoading, error } = props;
const { course, courseSlug, isLoading, error, onRegenerateOutline } = props;
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [showAILimitsPopup, setShowAILimitsPopup] = useState(false);
@ -49,21 +51,23 @@ export function AICourseContent(props: AICourseContentProps) {
>({});
const goToNextModule = () => {
if (activeModuleIndex < course.modules.length - 1) {
const nextModuleIndex = activeModuleIndex + 1;
setActiveModuleIndex(nextModuleIndex);
setActiveLessonIndex(0);
setExpandedModules((prev) => {
const newState: Record<number, boolean> = {};
course.modules.forEach((_, idx) => {
newState[idx] = false;
});
newState[nextModuleIndex] = true;
return newState;
});
if (activeModuleIndex >= course.modules.length) {
return;
}
const nextModuleIndex = activeModuleIndex + 1;
setActiveModuleIndex(nextModuleIndex);
setActiveLessonIndex(0);
setExpandedModules((prev) => {
const newState: Record<number, boolean> = {};
course.modules.forEach((_, idx) => {
newState[idx] = false;
});
newState[nextModuleIndex] = true;
return newState;
});
};
const goToNextLesson = () => {
@ -78,26 +82,29 @@ export function AICourseContent(props: AICourseContentProps) {
const goToPrevLesson = () => {
if (activeLessonIndex > 0) {
setActiveLessonIndex(activeLessonIndex - 1);
} else {
const prevModule = course.modules[activeModuleIndex - 1];
if (prevModule) {
const prevModuleIndex = activeModuleIndex - 1;
setActiveModuleIndex(prevModuleIndex);
setActiveLessonIndex(prevModule.lessons.length - 1);
// Expand the previous module in the sidebar
setExpandedModules((prev) => {
const newState: Record<number, boolean> = {};
// Set all modules to collapsed
course.modules.forEach((_, idx) => {
newState[idx] = false;
});
// Expand only the previous module
newState[prevModuleIndex] = true;
return newState;
});
}
return;
}
const prevModule = course.modules[activeModuleIndex - 1];
if (!prevModule) {
return;
}
const prevModuleIndex = activeModuleIndex - 1;
setActiveModuleIndex(prevModuleIndex);
setActiveLessonIndex(prevModule.lessons.length - 1);
// Expand the previous module in the sidebar
setExpandedModules((prev) => {
const newState: Record<number, boolean> = {};
// Set all modules to collapsed
course.modules.forEach((_, idx) => {
newState[idx] = false;
});
// Expand only the previous module
newState[prevModuleIndex] = true;
return newState;
});
};
const currentModule = course.modules[activeModuleIndex];
@ -109,6 +116,7 @@ export function AICourseContent(props: AICourseContentProps) {
(total, module) => total + module.lessons.length,
0,
);
const totalDoneLessons = aiCourseProgress?.done?.length || 0;
const finishedPercentage = Math.round(
(totalDoneLessons / totalCourseLessons) * 100,
@ -351,7 +359,7 @@ export function AICourseContent(props: AICourseContentProps) {
<div className="mx-auto rounded-xl border border-gray-200 bg-white shadow-sm lg:max-w-3xl">
<div
className={cn(
'mb-1 flex items-start justify-between border-b border-gray-100 p-6 max-lg:hidden',
'relative mb-1 flex items-start justify-between border-b border-gray-100 p-6 max-lg:hidden',
isLoading && 'striped-loader',
)}
>
@ -363,6 +371,12 @@ export function AICourseContent(props: AICourseContentProps) {
{course.title ? course.difficulty : 'Please wait ..'}
</p>
</div>
{!isLoading && (
<RegenerateOutline
onRegenerateOutline={onRegenerateOutline}
/>
)}
</div>
{course.title ? (
<div className="flex flex-col p-6 max-lg:mt-0.5 max-lg:p-4">

@ -2,18 +2,18 @@ import { useQuery } from '@tanstack/react-query';
import { BookOpen, Bot, Code, HelpCircle, LockIcon, Send } from 'lucide-react';
import { useEffect, useMemo, useRef, useState, type FormEvent } from 'react';
import { flushSync } from 'react-dom';
import TextareaAutosize from 'react-textarea-autosize';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { readAICourseLessonStream } from '../../helper/read-stream';
import { isLoggedIn, removeAuthToken } from '../../lib/jwt';
import { useToast } from '../../hooks/use-toast';
import { readStream } from '../../lib/ai';
import { cn } from '../../lib/classname';
import { isLoggedIn, removeAuthToken } from '../../lib/jwt';
import {
markdownToHtml,
markdownToHtmlWithHighlighting,
} from '../../lib/markdown';
import { cn } from '../../lib/classname';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { queryClient } from '../../stores/query-client';
import TextareaAutosize from 'react-textarea-autosize';
export type AllowedAIChatRole = 'user' | 'assistant';
export type AIChatHistoryType = {
@ -142,7 +142,7 @@ export function AICourseFollowUpPopover(props: AICourseFollowUpPopoverProps) {
return;
}
await readAICourseLessonStream(reader, {
await readStream(reader, {
onStream: async (content) => {
flushSync(() => {
setStreamedMessage(content);

@ -1,9 +1,9 @@
import { useQuery } from '@tanstack/react-query';
import { Gift, Info } from 'lucide-react';
import { getPercentage } from '../../lib/number';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { queryClient } from '../../stores/query-client';
import { billingDetailsOptions } from '../../queries/billing';
import { getPercentage } from '../../helper/number';
import { Gift, Info } from 'lucide-react';
import { queryClient } from '../../stores/query-client';
type AICourseLimitProps = {
onUpgrade: () => void;
@ -33,7 +33,7 @@ export function AICourseLimit(props: AICourseLimitProps) {
// has consumed 80% of the limit
const isNearLimit = used >= limit * 0.8;
const isPaidUser = userBillingDetails.status !== 'none';
const isPaidUser = userBillingDetails.status === 'active';
return (
<>

@ -8,7 +8,7 @@ import {
XIcon,
} from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { readAICourseLessonStream } from '../../helper/read-stream';
import { readStream } from '../../lib/ai';
import { cn } from '../../lib/classname';
import { isLoggedIn, removeAuthToken } from '../../lib/jwt';
import {
@ -136,7 +136,7 @@ export function AICourseModuleView(props: AICourseModuleViewProps) {
setIsLoading(false);
setIsGenerating(true);
await readAICourseLessonStream(reader, {
await readStream(reader, {
onStream: async (result) => {
if (abortController.signal.aborted) {
return;

@ -24,7 +24,7 @@ export function AILimitsPopup(props: AILimitsPopupProps) {
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
useQuery(billingDetailsOptions(), queryClient);
const isPaidUser = userBillingDetails?.status !== 'none';
const isPaidUser = userBillingDetails?.status === 'active';
return (
<Modal

@ -1,11 +1,9 @@
import { useEffect, useState } from 'react';
import { getUrlParams } from '../../lib/browser';
import { isLoggedIn } from '../../lib/jwt';
import { generateAiCourseStructure, type AiCourse } from '../../lib/ai';
import { readAICourseStream } from '../../helper/read-stream';
import { type AiCourse } from '../../lib/ai';
import { AICourseContent } from './AICourseContent';
import { queryClient } from '../../stores/query-client';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { generateCourse } from '../../helper/generate-ai-course';
type GenerateAICourseProps = {};
@ -38,119 +36,31 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
setTerm(paramsTerm);
setDifficulty(paramsDifficulty);
generateCourse({ term: paramsTerm, difficulty: paramsDifficulty });
handleGenerateCourse({ term: paramsTerm, difficulty: paramsDifficulty });
}, [term, difficulty]);
const generateCourse = async (options: {
const handleGenerateCourse = async (options: {
term: string;
difficulty: string;
isForce?: boolean;
}) => {
const { term, difficulty } = options;
const { term, difficulty, isForce } = options;
if (!isLoggedIn()) {
window.location.href = '/ai-tutor';
return;
}
setIsLoading(true);
setCourse({
title: '',
modules: [],
difficulty: '',
await generateCourse({
term,
difficulty,
onCourseIdChange: setCourseId,
onCourseSlugChange: setCourseSlug,
onCourseChange: setCourse,
onLoadingChange: setIsLoading,
onError: setError,
isForce,
});
setError('');
try {
const response = await fetch(
`${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-course`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
keyword: term,
difficulty,
}),
credentials: 'include',
},
);
if (!response.ok) {
const data = await response.json();
console.error(
'Error generating course:',
data?.message || 'Something went wrong',
);
setIsLoading(false);
setError(data?.message || 'Something went wrong');
return;
}
const reader = response.body?.getReader();
if (!reader) {
console.error('Failed to get reader from response');
setError('Something went wrong');
setIsLoading(false);
return;
}
const COURSE_ID_REGEX = new RegExp('@COURSEID:(\\w+)@');
const COURSE_SLUG_REGEX = new RegExp(/@COURSESLUG:([\w-]+)@/);
await readAICourseStream(reader, {
onStream: (result) => {
if (result.includes('@COURSEID') || result.includes('@COURSESLUG')) {
const courseIdMatch = result.match(COURSE_ID_REGEX);
const courseSlugMatch = result.match(COURSE_SLUG_REGEX);
const extractedCourseId = courseIdMatch?.[1] || '';
const extractedCourseSlug = courseSlugMatch?.[1] || '';
if (extractedCourseSlug) {
window.history.replaceState(
{
courseId,
courseSlug: extractedCourseSlug,
term,
difficulty,
},
'',
`${origin}/ai-tutor/${extractedCourseSlug}`,
);
}
result = result
.replace(COURSE_ID_REGEX, '')
.replace(COURSE_SLUG_REGEX, '');
setCourseId(extractedCourseId);
setCourseSlug(extractedCourseSlug);
}
try {
const aiCourse = generateAiCourseStructure(result);
setCourse({
...aiCourse,
difficulty: difficulty || '',
});
} catch (e) {
console.error('Error parsing streamed course content:', e);
}
},
onStreamEnd: (result) => {
result = result
.replace(COURSE_ID_REGEX, '')
.replace(COURSE_SLUG_REGEX, '');
setIsLoading(false);
queryClient.invalidateQueries(getAiCourseLimitOptions());
},
});
} catch (error: any) {
setError(error?.message || 'Something went wrong');
console.error('Error in course generation:', error);
setIsLoading(false);
}
};
useEffect(() => {
@ -167,7 +77,7 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
setDifficulty(difficulty);
setIsLoading(true);
generateCourse({ term, difficulty }).finally(() => {
handleGenerateCourse({ term, difficulty }).finally(() => {
setIsLoading(false);
});
};
@ -184,6 +94,9 @@ export function GenerateAICourse(props: GenerateAICourseProps) {
course={course}
isLoading={isLoading}
error={error}
onRegenerateOutline={() => {
handleGenerateCourse({ term, difficulty, isForce: true });
}}
/>
);
}

@ -5,6 +5,7 @@ import { useEffect, useState } from 'react';
import { AICourseContent } from './AICourseContent';
import { generateAiCourseStructure } from '../../lib/ai';
import { isLoggedIn } from '../../lib/jwt';
import { generateCourse } from '../../helper/generate-ai-course';
type GetAICourseProps = {
courseSlug: string;
@ -14,7 +15,8 @@ export function GetAICourse(props: GetAICourseProps) {
const { courseSlug } = props;
const [isLoading, setIsLoading] = useState(true);
const { data: aiCourse, error } = useQuery(
const [error, setError] = useState('');
const { data: aiCourse, error: queryError } = useQuery(
{
...getAiCourseOptions({ aiCourseSlug: courseSlug }),
select: (data) => {
@ -43,12 +45,27 @@ export function GetAICourse(props: GetAICourseProps) {
}, [aiCourse]);
useEffect(() => {
if (!error) {
if (!queryError) {
return;
}
setIsLoading(false);
}, [error]);
setError(queryError.message);
}, [queryError]);
const handleRegenerateCourse = async () => {
if (!aiCourse) {
return;
}
await generateCourse({
term: aiCourse.keyword,
difficulty: aiCourse.difficulty,
onLoadingChange: setIsLoading,
onError: setError,
isForce: true,
});
};
return (
<AICourseContent
@ -59,7 +76,8 @@ export function GetAICourse(props: GetAICourseProps) {
}}
isLoading={isLoading}
courseSlug={courseSlug}
error={error?.message}
error={error}
onRegenerateOutline={handleRegenerateCourse}
/>
);
}

@ -0,0 +1,75 @@
import { PenSquare, RefreshCcw } from 'lucide-react';
import { useRef, useState } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { cn } from '../../lib/classname';
import { useIsPaidUser } from '../../queries/billing';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
type RegenerateOutlineProps = {
onRegenerateOutline: () => void;
};
export function RegenerateOutline(props: RegenerateOutlineProps) {
const { onRegenerateOutline } = props;
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const isPaidUser = useIsPaidUser();
useOutsideClick(ref, () => setIsDropdownVisible(false));
return (
<>
{showUpgradeModal && (
<UpgradeAccountModal
onClose={() => {
setShowUpgradeModal(false);
}}
/>
)}
<div className="absolute right-3 top-3" ref={ref}>
<button
className={cn('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 right-0 top-full min-w-[170px] overflow-hidden rounded-md border border-gray-200 bg-white">
<button
onClick={() => {
if (!isPaidUser) {
setIsDropdownVisible(false);
setShowUpgradeModal(true);
} else {
onRegenerateOutline();
}
}}
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 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>
</>
);
}

@ -30,7 +30,7 @@ export function UserCoursesList(props: UserCoursesListProps) {
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
useQuery(billingDetailsOptions(), queryClient);
const isPaidUser = userBillingDetails?.status !== 'none';
const isPaidUser = userBillingDetails?.status !== 'active';
const { data: userAiCourses, isFetching: isUserAiCoursesLoading } = useQuery(
listUserAiCoursesOptions(),

@ -11,7 +11,6 @@ import { useToast } from '../../hooks/use-toast';
import { generateAIRoadmapFromText } from '../../../editor/utils/roadmap-generator';
import { renderFlowJSON } from '../../../editor/renderer/renderer';
import { replaceChildren } from '../../lib/dom';
import { readAIRoadmapStream } from '../../helper/read-stream';
import {
getOpenAIKey,
isLoggedIn,
@ -31,7 +30,7 @@ import { showLoginPopup } from '../../lib/popup.ts';
import { cn } from '../../lib/classname.ts';
import { RoadmapTopicDetail } from './RoadmapTopicDetail.tsx';
import { AIRoadmapAlert } from './AIRoadmapAlert.tsx';
import { IS_KEY_ONLY_ROADMAP_GENERATION } from '../../lib/ai.ts';
import { IS_KEY_ONLY_ROADMAP_GENERATION, readAIRoadmapStream } from '../../lib/ai.ts';
import { AITermSuggestionInput } from './AITermSuggestionInput.tsx';
import { IncreaseRoadmapLimit } from './IncreaseRoadmapLimit.tsx';
import { AuthenticationForm } from '../AuthenticationFlow/AuthenticationForm.tsx';

@ -3,13 +3,13 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useKeydown } from '../../hooks/use-keydown';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { markdownToHtml } from '../../lib/markdown';
import { Ban, Cog, Contact, FileText, User, UserRound, X } from 'lucide-react';
import { Ban, Cog, Contact, FileText, X } from 'lucide-react';
import { Spinner } from '../ReactIcons/Spinner';
import type { RoadmapNodeDetails } from './GenerateRoadmap';
import { getOpenAIKey, isLoggedIn, removeAuthToken } from '../../lib/jwt';
import { readAIRoadmapContentStream } from '../../helper/read-stream';
import { cn } from '../../lib/classname';
import { showLoginPopup } from '../../lib/popup';
import { readAIRoadmapContentStream } from '../../lib/ai';
type RoadmapTopicDetailProps = RoadmapNodeDetails & {
onClose?: () => void;

@ -49,7 +49,10 @@ import { CourseAnnouncement } from '../SQLCourse/CourseAnnouncement';
</span>
</span>
</a>
<a href='/teams' class='group hidden xl:block relative text-gray-400 hover:text-white'>
<a
href='/teams'
class='group relative hidden text-gray-400 hover:text-white xl:block'
>
Teams
</a>
</div>

@ -2,7 +2,7 @@ import type {
GetUserProfileRoadmapResponse,
GetPublicProfileResponse,
} from '../../api/user';
import { getPercentage } from '../../helper/number';
import { getPercentage } from '../../lib/number';
import { PrivateProfileBanner } from './PrivateProfileBanner';
import { UserProfileRoadmapRenderer } from './UserProfileRoadmapRenderer';

@ -1,5 +1,5 @@
import { getPercentage } from '../../helper/number';
import { getRelativeTimeString } from '../../lib/date';
import { getPercentage } from '../../lib/number';
type UserPublicProgressStats = {
resourceType: 'roadmap';

@ -1,6 +1,5 @@
import type { GetPublicProfileResponse } from '../../api/user';
import { UserPublicProgressStats } from './UserPublicProgressStats';
import { getPercentage } from '../../helper/number.ts';
import { getPercentage } from '../../lib/number';
type UserPublicProgressesProps = {
userId: string;
@ -73,15 +72,15 @@ export function UserPublicProgresses(props: UserPublicProgressesProps) {
target="_blank"
key={roadmap.id + counter}
href={`/${roadmap.id}?s=${userId}`}
className="relative group border-gray-300 flex items-center justify-between rounded-md border bg-white px-3 py-2 text-left text-sm transition-all hover:border-gray-400 overflow-hidden"
className="group relative flex items-center justify-between overflow-hidden rounded-md border border-gray-300 bg-white px-3 py-2 text-left text-sm transition-all hover:border-gray-400"
>
<span className="flex-grow truncate">{roadmap.title}</span>
<span className="text-xs text-gray-400">
{parseInt(percentageDone, 10)}%
{percentageDone}%
</span>
<span
className="absolute transition-colors left-0 top-0 block h-full cursor-pointer rounded-tl-md bg-black/5 group-hover:bg-black/10"
className="absolute left-0 top-0 block h-full cursor-pointer rounded-tl-md bg-black/5 transition-colors group-hover:bg-black/10"
style={{
width: `${percentageDone}%`,
}}

@ -0,0 +1,132 @@
import {
generateAiCourseStructure,
readStream,
type AiCourse,
} from '../lib/ai';
import { queryClient } from '../stores/query-client';
import { getAiCourseLimitOptions } from '../queries/ai-course';
type GenerateCourseOptions = {
term: string;
difficulty: string;
isForce?: boolean;
onCourseIdChange?: (courseId: string) => void;
onCourseSlugChange?: (courseSlug: string) => void;
onCourseChange?: (course: AiCourse) => void;
onLoadingChange?: (isLoading: boolean) => void;
onError?: (error: string) => void;
};
export async function generateCourse(options: GenerateCourseOptions) {
const {
term,
difficulty,
onCourseIdChange,
onCourseSlugChange,
onCourseChange,
onLoadingChange,
onError,
isForce = false,
} = options;
onLoadingChange?.(true);
onCourseChange?.({
title: '',
modules: [],
difficulty: '',
});
onError?.('');
try {
const response = await fetch(
`${import.meta.env.PUBLIC_API_URL}/v1-generate-ai-course`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
keyword: term,
difficulty,
isForce,
}),
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 reader = response.body?.getReader();
if (!reader) {
console.error('Failed to get reader from response');
onError?.('Something went wrong');
onLoadingChange?.(false);
return;
}
const COURSE_ID_REGEX = new RegExp('@COURSEID:(\\w+)@');
const COURSE_SLUG_REGEX = new RegExp(/@COURSESLUG:([\w-]+)@/);
await readStream(reader, {
onStream: (result) => {
if (result.includes('@COURSEID') || result.includes('@COURSESLUG')) {
const courseIdMatch = result.match(COURSE_ID_REGEX);
const courseSlugMatch = result.match(COURSE_SLUG_REGEX);
const extractedCourseId = courseIdMatch?.[1] || '';
const extractedCourseSlug = courseSlugMatch?.[1] || '';
if (extractedCourseSlug) {
window.history.replaceState(
{
courseId: extractedCourseId,
courseSlug: extractedCourseSlug,
term,
difficulty,
},
'',
`${origin}/ai-tutor/${extractedCourseSlug}`,
);
}
result = result
.replace(COURSE_ID_REGEX, '')
.replace(COURSE_SLUG_REGEX, '');
onCourseIdChange?.(extractedCourseId);
onCourseSlugChange?.(extractedCourseSlug);
}
try {
const aiCourse = generateAiCourseStructure(result);
onCourseChange?.({
...aiCourse,
difficulty: difficulty || '',
});
} catch (e) {
console.error('Error parsing streamed course content:', e);
}
},
onStreamEnd: (result) => {
result = result
.replace(COURSE_ID_REGEX, '')
.replace(COURSE_SLUG_REGEX, '');
onLoadingChange?.(false);
queryClient.invalidateQueries(getAiCourseLimitOptions());
},
});
} catch (error: any) {
onError?.(error?.message || 'Something went wrong');
console.error('Error in course generation:', error);
onLoadingChange?.(false);
}
}

@ -1,117 +1,5 @@
const NEW_LINE = '\n'.charCodeAt(0);
export async function readAIRoadmapStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
{
onStream,
onStreamEnd,
}: {
onStream?: (roadmap: string) => void;
onStreamEnd?: (roadmap: string) => void;
},
) {
const decoder = new TextDecoder('utf-8');
let result = '';
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
// We will call the renderRoadmap callback whenever we encounter
// a new line with the result until the new line
// otherwise, we will keep appending the result to the previous result
if (value) {
let start = 0;
for (let i = 0; i < value.length; i++) {
if (value[i] === NEW_LINE) {
result += decoder.decode(value.slice(start, i + 1));
onStream?.(result);
start = i + 1;
}
}
if (start < value.length) {
result += decoder.decode(value.slice(start));
}
}
}
onStream?.(result);
onStreamEnd?.(result);
reader.releaseLock();
}
export async function readAIRoadmapContentStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
{
onStream,
onStreamEnd,
}: {
onStream?: (roadmap: string) => void;
onStreamEnd?: (roadmap: string) => void;
},
) {
const decoder = new TextDecoder('utf-8');
let result = '';
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
if (value) {
result += decoder.decode(value);
onStream?.(result);
}
}
onStream?.(result);
onStreamEnd?.(result);
reader.releaseLock();
}
export async function readAICourseStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
{
onStream,
onStreamEnd,
}: {
onStream?: (course: string) => void;
onStreamEnd?: (course: string) => void;
},
) {
const decoder = new TextDecoder('utf-8');
let result = '';
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
// Process the stream data as it comes in
if (value) {
let start = 0;
for (let i = 0; i < value.length; i++) {
if (value[i] === NEW_LINE) {
result += decoder.decode(value.slice(start, i + 1));
onStream?.(result);
start = i + 1;
}
}
if (start < value.length) {
result += decoder.decode(value.slice(start));
}
}
}
onStream?.(result);
onStreamEnd?.(result);
reader.releaseLock();
}
export async function readAICourseLessonStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
{

@ -53,3 +53,117 @@ export function generateAiCourseStructure(
modules,
};
}
const NEW_LINE = '\n'.charCodeAt(0);
export async function readAIRoadmapStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
{
onStream,
onStreamEnd,
}: {
onStream?: (roadmap: string) => void;
onStreamEnd?: (roadmap: string) => void;
},
) {
const decoder = new TextDecoder('utf-8');
let result = '';
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
// We will call the renderRoadmap callback whenever we encounter
// a new line with the result until the new line
// otherwise, we will keep appending the result to the previous result
if (value) {
let start = 0;
for (let i = 0; i < value.length; i++) {
if (value[i] === NEW_LINE) {
result += decoder.decode(value.slice(start, i + 1));
onStream?.(result);
start = i + 1;
}
}
if (start < value.length) {
result += decoder.decode(value.slice(start));
}
}
}
onStream?.(result);
onStreamEnd?.(result);
reader.releaseLock();
}
export async function readAIRoadmapContentStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
{
onStream,
onStreamEnd,
}: {
onStream?: (roadmap: string) => void;
onStreamEnd?: (roadmap: string) => void;
},
) {
const decoder = new TextDecoder('utf-8');
let result = '';
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
if (value) {
result += decoder.decode(value);
onStream?.(result);
}
}
onStream?.(result);
onStreamEnd?.(result);
reader.releaseLock();
}
export async function readStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
{
onStream,
onStreamEnd,
}: {
onStream?: (course: string) => void;
onStreamEnd?: (course: string) => void;
},
) {
const decoder = new TextDecoder('utf-8');
let result = '';
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
// Process the stream data as it comes in
if (value) {
let start = 0;
for (let i = 0; i < value.length; i++) {
if (value[i] === NEW_LINE) {
result += decoder.decode(value.slice(start, i + 1));
onStream?.(result);
start = i + 1;
}
}
if (start < value.length) {
result += decoder.decode(value.slice(start));
}
}
}
onStream?.(result);
onStreamEnd?.(result);
reader.releaseLock();
}

@ -21,3 +21,16 @@ export function humanizeNumber(number: number): string {
return `${decimalIfNeeded(number / 1000000)}m`;
}
export function getPercentage(portion: number, total: number): number {
if (portion <= 0 || total <= 0) {
return 0;
}
if (portion >= total) {
return 100;
}
const percentage = (portion / total) * 100;
return Math.round(percentage);
}

@ -39,6 +39,7 @@ export interface AICourseDocument {
title: string;
slug?: string;
keyword: string;
done: string[];
difficulty: string;
data: string;
viewCount: number;
@ -75,7 +76,6 @@ export function getAiCourseLimitOptions() {
}
export type AICourseListItem = AICourseDocument & {
progress: AICourseProgressDocument;
lessonCount: number;
};

@ -1,6 +1,7 @@
import { queryOptions } from '@tanstack/react-query';
import { queryOptions, useQuery } from '@tanstack/react-query';
import { httpGet } from '../lib/query-http';
import { isLoggedIn } from '../lib/jwt';
import { queryClient } from '../stores/query-client';
export const allowedSubscriptionStatus = [
'active',
@ -53,6 +54,22 @@ export function billingDetailsOptions() {
});
}
export function useIsPaidUser() {
const { data } = useQuery(
{
queryKey: ['billing-details'],
queryFn: async () => {
return httpGet<BillingDetailsResponse>('/v1-billing-details');
},
enabled: !!isLoggedIn(),
select: (data) => data.status === 'active',
},
queryClient,
);
return data ?? false;
}
type CoursePriceParams = {
courseSlug: string;
};

Loading…
Cancel
Save