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:
@@ -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;
|
||||
}
|
||||
|
@@ -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"
|
||||
|
@@ -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';
|
||||
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
88
src/components/GenerateGuide/AIGuideRegenerate.tsx
Normal file
88
src/components/GenerateGuide/AIGuideRegenerate.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -10,7 +10,7 @@ import { getAiGuideOptions } from '../../queries/ai-guide';
|
||||
|
||||
type GenerateAIGuideProps = {
|
||||
onGuideSlugChange?: (guideSlug: string) => void;
|
||||
};
|
||||
};
|
||||
|
||||
export function GenerateAIGuide(props: GenerateAIGuideProps) {
|
||||
const { onGuideSlugChange } = props;
|
||||
|
@@ -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);
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
|
Reference in New Issue
Block a user