mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-09-03 14:22:41 +02:00
Chat inside floating chat
This commit is contained in:
@@ -1,11 +1,18 @@
|
|||||||
import { Wand2 } from 'lucide-react';
|
import { Wand2, SendIcon, PauseCircleIcon } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState, useMemo, Fragment } from 'react';
|
||||||
import { roadmapJSONOptions } from '../../queries/roadmap';
|
import { roadmapJSONOptions } from '../../queries/roadmap';
|
||||||
import { queryClient } from '../../stores/query-client';
|
import { queryClient } from '../../stores/query-client';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { RoadmapAIChatCard } from '../RoadmapAIChat/RoadmapAIChatCard';
|
import { RoadmapAIChatCard } from '../RoadmapAIChat/RoadmapAIChatCard';
|
||||||
import { lockBodyScroll } from '../../lib/dom';
|
import { lockBodyScroll } from '../../lib/dom';
|
||||||
import { useKeydown } from '../../hooks/use-keydown';
|
import { useKeydown } from '../../hooks/use-keydown';
|
||||||
|
import {
|
||||||
|
useRoadmapAIChat,
|
||||||
|
type RoadmapAIChatHistoryType,
|
||||||
|
} from '../../hooks/use-roadmap-ai-chat';
|
||||||
|
import { flushSync } from 'react-dom';
|
||||||
|
import type { JSONContent } from '@tiptap/core';
|
||||||
|
import { slugify } from '../../lib/slugger';
|
||||||
|
|
||||||
type RoadmapChatProps = {
|
type RoadmapChatProps = {
|
||||||
roadmapId: string;
|
roadmapId: string;
|
||||||
@@ -14,12 +21,56 @@ type RoadmapChatProps = {
|
|||||||
export function RoadmapFloatingChat(props: RoadmapChatProps) {
|
export function RoadmapFloatingChat(props: RoadmapChatProps) {
|
||||||
const { roadmapId } = props;
|
const { roadmapId } = props;
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const scrollareaRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
|
||||||
const { data: roadmapDetail, isLoading: isRoadmapDetailLoading } = useQuery(
|
const { data: roadmapDetail, isLoading: isRoadmapDetailLoading } = useQuery(
|
||||||
roadmapJSONOptions(roadmapId),
|
roadmapJSONOptions(roadmapId),
|
||||||
queryClient,
|
queryClient,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const totalTopicCount = useMemo(() => {
|
||||||
|
const allowedTypes = ['topic', 'subtopic', 'todo'];
|
||||||
|
return (
|
||||||
|
roadmapDetail?.json?.nodes.filter((node) =>
|
||||||
|
allowedTypes.includes(node.type || ''),
|
||||||
|
).length ?? 0
|
||||||
|
);
|
||||||
|
}, [roadmapDetail]);
|
||||||
|
|
||||||
|
const onSelectTopic = (topicId: string, topicTitle: string) => {
|
||||||
|
// For now just scroll to bottom and close overlay
|
||||||
|
const topicSlug = slugify(topicTitle) + '@' + topicId;
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('roadmap.node.click', {
|
||||||
|
detail: {
|
||||||
|
resourceType: 'roadmap',
|
||||||
|
resourceId: roadmapId,
|
||||||
|
topicId: topicSlug,
|
||||||
|
isCustomResource: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// ensure chat visible
|
||||||
|
flushSync(() => {
|
||||||
|
setIsOpen(true);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
aiChatHistory,
|
||||||
|
isStreamingMessage,
|
||||||
|
streamedMessage,
|
||||||
|
handleChatSubmit,
|
||||||
|
handleAbort,
|
||||||
|
scrollToBottom,
|
||||||
|
} = useRoadmapAIChat({
|
||||||
|
roadmapId,
|
||||||
|
totalTopicCount,
|
||||||
|
scrollareaRef,
|
||||||
|
onSelectTopic,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
lockBodyScroll(isOpen);
|
lockBodyScroll(isOpen);
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
@@ -28,6 +79,31 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
|
|||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const submitInput = () => {
|
||||||
|
const trimmed = inputValue.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json: JSONContent = {
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: trimmed,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
setInputValue('');
|
||||||
|
handleChatSubmit(json, isRoadmapDetailLoading);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
@@ -39,45 +115,87 @@ export function RoadmapFloatingChat(props: RoadmapChatProps) {
|
|||||||
></div>
|
></div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div className="animate-fade-slide-up fixed bottom-5 left-1/2 z-[99] max-h-[49vh] w-full max-w-[968px] -translate-x-1/4 transform flex-row gap-1.5 overflow-hidden px-4 transition-all duration-300 lg:flex">
|
||||||
className={
|
|
||||||
'animate-fade-slide-up fixed bottom-5 left-1/2 z-[99] max-h-[49vh] w-full max-w-[968px] -translate-x-1/4 transform flex-row gap-1.5 overflow-hidden px-4 transition-all duration-300 lg:flex'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className="flex h-full max-h-[49vh] w-full flex-col overflow-hidden rounded-lg bg-white shadow-lg">
|
<div className="flex h-full max-h-[49vh] w-full flex-col overflow-hidden rounded-lg bg-white shadow-lg">
|
||||||
{/* Messages area - scrollable */}
|
{/* Messages area */}
|
||||||
<div className="flex-1 overflow-y-auto px-3 py-2">
|
<div
|
||||||
|
className="flex-1 overflow-y-auto px-3 py-2"
|
||||||
|
ref={scrollareaRef}
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-2 text-sm">
|
<div className="flex flex-col gap-2 text-sm">
|
||||||
<RoadmapAIChatCard
|
<RoadmapAIChatCard
|
||||||
role="assistant"
|
role="assistant"
|
||||||
jsx={
|
jsx={<span>Hey, how can I help you?</span>}
|
||||||
<span className="relative top-[2px]">
|
isIntro
|
||||||
Hey, how can I help you?
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{aiChatHistory.map(
|
||||||
|
(chat: RoadmapAIChatHistoryType, index: number) => (
|
||||||
|
<Fragment key={`chat-${index}`}>
|
||||||
|
<RoadmapAIChatCard {...chat} />
|
||||||
|
</Fragment>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isStreamingMessage && !streamedMessage && (
|
||||||
|
<RoadmapAIChatCard role="assistant" html="Thinking..." />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{streamedMessage && (
|
||||||
|
<RoadmapAIChatCard role="assistant" jsx={streamedMessage} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input area - sticky at bottom */}
|
{/* Input area */}
|
||||||
<div className="border-t border-gray-200">
|
<div className="relative flex items-center border-t border-gray-200 text-sm">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
autoFocus
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isStreamingMessage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitInput();
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder="Ask me anything about this roadmap..."
|
placeholder="Ask me anything about this roadmap..."
|
||||||
className="w-full resize-none p-3 outline-none"
|
className="w-full resize-none p-3 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={isRoadmapDetailLoading}
|
||||||
|
onClick={() => {
|
||||||
|
if (isStreamingMessage) {
|
||||||
|
handleAbort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitInput();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isStreamingMessage ? (
|
||||||
|
<PauseCircleIcon className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<SendIcon className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isOpen && (
|
{!isOpen && (
|
||||||
<button
|
<button
|
||||||
className={
|
className="relative mx-auto flex cursor-text items-center justify-center gap-2 rounded-full bg-stone-900 py-2.5 pr-8 pl-6 text-center text-white shadow-2xl transition-all duration-300 hover:scale-101 hover:bg-stone-800"
|
||||||
'relative mx-auto flex cursor-text items-center justify-center gap-2 rounded-full bg-stone-900 py-2.5 pr-8 pl-6 text-center text-black text-white shadow-2xl transition-all duration-300 hover:scale-101 hover:bg-stone-800'
|
onClick={() => {
|
||||||
}
|
setIsOpen(true);
|
||||||
onClick={() => setIsOpen(true)}
|
setTimeout(() => scrollToBottom(), 0);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Wand2 className="h-4 w-4 text-yellow-400" />
|
<Wand2 className="h-4 w-4 text-yellow-400" />
|
||||||
<span className="mr-1 text-sm font-semibold text-yellow-400">
|
<span className="mr-1 text-sm font-semibold text-yellow-400">
|
||||||
|
@@ -86,7 +86,6 @@ export function useRoadmapAIChat(options: Options) {
|
|||||||
abortController?: AbortController,
|
abortController?: AbortController,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
setIsStreamingMessage(true);
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${import.meta.env.PUBLIC_API_URL}/v1-chat-roadmap`,
|
`${import.meta.env.PUBLIC_API_URL}/v1-chat-roadmap`,
|
||||||
{
|
{
|
||||||
@@ -174,6 +173,7 @@ export function useRoadmapAIChat(options: Options) {
|
|||||||
{ role: 'user' as AllowedAIChatRole, json, html },
|
{ role: 'user' as AllowedAIChatRole, json, html },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
setIsStreamingMessage(true);
|
||||||
flushSync(() => setAiChatHistory(newMessages));
|
flushSync(() => setAiChatHistory(newMessages));
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
completeAITutorChat(newMessages, abortControllerRef.current);
|
completeAITutorChat(newMessages, abortControllerRef.current);
|
||||||
|
Reference in New Issue
Block a user