243 lines
12 KiB
TypeScript
243 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useRef, useEffect } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { MessageCircle, X, Send, MinusSquare, Maximize2, Minimize2, Loader2, Bot } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { useStore } from '@/lib/store';
|
|
|
|
interface Message {
|
|
role: 'user' | 'assistant';
|
|
content: string;
|
|
}
|
|
|
|
export function GrokChat() {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [isMinimized, setIsMinimized] = useState(false);
|
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
const [input, setInput] = useState('');
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Auto-scroll to bottom
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}, [messages, isOpen]);
|
|
|
|
// Focus input when opened
|
|
useEffect(() => {
|
|
if (isOpen && !isMinimized) {
|
|
setTimeout(() => inputRef.current?.focus(), 100);
|
|
}
|
|
}, [isOpen, isMinimized]);
|
|
|
|
const handleSend = async () => {
|
|
if (!input.trim() || isLoading) return;
|
|
|
|
const userMsg = input.trim();
|
|
setInput('');
|
|
setMessages(prev => [...prev, { role: 'user', content: userMsg }]);
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
// Retrieve history for context (optional, limiting to last 10 messages)
|
|
const history = messages.slice(-10).map(m => ({ role: m.role, content: m.content }));
|
|
|
|
// Get cookies from store
|
|
const { settings } = useStore.getState();
|
|
const grokCookies = settings.grokCookies;
|
|
|
|
// Parse cookies string to object if retrieved from text area (simple key=value parsing)
|
|
let cookieObj: Record<string, string> = {};
|
|
if (grokCookies) {
|
|
// Basic parsing for "name=value; name2=value2" or JSON
|
|
try {
|
|
// Try JSON first
|
|
const parsed = JSON.parse(grokCookies);
|
|
|
|
if (Array.isArray(parsed)) {
|
|
// Handle standard cookie export format (list of objects)
|
|
parsed.forEach((c: any) => {
|
|
if (c.name && c.value) {
|
|
cookieObj[c.name] = c.value;
|
|
}
|
|
});
|
|
} else if (typeof parsed === 'object' && parsed !== null) {
|
|
// Handle direct key-value object
|
|
// Cast to ensure type compatibility if needed, though 'parsed' is anyish here
|
|
cookieObj = parsed as Record<string, string>;
|
|
}
|
|
} catch {
|
|
// Try semicolon separated
|
|
grokCookies.split(';').forEach((c: string) => {
|
|
const parts = c.trim().split('=');
|
|
if (parts.length >= 2) {
|
|
const key = parts[0].trim();
|
|
const val = parts.slice(1).join('=').trim();
|
|
if (key && val) cookieObj[key] = val;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
const res = await fetch('/api/grok-debug', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
message: userMsg,
|
|
history: history,
|
|
cookies: cookieObj,
|
|
userAgent: navigator.userAgent
|
|
})
|
|
});
|
|
|
|
const data = await res.json();
|
|
|
|
if (data.error || data.detail) {
|
|
// Handle both simple error string and FastAPI detail array
|
|
const errorMsg = data.error || JSON.stringify(data.detail);
|
|
throw new Error(errorMsg);
|
|
}
|
|
|
|
setMessages(prev => [...prev, { role: 'assistant', content: data.response }]);
|
|
|
|
} catch (error: any) {
|
|
console.error('Grok Chat Error:', error);
|
|
setMessages(prev => [...prev, {
|
|
role: 'assistant',
|
|
content: `Error: ${error.message || 'Failed to connect to Grok.'}`
|
|
}]);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="fixed bottom-4 right-4 z-[100] flex flex-col items-end pointer-events-none">
|
|
|
|
{/* Toggle Button */}
|
|
{!isOpen && (
|
|
<motion.button
|
|
initial={{ scale: 0 }}
|
|
animate={{ scale: 1 }}
|
|
whileHover={{ scale: 1.1 }}
|
|
whileTap={{ scale: 0.9 }}
|
|
onClick={() => setIsOpen(true)}
|
|
className="pointer-events-auto bg-black border border-white/20 text-white p-4 rounded-full shadow-2xl hover:shadow-purple-500/20 hover:border-purple-500/50 transition-all group"
|
|
>
|
|
<Bot className="h-8 w-8 group-hover:text-purple-400 transition-colors" />
|
|
</motion.button>
|
|
)}
|
|
|
|
{/* Chat Window */}
|
|
<AnimatePresence>
|
|
{isOpen && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20, scale: 0.9 }}
|
|
animate={{
|
|
opacity: 1,
|
|
y: 0,
|
|
scale: 1,
|
|
height: isMinimized ? 'auto' : '500px',
|
|
width: isMinimized ? '300px' : '380px'
|
|
}}
|
|
exit={{ opacity: 0, y: 20, scale: 0.9 }}
|
|
className="pointer-events-auto bg-black/90 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl overflow-hidden flex flex-col"
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-4 border-b border-white/10 bg-white/5 cursor-pointer"
|
|
onClick={() => setIsMinimized(!isMinimized)}>
|
|
<div className="flex items-center gap-2">
|
|
<Bot className="h-5 w-5 text-purple-400" />
|
|
<span className="font-bold text-white tracking-wide">Grok AI</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); setIsMinimized(!isMinimized); }}
|
|
className="p-1.5 hover:bg-white/10 rounded-md text-white/70 hover:text-white transition-colors"
|
|
>
|
|
{isMinimized ? <Maximize2 className="h-4 w-4" /> : <Minimize2 className="h-4 w-4" />}
|
|
</button>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); setIsOpen(false); }}
|
|
className="p-1.5 hover:bg-red-500/20 hover:text-red-400 rounded-md text-white/70 transition-colors"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Messages Area */}
|
|
{!isMinimized && (
|
|
<>
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4 custom-scrollbar">
|
|
{messages.length === 0 && (
|
|
<div className="flex flex-col items-center justify-center h-full text-center text-white/30 space-y-2">
|
|
<Bot className="h-12 w-12 opacity-20" />
|
|
<p className="text-sm">Ask Grok anything...</p>
|
|
</div>
|
|
)}
|
|
{messages.map((msg, idx) => (
|
|
<div key={idx} className={cn(
|
|
"flex w-full",
|
|
msg.role === 'user' ? "justify-end" : "justify-start"
|
|
)}>
|
|
<div className={cn(
|
|
"max-w-[85%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed",
|
|
msg.role === 'user'
|
|
? "bg-purple-600/80 text-white rounded-br-sm"
|
|
: "bg-white/10 text-white/90 rounded-bl-sm"
|
|
)}>
|
|
{msg.content}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{isLoading && (
|
|
<div className="flex justify-start">
|
|
<div className="bg-white/5 rounded-2xl px-4 py-2 flex items-center gap-2">
|
|
<Loader2 className="h-4 w-4 animate-spin text-purple-400" />
|
|
<span className="text-xs text-white/50">Computing...</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* Input Area */}
|
|
<div className="p-4 border-t border-white/10 bg-white/5">
|
|
<div className="flex gap-2">
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Type a message..."
|
|
className="flex-1 bg-black/50 border border-white/10 rounded-xl px-4 py-2.5 text-sm text-white focus:outline-none focus:border-purple-500/50 focus:ring-1 focus:ring-purple-500/20 transition-all placeholder:text-white/20"
|
|
disabled={isLoading}
|
|
/>
|
|
<button
|
|
onClick={handleSend}
|
|
disabled={!input.trim() || isLoading}
|
|
className="p-2.5 bg-purple-600 hover:bg-purple-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl text-white transition-colors shadow-lg shadow-purple-900/20"
|
|
>
|
|
<Send className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|