From 716f02868f2aa589d8ef02775869c53d3ee591a2 Mon Sep 17 00:00:00 2001 From: Mera Date: Wed, 17 Jun 2026 16:56:59 +0700 Subject: [PATCH 1/3] Add multi-provider machine translation --- .env.example | 9 + README.md | 55 +++-- src/app/api/translation/route.ts | 129 +++++++++++ src/components/learning-workspace.tsx | 19 +- src/lib/machine-translation.ts | 305 ++++++++++++++++++++++++++ 5 files changed, 495 insertions(+), 22 deletions(-) create mode 100644 src/app/api/translation/route.ts create mode 100644 src/lib/machine-translation.ts diff --git a/.env.example b/.env.example index 6170428..4c21c97 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,15 @@ ANTHROPIC_API_KEY= # Google Cloud Translation Basic (v2, server-side only) GOOGLE_CLOUD_TRANSLATION_API_KEY= +# Azure AI Translator F0/free tier (server-side only) +AZURE_TRANSLATOR_KEY= +AZURE_TRANSLATOR_REGION= +AZURE_TRANSLATOR_ENDPOINT=https://api.cognitive.microsofttranslator.com + +# LibreTranslate self-hosted or trusted instance (server-side only) +LIBRETRANSLATE_URL= +LIBRETRANSLATE_API_KEY= + # Stripe Billing STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= diff --git a/README.md b/README.md index e5e1947..af4edb8 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,8 @@ providers in one account-isolated application. - Difficult-question review queue persisted per learner. - AI Tutor with saved conversations, document context and answer feedback. - Reading, listening, speaking, writing, translation, quiz and vocabulary tools. -- Google Cloud Translation with automatic language detection and server-side keys. +- Azure, Google and LibreTranslate machine translation with automatic language + detection and server-side keys. - Flashcards with spaced review and learner-owned vocabulary. - PDF, DOCX and TXT extraction with AI learning tools. - XP, tokens, daily/weekly challenges, levels and competition leaderboards. @@ -174,6 +175,11 @@ OPENAI_API_KEY= OPENROUTER_API_KEY= ANTHROPIC_API_KEY= GOOGLE_CLOUD_TRANSLATION_API_KEY= +AZURE_TRANSLATOR_KEY= +AZURE_TRANSLATOR_REGION= +AZURE_TRANSLATOR_ENDPOINT=https://api.cognitive.microsofttranslator.com +LIBRETRANSLATE_URL= +LIBRETRANSLATE_API_KEY= STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= @@ -242,28 +248,51 @@ prefix, model name or custom Base URL. Retryable provider failures such as HTTP `429`, `500`, `502`, `503` and `504` use bounded retries and return actionable messages to the interface. -### Google Cloud Translation +### Machine Translation -The Translation workspace uses Google Cloud Translation Basic (v2) when -`GOOGLE_CLOUD_TRANSLATION_API_KEY` is configured. The key stays on the server; -the browser only calls Lingora's authenticated `/api/translation/google` -route. If the key is absent, Lingora falls back to the configured AI provider. +The Translation workspace uses a server-side provider chain: + +```text +Azure AI Translator → Google Cloud Translation → LibreTranslate → Lingora AI fallback +``` + +The browser only calls Lingora's authenticated `/api/translation` route. API +keys stay on the server, and translation text is not stored in learning-event +metadata. + +Recommended free/low-cost setup: + +1. Create an Azure account and enable **Azure AI Translator** on the F0 tier. +2. Copy the resource key and region into `.env.local`: + +```dotenv +AZURE_TRANSLATOR_KEY=your_azure_translator_key +AZURE_TRANSLATOR_REGION=your_resource_region +AZURE_TRANSLATOR_ENDPOINT=https://api.cognitive.microsofttranslator.com +``` + +Google Cloud Translation can be used as a second provider: 1. Create or select a project in Google Cloud Console. 2. Enable **Cloud Translation API** and attach a billing account. 3. Create an API key under **APIs & Services → Credentials**. -4. Restrict the key to **Cloud Translation API**. For production, also apply - the network or application restrictions appropriate for the deployment. -5. Add the key to `.env.local`, then restart the Next.js server: +4. Restrict the key to **Cloud Translation API**. +5. Add the key to `.env.local`: ```dotenv GOOGLE_CLOUD_TRANSLATION_API_KEY=your_server_side_key ``` -The integration supports automatic source-language detection, explicit -source/target selection, language swapping, copy-to-clipboard and private -usage events. Translation text itself is not stored in learning-event -metadata. +LibreTranslate can be self-hosted or pointed at a trusted instance: + +```dotenv +LIBRETRANSLATE_URL=http://localhost:5000 +LIBRETRANSLATE_API_KEY= +``` + +After changing translation environment variables, restart the Next.js server. +The UI supports automatic source-language detection, explicit source/target +selection, language swapping and copy-to-clipboard. ## Speech Recognition diff --git a/src/app/api/translation/route.ts b/src/app/api/translation/route.ts new file mode 100644 index 0000000..8e95591 --- /dev/null +++ b/src/app/api/translation/route.ts @@ -0,0 +1,129 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getOptionalViewer } from "@/lib/auth"; +import { consumeAiQuota } from "@/lib/billing"; +import { hasFeatureAccess } from "@/lib/economy"; +import { + MachineTranslationError, + translateWithMachineProvider, +} from "@/lib/machine-translation"; +import { + isTranslationLanguage, + translationLanguages, +} from "@/lib/translation-languages"; +import { createClient } from "@/lib/supabase/server"; + +const languageCodes = translationLanguages.map((language) => language.code) as [ + string, + ...string[], +]; + +const schema = z.object({ + text: z.string().trim().min(1).max(10_000), + source: z.enum(languageCodes).nullable().optional(), + target: z.enum(languageCodes), +}); + +const providerLabels = { + azure: "Azure AI Translator", + google: "Google Cloud Translation", + libretranslate: "LibreTranslate", +} as const; + +export async function POST(request: Request) { + try { + const viewer = await getOptionalViewer(); + if (!viewer) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (!(await hasFeatureAccess(viewer.id, "translation", "basic"))) { + return NextResponse.json( + { + error: "Dịch thuật yêu cầu gói Basic, Plus hoặc Pro.", + code: "PLAN_UPGRADE_REQUIRED", + requiredPlan: "basic", + }, + { status: 403 }, + ); + } + + const input = schema.parse(await request.json()); + if (input.source && input.source === input.target) { + return NextResponse.json( + { error: "Ngôn ngữ nguồn và đích phải khác nhau." }, + { status: 400 }, + ); + } + if ( + (input.source && !isTranslationLanguage(input.source)) || + !isTranslationLanguage(input.target) + ) { + return NextResponse.json( + { error: "Ngôn ngữ chưa được hỗ trợ." }, + { status: 400 }, + ); + } + + const usage = await consumeAiQuota(viewer.id); + if (!usage.allowed) { + return NextResponse.json( + { + error: `Bạn đã dùng hết ${usage.quota} lượt AI hôm nay.`, + code: "AI_QUOTA_EXCEEDED", + }, + { status: 429 }, + ); + } + + const result = await translateWithMachineProvider({ + text: input.text, + source: input.source ?? undefined, + target: input.target, + }); + const supabase = await createClient(); + await supabase.from("learning_events").insert({ + user_id: viewer.id, + event_type: "translation", + skill: "translation", + duration_seconds: 0, + metadata: { + provider: result.provider, + source: result.detectedSourceLanguage, + target: input.target, + characters: input.text.length, + }, + }); + + return NextResponse.json( + { + ...result, + providerLabel: providerLabels[result.provider], + usage: { + used: usage.used, + quota: usage.quota, + remaining: Math.max(0, usage.quota - usage.used), + }, + }, + { headers: { "Cache-Control": "private, no-store" } }, + ); + } catch (error) { + const message = + error instanceof z.ZodError + ? "Dữ liệu dịch không hợp lệ." + : error instanceof Error + ? error.message + : "Không thể dịch nội dung."; + const status = + error instanceof MachineTranslationError + ? error.status + : error instanceof z.ZodError + ? 400 + : 500; + const code = + error instanceof MachineTranslationError ? error.code : undefined; + return NextResponse.json( + { error: message, code }, + { status, headers: { "Cache-Control": "private, no-store" } }, + ); + } +} diff --git a/src/components/learning-workspace.tsx b/src/components/learning-workspace.tsx index c8a33b2..545d13d 100644 --- a/src/components/learning-workspace.tsx +++ b/src/components/learning-workspace.tsx @@ -577,7 +577,7 @@ export function LearningWorkspace({ setLoading(true); try { if (mode === "translation") { - const googleResponse = await fetch("/api/translation/google", { + const translationResponse = await fetch("/api/translation", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -586,24 +586,25 @@ export function LearningWorkspace({ target: translationTarget, }), }); - const googlePayload = (await googleResponse.json()) as { + const translationPayload = (await translationResponse.json()) as { text?: string; detectedSourceLanguage?: string | null; provider?: string; + providerLabel?: string; error?: string; code?: string; }; - if (googleResponse.ok) { - setResult(googlePayload.text ?? ""); - setDetectedLanguage(googlePayload.detectedSourceLanguage ?? null); - setTranslationProvider("Google Cloud Translation"); - toast.success("Đã dịch bằng Google Cloud Translation."); + if (translationResponse.ok) { + setResult(translationPayload.text ?? ""); + setDetectedLanguage(translationPayload.detectedSourceLanguage ?? null); + setTranslationProvider(translationPayload.providerLabel ?? "Dịch máy"); + toast.success(`Đã dịch bằng ${translationPayload.providerLabel ?? "dịch máy"}.`); play("complete"); router.refresh(); return; } - if (googlePayload.code !== "GOOGLE_TRANSLATION_NOT_CONFIGURED") { - throw new Error(googlePayload.error ?? "Google Translation không phản hồi."); + if (translationPayload.code !== "NOT_CONFIGURED") { + throw new Error(translationPayload.error ?? "Dịch máy không phản hồi."); } } diff --git a/src/lib/machine-translation.ts b/src/lib/machine-translation.ts new file mode 100644 index 0000000..1565eb6 --- /dev/null +++ b/src/lib/machine-translation.ts @@ -0,0 +1,305 @@ +import "server-only"; +import { translateWithGoogle } from "@/lib/google-cloud-translate"; + +export type MachineTranslationProvider = "azure" | "google" | "libretranslate"; + +type AzureTranslationResponse = Array<{ + detectedLanguage?: { + language?: string; + score?: number; + }; + translations?: Array<{ + text?: string; + to?: string; + }>; +}>; + +type AzureErrorResponse = { + error?: { + code?: string; + message?: string; + }; +}; + +type LibreTranslateResponse = { + translatedText?: string; + detectedLanguage?: + | string + | { + language?: string; + confidence?: number; + }; + error?: string; +}; + +export class MachineTranslationError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly code: + | "NOT_CONFIGURED" + | "INVALID_KEY" + | "QUOTA_EXCEEDED" + | "UPSTREAM_ERROR", + public readonly provider?: MachineTranslationProvider, + ) { + super(message); + this.name = "MachineTranslationError"; + } +} + +function normalizeEndpoint(endpoint: string) { + return endpoint.replace(/\/+$/, ""); +} + +function decodeHtmlEntities(value: string) { + const named: Record = { + amp: "&", + apos: "'", + gt: ">", + lt: "<", + quot: '"', + }; + + return value.replace( + /&(#x[\da-f]+|#\d+|amp|apos|gt|lt|quot);/gi, + (entity, token: string) => { + if (token.startsWith("#x")) { + return String.fromCodePoint(Number.parseInt(token.slice(2), 16)); + } + if (token.startsWith("#")) { + return String.fromCodePoint(Number.parseInt(token.slice(1), 10)); + } + return named[token.toLowerCase()] ?? entity; + }, + ); +} + +function providerError( + provider: MachineTranslationProvider, + status: number, + detail: string, +) { + const normalized = detail.toLowerCase(); + const code = + status === 401 || + status === 403 || + normalized.includes("api key") || + normalized.includes("subscription key") || + normalized.includes("unauthorized") + ? "INVALID_KEY" + : status === 429 || + normalized.includes("quota") || + normalized.includes("billing") || + normalized.includes("rate") + ? "QUOTA_EXCEEDED" + : "UPSTREAM_ERROR"; + return new MachineTranslationError(detail, status, code, provider); +} + +async function translateWithAzure({ + text, + source, + target, +}: { + text: string; + source?: string; + target: string; +}) { + const key = process.env.AZURE_TRANSLATOR_KEY?.trim(); + if (!key) { + throw new MachineTranslationError( + "Azure Translator chưa được cấu hình.", + 503, + "NOT_CONFIGURED", + "azure", + ); + } + + const endpoint = normalizeEndpoint( + process.env.AZURE_TRANSLATOR_ENDPOINT?.trim() || + "https://api.cognitive.microsofttranslator.com", + ); + const region = process.env.AZURE_TRANSLATOR_REGION?.trim(); + const url = new URL(`${endpoint}/translate`); + url.searchParams.set("api-version", "3.0"); + url.searchParams.append("to", target); + if (source) url.searchParams.set("from", source); + + const headers: Record = { + "Content-Type": "application/json", + "Ocp-Apim-Subscription-Key": key, + "X-ClientTraceId": crypto.randomUUID(), + }; + if (region) headers["Ocp-Apim-Subscription-Region"] = region; + + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify([{ text }]), + signal: AbortSignal.timeout(20_000), + cache: "no-store", + }); + const payload = (await response.json().catch(() => ({}))) as + | AzureTranslationResponse + | AzureErrorResponse; + + if (!response.ok) { + const detail = + !Array.isArray(payload) && payload.error?.message + ? payload.error.message + : `Azure Translator trả về HTTP ${response.status}.`; + throw providerError("azure", response.status, detail); + } + + const item = Array.isArray(payload) ? payload[0] : undefined; + const translatedText = item?.translations?.[0]?.text; + if (!translatedText) { + throw new MachineTranslationError( + "Azure Translator không trả về nội dung.", + 502, + "UPSTREAM_ERROR", + "azure", + ); + } + + return { + text: decodeHtmlEntities(translatedText), + detectedSourceLanguage: item?.detectedLanguage?.language ?? source ?? null, + provider: "azure" as const, + }; +} + +async function translateWithLibreTranslate({ + text, + source, + target, +}: { + text: string; + source?: string; + target: string; +}) { + const endpoint = process.env.LIBRETRANSLATE_URL?.trim(); + if (!endpoint) { + throw new MachineTranslationError( + "LibreTranslate chưa được cấu hình.", + 503, + "NOT_CONFIGURED", + "libretranslate", + ); + } + + const apiKey = process.env.LIBRETRANSLATE_API_KEY?.trim(); + const response = await fetch(`${normalizeEndpoint(endpoint)}/translate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + q: text, + source: source ?? "auto", + target, + format: "text", + ...(apiKey ? { api_key: apiKey } : {}), + }), + signal: AbortSignal.timeout(30_000), + cache: "no-store", + }); + const payload = (await response.json().catch(() => ({}))) as LibreTranslateResponse; + + if (!response.ok) { + throw providerError( + "libretranslate", + response.status, + payload.error ?? `LibreTranslate trả về HTTP ${response.status}.`, + ); + } + if (!payload.translatedText) { + throw new MachineTranslationError( + "LibreTranslate không trả về nội dung.", + 502, + "UPSTREAM_ERROR", + "libretranslate", + ); + } + + const detected = + typeof payload.detectedLanguage === "string" + ? payload.detectedLanguage + : payload.detectedLanguage?.language; + + return { + text: decodeHtmlEntities(payload.translatedText), + detectedSourceLanguage: detected ?? source ?? null, + provider: "libretranslate" as const, + }; +} + +async function translateWithGoogleProvider(input: { + text: string; + source?: string; + target: string; +}) { + const result = await translateWithGoogle(input); + return { + ...result, + provider: "google" as const, + }; +} + +export async function translateWithMachineProvider(input: { + text: string; + source?: string; + target: string; +}) { + const candidates: Array<() => Promise<{ + text: string; + detectedSourceLanguage: string | null; + provider: MachineTranslationProvider; + }>> = []; + + if (process.env.AZURE_TRANSLATOR_KEY?.trim()) { + candidates.push(() => translateWithAzure(input)); + } + if (process.env.GOOGLE_CLOUD_TRANSLATION_API_KEY?.trim()) { + candidates.push(() => translateWithGoogleProvider(input)); + } + if (process.env.LIBRETRANSLATE_URL?.trim()) { + candidates.push(() => translateWithLibreTranslate(input)); + } + + if (!candidates.length) { + throw new MachineTranslationError( + "Chưa cấu hình dịch máy. Thêm Azure Translator, Google Translation hoặc LibreTranslate.", + 503, + "NOT_CONFIGURED", + ); + } + + let lastError: unknown; + for (const candidate of candidates) { + try { + return await candidate(); + } catch (error) { + lastError = error; + if ( + error instanceof MachineTranslationError && + error.code === "INVALID_KEY" + ) { + continue; + } + if ( + error instanceof MachineTranslationError && + error.code === "QUOTA_EXCEEDED" + ) { + continue; + } + continue; + } + } + + if (lastError instanceof MachineTranslationError) throw lastError; + throw new MachineTranslationError( + "Không thể kết nối dịch máy.", + 502, + "UPSTREAM_ERROR", + ); +} From d5a620ea56946cab3819463d16171a58481dce45 Mon Sep 17 00:00:00 2001 From: Mera Date: Wed, 17 Jun 2026 17:17:21 +0700 Subject: [PATCH 2/3] Optimize runtime and mascot navigation --- .gitignore | 1 + next.config.ts | 13 +++- src/components/app-shell.tsx | 7 ++- src/components/lingora-mascot.tsx | 8 +-- src/lib/mascot-visibility.test.ts | 19 ++++++ src/lib/mascot-visibility.ts | 11 ++++ src/proxy.ts | 2 +- ...615144048_optimize_foreign_key_indexes.sql | 59 +++++++++++++++++++ ...0260615144329_consolidate_rls_policies.sql | 43 ++++++++++++++ 9 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 src/lib/mascot-visibility.test.ts create mode 100644 src/lib/mascot-visibility.ts create mode 100644 supabase/migrations/20260615144048_optimize_foreign_key_indexes.sql create mode 100644 supabase/migrations/20260615144329_consolidate_rls_policies.sql diff --git a/.gitignore b/.gitignore index 9da7737..67de595 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ # testing /coverage +/.playwright-cli/ # next.js /.next/ diff --git a/next.config.ts b/next.config.ts index e9ffa30..882c9ff 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,18 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + poweredByHeader: false, + compress: true, + images: { + formats: ["image/avif", "image/webp"], + minimumCacheTTL: 60 * 60 * 24 * 30, + }, + compiler: { + removeConsole: + process.env.NODE_ENV === "production" + ? { exclude: ["error", "warn"] } + : false, + }, }; export default nextConfig; diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index a6f220a..9cea27b 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -59,6 +59,7 @@ import { MascotSprite, openMascotChat, } from "@/components/lingora-mascot"; +import { canShowMascotCompanion } from "@/lib/mascot-visibility"; const navigation = [ { key: "dashboard", href: "/", icon: LayoutDashboard }, @@ -98,6 +99,7 @@ function Navigation({ isAdmin: boolean; }) { const pathname = usePathname(); + const { showMascot } = useExperience(); return (