refactor: ai-courses (#8327)
* Refactor ai courses * Refactor * Regenerate roadmap functionality * Title and difficulty to refresh also * Add course regeneration * Improve the non paid user headings * Update * Improve back button logic * Is paid user checksfix/ai-course
parent
cc5585171c
commit
79c6e2be53
31 changed files with 813 additions and 450 deletions
@ -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> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,69 @@ |
|||||||
|
import { useState } from 'react'; |
||||||
|
import { Modal } from '../Modal'; |
||||||
|
|
||||||
|
export type ModifyCoursePromptProps = { |
||||||
|
onClose: () => void; |
||||||
|
onSubmit: (prompt: string) => void; |
||||||
|
}; |
||||||
|
|
||||||
|
export function ModifyCoursePrompt(props: ModifyCoursePromptProps) { |
||||||
|
const { onClose, onSubmit } = props; |
||||||
|
|
||||||
|
const [prompt, setPrompt] = useState(''); |
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { |
||||||
|
e.preventDefault(); |
||||||
|
onSubmit(prompt); |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<Modal |
||||||
|
onClose={onClose} |
||||||
|
wrapperClassName="rounded-xl max-w-xl w-full h-auto" |
||||||
|
bodyClassName="p-6" |
||||||
|
overlayClassName="items-start md:items-center" |
||||||
|
> |
||||||
|
<div className="flex flex-col gap-4"> |
||||||
|
<div> |
||||||
|
<h2 className="mb-2 text-left text-xl font-semibold"> |
||||||
|
Give AI more context |
||||||
|
</h2> |
||||||
|
<p className="text-sm text-gray-500"> |
||||||
|
Pass additional information to the AI to generate a course outline. |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
<form className="flex flex-col gap-2" onSubmit={handleSubmit}> |
||||||
|
<textarea |
||||||
|
id="prompt" |
||||||
|
autoFocus |
||||||
|
rows={3} |
||||||
|
value={prompt} |
||||||
|
onChange={(e) => setPrompt(e.target.value)} |
||||||
|
className="w-full rounded-md border border-gray-200 p-2 placeholder:text-sm focus:outline-black" |
||||||
|
placeholder="e.g. make sure to add a section on React hooks" |
||||||
|
/> |
||||||
|
|
||||||
|
<p className="text-sm text-gray-500"> |
||||||
|
Complete the sentence: "I want AI to..." |
||||||
|
</p> |
||||||
|
|
||||||
|
<div className="flex justify-end gap-2"> |
||||||
|
<button |
||||||
|
className="rounded-md bg-gray-200 px-4 py-2.5 text-sm text-black hover:opacity-80" |
||||||
|
onClick={onClose} |
||||||
|
> |
||||||
|
Cancel |
||||||
|
</button> |
||||||
|
<button |
||||||
|
type="submit" |
||||||
|
disabled={!prompt.trim()} |
||||||
|
className="rounded-md bg-black px-4 py-2.5 text-sm text-white hover:opacity-80 disabled:opacity-50" |
||||||
|
> |
||||||
|
Modify Prompt |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</Modal> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,98 @@ |
|||||||
|
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'; |
||||||
|
import { ModifyCoursePrompt } from './ModifyCoursePrompt'; |
||||||
|
|
||||||
|
type RegenerateOutlineProps = { |
||||||
|
onRegenerateOutline: (prompt?: string) => void; |
||||||
|
}; |
||||||
|
|
||||||
|
export function RegenerateOutline(props: RegenerateOutlineProps) { |
||||||
|
const { onRegenerateOutline } = props; |
||||||
|
|
||||||
|
const [isDropdownVisible, setIsDropdownVisible] = useState(false); |
||||||
|
const [showUpgradeModal, setShowUpgradeModal] = useState(false); |
||||||
|
const [showPromptModal, setShowPromptModal] = useState(false); |
||||||
|
|
||||||
|
const ref = useRef<HTMLDivElement>(null); |
||||||
|
|
||||||
|
const { isPaidUser } = useIsPaidUser(); |
||||||
|
|
||||||
|
useOutsideClick(ref, () => setIsDropdownVisible(false)); |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
{showUpgradeModal && ( |
||||||
|
<UpgradeAccountModal |
||||||
|
onClose={() => { |
||||||
|
setShowUpgradeModal(false); |
||||||
|
}} |
||||||
|
/> |
||||||
|
)} |
||||||
|
|
||||||
|
{showPromptModal && ( |
||||||
|
<ModifyCoursePrompt |
||||||
|
onClose={() => setShowPromptModal(false)} |
||||||
|
onSubmit={(prompt) => { |
||||||
|
setShowPromptModal(false); |
||||||
|
onRegenerateOutline(prompt); |
||||||
|
}} |
||||||
|
/> |
||||||
|
)} |
||||||
|
|
||||||
|
<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 |
||||||
|
onClick={() => { |
||||||
|
setIsDropdownVisible(false); |
||||||
|
if (!isPaidUser) { |
||||||
|
setShowUpgradeModal(true); |
||||||
|
} else { |
||||||
|
setShowPromptModal(true); |
||||||
|
} |
||||||
|
}} |
||||||
|
className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm text-gray-600 hover:bg-gray-100" |
||||||
|
> |
||||||
|
<PenSquare |
||||||
|
size={16} |
||||||
|
className="text-gray-400" |
||||||
|
strokeWidth={2.5} |
||||||
|
/> |
||||||
|
Modify Prompt |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,162 @@ |
|||||||
|
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; |
||||||
|
slug?: string; |
||||||
|
isForce?: boolean; |
||||||
|
prompt?: string; |
||||||
|
onCourseIdChange?: (courseId: string) => void; |
||||||
|
onCourseSlugChange?: (courseSlug: string) => void; |
||||||
|
onCourseChange?: (course: AiCourse, rawData: string) => void; |
||||||
|
onLoadingChange?: (isLoading: boolean) => void; |
||||||
|
onError?: (error: string) => void; |
||||||
|
}; |
||||||
|
|
||||||
|
export async function generateCourse(options: GenerateCourseOptions) { |
||||||
|
const { |
||||||
|
term, |
||||||
|
slug, |
||||||
|
difficulty, |
||||||
|
onCourseIdChange, |
||||||
|
onCourseSlugChange, |
||||||
|
onCourseChange, |
||||||
|
onLoadingChange, |
||||||
|
onError, |
||||||
|
isForce = false, |
||||||
|
prompt, |
||||||
|
} = options; |
||||||
|
|
||||||
|
onLoadingChange?.(true); |
||||||
|
onCourseChange?.( |
||||||
|
{ |
||||||
|
title: '', |
||||||
|
modules: [], |
||||||
|
difficulty: '', |
||||||
|
}, |
||||||
|
'', |
||||||
|
); |
||||||
|
onError?.(''); |
||||||
|
|
||||||
|
try { |
||||||
|
let response = null; |
||||||
|
|
||||||
|
if (slug && isForce) { |
||||||
|
response = await fetch( |
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-regenerate-ai-course/${slug}`, |
||||||
|
{ |
||||||
|
method: 'POST', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json', |
||||||
|
}, |
||||||
|
credentials: 'include', |
||||||
|
body: JSON.stringify({ |
||||||
|
isForce, |
||||||
|
customPrompt: prompt, |
||||||
|
}), |
||||||
|
}, |
||||||
|
); |
||||||
|
} else { |
||||||
|
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, |
||||||
|
customPrompt: prompt, |
||||||
|
}), |
||||||
|
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 || '', |
||||||
|
}, |
||||||
|
result, |
||||||
|
); |
||||||
|
} 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,12 +0,0 @@ |
|||||||
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); |
|
||||||
} |
|
@ -1,141 +0,0 @@ |
|||||||
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>, |
|
||||||
{ |
|
||||||
onStream, |
|
||||||
onStreamEnd, |
|
||||||
}: { |
|
||||||
onStream?: (lesson: string) => void; |
|
||||||
onStreamEnd?: (lesson: string) => void; |
|
||||||
}, |
|
||||||
) { |
|
||||||
const decoder = new TextDecoder('utf-8'); |
|
||||||
let result = ''; |
|
||||||
|
|
||||||
while (true) { |
|
||||||
const { value, done } = await reader.read(); |
|
||||||
if (done) { |
|
||||||
break; |
|
||||||
} |
|
||||||
|
|
||||||
result += decoder.decode(value); |
|
||||||
onStream?.(result); |
|
||||||
} |
|
||||||
|
|
||||||
onStream?.(result); |
|
||||||
onStreamEnd?.(result); |
|
||||||
reader.releaseLock(); |
|
||||||
} |
|
Loading…
Reference in new issue