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:
home
2026-02-23 22:14:00 +03:00
parent cd6b7857ba
commit 328d968f3f
180 changed files with 3022 additions and 9798 deletions

View File

@@ -1,13 +1,16 @@
# syntax=docker/dockerfile:1
# web-svc — Next.js UI, standalone output
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
COPY services ./services
RUN npm ci
# npm cache — ускоряет npm ci, НЕ трогает билд
RUN --mount=type=cache,target=/root/.npm \
npm ci
ENV NEXT_TELEMETRY_DISABLED=1
# K8s: rewrites запекаются при сборке, нужен URL api-gateway
ARG API_GATEWAY_URL=http://api-gateway.gooseek:3015
ENV API_GATEWAY_URL=${API_GATEWAY_URL}
# БЕЗ cache для .next — иначе старый билд попадает в прод
RUN npm run build -w web-svc
FROM node:22-alpine AS runner

View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -31,6 +31,21 @@ const nextConfig = {
env: {
NEXT_PUBLIC_VERSION: pkg.version,
},
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Cache-Control',
value: 'no-cache, no-store, must-revalidate, max-age=0',
},
{ key: 'Pragma', value: 'no-cache' },
{ key: 'Expires', value: '0' },
],
},
];
},
async rewrites() {
const gateway = process.env.API_GATEWAY_URL ?? 'http://localhost:3015';
return [
@@ -52,14 +67,10 @@ const nextConfig = {
},
};
// PWA отключён — весь кэш выключен, хранятся только данные пользователя (localStorage)
const withPWA = require('@ducanh2912/next-pwa').default({
dest: 'public',
disable: process.env.NODE_ENV === 'development',
register: true,
skipWaiting: true,
fallbacks: {
document: '/offline',
},
disable: true,
});
export default withPWA(nextConfig);

View File

@@ -12,6 +12,7 @@ import { ChatProvider } from '@/lib/hooks/useChat';
import { ClientOnly } from '@/components/ClientOnly';
import GuestWarningBanner from '@/components/GuestWarningBanner';
import GuestMigration from '@/components/GuestMigration';
import UnregisterSW from '@/components/UnregisterSW';
const roboto = Roboto({
weight: ['300', '400', '500', '700'],
@@ -74,6 +75,7 @@ export default async function RootLayout({
}
>
<ChatProvider>
<UnregisterSW />
<Sidebar>{children}</Sidebar>
<GuestWarningBanner />
<GuestMigration />

View File

@@ -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

View File

@@ -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>
)}

View File

@@ -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;

View File

@@ -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';

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View 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;
}

View File

@@ -34,7 +34,10 @@ export type Section = {
suggestions?: string[];
};
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';
type ChatContext = {
messages: Message[];
@@ -115,27 +118,40 @@ const checkConfig = async (
}
const data = await res.json();
const providers: MinimalProvider[] = data.providers;
const providers: MinimalProvider[] = data.providers ?? [];
const envOnlyMode = data.envOnlyMode ?? false;
if (providers.length === 0) {
if (providers.length === 0 && !envOnlyMode) {
throw new Error('Сервис настраивается. Попробуйте позже.');
}
const chatModelProvider =
let chatModelProvider =
providers.find((p) => p.id === chatModelProviderId) ??
providers.find((p) => p.chatModels.length > 0);
if (!chatModelProvider) {
if (!chatModelProvider && !envOnlyMode) {
throw new Error('Сервис настраивается. Попробуйте позже.');
}
chatModelProviderId = chatModelProvider.id;
const chatModel =
chatModelProvider.chatModels.find((m) => m.key === chatModelKey) ??
chatModelProvider.chatModels[0];
chatModelKey = chatModel.key;
if (chatModelProvider) {
chatModelProviderId = chatModelProvider.id;
const chatModel =
chatModelProvider.chatModels.find((m) => m.key === chatModelKey) ??
chatModelProvider.chatModels[0];
chatModelKey = chatModel.key;
} else if (envOnlyMode && providers.length > 0) {
const envProvider = providers.find((p) => p.id.startsWith('env-'));
if (envProvider) {
chatModelProviderId = envProvider.id;
chatModelKey = envProvider.chatModels[0]?.key ?? 'default';
} else {
chatModelProviderId = providers[0].id;
chatModelKey = providers[0].chatModels[0]?.key ?? 'default';
}
} else {
chatModelProviderId = 'env';
chatModelKey = 'default';
}
const embeddingModelProvider =
providers.find((p) => p.id === embeddingModelProviderId) ??
@@ -637,7 +653,8 @@ export const ChatProvider = ({ children }: { children: React.ReactNode }) => {
useEffect(() => {
const urlMode = searchParams.get('answerMode');
if (urlMode && ['standard', 'focus', 'academic', 'writing', 'travel', 'finance'].includes(urlMode)) {
const validModes: AnswerModeKey[] = ['standard', 'focus', 'academic', 'writing', 'travel', 'finance', 'health', 'education', 'medicine', 'realEstate', 'psychology', 'sports', 'children', 'goods', 'shopping', 'games', 'taxes', 'legislation'];
if (urlMode && validModes.includes(urlMode as AnswerModeKey)) {
setAnswerMode(urlMode as AnswerModeKey);
}
}, [searchParams]);

View File

@@ -64,6 +64,10 @@ const defaultTranslations: Translations = {
'nav.games': 'Games',
'nav.taxes': 'Taxes',
'nav.legislation': 'Legislation',
'answerMode.standard': 'Standard',
'answerMode.focus': 'Focus',
'answerMode.academic': 'Academic',
'answerMode.writing': 'Writing',
'nav.sidebarSettings': 'Main menu settings',
'nav.configureMenu': 'Configure menu',
'nav.menuSettingsHint': 'Toggle items on/off with the switch, change order with up/down arrows.',

View File

@@ -43,6 +43,10 @@ export const embeddedTranslations: Record<
'nav.games': 'Games',
'nav.taxes': 'Taxes',
'nav.legislation': 'Legislation',
'answerMode.standard': 'Standard',
'answerMode.focus': 'Focus',
'answerMode.academic': 'Academic',
'answerMode.writing': 'Writing',
'nav.sidebarSettings': 'Main menu settings',
'nav.configureMenu': 'Configure menu',
'nav.menuSettingsHint': 'Toggle items on/off with the switch, change order with up/down arrows.',
@@ -142,6 +146,10 @@ export const embeddedTranslations: Record<
'nav.games': 'Игры',
'nav.taxes': 'Налоги',
'nav.legislation': 'Законодательство',
'answerMode.standard': 'Стандарт',
'answerMode.focus': 'Фокус',
'answerMode.academic': 'Академический',
'answerMode.writing': 'Письмо',
'nav.sidebarSettings': 'Настройка главного меню',
'nav.configureMenu': 'Настроить меню',
'nav.menuSettingsHint': 'Включайте и выключайте пункты переключателем, меняйте порядок стрелками вверх и вниз.',
@@ -241,6 +249,10 @@ export const embeddedTranslations: Record<
'nav.games': 'Spiele',
'nav.taxes': 'Steuern',
'nav.legislation': 'Gesetzgebung',
'answerMode.standard': 'Standard',
'answerMode.focus': 'Fokus',
'answerMode.academic': 'Akademisch',
'answerMode.writing': 'Schreiben',
'nav.sidebarSettings': 'Einstellungen Hauptmenü',
'nav.configureMenu': 'Menü anpassen',
'nav.menuSettingsHint': 'Punkte mit Schalter ein/aus, Reihenfolge mit Pfeilen oben/unten ändern.',
@@ -340,6 +352,10 @@ export const embeddedTranslations: Record<
'nav.games': 'Jeux',
'nav.taxes': 'Impôts',
'nav.legislation': 'Législation',
'answerMode.standard': 'Standard',
'answerMode.focus': 'Focus',
'answerMode.academic': 'Académique',
'answerMode.writing': 'Écriture',
'nav.sidebarSettings': 'Paramètres du menu principal',
'nav.configureMenu': 'Configurer le menu',
'nav.menuSettingsHint': 'Activer/désactiver avec l\'interrupteur, changer l\'ordre avec les flèches haut/bas.',
@@ -439,6 +455,10 @@ export const embeddedTranslations: Record<
'nav.games': 'Juegos',
'nav.taxes': 'Impuestos',
'nav.legislation': 'Legislación',
'answerMode.standard': 'Estándar',
'answerMode.focus': 'Enfoque',
'answerMode.academic': 'Académico',
'answerMode.writing': 'Escritura',
'nav.sidebarSettings': 'Configuración del menú principal',
'nav.configureMenu': 'Configurar menú',
'nav.menuSettingsHint': 'Activar/desactivar con el interruptor, cambiar orden con flechas arriba/abajo.',
@@ -538,6 +558,10 @@ export const embeddedTranslations: Record<
'nav.games': 'Ігри',
'nav.taxes': 'Податки',
'nav.legislation': 'Законодавство',
'answerMode.standard': 'Стандарт',
'answerMode.focus': 'Фокус',
'answerMode.academic': 'Академічний',
'answerMode.writing': 'Письмо',
'nav.sidebarSettings': 'Налаштування головного меню',
'nav.configureMenu': 'Налаштувати меню',
'nav.menuSettingsHint': 'Вмикайте і вимикайте пункти перемикачем, змінюйте порядок стрілками вгору і вниз.',