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/.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/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/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/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/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 (