mirror of
https://github.com/ItzCrazyKns/Perplexica.git
synced 2025-09-19 07:41:33 +00:00
feat(UI): Tabbed interface for messages.
This commit is contained in:
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;
|
Reference in New Issue
Block a user