feat: localization service and frontend integration

- Add localization-service microservice (locale resolution, translations)
- Add frontend API routes /api/locale and /api/translations/[locale]
- Add LocalizationProvider and localization context
- Integrate localization into layout, EmptyChat, MessageInput components
- Update MICROSERVICES.md architecture docs
- Add localization-service to workspaces

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
home
2026-02-20 21:45:44 +03:00
parent 4269c8d99b
commit f4d945a2b5
21 changed files with 878 additions and 107 deletions

View File

@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server';
const LOCALIZATION_SERVICE_URL =
process.env.LOCALIZATION_SERVICE_URL ?? 'http://localhost:4003';
const fallbackLocale = {
locale: 'en',
language: 'en',
region: null,
countryCode: null,
timezone: null,
source: 'fallback' as const,
};
export async function GET(req: NextRequest) {
try {
const res = await fetch(`${LOCALIZATION_SERVICE_URL}/api/locale`, {
headers: {
'x-forwarded-for': req.headers.get('x-forwarded-for') ?? '',
'x-real-ip': req.headers.get('x-real-ip') ?? '',
'user-agent': req.headers.get('user-agent') ?? '',
'accept-language': req.headers.get('accept-language') ?? '',
},
});
if (!res.ok) return NextResponse.json(fallbackLocale);
const data = await res.json();
return NextResponse.json(data);
} catch {
return NextResponse.json(fallbackLocale);
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const res = await fetch(`${LOCALIZATION_SERVICE_URL}/api/locale`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-forwarded-for': req.headers.get('x-forwarded-for') ?? '',
'x-real-ip': req.headers.get('x-real-ip') ?? '',
'user-agent': req.headers.get('user-agent') ?? '',
'accept-language': req.headers.get('accept-language') ?? '',
},
body: JSON.stringify(body),
});
if (!res.ok) return NextResponse.json(fallbackLocale);
const data = await res.json();
return NextResponse.json(data);
} catch {
return NextResponse.json(fallbackLocale);
}
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
const LOCALIZATION_SERVICE_URL =
process.env.LOCALIZATION_SERVICE_URL ?? 'http://localhost:4003';
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ locale: string }> },
) {
try {
const { locale } = await params;
if (!locale) {
return NextResponse.json(
{ error: 'Locale is required' },
{ status: 400 },
);
}
const res = await fetch(
`${LOCALIZATION_SERVICE_URL}/api/translations/${encodeURIComponent(locale)}`,
);
if (!res.ok) {
return NextResponse.json(
{ error: 'Failed to fetch translations' },
{ status: 502 },
);
}
const data = await res.json();
return NextResponse.json(data);
} catch {
return NextResponse.json(
{ error: 'Failed to fetch translations' },
{ status: 500 },
);
}
}

View File

@@ -7,6 +7,7 @@ import { cn } from '@/lib/utils';
import Sidebar from '@/components/Sidebar';
import { Toaster } from 'sonner';
import ThemeProvider from '@/components/theme/Provider';
import { LocalizationProvider } from '@/lib/localization/context';
import configManager from '@/lib/config';
import SetupWizard from '@/components/Setup/SetupWizard';
import { ChatProvider } from '@/lib/hooks/useChat';
@@ -36,8 +37,9 @@ export default function RootLayout({
<html className="h-full" lang="en" suppressHydrationWarning>
<body className={cn('h-full antialiased', roboto.className)}>
<ThemeProvider>
{setupComplete ? (
<ChatProvider>
<LocalizationProvider>
{setupComplete ? (
<ChatProvider>
<Sidebar>{children}</Sidebar>
<Toaster
toastOptions={{
@@ -48,10 +50,11 @@ export default function RootLayout({
},
}}
/>
</ChatProvider>
) : (
<SetupWizard configSections={configSections} />
)}
</ChatProvider>
) : (
<SetupWizard configSections={configSections} />
)}
</LocalizationProvider>
</ThemeProvider>
</body>
</html>

View File

@@ -1,10 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { Settings } from 'lucide-react';
import EmptyChatMessageInput from './EmptyChatMessageInput';
import { File } from './ChatWindow';
import Link from 'next/link';
import WeatherWidget from './WeatherWidget';
import NewsArticleWidget from './NewsArticleWidget';
import SettingsButtonMobile from '@/components/Settings/SettingsButtonMobile';
@@ -12,8 +9,10 @@ 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,
);
@@ -64,7 +63,7 @@ const EmptyChat = () => {
</span>
</div>
<h2 className="text-black/70 dark:text-white/70 text-3xl font-medium">
Research begins here.
{t('empty.subtitle')}
</h2>
<EmptyChatMessageInput />
</div>

View File

@@ -5,10 +5,12 @@ import Sources from './MessageInputActions/Sources';
import Optimization from './MessageInputActions/Optimization';
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('');
@@ -62,7 +64,7 @@ const EmptyChatMessageInput = () => {
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="Ask anything..."
placeholder={t('input.askAnything')}
/>
<div className="flex flex-row items-center justify-between mt-4">
<Optimization />

View File

@@ -4,9 +4,11 @@ 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('');
@@ -74,7 +76,7 @@ const MessageInput = () => {
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="Ask a follow-up"
placeholder={t('input.askFollowUp')}
/>
{mode === 'single' && (
<button

View File

@@ -0,0 +1,75 @@
/**
* Клиент для Localization Service.
* Получает locale на основе геопозиции (через geo-device-service) и переводы.
*/
const LOCALE_API = '/api/locale';
const TRANSLATIONS_API = '/api/translations';
export interface LocalizationContext {
locale: string;
language: string;
region: string | null;
countryCode: string | null;
timezone: string | null;
source: 'geo' | 'accept-language' | 'client' | 'fallback';
}
const collectClientData = () => {
if (typeof window === 'undefined') return {};
const nav = navigator;
return {
client: {
language: nav.language,
languages: nav.languages ? [...nav.languages] : undefined,
},
};
};
/**
* Получить locale (серверный вызов — без client data).
*/
export async function fetchLocale(): Promise<LocalizationContext> {
const res = await fetch(LOCALE_API);
if (!res.ok) throw new Error('Failed to fetch locale');
return res.json();
}
/**
* Получить locale с данными клиента (браузер language).
*/
export async function fetchLocaleWithClient(): Promise<LocalizationContext> {
const clientData = collectClientData();
const res = await fetch(LOCALE_API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(clientData),
});
if (!res.ok) throw new Error('Failed to fetch locale');
return res.json();
}
/**
* Получить переводы для locale.
*/
export async function fetchTranslations(
locale: string,
): Promise<Record<string, string>> {
const res = await fetch(
`${TRANSLATIONS_API}/${encodeURIComponent(locale)}`,
);
if (!res.ok) throw new Error('Failed to fetch translations');
return res.json();
}
/**
* Получить locale и переводы одним вызовом.
*/
export async function fetchLocalization(): Promise<{
locale: LocalizationContext;
translations: Record<string, string>;
}> {
const locale = await fetchLocaleWithClient();
const translations = await fetchTranslations(locale.locale);
return { locale, translations };
}

View File

@@ -0,0 +1,133 @@
'use client';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import {
fetchLocaleWithClient,
fetchTranslations,
type LocalizationContext as LocaleContext,
} from '@/lib/localization';
type Translations = Record<string, string>;
interface LocalizationState {
locale: LocaleContext | null;
translations: Translations | null;
loading: boolean;
error: boolean;
}
interface LocalizationContextValue extends LocalizationState {
t: (key: string) => string;
localeCode: string;
}
const defaultTranslations: Translations = {
'app.title': 'GooSeek',
'app.searchPlaceholder': 'Search...',
'empty.subtitle': 'Research begins here.',
'input.askAnything': 'Ask anything...',
'input.askFollowUp': 'Ask a follow-up',
'chat.send': 'Send',
'chat.newChat': 'New Chat',
'chat.suggestions': 'Suggestions',
'common.loading': 'Loading...',
'common.error': 'Error',
'common.retry': 'Retry',
};
const LocalizationContext = createContext<LocalizationContextValue | null>(null);
export function LocalizationProvider({
children,
}: {
children: React.ReactNode;
}) {
const [state, setState] = useState<LocalizationState>({
locale: null,
translations: null,
loading: true,
error: false,
});
useEffect(() => {
let cancelled = false;
async function load() {
try {
const loc = await fetchLocaleWithClient();
if (cancelled) return;
const trans = await fetchTranslations(loc.locale);
if (cancelled) return;
setState({
locale: loc,
translations: trans,
loading: false,
error: false,
});
if (typeof document !== 'undefined') {
document.documentElement.lang = loc.locale;
}
} catch {
if (cancelled) return;
setState({
locale: null,
translations: null,
loading: false,
error: true,
});
}
}
load();
return () => {
cancelled = true;
};
}, []);
const t = useCallback(
(key: string): string => {
if (state.translations && key in state.translations) {
return state.translations[key] ?? key;
}
return defaultTranslations[key] ?? key;
},
[state.translations],
);
const localeCode = state.locale?.locale ?? 'en';
const value = useMemo<LocalizationContextValue>(
() => ({
...state,
t,
localeCode,
}),
[state, t, localeCode],
);
return (
<LocalizationContext.Provider value={value}>
{children}
</LocalizationContext.Provider>
);
}
export function useTranslation(): LocalizationContextValue {
const ctx = useContext(LocalizationContext);
if (!ctx) {
return {
locale: null,
translations: null,
loading: false,
error: true,
t: (key: string) => defaultTranslations[key] ?? key,
localeCode: 'en',
};
}
return ctx;
}