723 lines
27 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}