1096 lines
45 KiB
Python
1096 lines
45 KiB
Python
import asyncio
|
||
import io
|
||
import logging
|
||
import time
|
||
import urllib
|
||
from pathlib import Path
|
||
from typing import List, Dict, Any, Optional
|
||
from urllib.parse import quote
|
||
|
||
import uvicorn
|
||
from fastapi import FastAPI, File, Form, UploadFile, Request, HTTPException
|
||
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
||
from fastapi.templating import Jinja2Templates
|
||
|
||
from docutranslate import FileTranslater
|
||
from docutranslate.logger import translater_logger
|
||
|
||
# --- HTML模板 (JS part needs modification) ---
|
||
# language=HTML
|
||
HTML_TEMPLATE = """
|
||
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>DocuTranslate</title>
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@latest/css/pico.min.css">
|
||
<style>
|
||
body {
|
||
padding: 20px;
|
||
}
|
||
|
||
.container {
|
||
max-width: 800px;
|
||
margin: auto;
|
||
padding: 1rem;
|
||
}
|
||
|
||
.log-area {
|
||
background-color: #f5f5f5;
|
||
border: 1px solid #e0e0e0;
|
||
padding: 10px;
|
||
height: 300px;
|
||
overflow-y: scroll;
|
||
white-space: pre-wrap;
|
||
font-family: monospace;
|
||
margin-top: 1rem;
|
||
}
|
||
|
||
.error-message {
|
||
color: #d32f2f;
|
||
}
|
||
|
||
.success-message {
|
||
color: #2e7d32;
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.form-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.button-group {
|
||
margin-top: 1rem;
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
flex-wrap: wrap;
|
||
justify-content: right;
|
||
align-items: baseline;
|
||
}
|
||
.spacer{
|
||
flex-grow: 1;
|
||
}
|
||
details {
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.checkbox-group {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
#resultArea {
|
||
margin-top: 1.5rem;
|
||
padding-top: 1rem;
|
||
border-top: 1px solid #eee;
|
||
}
|
||
|
||
#downloadButtons {
|
||
display: none;
|
||
margin-top: 1rem;
|
||
}
|
||
|
||
.hidden {
|
||
display: none !important;
|
||
}
|
||
|
||
.modal {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background-color: rgba(0, 0, 0, 0.6);
|
||
z-index: 1000;
|
||
overflow: auto;
|
||
}
|
||
|
||
.modal-content {
|
||
background-color: #fff;
|
||
margin: 2% auto;
|
||
padding: 20px;
|
||
width: 90%;
|
||
max-width: 900px;
|
||
max-height: 90vh;
|
||
border-radius: 8px;
|
||
overflow: auto;
|
||
}
|
||
|
||
#previewFrame {
|
||
width: 100%;
|
||
min-height: 500px;
|
||
border: 1px solid #ddd;
|
||
}
|
||
|
||
#printFrame {
|
||
display: none;
|
||
}
|
||
|
||
/* Styles for drag and drop area */
|
||
#fileDropArea {
|
||
border: 2px dashed #ccc;
|
||
padding: 20px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out;
|
||
}
|
||
|
||
#fileDropArea.drag-over {
|
||
border-color: #1095c1; /* Pico primary color (定量替换 var(--pico-primary-focus)) */
|
||
background-color: #e7f5fa; /* Pico primary background (定量替换 var(--pico-primary-background)) */
|
||
}
|
||
|
||
#fileDropArea.file-selected {
|
||
border-color: #2e7d32; /* Pico success color (定量替换 var(--pico-form-element-valid-border-color, #2e7d32)) */
|
||
background-color: #e8f5e9; /* Light green (定量替换 var(--pico-form-element-valid-background-color, #e8f5e9)) */
|
||
}
|
||
|
||
#fileDropArea p { /* General style for <p> inside drop area */
|
||
margin: 0.5rem 0;
|
||
color: #555;
|
||
}
|
||
|
||
#fileNameDisplay {
|
||
margin-top: 0.5rem;
|
||
font-style: italic;
|
||
color: #333;
|
||
}
|
||
|
||
#fileNameDisplay.has-file {
|
||
font-style: normal;
|
||
font-weight: bold;
|
||
color: #1a531d; /* Darker green or success color (定量替换 var(--pico-form-element-valid-border-color, #1a531d)) */
|
||
}
|
||
|
||
#fileDropArea.input-error {
|
||
border-color: #d32f2f !important; /* (定量替换 var(--pico-form-element-invalid-border-color, #d32f2f)) */
|
||
}
|
||
|
||
#fileNameDisplay.input-error-text {
|
||
color: #d32f2f !important; /* (定量替换 var(--pico-form-element-invalid-border-color, #d32f2f)) */
|
||
font-weight: bold;
|
||
}
|
||
|
||
|
||
@media (max-width: 768px) {
|
||
.form-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<main class="container">
|
||
<h1>
|
||
<a href="https://github.com/xunbu/docutranslate" target="_blank">DocuTranslate</a>
|
||
</h1>
|
||
<form id="translateForm">
|
||
|
||
<!-- Modified File Input Area -->
|
||
<div class="form-group">
|
||
<label for="file">文档选择</label>
|
||
<div id="fileDropArea">
|
||
<input type="file" id="file" name="file" required style="display: none;">
|
||
<p id="fileDropPrompt">点击此处选择文件,或将文件拖拽到这里</p>
|
||
<div id="fileNameDisplay">未选择文件</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label for="to_lang">目标语言</label>
|
||
<select id="to_lang" name="to_lang">
|
||
<option value="中文">中文 (Chinese)</option>
|
||
<option value="English">英文 (English)</option>
|
||
<option value="日本語">日语 (Japanese)</option>
|
||
<option value="한국어">韩语 (Korean)</option>
|
||
<option value="Français">法语 (French)</option>
|
||
<option value="Deutsch">德语 (German)</option>
|
||
<option value="Español">西班牙语 (Spanish)</option>
|
||
<option value="Italiano">意大利语 (Italian)</option>
|
||
<option value="Português">葡萄牙语 (Portuguese)</option>
|
||
<option value="Русский">俄语 (Russian)</option>
|
||
<option value="العربية">阿拉伯语 (Arabic)</option>
|
||
<option value="हिन्दी">印地语 (Hindi)</option>
|
||
<option value="Nederlands">荷兰语 (Dutch)</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>高级选项</label>
|
||
<div class="checkbox-group">
|
||
<label for="formula_ocr"><input type="checkbox" id="formula_ocr" name="formula_ocr">公式识别</label>
|
||
<label for="code_ocr"><input type="checkbox" id="code_ocr" name="code_ocr">代码识别</label>
|
||
<label for="refine_markdown"><input type="checkbox" id="refine_markdown"
|
||
name="refine_markdown">修正文本(耗时)</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<details>
|
||
<summary>API 配置</summary>
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label for="platform_select">AI 平台</label>
|
||
<select id="platform_select" name="platform_select_ui">
|
||
<option value="custom">自定义接口</option>
|
||
<option value="https://api.openai.com/v1">OpenAI</option>
|
||
<option value="https://open.bigmodel.cn/api/paas/v4">智谱AI</option>
|
||
<option value="https://api.deepseek.com/v1">DeepSeek</option>
|
||
<option value="https://dashscope.aliyuncs.com/compatible-mode/v1">阿里云百炼
|
||
</option>
|
||
<option value="https://www.dmxapi.cn/v1">DMXAPI</option>
|
||
<option value="https://openrouter.ai/api/v1">OpenRouter</option>
|
||
<option value="https://ark.cn-beijing.volces.com/api/v3">火山引擎</option>
|
||
<option value="https://api.siliconflow.cn/v1">硅基流动</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group hidden" id="baseUrlGroup">
|
||
<label for="base_url">API 地址 (Base URL)</label>
|
||
<input type="text" id="base_url" name="base_url"
|
||
placeholder="https://api.openai.com/v1">
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="apikey">API 密钥</label>
|
||
<input type="password" id="apikey" name="apikey" placeholder="平台对应的API Key"
|
||
required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="model_id">模型 ID</label>
|
||
<input type="text" id="model_id" name="model_id" placeholder="模型id" required>
|
||
</div>
|
||
</details>
|
||
<button type="submit" id="submitButton" class="primary">开始翻译</button>
|
||
</form>
|
||
<div id="resultArea">
|
||
<p id="statusMessage"></p>
|
||
<div id="downloadButtons" class="button-group">
|
||
<h4>翻译结果</h4>
|
||
<div class="spacer"></div>
|
||
<a id="downloadMarkdown" href="#" role="button" class="outline">下载 Markdown</a>
|
||
<a id="downloadHtml" href="#" role="button" class="outline">下载 HTML</a>
|
||
<button id="downloadPdf" class="outline">下载 PDF</button>
|
||
<button id="previewHtml" class="outline">预览</button>
|
||
</div>
|
||
</div>
|
||
<h4 style="margin-top: 1.5rem;">运行日志</h4>
|
||
<div class="log-area" id="logArea"></div>
|
||
</main>
|
||
<div id="previewModal" class="modal">
|
||
<div class="modal-content">
|
||
<span id="closeModalBtn" style="cursor:pointer; float:right;">×</span>
|
||
<h3>HTML 预览</h3>
|
||
<iframe id="previewFrame"></iframe>
|
||
<div class="button-group">
|
||
<button id="printFromPreview" class="primary">打印/保存为PDF</button>
|
||
<button id="closePreviewBtn" class="outline">关闭</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<iframe id="printFrame" style="display:none;"></iframe>
|
||
|
||
<script>
|
||
const platformSelect = document.getElementById('platform_select');
|
||
const baseUrlGroup = document.getElementById('baseUrlGroup');
|
||
const baseUrlInput = document.getElementById('base_url');
|
||
const apikeyInput = document.getElementById('apikey');
|
||
const modelInput = document.getElementById('model_id');
|
||
const toLangSelect = document.getElementById('to_lang');
|
||
const formulaCheckbox = document.getElementById('formula_ocr');
|
||
const codeCheckbox = document.getElementById('code_ocr');
|
||
const refineCheckbox = document.getElementById('refine_markdown');
|
||
const form = document.getElementById('translateForm');
|
||
const submitButton = document.getElementById('submitButton');
|
||
const logArea = document.getElementById('logArea');
|
||
const statusMsg = document.getElementById('statusMessage');
|
||
const downloadBtns = document.getElementById('downloadButtons');
|
||
const markdownLink = document.getElementById('downloadMarkdown');
|
||
const htmlLink = document.getElementById('downloadHtml');
|
||
const previewHtmlBtn = document.getElementById('previewHtml');
|
||
const downloadPdfBtn = document.getElementById('downloadPdf');
|
||
const printFrameEl = document.getElementById('printFrame');
|
||
const modal = document.getElementById('previewModal');
|
||
const previewFrame = document.getElementById('previewFrame');
|
||
const closeModalButton = document.getElementById('closeModalBtn');
|
||
const closePreviewBtn = document.getElementById('closePreviewBtn');
|
||
const printFromPreview = document.getElementById('printFromPreview');
|
||
|
||
const fileInput = document.getElementById('file');
|
||
const fileDropArea = document.getElementById('fileDropArea');
|
||
const fileNameDisplay = document.getElementById('fileNameDisplay');
|
||
const fileDropPrompt = document.getElementById('fileDropPrompt');
|
||
|
||
let logPollIntervalId = null;
|
||
let statusPollIntervalId = null;
|
||
// let lastLogCount = 0; // No longer needed for fetching logs
|
||
let isTranslating = false;
|
||
|
||
function saveToStorage(key, value) {
|
||
try {
|
||
localStorage.setItem(key, value);
|
||
} catch (e) {
|
||
console.warn("保存到本地存储失败:", e);
|
||
}
|
||
}
|
||
|
||
function getFromStorage(key, defaultValue = '') {
|
||
try {
|
||
return localStorage.getItem(key) || defaultValue;
|
||
} catch (e) {
|
||
console.warn("从本地存储读取失败:", e);
|
||
return defaultValue;
|
||
}
|
||
}
|
||
|
||
function updatePlatformUI() {
|
||
const selectedPlatformValue = platformSelect.value;
|
||
apikeyInput.value = getFromStorage(`translator_platform_${selectedPlatformValue}_apikey`);
|
||
modelInput.value = getFromStorage(`translator_platform_${selectedPlatformValue}_model_id`);
|
||
if (selectedPlatformValue === 'custom') {
|
||
baseUrlGroup.classList.remove('hidden');
|
||
baseUrlInput.required = true;
|
||
baseUrlInput.value = getFromStorage('translator_platform_custom_base_url');
|
||
} else {
|
||
baseUrlGroup.classList.add('hidden');
|
||
baseUrlInput.required = false;
|
||
baseUrlInput.value = selectedPlatformValue;
|
||
}
|
||
saveToStorage('translator_last_platform', selectedPlatformValue);
|
||
}
|
||
|
||
loadSettings();
|
||
|
||
platformSelect.addEventListener('change', updatePlatformUI);
|
||
apikeyInput.addEventListener('input', (e) => saveToStorage(`translator_platform_${platformSelect.value}_apikey`, e.target.value));
|
||
modelInput.addEventListener('input', (e) => saveToStorage(`translator_platform_${platformSelect.value}_model_id`, e.target.value));
|
||
baseUrlInput.addEventListener('input', (e) => {
|
||
if (platformSelect.value === 'custom') saveToStorage('translator_platform_custom_base_url', e.target.value);
|
||
});
|
||
toLangSelect.addEventListener('change', e => saveToStorage('translator_to_lang', e.target.value));
|
||
formulaCheckbox.addEventListener('change', e => saveToStorage('translator_formula_ocr', e.target.checked));
|
||
codeCheckbox.addEventListener('change', e => saveToStorage('translator_code_ocr', e.target.checked));
|
||
refineCheckbox.addEventListener('change', e => saveToStorage('translator_refine_markdown', e.target.checked));
|
||
|
||
[closeModalButton, closePreviewBtn].forEach(elem => elem.addEventListener('click', () => modal.style.display = 'none'));
|
||
window.addEventListener('click', (event) => {
|
||
if (event.target === modal) modal.style.display = 'none';
|
||
});
|
||
printFromPreview.addEventListener('click', () => {
|
||
try {
|
||
previewFrame.contentWindow.focus();
|
||
previewFrame.contentWindow.print();
|
||
} catch (err) {
|
||
console.error('打印预览内容失败:', err);
|
||
alert('打印失败,请尝试使用浏览器的打印功能 (Ctrl+P 或 ⌘+P)。');
|
||
}
|
||
});
|
||
|
||
fileDropArea.addEventListener('click', () => {
|
||
fileInput.click();
|
||
});
|
||
|
||
fileInput.addEventListener('change', () => {
|
||
if (fileInput.files.length > 0) {
|
||
fileNameDisplay.textContent = `已选择: ${fileInput.files[0].name}`;
|
||
fileDropArea.classList.add('file-selected');
|
||
fileNameDisplay.classList.add('has-file');
|
||
fileDropPrompt.classList.add('hidden');
|
||
fileDropArea.classList.remove('input-error');
|
||
fileNameDisplay.classList.remove('input-error-text');
|
||
statusMsg.textContent = '';
|
||
statusMsg.className = '';
|
||
} else {
|
||
fileNameDisplay.textContent = '未选择文件';
|
||
fileDropArea.classList.remove('file-selected');
|
||
fileNameDisplay.classList.remove('has-file');
|
||
fileDropPrompt.classList.remove('hidden');
|
||
}
|
||
});
|
||
|
||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||
fileDropArea.addEventListener(eventName, preventDefaults, false);
|
||
});
|
||
|
||
function preventDefaults(e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}
|
||
|
||
['dragenter', 'dragover'].forEach(eventName => {
|
||
fileDropArea.addEventListener(eventName, () => {
|
||
if (!fileDropArea.classList.contains('file-selected')) {
|
||
fileDropArea.classList.add('drag-over');
|
||
}
|
||
}, false);
|
||
});
|
||
|
||
['dragleave', 'drop'].forEach(eventName => {
|
||
fileDropArea.addEventListener(eventName, () => {
|
||
fileDropArea.classList.remove('drag-over');
|
||
}, false);
|
||
});
|
||
|
||
fileDropArea.addEventListener('drop', (e) => {
|
||
const dt = e.dataTransfer;
|
||
const files = dt.files;
|
||
|
||
if (files.length > 0) {
|
||
fileInput.files = files;
|
||
const event = new Event('change', {bubbles: true});
|
||
fileInput.dispatchEvent(event);
|
||
}
|
||
}, false);
|
||
|
||
async function pollLogs() {
|
||
try {
|
||
// const response = await fetch(`/get-logs?since=${lastLogCount}`); // OLD
|
||
const response = await fetch('/get-logs'); // NEW: No 'since' parameter
|
||
if (!response.ok) {
|
||
console.warn(`Log polling failed: ${response.status}`);
|
||
return;
|
||
}
|
||
const data = await response.json();
|
||
if (data.logs && data.logs.length > 0) {
|
||
data.logs.forEach(log => {
|
||
const escapedLog = log.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||
logArea.innerHTML += escapedLog + "<br>";
|
||
});
|
||
logArea.scrollTop = logArea.scrollHeight; // Scroll to bottom
|
||
}
|
||
// lastLogCount = data.total_count; // OLD: No longer tracking count this way
|
||
} catch (error) {
|
||
console.warn("Error polling logs:", error);
|
||
}
|
||
}
|
||
|
||
async function pollStatus() {
|
||
try {
|
||
const response = await fetch('/get-status');
|
||
if (!response.ok) {
|
||
console.warn(`Status polling failed: ${response.status}`);
|
||
statusMsg.textContent = `状态更新失败 (${response.status})`;
|
||
statusMsg.className = 'error-message';
|
||
return;
|
||
}
|
||
const status = await response.json();
|
||
statusMsg.textContent = status.status_message || '正在获取状态...';
|
||
statusMsg.className = status.error_flag ? 'error-message' : 'success-message';
|
||
|
||
if (!status.is_processing) {
|
||
stopPolling();
|
||
submitButton.disabled = false;
|
||
submitButton.removeAttribute('aria-busy');
|
||
submitButton.textContent = '开始翻译';
|
||
submitButton.classList.remove('secondary', 'contrast');
|
||
submitButton.classList.add('primary');
|
||
isTranslating = false;
|
||
|
||
if (status.download_ready && !status.error_flag) {
|
||
markdownLink.href = status.markdown_url;
|
||
markdownLink.setAttribute('download', status.original_filename_stem + '_translated.md');
|
||
htmlLink.href = status.html_url;
|
||
htmlLink.setAttribute('download', status.original_filename_stem + '_translated.html');
|
||
|
||
let htmlUrl = status.html_url;
|
||
let fileName = status.original_filename_stem;
|
||
|
||
previewHtmlBtn.onclick = function () {
|
||
const currentHtmlUrl = htmlUrl;
|
||
const currentFileName = fileName;
|
||
fetch(currentHtmlUrl)
|
||
.then(resp => {
|
||
if (!resp.ok) throw new Error(`HTTP error ${resp.status}`);
|
||
return resp.text();
|
||
})
|
||
.then(html => {
|
||
const blob = new Blob([html], {type: 'text/html'});
|
||
const blobUrl = URL.createObjectURL(blob);
|
||
previewFrame.src = blobUrl;
|
||
previewFrame.onload = function () {
|
||
try {
|
||
previewFrame.contentWindow.document.title = currentFileName + '_translated';
|
||
URL.revokeObjectURL(blobUrl);
|
||
} catch (e) {
|
||
console.warn('无法设置iframe标题或释放Blob URL', e);
|
||
}
|
||
};
|
||
modal.style.display = 'block';
|
||
})
|
||
.catch(err => {
|
||
console.error('预览: 获取HTML内容失败:', err);
|
||
statusMsg.textContent = '获取HTML内容失败,无法预览。';
|
||
statusMsg.className = 'error-message';
|
||
});
|
||
};
|
||
|
||
downloadPdfBtn.onclick = function () {
|
||
downloadPdfBtn.disabled = true;
|
||
downloadPdfBtn.textContent = '准备PDF...';
|
||
const currentHtmlUrl = htmlUrl;
|
||
const currentFileName = fileName;
|
||
const iframe = printFrameEl;
|
||
|
||
fetch(currentHtmlUrl)
|
||
.then(resp => {
|
||
if (!resp.ok) throw new Error(`HTTP error ${resp.status} for PDF HTML`);
|
||
return resp.text();
|
||
})
|
||
.then(htmlContent => {
|
||
iframe.onload = () => {
|
||
iframe.onload = null;
|
||
try {
|
||
const iframeWindow = iframe.contentWindow;
|
||
if (!iframeWindow) throw new Error("无法访问打印框架。");
|
||
iframeWindow.document.title = currentFileName + '_translated.pdf';
|
||
iframeWindow.focus();
|
||
iframeWindow.print();
|
||
} catch (err) {
|
||
console.error('打印PDF出错:', err);
|
||
statusMsg.textContent = '无法直接生成PDF。请预览HTML后,使用浏览器的打印功能 (Ctrl+P) 保存。';
|
||
statusMsg.className = 'error-message';
|
||
} finally {
|
||
setTimeout(() => {
|
||
downloadPdfBtn.disabled = false;
|
||
downloadPdfBtn.textContent = '下载 PDF';
|
||
}, 2000);
|
||
}
|
||
};
|
||
iframe.srcdoc = htmlContent;
|
||
})
|
||
.catch(err => {
|
||
console.error('PDF生成: 获取HTML内容失败:', err);
|
||
statusMsg.textContent = '获取HTML内容失败,无法生成PDF。请尝试预览。';
|
||
statusMsg.className = 'error-message';
|
||
downloadPdfBtn.disabled = false;
|
||
downloadPdfBtn.textContent = '下载 PDF';
|
||
});
|
||
};
|
||
downloadBtns.style.display = 'flex';
|
||
} else {
|
||
downloadBtns.style.display = 'none';
|
||
}
|
||
} else {
|
||
submitButton.textContent = '取消翻译';
|
||
submitButton.classList.remove('primary');
|
||
submitButton.classList.add('secondary');
|
||
isTranslating = true;
|
||
submitButton.disabled = false;
|
||
submitButton.removeAttribute('aria-busy');
|
||
downloadBtns.style.display = 'none';
|
||
}
|
||
} catch (error) {
|
||
console.error("Error polling status:", error);
|
||
statusMsg.textContent = '状态更新出错。';
|
||
statusMsg.className = 'error-message';
|
||
}
|
||
}
|
||
|
||
function startPolling() {
|
||
stopPolling();
|
||
// lastLogCount = 0; // No longer needed
|
||
logArea.innerHTML = ''; // Clear log area for new task
|
||
pollLogs(); // Initial poll for logs
|
||
pollStatus(); // Initial poll for status
|
||
logPollIntervalId = setInterval(pollLogs, 2000);
|
||
statusPollIntervalId = setInterval(pollStatus, 1500);
|
||
}
|
||
|
||
function stopPolling() {
|
||
if (logPollIntervalId) clearInterval(logPollIntervalId);
|
||
if (statusPollIntervalId) clearInterval(statusPollIntervalId);
|
||
logPollIntervalId = null;
|
||
statusPollIntervalId = null;
|
||
setTimeout(pollLogs, 500);
|
||
}
|
||
|
||
function loadSettings() {
|
||
platformSelect.value = getFromStorage('translator_last_platform', 'custom');
|
||
updatePlatformUI();
|
||
toLangSelect.value = getFromStorage('translator_to_lang', '中文');
|
||
formulaCheckbox.checked = getFromStorage('translator_formula_ocr') === 'true';
|
||
codeCheckbox.checked = getFromStorage('translator_code_ocr') === 'true';
|
||
refineCheckbox.checked = getFromStorage('translator_refine_markdown') === 'true';
|
||
}
|
||
|
||
async function cancelTranslation() {
|
||
submitButton.disabled = true;
|
||
submitButton.textContent = '正在取消...';
|
||
submitButton.setAttribute('aria-busy', 'true');
|
||
|
||
try {
|
||
const response = await fetch('/cancel-translate', {method: 'POST'});
|
||
const result = await response.json();
|
||
|
||
if (response.ok && result.cancelled) {
|
||
statusMsg.textContent = result.message || '取消请求已发送。';
|
||
statusMsg.className = '';
|
||
} else {
|
||
statusMsg.textContent = result.message || '取消失败。';
|
||
statusMsg.className = 'error-message';
|
||
submitButton.disabled = false;
|
||
submitButton.textContent = '取消翻译';
|
||
submitButton.removeAttribute('aria-busy');
|
||
}
|
||
} catch (error) {
|
||
console.error('取消请求失败:', error);
|
||
statusMsg.textContent = '取消请求发送失败。';
|
||
statusMsg.className = 'error-message';
|
||
submitButton.disabled = false;
|
||
submitButton.textContent = '取消翻译';
|
||
submitButton.removeAttribute('aria-busy');
|
||
}
|
||
}
|
||
|
||
form.addEventListener('submit', async function (event) {
|
||
event.preventDefault();
|
||
|
||
if (isTranslating) {
|
||
await cancelTranslation();
|
||
return;
|
||
}
|
||
|
||
if (fileInput.files.length === 0) {
|
||
statusMsg.textContent = '请选择一个文件进行翻译。';
|
||
statusMsg.className = 'error-message';
|
||
fileNameDisplay.textContent = '请选择文件!';
|
||
fileNameDisplay.classList.add('input-error-text');
|
||
fileDropArea.classList.add('input-error');
|
||
fileDropPrompt.classList.remove('hidden');
|
||
setTimeout(() => {
|
||
fileDropArea.classList.remove('input-error');
|
||
fileNameDisplay.classList.remove('input-error-text');
|
||
if (fileNameDisplay.textContent === '请选择文件!') {
|
||
fileNameDisplay.textContent = '未选择文件';
|
||
}
|
||
if (fileInput.files.length === 0) {
|
||
fileDropPrompt.classList.remove('hidden');
|
||
}
|
||
|
||
}, 3000);
|
||
return;
|
||
}
|
||
|
||
stopPolling();
|
||
submitButton.disabled = true;
|
||
submitButton.setAttribute('aria-busy', 'true');
|
||
submitButton.textContent = '初始化...';
|
||
logArea.innerHTML = '';
|
||
statusMsg.textContent = '正在提交任务...';
|
||
statusMsg.className = '';
|
||
downloadBtns.style.display = 'none';
|
||
// lastLogCount = 0; // No longer needed
|
||
|
||
const formData = new FormData(form);
|
||
try {
|
||
const response = await fetch('/translate', {method: 'POST', body: formData});
|
||
const result = await response.json();
|
||
if (response.ok && result.task_started) {
|
||
statusMsg.textContent = result.message || '任务已开始,正在处理...';
|
||
statusMsg.className = '';
|
||
submitButton.textContent = '取消翻译';
|
||
submitButton.classList.remove('primary');
|
||
submitButton.classList.add('secondary');
|
||
isTranslating = true;
|
||
submitButton.removeAttribute('aria-busy');
|
||
startPolling();
|
||
} else {
|
||
statusMsg.textContent = result.message || `请求失败 (${response.status})`;
|
||
statusMsg.className = 'error-message';
|
||
submitButton.disabled = false;
|
||
submitButton.removeAttribute('aria-busy');
|
||
submitButton.textContent = '开始翻译';
|
||
isTranslating = false;
|
||
}
|
||
} catch (error) {
|
||
console.error('请求失败:', error);
|
||
statusMsg.textContent = '请求翻译失败,请检查网络或服务状态。';
|
||
statusMsg.className = 'error-message';
|
||
submitButton.disabled = false;
|
||
submitButton.removeAttribute('aria-busy');
|
||
submitButton.textContent = '开始翻译';
|
||
isTranslating = false;
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>"""
|
||
|
||
app = FastAPI()
|
||
|
||
# --- 全局配置 ---
|
||
log_queue: Optional[asyncio.Queue] = None # Will be initialized in startup_event
|
||
current_state: Dict[str, Any] = {
|
||
"is_processing": False,
|
||
"status_message": "空闲",
|
||
"error_flag": False,
|
||
"download_ready": False,
|
||
"markdown_content": None,
|
||
"html_content": None,
|
||
"original_filename_stem": None,
|
||
"task_start_time": 0,
|
||
"task_end_time": 0,
|
||
"current_task_ref": None,
|
||
}
|
||
templates = Jinja2Templates(directory=".")
|
||
MAX_LOG_HISTORY = 200 # Max items for the persistent log_history list
|
||
log_history: List[str] = [] # Keeps a longer history, not directly for "unread"
|
||
|
||
|
||
# --- 日志处理器 ---
|
||
class QueueAndHistoryHandler(logging.Handler):
|
||
def __init__(self, queue_ref: asyncio.Queue, history_list_ref: List[str], max_history_items: int):
|
||
super().__init__()
|
||
self.queue = queue_ref
|
||
self.history_list = history_list_ref
|
||
self.max_history = max_history_items
|
||
|
||
def emit(self, record: logging.LogRecord):
|
||
log_entry = self.format(record)
|
||
|
||
# Add to the persistent history (capped)
|
||
self.history_list.append(log_entry)
|
||
if len(self.history_list) > self.max_history:
|
||
del self.history_list[:len(self.history_list) - self.max_history]
|
||
|
||
# Add to the "unread" queue for frontend consumption
|
||
try:
|
||
# Ensure self.queue is not None (it's initialized at startup)
|
||
if self.queue is not None:
|
||
main_loop = getattr(app.state, "main_event_loop", None)
|
||
if main_loop and main_loop.is_running():
|
||
main_loop.call_soon_threadsafe(self.queue.put_nowait, log_entry)
|
||
else:
|
||
self.queue.put_nowait(log_entry) # Fallback
|
||
else:
|
||
print(f"CRITICAL: Log queue not initialized. Log: {log_entry}")
|
||
except asyncio.QueueFull:
|
||
print(f"Log queue is full. Log dropped: {log_entry}") # Or handle differently
|
||
except Exception as e:
|
||
print(f"Error putting log to queue: {e}. Log: {log_entry}")
|
||
|
||
|
||
# --- 应用生命周期事件 ---
|
||
@app.on_event("startup")
|
||
async def startup_event():
|
||
global log_queue
|
||
app.state.main_event_loop = asyncio.get_running_loop()
|
||
log_queue = asyncio.Queue() # Initialize the global log_queue
|
||
|
||
for handler in translater_logger.handlers[:]:
|
||
translater_logger.removeHandler(handler)
|
||
|
||
queue_handler = QueueAndHistoryHandler(log_queue, log_history, MAX_LOG_HISTORY)
|
||
queue_handler.setLevel(logging.INFO)
|
||
queue_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
|
||
|
||
translater_logger.addHandler(queue_handler)
|
||
translater_logger.propagate = False
|
||
translater_logger.setLevel(logging.INFO)
|
||
|
||
log_history.clear()
|
||
while not log_queue.empty(): # Clear queue just in case
|
||
try:
|
||
log_queue.get_nowait()
|
||
except asyncio.QueueEmpty:
|
||
break
|
||
|
||
translater_logger.info("应用启动完成,日志队列/历史处理器已正确配置。")
|
||
|
||
|
||
# --- Background Task Logic ---
|
||
async def _perform_translation(params: Dict[str, Any], file_contents: bytes, original_filename: str):
|
||
global current_state
|
||
|
||
translater_logger.info(f"后台翻译任务开始: 文件 '{original_filename}'")
|
||
current_state["status_message"] = f"正在处理 '{original_filename}'..."
|
||
|
||
try:
|
||
translater_logger.info(f"使用 Base URL: {params['base_url']}, Model: {params['model_id']}")
|
||
translater_logger.info(f"文件大小: {len(file_contents)} 字节。目标语言: {params['to_lang']}")
|
||
translater_logger.info(
|
||
f"选项 - 公式: {params['formula_ocr']}, 代码: {params['code_ocr']}, 修正: {params['refine_markdown']}")
|
||
|
||
ft = FileTranslater(
|
||
base_url=params['base_url'],
|
||
key=params['apikey'],
|
||
model_id=params['model_id'],
|
||
tips=False
|
||
)
|
||
await ft.translate_bytes_async(
|
||
name=original_filename,
|
||
file=file_contents,
|
||
to_lang=params['to_lang'],
|
||
formula=params['formula_ocr'],
|
||
code=params['code_ocr'],
|
||
refine=params['refine_markdown'],
|
||
save=False
|
||
)
|
||
|
||
md_content = ft.export_to_markdown()
|
||
html_content = ft.export_to_html(title=current_state["original_filename_stem"])
|
||
end_time = time.time()
|
||
duration = end_time - current_state["task_start_time"]
|
||
|
||
current_state.update({
|
||
"markdown_content": md_content,
|
||
"html_content": html_content,
|
||
"status_message": f"翻译成功!用时 {duration:.2f} 秒。",
|
||
"download_ready": True,
|
||
"error_flag": False,
|
||
"task_end_time": end_time,
|
||
})
|
||
translater_logger.info(f"翻译成功完成,用时 {duration:.2f} 秒。")
|
||
|
||
except asyncio.CancelledError:
|
||
end_time = time.time()
|
||
duration = end_time - current_state["task_start_time"]
|
||
translater_logger.info(f"翻译任务 '{original_filename}' 已被取消 (用时 {duration:.2f} 秒).")
|
||
current_state.update({
|
||
"status_message": f"翻译任务已取消(若有转换任务仍会后台进行) (用时 {duration:.2f} 秒).",
|
||
"error_flag": False, # Cancellation is not an error in this context
|
||
"download_ready": False,
|
||
"markdown_content": None,
|
||
"html_content": None,
|
||
"task_end_time": end_time,
|
||
})
|
||
except Exception as e:
|
||
end_time = time.time()
|
||
duration = end_time - current_state["task_start_time"]
|
||
error_message = f"翻译失败: {e}"
|
||
translater_logger.error(error_message, exc_info=True)
|
||
current_state.update({
|
||
"status_message": f"翻译过程中发生错误 (用时 {duration:.2f} 秒): {e}",
|
||
"error_flag": True,
|
||
"download_ready": False,
|
||
"markdown_content": None,
|
||
"html_content": None,
|
||
"task_end_time": end_time,
|
||
})
|
||
finally:
|
||
current_state["is_processing"] = False
|
||
current_state["current_task_ref"] = None
|
||
translater_logger.info(f"后台翻译任务 '{original_filename}' 处理结束。")
|
||
|
||
|
||
# --- API Endpoints ---
|
||
@app.get("/", response_class=HTMLResponse)
|
||
async def main_page(request: Request):
|
||
return HTMLResponse(content=HTML_TEMPLATE)
|
||
|
||
|
||
@app.post("/translate")
|
||
async def handle_translate(
|
||
base_url: str = Form(...),
|
||
apikey: str = Form(...),
|
||
model_id: str = Form(...),
|
||
to_lang: str = Form("中文"),
|
||
formula_ocr: bool = Form(False),
|
||
code_ocr: bool = Form(False),
|
||
refine_markdown: bool = Form(False),
|
||
file: UploadFile = File(...)
|
||
):
|
||
global current_state, log_queue, log_history
|
||
if current_state["is_processing"] and \
|
||
current_state["current_task_ref"] and \
|
||
not current_state["current_task_ref"].done():
|
||
return JSONResponse(
|
||
status_code=429,
|
||
content={"task_started": False, "message": "另一个翻译任务正在进行中,请稍后再试。"}
|
||
)
|
||
|
||
if not file or not file.filename:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"task_started": False, "message": "没有选择文件或文件无效。"}
|
||
)
|
||
|
||
current_state["is_processing"] = True
|
||
original_filename_for_init = file.filename or "uploaded_file"
|
||
|
||
current_state.update({
|
||
"status_message": "任务初始化中...",
|
||
"error_flag": False,
|
||
"download_ready": False,
|
||
"markdown_content": None,
|
||
"html_content": None,
|
||
"original_filename_stem": Path(original_filename_for_init).stem,
|
||
"task_start_time": time.time(),
|
||
"task_end_time": 0,
|
||
"current_task_ref": None,
|
||
})
|
||
|
||
# Clear logs for the new task
|
||
log_history.clear()
|
||
if log_queue: # Ensure log_queue is initialized
|
||
while not log_queue.empty():
|
||
try:
|
||
log_queue.get_nowait()
|
||
except asyncio.QueueEmpty:
|
||
break
|
||
|
||
# Add initial log entry for the new task
|
||
# We create a LogRecord manually to ensure it goes through the formatter and handler
|
||
initial_log_msg = f"收到新的翻译请求: {original_filename_for_init}"
|
||
if translater_logger.handlers and isinstance(translater_logger.handlers[0], QueueAndHistoryHandler):
|
||
# Use the existing handler to format and queue/store the log
|
||
record = logging.LogRecord(
|
||
name=translater_logger.name, level=logging.INFO, pathname="", lineno=0,
|
||
msg=initial_log_msg, args=(), exc_info=None, func=""
|
||
)
|
||
translater_logger.handlers[0].emit(record) # This will add to both queue and history
|
||
else: # Fallback if handler setup is unusual
|
||
translater_logger.info(initial_log_msg)
|
||
|
||
try:
|
||
file_contents = await file.read()
|
||
original_filename = file.filename
|
||
await file.close()
|
||
|
||
task_params = {
|
||
"base_url": base_url, "apikey": apikey, "model_id": model_id,
|
||
"to_lang": to_lang, "formula_ocr": formula_ocr,
|
||
"code_ocr": code_ocr, "refine_markdown": refine_markdown,
|
||
}
|
||
|
||
loop = asyncio.get_running_loop()
|
||
task = loop.create_task(
|
||
_perform_translation(task_params, file_contents, original_filename)
|
||
)
|
||
current_state["current_task_ref"] = task
|
||
|
||
return JSONResponse(content={"task_started": True, "message": "翻译任务已成功启动,请稍候..."})
|
||
except Exception as e:
|
||
translater_logger.error(f"启动翻译任务失败: {e}", exc_info=True)
|
||
current_state["is_processing"] = False
|
||
current_state["status_message"] = f"启动任务失败: {e}"
|
||
current_state["error_flag"] = True
|
||
current_state["current_task_ref"] = None
|
||
return JSONResponse(status_code=500, content={"task_started": False, "message": f"启动翻译任务时出错: {e}"})
|
||
|
||
|
||
@app.post("/cancel-translate")
|
||
async def cancel_translate_task():
|
||
global current_state
|
||
if not current_state["is_processing"] or not current_state["current_task_ref"]:
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"cancelled": False, "message": "没有正在进行的翻译任务可取消。"}
|
||
)
|
||
|
||
task_to_cancel: Optional[asyncio.Task] = current_state["current_task_ref"]
|
||
|
||
if not task_to_cancel or task_to_cancel.done():
|
||
current_state["is_processing"] = False
|
||
current_state["current_task_ref"] = None
|
||
return JSONResponse(
|
||
status_code=400,
|
||
content={"cancelled": False, "message": "任务已完成或已被取消。"}
|
||
)
|
||
|
||
translater_logger.info("收到取消翻译任务的请求。")
|
||
task_to_cancel.cancel()
|
||
current_state["status_message"] = "正在取消任务..."
|
||
|
||
try:
|
||
await asyncio.wait_for(task_to_cancel, timeout=2.0)
|
||
except asyncio.CancelledError:
|
||
translater_logger.info("任务已成功取消并结束。")
|
||
except asyncio.TimeoutError:
|
||
translater_logger.warning("任务取消请求已发送,但任务未在2秒内结束。可能仍在清理中。")
|
||
except Exception as e:
|
||
translater_logger.error(f"等待任务取消时发生意外错误: {e}")
|
||
|
||
return JSONResponse(content={"cancelled": True, "message": "取消请求已发送。请等待状态更新。"})
|
||
|
||
|
||
@app.get("/get-status")
|
||
async def get_status():
|
||
global current_state
|
||
status_data = {
|
||
"is_processing": current_state["is_processing"],
|
||
"status_message": current_state["status_message"],
|
||
"error_flag": current_state["error_flag"],
|
||
"download_ready": current_state["download_ready"],
|
||
"original_filename_stem": current_state["original_filename_stem"],
|
||
"markdown_url": f"/download/markdown/{current_state['original_filename_stem']}_translated.md" if current_state[
|
||
"download_ready"] and
|
||
current_state[
|
||
"original_filename_stem"] else None,
|
||
"html_url": f"/download/html/{current_state['original_filename_stem']}_translated.html" if current_state[
|
||
"download_ready"] and
|
||
current_state[
|
||
"original_filename_stem"] else None,
|
||
"task_start_time": current_state["task_start_time"],
|
||
"task_end_time": current_state["task_end_time"],
|
||
}
|
||
return JSONResponse(content=status_data)
|
||
|
||
|
||
@app.get("/get-logs")
|
||
async def get_logs_from_queue(): # Renamed for clarity, though path is the same
|
||
global log_queue
|
||
new_logs = []
|
||
if log_queue: # Ensure log_queue is initialized
|
||
while not log_queue.empty():
|
||
try:
|
||
log_entry = log_queue.get_nowait() # Consume from queue
|
||
new_logs.append(log_entry)
|
||
log_queue.task_done() # Important for queue management if using join() elsewhere
|
||
except asyncio.QueueEmpty:
|
||
break
|
||
# No total_count, as the frontend just appends what it receives
|
||
return JSONResponse(content={"logs": new_logs})
|
||
|
||
|
||
@app.get("/download/markdown/{filename_with_ext}")
|
||
async def download_markdown(filename_with_ext: str):
|
||
if not current_state["download_ready"] or not current_state["markdown_content"] or not current_state[
|
||
"original_filename_stem"]:
|
||
raise HTTPException(status_code=404, detail="Markdown 内容尚未准备好或不可用。")
|
||
|
||
requested_stem = Path(filename_with_ext).stem.replace("_translated", "")
|
||
if requested_stem != current_state["original_filename_stem"]:
|
||
raise HTTPException(status_code=404, detail="请求的文件名与当前结果不符。")
|
||
|
||
actual_filename = f"{current_state['original_filename_stem']}_translated.md"
|
||
return StreamingResponse(
|
||
io.StringIO(current_state["markdown_content"]),
|
||
media_type="text/markdown",
|
||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(actual_filename, safe='', encoding='utf-8')}"}
|
||
)
|
||
|
||
|
||
@app.get("/download/html/{filename_with_ext}")
|
||
async def download_html(filename_with_ext: str):
|
||
if not current_state["download_ready"] or not current_state["html_content"] or not current_state[
|
||
"original_filename_stem"]:
|
||
raise HTTPException(status_code=404, detail="HTML 内容尚未准备好或不可用。")
|
||
|
||
requested_stem = Path(filename_with_ext).stem.replace("_translated", "")
|
||
if requested_stem != current_state["original_filename_stem"]:
|
||
raise HTTPException(status_code=404, detail="请求的文件名与当前结果不符。")
|
||
|
||
actual_filename = f"{current_state['original_filename_stem']}_translated.html"
|
||
return HTMLResponse(
|
||
content=current_state["html_content"],
|
||
media_type="text/html",
|
||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(actual_filename, safe='', encoding='utf-8')}"}
|
||
)
|
||
|
||
|
||
def run_app():
|
||
print("正在启动 DocuTranslate")
|
||
print("请访问 http://127.0.0.1:8010")
|
||
uvicorn.run(app, host="127.0.0.1", port=8010, workers=1)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
run_app()
|