feat: default locale Russian, geo determines language for other countries

- localization-svc: defaultLocale ru, resolveLocale only by geo
- web-svc: DEFAULT_LOCALE ru, layout lang=ru, embeddedTranslations fallback ru
- countryToLocale: default ru when no country or unknown country

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
home
2026-02-23 15:10:38 +03:00
parent 8fc82a3b90
commit cd6b7857ba
606 changed files with 26148 additions and 14297 deletions

View File

@@ -0,0 +1,282 @@
'use client';
import {
Brain,
Search,
FileText,
ChevronDown,
ChevronUp,
BookSearch,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useEffect, useState } from 'react';
import { ResearchBlock, ResearchBlockSubStep } from '@/lib/types';
import { useChat } from '@/lib/hooks/useChat';
import { useTranslation } from '@/lib/localization/context';
const getStepIcon = (step: ResearchBlockSubStep) => {
if (step.type === 'reasoning') {
return <Brain className="w-4 h-4" />;
} else if (step.type === 'searching' || step.type === 'upload_searching') {
return <Search className="w-4 h-4" />;
} else if (
step.type === 'search_results' ||
step.type === 'upload_search_results'
) {
return <FileText className="w-4 h-4" />;
} else if (step.type === 'reading') {
return <BookSearch className="w-4 h-4" />;
}
return null;
};
const getStepTitle = (
step: ResearchBlockSubStep,
isStreaming: boolean,
t: (key: string) => string,
): string => {
if (step.type === 'reasoning') {
return isStreaming && !step.reasoning ? t('chat.brainstorming') : t('chat.thinking');
} else if (step.type === 'searching') {
const n = step.searching.length;
return t('chat.searchingQueries').replace('{count}', String(n)).replace('{plural}', n === 1 ? t('chat.query') : t('chat.queries'));
} else if (step.type === 'search_results') {
const n = step.reading.length;
return t('chat.foundResults').replace('{count}', String(n)).replace('{plural}', n === 1 ? t('chat.result') : t('chat.results'));
} else if (step.type === 'reading') {
const n = step.reading.length;
return t('chat.readingSources').replace('{count}', String(n)).replace('{plural}', n === 1 ? t('chat.source') : t('chat.sources'));
} else if (step.type === 'upload_searching') {
return t('chat.scanningDocs');
} else if (step.type === 'upload_search_results') {
const n = step.results.length;
return t('chat.readingDocs').replace('{count}', String(n)).replace('{plural}', n === 1 ? t('chat.document') : t('chat.documents'));
}
return t('chat.processing');
};
const AssistantSteps = ({
block,
status,
isLast,
}: {
block: ResearchBlock;
status: 'answering' | 'completed' | 'error';
isLast: boolean;
}) => {
const { t } = useTranslation();
const [isExpanded, setIsExpanded] = useState(
isLast && status === 'answering' ? true : false,
);
const { researchEnded, loading } = useChat();
useEffect(() => {
if (researchEnded && isLast) {
setIsExpanded(false);
} else if (status === 'answering' && isLast) {
setIsExpanded(true);
}
}, [researchEnded, status]);
if (!block || block.data.subSteps.length === 0) return null;
return (
<div className="rounded-lg bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 overflow-hidden">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between p-3 hover:bg-light-200 dark:hover:bg-dark-200 transition duration-200"
>
<div className="flex items-center gap-2">
<Brain className="w-4 h-4 text-black dark:text-white" />
<span className="text-sm font-medium text-black dark:text-white">
{t('chat.researchProgress')} ({block.data.subSteps.length}{' '}
{block.data.subSteps.length === 1 ? t('chat.step') : t('chat.steps')})
{status === 'answering' && (
<span className="ml-2 font-normal text-black/60 dark:text-white/60">
(~3090 sec)
</span>
)}
</span>
</div>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-black/70 dark:text-white/70" />
) : (
<ChevronDown className="w-4 h-4 text-black/70 dark:text-white/70" />
)}
</button>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="border-t border-light-200 dark:border-dark-200"
>
<div className="p-3 space-y-2">
{block.data.subSteps.map((step, index) => {
const isLastStep = index === block.data.subSteps.length - 1;
const isStreaming = loading && isLastStep && !researchEnded;
return (
<motion.div
key={step.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2, delay: 0 }}
className="flex gap-2"
>
<div className="flex flex-col items-center -mt-0.5">
<div
className={`rounded-full p-1.5 bg-light-100 dark:bg-dark-100 text-black/70 dark:text-white/70 ${isStreaming ? 'animate-pulse' : ''}`}
>
{getStepIcon(step)}
</div>
{index < block.data.subSteps.length - 1 && (
<div className="w-0.5 flex-1 min-h-[20px] bg-light-200 dark:bg-dark-200 mt-1.5" />
)}
</div>
<div className="flex-1 pb-1">
<span className="text-sm font-medium text-black dark:text-white">
{getStepTitle(step, isStreaming, t)}
</span>
{step.type === 'reasoning' && (
<>
{step.reasoning && (
<p className="text-xs text-black/70 dark:text-white/70 mt-0.5">
{step.reasoning}
</p>
)}
{isStreaming && !step.reasoning && (
<div className="flex items-center gap-1.5 mt-0.5">
<div
className="w-1.5 h-1.5 bg-black/40 dark:bg-white/40 rounded-full animate-bounce"
style={{ animationDelay: '0ms' }}
/>
<div
className="w-1.5 h-1.5 bg-black/40 dark:bg-white/40 rounded-full animate-bounce"
style={{ animationDelay: '150ms' }}
/>
<div
className="w-1.5 h-1.5 bg-black/40 dark:bg-white/40 rounded-full animate-bounce"
style={{ animationDelay: '300ms' }}
/>
</div>
)}
</>
)}
{step.type === 'searching' &&
step.searching.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-1.5">
{step.searching.map((query, idx) => (
<span
key={idx}
className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-light-100 dark:bg-dark-100 text-black/70 dark:text-white/70 border border-light-200 dark:border-dark-200"
>
{query}
</span>
))}
</div>
)}
{(step.type === 'search_results' ||
step.type === 'reading') &&
step.reading.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-1.5">
{step.reading.slice(0, 4).map((result, idx) => {
const url = typeof result.metadata?.url === 'string' ? result.metadata.url : '';
const title = String(result.metadata?.title ?? 'Untitled');
let domain = '';
try {
if (url) domain = new URL(url).hostname;
} catch {
/* invalid url */
}
const faviconUrl = domain
? `https://s2.googleusercontent.com/s2/favicons?domain=${domain}&sz=128`
: '';
return (
<a
key={idx}
href={url}
target="_blank"
className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md text-xs font-medium bg-light-100 dark:bg-dark-100 text-black/70 dark:text-white/70 border border-light-200 dark:border-dark-200"
>
{faviconUrl && (
<img
src={faviconUrl}
alt=""
className="w-3 h-3 rounded-sm flex-shrink-0"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
)}
<span className="line-clamp-1">{title}</span>
</a>
);
})}
</div>
)}
{step.type === 'upload_searching' &&
step.queries.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-1.5">
{step.queries.map((query, idx) => (
<span
key={idx}
className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-light-100 dark:bg-dark-100 text-black/70 dark:text-white/70 border border-light-200 dark:border-dark-200"
>
{query}
</span>
))}
</div>
)}
{step.type === 'upload_search_results' &&
step.results.length > 0 && (
<div className="mt-1.5 grid gap-3 lg:grid-cols-3">
{step.results.slice(0, 4).map((result, idx) => {
const raw =
result.metadata?.title ?? result.metadata?.fileName;
const title =
typeof raw === 'string' ? raw : 'Untitled document';
return (
<div
key={idx}
className="flex flex-row space-x-3 rounded-lg border border-light-200 dark:border-dark-200 bg-light-100 dark:bg-dark-100 p-2 cursor-pointer"
>
<div className="mt-0.5 h-10 w-10 rounded-md bg-[#EA580C]/20 text-[#EA580C] dark:bg-[#EA580C] dark:text-white flex items-center justify-center">
<FileText className="w-5 h-5" />
</div>
<div className="flex flex-col justify-center">
<p className="text-[13px] text-black dark:text-white line-clamp-1">
{title}
</p>
</div>
</div>
);
})}
</div>
)}
</div>
</motion.div>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default AssistantSteps;

View File

@@ -0,0 +1,108 @@
'use client';
import { Fragment, useEffect, useRef, useState } from 'react';
import MessageInput from './MessageInput';
import MessageBox from './MessageBox';
import MessageBoxLoading from './MessageBoxLoading';
import { useChat } from '@/lib/hooks/useChat';
const Chat = () => {
const { sections, loading, messageAppeared, messages } = useChat();
const [dividerWidth, setDividerWidth] = useState(0);
const dividerRef = useRef<HTMLDivElement | null>(null);
const messageEnd = useRef<HTMLDivElement | null>(null);
const lastScrolledRef = useRef<number>(0);
useEffect(() => {
const updateDividerWidth = () => {
if (dividerRef.current) {
setDividerWidth(dividerRef.current.offsetWidth);
}
};
updateDividerWidth();
const resizeObserver = new ResizeObserver(() => {
updateDividerWidth();
});
const currentRef = dividerRef.current;
if (currentRef) {
resizeObserver.observe(currentRef);
}
window.addEventListener('resize', updateDividerWidth);
return () => {
if (currentRef) {
resizeObserver.unobserve(currentRef);
}
resizeObserver.disconnect();
window.removeEventListener('resize', updateDividerWidth);
};
}, [sections.length]);
useEffect(() => {
const scroll = () => {
messageEnd.current?.scrollIntoView({ behavior: 'auto' });
};
if (messages.length === 1) {
document.title = `${messages[0].query.substring(0, 30)} - GooSeek`;
}
if (sections.length > lastScrolledRef.current) {
scroll();
lastScrolledRef.current = sections.length;
}
}, [messages]);
return (
<div className="flex flex-col space-y-6 pt-8 pb-44 lg:pb-28 sm:mx-4 md:mx-8">
{sections.map((section, i) => {
const isLast = i === sections.length - 1;
return (
<Fragment key={section.message.messageId}>
<MessageBox
section={section}
sectionIndex={i}
dividerRef={isLast ? dividerRef : undefined}
isLast={isLast}
/>
{!isLast && (
<div className="h-px w-full bg-light-secondary dark:bg-dark-secondary" />
)}
</Fragment>
);
})}
{loading && !messageAppeared && <MessageBoxLoading />}
<div ref={messageEnd} className="h-0" />
{dividerWidth > 0 && (
<div
className="fixed z-40 bottom-24 lg:bottom-6"
style={{ width: dividerWidth }}
>
<div
className="pointer-events-none absolute -bottom-6 left-0 right-0 h-[calc(100%+24px+24px)] dark:hidden"
style={{
background:
'linear-gradient(to top, #ffffff 0%, #ffffff 35%, rgba(255,255,255,0.95) 45%, rgba(255,255,255,0.85) 55%, rgba(255,255,255,0.7) 65%, rgba(255,255,255,0.5) 75%, rgba(255,255,255,0.3) 85%, rgba(255,255,255,0.1) 92%, transparent 100%)',
}}
/>
<div
className="pointer-events-none absolute -bottom-6 left-0 right-0 h-[calc(100%+24px+24px)] hidden dark:block"
style={{
background:
'linear-gradient(to top, #0d1117 0%, #0d1117 35%, rgba(13,17,23,0.95) 45%, rgba(13,17,23,0.85) 55%, rgba(13,17,23,0.7) 65%, rgba(13,17,23,0.5) 75%, rgba(13,17,23,0.3) 85%, rgba(13,17,23,0.1) 92%, transparent 100%)',
}}
/>
<MessageInput />
</div>
)}
</div>
);
};
export default Chat;

View File

@@ -0,0 +1,77 @@
'use client';
import Navbar from './Navbar';
import Chat from './Chat';
import EmptyChat from './EmptyChat';
import NextError from 'next/error';
import { useChat } from '@/lib/hooks/useChat';
import SettingsButtonMobile from './Settings/SettingsButtonMobile';
import { Block } from '@/lib/types';
export interface BaseMessage {
chatId: string;
messageId: string;
createdAt: Date;
}
export interface Message extends BaseMessage {
backendId: string;
query: string;
responseBlocks: Block[];
status: 'answering' | 'completed' | 'error';
/** Заголовок статьи при переходе из Discover (Summary) */
articleTitle?: string;
}
export interface File {
fileName: string;
fileExtension: string;
fileId: string;
}
export interface Widget {
widgetType: string;
params: Record<string, any>;
}
const ChatWindow = () => {
const { hasError, notFound, messages, isReady } = useChat();
if (hasError) {
return (
<div className="relative">
<div className="absolute w-full flex flex-row items-center justify-end mr-5 mt-5">
<SettingsButtonMobile />
</div>
<div className="flex flex-col items-center justify-center min-h-screen">
<p className="dark:text-white/70 text-black/70 text-sm">
Failed to connect to the server. Please try again later.
</p>
</div>
</div>
);
}
return isReady ? (
notFound ? (
<NextError statusCode={404} />
) : (
<div>
{messages.length > 0 ? (
<>
<Navbar />
<Chat />
</>
) : (
<EmptyChat />
)}
</div>
)
) : (
<div className="min-h-screen w-full">
<EmptyChat />
</div>
);
};
export default ChatWindow;

View File

@@ -0,0 +1,25 @@
'use client';
import { useEffect, useState } from 'react';
/**
* Откладывает рендер детей до момента монтирования на клиенте.
* Устраняет ошибку "Can't perform a React state update on a component that hasn't mounted yet"
* при гидрации с next-themes и другими провайдерами.
*/
export function ClientOnly({
children,
fallback = null,
}: {
children: React.ReactNode;
fallback?: React.ReactNode;
}) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return fallback;
return <>{children}</>;
}

View File

@@ -0,0 +1,30 @@
/**
* Ошибка загрузки с кнопкой Retry
* docs/architecture: 05-gaps-and-best-practices.md §8
*/
import { RotateCcw } from 'lucide-react';
interface DataFetchErrorProps {
message?: string;
onRetry: () => void;
}
export default function DataFetchError({ message = 'Failed to load. Check your connection.', onRetry }: DataFetchErrorProps) {
return (
<div
role="alert"
className="mt-6 p-6 rounded-2xl bg-light-secondary dark:bg-dark-secondary border border-red-500/20 dark:border-red-500/30"
>
<p className="text-black/70 dark:text-white/70 mb-4">{message}</p>
<button
type="button"
onClick={onRetry}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-light-accent dark:bg-dark-accent text-white text-sm font-medium hover:opacity-90 transition-opacity focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#EA580C]"
>
<RotateCcw size={16} />
Retry
</button>
</div>
);
}

View File

@@ -0,0 +1,138 @@
import { Trash } from 'lucide-react';
import {
Description,
Dialog,
DialogBackdrop,
DialogPanel,
DialogTitle,
Transition,
TransitionChild,
} from '@headlessui/react';
import { Fragment, useState } from 'react';
import { toast } from 'sonner';
import { Chat } from '@/app/library/page';
const DeleteChat = ({
chatId,
chats,
setChats,
redirect = false,
}: {
chatId: string;
chats: Chat[];
setChats: (chats: Chat[]) => void;
redirect?: boolean;
}) => {
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
const handleDelete = async () => {
setLoading(true);
try {
const token =
typeof window !== 'undefined'
? localStorage.getItem('auth_token') ?? localStorage.getItem('access_token')
: null;
if (!token) {
const { deleteGuestChat } = await import('@/lib/guest-storage');
deleteGuestChat(chatId);
const newChats = chats.filter((chat) => chat.id !== chatId);
setChats(newChats);
if (redirect) window.location.href = '/';
setConfirmationDialogOpen(false);
setLoading(false);
return;
}
const libRes = await fetch(`/api/v1/library/threads/${chatId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
if (!libRes.ok) {
throw new Error('Failed to delete chat');
}
const newChats = chats.filter((chat) => chat.id !== chatId);
setChats(newChats);
if (redirect) {
window.location.href = '/';
}
} catch (err: any) {
toast.error(err.message);
} finally {
setConfirmationDialogOpen(false);
setLoading(false);
}
};
return (
<>
<button
onClick={() => {
setConfirmationDialogOpen(true);
}}
className="bg-transparent text-red-400 hover:scale-105 transition duration-200"
>
<Trash size={17} />
</button>
<Transition appear show={confirmationDialogOpen} as={Fragment}>
<Dialog
as="div"
className="relative z-50"
onClose={() => {
if (!loading) {
setConfirmationDialogOpen(false);
}
}}
>
<DialogBackdrop className="fixed inset-0 bg-black/30" />
<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">
Delete Confirmation
</DialogTitle>
<Description className="text-sm dark:text-white/70 text-black/70">
Are you sure you want to delete this chat?
</Description>
<div className="flex flex-row items-end justify-end space-x-4 mt-6">
<button
onClick={() => {
if (!loading) {
setConfirmationDialogOpen(false);
}
}}
className="text-black/50 dark:text-white/50 text-sm hover:text-black/70 hover:dark:text-white/70 transition duration-200"
>
Cancel
</button>
<button
onClick={handleDelete}
className="text-red-400 text-sm hover:text-red-500 transition duration200"
>
Delete
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
</>
);
};
export default DeleteChat;

View File

@@ -0,0 +1,60 @@
import { Discover } from '@/app/discover/page';
import Link from 'next/link';
const MajorNewsCard = ({
item,
isLeft = true,
}: {
item: Discover;
isLeft?: boolean;
}) => (
<Link
href={`/?q=${encodeURIComponent(`Summary: ${item.url}`)}&title=${encodeURIComponent(item.title)}`}
className="w-full group flex flex-row items-stretch gap-6 h-60 py-3"
target="_blank"
>
{isLeft ? (
<>
<div className="relative w-80 h-full overflow-hidden rounded-2xl flex-shrink-0">
<img
className="object-cover w-full h-full group-hover:scale-105 transition-transform duration-500"
src={item.thumbnail}
alt={item.title}
/>
</div>
<div className="flex flex-col justify-center flex-1 py-4">
<h2
className="text-3xl font-light mb-3 leading-tight line-clamp-3 group-hover:text-[#EA580C] transition duration-200"
>
{item.title}
</h2>
<p className="text-black/60 dark:text-white/60 text-base leading-relaxed line-clamp-4">
{item.content}
</p>
</div>
</>
) : (
<>
<div className="flex flex-col justify-center flex-1 py-4">
<h2
className="text-3xl font-light mb-3 leading-tight line-clamp-3 group-hover:text-[#EA580C] transition duration-200"
>
{item.title}
</h2>
<p className="text-black/60 dark:text-white/60 text-base leading-relaxed line-clamp-4">
{item.content}
</p>
</div>
<div className="relative w-80 h-full overflow-hidden rounded-2xl flex-shrink-0">
<img
className="object-cover w-full h-full group-hover:scale-105 transition-transform duration-500"
src={item.thumbnail}
alt={item.title}
/>
</div>
</>
)}
</Link>
);
export default MajorNewsCard;

View File

@@ -0,0 +1,28 @@
import { Discover } from '@/app/discover/page';
import Link from 'next/link';
const SmallNewsCard = ({ item }: { item: Discover }) => (
<Link
href={`/?q=${encodeURIComponent(`Summary: ${item.url}`)}&title=${encodeURIComponent(item.title)}`}
className="rounded-3xl overflow-hidden bg-light-secondary dark:bg-dark-secondary shadow-sm shadow-light-200/10 dark:shadow-black/25 group flex flex-col"
target="_blank"
>
<div className="relative aspect-video overflow-hidden">
<img
className="object-cover w-full h-full group-hover:scale-105 transition-transform duration-300"
src={item.thumbnail}
alt={item.title}
/>
</div>
<div className="p-4">
<h3 className="font-semibold text-sm mb-2 leading-tight line-clamp-2 group-hover:text-[#EA580C] transition duration-200">
{item.title}
</h3>
<p className="text-black/60 dark:text-white/60 text-xs leading-relaxed line-clamp-2">
{item.content}
</p>
</div>
</Link>
);
export default SmallNewsCard;

View File

@@ -0,0 +1,81 @@
'use client';
import { useEffect, useState } from 'react';
import EmptyChatMessageInput from './EmptyChatMessageInput';
import WeatherWidget from './WeatherWidget';
import NewsArticleWidget from './NewsArticleWidget';
import SettingsButtonMobile from '@/components/Settings/SettingsButtonMobile';
import {
getShowNewsWidget,
getShowWeatherWidget,
} from '@/lib/config/clientRegistry';
import { useTranslation } from '@/lib/localization/context';
const EmptyChat = () => {
const { t } = useTranslation();
const [showWeather, setShowWeather] = useState(() =>
typeof window !== 'undefined' ? getShowWeatherWidget() : true,
);
const [showNews, setShowNews] = useState(() =>
typeof window !== 'undefined' ? getShowNewsWidget() : true,
);
useEffect(() => {
const updateWidgetVisibility = () => {
setShowWeather(getShowWeatherWidget());
setShowNews(getShowNewsWidget());
};
updateWidgetVisibility();
window.addEventListener('client-config-changed', updateWidgetVisibility);
window.addEventListener('storage', updateWidgetVisibility);
return () => {
window.removeEventListener(
'client-config-changed',
updateWidgetVisibility,
);
window.removeEventListener('storage', updateWidgetVisibility);
};
}, []);
return (
<div className="relative">
<div className="absolute w-full flex flex-row items-center justify-end mr-5 mt-5">
<SettingsButtonMobile />
</div>
<div className="flex flex-col items-center justify-center min-h-screen max-w-screen-sm mx-auto p-2 space-y-4">
<div className="flex flex-col items-center justify-center w-full space-y-6">
<div className="flex flex-row items-center justify-center -mt-8 mb-2">
<img
src={`/logo.svg?v=${process.env.NEXT_PUBLIC_VERSION ?? '1'}`}
alt="GooSeek"
className="h-12 sm:h-14 w-auto select-none"
/>
</div>
<h2 className="text-black/70 dark:text-white/70 text-3xl font-medium">
{t('empty.subtitle')}
</h2>
{(showWeather || showNews) && (
<div className="flex flex-row flex-wrap w-full gap-2 sm:gap-3 justify-center items-stretch">
{showWeather && (
<div className="flex-1 min-w-[120px] shrink-0">
<WeatherWidget />
</div>
)}
{showNews && (
<div className="flex-1 min-w-[120px] shrink-0">
<NewsArticleWidget />
</div>
)}
</div>
)}
<EmptyChatMessageInput />
</div>
</div>
</div>
);
};
export default EmptyChat;

View File

@@ -0,0 +1,96 @@
import { ArrowRight } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import Sources from './MessageInputActions/Sources';
import Optimization from './MessageInputActions/Optimization';
import AnswerMode from './MessageInputActions/AnswerMode';
import InputBarPlus from './MessageInputActions/InputBarPlus';
import Attach from './MessageInputActions/Attach';
import { useChat } from '@/lib/hooks/useChat';
import { useTranslation } from '@/lib/localization/context';
import ModelSelector from './MessageInputActions/ChatModelSelector';
const EmptyChatMessageInput = () => {
const { sendMessage } = useChat();
const { t } = useTranslation();
/* const [copilotEnabled, setCopilotEnabled] = useState(false); */
const [message, setMessage] = useState('');
const inputRef = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const activeElement = document.activeElement;
const isInputFocused =
activeElement?.tagName === 'INPUT' ||
activeElement?.tagName === 'TEXTAREA' ||
activeElement?.hasAttribute('contenteditable');
if (e.key === '/' && !isInputFocused) {
e.preventDefault();
inputRef.current?.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
inputRef.current?.focus();
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, []);
return (
<form
onSubmit={(e) => {
e.preventDefault();
sendMessage(message);
setMessage('');
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage(message);
setMessage('');
}
}}
className="w-full"
>
<div className="flex flex-col bg-light-secondary dark:bg-dark-secondary px-3 pt-5 pb-3 rounded-2xl w-full border border-light-200 dark:border-dark-200 shadow-sm shadow-light-200/10 dark:shadow-black/20 transition-all duration-200 focus-within:border-light-300 dark:focus-within:border-dark-300">
<TextareaAutosize
ref={inputRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
minRows={2}
className="px-2 bg-transparent placeholder:text-[15px] placeholder:text-black/50 dark:placeholder:text-white/50 text-sm text-black dark:text-white resize-none focus:outline-none w-full max-h-24 lg:max-h-36 xl:max-h-48"
placeholder={t('input.askAnything')}
/>
<div className="flex flex-row items-center justify-between mt-4">
<div className="flex flex-row items-center gap-1">
<InputBarPlus />
<Optimization />
<AnswerMode />
</div>
<div className="flex flex-row items-center space-x-2">
<div className="flex flex-row items-center space-x-1">
<Sources />
<ModelSelector />
<Attach />
</div>
<button
disabled={message.trim().length === 0}
className="bg-[#EA580C] text-white disabled:text-black/50 dark:disabled:text-white/50 disabled:bg-[#e0e0dc] dark:disabled:bg-[#ececec21] hover:bg-opacity-85 transition duration-100 rounded-full p-2"
>
<ArrowRight className="bg-background" size={17} />
</button>
</div>
</div>
</div>
</form>
);
};
export default EmptyChatMessageInput;

View File

@@ -0,0 +1,48 @@
'use client';
import { useEffect, useRef } from 'react';
import { toast } from 'sonner';
import { hasGuestData } from '@/lib/guest-storage';
import { migrateGuestToProfile } from '@/lib/guest-migration';
export default function GuestMigration() {
const migratedRef = useRef(false);
useEffect(() => {
if (typeof window === 'undefined') return;
const token =
localStorage.getItem('auth_token') ?? localStorage.getItem('access_token');
if (!token || !hasGuestData() || migratedRef.current) return;
migratedRef.current = true;
migrateGuestToProfile(token)
.then(({ migrated }) => {
if (migrated > 0) {
toast.success('Ваши чаты сохранены в профиль');
window.dispatchEvent(new CustomEvent('gooseek:guest-migrated'));
}
})
.catch(() => {
migratedRef.current = false;
});
}, []);
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === 'auth_token' || e.key === 'access_token') {
const token =
localStorage.getItem('auth_token') ??
localStorage.getItem('access_token');
if (token && hasGuestData()) {
migratedRef.current = false;
}
}
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
return null;
}

View File

@@ -0,0 +1,55 @@
'use client';
/**
* Предупреждение гостям: история теряется при закрытии
* docs/architecture: 05-gaps-and-best-practices.md §8
*/
import { useChat } from '@/lib/hooks/useChat';
import { useEffect, useState } from 'react';
import Link from 'next/link';
export default function GuestWarningBanner() {
const { messages } = useChat();
const [isGuest, setIsGuest] = useState(false);
useEffect(() => {
const token =
typeof window !== 'undefined'
? localStorage.getItem('auth_token') ?? localStorage.getItem('access_token')
: null;
setIsGuest(!token);
}, []);
useEffect(() => {
if (!isGuest || messages.length === 0) return;
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
e.preventDefault();
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [isGuest, messages.length]);
if (!isGuest || messages.length === 0) return null;
return (
<div
role="alert"
className="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 max-w-lg w-[calc(100%-2rem)]"
>
<div className="rounded-xl bg-amber-500/95 dark:bg-amber-600/95 text-amber-950 dark:text-amber-50 px-4 py-3 shadow-lg flex flex-col sm:flex-row items-center justify-between gap-2">
<span className="text-sm font-medium">
Your chat history will be lost when you close the browser.
</span>
<Link
href="/profile"
className="text-sm font-semibold underline underline-offset-2 hover:no-underline whitespace-nowrap"
>
Save to account
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
const Layout = ({ children }: { children: React.ReactNode }) => {
return (
<main className="lg:pl-16 bg-light-primary dark:bg-dark-primary min-h-screen">
<div className="max-w-screen-lg lg:mx-auto mx-4">{children}</div>
</main>
);
};
export default Layout;

View File

@@ -0,0 +1,51 @@
import { Check, ClipboardList } from 'lucide-react';
import { Message } from '../ChatWindow';
import { useState } from 'react';
import { Section } from '@/lib/hooks/useChat';
import { SourceBlock } from '@/lib/types';
const Copy = ({
section,
initialMessage,
}: {
section: Section;
initialMessage: string;
}) => {
const [copied, setCopied] = useState(false);
return (
<button
onClick={() => {
const sources = section.message.responseBlocks.filter(
(b) => b.type === 'source' && b.data.length > 0,
) as SourceBlock[];
const contentToCopy = `${initialMessage}${
sources.length > 0
? `\n\nCitations:\n${sources
.map((source) => source.data)
.flat()
.map((s, i) => {
const url = String(s.metadata?.url ?? '');
const fileName = String(s.metadata?.fileName ?? '');
const label =
url.startsWith('file_id://') ? (fileName || 'Uploaded File') : url;
return `[${i + 1}] ${label}`;
})
.join(`\n`)}`
: ''
}`;
navigator.clipboard.writeText(contentToCopy);
setCopied(true);
setTimeout(() => setCopied(false), 1000);
}}
className="p-2 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"
>
{copied ? <Check size={16} /> : <ClipboardList size={16} />}
</button>
);
};
export default Copy;

View File

@@ -0,0 +1,20 @@
import { ArrowLeftRight, Repeat } from 'lucide-react';
const Rewrite = ({
rewrite,
messageId,
}: {
rewrite: (messageId: string) => void;
messageId: string;
}) => {
return (
<button
onClick={() => rewrite(messageId)}
className="p-2 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 flex flex-row items-center space-x-1"
>
<Repeat size={16} />
</button>
);
};
1;
export default Rewrite;

View File

@@ -0,0 +1,325 @@
'use client';
/* eslint-disable @next/next/no-img-element */
import React, { MutableRefObject } from 'react';
import { cn } from '@/lib/utils';
import {
BookCopy,
Disc3,
Volume2,
StopCircle,
Layers3,
Plus,
CornerDownRight,
} from 'lucide-react';
import Markdown, { MarkdownToJSX, RuleType } from 'markdown-to-jsx';
import Copy from './MessageActions/Copy';
import Rewrite from './MessageActions/Rewrite';
import MessageSources from './MessageSources';
import SearchImages from './SearchImages';
import SearchVideos from './SearchVideos';
import { useSpeech } from 'react-text-to-speech';
import ThinkBox from './ThinkBox';
import { useChat, Section } from '@/lib/hooks/useChat';
import { useTranslation } from '@/lib/localization/context';
import Citation from './MessageRenderer/Citation';
import AssistantSteps from './AssistantSteps';
import { ResearchBlock } from '@/lib/types';
import Renderer from './Widgets/Renderer';
import CodeBlock from './MessageRenderer/CodeBlock';
const ThinkTagProcessor = ({
children,
thinkingEnded,
}: {
children: React.ReactNode;
thinkingEnded: boolean;
}) => {
return (
<ThinkBox content={children as string} thinkingEnded={thinkingEnded} />
);
};
const MessageBox = ({
section,
sectionIndex,
dividerRef,
isLast,
}: {
section: Section;
sectionIndex: number;
dividerRef?: MutableRefObject<HTMLDivElement | null>;
isLast: boolean;
}) => {
const {
loading,
sendMessage,
rewrite,
messages,
researchEnded,
chatHistory,
} = useChat();
const { t } = useTranslation();
const parsedMessage = section.parsedTextBlocks.join('\n\n');
const speechMessage = section.speechMessage || '';
const thinkingEnded = section.thinkingEnded;
const sourceBlocks = section.message.responseBlocks.filter(
(block): block is typeof block & { type: 'source' } =>
block.type === 'source',
);
const sources = sourceBlocks.flatMap((block) => block.data);
const hasContent =
section.parsedTextBlocks.some(
(t) => typeof t === 'string' && t.trim().length > 0,
);
const { speechStatus, start, stop } = useSpeech({ text: speechMessage });
const markdownOverrides: MarkdownToJSX.Options = {
renderRule(next, node, renderChildren, state) {
if (node.type === RuleType.codeInline) {
return `\`${node.text}\``;
}
if (node.type === RuleType.codeBlock) {
return (
<CodeBlock key={state.key} language={node.lang || ''}>
{node.text}
</CodeBlock>
);
}
return next();
},
overrides: {
think: {
component: ThinkTagProcessor,
props: {
thinkingEnded: thinkingEnded,
},
},
citation: {
component: Citation,
},
},
};
const isSummaryArticle =
section.message.query.startsWith('Summary: ') &&
section.message.articleTitle;
const summaryUrl = isSummaryArticle
? section.message.query.slice(9)
: undefined;
return (
<div className="space-y-6">
<div className="w-full pt-8 break-words lg:max-w-[60%]">
{isSummaryArticle ? (
<div className="space-y-1 min-w-0 overflow-hidden">
<h2 className="text-black dark:text-white font-medium text-3xl">
{section.message.articleTitle}
</h2>
<a
href={summaryUrl}
target="_blank"
rel="noopener noreferrer"
className="block text-sm text-black/60 dark:text-white/60 hover:text-black/80 dark:hover:text-white/80 truncate min-w-0"
title={summaryUrl}
>
{summaryUrl}
</a>
</div>
) : (
<h2 className="text-black dark:text-white font-medium text-3xl">
{section.message.query}
</h2>
)}
</div>
<div className="flex flex-col space-y-9 lg:space-y-0 lg:flex-row lg:justify-between lg:space-x-9">
<div
ref={dividerRef}
className="flex flex-col space-y-6 w-full lg:w-9/12"
>
{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">
{t('chat.sources')}
</h3>
</div>
<MessageSources sources={sources} />
</div>
)}
{section.message.responseBlocks
.filter(
(block): block is ResearchBlock =>
block.type === 'research' && block.data.subSteps.length > 0,
)
.map((researchBlock) => (
<div key={researchBlock.id} className="flex flex-col space-y-2">
<AssistantSteps
block={researchBlock}
status={section.message.status}
isLast={isLast}
/>
</div>
))}
{isLast &&
loading &&
!researchEnded &&
!section.message.responseBlocks.some(
(b) => b.type === 'research' && b.data.subSteps.length > 0,
) && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200">
<Disc3 className="w-4 h-4 text-black dark:text-white" />
<span className="text-sm text-black/70 dark:text-white/70">
{t('chat.brainstorming')}
</span>
</div>
)}
{section.widgets.length > 0 && <Renderer widgets={section.widgets} />}
<div className="flex flex-col space-y-2">
{sources.length > 0 && (
<div className="flex flex-row items-center space-x-2">
<Disc3 className="text-black dark:text-white" size={20} />
<h3 className="text-black dark:text-white font-medium text-xl">
{t('chat.answer')}
</h3>
</div>
)}
{!hasContent && sources.length > 0 && isLast && loading && (
<p className="text-sm text-black/60 dark:text-white/60 italic">
{t('chat.formingAnswer')}
</p>
)}
{!hasContent && sources.length > 0 && isLast && !loading && (
<p className="text-sm text-black/60 dark:text-white/60 italic">
{t('chat.answerFailed')}
</p>
)}
{hasContent && (
<>
<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">
<div className="flex flex-row items-center -ml-2">
<Rewrite
rewrite={rewrite}
messageId={section.message.messageId}
/>
</div>
<div className="flex flex-row items-center -mr-2">
<Copy initialMessage={parsedMessage} section={section} />
<button
onClick={() => {
if (speechStatus === 'started') {
stop();
} else {
start();
}
}}
className="p-2 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"
>
{speechStatus === 'started' ? (
<StopCircle size={16} />
) : (
<Volume2 size={16} />
)}
</button>
</div>
</div>
)}
{isLast &&
section.suggestions &&
section.suggestions.length > 0 &&
hasContent &&
!loading && (
<div className="mt-6">
<div className="flex flex-row items-center space-x-2 mb-4">
<Layers3
className="text-black dark:text-white"
size={20}
/>
<h3 className="text-black dark:text-white font-medium text-xl">
{t('chat.related')}
</h3>
</div>
<div className="space-y-0">
{section.suggestions.map(
(suggestion: string, i: number) => (
<div key={i}>
<div className="h-px bg-light-200/40 dark:bg-dark-200/40" />
<button
onClick={() => sendMessage(suggestion)}
className="group w-full py-4 text-left transition-colors duration-200"
>
<div className="flex items-center justify-between gap-3">
<div className="flex flex-row space-x-3 items-center">
<CornerDownRight
size={15}
className="group-hover:text-[#EA580C] transition-colors duration-200 flex-shrink-0"
/>
<p className="text-sm text-black/70 dark:text-white/70 group-hover:text-[#EA580C] transition-colors duration-200 leading-relaxed">
{suggestion}
</p>
</div>
<Plus
size={16}
className="text-black/40 dark:text-white/40 group-hover:text-[#EA580C] transition-colors duration-200 flex-shrink-0"
/>
</div>
</button>
</div>
),
)}
</div>
</div>
)}
</>
)}
</div>
</div>
{hasContent && (
<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={section.message.query}
chatHistory={chatHistory}
messageId={section.message.messageId}
/>
<SearchVideos
chatHistory={chatHistory}
query={section.message.query}
messageId={section.message.messageId}
/>
</div>
)}
</div>
</div>
);
};
export default MessageBox;

View File

@@ -0,0 +1,11 @@
const MessageBoxLoading = () => {
return (
<div className="flex flex-col space-y-2 w-full lg:w-9/12 bg-light-primary dark:bg-dark-primary animate-pulse rounded-lg py-3">
<div className="h-2 rounded-full w-full bg-light-secondary dark:bg-dark-secondary" />
<div className="h-2 rounded-full w-9/12 bg-light-secondary dark:bg-dark-secondary" />
<div className="h-2 rounded-full w-10/12 bg-light-secondary dark:bg-dark-secondary" />
</div>
);
};
export default MessageBoxLoading;

View File

@@ -0,0 +1,104 @@
import { cn } from '@/lib/utils';
import { ArrowUp } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import AttachSmall from './MessageInputActions/AttachSmall';
import { useChat } from '@/lib/hooks/useChat';
import { useTranslation } from '@/lib/localization/context';
const MessageInput = () => {
const { loading, sendMessage } = useChat();
const { t } = useTranslation();
const [copilotEnabled, setCopilotEnabled] = useState(false);
const [message, setMessage] = useState('');
const [textareaRows, setTextareaRows] = useState(1);
const [mode, setMode] = useState<'multi' | 'single'>('single');
useEffect(() => {
if (textareaRows >= 2 && message && mode === 'single') {
setMode('multi');
} else if (!message && mode === 'multi') {
setMode('single');
}
}, [textareaRows, mode, message]);
const inputRef = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const activeElement = document.activeElement;
const isInputFocused =
activeElement?.tagName === 'INPUT' ||
activeElement?.tagName === 'TEXTAREA' ||
activeElement?.hasAttribute('contenteditable');
if (e.key === '/' && !isInputFocused) {
e.preventDefault();
inputRef.current?.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, []);
return (
<form
onSubmit={(e) => {
if (loading) return;
e.preventDefault();
sendMessage(message);
setMessage('');
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !loading) {
e.preventDefault();
sendMessage(message);
setMessage('');
}
}}
className={cn(
'relative bg-light-secondary dark:bg-dark-secondary p-4 flex items-center overflow-visible border border-light-200 dark:border-dark-200 shadow-sm shadow-light-200/10 dark:shadow-black/20 transition-all duration-200 focus-within:border-light-300 dark:focus-within:border-dark-300',
mode === 'multi' ? 'flex-col rounded-2xl' : 'flex-row rounded-full',
)}
>
{mode === 'single' && <AttachSmall />}
<TextareaAutosize
ref={inputRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
onHeightChange={(height, props) => {
setTextareaRows(Math.ceil(height / props.rowHeight));
}}
className="transition bg-transparent dark:placeholder:text-white/50 placeholder:text-sm text-sm dark:text-white resize-none focus:outline-none w-full px-2 max-h-24 lg:max-h-36 xl:max-h-48 flex-grow flex-shrink"
placeholder={t('input.askFollowUp')}
/>
{mode === 'single' && (
<button
disabled={message.trim().length === 0 || loading}
className="bg-[#EA580C] text-white disabled:text-black/50 dark:disabled:text-white/50 hover:bg-opacity-85 transition duration-100 disabled:bg-[#e0e0dc79] dark:disabled:bg-[#ececec21] rounded-full p-2"
>
<ArrowUp className="bg-background" size={17} />
</button>
)}
{mode === 'multi' && (
<div className="flex flex-row items-center justify-between w-full pt-2">
<AttachSmall />
<button
disabled={message.trim().length === 0 || loading}
className="bg-[#EA580C] text-white disabled:text-black/50 dark:disabled:text-white/50 hover:bg-opacity-85 transition duration-100 disabled:bg-[#e0e0dc79] dark:disabled:bg-[#ececec21] rounded-full p-2"
>
<ArrowUp className="bg-background" size={17} />
</button>
</div>
)}
</form>
);
};
export default MessageInput;

View File

@@ -0,0 +1,82 @@
import { ChevronDown, Globe, Plane, TrendingUp, BookOpen, PenLine } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Popover,
PopoverButton,
PopoverPanel,
} from '@headlessui/react';
import { useChat } from '@/lib/hooks/useChat';
import { AnimatePresence, motion } from 'motion/react';
type AnswerModeKey = 'standard' | 'focus' | 'academic' | 'writing' | 'travel' | 'finance';
const AnswerModes: { key: AnswerModeKey; title: string; icon: React.ReactNode }[] = [
{ key: 'standard', title: 'Standard', icon: <Globe size={16} className="text-[#EA580C]" /> },
{ key: 'travel', title: 'Travel', icon: <Plane size={16} className="text-[#EA580C]" /> },
{ key: 'finance', title: 'Finance', icon: <TrendingUp size={16} className="text-[#EA580C]" /> },
{ key: 'academic', title: 'Academic', icon: <BookOpen size={16} className="text-[#EA580C]" /> },
{ key: 'writing', title: 'Writing', icon: <PenLine size={16} className="text-[#EA580C]" /> },
{ key: 'focus', title: 'Focus', icon: <Globe size={16} className="text-[#EA580C]" /> },
];
const AnswerMode = () => {
const { answerMode, setAnswerMode } = useChat();
const current = AnswerModes.find((m) => m.key === answerMode) ?? AnswerModes[0];
return (
<Popover className="relative">
{({ open }) => (
<>
<PopoverButton
type="button"
className="p-2 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95 transition duration-200 hover:text-black dark:hover:text-white focus:outline-none"
title="Answer mode"
>
<div className="flex flex-row items-center space-x-1">
{current.icon}
<span className="text-xs hidden sm:inline">{current.title}</span>
<ChevronDown
size={14}
className={cn(open ? 'rotate-180' : 'rotate-0', 'transition')}
/>
</div>
</PopoverButton>
<AnimatePresence>
{open && (
<PopoverPanel
className="absolute z-10 w-48 left-0 bottom-full mb-2"
static
>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.1, ease: 'easeOut' }}
className="origin-bottom-left flex flex-col bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 p-2"
>
{AnswerModes.map((mode) => (
<PopoverButton
key={mode.key}
onClick={() => setAnswerMode(mode.key)}
className={cn(
'p-2 rounded-lg flex flex-row items-center gap-2 text-start cursor-pointer transition focus:outline-none',
answerMode === mode.key
? 'bg-light-secondary dark:bg-dark-secondary'
: 'hover:bg-light-secondary dark:hover:bg-dark-secondary',
)}
>
{mode.icon}
<span className="text-sm font-medium">{mode.title}</span>
</PopoverButton>
))}
</motion.div>
</PopoverPanel>
)}
</AnimatePresence>
</>
)}
</Popover>
);
};
export default AnswerMode;

View File

@@ -0,0 +1,218 @@
import { cn } from '@/lib/utils';
import {
Popover,
PopoverButton,
PopoverPanel,
Transition,
} from '@headlessui/react';
import {
CopyPlus,
File,
Link,
Paperclip,
Plus,
Trash,
} from 'lucide-react';
import { Fragment, useRef, useState } from 'react';
import { toast } from 'sonner';
import { useChat } from '@/lib/hooks/useChat';
import { AnimatePresence } from 'motion/react';
import { motion } from 'framer-motion';
const UPLOAD_TIMEOUT_MS = 300000; // 5 min — docs/architecture: 05-gaps-and-best-practices.md §8
const Attach = () => {
const { files, setFiles, setFileIds, fileIds, embeddingModelProvider } = useChat();
if (!embeddingModelProvider?.providerId) return null;
const [loading, setLoading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const abortRef = useRef<AbortController | null>(null);
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const fileList = e.target.files;
if (!fileList?.length) return;
const controller = new AbortController();
abortRef.current = controller;
const timeoutId = setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS);
setLoading(true);
setUploadError(null);
const data = new FormData();
for (let i = 0; i < fileList.length; i++) {
data.append('files', fileList[i]);
}
const provider = localStorage.getItem('embeddingModelProviderId');
const model = localStorage.getItem('embeddingModelKey');
data.append('embedding_model_provider_id', provider!);
data.append('embedding_model_key', model!);
try {
const res = await fetch(`/api/uploads`, {
method: 'POST',
body: data,
signal: controller.signal,
});
const resData = await res.json();
if (!res.ok) {
throw new Error(resData.error ?? 'Upload failed');
}
setFiles([...files, ...(resData.files ?? [])]);
setFileIds([...fileIds, ...(resData.files ?? []).map((f: { fileId: string }) => f.fileId)]);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Upload failed';
const isAbort = (err as Error)?.name === 'AbortError';
setUploadError(isAbort ? 'Upload cancelled' : msg);
if (!isAbort) toast.error(msg);
} finally {
clearTimeout(timeoutId);
setLoading(false);
abortRef.current = null;
e.target.value = '';
}
};
const handleCancel = () => {
if (abortRef.current) {
abortRef.current.abort();
}
};
return loading ? (
<div className="flex items-center gap-2 p-2 rounded-lg text-black/50 dark:text-white/50 text-xs">
<span className="text-[#EA580C]">Uploading</span>
<button
type="button"
onClick={handleCancel}
className="text-red-500 hover:underline focus:outline-none"
>
Cancel
</button>
</div>
) : uploadError ? (
<div className="p-2 rounded-lg text-xs text-red-500">
{uploadError}
<button
type="button"
onClick={() => setUploadError(null)}
className="ml-2 underline hover:no-underline"
>
Dismiss
</button>
</div>
) : files.length > 0 ? (
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
{({ open }) => (
<>
<PopoverButton
type="button"
className="active:border-none hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg focus:outline-none headless-open:text-black dark:headless-open:text-white text-black/50 dark:text-white/50 active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
>
<File size={16} className="text-[#EA580C]" />
</PopoverButton>
<AnimatePresence>
{open && (
<PopoverPanel
className="absolute z-10 w-64 md:w-[350px] right-0 bottom-full mb-2"
static
>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.1, ease: 'easeOut' }}
className="origin-bottom-right bg-light-primary dark:bg-dark-primary border rounded-md border-light-200 dark:border-dark-200 w-full max-h-[200px] md:max-h-none overflow-y-auto flex flex-col"
>
<div className="flex flex-row items-center justify-between px-3 py-2">
<h4 className="text-black/70 dark:text-white/70 text-sm">
Attached files
</h4>
<div className="flex flex-row items-center space-x-4">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200 focus:outline-none"
>
<input
type="file"
onChange={handleChange}
ref={fileInputRef}
accept=".pdf,.docx,.txt"
multiple
hidden
/>
<Plus size={16} />
<p className="text-xs">Add</p>
</button>
<button
onClick={() => {
setFiles([]);
setFileIds([]);
}}
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200 focus:outline-none"
>
<Trash size={13} />
<p className="text-xs">Clear</p>
</button>
</div>
</div>
<div className="h-[0.5px] mx-2 bg-white/10" />
<div className="flex flex-col items-center">
{files.map((file, i) => (
<div
key={i}
className="flex flex-row items-center justify-start w-full space-x-3 p-3"
>
<div className="bg-light-100 dark:bg-dark-100 flex items-center justify-center w-9 h-9 rounded-md">
<File
size={16}
className="text-black/70 dark:text-white/70"
/>
</div>
<p className="text-black/70 dark:text-white/70 text-xs">
{file.fileName.length > 25
? file.fileName
.replace(/\.\w+$/, '')
.substring(0, 25) +
'...' +
file.fileExtension
: file.fileName}
</p>
</div>
))}
</div>
</motion.div>
</PopoverPanel>
)}
</AnimatePresence>
</>
)}
</Popover>
) : (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className={cn(
'flex items-center justify-center active:border-none hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg focus:outline-none headless-open:text-black dark:headless-open:text-white text-black/50 dark:text-white/50 active:scale-95 transition duration-200 hover:text-black dark:hover:text-white',
)}
>
<input
type="file"
onChange={handleChange}
ref={fileInputRef}
accept=".pdf,.docx,.txt"
multiple
hidden
/>
<Paperclip size={16} />
</button>
);
};
export default Attach;

View File

@@ -0,0 +1,186 @@
import {
Popover,
PopoverButton,
PopoverPanel,
Transition,
} from '@headlessui/react';
import { File, Paperclip, Plus, Trash } from 'lucide-react';
import { Fragment, useRef, useState } from 'react';
import { useChat } from '@/lib/hooks/useChat';
import { AnimatePresence } from 'motion/react';
import { motion } from 'framer-motion';
const UPLOAD_TIMEOUT_MS = 300000;
const AttachSmall = () => {
const { files, setFiles, setFileIds, fileIds, embeddingModelProvider } = useChat();
if (!embeddingModelProvider?.providerId) return null;
const [loading, setLoading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const abortRef = useRef<AbortController | null>(null);
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const fileList = e.target.files;
if (!fileList?.length) return;
const controller = new AbortController();
abortRef.current = controller;
const timeoutId = setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS);
setLoading(true);
setUploadError(null);
const data = new FormData();
for (let i = 0; i < fileList.length; i++) {
data.append('files', fileList[i]);
}
const provider = localStorage.getItem('embeddingModelProviderId');
const model = localStorage.getItem('embeddingModelKey');
data.append('embedding_model_provider_id', provider!);
data.append('embedding_model_key', model!);
try {
const res = await fetch(`/api/uploads`, {
method: 'POST',
body: data,
signal: controller.signal,
});
const resData = await res.json();
if (!res.ok) throw new Error(resData.error ?? 'Upload failed');
setFiles([...files, ...(resData.files ?? [])]);
setFileIds([...fileIds, ...(resData.files ?? []).map((f: { fileId: string }) => f.fileId)]);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Upload failed';
const isAbort = (err as Error)?.name === 'AbortError';
setUploadError(isAbort ? 'Cancelled' : msg);
} finally {
clearTimeout(timeoutId);
setLoading(false);
abortRef.current = null;
e.target.value = '';
}
};
return loading ? (
<div className="flex flex-row items-center space-x-2 p-1 text-xs text-[#EA580C]">
Uploading
</div>
) : uploadError ? (
<div className="p-1 text-xs text-red-500">
{uploadError}
<button type="button" onClick={() => setUploadError(null)} className="ml-1 underline">
Dismiss
</button>
</div>
) : files.length > 0 ? (
<Popover className="max-w-[15rem] md:max-w-md lg:max-w-lg">
{({ open }) => (
<>
<PopoverButton
type="button"
className="flex flex-row items-center justify-between space-x-1 p-1 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
>
<File size={20} className="text-[#EA580C]" />
</PopoverButton>
<AnimatePresence>
{open && (
<PopoverPanel
className="absolute z-10 w-64 md:w-[350px] right-0 bottom-full mb-2"
static
>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.1, ease: 'easeOut' }}
className="origin-bottom-right bg-light-primary dark:bg-dark-primary border rounded-md border-light-200 dark:border-dark-200 w-full max-h-[200px] md:max-h-none overflow-y-auto flex flex-col"
>
<div className="flex flex-row items-center justify-between px-3 py-2">
<h4 className="text-black/70 dark:text-white/70 font-medium text-sm">
Attached files
</h4>
<div className="flex flex-row items-center space-x-4">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200"
>
<input
type="file"
onChange={handleChange}
ref={fileInputRef}
accept=".pdf,.docx,.txt"
multiple
hidden
/>
<Plus size={16} />
<p className="text-xs">Add</p>
</button>
<button
onClick={() => {
setFiles([]);
setFileIds([]);
}}
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200"
>
<Trash size={13} />
<p className="text-xs">Clear</p>
</button>
</div>
</div>
<div className="h-[0.5px] mx-2 bg-white/10" />
<div className="flex flex-col items-center">
{files.map((file, i) => (
<div
key={i}
className="flex flex-row items-center justify-start w-full space-x-3 p-3"
>
<div className="bg-light-100 dark:bg-dark-100 flex items-center justify-center w-9 h-9 rounded-md">
<File
size={16}
className="text-black/70 dark:text-white/70"
/>
</div>
<p className="text-black/70 dark:text-white/70 text-xs">
{file.fileName.length > 25
? file.fileName
.replace(/\.\w+$/, '')
.substring(0, 25) +
'...' +
file.fileExtension
: file.fileName}
</p>
</div>
))}
</div>
</motion.div>
</PopoverPanel>
)}
</AnimatePresence>
</>
)}
</Popover>
) : (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex flex-row items-center space-x-1 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white p-1"
>
<input
type="file"
onChange={handleChange}
ref={fileInputRef}
accept=".pdf,.docx,.txt"
multiple
hidden
/>
<Paperclip size={16} />
</button>
);
};
export default AttachSmall;

View File

@@ -0,0 +1,209 @@
'use client';
import { Cpu, Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
import { useEffect, useMemo, useState } from 'react';
import type { MinimalProvider } from '@/lib/types-ui';
import { useChat } from '@/lib/hooks/useChat';
import { AnimatePresence, motion } from 'motion/react';
const ModelSelector = () => {
const [providers, setProviders] = useState<MinimalProvider[]>([]);
const [envOnlyMode, setEnvOnlyMode] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const { setChatModelProvider, chatModelProvider } = useChat();
useEffect(() => {
const loadProviders = async () => {
try {
setIsLoading(true);
const res = await fetch('/api/providers');
if (!res.ok) {
throw new Error('Failed to fetch providers');
}
const data: { providers: MinimalProvider[]; envOnlyMode?: boolean } = await res.json();
setProviders(data.providers);
setEnvOnlyMode(data.envOnlyMode ?? false);
} catch (error) {
console.error('Error loading providers:', error);
} finally {
setIsLoading(false);
}
};
loadProviders();
}, []);
const orderedProviders = useMemo(() => {
if (!chatModelProvider?.providerId) return providers;
const currentProviderIndex = providers.findIndex(
(p) => p.id === chatModelProvider.providerId,
);
if (currentProviderIndex === -1) {
return providers;
}
const selectedProvider = providers[currentProviderIndex];
const remainingProviders = providers.filter(
(_, index) => index !== currentProviderIndex,
);
return [selectedProvider, ...remainingProviders];
}, [providers, chatModelProvider]);
const handleModelSelect = (providerId: string, modelKey: string) => {
setChatModelProvider({ providerId, key: modelKey });
localStorage.setItem('chatModelProviderId', providerId);
localStorage.setItem('chatModelKey', modelKey);
};
const filteredProviders = orderedProviders
.map((provider) => ({
...provider,
chatModels: provider.chatModels.filter(
(model) =>
model.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
provider.name.toLowerCase().includes(searchQuery.toLowerCase()),
),
}))
.filter((provider) => provider.chatModels.length > 0);
if (envOnlyMode) return null;
return (
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
{({ open }) => (
<>
<PopoverButton
type="button"
className="active:border-none hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg focus:outline-none headless-open:text-black dark:headless-open:text-white text-black/50 dark:text-white/50 active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
>
<Cpu size={16} className="text-[#EA580C]" />
</PopoverButton>
<AnimatePresence>
{open && (
<PopoverPanel
className="absolute z-10 w-[230px] sm:w-[270px] md:w-[300px] right-0 bottom-full mb-2"
static
>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.1, ease: 'easeOut' }}
className="origin-bottom-right bg-light-primary dark:bg-dark-primary max-h-[300px] sm:max-w-none border rounded-lg border-light-200 dark:border-dark-200 w-full flex flex-col shadow-lg overflow-hidden"
>
<div className="p-2 border-b border-light-200 dark:border-dark-200">
<div className="relative">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-black/40 dark:text-white/40"
/>
<input
type="text"
placeholder="Search models..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 pr-3 py-2 bg-light-secondary dark:bg-dark-secondary rounded-lg placeholder:text-xs placeholder:-translate-y-[1.5px] text-xs text-black dark:text-white placeholder:text-black/40 dark:placeholder:text-white/40 focus:outline-none border border-transparent transition duration-200"
/>
</div>
</div>
<div className="max-h-[320px] overflow-y-auto">
{isLoading ? (
<div className="flex flex-col gap-2 py-16 px-4">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="h-10 rounded-lg bg-light-200 dark:bg-dark-200 animate-pulse"
/>
))}
</div>
) : filteredProviders.length === 0 ? (
<div className="text-center py-16 px-4 text-black/60 dark:text-white/60 text-sm">
{searchQuery
? 'No models found'
: 'No chat models configured'}
</div>
) : (
<div className="flex flex-col">
{filteredProviders.map((provider, providerIndex) => (
<div key={provider.id}>
<div className="px-4 py-2.5 sticky top-0 bg-light-primary dark:bg-dark-primary border-b border-light-200/50 dark:border-dark-200/50">
<p className="text-xs text-black/50 dark:text-white/50 uppercase tracking-wider">
{provider.name}
</p>
</div>
<div className="flex flex-col px-2 py-2 space-y-0.5">
{provider.chatModels.map((model) => (
<button
key={model.key}
onClick={() =>
handleModelSelect(provider.id, model.key)
}
type="button"
className={cn(
'px-3 py-2 flex items-center justify-between text-start duration-200 cursor-pointer transition rounded-lg group',
chatModelProvider?.providerId ===
provider.id &&
chatModelProvider?.key === model.key
? 'bg-light-secondary dark:bg-dark-secondary'
: 'hover:bg-light-secondary dark:hover:bg-dark-secondary',
)}
>
<div className="flex items-center space-x-2.5 min-w-0 flex-1">
<Cpu
size={15}
className={cn(
'shrink-0',
chatModelProvider?.providerId ===
provider.id &&
chatModelProvider?.key === model.key
? 'text-[#EA580C]'
: 'text-black/50 dark:text-white/50 group-hover:text-black/70 group-hover:dark:text-white/70',
)}
/>
<p
className={cn(
'text-xs truncate',
chatModelProvider?.providerId ===
provider.id &&
chatModelProvider?.key === model.key
? 'text-[#EA580C] font-medium'
: 'text-black/70 dark:text-white/70 group-hover:text-black dark:group-hover:text-white',
)}
>
{model.name}
</p>
</div>
</button>
))}
</div>
{providerIndex < filteredProviders.length - 1 && (
<div className="h-px bg-light-200 dark:bg-dark-200" />
)}
</div>
))}
</div>
)}
</div>
</motion.div>
</PopoverPanel>
)}
</AnimatePresence>
</>
)}
</Popover>
);
};
export default ModelSelector;

View File

@@ -0,0 +1,194 @@
'use client';
/**
* Input bar «+» — меню: режимы, источники, Learn, Create, Model Council
* docs/architecture: 01-perplexity-analogue-design.md §2.2.A
*/
import { Plus, Zap, Sliders, Star, Globe, GraduationCap, Network, BookOpen, Users } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
import { useChat } from '@/lib/hooks/useChat';
import { AnimatePresence, motion } from 'motion/react';
import { useCallback, useEffect, useState } from 'react';
const MODES = [
{ key: 'speed', label: 'Quick', icon: Zap },
{ key: 'balanced', label: 'Pro', icon: Sliders },
{ key: 'quality', label: 'Deep', icon: Star },
] as const;
const SOURCES = [
{ key: 'web', label: 'Web', icon: Globe },
{ key: 'academic', label: 'Academic', icon: GraduationCap },
{ key: 'discussions', label: 'Social', icon: Network },
] as const;
const InputBarPlus = () => {
const { optimizationMode, setOptimizationMode, sources, setSources } = useChat();
const [learningMode, setLearningModeState] = useState(false);
const [modelCouncil, setModelCouncilState] = useState(false);
useEffect(() => {
setLearningModeState(typeof window !== 'undefined' && localStorage.getItem('learningMode') === 'true');
setModelCouncilState(typeof window !== 'undefined' && localStorage.getItem('modelCouncil') === 'true');
const handler = () => {
setLearningModeState(localStorage.getItem('learningMode') === 'true');
setModelCouncilState(localStorage.getItem('modelCouncil') === 'true');
};
window.addEventListener('client-config-changed', handler);
return () => window.removeEventListener('client-config-changed', handler);
}, []);
const setLearningMode = useCallback((v: boolean) => {
localStorage.setItem('learningMode', String(v));
setLearningModeState(v);
window.dispatchEvent(new Event('client-config-changed'));
}, []);
const setModelCouncil = useCallback((v: boolean) => {
localStorage.setItem('modelCouncil', String(v));
setModelCouncilState(v);
window.dispatchEvent(new Event('client-config-changed'));
}, []);
const toggleSource = useCallback(
(key: string) => {
if (sources.includes(key)) {
setSources(sources.filter((s) => s !== key));
} else {
setSources([...sources, key]);
}
},
[sources, setSources]
);
return (
<Popover className="relative">
{({ open }) => (
<>
<PopoverButton
type="button"
title="More options"
className={cn(
'p-2 rounded-xl transition duration-200 focus:outline-none',
'text-black/50 dark:text-white/50 hover:text-black dark:hover:text-white',
'hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95',
open && 'bg-light-secondary dark:bg-dark-secondary text-[#EA580C]'
)}
>
<Plus size={18} strokeWidth={2.5} />
</PopoverButton>
<AnimatePresence>
{open && (
<PopoverPanel
static
className="absolute z-20 w-72 left-0 bottom-full mb-2"
>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.12 }}
className="origin-bottom-left rounded-xl border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary shadow-xl overflow-hidden"
>
<div className="p-2 border-b border-light-200/50 dark:border-dark-200/50">
<p className="text-xs font-medium text-black/60 dark:text-white/60 px-2 py-1">Mode</p>
<div className="flex gap-1">
{MODES.map((m) => {
const Icon = m.icon;
const isActive = optimizationMode === m.key;
return (
<button
key={m.key}
type="button"
onClick={() => setOptimizationMode(m.key)}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-sm transition',
isActive
? 'bg-[#EA580C]/20 text-[#EA580C]'
: 'hover:bg-light-200/50 dark:hover:bg-dark-200/50'
)}
>
<Icon size={14} />
{m.label}
</button>
);
})}
</div>
</div>
<div className="p-2 border-b border-light-200/50 dark:border-dark-200/50">
<p className="text-xs font-medium text-black/60 dark:text-white/60 px-2 py-1">Sources</p>
<div className="flex flex-wrap gap-1">
{SOURCES.map((s) => {
const Icon = s.icon;
const checked = sources.includes(s.key);
return (
<button
key={s.key}
type="button"
onClick={() => toggleSource(s.key)}
className={cn(
'flex items-center gap-1.5 py-1.5 px-2.5 rounded-lg text-sm transition',
checked
? 'bg-[#EA580C]/20 text-[#EA580C]'
: 'hover:bg-light-200/50 dark:hover:bg-dark-200/50'
)}
>
<Icon size={14} />
{s.label}
</button>
);
})}
</div>
</div>
<div className="p-2 border-b border-light-200/50 dark:border-dark-200/50">
<button
type="button"
onClick={() => setLearningMode(!learningMode)}
className={cn(
'w-full flex items-center gap-2 py-2 px-3 rounded-lg text-sm transition',
learningMode ? 'bg-[#EA580C]/20 text-[#EA580C]' : 'hover:bg-light-200/50 dark:hover:bg-dark-200/50'
)}
>
<BookOpen size={16} />
Step-by-step Learning
{learningMode && <span className="ml-auto text-xs">On</span>}
</button>
</div>
<div className="p-2 border-b border-light-200/50 dark:border-dark-200/50">
<button
type="button"
onClick={() => setModelCouncil(!modelCouncil)}
className={cn(
'w-full flex items-center gap-2 py-2 px-3 rounded-lg text-sm transition',
modelCouncil ? 'bg-[#EA580C]/20 text-[#EA580C]' : 'hover:bg-light-200/50 dark:hover:bg-dark-200/50'
)}
title="Model Council (Max): 3 models in parallel → synthesis"
>
<Users size={16} />
Model Council
{modelCouncil && <span className="ml-auto text-xs">On</span>}
</button>
</div>
<div className="p-2">
<p className="text-xs font-medium text-black/60 dark:text-white/60 px-2 py-1">Create</p>
<p className="text-xs text-black/50 dark:text-white/50 px-2 pb-2">
Ask in chat: &quot;Create a table about...&quot; or &quot;Generate an image of...&quot;
</p>
</div>
</motion.div>
</PopoverPanel>
)}
</AnimatePresence>
</>
)}
</Popover>
);
};
export default InputBarPlus;

View File

@@ -0,0 +1,114 @@
import { ChevronDown, Sliders, Star, Zap } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Popover,
PopoverButton,
PopoverPanel,
Transition,
} from '@headlessui/react';
import { Fragment } from 'react';
import { useChat } from '@/lib/hooks/useChat';
import { AnimatePresence, motion } from 'motion/react';
const OptimizationModes = [
{
key: 'speed',
title: 'Speed',
description: 'Prioritize speed and get the quickest possible answer.',
icon: <Zap size={16} className="text-[#EA580C]" />,
},
{
key: 'balanced',
title: 'Balanced',
description: 'Find the right balance between speed and accuracy',
icon: <Sliders size={16} className="text-[#EA580C]" />,
},
{
key: 'quality',
title: 'Quality',
description: 'Get the most thorough and accurate answer',
icon: (
<Star
size={16}
className="text-[#EA580C] fill-[#EA580C]"
/>
),
},
];
const Optimization = () => {
const { optimizationMode, setOptimizationMode } = useChat();
return (
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
{({ open }) => (
<>
<PopoverButton
type="button"
className="p-2 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95 transition duration-200 hover:text-black dark:hover:text-white focus:outline-none"
>
<div className="flex flex-row items-center space-x-1">
{
OptimizationModes.find((mode) => mode.key === optimizationMode)
?.icon
}
<ChevronDown
size={16}
className={cn(
open ? 'rotate-180' : 'rotate-0',
'transition duration:200',
)}
/>
</div>
</PopoverButton>
<AnimatePresence>
{open && (
<PopoverPanel
className="absolute z-10 w-64 md:w-[250px] left-0 bottom-full mb-2"
static
>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.1, ease: 'easeOut' }}
className="origin-bottom-left flex flex-col space-y-2 bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 w-full p-2 max-h-[200px] md:max-h-none overflow-y-auto"
>
{OptimizationModes.map((mode, i) => (
<PopoverButton
onClick={() => setOptimizationMode(mode.key)}
key={i}
className={cn(
'p-2 rounded-lg flex flex-col items-start justify-start text-start space-y-1 duration-200 cursor-pointer transition focus:outline-none',
optimizationMode === mode.key
? 'bg-light-secondary dark:bg-dark-secondary'
: 'hover:bg-light-secondary dark:hover:bg-dark-secondary',
)}
>
<div className="flex flex-row justify-between w-full text-black dark:text-white">
<div className="flex flex-row space-x-1">
{mode.icon}
<p className="text-xs font-medium">{mode.title}</p>
</div>
{mode.key === 'quality' && (
<span className="bg-[#EA580C]/70 dark:bg-[#EA580C]/40 border border-[#EA580C] px-1 rounded-full text-[10px] text-white">
Beta
</span>
)}
</div>
<p className="text-black/70 dark:text-white/70 text-xs">
{mode.description}
</p>
</PopoverButton>
))}
</motion.div>
</PopoverPanel>
)}
</AnimatePresence>
</>
)}
</Popover>
);
};
export default Optimization;

View File

@@ -0,0 +1,93 @@
import { useChat } from '@/lib/hooks/useChat';
import {
Popover,
PopoverButton,
PopoverPanel,
Switch,
} from '@headlessui/react';
import {
GlobeIcon,
GraduationCapIcon,
NetworkIcon,
} from '@phosphor-icons/react';
import { AnimatePresence, motion } from 'motion/react';
const sourcesList = [
{
name: 'Web',
key: 'web',
icon: <GlobeIcon className="h-[16px] w-auto" />,
},
{
name: 'Academic',
key: 'academic',
icon: <GraduationCapIcon className="h-[16px] w-auto" />,
},
{
name: 'Social',
key: 'discussions',
icon: <NetworkIcon className="h-[16px] w-auto" />,
},
];
const Sources = () => {
const { sources, setSources } = useChat();
return (
<Popover className="relative">
{({ open }) => (
<>
<PopoverButton className="flex items-center justify-center active:border-none hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg focus:outline-none text-black/50 dark:text-white/50 active:scale-95 transition duration-200 hover:text-black dark:hover:text-white">
<GlobeIcon className="h-[18px] w-auto" />
</PopoverButton>
<AnimatePresence>
{open && (
<PopoverPanel
static
className="absolute z-10 w-64 md:w-[225px] right-0 bottom-full mb-2"
>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.1, ease: 'easeOut' }}
className="origin-bottom-right flex flex-col bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 w-full p-1 max-h-[200px] md:max-h-none overflow-y-auto shadow-lg"
>
{sourcesList.map((source, i) => (
<div
key={i}
className="flex flex-row justify-between hover:bg-light-100 hover:dark:bg-dark-100 rounded-md py-3 px-2 cursor-pointer"
onClick={() => {
if (!sources.includes(source.key)) {
setSources([...sources, source.key]);
} else {
setSources(sources.filter((s) => s !== source.key));
}
}}
>
<div className="flex flex-row space-x-1.5 text-black/80 dark:text-white/80">
{source.icon}
<p className="text-xs">{source.name}</p>
</div>
<Switch
checked={sources.includes(source.key)}
className="group relative flex h-4 w-7 shrink-0 cursor-pointer rounded-full bg-light-200 dark:bg-white/10 p-0.5 duration-200 ease-in-out focus:outline-none transition-colors disabled:opacity-60 disabled:cursor-not-allowed data-[checked]:bg-[#EA580C] dark:data-[checked]:bg-[#EA580C]"
>
<span
aria-hidden="true"
className="pointer-events-none inline-block size-3 translate-x-[1px] group-data-[checked]:translate-x-3 rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out"
/>
</Switch>
</div>
))}
</motion.div>
</PopoverPanel>
)}
</AnimatePresence>
</>
)}
</Popover>
);
};
export default Sources;

View File

@@ -0,0 +1,19 @@
const Citation = ({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) => {
return (
<a
href={href}
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"
>
{children}
</a>
);
};
export default Citation;

View File

@@ -0,0 +1,102 @@
import type { CSSProperties } from 'react';
const darkTheme = {
'hljs-comment': {
color: '#8b949e',
},
'hljs-quote': {
color: '#8b949e',
},
'hljs-variable': {
color: '#ff7b72',
},
'hljs-template-variable': {
color: '#ff7b72',
},
'hljs-tag': {
color: '#ff7b72',
},
'hljs-name': {
color: '#ff7b72',
},
'hljs-selector-id': {
color: '#ff7b72',
},
'hljs-selector-class': {
color: '#ff7b72',
},
'hljs-regexp': {
color: '#ff7b72',
},
'hljs-deletion': {
color: '#ff7b72',
},
'hljs-number': {
color: '#f2cc60',
},
'hljs-built_in': {
color: '#f2cc60',
},
'hljs-builtin-name': {
color: '#f2cc60',
},
'hljs-literal': {
color: '#f2cc60',
},
'hljs-type': {
color: '#f2cc60',
},
'hljs-params': {
color: '#f2cc60',
},
'hljs-meta': {
color: '#f2cc60',
},
'hljs-link': {
color: '#f2cc60',
},
'hljs-attribute': {
color: '#58a6ff',
},
'hljs-string': {
color: '#7ee787',
},
'hljs-symbol': {
color: '#7ee787',
},
'hljs-bullet': {
color: '#7ee787',
},
'hljs-addition': {
color: '#7ee787',
},
'hljs-title': {
color: '#79c0ff',
},
'hljs-section': {
color: '#79c0ff',
},
'hljs-keyword': {
color: '#c297ff',
},
'hljs-selector-tag': {
color: '#c297ff',
},
hljs: {
display: 'block',
overflowX: 'auto',
background: '#0d1117',
color: '#c9d1d9',
padding: '0.75em',
border: '1px solid #21262d',
borderRadius: '10px',
},
'hljs-emphasis': {
fontStyle: 'italic',
},
'hljs-strong': {
fontWeight: 'bold',
},
} satisfies Record<string, CSSProperties>;
export default darkTheme;

View File

@@ -0,0 +1,102 @@
import type { CSSProperties } from 'react';
const lightTheme = {
'hljs-comment': {
color: '#6e7781',
},
'hljs-quote': {
color: '#6e7781',
},
'hljs-variable': {
color: '#d73a49',
},
'hljs-template-variable': {
color: '#d73a49',
},
'hljs-tag': {
color: '#d73a49',
},
'hljs-name': {
color: '#d73a49',
},
'hljs-selector-id': {
color: '#d73a49',
},
'hljs-selector-class': {
color: '#d73a49',
},
'hljs-regexp': {
color: '#d73a49',
},
'hljs-deletion': {
color: '#d73a49',
},
'hljs-number': {
color: '#b08800',
},
'hljs-built_in': {
color: '#b08800',
},
'hljs-builtin-name': {
color: '#b08800',
},
'hljs-literal': {
color: '#b08800',
},
'hljs-type': {
color: '#b08800',
},
'hljs-params': {
color: '#b08800',
},
'hljs-meta': {
color: '#b08800',
},
'hljs-link': {
color: '#b08800',
},
'hljs-attribute': {
color: '#0a64ae',
},
'hljs-string': {
color: '#22863a',
},
'hljs-symbol': {
color: '#22863a',
},
'hljs-bullet': {
color: '#22863a',
},
'hljs-addition': {
color: '#22863a',
},
'hljs-title': {
color: '#005cc5',
},
'hljs-section': {
color: '#005cc5',
},
'hljs-keyword': {
color: '#6f42c1',
},
'hljs-selector-tag': {
color: '#6f42c1',
},
hljs: {
display: 'block',
overflowX: 'auto',
background: '#ffffff',
color: '#24292f',
padding: '0.75em',
border: '1px solid #e8edf1',
borderRadius: '10px',
},
'hljs-emphasis': {
fontStyle: 'italic',
},
'hljs-strong': {
fontWeight: 'bold',
},
} satisfies Record<string, CSSProperties>;
export default lightTheme;

View File

@@ -0,0 +1,64 @@
'use client';
import { CheckIcon, CopyIcon } from '@phosphor-icons/react';
import React, { useEffect, useMemo, useState } from 'react';
import { useTheme } from 'next-themes';
import SyntaxHighlighter from 'react-syntax-highlighter';
import darkTheme from './CodeBlockDarkTheme';
import lightTheme from './CodeBlockLightTheme';
const CodeBlock = ({
language,
children,
}: {
language: string;
children: React.ReactNode;
}) => {
const { resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const syntaxTheme = useMemo(() => {
if (!mounted) return lightTheme;
return resolvedTheme === 'dark' ? darkTheme : lightTheme;
}, [mounted, resolvedTheme]);
return (
<div className="relative">
<button
className="absolute top-2 right-2 p-1"
onClick={() => {
navigator.clipboard.writeText(children as string);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
>
{copied ? (
<CheckIcon
size={16}
className="absolute top-2 right-2 text-black/70 dark:text-white/70"
/>
) : (
<CopyIcon
size={16}
className="absolute top-2 right-2 transition duration-200 text-black/70 dark:text-white/70 hover:text-gray-800/70 hover:dark:text-gray-300/70"
/>
)}
</button>
<SyntaxHighlighter
language={language}
style={syntaxTheme}
showInlineLineNumbers
>
{children as string}
</SyntaxHighlighter>
</div>
);
};
export default CodeBlock;

View File

@@ -0,0 +1,175 @@
/* eslint-disable @next/next/no-img-element */
import {
Dialog,
DialogPanel,
DialogTitle,
Transition,
TransitionChild,
} from '@headlessui/react';
import { File } from 'lucide-react';
import { Fragment, useState } from 'react';
import { Chunk } from '@/lib/types';
import { useTranslation } from '@/lib/localization/context';
const MessageSources = ({ sources }: { sources: Chunk[] }) => {
const { t } = useTranslation();
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 (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2">
{sources.slice(0, 3).map((source, i) => {
const url = source.metadata.url ?? '';
const title = source.metadata.title ?? '';
return (
<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"
key={i}
href={url || '#'}
target="_blank"
>
<p className="dark:text-white text-xs overflow-hidden whitespace-nowrap text-ellipsis">
{title}
</p>
<div className="flex flex-row items-center justify-between">
<div className="flex flex-row items-center space-x-1">
{url.includes('file_id://') ? (
<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=${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">
{url.includes('file_id://')
? t('chat.uploadedFile')
: 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>
);
})}
{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) => {
const u = source.metadata.url ?? '';
return u === '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=${u}`}
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">
{t('chat.viewMore').replace('{count}', String(sources.length - 3))}
</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">
{t('chat.sources')}
</DialogTitle>
<div className="grid grid-cols-2 gap-2 overflow-auto max-h-[300px] mt-2 pr-2">
{sources.map((source, i) => {
const u = source.metadata.url ?? '';
const tit = source.metadata.title ?? '';
return (
<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={u || '#'}
target="_blank"
>
<p className="dark:text-white text-xs overflow-hidden whitespace-nowrap text-ellipsis">
{tit}
</p>
<div className="flex flex-row items-center justify-between">
<div className="flex flex-row items-center space-x-1">
{u === '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=${u}`}
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">
{u.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>
);
};
export default MessageSources;

View File

@@ -0,0 +1,403 @@
import { Clock, Edit, Share, Trash, FileText, FileDown } from 'lucide-react';
import { Message } from './ChatWindow';
import { useEffect, useState, Fragment } from 'react';
import { formatTimeDifference } from '@/lib/utils';
import DeleteChat from './DeleteChat';
import {
Popover,
PopoverButton,
PopoverPanel,
Transition,
} from '@headlessui/react';
import { useChat, Section } from '@/lib/hooks/useChat';
import { SourceBlock } from '@/lib/types';
const downloadFile = (filename: string, content: string, type: string) => {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 0);
};
const serializeSections = (sections: Section[]) =>
sections.map((s) => ({
query: s.message.query,
createdAt: s.message.createdAt,
parsedTextBlocks: s.parsedTextBlocks,
responseBlocks: s.message.responseBlocks,
}));
const exportViaApi = async (
format: 'pdf' | 'md',
sections: Section[],
title: string,
chatId?: string
) => {
if (chatId) {
const token =
typeof window !== 'undefined'
? localStorage.getItem('auth_token') ?? localStorage.getItem('access_token')
: null;
const res = await fetch(
`/api/v1/library/threads/${encodeURIComponent(chatId)}/export?format=${format}`,
{
method: 'GET',
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
}
);
if (res.ok) {
const blob = await res.blob();
const disposition = res.headers.get('Content-Disposition');
const match = disposition?.match(/filename="?([^";]+)"?/);
const filename = match?.[1] ?? `${title || 'chat'}.${format}`;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 0);
return true;
}
}
const res = await fetch('/api/v1/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
format,
title: title || 'chat',
sections: serializeSections(sections),
}),
});
if (res.ok) {
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title || 'chat'}.${format}`;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 0);
return true;
}
return false;
};
const exportAsMarkdown = (sections: Section[], title: string) => {
const date = new Date(
sections[0].message.createdAt || Date.now(),
).toLocaleString();
let md = `# 💬 Chat Export: ${title}\n\n`;
md += `*Exported on: ${date}*\n\n---\n`;
sections.forEach((section, idx) => {
md += `\n---\n`;
md += `**🧑 User**
`;
md += `*${new Date(section.message.createdAt).toLocaleString()}*\n\n`;
md += `> ${section.message.query.replace(/\n/g, '\n> ')}\n`;
if (section.message.responseBlocks.length > 0) {
md += `\n---\n`;
md += `**🤖 Assistant**
`;
md += `*${new Date(section.message.createdAt).toLocaleString()}*\n\n`;
md += `> ${section.message.responseBlocks
.filter((b) => b.type === 'text')
.map((block) => block.data)
.join('\n')
.replace(/\n/g, '\n> ')}\n`;
}
const sourceResponseBlock = section.message.responseBlocks.find(
(block) => block.type === 'source',
) as SourceBlock | undefined;
if (
sourceResponseBlock &&
sourceResponseBlock.data &&
sourceResponseBlock.data.length > 0
) {
md += `\n**Citations:**\n`;
sourceResponseBlock.data.forEach((src: any, i: number) => {
const url = src.metadata?.url || '';
md += `- [${i + 1}] [${url}](${url})\n`;
});
}
});
md += '\n---\n';
downloadFile(`${title || 'chat'}.md`, md, 'text/markdown');
};
const exportAsPDF = async (sections: Section[], title: string) => {
const { default: jsPDF } = await import('jspdf');
const doc = new jsPDF();
const date = new Date(
sections[0]?.message?.createdAt || Date.now(),
).toLocaleString();
let y = 15;
const pageHeight = doc.internal.pageSize.height;
doc.setFontSize(18);
doc.text(`Chat Export: ${title}`, 10, y);
y += 8;
doc.setFontSize(11);
doc.setTextColor(100);
doc.text(`Exported on: ${date}`, 10, y);
y += 8;
doc.setDrawColor(200);
doc.line(10, y, 200, y);
y += 6;
doc.setTextColor(30);
sections.forEach((section, idx) => {
if (y > pageHeight - 30) {
doc.addPage();
y = 15;
}
doc.setFont('helvetica', 'bold');
doc.text('User', 10, y);
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.setTextColor(120);
doc.text(`${new Date(section.message.createdAt).toLocaleString()}`, 40, y);
y += 6;
doc.setTextColor(30);
doc.setFontSize(12);
const userLines = doc.splitTextToSize(section.message.query, 180);
for (let i = 0; i < userLines.length; i++) {
if (y > pageHeight - 20) {
doc.addPage();
y = 15;
}
doc.text(userLines[i], 12, y);
y += 6;
}
y += 6;
doc.setDrawColor(230);
if (y > pageHeight - 10) {
doc.addPage();
y = 15;
}
doc.line(10, y, 200, y);
y += 4;
if (section.message.responseBlocks.length > 0) {
if (y > pageHeight - 30) {
doc.addPage();
y = 15;
}
doc.setFont('helvetica', 'bold');
doc.text('Assistant', 10, y);
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.setTextColor(120);
doc.text(
`${new Date(section.message.createdAt).toLocaleString()}`,
40,
y,
);
y += 6;
doc.setTextColor(30);
doc.setFontSize(12);
const assistantLines = doc.splitTextToSize(
section.parsedTextBlocks.join('\n'),
180,
);
for (let i = 0; i < assistantLines.length; i++) {
if (y > pageHeight - 20) {
doc.addPage();
y = 15;
}
doc.text(assistantLines[i], 12, y);
y += 6;
}
const sourceResponseBlock = section.message.responseBlocks.find(
(block) => block.type === 'source',
) as SourceBlock | undefined;
if (
sourceResponseBlock &&
sourceResponseBlock.data &&
sourceResponseBlock.data.length > 0
) {
doc.setFontSize(11);
doc.setTextColor(80);
if (y > pageHeight - 20) {
doc.addPage();
y = 15;
}
doc.text('Citations:', 12, y);
y += 5;
sourceResponseBlock.data.forEach((src: any, i: number) => {
const url = src.metadata?.url || '';
if (y > pageHeight - 15) {
doc.addPage();
y = 15;
}
doc.text(`- [${i + 1}] ${url}`, 15, y);
y += 5;
});
doc.setTextColor(30);
}
y += 6;
doc.setDrawColor(230);
if (y > pageHeight - 10) {
doc.addPage();
y = 15;
}
doc.line(10, y, 200, y);
y += 4;
}
});
doc.save(`${title || 'chat'}.pdf`);
};
const Navbar = () => {
const [title, setTitle] = useState<string>('');
const [timeAgo, setTimeAgo] = useState<string>('');
const { sections, chatId } = useChat();
useEffect(() => {
if (sections.length > 0 && sections[0].message) {
const newTitle =
sections[0].message.query.length > 30
? `${sections[0].message.query.substring(0, 30).trim()}...`
: sections[0].message.query || 'New Conversation';
setTitle(newTitle);
const newTimeAgo = formatTimeDifference(
new Date(),
sections[0].message.createdAt,
);
setTimeAgo(newTimeAgo);
}
}, [sections]);
useEffect(() => {
const intervalId = setInterval(() => {
if (sections.length > 0 && sections[0].message) {
const newTimeAgo = formatTimeDifference(
new Date(),
sections[0].message.createdAt,
);
setTimeAgo(newTimeAgo);
}
}, 1000);
return () => clearInterval(intervalId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="sticky -mx-4 lg:mx-0 top-0 z-40 bg-light-primary/95 dark:bg-dark-primary/95 backdrop-blur-sm border-b border-light-200/50 dark:border-dark-200/30">
<div className="px-4 lg:px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center min-w-0">
<a
href="/"
className="lg:hidden mr-3 p-2 -ml-2 rounded-lg hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors duration-200"
>
<Edit size={18} className="text-black/70 dark:text-white/70" />
</a>
<div className="hidden lg:flex items-center gap-2 text-black/50 dark:text-white/50 min-w-0">
<Clock size={14} />
<span className="text-xs whitespace-nowrap">{timeAgo} ago</span>
</div>
</div>
<div className="flex-1 mx-4 min-w-0">
<h1 className="text-center text-sm font-medium text-black/80 dark:text-white/90 truncate">
{title || 'New Conversation'}
</h1>
</div>
<div className="flex items-center gap-1 min-w-0">
<Popover className="relative">
<PopoverButton className="p-2 rounded-lg hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors duration-200">
<Share size={16} className="text-black/60 dark:text-white/60" />
</PopoverButton>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<PopoverPanel className="absolute right-0 mt-2 w-64 origin-top-right rounded-2xl bg-light-primary dark:bg-dark-primary border border-light-200 dark:border-dark-200 shadow-xl shadow-black/10 dark:shadow-black/30 z-50">
<div className="p-3">
<div className="mb-2">
<p className="text-xs font-medium text-black/40 dark:text-white/40 uppercase tracking-wide">
Export Chat
</p>
</div>
<div className="space-y-1">
<button
className="w-full flex items-center gap-3 px-3 py-2 text-left rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors duration-200"
onClick={async () => {
const ok = await exportViaApi('md', sections, title || '', chatId);
if (!ok) exportAsMarkdown(sections, title || '');
}}
>
<FileText size={16} className="text-[#EA580C]" />
<div>
<p className="text-sm font-medium text-black dark:text-white">
Markdown
</p>
<p className="text-xs text-black/50 dark:text-white/50">
.md format
</p>
</div>
</button>
<button
className="w-full flex items-center gap-3 px-3 py-2 text-left rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors duration-200"
onClick={async () => {
const ok = await exportViaApi('pdf', sections, title || '', chatId);
if (!ok) void exportAsPDF(sections, title || '');
}}
>
<FileDown size={16} className="text-[#EA580C]" />
<div>
<p className="text-sm font-medium text-black dark:text-white">
PDF
</p>
<p className="text-xs text-black/50 dark:text-white/50">
Document format
</p>
</div>
</button>
</div>
</div>
</PopoverPanel>
</Transition>
</Popover>
<DeleteChat
redirect
chatId={chatId!}
chats={[]}
setChats={() => {}}
/>
</div>
</div>
</div>
</div>
);
};
export default Navbar;

View File

@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react';
interface Article {
title: string;
content: string;
url: string;
thumbnail: string;
}
const NewsArticleWidget = () => {
const [article, setArticle] = useState<Article | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
fetch('/api/v1/discover?mode=preview')
.then((res) => res.json())
.then((data) => {
const articles = (data.blogs || []).filter((a: Article) => a.thumbnail);
setArticle(articles[Math.floor(Math.random() * articles.length)]);
setLoading(false);
})
.catch(() => {
setError(true);
setLoading(false);
});
}, []);
return (
<div className="bg-light-secondary dark:bg-dark-secondary rounded-2xl border border-light-200 dark:border-dark-200 shadow-sm shadow-light-200/10 dark:shadow-black/25 flex flex-row items-stretch w-full h-20 min-h-[80px] max-h-[80px] p-0 overflow-hidden">
{loading ? (
<div className="animate-pulse flex flex-row items-stretch w-full h-full">
<div className="w-20 min-w-20 max-w-20 h-full bg-light-200 dark:bg-dark-200" />
<div className="flex flex-col justify-center flex-1 px-2.5 py-1.5 gap-1">
<div className="h-3 w-full max-w-[80%] rounded bg-light-200 dark:bg-dark-200" />
<div className="h-3 w-16 rounded bg-light-200 dark:bg-dark-200" />
</div>
</div>
) : error ? (
<div className="w-full text-xs text-red-400">Could not load news.</div>
) : article ? (
<a
href={`/?q=${encodeURIComponent(`Summary: ${article.url}`)}&title=${encodeURIComponent(article.title)}`}
className="flex flex-row items-stretch w-full h-full relative overflow-hidden group"
>
<div className="relative w-20 min-w-20 max-w-20 h-full overflow-hidden">
<img
className="object-cover w-full h-full bg-light-200 dark:bg-dark-200 group-hover:scale-110 transition-transform duration-300"
src={
new URL(article.thumbnail).origin +
new URL(article.thumbnail).pathname +
`?id=${new URL(article.thumbnail).searchParams.get('id')}`
}
alt={article.title}
/>
</div>
<div className="flex flex-col justify-center flex-1 px-2.5 py-1.5 min-w-0">
<div className="font-semibold text-[11px] text-black dark:text-white leading-tight line-clamp-2 mb-0.5">
{article.title}
</div>
<p className="text-black/60 dark:text-white/60 text-[9px] leading-relaxed line-clamp-2">
{article.content}
</p>
</div>
</a>
) : null}
</div>
);
};
export default NewsArticleWidget;

View File

@@ -0,0 +1,165 @@
/* eslint-disable @next/next/no-img-element */
import { ImagesIcon, PlusIcon } from 'lucide-react';
import { useState } from 'react';
import Lightbox from 'yet-another-react-lightbox';
import 'yet-another-react-lightbox/styles.css';
import { Message } from './ChatWindow';
import { useTranslation } from '@/lib/localization/context';
type Image = {
url: string;
img_src: string;
title: string;
};
const MEDIA_TIMEOUT_MS = 15000; // docs/architecture: 05-gaps-and-best-practices.md §8
const SearchImages = ({
query,
chatHistory,
messageId,
}: {
query: string;
chatHistory: [string, string][];
messageId: string;
}) => {
const { t } = useTranslation();
const [images, setImages] = useState<Image[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const [slides, setSlides] = useState<any[]>([]);
const searchImages = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/images`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
chatHistory,
chatModel: {
providerId: localStorage.getItem('chatModelProviderId'),
key: localStorage.getItem('chatModelKey'),
},
}),
signal: AbortSignal.timeout(MEDIA_TIMEOUT_MS),
});
const data = await res.json();
const imgs = data.images ?? [];
setImages(imgs);
setSlides(imgs.map((img: Image) => ({ src: img.img_src })));
} catch (err) {
const msg = err instanceof Error ? err.message : 'Search failed';
const isTimeout = msg.includes('timeout') || (err as Error)?.name === 'AbortError';
setError(isTimeout ? 'Request timed out. Try again.' : msg);
} finally {
setLoading(false);
}
};
return (
<>
{!loading && images === null && !error && (
<button
id={`search-images-${messageId}`}
onClick={searchImages}
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>{t('chat.searchImages')}</p>
</div>
<PlusIcon className="text-[#EA580C]" size={17} />
</button>
)}
{error && (
<div className="rounded-lg border border-red-500/20 dark:border-red-500/30 bg-light-secondary dark:bg-dark-secondary p-3 text-sm">
<p className="text-red-500 mb-2">{error}</p>
<button
type="button"
onClick={searchImages}
className="text-[#EA580C] hover:underline text-sm"
>
Retry
</button>
</div>
)}
{loading && (
<div className="grid grid-cols-2 gap-2">
{[...Array(4)].map((_, i) => (
<div
key={i}
className="bg-light-secondary dark:bg-dark-secondary h-32 w-full rounded-lg animate-pulse aspect-video object-cover"
/>
))}
</div>
)}
{images !== null && images.length > 0 && (
<>
<div className="grid grid-cols-2 gap-2">
{images.length > 4
? images.slice(0, 3).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.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>
<Lightbox open={open} close={() => setOpen(false)} slides={slides} />
</>
)}
</>
);
};
export default SearchImages;

View File

@@ -0,0 +1,240 @@
/* eslint-disable @next/next/no-img-element */
import { PlayCircle, PlayIcon, PlusIcon, VideoIcon } from 'lucide-react';
import { useRef, useState } from 'react';
import Lightbox, { GenericSlide, VideoSlide } from 'yet-another-react-lightbox';
import 'yet-another-react-lightbox/styles.css';
import { Message } from './ChatWindow';
import { useTranslation } from '@/lib/localization/context';
type Video = {
url: string;
img_src: string;
title: string;
iframe_src: string;
};
declare module 'yet-another-react-lightbox' {
export interface VideoSlide extends GenericSlide {
type: 'video-slide';
src: string;
iframe_src: string;
}
interface SlideTypes {
'video-slide': VideoSlide;
}
}
const MEDIA_TIMEOUT_MS = 15000;
const SearchVideos = ({
query,
chatHistory,
messageId,
}: {
query: string;
chatHistory: [string, string][];
messageId: string;
}) => {
const { t } = useTranslation();
const [videos, setVideos] = useState<Video[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const [slides, setSlides] = useState<VideoSlide[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const videoRefs = useRef<(HTMLIFrameElement | null)[]>([]);
const searchVideos = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/videos`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
chatHistory,
chatModel: {
providerId: localStorage.getItem('chatModelProviderId'),
key: localStorage.getItem('chatModelKey'),
},
}),
signal: AbortSignal.timeout(MEDIA_TIMEOUT_MS),
});
const data = await res.json();
const vids = data.videos ?? [];
setVideos(vids);
setSlides(
vids.map((v: Video) => ({
type: 'video-slide' as const,
iframe_src: v.iframe_src,
src: v.img_src,
})),
);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Search failed';
const isTimeout = msg.includes('timeout') || (err as Error)?.name === 'AbortError';
setError(isTimeout ? 'Request timed out. Try again.' : msg);
} finally {
setLoading(false);
}
};
return (
<>
{!loading && videos === null && !error && (
<button
id={`search-videos-${messageId}`}
onClick={searchVideos}
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>{t('chat.searchVideos')}</p>
</div>
<PlusIcon className="text-[#EA580C]" size={17} />
</button>
)}
{error && (
<div className="rounded-lg border border-red-500/20 dark:border-red-500/30 bg-light-secondary dark:bg-dark-secondary p-3 text-sm">
<p className="text-red-500 mb-2">{error}</p>
<button
type="button"
onClick={searchVideos}
className="text-[#EA580C] hover:underline text-sm"
>
Retry
</button>
</div>
)}
{loading && (
<div className="grid grid-cols-2 gap-2">
{[...Array(4)].map((_, i) => (
<div
key={i}
className="bg-light-secondary dark:bg-dark-secondary h-32 w-full rounded-lg animate-pulse aspect-video object-cover"
/>
))}
</div>
)}
{videos !== null && videos.length > 0 && (
<>
<div className="grid grid-cols-2 gap-2">
{videos.length > 4
? videos.slice(0, 3).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">{t('chat.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">{t('chat.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">
{videos.slice(3, 6).map((video, i) => (
<img
key={i}
src={video.img_src}
alt={video.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 {videos.length - 3} more
</p>
</button>
)}
</div>
<Lightbox
open={open}
close={() => setOpen(false)}
slides={slides}
index={currentIndex}
on={{
view: ({ index }) => {
const previousIframe = videoRefs.current[currentIndex];
if (previousIframe?.contentWindow) {
previousIframe.contentWindow.postMessage(
'{"event":"command","func":"pauseVideo","args":""}',
'*',
);
}
setCurrentIndex(index);
},
}}
render={{
slide: ({ slide }) => {
const index = slides.findIndex((s) => s === slide);
return slide.type === 'video-slide' ? (
<div className="h-full w-full flex flex-row items-center justify-center">
<iframe
src={`${slide.iframe_src}${slide.iframe_src.includes('?') ? '&' : '?'}enablejsapi=1`}
ref={(el) => {
if (el) {
videoRefs.current[index] = el;
}
}}
className="aspect-video max-h-[95vh] w-[95vw] rounded-2xl md:w-[80vw]"
allowFullScreen
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
/>
</div>
) : null;
},
}}
/>
</>
)}
</>
);
};
export default SearchVideos;

View File

@@ -0,0 +1,159 @@
import { Dialog, DialogPanel } from '@headlessui/react';
import { Plus } from 'lucide-react';
import { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { ConfigModelProvider } from '@/lib/config/types';
import { toast } from 'sonner';
const AddModel = ({
providerId,
setProviders,
type,
}: {
providerId: string;
setProviders: React.Dispatch<React.SetStateAction<ConfigModelProvider[]>>;
type: 'chat' | 'embedding';
}) => {
const [open, setOpen] = useState(false);
const [modelName, setModelName] = useState('');
const [modelKey, setModelKey] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const res = await fetch(`/api/providers/${providerId}/models`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: modelName,
key: modelKey,
type: type,
}),
});
if (!res.ok) {
throw new Error('Failed to add model');
}
setProviders((prev) =>
prev.map((provider) => {
if (provider.id === providerId) {
const newModel = { name: modelName, key: modelKey };
return {
...provider,
chatModels:
type === 'chat'
? [...provider.chatModels, newModel]
: provider.chatModels,
embeddingModels:
type === 'embedding'
? [...provider.embeddingModels, newModel]
: provider.embeddingModels,
};
}
return provider;
}),
);
toast.success('Model added successfully.');
setModelName('');
setModelKey('');
setOpen(false);
} catch (error) {
console.error('Error adding model:', error);
toast.error('Failed to add model.');
} finally {
setLoading(false);
}
};
return (
<>
<button
onClick={() => setOpen(true)}
className="text-xs text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white flex flex-row items-center space-x-1 active:scale-95 transition duration-200"
>
<Plus size={12} />
<span>Add</span>
</button>
<AnimatePresence>
{open && (
<Dialog
static
open={open}
onClose={() => setOpen(false)}
className="relative z-[60]"
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
className="fixed inset-0 flex w-screen items-center justify-center p-4 bg-black/30 backdrop-blur-sm"
>
<DialogPanel className="w-full mx-4 lg:w-[600px] max-h-[85vh] flex flex-col border bg-light-primary dark:bg-dark-primary border-light-secondary dark:border-dark-secondary rounded-lg">
<div className="px-6 pt-6 pb-4">
<h3 className="text-black/90 dark:text-white/90 font-medium text-sm">
Add new {type === 'chat' ? 'chat' : 'embedding'} model
</h3>
</div>
<div className="border-t border-light-200 dark:border-dark-200" />
<div className="flex-1 overflow-y-auto px-6 py-4">
<form
onSubmit={handleSubmit}
className="flex flex-col h-full"
>
<div className="flex flex-col space-y-4 flex-1">
<div className="flex flex-col items-start space-y-2">
<label className="text-xs text-black/70 dark:text-white/70">
Model name*
</label>
<input
value={modelName}
onChange={(e) => setModelName(e.target.value)}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 text-[13px] text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
placeholder="e.g., GPT-4"
type="text"
required
/>
</div>
<div className="flex flex-col items-start space-y-2">
<label className="text-xs text-black/70 dark:text-white/70">
Model key*
</label>
<input
value={modelKey}
onChange={(e) => setModelKey(e.target.value)}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 text-[13px] text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
placeholder="e.g., gpt-4"
type="text"
required
/>
</div>
</div>
<div className="border-t border-light-200 dark:border-dark-200 -mx-6 my-4" />
<div className="flex justify-end">
<button
type="submit"
disabled={loading}
className="px-4 py-2 rounded-lg text-[13px] bg-[#EA580C] text-white font-medium disabled:opacity-85 hover:opacity-85 active:scale-95 transition duration-200"
>
{loading ? 'Adding…' : 'Add Model'}
</button>
</div>
</form>
</div>
</DialogPanel>
</motion.div>
</Dialog>
)}
</AnimatePresence>
</>
);
};
export default AddModel;

View File

@@ -0,0 +1,212 @@
import {
Description,
Dialog,
DialogPanel,
DialogTitle,
} from '@headlessui/react';
import { Plus } from 'lucide-react';
import { useMemo, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import {
ConfigModelProvider,
ModelProviderUISection,
StringUIConfigField,
UIConfigField,
} from '@/lib/config/types';
import Select from '@/components/ui/Select';
import { toast } from 'sonner';
const AddProvider = ({
modelProviders,
setProviders,
}: {
modelProviders: ModelProviderUISection[];
setProviders: React.Dispatch<React.SetStateAction<ConfigModelProvider[]>>;
}) => {
const [open, setOpen] = useState(false);
const [selectedProvider, setSelectedProvider] = useState<null | string>(
modelProviders[0]?.key || null,
);
const [config, setConfig] = useState<Record<string, any>>({});
const [name, setName] = useState('');
const [loading, setLoading] = useState(false);
const providerConfigMap = useMemo(() => {
const map: Record<string, { name: string; fields: UIConfigField[] }> = {};
modelProviders.forEach((p) => {
map[p.key] = {
name: p.name,
fields: p.fields,
};
});
return map;
}, [modelProviders]);
const selectedProviderFields = useMemo(() => {
if (!selectedProvider) return [];
const providerFields = providerConfigMap[selectedProvider]?.fields || [];
const config: Record<string, any> = {};
providerFields.forEach((field) => {
config[field.key] = field.default || '';
});
setConfig(config);
return providerFields;
}, [selectedProvider, providerConfigMap]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const res = await fetch('/api/providers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: selectedProvider,
name: name,
config: config,
}),
});
if (!res.ok) {
throw new Error('Failed to add provider');
}
const data: ConfigModelProvider = (await res.json()).provider;
setProviders((prev) => [...prev, data]);
toast.success('Connection added successfully.');
} catch (error) {
console.error('Error adding provider:', error);
toast.error('Failed to add connection.');
} finally {
setLoading(false);
setOpen(false);
}
};
return (
<>
<button
onClick={() => setOpen(true)}
className="px-3 md:px-4 py-1.5 md:py-2 rounded-lg text-xs sm:text-xs border border-light-200 dark:border-dark-200 text-black dark:text-white bg-light-secondary/50 dark:bg-dark-secondary/50 hover:bg-light-secondary hover:dark:bg-dark-secondary hover:border-light-300 hover:dark:border-dark-300 flex flex-row items-center space-x-1 active:scale-95 transition duration-200"
>
<Plus className="w-3.5 h-3.5 md:w-4 md:h-4" />
<span>Add Connection</span>
</button>
<AnimatePresence>
{open && (
<Dialog
static
open={open}
onClose={() => setOpen(false)}
className="relative z-[60]"
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
className="fixed inset-0 flex w-screen items-center justify-center p-4 bg-black/30 backdrop-blur-sm"
>
<DialogPanel className="w-full mx-4 lg:w-[600px] max-h-[85vh] flex flex-col border bg-light-primary dark:bg-dark-primary border-light-secondary dark:border-dark-secondary rounded-lg">
<form onSubmit={handleSubmit} className="flex flex-col flex-1">
<div className="px-6 pt-6 pb-4">
<h3 className="text-black/90 dark:text-white/90 font-medium text-sm">
Add new connection
</h3>
</div>
<div className="border-t border-light-200 dark:border-dark-200" />
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="flex flex-col space-y-4">
<div className="flex flex-col items-start space-y-2">
<label className="text-xs text-black/70 dark:text-white/70">
Select connection type
</label>
<Select
value={selectedProvider ?? ''}
onChange={(e) => setSelectedProvider(e.target.value)}
options={Object.entries(providerConfigMap).map(
([key, val]) => {
return {
label: val.name,
value: key,
};
},
)}
/>
</div>
<div
key="name"
className="flex flex-col items-start space-y-2"
>
<label className="text-xs text-black/70 dark:text-white/70">
Connection Name*
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 pr-10 text-sm text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
placeholder={'e.g., My OpenAI Connection'}
type="text"
required={true}
/>
</div>
{selectedProviderFields.map((field: UIConfigField) => (
<div
key={field.key}
className="flex flex-col items-start space-y-2"
>
<label className="text-xs text-black/70 dark:text-white/70">
{field.name}
{field.required && '*'}
</label>
<input
value={config[field.key] ?? field.default ?? ''}
onChange={(event) =>
setConfig((prev) => ({
...prev,
[field.key]: event.target.value,
}))
}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 pr-10 text-[13px] text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
placeholder={
(field as StringUIConfigField).placeholder
}
type="text"
required={field.required}
/>
</div>
))}
</div>
</div>
<div className="border-t border-light-200 dark:border-dark-200" />
<div className="px-6 py-4 flex justify-end">
<button
type="submit"
disabled={loading}
className="px-4 py-2 rounded-lg text-[13px] bg-[#EA580C] text-white font-medium disabled:opacity-85 hover:opacity-85 active:scale-95 transition duration-200"
>
{loading ? 'Adding…' : 'Add Connection'}
</button>
</div>
</form>
</DialogPanel>
</motion.div>
</Dialog>
)}
</AnimatePresence>
</>
);
};
export default AddProvider;

View File

@@ -0,0 +1,115 @@
import { Dialog, DialogPanel } from '@headlessui/react';
import { Trash2 } from 'lucide-react';
import { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { ConfigModelProvider } from '@/lib/config/types';
import { toast } from 'sonner';
const DeleteProvider = ({
modelProvider,
setProviders,
}: {
modelProvider: ConfigModelProvider;
setProviders: React.Dispatch<React.SetStateAction<ConfigModelProvider[]>>;
}) => {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const handleDelete = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const res = await fetch(`/api/providers/${modelProvider.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
});
if (!res.ok) {
throw new Error('Failed to delete provider');
}
setProviders((prev) => {
return prev.filter((p) => p.id !== modelProvider.id);
});
toast.success('Connection deleted successfully.');
} catch (error) {
console.error('Error deleting provider:', error);
toast.error('Failed to delete connection.');
} finally {
setLoading(false);
}
};
return (
<>
<button
onClick={(e) => {
e.stopPropagation();
setOpen(true);
}}
className="group p-1.5 rounded-md hover:bg-light-200 hover:dark:bg-dark-200 transition-colors group"
title="Delete connection"
>
<Trash2
size={14}
className="text-black/60 dark:text-white/60 group-hover:text-red-500 group-hover:dark:text-red-400"
/>
</button>
<AnimatePresence>
{open && (
<Dialog
static
open={open}
onClose={() => setOpen(false)}
className="relative z-[60]"
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
className="fixed inset-0 flex w-screen items-center justify-center p-4 bg-black/30 backdrop-blur-sm"
>
<DialogPanel className="w-full mx-4 lg:w-[600px] max-h-[85vh] flex flex-col border bg-light-primary dark:bg-dark-primary border-light-secondary dark:border-dark-secondary rounded-lg">
<div className="px-6 pt-6 pb-4">
<h3 className="text-black/90 dark:text-white/90 font-medium">
Delete connection
</h3>
</div>
<div className="border-t border-light-200 dark:border-dark-200" />
<div className="flex-1 overflow-y-auto px-6 py-4">
<p className="text-sm text-black/60 dark:text-white/60">
Are you sure you want to delete the connection &quot;
{modelProvider.name}&quot;? This action cannot be undone.
All associated models will also be removed.
</p>
</div>
<div className="px-6 py-6 flex justify-end space-x-2">
<button
disabled={loading}
onClick={() => setOpen(false)}
className="px-4 py-2 rounded-lg text-sm border border-light-200 dark:border-dark-200 text-black dark:text-white bg-light-secondary/50 dark:bg-dark-secondary/50 hover:bg-light-secondary hover:dark:bg-dark-secondary hover:border-light-300 hover:dark:border-dark-300 flex flex-row items-center space-x-1 active:scale-95 transition duration-200"
>
Cancel
</button>
<button
disabled={loading}
onClick={handleDelete}
className="px-4 py-2 rounded-lg text-sm bg-red-500 text-white font-medium disabled:opacity-85 hover:opacity-85 active:scale-95 transition duration-200"
>
{loading ? 'Deleting…' : 'Delete'}
</button>
</div>
</DialogPanel>
</motion.div>
</Dialog>
)}
</AnimatePresence>
</>
);
};
export default DeleteProvider;

View File

@@ -0,0 +1,224 @@
import { UIConfigField, ConfigModelProvider } from '@/lib/config/types';
import { cn } from '@/lib/utils';
import { AnimatePresence, motion } from 'framer-motion';
import { AlertCircle, Plug2, Plus, Pencil, Trash2, X } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import AddModel from './AddModelDialog';
import UpdateProvider from './UpdateProviderDialog';
import DeleteProvider from './DeleteProviderDialog';
const ModelProvider = ({
modelProvider,
setProviders,
fields,
}: {
modelProvider: ConfigModelProvider;
fields: UIConfigField[];
setProviders: React.Dispatch<React.SetStateAction<ConfigModelProvider[]>>;
}) => {
const [open, setOpen] = useState(true);
const handleModelDelete = async (
type: 'chat' | 'embedding',
modelKey: string,
) => {
try {
const res = await fetch(`/api/providers/${modelProvider.id}/models`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ key: modelKey, type: type }),
});
if (!res.ok) {
throw new Error('Failed to delete model: ' + (await res.text()));
}
setProviders(
(prev) =>
prev.map((provider) => {
if (provider.id === modelProvider.id) {
return {
...provider,
...(type === 'chat'
? {
chatModels: provider.chatModels.filter(
(m) => m.key !== modelKey,
),
}
: {
embeddingModels: provider.embeddingModels.filter(
(m) => m.key !== modelKey,
),
}),
};
}
return provider;
}) as ConfigModelProvider[],
);
toast.success('Model deleted successfully.');
} catch (err) {
console.error('Failed to delete model', err);
toast.error('Failed to delete model.');
}
};
const modelCount =
modelProvider.chatModels.filter((m) => m.key !== 'error').length +
modelProvider.embeddingModels.filter((m) => m.key !== 'error').length;
const hasError =
modelProvider.chatModels.some((m) => m.key === 'error') ||
modelProvider.embeddingModels.some((m) => m.key === 'error');
return (
<div
key={modelProvider.id}
className="border border-light-200 dark:border-dark-200 rounded-lg overflow-hidden bg-light-primary dark:bg-dark-primary"
>
<div className="px-5 py-3.5 flex flex-row justify-between w-full items-center border-b border-light-200 dark:border-dark-200 bg-light-secondary/30 dark:bg-dark-secondary/30">
<div className="flex items-center gap-2.5">
<div className="p-1.5 rounded-md bg-[#EA580C]/10 dark:bg-[#EA580C]/10">
<Plug2 size={14} className="text-[#EA580C]" />
</div>
<div className="flex flex-col">
<p className="text-sm lg:text-sm text-black dark:text-white font-medium">
{modelProvider.name}
</p>
{modelCount > 0 && (
<p className="text-[10px] lg:text-[11px] text-black/50 dark:text-white/50">
{modelCount} model{modelCount !== 1 ? 's' : ''} configured
</p>
)}
</div>
</div>
<div className="flex flex-row items-center gap-1">
<UpdateProvider
fields={fields}
modelProvider={modelProvider}
setProviders={setProviders}
/>
<DeleteProvider
modelProvider={modelProvider}
setProviders={setProviders}
/>
</div>
</div>
<div className="flex flex-col gap-y-4 px-5 py-4">
<div className="flex flex-col gap-y-2">
<div className="flex flex-row w-full justify-between items-center">
<p className="text-[11px] lg:text-[11px] font-medium text-black/70 dark:text-white/70 uppercase tracking-wide">
Chat Models
</p>
{!modelProvider.chatModels.some((m) => m.key === 'error') && (
<AddModel
providerId={modelProvider.id}
setProviders={setProviders}
type="chat"
/>
)}
</div>
<div className="flex flex-col gap-2">
{modelProvider.chatModels.some((m) => m.key === 'error') ? (
<div className="flex flex-row items-center gap-2 text-xs lg:text-xs text-red-500 dark:text-red-400 rounded-lg bg-red-50 dark:bg-red-950/20 px-3 py-2 border border-red-200 dark:border-red-900/30">
<AlertCircle size={16} className="shrink-0" />
<span className="break-words">
{
modelProvider.chatModels.find((m) => m.key === 'error')
?.name
}
</span>
</div>
) : modelProvider.chatModels.filter((m) => m.key !== 'error')
.length === 0 && !hasError ? (
<div className="flex flex-col items-center justify-center py-4 px-4 rounded-lg border-2 border-dashed border-light-200 dark:border-dark-200 bg-light-secondary/20 dark:bg-dark-secondary/20">
<p className="text-xs text-black/50 dark:text-white/50 text-center">
No chat models configured
</p>
</div>
) : modelProvider.chatModels.filter((m) => m.key !== 'error')
.length > 0 ? (
<div className="flex flex-row flex-wrap gap-2">
{modelProvider.chatModels.map((model, index) => (
<div
key={`${modelProvider.id}-chat-${model.key}-${index}`}
className="flex flex-row items-center space-x-1.5 text-xs lg:text-xs text-black/70 dark:text-white/70 rounded-lg bg-light-secondary dark:bg-dark-secondary px-3 py-1.5 border border-light-200 dark:border-dark-200"
>
<span>{model.name}</span>
<button
onClick={() => {
handleModelDelete('chat', model.key);
}}
className="hover:text-red-500 dark:hover:text-red-400 transition-colors"
>
<X size={12} />
</button>
</div>
))}
</div>
) : null}
</div>
</div>
<div className="flex flex-col gap-y-2">
<div className="flex flex-row w-full justify-between items-center">
<p className="text-[11px] lg:text-[11px] font-medium text-black/70 dark:text-white/70 uppercase tracking-wide">
Embedding Models
</p>
{!modelProvider.embeddingModels.some((m) => m.key === 'error') && (
<AddModel
providerId={modelProvider.id}
setProviders={setProviders}
type="embedding"
/>
)}
</div>
<div className="flex flex-col gap-2">
{modelProvider.embeddingModels.some((m) => m.key === 'error') ? (
<div className="flex flex-row items-center gap-2 text-xs lg:text-xs text-red-500 dark:text-red-400 rounded-lg bg-red-50 dark:bg-red-950/20 px-3 py-2 border border-red-200 dark:border-red-900/30">
<AlertCircle size={16} className="shrink-0" />
<span className="break-words">
{
modelProvider.embeddingModels.find((m) => m.key === 'error')
?.name
}
</span>
</div>
) : modelProvider.embeddingModels.filter((m) => m.key !== 'error')
.length === 0 && !hasError ? (
<div className="flex flex-col items-center justify-center py-4 px-4 rounded-lg border-2 border-dashed border-light-200 dark:border-dark-200 bg-light-secondary/20 dark:bg-dark-secondary/20">
<p className="text-xs text-black/50 dark:text-white/50 text-center">
No embedding models configured
</p>
</div>
) : modelProvider.embeddingModels.filter((m) => m.key !== 'error')
.length > 0 ? (
<div className="flex flex-row flex-wrap gap-2">
{modelProvider.embeddingModels.map((model, index) => (
<div
key={`${modelProvider.id}-embedding-${model.key}-${index}`}
className="flex flex-row items-center space-x-1.5 text-xs lg:text-xs text-black/70 dark:text-white/70 rounded-lg bg-light-secondary dark:bg-dark-secondary px-3 py-1.5 border border-light-200 dark:border-dark-200"
>
<span>{model.name}</span>
<button
onClick={() => {
handleModelDelete('embedding', model.key);
}}
className="hover:text-red-500 dark:hover:text-red-400 transition-colors"
>
<X size={12} />
</button>
</div>
))}
</div>
) : null}
</div>
</div>
</div>
</div>
);
};
export default ModelProvider;

View File

@@ -0,0 +1,98 @@
import Select from '@/components/ui/Select';
import { ConfigModelProvider } from '@/lib/config/types';
import { useChat } from '@/lib/hooks/useChat';
import { useState } from 'react';
import { toast } from 'sonner';
const ModelSelect = ({
providers,
type,
}: {
providers: ConfigModelProvider[];
type: 'chat' | 'embedding';
}) => {
const [selectedModel, setSelectedModel] = useState<string>(
type === 'chat'
? `${localStorage.getItem('chatModelProviderId')}/${localStorage.getItem('chatModelKey')}`
: `${localStorage.getItem('embeddingModelProviderId')}/${localStorage.getItem('embeddingModelKey')}`,
);
const [loading, setLoading] = useState(false);
const { setChatModelProvider, setEmbeddingModelProvider } = useChat();
const handleSave = async (newValue: string) => {
setLoading(true);
setSelectedModel(newValue);
try {
if (type === 'chat') {
const providerId = newValue.split('/')[0];
const modelKey = newValue.split('/').slice(1).join('/');
localStorage.setItem('chatModelProviderId', providerId);
localStorage.setItem('chatModelKey', modelKey);
setChatModelProvider({
providerId: providerId,
key: modelKey,
});
} else {
const providerId = newValue.split('/')[0];
const modelKey = newValue.split('/').slice(1).join('/');
localStorage.setItem('embeddingModelProviderId', providerId);
localStorage.setItem('embeddingModelKey', modelKey);
setEmbeddingModelProvider({
providerId: providerId,
key: modelKey,
});
}
} catch (error) {
console.error('Error saving config:', error);
toast.error('Failed to save configuration.');
} finally {
setLoading(false);
}
};
return (
<section className="rounded-xl border border-light-200 bg-light-primary/80 p-4 lg:p-6 transition-colors dark:border-dark-200 dark:bg-dark-primary/80">
<div className="space-y-3 lg:space-y-5">
<div>
<h4 className="text-sm lg:text-sm text-black dark:text-white">
Select {type === 'chat' ? 'Chat Model' : 'Embedding Model'}
</h4>
<p className="text-[11px] lg:text-xs text-black/50 dark:text-white/50">
{type === 'chat'
? 'Choose which model to use for generating responses'
: 'Choose which model to use for generating embeddings'}
</p>
</div>
<Select
value={selectedModel}
onChange={(event) => handleSave(event.target.value)}
options={
type === 'chat'
? providers.flatMap((provider) =>
provider.chatModels.map((model) => ({
value: `${provider.id}/${model.key}`,
label: `${provider.name} - ${model.name}`,
})),
)
: providers.flatMap((provider) =>
provider.embeddingModels.map((model) => ({
value: `${provider.id}/${model.key}`,
label: `${provider.name} - ${model.name}`,
})),
)
}
className="!text-xs lg:!text-[13px]"
loading={loading}
disabled={loading}
/>
</div>
</section>
);
};
export default ModelSelect;

View File

@@ -0,0 +1,91 @@
import React, { useState } from 'react';
import AddProvider from './AddProviderDialog';
import {
ConfigModelProvider,
ModelProviderUISection,
UIConfigField,
} from '@/lib/config/types';
import ModelProvider from './ModelProvider';
import ModelSelect from './ModelSelect';
const Models = ({
fields,
values,
}: {
fields: ModelProviderUISection[];
values: ConfigModelProvider[];
}) => {
const [providers, setProviders] = useState<ConfigModelProvider[]>(values);
return (
<div className="flex-1 space-y-6 overflow-y-auto py-6">
<div className="flex flex-col px-6 gap-y-4">
<h3 className="text-xs lg:text-xs text-black/70 dark:text-white/70">
Select models
</h3>
<ModelSelect
providers={values.filter((p) =>
p.chatModels.some((m) => m.key != 'error'),
)}
type="chat"
/>
<ModelSelect
providers={values.filter((p) =>
p.embeddingModels.some((m) => m.key != 'error'),
)}
type="embedding"
/>
</div>
<div className="border-t border-light-200 dark:border-dark-200" />
<div className="flex flex-row justify-between items-center px-6 ">
<p className="text-xs lg:text-xs text-black/70 dark:text-white/70">
Manage connections
</p>
<AddProvider modelProviders={fields} setProviders={setProviders} />
</div>
<div className="flex flex-col px-6 gap-y-4">
{providers.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 px-4 rounded-lg border-2 border-dashed border-light-200 dark:border-dark-200 bg-light-secondary/10 dark:bg-dark-secondary/10">
<div className="p-3 rounded-full bg-[#EA580C]/10 dark:bg-[#EA580C]/10 mb-3">
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-8 h-8 text-[#EA580C]"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
<p className="text-sm font-medium text-black/70 dark:text-white/70 mb-1">
No connections yet
</p>
<p className="text-xs text-black/50 dark:text-white/50 text-center max-w-sm mb-4">
Add your first connection to start using AI models. Connect to
OpenAI, Anthropic, Ollama, and more.
</p>
</div>
) : (
providers.map((provider) => (
<ModelProvider
key={`provider-${provider.id}`}
fields={
(fields.find((f) => f.key === provider.type)?.fields ??
[]) as UIConfigField[]
}
modelProvider={provider}
setProviders={setProviders}
/>
))
)}
</div>
</div>
);
};
export default Models;

View File

@@ -0,0 +1,184 @@
import { Dialog, DialogPanel } from '@headlessui/react';
import { Pencil } from 'lucide-react';
import { useEffect, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import {
ConfigModelProvider,
StringUIConfigField,
UIConfigField,
} from '@/lib/config/types';
import { toast } from 'sonner';
const UpdateProvider = ({
modelProvider,
fields,
setProviders,
}: {
fields: UIConfigField[];
modelProvider: ConfigModelProvider;
setProviders: React.Dispatch<React.SetStateAction<ConfigModelProvider[]>>;
}) => {
const [open, setOpen] = useState(false);
const [config, setConfig] = useState<Record<string, any>>({});
const [name, setName] = useState(modelProvider.name);
const [loading, setLoading] = useState(false);
useEffect(() => {
const config: Record<string, any> = {
name: modelProvider.name,
};
fields.forEach((field) => {
config[field.key] =
modelProvider.config[field.key] || field.default || '';
});
setConfig(config);
}, [fields]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const res = await fetch(`/api/providers/${modelProvider.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: name,
config: config,
}),
});
if (!res.ok) {
throw new Error('Failed to update provider');
}
const data: ConfigModelProvider = (await res.json()).provider;
setProviders((prev) => {
return prev.map((p) => {
if (p.id === modelProvider.id) {
return data;
}
return p;
});
});
toast.success('Connection updated successfully.');
} catch (error) {
console.error('Error updating provider:', error);
toast.error('Failed to update connection.');
} finally {
setLoading(false);
setOpen(false);
}
};
return (
<>
<button
onClick={(e) => {
e.stopPropagation();
setOpen(true);
}}
className="group p-1.5 rounded-md hover:bg-light-200 hover:dark:bg-dark-200 transition-colors group"
>
<Pencil
size={14}
className="text-black/60 dark:text-white/60 group-hover:text-black group-hover:dark:text-white"
/>
</button>
<AnimatePresence>
{open && (
<Dialog
static
open={open}
onClose={() => setOpen(false)}
className="relative z-[60]"
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
className="fixed inset-0 flex w-screen items-center justify-center p-4 bg-black/30 backdrop-blur-sm"
>
<DialogPanel className="w-full mx-4 lg:w-[600px] max-h-[85vh] flex flex-col border bg-light-primary dark:bg-dark-primary border-light-secondary dark:border-dark-secondary rounded-lg">
<form onSubmit={handleSubmit} className="flex flex-col flex-1">
<div className="px-6 pt-6 pb-4">
<h3 className="text-black/90 dark:text-white/90 font-medium text-sm">
Update connection
</h3>
</div>
<div className="border-t border-light-200 dark:border-dark-200" />
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="flex flex-col space-y-4">
<div
key="name"
className="flex flex-col items-start space-y-2"
>
<label className="text-xs text-black/70 dark:text-white/70">
Connection Name*
</label>
<input
value={name}
onChange={(event) => setName(event.target.value)}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 pr-10 text-sm text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
placeholder={'Connection Name'}
type="text"
required={true}
/>
</div>
{fields.map((field: UIConfigField) => (
<div
key={field.key}
className="flex flex-col items-start space-y-2"
>
<label className="text-xs text-black/70 dark:text-white/70">
{field.name}
{field.required && '*'}
</label>
<input
value={config[field.key] ?? field.default ?? ''}
onChange={(event) =>
setConfig((prev) => ({
...prev,
[field.key]: event.target.value,
}))
}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-4 py-3 pr-10 text-[13px] text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
placeholder={
(field as StringUIConfigField).placeholder
}
type="text"
required={field.required}
/>
</div>
))}
</div>
</div>
<div className="border-t border-light-200 dark:border-dark-200" />
<div className="px-6 py-4 flex justify-end">
<button
type="submit"
disabled={loading}
className="px-4 py-2 rounded-lg text-[13px] bg-[#EA580C] text-white font-medium disabled:opacity-85 hover:opacity-85 active:scale-95 transition duration-200"
>
{loading ? 'Updating…' : 'Update Connection'}
</button>
</div>
</form>
</DialogPanel>
</motion.div>
</Dialog>
)}
</AnimatePresence>
</>
);
};
export default UpdateProvider;

View File

@@ -0,0 +1,32 @@
import { UIConfigField } from '@/lib/config/types';
import SettingsField from '../SettingsField';
const Personalization = ({
fields,
values,
token,
}: {
fields: UIConfigField[];
values: Record<string, unknown>;
token?: string | null;
}) => {
return (
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-6">
{fields.map((field) => (
<SettingsField
key={field.key}
field={field}
value={
(values?.[field.key] as string | undefined) ??
(field.scope === 'client' ? localStorage.getItem(field.key) : undefined) ??
field.default
}
dataAdd="personalization"
token={token}
/>
))}
</div>
);
};
export default Personalization;

View File

@@ -0,0 +1,32 @@
import { UIConfigField } from '@/lib/config/types';
import SettingsField from '../SettingsField';
const Preferences = ({
fields,
values,
token,
}: {
fields: UIConfigField[];
values: Record<string, unknown>;
token?: string | null;
}) => {
return (
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-6">
{fields.map((field) => (
<SettingsField
key={field.key}
field={field}
value={
(values?.[field.key] as string | undefined) ??
(field.scope === 'client' ? localStorage.getItem(field.key) : undefined) ??
field.default
}
dataAdd="preferences"
token={token}
/>
))}
</div>
);
};
export default Preferences;

View File

@@ -0,0 +1,32 @@
import { UIConfigField } from '@/lib/config/types';
import SettingsField from '../SettingsField';
const Search = ({
fields,
values,
token,
}: {
fields: UIConfigField[];
values: Record<string, unknown>;
token?: string | null;
}) => {
return (
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-6">
{fields.map((field) => (
<SettingsField
key={field.key}
field={field}
value={
(values?.[field.key] as string | undefined) ??
(field.scope === 'client' ? localStorage.getItem(field.key) : undefined) ??
field.default
}
dataAdd="search"
token={token}
/>
))}
</div>
);
};
export default Search;

View File

@@ -0,0 +1,29 @@
import { Settings } from 'lucide-react';
import { useState, useEffect } from 'react';
import SettingsDialogue from './SettingsDialogue';
import { AnimatePresence } from 'framer-motion';
import { getStoredAuthToken } from '@/lib/auth-client';
const SettingsButton = () => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [token, setToken] = useState<string | null>(null);
useEffect(() => {
setToken(getStoredAuthToken());
}, [isOpen]);
return (
<>
<div
className="p-2.5 rounded-full bg-light-200 text-black/70 dark:bg-dark-200 dark:text-white/70 hover:opacity-70 hover:scale-105 transition duration-200 cursor-pointer active:scale-95"
onClick={() => setIsOpen(true)}
>
<Settings size={19} className="cursor-pointer" />
</div>
<AnimatePresence>
{isOpen && <SettingsDialogue isOpen={isOpen} setIsOpen={setIsOpen} token={token} />}
</AnimatePresence>
</>
);
};
export default SettingsButton;

View File

@@ -0,0 +1,26 @@
import { Settings } from 'lucide-react';
import { useState, useEffect } from 'react';
import SettingsDialogue from './SettingsDialogue';
import { AnimatePresence } from 'framer-motion';
import { getStoredAuthToken } from '@/lib/auth-client';
const SettingsButtonMobile = () => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [token, setToken] = useState<string | null>(null);
useEffect(() => {
setToken(getStoredAuthToken());
}, [isOpen]);
return (
<>
<button className="lg:hidden" onClick={() => setIsOpen(true)}>
<Settings size={18} />
</button>
<AnimatePresence>
{isOpen && <SettingsDialogue isOpen={isOpen} setIsOpen={setIsOpen} token={token} />}
</AnimatePresence>
</>
);
};
export default SettingsButtonMobile;

View File

@@ -0,0 +1,256 @@
import { Dialog, DialogPanel } from '@headlessui/react';
import {
ArrowLeft,
ChevronLeft,
ExternalLink,
Search,
Sliders,
ToggleRight,
} from 'lucide-react';
import Preferences from './Sections/Preferences';
import { motion } from 'framer-motion';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import SearchSection from './Sections/Search';
import Select from '@/components/ui/Select';
import Personalization from './Sections/Personalization';
// SaaS: провайдеры настраиваются в бэкенде, пользователь не видит Models
const baseSections = [
{
key: 'preferences',
name: 'Preferences',
description: 'Customize your application preferences.',
icon: Sliders,
component: Preferences,
dataAdd: 'preferences',
},
{
key: 'personalization',
name: 'Personalization',
description: 'Customize the behavior and tone of the model.',
icon: ToggleRight,
component: Personalization,
dataAdd: 'personalization',
},
{
key: 'search',
name: 'Search',
description: 'Manage search settings.',
icon: Search,
component: SearchSection,
dataAdd: 'search',
},
];
const SettingsDialogue = ({
isOpen,
setIsOpen,
token,
}: {
isOpen: boolean;
setIsOpen: (active: boolean) => void;
token?: string | null;
}) => {
const [isLoading, setIsLoading] = useState(true);
const [config, setConfig] = useState<{
values: Record<string, unknown>;
fields: Record<string, unknown>;
envOnlyMode?: boolean;
} | null>(null);
const sections = baseSections;
const [activeSection, setActiveSection] = useState<string>(sections[0].key);
const [selectedSection, setSelectedSection] = useState(sections[0]);
useEffect(() => {
setSelectedSection(sections.find((s) => s.key === activeSection) ?? sections[0]);
}, [activeSection, sections]);
useEffect(() => {
if (isOpen) {
const fetchConfig = async () => {
try {
const [configRes, profileRes] = await Promise.all([
fetch('/api/config', {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
}),
token
? fetch('/api/v1/profile', {
headers: { Authorization: `Bearer ${token}` },
})
: Promise.resolve(null),
]);
const data = await configRes.json();
if (!configRes.ok) throw new Error('Failed to load config');
if (token && profileRes?.ok) {
const profile = await profileRes.json();
if (profile?.preferences && typeof data.values === 'object') {
data.values = {
...data.values,
preferences: { ...((data.values.preferences as object) ?? {}), ...profile.preferences },
};
}
if (profile?.personalization && typeof data.values === 'object') {
data.values = {
...data.values,
personalization: { ...((data.values.personalization as object) ?? {}), ...profile.personalization },
};
}
}
setConfig(data);
} catch (error) {
console.error('Error fetching config:', error);
toast.error('Failed to load configuration.');
} finally {
setIsLoading(false);
}
};
fetchConfig();
}
}, [isOpen, token]);
return (
<Dialog
open={isOpen}
onClose={() => setIsOpen(false)}
className="relative z-50"
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
className="fixed inset-0 flex w-screen items-center justify-center p-4 bg-black/30 backdrop-blur-sm h-screen"
>
<DialogPanel className="space-y-4 border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary backdrop-blur-lg rounded-xl h-[calc(100vh-2%)] w-[calc(100vw-2%)] md:h-[calc(100vh-7%)] md:w-[calc(100vw-7%)] lg:h-[calc(100vh-20%)] lg:w-[calc(100vw-30%)] overflow-hidden flex flex-col">
{isLoading ? (
<div className="flex flex-col gap-3 p-6 h-full w-full overflow-y-auto">
<div className="h-8 w-48 rounded-lg bg-light-200 dark:bg-dark-200 animate-pulse" />
<div className="flex gap-4 mt-4">
<div className="w-48 space-y-2">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="h-10 rounded-lg bg-light-200 dark:bg-dark-200 animate-pulse"
/>
))}
</div>
<div className="flex-1 space-y-4">
<div className="h-24 rounded-lg bg-light-200 dark:bg-dark-200 animate-pulse" />
<div className="h-32 rounded-lg bg-light-200 dark:bg-dark-200 animate-pulse" />
</div>
</div>
</div>
) : (
<div className="flex flex-1 inset-0 h-full overflow-hidden">
<div className="hidden lg:flex flex-col justify-between w-[240px] border-r border-white-200 dark:border-dark-200 h-full px-3 pt-3 overflow-y-auto">
<div className="flex flex-col">
<button
onClick={() => setIsOpen(false)}
className="group flex flex-row items-center hover:bg-light-200 hover:dark:bg-dark-200 p-2 rounded-lg"
>
<ChevronLeft
size={18}
className="text-black/50 dark:text-white/50 group-hover:text-black/70 group-hover:dark:text-white/70"
/>
<p className="text-black/50 dark:text-white/50 group-hover:text-black/70 group-hover:dark:text-white/70 text-[14px]">
Back
</p>
</button>
<div className="flex flex-col items-start space-y-1 mt-8">
{sections.map((section) => (
<button
key={section.dataAdd}
className={cn(
`flex flex-row items-center space-x-2 px-2 py-1.5 rounded-lg w-full text-sm hover:bg-light-200 hover:dark:bg-dark-200 transition duration-200 active:scale-95`,
activeSection === section.key
? 'bg-light-200 dark:bg-dark-200 text-black/90 dark:text-white/90'
: ' text-black/70 dark:text-white/70',
)}
onClick={() => setActiveSection(section.key)}
>
<section.icon size={17} />
<p>{section.name}</p>
</button>
))}
</div>
</div>
<div className="flex flex-col space-y-1 py-[18px] px-2">
<p className="text-xs text-black/70 dark:text-white/70">
Version: {process.env.NEXT_PUBLIC_VERSION}
</p>
<a
href="https://github.com/itzcrazykns/gooseek"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-black/70 dark:text-white/70 flex flex-row space-x-1 items-center transition duration-200 hover:text-black/90 hover:dark:text-white/90"
>
<span>GitHub</span>
<ExternalLink size={12} />
</a>
</div>
</div>
<div className="w-full flex flex-col overflow-hidden">
<div className="flex flex-row lg:hidden w-full justify-between px-[20px] my-4 flex-shrink-0">
<button
onClick={() => setIsOpen(false)}
className="group flex flex-row items-center hover:bg-light-200 hover:dark:bg-dark-200 rounded-lg mr-[40%]"
>
<ArrowLeft
size={18}
className="text-black/50 dark:text-white/50 group-hover:text-black/70 group-hover:dark:text-white/70"
/>
</button>
<Select
options={sections.map((section) => {
return {
value: section.key,
key: section.key,
label: section.name,
};
})}
value={activeSection}
onChange={(e) => {
setActiveSection(e.target.value);
}}
className="!text-xs lg:!text-sm"
/>
</div>
{selectedSection.component && config && (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="border-b border-light-200/60 px-6 pb-6 lg:pt-6 dark:border-dark-200/60 flex-shrink-0">
<div className="flex flex-col">
<h4 className="font-medium text-black dark:text-white text-sm lg:text-sm">
{selectedSection.name}
</h4>
<p className="text-[11px] lg:text-xs text-black/50 dark:text-white/50">
{selectedSection.description}
</p>
</div>
</div>
<div className="flex-1 overflow-y-auto">
<selectedSection.component
fields={config.fields[selectedSection.dataAdd] as never}
values={config.values[selectedSection.dataAdd] as never}
token={token}
/>
</div>
</div>
)}
</div>
</div>
)}
</DialogPanel>
</motion.div>
</Dialog>
);
};
export default SettingsDialogue;

View File

@@ -0,0 +1,410 @@
import {
SelectUIConfigField,
StringUIConfigField,
SwitchUIConfigField,
TextareaUIConfigField,
UIConfigField,
} from '@/lib/config/types';
import { useState } from 'react';
import Select from '../ui/Select';
import { toast } from 'sonner';
import { useTheme } from 'next-themes';
import { Switch } from '@headlessui/react';
const emitClientConfigChanged = () => {
if (typeof window !== 'undefined') {
window.dispatchEvent(new Event('client-config-changed'));
}
};
async function saveToProfile(
token: string,
dataAdd: string,
key: string,
value: unknown
): Promise<void> {
if (dataAdd !== 'preferences' && dataAdd !== 'personalization') return;
await fetch('/api/v1/profile', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ [dataAdd]: { [key]: value } }),
});
}
const SettingsSelect = ({
field,
value,
setValue,
dataAdd,
token,
}: {
field: SelectUIConfigField;
value?: unknown;
setValue: (value: unknown) => void;
dataAdd: string;
token?: string | null;
}) => {
const [loading, setLoading] = useState(false);
const { setTheme } = useTheme();
const handleSave = async (newValue: any) => {
setLoading(true);
setValue(newValue);
try {
if (field.scope === 'client') {
localStorage.setItem(field.key, newValue);
if (field.key === 'theme') {
setTheme(newValue);
}
emitClientConfigChanged();
if (token && (dataAdd === 'preferences' || dataAdd === 'personalization')) {
await saveToProfile(token, dataAdd, field.key, newValue);
}
} else {
const res = await fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
key: `${dataAdd}.${field.key}`,
value: newValue,
}),
});
if (!res.ok) {
console.error('Failed to save config:', await res.text());
throw new Error('Failed to save configuration');
}
}
} catch (error) {
console.error('Error saving config:', error);
toast.error('Failed to save configuration.');
} finally {
setTimeout(() => setLoading(false), 150);
}
};
return (
<section className="rounded-xl border border-light-200 bg-light-primary/80 p-4 lg:p-6 transition-colors dark:border-dark-200 dark:bg-dark-primary/80">
<div className="space-y-3 lg:space-y-5">
<div>
<h4 className="text-sm lg:text-sm text-black dark:text-white">
{field.name}
</h4>
<p className="text-[11px] lg:text-xs text-black/50 dark:text-white/50">
{field.description}
</p>
</div>
<Select
value={(value ?? '') as string}
onChange={(event) => handleSave(event.target.value)}
options={field.options.map((option) => ({
value: option.value,
label: option.name,
}))}
className="!text-xs lg:!text-sm"
loading={loading}
disabled={loading}
/>
</div>
</section>
);
};
const SettingsInput = ({
field,
value,
setValue,
dataAdd,
token,
}: {
field: StringUIConfigField;
value?: unknown;
setValue: (value: unknown) => void;
dataAdd: string;
token?: string | null;
}) => {
const [loading, setLoading] = useState(false);
const handleSave = async (newValue: any) => {
setLoading(true);
setValue(newValue);
try {
if (field.scope === 'client') {
localStorage.setItem(field.key, newValue);
emitClientConfigChanged();
if (token && (dataAdd === 'preferences' || dataAdd === 'personalization')) {
await saveToProfile(token, dataAdd, field.key, newValue);
}
} else {
const res = await fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
key: `${dataAdd}.${field.key}`,
value: newValue,
}),
});
if (!res.ok) {
console.error('Failed to save config:', await res.text());
throw new Error('Failed to save configuration');
}
}
} catch (error) {
console.error('Error saving config:', error);
toast.error('Failed to save configuration.');
} finally {
setTimeout(() => setLoading(false), 150);
}
};
return (
<section className="rounded-xl border border-light-200 bg-light-primary/80 p-4 lg:p-6 transition-colors dark:border-dark-200 dark:bg-dark-primary/80">
<div className="space-y-3 lg:space-y-5">
<div>
<h4 className="text-sm lg:text-sm text-black dark:text-white">
{field.name}
</h4>
<p className="text-[11px] lg:text-xs text-black/50 dark:text-white/50">
{field.description}
</p>
</div>
<div className="relative">
<input
value={String(value ?? field.default ?? '')}
onChange={(event) => setValue(event.target.value)}
onBlur={(event) => handleSave(event.target.value)}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-3 py-2 lg:px-4 lg:py-3 pr-10 !text-xs lg:!text-[13px] text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
placeholder={field.placeholder}
type="text"
disabled={loading}
/>
</div>
</div>
</section>
);
};
const SettingsTextarea = ({
field,
value,
setValue,
dataAdd,
token,
}: {
field: TextareaUIConfigField;
value?: unknown;
setValue: (value: unknown) => void;
dataAdd: string;
token?: string | null;
}) => {
const [loading, setLoading] = useState(false);
const handleSave = async (newValue: unknown) => {
setLoading(true);
setValue(newValue);
try {
if (field.scope === 'client') {
localStorage.setItem(field.key, String(newValue));
emitClientConfigChanged();
if (token && (dataAdd === 'preferences' || dataAdd === 'personalization')) {
await saveToProfile(token, dataAdd, field.key, newValue);
}
} else {
const res = await fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
key: `${dataAdd}.${field.key}`,
value: newValue,
}),
});
if (!res.ok) {
console.error('Failed to save config:', await res.text());
throw new Error('Failed to save configuration');
}
}
} catch (error) {
console.error('Error saving config:', error);
toast.error('Failed to save configuration.');
} finally {
setTimeout(() => setLoading(false), 150);
}
};
return (
<section className="rounded-xl border border-light-200 bg-light-primary/80 p-4 lg:p-6 transition-colors dark:border-dark-200 dark:bg-dark-primary/80">
<div className="space-y-3 lg:space-y-5">
<div>
<h4 className="text-sm lg:text-sm text-black dark:text-white">
{field.name}
</h4>
<p className="text-[11px] lg:text-xs text-black/50 dark:text-white/50">
{field.description}
</p>
</div>
<div className="relative">
<textarea
value={String(value ?? field.default ?? '')}
onChange={(event) => setValue(event.target.value)}
onBlur={(event) => handleSave(event.target.value)}
className="w-full rounded-lg border border-light-200 dark:border-dark-200 bg-light-primary dark:bg-dark-primary px-3 py-2 lg:px-4 lg:py-3 pr-10 !text-xs lg:!text-[13px] text-black/80 dark:text-white/80 placeholder:text-black/40 dark:placeholder:text-white/40 focus-visible:outline-none focus-visible:border-light-300 dark:focus-visible:border-dark-300 transition-colors disabled:cursor-not-allowed disabled:opacity-60"
placeholder={field.placeholder}
rows={4}
disabled={loading}
/>
</div>
</div>
</section>
);
};
const SettingsSwitch = ({
field,
value,
setValue,
dataAdd,
token,
}: {
field: SwitchUIConfigField;
value?: unknown;
setValue: (value: unknown) => void;
dataAdd: string;
token?: string | null;
}) => {
const [loading, setLoading] = useState(false);
const handleSave = async (newValue: boolean) => {
setLoading(true);
setValue(newValue);
try {
if (field.scope === 'client') {
localStorage.setItem(field.key, String(newValue));
emitClientConfigChanged();
if (token && (dataAdd === 'preferences' || dataAdd === 'personalization')) {
await saveToProfile(token, dataAdd, field.key, newValue);
}
} else {
const res = await fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
key: `${dataAdd}.${field.key}`,
value: newValue,
}),
});
if (!res.ok) {
console.error('Failed to save config:', await res.text());
throw new Error('Failed to save configuration');
}
}
} catch (error) {
console.error('Error saving config:', error);
toast.error('Failed to save configuration.');
} finally {
setTimeout(() => setLoading(false), 150);
}
};
const isChecked = value === true || value === 'true';
return (
<section className="rounded-xl border border-light-200 bg-light-primary/80 p-4 lg:p-6 transition-colors dark:border-dark-200 dark:bg-dark-primary/80">
<div className="flex flex-row items-center space-x-3 lg:space-x-5 w-full justify-between">
<div>
<h4 className="text-sm lg:text-sm text-black dark:text-white">
{field.name}
</h4>
<p className="text-[11px] lg:text-xs text-black/50 dark:text-white/50">
{field.description}
</p>
</div>
<Switch
checked={isChecked}
onChange={handleSave}
disabled={loading}
className="group relative flex h-6 w-12 shrink-0 cursor-pointer rounded-full bg-light-200 dark:bg-white/10 p-1 duration-200 ease-in-out focus:outline-none transition-colors disabled:opacity-60 disabled:cursor-not-allowed data-[checked]:bg-[#EA580C] dark:data-[checked]:bg-[#EA580C]"
>
<span
aria-hidden="true"
className="pointer-events-none inline-block size-4 translate-x-0 rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out group-data-[checked]:translate-x-6"
/>
</Switch>
</div>
</section>
);
};
const SettingsField = ({
field,
value,
dataAdd,
token,
}: {
field: UIConfigField;
value: unknown;
dataAdd: string;
token?: string | null;
}) => {
const [val, setVal] = useState(value);
switch (field.type) {
case 'select':
return (
<SettingsSelect
field={field}
value={val}
setValue={setVal}
dataAdd={dataAdd}
token={token}
/>
);
case 'string':
return (
<SettingsInput
field={field}
value={val}
setValue={setVal}
dataAdd={dataAdd}
token={token}
/>
);
case 'textarea':
return (
<SettingsTextarea
field={field}
value={val}
setValue={setVal}
dataAdd={dataAdd}
token={token}
/>
);
case 'switch':
return (
<SettingsSwitch
field={field}
value={val}
setValue={setVal}
dataAdd={dataAdd}
token={token}
/>
);
default:
return <div>Unsupported field type: {field.type}</div>;
}
};
export default SettingsField;

View File

@@ -0,0 +1,806 @@
'use client';
import { cn } from '@/lib/utils';
import {
MessagesSquare,
Plus,
Search,
TrendingUp,
Plane,
FolderOpen,
User,
Stethoscope,
Building2,
Package,
SlidersHorizontal,
Baby,
GraduationCap,
HeartPulse,
Brain,
Trophy,
ShoppingCart,
Gamepad2,
Receipt,
Scale,
MoreHorizontal,
} from 'lucide-react';
import Link from 'next/link';
import { useSelectedLayoutSegments } from 'next/navigation';
import React, { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from '@/lib/localization/context';
import { getSidebarMenuConfig } from '@/lib/config/sidebarMenu';
import MenuSettingsPanel from './Sidebar/MenuSettingsPanel';
import Layout from './Layout';
interface ChatItem {
id: string;
title: string;
createdAt: string;
}
const VerticalIconContainer = ({ children }: { children: ReactNode }) => {
return <div className="flex flex-col items-center w-full">{children}</div>;
};
interface HistorySubmenuProps {
onMouseEnter: () => void;
onMouseLeave: () => void;
}
interface MoreSubmenuProps {
onMouseEnter: () => void;
onMouseLeave: () => void;
restIds: string[];
linkMap: Record<string, NavLink>;
itemLabels: Record<string, string>;
submenuStyle: { top: number; left: number };
isMobile: boolean;
cancelMoreHide: () => void;
}
const MoreSubmenu = ({
onMouseEnter,
onMouseLeave,
restIds,
linkMap,
itemLabels,
submenuStyle,
isMobile,
cancelMoreHide,
}: MoreSubmenuProps) => {
const { t } = useTranslation();
const [settingsHovered, setSettingsHovered] = useState(false);
return (
<div
className="min-w-[180px] w-56 h-screen overflow-y-auto bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 shadow-xl py-2 z-[9999]"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<nav className="flex flex-col">
{restIds.map((id) => {
const link = linkMap[id];
if (!link) return null;
const Icon = link.icon;
if (link.href) {
return (
<Link
key={id}
href={link.href}
className="px-4 py-2.5 text-sm text-black dark:text-white hover:bg-light-200 dark:hover:bg-dark-200 text-fade flex items-center gap-2 transition-colors"
title={link.label}
>
<Icon size={18} />
{link.label}
</Link>
);
}
return (
<span
key={id}
className="px-4 py-2.5 text-sm text-black/50 dark:text-white/50 flex items-center gap-2 cursor-default"
>
<Icon size={18} />
{link.label}
</span>
);
})}
<div
className="relative"
onMouseEnter={() => setSettingsHovered(true)}
onMouseLeave={() => setSettingsHovered(false)}
>
<button
type="button"
className="w-full px-4 py-2.5 text-sm text-left text-black dark:text-white hover:bg-light-200 dark:hover:bg-dark-200 transition-colors flex items-center gap-2"
>
<SlidersHorizontal size={18} />
{t('nav.configureMenu')}
</button>
{settingsHovered &&
typeof document !== 'undefined' &&
createPortal(
isMobile ? (
<div
className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/20 dark:bg-black/40"
onClick={() => setSettingsHovered(false)}
role="button"
tabIndex={0}
aria-label={t('common.close')}
>
<div
className="mx-4 max-h-[85vh] overflow-y-auto rounded-xl bg-light-secondary dark:bg-dark-secondary shadow-xl"
onClick={(e) => e.stopPropagation()}
onMouseEnter={cancelMoreHide}
onMouseLeave={() => setSettingsHovered(false)}
>
<MenuSettingsPanel
itemLabels={itemLabels}
onMouseEnter={cancelMoreHide}
onMouseLeave={() => {}}
/>
</div>
</div>
) : (
<div
style={{
top: submenuStyle.top,
left: submenuStyle.left + 220,
}}
className="fixed z-[10000]"
onMouseEnter={cancelMoreHide}
onMouseLeave={() => setSettingsHovered(false)}
>
<MenuSettingsPanel
itemLabels={itemLabels}
onMouseEnter={cancelMoreHide}
onMouseLeave={() => setSettingsHovered(false)}
/>
</div>
),
document.body,
)}
</div>
</nav>
</div>
);
};
const HistorySubmenu = ({ onMouseEnter, onMouseLeave }: HistorySubmenuProps) => {
const { t } = useTranslation();
const [chats, setChats] = useState<ChatItem[]>([]);
const [loading, setLoading] = useState(true);
const fetchChats = useCallback(async () => {
try {
const token =
typeof window !== 'undefined'
? localStorage.getItem('auth_token') ?? localStorage.getItem('access_token')
: null;
if (!token) {
const { getGuestChats } = await import('@/lib/guest-storage');
const guestChats = getGuestChats();
setChats(
guestChats.map((c) => ({
id: c.id,
title: c.title,
createdAt: c.createdAt,
sources: c.sources,
files: c.files,
})),
);
setLoading(false);
return;
}
const res = await fetch('/api/v1/library/threads', {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
setChats((data.chats ?? []).reverse());
} catch {
setChats([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchChats();
}, [fetchChats]);
useEffect(() => {
const onMigrated = () => fetchChats();
window.addEventListener('gooseek:guest-migrated', onMigrated);
return () => window.removeEventListener('gooseek:guest-migrated', onMigrated);
}, []);
return (
<div
className="min-w-[180px] w-56 h-screen overflow-y-auto bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 shadow-xl py-2 z-[9999]"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{loading ? (
<div className="px-4 py-3 text-sm text-black/60 dark:text-white/60">
{t('common.loading')}
</div>
) : chats.length === 0 ? (
<div className="px-4 py-3 text-sm text-black/60 dark:text-white/60">
</div>
) : (
<nav className="flex flex-col">
{chats.map((chat) => (
<Link
key={chat.id}
href={`/c/${chat.id}`}
className="px-4 py-2.5 text-sm text-black dark:text-white hover:bg-light-200 dark:hover:bg-dark-200 text-fade block transition-colors"
title={chat.title}
>
{chat.title}
</Link>
))}
</nav>
)}
</div>
);
};
type NavLink = {
icon: React.ComponentType<{ size?: number | string; className?: string }>;
href?: string;
active: boolean;
label: string;
};
const Sidebar = ({ children }: { children: React.ReactNode }) => {
const segments = useSelectedLayoutSegments();
const { t } = useTranslation();
const [historyOpen, setHistoryOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
const historyRef = useRef<HTMLDivElement>(null);
const moreRef = useRef<HTMLDivElement>(null);
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const moreHideRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [submenuStyle, setSubmenuStyle] = useState({ top: 0, left: 0 });
const [tooltip, setTooltip] = useState<{ label: string; x: number; y: number } | null>(null);
const [menuConfig, setMenuConfig] = useState(() =>
typeof window !== 'undefined' ? getSidebarMenuConfig() : { order: [], visible: {} },
);
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
if (typeof window === 'undefined') return;
const mq = window.matchMedia('(max-width: 1023px)');
setIsMobile(mq.matches);
const handler = () => setIsMobile(mq.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
const discoverLink = {
icon: Search,
href: '/discover',
active: segments.includes('discover'),
label: t('nav.discover'),
};
const libraryLink = {
icon: MessagesSquare,
href: '/library',
active: segments.includes('library'),
label: t('nav.messageHistory'),
};
const financeLink = {
icon: TrendingUp,
href: '/finance',
active: segments.includes('finance'),
label: t('nav.finance'),
};
const travelLink = {
icon: Plane,
href: '/travel',
active: segments.includes('travel'),
label: t('nav.travel'),
};
const spacesLink = {
icon: FolderOpen,
href: '/spaces',
active: segments.includes('spaces'),
label: t('nav.spaces'),
};
const profileLink = {
icon: User,
href: '/profile',
active: segments.includes('profile'),
label: t('nav.profile'),
};
const placeholderLink = (
icon: React.ComponentType<{ size?: number | string; className?: string }>,
labelKey: string,
) => ({
icon,
href: undefined as string | undefined,
active: false,
label: t(labelKey),
});
const medicineLink = placeholderLink(Stethoscope, 'nav.medicine');
const realEstateLink = placeholderLink(Building2, 'nav.realEstate');
const goodsLink = placeholderLink(Package, 'nav.goods');
const childrenLink = placeholderLink(Baby, 'nav.children');
const educationLink = placeholderLink(GraduationCap, 'nav.education');
const healthLink = placeholderLink(HeartPulse, 'nav.health');
const psychologyLink = placeholderLink(Brain, 'nav.psychology');
const sportsLink = placeholderLink(Trophy, 'nav.sports');
const shoppingLink = placeholderLink(ShoppingCart, 'nav.shopping');
const gamesLink = placeholderLink(Gamepad2, 'nav.games');
const taxesLink = placeholderLink(Receipt, 'nav.taxes');
const legislationLink = placeholderLink(Scale, 'nav.legislation');
const placeholderLinks = [
medicineLink,
realEstateLink,
goodsLink,
childrenLink,
educationLink,
healthLink,
psychologyLink,
sportsLink,
shoppingLink,
gamesLink,
taxesLink,
legislationLink,
];
const linkMap: Record<string, NavLink> = {
discover: discoverLink,
library: libraryLink,
finance: financeLink,
travel: travelLink,
spaces: spacesLink,
medicine: medicineLink,
realEstate: realEstateLink,
goods: goodsLink,
children: childrenLink,
education: educationLink,
health: healthLink,
psychology: psychologyLink,
sports: sportsLink,
shopping: shoppingLink,
games: gamesLink,
taxes: taxesLink,
legislation: legislationLink,
profile: profileLink,
};
const itemLabels: Record<string, string> = Object.fromEntries(
Object.entries(linkMap).map(([id, link]) => [id, link.label]),
);
const orderedVisibleIds = menuConfig.order.filter((id) => menuConfig.visible[id] !== false);
const mainIds = orderedVisibleIds.filter((id) => id !== 'profile' && id !== 'spaces');
const topMainIds = mainIds.slice(0, 5);
const restMainIds = mainIds.slice(5);
const showSpaces = menuConfig.visible['spaces'] !== false;
const showProfile = menuConfig.visible['profile'] !== false;
const refreshMenuConfig = useCallback(() => {
setMenuConfig(getSidebarMenuConfig());
}, []);
useEffect(() => {
const handler = () => refreshMenuConfig();
window.addEventListener('client-config-changed', handler);
return () => window.removeEventListener('client-config-changed', handler);
}, [refreshMenuConfig]);
const handleHistoryMouseEnter = () => {
setTooltip(null);
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
if (historyRef.current) {
const rect = historyRef.current.getBoundingClientRect();
setSubmenuStyle({ top: 0, left: rect.right + 4 });
}
setHistoryOpen(true);
};
const handleHistoryMouseLeave = () => {
hideTimeoutRef.current = setTimeout(() => setHistoryOpen(false), 150);
};
const cancelHide = () => {
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
hideTimeoutRef.current = null;
}
};
const handleMoreEnter = () => {
setTooltip(null);
if (moreHideRef.current) {
clearTimeout(moreHideRef.current);
moreHideRef.current = null;
}
if (moreRef.current) {
const rect = moreRef.current.getBoundingClientRect();
setSubmenuStyle({ top: 0, left: rect.right + 4 });
}
setMoreOpen(true);
};
const handleMoreLeave = () => {
moreHideRef.current = setTimeout(() => setMoreOpen(false), 150);
};
const cancelMoreHide = () => {
if (moreHideRef.current) {
clearTimeout(moreHideRef.current);
moreHideRef.current = null;
}
};
useEffect(() => {
return () => {
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
if (moreHideRef.current) clearTimeout(moreHideRef.current);
};
}, []);
useEffect(() => {
if (!moreOpen || !isMobile) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMoreOpen(false);
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [moreOpen, isMobile]);
const showTooltip = (label: string, e: React.MouseEvent<HTMLElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
setTooltip({ label, x: rect.right + 8, y: rect.top + rect.height / 2 });
};
const hideTooltip = () => setTooltip(null);
return (
<div>
{tooltip &&
typeof document !== 'undefined' &&
createPortal(
<div
className="fixed z-[9998] -translate-y-1/2 px-2.5 py-1.5 rounded-md bg-black/90 dark:bg-white/90 text-white dark:text-black text-xs font-medium whitespace-nowrap pointer-events-none"
style={{ left: tooltip.x, top: tooltip.y }}
>
{tooltip.label}
</div>,
document.body,
)}
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-[56px] lg:flex-col border-r border-light-200 dark:border-dark-200">
<div className="flex grow flex-col items-center justify-start gap-y-1 overflow-y-auto bg-light-secondary dark:bg-dark-secondary px-2 py-6 shadow-sm shadow-light-200/10 dark:shadow-black/25">
<a
className="p-2.5 rounded-full bg-light-200 text-black/70 dark:bg-dark-200 dark:text-white/70 hover:opacity-70 hover:scale-105 transition duration-200 cursor-pointer"
href="/"
onMouseEnter={(e) => showTooltip(t('chat.newChat'), e)}
onMouseLeave={hideTooltip}
>
<Plus size={19} className="cursor-pointer" />
</a>
{showSpaces && (
<Link
href={spacesLink.href}
className={cn(
'relative flex flex-col items-center justify-center cursor-pointer w-full py-2 rounded-lg',
spacesLink.active
? 'text-black/70 dark:text-white/70'
: 'text-black/60 dark:text-white/60',
)}
onMouseEnter={(e) => showTooltip(spacesLink.label, e)}
onMouseLeave={hideTooltip}
>
<div
className={cn(
spacesLink.active && 'bg-light-200 dark:bg-dark-200',
'group rounded-lg hover:bg-light-200 hover:dark:bg-dark-200 transition duration-200',
)}
>
<spacesLink.icon
size={25}
className={cn(
!spacesLink.active && 'group-hover:scale-105',
'transition duration:200 m-1.5',
)}
/>
</div>
</Link>
)}
<VerticalIconContainer>
{topMainIds.map((id) => {
if (id === 'library') {
return (
<div
key={id}
ref={historyRef}
className="relative w-full"
onMouseEnter={handleHistoryMouseEnter}
onMouseLeave={handleHistoryMouseLeave}
>
<Link
href={libraryLink.href}
className={cn(
'relative flex flex-col items-center justify-center cursor-pointer w-full py-2 rounded-lg',
libraryLink.active
? 'text-black/70 dark:text-white/70'
: 'text-black/60 dark:text-white/60',
)}
onMouseEnter={(e) => showTooltip(libraryLink.label, e)}
onMouseLeave={hideTooltip}
>
<div
className={cn(
libraryLink.active && 'bg-light-200 dark:bg-dark-200',
'group rounded-lg hover:bg-light-200 hover:dark:bg-dark-200 transition duration-200',
)}
>
<libraryLink.icon
size={25}
className={cn(
!libraryLink.active && 'group-hover:scale-105',
'transition duration:200 m-1.5',
)}
/>
</div>
</Link>
{historyOpen &&
typeof document !== 'undefined' &&
createPortal(
<div
style={{ top: submenuStyle.top, left: submenuStyle.left }}
className="fixed z-[9999]"
>
<HistorySubmenu
onMouseEnter={cancelHide}
onMouseLeave={handleHistoryMouseLeave}
/>
</div>,
document.body,
)}
</div>
);
}
const link = linkMap[id];
if (!link) return null;
if (link.href) {
return (
<Link
key={id}
href={link.href}
className={cn(
'relative flex flex-col items-center justify-center cursor-pointer w-full py-2 rounded-lg',
link.active
? 'text-black/70 dark:text-white/70'
: 'text-black/60 dark:text-white/60',
)}
onMouseEnter={(e) => showTooltip(link.label, e)}
onMouseLeave={hideTooltip}
>
<div
className={cn(
link.active && 'bg-light-200 dark:bg-dark-200',
'group rounded-lg hover:bg-light-200 hover:dark:bg-dark-200 transition duration-200',
)}
>
<link.icon
size={25}
className={cn(
!link.active && 'group-hover:scale-105',
'transition duration:200 m-1.5',
)}
/>
</div>
</Link>
);
}
return (
<span
key={id}
className={cn(
'relative flex flex-col items-center justify-center w-full py-2 rounded-lg cursor-default',
'text-black/50 dark:text-white/50',
)}
onMouseEnter={(e) => showTooltip(link.label, e)}
onMouseLeave={hideTooltip}
>
<div className="group rounded-lg opacity-80">
<link.icon size={25} className="m-1.5" />
</div>
</span>
);
})}
<div
ref={moreRef}
className="relative w-full"
onMouseEnter={handleMoreEnter}
onMouseLeave={handleMoreLeave}
>
<button
type="button"
onClick={() => {
if (moreRef.current) {
const rect = moreRef.current.getBoundingClientRect();
setSubmenuStyle({ top: 0, left: rect.right + 4 });
}
setMoreOpen((prev) => !prev);
}}
className={cn(
'relative flex flex-col items-center justify-center w-full py-2 rounded-lg cursor-pointer',
'text-black/60 dark:text-white/60 hover:text-black/70 dark:hover:text-white/70',
)}
onMouseEnter={(e) => showTooltip(t('nav.more'), e)}
onMouseLeave={hideTooltip}
>
<div className="group rounded-lg hover:bg-light-200 hover:dark:bg-dark-200 transition duration-200">
<MoreHorizontal
size={25}
className="group-hover:scale-105 transition duration:200 m-1.5"
/>
</div>
</button>
{moreOpen &&
typeof document !== 'undefined' &&
createPortal(
isMobile ? (
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/20 dark:bg-black/40"
onClick={() => setMoreOpen(false)}
role="button"
tabIndex={0}
aria-label={t('common.close')}
>
<div
className="mx-4 max-w-sm w-full max-h-[85vh] overflow-y-auto rounded-xl bg-light-secondary dark:bg-dark-secondary shadow-xl border border-light-200 dark:border-dark-200"
onClick={(e) => e.stopPropagation()}
onMouseEnter={cancelMoreHide}
onMouseLeave={() => {}}
>
<MoreSubmenu
restIds={restMainIds}
linkMap={linkMap}
itemLabels={itemLabels}
submenuStyle={submenuStyle}
isMobile={isMobile}
onMouseEnter={cancelMoreHide}
onMouseLeave={() => setMoreOpen(false)}
cancelMoreHide={cancelMoreHide}
/>
</div>
</div>
) : (
<div
style={{ top: submenuStyle.top, left: submenuStyle.left }}
className="fixed z-[9999]"
>
<MoreSubmenu
restIds={restMainIds}
linkMap={linkMap}
itemLabels={itemLabels}
submenuStyle={submenuStyle}
isMobile={isMobile}
onMouseEnter={cancelMoreHide}
onMouseLeave={handleMoreLeave}
cancelMoreHide={cancelMoreHide}
/>
</div>
),
document.body,
)}
</div>
</VerticalIconContainer>
<div className="mt-auto pt-4 flex flex-col items-center gap-y-1">
{showProfile && (
<Link
href={profileLink.href}
className={cn(
'relative flex flex-col items-center justify-center cursor-pointer w-full py-2 rounded-lg',
profileLink.active
? 'text-black/70 dark:text-white/70'
: 'text-black/60 dark:text-white/60',
)}
onMouseEnter={(e) => showTooltip(profileLink.label, e)}
onMouseLeave={hideTooltip}
>
<div
className={cn(
profileLink.active && 'bg-light-200 dark:bg-dark-200',
'group rounded-lg hover:bg-light-200 hover:dark:bg-dark-200 transition duration-200',
)}
>
<profileLink.icon
size={25}
className={cn(
!profileLink.active && 'group-hover:scale-105',
'transition duration:200 m-1.5',
)}
/>
</div>
</Link>
)}
</div>
</div>
</div>
<div className="fixed bottom-0 w-full z-50 flex flex-row items-center gap-x-6 bg-light-secondary dark:bg-dark-secondary px-4 py-4 shadow-sm lg:hidden">
{topMainIds.map((id) => {
const link = linkMap[id];
if (!link) return null;
return link.href ? (
<Link
href={link.href}
key={id}
className={cn(
'relative flex flex-col items-center space-y-1 text-center w-full',
link.active
? 'text-black dark:text-white'
: 'text-black dark:text-white/70',
)}
>
{link.active && (
<div className="absolute top-0 -mt-4 h-1 w-full rounded-b-lg bg-black dark:bg-white" />
)}
<link.icon />
<p className="text-xs text-fade min-w-0">{link.label}</p>
</Link>
) : (
<span
key={id}
className="relative flex flex-col items-center space-y-1 text-center w-full text-black/50 dark:text-white/50 cursor-default"
>
<link.icon />
<p className="text-xs text-fade min-w-0">{link.label}</p>
</span>
);
})}
<button
type="button"
onClick={() => setMoreOpen(true)}
className="relative flex flex-col items-center space-y-1 text-center shrink-0 text-black/70 dark:text-white/70"
title={t('nav.more')}
aria-label={t('nav.more')}
>
<MoreHorizontal size={20} />
<p className="text-xs text-fade">{t('nav.more')}</p>
</button>
{showProfile && (
<Link
href={profileLink.href}
className={cn(
'relative flex flex-col items-center space-y-1 text-center shrink-0',
profileLink.active
? 'text-black dark:text-white'
: 'text-black dark:text-white/70',
)}
title={profileLink.label}
>
{profileLink.active && (
<div className="absolute top-0 -mt-4 h-1 w-full rounded-b-lg bg-black dark:bg-white" />
)}
<profileLink.icon size={20} />
<p className="text-xs text-fade min-w-0">{profileLink.label}</p>
</Link>
)}
</div>
<Layout>{children}</Layout>
</div>
);
};
export default Sidebar;

View File

@@ -0,0 +1,220 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { GripVertical, ChevronUp, ChevronDown } from 'lucide-react';
import { useTranslation } from '@/lib/localization/context';
import {
getSidebarMenuConfig,
setSidebarMenuConfig,
DEFAULT_ORDER,
} from '@/lib/config/sidebarMenu';
import { cn } from '@/lib/utils';
interface MenuSettingsPanelProps {
onMouseEnter: () => void;
onMouseLeave: () => void;
itemLabels: Record<string, string>;
}
export default function MenuSettingsPanel({
onMouseEnter,
onMouseLeave,
itemLabels,
}: MenuSettingsPanelProps) {
const { t } = useTranslation();
const [config, setConfig] = useState(() => getSidebarMenuConfig());
const refreshConfig = useCallback(() => {
setConfig(getSidebarMenuConfig());
}, []);
useEffect(() => {
const handler = () => refreshConfig();
window.addEventListener('client-config-changed', handler);
return () => window.removeEventListener('client-config-changed', handler);
}, [refreshConfig]);
const toggleVisible = (id: string) => {
const next = { ...config.visible, [id]: !config.visible[id] };
setSidebarMenuConfig({ ...config, visible: next });
setConfig({ ...config, visible: next });
};
const mainIds = config.order.filter((id) => id !== 'profile' && id !== 'spaces');
const hasSpaces = config.order.includes('spaces');
const displayOrder = [...mainIds, ...(hasSpaces ? ['spaces'] : [])];
const moveUp = (displayIndex: number) => {
if (displayIndex <= 0) return;
const idA = displayOrder[displayIndex - 1];
const idB = displayOrder[displayIndex];
const order = [...config.order];
const idxA = order.indexOf(idA);
const idxB = order.indexOf(idB);
order[idxA] = idB;
order[idxB] = idA;
setSidebarMenuConfig({ ...config, order });
setConfig({ ...config, order });
};
const moveDown = (displayIndex: number) => {
if (displayIndex >= displayOrder.length - 1) return;
const idA = displayOrder[displayIndex];
const idB = displayOrder[displayIndex + 1];
const order = [...config.order];
const idxA = order.indexOf(idA);
const idxB = order.indexOf(idB);
order[idxA] = idB;
order[idxB] = idA;
setSidebarMenuConfig({ ...config, order });
setConfig({ ...config, order });
};
const moveToIndex = (fromIndex: number, toIndex: number) => {
if (fromIndex === toIndex || toIndex < 0 || toIndex >= displayOrder.length) return;
const newDisplayOrder = [...displayOrder];
const [removed] = newDisplayOrder.splice(fromIndex, 1);
newDisplayOrder.splice(toIndex, 0, removed);
const hasSpaces = config.order.includes('spaces');
const newMainIds = newDisplayOrder.filter((id) => id !== 'profile' && id !== 'spaces');
const newOrder = [...newMainIds, ...(hasSpaces ? ['spaces'] : []), 'profile'];
setSidebarMenuConfig({ ...config, order: newOrder });
setConfig({ ...config, order: newOrder });
};
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const handleDragStart = (e: React.DragEvent, index: number) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(index));
e.dataTransfer.setData('application/json', JSON.stringify({ index }));
setDraggedIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverIndex(index);
};
const handleDragLeave = () => {
setDragOverIndex(null);
};
const handleDrop = (e: React.DragEvent, toIndex: number) => {
e.preventDefault();
const fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10);
setDraggedIndex(null);
setDragOverIndex(null);
if (!Number.isNaN(fromIndex) && fromIndex !== toIndex) {
moveToIndex(fromIndex, toIndex);
}
};
const handleDragEnd = () => {
setDraggedIndex(null);
setDragOverIndex(null);
};
const resetToDefault = () => {
const defaultConfig = {
order: [...DEFAULT_ORDER],
visible: Object.fromEntries(DEFAULT_ORDER.map((id) => [id, true])),
};
setSidebarMenuConfig(defaultConfig);
setConfig(defaultConfig);
};
return (
<div
className="min-w-[180px] w-56 h-screen overflow-y-auto bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 shadow-xl py-2 z-[9999]"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<h3 className="text-sm font-medium text-black dark:text-white px-4 mb-1.5">
{t('nav.sidebarSettings')}
</h3>
<p className="text-xs text-black/60 dark:text-white/60 px-4 mb-2">
{t('nav.menuSettingsHint')}
</p>
<div className="flex flex-col gap-0.5 px-2">
{displayOrder.map((id, index) => (
<div
key={id}
onDragOver={(e) => handleDragOver(e, index)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, index)}
className={cn(
'flex items-center gap-1.5 py-1 px-2',
'hover:bg-light-200 dark:hover:bg-dark-200 transition-colors',
dragOverIndex === index && 'bg-light-200 dark:bg-dark-200 ring-1 ring-inset ring-black/10 dark:ring-white/10',
)}
>
<div
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragEnd={handleDragEnd}
className={cn(
'flex items-center gap-0.5 cursor-grab active:cursor-grabbing touch-none',
draggedIndex === index && 'opacity-50',
)}
title={t('nav.menuSettingsHint')}
>
<div className="flex flex-col gap-0.5">
<button
type="button"
onClick={() => moveUp(index)}
disabled={index === 0}
className="p-0.5 rounded hover:bg-light-200 dark:hover:bg-dark-200 disabled:opacity-30 disabled:cursor-not-allowed"
aria-label={t('nav.moveUp')}
>
<ChevronUp size={14} />
</button>
<button
type="button"
onClick={() => moveDown(index)}
disabled={index === displayOrder.length - 1}
className="p-0.5 rounded hover:bg-light-200 dark:hover:bg-dark-200 disabled:opacity-30 disabled:cursor-not-allowed"
aria-label={t('nav.moveDown')}
>
<ChevronDown size={14} />
</button>
</div>
<GripVertical size={14} className="text-black/40 dark:text-white/40 shrink-0" />
</div>
<span className="flex-1 text-sm text-black dark:text-white truncate min-w-0">
{itemLabels[id] ?? id}
</span>
<button
type="button"
role="switch"
aria-checked={config.visible[id] !== false}
onClick={() => toggleVisible(id)}
className={cn(
'relative flex h-4 w-7 shrink-0 rounded-full transition-colors',
config.visible[id] !== false
? 'bg-black dark:bg-white'
: 'bg-light-300 dark:bg-dark-300',
)}
>
<span
className={cn(
'absolute top-0.5 h-3 w-3 rounded-full bg-white dark:bg-black transition-transform',
config.visible[id] !== false ? 'translate-x-3 left-0.5' : 'translate-x-0 left-0.5',
)}
/>
</button>
</div>
))}
</div>
<button
type="button"
onClick={resetToDefault}
className="mt-2 mx-4 py-1.5 px-4 text-xs text-black/60 dark:text-white/60 hover:text-black dark:hover:text-white hover:bg-light-200 dark:hover:bg-dark-200 transition-colors"
>
{t('nav.resetMenu')}
</button>
</div>
);
}

View File

@@ -0,0 +1,51 @@
'use client';
import { useEffect, useState } from 'react';
import { ChevronDown, ChevronUp, BrainCircuit } from 'lucide-react';
interface ThinkBoxProps {
content: string;
thinkingEnded: boolean;
}
const ThinkBox = ({ content, thinkingEnded }: ThinkBoxProps) => {
const [isExpanded, setIsExpanded] = useState(true);
useEffect(() => {
if (thinkingEnded) {
setIsExpanded(false);
} else {
setIsExpanded(true);
}
}, [thinkingEnded]);
return (
<div className="my-4 bg-light-secondary/50 dark:bg-dark-secondary/50 rounded-xl border border-light-200 dark:border-dark-200 overflow-hidden">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-4 py-1 text-black/90 dark:text-white/90 hover:bg-light-200 dark:hover:bg-dark-200 transition duration-200"
>
<div className="flex items-center space-x-2">
<BrainCircuit
size={20}
className="text-[#9C27B0] dark:text-[#CE93D8]"
/>
<p className="font-medium text-sm">Thinking Process</p>
</div>
{isExpanded ? (
<ChevronUp size={18} className="text-black/70 dark:text-white/70" />
) : (
<ChevronDown size={18} className="text-black/70 dark:text-white/70" />
)}
</button>
{isExpanded && (
<div className="px-4 py-3 text-black/80 dark:text-white/80 text-sm border-t border-light-200 dark:border-dark-200 bg-light-100/50 dark:bg-dark-100/50 whitespace-pre-wrap">
{content}
</div>
)}
</div>
);
};
export default ThinkBox;

View File

@@ -0,0 +1,445 @@
'use client';
/**
* Travel Stepper — Поиск → Места → Маршрут → Отели → Билеты
* docs/architecture: 01-perplexity-analogue-design.md §2.2.D
* Состояние сохраняется в travel-svc (Redis) или sessionStorage (fallback)
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { cn } from '@/lib/utils';
import {
Search,
MapPin,
Route,
Hotel,
Plane,
ChevronRight,
ChevronLeft,
X,
} from 'lucide-react';
const STEPS = [
{ id: 'search', label: 'Search', icon: Search },
{ id: 'places', label: 'Places', icon: MapPin },
{ id: 'route', label: 'Route', icon: Route },
{ id: 'hotels', label: 'Hotels', icon: Hotel },
{ id: 'tickets', label: 'Tickets', icon: Plane },
] as const;
export type TravelStepperStepId = (typeof STEPS)[number]['id'];
export interface ItineraryDay {
day: number;
title: string;
activities: string[];
tips?: string;
}
export interface TravelStepperState {
step: TravelStepperStepId;
searchQuery: string;
places: string[];
route: string | null;
itineraryDays: number;
hotels: string[];
tickets: string | null;
updatedAt: number;
}
const DEFAULT_STATE: TravelStepperState = {
step: 'search',
searchQuery: '',
places: [],
route: null,
itineraryDays: 3,
hotels: [],
tickets: null,
updatedAt: Date.now(),
};
const STORAGE_KEY = 'gooseek_travel_stepper_session';
const API_PREFIX = '/api/v1/travel';
function getOrCreateSessionId(): string {
if (typeof window === 'undefined') return '';
let id = sessionStorage.getItem(STORAGE_KEY);
if (!id) {
id = crypto.randomUUID();
sessionStorage.setItem(STORAGE_KEY, id);
}
return id;
}
async function saveState(sessionId: string, state: TravelStepperState): Promise<boolean> {
try {
const res = await fetch(`${API_PREFIX}/stepper/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, state }),
});
return res.ok;
} catch {
return false;
}
}
async function fetchItinerary(query: string, days: number): Promise<{ days: ItineraryDay[]; summary?: string } | null> {
try {
const res = await fetch(`${API_PREFIX}/itinerary`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, days }),
signal: AbortSignal.timeout(90000),
});
const data = await res.json();
if (data?.days?.length) return data;
return null;
} catch {
return null;
}
}
async function loadState(sessionId: string): Promise<TravelStepperState | null> {
try {
const res = await fetch(`${API_PREFIX}/stepper/state/${sessionId}`);
const data = await res.json();
return data?.state ?? null;
} catch {
return null;
}
}
interface TravelStepperProps {
onClose: () => void;
}
export default function TravelStepper({ onClose }: TravelStepperProps) {
const [sessionId, setSessionId] = useState('');
const [state, setState] = useState<TravelStepperState>(DEFAULT_STATE);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [itineraryData, setItineraryData] = useState<{ days: ItineraryDay[]; summary?: string } | null>(null);
const [itineraryLoading, setItineraryLoading] = useState(false);
const [itineraryError, setItineraryError] = useState<string | null>(null);
const stepIndex = useMemo(
() => STEPS.findIndex((s) => s.id === state.step),
[state.step]
);
const canPrev = stepIndex > 0;
const canNext = stepIndex < STEPS.length - 1;
const persistState = useCallback(
async (next: TravelStepperState) => {
if (!sessionId) return;
setSaving(true);
const ok = await saveState(sessionId, next);
setSaving(false);
if (!ok && typeof window !== 'undefined') {
try {
sessionStorage.setItem('gooseek_travel_stepper_state', JSON.stringify(next));
} catch {
/* noop */
}
}
},
[sessionId]
);
useEffect(() => {
const id = getOrCreateSessionId();
setSessionId(id);
let cancelled = false;
(async () => {
const loaded = await loadState(id);
if (cancelled) return;
if (loaded && typeof loaded.step === 'string') {
setState({
...DEFAULT_STATE,
...loaded,
updatedAt: Date.now(),
});
} else if (typeof window !== 'undefined') {
try {
const raw = sessionStorage.getItem('gooseek_travel_stepper_state');
if (raw) {
const parsed = JSON.parse(raw) as Partial<TravelStepperState>;
if (parsed?.step) {
setState({ ...DEFAULT_STATE, ...parsed });
}
}
} catch {
/* noop */
}
}
setLoading(false);
})();
return () => {
cancelled = true;
};
}, []);
const goTo = useCallback(
(step: TravelStepperStepId) => {
const next = { ...state, step, updatedAt: Date.now() };
setState(next);
persistState(next);
},
[state, persistState]
);
const goPrev = useCallback(() => {
if (!canPrev) return;
const prevStep = STEPS[stepIndex - 1].id;
goTo(prevStep);
}, [canPrev, stepIndex, goTo]);
const goNext = useCallback(() => {
if (!canNext) return;
const nextStep = STEPS[stepIndex + 1].id;
goTo(nextStep);
}, [canNext, stepIndex, goTo]);
useEffect(() => {
if (state.step !== 'route' || !state.searchQuery.trim()) {
setItineraryData(null);
setItineraryError(null);
return;
}
let cancelled = false;
setItineraryLoading(true);
setItineraryError(null);
fetchItinerary(state.searchQuery, state.itineraryDays ?? 3)
.then((data) => {
if (!cancelled && data) setItineraryData(data);
else if (!cancelled) setItineraryError('Could not generate itinerary. OPENAI_API_KEY may be missing.');
})
.catch(() => {
if (!cancelled) setItineraryError('Request failed. Try again.');
})
.finally(() => {
if (!cancelled) setItineraryLoading(false);
});
return () => { cancelled = true; };
}, [state.step, state.searchQuery, state.itineraryDays]);
const updateSearch = useCallback(
(q: string) => {
const next = { ...state, searchQuery: q, updatedAt: Date.now() };
setState(next);
persistState(next);
},
[state, persistState]
);
const updateItineraryDays = useCallback(
(d: number) => {
const next = { ...state, itineraryDays: Math.min(14, Math.max(1, d)), updatedAt: Date.now() };
setState(next);
persistState(next);
setItineraryData(null);
},
[state, persistState]
);
if (loading) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="rounded-2xl bg-light-secondary dark:bg-dark-secondary p-8 animate-pulse">
<div className="h-6 w-48 bg-light-200 dark:bg-dark-200 rounded mb-4" />
<div className="h-4 w-32 bg-light-200 dark:bg-dark-200 rounded" />
</div>
</div>
);
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40">
<div
className={cn(
'w-full max-w-2xl rounded-2xl shadow-xl',
'bg-light-secondary dark:bg-dark-secondary border border-light-200/20 dark:border-dark-200/20',
'overflow-hidden'
)}
>
<div className="flex items-center justify-between p-4 border-b border-light-200/20 dark:border-dark-200/20">
<h2 className="text-lg font-semibold">Plan your trip</h2>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-light-200/50 dark:hover:bg-dark-200/50 transition"
aria-label="Close"
>
<X size={20} />
</button>
</div>
<div className="flex gap-1 p-4 overflow-x-auto">
{STEPS.map((s, i) => {
const Icon = s.icon;
const isActive = s.id === state.step;
const isDone = i < stepIndex;
return (
<button
key={s.id}
onClick={() => goTo(s.id)}
className={cn(
'flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm whitespace-nowrap transition',
isActive && 'bg-[#EA580C]/20 text-[#EA580C]',
isDone && !isActive && 'text-black/60 dark:text-white/60',
!isActive && !isDone && 'text-black/40 dark:text-white/40 hover:bg-light-200/50 dark:hover:bg-dark-200/50'
)}
>
<Icon size={16} />
{s.label}
</button>
);
})}
</div>
<div className="p-6 min-h-[200px]">
{state.step === 'search' && (
<div>
<label className="block text-sm font-medium mb-2">Where would you like to go?</label>
<input
type="text"
value={state.searchQuery}
onChange={(e) => updateSearch(e.target.value)}
placeholder="e.g. Paris, Japan, Iceland"
className={cn(
'w-full px-4 py-3 rounded-xl border',
'bg-light-primary dark:bg-dark-primary border-light-200 dark:border-dark-200',
'focus:outline-none focus:ring-2 focus:ring-[#EA580C]/50'
)}
autoFocus
/>
</div>
)}
{state.step === 'places' && (
<div>
<p className="text-black/60 dark:text-white/60 text-sm">
Places step search results for &quot;{state.searchQuery || '...'}&quot; will appear here.
</p>
<p className="text-sm mt-2 text-black/40 dark:text-white/40">
Coming soon: integration with search and map APIs.
</p>
</div>
)}
{state.step === 'route' && (
<div>
<div className="flex items-center gap-3 mb-4">
<label className="text-sm font-medium">Duration:</label>
<select
value={state.itineraryDays ?? 3}
onChange={(e) => updateItineraryDays(parseInt(e.target.value, 10))}
className="px-3 py-2 rounded-lg border bg-light-primary dark:bg-dark-primary border-light-200 dark:border-dark-200 text-sm"
>
{[1, 3, 5, 7, 10, 14].map((d) => (
<option key={d} value={d}>{d} day{d > 1 ? 's' : ''}</option>
))}
</select>
<span className="text-black/50 dark:text-white/50 text-sm">
for &quot;{state.searchQuery || '...'}&quot;
</span>
</div>
{itineraryLoading ? (
<div className="space-y-3 animate-pulse">
<div className="h-4 w-3/4 bg-light-200 dark:bg-dark-200 rounded" />
<div className="h-3 w-full bg-light-200 dark:bg-dark-200 rounded" />
<div className="h-3 w-2/3 bg-light-200 dark:bg-dark-200 rounded" />
</div>
) : itineraryError ? (
<div>
<p className="text-sm text-red-600 dark:text-red-400">{itineraryError}</p>
<button
type="button"
onClick={() => {
setItineraryError(null);
setItineraryLoading(true);
fetchItinerary(state.searchQuery, state.itineraryDays ?? 3)
.then((data) => {
if (data) setItineraryData(data);
else setItineraryError('Could not generate itinerary.');
})
.catch(() => setItineraryError('Request failed. Try again.'))
.finally(() => setItineraryLoading(false));
}}
className="mt-2 text-sm text-[#EA580C] hover:underline"
>
Retry
</button>
</div>
) : itineraryData?.days?.length ? (
<div className="space-y-4 max-h-[280px] overflow-y-auto">
{itineraryData.summary && (
<p className="text-sm text-black/70 dark:text-white/70">{itineraryData.summary}</p>
)}
{itineraryData.days.map((d) => (
<div
key={d.day}
className="p-3 rounded-xl bg-light-primary dark:bg-dark-primary border border-light-200/50 dark:border-dark-200/50"
>
<h4 className="font-medium text-sm">{d.title}</h4>
<ul className="mt-2 space-y-1 text-sm text-black/70 dark:text-white/70">
{d.activities.map((a, i) => (
<li key={i}> {a}</li>
))}
</ul>
{d.tips && (
<p className="mt-2 text-xs text-[#EA580C]">{d.tips}</p>
)}
</div>
))}
</div>
) : (
<p className="text-sm text-black/50 dark:text-white/50">
Enter a destination in the Search step first, then return here.
</p>
)}
</div>
)}
{state.step === 'hotels' && (
<div>
<p className="text-black/60 dark:text-white/60 text-sm">Hotels Tripadvisor and Selfbook integration.</p>
<p className="text-sm mt-2 text-black/40 dark:text-white/40">Coming soon: hotel recommendations.</p>
</div>
)}
{state.step === 'tickets' && (
<div>
<p className="text-black/60 dark:text-white/60 text-sm">Tickets flight and transport options.</p>
<p className="text-sm mt-2 text-black/40 dark:text-white/40">Coming soon: booking integration.</p>
</div>
)}
</div>
<div className="flex items-center justify-between p-4 border-t border-light-200/20 dark:border-dark-200/20">
<button
onClick={goPrev}
disabled={!canPrev}
className={cn(
'flex items-center gap-1 px-4 py-2 rounded-lg text-sm font-medium transition',
canPrev
? 'hover:bg-light-200/50 dark:hover:bg-dark-200/50'
: 'opacity-40 cursor-not-allowed'
)}
>
<ChevronLeft size={18} />
Back
</button>
{saving && <span className="text-xs text-black/40 dark:text-white/40">Saving...</span>}
<button
onClick={goNext}
disabled={!canNext}
className={cn(
'flex items-center gap-1 px-4 py-2 rounded-lg text-sm font-medium bg-[#EA580C] text-white transition hover:opacity-90',
!canNext && 'opacity-50 cursor-not-allowed'
)}
>
Next
<ChevronRight size={18} />
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,222 @@
import { Wind } from 'lucide-react';
import { useEffect, useState } from 'react';
import { fetchContextWithGeolocation } from '@/lib/geoDevice';
const WeatherWidget = () => {
const [data, setData] = useState({
temperature: 0,
condition: '',
location: '',
humidity: 0,
windSpeed: 0,
icon: '',
temperatureUnit: 'C',
windSpeedUnit: 'm/s',
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const fetchWeather = async (
lat: number,
lng: number,
city: string,
) => {
const res = await fetch('/api/weather', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
lat,
lng,
city: city || undefined,
measureUnit: localStorage.getItem('measureUnit') ?? 'Metric',
}),
});
const weatherData = await res.json();
if (res.status !== 200) {
throw new Error(weatherData.message ?? 'Weather fetch failed');
}
setData({
temperature: weatherData.temperature,
condition: weatherData.condition,
location: weatherData.city ?? city ?? 'Unknown',
humidity: weatherData.humidity,
windSpeed: weatherData.windSpeed,
icon: weatherData.icon,
temperatureUnit: weatherData.temperatureUnit,
windSpeedUnit: weatherData.windSpeedUnit,
});
};
const updateWeather = async () => {
setError(false);
setLoading(true);
try {
let context: Awaited<ReturnType<typeof fetchContextWithGeolocation>> | null =
null;
try {
context = await fetchContextWithGeolocation();
} catch {
// geo-device не запущен (503) — пропускаем
}
if (context?.geo?.latitude != null && context.geo.longitude != null) {
const city =
context.geo.city ||
(await reverseGeocode(context.geo.latitude, context.geo.longitude));
await fetchWeather(
context.geo.latitude,
context.geo.longitude,
city,
);
} else {
await tryIpFallback();
}
} catch {
await tryIpFallback();
} finally {
setLoading(false);
}
};
const reverseGeocode = async (
lat: number,
lng: number,
): Promise<string> => {
try {
const res = await fetch(
`https://api-bdc.io/data/reverse-geocode-client?latitude=${lat}&longitude=${lng}&localityLanguage=en`,
);
const d = await res.json();
return d?.locality ?? '';
} catch {
return '';
}
};
const tryIpFallback = async () => {
const providers: Array<{
url: string;
getCoords: (d: Record<string, unknown>) => { lat: number; lng: number; city: string } | null;
}> = [
{
url: 'https://get.geojs.io/v1/ip/geo.json',
getCoords: (d) => {
const lat = Number(d.latitude);
const lng = Number(d.longitude);
if (Number.isFinite(lat) && Number.isFinite(lng)) {
return {
lat,
lng,
city: String(d.city ?? d.region ?? '').trim() || 'Unknown',
};
}
return null;
},
},
{
url: 'https://ipwhois.app/json/',
getCoords: (d) => {
const lat = Number(d.latitude);
const lng = Number(d.longitude);
if (Number.isFinite(lat) && Number.isFinite(lng)) {
return {
lat,
lng,
city: String(d.city ?? '').trim() || 'Unknown',
};
}
return null;
},
},
];
for (const p of providers) {
try {
const res = await fetch(p.url);
const d = (await res.json()) as Record<string, unknown>;
const coords = p.getCoords(d);
if (coords) {
await fetchWeather(coords.lat, coords.lng, coords.city || 'Unknown');
return;
}
} catch {
// следующий провайдер
}
}
setError(true);
};
useEffect(() => {
updateWeather();
const intervalId = setInterval(updateWeather, 30 * 60 * 1000);
return () => clearInterval(intervalId);
}, []);
return (
<div className="bg-light-secondary dark:bg-dark-secondary rounded-2xl border border-light-200 dark:border-dark-200 shadow-sm shadow-light-200/10 dark:shadow-black/25 flex flex-row items-center w-full h-20 min-h-[80px] max-h-[80px] px-2.5 py-1.5 gap-2">
{error ? (
<div className="flex items-center justify-center w-full h-full text-xs text-black/50 dark:text-white/50">
Weather unavailable
</div>
) : loading ? (
<>
<div className="flex flex-col items-center justify-center w-12 min-w-12 max-w-12 h-full animate-pulse">
<div className="h-8 w-8 rounded-full bg-light-200 dark:bg-dark-200 mb-1" />
<div className="h-3 w-8 rounded bg-light-200 dark:bg-dark-200" />
</div>
<div className="flex flex-col justify-between flex-1 h-full py-1 animate-pulse">
<div className="flex flex-row items-center justify-between">
<div className="h-3 w-20 rounded bg-light-200 dark:bg-dark-200" />
<div className="h-3 w-12 rounded bg-light-200 dark:bg-dark-200" />
</div>
<div className="h-3 w-16 rounded bg-light-200 dark:bg-dark-200 mt-1" />
<div className="flex flex-row justify-between w-full mt-auto pt-1 border-t border-light-200 dark:border-dark-200">
<div className="h-3 w-16 rounded bg-light-200 dark:bg-dark-200" />
<div className="h-3 w-8 rounded bg-light-200 dark:bg-dark-200" />
</div>
</div>
</>
) : (
<>
<div className="flex flex-col items-center justify-center w-12 min-w-12 max-w-12 h-full">
<img
src={`/weather-ico/${data.icon}.svg`}
alt={data.condition}
className="h-8 w-auto"
/>
<span className="text-sm font-semibold text-black dark:text-white">
{data.temperature}°{data.temperatureUnit}
</span>
</div>
<div className="flex flex-col justify-between flex-1 h-full py-1">
<div className="flex flex-row items-center justify-between">
<span className="text-xs font-semibold text-black dark:text-white truncate">
{data.location}
</span>
<span className="flex items-center text-[10px] text-black/60 dark:text-white/60 font-medium shrink-0">
<Wind className="w-2.5 h-2.5 mr-0.5" />
{data.windSpeed} {data.windSpeedUnit}
</span>
</div>
<span className="text-[10px] text-black/50 dark:text-white/50 italic truncate">
{data.condition}
</span>
<div className="flex flex-row justify-between w-full mt-auto pt-1 border-t border-light-200/50 dark:border-dark-200/50 text-[10px] text-black/50 dark:text-white/50 font-medium">
<span>Humidity {data.humidity}%</span>
<span className="font-semibold text-black/70 dark:text-white/70">
Now
</span>
</div>
</div>
</>
)}
</div>
);
};
export default WeatherWidget;

View File

@@ -0,0 +1,46 @@
'use client';
import { Calculator, Equal } from 'lucide-react';
type CalculationWidgetProps = {
expression: string;
result: number;
};
const Calculation = ({ expression, result }: CalculationWidgetProps) => {
return (
<div className="rounded-lg border border-light-200 dark:border-dark-200">
<div className="p-4 space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-black/60 dark:text-white/70">
<Calculator className="w-4 h-4" />
<span className="text-xs uppercase font-semibold tracking-wide">
Expression
</span>
</div>
<div className="rounded-lg border border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary p-3">
<code className="text-sm text-black dark:text-white font-mono break-all">
{expression}
</code>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-black/60 dark:text-white/70">
<Equal className="w-4 h-4" />
<span className="text-xs uppercase font-semibold tracking-wide">
Result
</span>
</div>
<div className="rounded-xl border border-light-200 dark:border-dark-200 bg-light-secondary dark:bg-dark-secondary p-5">
<div className="text-4xl font-bold text-black dark:text-white font-mono tabular-nums">
{result.toLocaleString()}
</div>
</div>
</div>
</div>
</div>
);
};
export default Calculation;

View File

@@ -0,0 +1,78 @@
import React from 'react';
import dynamic from 'next/dynamic';
import { Widget } from '../ChatWindow';
import Weather from './Weather';
import Calculation from './Calculation';
const Stock = dynamic(() => import('./Stock'), { ssr: false });
const Renderer = ({ widgets }: { widgets: Widget[] }) => {
return widgets.map((widget, index) => {
switch (widget.widgetType) {
case 'weather':
return (
<Weather
key={index}
location={widget.params.location}
current={widget.params.current}
daily={widget.params.daily}
timezone={widget.params.timezone}
/>
);
case 'calculation_result':
return (
<Calculation
expression={widget.params.expression}
result={widget.params.result}
key={index}
/>
);
case 'stock':
return (
<Stock
key={index}
symbol={widget.params.symbol}
shortName={widget.params.shortName}
longName={widget.params.longName}
exchange={widget.params.exchange}
currency={widget.params.currency}
marketState={widget.params.marketState}
regularMarketPrice={widget.params.regularMarketPrice}
regularMarketChange={widget.params.regularMarketChange}
regularMarketChangePercent={
widget.params.regularMarketChangePercent
}
regularMarketPreviousClose={
widget.params.regularMarketPreviousClose
}
regularMarketOpen={widget.params.regularMarketOpen}
regularMarketDayHigh={widget.params.regularMarketDayHigh}
regularMarketDayLow={widget.params.regularMarketDayLow}
regularMarketVolume={widget.params.regularMarketVolume}
averageDailyVolume3Month={widget.params.averageDailyVolume3Month}
marketCap={widget.params.marketCap}
fiftyTwoWeekLow={widget.params.fiftyTwoWeekLow}
fiftyTwoWeekHigh={widget.params.fiftyTwoWeekHigh}
trailingPE={widget.params.trailingPE}
forwardPE={widget.params.forwardPE}
dividendYield={widget.params.dividendYield}
earningsPerShare={widget.params.earningsPerShare}
website={widget.params.website}
postMarketPrice={widget.params.postMarketPrice}
postMarketChange={widget.params.postMarketChange}
postMarketChangePercent={widget.params.postMarketChangePercent}
preMarketPrice={widget.params.preMarketPrice}
preMarketChange={widget.params.preMarketChange}
preMarketChangePercent={widget.params.preMarketChangePercent}
chartData={widget.params.chartData}
comparisonData={widget.params.comparisonData}
error={widget.params.error}
/>
);
default:
return <div key={index}>Unknown widget type: {widget.widgetType}</div>;
}
});
};
export default Renderer;

View File

@@ -0,0 +1,517 @@
'use client';
import { Clock, ArrowUpRight, ArrowDownRight, Minus } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import {
createChart,
ColorType,
LineStyle,
BaselineSeries,
LineSeries,
} from 'lightweight-charts';
type StockWidgetProps = {
symbol: string;
shortName: string;
longName?: string;
exchange?: string;
currency?: string;
marketState?: string;
regularMarketPrice?: number;
regularMarketChange?: number;
regularMarketChangePercent?: number;
regularMarketPreviousClose?: number;
regularMarketOpen?: number;
regularMarketDayHigh?: number;
regularMarketDayLow?: number;
regularMarketVolume?: number;
averageDailyVolume3Month?: number;
marketCap?: number;
fiftyTwoWeekLow?: number;
fiftyTwoWeekHigh?: number;
trailingPE?: number;
forwardPE?: number;
dividendYield?: number;
earningsPerShare?: number;
website?: string;
postMarketPrice?: number;
postMarketChange?: number;
postMarketChangePercent?: number;
preMarketPrice?: number;
preMarketChange?: number;
preMarketChangePercent?: number;
chartData?: {
'1D'?: { timestamps: number[]; prices: number[] } | null;
'5D'?: { timestamps: number[]; prices: number[] } | null;
'1M'?: { timestamps: number[]; prices: number[] } | null;
'3M'?: { timestamps: number[]; prices: number[] } | null;
'6M'?: { timestamps: number[]; prices: number[] } | null;
'1Y'?: { timestamps: number[]; prices: number[] } | null;
MAX?: { timestamps: number[]; prices: number[] } | null;
} | null;
comparisonData?: Array<{
ticker: string;
name: string;
chartData: {
'1D'?: { timestamps: number[]; prices: number[] } | null;
'5D'?: { timestamps: number[]; prices: number[] } | null;
'1M'?: { timestamps: number[]; prices: number[] } | null;
'3M'?: { timestamps: number[]; prices: number[] } | null;
'6M'?: { timestamps: number[]; prices: number[] } | null;
'1Y'?: { timestamps: number[]; prices: number[] } | null;
MAX?: { timestamps: number[]; prices: number[] } | null;
};
}> | null;
error?: string;
};
const formatNumber = (num: number | undefined, decimals = 2): string => {
if (num === undefined || num === null) return 'N/A';
return num.toLocaleString(undefined, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
};
const formatLargeNumber = (num: number | undefined): string => {
if (num === undefined || num === null) return 'N/A';
if (num >= 1e12) return `$${(num / 1e12).toFixed(2)}T`;
if (num >= 1e9) return `$${(num / 1e9).toFixed(2)}B`;
if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
if (num >= 1e3) return `$${(num / 1e3).toFixed(2)}K`;
return `$${num.toFixed(2)}`;
};
const Stock = (props: StockWidgetProps) => {
const [isDarkMode, setIsDarkMode] = useState(false);
const [selectedTimeframe, setSelectedTimeframe] = useState<
'1D' | '5D' | '1M' | '3M' | '6M' | '1Y' | 'MAX'
>('1M');
const chartContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const checkDarkMode = () => {
setIsDarkMode(document.documentElement.classList.contains('dark'));
};
checkDarkMode();
const observer = new MutationObserver(checkDarkMode);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
});
return () => observer.disconnect();
}, []);
useEffect(() => {
const currentChartData = props.chartData?.[selectedTimeframe];
if (
!chartContainerRef.current ||
!currentChartData ||
currentChartData.timestamps.length === 0
) {
return;
}
const chart = createChart(chartContainerRef.current, {
width: chartContainerRef.current.clientWidth,
height: 280,
layout: {
background: { type: ColorType.Solid, color: 'transparent' },
textColor: isDarkMode ? '#6b7280' : '#9ca3af',
fontSize: 11,
attributionLogo: false,
},
grid: {
vertLines: {
color: isDarkMode ? '#21262d' : '#e8edf1',
style: LineStyle.Solid,
},
horzLines: {
color: isDarkMode ? '#21262d' : '#e8edf1',
style: LineStyle.Solid,
},
},
crosshair: {
vertLine: {
color: isDarkMode ? '#30363d' : '#d0d7de',
labelVisible: false,
},
horzLine: {
color: isDarkMode ? '#30363d' : '#d0d7de',
labelVisible: true,
},
},
rightPriceScale: {
borderVisible: false,
visible: false,
},
leftPriceScale: {
borderVisible: false,
visible: true,
},
timeScale: {
borderVisible: false,
timeVisible: false,
},
handleScroll: false,
handleScale: false,
});
const prices = currentChartData.prices;
let baselinePrice: number;
if (selectedTimeframe === '1D') {
baselinePrice = props.regularMarketPreviousClose ?? prices[0];
} else {
baselinePrice = prices[0];
}
const baselineSeries = chart.addSeries(BaselineSeries);
baselineSeries.applyOptions({
baseValue: { type: 'price', price: baselinePrice },
topLineColor: isDarkMode ? '#14b8a6' : '#0d9488',
topFillColor1: isDarkMode
? 'rgba(20, 184, 166, 0.28)'
: 'rgba(13, 148, 136, 0.24)',
topFillColor2: isDarkMode
? 'rgba(20, 184, 166, 0.05)'
: 'rgba(13, 148, 136, 0.05)',
bottomLineColor: isDarkMode ? '#f87171' : '#dc2626',
bottomFillColor1: isDarkMode
? 'rgba(248, 113, 113, 0.05)'
: 'rgba(220, 38, 38, 0.05)',
bottomFillColor2: isDarkMode
? 'rgba(248, 113, 113, 0.28)'
: 'rgba(220, 38, 38, 0.24)',
lineWidth: 2,
crosshairMarkerVisible: true,
crosshairMarkerRadius: 4,
crosshairMarkerBorderColor: '',
crosshairMarkerBackgroundColor: '',
});
const data = currentChartData.timestamps.map((timestamp, index) => {
const price = currentChartData.prices[index];
return {
time: (timestamp / 1000) as any,
value: price,
};
});
baselineSeries.setData(data);
const comparisonColors = ['#8b5cf6', '#f59e0b', '#ec4899'];
if (props.comparisonData && props.comparisonData.length > 0) {
props.comparisonData.forEach((comp, index) => {
const compChartData = comp.chartData[selectedTimeframe];
if (compChartData && compChartData.prices.length > 0) {
const compData = compChartData.timestamps.map((timestamp, i) => ({
time: (timestamp / 1000) as any,
value: compChartData.prices[i],
}));
const compSeries = chart.addSeries(LineSeries);
compSeries.applyOptions({
color: comparisonColors[index] || '#6b7280',
lineWidth: 2,
crosshairMarkerVisible: true,
crosshairMarkerRadius: 4,
priceScaleId: 'left',
});
compSeries.setData(compData);
}
});
}
chart.timeScale().fitContent();
const handleResize = () => {
if (chartContainerRef.current) {
chart.applyOptions({
width: chartContainerRef.current.clientWidth,
});
}
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
chart.remove();
};
}, [
props.chartData,
props.comparisonData,
selectedTimeframe,
isDarkMode,
props.regularMarketPreviousClose,
]);
const isPositive = (props.regularMarketChange ?? 0) >= 0;
const isMarketOpen = props.marketState === 'REGULAR';
const isPreMarket = props.marketState === 'PRE';
const isPostMarket = props.marketState === 'POST';
const displayPrice = isPostMarket
? props.postMarketPrice ?? props.regularMarketPrice
: isPreMarket
? props.preMarketPrice ?? props.regularMarketPrice
: props.regularMarketPrice;
const displayChange = isPostMarket
? props.postMarketChange ?? props.regularMarketChange
: isPreMarket
? props.preMarketChange ?? props.regularMarketChange
: props.regularMarketChange;
const displayChangePercent = isPostMarket
? props.postMarketChangePercent ?? props.regularMarketChangePercent
: isPreMarket
? props.preMarketChangePercent ?? props.regularMarketChangePercent
: props.regularMarketChangePercent;
const changeColor = isPositive
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400';
if (props.error) {
return (
<div className="rounded-lg bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 p-4">
<p className="text-sm text-black dark:text-white">
Error: {props.error}
</p>
</div>
);
}
return (
<div className="rounded-lg border border-light-200 dark:border-dark-200 overflow-hidden">
<div className="p-4 space-y-4">
<div className="flex items-start justify-between gap-4 pb-4 border-b border-light-200 dark:border-dark-200">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
{props.website && (
<img
src={`https://logo.clearbit.com/${new URL(props.website).hostname}`}
alt={`${props.symbol} logo`}
className="w-8 h-8 rounded-lg"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
<h3 className="text-2xl font-bold text-black dark:text-white">
{props.symbol}
</h3>
{props.exchange && (
<span className="px-2 py-0.5 text-xs font-medium rounded bg-light-100 dark:bg-dark-100 text-black/60 dark:text-white/60">
{props.exchange}
</span>
)}
{isMarketOpen && (
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-green-100 dark:bg-green-950/40 border border-green-300 dark:border-green-800">
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
<span className="text-xs font-medium text-green-700 dark:text-green-400">
Live
</span>
</div>
)}
{isPreMarket && (
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-950/40 border border-blue-300 dark:border-blue-800">
<Clock className="w-3 h-3 text-blue-600 dark:text-blue-400" />
<span className="text-xs font-medium text-blue-700 dark:text-blue-400">
Pre-Market
</span>
</div>
)}
{isPostMarket && (
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-orange-100 dark:bg-orange-950/40 border border-orange-300 dark:border-orange-800">
<Clock className="w-3 h-3 text-orange-600 dark:text-orange-400" />
<span className="text-xs font-medium text-orange-700 dark:text-orange-400">
After Hours
</span>
</div>
)}
</div>
<p className="text-sm text-black/60 dark:text-white/60">
{props.longName || props.shortName}
</p>
</div>
<div className="text-right">
<div className="flex items-baseline gap-2 mb-1">
<span className="text-3xl font-medium text-black dark:text-white">
{props.currency === 'USD' ? '$' : ''}
{formatNumber(displayPrice)}
</span>
</div>
<div
className={`flex items-center justify-end gap-1 ${changeColor}`}
>
{isPositive ? (
<ArrowUpRight className="w-4 h-4" />
) : displayChange === 0 ? (
<Minus className="w-4 h-4" />
) : (
<ArrowDownRight className="w-4 h-4" />
)}
<span className="text-lg font-normal">
{displayChange !== undefined && displayChange >= 0 ? '+' : ''}
{formatNumber(displayChange)}
</span>
<span className="text-sm font-normal">
(
{displayChangePercent !== undefined && displayChangePercent >= 0
? '+'
: ''}
{formatNumber(displayChangePercent)}%)
</span>
</div>
</div>
</div>
{props.chartData && (
<div className="bg-light-secondary dark:bg-dark-secondary rounded-lg overflow-hidden">
<div className="flex items-center justify-between p-3 border-b border-light-200 dark:border-dark-200">
<div className="flex items-center gap-1">
{(['1D', '5D', '1M', '3M', '6M', '1Y', 'MAX'] as const).map(
(timeframe) => (
<button
key={timeframe}
onClick={() => setSelectedTimeframe(timeframe)}
disabled={!props.chartData?.[timeframe]}
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
selectedTimeframe === timeframe
? 'bg-black/10 dark:bg-white/10 text-black dark:text-white'
: 'text-black/50 dark:text-white/50 hover:text-black/80 dark:hover:text-white/80'
} disabled:opacity-30 disabled:cursor-not-allowed`}
>
{timeframe}
</button>
),
)}
</div>
{props.comparisonData && props.comparisonData.length > 0 && (
<div className="flex items-center gap-3 ml-auto">
<span className="text-xs text-black/50 dark:text-white/50">
{props.symbol}
</span>
{props.comparisonData.map((comp, index) => {
const colors = ['#8b5cf6', '#f59e0b', '#ec4899'];
return (
<div
key={comp.ticker}
className="flex items-center gap-1.5"
>
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: colors[index] }}
/>
<span className="text-xs text-black/70 dark:text-white/70">
{comp.ticker}
</span>
</div>
);
})}
</div>
)}
</div>
<div className="p-4">
<div ref={chartContainerRef} />
</div>
<div className="grid grid-cols-3 border-t border-light-200 dark:border-dark-200">
<div className="flex justify-between p-3 border-r border-light-200 dark:border-dark-200">
<span className="text-xs text-black/50 dark:text-white/50">
Prev Close
</span>
<span className="text-xs text-black dark:text-white font-medium">
${formatNumber(props.regularMarketPreviousClose)}
</span>
</div>
<div className="flex justify-between p-3 border-r border-light-200 dark:border-dark-200">
<span className="text-xs text-black/50 dark:text-white/50">
52W Range
</span>
<span className="text-xs text-black dark:text-white font-medium">
${formatNumber(props.fiftyTwoWeekLow, 2)}-$
{formatNumber(props.fiftyTwoWeekHigh, 2)}
</span>
</div>
<div className="flex justify-between p-3">
<span className="text-xs text-black/50 dark:text-white/50">
Market Cap
</span>
<span className="text-xs text-black dark:text-white font-medium">
{formatLargeNumber(props.marketCap)}
</span>
</div>
<div className="flex justify-between p-3 border-t border-r border-light-200 dark:border-dark-200">
<span className="text-xs text-black/50 dark:text-white/50">
Open
</span>
<span className="text-xs text-black dark:text-white font-medium">
${formatNumber(props.regularMarketOpen)}
</span>
</div>
<div className="flex justify-between p-3 border-t border-r border-light-200 dark:border-dark-200">
<span className="text-xs text-black/50 dark:text-white/50">
P/E Ratio
</span>
<span className="text-xs text-black dark:text-white font-medium">
{props.trailingPE ? formatNumber(props.trailingPE, 2) : 'N/A'}
</span>
</div>
<div className="flex justify-between p-3 border-t border-light-200 dark:border-dark-200">
<span className="text-xs text-black/50 dark:text-white/50">
Dividend Yield
</span>
<span className="text-xs text-black dark:text-white font-medium">
{props.dividendYield
? `${formatNumber(props.dividendYield * 100, 2)}%`
: 'N/A'}
</span>
</div>
<div className="flex justify-between p-3 border-t border-r border-light-200 dark:border-dark-200">
<span className="text-xs text-black/50 dark:text-white/50">
Day Range
</span>
<span className="text-xs text-black dark:text-white font-medium">
${formatNumber(props.regularMarketDayLow, 2)}-$
{formatNumber(props.regularMarketDayHigh, 2)}
</span>
</div>
<div className="flex justify-between p-3 border-t border-r border-light-200 dark:border-dark-200">
<span className="text-xs text-black/50 dark:text-white/50">
Volume
</span>
<span className="text-xs text-black dark:text-white font-medium">
{formatLargeNumber(props.regularMarketVolume)}
</span>
</div>
<div className="flex justify-between p-3 border-t border-light-200 dark:border-dark-200">
<span className="text-xs text-black/50 dark:text-white/50">
EPS
</span>
<span className="text-xs text-black dark:text-white font-medium">
$
{props.earningsPerShare
? formatNumber(props.earningsPerShare, 2)
: 'N/A'}
</span>
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default Stock;

View File

@@ -0,0 +1,422 @@
'use client';
import { getMeasurementUnit } from '@/lib/config/clientRegistry';
import { Wind, Droplets, Gauge } from 'lucide-react';
import { useMemo, useEffect, useState } from 'react';
type WeatherWidgetProps = {
location: string;
current: {
time: string;
temperature_2m: number;
relative_humidity_2m: number;
apparent_temperature: number;
is_day: number;
precipitation: number;
weather_code: number;
wind_speed_10m: number;
wind_direction_10m: number;
wind_gusts_10m?: number;
};
daily: {
time: string[];
weather_code: number[];
temperature_2m_max: number[];
temperature_2m_min: number[];
precipitation_probability_max: number[];
};
timezone: string;
};
const getWeatherInfo = (code: number, isDay: boolean, isDarkMode: boolean) => {
const dayNight = isDay ? 'day' : 'night';
const weatherMap: Record<
number,
{ icon: string; description: string; gradient: string }
> = {
0: {
icon: `clear-${dayNight}.svg`,
description: 'Clear',
gradient: isDarkMode
? isDay
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #E8F1FA, #7A9DBF 35%, #4A7BA8 60%, #2F5A88)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #5A6A7E, #3E4E63 40%, #2A3544 65%, #1A2230)'
: isDay
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #FFFFFF, #DBEAFE 30%, #93C5FD 60%, #60A5FA)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #7B8694, #475569 45%, #334155 70%, #1E293B)',
},
1: {
icon: `clear-${dayNight}.svg`,
description: 'Mostly Clear',
gradient: isDarkMode
? isDay
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #E8F1FA, #7A9DBF 35%, #4A7BA8 60%, #2F5A88)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #5A6A7E, #3E4E63 40%, #2A3544 65%, #1A2230)'
: isDay
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #FFFFFF, #DBEAFE 30%, #93C5FD 60%, #60A5FA)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #7B8694, #475569 45%, #334155 70%, #1E293B)',
},
2: {
icon: `cloudy-1-${dayNight}.svg`,
description: 'Partly Cloudy',
gradient: isDarkMode
? isDay
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #D4E1ED, #8BA3B8 35%, #617A93 60%, #426070)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #6B7583, #4A5563 40%, #3A4450 65%, #2A3340)'
: isDay
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #FFFFFF, #E0F2FE 28%, #BFDBFE 58%, #93C5FD)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #8B99AB, #64748B 45%, #475569 70%, #334155)',
},
3: {
icon: `cloudy-1-${dayNight}.svg`,
description: 'Cloudy',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #B8C3CF, #758190 38%, #546270 65%, #3D4A58)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #F5F8FA, #CBD5E1 32%, #94A3B8 65%, #64748B)',
},
45: {
icon: `fog-${dayNight}.svg`,
description: 'Foggy',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #C5CDD8, #8892A0 38%, #697380 65%, #4F5A68)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #FFFFFF, #E2E8F0 30%, #CBD5E1 62%, #94A3B8)',
},
48: {
icon: `fog-${dayNight}.svg`,
description: 'Rime Fog',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #C5CDD8, #8892A0 38%, #697380 65%, #4F5A68)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #FFFFFF, #E2E8F0 30%, #CBD5E1 62%, #94A3B8)',
},
51: {
icon: `rainy-1-${dayNight}.svg`,
description: 'Light Drizzle',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #B8D4E5, #6FA4C5 35%, #4A85AC 60%, #356A8E)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #E5FBFF, #A5F3FC 28%, #67E8F9 60%, #22D3EE)',
},
53: {
icon: `rainy-1-${dayNight}.svg`,
description: 'Drizzle',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #B8D4E5, #6FA4C5 35%, #4A85AC 60%, #356A8E)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #E5FBFF, #A5F3FC 28%, #67E8F9 60%, #22D3EE)',
},
55: {
icon: `rainy-2-${dayNight}.svg`,
description: 'Heavy Drizzle',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #A5C5D8, #5E92B0 35%, #3F789D 60%, #2A5F82)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #D4F3FF, #7DD3FC 30%, #38BDF8 62%, #0EA5E9)',
},
61: {
icon: `rainy-2-${dayNight}.svg`,
description: 'Light Rain',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #A5C5D8, #5E92B0 35%, #3F789D 60%, #2A5F82)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #D4F3FF, #7DD3FC 30%, #38BDF8 62%, #0EA5E9)',
},
63: {
icon: `rainy-2-${dayNight}.svg`,
description: 'Rain',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #8DB3C8, #4D819F 38%, #326A87 65%, #215570)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #B8E8FF, #38BDF8 32%, #0EA5E9 65%, #0284C7)',
},
65: {
icon: `rainy-3-${dayNight}.svg`,
description: 'Heavy Rain',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #7BA3B8, #3D6F8A 38%, #295973 65%, #1A455D)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #9CD9F5, #0EA5E9 32%, #0284C7 65%, #0369A1)',
},
71: {
icon: `snowy-1-${dayNight}.svg`,
description: 'Light Snow',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #E5F0FA, #9BB5CE 32%, #7496B8 58%, #527A9E)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #FFFFFF, #F0F9FF 25%, #E0F2FE 55%, #BAE6FD)',
},
73: {
icon: `snowy-2-${dayNight}.svg`,
description: 'Snow',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #D4E5F3, #85A1BD 35%, #6584A8 60%, #496A8E)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #FAFEFF, #E0F2FE 28%, #BAE6FD 60%, #7DD3FC)',
},
75: {
icon: `snowy-3-${dayNight}.svg`,
description: 'Heavy Snow',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #BDD8EB, #6F92AE 35%, #4F7593 60%, #365A78)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #F0FAFF, #BAE6FD 30%, #7DD3FC 62%, #38BDF8)',
},
77: {
icon: `snowy-1-${dayNight}.svg`,
description: 'Snow Grains',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #E5F0FA, #9BB5CE 32%, #7496B8 58%, #527A9E)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #FFFFFF, #F0F9FF 25%, #E0F2FE 55%, #BAE6FD)',
},
80: {
icon: `rainy-2-${dayNight}.svg`,
description: 'Light Showers',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #A5C5D8, #5E92B0 35%, #3F789D 60%, #2A5F82)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #D4F3FF, #7DD3FC 30%, #38BDF8 62%, #0EA5E9)',
},
81: {
icon: `rainy-2-${dayNight}.svg`,
description: 'Showers',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #8DB3C8, #4D819F 38%, #326A87 65%, #215570)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #B8E8FF, #38BDF8 32%, #0EA5E9 65%, #0284C7)',
},
82: {
icon: `rainy-3-${dayNight}.svg`,
description: 'Heavy Showers',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #7BA3B8, #3D6F8A 38%, #295973 65%, #1A455D)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #9CD9F5, #0EA5E9 32%, #0284C7 65%, #0369A1)',
},
85: {
icon: `snowy-2-${dayNight}.svg`,
description: 'Light Snow Showers',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #D4E5F3, #85A1BD 35%, #6584A8 60%, #496A8E)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #FAFEFF, #E0F2FE 28%, #BAE6FD 60%, #7DD3FC)',
},
86: {
icon: `snowy-3-${dayNight}.svg`,
description: 'Snow Showers',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #BDD8EB, #6F92AE 35%, #4F7593 60%, #365A78)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #F0FAFF, #BAE6FD 30%, #7DD3FC 62%, #38BDF8)',
},
95: {
icon: `scattered-thunderstorms-${dayNight}.svg`,
description: 'Thunderstorm',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #8A95A3, #5F6A7A 38%, #475260 65%, #2F3A48)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #C8D1DD, #94A3B8 32%, #64748B 65%, #475569)',
},
96: {
icon: 'severe-thunderstorm.svg',
description: 'Thunderstorm + Hail',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #7A8593, #515C6D 38%, #3A4552 65%, #242D3A)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #B0BBC8, #64748B 32%, #475569 65%, #334155)',
},
99: {
icon: 'severe-thunderstorm.svg',
description: 'Severe Thunderstorm',
gradient: isDarkMode
? 'radial-gradient(ellipse 150% 100% at 50% 100%, #6A7583, #434E5D 40%, #2F3A47 68%, #1C2530)'
: 'radial-gradient(ellipse 150% 100% at 50% 100%, #9BA8B8, #475569 35%, #334155 68%, #1E293B)',
},
};
return weatherMap[code] || weatherMap[0];
};
const Weather = ({
location,
current,
daily,
timezone,
}: WeatherWidgetProps) => {
const [isDarkMode, setIsDarkMode] = useState(false);
const unit = getMeasurementUnit();
const isImperial = unit === 'imperial';
const tempUnitLabel = isImperial ? '°F' : '°C';
const windUnitLabel = isImperial ? 'mph' : 'km/h';
const formatTemp = (celsius: number) => {
if (!Number.isFinite(celsius)) return 0;
return Math.round(isImperial ? (celsius * 9) / 5 + 32 : celsius);
};
const formatWind = (speedKmh: number) => {
if (!Number.isFinite(speedKmh)) return 0;
return Math.round(isImperial ? speedKmh * 0.621371 : speedKmh);
};
useEffect(() => {
const checkDarkMode = () => {
setIsDarkMode(document.documentElement.classList.contains('dark'));
};
checkDarkMode();
const observer = new MutationObserver(checkDarkMode);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class'],
});
return () => observer.disconnect();
}, []);
const weatherInfo = useMemo(
() =>
getWeatherInfo(
current?.weather_code || 0,
current?.is_day === 1,
isDarkMode,
),
[current?.weather_code, current?.is_day, isDarkMode],
);
const forecast = useMemo(() => {
if (!daily?.time || daily.time.length === 0) return [];
return daily.time.slice(1, 7).map((time, idx) => {
const date = new Date(time);
const dayName = date.toLocaleDateString('en-US', { weekday: 'short' });
const isDay = true;
const weatherCode = daily.weather_code[idx + 1];
const info = getWeatherInfo(weatherCode, isDay, isDarkMode);
return {
day: dayName,
icon: info.icon,
high: formatTemp(daily.temperature_2m_max[idx + 1]),
low: formatTemp(daily.temperature_2m_min[idx + 1]),
precipitation: daily.precipitation_probability_max[idx + 1] || 0,
};
});
}, [daily, isDarkMode, isImperial]);
if (!current || !daily || !daily.time || daily.time.length === 0) {
return (
<div className="relative overflow-hidden rounded-lg shadow-md bg-gray-200 dark:bg-gray-800">
<div className="p-4 text-black dark:text-white">
<p className="text-sm">Weather data unavailable for {location}</p>
</div>
</div>
);
}
return (
<div className="relative overflow-hidden rounded-lg shadow-md">
<div
className="absolute inset-0"
style={{
background: weatherInfo.gradient,
}}
/>
<div className="relative p-4 text-gray-800 dark:text-white">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<img
src={`/weather-ico/${weatherInfo.icon}`}
alt={weatherInfo.description}
className="w-16 h-16 drop-shadow-lg"
/>
<div>
<div className="flex items-baseline gap-1">
<span className="text-4xl font-bold drop-shadow-md">
{formatTemp(current.temperature_2m)}°
</span>
<span className="text-lg">{tempUnitLabel}</span>
</div>
<p className="text-sm font-medium drop-shadow mt-0.5">
{weatherInfo.description}
</p>
</div>
</div>
<div className="text-right">
<p className="text-xs font-medium opacity-90">
{formatTemp(daily.temperature_2m_max[0])}°{' '}
{formatTemp(daily.temperature_2m_min[0])}°
</p>
</div>
</div>
<div className="mb-3 pb-3 border-b border-gray-800/20 dark:border-white/20">
<h3 className="text-base font-semibold drop-shadow-md">{location}</h3>
<p className="text-xs text-gray-700 dark:text-white/80 drop-shadow mt-0.5">
{new Date(current.time).toLocaleString('en-US', {
weekday: 'short',
hour: 'numeric',
minute: '2-digit',
})}
</p>
</div>
<div className="grid grid-cols-6 gap-2 mb-3 pb-3 border-b border-gray-800/20 dark:border-white/20">
{forecast.map((day, idx) => (
<div
key={idx}
className="flex flex-col items-center bg-gray-800/10 dark:bg-white/10 backdrop-blur-sm rounded-md p-2"
>
<p className="text-xs font-medium mb-1">{day.day}</p>
<img
src={`/weather-ico/${day.icon}`}
alt=""
className="w-8 h-8 mb-1"
/>
<div className="flex items-center gap-1 text-xs">
<span className="font-semibold">{day.high}°</span>
<span className="text-gray-600 dark:text-white/60">
{day.low}°
</span>
</div>
{day.precipitation > 0 && (
<div className="flex items-center gap-0.5 mt-1">
<Droplets className="w-3 h-3 text-gray-600 dark:text-white/70" />
<span className="text-[10px] text-gray-600 dark:text-white/70">
{day.precipitation}%
</span>
</div>
)}
</div>
))}
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="flex items-center gap-2 bg-gray-800/10 dark:bg-white/10 backdrop-blur-sm rounded-md p-2">
<Wind className="w-4 h-4 text-gray-700 dark:text-white/80 flex-shrink-0" />
<div>
<p className="text-[10px] text-gray-600 dark:text-white/70">
Wind
</p>
<p className="font-semibold">
{formatWind(current.wind_speed_10m)} {windUnitLabel}
</p>
</div>
</div>
<div className="flex items-center gap-2 bg-gray-800/10 dark:bg-white/10 backdrop-blur-sm rounded-md p-2">
<Droplets className="w-4 h-4 text-gray-700 dark:text-white/80 flex-shrink-0" />
<div>
<p className="text-[10px] text-gray-600 dark:text-white/70">
Humidity
</p>
<p className="font-semibold">
{Math.round(current.relative_humidity_2m)}%
</p>
</div>
</div>
<div className="flex items-center gap-2 bg-gray-800/10 dark:bg-white/10 backdrop-blur-sm rounded-md p-2">
<Gauge className="w-4 h-4 text-gray-700 dark:text-white/80 flex-shrink-0" />
<div>
<p className="text-[10px] text-gray-600 dark:text-white/70">
Feels Like
</p>
<p className="font-semibold">
{formatTemp(current.apparent_temperature)}
{tempUnitLabel}
</p>
</div>
</div>
</div>
</div>
</div>
);
};
export default Weather;

View File

@@ -0,0 +1,16 @@
'use client';
import { ThemeProvider } from 'next-themes';
const ThemeProviderComponent = ({
children,
}: {
children: React.ReactNode;
}) => {
return (
<ThemeProvider attribute="class" enableSystem={false} defaultTheme="dark">
{children}
</ThemeProvider>
);
};
export default ThemeProviderComponent;

View File

@@ -0,0 +1,60 @@
'use client';
import { useTheme } from 'next-themes';
import { useCallback, useEffect, useState } from 'react';
import Select from '../ui/Select';
type Theme = 'dark' | 'light' | 'system';
const ThemeSwitcher = ({ className }: { className?: string }) => {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
const isTheme = useCallback((t: Theme) => t === theme, [theme]);
const handleThemeSwitch = (theme: Theme) => {
setTheme(theme);
};
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
if (isTheme('system')) {
const preferDarkScheme = window.matchMedia(
'(prefers-color-scheme: dark)',
);
const detectThemeChange = (event: MediaQueryListEvent) => {
const theme: Theme = event.matches ? 'dark' : 'light';
setTheme(theme);
};
preferDarkScheme.addEventListener('change', detectThemeChange);
return () => {
preferDarkScheme.removeEventListener('change', detectThemeChange);
};
}
}, [isTheme, setTheme, theme]);
// Avoid Hydration Mismatch
if (!mounted) {
return null;
}
return (
<Select
className={className}
value={theme}
onChange={(e) => handleThemeSwitch(e.target.value as Theme)}
options={[
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
]}
/>
);
};
export default ThemeSwitcher;

View File

@@ -0,0 +1,22 @@
const Loader = () => {
return (
<svg
aria-hidden="true"
className="w-8 h-8 text-light-200 fill-light-secondary dark:text-[#202020] animate-spin dark:fill-[#ffffff3b]"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100.003 78.2051 78.1951 100.003 50.5908 100C22.9765 99.9972 0.997224 78.018 1 50.4037C1.00281 22.7993 22.8108 0.997224 50.4251 1C78.0395 1.00281 100.018 22.8108 100 50.4251ZM9.08164 50.594C9.06312 73.3997 27.7909 92.1272 50.5966 92.1457C73.4023 92.1642 92.1298 73.4365 92.1483 50.6308C92.1669 27.8251 73.4392 9.0973 50.6335 9.07878C27.8278 9.06026 9.10003 27.787 9.08164 50.594Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4037 97.8624 35.9116 96.9801 33.5533C95.1945 28.8227 92.871 24.3692 90.0681 20.348C85.6237 14.1775 79.4473 9.36872 72.0454 6.45794C64.6435 3.54717 56.3134 2.65431 48.3133 3.89319C45.869 4.27179 44.3768 6.77534 45.014 9.20079C45.6512 11.6262 48.1343 13.0956 50.5786 12.717C56.5073 11.8281 62.5542 12.5399 68.0406 14.7911C73.527 17.0422 78.2187 20.7487 81.5841 25.4923C83.7976 28.5886 85.4467 32.059 86.4416 35.7474C87.1273 38.1189 89.5423 39.6781 91.9676 39.0409Z"
fill="currentFill"
/>
</svg>
);
};
export default Loader;

View File

@@ -0,0 +1,46 @@
import { cn } from '@/lib/utils';
import { ChevronDown } from 'lucide-react';
import { SelectHTMLAttributes, forwardRef } from 'react';
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
options: { value: any; label: string; disabled?: boolean }[];
loading?: boolean;
}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ className, options, loading = false, disabled, ...restProps }, ref) => {
return (
<div
className={cn(
'relative inline-flex w-full items-center',
disabled && 'opacity-60',
)}
>
<select
{...restProps}
ref={ref}
disabled={disabled || loading}
className={cn(
'bg-light-secondary dark:bg-dark-secondary px-3 py-2 flex items-center overflow-hidden border border-light-200 dark:border-dark-200 dark:text-white rounded-lg appearance-none w-full pr-10 text-xs lg:text-sm',
className,
)}
>
{options.map(({ label, value, disabled: optionDisabled }) => {
return (
<option key={value} value={value} disabled={optionDisabled}>
{label}
</option>
);
})}
</select>
<span className="pointer-events-none absolute right-3 flex h-4 w-4 items-center justify-center text-black/50 dark:text-white/60">
<ChevronDown className="h-4 w-4" />
</span>
</div>
);
},
);
Select.displayName = 'Select';
export default Select;