add layout choice
This commit is contained in:
parent
0668c4d8b8
commit
88ab31e820
5 changed files with 398 additions and 103 deletions
|
|
@ -417,11 +417,34 @@ const Page = () => {
|
|||
config && (
|
||||
<div className="flex flex-col space-y-6 pb-28 lg:pb-8">
|
||||
<SettingsSection title="Appearance">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Theme
|
||||
</p>
|
||||
<ThemeSwitcher />
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Theme
|
||||
</p>
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
Layout Mode
|
||||
</p>
|
||||
<p className="text-xs text-black/60 dark:text-white/60">
|
||||
Choose how to display answer, sources, and related content (stored locally in browser)
|
||||
</p>
|
||||
<Select
|
||||
value={localStorage.getItem('layoutMode') || 'default'}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
localStorage.setItem('layoutMode', value);
|
||||
// Force a re-render to update the UI
|
||||
window.location.reload();
|
||||
}}
|
||||
options={[
|
||||
{ value: 'default', label: 'Default (Separate Sections)' },
|
||||
{ value: 'tabs', label: 'Tabs (Answer, Sources, Related)' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import SearchImages from './SearchImages';
|
|||
import SearchVideos from './SearchVideos';
|
||||
import { useSpeech } from 'react-text-to-speech';
|
||||
import ThinkBox from './ThinkBox';
|
||||
import MessageTabs from './MessageTabs';
|
||||
|
||||
const ThinkTagProcessor = ({ children }: { children: React.ReactNode }) => {
|
||||
return <ThinkBox content={children as string} />;
|
||||
|
|
@ -46,6 +47,13 @@ const MessageBox = ({
|
|||
}) => {
|
||||
const [parsedMessage, setParsedMessage] = useState(message.content);
|
||||
const [speechMessage, setSpeechMessage] = useState(message.content);
|
||||
const [layoutMode, setLayoutMode] = useState('default');
|
||||
|
||||
useEffect(() => {
|
||||
// Get layout mode from localStorage only
|
||||
const localLayoutMode = localStorage.getItem('layoutMode') || 'default';
|
||||
setLayoutMode(localLayoutMode);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const citationRegex = /\[([^\]]+)\]/g;
|
||||
|
|
@ -137,109 +145,148 @@ const MessageBox = ({
|
|||
ref={dividerRef}
|
||||
className="flex flex-col space-y-6 w-full lg:w-9/12"
|
||||
>
|
||||
{message.sources && message.sources.length > 0 && (
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
<BookCopy className="text-black dark:text-white" size={20} />
|
||||
<h3 className="text-black dark:text-white font-medium text-xl">
|
||||
Sources
|
||||
</h3>
|
||||
</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>
|
||||
</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] dark:prose-invert prose-p:leading-relaxed prose-pre:p-0 font-[400]',
|
||||
'max-w-none break-words text-black dark:text-white',
|
||||
{layoutMode === 'tabs' ? (
|
||||
<MessageTabs
|
||||
message={message}
|
||||
parsedMessage={parsedMessage}
|
||||
loading={loading}
|
||||
isLast={isLast}
|
||||
sendMessage={sendMessage}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{message.sources && message.sources.length > 0 && (
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
<BookCopy className="text-black dark:text-white" size={20} />
|
||||
<h3 className="text-black dark:text-white font-medium text-xl">
|
||||
Sources
|
||||
</h3>
|
||||
</div>
|
||||
<MessageSources sources={message.sources} />
|
||||
</div>
|
||||
)}
|
||||
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} />
|
||||
<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',
|
||||
)}
|
||||
</button>
|
||||
size={20}
|
||||
/>
|
||||
<h3 className="text-black dark:text-white font-medium text-xl">
|
||||
Answer
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isLast &&
|
||||
message.suggestions &&
|
||||
message.suggestions.length > 0 &&
|
||||
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>
|
||||
|
||||
<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] dark:prose-invert prose-p:leading-relaxed prose-pre:p-0 font-[400]',
|
||||
'max-w-none break-words text-black dark: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-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 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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isLast &&
|
||||
message.suggestions &&
|
||||
message.suggestions.length > 0 &&
|
||||
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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* Action buttons for tab mode */}
|
||||
{layoutMode === 'tabs' && !loading && !isLast && (
|
||||
<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">
|
||||
<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>
|
||||
)}
|
||||
</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
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { Document } from '@langchain/core/documents';
|
|||
import { File } from 'lucide-react';
|
||||
import { Fragment, useState } from 'react';
|
||||
|
||||
const MessageSources = ({ sources }: { sources: Document[] }) => {
|
||||
const MessageSources = ({ sources, layout = 'grid' }: { sources: Document[]; layout?: 'grid' | 'list' }) => {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
const closeModal = () => {
|
||||
|
|
@ -23,6 +23,71 @@ const MessageSources = ({ sources }: { sources: Document[] }) => {
|
|||
document.body.classList.add('overflow-hidden-scrollable');
|
||||
};
|
||||
|
||||
if (layout === 'list') {
|
||||
return (
|
||||
<div className="flex flex-col space-y-6">
|
||||
{sources.map((source, i) => (
|
||||
<div key={i} className="flex items-start space-x-4">
|
||||
{/* Left side: favicon */}
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
{source.metadata.url === 'File' ? (
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-700">
|
||||
<File size={16} className="text-gray-600 dark:text-gray-400" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-700 p-1">
|
||||
<img
|
||||
src={`https://s2.googleusercontent.com/s2/favicons?domain_url=${source.metadata.url}`}
|
||||
width={20}
|
||||
height={20}
|
||||
alt="favicon"
|
||||
className="rounded-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<a
|
||||
href={source.metadata.url}
|
||||
target="_blank"
|
||||
className="group block"
|
||||
>
|
||||
{/* Number and domain line */}
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{i + 1}. {source.metadata.url.replace(/^https?:\/\//, '').split('/')[0]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title line */}
|
||||
<h3 className="text-xl text-gray-700 dark:text-white hover:underline group-hover:underline font-normal leading-tight mb-2 overflow-hidden" style={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical'
|
||||
}}>
|
||||
{source.metadata.title}
|
||||
</h3>
|
||||
|
||||
{/* Description/snippet line */}
|
||||
{source.pageContent && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 leading-relaxed overflow-hidden" style={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical'
|
||||
}}>
|
||||
{source.pageContent.substring(0, 300)}...
|
||||
</p>
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2">
|
||||
{sources.slice(0, 3).map((source, i) => (
|
||||
|
|
|
|||
155
src/components/MessageTabs.tsx
Normal file
155
src/components/MessageTabs.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Message } from './ChatWindow';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
BookCopy,
|
||||
Disc3,
|
||||
Layers3,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import Markdown, { MarkdownToJSX } from 'markdown-to-jsx';
|
||||
import MessageSources from './MessageSources';
|
||||
import ThinkBox from './ThinkBox';
|
||||
|
||||
const ThinkTagProcessor = ({ children }: { children: React.ReactNode }) => {
|
||||
return <ThinkBox content={children as string} />;
|
||||
};
|
||||
|
||||
interface MessageTabsProps {
|
||||
message: Message;
|
||||
parsedMessage: string;
|
||||
loading: boolean;
|
||||
isLast: boolean;
|
||||
sendMessage: (message: string) => void;
|
||||
}
|
||||
|
||||
const MessageTabs = ({
|
||||
message,
|
||||
parsedMessage,
|
||||
loading,
|
||||
isLast,
|
||||
sendMessage,
|
||||
}: MessageTabsProps) => {
|
||||
const [activeTab, setActiveTab] = useState<'answer' | 'sources' | 'related'>('answer');
|
||||
|
||||
const markdownOverrides: MarkdownToJSX.Options = {
|
||||
overrides: {
|
||||
think: {
|
||||
component: ThinkTagProcessor,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: 'answer' as const,
|
||||
label: 'Answer',
|
||||
icon: Disc3,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
id: 'sources' as const,
|
||||
label: 'Sources',
|
||||
icon: BookCopy,
|
||||
show: message.sources && message.sources.length > 0,
|
||||
},
|
||||
{
|
||||
id: 'related' as const,
|
||||
label: 'Related',
|
||||
icon: Layers3,
|
||||
show: isLast && message.suggestions && message.suggestions.length > 0 && message.role === 'assistant' && !loading,
|
||||
},
|
||||
].filter(tab => tab.show);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
{/* Tab Headers */}
|
||||
<div className="flex border-b border-light-200 dark:border-dark-200">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
'flex items-center space-x-2 px-4 py-3 border-b-2 transition-colors',
|
||||
activeTab === tab.id
|
||||
? 'border-[#24A0ED] text-[#24A0ED]'
|
||||
: 'border-transparent text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
activeTab === tab.id && tab.id === 'answer' && isLast && loading ? 'animate-spin' : 'animate-none'
|
||||
)}
|
||||
size={18}
|
||||
/>
|
||||
<span className="font-medium">{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="min-h-[200px]">
|
||||
{activeTab === 'answer' && (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<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] dark:prose-invert prose-p:leading-relaxed prose-pre:p-0 font-[400]',
|
||||
'max-w-none break-words text-black dark:text-white',
|
||||
)}
|
||||
options={markdownOverrides}
|
||||
>
|
||||
{parsedMessage}
|
||||
</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'sources' && message.sources && message.sources.length > 0 && (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<MessageSources sources={message.sources} layout="list" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'related' &&
|
||||
isLast &&
|
||||
message.suggestions &&
|
||||
message.suggestions.length > 0 &&
|
||||
message.role === 'assistant' &&
|
||||
!loading && (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<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}
|
||||
>
|
||||
{i > 0 && <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-3 rounded-lg hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors"
|
||||
>
|
||||
<p className="transition duration-200 hover:text-[#24A0ED] text-black dark:text-white">
|
||||
{suggestion}
|
||||
</p>
|
||||
<Plus
|
||||
size={20}
|
||||
className="text-[#24A0ED] flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageTabs;
|
||||
|
|
@ -16,6 +16,9 @@ interface Config {
|
|||
SIMILARITY_MEASURE: string;
|
||||
KEEP_ALIVE: string;
|
||||
};
|
||||
UI: {
|
||||
LAYOUT_MODE: string;
|
||||
};
|
||||
HISTORY: {
|
||||
RETENTION_DAYS: number;
|
||||
};
|
||||
|
|
@ -73,6 +76,8 @@ export const getSimilarityMeasure = () =>
|
|||
|
||||
export const getKeepAlive = () => loadConfig().GENERAL.KEEP_ALIVE;
|
||||
|
||||
export const getLayoutMode = () => loadConfig().UI?.LAYOUT_MODE || 'default';
|
||||
|
||||
export const getHistoryRetentionDays = () => loadConfig().HISTORY.RETENTION_DAYS;
|
||||
|
||||
export const getOpenaiApiKey = () => loadConfig().MODELS.OPENAI.API_KEY;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue