1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-09-25 00:21:28 +02:00

feat: regenerate guide

This commit is contained in:
Arik Chakma
2025-06-16 19:23:14 +06:00
parent f1dd448222
commit b91bb254b1
9 changed files with 177 additions and 25 deletions

View File

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

@@ -12,6 +12,9 @@ import { AIGuideChat } from './AIGuideChat';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { isLoggedIn } from '../../lib/jwt';
import { shuffle } from '../../helper/shuffle';
import { generateGuide } from '../../helper/generate-ai-guide';
import { useToast } from '../../hooks/use-toast';
import { flushSync } from 'react-dom';
type AIGuideProps = {
guideSlug?: string;
@@ -21,7 +24,10 @@ 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
@@ -45,6 +51,43 @@ export function AIGuide(props: AIGuideProps) {
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?.difficulty || '',
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"
@@ -55,10 +98,16 @@ export function AIGuide(props: AIGuideProps) {
)}
<div className="grow overflow-y-auto p-4 pt-0">
{guideSlug && <AIGuideContent html={aiGuide?.html || ''} />}
{guideSlug && (
<AIGuideContent
html={regeneratedHtml || aiGuide?.html || ''}
onRegenerate={handleRegenerate}
isRegenerating={isRegenerating}
/>
)}
{!guideSlug && <GenerateAIGuide onGuideSlugChange={setGuideSlug} />}
{!isAiGuideSuggestionsLoading && aiGuide && (
{!isAiGuideSuggestionsLoading && aiGuide && !isRegenerating && (
<div className="mt-4 grid grid-cols-2 divide-x divide-gray-200 rounded-lg border border-gray-200 bg-white">
<ListSuggestions
title="Related Topics"

View File

@@ -48,13 +48,6 @@ export function AIGuideChat(props: AIGuideChatProps) {
refetch: refetchBillingDetails,
} = useQuery(billingDetailsOptions(), queryClient);
// const {suggestions}
// const randomAiGuideSuggestions = useMemo(() => {
// return aiGuideSuggestions?.relatedTopics[
// Math.floor(Math.random() * aiGuideSuggestions.relatedTopics.length)
// ];
// }, [aiGuideSuggestions]);
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
const isPaidUser = userBillingDetails?.status === 'active';

View File

@@ -1,18 +1,43 @@
import { Loader2Icon } from 'lucide-react';
import './AIGuideContent.css';
import { AIGuideRegenerate } from './AIGuideRegenerate';
import { cn } from '../../lib/classname';
type AIGuideContentProps = {
html: string;
onRegenerate?: (prompt?: string) => void;
isRegenerating?: boolean;
};
export function AIGuideContent(props: AIGuideContentProps) {
const { html } = props;
const { html, onRegenerate, isRegenerating } = props;
return (
<div className="mx-auto w-full max-w-4xl">
<div
className={cn(
'relative mx-auto w-full max-w-4xl',
isRegenerating && 'min-h-full',
)}
>
<div
className="course-content 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 }}
/>
{isRegenerating && !html && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="flex items-center gap-2 rounded-lg border bg-white p-2">
<Loader2Icon className="size-4 animate-spin" />
<span>Regenerating...</span>
</div>
</div>
)}
{onRegenerate && !isRegenerating && (
<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

@@ -10,7 +10,7 @@ import { getAiGuideOptions } from '../../queries/ai-guide';
type GenerateAIGuideProps = {
onGuideSlugChange?: (guideSlug: string) => void;
};
};
export function GenerateAIGuide(props: GenerateAIGuideProps) {
const { onGuideSlugChange } = props;

View File

@@ -1,4 +1,3 @@
import { readStream } from '../lib/ai';
import { queryClient } from '../stores/query-client';
import { getAiCourseLimitOptions } from '../queries/ai-course';
import { readChatStream } from '../lib/chat';
@@ -28,6 +27,7 @@ type GenerateGuideOptions = {
onHtmlChange?: (html: string) => void;
onStreamingChange?: (isStreaming: boolean) => void;
onDetailsChange?: (details: GuideDetails) => void;
onFinish?: () => void;
};
export async function generateGuide(options: GenerateGuideOptions) {
@@ -47,11 +47,11 @@ export async function generateGuide(options: GenerateGuideOptions) {
onHtmlChange,
onStreamingChange,
onDetailsChange,
onFinish,
} = options;
onLoadingChange?.(true);
onGuideChange?.('');
onError?.('');
try {
let response = null;
@@ -66,8 +66,7 @@ export async function generateGuide(options: GenerateGuideOptions) {
},
credentials: 'include',
body: JSON.stringify({
isForce,
customPrompt: prompt,
prompt,
}),
},
);
@@ -127,14 +126,14 @@ export async function generateGuide(options: GenerateGuideOptions) {
onStreamingChange?.(false);
},
onDetails: async (details) => {
const detailsJson = JSON.parse(details);
if (!detailsJson?.guideId || !detailsJson?.guideSlug) {
if (!details?.guideId || !details?.guideSlug) {
throw new Error('Invalid details');
}
onDetailsChange?.(detailsJson);
onDetailsChange?.(details);
},
});
onFinish?.();
} catch (error: any) {
onError?.(error?.message || 'Something went wrong');
console.error('Error in course generation:', error);

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