1
0
mirror of https://github.com/kamranahmedse/developer-roadmap.git synced 2025-10-03 20:31:52 +02:00

feat: add abort functionality to chat and update UI elements

This commit is contained in:
Arik Chakma
2025-05-22 04:07:25 +06:00
parent aa0833de6f
commit 982898a1a7
3 changed files with 59 additions and 9 deletions

View File

@@ -14,7 +14,7 @@ import {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { BotIcon, Loader2Icon, SendIcon } from 'lucide-react'; import { BotIcon, Loader2Icon, PauseCircleIcon, SendIcon } from 'lucide-react';
import { ChatEditor } from '../ChatEditor/ChatEditor'; import { ChatEditor } from '../ChatEditor/ChatEditor';
import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree'; import { roadmapTreeMappingOptions } from '../../queries/roadmap-tree';
import { import {
@@ -112,11 +112,20 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
setIsLoading(false); setIsLoading(false);
}, [roadmapTreeData, roadmapJSONData, roadmapDetailsData]); }, [roadmapTreeData, roadmapJSONData, roadmapDetailsData]);
const abortControllerRef = useRef<AbortController | null>(null);
const handleChatSubmit = (json: JSONContent) => { const handleChatSubmit = (json: JSONContent) => {
if (!json || isStreamingMessage || !isLoggedIn() || isLoading) { if (
!json ||
isStreamingMessage ||
!isLoggedIn() ||
isLoading ||
abortControllerRef.current
) {
return; return;
} }
abortControllerRef.current = new AbortController();
const html = htmlFromTiptapJSON(json); const html = htmlFromTiptapJSON(json);
const newMessages: RoamdapAIChatHistoryType[] = [ const newMessages: RoamdapAIChatHistoryType[] = [
...aiChatHistory, ...aiChatHistory,
@@ -133,7 +142,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
}); });
scrollToBottom(); scrollToBottom();
completeAITutorChat(newMessages); completeAITutorChat(newMessages, abortControllerRef.current);
}; };
const scrollToBottom = useCallback(() => { const scrollToBottom = useCallback(() => {
@@ -160,7 +169,10 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
}; };
}, [roadmapId]); }, [roadmapId]);
const completeAITutorChat = async (messages: RoamdapAIChatHistoryType[]) => { const completeAITutorChat = async (
messages: RoamdapAIChatHistoryType[],
abortController?: AbortController,
) => {
try { try {
setIsStreamingMessage(true); setIsStreamingMessage(true);
@@ -172,6 +184,7 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
credentials: 'include', credentials: 'include',
signal: abortController?.signal,
body: JSON.stringify({ body: JSON.stringify({
roadmapId, roadmapId,
messages: messages.slice(-10), messages: messages.slice(-10),
@@ -205,6 +218,10 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
await readStream(reader, { await readStream(reader, {
onStream: async (content) => { onStream: async (content) => {
if (abortController?.signal.aborted) {
return;
}
const jsx = await renderMessage(content, renderer); const jsx = await renderMessage(content, renderer);
flushSync(() => { flushSync(() => {
@@ -214,6 +231,10 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
scrollToBottom(); scrollToBottom();
}, },
onStreamEnd: async (content) => { onStreamEnd: async (content) => {
if (abortController?.signal.aborted) {
return;
}
const jsx = await renderMessage(content, renderer); const jsx = await renderMessage(content, renderer);
const newMessages: RoamdapAIChatHistoryType[] = [ const newMessages: RoamdapAIChatHistoryType[] = [
...messages, ...messages,
@@ -236,10 +257,25 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
}); });
setIsStreamingMessage(false); setIsStreamingMessage(false);
abortControllerRef.current = null;
} catch (error) { } catch (error) {
toast.error('Something went wrong');
setIsStreamingMessage(false); setIsStreamingMessage(false);
setStreamedMessage(null);
abortControllerRef.current = null;
if (abortController?.signal.aborted) {
return;
} }
toast.error('Something went wrong');
}
};
const handleAbort = () => {
abortControllerRef.current?.abort();
abortControllerRef.current = null;
setIsStreamingMessage(false);
setStreamedMessage(null);
setAiChatHistory([...aiChatHistory].slice(0, aiChatHistory.length - 1));
}; };
useEffect(() => { useEffect(() => {
@@ -310,6 +346,11 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
className="relative flex items-start border-t border-gray-200 text-sm" className="relative flex items-start border-t border-gray-200 text-sm"
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
if (isStreamingMessage && abortControllerRef.current) {
handleAbort();
return;
}
handleChatSubmit(editorRef.current?.getJSON() || {}); handleChatSubmit(editorRef.current?.getJSON() || {});
}} }}
> >
@@ -317,6 +358,11 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
editorRef={editorRef} editorRef={editorRef}
roadmapId={roadmapId} roadmapId={roadmapId}
onSubmit={(content) => { onSubmit={(content) => {
if (isStreamingMessage && abortControllerRef.current) {
handleAbort();
return;
}
handleChatSubmit(content); handleChatSubmit(content);
}} }}
/> />
@@ -325,7 +371,11 @@ export function RoadmapAIChat(props: RoadmapAIChatProps) {
type="submit" type="submit"
className="flex aspect-square size-[36px] items-center justify-center p-2 text-zinc-500 hover:text-black disabled:cursor-not-allowed disabled:opacity-50" className="flex aspect-square size-[36px] items-center justify-center p-2 text-zinc-500 hover:text-black disabled:cursor-not-allowed disabled:opacity-50"
> >
{isStreamingMessage ? (
<PauseCircleIcon className="size-4 stroke-[2.5]" />
) : (
<SendIcon className="size-4 stroke-[2.5]" /> <SendIcon className="size-4 stroke-[2.5]" />
)}
</button> </button>
</form> </form>
</div> </div>

View File

@@ -32,9 +32,9 @@ export function RoadmapAIChatCard(props: RoadmapAIChatCardProps) {
{!!jsx && jsx} {!!jsx && jsx}
{html && ( {!!html && (
<div <div
className="course-content course-ai-content prose prose-sm mt-0.5 max-w-full overflow-hidden text-sm" className="course-content course-ai-content prose prose-sm mt-0.5 max-w-[calc(100%-38px)] overflow-hidden text-sm"
dangerouslySetInnerHTML={{ __html: html }} dangerouslySetInnerHTML={{ __html: html }}
/> />
)} )}

View File

@@ -113,7 +113,7 @@ export async function renderMessage(
const parts = await parseMessageParts(content, renderer); const parts = await parseMessageParts(content, renderer);
return ( return (
<div> <div className="max-w-[calc(100%-38px)]">
{parts.map((item) => { {parts.map((item) => {
if ( if (
(item.type === 'html' || item.type === 'text') && (item.type === 'html' || item.type === 'text') &&