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(),
+ };
+}