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
This commit is contained in:
parent
62e282da63
commit
dd59608d73
@ -6,69 +6,61 @@ export interface Env {
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
async fetch(request: Request, env: Env): Promise<Response> {
|
async fetch(request: Request, env: Env): Promise<Response> {
|
||||||
|
// Handle CORS preflight requests
|
||||||
|
if (request.method === "OPTIONS") {
|
||||||
|
return new Response(null, {
|
||||||
|
headers: {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (request.method !== "GET") {
|
if (request.method !== "GET") {
|
||||||
return new Response("Method Not Allowed", { status: 405 });
|
return new Response("Method Not Allowed", { status: 405 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
// const fileName = url.searchParams.get("fileName");
|
|
||||||
// const line = url.searchParams.get("line");
|
|
||||||
const instructions = url.searchParams.get("instructions");
|
const instructions = url.searchParams.get("instructions");
|
||||||
const code = url.searchParams.get("code");
|
const context = url.searchParams.get("context");
|
||||||
|
|
||||||
const prompt = `
|
if (!instructions) {
|
||||||
Make the following changes to the code below:
|
return new Response("Missing instructions parameter", { status: 400 });
|
||||||
- ${instructions}
|
}
|
||||||
|
|
||||||
Return the complete code chunk. Do not refer to other code files. Do not add code before or after the chunk. Start your reponse with \`\`\`, and end with \`\`\`. Do not include any other text.
|
const prompt = `You are an intelligent programming assistant. Please respond to the following request:
|
||||||
|
|
||||||
|
${instructions}
|
||||||
|
|
||||||
|
${context ? `Context:\n${context}\n` : ''}
|
||||||
|
|
||||||
|
If your response includes code, please format it using triple backticks (\`\`\`) with the appropriate language identifier. For example:
|
||||||
|
|
||||||
|
\`\`\`python
|
||||||
|
print("Hello, World!")
|
||||||
\`\`\`
|
\`\`\`
|
||||||
${code}
|
|
||||||
\`\`\`
|
|
||||||
`;
|
|
||||||
console.log(prompt);
|
|
||||||
|
|
||||||
try {
|
Provide a clear and concise explanation along with any code snippets.`;
|
||||||
|
|
||||||
|
try {
|
||||||
const anthropic = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });
|
const anthropic = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });
|
||||||
|
|
||||||
interface TextBlock {
|
|
||||||
type: "text";
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ToolUseBlock {
|
|
||||||
type: "tool_use";
|
|
||||||
tool_use: {
|
|
||||||
// Add properties if needed
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type ContentBlock = TextBlock | ToolUseBlock;
|
|
||||||
|
|
||||||
function getTextContent(content: ContentBlock[]): string {
|
|
||||||
for (const block of content) {
|
|
||||||
if (block.type === "text") {
|
|
||||||
return block.text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "No text content found";
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await anthropic.messages.create({
|
const response = await anthropic.messages.create({
|
||||||
model: "claude-3-5-sonnet-20240620",
|
model: "claude-3-opus-20240229",
|
||||||
max_tokens: 1024,
|
max_tokens: 1024,
|
||||||
messages: [{ role: "user", content: prompt }],
|
messages: [{ role: "user", content: prompt }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const message = response.content as ContentBlock[];
|
const assistantResponse = response.content[0].type === 'text' ? response.content[0].text : '';
|
||||||
const textBlockContent = getTextContent(message);
|
|
||||||
|
|
||||||
const pattern = /```[a-zA-Z]*\n([\s\S]*?)\n```/;
|
// When sending the response, include CORS headers
|
||||||
const match = textBlockContent.match(pattern);
|
return new Response(JSON.stringify({ "response": assistantResponse }), {
|
||||||
|
headers: {
|
||||||
const codeContent = match ? match[1] : "Error: Could not extract code.";
|
"Content-Type": "application/json",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
return new Response(JSON.stringify({ "response": codeContent }))
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error:", error);
|
console.error("Error:", error);
|
||||||
return new Response("Internal Server Error", { status: 500 });
|
return new Response("Internal Server Error", { status: 500 });
|
||||||
|
@ -1,62 +1,334 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Send } from 'lucide-react';
|
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 {
|
interface Message {
|
||||||
role: 'user' | 'assistant';
|
role: 'user' | 'assistant';
|
||||||
content: string;
|
content: string;
|
||||||
|
context?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AIChat() {
|
export default function AIChat() {
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const chatContainerRef = useRef<HTMLDivElement>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (chatContainerRef.current) {
|
scrollToBottom();
|
||||||
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
const handleSend = async () => {
|
const scrollToBottom = () => {
|
||||||
if (input.trim() === '') return;
|
if (chatContainerRef.current) {
|
||||||
|
setTimeout(() => {
|
||||||
|
chatContainerRef.current?.scrollTo({
|
||||||
|
top: chatContainerRef.current.scrollHeight,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const newMessage: Message = { role: 'user', content: input };
|
const handleSend = async () => {
|
||||||
|
if (input.trim() === '' && !context) return;
|
||||||
|
|
||||||
|
const newMessage: Message = {
|
||||||
|
role: 'user',
|
||||||
|
content: input,
|
||||||
|
context: context || undefined
|
||||||
|
};
|
||||||
setMessages(prev => [...prev, newMessage]);
|
setMessages(prev => [...prev, newMessage]);
|
||||||
setInput('');
|
setInput('');
|
||||||
|
setIsContextExpanded(false);
|
||||||
|
setIsGenerating(true);
|
||||||
|
setIsLoading(true); // Set loading state to true
|
||||||
|
|
||||||
// TODO: Implement actual API call to LLM here
|
abortControllerRef.current = new AbortController();
|
||||||
// For now, we'll just simulate a response
|
|
||||||
setTimeout(() => {
|
try {
|
||||||
const assistantMessage: Message = { role: 'assistant', content: 'This is a simulated response from the AI.' };
|
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]);
|
setMessages(prev => [...prev, assistantMessage]);
|
||||||
}, 1000);
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<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">
|
<div ref={chatContainerRef} className="flex-grow overflow-y-auto p-4 space-y-4">
|
||||||
{messages.map((message, index) => (
|
{messages.map((message, messageIndex) => (
|
||||||
<div key={index} className={`${message.role === 'user' ? 'text-right' : 'text-left'}`}>
|
<div key={messageIndex} className="text-left">
|
||||||
<div className={`inline-block p-2 rounded-lg ${message.role === 'user' ? 'bg-blue-500 text-white' : 'bg-gray-200 text-black'}`}>
|
<div className={`inline-block p-2 rounded-lg ${
|
||||||
{message.content}
|
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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{isLoading && (
|
||||||
|
<LoadingDots />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 border-t">
|
<div className="p-4 border-t mb-14">
|
||||||
<div className="flex space-x-2">
|
{context && (
|
||||||
<input
|
<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"
|
type="text"
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyPress={(e) => e.key === 'Enter' && handleSend()}
|
onKeyPress={(e) => e.key === 'Enter' && !isGenerating && handleSend()}
|
||||||
className="flex-grow p-2 border rounded-lg"
|
className="flex-grow p-2 border rounded-lg min-w-0 bg-input"
|
||||||
placeholder="Type your message..."
|
placeholder={context ? "Add more context or ask a question..." : "Type your message..."}
|
||||||
|
disabled={isGenerating}
|
||||||
/>
|
/>
|
||||||
<Button onClick={handleSend}>
|
{isGenerating ? (
|
||||||
<Send className="w-4 h-4" />
|
<Button onClick={handleStopGeneration} variant="destructive" size="icon" className="h-10 w-10">
|
||||||
</Button>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
32
frontend/components/ui/LoadingDots.tsx
Normal file
32
frontend/components/ui/LoadingDots.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const LoadingDots: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<span className="loading-dots">
|
||||||
|
<span className="dot">.</span>
|
||||||
|
<span className="dot">.</span>
|
||||||
|
<span className="dot">.</span>
|
||||||
|
<style jsx>{`
|
||||||
|
.loading-dots {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
opacity: 0;
|
||||||
|
animation: showHideDot 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.dot:nth-child(1) { animation-delay: 0s; }
|
||||||
|
.dot:nth-child(2) { animation-delay: 0.5s; }
|
||||||
|
.dot:nth-child(3) { animation-delay: 1s; }
|
||||||
|
@keyframes showHideDot {
|
||||||
|
0% { opacity: 0; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoadingDots;
|
||||||
|
|
1453
frontend/package-lock.json
generated
1453
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -46,7 +46,9 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"react-hook-form": "^7.51.3",
|
"react-hook-form": "^7.51.3",
|
||||||
|
"react-markdown": "^9.0.1",
|
||||||
"react-resizable-panels": "^2.0.16",
|
"react-resizable-panels": "^2.0.16",
|
||||||
|
"react-syntax-highlighter": "^15.5.0",
|
||||||
"socket.io-client": "^4.7.5",
|
"socket.io-client": "^4.7.5",
|
||||||
"sonner": "^1.4.41",
|
"sonner": "^1.4.41",
|
||||||
"tailwind-merge": "^2.3.0",
|
"tailwind-merge": "^2.3.0",
|
||||||
@ -59,9 +61,11 @@
|
|||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/estree": "^1.0.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@types/three": "^0.164.0",
|
"@types/three": "^0.164.0",
|
||||||
"autoprefixer": "^10.0.1",
|
"autoprefixer": "^10.0.1",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
|
3
frontend/react-syntax-highlighter.d.ts
vendored
Normal file
3
frontend/react-syntax-highlighter.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
declare module 'react-syntax-highlighter';
|
||||||
|
declare module 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
|
|
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"types": ["node"],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user