feat: revue de code, doc CODE_REVIEW, forfaits 2026, traduction LLM, providers avec modèle
Made-with: Cursor
This commit is contained in:
209
frontend/src/app/dashboard/translate/useTranslationSubmit.ts
Normal file
209
frontend/src/app/dashboard/translate/useTranslationSubmit.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import type {
|
||||
UseTranslationSubmitReturn,
|
||||
TranslationConfig,
|
||||
TranslationStatus,
|
||||
TranslationSubmitResponse,
|
||||
TranslationStatusResponse
|
||||
} from './types';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||
const POLLING_INTERVAL_MS = 2000;
|
||||
const MAX_POLLING_FAILURES = 3;
|
||||
|
||||
export function useTranslationSubmit(): UseTranslationSubmitReturn {
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<TranslationStatus>('idle');
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [currentStep, setCurrentStep] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [estimatedRemaining, setEstimatedRemaining] = useState<number | null>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [pollingFailures, setPollingFailures] = useState(0);
|
||||
const [isPolling, setIsPolling] = useState(false);
|
||||
|
||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isPollingRef = useRef(false);
|
||||
// Use a ref for failure count to avoid stale closure in the interval callback.
|
||||
// If we relied on state, the setInterval callback would always read the initial
|
||||
// value of pollingFailures (0) and never reach MAX_POLLING_FAILURES.
|
||||
const pollingFailuresRef = useRef(0);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
isPollingRef.current = false;
|
||||
setIsPolling(false);
|
||||
}, []);
|
||||
|
||||
const pollProgress = useCallback(async (id: string) => {
|
||||
if (isPollingRef.current) return;
|
||||
|
||||
isPollingRef.current = true;
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/v1/translations/${id}`, { headers });
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
stopPolling();
|
||||
setStatus('failed');
|
||||
setError('Translation job not found');
|
||||
return;
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data: TranslationStatusResponse = await response.json();
|
||||
const job = data.data;
|
||||
|
||||
setStatus(job.status as TranslationStatus);
|
||||
setProgress(job.progress_percent || 0);
|
||||
setCurrentStep(job.current_step || '');
|
||||
setEstimatedRemaining(data.meta.estimated_remaining_seconds ?? null);
|
||||
pollingFailuresRef.current = 0;
|
||||
setPollingFailures(0);
|
||||
|
||||
if (job.file_name) {
|
||||
setFileName(job.file_name);
|
||||
}
|
||||
|
||||
if (job.status === 'completed' || job.status === 'failed') {
|
||||
stopPolling();
|
||||
if (job.status === 'failed') {
|
||||
setError(job.error_message || 'Translation failed');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Polling error:', err);
|
||||
pollingFailuresRef.current += 1;
|
||||
setPollingFailures(pollingFailuresRef.current);
|
||||
|
||||
if (pollingFailuresRef.current >= MAX_POLLING_FAILURES) {
|
||||
stopPolling();
|
||||
setStatus('failed');
|
||||
setError('Lost connection to translation service. Please check your internet connection and try again.');
|
||||
}
|
||||
} finally {
|
||||
isPollingRef.current = false;
|
||||
}
|
||||
}, [stopPolling]);
|
||||
|
||||
const startPolling = useCallback((id: string) => {
|
||||
stopPolling();
|
||||
pollingFailuresRef.current = 0;
|
||||
setIsPolling(true);
|
||||
setPollingFailures(0);
|
||||
|
||||
pollProgress(id);
|
||||
|
||||
pollingIntervalRef.current = setInterval(() => {
|
||||
pollProgress(id);
|
||||
}, POLLING_INTERVAL_MS);
|
||||
}, [pollProgress, stopPolling]);
|
||||
|
||||
const submitTranslation = useCallback(async (file: File, config: TranslationConfig) => {
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
setProgress(0);
|
||||
setCurrentStep('Uploading file...');
|
||||
setEstimatedRemaining(null);
|
||||
setStatus('processing'); // IMPORTANT: Set to 'processing' IMMEDIATELY so progress bar shows
|
||||
setFileName(file.name);
|
||||
setJobId(null);
|
||||
|
||||
try {
|
||||
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);
|
||||
// Provider is configured server-side by admin — only send the provider name.
|
||||
if (config.mode === 'llm' && config.provider) {
|
||||
formData.append('provider', config.provider);
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/v1/translate`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `Translation failed: ${response.status}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMessage = errorData.message || errorData.error || errorMessage;
|
||||
} catch {
|
||||
// Response not JSON, use default message
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const data: TranslationSubmitResponse = await response.json();
|
||||
|
||||
setJobId(data.data.id);
|
||||
setFileName(data.data.file_name || file.name);
|
||||
setProgress(data.data.progress_percent || 5); // Start with at least 5%
|
||||
setCurrentStep(data.data.current_step || 'Translating...');
|
||||
|
||||
startPolling(data.data.id);
|
||||
} catch (err) {
|
||||
setStatus('failed');
|
||||
setError(err instanceof Error ? err.message : 'Translation failed');
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
// NOTE: Don't set isSubmitting(false) here - let polling handle the transition
|
||||
}, [startPolling]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
stopPolling();
|
||||
setJobId(null);
|
||||
setStatus('idle');
|
||||
setProgress(0);
|
||||
setCurrentStep('');
|
||||
setError(null);
|
||||
setEstimatedRemaining(null);
|
||||
setFileName(null);
|
||||
setIsSubmitting(false);
|
||||
setPollingFailures(0);
|
||||
}, [stopPolling]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopPolling();
|
||||
};
|
||||
}, [stopPolling]);
|
||||
|
||||
return {
|
||||
submitTranslation,
|
||||
jobId,
|
||||
status,
|
||||
progress,
|
||||
currentStep,
|
||||
error,
|
||||
estimatedRemaining,
|
||||
fileName,
|
||||
reset,
|
||||
isSubmitting,
|
||||
isPolling,
|
||||
pollingFailures,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user