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:
parent
0dc17286b9
commit
9a772d6abe
56 changed files with 3673 additions and 365 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}...
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
26
src/components/LocaleBootstrap.tsx
Normal file
26
src/components/LocaleBootstrap.tsx
Normal 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;
|
||||
}
|
||||
61
src/components/LocaleSwitcher.tsx
Normal file
61
src/components/LocaleSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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
40
src/i18n/locales.ts
Normal 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
115
src/i18n/request.ts
Normal 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
45
src/lib/pdfFont.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue