feat(i18n): Integrate next-intl, localize core UI, add regional locales and zh-TW Discover sources

**Overview**
- Integrates next-intl (App Router, no i18n routing) with cookie-based locale and Accept-Language fallback.
- Adds message bundles and regional variants; sets en-US as the default.

**Key changes**
- i18n foundation
  - Adds request-scoped config to load messages per locale and injects NextIntlClientProvider in [layout.tsx]
  - Adds/updates messages for: en-US, en-GB, zh-TW, zh-HK, zh-CN, ja, ko, fr-FR, fr-CA, de.
Centralizes LOCALES, LOCALE_LABELS, and DEFAULT_LOCALE in [locales.ts]
  - Adds LocaleSwitcher (cookie-based) and [LocaleBootstrap]

- Pages and components
  - Localizes Sidebar, Home (including metadata/manifest), Settings, Discover, Library.
  - Localizes common components: MessageInput, Attach, Focus, Optimization, MessageBox, MessageSources, SearchImages, SearchVideos, EmptyChat, NewsArticleWidget, WeatherWidget.

- APIs
  - Weather API returns localized condition strings server-side.

- UX and quality
  - Converts all remaining <img> to Next Image.
  - Updates browserslist/caniuse DB to silence warnings.
  - Security: Settings API Key inputs are now password fields and placeholders were removed.
This commit is contained in:
wei840222 2025-08-16 12:27:18 +08:00
parent 0dc17286b9
commit 9a772d6abe
56 changed files with 3673 additions and 365 deletions

View file

@ -1,4 +1,5 @@
import { searchSearxng } from '@/lib/searxng';
import { getLocale } from 'next-intl/server';
const websitesForTopic = {
tech: {
@ -37,6 +38,10 @@ export const GET = async (req: Request) => {
let data = [];
// derive base language from current locale (e.g., zh-TW -> zh)
const locale = await getLocale();
const searxLanguage = 'en';
if (mode === 'normal') {
const seenUrls = new Set();
@ -46,9 +51,9 @@ export const GET = async (req: Request) => {
selectedTopic.query.map(async (query) => {
return (
await searchSearxng(`site:${link} ${query}`, {
engines: ['bing news'],
engines: ['google news', 'bing news'],
pageno: 1,
language: 'en',
language: searxLanguage,
})
).results;
}),
@ -68,9 +73,9 @@ export const GET = async (req: Request) => {
await searchSearxng(
`site:${selectedTopic.links[Math.floor(Math.random() * selectedTopic.links.length)]} ${selectedTopic.query[Math.floor(Math.random() * selectedTopic.query.length)]}`,
{
engines: ['bing news'],
engines: ['google news', 'bing news'],
pageno: 1,
language: 'en',
language: searxLanguage,
},
)
).results;

View file

@ -1,5 +1,8 @@
import { getTranslations } from 'next-intl/server';
export const POST = async (req: Request) => {
try {
const t = await getTranslations('weather.conditions');
const body: {
lat: number;
lng: number;
@ -58,104 +61,104 @@ export const POST = async (req: Request) => {
switch (code) {
case 0:
weather.icon = `clear-${dayOrNight}`;
weather.condition = 'Clear';
weather.condition = t('clear');
break;
case 1:
weather.condition = 'Mainly Clear';
weather.condition = t('mainlyClear');
case 2:
weather.condition = 'Partly Cloudy';
weather.condition = t('partlyCloudy');
case 3:
weather.icon = `cloudy-1-${dayOrNight}`;
weather.condition = 'Cloudy';
weather.condition = t('cloudy');
break;
case 45:
weather.condition = 'Fog';
weather.condition = t('fog');
case 48:
weather.icon = `fog-${dayOrNight}`;
weather.condition = 'Fog';
weather.condition = t('fog');
break;
case 51:
weather.condition = 'Light Drizzle';
weather.condition = t('lightDrizzle');
case 53:
weather.condition = 'Moderate Drizzle';
weather.condition = t('moderateDrizzle');
case 55:
weather.icon = `rainy-1-${dayOrNight}`;
weather.condition = 'Dense Drizzle';
weather.condition = t('denseDrizzle');
break;
case 56:
weather.condition = 'Light Freezing Drizzle';
weather.condition = t('lightFreezingDrizzle');
case 57:
weather.icon = `frost-${dayOrNight}`;
weather.condition = 'Dense Freezing Drizzle';
weather.condition = t('denseFreezingDrizzle');
break;
case 61:
weather.condition = 'Slight Rain';
weather.condition = t('slightRain');
case 63:
weather.condition = 'Moderate Rain';
weather.condition = t('moderateRain');
case 65:
weather.condition = 'Heavy Rain';
weather.condition = t('heavyRain');
weather.icon = `rainy-2-${dayOrNight}`;
break;
case 66:
weather.condition = 'Light Freezing Rain';
weather.condition = t('lightFreezingRain');
case 67:
weather.condition = 'Heavy Freezing Rain';
weather.condition = t('heavyFreezingRain');
weather.icon = 'rain-and-sleet-mix';
break;
case 71:
weather.condition = 'Slight Snow Fall';
weather.condition = t('slightSnowFall');
case 73:
weather.condition = 'Moderate Snow Fall';
weather.condition = t('moderateSnowFall');
case 75:
weather.condition = 'Heavy Snow Fall';
weather.condition = t('heavySnowFall');
weather.icon = `snowy-2-${dayOrNight}`;
break;
case 77:
weather.condition = 'Snow';
weather.condition = t('snow');
weather.icon = `snowy-1-${dayOrNight}`;
break;
case 80:
weather.condition = 'Slight Rain Showers';
weather.condition = t('slightRainShowers');
case 81:
weather.condition = 'Moderate Rain Showers';
weather.condition = t('moderateRainShowers');
case 82:
weather.condition = 'Heavy Rain Showers';
weather.condition = t('heavyRainShowers');
weather.icon = `rainy-3-${dayOrNight}`;
break;
case 85:
weather.condition = 'Slight Snow Showers';
weather.condition = t('slightSnowShowers');
case 86:
weather.condition = 'Moderate Snow Showers';
weather.condition = t('moderateSnowShowers');
case 87:
weather.condition = 'Heavy Snow Showers';
weather.condition = t('heavySnowShowers');
weather.icon = `snowy-3-${dayOrNight}`;
break;
case 95:
weather.condition = 'Thunderstorm';
weather.condition = t('thunderstorm');
weather.icon = `scattered-thunderstorms-${dayOrNight}`;
break;
case 96:
weather.condition = 'Thunderstorm with Slight Hail';
weather.condition = t('thunderstormSlightHail');
case 99:
weather.condition = 'Thunderstorm with Heavy Hail';
weather.condition = t('thunderstormHeavyHail');
weather.icon = 'severe-thunderstorm';
break;
default:
weather.icon = `clear-${dayOrNight}`;
weather.condition = 'Clear';
weather.condition = t('clear');
break;
}

View file

@ -1,8 +1,10 @@
'use client';
import { Search } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import Link from 'next/link';
import Image from 'next/image';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
@ -13,64 +15,55 @@ interface Discover {
thumbnail: string;
}
const topics: { key: string; display: string }[] = [
{
display: 'Tech & Science',
key: 'tech',
},
{
display: 'Finance',
key: 'finance',
},
{
display: 'Art & Culture',
key: 'art',
},
{
display: 'Sports',
key: 'sports',
},
{
display: 'Entertainment',
key: 'entertainment',
},
const topics: {
key: 'tech' | 'finance' | 'art' | 'sports' | 'entertainment';
}[] = [
{ key: 'tech' },
{ key: 'finance' },
{ key: 'art' },
{ key: 'sports' },
{ key: 'entertainment' },
];
const Page = () => {
const [discover, setDiscover] = useState<Discover[] | null>(null);
const [loading, setLoading] = useState(true);
const [activeTopic, setActiveTopic] = useState<string>(topics[0].key);
const t = useTranslations('pages.discover');
const fetchArticles = async (topic: string) => {
setLoading(true);
try {
const res = await fetch(`/api/discover?topic=${topic}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const fetchArticles = useCallback(
async (topic: string) => {
setLoading(true);
try {
const res = await fetch(`/api/discover?topic=${topic}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const data = await res.json();
const data = await res.json();
if (!res.ok) {
throw new Error(data.message);
if (!res.ok) {
throw new Error(data.message);
}
data.blogs = data.blogs.filter((blog: Discover) => blog.thumbnail);
setDiscover(data.blogs);
} catch (err: any) {
console.error('Error fetching data:', err.message);
toast.error(t('errorFetchingData'));
} finally {
setLoading(false);
}
data.blogs = data.blogs.filter((blog: Discover) => blog.thumbnail);
setDiscover(data.blogs);
} catch (err: any) {
console.error('Error fetching data:', err.message);
toast.error('Error fetching data');
} finally {
setLoading(false);
}
};
},
[t],
);
useEffect(() => {
fetchArticles(activeTopic);
}, [activeTopic]);
}, [activeTopic, fetchArticles]);
return (
<>
@ -78,24 +71,24 @@ const Page = () => {
<div className="flex flex-col pt-4">
<div className="flex items-center">
<Search />
<h1 className="text-3xl font-medium p-2">Discover</h1>
<h1 className="text-3xl font-medium p-2">{t('title')}</h1>
</div>
<hr className="border-t border-[#2B2C2C] my-4 w-full" />
</div>
<div className="flex flex-row items-center space-x-2 overflow-x-auto">
{topics.map((t, i) => (
{topics.map((topic, i) => (
<div
key={i}
className={cn(
'border-[0.1px] rounded-full text-sm px-3 py-1 text-nowrap transition duration-200 cursor-pointer',
activeTopic === t.key
? 'text-cyan-300 bg-cyan-300/30 border-cyan-300/60'
: 'border-white/30 text-white/70 hover:text-white hover:border-white/40 hover:bg-white/5',
activeTopic === topic.key
? 'text-cyan-600 bg-cyan-100 border-cyan-300 dark:text-cyan-300 dark:bg-cyan-300/30 dark:border-cyan-300/60'
: 'text-gray-700 border-gray-300 hover:text-gray-900 hover:bg-gray-100 hover:border-gray-400 dark:text-white/70 dark:border-white/30 dark:hover:text-white dark:hover:bg-white/5 dark:hover:border-white/40',
)}
onClick={() => setActiveTopic(t.key)}
onClick={() => setActiveTopic(topic.key)}
>
<span>{t.display}</span>
<span>{t(`topics.${topic.key}`)}</span>
</div>
))}
</div>
@ -129,15 +122,20 @@ const Page = () => {
className="max-w-sm rounded-lg overflow-hidden bg-light-secondary dark:bg-dark-secondary hover:-translate-y-[1px] transition duration-200"
target="_blank"
>
<img
className="object-cover w-full aspect-video"
src={
new URL(item.thumbnail).origin +
new URL(item.thumbnail).pathname +
`?id=${new URL(item.thumbnail).searchParams.get('id')}`
}
alt={item.title}
/>
<div className="relative w-full aspect-video">
<Image
fill
unoptimized
className="object-cover"
src={
new URL(item.thumbnail).origin +
new URL(item.thumbnail).pathname +
`?id=${new URL(item.thumbnail).searchParams.get('id')}`
}
alt={item.title}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>
</div>
<div className="px-6 py-4">
<div className="font-bold text-lg mb-2">
{item.title.slice(0, 100)}...

View file

@ -5,6 +5,10 @@ import { cn } from '@/lib/utils';
import Sidebar from '@/components/Sidebar';
import { Toaster } from 'sonner';
import ThemeProvider from '@/components/theme/Provider';
import { NextIntlClientProvider } from 'next-intl';
import { getLocale, getTranslations } from 'next-intl/server';
import LocaleBootstrap from '@/components/LocaleBootstrap';
import { LOCALES, DEFAULT_LOCALE, type AppLocale } from '@/i18n/locales';
const montserrat = Montserrat({
weight: ['300', '400', '500', '700'],
@ -13,32 +17,43 @@ const montserrat = Montserrat({
fallback: ['Arial', 'sans-serif'],
});
export const metadata: Metadata = {
title: 'Perplexica - Chat with the internet',
description:
'Perplexica is an AI powered chatbot that is connected to the internet.',
};
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('metadata');
return {
title: t('title'),
description: t('description'),
};
}
export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const locale = await getLocale();
const appLocale: AppLocale = (LOCALES as readonly string[]).includes(
locale as string,
)
? (locale as AppLocale)
: DEFAULT_LOCALE;
return (
<html className="h-full" lang="en" suppressHydrationWarning>
<html className="h-full" lang={locale} suppressHydrationWarning>
<body className={cn('h-full', montserrat.className)}>
<ThemeProvider>
<Sidebar>{children}</Sidebar>
<Toaster
toastOptions={{
unstyled: true,
classNames: {
toast:
'bg-light-primary dark:bg-dark-secondary dark:text-white/70 text-black-70 rounded-lg p-4 flex flex-row items-center space-x-2',
},
}}
/>
</ThemeProvider>
<LocaleBootstrap initialLocale={appLocale} />
<NextIntlClientProvider>
<ThemeProvider>
<Sidebar>{children}</Sidebar>
<Toaster
toastOptions={{
unstyled: true,
classNames: {
toast:
'bg-light-primary dark:bg-dark-secondary dark:text-white/70 text-black-70 rounded-lg p-4 flex flex-row items-center space-x-2',
},
}}
/>
</ThemeProvider>
</NextIntlClientProvider>
</body>
</html>
);

View file

@ -1,9 +1,13 @@
import { Metadata } from 'next';
import React from 'react';
import { getTranslations } from 'next-intl/server';
export const metadata: Metadata = {
title: 'Library - Perplexica',
};
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('pages.library');
return {
title: `${t('title')} - Perplexica`,
};
}
const Layout = ({ children }: { children: React.ReactNode }) => {
return <div>{children}</div>;

View file

@ -1,10 +1,11 @@
'use client';
import DeleteChat from '@/components/DeleteChat';
import { cn, formatTimeDifference } from '@/lib/utils';
import { cn, formatTimeDifference, formatRelativeTime } from '@/lib/utils';
import { BookOpenText, ClockIcon, Delete, ScanEye } from 'lucide-react';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
export interface Chat {
id: string;
@ -16,6 +17,8 @@ export interface Chat {
const Page = () => {
const [chats, setChats] = useState<Chat[]>([]);
const [loading, setLoading] = useState(true);
const t = useTranslations('pages.library');
const locale = useLocale();
useEffect(() => {
const fetchChats = async () => {
@ -61,14 +64,14 @@ const Page = () => {
<div className="flex flex-col pt-4">
<div className="flex items-center">
<BookOpenText />
<h1 className="text-3xl font-medium p-2">Library</h1>
<h1 className="text-3xl font-medium p-2">{t('title')}</h1>
</div>
<hr className="border-t border-[#2B2C2C] my-4 w-full" />
</div>
{chats.length === 0 && (
<div className="flex flex-row items-center justify-center min-h-screen">
<p className="text-black/70 dark:text-white/70 text-sm">
No chats found.
{t('empty')}
</p>
</div>
)}
@ -94,7 +97,7 @@ const Page = () => {
<div className="flex flex-row items-center space-x-1 lg:space-x-1.5 text-black/70 dark:text-white/70">
<ClockIcon size={15} />
<p className="text-xs">
{formatTimeDifference(new Date(), chat.createdAt)} Ago
{formatRelativeTime(new Date(), chat.createdAt, locale)}
</p>
</div>
<DeleteChat

View file

@ -1,11 +1,12 @@
import type { MetadataRoute } from 'next';
import { getTranslations } from 'next-intl/server';
export default function manifest(): MetadataRoute.Manifest {
export default async function manifest(): Promise<MetadataRoute.Manifest> {
const t = await getTranslations('manifest');
return {
name: 'Perplexica - Chat with the internet',
short_name: 'Perplexica',
description:
'Perplexica is an AI powered chatbot that is connected to the internet.',
name: t('name'),
short_name: t('shortName'),
description: t('description'),
start_url: '/',
display: 'standalone',
background_color: '#0a0a0a',

View file

@ -1,11 +1,15 @@
import ChatWindow from '@/components/ChatWindow';
import { Metadata } from 'next';
import type { Metadata } from 'next';
import { Suspense } from 'react';
import { getTranslations } from 'next-intl/server';
export const metadata: Metadata = {
title: 'Chat - Perplexica',
description: 'Chat with the internet, chat with Perplexica.',
};
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('pages.home');
return {
title: t('title'),
description: t('description'),
};
}
const Home = () => {
return (

View file

@ -1,13 +1,16 @@
'use client';
import { Settings as SettingsIcon, ArrowLeft, Loader2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { cn } from '@/lib/utils';
import { Switch } from '@headlessui/react';
import ThemeSwitcher from '@/components/theme/Switcher';
import { ImagesIcon, VideoIcon } from 'lucide-react';
import Link from 'next/link';
import { PROVIDER_METADATA } from '@/lib/providers';
import LocaleSwitcher from '@/components/LocaleSwitcher';
import { getPromptLanguageName } from '@/i18n/locales';
import { useLocale, useTranslations } from 'next-intl';
interface SettingsType {
chatModelProviders: {
@ -128,6 +131,8 @@ const SettingsSection = ({
);
const Page = () => {
const t = useTranslations('pages.settings');
const locale = useLocale();
const [config, setConfig] = useState<SettingsType | null>(null);
const [chatModels, setChatModels] = useState<Record<string, any>>({});
const [embeddingModels, setEmbeddingModels] = useState<Record<string, any>>(
@ -211,7 +216,8 @@ const Page = () => {
localStorage.getItem('autoVideoSearch') === 'true',
);
setSystemInstructions(localStorage.getItem('systemInstructions')!);
const stored = localStorage.getItem('systemInstructions') || '';
setSystemInstructions(stripPrefixedPrompt(stored));
setMeasureUnit(
localStorage.getItem('measureUnit')! as 'Imperial' | 'Metric',
@ -223,6 +229,37 @@ const Page = () => {
fetchConfig();
}, []);
// Remove prefix for UI display if it exists in stored value
const stripPrefixedPrompt = (text: string) => {
const trimmed = (text || '').trim();
const starts = 'Always respond to all non-code content and explanations in';
if (trimmed.startsWith(starts)) {
const parts = trimmed.split('\n\n');
// Drop the first block (prefix paragraph and rules)
const rest = parts.slice(1).join('\n\n');
return rest || '';
}
return trimmed;
};
const buildPrefixedPrompt = useCallback((base: string, loc: string) => {
const langName = getPromptLanguageName(loc);
const prefix = `Always respond to all non-code content and explanations in ${langName}.\nRules:\n1. All descriptions, explanations, and example clarifications must be in ${langName}.\n2. Any content inside code blocks and code comments must be entirely in English.\n3. For language-specific or technical terms, use the original term in that specific language (do not translate it).`;
const trimmed = (base || '').trim();
// If already starts with the prefix (by simple inclusion of first sentence), avoid duplicating
if (
trimmed.startsWith(
`Always respond to all non-code content and explanations in`,
)
) {
// If locale changed, replace the existing first paragraph block
const parts = trimmed.split('\n\n');
const rest = parts.slice(1).join('\n\n');
return `${prefix}${rest ? '\n\n' + rest : ''}`;
}
return prefix + (trimmed ? `\n\n${trimmed}` : '');
}, []);
const saveConfig = async (key: string, value: any) => {
setSavingStates((prev) => ({ ...prev, [key]: true }));
@ -397,7 +434,7 @@ const Page = () => {
</Link>
<div className="flex flex-row space-x-0.5 items-center">
<SettingsIcon size={23} />
<h1 className="text-3xl font-medium p-2">Settings</h1>
<h1 className="text-3xl font-medium p-2">{t('title')}</h1>
</div>
</div>
<hr className="border-t border-[#2B2C2C] my-4 w-full" />
@ -425,16 +462,16 @@ const Page = () => {
) : (
config && (
<div className="flex flex-col space-y-6 pb-28 lg:pb-8">
<SettingsSection title="Preferences">
<SettingsSection title={t('sections.preferences')}>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Theme
{t('preferences.theme')}
</p>
<ThemeSwitcher />
</div>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Measurement Units
{t('preferences.measurementUnits')}
</p>
<Select
value={measureUnit ?? undefined}
@ -444,19 +481,34 @@ const Page = () => {
}}
options={[
{
label: 'Metric',
label: t('preferences.metric'),
value: 'Metric',
},
{
label: 'Imperial',
label: t('preferences.imperial'),
value: 'Imperial',
},
]}
/>
</div>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
{t('preferences.language')}
</p>
<LocaleSwitcher
onChange={(nextLocale) => {
// Rebuild and persist with new locale prefix; keep UI clean
const prefixed = buildPrefixedPrompt(
systemInstructions,
nextLocale,
);
saveConfig('systemInstructions', prefixed);
}}
/>
</div>
</SettingsSection>
<SettingsSection title="Automatic Search">
<SettingsSection title={t('sections.automaticSearch')}>
<div className="flex flex-col space-y-4">
<div className="flex items-center justify-between p-3 bg-light-secondary dark:bg-dark-secondary rounded-lg hover:bg-light-200 dark:hover:bg-dark-200 transition-colors">
<div className="flex items-center space-x-3">
@ -468,11 +520,10 @@ const Page = () => {
</div>
<div>
<p className="text-sm text-black/90 dark:text-white/90 font-medium">
Automatic Image Search
{t('automaticSearch.image.title')}
</p>
<p className="text-xs text-black/60 dark:text-white/60 mt-0.5">
Automatically search for relevant images in chat
responses
{t('automaticSearch.image.desc')}
</p>
</div>
</div>
@ -510,11 +561,10 @@ const Page = () => {
</div>
<div>
<p className="text-sm text-black/90 dark:text-white/90 font-medium">
Automatic Video Search
{t('automaticSearch.video.title')}
</p>
<p className="text-xs text-black/60 dark:text-white/60 mt-0.5">
Automatically search for relevant videos in chat
responses
{t('automaticSearch.video.desc')}
</p>
</div>
</div>
@ -544,7 +594,7 @@ const Page = () => {
</div>
</SettingsSection>
<SettingsSection title="System Instructions">
<SettingsSection title={t('sections.systemInstructions')}>
<div className="flex flex-col space-y-4">
<Textarea
value={systemInstructions ?? undefined}
@ -552,17 +602,23 @@ const Page = () => {
onChange={(e) => {
setSystemInstructions(e.target.value);
}}
onSave={(value) => saveConfig('systemInstructions', value)}
onSave={(value) => {
const prefixed = buildPrefixedPrompt(value, locale);
// Keep UI as user input without prefix
setSystemInstructions(value);
saveConfig('systemInstructions', prefixed);
}}
placeholder={t('systemInstructions.placeholder')}
/>
</div>
</SettingsSection>
<SettingsSection title="Model Settings">
<SettingsSection title={t('sections.modelSettings')}>
{config.chatModelProviders && (
<div className="flex flex-col space-y-4">
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Chat Model Provider
{t('model.chatProvider')}
</p>
<Select
value={selectedChatModelProvider ?? undefined}
@ -593,7 +649,7 @@ const Page = () => {
selectedChatModelProvider != 'custom_openai' && (
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Chat Model
{t('model.chat')}
</p>
<Select
value={selectedChatModel ?? undefined}
@ -616,15 +672,14 @@ const Page = () => {
: [
{
value: '',
label: 'No models available',
label: t('model.noModels'),
disabled: true,
},
]
: [
{
value: '',
label:
'Invalid provider, please check backend logs',
label: t('model.invalidProvider'),
disabled: true,
},
];
@ -640,11 +695,11 @@ const Page = () => {
<div className="flex flex-col space-y-4">
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Model Name
{t('model.custom.modelName')}
</p>
<Input
type="text"
placeholder="Model name"
placeholder={t('model.custom.modelName')}
value={config.customOpenaiModelName}
isSaving={savingStates['customOpenaiModelName']}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
@ -660,11 +715,10 @@ const Page = () => {
</div>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Custom OpenAI API Key
{t('model.custom.apiKey')}
</p>
<Input
type="text"
placeholder="Custom OpenAI API Key"
type="password"
value={config.customOpenaiApiKey}
isSaving={savingStates['customOpenaiApiKey']}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
@ -680,11 +734,11 @@ const Page = () => {
</div>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Custom OpenAI Base URL
{t('model.custom.baseUrl')}
</p>
<Input
type="text"
placeholder="Custom OpenAI Base URL"
placeholder={t('model.custom.baseUrl')}
value={config.customOpenaiApiUrl}
isSaving={savingStates['customOpenaiApiUrl']}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
@ -705,7 +759,7 @@ const Page = () => {
<div className="flex flex-col space-y-4 mt-4 pt-4 border-t border-light-200 dark:border-dark-200">
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Embedding Model Provider
{t('embedding.provider')}
</p>
<Select
value={selectedEmbeddingModelProvider ?? undefined}
@ -735,7 +789,7 @@ const Page = () => {
{selectedEmbeddingModelProvider && (
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Embedding Model
{t('embedding.model')}
</p>
<Select
value={selectedEmbeddingModel ?? undefined}
@ -758,15 +812,14 @@ const Page = () => {
: [
{
value: '',
label: 'No models available',
label: t('model.noModels'),
disabled: true,
},
]
: [
{
value: '',
label:
'Invalid provider, please check backend logs',
label: t('model.invalidProvider'),
disabled: true,
},
];
@ -778,15 +831,14 @@ const Page = () => {
)}
</SettingsSection>
<SettingsSection title="API Keys">
<SettingsSection title={t('sections.apiKeys')}>
<div className="flex flex-col space-y-4">
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
OpenAI API Key
{t('api.openaiApiKey')}
</p>
<Input
type="text"
placeholder="OpenAI API Key"
type="password"
value={config.openaiApiKey}
isSaving={savingStates['openaiApiKey']}
onChange={(e) => {
@ -801,11 +853,11 @@ const Page = () => {
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Ollama API URL
{t('api.ollamaApiUrl')}
</p>
<Input
type="text"
placeholder="Ollama API URL"
placeholder={t('api.ollamaApiUrl')}
value={config.ollamaApiUrl}
isSaving={savingStates['ollamaApiUrl']}
onChange={(e) => {
@ -820,11 +872,10 @@ const Page = () => {
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
GROQ API Key
{t('api.groqApiKey')}
</p>
<Input
type="text"
placeholder="GROQ API Key"
type="password"
value={config.groqApiKey}
isSaving={savingStates['groqApiKey']}
onChange={(e) => {
@ -839,11 +890,10 @@ const Page = () => {
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Anthropic API Key
{t('api.anthropicApiKey')}
</p>
<Input
type="text"
placeholder="Anthropic API key"
type="password"
value={config.anthropicApiKey}
isSaving={savingStates['anthropicApiKey']}
onChange={(e) => {
@ -858,11 +908,10 @@ const Page = () => {
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Gemini API Key
{t('api.geminiApiKey')}
</p>
<Input
type="text"
placeholder="Gemini API key"
type="password"
value={config.geminiApiKey}
isSaving={savingStates['geminiApiKey']}
onChange={(e) => {
@ -877,11 +926,10 @@ const Page = () => {
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Deepseek API Key
{t('api.deepseekApiKey')}
</p>
<Input
type="text"
placeholder="Deepseek API Key"
type="password"
value={config.deepseekApiKey}
isSaving={savingStates['deepseekApiKey']}
onChange={(e) => {
@ -896,11 +944,10 @@ const Page = () => {
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
AI/ML API Key
{t('api.aimlApiKey')}
</p>
<Input
type="text"
placeholder="AI/ML API Key"
type="password"
value={config.aimlApiKey}
isSaving={savingStates['aimlApiKey']}
onChange={(e) => {
@ -915,11 +962,11 @@ const Page = () => {
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
LM Studio API URL
{t('api.lmStudioApiUrl')}
</p>
<Input
type="text"
placeholder="LM Studio API URL"
placeholder={t('api.lmStudioApiUrl')}
value={config.lmStudioApiUrl}
isSaving={savingStates['lmStudioApiUrl']}
onChange={(e) => {

View file

@ -7,6 +7,7 @@ import Chat from './Chat';
import EmptyChat from './EmptyChat';
import crypto from 'crypto';
import { toast } from 'sonner';
import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
import { getSuggestions } from '@/lib/actions';
import { Settings } from 'lucide-react';
@ -44,6 +45,7 @@ const checkConfig = async (
setEmbeddingModelProvider: (provider: EmbeddingModelProvider) => void,
setIsConfigReady: (ready: boolean) => void,
setHasError: (hasError: boolean) => void,
t: (key: string) => string,
) => {
try {
let chatModel = localStorage.getItem('chatModel');
@ -85,7 +87,7 @@ const checkConfig = async (
const chatModelProvidersKeys = Object.keys(chatModelProviders);
if (!chatModelProviders || chatModelProvidersKeys.length === 0) {
return toast.error('No chat models available');
return toast.error(t('common.errors.noChatModelsAvailable'));
} else {
chatModelProvider =
chatModelProvidersKeys.find(
@ -98,9 +100,7 @@ const checkConfig = async (
chatModelProvider === 'custom_openai' &&
Object.keys(chatModelProviders[chatModelProvider]).length === 0
) {
toast.error(
"Looks like you haven't configured any chat model providers. Please configure them from the settings page or the config file.",
);
toast.error(t('common.errors.chatProviderNotConfigured'));
return setHasError(true);
}
@ -114,7 +114,7 @@ const checkConfig = async (
!embeddingModelProviders ||
Object.keys(embeddingModelProviders).length === 0
)
return toast.error('No embedding models available');
return toast.error(t('common.errors.noEmbeddingModelsAvailable'));
embeddingModelProvider = Object.keys(embeddingModelProviders)[0];
embeddingModel = Object.keys(
@ -152,9 +152,7 @@ const checkConfig = async (
chatModelProvider === 'custom_openai' &&
Object.keys(chatModelProviders[chatModelProvider]).length === 0
) {
toast.error(
"Looks like you haven't configured any chat model providers. Please configure them from the settings page or the config file.",
);
toast.error(t('common.errors.chatProviderNotConfigured'));
return setHasError(true);
}
@ -265,6 +263,7 @@ const loadMessages = async (
};
const ChatWindow = ({ id }: { id?: string }) => {
const t = useTranslations();
const searchParams = useSearchParams();
const initialMessage = searchParams.get('q');
@ -294,6 +293,7 @@ const ChatWindow = ({ id }: { id?: string }) => {
setEmbeddingModelProvider,
setIsConfigReady,
setHasError,
t,
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@ -361,7 +361,7 @@ const ChatWindow = ({ id }: { id?: string }) => {
) => {
if (loading) return;
if (!isConfigReady) {
toast.error('Cannot send message before the configuration is ready');
toast.error(t('common.errors.cannotSendBeforeConfigReady'));
return;
}

View file

@ -11,6 +11,7 @@ import {
import { Fragment, useState } from 'react';
import { toast } from 'sonner';
import { Chat } from '@/app/library/page';
import { useTranslations } from 'next-intl';
const DeleteChat = ({
chatId,
@ -25,6 +26,7 @@ const DeleteChat = ({
}) => {
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const [loading, setLoading] = useState(false);
const t = useTranslations();
const handleDelete = async () => {
setLoading(true);
@ -37,7 +39,7 @@ const DeleteChat = ({
});
if (res.status != 200) {
throw new Error('Failed to delete chat');
throw new Error(t('common.errors.failedToDeleteChat'));
}
const newChats = chats.filter((chat) => chat.id !== chatId);
@ -48,7 +50,7 @@ const DeleteChat = ({
window.location.href = '/';
}
} catch (err: any) {
toast.error(err.message);
toast.error(err.message || t('common.errors.failedToDeleteChat'));
} finally {
setConfirmationDialogOpen(false);
setLoading(false);
@ -89,10 +91,10 @@ const DeleteChat = ({
>
<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
{t('components.deleteChat.title')}
</DialogTitle>
<Description className="text-sm dark:text-white/70 text-black/70">
Are you sure you want to delete this chat?
{t('components.deleteChat.description')}
</Description>
<div className="flex flex-row items-end justify-end space-x-4 mt-6">
<button
@ -103,13 +105,13 @@ const DeleteChat = ({
}}
className="text-black/50 dark:text-white/50 text-sm hover:text-black/70 hover:dark:text-white/70 transition duration-200"
>
Cancel
{t('components.deleteChat.cancel')}
</button>
<button
onClick={handleDelete}
className="text-red-400 text-sm hover:text-red-500 transition duration200"
>
Delete
{t('components.deleteChat.delete')}
</button>
</div>
</DialogPanel>

View file

@ -4,6 +4,7 @@ import { File } from './ChatWindow';
import Link from 'next/link';
import WeatherWidget from './WeatherWidget';
import NewsArticleWidget from './NewsArticleWidget';
import { useTranslations } from 'next-intl';
const EmptyChat = ({
sendMessage,
@ -26,6 +27,7 @@ const EmptyChat = ({
files: File[];
setFiles: (files: File[]) => void;
}) => {
const t = useTranslations('components');
return (
<div className="relative">
<div className="absolute w-full flex flex-row items-center justify-end mr-5 mt-5">
@ -36,7 +38,7 @@ const EmptyChat = ({
<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-8">
<h2 className="text-black/70 dark:text-white/70 text-3xl font-medium -mt-8">
Research begins here.
{t('emptyChat.title')}
</h2>
<EmptyChatMessageInput
sendMessage={sendMessage}

View file

@ -6,6 +6,7 @@ import Focus from './MessageInputActions/Focus';
import Optimization from './MessageInputActions/Optimization';
import Attach from './MessageInputActions/Attach';
import { File } from './ChatWindow';
import { useTranslations } from 'next-intl';
const EmptyChatMessageInput = ({
sendMessage,
@ -30,6 +31,7 @@ const EmptyChatMessageInput = ({
}) => {
const [copilotEnabled, setCopilotEnabled] = useState(false);
const [message, setMessage] = useState('');
const t = useTranslations('components');
const inputRef = useRef<HTMLTextAreaElement | null>(null);
@ -80,7 +82,7 @@ const EmptyChatMessageInput = ({
onChange={(e) => setMessage(e.target.value)}
minRows={2}
className="bg-transparent 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('emptyChatMessageInput.placeholder')}
/>
<div className="flex flex-row items-center justify-between mt-4">
<div className="flex flex-row items-center space-x-2 lg:space-x-4">

View file

@ -0,0 +1,26 @@
'use client';
import { useEffect } from 'react';
import { LOCALES, DEFAULT_LOCALE, type AppLocale } from '@/i18n/locales';
export default function LocaleBootstrap({
initialLocale,
}: {
initialLocale: AppLocale;
}) {
useEffect(() => {
// 若已有 cookie跳過
const hasCookie = /(?:^|; )locale=/.test(document.cookie);
if (hasCookie) return;
// 僅接受支援清單內的語系
const supported = new Set<string>(LOCALES as readonly string[]);
const loc = (initialLocale || DEFAULT_LOCALE) as string;
const chosen = Array.from(supported).find(
(s) => s.toLowerCase() === loc.toLowerCase(),
) as AppLocale | undefined;
const finalLocale: AppLocale = chosen || DEFAULT_LOCALE;
document.cookie = `locale=${finalLocale}; Path=/; Max-Age=31536000; SameSite=Lax`;
}, [initialLocale]);
return null;
}

View file

@ -0,0 +1,61 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useLocale } from 'next-intl';
import { useRouter } from 'next/navigation';
import {
LOCALES,
DEFAULT_LOCALE,
type AppLocale,
LOCALE_LABELS,
} from '@/i18n/locales';
function setLocaleCookie(value: AppLocale) {
const oneYear = 60 * 60 * 24 * 365;
const isSecure =
typeof window !== 'undefined' && window.location.protocol === 'https:';
document.cookie = `locale=${value}; path=/; max-age=${oneYear}; samesite=lax${isSecure ? '; secure' : ''}`;
}
export default function LocaleSwitcher({
onChange,
}: {
onChange?: (next: AppLocale) => void;
}) {
const router = useRouter();
const current = useLocale();
const currentLocale: AppLocale = useMemo(() => {
return (LOCALES as readonly string[]).includes(current)
? (current as AppLocale)
: DEFAULT_LOCALE;
}, [current]);
const [value, setValue] = useState<AppLocale>(currentLocale);
useEffect(() => {
setValue(currentLocale);
}, [currentLocale]);
return (
<select
value={value}
onChange={(e) => {
const next = e.target.value as AppLocale;
setValue(next);
setLocaleCookie(next);
onChange?.(next);
router.refresh();
}}
className={
'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 text-sm'
}
aria-label="Language"
>
{LOCALES.map((loc) => (
<option key={loc} value={loc}>
{LOCALE_LABELS[loc]}
</option>
))}
</select>
);
}

View file

@ -1,6 +1,7 @@
import { Check, ClipboardList } from 'lucide-react';
import { Message } from '../ChatWindow';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
const Copy = ({
message,
@ -10,11 +11,12 @@ const Copy = ({
initialMessage: string;
}) => {
const [copied, setCopied] = useState(false);
const t = useTranslations();
return (
<button
onClick={() => {
const contentToCopy = `${initialMessage}${message.sources && message.sources.length > 0 && `\n\nCitations:\n${message.sources?.map((source: any, i: any) => `[${i + 1}] ${source.metadata.url}`).join(`\n`)}`}`;
const contentToCopy = `${initialMessage}${message.sources && message.sources.length > 0 && `\n\n${t('common.citations')}\n${message.sources?.map((source: any, i: any) => `[${i + 1}] ${source.metadata.url}`).join(`\n`)}`}`;
navigator.clipboard.writeText(contentToCopy);
setCopied(true);
setTimeout(() => setCopied(false), 1000);

View file

@ -1,4 +1,5 @@
import { ArrowLeftRight } from 'lucide-react';
import { useTranslations } from 'next-intl';
const Rewrite = ({
rewrite,
@ -7,13 +8,14 @@ const Rewrite = ({
rewrite: (messageId: string) => void;
messageId: string;
}) => {
const t = useTranslations('components');
return (
<button
onClick={() => rewrite(messageId)}
className="py-2 px-3 text-black/70 dark:text-white/70 rounded-xl 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"
>
<ArrowLeftRight size={18} />
<p className="text-xs font-medium">Rewrite</p>
<p className="text-xs font-medium">{t('messageActions.rewrite')}</p>
</button>
);
};

View file

@ -20,6 +20,7 @@ import SearchImages from './SearchImages';
import SearchVideos from './SearchVideos';
import { useSpeech } from 'react-text-to-speech';
import ThinkBox from './ThinkBox';
import { useTranslations } from 'next-intl';
const ThinkTagProcessor = ({
children,
@ -52,6 +53,7 @@ const MessageBox = ({
rewrite: (messageId: string) => void;
sendMessage: (message: string) => void;
}) => {
const t = useTranslations('components');
const [parsedMessage, setParsedMessage] = useState(message.content);
const [speechMessage, setSpeechMessage] = useState(message.content);
const [thinkingEnded, setThinkingEnded] = useState(false);
@ -166,7 +168,7 @@ const MessageBox = ({
<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">
Sources
{t('messageBox.sources')}
</h3>
</div>
<MessageSources sources={message.sources} />
@ -182,7 +184,7 @@ const MessageBox = ({
size={20}
/>
<h3 className="text-black dark:text-white font-medium text-xl">
Answer
{t('messageBox.answer')}
</h3>
</div>
@ -216,9 +218,9 @@ const MessageBox = ({
className="p-2 text-black/70 dark:text-white/70 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white"
>
{speechStatus === 'started' ? (
<StopCircle size={18} />
<StopCircle size={18} aria-label="Stop TTS" />
) : (
<Volume2 size={18} />
<Volume2 size={18} aria-label="Start TTS" />
)}
</button>
</div>
@ -234,7 +236,9 @@ const MessageBox = ({
<div className="flex flex-col space-y-3 text-black dark:text-white">
<div className="flex flex-row items-center space-x-2 mt-4">
<Layers3 />
<h3 className="text-xl font-medium">Related</h3>
<h3 className="text-xl font-medium">
{t('messageBox.related')}
</h3>
</div>
<div className="flex flex-col space-y-3">
{message.suggestions.map((suggestion, i) => (

View file

@ -6,6 +6,7 @@ import Attach from './MessageInputActions/Attach';
import CopilotToggle from './MessageInputActions/Copilot';
import { File } from './ChatWindow';
import AttachSmall from './MessageInputActions/AttachSmall';
import { useTranslations } from 'next-intl';
const MessageInput = ({
sendMessage,
@ -22,6 +23,7 @@ const MessageInput = ({
files: File[];
setFiles: (files: File[]) => void;
}) => {
const t = useTranslations('components.messageInput');
const [copilotEnabled, setCopilotEnabled] = useState(false);
const [message, setMessage] = useState('');
const [textareaRows, setTextareaRows] = useState(1);
@ -95,7 +97,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('placeholder')}
/>
{mode === 'single' && (
<div className="flex flex-row items-center space-x-4">

View file

@ -8,6 +8,7 @@ import {
import { CopyPlus, File, LoaderCircle, Plus, Trash } from 'lucide-react';
import { Fragment, useRef, useState } from 'react';
import { File as FileType } from '../ChatWindow';
import { useTranslations } from 'next-intl';
const Attach = ({
fileIds,
@ -22,6 +23,7 @@ const Attach = ({
files: FileType[];
setFiles: (files: FileType[]) => void;
}) => {
const t = useTranslations('components.attach');
const [loading, setLoading] = useState(false);
const fileInputRef = useRef<any>();
@ -57,7 +59,7 @@ const Attach = ({
<div className="flex flex-row items-center justify-between space-x-1">
<LoaderCircle size={18} className="text-sky-400 animate-spin" />
<p className="text-sky-400 inline whitespace-nowrap text-xs font-medium">
Uploading..
{t('uploading')}
</p>
</div>
) : files.length > 0 ? (
@ -104,7 +106,7 @@ const Attach = ({
<div className="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 dark:text-white font-medium text-sm">
Attached files
{t('attachedFiles')}
</h4>
<div className="flex flex-row items-center space-x-4">
<button
@ -121,7 +123,7 @@ const Attach = ({
hidden
/>
<Plus size={18} />
<p className="text-xs">Add</p>
<p className="text-xs">{t('add')}</p>
</button>
<button
onClick={() => {
@ -131,7 +133,7 @@ const Attach = ({
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={14} />
<p className="text-xs">Clear</p>
<p className="text-xs">{t('clear')}</p>
</button>
</div>
</div>
@ -177,7 +179,9 @@ const Attach = ({
hidden
/>
<CopyPlus size={showText ? 18 : undefined} />
{showText && <p className="text-xs font-medium pl-[1px]">Attach</p>}
{showText && (
<p className="text-xs font-medium pl-[1px] w-max">{t('attach')}</p>
)}
</button>
);
};

View file

@ -8,6 +8,7 @@ import {
import { CopyPlus, File, LoaderCircle, Plus, Trash } from 'lucide-react';
import { Fragment, useRef, useState } from 'react';
import { File as FileType } from '../ChatWindow';
import { useTranslations } from 'next-intl';
const AttachSmall = ({
fileIds,
@ -20,6 +21,7 @@ const AttachSmall = ({
files: FileType[];
setFiles: (files: FileType[]) => void;
}) => {
const t = useTranslations('components.attach');
const [loading, setLoading] = useState(false);
const fileInputRef = useRef<any>();
@ -76,7 +78,7 @@ const AttachSmall = ({
<div className="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 dark:text-white font-medium text-sm">
Attached files
{t('attachedFiles')}
</h4>
<div className="flex flex-row items-center space-x-4">
<button
@ -93,7 +95,7 @@ const AttachSmall = ({
hidden
/>
<Plus size={18} />
<p className="text-xs">Add</p>
<p className="text-xs">{t('add')}</p>
</button>
<button
onClick={() => {
@ -103,7 +105,7 @@ const AttachSmall = ({
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={14} />
<p className="text-xs">Clear</p>
<p className="text-xs">{t('clear')}</p>
</button>
</div>
</div>

View file

@ -1,5 +1,6 @@
import { cn } from '@/lib/utils';
import { Switch } from '@headlessui/react';
import { useTranslations } from 'next-intl';
const CopilotToggle = ({
copilotEnabled,
@ -8,6 +9,7 @@ const CopilotToggle = ({
copilotEnabled: boolean;
setCopilotEnabled: (enabled: boolean) => void;
}) => {
const t = useTranslations('components.copilot');
return (
<div className="group flex flex-row items-center space-x-1 active:scale-95 duration-200 transition cursor-pointer">
<Switch
@ -15,7 +17,7 @@ const CopilotToggle = ({
onChange={setCopilotEnabled}
className="bg-light-secondary dark:bg-dark-secondary border border-light-200/70 dark:border-dark-200 relative inline-flex h-5 w-10 sm:h-6 sm:w-11 items-center rounded-full"
>
<span className="sr-only">Copilot</span>
<span className="sr-only">{t('label')}</span>
<span
className={cn(
copilotEnabled
@ -34,7 +36,7 @@ const CopilotToggle = ({
: 'text-black/50 dark:text-white/50 group-hover:text-black dark:group-hover:text-white',
)}
>
Copilot
{t('label')}
</p>
</div>
);

View file

@ -15,45 +15,16 @@ import {
} from '@headlessui/react';
import { SiReddit, SiYoutube } from '@icons-pack/react-simple-icons';
import { Fragment } from 'react';
import { useTranslations } from 'next-intl';
const focusModes = [
{
key: 'webSearch',
title: 'All',
description: 'Searches across all of the internet',
icon: <Globe size={20} />,
},
{
key: 'academicSearch',
title: 'Academic',
description: 'Search in published academic papers',
icon: <SwatchBook size={20} />,
},
{
key: 'writingAssistant',
title: 'Writing',
description: 'Chat without searching the web',
icon: <Pencil size={16} />,
},
{
key: 'wolframAlphaSearch',
title: 'Wolfram Alpha',
description: 'Computational knowledge engine',
icon: <BadgePercent size={20} />,
},
{
key: 'youtubeSearch',
title: 'Youtube',
description: 'Search and watch videos',
icon: <SiYoutube className="h-5 w-auto mr-0.5" />,
},
{
key: 'redditSearch',
title: 'Reddit',
description: 'Search for discussions and opinions',
icon: <SiReddit className="h-5 w-auto mr-0.5" />,
},
];
const focusModeIcons: Record<string, JSX.Element> = {
webSearch: <Globe size={20} />,
academicSearch: <SwatchBook size={20} />,
writingAssistant: <Pencil size={16} />,
wolframAlphaSearch: <BadgePercent size={20} />,
youtubeSearch: <SiYoutube className="h-5 w-auto mr-0.5" />,
redditSearch: <SiReddit className="h-5 w-auto mr-0.5" />,
};
const Focus = ({
focusMode,
@ -62,6 +33,15 @@ const Focus = ({
focusMode: string;
setFocusMode: (mode: string) => void;
}) => {
const t = useTranslations('components.focus');
const modes = [
'webSearch',
'academicSearch',
'writingAssistant',
'wolframAlphaSearch',
'youtubeSearch',
'redditSearch',
];
return (
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg mt-[6.5px]">
<PopoverButton
@ -70,16 +50,16 @@ const Focus = ({
>
{focusMode !== 'webSearch' ? (
<div className="flex flex-row items-center space-x-1">
{focusModes.find((mode) => mode.key === focusMode)?.icon}
{focusModeIcons[focusMode]}
<p className="text-xs font-medium hidden lg:block">
{focusModes.find((mode) => mode.key === focusMode)?.title}
{t(`modes.${focusMode}.title`)}
</p>
<ChevronDown size={20} className="-translate-x-1" />
</div>
) : (
<div className="flex flex-row items-center space-x-1">
<ScanEye size={20} />
<p className="text-xs font-medium hidden lg:block">Focus</p>
<p className="text-xs font-medium hidden lg:block">{t('button')}</p>
</div>
)}
</PopoverButton>
@ -94,13 +74,13 @@ const Focus = ({
>
<PopoverPanel className="absolute z-10 w-64 md:w-[500px] left-0">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 w-full p-4 max-h-[200px] md:max-h-none overflow-y-auto">
{focusModes.map((mode, i) => (
{modes.map((key, i) => (
<PopoverButton
onClick={() => setFocusMode(mode.key)}
onClick={() => setFocusMode(key)}
key={i}
className={cn(
'p-2 rounded-lg flex flex-col items-start justify-start text-start space-y-2 duration-200 cursor-pointer transition',
focusMode === mode.key
focusMode === key
? 'bg-light-secondary dark:bg-dark-secondary'
: 'hover:bg-light-secondary dark:hover:bg-dark-secondary',
)}
@ -108,16 +88,18 @@ const Focus = ({
<div
className={cn(
'flex flex-row items-center space-x-1',
focusMode === mode.key
focusMode === key
? 'text-[#24A0ED]'
: 'text-black dark:text-white',
)}
>
{mode.icon}
<p className="text-sm font-medium">{mode.title}</p>
{focusModeIcons[key]}
<p className="text-sm font-medium">
{t(`modes.${key}.title`)}
</p>
</div>
<p className="text-black/70 dark:text-white/70 text-xs">
{mode.description}
{t(`modes.${key}.description`)}
</p>
</PopoverButton>
))}

View file

@ -7,24 +7,19 @@ import {
Transition,
} from '@headlessui/react';
import { Fragment } from 'react';
import { useTranslations } from 'next-intl';
const OptimizationModes = [
{
key: 'speed',
title: 'Speed',
description: 'Prioritize speed and get the quickest possible answer.',
icon: <Zap size={20} className="text-[#FF9800]" />,
},
{
key: 'balanced',
title: 'Balanced',
description: 'Find the right balance between speed and accuracy',
icon: <Sliders size={20} className="text-[#4CAF50]" />,
},
{
key: 'quality',
title: 'Quality (Soon)',
description: 'Get the most thorough and accurate answer',
icon: (
<Star
size={16}
@ -41,6 +36,7 @@ const Optimization = ({
optimizationMode: string;
setOptimizationMode: (mode: string) => void;
}) => {
const t = useTranslations('components.optimization');
return (
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
<PopoverButton
@ -53,10 +49,7 @@ const Optimization = ({
?.icon
}
<p className="text-xs font-medium">
{
OptimizationModes.find((mode) => mode.key === optimizationMode)
?.title
}
{t(`modes.${optimizationMode}.title`)}
</p>
<ChevronDown size={20} />
</div>
@ -87,10 +80,12 @@ const Optimization = ({
>
<div className="flex flex-row items-center space-x-1 text-black dark:text-white">
{mode.icon}
<p className="text-sm font-medium">{mode.title}</p>
<p className="text-sm font-medium">
{t(`modes.${mode.key}.title`)}
</p>
</div>
<p className="text-black/70 dark:text-white/70 text-xs">
{mode.description}
{t(`modes.${mode.key}.description`)}
</p>
</PopoverButton>
))}

View file

@ -9,9 +9,11 @@ import {
import { Document } from '@langchain/core/documents';
import { File } from 'lucide-react';
import { Fragment, useState } from 'react';
import { useTranslations } from 'next-intl';
const MessageSources = ({ sources }: { sources: Document[] }) => {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const t = useTranslations('components');
const closeModal = () => {
setIsDialogOpen(false);
@ -88,7 +90,7 @@ const MessageSources = ({ sources }: { sources: Document[] }) => {
})}
</div>
<p className="text-xs text-black/50 dark:text-white/50">
View {sources.length - 3} more
{t('common.viewMore', { count: sources.length - 3 })}
</p>
</button>
)}
@ -107,7 +109,7 @@ const MessageSources = ({ sources }: { sources: Document[] }) => {
>
<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">
Sources
{t('messageBox.sources')}
</DialogTitle>
<div className="grid grid-cols-2 gap-2 overflow-auto max-h-[300px] mt-2 pr-2">
{sources.map((source, i) => (

View file

@ -1,7 +1,12 @@
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 {
formatTimeDifference,
formatRelativeTime,
formatDate,
} from '@/lib/utils';
import { useLocale, useTranslations } from 'next-intl';
import DeleteChat from './DeleteChat';
import {
Popover,
@ -10,6 +15,15 @@ import {
Transition,
} from '@headlessui/react';
import jsPDF from 'jspdf';
import { ensureNotoSansTC } from '@/lib/pdfFont';
type ExportLabels = {
chatExportTitle: (p: { title: string }) => string;
exportedOn: string;
user: string;
assistant: string;
citations: string;
};
const downloadFile = (filename: string, content: string, type: string) => {
const blob = new Blob([content], { type });
@ -25,18 +39,22 @@ const downloadFile = (filename: string, content: string, type: string) => {
}, 0);
};
const exportAsMarkdown = (messages: Message[], title: string) => {
const date = new Date(messages[0]?.createdAt || Date.now()).toLocaleString();
let md = `# 💬 Chat Export: ${title}\n\n`;
md += `*Exported on: ${date}*\n\n---\n`;
messages.forEach((msg, idx) => {
const exportAsMarkdown = (
messages: Message[],
title: string,
labels: ExportLabels,
locale: string,
) => {
const date = formatDate(messages[0]?.createdAt || Date.now(), locale);
let md = `# 💬 ${labels.chatExportTitle({ title })}\n\n`;
md += `*${labels.exportedOn} ${date}*\n\n---\n`;
messages.forEach((msg) => {
md += `\n---\n`;
md += `**${msg.role === 'user' ? '🧑 User' : '🤖 Assistant'}**
`;
md += `*${new Date(msg.createdAt).toLocaleString()}*\n\n`;
md += `**${msg.role === 'user' ? `🧑 ${labels.user}` : `🤖 ${labels.assistant}`}** \n`;
md += `*${formatDate(msg.createdAt, locale)}*\n\n`;
md += `> ${msg.content.replace(/\n/g, '\n> ')}\n`;
if (msg.sources && msg.sources.length > 0) {
md += `\n**Citations:**\n`;
md += `\n**${labels.citations}**\n`;
msg.sources.forEach((src: any, i: number) => {
const url = src.metadata?.url || '';
md += `- [${i + 1}] [${url}](${url})\n`;
@ -47,33 +65,45 @@ const exportAsMarkdown = (messages: Message[], title: string) => {
downloadFile(`${title || 'chat'}.md`, md, 'text/markdown');
};
const exportAsPDF = (messages: Message[], title: string) => {
const exportAsPDF = async (
messages: Message[],
title: string,
labels: ExportLabels,
locale: string,
) => {
const doc = new jsPDF();
const date = new Date(messages[0]?.createdAt || Date.now()).toLocaleString();
// Ensure CJK-capable font is available, then set fonts
try {
await ensureNotoSansTC(doc);
doc.setFont('NotoSansTC', 'normal');
} catch (e) {
// If network fails, fallback to default font (may garble CJK)
}
const date = formatDate(messages[0]?.createdAt || Date.now(), locale);
let y = 15;
const pageHeight = doc.internal.pageSize.height;
doc.setFontSize(18);
doc.text(`Chat Export: ${title}`, 10, y);
doc.text(labels.chatExportTitle({ title }), 10, y);
y += 8;
doc.setFontSize(11);
doc.setTextColor(100);
doc.text(`Exported on: ${date}`, 10, y);
doc.text(`${labels.exportedOn} ${date}`, 10, y);
y += 8;
doc.setDrawColor(200);
doc.line(10, y, 200, y);
y += 6;
doc.setTextColor(30);
messages.forEach((msg, idx) => {
messages.forEach((msg) => {
if (y > pageHeight - 30) {
doc.addPage();
y = 15;
}
doc.setFont('helvetica', 'bold');
doc.text(`${msg.role === 'user' ? 'User' : 'Assistant'}`, 10, y);
doc.setFont('helvetica', 'normal');
doc.setFont('NotoSansTC', 'bold');
doc.text(`${msg.role === 'user' ? labels.user : labels.assistant}`, 10, y);
doc.setFont('NotoSansTC', 'normal');
doc.setFontSize(10);
doc.setTextColor(120);
doc.text(`${new Date(msg.createdAt).toLocaleString()}`, 40, y);
doc.text(`${formatDate(msg.createdAt, locale)}`, 40, y);
y += 6;
doc.setTextColor(30);
doc.setFontSize(12);
@ -93,7 +123,7 @@ const exportAsPDF = (messages: Message[], title: string) => {
doc.addPage();
y = 15;
}
doc.text('Citations:', 12, y);
doc.text(labels.citations, 12, y);
y += 5;
msg.sources.forEach((src: any, i: number) => {
const url = src.metadata?.url || '';
@ -126,7 +156,10 @@ const Navbar = ({
chatId: string;
}) => {
const [title, setTitle] = useState<string>('');
const [timeAgo, setTimeAgo] = useState<string>('');
const tCommon = useTranslations('common');
const tNavbar = useTranslations('navbar');
const tExport = useTranslations('export');
const locale = useLocale();
useEffect(() => {
if (messages.length > 0) {
@ -135,28 +168,11 @@ const Navbar = ({
? `${messages[0].content.substring(0, 20).trim()}...`
: messages[0].content;
setTitle(newTitle);
const newTimeAgo = formatTimeDifference(
new Date(),
messages[0].createdAt,
);
setTimeAgo(newTimeAgo);
// title already set above
}
}, [messages]);
useEffect(() => {
const intervalId = setInterval(() => {
if (messages.length > 0) {
const newTimeAgo = formatTimeDifference(
new Date(),
messages[0].createdAt,
);
setTimeAgo(newTimeAgo);
}
}, 1000);
return () => clearInterval(intervalId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Removed per-locale relative time uses render-time computation
return (
<div className="fixed z-40 top-0 left-0 right-0 px-4 lg:pl-[104px] lg:pr-6 lg:px-8 flex flex-row items-center justify-between w-full py-4 text-sm text-black dark:text-white/70 border-b bg-light-primary dark:bg-dark-primary border-light-100 dark:border-dark-200">
@ -168,7 +184,13 @@ const Navbar = ({
</a>
<div className="hidden lg:flex flex-row items-center justify-center space-x-2">
<Clock size={17} />
<p className="text-xs">{timeAgo} ago</p>
<p className="text-xs">
{formatRelativeTime(
new Date(),
messages[0]?.createdAt || new Date(),
locale,
)}
</p>
</div>
<p className="hidden lg:flex">{title}</p>
@ -190,17 +212,43 @@ const Navbar = ({
<div className="flex flex-col py-3 px-3 gap-2">
<button
className="flex items-center gap-2 px-4 py-2 text-left hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors text-black dark:text-white rounded-lg font-medium"
onClick={() => exportAsMarkdown(messages, title || '')}
onClick={async () => {
exportAsMarkdown(
messages,
title || '',
{
chatExportTitle: (p) => tExport('chatExportTitle', p),
exportedOn: tCommon('exportedOn'),
user: tCommon('user'),
assistant: tCommon('assistant'),
citations: tCommon('citations'),
},
locale,
);
}}
>
<FileText size={17} className="text-[#24A0ED]" />
Export as Markdown
{tNavbar('exportAsMarkdown')}
</button>
<button
className="flex items-center gap-2 px-4 py-2 text-left hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors text-black dark:text-white rounded-lg font-medium"
onClick={() => exportAsPDF(messages, title || '')}
onClick={async () => {
await exportAsPDF(
messages,
title || '',
{
chatExportTitle: (p) => tExport('chatExportTitle', p),
exportedOn: tCommon('exportedOn'),
user: tCommon('user'),
assistant: tCommon('assistant'),
citations: tCommon('citations'),
},
locale,
);
}}
>
<FileDown size={17} className="text-[#24A0ED]" />
Export as PDF
{tNavbar('exportAsPDF')}
</button>
</div>
</PopoverPanel>

View file

@ -1,4 +1,6 @@
import { useEffect, useState } from 'react';
import Image from 'next/image';
import { useTranslations } from 'next-intl';
interface Article {
title: string;
@ -8,6 +10,7 @@ interface Article {
}
const NewsArticleWidget = () => {
const t = useTranslations('components');
const [article, setArticle] = useState<Article | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
@ -39,13 +42,15 @@ const NewsArticleWidget = () => {
</div>
</>
) : error ? (
<div className="w-full text-xs text-red-400">Could not load news.</div>
<div className="w-full text-xs text-red-400">
{t('newsArticleWidget.error')}
</div>
) : article ? (
<a
href={`/?q=Summary: ${article.url}`}
className="flex flex-row items-center w-full h-full group"
>
<img
<Image
className="object-cover rounded-lg w-16 min-w-16 max-w-16 h-16 min-h-16 max-h-16 border border-light-200 dark:border-dark-200 bg-light-200 dark:bg-dark-200 group-hover:opacity-90 transition"
src={
new URL(article.thumbnail).origin +
@ -53,6 +58,9 @@ const NewsArticleWidget = () => {
`?id=${new URL(article.thumbnail).searchParams.get('id')}`
}
alt={article.title}
width={64}
height={64}
unoptimized
/>
<div className="flex flex-col justify-center flex-1 h-full pl-3 w-0">
<div className="font-bold text-xs text-black dark:text-white leading-tight truncate overflow-hidden whitespace-nowrap">

View file

@ -1,6 +1,7 @@
/* eslint-disable @next/next/no-img-element */
import { ImagesIcon, PlusIcon } from 'lucide-react';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import Lightbox from 'yet-another-react-lightbox';
import 'yet-another-react-lightbox/styles.css';
import { Message } from './ChatWindow';
@ -20,6 +21,7 @@ const SearchImages = ({
chatHistory: Message[];
messageId: string;
}) => {
const t = useTranslations('components');
const [images, setImages] = useState<Image[] | null>(null);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
@ -75,7 +77,7 @@ const SearchImages = ({
>
<div className="flex flex-row items-center space-x-2">
<ImagesIcon size={17} />
<p>Search images</p>
<p>{t('searchImages.searchButton')}</p>
</div>
<PlusIcon className="text-[#24A0ED]" size={17} />
</button>
@ -142,7 +144,7 @@ const SearchImages = ({
))}
</div>
<p className="text-black/70 dark:text-white/70 text-xs">
View {images.length - 3} more
{t('common.viewMore', { count: images.length - 3 })}
</p>
</button>
)}

View file

@ -4,6 +4,7 @@ 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 { useTranslations } from 'next-intl';
type Video = {
url: string;
@ -33,6 +34,7 @@ const Searchvideos = ({
chatHistory: Message[];
messageId: string;
}) => {
const t = useTranslations('components');
const [videos, setVideos] = useState<Video[] | null>(null);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
@ -92,7 +94,7 @@ const Searchvideos = ({
>
<div className="flex flex-row items-center space-x-2">
<VideoIcon size={17} />
<p>Search videos</p>
<p>{t('searchVideos.searchButton')}</p>
</div>
<PlusIcon className="text-[#24A0ED]" size={17} />
</button>
@ -131,7 +133,7 @@ const Searchvideos = ({
/>
<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">Video</p>
<p className="text-xs">{t('searchVideos.badge')}</p>
</div>
</div>
))
@ -155,7 +157,7 @@ const Searchvideos = ({
/>
<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">Video</p>
<p className="text-xs">{t('searchVideos.badge')}</p>
</div>
</div>
))}
@ -175,7 +177,7 @@ const Searchvideos = ({
))}
</div>
<p className="text-black/70 dark:text-white/70 text-xs">
View {videos.length - 3} more
{t('common.viewMore', { count: videos.length - 3 })}
</p>
</button>
)}

View file

@ -6,6 +6,7 @@ import Link from 'next/link';
import { useSelectedLayoutSegments } from 'next/navigation';
import React, { useState, type ReactNode } from 'react';
import Layout from './Layout';
import { useTranslations } from 'next-intl';
const VerticalIconContainer = ({ children }: { children: ReactNode }) => {
return (
@ -15,25 +16,26 @@ const VerticalIconContainer = ({ children }: { children: ReactNode }) => {
const Sidebar = ({ children }: { children: React.ReactNode }) => {
const segments = useSelectedLayoutSegments();
const t = useTranslations('navigation');
const navLinks = [
{
icon: Home,
href: '/',
active: segments.length === 0 || segments.includes('c'),
label: 'Home',
label: t('home'),
},
{
icon: Search,
href: '/discover',
active: segments.includes('discover'),
label: 'Discover',
label: t('discover'),
},
{
icon: BookOpenText,
href: '/library',
active: segments.includes('library'),
label: 'Library',
label: t('library'),
},
];

View file

@ -1,7 +1,11 @@
import { Cloud, Sun, CloudRain, CloudSnow, Wind } from 'lucide-react';
import { useEffect, useState } from 'react';
import Image from 'next/image';
import { useLocale, useTranslations } from 'next-intl';
const WeatherWidget = () => {
const t = useTranslations('components');
const locale = useLocale();
const [data, setData] = useState({
temperature: 0,
condition: '',
@ -42,7 +46,7 @@ const WeatherWidget = () => {
if (result.state === 'granted') {
navigator.geolocation.getCurrentPosition(async (position) => {
const res = await fetch(
`https://api-bdc.io/data/reverse-geocode-client?latitude=${position.coords.latitude}&longitude=${position.coords.longitude}&localityLanguage=en`,
`https://api-bdc.io/data/reverse-geocode-client?latitude=${position.coords.latitude}&longitude=${position.coords.longitude}&localityLanguage=${locale}`,
{
method: 'GET',
headers: {
@ -100,7 +104,7 @@ const WeatherWidget = () => {
});
setLoading(false);
});
}, []);
}, [locale]);
return (
<div className="bg-light-secondary dark:bg-dark-secondary rounded-xl border border-light-200 dark:border-dark-200 shadow-sm flex flex-row items-center w-full h-24 min-h-[96px] max-h-[96px] px-3 py-2 gap-3">
@ -125,9 +129,11 @@ const WeatherWidget = () => {
) : (
<>
<div className="flex flex-col items-center justify-center w-16 min-w-16 max-w-16 h-full">
<img
<Image
src={`/weather-ico/${data.icon}.svg`}
alt={data.condition}
width={40}
height={40}
className="h-10 w-auto"
/>
<span className="text-base font-semibold text-black dark:text-white">
@ -148,8 +154,10 @@ const WeatherWidget = () => {
{data.condition}
</span>
<div className="flex flex-row justify-between w-full mt-auto pt-1 border-t border-light-200 dark:border-dark-200 text-xs text-black/60 dark:text-white/60">
<span>Humidity: {data.humidity}%</span>
<span>Now</span>
<span>
{t('weather.humidity')}: {data.humidity}%
</span>
<span>{t('weather.now')}</span>
</div>
</div>
</>

View file

@ -2,11 +2,13 @@
import { useTheme } from 'next-themes';
import { useCallback, useEffect, useState } from 'react';
import Select from '../ui/Select';
import { useTranslations } from 'next-intl';
type Theme = 'dark' | 'light' | 'system';
const ThemeSwitcher = ({ className }: { className?: string }) => {
const [mounted, setMounted] = useState(false);
const t = useTranslations('components');
const { theme, setTheme } = useTheme();
@ -50,8 +52,8 @@ const ThemeSwitcher = ({ className }: { className?: string }) => {
value={theme}
onChange={(e) => handleThemeSwitch(e.target.value as Theme)}
options={[
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'light', label: t('themeSwitcher.options.light') },
{ value: 'dark', label: t('themeSwitcher.options.dark') },
]}
/>
);

40
src/i18n/locales.ts Normal file
View file

@ -0,0 +1,40 @@
export const LOCALES = [
'en-US',
'en-GB',
'zh-TW',
'zh-HK',
'zh-CN',
'ja',
'ko',
'fr-FR',
'fr-CA',
'de',
] as const;
export type AppLocale = (typeof LOCALES)[number];
// Default locale for fallbacks
export const DEFAULT_LOCALE: AppLocale = 'en-US';
// UI labels for language options
export const LOCALE_LABELS: Record<AppLocale, string> = {
'en-US': 'English (US)',
'en-GB': 'English (UK)',
'zh-TW': '繁體中文',
'zh-HK': '繁體中文(香港)',
'zh-CN': '简体中文',
ja: '日本語',
ko: '한국어',
'fr-FR': 'Français (France)',
'fr-CA': 'Français (Canada)',
de: 'Deutsch',
};
// Human-readable language name for prompt prefix
export function getPromptLanguageName(loc: string): string {
const l = (loc || '').toLowerCase();
const match = (
Object.keys(LOCALE_LABELS) as Array<keyof typeof LOCALE_LABELS>
).find((k) => k.toLowerCase() === l);
if (match) return LOCALE_LABELS[match];
return LOCALE_LABELS[DEFAULT_LOCALE];
}

115
src/i18n/request.ts Normal file
View file

@ -0,0 +1,115 @@
import { cookies, headers } from 'next/headers';
import { getRequestConfig } from 'next-intl/server';
import { LOCALES, DEFAULT_LOCALE, type AppLocale } from './locales';
export default getRequestConfig(async () => {
const cookieStore = await cookies();
const rawCookieLocale = cookieStore.get('locale')?.value;
// Helper: parse Accept-Language and pick best supported locale
function resolveFromAcceptLanguage(al: string | null | undefined): AppLocale {
const supported = new Set<string>(LOCALES as readonly string[]);
const raw = (al || '').toLowerCase();
if (!raw) return DEFAULT_LOCALE;
type Candidate = { tag: string; q: number };
const candidates: Candidate[] = raw
.split(',')
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const [tagPart, ...params] = part.split(';');
const tag = tagPart.trim();
let q = 1;
for (const p of params) {
const m = p.trim().match(/^q=([0-9.]+)$/);
if (m) {
const v = parseFloat(m[1]);
if (!Number.isNaN(v)) q = v;
}
}
return { tag, q } as Candidate;
})
.sort((a, b) => b.q - a.q);
// Try in order: exact match -> base language match -> custom mapping
for (const { tag } of candidates) {
// exact match against supported
const exact = Array.from(supported).find((s) => s.toLowerCase() === tag);
if (exact) return exact as AppLocale;
// base language match (e.g., en-US -> en-GB/en-US: prefer en-US if available)
const base = tag.split('-')[0];
const englishVariants = Array.from(supported).filter((s) =>
s.toLowerCase().startsWith('en-'),
) as AppLocale[];
if (base === 'en' && englishVariants.length > 0) {
// prefer en-US as default English
const enUS = englishVariants.find((e) => e.toLowerCase() === 'en-us');
return (enUS || englishVariants[0]) as AppLocale;
}
const baseMatch = Array.from(supported).find(
(s) => s.split('-')[0].toLowerCase() === base,
);
if (baseMatch) return baseMatch as AppLocale;
// custom mapping for Chinese:
// - zh-HK -> zh-HK
// - zh-TW -> zh-TW
// - zh-CN, zh-SG -> zh-CN
if (tag.startsWith('zh')) {
if (/^zh-(hk)/i.test(tag)) return 'zh-HK';
if (/^zh-(tw)/i.test(tag)) return 'zh-TW';
if (/^zh-(cn|sg)/i.test(tag)) return 'zh-CN';
// default Chinese fallback: zh-TW
return 'zh-TW';
}
}
return DEFAULT_LOCALE;
}
// Normalize any incoming locale (including legacy cookies like 'en' or 'fr')
function normalizeToSupported(loc?: string | null): AppLocale | null {
const val = (loc || '').trim();
if (!val) return null;
const lower = val.toLowerCase();
// exact case-insensitive match against supported
const exact = (LOCALES as readonly string[]).find(
(s) => s.toLowerCase() === lower,
);
if (exact) return exact as AppLocale;
// map base tags to preferred regional variants
const base = lower.split('-')[0];
if (base === 'en') return 'en-US';
if (base === 'fr') return 'fr-FR';
if (base === 'zh') {
if (/^zh-(hk)/i.test(lower)) return 'zh-HK';
if (/^zh-(tw)/i.test(lower)) return 'zh-TW';
if (/^zh-(cn|sg)/i.test(lower)) return 'zh-CN';
// default Chinese fallback
return 'zh-TW';
}
// try base language match generically
const baseMatch = (LOCALES as readonly string[]).find(
(s) => s.split('-')[0].toLowerCase() === base,
);
return (baseMatch as AppLocale) || null;
}
// Prefer normalized cookie if present and valid; otherwise use Accept-Language
let locale: AppLocale;
const normalizedCookie = normalizeToSupported(rawCookieLocale);
if (normalizedCookie) {
locale = normalizedCookie;
} else {
const hdrs = await headers();
const acceptLanguage = hdrs.get('accept-language');
locale = resolveFromAcceptLanguage(acceptLanguage);
}
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default,
};
});

45
src/lib/pdfFont.ts Normal file
View file

@ -0,0 +1,45 @@
// Runtime loader for a Unicode-capable font (Noto Sans TC) for jsPDF, to fix CJK garbled text
// Note: Load fonts from local /public/fonts to avoid any external CDN dependency.
// License: Noto fonts are under SIL Open Font License 1.1
import jsPDF from 'jspdf';
let fontsLoaded = false;
// Files should be placed at public/fonts (see public/fonts/README.md)
const NOTO_TC_REGULAR = '/fonts/NotoSansTC-Regular.ttf';
const NOTO_TC_BOLD = '/fonts/NotoSansTC-Bold.ttf';
function arrayBufferToBinaryString(buffer: ArrayBuffer): string {
let binary = '';
const bytes = new Uint8Array(buffer);
const chunkSize = 0x8000; // avoid call stack overflow
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode.apply(
null,
Array.from(chunk) as unknown as number[],
);
}
return binary;
}
export async function ensureNotoSansTC(doc: jsPDF): Promise<void> {
if (fontsLoaded) return;
const [regBuf, boldBuf] = await Promise.all([
fetch(NOTO_TC_REGULAR).then((r) => r.arrayBuffer()),
fetch(NOTO_TC_BOLD).then((r) => r.arrayBuffer()),
]);
const regBin = arrayBufferToBinaryString(regBuf);
const boldBin = arrayBufferToBinaryString(boldBuf);
// Register into VFS and add fonts
doc.addFileToVFS('NotoSansTC-Regular.ttf', regBin);
doc.addFont('NotoSansTC-Regular.ttf', 'NotoSansTC', 'normal');
doc.addFileToVFS('NotoSansTC-Bold.ttf', boldBin);
doc.addFont('NotoSansTC-Bold.ttf', 'NotoSansTC', 'bold');
fontsLoaded = true;
}

View file

@ -17,7 +17,7 @@ import {
youtubeSearchRetrieverPrompt,
} from './youtubeSearch';
export default {
const prompts = {
webSearchResponsePrompt,
webSearchRetrieverPrompt,
academicSearchResponsePrompt,
@ -30,3 +30,5 @@ export default {
youtubeSearchResponsePrompt,
youtubeSearchRetrieverPrompt,
};
export default prompts;

View file

@ -18,6 +18,7 @@ import LineOutputParser from '../outputParsers/lineOutputParser';
import { getDocumentsFromLinks } from '../utils/documents';
import { Document } from 'langchain/document';
import { searchSearxng } from '../searxng';
import { getLocale } from 'next-intl/server';
import path from 'node:path';
import fs from 'node:fs';
import computeSimilarity from '../utils/computeSimilarity';
@ -205,8 +206,10 @@ class MetaSearchAgent implements MetaSearchAgentType {
} else {
question = question.replace(/<think>.*?<\/think>/g, '');
const currentLocale = await getLocale();
const baseLang = (currentLocale?.split('-')[0] || 'en') as string;
const res = await searchSearxng(question, {
language: 'en',
language: baseLang,
engines: this.config.activeEngines,
});

View file

@ -3,6 +3,22 @@ import { twMerge } from 'tailwind-merge';
export const cn = (...classes: ClassValue[]) => twMerge(clsx(...classes));
// Locale-aware absolute date formatting
export const formatDate = (
date: Date | string,
locale: string,
options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
},
) => {
const d = new Date(date);
return new Intl.DateTimeFormat(locale || undefined, options).format(d);
};
export const formatTimeDifference = (
date1: Date | string,
date2: Date | string,
@ -25,3 +41,45 @@ export const formatTimeDifference = (
else
return `${Math.floor(diffInSeconds / 31536000)} year${Math.floor(diffInSeconds / 31536000) !== 1 ? 's' : ''}`;
};
// Locale-aware relative time using Intl.RelativeTimeFormat
export const formatRelativeTime = (
date1: Date | string,
date2: Date | string,
locale: string,
): string => {
const d1 = new Date(date1);
const d2 = new Date(date2);
const diffSeconds = Math.floor((d2.getTime() - d1.getTime()) / 1000); // positive if d2 > d1
const abs = Math.abs(diffSeconds);
let value: number;
let unit: Intl.RelativeTimeFormatUnit;
if (abs < 60) {
value = Math.round(diffSeconds);
unit = 'second';
} else if (abs < 3600) {
value = Math.round(diffSeconds / 60);
unit = 'minute';
} else if (abs < 86400) {
value = Math.round(diffSeconds / 3600);
unit = 'hour';
} else if (abs < 2629800) {
// ~1 month (30.4 days)
value = Math.round(diffSeconds / 86400);
unit = 'day';
} else if (abs < 31557600) {
// ~1 year
value = Math.round(diffSeconds / 2629800);
unit = 'month';
} else {
value = Math.round(diffSeconds / 31557600);
unit = 'year';
}
const rtf = new Intl.RelativeTimeFormat(locale || undefined, {
numeric: 'auto',
});
return rtf.format(value, unit);
};