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:
282
services/web-svc/src/components/AssistantSteps.tsx
Normal file
282
services/web-svc/src/components/AssistantSteps.tsx
Normal 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">
|
||||
(~30–90 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;
|
||||
108
services/web-svc/src/components/Chat.tsx
Normal file
108
services/web-svc/src/components/Chat.tsx
Normal 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;
|
||||
77
services/web-svc/src/components/ChatWindow.tsx
Normal file
77
services/web-svc/src/components/ChatWindow.tsx
Normal 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;
|
||||
25
services/web-svc/src/components/ClientOnly.tsx
Normal file
25
services/web-svc/src/components/ClientOnly.tsx
Normal 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}</>;
|
||||
}
|
||||
30
services/web-svc/src/components/DataFetchError.tsx
Normal file
30
services/web-svc/src/components/DataFetchError.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
services/web-svc/src/components/DeleteChat.tsx
Normal file
138
services/web-svc/src/components/DeleteChat.tsx
Normal 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;
|
||||
60
services/web-svc/src/components/Discover/MajorNewsCard.tsx
Normal file
60
services/web-svc/src/components/Discover/MajorNewsCard.tsx
Normal 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;
|
||||
28
services/web-svc/src/components/Discover/SmallNewsCard.tsx
Normal file
28
services/web-svc/src/components/Discover/SmallNewsCard.tsx
Normal 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;
|
||||
81
services/web-svc/src/components/EmptyChat.tsx
Normal file
81
services/web-svc/src/components/EmptyChat.tsx
Normal 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;
|
||||
96
services/web-svc/src/components/EmptyChatMessageInput.tsx
Normal file
96
services/web-svc/src/components/EmptyChatMessageInput.tsx
Normal 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;
|
||||
48
services/web-svc/src/components/GuestMigration.tsx
Normal file
48
services/web-svc/src/components/GuestMigration.tsx
Normal 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;
|
||||
}
|
||||
55
services/web-svc/src/components/GuestWarningBanner.tsx
Normal file
55
services/web-svc/src/components/GuestWarningBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
services/web-svc/src/components/Layout.tsx
Normal file
9
services/web-svc/src/components/Layout.tsx
Normal 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;
|
||||
51
services/web-svc/src/components/MessageActions/Copy.tsx
Normal file
51
services/web-svc/src/components/MessageActions/Copy.tsx
Normal 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;
|
||||
20
services/web-svc/src/components/MessageActions/Rewrite.tsx
Normal file
20
services/web-svc/src/components/MessageActions/Rewrite.tsx
Normal 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;
|
||||
325
services/web-svc/src/components/MessageBox.tsx
Normal file
325
services/web-svc/src/components/MessageBox.tsx
Normal 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;
|
||||
11
services/web-svc/src/components/MessageBoxLoading.tsx
Normal file
11
services/web-svc/src/components/MessageBoxLoading.tsx
Normal 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;
|
||||
104
services/web-svc/src/components/MessageInput.tsx
Normal file
104
services/web-svc/src/components/MessageInput.tsx
Normal 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;
|
||||
@@ -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;
|
||||
218
services/web-svc/src/components/MessageInputActions/Attach.tsx
Normal file
218
services/web-svc/src/components/MessageInputActions/Attach.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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: "Create a table about..." or "Generate an image of..."
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</PopoverPanel>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputBarPlus;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
19
services/web-svc/src/components/MessageRenderer/Citation.tsx
Normal file
19
services/web-svc/src/components/MessageRenderer/Citation.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
175
services/web-svc/src/components/MessageSources.tsx
Normal file
175
services/web-svc/src/components/MessageSources.tsx
Normal 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;
|
||||
403
services/web-svc/src/components/Navbar.tsx
Normal file
403
services/web-svc/src/components/Navbar.tsx
Normal 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;
|
||||
71
services/web-svc/src/components/NewsArticleWidget.tsx
Normal file
71
services/web-svc/src/components/NewsArticleWidget.tsx
Normal 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;
|
||||
165
services/web-svc/src/components/SearchImages.tsx
Normal file
165
services/web-svc/src/components/SearchImages.tsx
Normal 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;
|
||||
240
services/web-svc/src/components/SearchVideos.tsx
Normal file
240
services/web-svc/src/components/SearchVideos.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 "
|
||||
{modelProvider.name}"? 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
32
services/web-svc/src/components/Settings/Sections/Search.tsx
Normal file
32
services/web-svc/src/components/Settings/Sections/Search.tsx
Normal 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;
|
||||
29
services/web-svc/src/components/Settings/SettingsButton.tsx
Normal file
29
services/web-svc/src/components/Settings/SettingsButton.tsx
Normal 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;
|
||||
@@ -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;
|
||||
256
services/web-svc/src/components/Settings/SettingsDialogue.tsx
Normal file
256
services/web-svc/src/components/Settings/SettingsDialogue.tsx
Normal 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;
|
||||
410
services/web-svc/src/components/Settings/SettingsField.tsx
Normal file
410
services/web-svc/src/components/Settings/SettingsField.tsx
Normal 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;
|
||||
806
services/web-svc/src/components/Sidebar.tsx
Normal file
806
services/web-svc/src/components/Sidebar.tsx
Normal 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;
|
||||
220
services/web-svc/src/components/Sidebar/MenuSettingsPanel.tsx
Normal file
220
services/web-svc/src/components/Sidebar/MenuSettingsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
services/web-svc/src/components/ThinkBox.tsx
Normal file
51
services/web-svc/src/components/ThinkBox.tsx
Normal 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;
|
||||
445
services/web-svc/src/components/TravelStepper.tsx
Normal file
445
services/web-svc/src/components/TravelStepper.tsx
Normal 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 "{state.searchQuery || '...'}" 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 "{state.searchQuery || '...'}"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
222
services/web-svc/src/components/WeatherWidget.tsx
Normal file
222
services/web-svc/src/components/WeatherWidget.tsx
Normal 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;
|
||||
46
services/web-svc/src/components/Widgets/Calculation.tsx
Normal file
46
services/web-svc/src/components/Widgets/Calculation.tsx
Normal 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;
|
||||
78
services/web-svc/src/components/Widgets/Renderer.tsx
Normal file
78
services/web-svc/src/components/Widgets/Renderer.tsx
Normal 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;
|
||||
517
services/web-svc/src/components/Widgets/Stock.tsx
Normal file
517
services/web-svc/src/components/Widgets/Stock.tsx
Normal 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;
|
||||
422
services/web-svc/src/components/Widgets/Weather.tsx
Normal file
422
services/web-svc/src/components/Widgets/Weather.tsx
Normal 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;
|
||||
16
services/web-svc/src/components/theme/Provider.tsx
Normal file
16
services/web-svc/src/components/theme/Provider.tsx
Normal 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;
|
||||
60
services/web-svc/src/components/theme/Switcher.tsx
Normal file
60
services/web-svc/src/components/theme/Switcher.tsx
Normal 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;
|
||||
22
services/web-svc/src/components/ui/Loader.tsx
Normal file
22
services/web-svc/src/components/ui/Loader.tsx
Normal 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;
|
||||
46
services/web-svc/src/components/ui/Select.tsx
Normal file
46
services/web-svc/src/components/ui/Select.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user