feat(translate): multi-file queue with dedicated runner tests + context polish
Some checks failed
Deploy to Production / Build and Deploy (push) Has been cancelled
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:
@@ -19,14 +19,14 @@ import { SUPPORTED_LANGUAGES } from './types';
|
|||||||
import { useTranslationStore } from '@/lib/store';
|
import { useTranslationStore } from '@/lib/store';
|
||||||
|
|
||||||
// ── Chips de suggestions pour les consignes de contexte ─────────────────────
|
// ── Chips de suggestions pour les consignes de contexte ─────────────────────
|
||||||
// Prompt bodies stay in French (LLM content); labels translate with the UI.
|
// Prompt bodies are LLM content — resolved through t() so they follow the UI locale.
|
||||||
const CONTEXT_SUGGESTIONS: { labelKey: string; value: string }[] = [
|
const CONTEXT_SUGGESTIONS: { labelKey: string; valueKey: string }[] = [
|
||||||
{ labelKey: 'glossaries.suggestion.formal', value: 'Utilise toujours un ton formel et professionnel dans tes traductions.' },
|
{ labelKey: 'glossaries.suggestion.formal', valueKey: 'glossaries.suggestion.formal.value' },
|
||||||
{ labelKey: 'glossaries.suggestion.proprietary', value: 'Ne traduis pas les noms propres, marques et noms de personnes.' },
|
{ labelKey: 'glossaries.suggestion.proprietary', valueKey: 'glossaries.suggestion.proprietary.value' },
|
||||||
{ labelKey: 'glossaries.suggestion.numbers', value: 'Garde tous les chiffres, pourcentages et montants tels quels sans les modifier.' },
|
{ labelKey: 'glossaries.suggestion.numbers', valueKey: 'glossaries.suggestion.numbers.value' },
|
||||||
{ labelKey: 'glossaries.suggestion.placeholders', value: 'Ne traduis pas les variables entre accolades comme {nom}, {date}, {montant}.' },
|
{ labelKey: 'glossaries.suggestion.placeholders', valueKey: 'glossaries.suggestion.placeholders.value' },
|
||||||
{ labelKey: 'glossaries.suggestion.technical', value: 'Conserve les termes techniques en langue originale et ne les traduis pas.' },
|
{ labelKey: 'glossaries.suggestion.technical', valueKey: 'glossaries.suggestion.technical.value' },
|
||||||
{ labelKey: 'glossaries.suggestion.concise', value: 'Préfère des formulations courtes et directes. Évite les périphrases.' },
|
{ labelKey: 'glossaries.suggestion.concise', valueKey: 'glossaries.suggestion.concise.value' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function GlossariesPage() {
|
export default function GlossariesPage() {
|
||||||
@@ -58,17 +58,13 @@ export default function GlossariesPage() {
|
|||||||
setPromptSaved(false);
|
setPromptSaved(false);
|
||||||
}, [settings.systemPrompt]);
|
}, [settings.systemPrompt]);
|
||||||
|
|
||||||
const handleSavePrompt = async () => {
|
const handleSavePrompt = () => {
|
||||||
setIsSavingPrompt(true);
|
setIsSavingPrompt(true);
|
||||||
try {
|
updateSettings({ systemPrompt });
|
||||||
updateSettings({ systemPrompt });
|
setPromptSaved(true);
|
||||||
await new Promise(resolve => setTimeout(resolve, 300));
|
|
||||||
setPromptSaved(true);
|
|
||||||
toast({ title: t('context.saved'), description: t('context.savedDesc') });
|
toast({ title: t('context.saved'), description: t('context.savedDesc') });
|
||||||
setTimeout(() => setPromptSaved(false), 3000);
|
setTimeout(() => setPromptSaved(false), 3000);
|
||||||
} finally {
|
setIsSavingPrompt(false);
|
||||||
setIsSavingPrompt(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearPrompt = () => {
|
const handleClearPrompt = () => {
|
||||||
@@ -76,7 +72,8 @@ export default function GlossariesPage() {
|
|||||||
setSystemPrompt('');
|
setSystemPrompt('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddSuggestion = (value: string) => {
|
const handleAddSuggestion = (valueKey: string) => {
|
||||||
|
const value = t(valueKey);
|
||||||
const current = systemPrompt.trim();
|
const current = systemPrompt.trim();
|
||||||
const newPrompt = current ? `${current}\n${value}` : value;
|
const newPrompt = current ? `${current}\n${value}` : value;
|
||||||
setSystemPrompt(newPrompt);
|
setSystemPrompt(newPrompt);
|
||||||
@@ -227,7 +224,7 @@ export default function GlossariesPage() {
|
|||||||
{CONTEXT_SUGGESTIONS.map((s) => (
|
{CONTEXT_SUGGESTIONS.map((s) => (
|
||||||
<button
|
<button
|
||||||
key={s.labelKey}
|
key={s.labelKey}
|
||||||
onClick={() => handleAddSuggestion(s.value)}
|
onClick={() => handleAddSuggestion(s.valueKey)}
|
||||||
className="px-3 py-1.5 rounded-lg text-[11px] font-semibold text-[#3D3D3D] dark:text-white/70 bg-[#F0EDE8] dark:bg-white/5 border border-[#D9D5CE] dark:border-white/10 hover:bg-[#E8E2DA] hover:border-[#C5A17A] dark:hover:bg-brand-accent/10 dark:hover:border-brand-accent/30 transition-all cursor-pointer"
|
className="px-3 py-1.5 rounded-lg text-[11px] font-semibold text-[#3D3D3D] dark:text-white/70 bg-[#F0EDE8] dark:bg-white/5 border border-[#D9D5CE] dark:border-white/10 hover:bg-[#E8E2DA] hover:border-[#C5A17A] dark:hover:bg-brand-accent/10 dark:hover:border-brand-accent/30 transition-all cursor-pointer"
|
||||||
>
|
>
|
||||||
+ {t(s.labelKey)}
|
+ {t(s.labelKey)}
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useFileUpload } from './useFileUpload';
|
import { useFileUpload } from './useFileUpload';
|
||||||
import { useTranslationConfig } from './useTranslationConfig';
|
import { useTranslationConfig } from './useTranslationConfig';
|
||||||
import { useTranslationSubmit, getRecentJobs, fetchServerHistory, type RecentJob } from './useTranslationSubmit';
|
import { useTranslationSubmit, getRecentJobs, fetchServerHistory, pushRecentJob, type RecentJob } from './useTranslationSubmit';
|
||||||
|
import { runTranslationJob } from './translationRunner';
|
||||||
|
import type { BatchItem } from './types';
|
||||||
import LanguageSelector from './LanguageSelector';
|
import LanguageSelector from './LanguageSelector';
|
||||||
import { ProviderSelector } from './ProviderSelector';
|
import { ProviderSelector } from './ProviderSelector';
|
||||||
import { GlossarySelector } from './GlossarySelector';
|
import { GlossarySelector } from './GlossarySelector';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { useNotification } from '@/components/ui/notification';
|
import { useNotification } from '@/components/ui/notification';
|
||||||
|
import { useTranslationStore } from '@/lib/store';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { API_BASE } from '@/lib/config';
|
import { API_BASE } from '@/lib/config';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -66,14 +69,18 @@ function SplitTitle({ base, accent }: { base: string; accent: string }) {
|
|||||||
/* ── Page ────────────────────────────────────────────────────────── */
|
/* ── Page ────────────────────────────────────────────────────────── */
|
||||||
export default function TranslatePage() {
|
export default function TranslatePage() {
|
||||||
const upload = useFileUpload();
|
const upload = useFileUpload();
|
||||||
const config = useTranslationConfig(!!upload.file);
|
const config = useTranslationConfig(upload.files.length > 0);
|
||||||
const submit = useTranslationSubmit();
|
const submit = useTranslationSubmit();
|
||||||
const { error: showError } = useNotification();
|
const { error: showError } = useNotification();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const systemPrompt = useTranslationStore((s) => s.settings.systemPrompt);
|
||||||
const lastErrorRef = useRef<string | null>(null);
|
const lastErrorRef = useRef<string | null>(null);
|
||||||
const replaceInputRef = useRef<HTMLInputElement>(null);
|
const replaceInputRef = useRef<HTMLInputElement>(null);
|
||||||
const dropzoneInputRef = useRef<HTMLInputElement>(null);
|
const dropzoneInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [pdfMode, setPdfMode] = useState<'layout' | 'text_only'>('layout');
|
const [pdfMode, setPdfMode] = useState<'layout' | 'text_only'>('layout');
|
||||||
|
const [batch, setBatch] = useState<{ items: BatchItem[]; running: boolean } | null>(null);
|
||||||
|
const batchAbortRef = useRef(false);
|
||||||
|
const batchJobRef = useRef<string | null>(null);
|
||||||
const [elapsed, setElapsed] = useState(0);
|
const [elapsed, setElapsed] = useState(0);
|
||||||
const [recentJobs, setRecentJobs] = useState<RecentJob[]>([]);
|
const [recentJobs, setRecentJobs] = useState<RecentJob[]>([]);
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
@@ -133,10 +140,83 @@ export default function TranslatePage() {
|
|||||||
}, [submit.status]);
|
}, [submit.status]);
|
||||||
|
|
||||||
const handleTranslate = async () => {
|
const handleTranslate = async () => {
|
||||||
if (!upload.file || !config.isConfigValid) return;
|
if (upload.files.length === 0 || !config.isConfigValid) return;
|
||||||
const cfg = config.getConfig();
|
const cfg = config.getConfig();
|
||||||
if (isPdf) cfg.pdfMode = pdfMode;
|
if (isPdf) cfg.pdfMode = pdfMode;
|
||||||
await submit.submitTranslation(upload.file, cfg);
|
if (upload.files.length === 1) {
|
||||||
|
await submit.submitTranslation(upload.files[0], cfg);
|
||||||
|
} else {
|
||||||
|
await handleBatch(cfg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateBatchItem = (index: number, patch: Partial<BatchItem>) => {
|
||||||
|
setBatch((b) => (b ? { ...b, items: b.items.map((it, i) => (i === index ? { ...it, ...patch } : it)) } : b));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatch = async (cfg: ReturnType<typeof config.getConfig>) => {
|
||||||
|
const files = upload.files;
|
||||||
|
batchAbortRef.current = false;
|
||||||
|
setBatch({
|
||||||
|
running: true,
|
||||||
|
items: files.map((f) => ({ name: f.name, size: f.size, status: 'pending', progress: 0 })),
|
||||||
|
});
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
if (batchAbortRef.current) {
|
||||||
|
updateBatchItem(i, { status: 'aborted' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
updateBatchItem(i, { status: 'processing', progress: 0 });
|
||||||
|
const result = await runTranslationJob(files[i], cfg, {
|
||||||
|
onJobId: (jobId) => { batchJobRef.current = jobId; },
|
||||||
|
onProgress: ({ progress }) => updateBatchItem(i, { progress }),
|
||||||
|
shouldAbort: () => batchAbortRef.current,
|
||||||
|
});
|
||||||
|
batchJobRef.current = null;
|
||||||
|
if (result.status === 'completed') {
|
||||||
|
updateBatchItem(i, { status: 'completed', jobId: result.jobId ?? undefined, progress: 100 });
|
||||||
|
pushRecentJob({ jobId: result.jobId ?? '', fileName: result.fileName, completedAt: Date.now() });
|
||||||
|
} else if (result.status === 'aborted') {
|
||||||
|
updateBatchItem(i, { status: 'aborted' });
|
||||||
|
} else {
|
||||||
|
updateBatchItem(i, { status: 'failed', error: result.error });
|
||||||
|
}
|
||||||
|
// small breather between jobs so the backend never sees a burst
|
||||||
|
if (i < files.length - 1) await new Promise((r) => setTimeout(r, 600));
|
||||||
|
}
|
||||||
|
setBatch((b) => (b ? { ...b, running: false } : b));
|
||||||
|
setRecentJobs(getRecentJobs());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchAbort = () => {
|
||||||
|
batchAbortRef.current = true;
|
||||||
|
const jobId = batchJobRef.current;
|
||||||
|
if (jobId) {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
fetch(`${API_BASE}/api/v1/translations/${jobId}/cancel`, { method: 'POST', headers }).catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchClose = () => {
|
||||||
|
setBatch(null);
|
||||||
|
upload.removeFile();
|
||||||
|
setElapsed(0);
|
||||||
|
setRecentJobs(getRecentJobs());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadAll = () => {
|
||||||
|
const items = batch?.items ?? [];
|
||||||
|
let delay = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.status === 'completed' && item.jobId) {
|
||||||
|
// stagger downloads so browsers don't block them as popups
|
||||||
|
const jobId = item.jobId;
|
||||||
|
setTimeout(() => { handleDownload(jobId); }, delay);
|
||||||
|
delay += 400;
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRetry = async () => {
|
const handleRetry = async () => {
|
||||||
@@ -157,7 +237,7 @@ export default function TranslatePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNewTranslation = () => { submit.reset(); upload.removeFile(); setElapsed(0); setRecentJobs(getRecentJobs()); };
|
const handleNewTranslation = () => { batchAbortRef.current = true; setBatch(null); submit.reset(); upload.removeFile(); setElapsed(0); setRecentJobs(getRecentJobs()); };
|
||||||
const handleDownload = async (jobId: string = submit.jobId ?? '') => {
|
const handleDownload = async (jobId: string = submit.jobId ?? '') => {
|
||||||
if (!jobId) return;
|
if (!jobId) return;
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
@@ -191,7 +271,7 @@ export default function TranslatePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/* ── Derived states ──────────────────────────────────────────── */
|
/* ── Derived states ──────────────────────────────────────────── */
|
||||||
const isConfiguring = !!upload.file && submit.status === 'idle' && !submit.isSubmitting;
|
const isConfiguring = upload.files.length > 0 && submit.status === 'idle' && !submit.isSubmitting && batch === null;
|
||||||
const isProcessing = (submit.status === 'processing' || submit.isSubmitting) && submit.status !== 'completed';
|
const isProcessing = (submit.status === 'processing' || submit.isSubmitting) && submit.status !== 'completed';
|
||||||
const isCompleted = submit.status === 'completed';
|
const isCompleted = submit.status === 'completed';
|
||||||
const isFailed = submit.status === 'failed';
|
const isFailed = submit.status === 'failed';
|
||||||
@@ -209,7 +289,8 @@ export default function TranslatePage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [upload.file, config.isConfigValid, submit.isSubmitting, isProcessing]);
|
}, [upload.file, config.isConfigValid, submit.isSubmitting, isProcessing]);
|
||||||
|
|
||||||
const showUpload = !upload.file && !isProcessing && !isCompleted && !isFailed;
|
const showBatch = batch !== null;
|
||||||
|
const showUpload = !upload.file && !isProcessing && !isCompleted && !isFailed && !showBatch;
|
||||||
const showConfiguring = isConfiguring;
|
const showConfiguring = isConfiguring;
|
||||||
const showProcessing = isProcessing;
|
const showProcessing = isProcessing;
|
||||||
const showComplete = isCompleted && !!submit.jobId;
|
const showComplete = isCompleted && !!submit.jobId;
|
||||||
@@ -325,6 +406,7 @@ export default function TranslatePage() {
|
|||||||
ref={dropzoneInputRef}
|
ref={dropzoneInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".xlsx,.docx,.pptx,.pdf"
|
accept=".xlsx,.docx,.pptx,.pdf"
|
||||||
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={upload.handleFileSelect}
|
onChange={upload.handleFileSelect}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -333,6 +415,112 @@ export default function TranslatePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── BATCH QUEUE: sequential multi-file translations ── */}
|
||||||
|
{showBatch && batch && (
|
||||||
|
<div className="editorial-card p-8 bg-white shadow-editorial dark:bg-[#141414] space-y-6">
|
||||||
|
<div className="flex items-center justify-between gap-4 border-b border-black/5 pb-4 dark:border-white/5">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-serif font-medium tracking-tight text-brand-dark dark:text-white">
|
||||||
|
{batch.running
|
||||||
|
? t('translate.batch.runningTitle')
|
||||||
|
: t('translate.batch.doneTitle', {
|
||||||
|
done: batch.items.filter((i) => i.status === 'completed').length,
|
||||||
|
total: batch.items.length,
|
||||||
|
})}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-[11px] font-semibold uppercase tracking-wider text-brand-dark/50 dark:text-white/50">
|
||||||
|
{srcLangName} → {tgtLangName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{batch.running ? (
|
||||||
|
<button
|
||||||
|
onClick={handleBatchAbort}
|
||||||
|
className="shrink-0 rounded-xl border border-red-200 px-4 py-2 text-[10px] font-bold uppercase tracking-wider text-red-500 transition-colors hover:bg-red-50 dark:border-red-900/40 dark:hover:bg-red-950/30"
|
||||||
|
>
|
||||||
|
{t('translate.batch.stop')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex shrink-0 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleDownloadAll}
|
||||||
|
disabled={!batch.items.some((i) => i.status === 'completed' && i.jobId)}
|
||||||
|
className="rounded-xl border border-brand-accent/30 px-4 py-2 text-[10px] font-bold uppercase tracking-wider text-brand-accent transition-colors hover:bg-brand-accent/10 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Download size={11} className="mr-1 inline" /> {t('translate.batch.downloadAll')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleBatchClose}
|
||||||
|
className="rounded-xl bg-brand-dark px-4 py-2 text-[10px] font-bold uppercase tracking-wider text-white transition-colors hover:bg-brand-accent dark:bg-brand-accent dark:text-brand-dark"
|
||||||
|
>
|
||||||
|
{t('translate.batch.newSession')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{batch.items.map((item, i) => (
|
||||||
|
<li
|
||||||
|
key={`${item.name}-${i}`}
|
||||||
|
className="flex items-center gap-3 rounded-2xl border border-black/5 bg-brand-muted/30 px-4 py-3 dark:border-white/5 dark:bg-white/5"
|
||||||
|
>
|
||||||
|
{item.status === 'processing' ? (
|
||||||
|
<Loader2 className="size-4 shrink-0 animate-spin text-brand-accent" />
|
||||||
|
) : item.status === 'completed' ? (
|
||||||
|
<CheckCircle2 className="size-4 shrink-0 text-emerald-500" />
|
||||||
|
) : item.status === 'failed' ? (
|
||||||
|
<AlertTriangle className="size-4 shrink-0 text-red-500" />
|
||||||
|
) : item.status === 'aborted' ? (
|
||||||
|
<X className="size-4 shrink-0 text-brand-dark/40 dark:text-white/40" />
|
||||||
|
) : (
|
||||||
|
<span className="size-4 shrink-0 rounded-full border-2 border-brand-dark/20 dark:border-white/20" />
|
||||||
|
)}
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="truncate text-xs font-bold text-brand-dark dark:text-white">{item.name}</span>
|
||||||
|
{item.status === 'processing' && (
|
||||||
|
<span className="mt-1 h-1 w-full overflow-hidden rounded-full bg-brand-dark/10 dark:bg-white/10">
|
||||||
|
<span
|
||||||
|
className="block h-full rounded-full bg-brand-accent transition-all"
|
||||||
|
style={{ width: `${item.progress}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.status === 'failed' && item.error && (
|
||||||
|
<span className="truncate text-[10px] text-red-500">{humanFriendlyError(item.error)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{item.status === 'completed' && item.jobId ? (
|
||||||
|
<span className="flex shrink-0 items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDownload(item.jobId!)}
|
||||||
|
aria-label={`${t('translate.download')} — ${item.name}`}
|
||||||
|
className="rounded-lg border border-black/10 px-2.5 py-1.5 text-[10px] font-bold uppercase tracking-wider text-brand-dark/60 dark:border-white/10 dark:text-white/60"
|
||||||
|
>
|
||||||
|
<Download size={11} />
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href={`/dashboard/reviews/${item.jobId}`}
|
||||||
|
className="rounded-lg border border-brand-accent/25 px-2.5 py-1.5 text-[10px] font-bold uppercase tracking-wider text-brand-accent"
|
||||||
|
>
|
||||||
|
{t('translate.recent.review')}
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="w-10 shrink-0 text-right text-[10px] font-bold uppercase tracking-wider text-brand-dark/50 dark:text-white/50">
|
||||||
|
{item.status === 'processing'
|
||||||
|
? `${Math.round(item.progress)}%`
|
||||||
|
: item.status === 'completed'
|
||||||
|
? '100%'
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── RECENT JOBS: client-side history with review access ── */}
|
{/* ── RECENT JOBS: client-side history with review access ── */}
|
||||||
{showUpload && recentJobs.length > 0 && (
|
{showUpload && recentJobs.length > 0 && (
|
||||||
<div className="editorial-card p-6 bg-white dark:bg-[#141414] border-none shadow-editorial">
|
<div className="editorial-card p-6 bg-white dark:bg-[#141414] border-none shadow-editorial">
|
||||||
@@ -374,8 +562,33 @@ export default function TranslatePage() {
|
|||||||
{t('landing.translate.sourceDocument') || 'Document Source'}
|
{t('landing.translate.sourceDocument') || 'Document Source'}
|
||||||
</h4>
|
</h4>
|
||||||
<FileStrip file={upload.file!} onRemove={upload.removeFile} onReplace={() => replaceInputRef.current?.click()} t={t} />
|
<FileStrip file={upload.file!} onRemove={upload.removeFile} onReplace={() => replaceInputRef.current?.click()} t={t} />
|
||||||
<input ref={replaceInputRef} type="file" accept=".xlsx,.docx,.pptx,.pdf" className="hidden" onChange={upload.handleFileSelect} />
|
<input ref={replaceInputRef} type="file" accept=".xlsx,.docx,.pptx,.pdf" multiple className="hidden" onChange={upload.handleFileSelect} />
|
||||||
{upload.error && <p className="mt-2 text-xs text-destructive">{t(upload.error)}</p>}
|
{upload.error && <p className="mt-2 text-xs text-destructive">{t(upload.error)}</p>}
|
||||||
|
{upload.files.length > 1 && (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{upload.files.map((f, i) => (
|
||||||
|
<li key={`${f.name}-${f.lastModified}-${i}`} className="flex items-center gap-3 rounded-2xl border border-black/5 bg-brand-muted/30 px-4 py-2.5 dark:border-white/5 dark:bg-white/5">
|
||||||
|
{(() => {
|
||||||
|
const ext = f.name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
const FileIcon = FILE_ICONS[ext] ?? FileText;
|
||||||
|
return <FileIcon className={`size-4 shrink-0 ${FILE_COLORS[ext] ?? ''}`} />;
|
||||||
|
})()}
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="truncate text-[11px] font-bold text-brand-dark dark:text-white">{f.name}</span>
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-brand-dark/50 dark:text-white/50">{fmt(f.size)}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => upload.removeAt(i)}
|
||||||
|
aria-label={`${t('dashboard.translate.replace')} — ${f.name}`}
|
||||||
|
className="flex size-6 shrink-0 items-center justify-center rounded-lg text-brand-dark/40 transition hover:bg-brand-muted hover:text-brand-dark dark:text-white/40 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -383,26 +596,28 @@ export default function TranslatePage() {
|
|||||||
{(showUpload || showConfiguring) && (
|
{(showUpload || showConfiguring) && (
|
||||||
<div className="hidden lg:block editorial-card p-6 bg-white dark:bg-[#141414] border-none shadow-editorial space-y-4">
|
<div className="hidden lg:block editorial-card p-6 bg-white dark:bg-[#141414] border-none shadow-editorial space-y-4">
|
||||||
<button
|
<button
|
||||||
disabled={!config.isConfigValid || submit.isSubmitting || !upload.file}
|
disabled={!config.isConfigValid || submit.isSubmitting || upload.files.length === 0}
|
||||||
onClick={handleTranslate}
|
onClick={handleTranslate}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full py-4 text-xs font-bold uppercase tracking-[0.18em] flex items-center justify-center gap-2 rounded-2xl transition-all shadow-sm active:scale-98',
|
'w-full py-4 text-xs font-bold uppercase tracking-[0.18em] flex items-center justify-center gap-2 rounded-2xl transition-all shadow-sm active:scale-98',
|
||||||
config.isConfigValid && upload.file && !submit.isSubmitting
|
config.isConfigValid && upload.files.length > 0 && !submit.isSubmitting
|
||||||
? 'bg-brand-dark text-white hover:bg-brand-accent dark:bg-brand-accent dark:text-brand-dark hover:shadow-xl cursor-pointer'
|
? 'bg-brand-dark text-white hover:bg-brand-accent dark:bg-brand-accent dark:text-brand-dark hover:shadow-xl cursor-pointer'
|
||||||
: 'bg-brand-muted/70 text-brand-dark/40 dark:bg-white/5 dark:text-white/30 cursor-not-allowed border border-black/[0.03] dark:border-white/[0.03]'
|
: 'bg-brand-muted/70 text-brand-dark/40 dark:bg-white/5 dark:text-white/30 cursor-not-allowed border border-black/[0.03] dark:border-white/[0.03]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{submit.isSubmitting ? (
|
{submit.isSubmitting ? (
|
||||||
<><Loader2 className="size-4 animate-spin" /> {t('translate.submit')}</>
|
<><Loader2 className="size-4 animate-spin" /> {t('translate.submit')}</>
|
||||||
|
) : upload.files.length > 1 ? (
|
||||||
|
<>{t('translate.startBatch', { count: upload.files.length })} <ArrowRight size={13} className="text-brand-accent" /></>
|
||||||
) : (
|
) : (
|
||||||
<>{t('translate.startTranslation')} <ArrowRight size={13} className={upload.file ? 'text-brand-accent' : 'opacity-20'} /></>
|
<>{t('translate.startTranslation')} <ArrowRight size={13} className={upload.files.length > 0 ? 'text-brand-accent' : 'opacity-20'} /></>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!upload.file && (
|
{upload.files.length === 0 && (
|
||||||
<p className="text-center text-[10px] text-brand-dark/50 dark:text-white/50 font-semibold uppercase tracking-wider">{t('translate.pleaseLoadFile')}</p>
|
<p className="text-center text-[10px] text-brand-dark/50 dark:text-white/50 font-semibold uppercase tracking-wider">{t('translate.pleaseLoadFile')}</p>
|
||||||
)}
|
)}
|
||||||
{upload.file && !config.targetLang && (
|
{upload.files.length > 0 && !config.targetLang && (
|
||||||
<p className="text-center text-[10px] text-brand-dark/50 dark:text-white/50 font-semibold uppercase tracking-wider">{t('translate.chooseTargetLang')}</p>
|
<p className="text-center text-[10px] text-brand-dark/50 dark:text-white/50 font-semibold uppercase tracking-wider">{t('translate.chooseTargetLang')}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -567,7 +782,7 @@ export default function TranslatePage() {
|
|||||||
<div className="lg:col-span-5 space-y-6">
|
<div className="lg:col-span-5 space-y-6">
|
||||||
|
|
||||||
{/* ── CONFIG (upload / configuring / failed) ──────────── */}
|
{/* ── CONFIG (upload / configuring / failed) ──────────── */}
|
||||||
{(showUpload || showConfiguring || showFailed) && (
|
{(showUpload || showConfiguring || showFailed) && !showBatch && (
|
||||||
<div className="editorial-card bg-white dark:bg-[#141414] border-none shadow-editorial overflow-hidden flex flex-col lg:sticky lg:top-8 lg:max-h-[calc(100vh-6rem)]">
|
<div className="editorial-card bg-white dark:bg-[#141414] border-none shadow-editorial overflow-hidden flex flex-col lg:sticky lg:top-8 lg:max-h-[calc(100vh-6rem)]">
|
||||||
{/* Scrollable config content */}
|
{/* Scrollable config content */}
|
||||||
<div className="flex-1 overflow-y-auto p-6 space-y-5">
|
<div className="flex-1 overflow-y-auto p-6 space-y-5">
|
||||||
@@ -612,6 +827,17 @@ export default function TranslatePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Context guidelines indicator — ties the glossaries tab to this flow */}
|
||||||
|
{config.mode === 'llm' && !!systemPrompt?.trim() && (
|
||||||
|
<Link
|
||||||
|
href="/dashboard/glossaries"
|
||||||
|
className="flex items-center gap-2 rounded-xl border border-brand-accent/25 bg-brand-accent/5 px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-brand-accent transition-colors hover:bg-brand-accent/10"
|
||||||
|
>
|
||||||
|
<FileText className="size-3.5 shrink-0" />
|
||||||
|
{t('translate.contextActive')}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Glossary selector — Pro only; hidden entirely for free users */}
|
{/* Glossary selector — Pro only; hidden entirely for free users */}
|
||||||
{config.isPro && (
|
{config.isPro && (
|
||||||
<GlossarySelector
|
<GlossarySelector
|
||||||
@@ -816,14 +1042,14 @@ export default function TranslatePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Sticky Action Bar (visible on mobile, hidden on lg) */}
|
{/* Mobile Sticky Action Bar (visible on mobile, hidden on lg) */}
|
||||||
{(showUpload || showConfiguring) && (
|
{(showUpload || showConfiguring) && !showBatch && (
|
||||||
<div className="block lg:hidden fixed bottom-0 left-0 right-0 z-40 p-4 bg-white/90 dark:bg-[#141414]/90 backdrop-blur-md border-t border-black/5 dark:border-white/5 shadow-lg">
|
<div className="block lg:hidden fixed bottom-0 left-0 right-0 z-40 p-4 bg-white/90 dark:bg-[#141414]/90 backdrop-blur-md border-t border-black/5 dark:border-white/5 shadow-lg">
|
||||||
<button
|
<button
|
||||||
disabled={!config.isConfigValid || submit.isSubmitting || !upload.file}
|
disabled={!config.isConfigValid || submit.isSubmitting || upload.files.length === 0}
|
||||||
onClick={handleTranslate}
|
onClick={handleTranslate}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full py-4 text-xs font-bold uppercase tracking-[0.15em] flex items-center justify-center gap-2 rounded-xl transition-all active:scale-98',
|
'w-full py-4 text-xs font-bold uppercase tracking-[0.15em] flex items-center justify-center gap-2 rounded-xl transition-all active:scale-98',
|
||||||
config.isConfigValid && upload.file && !submit.isSubmitting
|
config.isConfigValid && upload.files.length > 0 && !submit.isSubmitting
|
||||||
? 'bg-brand-dark text-white hover:bg-brand-accent dark:bg-brand-accent dark:text-brand-dark cursor-pointer'
|
? 'bg-brand-dark text-white hover:bg-brand-accent dark:bg-brand-accent dark:text-brand-dark cursor-pointer'
|
||||||
: 'bg-brand-muted/70 text-brand-dark/40 dark:bg-white/5 dark:text-white/30 cursor-not-allowed'
|
: 'bg-brand-muted/70 text-brand-dark/40 dark:bg-white/5 dark:text-white/30 cursor-not-allowed'
|
||||||
)}
|
)}
|
||||||
|
|||||||
147
frontend/src/app/dashboard/translate/translationRunner.ts
Normal file
147
frontend/src/app/dashboard/translate/translationRunner.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* Standalone single-job runner used by the multi-file queue.
|
||||||
|
* Pure async — no React state — so it is easy to sequence and to unit-test.
|
||||||
|
*/
|
||||||
|
import { API_BASE } from '@/lib/config';
|
||||||
|
import type { TranslationConfig } from './types';
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 2000;
|
||||||
|
const MAX_POLL_FAILURES = 5;
|
||||||
|
|
||||||
|
export interface RunnerProgress {
|
||||||
|
jobId: string;
|
||||||
|
status: string;
|
||||||
|
progress: number;
|
||||||
|
step: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunnerResult {
|
||||||
|
jobId: string | null;
|
||||||
|
status: 'completed' | 'failed' | 'aborted';
|
||||||
|
error?: string;
|
||||||
|
fileName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunnerOptions {
|
||||||
|
onProgress?: (info: RunnerProgress) => void;
|
||||||
|
/** Checked between polls and before submit — the queue sets it on abort. */
|
||||||
|
shouldAbort?: () => boolean;
|
||||||
|
onJobId?: (jobId: string) => void;
|
||||||
|
/** Poll interval override — tests run it at 10ms. */
|
||||||
|
pollIntervalMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeaders(): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runTranslationJob(
|
||||||
|
file: File,
|
||||||
|
config: TranslationConfig,
|
||||||
|
opts: RunnerOptions = {},
|
||||||
|
): Promise<RunnerResult> {
|
||||||
|
const { onProgress, shouldAbort, onJobId, pollIntervalMs = POLL_INTERVAL_MS } = opts;
|
||||||
|
|
||||||
|
if (shouldAbort?.()) {
|
||||||
|
return { jobId: null, status: 'aborted', fileName: file.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('source_lang', config.sourceLang);
|
||||||
|
formData.append('target_lang', config.targetLang);
|
||||||
|
formData.append('mode', config.mode);
|
||||||
|
if (config.mode === 'llm' && config.provider) {
|
||||||
|
formData.append('provider', config.provider);
|
||||||
|
}
|
||||||
|
if (config.pdfMode) formData.append('pdf_mode', config.pdfMode);
|
||||||
|
if (config.glossaryId) formData.append('glossary_id', config.glossaryId);
|
||||||
|
if (config.translateImages !== undefined) {
|
||||||
|
formData.append('translate_images', String(config.translateImages));
|
||||||
|
}
|
||||||
|
const { useTranslationStore } = await import('@/lib/store');
|
||||||
|
const { settings } = useTranslationStore.getState();
|
||||||
|
if (settings.systemPrompt?.trim()) {
|
||||||
|
formData.append('custom_prompt', settings.systemPrompt.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit
|
||||||
|
let jobId: string | null = null;
|
||||||
|
let fileName = file.name;
|
||||||
|
const submitRes = await fetch(`${API_BASE}/api/v1/translate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(),
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!submitRes.ok) {
|
||||||
|
let message = `HTTP ${submitRes.status}`;
|
||||||
|
try {
|
||||||
|
const err = await submitRes.json();
|
||||||
|
message = err.message || err.error || message;
|
||||||
|
} catch { /* not JSON */ }
|
||||||
|
return { jobId: null, status: 'failed', error: message, fileName };
|
||||||
|
}
|
||||||
|
const submitData = await submitRes.json();
|
||||||
|
jobId = submitData?.data?.id ?? null;
|
||||||
|
fileName = submitData?.data?.file_name || file.name;
|
||||||
|
if (!jobId) {
|
||||||
|
return { jobId: null, status: 'failed', error: 'No job id returned', fileName };
|
||||||
|
}
|
||||||
|
onJobId?.(jobId);
|
||||||
|
|
||||||
|
// Poll until terminal
|
||||||
|
let failures = 0;
|
||||||
|
for (;;) {
|
||||||
|
if (shouldAbort?.()) {
|
||||||
|
// Best effort: tell the server to stop, then report locally as aborted.
|
||||||
|
fetch(`${API_BASE}/api/v1/translations/${jobId}/cancel`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(),
|
||||||
|
}).catch(() => { /* ignore */ });
|
||||||
|
return { jobId, status: 'aborted', fileName };
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${API_BASE}/api/v1/translations/${jobId}`, { headers: authHeaders() });
|
||||||
|
} catch {
|
||||||
|
failures += 1;
|
||||||
|
if (failures >= MAX_POLL_FAILURES) {
|
||||||
|
return { jobId, status: 'failed', error: 'Lost connection to translation service.', fileName };
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 429) continue; // rate-limited poll — not a failure
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 404) {
|
||||||
|
return { jobId, status: 'failed', error: 'Translation job not found.', fileName };
|
||||||
|
}
|
||||||
|
failures += 1;
|
||||||
|
if (failures >= MAX_POLL_FAILURES) {
|
||||||
|
return { jobId, status: 'failed', error: `HTTP ${res.status}`, fileName };
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const job = data?.data;
|
||||||
|
const status = job?.status ?? 'processing';
|
||||||
|
const progress = job?.progress_percent ?? 0;
|
||||||
|
onProgress?.({ jobId: jobId!, status, progress, step: job?.current_step ?? '' });
|
||||||
|
failures = 0;
|
||||||
|
|
||||||
|
if (job?.file_name) fileName = job.file_name;
|
||||||
|
if (status === 'completed') {
|
||||||
|
return { jobId, status: 'completed', fileName };
|
||||||
|
}
|
||||||
|
if (status === 'failed' || status === 'cancelled') {
|
||||||
|
return { jobId, status: 'failed', error: job?.error_message ?? 'Translation failed', fileName };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
export type SupportedFormat = 'xlsx' | 'docx' | 'pptx';
|
export type SupportedFormat = 'xlsx' | 'docx' | 'pptx';
|
||||||
|
|
||||||
export interface FileUploadState {
|
export interface FileUploadState {
|
||||||
|
/** First file — drives the single-file flow. */
|
||||||
file: File | null;
|
file: File | null;
|
||||||
|
/** All queued files (1..MAX_BATCH_FILES) — drives the batch flow. */
|
||||||
|
files: File[];
|
||||||
/** i18n key (from ERROR_MESSAGES) — render through t() */
|
/** i18n key (from ERROR_MESSAGES) — render through t() */
|
||||||
error: string | null;
|
error: string | null;
|
||||||
isDragOver: boolean;
|
isDragOver: boolean;
|
||||||
@@ -12,9 +15,21 @@ export interface FileUploadActions {
|
|||||||
handleDragOver: (e: React.DragEvent) => void;
|
handleDragOver: (e: React.DragEvent) => void;
|
||||||
handleDragLeave: (e: React.DragEvent) => void;
|
handleDragLeave: (e: React.DragEvent) => void;
|
||||||
handleFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
handleFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
addFiles: (incoming: Iterable<File>) => void;
|
||||||
|
removeAt: (index: number) => void;
|
||||||
removeFile: () => void;
|
removeFile: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One file inside a running batch queue. */
|
||||||
|
export interface BatchItem {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
status: 'pending' | 'processing' | 'completed' | 'failed' | 'aborted';
|
||||||
|
jobId?: string;
|
||||||
|
progress: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UseFileUploadReturn extends FileUploadState, FileUploadActions {}
|
export interface UseFileUploadReturn extends FileUploadState, FileUploadActions {}
|
||||||
|
|
||||||
export type TranslationMode = 'classic' | 'llm';
|
export type TranslationMode = 'classic' | 'llm';
|
||||||
|
|||||||
@@ -3,48 +3,68 @@ import type { UseFileUploadReturn } from './types';
|
|||||||
|
|
||||||
const ACCEPTED_EXTENSIONS = ['xlsx', 'docx', 'pptx', 'pdf'];
|
const ACCEPTED_EXTENSIONS = ['xlsx', 'docx', 'pptx', 'pdf'];
|
||||||
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||||||
|
/** Batch cap — keeps a drag-and-dropped folder from turning into a runaway queue. */
|
||||||
|
export const MAX_BATCH_FILES = 10;
|
||||||
|
|
||||||
// i18n keys — rendered through t() so the message follows the UI locale.
|
// i18n keys — rendered through t() so the message follows the UI locale.
|
||||||
export const ERROR_MESSAGES = {
|
export const ERROR_MESSAGES = {
|
||||||
INVALID_FORMAT: 'fileUploader.error.invalidFormat',
|
INVALID_FORMAT: 'fileUploader.error.invalidFormat',
|
||||||
FILE_TOO_LARGE: 'fileUploader.error.tooLarge',
|
FILE_TOO_LARGE: 'fileUploader.error.tooLarge',
|
||||||
|
TOO_MANY: 'fileUploader.error.tooMany',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function useFileUpload(): UseFileUploadReturn {
|
export function useFileUpload(): UseFileUploadReturn {
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|
||||||
const validateFile = useCallback((file: File): string | null => {
|
const validateFile = useCallback((file: File): string | null => {
|
||||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||||
|
|
||||||
if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) {
|
if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) {
|
||||||
return ERROR_MESSAGES.INVALID_FORMAT;
|
return ERROR_MESSAGES.INVALID_FORMAT;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
return ERROR_MESSAGES.FILE_TOO_LARGE;
|
return ERROR_MESSAGES.FILE_TOO_LARGE;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/** Append files, validating each; keeps the first invalid-file message. */
|
||||||
|
const addFiles = useCallback((incoming: Iterable<File>) => {
|
||||||
|
setError(null);
|
||||||
|
setFiles((current) => {
|
||||||
|
const next = [...current];
|
||||||
|
let firstError: string | null = null;
|
||||||
|
for (const file of incoming) {
|
||||||
|
const validationError = validateFile(file);
|
||||||
|
if (validationError) {
|
||||||
|
firstError ??= validationError;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const duplicate = next.some(
|
||||||
|
(f) => f.name === file.name && f.size === file.size && f.lastModified === file.lastModified
|
||||||
|
);
|
||||||
|
if (!duplicate) next.push(file);
|
||||||
|
}
|
||||||
|
if (next.length > MAX_BATCH_FILES) {
|
||||||
|
firstError ??= ERROR_MESSAGES.TOO_MANY;
|
||||||
|
next.length = MAX_BATCH_FILES;
|
||||||
|
}
|
||||||
|
if (firstError) setError(firstError);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [validateFile]);
|
||||||
|
|
||||||
|
const removeAt = useCallback((index: number) => {
|
||||||
|
setFiles((current) => current.filter((_, i) => i !== index));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDragOver(false);
|
setIsDragOver(false);
|
||||||
|
const dropped = Array.from(e.dataTransfer.files ?? []);
|
||||||
const droppedFile = e.dataTransfer.files[0];
|
if (dropped.length > 0) addFiles(dropped);
|
||||||
if (droppedFile) {
|
}, [addFiles]);
|
||||||
const validationError = validateFile(droppedFile);
|
|
||||||
if (validationError) {
|
|
||||||
setError(validationError);
|
|
||||||
setFile(null);
|
|
||||||
} else {
|
|
||||||
setFile(droppedFile);
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [validateFile]);
|
|
||||||
|
|
||||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -57,33 +77,32 @@ export function useFileUpload(): UseFileUploadReturn {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const selected = e.target.files?.[0];
|
const selected = Array.from(e.target.files ?? []);
|
||||||
if (selected) {
|
if (selected.length > 0) addFiles(selected);
|
||||||
const validationError = validateFile(selected);
|
// allow re-selecting the same file after removal
|
||||||
if (validationError) {
|
e.target.value = '';
|
||||||
setError(validationError);
|
}, [addFiles]);
|
||||||
setFile(null);
|
|
||||||
} else {
|
|
||||||
setFile(selected);
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [validateFile]);
|
|
||||||
|
|
||||||
const removeFile = useCallback(() => {
|
const removeFile = useCallback(() => {
|
||||||
setFile(null);
|
setFiles([]);
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsDragOver(false);
|
setIsDragOver(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Single-file compatibility: the first file drives the existing one-file flow.
|
||||||
|
const file = files[0] ?? null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
file,
|
file,
|
||||||
|
files,
|
||||||
error,
|
error,
|
||||||
isDragOver,
|
isDragOver,
|
||||||
handleDrop,
|
handleDrop,
|
||||||
handleDragOver,
|
handleDragOver,
|
||||||
handleDragLeave,
|
handleDragLeave,
|
||||||
handleFileSelect,
|
handleFileSelect,
|
||||||
|
addFiles,
|
||||||
|
removeAt,
|
||||||
removeFile,
|
removeFile,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ function persistActiveJob(job: StoredJob | null) {
|
|||||||
} catch { /* storage unavailable — session-only fallback */ }
|
} catch { /* storage unavailable — session-only fallback */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushRecentJob(job: RecentJob) {
|
export function pushRecentJob(job: RecentJob) {
|
||||||
try {
|
try {
|
||||||
const list = getRecentJobs().filter(j => j.jobId !== job.jobId);
|
const list = getRecentJobs().filter(j => j.jobId !== job.jobId);
|
||||||
list.unshift(job);
|
list.unshift(job);
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -29,5 +29,6 @@
|
|||||||
"fileUploader.reconstructing": "Reconstructing document...",
|
"fileUploader.reconstructing": "Reconstructing document...",
|
||||||
"fileUploader.translatingLocally": "Translating locally with WebLLM...",
|
"fileUploader.translatingLocally": "Translating locally with WebLLM...",
|
||||||
"fileUploader.error.invalidFormat": "Unsupported format. Accepted: .xlsx, .docx, .pptx, .pdf",
|
"fileUploader.error.invalidFormat": "Unsupported format. Accepted: .xlsx, .docx, .pptx, .pdf",
|
||||||
"fileUploader.error.tooLarge": "File too large (max 50 MB)"
|
"fileUploader.error.tooLarge": "File too large (max 50 MB)",
|
||||||
|
"fileUploader.error.tooMany": "Queue limited to 10 documents per run."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,5 +226,11 @@
|
|||||||
"glossaries.suggestion.numbers": "Numbers",
|
"glossaries.suggestion.numbers": "Numbers",
|
||||||
"glossaries.suggestion.placeholders": "Placeholders",
|
"glossaries.suggestion.placeholders": "Placeholders",
|
||||||
"glossaries.suggestion.technical": "Technical terms",
|
"glossaries.suggestion.technical": "Technical terms",
|
||||||
"glossaries.suggestion.concise": "Concise style"
|
"glossaries.suggestion.concise": "Concise style",
|
||||||
|
"glossaries.suggestion.formal.value": "Always use a formal, professional tone in your translations.",
|
||||||
|
"glossaries.suggestion.proprietary.value": "Do not translate proper nouns, brand names, or people's names.",
|
||||||
|
"glossaries.suggestion.numbers.value": "Keep all figures, percentages, and amounts exactly as written.",
|
||||||
|
"glossaries.suggestion.placeholders.value": "Do not translate variables in braces such as {name}, {date}, {amount}.",
|
||||||
|
"glossaries.suggestion.technical.value": "Keep technical terms in the source language; do not translate them.",
|
||||||
|
"glossaries.suggestion.concise.value": "Prefer short, direct phrasing. Avoid circumlocutions."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,5 +101,12 @@
|
|||||||
"translate.cancelledTitle": "Translation cancelled",
|
"translate.cancelledTitle": "Translation cancelled",
|
||||||
"translate.cancelledDesc": "The job was stopped and the reserved document slot released.",
|
"translate.cancelledDesc": "The job was stopped and the reserved document slot released.",
|
||||||
"translate.cancelFailedTitle": "Could not cancel",
|
"translate.cancelFailedTitle": "Could not cancel",
|
||||||
"translate.cancelFailedDesc": "The job may have already finished. The display will update shortly."
|
"translate.cancelFailedDesc": "The job may have already finished. The display will update shortly.",
|
||||||
|
"translate.contextActive": "Context guidelines active",
|
||||||
|
"translate.startBatch": "Translate {count} documents",
|
||||||
|
"translate.batch.runningTitle": "Translating your documents",
|
||||||
|
"translate.batch.doneTitle": "{done}/{total} translated",
|
||||||
|
"translate.batch.stop": "Stop the queue",
|
||||||
|
"translate.batch.downloadAll": "Download all",
|
||||||
|
"translate.batch.newSession": "New session"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,5 +29,6 @@
|
|||||||
"fileUploader.reconstructing": "Reconstruction du document…",
|
"fileUploader.reconstructing": "Reconstruction du document…",
|
||||||
"fileUploader.translatingLocally": "Traduction locale avec WebLLM…",
|
"fileUploader.translatingLocally": "Traduction locale avec WebLLM…",
|
||||||
"fileUploader.error.invalidFormat": "Format non supporté. Formats acceptés : .xlsx, .docx, .pptx, .pdf",
|
"fileUploader.error.invalidFormat": "Format non supporté. Formats acceptés : .xlsx, .docx, .pptx, .pdf",
|
||||||
"fileUploader.error.tooLarge": "Fichier trop volumineux (max 50 Mo)"
|
"fileUploader.error.tooLarge": "Fichier trop volumineux (max 50 Mo)",
|
||||||
|
"fileUploader.error.tooMany": "File limitée à 10 documents par exécution."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,5 +226,11 @@
|
|||||||
"glossaries.suggestion.numbers": "Chiffres",
|
"glossaries.suggestion.numbers": "Chiffres",
|
||||||
"glossaries.suggestion.placeholders": "Placeholders",
|
"glossaries.suggestion.placeholders": "Placeholders",
|
||||||
"glossaries.suggestion.technical": "Termes techniques",
|
"glossaries.suggestion.technical": "Termes techniques",
|
||||||
"glossaries.suggestion.concise": "Style concis"
|
"glossaries.suggestion.concise": "Style concis",
|
||||||
|
"glossaries.suggestion.formal.value": "Utilise toujours un ton formel et professionnel dans tes traductions.",
|
||||||
|
"glossaries.suggestion.proprietary.value": "Ne traduis pas les noms propres, marques et noms de personnes.",
|
||||||
|
"glossaries.suggestion.numbers.value": "Garde tous les chiffres, pourcentages et montants tels quels sans les modifier.",
|
||||||
|
"glossaries.suggestion.placeholders.value": "Ne traduis pas les variables entre accolades comme {nom}, {date}, {montant}.",
|
||||||
|
"glossaries.suggestion.technical.value": "Conserve les termes techniques en langue originale et ne les traduis pas.",
|
||||||
|
"glossaries.suggestion.concise.value": "Préfère des formulations courtes et directes. Évite les périphrases."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,5 +101,12 @@
|
|||||||
"translate.cancelledTitle": "Traduction annulée",
|
"translate.cancelledTitle": "Traduction annulée",
|
||||||
"translate.cancelledDesc": "Le job a été arrêté et le document réservé a été libéré.",
|
"translate.cancelledDesc": "Le job a été arrêté et le document réservé a été libéré.",
|
||||||
"translate.cancelFailedTitle": "Annulation impossible",
|
"translate.cancelFailedTitle": "Annulation impossible",
|
||||||
"translate.cancelFailedDesc": "Le job est peut-être déjà terminé. L'affichage se mettra à jour sous peu."
|
"translate.cancelFailedDesc": "Le job est peut-être déjà terminé. L'affichage se mettra à jour sous peu.",
|
||||||
|
"translate.contextActive": "Consignes de contexte actives",
|
||||||
|
"translate.startBatch": "Traduire {count} documents",
|
||||||
|
"translate.batch.runningTitle": "Traduction de vos documents",
|
||||||
|
"translate.batch.doneTitle": "{done}/{total} traduits",
|
||||||
|
"translate.batch.stop": "Arrêter la file",
|
||||||
|
"translate.batch.downloadAll": "Tout télécharger",
|
||||||
|
"translate.batch.newSession": "Nouvelle session"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,8 +33,12 @@ interface TranslationState {
|
|||||||
settings: TranslationSettings;
|
settings: TranslationSettings;
|
||||||
updateSettings: (partial: Partial<TranslationSettings>) => void;
|
updateSettings: (partial: Partial<TranslationSettings>) => void;
|
||||||
setAdminToken: (token: string | undefined) => void;
|
setAdminToken: (token: string | undefined) => void;
|
||||||
applyPreset: (preset: string) => void;
|
}
|
||||||
clearContext: () => void;
|
|
||||||
|
interface TranslationState {
|
||||||
|
settings: TranslationSettings;
|
||||||
|
updateSettings: (partial: Partial<TranslationSettings>) => void;
|
||||||
|
setAdminToken: (token: string | undefined) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRESETS: Record<string, { systemPrompt: string; glossary: string }> = {
|
const PRESETS: Record<string, { systemPrompt: string; glossary: string }> = {
|
||||||
@@ -116,17 +120,6 @@ export const useTranslationStore = create<TranslationState>()(
|
|||||||
set((state) => ({
|
set((state) => ({
|
||||||
settings: { ...state.settings, adminToken: token },
|
settings: { ...state.settings, adminToken: token },
|
||||||
})),
|
})),
|
||||||
applyPreset: (preset) =>
|
|
||||||
set((state) => ({
|
|
||||||
settings: {
|
|
||||||
...state.settings,
|
|
||||||
...PRESETS[preset],
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
clearContext: () =>
|
|
||||||
set((state) => ({
|
|
||||||
settings: { ...state.settings, systemPrompt: "", glossary: "" },
|
|
||||||
})),
|
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "translation-settings",
|
name: "translation-settings",
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { useState, useCallback } from "react";
|
|
||||||
|
|
||||||
interface WebLLMState {
|
|
||||||
isLoaded: boolean;
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useWebLLM() {
|
|
||||||
const [state, setState] = useState<WebLLMState>({
|
|
||||||
isLoaded: false,
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isWebGPUSupported = useCallback(() => {
|
|
||||||
if (typeof navigator === "undefined") return false;
|
|
||||||
return "gpu" in navigator;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const translate = useCallback(
|
|
||||||
async (
|
|
||||||
_text: string,
|
|
||||||
_targetLang: string,
|
|
||||||
_systemPrompt?: string,
|
|
||||||
_glossary?: string,
|
|
||||||
): Promise<string> => {
|
|
||||||
setState((s) => ({ ...s, loading: true, error: null }));
|
|
||||||
try {
|
|
||||||
throw new Error("WebLLM is not available in this environment");
|
|
||||||
} catch (err) {
|
|
||||||
const message =
|
|
||||||
err instanceof Error ? err.message : "WebLLM translation failed";
|
|
||||||
setState((s) => ({ ...s, loading: false, error: message }));
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
isWebGPUSupported,
|
|
||||||
translate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
137
frontend/src/test/translationRunner.test.ts
Normal file
137
frontend/src/test/translationRunner.test.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { runTranslationJob } from '../app/dashboard/translate/translationRunner';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dedicated tests for the multi-file queue runner — the piece that drives
|
||||||
|
* N sequential translations. Fetch is fully mocked; no network, no React.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TEST_CONFIG = {
|
||||||
|
sourceLang: 'fr',
|
||||||
|
targetLang: 'en',
|
||||||
|
mode: 'classic' as const,
|
||||||
|
provider: 'google',
|
||||||
|
glossaryId: null,
|
||||||
|
translateImages: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeFile(name = 'rapport_q3.docx'): File {
|
||||||
|
return new File(['dummy content'], name, {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, ok = true, status = 200): Response {
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
status,
|
||||||
|
json: async () => body,
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('runTranslationJob', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn());
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('submits, polls, and reports completion with progress callbacks', async () => {
|
||||||
|
const fetchMock = vi.mocked(fetch);
|
||||||
|
fetchMock
|
||||||
|
// POST /translate
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ data: { id: 'job_1', file_name: 'rapport_q3.docx' } }))
|
||||||
|
// poll 1: processing at 45%
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ data: { id: 'job_1', status: 'processing', progress_percent: 45, current_step: 'Translating' } }))
|
||||||
|
// poll 2: completed
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ data: { id: 'job_1', status: 'completed', progress_percent: 100, file_name: 'rapport_q3.docx' } }));
|
||||||
|
|
||||||
|
const onProgress = vi.fn();
|
||||||
|
const onJobId = vi.fn();
|
||||||
|
const result = await runTranslationJob(makeFile(), TEST_CONFIG, { onProgress, onJobId, pollIntervalMs: 10 });
|
||||||
|
|
||||||
|
expect(result.status).toBe('completed');
|
||||||
|
expect(result.jobId).toBe('job_1');
|
||||||
|
expect(result.fileName).toBe('rapport_q3.docx');
|
||||||
|
expect(onJobId).toHaveBeenCalledWith('job_1');
|
||||||
|
expect(onProgress).toHaveBeenCalledTimes(2);
|
||||||
|
expect(onProgress).toHaveBeenNthCalledWith(1, expect.objectContaining({ status: 'processing', progress: 45 }));
|
||||||
|
|
||||||
|
// the multipart submit carried the core fields
|
||||||
|
const submitCall = fetchMock.mock.calls[0];
|
||||||
|
expect(submitCall[0]).toContain('/api/v1/translate');
|
||||||
|
const body = submitCall[1]?.body as FormData;
|
||||||
|
expect(body.get('source_lang')).toBe('fr');
|
||||||
|
expect(body.get('target_lang')).toBe('en');
|
||||||
|
expect(body.get('mode')).toBe('classic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a failed job with its backend error message', async () => {
|
||||||
|
const fetchMock = vi.mocked(fetch);
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ data: { id: 'job_2' } }))
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
jsonResponse({ data: { id: 'job_2', status: 'failed', error_message: 'quota exceeded', progress_percent: 20 } })
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await runTranslationJob(makeFile(), TEST_CONFIG, { pollIntervalMs: 10 });
|
||||||
|
|
||||||
|
expect(result.status).toBe('failed');
|
||||||
|
expect(result.error).toBe('quota exceeded');
|
||||||
|
expect(result.jobId).toBe('job_2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a submit-time HTTP error without polling', async () => {
|
||||||
|
const fetchMock = vi.mocked(fetch);
|
||||||
|
fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'Insufficient credits' }, false, 402));
|
||||||
|
|
||||||
|
const onProgress = vi.fn();
|
||||||
|
const result = await runTranslationJob(makeFile(), TEST_CONFIG, { onProgress, pollIntervalMs: 10 });
|
||||||
|
|
||||||
|
expect(result.status).toBe('failed');
|
||||||
|
expect(result.error).toBe('Insufficient credits');
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1); // no polling started
|
||||||
|
expect(onProgress).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aborts between polls, cancels server-side, and never reports completion', async () => {
|
||||||
|
const fetchMock = vi.mocked(fetch);
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ data: { id: 'job_3' } }))
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ data: { id: 'job_3', status: 'processing', progress_percent: 30 } }))
|
||||||
|
// cancel call
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ data: { id: 'job_3', status: 'cancelled' } }))
|
||||||
|
// a late poll that must never be consumed
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ data: { id: 'job_3', status: 'completed', progress_percent: 100 } }));
|
||||||
|
|
||||||
|
let calls = 0;
|
||||||
|
const result = await runTranslationJob(makeFile(), TEST_CONFIG, {
|
||||||
|
shouldAbort: () => calls++ > 1, // abort after submit + one poll
|
||||||
|
pollIntervalMs: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe('aborted');
|
||||||
|
expect(result.jobId).toBe('job_3');
|
||||||
|
// a cancel was sent for the in-flight job
|
||||||
|
const cancelCall = fetchMock.mock.calls.find((c) => String(c[0]).includes('/cancel'));
|
||||||
|
expect(cancelCall).toBeDefined();
|
||||||
|
// and the trailing "completed" poll was never reached
|
||||||
|
const lastCall = fetchMock.mock.calls[fetchMock.mock.calls.length - 1];
|
||||||
|
expect(String(lastCall?.[0])).not.toContain('completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops polling after repeated network failures', async () => {
|
||||||
|
const fetchMock = vi.mocked(fetch);
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ data: { id: 'job_4' } }))
|
||||||
|
.mockRejectedValue(new TypeError('network down'));
|
||||||
|
|
||||||
|
const result = await runTranslationJob(makeFile(), TEST_CONFIG, { pollIntervalMs: 10 });
|
||||||
|
|
||||||
|
expect(result.status).toBe('failed');
|
||||||
|
expect(result.error).toContain('Lost connection');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user