mirror of
https://github.com/ItzCrazyKns/Perplexica.git
synced 2025-06-17 23:38:30 +00:00
feat(UI): Tabbed interface for messages.
This commit is contained in:
11
README.md
11
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:
|
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`)
|
- 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**:
|
2. **Ensure proper headers forwarding**:
|
||||||
|
|
||||||
- Your reverse proxy should forward the following headers:
|
- Your reverse proxy should forward the following headers:
|
||||||
- `X-Forwarded-Host`
|
- `X-Forwarded-Host`
|
||||||
- `X-Forwarded-Proto`
|
- `X-Forwarded-Proto`
|
||||||
- `X-Forwarded-Port` (if using non-standard ports)
|
- `X-Forwarded-Port` (if using non-standard ports)
|
||||||
|
|
||||||
3. **Example Nginx configuration**:
|
3. **Example Nginx configuration**:
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name perplexica.yourdomain.com;
|
server_name perplexica.yourdomain.com;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://localhost:3000;
|
proxy_pass http://localhost:3000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
@ -26,38 +26,44 @@ function generateOpenSearchResponse(origin: string): NextResponse {
|
|||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
// Check if a BASE_URL is explicitly configured
|
// Check if a BASE_URL is explicitly configured
|
||||||
const configBaseUrl = getBaseUrl();
|
const configBaseUrl = getBaseUrl();
|
||||||
|
|
||||||
// If BASE_URL is configured, use it, otherwise detect from request
|
// If BASE_URL is configured, use it, otherwise detect from request
|
||||||
if (configBaseUrl) {
|
if (configBaseUrl) {
|
||||||
// Remove any trailing slashes for consistency
|
// Remove any trailing slashes for consistency
|
||||||
let origin = configBaseUrl.replace(/\/+$/, '');
|
let origin = configBaseUrl.replace(/\/+$/, '');
|
||||||
return generateOpenSearchResponse(origin);
|
return generateOpenSearchResponse(origin);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect the correct origin, taking into account reverse proxy headers
|
// Detect the correct origin, taking into account reverse proxy headers
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
let origin = url.origin;
|
let origin = url.origin;
|
||||||
|
|
||||||
// Extract headers
|
// Extract headers
|
||||||
const headers = Object.fromEntries(request.headers);
|
const headers = Object.fromEntries(request.headers);
|
||||||
|
|
||||||
// Check for X-Forwarded-Host and related headers to handle reverse proxies
|
// Check for X-Forwarded-Host and related headers to handle reverse proxies
|
||||||
if (headers['x-forwarded-host']) {
|
if (headers['x-forwarded-host']) {
|
||||||
// Determine protocol: prefer X-Forwarded-Proto, fall back to original or https
|
// 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
|
// Build the correct public-facing origin
|
||||||
origin = `${protocol}://${headers['x-forwarded-host']}`;
|
origin = `${protocol}://${headers['x-forwarded-host']}`;
|
||||||
|
|
||||||
// Handle non-standard ports if specified in X-Forwarded-Port
|
// Handle non-standard ports if specified in X-Forwarded-Port
|
||||||
if (headers['x-forwarded-port']) {
|
if (headers['x-forwarded-port']) {
|
||||||
const port = headers['x-forwarded-port'];
|
const port = headers['x-forwarded-port'];
|
||||||
// Don't append standard ports (80 for HTTP, 443 for HTTPS)
|
// 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}`;
|
origin = `${origin}:${port}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate and return the OpenSearch response
|
// Generate and return the OpenSearch response
|
||||||
return generateOpenSearchResponse(origin);
|
return generateOpenSearchResponse(origin);
|
||||||
}
|
}
|
||||||
|
@ -145,8 +145,6 @@ const Page = () => {
|
|||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [automaticImageSearch, setAutomaticImageSearch] = useState(false);
|
|
||||||
const [automaticVideoSearch, setAutomaticVideoSearch] = useState(false);
|
|
||||||
const [automaticSuggestions, setAutomaticSuggestions] = useState(true);
|
const [automaticSuggestions, setAutomaticSuggestions] = useState(true);
|
||||||
const [systemInstructions, setSystemInstructions] = useState<string>('');
|
const [systemInstructions, setSystemInstructions] = useState<string>('');
|
||||||
const [savingStates, setSavingStates] = useState<Record<string, boolean>>({});
|
const [savingStates, setSavingStates] = useState<Record<string, boolean>>({});
|
||||||
@ -209,12 +207,6 @@ const Page = () => {
|
|||||||
setChatModels(data.chatModelProviders || {});
|
setChatModels(data.chatModelProviders || {});
|
||||||
setEmbeddingModels(data.embeddingModelProviders || {});
|
setEmbeddingModels(data.embeddingModelProviders || {});
|
||||||
|
|
||||||
setAutomaticImageSearch(
|
|
||||||
localStorage.getItem('autoImageSearch') === 'true',
|
|
||||||
);
|
|
||||||
setAutomaticVideoSearch(
|
|
||||||
localStorage.getItem('autoVideoSearch') === 'true',
|
|
||||||
);
|
|
||||||
setAutomaticSuggestions(
|
setAutomaticSuggestions(
|
||||||
localStorage.getItem('autoSuggestions') !== 'false', // default to true if not set
|
localStorage.getItem('autoSuggestions') !== 'false', // default to true if not set
|
||||||
);
|
);
|
||||||
@ -372,11 +364,7 @@ const Page = () => {
|
|||||||
setConfig(data);
|
setConfig(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'automaticImageSearch') {
|
if (key === 'automaticSuggestions') {
|
||||||
localStorage.setItem('autoImageSearch', value.toString());
|
|
||||||
} else if (key === 'automaticVideoSearch') {
|
|
||||||
localStorage.setItem('autoVideoSearch', value.toString());
|
|
||||||
} else if (key === 'automaticSuggestions') {
|
|
||||||
localStorage.setItem('autoSuggestions', value.toString());
|
localStorage.setItem('autoSuggestions', value.toString());
|
||||||
} else if (key === 'chatModelProvider') {
|
} else if (key === 'chatModelProvider') {
|
||||||
localStorage.setItem('chatModelProvider', value);
|
localStorage.setItem('chatModelProvider', value);
|
||||||
@ -449,90 +437,6 @@ const Page = () => {
|
|||||||
|
|
||||||
<SettingsSection title="Automatic Search">
|
<SettingsSection title="Automatic Search">
|
||||||
<div className="flex flex-col space-y-4">
|
<div className="flex flex-col space-y-4">
|
||||||
<div className="flex items-center justify-between p-3 bg-light-secondary dark:bg-dark-secondary rounded-lg hover:bg-light-200 dark:hover:bg-dark-200 transition-colors">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div className="p-2 bg-light-200 dark:bg-dark-200 rounded-lg">
|
|
||||||
<ImagesIcon
|
|
||||||
size={18}
|
|
||||||
className="text-black/70 dark:text-white/70"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-black/90 dark:text-white/90 font-medium">
|
|
||||||
Automatic Image Search
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-black/60 dark:text-white/60 mt-0.5">
|
|
||||||
Automatically search for relevant images in chat
|
|
||||||
responses
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={automaticImageSearch}
|
|
||||||
onChange={(checked) => {
|
|
||||||
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',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
automaticImageSearch
|
|
||||||
? 'translate-x-6'
|
|
||||||
: 'translate-x-1',
|
|
||||||
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-3 bg-light-secondary dark:bg-dark-secondary rounded-lg hover:bg-light-200 dark:hover:bg-dark-200 transition-colors">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<div className="p-2 bg-light-200 dark:bg-dark-200 rounded-lg">
|
|
||||||
<VideoIcon
|
|
||||||
size={18}
|
|
||||||
className="text-black/70 dark:text-white/70"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-black/90 dark:text-white/90 font-medium">
|
|
||||||
Automatic Video Search
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-black/60 dark:text-white/60 mt-0.5">
|
|
||||||
Automatically search for relevant videos in chat
|
|
||||||
responses
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={automaticVideoSearch}
|
|
||||||
onChange={(checked) => {
|
|
||||||
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',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
automaticVideoSearch
|
|
||||||
? 'translate-x-6'
|
|
||||||
: 'translate-x-1',
|
|
||||||
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-3 bg-light-secondary dark:bg-dark-secondary rounded-lg hover:bg-light-200 dark:hover:bg-dark-200 transition-colors">
|
<div className="flex items-center justify-between p-3 bg-light-secondary dark:bg-dark-secondary rounded-lg hover:bg-light-200 dark:hover:bg-dark-200 transition-colors">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="p-2 bg-light-200 dark:bg-dark-200 rounded-lg">
|
<div className="p-2 bg-light-200 dark:bg-dark-200 rounded-lg">
|
||||||
|
@ -1,11 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||||
import MessageInput from './MessageInput';
|
|
||||||
import { File, Message } from './ChatWindow';
|
import { File, Message } from './ChatWindow';
|
||||||
import MessageBox from './MessageBox';
|
import MessageBox from './MessageBox';
|
||||||
import MessageBoxLoading from './MessageBoxLoading';
|
import MessageBoxLoading from './MessageBoxLoading';
|
||||||
import { check } from 'drizzle-orm/gel-core';
|
import MessageInput from './MessageInput';
|
||||||
|
|
||||||
const Chat = ({
|
const Chat = ({
|
||||||
loading,
|
loading,
|
||||||
@ -43,11 +42,11 @@ const Chat = ({
|
|||||||
focusMode: string;
|
focusMode: string;
|
||||||
setFocusMode: (mode: string) => void;
|
setFocusMode: (mode: string) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const [dividerWidth, setDividerWidth] = useState(0);
|
|
||||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||||
const [manuallyScrolledUp, setManuallyScrolledUp] = useState(false);
|
const [manuallyScrolledUp, setManuallyScrolledUp] = useState(false);
|
||||||
const dividerRef = useRef<HTMLDivElement | null>(null);
|
const [inputStyle, setInputStyle] = useState<React.CSSProperties>({});
|
||||||
const messageEnd = useRef<HTMLDivElement | null>(null);
|
const messageEnd = useRef<HTMLDivElement | null>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const SCROLL_THRESHOLD = 250; // pixels from bottom to consider "at bottom"
|
const SCROLL_THRESHOLD = 250; // pixels from bottom to consider "at bottom"
|
||||||
|
|
||||||
// Check if user is at bottom of page
|
// Check if user is at bottom of page
|
||||||
@ -111,22 +110,6 @@ const Chat = ({
|
|||||||
};
|
};
|
||||||
}, [isAtBottom]);
|
}, [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
|
// Scroll when user sends a message
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scroll = () => {
|
const scroll = () => {
|
||||||
@ -157,8 +140,32 @@ const Chat = ({
|
|||||||
}
|
}
|
||||||
}, [scrollTrigger, isAtBottom, messages.length, manuallyScrolledUp]);
|
}, [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 (
|
return (
|
||||||
<div className="flex flex-col space-y-6 pt-8 pb-48 sm:mx-4 md:mx-8">
|
<div ref={containerRef} className="space-y-6 pt-8 pb-48 sm:mx-4 md:mx-8">
|
||||||
{messages.map((msg, i) => {
|
{messages.map((msg, i) => {
|
||||||
const isLast = i === messages.length - 1;
|
const isLast = i === messages.length - 1;
|
||||||
|
|
||||||
@ -170,7 +177,6 @@ const Chat = ({
|
|||||||
messageIndex={i}
|
messageIndex={i}
|
||||||
history={messages}
|
history={messages}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
dividerRef={isLast ? dividerRef : undefined}
|
|
||||||
isLast={isLast}
|
isLast={isLast}
|
||||||
rewrite={rewrite}
|
rewrite={rewrite}
|
||||||
sendMessage={sendMessage}
|
sendMessage={sendMessage}
|
||||||
@ -182,58 +188,52 @@ const Chat = ({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{loading && <MessageBoxLoading />}
|
{loading && <MessageBoxLoading />}
|
||||||
<div ref={messageEnd} className="h-0" />
|
<div className="fixed bottom-24 lg:bottom-10 z-40" style={inputStyle}>
|
||||||
|
{/* Scroll to bottom button - appears above the MessageInput when user has scrolled up */}
|
||||||
{dividerWidth > 0 && (
|
{manuallyScrolledUp && !isAtBottom && (
|
||||||
<div
|
<div className="absolute -top-14 right-2 z-10">
|
||||||
className="bottom-24 lg:bottom-10 fixed z-40"
|
<button
|
||||||
style={{ width: dividerWidth }}
|
onClick={() => {
|
||||||
>
|
setManuallyScrolledUp(false);
|
||||||
{/* Scroll to bottom button - appears above the MessageInput when user has scrolled up */}
|
setIsAtBottom(true);
|
||||||
{manuallyScrolledUp && !isAtBottom && (
|
messageEnd.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
<div className="absolute -top-14 right-2 z-10">
|
}}
|
||||||
<button
|
className="bg-[#24A0ED] text-white hover:bg-opacity-85 transition duration-100 rounded-full px-4 py-2 shadow-lg flex items-center justify-center"
|
||||||
onClick={() => {
|
aria-label="Scroll to bottom"
|
||||||
setManuallyScrolledUp(false);
|
>
|
||||||
setIsAtBottom(true);
|
<svg
|
||||||
messageEnd.current?.scrollIntoView({ behavior: 'smooth' });
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
}}
|
className="h-5 w-5 mr-1"
|
||||||
className="bg-[#24A0ED] text-white hover:bg-opacity-85 transition duration-100 rounded-full px-4 py-2 shadow-lg flex items-center justify-center"
|
viewBox="0 0 20 20"
|
||||||
aria-label="Scroll to bottom"
|
fill="currentColor"
|
||||||
>
|
>
|
||||||
<svg
|
<path
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
fillRule="evenodd"
|
||||||
className="h-5 w-5 mr-1"
|
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z"
|
||||||
viewBox="0 0 20 20"
|
clipRule="evenodd"
|
||||||
fill="currentColor"
|
transform="rotate(180 10 10)"
|
||||||
>
|
/>
|
||||||
<path
|
</svg>
|
||||||
fillRule="evenodd"
|
<span className="text-sm">Scroll to bottom</span>
|
||||||
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z"
|
</button>
|
||||||
clipRule="evenodd"
|
</div>
|
||||||
transform="rotate(180 10 10)"
|
)}
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span className="text-sm">Scroll to bottom</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<MessageInput
|
<MessageInput
|
||||||
firstMessage={messages.length === 0}
|
firstMessage={messages.length === 0}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
sendMessage={sendMessage}
|
sendMessage={sendMessage}
|
||||||
fileIds={fileIds}
|
fileIds={fileIds}
|
||||||
setFileIds={setFileIds}
|
setFileIds={setFileIds}
|
||||||
files={files}
|
files={files}
|
||||||
setFiles={setFiles}
|
setFiles={setFiles}
|
||||||
optimizationMode={optimizationMode}
|
optimizationMode={optimizationMode}
|
||||||
setOptimizationMode={setOptimizationMode}
|
setOptimizationMode={setOptimizationMode}
|
||||||
focusMode={focusMode}
|
focusMode={focusMode}
|
||||||
setFocusMode={setFocusMode}
|
setFocusMode={setFocusMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div ref={messageEnd} className="h-0" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -59,17 +59,6 @@ const checkConfig = async (
|
|||||||
let embeddingModel = localStorage.getItem('embeddingModel');
|
let embeddingModel = localStorage.getItem('embeddingModel');
|
||||||
let embeddingModelProvider = localStorage.getItem('embeddingModelProvider');
|
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`, {
|
const providers = await fetch(`/api/models`, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@ -499,22 +488,8 @@ const ChatWindow = ({ id }: { id?: string }) => {
|
|||||||
|
|
||||||
const lastMsg = messagesRef.current[messagesRef.current.length - 1];
|
const lastMsg = messagesRef.current[messagesRef.current.length - 1];
|
||||||
|
|
||||||
const autoImageSearch = localStorage.getItem('autoImageSearch');
|
|
||||||
const autoVideoSearch = localStorage.getItem('autoVideoSearch');
|
|
||||||
const autoSuggestions = localStorage.getItem('autoSuggestions');
|
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 (
|
if (
|
||||||
lastMsg.role === 'assistant' &&
|
lastMsg.role === 'assistant' &&
|
||||||
lastMsg.sources &&
|
lastMsg.sources &&
|
||||||
|
@ -39,11 +39,11 @@ const ModelInfoButton: React.FC<ModelInfoButtonProps> = ({ modelStats }) => {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
className="p-1 ml-1 text-black/50 dark:text-white/50 rounded-full hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white"
|
className="p-1 ml-1 text-black/70 dark:text-white/70 rounded-full hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white"
|
||||||
onClick={() => setShowPopover(!showPopover)}
|
onClick={() => setShowPopover(!showPopover)}
|
||||||
aria-label="Show model information"
|
aria-label="Show model information"
|
||||||
>
|
>
|
||||||
<Info size={14} />
|
<Info size={18} />
|
||||||
</button>
|
</button>
|
||||||
{showPopover && (
|
{showPopover && (
|
||||||
<div
|
<div
|
||||||
|
@ -1,32 +1,11 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
/* eslint-disable @next/next/no-img-element */
|
|
||||||
import React, { MutableRefObject, useEffect, useState } from 'react';
|
|
||||||
import { Message } from './ChatWindow';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getSuggestions } from '@/lib/actions';
|
import { CheckCheck, CopyIcon } from 'lucide-react';
|
||||||
import {
|
import { useState } from 'react';
|
||||||
BookCopy,
|
|
||||||
Disc3,
|
|
||||||
Volume2,
|
|
||||||
StopCircle,
|
|
||||||
Layers3,
|
|
||||||
Plus,
|
|
||||||
Sparkles,
|
|
||||||
Copy as CopyIcon,
|
|
||||||
CheckCheck,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import Markdown, { MarkdownToJSX } from 'markdown-to-jsx';
|
|
||||||
import Copy from './MessageActions/Copy';
|
|
||||||
import Rewrite from './MessageActions/Rewrite';
|
|
||||||
import ModelInfoButton from './MessageActions/ModelInfo';
|
|
||||||
import MessageSources from './MessageSources';
|
|
||||||
import SearchImages from './SearchImages';
|
|
||||||
import SearchVideos from './SearchVideos';
|
|
||||||
import { useSpeech } from 'react-text-to-speech';
|
|
||||||
import ThinkBox from './ThinkBox';
|
|
||||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
|
import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
|
||||||
|
import { Message } from './ChatWindow';
|
||||||
|
import MessageTabs from './MessageTabs';
|
||||||
|
import ThinkBox from './ThinkBox';
|
||||||
|
|
||||||
const ThinkTagProcessor = ({ children }: { children: React.ReactNode }) => {
|
const ThinkTagProcessor = ({ children }: { children: React.ReactNode }) => {
|
||||||
return <ThinkBox content={children as string} />;
|
return <ThinkBox content={children as string} />;
|
||||||
@ -103,7 +82,6 @@ const MessageBox = ({
|
|||||||
messageIndex,
|
messageIndex,
|
||||||
history,
|
history,
|
||||||
loading,
|
loading,
|
||||||
dividerRef,
|
|
||||||
isLast,
|
isLast,
|
||||||
rewrite,
|
rewrite,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
@ -112,7 +90,6 @@ const MessageBox = ({
|
|||||||
messageIndex: number;
|
messageIndex: number;
|
||||||
history: Message[];
|
history: Message[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
dividerRef?: MutableRefObject<HTMLDivElement | null>;
|
|
||||||
isLast: boolean;
|
isLast: boolean;
|
||||||
rewrite: (messageId: string) => void;
|
rewrite: (messageId: string) => void;
|
||||||
sendMessage: (
|
sendMessage: (
|
||||||
@ -124,130 +101,6 @@ const MessageBox = ({
|
|||||||
},
|
},
|
||||||
) => void;
|
) => 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('<think>')) {
|
|
||||||
const openThinkTag = processedMessage.match(/<think>/g)?.length || 0;
|
|
||||||
const closeThinkTag = processedMessage.match(/<\/think>/g)?.length || 0;
|
|
||||||
|
|
||||||
if (openThinkTag > closeThinkTag) {
|
|
||||||
processedMessage += '</think> <a> </a>'; // The extra <a> </a> 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 `<a href="${url}" target="_blank" className="bg-light-secondary dark:bg-dark-secondary px-1 rounded ml-1 no-underline text-xs text-black/70 dark:text-white/70 relative">${numStr}</a>`;
|
|
||||||
} 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 <CodeBlock className={className}>{children}</CodeBlock>;
|
|
||||||
}
|
|
||||||
// This is an inline code block (`code`)
|
|
||||||
return (
|
|
||||||
<code className="px-1.5 py-0.5 rounded bg-dark-secondary text-white font-mono text-sm">
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pre: {
|
|
||||||
component: ({ children }) => children,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{message.role === 'user' && (
|
{message.role === 'user' && (
|
||||||
@ -265,174 +118,16 @@ const MessageBox = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{message.role === 'assistant' && (
|
{message.role === 'assistant' && (
|
||||||
<div className="flex flex-col space-y-9 lg:space-y-0 lg:flex-row lg:justify-between lg:space-x-9">
|
<MessageTabs
|
||||||
<div
|
query={history[messageIndex - 1].content}
|
||||||
ref={dividerRef}
|
chatHistory={history.slice(0, messageIndex - 1)}
|
||||||
className="flex flex-col space-y-6 w-full lg:w-9/12"
|
messageId={message.messageId}
|
||||||
>
|
message={message}
|
||||||
{message.sources && message.sources.length > 0 && (
|
isLast={isLast}
|
||||||
<div className="flex flex-col space-y-2">
|
loading={loading}
|
||||||
<div className="flex flex-row items-center space-x-2">
|
rewrite={rewrite}
|
||||||
<BookCopy className="text-black dark:text-white" size={20} />
|
sendMessage={sendMessage}
|
||||||
<h3 className="text-black dark:text-white font-medium text-xl">
|
/>
|
||||||
Sources
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
{message.searchQuery && (
|
|
||||||
<div className="mb-2 text-sm bg-light-secondary dark:bg-dark-secondary rounded-lg p-3">
|
|
||||||
<span className="font-medium text-black/70 dark:text-white/70">
|
|
||||||
Search query:
|
|
||||||
</span>{' '}
|
|
||||||
{message.searchUrl ? (
|
|
||||||
<a
|
|
||||||
href={message.searchUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="dark:text-white text-black hover:underline"
|
|
||||||
>
|
|
||||||
{message.searchQuery}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<span className="text-black dark:text-white">
|
|
||||||
{message.searchQuery}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<MessageSources sources={message.sources} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col space-y-2">
|
|
||||||
{' '}
|
|
||||||
<div className="flex flex-row items-center space-x-2">
|
|
||||||
<Disc3
|
|
||||||
className={cn(
|
|
||||||
'text-black dark:text-white',
|
|
||||||
isLast && loading ? 'animate-spin' : 'animate-none',
|
|
||||||
)}
|
|
||||||
size={20}
|
|
||||||
/>
|
|
||||||
<h3 className="text-black dark:text-white font-medium text-xl">
|
|
||||||
Answer
|
|
||||||
</h3>
|
|
||||||
{message.modelStats && (
|
|
||||||
<ModelInfoButton modelStats={message.modelStats} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Markdown
|
|
||||||
className={cn(
|
|
||||||
'prose prose-h1:mb-3 prose-h2:mb-2 prose-h2:mt-6 prose-h2:font-[800] prose-h3:mt-4 prose-h3:mb-1.5 prose-h3:font-[600] prose-invert prose-p:leading-relaxed prose-pre:p-0 font-[400]',
|
|
||||||
'prose-code:bg-transparent prose-code:p-0 prose-code:text-inherit prose-code:font-normal prose-code:before:content-none prose-code:after:content-none',
|
|
||||||
'prose-pre:bg-transparent prose-pre:border-0 prose-pre:m-0 prose-pre:p-0',
|
|
||||||
'max-w-none break-words text-white',
|
|
||||||
)}
|
|
||||||
options={markdownOverrides}
|
|
||||||
>
|
|
||||||
{parsedMessage}
|
|
||||||
</Markdown>
|
|
||||||
{loading && isLast ? null : (
|
|
||||||
<div className="flex flex-row items-center justify-between w-full text-black dark:text-white py-4 -mx-2">
|
|
||||||
<div className="flex flex-row items-center space-x-1">
|
|
||||||
{/* <button className="p-2 text-black/70 dark:text-white/70 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black text-black dark:hover:text-white">
|
|
||||||
<Share size={18} />
|
|
||||||
</button> */}
|
|
||||||
<Rewrite rewrite={rewrite} messageId={message.messageId} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row items-center space-x-1">
|
|
||||||
<Copy initialMessage={message.content} message={message} />
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (speechStatus === 'started') {
|
|
||||||
stop();
|
|
||||||
} else {
|
|
||||||
start();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="p-2 text-black/70 dark:text-white/70 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white"
|
|
||||||
>
|
|
||||||
{speechStatus === 'started' ? (
|
|
||||||
<StopCircle size={18} />
|
|
||||||
) : (
|
|
||||||
<Volume2 size={18} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isLast && message.role === 'assistant' && !loading && (
|
|
||||||
<>
|
|
||||||
<div className="h-px w-full bg-light-secondary dark:bg-dark-secondary" />
|
|
||||||
<div className="flex flex-col space-y-3 text-black dark:text-white">
|
|
||||||
<div className="flex flex-row items-center space-x-2 mt-4">
|
|
||||||
<Layers3 />
|
|
||||||
<h3 className="text-xl font-medium">Related</h3>{' '}
|
|
||||||
{(!autoSuggestions || autoSuggestions === 'false') &&
|
|
||||||
(!message.suggestions ||
|
|
||||||
message.suggestions.length === 0) ? (
|
|
||||||
<div className="bg-light-secondary dark:bg-dark-secondary">
|
|
||||||
<button
|
|
||||||
onClick={handleLoadSuggestions}
|
|
||||||
disabled={loadingSuggestions}
|
|
||||||
className="px-4 py-2 flex flex-row items-center justify-center space-x-2 rounded-lg bg-light-secondary dark:bg-dark-secondary hover:bg-light-200 dark:hover:bg-dark-200 transition duration-200 text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white"
|
|
||||||
>
|
|
||||||
{loadingSuggestions ? (
|
|
||||||
<div className="w-4 h-4 border-2 border-t-transparent border-gray-400 dark:border-gray-500 rounded-full animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Sparkles size={16} />
|
|
||||||
)}
|
|
||||||
<span>
|
|
||||||
{loadingSuggestions
|
|
||||||
? 'Loading suggestions...'
|
|
||||||
: 'Load suggestions'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{message.suggestions && message.suggestions.length > 0 ? (
|
|
||||||
<div className="flex flex-col space-y-3">
|
|
||||||
{message.suggestions.map((suggestion, i) => (
|
|
||||||
<div
|
|
||||||
className="flex flex-col space-y-3 text-sm"
|
|
||||||
key={i}
|
|
||||||
>
|
|
||||||
<div className="h-px w-full bg-light-secondary dark:bg-dark-secondary" />
|
|
||||||
<div
|
|
||||||
onClick={() => {
|
|
||||||
sendMessage(suggestion);
|
|
||||||
}}
|
|
||||||
className="cursor-pointer flex flex-row justify-between font-medium space-x-2 items-center"
|
|
||||||
>
|
|
||||||
<p className="transition duration-200 hover:text-[#24A0ED]">
|
|
||||||
{suggestion}
|
|
||||||
</p>
|
|
||||||
<Plus
|
|
||||||
size={20}
|
|
||||||
className="text-[#24A0ED] flex-shrink-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="lg:sticky lg:top-20 flex flex-col items-center space-y-3 w-full lg:w-3/12 z-30 h-full pb-4">
|
|
||||||
<SearchImages
|
|
||||||
query={history[messageIndex - 1].content}
|
|
||||||
chatHistory={history.slice(0, messageIndex - 1)}
|
|
||||||
messageId={message.messageId}
|
|
||||||
/>
|
|
||||||
<SearchVideos
|
|
||||||
chatHistory={history.slice(0, messageIndex - 1)}
|
|
||||||
query={history[messageIndex - 1].content}
|
|
||||||
messageId={message.messageId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,31 +1,11 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogPanel,
|
|
||||||
DialogTitle,
|
|
||||||
Transition,
|
|
||||||
TransitionChild,
|
|
||||||
} from '@headlessui/react';
|
|
||||||
import { Document } from '@langchain/core/documents';
|
import { Document } from '@langchain/core/documents';
|
||||||
import { File } from 'lucide-react';
|
import { File } from 'lucide-react';
|
||||||
import { Fragment, useState } from 'react';
|
|
||||||
|
|
||||||
const MessageSources = ({ sources }: { sources: Document[] }) => {
|
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 (
|
return (
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2">
|
||||||
{sources.slice(0, 3).map((source, i) => (
|
{sources.map((source, i) => (
|
||||||
<a
|
<a
|
||||||
className="bg-light-100 hover:bg-light-200 dark:bg-dark-100 dark:hover:bg-dark-200 transition duration-200 rounded-lg p-3 flex flex-col space-y-2 font-medium"
|
className="bg-light-100 hover:bg-light-200 dark:bg-dark-100 dark:hover:bg-dark-200 transition duration-200 rounded-lg p-3 flex flex-col space-y-2 font-medium"
|
||||||
key={i}
|
key={i}
|
||||||
@ -61,101 +41,6 @@ const MessageSources = ({ sources }: { sources: Document[] }) => {
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
{sources.length > 3 && (
|
|
||||||
<button
|
|
||||||
onClick={openModal}
|
|
||||||
className="bg-light-100 hover:bg-light-200 dark:bg-dark-100 dark:hover:bg-dark-200 transition duration-200 rounded-lg p-3 flex flex-col space-y-2 font-medium"
|
|
||||||
>
|
|
||||||
<div className="flex flex-row items-center space-x-1">
|
|
||||||
{sources.slice(3, 6).map((source, i) => {
|
|
||||||
return source.metadata.url === 'File' ? (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="bg-dark-200 hover:bg-dark-100 transition duration-200 flex items-center justify-center w-6 h-6 rounded-full"
|
|
||||||
>
|
|
||||||
<File size={12} className="text-white/70" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<img
|
|
||||||
key={i}
|
|
||||||
src={`https://s2.googleusercontent.com/s2/favicons?domain_url=${source.metadata.url}`}
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
alt="favicon"
|
|
||||||
className="rounded-lg h-4 w-4"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-black/50 dark:text-white/50">
|
|
||||||
View {sources.length - 3} more
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<Transition appear show={isDialogOpen} as={Fragment}>
|
|
||||||
<Dialog as="div" className="relative z-50" onClose={closeModal}>
|
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
|
||||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
|
||||||
<TransitionChild
|
|
||||||
as={Fragment}
|
|
||||||
enter="ease-out duration-200"
|
|
||||||
enterFrom="opacity-0 scale-95"
|
|
||||||
enterTo="opacity-100 scale-100"
|
|
||||||
leave="ease-in duration-100"
|
|
||||||
leaveFrom="opacity-100 scale-200"
|
|
||||||
leaveTo="opacity-0 scale-95"
|
|
||||||
>
|
|
||||||
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 p-6 text-left align-middle shadow-xl transition-all">
|
|
||||||
<DialogTitle className="text-lg font-medium leading-6 dark:text-white">
|
|
||||||
Sources
|
|
||||||
</DialogTitle>
|
|
||||||
<div className="grid grid-cols-2 gap-2 overflow-auto max-h-[300px] mt-2 pr-2">
|
|
||||||
{sources.map((source, i) => (
|
|
||||||
<a
|
|
||||||
className="bg-light-secondary hover:bg-light-200 dark:bg-dark-secondary dark:hover:bg-dark-200 border border-light-200 dark:border-dark-200 transition duration-200 rounded-lg p-3 flex flex-col space-y-2 font-medium"
|
|
||||||
key={i}
|
|
||||||
href={source.metadata.url}
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<p className="dark:text-white text-xs overflow-hidden whitespace-nowrap text-ellipsis">
|
|
||||||
{source.metadata.title}
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-row items-center justify-between">
|
|
||||||
<div className="flex flex-row items-center space-x-1">
|
|
||||||
{source.metadata.url === 'File' ? (
|
|
||||||
<div className="bg-dark-200 hover:bg-dark-100 transition duration-200 flex items-center justify-center w-6 h-6 rounded-full">
|
|
||||||
<File size={12} className="text-white/70" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<img
|
|
||||||
src={`https://s2.googleusercontent.com/s2/favicons?domain_url=${source.metadata.url}`}
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
alt="favicon"
|
|
||||||
className="rounded-lg h-4 w-4"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-black/50 dark:text-white/50 overflow-hidden whitespace-nowrap text-ellipsis">
|
|
||||||
{source.metadata.url.replace(
|
|
||||||
/.+\/\/|www.|\..+/g,
|
|
||||||
'',
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row items-center space-x-1 text-black/50 dark:text-white/50 text-xs">
|
|
||||||
<div className="bg-black/50 dark:bg-white/50 h-[4px] w-[4px] rounded-full" />
|
|
||||||
<span>{i + 1}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</DialogPanel>
|
|
||||||
</TransitionChild>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
535
src/components/MessageTabs.tsx
Normal file
535
src/components/MessageTabs.tsx
Normal file
@ -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 <ThinkBox content={children as string} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="rounded-md overflow-hidden my-4 relative group border border-dark-secondary">
|
||||||
|
<div className="flex justify-between items-center px-4 py-2 bg-dark-200 border-b border-dark-secondary text-xs text-white/70 font-mono">
|
||||||
|
<span>{language}</span>
|
||||||
|
<button
|
||||||
|
onClick={handleCopyCode}
|
||||||
|
className="p-1 rounded-md hover:bg-dark-secondary transition duration-200"
|
||||||
|
aria-label="Copy code to clipboard"
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<CheckCheck size={14} className="text-green-500" />
|
||||||
|
) : (
|
||||||
|
<CopyIcon size={14} className="text-white/70" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<SyntaxHighlighter
|
||||||
|
language={language || 'text'}
|
||||||
|
style={oneDark}
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
padding: '1rem',
|
||||||
|
borderRadius: 0,
|
||||||
|
backgroundColor: '#1c1c1c',
|
||||||
|
}}
|
||||||
|
wrapLines={true}
|
||||||
|
wrapLongLines={true}
|
||||||
|
showLineNumbers={language !== '' && content.split('\n').length > 1}
|
||||||
|
useInlineStyles={true}
|
||||||
|
PreTag="div"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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<TabType>('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('<think>')) {
|
||||||
|
const openThinkTag = processedMessage.match(/<think>/g)?.length || 0;
|
||||||
|
const closeThinkTag = processedMessage.match(/<\/think>/g)?.length || 0;
|
||||||
|
|
||||||
|
if (openThinkTag > closeThinkTag) {
|
||||||
|
processedMessage += '</think> <a> </a>'; // The extra <a> </a> 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 `<a href="${url}" target="_blank" className="bg-light-secondary dark:bg-dark-secondary px-1 rounded ml-1 no-underline text-xs text-black/70 dark:text-white/70 relative">${numStr}</a>`;
|
||||||
|
} 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 <CodeBlock className={className}>{children}</CodeBlock>;
|
||||||
|
}
|
||||||
|
// This is an inline code block (`code`)
|
||||||
|
return (
|
||||||
|
<code className="px-1.5 py-0.5 rounded bg-dark-secondary text-white font-mono text-sm">
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pre: {
|
||||||
|
component: ({ children }) => children,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-full">
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex border-b border-light-200 dark:border-dark-200 overflow-x-auto no-scrollbar sticky top-0 bg-light-primary dark:bg-dark-primary z-10 -mx-4 px-4 mb-2 shadow-sm">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('text')}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center px-4 py-3 text-sm font-medium transition-all duration-200 relative',
|
||||||
|
activeTab === 'text'
|
||||||
|
? 'border-b-2 border-[#24A0ED] text-[#24A0ED] bg-light-100 dark:bg-dark-100'
|
||||||
|
: 'text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white hover:bg-light-100 dark:hover:bg-dark-100',
|
||||||
|
)}
|
||||||
|
aria-selected={activeTab === 'text'}
|
||||||
|
role="tab"
|
||||||
|
>
|
||||||
|
<Disc3 size={16} className="mr-2" />
|
||||||
|
<span className="whitespace-nowrap">Answer</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{message.sources && message.sources.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('sources')}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center space-x-2 px-4 py-3 text-sm font-medium transition-all duration-200 relative',
|
||||||
|
activeTab === 'sources'
|
||||||
|
? 'border-b-2 border-[#24A0ED] text-[#24A0ED] bg-light-100 dark:bg-dark-100'
|
||||||
|
: 'text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white hover:bg-light-100 dark:hover:bg-dark-100',
|
||||||
|
)}
|
||||||
|
aria-selected={activeTab === 'sources'}
|
||||||
|
role="tab"
|
||||||
|
>
|
||||||
|
<BookCopy size={16} />
|
||||||
|
<span className="whitespace-nowrap">Sources</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'ml-1.5 px-1.5 py-0.5 text-xs rounded-full',
|
||||||
|
activeTab === 'sources'
|
||||||
|
? 'bg-[#24A0ED]/20 text-[#24A0ED]'
|
||||||
|
: 'bg-light-200 dark:bg-dark-200 text-black/70 dark:text-white/70',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{message.sources.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('images')}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center space-x-2 px-4 py-3 text-sm font-medium transition-all duration-200 relative',
|
||||||
|
activeTab === 'images'
|
||||||
|
? 'border-b-2 border-[#24A0ED] text-[#24A0ED] bg-light-100 dark:bg-dark-100'
|
||||||
|
: 'text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white hover:bg-light-100 dark:hover:bg-dark-100',
|
||||||
|
)}
|
||||||
|
aria-selected={activeTab === 'images'}
|
||||||
|
role="tab"
|
||||||
|
>
|
||||||
|
<ImagesIcon size={16} />
|
||||||
|
<span className="whitespace-nowrap">Images</span>
|
||||||
|
{imageCount > 0 && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'ml-1.5 px-1.5 py-0.5 text-xs rounded-full',
|
||||||
|
activeTab === 'images'
|
||||||
|
? 'bg-[#24A0ED]/20 text-[#24A0ED]'
|
||||||
|
: 'bg-light-200 dark:bg-dark-200 text-black/70 dark:text-white/70',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{imageCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('videos')}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center space-x-2 px-4 py-3 text-sm font-medium transition-all duration-200 relative',
|
||||||
|
activeTab === 'videos'
|
||||||
|
? 'border-b-2 border-[#24A0ED] text-[#24A0ED] bg-light-100 dark:bg-dark-100'
|
||||||
|
: 'text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white hover:bg-light-100 dark:hover:bg-dark-100',
|
||||||
|
)}
|
||||||
|
aria-selected={activeTab === 'videos'}
|
||||||
|
role="tab"
|
||||||
|
>
|
||||||
|
<VideoIcon size={16} />
|
||||||
|
<span className="whitespace-nowrap">Videos</span>
|
||||||
|
{videoCount > 0 && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'ml-1.5 px-1.5 py-0.5 text-xs rounded-full',
|
||||||
|
activeTab === 'videos'
|
||||||
|
? 'bg-[#24A0ED]/20 text-[#24A0ED]'
|
||||||
|
: 'bg-light-200 dark:bg-dark-200 text-black/70 dark:text-white/70',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{videoCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
<div
|
||||||
|
className="min-h-[150px] transition-all duration-200 ease-in-out"
|
||||||
|
role="tabpanel"
|
||||||
|
>
|
||||||
|
{/* Answer Tab */}
|
||||||
|
{activeTab === 'text' && (
|
||||||
|
<div className="flex flex-col space-y-4 animate-fadeIn">
|
||||||
|
<Markdown
|
||||||
|
className={cn(
|
||||||
|
'prose prose-h1:mb-3 prose-h2:mb-2 prose-h2:mt-6 prose-h2:font-[800] prose-h3:mt-4 prose-h3:mb-1.5 prose-h3:font-[600] prose-invert prose-p:leading-relaxed prose-pre:p-0 font-[400]',
|
||||||
|
'prose-code:bg-transparent prose-code:p-0 prose-code:text-inherit prose-code:font-normal prose-code:before:content-none prose-code:after:content-none',
|
||||||
|
'prose-pre:bg-transparent prose-pre:border-0 prose-pre:m-0 prose-pre:p-0',
|
||||||
|
'max-w-none break-words px-4 text-white',
|
||||||
|
)}
|
||||||
|
options={markdownOverrides}
|
||||||
|
>
|
||||||
|
{parsedMessage}
|
||||||
|
</Markdown>
|
||||||
|
|
||||||
|
{loading && isLast ? null : (
|
||||||
|
<div className="flex flex-row items-center justify-between w-full text-black dark:text-white px-4 py-4">
|
||||||
|
<div className="flex flex-row items-center space-x-1">
|
||||||
|
<Rewrite rewrite={rewrite} messageId={message.messageId} />
|
||||||
|
{message.modelStats && (
|
||||||
|
<ModelInfoButton modelStats={message.modelStats} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row items-center space-x-1">
|
||||||
|
<Copy initialMessage={message.content} message={message} />
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (speechStatus === 'started') {
|
||||||
|
stop();
|
||||||
|
} else {
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-2 text-black/70 dark:text-white/70 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white"
|
||||||
|
>
|
||||||
|
{speechStatus === 'started' ? (
|
||||||
|
<StopCircle size={18} />
|
||||||
|
) : (
|
||||||
|
<Volume2 size={18} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLast && message.role === 'assistant' && !loading && (
|
||||||
|
<>
|
||||||
|
<div className="border-t border-light-secondary dark:border-dark-secondary px-4 pt-4 mt-4">
|
||||||
|
<div className="flex flex-row items-center space-x-2 mb-3">
|
||||||
|
<Layers3 size={20} />
|
||||||
|
<h3 className="text-xl font-medium">Related</h3>
|
||||||
|
|
||||||
|
{(!message.suggestions ||
|
||||||
|
message.suggestions.length === 0) && (
|
||||||
|
<button
|
||||||
|
onClick={handleLoadSuggestions}
|
||||||
|
disabled={loadingSuggestions}
|
||||||
|
className="px-4 py-2 flex flex-row items-center justify-center space-x-2 rounded-lg bg-light-secondary dark:bg-dark-secondary hover:bg-light-200 dark:hover:bg-dark-200 transition duration-200 text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white"
|
||||||
|
>
|
||||||
|
{loadingSuggestions ? (
|
||||||
|
<div className="w-4 h-4 border-2 border-t-transparent border-gray-400 dark:border-gray-500 rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles size={16} />
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{loadingSuggestions
|
||||||
|
? 'Loading suggestions...'
|
||||||
|
: 'Load suggestions'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message.suggestions && message.suggestions.length > 0 && (
|
||||||
|
<div className="flex flex-col space-y-3 mt-2">
|
||||||
|
{message.suggestions.map((suggestion, i) => (
|
||||||
|
<div
|
||||||
|
className="flex flex-col space-y-3 text-sm"
|
||||||
|
key={i}
|
||||||
|
>
|
||||||
|
<div className="h-px w-full bg-light-secondary dark:bg-dark-secondary" />
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
sendMessage(suggestion);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer flex flex-row justify-between font-medium space-x-2 items-center"
|
||||||
|
>
|
||||||
|
<p className="transition duration-200 hover:text-[#24A0ED]">
|
||||||
|
{suggestion}
|
||||||
|
</p>
|
||||||
|
<Plus
|
||||||
|
size={20}
|
||||||
|
className="text-[#24A0ED] flex-shrink-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sources Tab */}
|
||||||
|
{activeTab === 'sources' &&
|
||||||
|
message.sources &&
|
||||||
|
message.sources.length > 0 && (
|
||||||
|
<div className="p-4 animate-fadeIn">
|
||||||
|
{message.searchQuery && (
|
||||||
|
<div className="mb-4 text-sm bg-light-secondary dark:bg-dark-secondary rounded-lg p-3">
|
||||||
|
<span className="font-medium text-black/70 dark:text-white/70">
|
||||||
|
Search query:
|
||||||
|
</span>{' '}
|
||||||
|
{message.searchUrl ? (
|
||||||
|
<a
|
||||||
|
href={message.searchUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="dark:text-white text-black hover:underline"
|
||||||
|
>
|
||||||
|
{message.searchQuery}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-black dark:text-white">
|
||||||
|
{message.searchQuery}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<MessageSources sources={message.sources} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Images Tab */}
|
||||||
|
{activeTab === 'images' && (
|
||||||
|
<div className="p-3 animate-fadeIn">
|
||||||
|
<SearchImages
|
||||||
|
query={query}
|
||||||
|
chatHistory={chatHistory}
|
||||||
|
messageId={messageId}
|
||||||
|
onImagesLoaded={updateImageCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Videos Tab */}
|
||||||
|
{activeTab === 'videos' && (
|
||||||
|
<div className="p-3 animate-fadeIn">
|
||||||
|
<SearchVideos
|
||||||
|
query={query}
|
||||||
|
chatHistory={chatHistory}
|
||||||
|
messageId={messageId}
|
||||||
|
onVideosLoaded={updateVideoCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MessageTabs;
|
@ -1,6 +1,5 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import { ImagesIcon, PlusIcon } from 'lucide-react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useState } from 'react';
|
|
||||||
import Lightbox from 'yet-another-react-lightbox';
|
import Lightbox from 'yet-another-react-lightbox';
|
||||||
import 'yet-another-react-lightbox/styles.css';
|
import 'yet-another-react-lightbox/styles.css';
|
||||||
import { Message } from './ChatWindow';
|
import { Message } from './ChatWindow';
|
||||||
@ -15,75 +14,91 @@ const SearchImages = ({
|
|||||||
query,
|
query,
|
||||||
chatHistory,
|
chatHistory,
|
||||||
messageId,
|
messageId,
|
||||||
|
onImagesLoaded,
|
||||||
}: {
|
}: {
|
||||||
query: string;
|
query: string;
|
||||||
chatHistory: Message[];
|
chatHistory: Message[];
|
||||||
messageId: string;
|
messageId: string;
|
||||||
|
onImagesLoaded?: (count: number) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const [images, setImages] = useState<Image[] | null>(null);
|
const [images, setImages] = useState<Image[] | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(true);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [slides, setSlides] = useState<any[]>([]);
|
const [slides, setSlides] = useState<any[]>([]);
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{!loading && images === null && (
|
|
||||||
<button
|
|
||||||
id={`search-images-${messageId}`}
|
|
||||||
onClick={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';
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
setLoading(false);
|
|
||||||
}}
|
|
||||||
className="border border-dashed border-light-200 dark:border-dark-200 hover:bg-light-200 dark:hover:bg-dark-200 active:scale-95 duration-200 transition px-4 py-2 flex flex-row items-center justify-between rounded-lg dark:text-white text-sm w-full"
|
|
||||||
>
|
|
||||||
<div className="flex flex-row items-center space-x-2">
|
|
||||||
<ImagesIcon size={17} />
|
|
||||||
<p>Search images</p>
|
|
||||||
</div>
|
|
||||||
<PlusIcon className="text-[#24A0ED]" size={17} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{[...Array(4)].map((_, i) => (
|
{[...Array(4)].map((_, i) => (
|
||||||
@ -97,59 +112,22 @@ const SearchImages = ({
|
|||||||
{images !== null && images.length > 0 && (
|
{images !== null && images.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{images.length > 4
|
{images.map((image, i) => (
|
||||||
? images.slice(0, 3).map((image, i) => (
|
<img
|
||||||
<img
|
onClick={() => {
|
||||||
onClick={() => {
|
setOpen(true);
|
||||||
setOpen(true);
|
setSlides([
|
||||||
setSlides([
|
slides[i],
|
||||||
slides[i],
|
...slides.slice(0, i),
|
||||||
...slides.slice(0, i),
|
...slides.slice(i + 1),
|
||||||
...slides.slice(i + 1),
|
]);
|
||||||
]);
|
}}
|
||||||
}}
|
key={i}
|
||||||
key={i}
|
src={image.img_src}
|
||||||
src={image.img_src}
|
alt={image.title}
|
||||||
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"
|
||||||
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) => (
|
|
||||||
<img
|
|
||||||
onClick={() => {
|
|
||||||
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 && (
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
className="bg-light-100 hover:bg-light-200 dark:bg-dark-100 dark:hover:bg-dark-200 transition duration-200 active:scale-95 hover:scale-[1.02] h-auto w-full rounded-lg flex flex-col justify-between text-white p-2"
|
|
||||||
>
|
|
||||||
<div className="flex flex-row items-center space-x-1">
|
|
||||||
{images.slice(3, 6).map((image, i) => (
|
|
||||||
<img
|
|
||||||
key={i}
|
|
||||||
src={image.img_src}
|
|
||||||
alt={image.title}
|
|
||||||
className="h-6 w-12 rounded-md lg:h-3 lg:w-6 lg:rounded-sm aspect-video object-cover"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-black/70 dark:text-white/70 text-xs">
|
|
||||||
View {images.length - 3} more
|
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<Lightbox open={open} close={() => setOpen(false)} slides={slides} />
|
<Lightbox open={open} close={() => setOpen(false)} slides={slides} />
|
||||||
</>
|
</>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
/* eslint-disable @next/next/no-img-element */
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import { PlayCircle, PlayIcon, PlusIcon, VideoIcon } from 'lucide-react';
|
import { PlayCircle } from 'lucide-react';
|
||||||
import { useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import Lightbox, { GenericSlide, VideoSlide } from 'yet-another-react-lightbox';
|
import Lightbox, { GenericSlide, VideoSlide } from 'yet-another-react-lightbox';
|
||||||
import 'yet-another-react-lightbox/styles.css';
|
import 'yet-another-react-lightbox/styles.css';
|
||||||
import { Message } from './ChatWindow';
|
import { Message } from './ChatWindow';
|
||||||
@ -28,79 +28,95 @@ const Searchvideos = ({
|
|||||||
query,
|
query,
|
||||||
chatHistory,
|
chatHistory,
|
||||||
messageId,
|
messageId,
|
||||||
|
onVideosLoaded,
|
||||||
}: {
|
}: {
|
||||||
query: string;
|
query: string;
|
||||||
chatHistory: Message[];
|
chatHistory: Message[];
|
||||||
messageId: string;
|
messageId: string;
|
||||||
|
onVideosLoaded?: (count: number) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const [videos, setVideos] = useState<Video[] | null>(null);
|
const [videos, setVideos] = useState<Video[] | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(true);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [slides, setSlides] = useState<VideoSlide[]>([]);
|
const [slides, setSlides] = useState<VideoSlide[]>([]);
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
const videoRefs = useRef<(HTMLIFrameElement | null)[]>([]);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{!loading && videos === null && (
|
|
||||||
<button
|
|
||||||
id={`search-videos-${messageId}`}
|
|
||||||
onClick={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';
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
setLoading(false);
|
|
||||||
}}
|
|
||||||
className="border border-dashed border-light-200 dark:border-dark-200 hover:bg-light-200 dark:hover:bg-dark-200 active:scale-95 duration-200 transition px-4 py-2 flex flex-row items-center justify-between rounded-lg dark:text-white text-sm w-full"
|
|
||||||
>
|
|
||||||
<div className="flex flex-row items-center space-x-2">
|
|
||||||
<VideoIcon size={17} />
|
|
||||||
<p>Search videos</p>
|
|
||||||
</div>
|
|
||||||
<PlusIcon className="text-[#24A0ED]" size={17} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{[...Array(4)].map((_, i) => (
|
{[...Array(4)].map((_, i) => (
|
||||||
@ -114,75 +130,30 @@ const Searchvideos = ({
|
|||||||
{videos !== null && videos.length > 0 && (
|
{videos !== null && videos.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{videos.length > 4
|
{videos.map((video, i) => (
|
||||||
? videos.slice(0, 3).map((video, i) => (
|
<div
|
||||||
<div
|
onClick={() => {
|
||||||
onClick={() => {
|
setOpen(true);
|
||||||
setOpen(true);
|
setSlides([
|
||||||
setSlides([
|
slides[i],
|
||||||
slides[i],
|
...slides.slice(0, i),
|
||||||
...slides.slice(0, i),
|
...slides.slice(i + 1),
|
||||||
...slides.slice(i + 1),
|
]);
|
||||||
]);
|
}}
|
||||||
}}
|
className="relative transition duration-200 active:scale-95 hover:scale-[1.02] cursor-pointer"
|
||||||
className="relative transition duration-200 active:scale-95 hover:scale-[1.02] cursor-pointer"
|
key={i}
|
||||||
key={i}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={video.img_src}
|
|
||||||
alt={video.title}
|
|
||||||
className="relative h-full w-full aspect-video object-cover rounded-lg"
|
|
||||||
/>
|
|
||||||
<div className="absolute bg-white/70 dark:bg-black/70 text-black/70 dark:text-white/70 px-2 py-1 flex flex-row items-center space-x-1 bottom-1 right-1 rounded-md">
|
|
||||||
<PlayCircle size={15} />
|
|
||||||
<p className="text-xs">Video</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
: videos.map((video, i) => (
|
|
||||||
<div
|
|
||||||
onClick={() => {
|
|
||||||
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}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={video.img_src}
|
|
||||||
alt={video.title}
|
|
||||||
className="relative h-full w-full aspect-video object-cover rounded-lg"
|
|
||||||
/>
|
|
||||||
<div className="absolute bg-white/70 dark:bg-black/70 text-black/70 dark:text-white/70 px-2 py-1 flex flex-row items-center space-x-1 bottom-1 right-1 rounded-md">
|
|
||||||
<PlayCircle size={15} />
|
|
||||||
<p className="text-xs">Video</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{videos.length > 4 && (
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
className="bg-light-100 hover:bg-light-200 dark:bg-dark-100 dark:hover:bg-dark-200 transition duration-200 active:scale-95 hover:scale-[1.02] h-auto w-full rounded-lg flex flex-col justify-between text-white p-2"
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center space-x-1">
|
<img
|
||||||
{videos.slice(3, 6).map((video, i) => (
|
src={video.img_src}
|
||||||
<img
|
alt={video.title}
|
||||||
key={i}
|
className="relative h-full w-full aspect-video object-cover rounded-lg"
|
||||||
src={video.img_src}
|
/>
|
||||||
alt={video.title}
|
<div className="absolute bg-white/70 dark:bg-black/70 text-black/70 dark:text-white/70 px-2 py-1 flex flex-row items-center space-x-1 bottom-1 right-1 rounded-md">
|
||||||
className="h-6 w-12 rounded-md lg:h-3 lg:w-6 lg:rounded-sm aspect-video object-cover"
|
<PlayCircle size={15} />
|
||||||
/>
|
<p className="text-xs">Video</p>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-black/70 dark:text-white/70 text-xs">
|
</div>
|
||||||
View {videos.length - 3} more
|
))}
|
||||||
</p>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<Lightbox
|
<Lightbox
|
||||||
open={open}
|
open={open}
|
||||||
|
Reference in New Issue
Block a user