feat: ai roadmaps and chat (#8821)

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip: ai roadmap

* wip

* wip

* Add question answers

* Update content generator

* Update question answer chat

* Refresh button not working

* UI for chat

* Update question answers functionality

* wip

* wip

* fix: ai roadmap regenerate

* Improve preferences

* feat: roadmap actions

* wip

* fix: hydration error

* Fix responsiveness issue

* fix: show preferences option only when questions exist

* fix: infinite loop issue

* feat: node click message populate

* Improve UI

* wip

* wip

* fix: roadmap regenerate

* Update UI

* Update placeholder

* Update answers

* Update navigations

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
This commit is contained in:
Arik ChakmaandKamran Ahmed authored and GitHub committed 2025-06-26 13:00:46 +01:00
1 parent 08e8b83262
commit e6d10ef592
53 files changed
+2933 -949

No files matched your search

+55 -10
View File
@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { ExternalLink } from 'lucide-react';
import { AlertCircleIcon, ExternalLink } from 'lucide-react';
import { useMemo, useState } from 'react';
import { flushSync } from 'react-dom';
import { generateGuide } from '../../helper/generate-ai-guide';
@@ -16,6 +16,9 @@ import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { AIGuideChat } from './AIGuideChat';
import { AIGuideContent } from './AIGuideContent';
import { GenerateAIGuide } from './GenerateAIGuide';
import { getAiCourseLimitOptions } from '../../queries/ai-course';
import { billingDetailsOptions } from '../../queries/billing';
import { showLoginPopup } from '../../lib/popup';
type AIGuideProps = {
guideSlug?: string;
@@ -32,10 +35,23 @@ export function AIGuide(props: AIGuideProps) {
// only fetch the guide if the guideSlug is provided
// otherwise we are still generating the guide
const { data: aiGuide, isLoading: isLoadingBySlug } = useQuery(
getAiGuideOptions(guideSlug),
queryClient,
);
const {
data: aiGuide,
isLoading: isLoadingBySlug,
error: aiGuideError,
} = useQuery(getAiGuideOptions(guideSlug), queryClient);
const {
data: tokenUsage,
isLoading: isTokenUsageLoading,
refetch: refetchTokenUsage,
} = useQuery(getAiCourseLimitOptions(), queryClient);
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
useQuery(billingDetailsOptions(), queryClient);
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
const isPaidUser = userBillingDetails?.status === 'active';
const { data: aiGuideSuggestions, isLoading: isAiGuideSuggestionsLoading } =
useQuery(
@@ -57,6 +73,16 @@ export function AIGuide(props: AIGuideProps) {
}, [aiGuideSuggestions]);
const handleRegenerate = async (prompt?: string) => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
if (!isPaidUser && isLimitExceeded) {
setShowUpgradeModal(true);
return;
}
flushSync(() => {
setIsRegenerating(true);
setRegeneratedHtml(null);
@@ -78,7 +104,6 @@ export function AIGuide(props: AIGuideProps) {
await generateGuide({
slug: aiGuide?.slug || '',
term: aiGuide?.keyword || '',
depth: aiGuide?.depth || '',
prompt,
onStreamingChange: setIsRegenerating,
onHtmlChange: setRegeneratedHtml,
@@ -93,24 +118,44 @@ export function AIGuide(props: AIGuideProps) {
});
};
const isLoading =
isLoadingBySlug ||
isRegenerating ||
isTokenUsageLoading ||
isBillingDetailsLoading;
return (
<AITutorLayout
wrapperClassName="flex-row p-0 lg:p-0 overflow-hidden bg-white"
wrapperClassName="flex-row p-0 lg:p-0 relative overflow-hidden bg-white"
containerClassName="h-[calc(100vh-49px)] overflow-hidden relative"
>
{showUpgradeModal && (
<UpgradeAccountModal onClose={() => setShowUpgradeModal(false)} />
)}
{!isLoading && aiGuideError && (
<div className="absolute inset-0 z-10 flex h-full flex-col items-center justify-center bg-white">
<div className="flex flex-col items-center justify-center gap-2">
<AlertCircleIcon className="size-10 text-gray-500" />
<p className="text-center">
{aiGuideError?.message || 'Something went wrong'}
</p>
</div>
</div>
)}
<div className="grow overflow-y-auto p-4 pt-0">
{guideSlug && (
{guideSlug && !aiGuideError && (
<AIGuideContent
html={regeneratedHtml || aiGuide?.html || ''}
onRegenerate={handleRegenerate}
isLoading={isLoadingBySlug || isRegenerating}
isLoading={isLoading}
guideSlug={guideSlug}
/>
)}
{!guideSlug && <GenerateAIGuide onGuideSlugChange={setGuideSlug} />}
{!guideSlug && !aiGuideError && (
<GenerateAIGuide onGuideSlugChange={setGuideSlug} />
)}
{aiGuide && !isRegenerating && (
<div className="mx-auto mt-12 mb-12 max-w-4xl">
+24 -5
View File
@@ -60,11 +60,8 @@ export function AIGuideChat(props: AIGuideChatProps) {
refetch: refetchTokenUsage,
} = useQuery(getAiCourseLimitOptions(), queryClient);
const {
data: userBillingDetails,
isLoading: isBillingDetailsLoading,
refetch: refetchBillingDetails,
} = useQuery(billingDetailsOptions(), queryClient);
const { data: userBillingDetails, isLoading: isBillingDetailsLoading } =
useQuery(billingDetailsOptions(), queryClient);
const isLimitExceeded = (tokenUsage?.used || 0) >= (tokenUsage?.limit || 0);
const isPaidUser = userBillingDetails?.status === 'active';
@@ -127,6 +124,10 @@ export function AIGuideChat(props: AIGuideChatProps) {
});
sendMessages(newMessages);
setInputValue('');
setTimeout(() => {
scrollToBottom('smooth');
}, 0);
},
[inputValue, isStreamingMessage, messages, sendMessages, setMessages],
);
@@ -332,6 +333,24 @@ export function AIGuideChat(props: AIGuideChatProps) {
</div>
)}
{!isLoggedIn() && (
<div className="absolute inset-0 z-10 flex items-center justify-center gap-2 bg-black text-white">
<LockIcon
className="size-4 cursor-not-allowed"
strokeWidth={2.5}
/>
<p className="cursor-not-allowed">Please login to continue</p>
<button
onClick={() => {
showLoginPopup();
}}
className="rounded-md bg-white px-2 py-1 text-xs font-medium text-black hover:bg-gray-300"
>
Login / Register
</button>
</div>
)}
<input
ref={inputRef}
type="text"
@@ -2,15 +2,17 @@ import './AIGuideContent.css';
import { AIGuideRegenerate } from './AIGuideRegenerate';
import { cn } from '../../lib/classname';
import { LoadingChip } from '../LoadingChip';
import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat';
type AIGuideContentProps = {
html: string;
onRegenerate?: (prompt?: string) => void;
isLoading?: boolean;
guideSlug?: string;
};
export function AIGuideContent(props: AIGuideContentProps) {
const { html, onRegenerate, isLoading } = props;
const { html, onRegenerate, isLoading, guideSlug } = props;
return (
<div
@@ -30,9 +32,12 @@ export function AIGuideContent(props: AIGuideContentProps) {
</div>
)}
{onRegenerate && !isLoading && (
{onRegenerate && !isLoading && guideSlug && (
<div className="absolute top-4 right-4">
<AIGuideRegenerate onRegenerate={onRegenerate} />
<AIGuideRegenerate
onRegenerate={onRegenerate}
guideSlug={guideSlug}
/>
</div>
)}
</div>
@@ -1,25 +1,74 @@
import { PenSquare, RefreshCcw } from 'lucide-react';
import { PenSquare, RefreshCcw, SettingsIcon } 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';
import { UpdatePreferences } from './UpdatePreferences';
import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat';
import { useMutation, useQuery } from '@tanstack/react-query';
import { getAiGuideOptions } from '../../queries/ai-guide';
import { queryClient } from '../../stores/query-client';
import { httpPost } from '../../lib/query-http';
import { useAuth } from '../../hooks/use-auth';
import { showLoginPopup } from '../../lib/popup';
import { isLoggedIn } from '../../lib/jwt';
type AIGuideRegenerateProps = {
onRegenerate: (prompt?: string) => void;
guideSlug: string;
};
export function AIGuideRegenerate(props: AIGuideRegenerateProps) {
const { onRegenerate } = props;
const { onRegenerate, guideSlug } = props;
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
const [showPromptModal, setShowPromptModal] = useState(false);
const [showUpdatePreferencesModal, setShowUpdatePreferencesModal] =
useState(false);
const currentUser = useAuth();
const ref = useRef<HTMLDivElement>(null);
useOutsideClick(ref, () => setIsDropdownVisible(false));
const { data: aiGuide } = useQuery(getAiGuideOptions(guideSlug), queryClient);
const { mutate: updatePreferences, isPending: isUpdating } = useMutation(
{
mutationFn: (questionAndAnswers: QuestionAnswerChatMessage[]) => {
return httpPost(`/v1-update-guide-preferences/${guideSlug}`, {
questionAndAnswers,
});
},
onSuccess: (_, vars) => {
queryClient.setQueryData(
getAiGuideOptions(guideSlug).queryKey,
(old) => {
if (!old) {
return old;
}
return {
...old,
questionAndAnswers: vars,
};
},
);
setShowUpdatePreferencesModal(false);
setIsDropdownVisible(false);
onRegenerate();
},
},
queryClient,
);
const showUpdatePreferences =
aiGuide?.questionAndAnswers &&
aiGuide.questionAndAnswers.length > 0 &&
currentUser?.id === aiGuide.userId;
return (
<>
{showUpgradeModal && (
@@ -41,6 +90,19 @@ export function AIGuideRegenerate(props: AIGuideRegenerateProps) {
/>
)}
{showUpdatePreferencesModal && (
<UpdatePreferences
onClose={() => setShowUpdatePreferencesModal(false)}
questionAndAnswers={aiGuide?.questionAndAnswers}
term={aiGuide?.keyword || ''}
format="guide"
onUpdatePreferences={(questionAndAnswers) => {
updatePreferences(questionAndAnswers);
}}
isUpdating={isUpdating}
/>
)}
<div ref={ref} className="relative flex items-stretch">
<button
className={cn('rounded-md px-2.5 text-gray-400 hover:text-black', {
@@ -51,9 +113,31 @@ export function AIGuideRegenerate(props: AIGuideRegenerateProps) {
<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">
<div className="absolute top-full right-0 min-w-[190px] translate-y-1 overflow-hidden rounded-md border border-gray-200 bg-white shadow-md">
{showUpdatePreferences && (
<button
onClick={() => {
setIsDropdownVisible(false);
setShowUpdatePreferencesModal(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"
>
<SettingsIcon
size={16}
className="text-gray-400"
strokeWidth={2.5}
/>
Update Preferences
</button>
)}
<button
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
setIsDropdownVisible(false);
onRegenerate();
}}
@@ -68,6 +152,11 @@ export function AIGuideRegenerate(props: AIGuideRegenerateProps) {
</button>
<button
onClick={() => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
setIsDropdownVisible(false);
setShowPromptModal(true);
}}
@@ -7,6 +7,8 @@ import { AIGuideContent } from './AIGuideContent';
import { queryClient } from '../../stores/query-client';
import { getAiGuideOptions } from '../../queries/ai-guide';
import { LoadingChip } from '../LoadingChip';
import type { QuestionAnswerChatMessage } from '../ContentGenerator/QuestionAnswerChat';
import { getQuestionAnswerChatMessages } from '../../lib/ai-questions';
type GenerateAIGuideProps = {
onGuideSlugChange?: (guideSlug: string) => void;
@@ -26,48 +28,32 @@ export function GenerateAIGuide(props: GenerateAIGuideProps) {
useEffect(() => {
const params = getUrlParams();
const paramsTerm = params?.term;
const paramsDepth = params?.depth;
const paramsSrc = params?.src || 'search';
if (!paramsTerm || !paramsDepth) {
if (!paramsTerm) {
return;
}
let paramsGoal = '';
let paramsAbout = '';
let paramsCustomInstructions = '';
let questionAndAnswers: QuestionAnswerChatMessage[] = [];
const sessionId = params?.id;
if (sessionId) {
const fineTuneData = getCourseFineTuneData(sessionId);
if (fineTuneData) {
paramsGoal = fineTuneData.goal;
paramsAbout = fineTuneData.about;
paramsCustomInstructions = fineTuneData.customInstructions;
}
questionAndAnswers = getQuestionAnswerChatMessages(sessionId);
}
handleGenerateDocument({
term: paramsTerm,
depth: paramsDepth,
instructions: paramsCustomInstructions,
goal: paramsGoal,
about: paramsAbout,
src: paramsSrc,
questionAndAnswers,
});
}, []);
const handleGenerateDocument = async (options: {
term: string;
depth: string;
instructions?: string;
goal?: string;
about?: string;
isForce?: boolean;
prompt?: string;
src?: string;
questionAndAnswers?: QuestionAnswerChatMessage[];
}) => {
const { term, depth, isForce, prompt, instructions, goal, about, src } =
options;
const { term, isForce, prompt, src, questionAndAnswers } = options;
if (!isLoggedIn()) {
window.location.href = '/ai';
@@ -76,7 +62,6 @@ export function GenerateAIGuide(props: GenerateAIGuideProps) {
await generateGuide({
term,
depth,
onDetailsChange: (details) => {
const { guideId, guideSlug, creatorId, title } = details;
@@ -86,7 +71,6 @@ export function GenerateAIGuide(props: GenerateAIGuideProps) {
title,
html: htmlRef.current,
keyword: term,
depth,
content,
tokens: {
prompt: 0,
@@ -96,6 +80,7 @@ export function GenerateAIGuide(props: GenerateAIGuideProps) {
relatedTopics: [],
deepDiveTopics: [],
questions: [],
questionAndAnswers,
viewCount: 0,
lastVisitedAt: new Date(),
createdAt: new Date(),
@@ -112,12 +97,10 @@ export function GenerateAIGuide(props: GenerateAIGuideProps) {
},
onLoadingChange: setIsLoading,
onError: setError,
instructions,
goal,
about,
isForce,
prompt,
src,
questionAndAnswers,
onHtmlChange: (html) => {
htmlRef.current = html;
setHtml(html);
@@ -0,0 +1,98 @@
import { useMemo, useState } from 'react';
import type { AllowedFormat } from '../ContentGenerator/ContentGenerator';
import {
QuestionAnswerChat,
type QuestionAnswerChatMessage,
} from '../ContentGenerator/QuestionAnswerChat';
import { Modal } from '../Modal';
type UpdatePreferencesProps = {
onClose: () => void;
term: string;
format: AllowedFormat;
questionAndAnswers?: QuestionAnswerChatMessage[];
onUpdatePreferences: (
questionAndAnswers: QuestionAnswerChatMessage[],
) => void;
isUpdating: boolean;
};
export function UpdatePreferences(props: UpdatePreferencesProps) {
const {
onClose,
questionAndAnswers: defaultQuestionAndAnswers,
term,
format,
onUpdatePreferences,
isUpdating,
} = props;
const [questionAnswerChatMessages, setQuestionAnswerChatMessages] = useState<
QuestionAnswerChatMessage[]
>(defaultQuestionAndAnswers || []);
const defaultQuestions = defaultQuestionAndAnswers
?.filter((message) => message.role === 'assistant')
.map((message) => ({
question: message.question,
possibleAnswers: message.possibleAnswers,
}));
const hasChangedQuestionAndAnswers = useMemo(() => {
return (
JSON.stringify(questionAnswerChatMessages) !==
JSON.stringify(defaultQuestionAndAnswers)
);
}, [questionAnswerChatMessages, defaultQuestionAndAnswers]);
console.log(questionAnswerChatMessages);
console.log(defaultQuestionAndAnswers);
const userAnswers = questionAnswerChatMessages.filter(
(message) => message.role === 'user',
);
const hasAnsweredAllQuestions =
userAnswers.length === defaultQuestions?.length;
return (
<Modal
onClose={onClose}
bodyClassName="p-4 flex flex-col gap-4"
wrapperClassName="max-w-xl h-auto"
overlayClassName="items-start md:items-center"
>
<div className="flex flex-col gap-1">
<h2 className="text-lg font-medium">Update Preferences</h2>
<p className="text-sm text-gray-500">
Update your preferences for better content
</p>
</div>
<QuestionAnswerChat
term={term}
format={format}
questionAnswerChatMessages={questionAnswerChatMessages}
setQuestionAnswerChatMessages={setQuestionAnswerChatMessages}
defaultQuestions={defaultQuestions}
onGenerateNow={() => {
onUpdatePreferences(questionAnswerChatMessages);
}}
className="-mx-2 h-[400px] border-none p-0"
type="update"
/>
{hasChangedQuestionAndAnswers && hasAnsweredAllQuestions && (
<button
className="rounded-lg bg-black px-4 py-2 text-white hover:opacity-80 disabled:opacity-50"
disabled={isUpdating || !hasChangedQuestionAndAnswers}
onClick={() => {
onUpdatePreferences(questionAnswerChatMessages);
}}
>
{isUpdating ? 'Updating...' : 'Apply preferences'}
</button>
)}
</Modal>
);
}