723 lines
27 KiB
TypeScript
723 lines
27 KiB
TypeScript
"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>
|
||
);
|
||
}
|