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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user