Add Next.js frontend with WebLLM, OpenAI support - Add complete Next.js frontend with Tailwind CSS and shadcn/ui - Integrate WebLLM for client-side browser-based translations - Add OpenAI provider support with gpt-4o-mini default - Add Context & Glossary page for LLM customization - Reorganize settings: Translation Services includes all providers - Add system prompt and glossary support for all LLMs - Remove test files and requirements-test.txt

This commit is contained in:
2025-11-30 19:02:41 +01:00
parent a4ecd3e0ec
commit 8c7716bf4d
44 changed files with 11885 additions and 15 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,122 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: #262626;
--foreground: oklch(0.985 0 0);
--card: #2d2d2d;
--card-foreground: oklch(0.985 0 0);
--popover: #2d2d2d;
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: #333333;
--secondary-foreground: oklch(0.985 0 0);
--muted: #333333;
--muted-foreground: oklch(0.708 0 0);
--accent: #333333;
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: #1f1f1f;
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: #333333;
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,32 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Sidebar } from "@/components/sidebar";
const inter = Inter({
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Translate Co. - Document Translation",
description: "Translate Excel, Word, and PowerPoint documents while preserving formatting",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="dark">
<body className={`${inter.className} bg-[#262626] text-zinc-100 antialiased`}>
<Sidebar />
<main className="ml-64 min-h-screen p-8">
<div className="max-w-6xl mx-auto">
{children}
</div>
</main>
</body>
</html>
);
}

49
frontend/src/app/page.tsx Normal file
View File

@@ -0,0 +1,49 @@
"use client";
import { FileUploader } from "@/components/file-uploader";
import { useTranslationStore } from "@/lib/store";
import { Badge } from "@/components/ui/badge";
import { Settings } from "lucide-react";
import Link from "next/link";
export default function Home() {
const { settings } = useTranslationStore();
const providerNames: Record<string, string> = {
google: "Google Translate",
ollama: "Ollama",
deepl: "DeepL",
libre: "LibreTranslate",
webllm: "WebLLM",
};
return (
<div className="space-y-6">
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-white">Translate Documents</h1>
<p className="text-zinc-400 mt-1">
Upload and translate Excel, Word, and PowerPoint files while preserving all formatting.
</p>
</div>
{/* Current Configuration Badge */}
<Link href="/settings/services" className="flex items-center gap-2 px-3 py-2 rounded-lg bg-zinc-800/50 border border-zinc-700 hover:bg-zinc-800 transition-colors">
<Settings className="h-4 w-4 text-zinc-400" />
<div className="flex items-center gap-2">
<Badge variant="outline" className="border-teal-500/50 text-teal-400 text-xs">
{providerNames[settings.defaultProvider]}
</Badge>
{settings.defaultProvider === "ollama" && settings.ollamaModel && (
<Badge variant="outline" className="border-zinc-600 text-zinc-400 text-xs">
{settings.ollamaModel}
</Badge>
)}
</div>
</Link>
</div>
<FileUploader />
</div>
);
}

View File

@@ -0,0 +1,239 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { useTranslationStore } from "@/lib/store";
import { Save, Loader2, Brain, BookOpen, Sparkles, Trash2 } from "lucide-react";
export default function ContextGlossaryPage() {
const { settings, updateSettings, applyPreset, clearContext } = useTranslationStore();
const [isSaving, setIsSaving] = useState(false);
const [localSettings, setLocalSettings] = useState({
systemPrompt: settings.systemPrompt,
glossary: settings.glossary,
});
useEffect(() => {
setLocalSettings({
systemPrompt: settings.systemPrompt,
glossary: settings.glossary,
});
}, [settings]);
const handleSave = async () => {
setIsSaving(true);
try {
updateSettings(localSettings);
await new Promise((resolve) => setTimeout(resolve, 500));
} finally {
setIsSaving(false);
}
};
const handleApplyPreset = (preset: 'hvac' | 'it' | 'legal' | 'medical') => {
applyPreset(preset);
// Need to get the updated values from the store after applying preset
setTimeout(() => {
setLocalSettings({
systemPrompt: useTranslationStore.getState().settings.systemPrompt,
glossary: useTranslationStore.getState().settings.glossary,
});
}, 0);
};
const handleClear = () => {
clearContext();
setLocalSettings({
systemPrompt: "",
glossary: "",
});
};
// Check which LLM providers are configured
const isOllamaConfigured = settings.ollamaUrl && settings.ollamaModel;
const isOpenAIConfigured = !!settings.openaiApiKey;
const isWebLLMAvailable = typeof window !== 'undefined' && 'gpu' in navigator;
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-white">Context & Glossary</h1>
<p className="text-zinc-400 mt-1">
Configure translation context and glossary for LLM-based providers.
</p>
{/* LLM Provider Status */}
<div className="flex flex-wrap gap-2 mt-3">
<Badge
variant="outline"
className={`${isOllamaConfigured ? 'border-green-500 text-green-400' : 'border-zinc-600 text-zinc-500'}`}
>
🤖 Ollama {isOllamaConfigured ? '✓' : '○'}
</Badge>
<Badge
variant="outline"
className={`${isOpenAIConfigured ? 'border-green-500 text-green-400' : 'border-zinc-600 text-zinc-500'}`}
>
🧠 OpenAI {isOpenAIConfigured ? '✓' : '○'}
</Badge>
<Badge
variant="outline"
className={`${isWebLLMAvailable ? 'border-green-500 text-green-400' : 'border-zinc-600 text-zinc-500'}`}
>
💻 WebLLM {isWebLLMAvailable ? '✓' : '○'}
</Badge>
</div>
</div>
{/* Info Banner */}
<div className="p-4 rounded-lg bg-teal-500/10 border border-teal-500/30">
<p className="text-teal-400 text-sm flex items-center gap-2">
<Sparkles className="h-4 w-4" />
<span>
<strong>Context & Glossary</strong> settings apply to all LLM providers:
<strong> Ollama</strong>, <strong>OpenAI</strong>, and <strong>WebLLM</strong>.
Use them to improve translation quality with domain-specific instructions.
</span>
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column */}
<div className="space-y-6">
{/* System Prompt */}
<Card className="border-zinc-800 bg-zinc-900/50">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Brain className="h-5 w-5 text-teal-400" />
System Prompt
</CardTitle>
<CardDescription>
Instructions for the LLM to follow during translation.
Works with Ollama, OpenAI, and WebLLM.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Textarea
id="system-prompt"
value={localSettings.systemPrompt}
onChange={(e) =>
setLocalSettings({ ...localSettings, systemPrompt: e.target.value })
}
placeholder="Example: You are translating technical HVAC documents. Use precise engineering terminology. Maintain consistency with industry standards..."
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 min-h-[200px] resize-y"
/>
<p className="text-xs text-zinc-500">
💡 Tip: Include domain context, tone preferences, or specific terminology rules.
</p>
</CardContent>
</Card>
{/* Presets */}
<Card className="border-zinc-800 bg-zinc-900/50">
<CardHeader>
<CardTitle className="text-white">Quick Presets</CardTitle>
<CardDescription>
Load pre-configured prompts & glossaries for common domains.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-2">
<Button
variant="outline"
onClick={() => handleApplyPreset("hvac")}
className="border-zinc-700 text-zinc-300 hover:bg-zinc-800 hover:text-teal-400 justify-start"
>
🔧 HVAC / Engineering
</Button>
<Button
variant="outline"
onClick={() => handleApplyPreset("it")}
className="border-zinc-700 text-zinc-300 hover:bg-zinc-800 hover:text-teal-400 justify-start"
>
💻 IT / Software
</Button>
<Button
variant="outline"
onClick={() => handleApplyPreset("legal")}
className="border-zinc-700 text-zinc-300 hover:bg-zinc-800 hover:text-teal-400 justify-start"
>
Legal / Contracts
</Button>
<Button
variant="outline"
onClick={() => handleApplyPreset("medical")}
className="border-zinc-700 text-zinc-300 hover:bg-zinc-800 hover:text-teal-400 justify-start"
>
🏥 Medical / Healthcare
</Button>
</div>
<Button
variant="ghost"
onClick={handleClear}
className="w-full mt-3 text-red-400 hover:text-red-300 hover:bg-red-500/10"
>
<Trash2 className="h-4 w-4 mr-2" />
Clear All
</Button>
</CardContent>
</Card>
</div>
{/* Right Column */}
<div className="space-y-6">
{/* Glossary */}
<Card className="border-zinc-800 bg-zinc-900/50">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<BookOpen className="h-5 w-5 text-teal-400" />
Technical Glossary
</CardTitle>
<CardDescription>
Define specific term translations. Format: source=target (one per line).
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Textarea
id="glossary"
value={localSettings.glossary}
onChange={(e) =>
setLocalSettings({ ...localSettings, glossary: e.target.value })
}
placeholder="pression statique=static pressure&#10;récupérateur=heat recovery unit&#10;ventilo-connecteur=fan coil unit&#10;gaine=duct&#10;diffuseur=diffuser"
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 min-h-[280px] resize-y font-mono text-sm"
/>
<p className="text-xs text-zinc-500">
💡 The glossary is included in the system prompt to guide translations.
</p>
</CardContent>
</Card>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end">
<Button
onClick={handleSave}
disabled={isSaving}
className="bg-teal-600 hover:bg-teal-700 text-white px-8"
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Save Settings
</>
)}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,247 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { useTranslationStore } from "@/lib/store";
import { languages } from "@/lib/api";
import { Save, Loader2, Settings, Globe, Trash2 } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export default function GeneralSettingsPage() {
const { settings, updateSettings } = useTranslationStore();
const [isSaving, setIsSaving] = useState(false);
const [isClearing, setIsClearing] = useState(false);
const [defaultLanguage, setDefaultLanguage] = useState(settings.defaultTargetLanguage);
useEffect(() => {
setDefaultLanguage(settings.defaultTargetLanguage);
}, [settings.defaultTargetLanguage]);
const handleSave = async () => {
setIsSaving(true);
try {
updateSettings({ defaultTargetLanguage: defaultLanguage });
await new Promise((resolve) => setTimeout(resolve, 500));
} finally {
setIsSaving(false);
}
};
const handleClearCache = async () => {
setIsClearing(true);
try {
// Clear localStorage
localStorage.removeItem('translation-settings');
// Clear sessionStorage
sessionStorage.clear();
// Clear any cached files/blobs
if ('caches' in window) {
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map(name => caches.delete(name)));
}
await new Promise((resolve) => setTimeout(resolve, 500));
// Reload to reset state
window.location.reload();
} catch (error) {
console.error('Error clearing cache:', error);
setIsClearing(false);
}
};
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-white">General Settings</h1>
<p className="text-zinc-400 mt-1">
Configure general application settings and preferences.
</p>
</div>
<Card className="border-zinc-800 bg-zinc-900/50">
<CardHeader>
<div className="flex items-center gap-3">
<Settings className="h-6 w-6 text-teal-400" />
<div>
<CardTitle className="text-white">Application Settings</CardTitle>
<CardDescription>
General configuration options
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="default-language" className="text-zinc-300">
Default Target Language
</Label>
<Select value={defaultLanguage} onValueChange={setDefaultLanguage}>
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-white">
<SelectValue placeholder="Select default language" />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700 max-h-[300px]">
{languages.map((lang) => (
<SelectItem
key={lang.code}
value={lang.code}
className="text-white hover:bg-zinc-700"
>
<span className="flex items-center gap-2">
<span>{lang.flag}</span>
<span>{lang.name}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-zinc-500">
This language will be pre-selected when translating documents
</p>
</div>
</CardContent>
</Card>
{/* Supported Formats */}
<Card className="border-zinc-800 bg-zinc-900/50">
<CardHeader>
<div className="flex items-center gap-3">
<Globe className="h-6 w-6 text-teal-400" />
<div>
<CardTitle className="text-white">Supported Formats</CardTitle>
<CardDescription>
Document types that can be translated
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="p-4 rounded-lg border border-zinc-800 bg-zinc-800/30">
<div className="text-2xl mb-2">📊</div>
<h3 className="font-medium text-white">Excel</h3>
<p className="text-xs text-zinc-500 mt-1">.xlsx, .xls</p>
<div className="flex flex-wrap gap-1 mt-2">
<Badge variant="outline" className="border-zinc-700 text-zinc-400 text-xs">
Formulas
</Badge>
<Badge variant="outline" className="border-zinc-700 text-zinc-400 text-xs">
Styles
</Badge>
<Badge variant="outline" className="border-zinc-700 text-zinc-400 text-xs">
Images
</Badge>
</div>
</div>
<div className="p-4 rounded-lg border border-zinc-800 bg-zinc-800/30">
<div className="text-2xl mb-2">📝</div>
<h3 className="font-medium text-white">Word</h3>
<p className="text-xs text-zinc-500 mt-1">.docx, .doc</p>
<div className="flex flex-wrap gap-1 mt-2">
<Badge variant="outline" className="border-zinc-700 text-zinc-400 text-xs">
Headers
</Badge>
<Badge variant="outline" className="border-zinc-700 text-zinc-400 text-xs">
Tables
</Badge>
<Badge variant="outline" className="border-zinc-700 text-zinc-400 text-xs">
Images
</Badge>
</div>
</div>
<div className="p-4 rounded-lg border border-zinc-800 bg-zinc-800/30">
<div className="text-2xl mb-2">📽</div>
<h3 className="font-medium text-white">PowerPoint</h3>
<p className="text-xs text-zinc-500 mt-1">.pptx, .ppt</p>
<div className="flex flex-wrap gap-1 mt-2">
<Badge variant="outline" className="border-zinc-700 text-zinc-400 text-xs">
Slides
</Badge>
<Badge variant="outline" className="border-zinc-700 text-zinc-400 text-xs">
Notes
</Badge>
<Badge variant="outline" className="border-zinc-700 text-zinc-400 text-xs">
Images
</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
{/* API Status */}
<Card className="border-zinc-800 bg-zinc-900/50">
<CardHeader>
<CardTitle className="text-white">API Information</CardTitle>
<CardDescription>
Backend server connection details
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800/50">
<span className="text-zinc-400">API Endpoint</span>
<code className="text-teal-400 text-sm">http://localhost:8000</code>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800/50">
<span className="text-zinc-400">Health Check</span>
<code className="text-teal-400 text-sm">/health</code>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800/50">
<span className="text-zinc-400">Translate Endpoint</span>
<code className="text-teal-400 text-sm">/translate</code>
</div>
</div>
</CardContent>
</Card>
{/* Save Button */}
<div className="flex justify-between items-center">
<Button
onClick={handleClearCache}
disabled={isClearing}
variant="destructive"
className="bg-red-600 hover:bg-red-700 text-white px-6"
>
{isClearing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Clearing...
</>
) : (
<>
<Trash2 className="mr-2 h-4 w-4" />
Clear Cache
</>
)}
</Button>
<Button
onClick={handleSave}
disabled={isSaving}
className="bg-teal-600 hover:bg-teal-700 text-white px-8"
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Save Settings
</>
)}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,722 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { useTranslationStore, webllmModels, openaiModels } from "@/lib/store";
import { providers, testOpenAIConnection, testOllamaConnection, getOllamaModels, type OllamaModel } from "@/lib/api";
import { useWebLLM } from "@/lib/webllm";
import { Save, Loader2, Cloud, Check, ExternalLink, Wifi, CheckCircle, XCircle, Download, Trash2, Cpu, Server, RefreshCw } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Progress } from "@/components/ui/progress";
export default function TranslationServicesPage() {
const { settings, updateSettings } = useTranslationStore();
const [isSaving, setIsSaving] = useState(false);
const [selectedProvider, setSelectedProvider] = useState(settings.defaultProvider);
const [translateImages, setTranslateImages] = useState(settings.translateImages);
// Provider-specific states
const [deeplApiKey, setDeeplApiKey] = useState(settings.deeplApiKey);
const [openaiApiKey, setOpenaiApiKey] = useState(settings.openaiApiKey);
const [openaiModel, setOpenaiModel] = useState(settings.openaiModel);
const [libreUrl, setLibreUrl] = useState(settings.libreTranslateUrl);
const [webllmModel, setWebllmModel] = useState(settings.webllmModel);
// Ollama states
const [ollamaUrl, setOllamaUrl] = useState(settings.ollamaUrl);
const [ollamaModel, setOllamaModel] = useState(settings.ollamaModel);
const [ollamaModels, setOllamaModels] = useState<OllamaModel[]>([]);
const [loadingOllamaModels, setLoadingOllamaModels] = useState(false);
const [ollamaTestStatus, setOllamaTestStatus] = useState<"idle" | "testing" | "success" | "error">("idle");
const [ollamaTestMessage, setOllamaTestMessage] = useState("");
// OpenAI connection test state
const [openaiTestStatus, setOpenaiTestStatus] = useState<"idle" | "testing" | "success" | "error">("idle");
const [openaiTestMessage, setOpenaiTestMessage] = useState("");
// WebLLM hook
const webllm = useWebLLM();
useEffect(() => {
setSelectedProvider(settings.defaultProvider);
setTranslateImages(settings.translateImages);
setDeeplApiKey(settings.deeplApiKey);
setOpenaiApiKey(settings.openaiApiKey);
setOpenaiModel(settings.openaiModel);
setLibreUrl(settings.libreTranslateUrl);
setWebllmModel(settings.webllmModel);
setOllamaUrl(settings.ollamaUrl);
setOllamaModel(settings.ollamaModel);
}, [settings]);
// Load Ollama models when provider is selected
const loadOllamaModels = async () => {
setLoadingOllamaModels(true);
try {
const models = await getOllamaModels(ollamaUrl);
setOllamaModels(models);
} catch (error) {
console.error("Failed to load Ollama models:", error);
} finally {
setLoadingOllamaModels(false);
}
};
useEffect(() => {
if (selectedProvider === "ollama") {
loadOllamaModels();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedProvider]);
const handleTestOllama = async () => {
setOllamaTestStatus("testing");
setOllamaTestMessage("");
try {
const result = await testOllamaConnection(ollamaUrl);
setOllamaTestStatus(result.success ? "success" : "error");
setOllamaTestMessage(result.message);
if (result.success) {
await loadOllamaModels();
updateSettings({ ollamaUrl, ollamaModel });
setOllamaTestMessage(result.message + " - Settings saved!");
}
} catch {
setOllamaTestStatus("error");
setOllamaTestMessage("Connection test failed");
}
};
const handleTestOpenAI = async () => {
if (!openaiApiKey.trim()) {
setOpenaiTestStatus("error");
setOpenaiTestMessage("Please enter an API key first");
return;
}
setOpenaiTestStatus("testing");
setOpenaiTestMessage("");
try {
const result = await testOpenAIConnection(openaiApiKey);
setOpenaiTestStatus(result.success ? "success" : "error");
setOpenaiTestMessage(result.message);
if (result.success) {
updateSettings({ openaiApiKey, openaiModel });
setOpenaiTestMessage(result.message + " - Settings saved!");
}
} catch {
setOpenaiTestStatus("error");
setOpenaiTestMessage("Connection test failed");
}
};
const handleSave = async () => {
setIsSaving(true);
try {
updateSettings({
defaultProvider: selectedProvider,
translateImages,
deeplApiKey,
openaiApiKey,
openaiModel,
libreTranslateUrl: libreUrl,
webllmModel,
ollamaUrl,
ollamaModel,
});
await new Promise((resolve) => setTimeout(resolve, 500));
} finally {
setIsSaving(false);
}
};
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-white">Translation Services</h1>
<p className="text-zinc-400 mt-1">
Select and configure your preferred translation provider.
</p>
</div>
{/* Provider Selection */}
<Card className="border-zinc-800 bg-zinc-900/50">
<CardHeader>
<div className="flex items-center gap-3">
<Cloud className="h-6 w-6 text-teal-400" />
<div>
<CardTitle className="text-white">Choose Provider</CardTitle>
<CardDescription>
Select your default translation service
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{providers.map((provider) => (
<div
key={provider.id}
onClick={() => setSelectedProvider(provider.id as typeof selectedProvider)}
tabIndex={-1}
className={`
relative p-4 rounded-lg border-2 cursor-pointer transition-all
${
selectedProvider === provider.id
? "border-teal-500 bg-teal-500/10"
: "border-zinc-700 hover:border-zinc-600 bg-zinc-800/50"
}
`}
>
{selectedProvider === provider.id && (
<div className="absolute top-2 right-2">
<Check className="h-5 w-5 text-teal-400" />
</div>
)}
<div className="text-2xl mb-2">{provider.icon}</div>
<h3 className="font-medium text-white">{provider.name}</h3>
<p className="text-xs text-zinc-500 mt-1">{provider.description}</p>
</div>
))}
</div>
</CardContent>
</Card>
{/* Google - No config needed */}
{selectedProvider === "google" && (
<Card className="border-zinc-800 bg-zinc-900/50 border-l-4 border-l-green-500">
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<CheckCircle className="h-6 w-6 text-green-400" />
<div>
<p className="text-white font-medium">Ready to use!</p>
<p className="text-sm text-zinc-400">
Google Translate works out of the box. No configuration needed.
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Ollama Settings */}
{selectedProvider === "ollama" && (
<Card className="border-zinc-800 bg-zinc-900/50">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-orange-400" />
<div>
<CardTitle className="text-white">Ollama Configuration</CardTitle>
<CardDescription>
Connect to your local Ollama server
</CardDescription>
</div>
</div>
{ollamaTestStatus !== "idle" && ollamaTestStatus !== "testing" && (
<Badge
variant="outline"
className={
ollamaTestStatus === "success"
? "border-green-500 text-green-400"
: "border-red-500 text-red-400"
}
>
{ollamaTestStatus === "success" && <CheckCircle className="h-3 w-3 mr-1" />}
{ollamaTestStatus === "error" && <XCircle className="h-3 w-3 mr-1" />}
{ollamaTestStatus === "success" ? "Connected" : "Error"}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="ollama-url" className="text-zinc-300">
Server URL
</Label>
<div className="flex gap-2">
<Input
id="ollama-url"
value={ollamaUrl}
onChange={(e) => setOllamaUrl(e.target.value)}
placeholder="http://localhost:11434"
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
/>
<Button
variant="outline"
onClick={handleTestOllama}
disabled={ollamaTestStatus === "testing"}
className="border-zinc-700 text-zinc-300 hover:bg-zinc-800"
>
{ollamaTestStatus === "testing" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Wifi className="h-4 w-4" />
)}
</Button>
</div>
{ollamaTestMessage && (
<p className={`text-xs ${ollamaTestStatus === "success" ? "text-green-400" : "text-red-400"}`}>
{ollamaTestMessage}
</p>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="ollama-model" className="text-zinc-300">
Model
</Label>
<Button
variant="ghost"
size="sm"
onClick={loadOllamaModels}
disabled={loadingOllamaModels}
className="text-zinc-400 hover:text-teal-400 h-7 px-2"
>
<RefreshCw className={`h-3 w-3 mr-1 ${loadingOllamaModels ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
<Select
value={ollamaModel}
onValueChange={setOllamaModel}
>
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-white">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700">
{ollamaModels.length > 0 ? (
ollamaModels.map((model) => (
<SelectItem
key={model.name}
value={model.name}
className="text-white hover:bg-zinc-700"
>
{model.name}
</SelectItem>
))
) : (
<SelectItem value={ollamaModel} className="text-white">
{ollamaModel || "No models found"}
</SelectItem>
)}
</SelectContent>
</Select>
<p className="text-xs text-zinc-500">
Don&apos;t have Ollama? Install it from{" "}
<a
href="https://ollama.ai"
target="_blank"
rel="noopener noreferrer"
className="text-teal-400 hover:underline"
>
ollama.ai
</a>
{" "}then run: <code className="bg-zinc-800 px-1 rounded">ollama pull llama3.2</code>
</p>
</div>
</CardContent>
</Card>
)}
{/* WebLLM Settings */}
{selectedProvider === "webllm" && (
<Card className="border-zinc-800 bg-zinc-900/50">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Cpu className="h-5 w-5 text-teal-400" />
WebLLM Settings
</CardTitle>
<CardDescription>
Run AI models directly in your browser using WebGPU - no server required!
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* WebGPU Support Check */}
{!webllm.isWebGPUSupported() && (
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/30">
<p className="text-red-400 text-sm">
WebGPU is not supported in this browser. Please use Chrome 113+, Edge 113+, or another WebGPU-compatible browser.
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="webllm-model" className="text-zinc-300">
Model
</Label>
<Select value={webllmModel} onValueChange={setWebllmModel}>
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-white">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700">
{webllmModels.map((model) => (
<SelectItem
key={model.id}
value={model.id}
className="text-white hover:bg-zinc-700"
>
<span className="flex items-center justify-between gap-4">
<span>{model.name}</span>
<Badge variant="outline" className="border-zinc-600 text-zinc-400 text-xs ml-2">
{model.size}
</Badge>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Model Loading Status */}
{webllm.isLoading && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-zinc-400">{webllm.loadStatus}</span>
<span className="text-teal-400">{webllm.loadProgress}%</span>
</div>
<Progress value={webllm.loadProgress} className="h-2" />
</div>
)}
{webllm.isLoaded && (
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/30">
<p className="text-green-400 text-sm flex items-center gap-2">
<CheckCircle className="h-4 w-4" />
Model loaded: {webllm.currentModel}
</p>
</div>
)}
{webllm.error && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30">
<p className="text-red-400 text-sm flex items-center gap-2">
<XCircle className="h-4 w-4" />
{webllm.error}
</p>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-3">
<Button
onClick={() => webllm.loadModel(webllmModel)}
disabled={webllm.isLoading || !webllm.isWebGPUSupported()}
className="bg-teal-600 hover:bg-teal-700 text-white flex-1"
>
{webllm.isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
</>
) : webllm.isLoaded && webllm.currentModel === webllmModel ? (
<>
<CheckCircle className="mr-2 h-4 w-4" />
Loaded
</>
) : (
<>
<Download className="mr-2 h-4 w-4" />
Load Model
</>
)}
</Button>
<Button
onClick={() => webllm.clearCache()}
variant="destructive"
className="bg-red-600 hover:bg-red-700"
>
<Trash2 className="mr-2 h-4 w-4" />
Clear Cache
</Button>
</div>
<p className="text-xs text-zinc-500">
💡 Models are downloaded once and cached in your browser (~1-5GB depending on model).
Loading may take a minute on first use.
</p>
</CardContent>
</Card>
)}
{/* DeepL Settings */}
{selectedProvider === "deepl" && (
<Card className="border-zinc-800 bg-zinc-900/50">
<CardHeader>
<CardTitle className="text-white">DeepL Settings</CardTitle>
<CardDescription>
Configure your DeepL API credentials
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="deepl-key" className="text-zinc-300">
API Key
</Label>
<Input
id="deepl-key"
type="password"
value={deeplApiKey}
onChange={(e) => setDeeplApiKey(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
placeholder="Enter your DeepL API key"
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
/>
<p className="text-xs text-zinc-500">
Get your API key from{" "}
<a
href="https://www.deepl.com/pro-api"
target="_blank"
rel="noopener noreferrer"
className="text-teal-400 hover:underline"
>
deepl.com/pro-api
</a>
</p>
</div>
</CardContent>
</Card>
)}
{/* LibreTranslate Settings */}
{selectedProvider === "libre" && (
<Card className="border-zinc-800 bg-zinc-900/50">
<CardHeader>
<CardTitle className="text-white">LibreTranslate Settings</CardTitle>
<CardDescription>
Configure your LibreTranslate server (open-source, self-hosted)
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="libre-url" className="text-zinc-300">
Server URL
</Label>
<Input
id="libre-url"
value={libreUrl}
onChange={(e) => setLibreUrl(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
placeholder="https://libretranslate.com"
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
/>
<div className="flex flex-col gap-1 text-xs text-zinc-500">
<p>Public instances (free but rate-limited):</p>
<div className="flex flex-wrap gap-2 mt-1">
<Button
variant="outline"
size="sm"
className="h-6 text-xs border-zinc-700 text-zinc-400 hover:text-teal-400"
onClick={() => setLibreUrl("https://libretranslate.com")}
>
libretranslate.com <ExternalLink className="h-3 w-3 ml-1" />
</Button>
<Button
variant="outline"
size="sm"
className="h-6 text-xs border-zinc-700 text-zinc-400 hover:text-teal-400"
onClick={() => setLibreUrl("https://translate.argosopentech.com")}
>
argosopentech.com <ExternalLink className="h-3 w-3 ml-1" />
</Button>
</div>
<p className="mt-2">
Or{" "}
<a
href="https://github.com/LibreTranslate/LibreTranslate"
target="_blank"
rel="noopener noreferrer"
className="text-teal-400 hover:underline"
>
self-host your own instance
</a>
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* OpenAI Settings */}
{selectedProvider === "openai" && (
<Card className="border-zinc-800 bg-zinc-900/50">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-white">OpenAI Settings</CardTitle>
<CardDescription>
Configure your OpenAI API for GPT-4 Vision translations
</CardDescription>
</div>
{openaiTestStatus !== "idle" && openaiTestStatus !== "testing" && (
<Badge
variant="outline"
className={
openaiTestStatus === "success"
? "border-green-500 text-green-400"
: "border-red-500 text-red-400"
}
>
{openaiTestStatus === "success" && <CheckCircle className="h-3 w-3 mr-1" />}
{openaiTestStatus === "error" && <XCircle className="h-3 w-3 mr-1" />}
{openaiTestStatus === "success" ? "Connected" : "Error"}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="openai-key" className="text-zinc-300">
API Key
</Label>
<div className="flex gap-2">
<Input
id="openai-key"
type="password"
value={openaiApiKey}
onChange={(e) => setOpenaiApiKey(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
placeholder="sk-..."
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
/>
<Button
variant="outline"
onClick={handleTestOpenAI}
disabled={openaiTestStatus === "testing"}
className="border-zinc-700 text-zinc-300 hover:bg-zinc-800"
>
{openaiTestStatus === "testing" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Wifi className="h-4 w-4" />
)}
</Button>
</div>
{openaiTestMessage && (
<p className={`text-xs ${openaiTestStatus === "success" ? "text-green-400" : "text-red-400"}`}>
{openaiTestMessage}
</p>
)}
<p className="text-xs text-zinc-500">
Get your API key from{" "}
<a
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noopener noreferrer"
className="text-teal-400 hover:underline"
>
platform.openai.com
</a>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="openai-model" className="text-zinc-300">
Model
</Label>
<Select
value={openaiModel}
onValueChange={setOpenaiModel}
>
<SelectTrigger
id="openai-model"
className="bg-zinc-800 border-zinc-700 text-white"
>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700">
{openaiModels.map((model) => (
<SelectItem
key={model.id}
value={model.id}
className="text-white hover:bg-zinc-700"
>
<span className="flex items-center justify-between gap-4">
<span>{model.name}</span>
{model.vision && (
<Badge variant="outline" className="border-teal-600 text-teal-400 text-xs ml-2">
Vision
</Badge>
)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-zinc-500">
Models with Vision can translate text in images
</p>
</div>
</CardContent>
</Card>
)}
{/* Image Translation - Only for Ollama and OpenAI */}
{(selectedProvider === "ollama" || selectedProvider === "openai") && (
<Card className="border-zinc-800 bg-zinc-900/50">
<CardHeader>
<CardTitle className="text-white">Advanced Options</CardTitle>
<CardDescription>
Additional translation features
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between rounded-lg border border-zinc-800 p-4">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<Label className="text-zinc-300">Translate Images by Default</Label>
<Badge variant="outline" className="border-teal-600 text-teal-400 text-xs">
Vision Models
</Badge>
</div>
<p className="text-xs text-zinc-500">
Extract and translate text from embedded images using vision models
</p>
</div>
<Switch
checked={translateImages}
onCheckedChange={setTranslateImages}
/>
</div>
</CardContent>
</Card>
)}
{/* Save Button */}
<div className="flex justify-end">
<Button
onClick={handleSave}
disabled={isSaving}
className="bg-teal-600 hover:bg-teal-700 text-white px-8"
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Save Settings
</>
)}
</Button>
</div>
</div>
);
}