Akhileshrangani4 dd59608d73 feature: add AI chat
features:

1. Real-time message display
2. User input handling
3. AI response generation
4. Markdown rendering for AI responses
5. Syntax highlighting for code blocks
6. Copy to clipboard functionality for messages and code blocks
7. Context handling (setting, displaying, and removing context)
8. Expandable/collapsible context display
9. Ability to ask about specific code snippets
10. Auto-scrolling to the latest message
11. Loading indicator during AI response generation
12. Stop generation functionality
13. Error handling for failed API requests
14. Responsive design (flex layout)
15. Custom styling for user and AI messages
16. Support for various Markdown elements (paragraphs, lists, code blocks)
17. Language detection and display for code blocks
18. Animated text generation effect for AI responses
19. Input field placeholder changes based on context presence
20. Disable input during message generation
21. Send message on Enter key press
22. Expandable/collapsible message context for each message
23. Editable context in expanded view
24. Icons for various actions (send, stop, copy, expand/collapse)
25. Visual feedback for copied text (checkmark icon)
26. Abortable fetch requests for AI responses
27. Custom button components
28. Custom loading dots component
29. Truncated display of long messages with expand/collapse functionality
2024-10-13 22:47:47 -04:00

337 lines
13 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react';
import { Button } from '../ui/button';
import { Send, StopCircle, Copy, Check, ChevronDown, ChevronUp, X, CornerUpLeft, Loader2 } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import LoadingDots from '../ui/LoadingDots';
interface Message {
role: 'user' | 'assistant';
content: string;
context?: string;
}
export default function AIChat() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const chatContainerRef = useRef<HTMLDivElement>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const [context, setContext] = useState<string | null>(null);
const [isContextExpanded, setIsContextExpanded] = useState(false);
const [expandedMessageIndex, setExpandedMessageIndex] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
scrollToBottom();
}, [messages]);
const scrollToBottom = () => {
if (chatContainerRef.current) {
setTimeout(() => {
chatContainerRef.current?.scrollTo({
top: chatContainerRef.current.scrollHeight,
behavior: 'smooth'
});
}, 100);
}
};
const handleSend = async () => {
if (input.trim() === '' && !context) return;
const newMessage: Message = {
role: 'user',
content: input,
context: context || undefined
};
setMessages(prev => [...prev, newMessage]);
setInput('');
setIsContextExpanded(false);
setIsGenerating(true);
setIsLoading(true); // Set loading state to true
abortControllerRef.current = new AbortController();
try {
const queryParams = new URLSearchParams({
instructions: input,
...(context && { context }) // Include context only if it exists
});
const response = await fetch(`http://127.0.0.1:8787/api?${queryParams}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
signal: abortControllerRef.current.signal,
});
if (!response.ok) {
throw new Error('Failed to get AI response');
}
const data = await response.json();
const assistantMessage: Message = { role: 'assistant', content: '' };
setMessages(prev => [...prev, assistantMessage]);
setIsLoading(false); // Set loading state to false once we start receiving the response
// Simulate text generation
for (let i = 0; i <= data.response.length; i++) {
if (abortControllerRef.current.signal.aborted) {
break;
}
setMessages(prev => {
const updatedMessages = [...prev];
updatedMessages[updatedMessages.length - 1].content = data.response.slice(0, i);
return updatedMessages;
});
await new Promise(resolve => setTimeout(resolve, 20));
}
} catch (error: any) {
if (error.name === 'AbortError') {
console.log('Generation aborted');
} else {
console.error('Error fetching AI response:', error);
const errorMessage: Message = { role: 'assistant', content: 'Sorry, I encountered an error. Please try again.' };
setMessages(prev => [...prev, errorMessage]);
}
} finally {
setIsGenerating(false);
setIsLoading(false); // Ensure loading state is set to false
abortControllerRef.current = null;
}
};
const handleStopGeneration = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
const copyToClipboard = (text: string, index: number) => {
navigator.clipboard.writeText(text).then(() => {
setCopiedIndex(index);
setTimeout(() => setCopiedIndex(null), 1000); // Reset after 1 seconds
});
};
const askAboutCode = (code: string) => {
setContext(`Regarding this code:\n${code}`);
setIsContextExpanded(false);
};
const removeContext = () => {
setContext(null);
setIsContextExpanded(false);
};
return (
<div className="flex flex-col h-screen w-full">
<span className="text-muted-foreground/50 font-medium p-2">CHAT</span>
<div ref={chatContainerRef} className="flex-grow overflow-y-auto p-4 space-y-4">
{messages.map((message, messageIndex) => (
<div key={messageIndex} className="text-left">
<div className={`inline-block p-2 rounded-lg ${
message.role === 'user'
? 'bg-[#262626] text-white'
: 'bg-transparent text-white'
} max-w-full`}>
{message.context && (
<div className="mb-2 bg-input rounded-lg">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => setExpandedMessageIndex(expandedMessageIndex === messageIndex ? null : messageIndex)}
>
<span className="text-sm text-gray-300">
Context
</span>
{expandedMessageIndex === messageIndex ? (
<ChevronUp size={16} />
) : (
<ChevronDown size={16} />
)}
</div>
{expandedMessageIndex === messageIndex && (
<div className="relative">
<div className="absolute top-0 right-0 flex p-1">
<Button
onClick={() => copyToClipboard(message.context!.replace(/^Regarding this code:\n/, ''), messageIndex)}
size="sm"
variant="ghost"
className="p-1 h-6"
>
{copiedIndex === messageIndex ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
</div>
{(() => {
// need to fix the language detection
const code = message.context!.replace(/^Regarding this code:\n/, '');
const match = /language-(\w+)/.exec(code);
const language = match ? match[1] : 'typescript';
return (
<div className="pt-6">
<SyntaxHighlighter
style={vscDarkPlus as any}
language={language}
PreTag="div"
customStyle={{
margin: 0,
padding: '0.5rem',
fontSize: '0.875rem',
}}
>
{code}
</SyntaxHighlighter>
</div>
);
})()}
</div>
)}
</div>
)}
{message.role === 'assistant' ? (
<ReactMarkdown
components={{
code({node, className, children, ...props}) {
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : '';
return match ? (
<div className="relative border border-input rounded-md my-4">
<div className="absolute top-0 left-0 px-2 py-1 text-xs font-semibold text-gray-200 bg-#1e1e1e rounded-tl">
{language}
</div>
<div className="absolute top-0 right-0 flex">
<Button
onClick={() => copyToClipboard(String(children), messageIndex)}
size="sm"
variant="ghost"
className="p-1 h-6"
>
{copiedIndex === messageIndex ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
<Button
onClick={() => askAboutCode(String(children))}
size="sm"
variant="ghost"
className="p-1 h-6"
>
<CornerUpLeft className="w-4 h-4" />
</Button>
</div>
<div className="pt-6">
<SyntaxHighlighter
style={vscDarkPlus as any}
language={match[1]}
PreTag="div"
customStyle={{
margin: 0,
padding: '0.5rem',
fontSize: '0.875rem',
}}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
</div>
</div>
) : (
<code className={className} {...props}>
{children}
</code>
);
},
p({children}) {
return <p className="mb-4 whitespace-pre-line">{children}</p>;
},
ul({children}) {
return <ul className="list-disc pl-6 mb-4">{children}</ul>;
},
ol({children}) {
return <ol className="list-decimal pl-6 mb-4">{children}</ol>;
},
li({children}) {
return <li className="mb-2">{children}</li>;
},
}}
>
{message.content}
</ReactMarkdown>
) : (
<div className="whitespace-pre-wrap">{message.content}</div>
)}
</div>
</div>
))}
{isLoading && (
<LoadingDots />
)}
</div>
<div className="p-4 border-t mb-14">
{context && (
<div className="mb-2 bg-input p-2 rounded-lg">
<div className="flex justify-between items-center">
<div
className="flex-grow cursor-pointer"
onClick={() => setIsContextExpanded(!isContextExpanded)}
>
<span className="text-sm text-gray-300">
Context
</span>
</div>
<div className="flex items-center">
{isContextExpanded ? (
<ChevronUp size={16} className="cursor-pointer" onClick={() => setIsContextExpanded(false)} />
) : (
<ChevronDown size={16} className="cursor-pointer" onClick={() => setIsContextExpanded(true)} />
)}
<X
size={16}
className="ml-2 cursor-pointer text-gray-400 hover:text-gray-200"
onClick={removeContext}
/>
</div>
</div>
{isContextExpanded && (
<textarea
value={context.replace(/^Regarding this code:\n/, '')}
onChange={(e) => setContext(e.target.value)}
className="w-full mt-2 p-2 bg-#1e1e1e text-white rounded"
rows={5}
/>
)}
</div>
)}
<div className="flex space-x-2 min-w-0">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && !isGenerating && handleSend()}
className="flex-grow p-2 border rounded-lg min-w-0 bg-input"
placeholder={context ? "Add more context or ask a question..." : "Type your message..."}
disabled={isGenerating}
/>
{isGenerating ? (
<Button onClick={handleStopGeneration} variant="destructive" size="icon" className="h-10 w-10">
<StopCircle className="w-4 h-4" />
</Button>
) : (
<Button onClick={handleSend} disabled={isGenerating} size="icon" className="h-10 w-10">
<Send className="w-4 h-4" />
</Button>
)}
</div>
</div>
</div>
);
}