Compare commits

...

2 Commits

3 changed files with 250 additions and 87 deletions

View File

@ -2,19 +2,18 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback, memo } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
Settings, Settings,
Cloud, Cloud,
BookText, BookText,
Upload, Upload,
Shield,
CreditCard,
LayoutDashboard, LayoutDashboard,
LogIn, LogIn,
Crown, Crown,
LogOut, LogOut,
Server,
} from "lucide-react"; } from "lucide-react";
import { import {
Tooltip, Tooltip,
@ -38,12 +37,6 @@ const navigation = [
icon: Upload, icon: Upload,
description: "Translate documents", description: "Translate documents",
}, },
{
name: "General Settings",
href: "/settings",
icon: Settings,
description: "Configure general settings",
},
{ {
name: "Translation Services", name: "Translation Services",
href: "/settings/services", href: "/settings/services",
@ -56,23 +49,61 @@ const navigation = [
icon: BookText, icon: BookText,
description: "System prompts and glossary", description: "System prompts and glossary",
}, },
];
const adminNavigation = [
{ {
name: "Admin Dashboard", name: "General Settings",
href: "/admin", href: "/settings",
icon: Shield, icon: Settings,
description: "System monitoring (login required)", description: "Configure general settings",
}, },
]; ];
const planColors: Record<string, string> = {
free: "bg-zinc-600",
starter: "bg-blue-500",
pro: "bg-teal-500",
business: "bg-purple-500",
enterprise: "bg-amber-500",
};
// Memoized NavItem for performance
const NavItem = memo(function NavItem({
item,
isActive
}: {
item: typeof navigation[0];
isActive: boolean;
}) {
const Icon = item.icon;
return (
<Tooltip>
<TooltipTrigger asChild>
<Link
href={item.href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
isActive
? "bg-teal-500/10 text-teal-400"
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
)}
>
<Icon className="h-5 w-5" />
<span>{item.name}</span>
</Link>
</TooltipTrigger>
<TooltipContent side="right">
<p>{item.description}</p>
</TooltipContent>
</Tooltip>
);
});
export function Sidebar() { export function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => { useEffect(() => {
// Check for user in localStorage setMounted(true);
const storedUser = localStorage.getItem("user"); const storedUser = localStorage.getItem("user");
if (storedUser) { if (storedUser) {
try { try {
@ -81,26 +112,38 @@ export function Sidebar() {
setUser(null); setUser(null);
} }
} }
// Listen for storage changes
const handleStorage = (e: StorageEvent) => {
if (e.key === "user") {
setUser(e.newValue ? JSON.parse(e.newValue) : null);
}
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []); }, []);
const handleLogout = () => { const handleLogout = useCallback(() => {
localStorage.removeItem("token"); localStorage.removeItem("token");
localStorage.removeItem("refresh_token"); localStorage.removeItem("refresh_token");
localStorage.removeItem("user"); localStorage.removeItem("user");
setUser(null); setUser(null);
window.location.href = "/"; window.location.href = "/";
}; }, []);
const planColors: Record<string, string> = { // Prevent hydration mismatch
free: "bg-zinc-600", if (!mounted) {
starter: "bg-blue-500", return (
pro: "bg-teal-500", <aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r border-zinc-800 bg-[#1a1a1a]">
business: "bg-purple-500", <div className="flex h-16 items-center gap-3 border-b border-zinc-800 px-6">
enterprise: "bg-amber-500", <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-teal-500 text-white font-bold">A</div>
}; <span className="text-lg font-semibold text-white">Translate Co.</span>
</div>
</aside>
);
}
return ( return (
<TooltipProvider> <TooltipProvider delayDuration={300}>
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r border-zinc-800 bg-[#1a1a1a]"> <aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r border-zinc-800 bg-[#1a1a1a]">
{/* Logo */} {/* Logo */}
<div className="flex h-16 items-center gap-3 border-b border-zinc-800 px-6"> <div className="flex h-16 items-center gap-3 border-b border-zinc-800 px-6">
@ -164,57 +207,51 @@ export function Sidebar() {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> {user.plan === "free" && (
<TooltipTrigger asChild> <Tooltip>
<Link
href="/pricing"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
pathname === "/pricing"
? "bg-amber-500/10 text-amber-400"
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
)}
>
<Crown className="h-5 w-5" />
<span>Upgrade Plan</span>
</Link>
</TooltipTrigger>
<TooltipContent side="right">
<p>View plans and pricing</p>
</TooltipContent>
</Tooltip>
</div>
)}
{/* Admin Section */}
<div className="mt-4 pt-4 border-t border-zinc-800">
<p className="px-3 mb-2 text-xs font-medium text-zinc-600 uppercase tracking-wider">Admin</p>
{adminNavigation.map((item) => {
const isActive = pathname === item.href;
const Icon = item.icon;
return (
<Tooltip key={item.name}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Link <Link
href={item.href} href="/pricing"
className={cn( className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors", "flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
isActive pathname === "/pricing"
? "bg-blue-500/10 text-blue-400" ? "bg-amber-500/10 text-amber-400"
: "text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300" : "text-amber-400/70 hover:bg-zinc-800 hover:text-amber-400"
)} )}
> >
<Icon className="h-5 w-5" /> <Crown className="h-5 w-5" />
<span>{item.name}</span> <span>Upgrade Plan</span>
</Link> </Link>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
<p>{item.description}</p> <p>Get more translations and features</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
); )}
})} </div>
)}
{/* Self-Host Option */}
<div className="mt-4 pt-4 border-t border-zinc-800">
<Tooltip>
<TooltipTrigger asChild>
<Link
href="/ollama-setup"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
pathname === "/ollama-setup"
? "bg-orange-500/10 text-orange-400"
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
)}
>
<Server className="h-5 w-5" />
<span>Self-Host (Free)</span>
</Link>
</TooltipTrigger>
<TooltipContent side="right">
<p>Run your own Ollama for unlimited free translations</p>
</TooltipContent>
</Tooltip>
</div> </div>
</nav> </nav>
@ -222,17 +259,17 @@ export function Sidebar() {
<div className="absolute bottom-0 left-0 right-0 border-t border-zinc-800 p-4"> <div className="absolute bottom-0 left-0 right-0 border-t border-zinc-800 p-4">
{user ? ( {user ? (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <Link href="/dashboard" className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-teal-600 text-white text-sm font-medium"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-teal-500 to-teal-600 text-white text-sm font-medium shrink-0">
{user.name.charAt(0).toUpperCase()} {user.name.charAt(0).toUpperCase()}
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col min-w-0">
<span className="text-sm font-medium text-white">{user.name}</span> <span className="text-sm font-medium text-white truncate">{user.name}</span>
<Badge className={cn("text-xs mt-0.5", planColors[user.plan] || "bg-zinc-600")}> <Badge className={cn("text-xs mt-0.5 w-fit", planColors[user.plan] || "bg-zinc-600")}>
{user.plan.charAt(0).toUpperCase() + user.plan.slice(1)} {user.plan.charAt(0).toUpperCase() + user.plan.slice(1)}
</Badge> </Badge>
</div> </div>
</div> </Link>
<button <button
onClick={handleLogout} onClick={handleLogout}
className="p-2 rounded-lg text-zinc-400 hover:text-white hover:bg-zinc-800" className="p-2 rounded-lg text-zinc-400 hover:text-white hover:bg-zinc-800"

View File

@ -21,6 +21,7 @@ import time
from config import config from config import config
from translators import excel_translator, word_translator, pptx_translator from translators import excel_translator, word_translator, pptx_translator
from utils import file_handler, handle_translation_error, DocumentProcessingError from utils import file_handler, handle_translation_error, DocumentProcessingError
from services.translation_service import _translation_cache
# Import auth routes # Import auth routes
from routes.auth_routes import router as auth_router from routes.auth_routes import router as auth_router
@ -228,7 +229,8 @@ async def health_check():
"rate_limits": { "rate_limits": {
"requests_per_minute": rate_limit_config.requests_per_minute, "requests_per_minute": rate_limit_config.requests_per_minute,
"translations_per_minute": rate_limit_config.translations_per_minute, "translations_per_minute": rate_limit_config.translations_per_minute,
} },
"translation_cache": _translation_cache.stats()
} }
) )

View File

@ -1,14 +1,87 @@
""" """
Translation Service Abstraction Translation Service Abstraction
Provides a unified interface for different translation providers Provides a unified interface for different translation providers
Optimized for high performance with parallel processing and caching
""" """
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Optional, List, Dict from typing import Optional, List, Dict, Tuple
import requests import requests
from deep_translator import GoogleTranslator, DeeplTranslator, LibreTranslator from deep_translator import GoogleTranslator, DeeplTranslator, LibreTranslator
from config import config from config import config
import concurrent.futures import concurrent.futures
import threading import threading
import asyncio
from functools import lru_cache
import time
import hashlib
from collections import OrderedDict
# Global thread pool for parallel translations
_executor = concurrent.futures.ThreadPoolExecutor(max_workers=8)
class TranslationCache:
"""Thread-safe LRU cache for translations to avoid redundant API calls"""
def __init__(self, maxsize: int = 5000):
self.cache: OrderedDict = OrderedDict()
self.maxsize = maxsize
self.lock = threading.RLock()
self.hits = 0
self.misses = 0
def _make_key(self, text: str, target_language: str, source_language: str, provider: str) -> str:
"""Create a unique cache key"""
content = f"{provider}:{source_language}:{target_language}:{text}"
return hashlib.md5(content.encode('utf-8')).hexdigest()
def get(self, text: str, target_language: str, source_language: str, provider: str) -> Optional[str]:
"""Get a cached translation if available"""
key = self._make_key(text, target_language, source_language, provider)
with self.lock:
if key in self.cache:
self.hits += 1
# Move to end (most recently used)
self.cache.move_to_end(key)
return self.cache[key]
self.misses += 1
return None
def set(self, text: str, target_language: str, source_language: str, provider: str, translation: str):
"""Cache a translation result"""
key = self._make_key(text, target_language, source_language, provider)
with self.lock:
if key in self.cache:
self.cache.move_to_end(key)
self.cache[key] = translation
# Remove oldest if exceeding maxsize
while len(self.cache) > self.maxsize:
self.cache.popitem(last=False)
def clear(self):
"""Clear the cache"""
with self.lock:
self.cache.clear()
self.hits = 0
self.misses = 0
def stats(self) -> Dict:
"""Get cache statistics"""
with self.lock:
total = self.hits + self.misses
hit_rate = (self.hits / total * 100) if total > 0 else 0
return {
"size": len(self.cache),
"maxsize": self.maxsize,
"hits": self.hits,
"misses": self.misses,
"hit_rate": f"{hit_rate:.1f}%"
}
# Global translation cache
_translation_cache = TranslationCache(maxsize=5000)
class TranslationProvider(ABC): class TranslationProvider(ABC):
@ -22,13 +95,44 @@ class TranslationProvider(ABC):
def translate_batch(self, texts: List[str], target_language: str, source_language: str = 'auto') -> List[str]: def translate_batch(self, texts: List[str], target_language: str, source_language: str = 'auto') -> List[str]:
"""Translate multiple texts at once - default implementation""" """Translate multiple texts at once - default implementation"""
return [self.translate(text, target_language, source_language) for text in texts] return [self.translate(text, target_language, source_language) for text in texts]
def translate_batch_parallel(self, texts: List[str], target_language: str, source_language: str = 'auto', max_workers: int = 4) -> List[str]:
"""Parallel batch translation using thread pool"""
if not texts:
return []
results = [''] * len(texts)
non_empty = [(i, t) for i, t in enumerate(texts) if t and t.strip()]
if not non_empty:
return [t if t else '' for t in texts]
def translate_one(item: Tuple[int, str]) -> Tuple[int, str]:
idx, text = item
try:
return (idx, self.translate(text, target_language, source_language))
except Exception as e:
print(f"Translation error at index {idx}: {e}")
return (idx, text)
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
for idx, translated in executor.map(translate_one, non_empty):
results[idx] = translated
# Fill empty positions
for i, text in enumerate(texts):
if not text or not text.strip():
results[i] = text if text else ''
return results
class GoogleTranslationProvider(TranslationProvider): class GoogleTranslationProvider(TranslationProvider):
"""Google Translate implementation with batch support""" """Google Translate implementation with batch support and caching"""
def __init__(self): def __init__(self):
self._local = threading.local() self._local = threading.local()
self.provider_name = "google"
def _get_translator(self, source_language: str, target_language: str) -> GoogleTranslator: def _get_translator(self, source_language: str, target_language: str) -> GoogleTranslator:
"""Get or create a translator instance for the current thread""" """Get or create a translator instance for the current thread"""
@ -43,9 +147,17 @@ class GoogleTranslationProvider(TranslationProvider):
if not text or not text.strip(): if not text or not text.strip():
return text return text
# Check cache first
cached = _translation_cache.get(text, target_language, source_language, self.provider_name)
if cached is not None:
return cached
try: try:
translator = self._get_translator(source_language, target_language) translator = self._get_translator(source_language, target_language)
return translator.translate(text) result = translator.translate(text)
# Cache the result
_translation_cache.set(text, target_language, source_language, self.provider_name, result)
return result
except Exception as e: except Exception as e:
print(f"Translation error: {e}") print(f"Translation error: {e}")
return text return text
@ -53,7 +165,7 @@ class GoogleTranslationProvider(TranslationProvider):
def translate_batch(self, texts: List[str], target_language: str, source_language: str = 'auto', batch_size: int = 50) -> List[str]: def translate_batch(self, texts: List[str], target_language: str, source_language: str = 'auto', batch_size: int = 50) -> List[str]:
""" """
Translate multiple texts using batch processing for speed. Translate multiple texts using batch processing for speed.
Uses deep_translator's batch capability when possible. Uses caching to avoid redundant translations.
""" """
if not texts: if not texts:
return [] return []
@ -62,15 +174,24 @@ class GoogleTranslationProvider(TranslationProvider):
results = [''] * len(texts) results = [''] * len(texts)
non_empty_indices = [] non_empty_indices = []
non_empty_texts = [] non_empty_texts = []
texts_to_translate = []
indices_to_translate = []
for i, text in enumerate(texts): for i, text in enumerate(texts):
if text and text.strip(): if text and text.strip():
non_empty_indices.append(i) # Check cache first
non_empty_texts.append(text) cached = _translation_cache.get(text, target_language, source_language, self.provider_name)
if cached is not None:
results[i] = cached
else:
non_empty_indices.append(i)
non_empty_texts.append(text)
texts_to_translate.append(text)
indices_to_translate.append(i)
else: else:
results[i] = text if text else '' results[i] = text if text else ''
if not non_empty_texts: if not texts_to_translate:
return results return results
try: try:
@ -78,8 +199,8 @@ class GoogleTranslationProvider(TranslationProvider):
# Process in batches # Process in batches
translated_texts = [] translated_texts = []
for i in range(0, len(non_empty_texts), batch_size): for i in range(0, len(texts_to_translate), batch_size):
batch = non_empty_texts[i:i + batch_size] batch = texts_to_translate[i:i + batch_size]
try: try:
# Use translate_batch if available # Use translate_batch if available
if hasattr(translator, 'translate_batch'): if hasattr(translator, 'translate_batch'):
@ -107,16 +228,19 @@ class GoogleTranslationProvider(TranslationProvider):
except: except:
translated_texts.append(text) translated_texts.append(text)
# Map back to original positions # Map back to original positions and cache results
for idx, translated in zip(non_empty_indices, translated_texts): for idx, (original, translated) in zip(indices_to_translate, zip(texts_to_translate, translated_texts)):
results[idx] = translated if translated else texts[idx] result = translated if translated else texts[idx]
results[idx] = result
# Cache successful translations
_translation_cache.set(texts[idx], target_language, source_language, self.provider_name, result)
return results return results
except Exception as e: except Exception as e:
print(f"Batch translation failed: {e}") print(f"Batch translation failed: {e}")
# Fallback to individual translations # Fallback to individual translations
for idx, text in zip(non_empty_indices, non_empty_texts): for idx, text in zip(indices_to_translate, texts_to_translate):
try: try:
results[idx] = GoogleTranslator(source=source_language, target=target_language).translate(text) or text results[idx] = GoogleTranslator(source=source_language, target=target_language).translate(text) or text
except: except: