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:
@@ -12,12 +12,15 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useFileUpload } from './useFileUpload';
|
||||
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 { ProviderSelector } from './ProviderSelector';
|
||||
import { GlossarySelector } from './GlossarySelector';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useNotification } from '@/components/ui/notification';
|
||||
import { useTranslationStore } from '@/lib/store';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { API_BASE } from '@/lib/config';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -66,14 +69,18 @@ function SplitTitle({ base, accent }: { base: string; accent: string }) {
|
||||
/* ── Page ────────────────────────────────────────────────────────── */
|
||||
export default function TranslatePage() {
|
||||
const upload = useFileUpload();
|
||||
const config = useTranslationConfig(!!upload.file);
|
||||
const config = useTranslationConfig(upload.files.length > 0);
|
||||
const submit = useTranslationSubmit();
|
||||
const { error: showError } = useNotification();
|
||||
const { t } = useI18n();
|
||||
const systemPrompt = useTranslationStore((s) => s.settings.systemPrompt);
|
||||
const lastErrorRef = useRef<string | null>(null);
|
||||
const replaceInputRef = useRef<HTMLInputElement>(null);
|
||||
const dropzoneInputRef = useRef<HTMLInputElement>(null);
|
||||
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 [recentJobs, setRecentJobs] = useState<RecentJob[]>([]);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@@ -133,10 +140,83 @@ export default function TranslatePage() {
|
||||
}, [submit.status]);
|
||||
|
||||
const handleTranslate = async () => {
|
||||
if (!upload.file || !config.isConfigValid) return;
|
||||
if (upload.files.length === 0 || !config.isConfigValid) return;
|
||||
const cfg = config.getConfig();
|
||||
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 () => {
|
||||
@@ -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 ?? '') => {
|
||||
if (!jobId) return;
|
||||
const token = localStorage.getItem('token');
|
||||
@@ -191,7 +271,7 @@ export default function TranslatePage() {
|
||||
};
|
||||
|
||||
/* ── 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 isCompleted = submit.status === 'completed';
|
||||
const isFailed = submit.status === 'failed';
|
||||
@@ -209,7 +289,8 @@ export default function TranslatePage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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 showProcessing = isProcessing;
|
||||
const showComplete = isCompleted && !!submit.jobId;
|
||||
@@ -325,6 +406,7 @@ export default function TranslatePage() {
|
||||
ref={dropzoneInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.docx,.pptx,.pdf"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={upload.handleFileSelect}
|
||||
aria-hidden="true"
|
||||
@@ -333,6 +415,112 @@ export default function TranslatePage() {
|
||||
</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 ── */}
|
||||
{showUpload && recentJobs.length > 0 && (
|
||||
<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'}
|
||||
</h4>
|
||||
<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.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>
|
||||
)}
|
||||
|
||||
@@ -383,26 +596,28 @@ export default function TranslatePage() {
|
||||
{(showUpload || showConfiguring) && (
|
||||
<div className="hidden lg:block editorial-card p-6 bg-white dark:bg-[#141414] border-none shadow-editorial space-y-4">
|
||||
<button
|
||||
disabled={!config.isConfigValid || submit.isSubmitting || !upload.file}
|
||||
disabled={!config.isConfigValid || submit.isSubmitting || upload.files.length === 0}
|
||||
onClick={handleTranslate}
|
||||
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',
|
||||
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-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 ? (
|
||||
<><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>
|
||||
|
||||
{!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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -567,7 +782,7 @@ export default function TranslatePage() {
|
||||
<div className="lg:col-span-5 space-y-6">
|
||||
|
||||
{/* ── 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)]">
|
||||
{/* Scrollable config content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-5">
|
||||
@@ -612,6 +827,17 @@ export default function TranslatePage() {
|
||||
</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 */}
|
||||
{config.isPro && (
|
||||
<GlossarySelector
|
||||
@@ -816,14 +1042,14 @@ export default function TranslatePage() {
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<button
|
||||
disabled={!config.isConfigValid || submit.isSubmitting || !upload.file}
|
||||
disabled={!config.isConfigValid || submit.isSubmitting || upload.files.length === 0}
|
||||
onClick={handleTranslate}
|
||||
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',
|
||||
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-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 interface FileUploadState {
|
||||
/** First file — drives the single-file flow. */
|
||||
file: File | null;
|
||||
/** All queued files (1..MAX_BATCH_FILES) — drives the batch flow. */
|
||||
files: File[];
|
||||
/** i18n key (from ERROR_MESSAGES) — render through t() */
|
||||
error: string | null;
|
||||
isDragOver: boolean;
|
||||
@@ -12,9 +15,21 @@ export interface FileUploadActions {
|
||||
handleDragOver: (e: React.DragEvent) => void;
|
||||
handleDragLeave: (e: React.DragEvent) => void;
|
||||
handleFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
addFiles: (incoming: Iterable<File>) => void;
|
||||
removeAt: (index: number) => 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 type TranslationMode = 'classic' | 'llm';
|
||||
|
||||
@@ -3,48 +3,68 @@ import type { UseFileUploadReturn } from './types';
|
||||
|
||||
const ACCEPTED_EXTENSIONS = ['xlsx', 'docx', 'pptx', 'pdf'];
|
||||
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.
|
||||
export const ERROR_MESSAGES = {
|
||||
INVALID_FORMAT: 'fileUploader.error.invalidFormat',
|
||||
FILE_TOO_LARGE: 'fileUploader.error.tooLarge',
|
||||
TOO_MANY: 'fileUploader.error.tooMany',
|
||||
} as const;
|
||||
|
||||
export function useFileUpload(): UseFileUploadReturn {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const validateFile = useCallback((file: File): string | null => {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
|
||||
if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) {
|
||||
return ERROR_MESSAGES.INVALID_FORMAT;
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return ERROR_MESSAGES.FILE_TOO_LARGE;
|
||||
}
|
||||
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
|
||||
const droppedFile = e.dataTransfer.files[0];
|
||||
if (droppedFile) {
|
||||
const validationError = validateFile(droppedFile);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
setFile(null);
|
||||
} else {
|
||||
setFile(droppedFile);
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
}, [validateFile]);
|
||||
const dropped = Array.from(e.dataTransfer.files ?? []);
|
||||
if (dropped.length > 0) addFiles(dropped);
|
||||
}, [addFiles]);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -57,33 +77,32 @@ export function useFileUpload(): UseFileUploadReturn {
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = e.target.files?.[0];
|
||||
if (selected) {
|
||||
const validationError = validateFile(selected);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
setFile(null);
|
||||
} else {
|
||||
setFile(selected);
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
}, [validateFile]);
|
||||
const selected = Array.from(e.target.files ?? []);
|
||||
if (selected.length > 0) addFiles(selected);
|
||||
// allow re-selecting the same file after removal
|
||||
e.target.value = '';
|
||||
}, [addFiles]);
|
||||
|
||||
const removeFile = useCallback(() => {
|
||||
setFile(null);
|
||||
setFiles([]);
|
||||
setError(null);
|
||||
setIsDragOver(false);
|
||||
}, []);
|
||||
|
||||
// Single-file compatibility: the first file drives the existing one-file flow.
|
||||
const file = files[0] ?? null;
|
||||
|
||||
return {
|
||||
file,
|
||||
files,
|
||||
error,
|
||||
isDragOver,
|
||||
handleDrop,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleFileSelect,
|
||||
addFiles,
|
||||
removeAt,
|
||||
removeFile,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ function persistActiveJob(job: StoredJob | null) {
|
||||
} catch { /* storage unavailable — session-only fallback */ }
|
||||
}
|
||||
|
||||
function pushRecentJob(job: RecentJob) {
|
||||
export function pushRecentJob(job: RecentJob) {
|
||||
try {
|
||||
const list = getRecentJobs().filter(j => j.jobId !== job.jobId);
|
||||
list.unshift(job);
|
||||
|
||||
Reference in New Issue
Block a user