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