Spaces:
Paused
Paused
| import { useState, useRef, useEffect, useMemo } from 'react'; | |
| import { ArrowUp, Bot, Trash2, Copy, Check, ImageUp, X, Brain, Pencil, Globe, Download, Wand2 } from 'lucide-react'; | |
| import { Sun, Moon } from 'lucide-react'; | |
| import { | |
| GROQ_API_KEYS, | |
| GROQ_API_URL, | |
| GROQ_MODEL_REASONER, | |
| POLLINATIONS_API_URL, | |
| POLLINATIONS_MODEL_TEXT, | |
| POLLINATIONS_MODEL_VISION, | |
| POLLINATIONS_MODEL_FALLBACK, | |
| POLLINATIONS_MODEL_SEARCH, | |
| TACTIQ_API_URL | |
| } from './config/api'; | |
| import ReactMarkdown, { type Components } from 'react-markdown'; | |
| import remarkGfm from 'remark-gfm'; | |
| import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; | |
| import { materialDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; | |
| import 'katex/dist/katex.min.css'; | |
| import { InlineMath, BlockMath } from 'react-katex'; | |
| import type { Message, EditingMessage } from './types/msg'; | |
| import { SYSTEM_PROMPT, STORAGE_KEY, MAX_RETRIES, RETRY_DELAY, REQUEST_TIMEOUT } from './config/const'; | |
| const CodeBlock = ({ children, inline, className }: { children: string | string[]; inline?: boolean; className?: string }) => { | |
| const codeContent = Array.isArray(children) ? children.join('') : children?.toString() || ''; | |
| if (inline || !className) { | |
| return <code className="px-1 py-0.5 rounded text-sm font-mono custom-scrollbar">{codeContent}</code>; | |
| } | |
| const [copied, setCopied] = useState(false); | |
| const language = className?.replace(/language-/, '') || 'plaintext'; | |
| const handleCopy = async () => { | |
| await navigator.clipboard.writeText(codeContent); | |
| setCopied(true); | |
| setTimeout(() => setCopied(false), 2000); | |
| }; | |
| const customStyle = { | |
| ...materialDark, | |
| 'pre[class*="language-"]': { | |
| ...materialDark['pre[class*="language-"]'], | |
| background: 'transparent' | |
| } | |
| }; | |
| return ( | |
| <div className="relative group bg-gray-800 rounded-lg custom-scrollbar"> | |
| <button | |
| onClick={handleCopy} | |
| className="absolute top-2 right-2 px-4 py-2 text-white rounded-lg hover:bg-blue-500 invisible group-hover:visible transition-all z-10" | |
| title="Copy code" | |
| > | |
| {copied ? ( | |
| <Check className="w-4 h-4 text-green-400" /> | |
| ) : ( | |
| <Copy className="w-4 h-4 text-gray-300" /> | |
| )} | |
| </button> | |
| <SyntaxHighlighter | |
| language={language} | |
| style={customStyle} | |
| showLineNumbers | |
| wrapLongLines | |
| > | |
| {codeContent} | |
| </SyntaxHighlighter> | |
| </div> | |
| ); | |
| }; | |
| interface ThinkingBlockProps { | |
| id: string; | |
| children: React.ReactNode; | |
| isExpanded: boolean; | |
| onToggle: (id: string) => void; | |
| } | |
| const ThinkingProcess: React.FC<ThinkingBlockProps> = ({ id, children, isExpanded, onToggle }) => { | |
| return ( | |
| <div className="relative pt-1 pl-2"> | |
| <button | |
| onClick={() => onToggle(id)} | |
| className="absolute -top-4 -left-6 p-1 bg-gray-100 hover:bg-gray-200 text-gray-500 rounded-full transition-colors z-20" | |
| title="View thinking process" | |
| > | |
| <Brain className="w-4 h-4" /> | |
| </button> | |
| {isExpanded && ( | |
| <div className="relative"> | |
| <div className="absolute -left-4 top-0 flex flex-col gap-0.5"> | |
| <div className="w-1.5 h-1.5 bg-gray-50 border border-gray-200 rounded-full ml-0.5" /> | |
| <div className="w-2 h-2 bg-gray-50 border border-gray-200 rounded-full ml-1" /> | |
| <div className="w-2.5 h-2.5 bg-gray-50 border border-gray-200 rounded-full ml-1.5" /> | |
| </div> | |
| <div className="bg-gray-50 border border-gray-200 p-3 rounded-lg mt-4"> | |
| <div className="text-sm text-gray-600"> | |
| {children} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| const MathBlock = ({ math, isInParagraph }: { math: string; isInParagraph: boolean }) => { | |
| try { | |
| if (isInParagraph) { | |
| return ( | |
| <span className="block my-2 overflow-x-auto text-center no-scrollbar"> | |
| <InlineMath>{`\\displaystyle ${math}`}</InlineMath> | |
| </span> | |
| ); | |
| } | |
| return ( | |
| <div className="my-2 overflow-x-auto no-scrollbar"> | |
| <BlockMath>{math}</BlockMath> | |
| </div> | |
| ); | |
| } catch (error) { | |
| console.error('Math rendering error:', error); | |
| return <span className="text-red-500">Error rendering equation: {math}</span>; | |
| } | |
| }; | |
| const ChatMessage = ({ | |
| message, | |
| onEdit, | |
| isEditing, | |
| editContent, | |
| onEditChange, | |
| onEditSave, | |
| onEditCancel, | |
| setFullScreenImage | |
| }: { | |
| message: Message; | |
| onEdit?: (message: Message) => void; | |
| isEditing?: boolean; | |
| editContent?: string; | |
| onEditChange?: (content: string) => void; | |
| onEditSave?: () => void; | |
| onEditCancel?: () => void; | |
| setFullScreenImage: (image: string | null) => void; | |
| }) => { | |
| const [expandedThinkBlocks, setExpandedThinkBlocks] = useState<Set<string>>(new Set()); | |
| const toggleThinkBlock = (id: string) => { | |
| setExpandedThinkBlocks(prev => { | |
| const newSet = new Set(prev); | |
| if (newSet.has(id)) { | |
| newSet.delete(id); | |
| } else { | |
| newSet.add(id); | |
| } | |
| return newSet; | |
| }); | |
| }; | |
| const TextBlock = ({ children, isInParagraph = false }: { children: React.ReactNode; isInParagraph?: boolean }) => { | |
| if (Array.isArray(children)) { | |
| return <>{children.map((child, i) => <TextBlock key={i} isInParagraph={isInParagraph}>{child}</TextBlock>)}</>; | |
| } | |
| if (!children || typeof children !== 'string') { | |
| return <>{children}</>; | |
| } | |
| if (!children.includes('$')) { | |
| return <>{children}</>; | |
| } | |
| const segments: string[] = []; | |
| let currentText = ''; | |
| let pos = 0; | |
| let inMath = false; | |
| let mathStart = 0; | |
| while (pos < children.length) { | |
| if (children[pos] === '$') { | |
| if (pos + 1 < children.length && children[pos + 1] === '$') { | |
| if (!inMath) { | |
| if (currentText) segments.push(currentText); | |
| currentText = '$$'; | |
| mathStart = pos; | |
| inMath = true; | |
| pos += 2; | |
| } else if (pos > mathStart + 2) { | |
| segments.push(currentText + '$$'); | |
| currentText = ''; | |
| inMath = false; | |
| pos += 2; | |
| } else { | |
| currentText += '$$'; | |
| pos += 2; | |
| } | |
| } else { | |
| if (!inMath) { | |
| if (currentText) segments.push(currentText); | |
| currentText = '$'; | |
| mathStart = pos; | |
| inMath = true; | |
| pos++; | |
| } else if (pos > mathStart + 1) { | |
| segments.push(currentText + '$'); | |
| currentText = ''; | |
| inMath = false; | |
| pos++; | |
| } else { | |
| currentText += '$'; | |
| pos++; | |
| } | |
| } | |
| } else { | |
| currentText += children[pos]; | |
| pos++; | |
| } | |
| } | |
| if (currentText) { | |
| segments.push(currentText); | |
| } | |
| return ( | |
| <> | |
| {segments.map((segment, i) => { | |
| try { | |
| if (segment.startsWith('$$') && segment.endsWith('$$')) { | |
| const math = segment.slice(2, -2); | |
| return <MathBlock key={i} math={math} isInParagraph={isInParagraph} />; | |
| } else if (segment.startsWith('$') && segment.endsWith('$')) { | |
| const math = segment.slice(1, -1); | |
| return <InlineMath key={i}>{math}</InlineMath>; | |
| } | |
| return <span key={i}>{segment}</span>; | |
| } catch (error) { | |
| console.error('LaTeX rendering error:', error); | |
| return <span key={i} className="text-red-500">{segment}</span>; | |
| } | |
| })} | |
| </> | |
| ); | |
| }; | |
| const components = { | |
| code: ({ children, inline, className }: { children: string; inline?: boolean; className?: string }) => ( | |
| <CodeBlock inline={inline} className={className}>{children as string}</CodeBlock> | |
| ), | |
| think: ({ children }: { children: React.ReactNode }) => { | |
| const thinkId = useMemo(() => { | |
| const content = children?.toString() || ''; | |
| return `think-${message.id}-${content.slice(0, 32)}`; | |
| }, [children, message.id]); | |
| return ( | |
| <ThinkingProcess | |
| id={thinkId} | |
| isExpanded={expandedThinkBlocks.has(thinkId)} | |
| onToggle={toggleThinkBlock} | |
| > | |
| {children} | |
| </ThinkingProcess> | |
| ); | |
| }, | |
| p: ({ children }: { children: React.ReactNode }) => ( | |
| <p className="my-1"> | |
| <TextBlock isInParagraph={true}>{children}</TextBlock> | |
| </p> | |
| ), | |
| strong: ({ children }: { children: React.ReactNode }) => ( | |
| <strong> | |
| <TextBlock>{children}</TextBlock> | |
| </strong> | |
| ), | |
| em: ({ children }: { children: React.ReactNode }) => ( | |
| <em> | |
| <TextBlock>{children}</TextBlock> | |
| </em> | |
| ), | |
| li: ({ children }: { children: React.ReactNode }) => ( | |
| <li> | |
| <TextBlock>{children}</TextBlock> | |
| </li> | |
| ), | |
| h1: ({ children }: { children: React.ReactNode }) => ( | |
| <h1 className="text-2xl font-bold mb-4"> | |
| <TextBlock>{children}</TextBlock> | |
| </h1> | |
| ), | |
| h2: ({ children }: { children: React.ReactNode }) => ( | |
| <h2 className="text-xl font-bold mb-3"> | |
| <TextBlock>{children}</TextBlock> | |
| </h2> | |
| ), | |
| h3: ({ children }: { children: React.ReactNode }) => ( | |
| <h3 className="text-lg font-bold mb-2"> | |
| <TextBlock>{children}</TextBlock> | |
| </h3> | |
| ) | |
| }; | |
| const processMessageContent = (content: string) => { | |
| if (content.includes('<think>') && !content.includes('</think>')) { | |
| content = content.replace(/<think>/g, ''); | |
| } | |
| const thinkMatch = content.match(/<think>([\s\S]*?)<\/think>/); | |
| if (thinkMatch) { | |
| const thinking = thinkMatch[1].trim(); | |
| const restOfContent = content.replace(/<think>[\s\S]*?<\/think>/, '').trim(); | |
| return ( | |
| <> | |
| <components.think>{thinking}</components.think> | |
| <ReactMarkdown | |
| remarkPlugins={[remarkGfm]} | |
| components={components as Partial<Components>} | |
| > | |
| {restOfContent} | |
| </ReactMarkdown> | |
| </> | |
| ); | |
| } | |
| return ( | |
| <ReactMarkdown | |
| remarkPlugins={[remarkGfm]} | |
| components={components as Partial<Components>} | |
| > | |
| {content} | |
| </ReactMarkdown> | |
| ); | |
| }; | |
| const downloadImage = async (imageUrl: string) => { | |
| try { | |
| const response = await fetch(imageUrl); | |
| const blob = await response.blob(); | |
| const url = window.URL.createObjectURL(blob); | |
| const link = document.createElement('a'); | |
| link.href = url; | |
| link.download = `image-${Date.now()}.png`; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| window.URL.revokeObjectURL(url); | |
| } catch (error) { | |
| console.error('Error downloading image:', error); | |
| } | |
| }; | |
| return ( | |
| <div className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}> | |
| <div | |
| className={`relative max-w-[80%] md:max-w-[70%] lg:max-w-[60%] rounded-2xl px-4 py-2 \ | |
| ${message.role === 'user' | |
| ? 'bg-blue-500 text-white rounded-br-none dark:bg-blue-600' | |
| : 'bg-white text-gray-800 rounded-bl-none shadow-sm dark:bg-gray-800 dark:text-gray-100 dark:shadow-none'} | |
| `} | |
| > | |
| {isEditing ? ( | |
| <div className="flex flex-col gap-2"> | |
| <textarea | |
| value={editContent} | |
| onChange={(e) => onEditChange?.(e.target.value)} | |
| className="w-full p-2 rounded border-none bg-blue-500 text-white placeholder-white/75 focus:outline-none custom-scrollbar" | |
| rows={5} | |
| cols={50} | |
| placeholder="Edit your message..." | |
| /> | |
| <div className="flex justify-end gap-2"> | |
| <button | |
| onClick={onEditCancel} | |
| className="p-1.5 rounded-full bg-blue-600 hover:bg-blue-700 transition-colors" | |
| title="Cancel" | |
| > | |
| <X className="w-4 h-4" /> | |
| </button> | |
| <button | |
| onClick={onEditSave} | |
| className="p-1.5 rounded-full bg-blue-600 hover:bg-blue-700 transition-colors" | |
| title="Save changes" | |
| > | |
| <Check className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| </div> | |
| ) : ( | |
| <> | |
| {message.image && ( | |
| <div className="relative group"> | |
| <img | |
| src={message.image.startsWith('https://image.pollinations.ai') ? '/loading.gif' : message.image} | |
| alt="Attached" | |
| className="max-w-full rounded-lg mb-2 max-h-[300px] object-contain cursor-pointer" | |
| onClick={() => setFullScreenImage(message.image || null)} | |
| onLoad={(e) => { | |
| if (message.image?.startsWith('https://image.pollinations.ai')) { | |
| e.currentTarget.src = message.image!; | |
| } | |
| }} | |
| /> | |
| <button | |
| onClick={() => downloadImage(message.image!)} | |
| className="absolute top-2 right-2 p-2 bg-black/50 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/70" | |
| title="Download image" | |
| > | |
| <Download className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| )} | |
| <div className={`prose ${message.role === 'user' ? 'prose-invert' : ''} max-w-none prose-sm prose-p:my-1 prose-pre:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 dark:prose-invert`}> | |
| {processMessageContent(message.content)} | |
| </div> | |
| <div className="text-xs opacity-70 mt-1 text-right flex items-center justify-end gap-2"> | |
| {message.role === 'user' && ( | |
| <button | |
| onClick={() => onEdit?.(message)} | |
| className="p-1 hover:bg-white/20 rounded transition-colors" | |
| title="Edit message" | |
| > | |
| <Pencil className="w-3 h-3" /> | |
| </button> | |
| )} | |
| {message.timestamp.toLocaleTimeString([], { | |
| hour: '2-digit', | |
| minute: '2-digit' | |
| })} | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| function App() { | |
| const [messages, setMessages] = useState<Message[]>(() => { | |
| const savedMessages = localStorage.getItem(STORAGE_KEY); | |
| if (savedMessages) { | |
| const parsed = JSON.parse(savedMessages); | |
| return parsed.map((msg: any) => ({ | |
| ...msg, | |
| timestamp: new Date(msg.timestamp) | |
| })); | |
| } | |
| return [SYSTEM_PROMPT]; | |
| }); | |
| const [input, setInput] = useState(''); | |
| const [isLoading, setIsLoading] = useState(false); | |
| const [showClearConfirm, setShowClearConfirm] = useState(false); | |
| const messagesEndRef = useRef<HTMLDivElement>(null); | |
| const [selectedImage, setSelectedImage] = useState<string | null>(null); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| const [fullScreenImage, setFullScreenImage] = useState<string | null>(null); | |
| const textareaRef = useRef<HTMLTextAreaElement>(null); | |
| const [reasonerEnabled, setReasonerEnabled] = useState(false); | |
| const [editingMessage, setEditingMessage] = useState<EditingMessage | null>(null); | |
| const [searchEnabled, setSearchEnabled] = useState(false); | |
| const [imageGenEnabled, setImageGenEnabled] = useState(false); | |
| const [generatingImage, setGeneratingImage] = useState(false); | |
| const [darkMode, setDarkMode] = useState(() => { | |
| if (typeof window !== 'undefined') { | |
| return localStorage.getItem('theme') === 'dark' || | |
| (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches); | |
| } | |
| return false; | |
| }); | |
| useEffect(() => { | |
| if (darkMode) { | |
| document.documentElement.classList.add('dark'); | |
| localStorage.setItem('theme', 'dark'); | |
| } else { | |
| document.documentElement.classList.remove('dark'); | |
| localStorage.setItem('theme', 'light'); | |
| } | |
| }, [darkMode]); | |
| const scrollToBottom = () => { | |
| messagesEndRef.current?.scrollIntoView({ behavior: 'auto' }); | |
| }; | |
| useEffect(() => { | |
| scrollToBottom(); | |
| }, [messages]); | |
| // Save messages to localStorage whenever they change | |
| useEffect(() => { | |
| localStorage.setItem(STORAGE_KEY, JSON.stringify(messages)); | |
| }, [messages]); | |
| const handleClearHistory = () => { | |
| setMessages([SYSTEM_PROMPT as Message]); | |
| setShowClearConfirm(false); | |
| }; | |
| const toggleMode = (mode: 'reasoner' | 'search' | 'image' | 'generate') => { | |
| switch (mode) { | |
| case 'reasoner': | |
| if (reasonerEnabled) { | |
| setReasonerEnabled(false); | |
| } else { | |
| setReasonerEnabled(true); | |
| setSearchEnabled(false); | |
| setImageGenEnabled(false); | |
| setSelectedImage(null); | |
| if (fileInputRef.current) { | |
| fileInputRef.current.value = ''; | |
| } | |
| } | |
| break; | |
| case 'search': | |
| if (searchEnabled) { | |
| setSearchEnabled(false); | |
| } else { | |
| setSearchEnabled(true); | |
| setReasonerEnabled(false); | |
| setImageGenEnabled(false); | |
| setSelectedImage(null); | |
| if (fileInputRef.current) { | |
| fileInputRef.current.value = ''; | |
| } | |
| } | |
| break; | |
| case 'image': | |
| if (selectedImage) { | |
| setSelectedImage(null); | |
| if (fileInputRef.current) { | |
| fileInputRef.current.value = ''; | |
| } | |
| } else { | |
| fileInputRef.current?.click(); | |
| setReasonerEnabled(false); | |
| setSearchEnabled(false); | |
| setImageGenEnabled(false); | |
| } | |
| break; | |
| case 'generate': | |
| if (imageGenEnabled) { | |
| setImageGenEnabled(false); | |
| } else { | |
| setImageGenEnabled(true); | |
| setReasonerEnabled(false); | |
| setSearchEnabled(false); | |
| setSelectedImage(null); | |
| if (fileInputRef.current) { | |
| fileInputRef.current.value = ''; | |
| } | |
| } | |
| break; | |
| } | |
| }; | |
| const generateImage = async (prompt: string) => { | |
| if (!prompt.trim() || generatingImage) return; | |
| setGeneratingImage(true); | |
| try { | |
| const encodedPrompt = encodeURIComponent(prompt.trim()); | |
| const imageUrl = `https://image.pollinations.ai/prompt/${encodedPrompt}?width=1024&height=1024&nologo=true&private=true&enhance=true&safe=true`; | |
| const userMessage: Message = { | |
| id: Date.now().toString(), | |
| role: 'user', | |
| content: `Generate an image: ${prompt}`, | |
| timestamp: new Date() | |
| }; | |
| setMessages(prev => [...prev, userMessage]); | |
| const assistantMessage: Message = { | |
| id: (Date.now() + 1).toString(), | |
| role: 'assistant', | |
| content: 'Here\'s your generated image!', | |
| timestamp: new Date(), | |
| image: imageUrl | |
| }; | |
| setMessages(prev => [...prev, assistantMessage]); | |
| setInput(''); | |
| } catch (error) { | |
| console.error('Error generating image:', error); | |
| setMessages(prev => [...prev, { | |
| id: Date.now().toString(), | |
| role: 'assistant', | |
| content: 'Sorry, I encountered an error while generating the image. Please try again.', | |
| timestamp: new Date() | |
| }]); | |
| } finally { | |
| setGeneratingImage(false); | |
| } | |
| }; | |
| const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| const file = e.target.files?.[0]; | |
| if (file) { | |
| if (file.size > 5 * 1024 * 1024) { // 5MB limit | |
| alert('Image size should be less than 5MB'); | |
| return; | |
| } | |
| const reader = new FileReader(); | |
| reader.onloadend = () => { | |
| setSelectedImage(reader.result as string); | |
| setReasonerEnabled(false); | |
| setSearchEnabled(false); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| }; | |
| const removeSelectedImage = () => { | |
| setSelectedImage(null); | |
| if (fileInputRef.current) { | |
| fileInputRef.current.value = ''; | |
| } | |
| }; | |
| const makeApiRequest = async (messages: Message[], retryCount = 0, useFallback = false): Promise<any> => { | |
| const randomApiKey = GROQ_API_KEYS[Math.floor(Math.random() * GROQ_API_KEYS.length)]; | |
| try { | |
| const systemPrompt = messages.find(msg => msg.role === 'system'); | |
| const lastMessages = messages.filter(msg => msg.role !== 'system').slice(-5); | |
| const apiMessages = systemPrompt ? [systemPrompt, ...lastMessages] : lastMessages; | |
| const hasImageInHistory = apiMessages.some(msg => !!msg.image); | |
| const filteredMessages = apiMessages.map(msg => { | |
| // Skip messages with images if using reasoning model | |
| if (reasonerEnabled && msg.image) { | |
| return null; | |
| } | |
| if (msg.role === 'assistant' && msg.image) { | |
| return null; | |
| } | |
| const lastImageMessage = hasImageInHistory ? | |
| [...apiMessages].reverse().find((m: Message) => !!m.image) : null; | |
| if (msg.image && msg === lastImageMessage && !reasonerEnabled) { | |
| return { | |
| role: msg.role, | |
| content: [ | |
| { type: "text", text: msg.content }, | |
| { type: "image_url", image_url: { url: msg.image } } | |
| ] | |
| }; | |
| } else { | |
| return { | |
| role: msg.role, | |
| content: msg.content | |
| }; | |
| } | |
| }).filter(Boolean); // Remove any null messages (filtered image messages) | |
| let model; | |
| let apiUrl; | |
| if (reasonerEnabled) { | |
| // Use GROQ API for reasoning | |
| model = GROQ_MODEL_REASONER; | |
| apiUrl = GROQ_API_URL; | |
| } else { | |
| // Use Pollinations API for everything else | |
| model = searchEnabled ? POLLINATIONS_MODEL_SEARCH : | |
| (hasImageInHistory ? POLLINATIONS_MODEL_VISION : | |
| (useFallback ? POLLINATIONS_MODEL_FALLBACK : POLLINATIONS_MODEL_TEXT)); | |
| apiUrl = POLLINATIONS_API_URL; | |
| } | |
| // Create AbortController for timeout | |
| const controller = new AbortController(); | |
| const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT); | |
| try { | |
| const headers: Record<string, string> = { | |
| 'Content-Type': 'application/json' | |
| }; | |
| if (apiUrl === GROQ_API_URL) { | |
| headers['Authorization'] = `Bearer ${randomApiKey}`; | |
| } | |
| const response = await fetch(apiUrl, { | |
| method: 'POST', | |
| headers, | |
| body: JSON.stringify({ | |
| model, | |
| messages: filteredMessages, | |
| temperature: 0.7, | |
| max_tokens: (model === GROQ_MODEL_REASONER) ? 8192 : 4096, | |
| stream: true | |
| }), | |
| signal: controller.signal | |
| }); | |
| clearTimeout(timeoutId); // Clear timeout if request completes | |
| if (!response.ok) { | |
| const errorData = await response.json().catch(() => ({})); | |
| throw new Error(`HTTP error! status: ${response.status}, message: ${errorData.error?.message || 'Unknown error'}`); | |
| } | |
| return response; | |
| } catch (error) { | |
| clearTimeout(timeoutId); // Clear timeout on error | |
| if (error instanceof Error && error.name === 'AbortError') { | |
| throw new Error('Request timed out. Please try again.'); | |
| } | |
| throw error; | |
| } | |
| } catch (error) { | |
| // If error is timeout and not using fallback, try fallback immediately | |
| if (error instanceof Error && error.message === 'Request timed out. Please try again.' && !useFallback) { | |
| console.log('Request timed out, switching to fallback model...'); | |
| return makeApiRequest(messages, 0, true); | |
| } | |
| if (!useFallback) { | |
| console.log('Switching to fallback model...'); | |
| return makeApiRequest(messages, 0, true); | |
| } | |
| if (retryCount < MAX_RETRIES) { | |
| console.log(`Retry attempt ${retryCount + 1} of ${MAX_RETRIES}`); | |
| await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * (retryCount + 1))); | |
| return makeApiRequest(messages, retryCount + 1, true); | |
| } | |
| throw error; | |
| } | |
| }; | |
| const fetchYouTubeTranscript = async (url: string): Promise<string> => { | |
| try { | |
| const response = await fetch(TACTIQ_API_URL, { | |
| method: 'POST', | |
| headers: { | |
| 'accept': '*/*', | |
| 'content-type': 'application/json', | |
| 'sec-ch-ua': '"Chromium";v="134", "Not:A-Brand";v="24", "Brave";v="134"', | |
| 'sec-ch-ua-mobile': '?0', | |
| 'sec-ch-ua-platform': '"Windows"', | |
| 'Referer': 'https://tactiq.io/', | |
| }, | |
| body: JSON.stringify({ videoUrl: url }) | |
| }); | |
| if (!response.ok) { | |
| throw new Error('Failed to fetch transcript'); | |
| } | |
| const data = await response.json(); | |
| let text = `Title: ${data.title}\n\nTranscript:\n`; | |
| data.captions.forEach((caption: any) => { | |
| text += caption.text + ' '; | |
| }); | |
| return text.trim(); | |
| } catch (error) { | |
| console.error('Error fetching YouTube transcript:', error); | |
| throw error; | |
| } | |
| }; | |
| const extractYouTubeUrl = (text: string): string | null => { | |
| const regex = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)([^\s&]+)/; | |
| const match = text.match(regex); | |
| return match ? match[0] : null; | |
| }; | |
| const handleSubmit = async (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| if ((!input.trim() && !selectedImage) || isLoading) return; | |
| // If image generation is enabled, use the generateImage function instead | |
| if (imageGenEnabled || input.trim().startsWith('Generate an image')) { | |
| const prompt = input.trim().replace('Generate an image: ', ''); | |
| const reprompt = prompt.replace('Generate an image', ''); | |
| await generateImage(reprompt.trim()); | |
| return; | |
| } | |
| // Create visible user message | |
| const userMessage: Message = { | |
| id: Date.now().toString(), | |
| role: 'user', | |
| content: input.trim() || "What's in this image?", | |
| timestamp: new Date(), | |
| image: selectedImage || undefined | |
| }; | |
| setMessages(prev => [...prev, userMessage]); | |
| setInput(''); | |
| setSelectedImage(null); | |
| if (fileInputRef.current) { | |
| fileInputRef.current.value = ''; | |
| } | |
| setIsLoading(true); | |
| if (textareaRef.current) { | |
| textareaRef.current.style.height = 'auto'; | |
| textareaRef.current.focus(); | |
| } | |
| try { | |
| // Check for YouTube URL and get transcript if exists | |
| const youtubeUrl = extractYouTubeUrl(userMessage.content); | |
| let transcriptText = ''; | |
| if (youtubeUrl) { | |
| try { | |
| transcriptText = await fetchYouTubeTranscript(youtubeUrl); | |
| } catch (error) { | |
| console.error('Failed to fetch YouTube transcript:', error); | |
| } | |
| } | |
| const apiMessages: Message[] = selectedImage ? | |
| [{ ...SYSTEM_PROMPT, role: 'system' as const }] : | |
| [...messages]; | |
| apiMessages.push({ | |
| ...userMessage, | |
| content: transcriptText ? | |
| `${userMessage.content}\n\nContext from video:\n${transcriptText}` : | |
| userMessage.content | |
| }); | |
| const response = await makeApiRequest(apiMessages); | |
| const reader = response.body?.getReader(); | |
| if (!reader) throw new Error('Failed to get response reader'); | |
| // Create a new message for streaming response | |
| const assistantMessage: Message = { | |
| id: Date.now().toString(), | |
| role: 'assistant', | |
| content: '', | |
| timestamp: new Date(), | |
| }; | |
| setMessages(prev => [...prev, assistantMessage]); | |
| // Read the stream | |
| const decoder = new TextDecoder(); | |
| let buffer = ''; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| buffer += decoder.decode(value, { stream: true }); | |
| const lines = buffer.split('\n'); | |
| buffer = lines.pop() || ''; // Keep the incomplete line in buffer | |
| for (const line of lines) { | |
| if (line.trim() === '') continue; | |
| if (line === 'data: [DONE]') continue; | |
| try { | |
| const data = JSON.parse(line.replace(/^data: /, '')); | |
| const content = data.choices[0]?.delta?.content || ''; | |
| setMessages(prev => prev.map(msg => | |
| msg.id === assistantMessage.id | |
| ? { ...msg, content: msg.content + content } | |
| : msg | |
| )); | |
| } catch (error) { | |
| console.error('Error parsing stream:', error); | |
| } | |
| } | |
| } | |
| } catch (error) { | |
| console.error('Error:', error); | |
| setMessages(prev => [...prev, { | |
| id: Date.now().toString(), | |
| role: 'assistant', | |
| content: 'Sorry, I encountered an error. Please try again. If the problem persists, try refreshing the page or clearing the chat history.', | |
| timestamp: new Date(), | |
| }]); | |
| } finally { | |
| setIsLoading(false); | |
| requestAnimationFrame(() => { | |
| if (textareaRef.current) { | |
| textareaRef.current.focus(); | |
| } | |
| }); | |
| } | |
| }; | |
| const handleEditMessage = (message: Message) => { | |
| setEditingMessage({ | |
| id: message.id, | |
| content: message.content, | |
| image: message.image | |
| }); | |
| }; | |
| const handleEditCancel = () => { | |
| setEditingMessage(null); | |
| }; | |
| const handleEditSave = async () => { | |
| if (!editingMessage) return; | |
| const messageIndex = messages.findIndex(m => m.id === editingMessage.id); | |
| if (messageIndex === -1) return; | |
| // Check if content has actually changed | |
| const originalMessage = messages[messageIndex]; | |
| if (originalMessage.content === editingMessage.content && | |
| originalMessage.image === editingMessage.image) { | |
| setEditingMessage(null); | |
| return; | |
| } | |
| const updatedMessages = [...messages]; | |
| updatedMessages[messageIndex] = { | |
| ...updatedMessages[messageIndex], | |
| content: editingMessage.content, | |
| image: editingMessage.image | |
| }; | |
| updatedMessages.splice(messageIndex + 1); | |
| setMessages(updatedMessages); | |
| setEditingMessage(null); | |
| // If the edited message was an image generation prompt, regenerate the image | |
| if (imageGenEnabled || editingMessage.content.trim().startsWith('Generate an image')) { | |
| const prompt = editingMessage.content.trim().replace('Generate an image: ', ''); | |
| const reprompt = prompt.replace('Generate an image', ''); | |
| const encodedPrompt = encodeURIComponent(reprompt.trim()); | |
| const imageUrl = `https://image.pollinations.ai/prompt/${encodedPrompt}?width=1024&height=1024&nologo=true&private=true&enhance=true&safe=true`; | |
| const assistantMessage: Message = { | |
| id: (Date.now() + 1).toString(), | |
| role: 'assistant', | |
| content: 'Here\'s your generated image!', | |
| timestamp: new Date(), | |
| image: imageUrl | |
| }; | |
| setMessages(prev => [...prev, assistantMessage]); | |
| return; | |
| } | |
| setIsLoading(true); | |
| try { | |
| const response = await makeApiRequest(updatedMessages); | |
| const reader = response.body?.getReader(); | |
| if (!reader) throw new Error('Failed to get response reader'); | |
| const assistantMessage: Message = { | |
| id: Date.now().toString(), | |
| role: 'assistant', | |
| content: '', | |
| timestamp: new Date(), | |
| }; | |
| setMessages(prev => [...prev, assistantMessage]); | |
| const decoder = new TextDecoder(); | |
| let buffer = ''; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| buffer += decoder.decode(value, { stream: true }); | |
| const lines = buffer.split('\n'); | |
| buffer = lines.pop() || ''; | |
| for (const line of lines) { | |
| if (line.trim() === '' || line === 'data: [DONE]') continue; | |
| try { | |
| const data = JSON.parse(line.replace(/^data: /, '')); | |
| const content = data.choices[0]?.delta?.content || ''; | |
| setMessages(prev => prev.map(msg => | |
| msg.id === assistantMessage.id | |
| ? { ...msg, content: msg.content + content } | |
| : msg | |
| )); | |
| } catch (error) { | |
| console.error('Error parsing stream:', error); | |
| } | |
| } | |
| } | |
| } catch (error) { | |
| console.error('Error:', error); | |
| setMessages(prev => [...prev, { | |
| id: Date.now().toString(), | |
| role: 'assistant', | |
| content: 'Sorry, I encountered an error. Please try again. If the problem persists, try refreshing the page or clearing the chat history.', | |
| timestamp: new Date(), | |
| }]); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| // Add a warning if messages fail to load from localStorage | |
| useEffect(() => { | |
| const savedMessages = localStorage.getItem(STORAGE_KEY); | |
| if (savedMessages) { | |
| try { | |
| JSON.parse(savedMessages); | |
| } catch (error) { | |
| console.error('Error loading chat history:', error); | |
| localStorage.removeItem(STORAGE_KEY); | |
| setMessages([SYSTEM_PROMPT as Message]); | |
| } | |
| } | |
| }, []); | |
| // Add clipboard paste handler | |
| const handlePaste = async (e: ClipboardEvent) => { | |
| const items = e.clipboardData?.items; | |
| if (!items) return; | |
| for (const item of Array.from(items)) { | |
| if (item.type.startsWith('image/')) { | |
| e.preventDefault(); | |
| const file = item.getAsFile(); | |
| if (!file) continue; | |
| if (file.size > 5 * 1024 * 1024) { // 5MB limit | |
| alert('Image size should be less than 5MB'); | |
| return; | |
| } | |
| const reader = new FileReader(); | |
| reader.onloadend = () => { | |
| setSelectedImage(reader.result as string); | |
| setReasonerEnabled(false); | |
| setSearchEnabled(false); | |
| }; | |
| reader.readAsDataURL(file); | |
| break; | |
| } | |
| } | |
| }; | |
| useEffect(() => { | |
| document.addEventListener('paste', handlePaste); | |
| return () => { | |
| document.removeEventListener('paste', handlePaste); | |
| }; | |
| }, []); | |
| const adjustTextareaHeight = () => { | |
| const textarea = textareaRef.current; | |
| if (textarea) { | |
| textarea.style.height = 'auto'; | |
| textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; // Max height of 200px | |
| } | |
| }; | |
| const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { | |
| setInput(e.target.value); | |
| adjustTextareaHeight(); | |
| }; | |
| useEffect(() => { | |
| if (textareaRef.current) { | |
| textareaRef.current.focus(); | |
| } | |
| }, []); | |
| return ( | |
| <div className="fixed inset-0 bg-gray-100 dark:bg-gray-900 z-50 flex flex-col"> | |
| {/* Header */} | |
| <div className="bg-white dark:bg-gray-900 shadow-sm dark:shadow-none flex-shrink-0 border-b border-gray-200 dark:border-gray-800"> | |
| <div className="w-full px-2 sm:px-4 flex items-center justify-between py-4"> | |
| <div className="flex items-center gap-3"> | |
| <div className="flex items-center gap-2"> | |
| <div className="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center"> | |
| <Bot className="w-6 h-6 text-white" /> | |
| </div> | |
| <div> | |
| <h1 className="font-semibold text-gray-900 dark:text-gray-100">Study Assistant</h1> | |
| <p className="text-sm text-gray-500 dark:text-green-400"> | |
| {isLoading ? 'Typing...' : 'Online'} | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <button | |
| onClick={() => setDarkMode((d) => !d)} | |
| className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors" | |
| title={darkMode ? 'Switch to light mode' : 'Switch to dark mode'} | |
| > | |
| {darkMode ? <Sun className="w-5 h-5 text-yellow-400" /> : <Moon className="w-5 h-5 text-gray-700" />} | |
| </button> | |
| <button | |
| onClick={() => setShowClearConfirm(true)} | |
| className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors text-red-600" | |
| title="Clear chat history" | |
| > | |
| <Trash2 className="w-5 h-5" /> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Messages */} | |
| <div className="flex-1 overflow-y-auto min-h-0 no-scrollbar"> | |
| <div className="w-full px-2 sm:px-8"> | |
| {messages.length <= 1 ? ( | |
| <div className="flex justify-center items-center min-h-[80vh]"> | |
| <div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8 max-w-xl w-full text-center border border-gray-200 dark:border-gray-700"> | |
| <div className="flex flex-col items-center mb-4"> | |
| <span className="text-4xl mb-2 animate-bounce">👋</span> | |
| <h2 className="text-2xl font-bold mb-1 text-gray-900 dark:text-gray-100">Welcome to Study Assistant!</h2> | |
| </div> | |
| <p className="mb-4 text-gray-600 dark:text-gray-300 text-lg"> | |
| I'm your friendly AI study buddy—here to help you learn, solve problems, and master new topics. Just ask me anything! | |
| </p> | |
| <ul className="text-left text-gray-700 dark:text-gray-200 list-disc list-inside space-y-2 mb-4"> | |
| <li><b>Breaks down</b> tough topics into simple, bite-sized explanations.</li> | |
| <li><b>Guides you</b> step-by-step through problems and concepts.</li> | |
| <li><b>Encourages</b> critical thinking and independent learning.</li> | |
| <li><b>Supports you</b> with tips, resources, and clear answers.</li> | |
| </ul> | |
| <div className="mt-4 text-gray-500 dark:text-gray-400 text-base"> | |
| 🚀 <b>Get started:</b> Type your question below and let's learn together! | |
| </div> | |
| <div className="mt-8 text-xs text-gray-400 dark:text-gray-500"> | |
| Developed by <a href="https://www.instagram.com/sanch1t_" target="_blank" rel="noopener noreferrer" className="underline hover:text-blue-500">Sanchit</a> | |
| </div> | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="py-4 space-y-4"> | |
| {messages.slice(1).map(message => ( | |
| <ChatMessage | |
| key={message.id} | |
| message={message} | |
| onEdit={handleEditMessage} | |
| isEditing={editingMessage?.id === message.id} | |
| editContent={editingMessage?.content} | |
| onEditChange={(content) => setEditingMessage(prev => prev ? { ...prev, content } : null)} | |
| onEditSave={handleEditSave} | |
| onEditCancel={handleEditCancel} | |
| setFullScreenImage={setFullScreenImage} | |
| /> | |
| ))} | |
| {isLoading && ( | |
| <div className="flex justify-start"> | |
| <div className="bg-white text-gray-800 rounded-2xl rounded-bl-none px-4 py-2 shadow-sm dark:bg-gray-800 dark:text-gray-100 dark:shadow-none"> | |
| <div className="flex space-x-2"> | |
| <div className="w-2 h-2 rounded-full animate-bounce bg-gray-400 dark:bg-gray-300" /> | |
| <div className="w-2 h-2 rounded-full animate-bounce bg-gray-400 dark:bg-gray-300" style={{ animationDelay: '0.2s' }} /> | |
| <div className="w-2 h-2 rounded-full animate-bounce bg-gray-400 dark:bg-gray-300" style={{ animationDelay: '0.4s' }} /> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| <div ref={messagesEndRef} /> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| {/* Input section */} | |
| <div className="bg-white dark:bg-gray-900 shadow-lg dark:shadow-none mt-auto flex-shrink-0 border-t border-gray-200 dark:border-gray-800"> | |
| <div className="w-full px-2 sm:px-4"> | |
| <div className="py-4"> | |
| {selectedImage && ( | |
| <div className="mb-2 relative inline-block"> | |
| <img | |
| src={selectedImage} | |
| alt="Selected" | |
| className="h-20 rounded-lg object-contain" | |
| /> | |
| <button | |
| onClick={removeSelectedImage} | |
| className="absolute -top-2 -right-2 p-1 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors" | |
| > | |
| <X className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| )} | |
| <form onSubmit={handleSubmit} className="flex gap-4 items-start max-w-full"> | |
| <div className="flex gap-1"> | |
| <button | |
| type="button" | |
| onClick={() => toggleMode('reasoner')} | |
| className={`p-2 rounded-full transition-colors flex-shrink-0 ${ | |
| reasonerEnabled | |
| ? 'text-white bg-blue-500 hover:bg-blue-600' | |
| : 'text-gray-500 hover:bg-gray-100 hover:text-gray-700' | |
| }`} | |
| title={`${reasonerEnabled ? 'Disable' : 'Enable'} reasoner mode`} | |
| > | |
| <Brain className="w-5 h-5" /> | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => toggleMode('search')} | |
| className={`p-2 rounded-full transition-colors flex-shrink-0 ${ | |
| searchEnabled | |
| ? 'text-white bg-blue-500 hover:bg-blue-600' | |
| : 'text-gray-500 hover:bg-gray-100 hover:text-gray-700' | |
| }`} | |
| title={`${searchEnabled ? 'Disable' : 'Enable'} search mode`} | |
| > | |
| <Globe className="w-5 h-5" /> | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => toggleMode('generate')} | |
| className={`p-2 rounded-full transition-colors flex-shrink-0 ${ | |
| imageGenEnabled | |
| ? 'text-white bg-blue-500 hover:bg-blue-600' | |
| : 'text-gray-500 hover:bg-gray-100 hover:text-gray-700' | |
| }`} | |
| title={`${imageGenEnabled ? 'Disable' : 'Enable'} image generation mode`} | |
| disabled={generatingImage} | |
| > | |
| <Wand2 className="w-5 h-5" /> | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => toggleMode('image')} | |
| disabled={isLoading} | |
| className={`group relative p-2 rounded-full transition-colors flex-shrink-0 ${ | |
| selectedImage | |
| ? 'text-white bg-blue-500 hover:bg-blue-600' | |
| : 'text-gray-500 hover:bg-gray-100 hover:text-gray-700' | |
| }`} | |
| title="Attach image or paste from clipboard" | |
| > | |
| <ImageUp className="w-5 h-5" /> | |
| </button> | |
| </div> | |
| <textarea | |
| ref={textareaRef} | |
| value={input} | |
| onChange={handleInputChange} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| handleSubmit(e); | |
| } | |
| }} | |
| placeholder={selectedImage ? "Ask about this image..." : "Ask me anything..."} | |
| className="flex-1 px-4 py-2 rounded-lg border bg-transparent focus:outline-none resize-none min-h-[40px] max-h-[100px] overflow-y-auto custom-scrollbar text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800" | |
| disabled={isLoading} | |
| rows={1} | |
| /> | |
| <input | |
| type="file" | |
| accept="image/*" | |
| onChange={handleImageSelect} | |
| ref={fileInputRef} | |
| className="hidden" | |
| /> | |
| <button | |
| type="submit" | |
| disabled={(!input.trim() && !selectedImage) || isLoading || generatingImage} | |
| className="w-10 h-10 bg-blue-500 text-white rounded-full hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center flex-shrink-0" | |
| > | |
| <ArrowUp className="w-5 h-5" /> | |
| </button> | |
| </form> | |
| </div> | |
| </div> | |
| </div> | |
| {showClearConfirm && ( | |
| <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"> | |
| <div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-sm w-full mx-4"> | |
| <h3 className="text-lg font-semibold mb-2">Clear Chat History</h3> | |
| <p className="text-gray-600 mb-4"> | |
| Are you sure you want to clear all chat history? This action cannot be undone. | |
| </p> | |
| <div className="flex justify-end gap-2"> | |
| <button | |
| onClick={() => setShowClearConfirm(false)} | |
| className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors" | |
| > | |
| Cancel | |
| </button> | |
| <button | |
| onClick={handleClearHistory} | |
| className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors" | |
| > | |
| Clear History | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {fullScreenImage && ( | |
| <div | |
| className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center cursor-pointer" | |
| onClick={() => setFullScreenImage(null)} | |
| > | |
| <button | |
| onClick={() => setFullScreenImage(null)} | |
| className="absolute top-4 right-4 p-2 bg-black/50 text-white rounded-full hover:bg-black/70 transition-colors" | |
| > | |
| <X className="w-6 h-6" /> | |
| </button> | |
| <img | |
| src={fullScreenImage} | |
| alt="Full screen" | |
| className="max-w-[90%] max-h-[90vh] object-contain" | |
| onClick={(e) => e.stopPropagation()} | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| export default App | |