feat(translate): multi-file queue with dedicated runner tests + context polish
Some checks failed
Deploy to Production / Build and Deploy (push) Has been cancelled

Batch upload: dropzone and inputs accept multiple files (cap 10/run,
validated + deduped); 2+ files switch the submit to a queue screen —
sequential jobs with per-file progress, failure messages through the
humanizer, per-file download/review links, staggered download-all,
real server-side stop (cancel API) plus between-files abort. The
one-file flow is untouched: files.length === 1 takes the exact
existing path.

Runner: standalone runTranslationJob (submit + poll + abort + cancel)
with injectable poll interval; 5 dedicated vitest cases covering
completed-with-progress, backend failure, submit HTTP error, mid-flight
abort with server cancel, and network-failure cutoff.

Context page close review: /dashboard/context is a clean redirect to
glossaries (no duplicate); the fake 300ms save delay removed (instant
zustand persist); suggestion chips now resolve their prompt bodies
through i18n (EN+FR — no more French prompts for English users); the
translate config column shows a 'Context guidelines active' chip that
links to the editor when a Pro LLM prompt is set.

Dead code: legacy file-uploader.tsx (+webllm.ts, its only consumer)
and the unused PRESETS/applyPreset/clearContext store block removed
(-14KB of glossary strings).

Verified: build exit 0, vitest 14/14 (9 prior + 5 runner), eslint 62
errors (vs 64 at HEAD), 0 missing i18n keys.
This commit is contained in:
2026-08-30 23:05:37 +02:00
parent 67365918ae
commit a96b3c099a
16 changed files with 647 additions and 779 deletions

View File

@@ -1,648 +0,0 @@
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import { useDropzone } from "react-dropzone";
import {
Upload,
FileText,
FileSpreadsheet,
Presentation,
X,
Download,
Loader2,
Cpu,
AlertTriangle,
Brain,
CheckCircle,
File,
Zap,
Shield,
Eye,
Trash2,
Copy,
ExternalLink,
ChevronRight
} from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import { useTranslationStore, openaiModels, openrouterModels } from "@/lib/store";
import { translateDocument, languages, providers, extractTextsFromDocument, reconstructDocument, TranslatedText } from "@/lib/api";
import { useWebLLM } from "@/lib/webllm";
import { useI18n } from "@/lib/i18n";
import { cn } from "@/lib/utils";
const fileIcons: Record<string, React.ElementType> = {
xlsx: FileSpreadsheet,
xls: FileSpreadsheet,
docx: FileText,
doc: FileText,
pptx: Presentation,
ppt: Presentation,
};
type ProviderType = "google" | "ollama" | "libre" | "webllm" | "openai" | "openrouter";
interface FilePreviewProps {
file: File;
onRemove: () => void;
}
const FilePreview = ({ file, onRemove }: FilePreviewProps) => {
const { t } = useI18n();
const [preview, setPreview] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
const generatePreview = async () => {
if (!file) return;
setLoading(true);
try {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
setPreview(e.target?.result as string);
};
reader.readAsDataURL(file);
} else if (file.type === 'application/pdf') {
setPreview('/pdf-preview.png'); // Placeholder
} else {
// Generate text preview for documents
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
setPreview(text.substring(0, 200) + (text.length > 200 ? '...' : ''));
};
reader.readAsText(file);
}
} catch (error) {
console.error('Preview generation failed:', error);
} finally {
setLoading(false);
}
};
generatePreview();
}, [file]);
const getFileExtension = (filename: string) => {
return filename.split(".").pop()?.toLowerCase() || "";
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
};
const FileIcon = fileIcons[getFileExtension(file.name)] || FileText;
return (
<Card variant="elevated" className="overflow-hidden group">
<CardContent className="p-0">
{/* File Header */}
<div className="flex items-center justify-between p-4 border-b border-border-subtle bg-surface/50">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center">
<FileIcon className="w-6 h-6 text-primary" />
</div>
<div>
<p className="font-medium text-foreground truncate max-w-xs">
{file.name}
</p>
<p className="text-sm text-text-tertiary">
{formatFileSize(file.size)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" size="sm">
{getFileExtension(file.name).toUpperCase()}
</Badge>
<Button
variant="ghost"
size="icon-sm"
onClick={onRemove}
className="text-text-tertiary hover:text-destructive"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* File Preview */}
<div className="relative h-48 bg-surface/30">
{loading ? (
<div className="absolute inset-0 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : preview ? (
<div className="p-4 h-full overflow-hidden">
{file.type.startsWith('image/') ? (
<img
src={preview}
alt="Preview"
className="w-full h-full object-contain rounded"
/>
) : (
<div className="text-sm text-text-secondary font-mono whitespace-pre-wrap break-all">
{preview}
</div>
)}
</div>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<File className="h-12 w-12 text-border" />
</div>
)}
</div>
{/* File Actions */}
<div className="flex items-center justify-between p-4 border-t border-border-subtle">
<div className="flex items-center gap-2 text-sm text-text-tertiary">
<Eye className="h-4 w-4" />
{t('fileUploader.preview')}
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon-sm">
<Copy className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon-sm">
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
);
};
export function FileUploader() {
const { t } = useI18n();
const { settings } = useTranslationStore();
const webllm = useWebLLM();
const [file, setFile] = useState<File | null>(null);
const [targetLanguage, setTargetLanguage] = useState(settings.defaultTargetLanguage);
const [provider, setProvider] = useState<ProviderType>(settings.defaultProvider as ProviderType);
const [translateImages, setTranslateImages] = useState(settings.translateImages);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [translationStatus, setTranslationStatus] = useState<string>("");
const [showAdvanced, setShowAdvanced] = useState(false);
const [isTranslating, setTranslating] = useState(false);
const [progress, setProgress] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
// Sync with store settings when they change
useEffect(() => {
setTargetLanguage(settings.defaultTargetLanguage);
setProvider(settings.defaultProvider as ProviderType);
setTranslateImages(settings.translateImages);
}, [settings.defaultTargetLanguage, settings.defaultProvider, settings.translateImages]);
const onDrop = useCallback((acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
setFile(acceptedFiles[0]);
setDownloadUrl(null);
setError(null);
}
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [".xlsx"],
"application/vnd.ms-excel": [".xls"],
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": [".docx"],
"application/msword": [".doc"],
"application/vnd.openxmlformats-officedocument.presentationml.presentation": [".pptx"],
"application/vnd.ms-powerpoint": [".ppt"],
},
multiple: false,
});
const handleTranslate = async () => {
if (!file) return;
// WebLLM specific validation
if (provider === "webllm") {
if (!webllm.isWebGPUSupported()) {
setError(t('fileUploader.webgpuUnsupported'));
return;
}
if (!webllm.isLoaded) {
setError(t('fileUploader.webllmNotLoaded'));
return;
}
}
setTranslating(true);
setProgress(0);
setError(null);
setDownloadUrl(null);
setTranslationStatus("");
try {
// For WebLLM, use client-side translation
if (provider === "webllm") {
await handleWebLLMTranslation();
} else {
await handleServerTranslation();
}
} catch (err) {
setError(err instanceof Error ? err.message : t('fileUploader.translationError'));
} finally {
setTranslating(false);
setTranslationStatus("");
}
};
// Get language name from code
const getLanguageName = (code: string): string => {
const lang = languages.find(l => l.code === code);
return lang ? lang.name : code;
};
// WebLLM client-side translation
const handleWebLLMTranslation = async () => {
if (!file) return;
try {
// Step 1: Extract texts from document
setTranslationStatus(t('fileUploader.extracting'));
setProgress(5);
const extractResult = await extractTextsFromDocument(file);
if (extractResult.texts.length === 0) {
throw new Error(t('fileUploader.noTranslatable'));
}
setTranslationStatus(t('fileUploader.foundTexts', { count: extractResult.texts.length }));
setProgress(10);
// Step 2: Translate each text using WebLLM
const translations: TranslatedText[] = [];
const totalTexts = extractResult.texts.length;
const langName = getLanguageName(targetLanguage);
for (let i = 0; i < totalTexts; i++) {
const item = extractResult.texts[i];
setTranslationStatus(t('fileUploader.translatingItem', {
current: String(i + 1),
total: String(totalTexts),
preview: item.text.substring(0, 30),
}));
const translatedText = await webllm.translate(
item.text,
langName,
settings.systemPrompt || undefined,
settings.glossary || undefined
);
translations.push({
id: item.id,
translated_text: translatedText,
});
// Update progress (10% for extraction, 80% for translation, 10% for reconstruction)
const translationProgress = 10 + (80 * (i + 1)) / totalTexts;
setProgress(translationProgress);
}
// Step 3: Reconstruct document with translations
setTranslationStatus(t('fileUploader.reconstructing'));
setProgress(92);
const blob = await reconstructDocument(
extractResult.session_id,
translations,
targetLanguage
);
setProgress(100);
setTranslationStatus(t('fileUploader.translationComplete'));
const url = URL.createObjectURL(blob);
setDownloadUrl(url);
} catch (err) {
throw err;
}
};
// Server-side translation (existing logic)
const handleServerTranslation = async () => {
if (!file) return;
// Simulate progress for UX
let currentProgress = 0;
const progressInterval = setInterval(() => {
currentProgress = Math.min(currentProgress + Math.random() * 10, 90);
setProgress(currentProgress);
}, 500);
try {
const blob = await translateDocument({
file,
targetLanguage,
provider,
ollamaModel: settings.ollamaModel,
translateImages: translateImages || settings.translateImages,
systemPrompt: settings.systemPrompt,
glossary: settings.glossary,
libreUrl: settings.libreTranslateUrl,
openaiApiKey: settings.openaiApiKey,
openaiModel: settings.openaiModel,
openrouterApiKey: settings.openrouterApiKey,
openrouterModel: settings.openrouterModel,
});
clearInterval(progressInterval);
setProgress(100);
const url = URL.createObjectURL(blob);
setDownloadUrl(url);
} catch (err) {
clearInterval(progressInterval);
throw err;
}
};
const handleDownload = () => {
if (!downloadUrl || !file) return;
const a = document.createElement("a");
a.href = downloadUrl;
const ext = getFileExtension(file.name);
const baseName = file.name.replace(`.${ext}`, "");
a.download = `${baseName}_translated.${ext}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const getFileExtension = (filename: string) => {
return filename.split(".").pop()?.toLowerCase() || "";
};
const removeFile = () => {
setFile(null);
setDownloadUrl(null);
setError(null);
setProgress(0);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const FileIcon = file ? fileIcons[getFileExtension(file.name)] : FileText;
return (
<div className="space-y-8">
{/* Enhanced File Drop Zone */}
<Card variant="elevated" className="overflow-hidden">
<CardHeader>
<CardTitle className="flex items-center gap-3">
<Upload className="h-5 w-5 text-primary" />
{t('fileUploader.uploadDocument')}
</CardTitle>
<CardDescription>
{t('fileUploader.uploadDesc')}
</CardDescription>
</CardHeader>
<CardContent className="p-6">
{!file ? (
<div
{...getRootProps()}
className={cn(
"relative border-2 border-dashed rounded-xl p-12 text-center cursor-pointer transition-all duration-300",
isDragActive
? "border-primary bg-primary/5 scale-[1.02]"
: "border-border-subtle hover:border-border hover:bg-surface/50"
)}
>
<input {...getInputProps()} ref={fileInputRef} className="hidden" />
{/* Upload Icon with animation */}
<div className={cn(
"w-16 h-16 mx-auto mb-4 rounded-2xl bg-primary/10 flex items-center justify-center transition-all duration-300",
isDragActive ? "scale-110 bg-primary/20" : ""
)}>
<Upload className={cn(
"w-8 h-8 text-primary transition-transform duration-300",
isDragActive ? "scale-110" : ""
)} />
</div>
<p className="text-lg font-medium text-foreground mb-2">
{isDragActive
? t('fileUploader.dropHere')
: t('fileUploader.dragAndDrop')}
</p>
<p className="text-sm text-text-tertiary mb-6">
{t('fileUploader.orClickBrowse')}
</p>
{/* Supported formats */}
<div className="flex flex-wrap justify-center gap-3">
{[
{ ext: "xlsx", name: "Excel", icon: FileSpreadsheet, color: "text-green-400" },
{ ext: "docx", name: "Word", icon: FileText, color: "text-blue-400" },
{ ext: "pptx", name: "PowerPoint", icon: Presentation, color: "text-orange-400" },
].map((format) => (
<div key={format.ext} className="flex items-center gap-2 px-3 py-2 rounded-lg bg-surface border border-border-subtle">
<format.icon className={cn("w-4 h-4", format.color)} />
<span className="text-sm text-text-secondary">{format.name}</span>
<span className="text-xs text-text-tertiary">.{format.ext}</span>
</div>
))}
</div>
</div>
) : (
<FilePreview file={file} onRemove={removeFile} />
)}
</CardContent>
</Card>
{/* Enhanced Translation Options */}
{file && (
<Card variant="elevated">
<CardHeader>
<CardTitle className="flex items-center gap-3">
<Brain className="h-5 w-5 text-primary" />
{t('fileUploader.translationOptions')}
</CardTitle>
<CardDescription>
{t('fileUploader.configureSettings')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Target Language */}
<div className="space-y-3">
<Label htmlFor="language" className="text-text-secondary font-medium">{t('fileUploader.targetLanguage')}</Label>
<Select value={targetLanguage} onValueChange={setTargetLanguage}>
<SelectTrigger id="language" className="bg-surface border-border-subtle">
<SelectValue placeholder={t('fileUploader.selectLanguage')} />
</SelectTrigger>
<SelectContent className="bg-surface-elevated border-border max-h-80">
{languages.map((lang) => (
<SelectItem
key={lang.code}
value={lang.code}
className="text-foreground hover:bg-surface hover:text-primary"
>
<span className="flex items-center gap-2">
<span>{lang.flag}</span>
<span>{lang.name}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Provider Selection */}
<div className="space-y-3">
<Label className="text-text-secondary font-medium">{t('fileUploader.translationProvider')}</Label>
<Select value={provider} onValueChange={(value: ProviderType) => setProvider(value)}>
<SelectTrigger className="bg-surface border-border-subtle">
<SelectValue placeholder={t('fileUploader.selectProvider')} />
</SelectTrigger>
<SelectContent className="bg-surface-elevated border-border">
{providers.map((p) => (
<SelectItem
key={p.id}
value={p.id}
className="text-foreground hover:bg-surface hover:text-primary"
>
<span className="flex items-center gap-2">
<span>{p.icon}</span>
<div className="flex flex-col">
<span className="font-medium">{p.name}</span>
<span className="text-xs text-text-tertiary">{p.description}</span>
</div>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Advanced Options Toggle */}
<Button
variant="ghost"
onClick={() => setShowAdvanced(!showAdvanced)}
className="w-full justify-between text-primary hover:text-primary/80"
>
<span>{t('fileUploader.advancedOptions')}</span>
<ChevronRight className={cn(
"h-4 w-4 transition-transform duration-200",
showAdvanced && "rotate-90"
)} />
</Button>
{/* Advanced Options */}
{showAdvanced && (
<div className="space-y-4 p-4 rounded-lg bg-surface/50 border border-border-subtle animate-slide-up">
<div className="flex items-center justify-between">
<Label htmlFor="translate-images" className="text-text-secondary">{t('fileUploader.translateImages')}</Label>
<Switch
id="translate-images"
checked={translateImages}
onCheckedChange={setTranslateImages}
/>
</div>
</div>
)}
{/* Translate Button */}
<Button
onClick={handleTranslate}
disabled={isTranslating}
variant="premium"
size="lg"
className="w-full h-12 text-lg group"
>
{isTranslating ? (
<>
<Loader2 className="me-2 h-5 w-5 animate-spin" />
{t('fileUploader.translating')}
</>
) : (
<>
<Zap className="me-2 h-5 w-5 transition-transform group-hover:scale-110" />
{t('fileUploader.translateDocument')}
</>
)}
</Button>
{/* Progress Bar */}
{isTranslating && (
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-text-secondary">
{translationStatus || t('fileUploader.processing')}
</span>
<span className="text-primary font-medium">{Math.round(progress)}%</span>
</div>
<Progress value={progress} className="h-2" />
{provider === "webllm" && (
<div className="flex items-center gap-2 text-xs text-text-tertiary p-3 rounded-lg bg-primary/5">
<Cpu className="h-3 w-3" />
{t('fileUploader.translatingLocally')}
</div>
)}
</div>
)}
{/* Error Display */}
{error && (
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4 animate-slide-up">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-destructive mb-1">{t('fileUploader.translationError')}</p>
<p className="text-sm text-destructive/80">{error}</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Enhanced Download Section */}
{downloadUrl && (
<Card variant="gradient" className="overflow-hidden animate-slide-up">
<CardContent className="p-8 text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/20 flex items-center justify-center animate-pulse">
<CheckCircle className="w-8 h-8 text-white" />
</div>
<CardTitle className="text-2xl mb-2">{t('fileUploader.translationComplete')}</CardTitle>
<CardDescription className="mb-6">
{t('fileUploader.translationCompleteDesc')}
</CardDescription>
<Button
onClick={handleDownload}
variant="glass"
size="lg"
className="group px-8"
>
<Download className="me-2 h-5 w-5 transition-transform group-hover:scale-110" />
{t('fileUploader.download')}
</Button>
</CardContent>
</Card>
)}
</div>
);
}