mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-25 00:21:28 +02:00
wip
This commit is contained in:
@@ -219,7 +219,7 @@ export function AIChat(props: AIChatProps) {
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
chatHistoryId: defaultChatHistoryId,
|
||||
messages: messages.slice(-10),
|
||||
messages,
|
||||
force,
|
||||
}),
|
||||
});
|
||||
|
@@ -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(
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
134
src/components/GenerateGuide/AIGuideChat.tsx
Normal file
134
src/components/GenerateGuide/AIGuideChat.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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
125
src/hooks/use-chat.ts
Normal 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,
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user