diff --git a/README.md b/README.md index 94a3dc4..53f0c46 100644 --- a/README.md +++ b/README.md @@ -144,21 +144,24 @@ Perplexica runs on Next.js and handles all API requests. It works right away on When running Perplexica behind a reverse proxy (like Nginx, Apache, or Traefik), follow these steps to ensure proper functionality: -1. **Configure the BASE_URL setting**: +1. **Configure the BASE_URL setting**: + - In `config.toml`, set the `BASE_URL` parameter under the `[GENERAL]` section to your public-facing URL (e.g., `https://perplexica.yourdomain.com`) 2. **Ensure proper headers forwarding**: + - Your reverse proxy should forward the following headers: - - `X-Forwarded-Host` - - `X-Forwarded-Proto` + - `X-Forwarded-Host` + - `X-Forwarded-Proto` - `X-Forwarded-Port` (if using non-standard ports) 3. **Example Nginx configuration**: + ```nginx server { listen 80; server_name perplexica.yourdomain.com; - + location / { proxy_pass http://localhost:3000; proxy_set_header Host $host; diff --git a/src/app/api/opensearch/route.ts b/src/app/api/opensearch/route.ts index a5f4079..f49535d 100644 --- a/src/app/api/opensearch/route.ts +++ b/src/app/api/opensearch/route.ts @@ -26,38 +26,44 @@ function generateOpenSearchResponse(origin: string): NextResponse { export async function GET(request: Request) { // Check if a BASE_URL is explicitly configured const configBaseUrl = getBaseUrl(); - + // If BASE_URL is configured, use it, otherwise detect from request if (configBaseUrl) { // Remove any trailing slashes for consistency let origin = configBaseUrl.replace(/\/+$/, ''); return generateOpenSearchResponse(origin); } - + // Detect the correct origin, taking into account reverse proxy headers const url = new URL(request.url); let origin = url.origin; - + // Extract headers const headers = Object.fromEntries(request.headers); - + // Check for X-Forwarded-Host and related headers to handle reverse proxies if (headers['x-forwarded-host']) { // Determine protocol: prefer X-Forwarded-Proto, fall back to original or https - const protocol = headers['x-forwarded-proto'] || url.protocol.replace(':', ''); + const protocol = + headers['x-forwarded-proto'] || url.protocol.replace(':', ''); // Build the correct public-facing origin origin = `${protocol}://${headers['x-forwarded-host']}`; - + // Handle non-standard ports if specified in X-Forwarded-Port if (headers['x-forwarded-port']) { const port = headers['x-forwarded-port']; // Don't append standard ports (80 for HTTP, 443 for HTTPS) - if (!((protocol === 'http' && port === '80') || (protocol === 'https' && port === '443'))) { + if ( + !( + (protocol === 'http' && port === '80') || + (protocol === 'https' && port === '443') + ) + ) { origin = `${origin}:${port}`; } } } - + // Generate and return the OpenSearch response return generateOpenSearchResponse(origin); } diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 83a8831..ee78052 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -145,8 +145,6 @@ const Page = () => { string | null >(null); const [isLoading, setIsLoading] = useState(false); - const [automaticImageSearch, setAutomaticImageSearch] = useState(false); - const [automaticVideoSearch, setAutomaticVideoSearch] = useState(false); const [automaticSuggestions, setAutomaticSuggestions] = useState(true); const [systemInstructions, setSystemInstructions] = useState(''); const [savingStates, setSavingStates] = useState>({}); @@ -209,12 +207,6 @@ const Page = () => { setChatModels(data.chatModelProviders || {}); setEmbeddingModels(data.embeddingModelProviders || {}); - setAutomaticImageSearch( - localStorage.getItem('autoImageSearch') === 'true', - ); - setAutomaticVideoSearch( - localStorage.getItem('autoVideoSearch') === 'true', - ); setAutomaticSuggestions( localStorage.getItem('autoSuggestions') !== 'false', // default to true if not set ); @@ -372,11 +364,7 @@ const Page = () => { setConfig(data); } - if (key === 'automaticImageSearch') { - localStorage.setItem('autoImageSearch', value.toString()); - } else if (key === 'automaticVideoSearch') { - localStorage.setItem('autoVideoSearch', value.toString()); - } else if (key === 'automaticSuggestions') { + if (key === 'automaticSuggestions') { localStorage.setItem('autoSuggestions', value.toString()); } else if (key === 'chatModelProvider') { localStorage.setItem('chatModelProvider', value); @@ -449,90 +437,6 @@ const Page = () => {
-
-
-
- -
-
-

- Automatic Image Search -

-

- Automatically search for relevant images in chat - responses -

-
-
- { - setAutomaticImageSearch(checked); - saveConfig('automaticImageSearch', checked); - }} - className={cn( - automaticImageSearch - ? 'bg-[#24A0ED]' - : 'bg-light-200 dark:bg-dark-200', - 'relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none', - )} - > - - -
- -
-
-
- -
-
-

- Automatic Video Search -

-

- Automatically search for relevant videos in chat - responses -

-
-
- { - setAutomaticVideoSearch(checked); - saveConfig('automaticVideoSearch', checked); - }} - className={cn( - automaticVideoSearch - ? 'bg-[#24A0ED]' - : 'bg-light-200 dark:bg-dark-200', - 'relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none', - )} - > - - -
-
diff --git a/src/components/Chat.tsx b/src/components/Chat.tsx index fdedc0d..59de5d9 100644 --- a/src/components/Chat.tsx +++ b/src/components/Chat.tsx @@ -1,11 +1,10 @@ 'use client'; import { Fragment, useEffect, useRef, useState } from 'react'; -import MessageInput from './MessageInput'; import { File, Message } from './ChatWindow'; import MessageBox from './MessageBox'; import MessageBoxLoading from './MessageBoxLoading'; -import { check } from 'drizzle-orm/gel-core'; +import MessageInput from './MessageInput'; const Chat = ({ loading, @@ -43,11 +42,11 @@ const Chat = ({ focusMode: string; setFocusMode: (mode: string) => void; }) => { - const [dividerWidth, setDividerWidth] = useState(0); const [isAtBottom, setIsAtBottom] = useState(true); const [manuallyScrolledUp, setManuallyScrolledUp] = useState(false); - const dividerRef = useRef(null); + const [inputStyle, setInputStyle] = useState({}); const messageEnd = useRef(null); + const containerRef = useRef(null); const SCROLL_THRESHOLD = 250; // pixels from bottom to consider "at bottom" // Check if user is at bottom of page @@ -111,22 +110,6 @@ const Chat = ({ }; }, [isAtBottom]); - useEffect(() => { - const updateDividerWidth = () => { - if (dividerRef.current) { - setDividerWidth(dividerRef.current.scrollWidth); - } - }; - - updateDividerWidth(); - - window.addEventListener('resize', updateDividerWidth); - - return () => { - window.removeEventListener('resize', updateDividerWidth); - }; - }); - // Scroll when user sends a message useEffect(() => { const scroll = () => { @@ -157,8 +140,32 @@ const Chat = ({ } }, [scrollTrigger, isAtBottom, messages.length, manuallyScrolledUp]); + // Sync input width with main container width + useEffect(() => { + const updateInputStyle = () => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + setInputStyle({ + width: rect.width, + left: rect.left, + right: window.innerWidth - rect.right, + }); + } + }; + + // Initial calculation + updateInputStyle(); + + // Update on resize + window.addEventListener('resize', updateInputStyle); + + return () => { + window.removeEventListener('resize', updateInputStyle); + }; + }, []); + return ( -
+
{messages.map((msg, i) => { const isLast = i === messages.length - 1; @@ -170,7 +177,6 @@ const Chat = ({ messageIndex={i} history={messages} loading={loading} - dividerRef={isLast ? dividerRef : undefined} isLast={isLast} rewrite={rewrite} sendMessage={sendMessage} @@ -182,58 +188,52 @@ const Chat = ({ ); })} {loading && } -
- - {dividerWidth > 0 && ( -
- {/* Scroll to bottom button - appears above the MessageInput when user has scrolled up */} - {manuallyScrolledUp && !isAtBottom && ( -
- -
- )} + + + Scroll to bottom + +
+ )} - -
- )} + +
+
); }; diff --git a/src/components/ChatWindow.tsx b/src/components/ChatWindow.tsx index fa76796..9c8e8ba 100644 --- a/src/components/ChatWindow.tsx +++ b/src/components/ChatWindow.tsx @@ -59,17 +59,6 @@ const checkConfig = async ( let embeddingModel = localStorage.getItem('embeddingModel'); let embeddingModelProvider = localStorage.getItem('embeddingModelProvider'); - const autoImageSearch = localStorage.getItem('autoImageSearch'); - const autoVideoSearch = localStorage.getItem('autoVideoSearch'); - - if (!autoImageSearch) { - localStorage.setItem('autoImageSearch', 'true'); - } - - if (!autoVideoSearch) { - localStorage.setItem('autoVideoSearch', 'false'); - } - const providers = await fetch(`/api/models`, { headers: { 'Content-Type': 'application/json', @@ -499,22 +488,8 @@ const ChatWindow = ({ id }: { id?: string }) => { const lastMsg = messagesRef.current[messagesRef.current.length - 1]; - const autoImageSearch = localStorage.getItem('autoImageSearch'); - const autoVideoSearch = localStorage.getItem('autoVideoSearch'); const autoSuggestions = localStorage.getItem('autoSuggestions'); - if (autoImageSearch === 'true') { - document - .getElementById(`search-images-${lastMsg.messageId}`) - ?.click(); - } - - if (autoVideoSearch === 'true') { - document - .getElementById(`search-videos-${lastMsg.messageId}`) - ?.click(); - } - if ( lastMsg.role === 'assistant' && lastMsg.sources && diff --git a/src/components/MessageActions/ModelInfo.tsx b/src/components/MessageActions/ModelInfo.tsx index fc06b87..330352c 100644 --- a/src/components/MessageActions/ModelInfo.tsx +++ b/src/components/MessageActions/ModelInfo.tsx @@ -39,11 +39,11 @@ const ModelInfoButton: React.FC = ({ modelStats }) => {
{showPopover && (
{ return ; @@ -103,7 +82,6 @@ const MessageBox = ({ messageIndex, history, loading, - dividerRef, isLast, rewrite, sendMessage, @@ -112,7 +90,6 @@ const MessageBox = ({ messageIndex: number; history: Message[]; loading: boolean; - dividerRef?: MutableRefObject; isLast: boolean; rewrite: (messageId: string) => void; sendMessage: ( @@ -124,130 +101,6 @@ const MessageBox = ({ }, ) => void; }) => { - const [parsedMessage, setParsedMessage] = useState(message.content); - const [speechMessage, setSpeechMessage] = useState(message.content); - const [loadingSuggestions, setLoadingSuggestions] = useState(false); - const [autoSuggestions, setAutoSuggestions] = useState( - localStorage.getItem('autoSuggestions'), - ); - - const handleLoadSuggestions = async () => { - if ( - loadingSuggestions || - (message?.suggestions && message.suggestions.length > 0) - ) - return; - - setLoadingSuggestions(true); - try { - const suggestions = await getSuggestions([...history]); - // We need to update the message.suggestions property through parent component - sendMessage('', { messageId: message.messageId, suggestions }); - } catch (error) { - console.error('Error loading suggestions:', error); - } finally { - setLoadingSuggestions(false); - } - }; - - useEffect(() => { - const citationRegex = /\[([^\]]+)\]/g; - const regex = /\[(\d+)\]/g; - let processedMessage = message.content; - - if (message.role === 'assistant' && message.content.includes('')) { - const openThinkTag = processedMessage.match(//g)?.length || 0; - const closeThinkTag = processedMessage.match(/<\/think>/g)?.length || 0; - - if (openThinkTag > closeThinkTag) { - processedMessage += ' '; // The extra is to prevent the the think component from looking bad - } - } - - if ( - message.role === 'assistant' && - message?.sources && - message.sources.length > 0 - ) { - setParsedMessage( - processedMessage.replace( - citationRegex, - (_, capturedContent: string) => { - const numbers = capturedContent - .split(',') - .map((numStr) => numStr.trim()); - - const linksHtml = numbers - .map((numStr) => { - const number = parseInt(numStr); - - if (isNaN(number) || number <= 0) { - return `[${numStr}]`; - } - - const source = message.sources?.[number - 1]; - const url = source?.metadata?.url; - - if (url) { - return `${numStr}`; - } else { - return `[${numStr}]`; - } - }) - .join(''); - - return linksHtml; - }, - ), - ); - setSpeechMessage(message.content.replace(regex, '')); - return; - } - - setSpeechMessage(message.content.replace(regex, '')); - setParsedMessage(processedMessage); - }, [message.content, message.sources, message.role]); - - useEffect(() => { - const handleStorageChange = () => { - setAutoSuggestions(localStorage.getItem('autoSuggestions')); - }; - - window.addEventListener('storage', handleStorageChange); - - return () => { - window.removeEventListener('storage', handleStorageChange); - }; - }, []); - - const { speechStatus, start, stop } = useSpeech({ text: speechMessage }); - - const markdownOverrides: MarkdownToJSX.Options = { - overrides: { - think: { - component: ThinkTagProcessor, - }, - code: { - component: ({ className, children }) => { - // Check if it's an inline code block or a fenced code block - if (className) { - // This is a fenced code block (```code```) - return {children}; - } - // This is an inline code block (`code`) - return ( - - {children} - - ); - }, - }, - pre: { - component: ({ children }) => children, - }, - }, - }; - return (
{message.role === 'user' && ( @@ -265,174 +118,16 @@ const MessageBox = ({ )} {message.role === 'assistant' && ( -
-
- {message.sources && message.sources.length > 0 && ( -
-
- -

- Sources -

-
- {message.searchQuery && ( -
- - Search query: - {' '} - {message.searchUrl ? ( - - {message.searchQuery} - - ) : ( - - {message.searchQuery} - - )} -
- )} - -
- )} -
- {' '} -
- -

- Answer -

- {message.modelStats && ( - - )} -
- - {parsedMessage} - - {loading && isLast ? null : ( -
-
- {/* */} - -
-
- - -
-
- )} - {isLast && message.role === 'assistant' && !loading && ( - <> -
-
-
- -

Related

{' '} - {(!autoSuggestions || autoSuggestions === 'false') && - (!message.suggestions || - message.suggestions.length === 0) ? ( -
- -
- ) : null} -
- {message.suggestions && message.suggestions.length > 0 ? ( -
- {message.suggestions.map((suggestion, i) => ( -
-
-
{ - sendMessage(suggestion); - }} - className="cursor-pointer flex flex-row justify-between font-medium space-x-2 items-center" - > -

- {suggestion} -

- -
-
- ))} -
- ) : null} -
- - )} -
-
-
- - -
-
+ )}
); diff --git a/src/components/MessageSources.tsx b/src/components/MessageSources.tsx index fb2b5bb..7d9afa1 100644 --- a/src/components/MessageSources.tsx +++ b/src/components/MessageSources.tsx @@ -1,31 +1,11 @@ /* eslint-disable @next/next/no-img-element */ -import { - Dialog, - DialogPanel, - DialogTitle, - Transition, - TransitionChild, -} from '@headlessui/react'; import { Document } from '@langchain/core/documents'; import { File } from 'lucide-react'; -import { Fragment, useState } from 'react'; const MessageSources = ({ sources }: { sources: Document[] }) => { - const [isDialogOpen, setIsDialogOpen] = useState(false); - - const closeModal = () => { - setIsDialogOpen(false); - document.body.classList.remove('overflow-hidden-scrollable'); - }; - - const openModal = () => { - setIsDialogOpen(true); - document.body.classList.add('overflow-hidden-scrollable'); - }; - return (
- {sources.slice(0, 3).map((source, i) => ( + {sources.map((source, i) => ( {
))} - {sources.length > 3 && ( - - )} - - - -
); }; diff --git a/src/components/MessageTabs.tsx b/src/components/MessageTabs.tsx new file mode 100644 index 0000000..1869739 --- /dev/null +++ b/src/components/MessageTabs.tsx @@ -0,0 +1,535 @@ +/* eslint-disable @next/next/no-img-element */ +'use client'; + +import { getSuggestions } from '@/lib/actions'; +import { cn } from '@/lib/utils'; +import { + BookCopy, + CheckCheck, + Copy as CopyIcon, + Disc3, + ImagesIcon, + Layers3, + Plus, + Sparkles, + StopCircle, + VideoIcon, + Volume2, +} from 'lucide-react'; +import Markdown, { MarkdownToJSX } from 'markdown-to-jsx'; +import { useEffect, useState } from 'react'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism'; +import { useSpeech } from 'react-text-to-speech'; +import { Message } from './ChatWindow'; +import Copy from './MessageActions/Copy'; +import ModelInfoButton from './MessageActions/ModelInfo'; +import Rewrite from './MessageActions/Rewrite'; +import MessageSources from './MessageSources'; +import SearchImages from './SearchImages'; +import SearchVideos from './SearchVideos'; +import ThinkBox from './ThinkBox'; + +const ThinkTagProcessor = ({ children }: { children: React.ReactNode }) => { + return ; +}; + +const CodeBlock = ({ + className, + children, +}: { + className?: string; + children: React.ReactNode; +}) => { + // Extract language from className (format could be "language-javascript" or "lang-javascript") + let language = ''; + if (className) { + if (className.startsWith('language-')) { + language = className.replace('language-', ''); + } else if (className.startsWith('lang-')) { + language = className.replace('lang-', ''); + } + } + + const content = children as string; + const [isCopied, setIsCopied] = useState(false); + + const handleCopyCode = () => { + navigator.clipboard.writeText(content); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + }; + + return ( +
+
+ {language} + +
+ 1} + useInlineStyles={true} + PreTag="div" + > + {content} + +
+ ); +}; + +type TabType = 'text' | 'sources' | 'images' | 'videos'; + +interface SearchTabsProps { + chatHistory: Message[]; + query: string; + messageId: string; + message: Message; + isLast: boolean; + loading: boolean; + rewrite: (messageId: string) => void; + sendMessage: ( + message: string, + options?: { + messageId?: string; + rewriteIndex?: number; + suggestions?: string[]; + }, + ) => void; +} + +const MessageTabs = ({ + chatHistory, + query, + messageId, + message, + isLast, + loading, + rewrite, + sendMessage, +}: SearchTabsProps) => { + const [activeTab, setActiveTab] = useState('text'); + const [imageCount, setImageCount] = useState(0); + const [videoCount, setVideoCount] = useState(0); + const [parsedMessage, setParsedMessage] = useState(message.content); + const [speechMessage, setSpeechMessage] = useState(message.content); + const [loadingSuggestions, setLoadingSuggestions] = useState(false); + const { speechStatus, start, stop } = useSpeech({ text: speechMessage }); + + // Callback functions to update counts + const updateImageCount = (count: number) => { + setImageCount(count); + }; + + const updateVideoCount = (count: number) => { + setVideoCount(count); + }; + + // Load suggestions handling + const handleLoadSuggestions = async () => { + if ( + loadingSuggestions || + (message?.suggestions && message.suggestions.length > 0) + ) + return; + + setLoadingSuggestions(true); + try { + const suggestions = await getSuggestions([...chatHistory, message]); + // Update the message.suggestions property through parent component + sendMessage('', { messageId: message.messageId, suggestions }); + } catch (error) { + console.error('Error loading suggestions:', error); + } finally { + setLoadingSuggestions(false); + } + }; + + // Process message content + useEffect(() => { + const citationRegex = /\[([^\]]+)\]/g; + const regex = /\[(\d+)\]/g; + let processedMessage = message.content; + + if (message.role === 'assistant' && message.content.includes('')) { + const openThinkTag = processedMessage.match(//g)?.length || 0; + const closeThinkTag = processedMessage.match(/<\/think>/g)?.length || 0; + + if (openThinkTag > closeThinkTag) { + processedMessage += ' '; // The extra is to prevent the think component from looking bad + } + } + + if ( + message.role === 'assistant' && + message?.sources && + message.sources.length > 0 + ) { + setParsedMessage( + processedMessage.replace( + citationRegex, + (_, capturedContent: string) => { + const numbers = capturedContent + .split(',') + .map((numStr) => numStr.trim()); + + const linksHtml = numbers + .map((numStr) => { + const number = parseInt(numStr); + + if (isNaN(number) || number <= 0) { + return `[${numStr}]`; + } + + const source = message.sources?.[number - 1]; + const url = source?.metadata?.url; + + if (url) { + return `${numStr}`; + } else { + return `[${numStr}]`; + } + }) + .join(''); + + return linksHtml; + }, + ), + ); + setSpeechMessage(message.content.replace(regex, '')); + return; + } + + setSpeechMessage(message.content.replace(regex, '')); + setParsedMessage(processedMessage); + }, [message.content, message.sources, message.role]); + + // Auto-suggest effect (similar to MessageBox) + useEffect(() => { + const autoSuggestions = localStorage.getItem('autoSuggestions'); + if ( + isLast && + message.role === 'assistant' && + !loading && + autoSuggestions === 'true' + ) { + handleLoadSuggestions(); + } + }, [isLast, loading, message.role]); + + // Markdown formatting options + const markdownOverrides: MarkdownToJSX.Options = { + overrides: { + think: { + component: ThinkTagProcessor, + }, + code: { + component: ({ className, children }) => { + // Check if it's an inline code block or a fenced code block + if (className) { + // This is a fenced code block (```code```) + return {children}; + } + // This is an inline code block (`code`) + return ( + + {children} + + ); + }, + }, + pre: { + component: ({ children }) => children, + }, + }, + }; + + return ( +
+ {/* Tabs */} +
+ + + {message.sources && message.sources.length > 0 && ( + + )} + + + + +
+ + {/* Tab content */} +
+ {/* Answer Tab */} + {activeTab === 'text' && ( +
+ + {parsedMessage} + + + {loading && isLast ? null : ( +
+
+ + {message.modelStats && ( + + )} +
+
+ + +
+
+ )} + + {isLast && message.role === 'assistant' && !loading && ( + <> +
+
+ +

Related

+ + {(!message.suggestions || + message.suggestions.length === 0) && ( + + )} +
+ + {message.suggestions && message.suggestions.length > 0 && ( +
+ {message.suggestions.map((suggestion, i) => ( +
+
+
{ + sendMessage(suggestion); + }} + className="cursor-pointer flex flex-row justify-between font-medium space-x-2 items-center" + > +

+ {suggestion} +

+ +
+
+ ))} +
+ )} +
+ + )} +
+ )} + + {/* Sources Tab */} + {activeTab === 'sources' && + message.sources && + message.sources.length > 0 && ( +
+ {message.searchQuery && ( +
+ + Search query: + {' '} + {message.searchUrl ? ( + + {message.searchQuery} + + ) : ( + + {message.searchQuery} + + )} +
+ )} + +
+ )} + + {/* Images Tab */} + {activeTab === 'images' && ( +
+ +
+ )} + + {/* Videos Tab */} + {activeTab === 'videos' && ( +
+ +
+ )} +
+
+ ); +}; + +export default MessageTabs; diff --git a/src/components/SearchImages.tsx b/src/components/SearchImages.tsx index f41ab46..9e9185c 100644 --- a/src/components/SearchImages.tsx +++ b/src/components/SearchImages.tsx @@ -1,6 +1,5 @@ /* eslint-disable @next/next/no-img-element */ -import { ImagesIcon, PlusIcon } from 'lucide-react'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import Lightbox from 'yet-another-react-lightbox'; import 'yet-another-react-lightbox/styles.css'; import { Message } from './ChatWindow'; @@ -15,75 +14,91 @@ const SearchImages = ({ query, chatHistory, messageId, + onImagesLoaded, }: { query: string; chatHistory: Message[]; messageId: string; + onImagesLoaded?: (count: number) => void; }) => { const [images, setImages] = useState(null); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const [open, setOpen] = useState(false); const [slides, setSlides] = useState([]); + const hasLoadedRef = useRef(false); + + useEffect(() => { + // Skip fetching if images are already loaded for this message + if (hasLoadedRef.current) { + return; + } + + const fetchImages = async () => { + setLoading(true); + + const chatModelProvider = localStorage.getItem('chatModelProvider'); + const chatModel = localStorage.getItem('chatModel'); + const customOpenAIBaseURL = localStorage.getItem('openAIBaseURL'); + const customOpenAIKey = localStorage.getItem('openAIApiKey'); + const ollamaContextWindow = + localStorage.getItem('ollamaContextWindow') || '2048'; + + try { + const res = await fetch(`/api/images`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: query, + chatHistory: chatHistory, + chatModel: { + provider: chatModelProvider, + model: chatModel, + ...(chatModelProvider === 'custom_openai' && { + customOpenAIBaseURL: customOpenAIBaseURL, + customOpenAIKey: customOpenAIKey, + }), + ...(chatModelProvider === 'ollama' && { + ollamaContextWindow: parseInt(ollamaContextWindow), + }), + }, + }), + }); + + const data = await res.json(); + + const images = data.images ?? []; + setImages(images); + setSlides( + images.map((image: Image) => { + return { + src: image.img_src, + }; + }), + ); + if (onImagesLoaded && images.length > 0) { + onImagesLoaded(images.length); + } + // Mark as loaded to prevent refetching + hasLoadedRef.current = true; + } catch (error) { + console.error('Error fetching images:', error); + } finally { + setLoading(false); + } + }; + + fetchImages(); + + // Reset the loading state when component unmounts + return () => { + hasLoadedRef.current = false; + }; + }, [query, messageId]); return ( <> - {!loading && images === null && ( - - )} {loading && (
{[...Array(4)].map((_, i) => ( @@ -97,59 +112,22 @@ const SearchImages = ({ {images !== null && images.length > 0 && ( <>
- {images.length > 4 - ? images.slice(0, 3).map((image, i) => ( - { - setOpen(true); - setSlides([ - slides[i], - ...slides.slice(0, i), - ...slides.slice(i + 1), - ]); - }} - key={i} - src={image.img_src} - alt={image.title} - className="h-full w-full aspect-video object-cover rounded-lg transition duration-200 active:scale-95 hover:scale-[1.02] cursor-zoom-in" - /> - )) - : images.map((image, i) => ( - { - setOpen(true); - setSlides([ - slides[i], - ...slides.slice(0, i), - ...slides.slice(i + 1), - ]); - }} - key={i} - src={image.img_src} - alt={image.title} - className="h-full w-full aspect-video object-cover rounded-lg transition duration-200 active:scale-95 hover:scale-[1.02] cursor-zoom-in" - /> - ))} - {images.length > 4 && ( - - )} + {images.map((image, i) => ( + { + setOpen(true); + setSlides([ + slides[i], + ...slides.slice(0, i), + ...slides.slice(i + 1), + ]); + }} + key={i} + src={image.img_src} + alt={image.title} + className="h-full w-full aspect-video object-cover rounded-lg transition duration-200 active:scale-95 hover:scale-[1.02] cursor-zoom-in" + /> + ))}
setOpen(false)} slides={slides} /> diff --git a/src/components/SearchVideos.tsx b/src/components/SearchVideos.tsx index 5b56256..234cbf3 100644 --- a/src/components/SearchVideos.tsx +++ b/src/components/SearchVideos.tsx @@ -1,6 +1,6 @@ /* eslint-disable @next/next/no-img-element */ -import { PlayCircle, PlayIcon, PlusIcon, VideoIcon } from 'lucide-react'; -import { useRef, useState } from 'react'; +import { PlayCircle } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; import Lightbox, { GenericSlide, VideoSlide } from 'yet-another-react-lightbox'; import 'yet-another-react-lightbox/styles.css'; import { Message } from './ChatWindow'; @@ -28,79 +28,95 @@ const Searchvideos = ({ query, chatHistory, messageId, + onVideosLoaded, }: { query: string; chatHistory: Message[]; messageId: string; + onVideosLoaded?: (count: number) => void; }) => { const [videos, setVideos] = useState(null); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const [open, setOpen] = useState(false); const [slides, setSlides] = useState([]); const [currentIndex, setCurrentIndex] = useState(0); const videoRefs = useRef<(HTMLIFrameElement | null)[]>([]); + const hasLoadedRef = useRef(false); + + useEffect(() => { + // Skip fetching if videos are already loaded for this message + if (hasLoadedRef.current) { + return; + } + + const fetchVideos = async () => { + setLoading(true); + + const chatModelProvider = localStorage.getItem('chatModelProvider'); + const chatModel = localStorage.getItem('chatModel'); + const customOpenAIBaseURL = localStorage.getItem('openAIBaseURL'); + const customOpenAIKey = localStorage.getItem('openAIApiKey'); + const ollamaContextWindow = + localStorage.getItem('ollamaContextWindow') || '2048'; + + try { + const res = await fetch(`/api/videos`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: query, + chatHistory: chatHistory, + chatModel: { + provider: chatModelProvider, + model: chatModel, + ...(chatModelProvider === 'custom_openai' && { + customOpenAIBaseURL: customOpenAIBaseURL, + customOpenAIKey: customOpenAIKey, + }), + ...(chatModelProvider === 'ollama' && { + ollamaContextWindow: parseInt(ollamaContextWindow), + }), + }, + }), + }); + + const data = await res.json(); + + const videos = data.videos ?? []; + setVideos(videos); + setSlides( + videos.map((video: Video) => { + return { + type: 'video-slide', + iframe_src: video.iframe_src, + src: video.img_src, + }; + }), + ); + if (onVideosLoaded && videos.length > 0) { + onVideosLoaded(videos.length); + } + // Mark as loaded to prevent refetching + hasLoadedRef.current = true; + } catch (error) { + console.error('Error fetching videos:', error); + } finally { + setLoading(false); + } + }; + + fetchVideos(); + + // Reset the loading state when component unmounts + return () => { + hasLoadedRef.current = false; + }; + }, [query, messageId]); return ( <> - {!loading && videos === null && ( - - )} {loading && (
{[...Array(4)].map((_, i) => ( @@ -114,75 +130,30 @@ const Searchvideos = ({ {videos !== null && videos.length > 0 && ( <>
- {videos.length > 4 - ? videos.slice(0, 3).map((video, i) => ( -
{ - setOpen(true); - setSlides([ - slides[i], - ...slides.slice(0, i), - ...slides.slice(i + 1), - ]); - }} - className="relative transition duration-200 active:scale-95 hover:scale-[1.02] cursor-pointer" - key={i} - > - {video.title} -
- -

Video

-
-
- )) - : videos.map((video, i) => ( -
{ - setOpen(true); - setSlides([ - slides[i], - ...slides.slice(0, i), - ...slides.slice(i + 1), - ]); - }} - className="relative transition duration-200 active:scale-95 hover:scale-[1.02] cursor-pointer" - key={i} - > - {video.title} -
- -

Video

-
-
- ))} - {videos.length > 4 && ( - - )} +
+ ))}