mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-25 08:35:42 +02:00
wip
This commit is contained in:
@@ -219,7 +219,7 @@ export function AIChat(props: AIChatProps) {
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
chatHistoryId: defaultChatHistoryId,
|
chatHistoryId: defaultChatHistoryId,
|
||||||
messages: messages.slice(-10),
|
messages,
|
||||||
force,
|
force,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
@@ -47,7 +47,7 @@ type ChatHeaderButtonProps = {
|
|||||||
target?: string;
|
target?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ChatHeaderButton(props: ChatHeaderButtonProps) {
|
export function ChatHeaderButton(props: ChatHeaderButtonProps) {
|
||||||
const { onClick, href, icon, children, className, target } = props;
|
const { onClick, href, icon, children, className, target } = props;
|
||||||
|
|
||||||
const classNames = cn(
|
const classNames = cn(
|
||||||
|
@@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { getAiGuideOptions } from '../../queries/ai-guide';
|
import { getAiGuideOptions } from '../../queries/ai-guide';
|
||||||
import { queryClient } from '../../stores/query-client';
|
import { queryClient } from '../../stores/query-client';
|
||||||
import { GenerateAIGuide } from './GenerateAIGuide';
|
import { GenerateAIGuide } from './GenerateAIGuide';
|
||||||
|
import { AIGuideChat } from './AIGuideChat';
|
||||||
|
|
||||||
type AIGuideProps = {
|
type AIGuideProps = {
|
||||||
guideSlug?: string;
|
guideSlug?: string;
|
||||||
@@ -27,7 +28,7 @@ export function AIGuide(props: AIGuideProps) {
|
|||||||
{guideSlug && <AIGuideContent html={aiGuide?.html || ''} />}
|
{guideSlug && <AIGuideContent html={aiGuide?.html || ''} />}
|
||||||
{!guideSlug && <GenerateAIGuide onGuideSlugChange={setGuideSlug} />}
|
{!guideSlug && <GenerateAIGuide onGuideSlugChange={setGuideSlug} />}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full max-w-[40%]">Chat Window</div>
|
<AIGuideChat guideSlug={guideSlug} />
|
||||||
</AITutorLayout>
|
</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 { cn } from '../../lib/classname';
|
||||||
import { BotIcon, User2Icon } from 'lucide-react';
|
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