mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-25 08:35:42 +02:00
feat: regenerate guide
This commit is contained in:
@@ -283,8 +283,7 @@ export function AIChat(props: AIChatProps) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onDetails: (details) => {
|
onDetails: (details) => {
|
||||||
const detailsJson = JSON.parse(details);
|
const chatHistoryId = details?.chatHistoryId;
|
||||||
const chatHistoryId = detailsJson?.chatHistoryId;
|
|
||||||
if (!chatHistoryId) {
|
if (!chatHistoryId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@@ -12,6 +12,9 @@ import { AIGuideChat } from './AIGuideChat';
|
|||||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||||
import { isLoggedIn } from '../../lib/jwt';
|
import { isLoggedIn } from '../../lib/jwt';
|
||||||
import { shuffle } from '../../helper/shuffle';
|
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 = {
|
type AIGuideProps = {
|
||||||
guideSlug?: string;
|
guideSlug?: string;
|
||||||
@@ -21,7 +24,10 @@ export function AIGuide(props: AIGuideProps) {
|
|||||||
const { guideSlug: defaultGuideSlug } = props;
|
const { guideSlug: defaultGuideSlug } = props;
|
||||||
const [guideSlug, setGuideSlug] = useState(defaultGuideSlug);
|
const [guideSlug, setGuideSlug] = useState(defaultGuideSlug);
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
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
|
// only fetch the guide if the guideSlug is provided
|
||||||
// otherwise we are still generating the guide
|
// otherwise we are still generating the guide
|
||||||
@@ -45,6 +51,43 @@ export function AIGuide(props: AIGuideProps) {
|
|||||||
return shuffle(aiGuideSuggestions?.deepDiveTopics || []).slice(0, 2);
|
return shuffle(aiGuideSuggestions?.deepDiveTopics || []).slice(0, 2);
|
||||||
}, [aiGuideSuggestions]);
|
}, [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 (
|
return (
|
||||||
<AITutorLayout
|
<AITutorLayout
|
||||||
wrapperClassName="flex-row p-0 lg:p-0 overflow-hidden"
|
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">
|
<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} />}
|
{!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">
|
<div className="mt-4 grid grid-cols-2 divide-x divide-gray-200 rounded-lg border border-gray-200 bg-white">
|
||||||
<ListSuggestions
|
<ListSuggestions
|
||||||
title="Related Topics"
|
title="Related Topics"
|
||||||
|
@@ -48,13 +48,6 @@ export function AIGuideChat(props: AIGuideChatProps) {
|
|||||||
refetch: refetchBillingDetails,
|
refetch: refetchBillingDetails,
|
||||||
} = useQuery(billingDetailsOptions(), queryClient);
|
} = 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 isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
|
||||||
const isPaidUser = userBillingDetails?.status === 'active';
|
const isPaidUser = userBillingDetails?.status === 'active';
|
||||||
|
|
||||||
|
@@ -1,18 +1,43 @@
|
|||||||
|
import { Loader2Icon } from 'lucide-react';
|
||||||
import './AIGuideContent.css';
|
import './AIGuideContent.css';
|
||||||
|
import { AIGuideRegenerate } from './AIGuideRegenerate';
|
||||||
|
import { cn } from '../../lib/classname';
|
||||||
|
|
||||||
type AIGuideContentProps = {
|
type AIGuideContentProps = {
|
||||||
html: string;
|
html: string;
|
||||||
|
onRegenerate?: (prompt?: string) => void;
|
||||||
|
isRegenerating?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AIGuideContent(props: AIGuideContentProps) {
|
export function AIGuideContent(props: AIGuideContentProps) {
|
||||||
const { html } = props;
|
const { html, onRegenerate, isRegenerating } = props;
|
||||||
|
|
||||||
return (
|
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
|
<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"
|
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 }}
|
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>
|
</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 = {
|
type GenerateAIGuideProps = {
|
||||||
onGuideSlugChange?: (guideSlug: string) => void;
|
onGuideSlugChange?: (guideSlug: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function GenerateAIGuide(props: GenerateAIGuideProps) {
|
export function GenerateAIGuide(props: GenerateAIGuideProps) {
|
||||||
const { onGuideSlugChange } = props;
|
const { onGuideSlugChange } = props;
|
||||||
|
@@ -1,4 +1,3 @@
|
|||||||
import { readStream } from '../lib/ai';
|
|
||||||
import { queryClient } from '../stores/query-client';
|
import { queryClient } from '../stores/query-client';
|
||||||
import { getAiCourseLimitOptions } from '../queries/ai-course';
|
import { getAiCourseLimitOptions } from '../queries/ai-course';
|
||||||
import { readChatStream } from '../lib/chat';
|
import { readChatStream } from '../lib/chat';
|
||||||
@@ -28,6 +27,7 @@ type GenerateGuideOptions = {
|
|||||||
onHtmlChange?: (html: string) => void;
|
onHtmlChange?: (html: string) => void;
|
||||||
onStreamingChange?: (isStreaming: boolean) => void;
|
onStreamingChange?: (isStreaming: boolean) => void;
|
||||||
onDetailsChange?: (details: GuideDetails) => void;
|
onDetailsChange?: (details: GuideDetails) => void;
|
||||||
|
onFinish?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function generateGuide(options: GenerateGuideOptions) {
|
export async function generateGuide(options: GenerateGuideOptions) {
|
||||||
@@ -47,11 +47,11 @@ export async function generateGuide(options: GenerateGuideOptions) {
|
|||||||
onHtmlChange,
|
onHtmlChange,
|
||||||
onStreamingChange,
|
onStreamingChange,
|
||||||
onDetailsChange,
|
onDetailsChange,
|
||||||
|
onFinish,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
onLoadingChange?.(true);
|
onLoadingChange?.(true);
|
||||||
onGuideChange?.('');
|
onGuideChange?.('');
|
||||||
onError?.('');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let response = null;
|
let response = null;
|
||||||
@@ -66,8 +66,7 @@ export async function generateGuide(options: GenerateGuideOptions) {
|
|||||||
},
|
},
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
isForce,
|
prompt,
|
||||||
customPrompt: prompt,
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -127,14 +126,14 @@ export async function generateGuide(options: GenerateGuideOptions) {
|
|||||||
onStreamingChange?.(false);
|
onStreamingChange?.(false);
|
||||||
},
|
},
|
||||||
onDetails: async (details) => {
|
onDetails: async (details) => {
|
||||||
const detailsJson = JSON.parse(details);
|
if (!details?.guideId || !details?.guideSlug) {
|
||||||
if (!detailsJson?.guideId || !detailsJson?.guideSlug) {
|
|
||||||
throw new Error('Invalid details');
|
throw new Error('Invalid details');
|
||||||
}
|
}
|
||||||
|
|
||||||
onDetailsChange?.(detailsJson);
|
onDetailsChange?.(details);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
onFinish?.();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
onError?.(error?.message || 'Something went wrong');
|
onError?.(error?.message || 'Something went wrong');
|
||||||
console.error('Error in course generation:', error);
|
console.error('Error in course generation:', error);
|
||||||
|
@@ -237,8 +237,7 @@ export function useRoadmapAIChat(options: Options) {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onDetails: (details) => {
|
onDetails: (details) => {
|
||||||
const detailsJson = JSON.parse(details);
|
const chatHistoryId = details?.chatHistoryId;
|
||||||
const chatHistoryId = detailsJson?.chatHistoryId;
|
|
||||||
if (!chatHistoryId) {
|
if (!chatHistoryId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@@ -27,7 +27,7 @@ export async function readChatStream(
|
|||||||
}: {
|
}: {
|
||||||
onMessage?: (message: string) => Promise<void>;
|
onMessage?: (message: string) => Promise<void>;
|
||||||
onMessageEnd?: (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();
|
const reader = stream.getReader();
|
||||||
@@ -73,7 +73,7 @@ export async function readChatStream(
|
|||||||
case CHAT_RESPONSE_PREFIX.message:
|
case CHAT_RESPONSE_PREFIX.message:
|
||||||
return { type: 'message', content: JSON.parse(content) };
|
return { type: 'message', content: JSON.parse(content) };
|
||||||
case CHAT_RESPONSE_PREFIX.details:
|
case CHAT_RESPONSE_PREFIX.details:
|
||||||
return { type: 'details', content };
|
return { type: 'details', content: JSON.parse(content) };
|
||||||
default:
|
default:
|
||||||
throw new Error('Invalid prefix: ' + prefix);
|
throw new Error('Invalid prefix: ' + prefix);
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user