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:
BIN
frontend/src/app/favicon.ico
Normal file
BIN
frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
122
frontend/src/app/globals.css
Normal file
122
frontend/src/app/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
32
frontend/src/app/layout.tsx
Normal file
32
frontend/src/app/layout.tsx
Normal 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
49
frontend/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
239
frontend/src/app/settings/context/page.tsx
Normal file
239
frontend/src/app/settings/context/page.tsx
Normal 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 récupérateur=heat recovery unit ventilo-connecteur=fan coil unit gaine=duct 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>
|
||||
);
|
||||
}
|
||||
247
frontend/src/app/settings/page.tsx
Normal file
247
frontend/src/app/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
722
frontend/src/app/settings/services/page.tsx
Normal file
722
frontend/src/app/settings/services/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user