mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-08 00:00:42 +02:00
wip
This commit is contained in:
@@ -6,9 +6,6 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useEffect, useId, useState, type FormEvent } from 'react';
|
import { useEffect, useId, useState, type FormEvent } from 'react';
|
||||||
import { FormatItem } from './FormatItem';
|
import { FormatItem } from './FormatItem';
|
||||||
import { GuideOptions } from './GuideOptions';
|
|
||||||
import { FineTuneCourse } from '../GenerateCourse/FineTuneCourse';
|
|
||||||
import { CourseOptions } from './CourseOptions';
|
|
||||||
import {
|
import {
|
||||||
clearFineTuneData,
|
clearFineTuneData,
|
||||||
getCourseFineTuneData,
|
getCourseFineTuneData,
|
||||||
@@ -20,9 +17,10 @@ import { showLoginPopup } from '../../lib/popup';
|
|||||||
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
import { UpgradeAccountModal } from '../Billing/UpgradeAccountModal';
|
||||||
import { useIsPaidUser } from '../../queries/billing';
|
import { useIsPaidUser } from '../../queries/billing';
|
||||||
import { cn } from '../../lib/classname';
|
import { cn } from '../../lib/classname';
|
||||||
|
import { QuestionAnswerChat } from './QuestionAnswerChat';
|
||||||
|
|
||||||
const allowedFormats = ['course', 'guide', 'roadmap'] as const;
|
const allowedFormats = ['course', 'guide', 'roadmap'] as const;
|
||||||
type AllowedFormat = (typeof allowedFormats)[number];
|
export type AllowedFormat = (typeof allowedFormats)[number];
|
||||||
|
|
||||||
export function ContentGenerator() {
|
export function ContentGenerator() {
|
||||||
const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false);
|
const [isUpgradeModalOpen, setIsUpgradeModalOpen] = useState(false);
|
||||||
@@ -145,7 +143,10 @@ export function ContentGenerator() {
|
|||||||
id={titleFieldId}
|
id={titleFieldId}
|
||||||
placeholder="Enter a topic"
|
placeholder="Enter a topic"
|
||||||
value={title}
|
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"
|
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
|
required
|
||||||
minLength={3}
|
minLength={3}
|
||||||
@@ -172,48 +173,21 @@ export function ContentGenerator() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedFormat === 'guide' && (
|
<label
|
||||||
<GuideOptions depth={depth} setDepth={setDepth} />
|
className="flex items-center gap-2 rounded-xl border border-gray-200 bg-white p-4"
|
||||||
)}
|
htmlFor={fineTuneOptionsId}
|
||||||
|
>
|
||||||
{selectedFormat === 'course' && (
|
<input
|
||||||
<CourseOptions
|
type="checkbox"
|
||||||
difficulty={difficulty}
|
id={fineTuneOptionsId}
|
||||||
setDifficulty={setDifficulty}
|
checked={showFineTuneOptions}
|
||||||
|
onChange={(e) => setShowFineTuneOptions(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
)}
|
Answer the following questions for a better {selectedFormat}
|
||||||
|
</label>
|
||||||
|
|
||||||
{selectedFormat !== 'roadmap' && (
|
{showFineTuneOptions && (
|
||||||
<>
|
<QuestionAnswerChat term={title} format={selectedFormat} />
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
241
src/components/ContentGenerator/QuestionAnswerChat.tsx
Normal file
241
src/components/ContentGenerator/QuestionAnswerChat.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
30
src/queries/user-ai-session.ts
Normal file
30
src/queries/user-ai-session.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
Reference in New Issue
Block a user