1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-09-03 14:22:41 +02:00
This commit is contained in:
Arik Chakma
2025-06-20 01:22:56 +06:00
parent 2379ab3640
commit 25967a85e1
5 changed files with 290 additions and 196 deletions

View File

@@ -6,9 +6,6 @@ import {
} from 'lucide-react';
import { useEffect, useId, useState, type FormEvent } from 'react';
import { FormatItem } from './FormatItem';
import { GuideOptions } from './GuideOptions';
import { FineTuneCourse } from '../GenerateCourse/FineTuneCourse';
import { CourseOptions } from './CourseOptions';
import {
clearFineTuneData,
getCourseFineTuneData,
@@ -20,9 +17,10 @@ import { showLoginPopup } from '../../lib/popup';
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
import { useIsPaidUser } from '../../queries/billing';
import { cn } from '../../lib/classname';
import { QuestionAnswerChat } from './QuestionAnswerChat';
const allowedFormats = ['course', 'guide', 'roadmap'] as const;
type AllowedFormat = (typeof allowedFormats)[number];
export type AllowedFormat = (typeof allowedFormats)[number];
export function ContentGenerator() {
const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false);
@@ -145,7 +143,10 @@ export function ContentGenerator() {
id={titleFieldId}
placeholder="Enter a topic"
value={title}
onChange={(e) => setTitle(e.target.value)}
onChange={(e) => {
setTitle(e.target.value);
setShowFineTuneOptions(false);
}}
className="block w-full rounded-xl border border-gray-200 bg-white p-4 outline-none placeholder:text-gray-500 focus:border-gray-500"
required
minLength={3}
@@ -172,48 +173,21 @@ export function ContentGenerator() {
</div>
</div>
{selectedFormat === 'guide' && (
<GuideOptions depth={depth} setDepth={setDepth} />
)}
{selectedFormat === 'course' && (
<CourseOptions
difficulty={difficulty}
setDifficulty={setDifficulty}
<label
className="flex items-center gap-2 rounded-xl border border-gray-200 bg-white p-4"
htmlFor={fineTuneOptionsId}
>
<input
type="checkbox"
id={fineTuneOptionsId}
checked={showFineTuneOptions}
onChange={(e) => setShowFineTuneOptions(e.target.checked)}
/>
)}
Answer the following questions for a better {selectedFormat}
</label>
{selectedFormat !== 'roadmap' && (
<>
<label
className={cn(
'flex items-center gap-2 border border-gray-200 bg-white p-4',
showFineTuneOptions && 'rounded-t-xl',
!showFineTuneOptions && 'rounded-xl',
)}
htmlFor={fineTuneOptionsId}
>
<input
type="checkbox"
id={fineTuneOptionsId}
checked={showFineTuneOptions}
onChange={(e) => setShowFineTuneOptions(e.target.checked)}
/>
Explain more for a better result
</label>
{showFineTuneOptions && (
<FineTuneCourse
hasFineTuneData={showFineTuneOptions}
about={about}
goal={goal}
customInstructions={customInstructions}
setAbout={setAbout}
setGoal={setGoal}
setCustomInstructions={setCustomInstructions}
className="-mt-4.5 overflow-hidden rounded-b-xl border border-gray-200 bg-white [&_div:first-child_label]:border-t-0"
/>
)}
</>
{showFineTuneOptions && (
<QuestionAnswerChat term={title} format={selectedFormat} />
)}
<button

View File

@@ -1,79 +0,0 @@
import { useId, useState } from 'react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../Select';
type CourseOptionsProps = {
difficulty: string;
setDifficulty: (difficulty: string) => void;
};
export function CourseOptions(props: CourseOptionsProps) {
const { difficulty, setDifficulty } = props;
const difficultySelectId = useId();
const difficultyOptions = [
{
label: 'Beginner',
value: 'beginner',
description: 'Covers fundamental concepts',
},
{
label: 'Intermediate',
value: 'intermediate',
description: 'Explore advanced topics',
},
{
label: 'Advanced',
value: 'advanced',
description: 'Deep dives into complex concepts',
},
];
const selectedDifficulty = difficultyOptions.find(
(option) => option.value === difficulty,
);
return (
<div className="flex flex-col gap-2">
<label
htmlFor={difficultySelectId}
className="inline-block text-gray-500"
>
Choose difficulty level
</label>
<Select value={difficulty} onValueChange={setDifficulty}>
<SelectTrigger
id={difficultySelectId}
className="h-auto rounded-xl bg-white p-4 text-base"
>
{selectedDifficulty && (
<div className="flex flex-col gap-1">
<span>{selectedDifficulty.label}</span>
</div>
)}
{!selectedDifficulty && (
<SelectValue placeholder="Select a difficulty" />
)}
</SelectTrigger>
<SelectContent className="rounded-xl bg-white">
{difficultyOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex flex-col gap-1">
<span>{option.label}</span>
<span className="text-xs text-gray-500">
{option.description}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@@ -1,72 +0,0 @@
import { useId } from 'react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../Select';
type GuideOptionsProps = {
depth: string;
setDepth: (depth: string) => void;
};
export function GuideOptions(props: GuideOptionsProps) {
const { depth, setDepth } = props;
const depthSelectId = useId();
const depthOptions = [
{
label: 'Essentials',
value: 'essentials',
description: 'Just the core concepts',
},
{
label: 'Detailed',
value: 'detailed',
description: 'In-depth explanation',
},
{
label: 'Complete',
value: 'complete',
description: 'Cover the topic fully',
},
];
const selectedDepth = depthOptions.find((option) => option.value === depth);
return (
<div className="flex flex-col gap-2">
<label htmlFor={depthSelectId} className="inline-block text-gray-500">
Choose depth of content
</label>
<Select value={depth} onValueChange={setDepth}>
<SelectTrigger
id={depthSelectId}
className="h-auto rounded-xl bg-white p-4 text-base"
>
{selectedDepth && (
<div className="flex flex-col gap-1">
<span>{selectedDepth.label}</span>
</div>
)}
{!selectedDepth && <SelectValue placeholder="Select a depth" />}
</SelectTrigger>
<SelectContent className="rounded-xl bg-white">
{depthOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex flex-col gap-1">
<span>{option.label}</span>
<span className="text-xs text-gray-500">
{option.description}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@@ -0,0 +1,241 @@
import { useQuery } from '@tanstack/react-query';
import { queryClient } from '../../stores/query-client';
import { aiQuestionSuggestionsOptions } from '../../queries/user-ai-session';
import type { AllowedFormat } from './ContentGenerator';
import { Loader2Icon, SendIcon } from 'lucide-react';
import { useRef, useState } from 'react';
import { cn } from '../../lib/classname';
import { flushSync } from 'react-dom';
import { CheckIcon } from '../ReactIcons/CheckIcon';
type QuestionAnswerChatProps = {
term: string;
format: AllowedFormat;
};
type QuestionAnswerChatMessage =
| { role: 'user'; answer: string }
| {
role: 'assistant';
question: string;
possibleAnswers: string[];
};
export function QuestionAnswerChat(props: QuestionAnswerChatProps) {
const { term, format } = props;
const [questionAnswerChatMessages, setQuestionAnswerChatMessages] = useState<
QuestionAnswerChatMessage[]
>([]);
const [activeMessageIndex, setActiveMessageIndex] = useState(0);
const [message, setMessage] = useState('');
const [status, setStatus] = useState<'answering' | 'done'>('answering');
const scrollAreaRef = useRef<HTMLDivElement>(null);
const {
data: aiQuestionSuggestions,
isLoading: isLoadingAiQuestionSuggestions,
} = useQuery(aiQuestionSuggestionsOptions({ term, format }), queryClient);
const activeMessage = aiQuestionSuggestions?.questions[activeMessageIndex];
const scrollToBottom = () => {
if (!scrollAreaRef.current) {
return;
}
scrollAreaRef.current.scrollTo({
top: scrollAreaRef.current.scrollHeight,
behavior: 'instant',
});
};
const handleAnswerSelect = (answer: string) => {
const trimmedAnswer = answer.trim();
if (!activeMessage || !trimmedAnswer) {
return;
}
setQuestionAnswerChatMessages((prev) => {
return [
...prev,
{
role: 'assistant',
...activeMessage,
},
{
role: 'user',
answer: trimmedAnswer,
},
];
});
setMessage('');
const hasMoreMessages =
activeMessageIndex < aiQuestionSuggestions.questions.length - 1;
if (!hasMoreMessages) {
setStatus('done');
return;
}
flushSync(() => {
setActiveMessageIndex(activeMessageIndex + 1);
setStatus('answering');
});
scrollToBottom();
};
const canGenerateNow =
// user can generate after answering 5 questions -> 5 * 2 messages (user and assistant)
!isLoadingAiQuestionSuggestions && questionAnswerChatMessages.length > 10;
return (
<>
<div className="relative h-[300px] w-full overflow-hidden rounded-xl border border-gray-200 bg-white">
{isLoadingAiQuestionSuggestions && (
<div className="absolute inset-0 flex items-center justify-center bg-white">
<div className="flex animate-pulse items-center gap-2 rounded-full border border-gray-200 bg-gray-50 p-2 px-4 text-sm">
<Loader2Icon className="size-4 animate-spin" />
<span>Generating personalized questions...</span>
</div>
</div>
)}
{!isLoadingAiQuestionSuggestions && status === 'done' && (
<div className="absolute inset-0 flex items-center justify-center bg-white">
<div className="flex flex-col items-center">
<CheckIcon additionalClasses="size-12" />
<p className="mt-3 text-lg">Preferences saved</p>
<p className="text-sm text-gray-500">
You can now start generating {format}
</p>
</div>
</div>
)}
{!isLoadingAiQuestionSuggestions && status === 'answering' && (
<div className="flex h-full w-full flex-col bg-white">
<div
ref={scrollAreaRef}
className="relative h-full w-full grow overflow-y-auto"
>
<div className="absolute inset-0 flex flex-col">
<div className="flex w-full grow flex-col justify-end gap-2 p-2">
{questionAnswerChatMessages.map((message, index) => (
<QuestionAnswerChatMessage
key={index}
role={message.role}
question={
message.role === 'assistant'
? message.question
: undefined
}
answer={
message.role === 'user' ? message.answer : undefined
}
/>
))}
<QuestionAnswerChatMessage
role="assistant"
question={activeMessage?.question ?? ''}
/>
{activeMessage && (
<div>
<p className="text-sm text-gray-500">
Pick an answer from these or write it below
</p>
<div className="mt-2 flex flex-wrap gap-2 text-sm text-gray-500">
{activeMessage.possibleAnswers.map((answer) => (
<button
type="button"
key={answer}
className="cursor-pointer rounded-lg bg-gray-100 p-1 px-2 hover:bg-gray-200"
onClick={() => {
handleAnswerSelect(answer);
}}
>
{answer}
</button>
))}
</div>
</div>
)}
</div>
</div>
</div>
<div className="p-2">
<div
className="flex w-full items-center justify-between gap-2 rounded-lg border border-gray-200 bg-white p-2"
onSubmit={(e) => {
e.preventDefault();
handleAnswerSelect(message);
}}
>
<input
value={message}
onChange={(e) => setMessage(e.target.value)}
className="w-full bg-transparent text-sm focus:outline-none"
placeholder="Write your answer here..."
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleAnswerSelect(message);
setMessage('');
}
}}
/>
<button
type="button"
className="flex size-7 shrink-0 items-center justify-center rounded-md hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
>
<SendIcon className="size-4" />
</button>
</div>
</div>
</div>
)}
</div>
{canGenerateNow && status !== 'done' && (
<div className="flex w-full items-center rounded-lg border border-gray-200 bg-white p-2">
<p className="text-sm">
Keep answering for better output or{' '}
<button className="text-blue-500 underline underline-offset-2 hover:no-underline focus:outline-none">
Generate now.
</button>
</p>
</div>
)}
</>
);
}
type QuestionAnswerChatMessageProps = {
role: 'user' | 'assistant';
question?: string;
answer?: string;
};
function QuestionAnswerChatMessage(props: QuestionAnswerChatMessageProps) {
const { role, question, answer } = props;
return (
<div
className={cn(
'flex w-fit items-center gap-2 rounded-lg border p-2 text-pretty',
role === 'user' && 'self-end border-gray-200 bg-gray-300/30',
role === 'assistant' && 'border-yellow-200 bg-yellow-300/30',
)}
>
{role === 'assistant' && <div className="text-sm">{question}</div>}
{role === 'user' && <div className="text-sm">{answer}</div>}
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { queryOptions } from '@tanstack/react-query';
import { httpGet } from '../lib/query-http';
type AIQuestionSuggestionsQuery = {
term: string;
format: string;
};
export type AIQuestionSuggestionsResponse = {
questions: {
question: string;
possibleAnswers: string[];
}[];
};
export function aiQuestionSuggestionsOptions(
query: AIQuestionSuggestionsQuery,
) {
return queryOptions({
queryKey: ['ai-question-suggestions', query],
queryFn: () => {
return httpGet<AIQuestionSuggestionsResponse>(
`/v1-ai-question-suggestions`,
query,
);
},
enabled: !!query.term && !!query.format,
refetchOnMount: false,
});
}