1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-09-25 00:21:28 +02:00
This commit is contained in:
Arik Chakma
2025-06-13 16:41:32 +06:00
parent 9a58991ad1
commit 752e4759b1
6 changed files with 264 additions and 4 deletions

View File

@@ -219,7 +219,7 @@ export function AIChat(props: AIChatProps) {
credentials: 'include',
body: JSON.stringify({
chatHistoryId: defaultChatHistoryId,
messages: messages.slice(-10),
messages,
force,
}),
});

View File

@@ -47,7 +47,7 @@ type ChatHeaderButtonProps = {
target?: string;
};
function ChatHeaderButton(props: ChatHeaderButtonProps) {
export function ChatHeaderButton(props: ChatHeaderButtonProps) {
const { onClick, href, icon, children, className, target } = props;
const classNames = cn(

View File

@@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query';
import { getAiGuideOptions } from '../../queries/ai-guide';
import { queryClient } from '../../stores/query-client';
import { GenerateAIGuide } from './GenerateAIGuide';
import { AIGuideChat } from './AIGuideChat';
type AIGuideProps = {
guideSlug?: string;
@@ -27,7 +28,7 @@ export function AIGuide(props: AIGuideProps) {
{guideSlug && <AIGuideContent html={aiGuide?.html || ''} />}
{!guideSlug && <GenerateAIGuide onGuideSlugChange={setGuideSlug} />}
</div>
<div className="w-full max-w-[40%]">Chat Window</div>
<AIGuideChat guideSlug={guideSlug} />
</AITutorLayout>
);
}

View File

@@ -0,0 +1,134 @@
import { useCallback, useRef, useState } from 'react';
import { useChat } from '../../hooks/use-chat';
import { RoadmapAIChatCard } from '../RoadmapAIChat/RoadmapAIChatCard';
import { PauseCircleIcon, SendIcon, Trash2Icon } from 'lucide-react';
import { ChatHeaderButton } from '../FrameRenderer/RoadmapFloatingChat';
type AIGuideChatProps = {
guideSlug?: string;
};
export function AIGuideChat(props: AIGuideChatProps) {
const { guideSlug } = props;
const scrollareaRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useState('');
const { messages, status, streamedMessageHtml, sendMessages, stop } = useChat(
{
endpoint: '/v1-ai-guide-chat',
onError: (error) => {
console.error(error);
},
data: {
guideSlug,
},
},
);
const scrollToBottom = useCallback(
(behavior: 'smooth' | 'instant' = 'smooth') => {
scrollareaRef.current?.scrollTo({
top: scrollareaRef.current.scrollHeight,
behavior,
});
},
[scrollareaRef],
);
const isStreamingMessage = status === 'streaming';
const hasMessages = messages.length > 0;
const handleSubmitInput = useCallback(() => {
if (isStreamingMessage) {
return;
}
sendMessages([]);
}, [inputValue, isStreamingMessage, sendMessages]);
return (
<div className="flex h-full w-full max-w-[40%] flex-col overflow-hidden">
<div className="relative grow overflow-y-auto" ref={scrollareaRef}>
<div className="absolute inset-0 flex flex-col">
<div className="relative flex grow flex-col justify-end">
<div className="flex flex-col justify-end gap-2 px-3 py-2">
<RoadmapAIChatCard
role="assistant"
html="Hello, how can I help you today?"
isIntro
/>
{messages.map((chat, index) => {
return <RoadmapAIChatCard key={`chat-${index}`} {...chat} />;
})}
{status === 'streaming' && !streamedMessageHtml && (
<RoadmapAIChatCard role="assistant" html="Thinking..." />
)}
{status === 'streaming' && streamedMessageHtml && (
<RoadmapAIChatCard
role="assistant"
html={streamedMessageHtml}
/>
)}
</div>
</div>
</div>
</div>
{hasMessages && (
<div className="flex flex-row justify-end border-t border-gray-200 px-3 pt-2">
<ChatHeaderButton
icon={<Trash2Icon className="h-3.5 w-3.5" />}
className="rounded-md bg-gray-200 py-1 pr-2 pl-1.5 text-gray-500 hover:bg-gray-300"
>
Clear
</ChatHeaderButton>
</div>
)}
<div className="relative flex items-center border-t border-gray-200 text-sm">
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (isStreamingMessage) {
return;
}
handleSubmitInput();
}
}}
placeholder="Ask me anything about this roadmap..."
className="w-full resize-none px-3 py-4 outline-none"
/>
<button
className="absolute top-1/2 right-2 -translate-y-1/2 p-1 text-zinc-500 hover:text-black disabled:opacity-50"
disabled={isStreamingMessage}
onClick={() => {
if (isStreamingMessage) {
stop();
return;
}
handleSubmitInput();
}}
>
{isStreamingMessage ? (
<PauseCircleIcon className="h-4 w-4" />
) : (
<SendIcon className="h-4 w-4" />
)}
</button>
</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import type { RoadmapAIChatHistoryType } from './RoadmapAIChat';
import type { RoadmapAIChatHistoryType } from '../../hooks/use-roadmap-ai-chat';
import { cn } from '../../lib/classname';
import { BotIcon, User2Icon } from 'lucide-react';

125
src/hooks/use-chat.ts Normal file
View File

@@ -0,0 +1,125 @@
import { useCallback, useRef, useState } from 'react';
import { removeAuthToken } from '../lib/jwt';
import { readChatStream } from '../lib/chat';
import { markdownToHtmlWithHighlighting } from '../lib/markdown';
import { flushSync } from 'react-dom';
type ChatMessage = {
role: 'user' | 'assistant';
content: string;
html?: string;
};
type UseChatOptions = {
endpoint: string;
initialMessages?: ChatMessage[];
onError?: (error: Error) => void;
data?: Record<string, any>;
};
export function useChat(options: UseChatOptions) {
const { endpoint, initialMessages, onError, data = {} } = options;
const abortControllerRef = useRef<AbortController | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>(
initialMessages || [],
);
// we use it to show optimistic message
// and then replace it with the actual message
const [streamedMessageHtml, setStreamedMessageHtml] = useState<string | null>(
null,
);
const [status, setStatus] = useState<
'idle' | 'streaming' | 'loading' | 'ready' | 'error'
>('idle');
const sendMessages = useCallback(
async (messages: ChatMessage[]) => {
try {
setStatus('loading');
abortControllerRef.current?.abort();
abortControllerRef.current = new AbortController();
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ messages, ...data }),
signal: abortControllerRef.current?.signal,
});
if (!response.ok) {
const data = await response.json();
setStatus('error');
setMessages([...messages].slice(0, messages.length - 1));
if (data.status === 401) {
removeAuthToken();
window.location.reload();
}
throw new Error(data?.message || 'Something went wrong');
}
const stream = response.body;
if (!stream) {
setStatus('error');
setMessages([...messages].slice(0, messages.length - 1));
throw new Error('Something went wrong');
}
await readChatStream(stream, {
onMessage: async (content) => {
const html = await markdownToHtmlWithHighlighting(content);
flushSync(() => {
setStatus('streaming');
setStreamedMessageHtml(html);
});
},
onMessageEnd: async (content) => {
const html = await markdownToHtmlWithHighlighting(content);
flushSync(() => {
setStreamedMessageHtml(null);
setStatus('ready');
setMessages((prevMessages) => {
return [
...prevMessages,
{
role: 'assistant',
content,
html,
},
];
});
});
},
});
abortControllerRef.current = null;
} catch (error) {
onError?.(error as Error);
}
},
[endpoint, onError],
);
const stop = useCallback(() => {
if (!abortControllerRef.current) {
return;
}
abortControllerRef.current.abort();
abortControllerRef.current = null;
}, []);
return {
messages,
sendMessages,
status,
streamedMessageHtml,
stop,
};
}