Add PDF translation API, utilities, docs, and config

Introduces core backend and frontend infrastructure for a PDF translation interface. Adds API endpoints for translation, PDF testing, and AI provider testing; implements PDF text extraction, cost tracking, and pricing logic in the lib directory; adds reusable UI components; and provides comprehensive documentation (SDD, environment setup, Claude instructions). Updates Tailwind and global styles, and includes a sample test PDF and configuration files.
This commit is contained in:
2025-10-15 23:34:44 +08:00
parent c899702d51
commit 39a4788cc4
21 changed files with 11041 additions and 251 deletions

67
app/api/test-api/route.ts Normal file
View File

@@ -0,0 +1,67 @@
import { type NextRequest, NextResponse } from "next/server"
import { createOpenAI } from "@ai-sdk/openai"
import { openai } from "@ai-sdk/openai"
import { generateText } from "ai"
export async function POST(request: NextRequest) {
try {
const { provider, apiKey } = await request.json()
if (!provider || !apiKey) {
return NextResponse.json({ error: "缺少必要參數" }, { status: 400 })
}
let model
let modelName: string
if (provider === "openai") {
// Test OpenAI
modelName = "gpt-4o-mini"
model = openai(modelName, { apiKey })
} else {
// Test DeepSeek
modelName = "deepseek-chat"
const deepseek = createOpenAI({
apiKey: apiKey,
baseURL: "https://api.deepseek.com",
})
model = deepseek(modelName)
}
// Test with a simple prompt
const { text } = await generateText({
model: model,
prompt: "Hello, this is a test. Please respond with 'API connection successful!'",
maxTokens: 50,
temperature: 0,
})
return NextResponse.json({
success: true,
message: "API 連接成功!",
provider,
model: modelName,
testResponse: text
})
} catch (error: any) {
console.error("API Test Error:", error)
let errorMessage = "API 測試失敗"
if (error.message?.includes("API key")) {
errorMessage = "API 金鑰無效或已過期"
} else if (error.message?.includes("rate limit")) {
errorMessage = "API 請求次數超過限制"
} else if (error.message?.includes("quota")) {
errorMessage = "API 配額已用完"
} else if (error.message?.includes("Not Found")) {
errorMessage = "API 端點錯誤或模型不存在"
}
return NextResponse.json({
error: errorMessage,
details: error.message
}, { status: 400 })
}
}

42
app/api/test-pdf/route.ts Normal file
View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server"
import { extractTextFromPDF } from "@/lib/pdf-processor"
export async function POST(request: NextRequest) {
try {
const formData = await request.formData()
const file = formData.get("file") as File
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 })
}
if (file.type !== "application/pdf") {
return NextResponse.json({ error: "File must be PDF" }, { status: 400 })
}
console.log(`Testing PDF: ${file.name}, size: ${file.size} bytes`)
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
const result = await extractTextFromPDF(buffer)
return NextResponse.json({
success: true,
result: {
text: result.text,
textLength: result.text.length,
pageCount: result.pageCount,
isScanned: result.isScanned,
metadata: result.metadata
}
})
} catch (error) {
console.error("PDF test error:", error)
return NextResponse.json({
error: `PDF test failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
details: error instanceof Error ? error.stack : undefined
}, { status: 500 })
}
}

View File

@@ -1,30 +1,133 @@
import { type NextRequest, NextResponse } from "next/server"
import { createOpenAI } from "@ai-sdk/openai"
import { openai } from "@ai-sdk/openai"
import { generateText } from "ai"
import { extractTextFromPDF, generateTranslatedPDF, processImageFile, processPDFWithOCR, ocrLanguageMap, isImageFile, isPDFFile } from "@/lib/pdf-processor"
import { calculateCost, estimateTokens, formatTokenCount, MODEL_PRICING } from "@/lib/pricing"
import { costTracker } from "@/lib/cost-tracker"
export async function POST(request: NextRequest) {
try {
// Select AI provider based on environment variable
const aiProvider = process.env.AI_PROVIDER || "deepseek"
let model
let modelName: string
if (aiProvider === "openai") {
// Use OpenAI
modelName = process.env.OPENAI_MODEL || "gpt-4o-mini"
model = openai(modelName)
} else {
// Use DeepSeek (default)
modelName = process.env.DEEPSEEK_MODEL || "deepseek-chat"
const deepseek = createOpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: process.env.DEEPSEEK_BASE_URL || "https://api.deepseek.com",
})
model = deepseek(modelName)
}
const formData = await request.formData()
const file = formData.get("file") as File
const targetLanguage = formData.get("targetLanguage") as string
const sourceLanguage = formData.get("sourceLanguage") as string
const returnPDF = formData.get("returnPDF") === "true"
if (!file || !targetLanguage) {
return NextResponse.json({ error: "缺少必要參數" }, { status: 400 })
}
// Extract text from PDF
// Validate file type
if (!isPDFFile(file.type) && !isImageFile(file.type)) {
return NextResponse.json({ error: "請上傳 PDF 檔案或圖片檔案" }, { status: 400 })
}
// Check file size (10MB limit)
const maxSize = parseInt(process.env.MAX_FILE_SIZE || "10485760")
if (file.size > maxSize) {
return NextResponse.json({ error: "檔案太大,請上傳小於 10MB 的檔案" }, { status: 413 })
}
// Process file based on type
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
let extractedText = ""
let metadata: any = {}
try {
if (isImageFile(file.type)) {
// Image file - OCR功能已停用
console.log("Image file detected - OCR功能已停用")
extractedText = "目前僅支援包含文字的 PDF 文件,不支援圖片檔案。"
metadata = { title: file.name, type: 'image' }
} else if (isPDFFile(file.type)) {
// Process PDF file
console.log("Processing PDF file...")
const result = await extractTextFromPDF(buffer)
metadata = result.metadata
if (result.isScanned) {
// PDF is scanned or text extraction failed
console.log("Detected scanned PDF or text extraction failed")
const message = result.metadata?.message || "此 PDF 為掃描檔案,目前僅支援包含文字的 PDF 文件。"
return NextResponse.json({
error: `${message}
// For demo purposes, we'll simulate PDF text extraction
// In production, you'd use a library like pdf-parse
const pdfText = `這是從PDF提取的示例文本。在實際應用中這裡會是真實的PDF內容。
📋 可能的原因:
• PDF 是掃描的圖片檔案
• PDF 文件已加密或受保護
• PDF 內容格式特殊,無法提取文字
• PDF 文件損壞
這個應用展示了如何使用AI來翻譯文檔內容。您可以上傳任何PDF文件選擇目標語言然後獲得翻譯結果。
💡 建議:
• 嘗試其他包含純文字的 PDF 文件
• 確認 PDF 可以在其他軟體中複製文字
• 如果是掃描檔案,建議轉換為圖片格式`,
details: {
pageCount: result.pageCount,
textLength: result.metadata?.extractedTextLength || 0,
hasTextContent: result.metadata?.hasTextContent || false
}
}, { status: 400 })
} else {
// PDF has extractable text, use it directly
console.log("PDF contains extractable text, using direct extraction")
extractedText = result.text
}
}
if (!extractedText || extractedText.trim().length === 0) {
extractedText = "無法從檔案擷取文字內容。請確認檔案包含可讀取的文字或清晰的圖像。"
}
} catch (error) {
console.error("File processing error:", error)
// Provide helpful error message for PDF conversion issues
if (error instanceof Error && error.message.includes('PDF 轉圖片失敗')) {
return NextResponse.json({
error: `📄 掃描 PDF 需要額外工具支援
主要功能包括
- 支持多種語言翻譯
- 清爽的用戶介面
- 簡單易用的操作流程`
🎯 建議解決方案
1. 💡 立即可用:將 PDF 轉換為圖片格式JPG/PNG後上傳
- 使用 PDF 閱讀器截圖
- 或使用線上 PDF 轉圖片工具
2. 🔧 安裝系統工具:
• Windows: 下載安裝 ImageMagick (https://imagemagick.org/script/download.php#windows)
• Mac: brew install imagemagick
• Linux: apt-get install imagemagick
📸 提示:圖片格式的 OCR 識別效果通常比掃描 PDF 更好!`,
suggestion: "convert_to_image",
downloadLink: "https://imagemagick.org/script/download.php#windows"
}, { status: 400 })
}
extractedText = `檔案處理過程中發生錯誤:${error instanceof Error ? error.message : '未知錯誤'}`
}
// Get language name for better translation context
const languageNames: Record<string, string> = {
@@ -40,23 +143,137 @@ export async function POST(request: NextRequest) {
pt: "Português",
ru: "Русский",
ar: "العربية",
hi: "हिन्दी",
th: "ไทย",
vi: "Tiếng Việt",
}
const targetLanguageName = languageNames[targetLanguage] || targetLanguage
const sourceLanguageName = languageNames[sourceLanguage] || "自動偵測"
// Translate using AI SDK
const { text: translatedText } = await generateText({
model: "openai/gpt-4o-mini",
prompt: `請將以下文本翻譯成${targetLanguageName}。保持原文的格式和結構,只翻譯內容:
// Prepare translation prompt
const prompt = `You are a professional translator. Translate the following text from ${sourceLanguageName} to ${targetLanguageName}.
Keep the original format and structure. Only translate the content, preserving line breaks and paragraphs.
If the text appears to be an error message or system message, translate it appropriately.
${pdfText}`,
Text to translate:
${extractedText}`
// Estimate input tokens
const estimatedInputTokens = estimateTokens(prompt)
let translatedText: string
let usage: any = null
try {
// Try to translate using selected AI provider
const result = await generateText({
model: model,
prompt: prompt,
temperature: 0.3, // Lower temperature for more accurate translation
maxTokens: 4000,
})
translatedText = result.text
usage = result.usage
} catch (error) {
console.error("AI API Error:", error)
// Fallback to a simple mock translation for demo purposes
translatedText = `[模擬翻譯結果]\n\n原文內容: ${extractedText}\n\n注意: 這是模擬翻譯結果,因為 AI API 連接失敗。請檢查 API 金鑰配置。\n\n目標語言: ${targetLanguageName}\n來源語言: ${sourceLanguageName}\n\n實際應用中這裡會顯示真正的 AI 翻譯結果。`
}
// Calculate token usage and cost
const tokenUsage = {
promptTokens: usage?.promptTokens || estimatedInputTokens,
completionTokens: usage?.completionTokens || estimateTokens(translatedText),
totalTokens: (usage?.promptTokens || estimatedInputTokens) + (usage?.completionTokens || estimateTokens(translatedText))
}
const costCalculation = calculateCost(modelName, tokenUsage)
const modelDisplayName = MODEL_PRICING[modelName as keyof typeof MODEL_PRICING]?.displayName || modelName
// Track cost in accumulator (client-side will handle storage)
const costSession = {
model: modelName,
provider: aiProvider,
tokenUsage: {
promptTokens: tokenUsage.promptTokens,
completionTokens: tokenUsage.completionTokens,
totalTokens: tokenUsage.totalTokens
},
cost: {
inputCost: costCalculation.inputCost,
outputCost: costCalculation.outputCost,
totalCost: costCalculation.totalCost,
currency: costCalculation.currency
}
}
// Generate translated PDF if requested
let pdfBase64 = ""
if (returnPDF) {
try {
const pdfBytes = await generateTranslatedPDF(translatedText, metadata, targetLanguage)
pdfBase64 = Buffer.from(pdfBytes).toString('base64')
} catch (error) {
console.error("PDF generation error:", error)
// Continue without PDF generation
}
}
return NextResponse.json({
translatedText,
pdfBase64: pdfBase64,
hasPDF: pdfBase64.length > 0,
tokenUsage: {
promptTokens: tokenUsage.promptTokens,
completionTokens: tokenUsage.completionTokens,
totalTokens: tokenUsage.totalTokens,
formattedCounts: {
prompt: formatTokenCount(tokenUsage.promptTokens),
completion: formatTokenCount(tokenUsage.completionTokens),
total: formatTokenCount(tokenUsage.totalTokens)
}
},
cost: {
inputCost: costCalculation.inputCost,
outputCost: costCalculation.outputCost,
totalCost: costCalculation.totalCost,
formattedCost: costCalculation.formattedCost,
currency: costCalculation.currency
},
model: {
name: modelName,
displayName: modelDisplayName,
provider: aiProvider
},
costSession: costSession
})
return NextResponse.json({ translatedText })
} catch (error) {
console.error("翻譯錯誤:", error)
return NextResponse.json({ error: "翻譯過程中發生錯誤" }, { status: 500 })
// Check for specific error types
if (error instanceof Error) {
if (error.message.includes("API key")) {
return NextResponse.json({ error: "API 金鑰配置錯誤,請聯繫管理員" }, { status: 500 })
}
if (error.message.includes("rate limit")) {
return NextResponse.json({ error: "API 請求過於頻繁,請稍後再試" }, { status: 429 })
}
}
return NextResponse.json({ error: "翻譯過程中發生錯誤,請稍後再試" }, { status: 500 })
}
}
// OPTIONS method for CORS
export async function OPTIONS(request: NextRequest) {
return new NextResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
})
}

View File

@@ -1,122 +1,74 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(0.95 0.02 85);
--foreground: oklch(0.25 0.05 250);
--card: oklch(0.98 0.01 85);
--card-foreground: oklch(0.25 0.05 250);
--popover: oklch(0.98 0.01 85);
--popover-foreground: oklch(0.25 0.05 250);
--primary: oklch(0.3 0.08 250);
--primary-foreground: oklch(0.98 0.01 85);
--secondary: oklch(0.92 0.02 85);
--secondary-foreground: oklch(0.25 0.05 250);
--muted: oklch(0.92 0.02 85);
--muted-foreground: oklch(0.5 0.03 250);
--accent: oklch(0.65 0.18 35);
--accent-foreground: oklch(0.98 0.01 85);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.98 0.01 85);
--border: oklch(0.3 0.08 250);
--input: oklch(0.98 0.01 85);
--ring: oklch(0.65 0.18 35);
--chart-1: oklch(0.65 0.18 35);
--chart-2: oklch(0.3 0.08 250);
--chart-3: oklch(0.5 0.15 200);
--chart-4: oklch(0.7 0.12 150);
--chart-5: oklch(0.55 0.2 60);
--radius: 0.25rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: oklch(0.2 0.03 250);
--foreground: oklch(0.95 0.02 85);
--card: oklch(0.25 0.03 250);
--card-foreground: oklch(0.95 0.02 85);
--popover: oklch(0.25 0.03 250);
--popover-foreground: oklch(0.95 0.02 85);
--primary: oklch(0.95 0.02 85);
--primary-foreground: oklch(0.25 0.05 250);
--secondary: oklch(0.3 0.04 250);
--secondary-foreground: oklch(0.95 0.02 85);
--muted: oklch(0.3 0.04 250);
--muted-foreground: oklch(0.65 0.03 250);
--accent: oklch(0.65 0.18 35);
--accent-foreground: oklch(0.98 0.01 85);
--destructive: oklch(0.5 0.2 27);
--destructive-foreground: oklch(0.95 0.02 85);
--border: oklch(0.35 0.05 250);
--input: oklch(0.3 0.04 250);
--ring: oklch(0.65 0.18 35);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
@theme {
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--radius: var(--radius);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}