feat: add document translation via DIFY AI API

Implement document translation feature using DIFY AI API with batch processing:

Backend:
- Add DIFY client with batch translation support (5000 chars, 20 items per batch)
- Add translation service with element extraction and result building
- Add translation router with start/status/result/list/delete endpoints
- Add translation schemas (TranslationRequest, TranslationStatus, etc.)

Frontend:
- Enable translation UI in TaskDetailPage
- Add translation API methods to apiV2.ts
- Add translation types

Features:
- Batch translation with numbered markers [1], [2], [3]...
- Support for text, title, header, footer, paragraph, footnote, table cells
- Translation result JSON with statistics (tokens, latency, batch_count)
- Background task processing with progress tracking

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
egg
2025-12-02 11:57:02 +08:00
parent 87dc97d951
commit 8d9b69ba93
18 changed files with 2970 additions and 26 deletions

View File

@@ -1,7 +1,7 @@
import { useMemo } from 'react'
import { useMemo, useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import PDFViewer from '@/components/PDFViewer'
@@ -11,23 +11,23 @@ import {
FileText,
Download,
AlertCircle,
TrendingUp,
Clock,
Layers,
FileJson,
Loader2,
ArrowLeft,
RefreshCw,
Cpu,
FileSearch,
Table2,
Image,
BarChart3,
Database,
Languages,
Globe
Globe,
CheckCircle,
Trash2
} from 'lucide-react'
import type { ProcessingTrack, ProcessingMetadata } from '@/types/apiV2'
import type { ProcessingTrack, TranslationStatus, TranslationListItem } from '@/types/apiV2'
import { Badge } from '@/components/ui/badge'
import {
Select,
@@ -36,12 +36,37 @@ import {
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { Progress } from '@/components/ui/progress'
// Language options for translation
const LANGUAGE_OPTIONS = [
{ value: 'en', label: 'English' },
{ value: 'ja', label: '日本語' },
{ value: 'ko', label: '한국어' },
{ value: 'zh-TW', label: '繁體中文' },
{ value: 'zh-CN', label: '简体中文' },
{ value: 'de', label: 'Deutsch' },
{ value: 'fr', label: 'Français' },
{ value: 'es', label: 'Español' },
{ value: 'pt', label: 'Português' },
{ value: 'it', label: 'Italiano' },
{ value: 'ru', label: 'Русский' },
{ value: 'vi', label: 'Tiếng Việt' },
{ value: 'th', label: 'ไทย' },
]
export default function TaskDetailPage() {
const { taskId } = useParams<{ taskId: string }>()
const { t } = useTranslation()
const navigate = useNavigate()
const { toast } = useToast()
const queryClient = useQueryClient()
// Translation state
const [targetLang, setTargetLang] = useState('en')
const [isTranslating, setIsTranslating] = useState(false)
const [translationStatus, setTranslationStatus] = useState<TranslationStatus | null>(null)
const [translationProgress, setTranslationProgress] = useState(0)
// Get task details
const { data: taskDetail, isLoading, refetch } = useQuery({
@@ -66,6 +91,52 @@ export default function TaskDetailPage() {
retry: false,
})
// Get existing translations
const { data: translationList, refetch: refetchTranslations } = useQuery({
queryKey: ['translations', taskId],
queryFn: () => apiClientV2.listTranslations(taskId!),
enabled: !!taskId && taskDetail?.status === 'completed',
retry: false,
})
// Poll translation status when translating
useEffect(() => {
if (!isTranslating || !taskId) return
const pollInterval = setInterval(async () => {
try {
const status = await apiClientV2.getTranslationStatus(taskId)
setTranslationStatus(status.status)
if (status.progress) {
setTranslationProgress(status.progress.percentage)
}
if (status.status === 'completed') {
setIsTranslating(false)
setTranslationProgress(100)
toast({
title: '翻譯完成',
description: `文件已翻譯為 ${LANGUAGE_OPTIONS.find(l => l.value === targetLang)?.label || targetLang}`,
variant: 'success',
})
refetchTranslations()
} else if (status.status === 'failed') {
setIsTranslating(false)
toast({
title: '翻譯失敗',
description: status.error_message || '未知錯誤',
variant: 'destructive',
})
}
} catch (error) {
console.error('Failed to poll translation status:', error)
}
}, 2000)
return () => clearInterval(pollInterval)
}, [isTranslating, taskId, targetLang, toast, refetchTranslations])
// Construct PDF URL for preview - memoize to prevent unnecessary reloads
// Must be called unconditionally before any early returns (React hooks rule)
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000'
@@ -178,6 +249,84 @@ export default function TaskDetailPage() {
}
}
const handleStartTranslation = async () => {
if (!taskId || isTranslating) return
try {
setIsTranslating(true)
setTranslationStatus('pending')
setTranslationProgress(0)
const response = await apiClientV2.startTranslation(taskId, {
target_lang: targetLang,
source_lang: 'auto',
})
if (response.status === 'completed') {
// Translation already exists
setIsTranslating(false)
setTranslationProgress(100)
toast({
title: '翻譯已存在',
description: response.message,
variant: 'success',
})
refetchTranslations()
} else {
setTranslationStatus(response.status)
toast({
title: '開始翻譯',
description: '翻譯任務已啟動,請稍候...',
})
}
} catch (error: any) {
setIsTranslating(false)
setTranslationStatus(null)
toast({
title: '啟動翻譯失敗',
description: error.response?.data?.detail || t('errors.networkError'),
variant: 'destructive',
})
}
}
const handleDownloadTranslation = async (lang: string) => {
if (!taskId) return
try {
await apiClientV2.downloadTranslation(taskId, lang)
toast({
title: '下載成功',
description: `翻譯結果 (${lang}) 已下載`,
variant: 'success',
})
} catch (error: any) {
toast({
title: '下載失敗',
description: error.response?.data?.detail || t('errors.networkError'),
variant: 'destructive',
})
}
}
const handleDeleteTranslation = async (lang: string) => {
if (!taskId) return
try {
await apiClientV2.deleteTranslation(taskId, lang)
toast({
title: '刪除成功',
description: `翻譯結果 (${lang}) 已刪除`,
variant: 'success',
})
refetchTranslations()
} catch (error: any) {
toast({
title: '刪除失敗',
description: error.response?.data?.detail || t('errors.networkError'),
variant: 'destructive',
})
}
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'completed':
@@ -191,6 +340,23 @@ export default function TaskDetailPage() {
}
}
const getTranslationStatusText = (status: TranslationStatus | null) => {
switch (status) {
case 'pending':
return '準備中...'
case 'loading_model':
return '載入翻譯模型...'
case 'translating':
return '翻譯中...'
case 'completed':
return '完成'
case 'failed':
return '失敗'
default:
return ''
}
}
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return date.toLocaleString('zh-TW')
@@ -350,42 +516,113 @@ export default function TaskDetailPage() {
</Card>
)}
{/* Translation Options (Coming Soon) */}
{/* Translation Options */}
{isCompleted && (
<Card className="opacity-60">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Languages className="w-5 h-5" />
<Badge variant="secondary" className="ml-2"></Badge>
<Badge variant="secondary" className="ml-2">MADLAD-400</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="space-y-4">
{/* Start New Translation */}
<div className="flex flex-col md:flex-row items-start md:items-center gap-4">
<div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground"></span>
<Select disabled defaultValue="en">
<Select
value={targetLang}
onValueChange={setTargetLang}
disabled={isTranslating}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="選擇語言" />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="ja"></SelectItem>
<SelectItem value="ko"></SelectItem>
<SelectItem value="zh-TW"></SelectItem>
<SelectItem value="zh-CN"></SelectItem>
{LANGUAGE_OPTIONS.map(lang => (
<SelectItem key={lang.value} value={lang.value}>
{lang.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button disabled className="gap-2">
<Languages className="w-4 h-4" />
<Button
onClick={handleStartTranslation}
disabled={isTranslating}
className="gap-2"
>
{isTranslating ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Languages className="w-4 h-4" />
)}
{isTranslating ? getTranslationStatusText(translationStatus) : '開始翻譯'}
</Button>
<p className="text-sm text-muted-foreground">
</p>
</div>
{/* Translation Progress */}
{isTranslating && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{getTranslationStatusText(translationStatus)}</span>
<span className="text-muted-foreground">{translationProgress.toFixed(0)}%</span>
</div>
<Progress value={translationProgress} className="h-2" />
</div>
)}
{/* Existing Translations */}
{translationList && translationList.translations.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground"></p>
<div className="space-y-2">
{translationList.translations.map((item: TranslationListItem) => (
<div
key={item.target_lang}
className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"
>
<div className="flex items-center gap-3">
<CheckCircle className="w-4 h-4 text-green-500" />
<div>
<span className="font-medium">
{LANGUAGE_OPTIONS.find(l => l.value === item.target_lang)?.label || item.target_lang}
</span>
<span className="text-sm text-muted-foreground ml-2">
({item.statistics.translated_elements} , {item.statistics.processing_time_seconds.toFixed(1)}s)
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleDownloadTranslation(item.target_lang)}
className="gap-1"
>
<Download className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteTranslation(item.target_lang)}
className="gap-1 text-destructive hover:text-destructive"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
))}
</div>
</div>
)}
<p className="text-xs text-muted-foreground">
使 MADLAD-400-3B 450+ 10-30
</p>
</CardContent>
</Card>
)}

View File

@@ -35,6 +35,11 @@ import type {
DocumentAnalysisResponse,
PreprocessingPreviewRequest,
PreprocessingPreviewResponse,
TranslationRequest,
TranslationStartResponse,
TranslationStatusResponse,
TranslationListResponse,
TranslationResult,
} from '@/types/apiV2'
/**
@@ -613,6 +618,74 @@ class ApiClientV2 {
)
return response.data
}
// ==================== Translation APIs ====================
/**
* Start a translation job
*/
async startTranslation(taskId: string, request: TranslationRequest): Promise<TranslationStartResponse> {
const response = await this.client.post<TranslationStartResponse>(
`/translate/${taskId}`,
request
)
return response.data
}
/**
* Get translation status
*/
async getTranslationStatus(taskId: string): Promise<TranslationStatusResponse> {
const response = await this.client.get<TranslationStatusResponse>(
`/translate/${taskId}/status`
)
return response.data
}
/**
* Get translation result
*/
async getTranslationResult(taskId: string, lang: string): Promise<TranslationResult> {
const response = await this.client.get<TranslationResult>(
`/translate/${taskId}/result`,
{ params: { lang } }
)
return response.data
}
/**
* Download translation result as JSON file
*/
async downloadTranslation(taskId: string, lang: string): Promise<void> {
const response = await this.client.get(`/translate/${taskId}/result`, {
params: { lang },
responseType: 'blob',
})
const blob = new Blob([response.data], { type: 'application/json' })
const link = document.createElement('a')
link.href = window.URL.createObjectURL(blob)
link.download = `${taskId}_translated_${lang}.json`
link.click()
window.URL.revokeObjectURL(link.href)
}
/**
* List available translations for a task
*/
async listTranslations(taskId: string): Promise<TranslationListResponse> {
const response = await this.client.get<TranslationListResponse>(
`/translate/${taskId}/translations`
)
return response.data
}
/**
* Delete a translation
*/
async deleteTranslation(taskId: string, lang: string): Promise<void> {
await this.client.delete(`/translate/${taskId}/translations/${lang}`)
}
}
// Export singleton instance

View File

@@ -307,3 +307,70 @@ export interface UserActivitySummary {
categories: Record<string, number>
recent_actions: AuditLog[]
}
// ==================== Translation Types ====================
export type TranslationStatus = 'pending' | 'loading_model' | 'translating' | 'completed' | 'failed'
export interface TranslationRequest {
target_lang: string
source_lang?: string
}
export interface TranslationProgress {
current_page: number
total_pages: number
current_element: number
total_elements: number
percentage: number
}
export interface TranslationStartResponse {
task_id: string
status: TranslationStatus
target_lang: string
message: string
}
export interface TranslationStatusResponse {
task_id: string
status: TranslationStatus
target_lang: string
progress: TranslationProgress | null
error_message: string | null
started_at: string | null
completed_at: string | null
}
export interface TranslationStatistics {
total_elements: number
translated_elements: number
skipped_elements: number
total_characters: number
processing_time_seconds: number
}
export interface TranslationListItem {
target_lang: string
translated_at: string
model: string
statistics: TranslationStatistics
file_path: string
}
export interface TranslationListResponse {
task_id: string
translations: TranslationListItem[]
}
export interface TranslationResult {
schema_version: string
source_document: string
source_lang: string
target_lang: string
model: string
model_version: string
translated_at: string
statistics: TranslationStatistics
translations: Record<string, any>
}