diff --git a/.astro/types.d.ts b/.astro/types.d.ts index f964fe0cf..03d7cc43f 100644 --- a/.astro/types.d.ts +++ b/.astro/types.d.ts @@ -1 +1,2 @@ /// +/// \ No newline at end of file diff --git a/src/components/AITutor/AITutorLayout.tsx b/src/components/AITutor/AITutorLayout.tsx index cb72434fd..dc62989cb 100644 --- a/src/components/AITutor/AITutorLayout.tsx +++ b/src/components/AITutor/AITutorLayout.tsx @@ -6,7 +6,7 @@ import { cn } from '../../lib/classname'; type AITutorLayoutProps = { children: React.ReactNode; - activeTab: AITutorTab; + activeTab?: AITutorTab; wrapperClassName?: string; containerClassName?: string; }; diff --git a/src/components/AITutor/AITutorSidebar.tsx b/src/components/AITutor/AITutorSidebar.tsx index 1d9215896..0b1576631 100644 --- a/src/components/AITutor/AITutorSidebar.tsx +++ b/src/components/AITutor/AITutorSidebar.tsx @@ -14,7 +14,7 @@ import { UserDropdown } from './UserDropdown'; type AITutorSidebarProps = { isFloating: boolean; - activeTab: AITutorTab; + activeTab?: AITutorTab; onClose: () => void; }; diff --git a/src/components/GenerateCourse/AICourse.tsx b/src/components/GenerateCourse/AICourse.tsx index 36a957d27..bdee5e007 100644 --- a/src/components/GenerateCourse/AICourse.tsx +++ b/src/components/GenerateCourse/AICourse.tsx @@ -83,7 +83,11 @@ export function AICourse(props: AICourseProps) { }); } - window.location.href = `/ai/course?term=${encodeURIComponent(keyword)}&difficulty=${difficulty}&id=${sessionId}&nature=${nature}`; + 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 ( diff --git a/src/components/GenerateDocument/AIDocumentContent.tsx b/src/components/GenerateDocument/AIDocumentContent.tsx new file mode 100644 index 000000000..221d66840 --- /dev/null +++ b/src/components/GenerateDocument/AIDocumentContent.tsx @@ -0,0 +1,20 @@ +import { markdownToHtml } from '../../lib/markdown'; + +type AIDocumentContentProps = { + document: string; +}; + +export function AIDocumentContent(props: AIDocumentContentProps) { + const { document } = props; + + const html = markdownToHtml(document, false); + + return ( +
+
+
+ ); +} diff --git a/src/components/GenerateDocument/GenerateAIDocument.tsx b/src/components/GenerateDocument/GenerateAIDocument.tsx new file mode 100644 index 000000000..93b7e8d61 --- /dev/null +++ b/src/components/GenerateDocument/GenerateAIDocument.tsx @@ -0,0 +1,154 @@ +import { useQuery } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; +import { generateDocument } from '../../helper/generate-ai-document'; +import { getCourseFineTuneData } from '../../lib/ai'; +import { getUrlParams } from '../../lib/browser'; +import { isLoggedIn } from '../../lib/jwt'; +import { getAiCourseOptions } from '../../queries/ai-course'; +import { queryClient } from '../../stores/query-client'; +import { AIDocumentContent } from './AIDocumentContent'; + +type GenerateAIDocumentProps = {}; + +export function GenerateAIDocument(props: GenerateAIDocumentProps) { + const [term, setTerm] = useState(''); + const [difficulty, setDifficulty] = useState(''); + const [sessionId, setSessionId] = useState(''); + const [goal, setGoal] = useState(''); + const [about, setAbout] = useState(''); + const [customInstructions, setCustomInstructions] = useState(''); + + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(''); + + const [creatorId, setCreatorId] = useState(''); + const [documentId, setDocumentId] = useState(''); + const [documentSlug, setDocumentSlug] = useState(''); + const [document, setDocument] = useState(''); + + // Once the course is generated, we fetch the course from the database + // so that we get the up-to-date course data and also so that we + // can reload the changes (e.g. progress) etc using queryClient.setQueryData + const { data: aiCourse } = useQuery( + getAiCourseOptions({ aiCourseSlug: documentSlug }), + queryClient, + ); + + useEffect(() => { + if (term || difficulty) { + return; + } + + const params = getUrlParams(); + const paramsTerm = params?.term; + const paramsDifficulty = params?.difficulty; + const paramsSrc = params?.src || 'search'; + if (!paramsTerm || !paramsDifficulty) { + return; + } + + setTerm(paramsTerm); + setDifficulty(paramsDifficulty); + + const sessionId = params?.id; + setSessionId(sessionId); + + let paramsGoal = ''; + let paramsAbout = ''; + let paramsCustomInstructions = ''; + + if (sessionId) { + const fineTuneData = getCourseFineTuneData(sessionId); + if (fineTuneData) { + paramsGoal = fineTuneData.goal; + paramsAbout = fineTuneData.about; + paramsCustomInstructions = fineTuneData.customInstructions; + + setGoal(paramsGoal); + setAbout(paramsAbout); + setCustomInstructions(paramsCustomInstructions); + } + } + + handleGenerateDocument({ + term: paramsTerm, + difficulty: paramsDifficulty, + instructions: paramsCustomInstructions, + goal: paramsGoal, + about: paramsAbout, + src: paramsSrc, + }); + }, [term, difficulty]); + + const handleGenerateDocument = async (options: { + term: string; + difficulty: string; + instructions?: string; + goal?: string; + about?: string; + isForce?: boolean; + prompt?: string; + src?: string; + }) => { + const { + term, + difficulty, + isForce, + prompt, + instructions, + goal, + about, + src, + } = options; + + if (!isLoggedIn()) { + window.location.href = '/ai'; + return; + } + + await generateDocument({ + term, + difficulty, + slug: documentSlug, + onDocumentIdChange: setDocumentId, + onDocumentSlugChange: setDocumentSlug, + onCreatorIdChange: setCreatorId, + onDocumentChange: setDocument, + onLoadingChange: setIsLoading, + onError: setError, + instructions, + goal, + about, + isForce, + prompt, + src, + }); + }; + + useEffect(() => { + const handlePopState = (e: PopStateEvent) => { + const { documentId, documentSlug, term, difficulty } = e.state || {}; + if (!documentId || !documentSlug) { + window.location.reload(); + return; + } + + setDocumentId(documentId); + setDocumentSlug(documentSlug); + setTerm(term); + setDifficulty(difficulty); + + setIsLoading(true); + handleGenerateDocument({ term, difficulty }).finally(() => { + setIsLoading(false); + }); + }; + + window.addEventListener('popstate', handlePopState); + return () => { + window.removeEventListener('popstate', handlePopState); + }; + }, []); + + return ; +} diff --git a/src/components/GenerateDocument/GetAIDocument.tsx b/src/components/GenerateDocument/GetAIDocument.tsx new file mode 100644 index 000000000..19734a3ac --- /dev/null +++ b/src/components/GenerateDocument/GetAIDocument.tsx @@ -0,0 +1,87 @@ +import { useQuery } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; +import { generateDocument } from '../../helper/generate-ai-document'; +import { queryClient } from '../../stores/query-client'; +import { getAiDocumentOptions } from '../../queries/ai-document'; +import { AIDocumentContent } from './AIDocumentContent'; + +type GetAIDocumentProps = { + slug: string; +}; + +export function GetAIDocument(props: GetAIDocumentProps) { + const { slug: documentSlug } = props; + + const [isLoading, setIsLoading] = useState(true); + const [isRegenerating, setIsRegenerating] = useState(false); + + const [error, setError] = useState(''); + const { data: aiDocument, error: queryError } = useQuery( + { + ...getAiDocumentOptions({ documentSlug: documentSlug }), + enabled: !!documentSlug, + }, + queryClient, + ); + + useEffect(() => { + if (!aiDocument) { + return; + } + + setIsLoading(false); + }, [aiDocument]); + + 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 ; +} diff --git a/src/helper/generate-ai-document.ts b/src/helper/generate-ai-document.ts new file mode 100644 index 000000000..e9c28753c --- /dev/null +++ b/src/helper/generate-ai-document.ts @@ -0,0 +1,167 @@ +import { + readStream +} from '../lib/ai'; +import { queryClient } from '../stores/query-client'; +import { getAiCourseLimitOptions } from '../queries/ai-course'; + +type GenerateDocumentOptions = { + term: string; + difficulty: string; + slug?: string; + isForce?: boolean; + prompt?: string; + instructions?: string; + goal?: string; + about?: string; + onDocumentIdChange?: (documentId: string) => void; + onDocumentSlugChange?: (documentSlug: string) => void; + onDocumentChange?: (document: string) => void; + onLoadingChange?: (isLoading: boolean) => void; + onCreatorIdChange?: (creatorId: string) => void; + onError?: (error: string) => void; + src?: string; +}; + +export async function generateDocument(options: GenerateDocumentOptions) { + const { + term, + slug, + difficulty, + onDocumentIdChange, + onDocumentSlugChange, + onDocumentChange, + onLoadingChange, + onError, + onCreatorIdChange, + isForce = false, + prompt, + instructions, + goal, + about, + src = 'search', + } = options; + + onLoadingChange?.(true); + onDocumentChange?.(''); + onError?.(''); + + try { + let response = null; + + if (slug && isForce) { + response = await fetch( + `${import.meta.env.PUBLIC_API_URL}/v1-regenerate-ai-document/${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-document`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + keyword: term, + difficulty, + 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 reader = response.body?.getReader(); + + if (!reader) { + console.error('Failed to get reader from response'); + onError?.('Something went wrong'); + onLoadingChange?.(false); + return; + } + + const DOCUMENT_ID_REGEX = new RegExp('@DOCID:(\\w+)@'); + const DOCUMENT_SLUG_REGEX = new RegExp(/@DOCSLUG:([\w-]+)@/); + const CREATOR_ID_REGEX = new RegExp('@CREATORID:(\\w+)@'); + + await readStream(reader, { + onStream: async (result) => { + if (result.includes('@DOCID') || result.includes('@DOCSLUG')) { + const documentIdMatch = result.match(DOCUMENT_ID_REGEX); + const documentSlugMatch = result.match(DOCUMENT_SLUG_REGEX); + const creatorIdMatch = result.match(CREATOR_ID_REGEX); + const extractedDocumentId = documentIdMatch?.[1] || ''; + const extractedDocumentSlug = documentSlugMatch?.[1] || ''; + const extractedCreatorId = creatorIdMatch?.[1] || ''; + + if (extractedDocumentSlug) { + window.history.replaceState( + { + documentId: extractedDocumentId, + documentSlug: extractedDocumentSlug, + term, + difficulty, + }, + '', + `${origin}/ai/document/${extractedDocumentSlug}`, + ); + } + + result = result + .replace(DOCUMENT_ID_REGEX, '') + .replace(DOCUMENT_SLUG_REGEX, '') + .replace(CREATOR_ID_REGEX, ''); + + onDocumentIdChange?.(extractedDocumentId); + onDocumentSlugChange?.(extractedDocumentSlug); + onCreatorIdChange?.(extractedCreatorId); + } + + try { + onDocumentChange?.(result); + } catch (e) { + console.error('Error parsing streamed course content:', e); + } + }, + onStreamEnd: async (result) => { + result = result + .replace(DOCUMENT_ID_REGEX, '') + .replace(DOCUMENT_SLUG_REGEX, '') + .replace(CREATOR_ID_REGEX, ''); + + onLoadingChange?.(false); + queryClient.invalidateQueries(getAiCourseLimitOptions()); + }, + }); + } catch (error: any) { + onError?.(error?.message || 'Something went wrong'); + console.error('Error in course generation:', error); + onLoadingChange?.(false); + } +} diff --git a/src/pages/ai/document.astro b/src/pages/ai/document.astro new file mode 100644 index 000000000..1c67dc327 --- /dev/null +++ b/src/pages/ai/document.astro @@ -0,0 +1,20 @@ +--- +import { AITutorLayout } from '../../components/AITutor/AITutorLayout'; +import { CheckSubscriptionVerification } from '../../components/Billing/CheckSubscriptionVerification'; +import { GenerateAIDocument } from '../../components/GenerateDocument/GenerateAIDocument'; +import SkeletonLayout from '../../layouts/SkeletonLayout.astro'; +--- + + + + + + + diff --git a/src/pages/ai/document/[slug].astro b/src/pages/ai/document/[slug].astro new file mode 100644 index 000000000..822fcd87c --- /dev/null +++ b/src/pages/ai/document/[slug].astro @@ -0,0 +1,26 @@ +--- +import { AITutorLayout } from '../../../components/AITutor/AITutorLayout'; +import { GetAIDocument } from '../../../components/GenerateDocument/GetAIDocument'; +import SkeletonLayout from '../../../layouts/SkeletonLayout.astro'; + +export const prerender = false; + +interface Params extends Record { + slug: string; +} + +const { slug } = Astro.params as Params; +--- + + + +
+ +
+
diff --git a/src/queries/ai-document.ts b/src/queries/ai-document.ts new file mode 100644 index 000000000..c577c0384 --- /dev/null +++ b/src/queries/ai-document.ts @@ -0,0 +1,66 @@ +import { httpGet } from '../lib/query-http'; +import { isLoggedIn } from '../lib/jwt'; + +type GetAIDocumentParams = { + documentSlug: string; +}; + +export interface AIDocumentDocument { + _id: string; + userId: string; + title: string; + slug?: string; + keyword: string; + difficulty: string; + content: string; + viewCount: number; + createdAt: Date; + updatedAt: Date; +} + +type GetAIDocumentResponse = AIDocumentDocument; + +export function getAiDocumentOptions(params: GetAIDocumentParams) { + return { + queryKey: ['ai-document', params], + queryFn: () => { + return httpGet( + `/v1-get-ai-document/${params.documentSlug}`, + ); + }, + enabled: !!params.documentSlug, + }; +} + +export type ListUserAiDocumentsQuery = { + perPage?: string; + currPage?: string; + query?: string; +}; + +type ListUserAiDocumentsResponse = { + data: AIDocumentDocument[]; + totalCount: number; + totalPages: number; + currPage: number; + perPage: number; +}; + +export function listUserAiDocumentsOptions( + params: ListUserAiDocumentsQuery = { + perPage: '21', + currPage: '1', + query: '', + }, +) { + return { + queryKey: ['user-ai-documents', params], + queryFn: () => { + return httpGet( + `/v1-list-user-ai-documents`, + params, + ); + }, + enabled: !!isLoggedIn(), + }; +}