Deploy: migrate k3s → Docker; search logic → master-agents-svc
- deploy/k3s удалён, deploy/docker добавлен (Caddyfile, docker-compose, searxng) - chat-svc: agents/models/prompts удалены, использует llm-svc (LLMClient, EmbeddingClient) - master-agents-svc: SearchOrchestrator, classifier, researcher, actions, widgets - web-svc: ChatModelSelector, Optimization, Sources удалены; InputBarPlus; UnregisterSW - geo-device-svc, localization-svc: Dockerfiles - docs: 02-k3s-services-spec.md, RUNBOOK/TELEMETRY/WORKING удалены Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,14 +1,11 @@
|
||||
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();
|
||||
@@ -71,13 +68,10 @@ const EmptyChatMessageInput = () => {
|
||||
<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
|
||||
|
||||
@@ -1,27 +1,65 @@
|
||||
import { ChevronDown, Globe, Plane, TrendingUp, BookOpen, PenLine } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Popover,
|
||||
PopoverButton,
|
||||
PopoverPanel,
|
||||
} from '@headlessui/react';
|
||||
Globe,
|
||||
Plane,
|
||||
TrendingUp,
|
||||
BookOpen,
|
||||
PenLine,
|
||||
HeartPulse,
|
||||
GraduationCap,
|
||||
Stethoscope,
|
||||
Building2,
|
||||
Brain,
|
||||
Trophy,
|
||||
Baby,
|
||||
Package,
|
||||
ShoppingCart,
|
||||
Gamepad2,
|
||||
Receipt,
|
||||
Scale,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
|
||||
import { useChat } from '@/lib/hooks/useChat';
|
||||
import { useTranslation } from '@/lib/localization/context';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
|
||||
type AnswerModeKey = 'standard' | 'focus' | 'academic' | 'writing' | 'travel' | 'finance';
|
||||
type AnswerModeKey =
|
||||
| 'standard' | 'focus' | 'academic' | 'writing' | 'travel' | 'finance'
|
||||
| 'health' | 'education' | 'medicine' | 'realEstate' | 'psychology' | 'sports'
|
||||
| 'children' | 'goods' | 'shopping' | 'games' | 'taxes' | 'legislation';
|
||||
|
||||
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 ANSWER_MODE_CONFIG: {
|
||||
key: AnswerModeKey;
|
||||
Icon: React.ComponentType<{ size?: number | string; className?: string }>;
|
||||
labelKey: string;
|
||||
}[] = [
|
||||
{ key: 'standard', Icon: Globe, labelKey: 'answerMode.standard' },
|
||||
{ key: 'focus', Icon: Globe, labelKey: 'answerMode.focus' },
|
||||
{ key: 'academic', Icon: BookOpen, labelKey: 'answerMode.academic' },
|
||||
{ key: 'writing', Icon: PenLine, labelKey: 'answerMode.writing' },
|
||||
{ key: 'travel', Icon: Plane, labelKey: 'nav.travel' },
|
||||
{ key: 'finance', Icon: TrendingUp, labelKey: 'nav.finance' },
|
||||
{ key: 'health', Icon: HeartPulse, labelKey: 'nav.health' },
|
||||
{ key: 'education', Icon: GraduationCap, labelKey: 'nav.education' },
|
||||
{ key: 'medicine', Icon: Stethoscope, labelKey: 'nav.medicine' },
|
||||
{ key: 'realEstate', Icon: Building2, labelKey: 'nav.realEstate' },
|
||||
{ key: 'psychology', Icon: Brain, labelKey: 'nav.psychology' },
|
||||
{ key: 'sports', Icon: Trophy, labelKey: 'nav.sports' },
|
||||
{ key: 'children', Icon: Baby, labelKey: 'nav.children' },
|
||||
{ key: 'goods', Icon: Package, labelKey: 'nav.goods' },
|
||||
{ key: 'shopping', Icon: ShoppingCart, labelKey: 'nav.shopping' },
|
||||
{ key: 'games', Icon: Gamepad2, labelKey: 'nav.games' },
|
||||
{ key: 'taxes', Icon: Receipt, labelKey: 'nav.taxes' },
|
||||
{ key: 'legislation', Icon: Scale, labelKey: 'nav.legislation' },
|
||||
];
|
||||
|
||||
const AnswerMode = () => {
|
||||
const { answerMode, setAnswerMode } = useChat();
|
||||
const current = AnswerModes.find((m) => m.key === answerMode) ?? AnswerModes[0];
|
||||
const { t } = useTranslation();
|
||||
const current = ANSWER_MODE_CONFIG.find((m) => m.key === answerMode) ?? ANSWER_MODE_CONFIG[0];
|
||||
|
||||
return (
|
||||
<Popover className="relative">
|
||||
@@ -33,8 +71,8 @@ const AnswerMode = () => {
|
||||
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>
|
||||
<current.Icon size={16} className="text-[#EA580C]" />
|
||||
<span className="text-xs">{t(current.labelKey)}</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn(open ? 'rotate-180' : 'rotate-0', 'transition')}
|
||||
@@ -44,7 +82,7 @@ const AnswerMode = () => {
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<PopoverPanel
|
||||
className="absolute z-10 w-48 left-0 bottom-full mb-2"
|
||||
className="absolute z-10 left-0 bottom-full mb-2"
|
||||
static
|
||||
>
|
||||
<motion.div
|
||||
@@ -52,23 +90,31 @@ const AnswerMode = () => {
|
||||
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"
|
||||
className="origin-bottom-left bg-light-primary dark:bg-dark-primary border rounded-xl border-light-200 dark:border-dark-200 p-2 shadow-xl min-w-[380px]"
|
||||
>
|
||||
{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>
|
||||
))}
|
||||
<div className="grid grid-cols-6 gap-2 max-h-[220px] overflow-y-auto">
|
||||
{ANSWER_MODE_CONFIG.map((mode) => {
|
||||
const Icon = mode.Icon;
|
||||
return (
|
||||
<PopoverButton
|
||||
key={mode.key}
|
||||
onClick={() => setAnswerMode(mode.key)}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-1 w-14 h-14 rounded-xl transition duration-200 focus:outline-none shrink-0',
|
||||
answerMode === mode.key
|
||||
? 'bg-[#EA580C]/20 text-[#EA580C] border border-[#EA580C]/40'
|
||||
: 'bg-light-200/80 dark:bg-dark-200/80 text-black/70 dark:text-white/70 hover:bg-light-200 dark:hover:bg-dark-200 border border-transparent',
|
||||
)}
|
||||
title={t(mode.labelKey)}
|
||||
>
|
||||
<Icon size={18} className="text-[#EA580C]" />
|
||||
<span className="text-[9px] font-medium leading-tight text-center truncate max-w-full px-0.5">
|
||||
{t(mode.labelKey)}
|
||||
</span>
|
||||
</PopoverButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
</PopoverPanel>
|
||||
)}
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
'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;
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
/**
|
||||
* 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';
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
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;
|
||||
@@ -1,93 +0,0 @@
|
||||
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;
|
||||
@@ -737,7 +737,7 @@ const Sidebar = ({ children }: { children: React.ReactNode }) => {
|
||||
</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">
|
||||
<div className="fixed bottom-0 w-full z-50 flex flex-row items-center justify-around gap-x-1 bg-light-secondary dark:bg-dark-secondary px-2 py-2 shadow-sm lg:hidden">
|
||||
{topMainIds.map((id) => {
|
||||
const link = linkMap[id];
|
||||
if (!link) return null;
|
||||
@@ -746,43 +746,43 @@ const Sidebar = ({ children }: { children: React.ReactNode }) => {
|
||||
href={link.href}
|
||||
key={id}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center space-y-1 text-center w-full',
|
||||
'relative flex flex-col items-center gap-0.5 text-center flex-1 min-w-0',
|
||||
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" />
|
||||
<div className="absolute top-0 -mt-2 h-0.5 w-full rounded-b bg-black dark:bg-white" />
|
||||
)}
|
||||
<link.icon />
|
||||
<p className="text-xs text-fade min-w-0">{link.label}</p>
|
||||
<link.icon size={18} />
|
||||
<p className="text-[10px] leading-tight text-fade min-w-0 truncate">{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"
|
||||
className="relative flex flex-col items-center gap-0.5 text-center flex-1 min-w-0 text-black/50 dark:text-white/50 cursor-default"
|
||||
>
|
||||
<link.icon />
|
||||
<p className="text-xs text-fade min-w-0">{link.label}</p>
|
||||
<link.icon size={18} />
|
||||
<p className="text-[10px] leading-tight text-fade min-w-0 truncate">{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"
|
||||
className="relative flex flex-col items-center gap-0.5 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>
|
||||
<MoreHorizontal size={18} />
|
||||
<p className="text-[10px] leading-tight 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',
|
||||
'relative flex flex-col items-center gap-0.5 text-center shrink-0',
|
||||
profileLink.active
|
||||
? 'text-black dark:text-white'
|
||||
: 'text-black dark:text-white/70',
|
||||
@@ -790,10 +790,10 @@ const Sidebar = ({ children }: { children: React.ReactNode }) => {
|
||||
title={profileLink.label}
|
||||
>
|
||||
{profileLink.active && (
|
||||
<div className="absolute top-0 -mt-4 h-1 w-full rounded-b-lg bg-black dark:bg-white" />
|
||||
<div className="absolute top-0 -mt-2 h-0.5 w-full rounded-b bg-black dark:bg-white" />
|
||||
)}
|
||||
<profileLink.icon size={20} />
|
||||
<p className="text-xs text-fade min-w-0">{profileLink.label}</p>
|
||||
<profileLink.icon size={18} />
|
||||
<p className="text-[10px] leading-tight text-fade min-w-0 truncate">{profileLink.label}</p>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
19
services/web-svc/src/components/UnregisterSW.tsx
Normal file
19
services/web-svc/src/components/UnregisterSW.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Снимает регистрацию старых Service Worker (PWA отключён).
|
||||
* Очищает кэш SW у пользователей, у которых была предыдущая версия.
|
||||
*/
|
||||
export default function UnregisterSW() {
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) return;
|
||||
|
||||
navigator.serviceWorker.getRegistrations().then((registrations) => {
|
||||
registrations.forEach((reg) => reg.unregister());
|
||||
});
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user