This commit is contained in:
xunbu
2025-05-11 22:40:29 +08:00
parent 58bbc29186
commit 6896d018c8
4 changed files with 377 additions and 473 deletions

88
.idea/workspace.xml generated
View File

@@ -6,7 +6,9 @@
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="6b18b44a-df57-4212-a857-9e291ebe5dd2" name="更改" comment=""> <list default="true" id="6b18b44a-df57-4212-a857-9e291ebe5dd2" name="更改" comment="">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docutranslate/app.py" beforeDir="false" afterPath="$PROJECT_DIR$/docutranslate/app.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/docutranslate/app.py" beforeDir="false" afterPath="$PROJECT_DIR$/docutranslate/app.py" afterDir="false" />
<change beforePath="$PROJECT_DIR$/pyproject.toml" beforeDir="false" afterPath="$PROJECT_DIR$/pyproject.toml" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -32,49 +34,49 @@
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent"><![CDATA[{ <component name="PropertiesComponent">{
"keyToString": { &quot;keyToString&quot;: {
"DefaultHtmlFileTemplate": "HTML File", &quot;DefaultHtmlFileTemplate&quot;: &quot;HTML File&quot;,
"JavaScript 调试.output.html (1).executor": "Run", &quot;JavaScript 调试.output.html (1).executor&quot;: &quot;Run&quot;,
"JavaScript 调试.output.html.executor": "Run", &quot;JavaScript 调试.output.html.executor&quot;: &quot;Run&quot;,
"JavaScript 调试.regex_中文.html.executor": "Run", &quot;JavaScript 调试.regex_中文.html.executor&quot;: &quot;Run&quot;,
"JavaScript 调试.test2.html.executor": "Run", &quot;JavaScript 调试.test2.html.executor&quot;: &quot;Run&quot;,
"JavaScript 调试.test2_英文.html.executor": "Run", &quot;JavaScript 调试.test2_英文.html.executor&quot;: &quot;Run&quot;,
"JavaScript 调试.test4-1_中文.html.executor": "Run", &quot;JavaScript 调试.test4-1_中文.html.executor&quot;: &quot;Run&quot;,
"JavaScript 调试.互联网认证授权机制.html.executor": "Run", &quot;JavaScript 调试.互联网认证授权机制.html.executor&quot;: &quot;Run&quot;,
"JavaScript 调试.互联网认证授权机制_英文.html.executor": "Run", &quot;JavaScript 调试.互联网认证授权机制_英文.html.executor&quot;: &quot;Run&quot;,
"JavaScript 调试.毕业论文_英文.html.executor": "Run", &quot;JavaScript 调试.毕业论文_英文.html.executor&quot;: &quot;Run&quot;,
"ModuleVcsDetector.initialDetectionPerformed": "true", &quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
"Python 测试.Python 测试 (markdown_mask.py 内).executor": "Run", &quot;Python 测试.Python 测试 (markdown_mask.py 内).executor&quot;: &quot;Run&quot;,
"Python 测试.markdown_mask.Test.test_basic_link_masking 的 Python 测试.executor": "Run", &quot;Python 测试.markdown_mask.Test.test_basic_link_masking 的 Python 测试.executor&quot;: &quot;Run&quot;,
"Python.PDFtranslater (1).executor": "Run", &quot;Python.PDFtranslater (1).executor&quot;: &quot;Run&quot;,
"Python.PDFtranslater (2).executor": "Run", &quot;Python.PDFtranslater (2).executor&quot;: &quot;Run&quot;,
"Python.agent.executor": "Debug", &quot;Python.agent.executor&quot;: &quot;Debug&quot;,
"Python.agent_utils.executor": "Run", &quot;Python.agent_utils.executor&quot;: &quot;Run&quot;,
"Python.app.executor": "Run", &quot;Python.app.executor&quot;: &quot;Run&quot;,
"Python.convert.executor": "Run", &quot;Python.convert.executor&quot;: &quot;Run&quot;,
"Python.markdown_splitter.executor": "Debug", &quot;Python.markdown_splitter.executor&quot;: &quot;Debug&quot;,
"Python.markdown_utils.executor": "Run", &quot;Python.markdown_utils.executor&quot;: &quot;Run&quot;,
"Python.test.executor": "Run", &quot;Python.test.executor&quot;: &quot;Run&quot;,
"Python.test1.executor": "Run", &quot;Python.test1.executor&quot;: &quot;Run&quot;,
"Python.test2.executor": "Run", &quot;Python.test2.executor&quot;: &quot;Run&quot;,
"Python.test3.executor": "Run", &quot;Python.test3.executor&quot;: &quot;Run&quot;,
"Python.test4.executor": "Run", &quot;Python.test4.executor&quot;: &quot;Run&quot;,
"Python.translater.executor": "Run", &quot;Python.translater.executor&quot;: &quot;Run&quot;,
"RunOnceActivity.ShowReadmeOnStart": "true", &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager": "true", &quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager&quot;: &quot;true&quot;,
"RunOnceActivity.git.unshallow": "true", &quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
"git-widget-placeholder": "main", &quot;git-widget-placeholder&quot;: &quot;main&quot;,
"last_opened_file_path": "C:/Users/jxgm/Desktop/FileTranslate/tests", &quot;last_opened_file_path&quot;: &quot;C:/Users/jxgm/Desktop/FileTranslate/tests&quot;,
"node.js.detected.package.eslint": "true", &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
"node.js.detected.package.tslint": "true", &quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
"node.js.selected.package.eslint": "(autodetect)", &quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
"node.js.selected.package.tslint": "(autodetect)", &quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
"nodejs_package_manager_path": "npm", &quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
"settings.editor.selected.configurable": "preferences.pluginManager", &quot;settings.editor.selected.configurable&quot;: &quot;preferences.pluginManager&quot;,
"vue.rearranger.settings.migration": "true" &quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
} }
}]]></component> }</component>
<component name="RecentsManager"> <component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS"> <key name="CopyFile.RECENT_KEYS">
<recent name="C:\Users\jxgm\Desktop\FileTranslate\tests" /> <recent name="C:\Users\jxgm\Desktop\FileTranslate\tests" />
@@ -645,7 +647,7 @@
<SUITE FILE_PATH="coverage/PDFtranslate$PDFtranslater__1_.coverage" NAME="PDFtranslater (1) 覆盖结果" MODIFIED="1746633258205" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$/pdftranslate_packages" /> <SUITE FILE_PATH="coverage/PDFtranslate$PDFtranslater__1_.coverage" NAME="PDFtranslater (1) 覆盖结果" MODIFIED="1746633258205" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$/pdftranslate_packages" />
<SUITE FILE_PATH="coverage/PDFtranslate$convert.coverage" NAME="convert 覆盖结果" MODIFIED="1746596984213" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$/pdftranslate_packages/utils" /> <SUITE FILE_PATH="coverage/PDFtranslate$convert.coverage" NAME="convert 覆盖结果" MODIFIED="1746596984213" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$/pdftranslate_packages/utils" />
<SUITE FILE_PATH="coverage/PDFtranslate$agent_utils.coverage" NAME="agent_utils 覆盖结果" MODIFIED="1746617703678" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$/pdftranslate_packages/utils" /> <SUITE FILE_PATH="coverage/PDFtranslate$agent_utils.coverage" NAME="agent_utils 覆盖结果" MODIFIED="1746617703678" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$/pdftranslate_packages/utils" />
<SUITE FILE_PATH="coverage/filetranslate$app.coverage" NAME="app 覆盖结果" MODIFIED="1746971887722" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$/docutranslate" /> <SUITE FILE_PATH="coverage/filetranslate$app.coverage" NAME="app 覆盖结果" MODIFIED="1746973983293" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$/docutranslate" />
<SUITE FILE_PATH="coverage/PDFtranslate$markdown_splitter.coverage" NAME="markdown_splitter 覆盖结果" MODIFIED="1746599883603" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$/pdftranslate_packages/utils" /> <SUITE FILE_PATH="coverage/PDFtranslate$markdown_splitter.coverage" NAME="markdown_splitter 覆盖结果" MODIFIED="1746599883603" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$/pdftranslate_packages/utils" />
<SUITE FILE_PATH="coverage/filetranslate$test4.coverage" NAME="test4 覆盖结果" MODIFIED="1746887036353" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$/tests" /> <SUITE FILE_PATH="coverage/filetranslate$test4.coverage" NAME="test4 覆盖结果" MODIFIED="1746887036353" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$/tests" />
<SUITE FILE_PATH="coverage/filetranslate$test3.coverage" NAME="test3 覆盖结果" MODIFIED="1746884110572" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$/tests" /> <SUITE FILE_PATH="coverage/filetranslate$test3.coverage" NAME="test3 覆盖结果" MODIFIED="1746884110572" SOURCE_PROVIDER="com.intellij.coverage.DefaultCoverageFileProvider" RUNNER="coverage.py" COVERAGE_BY_TEST_ENABLED="false" COVERAGE_TRACING_ENABLED="false" WORKING_DIRECTORY="$PROJECT_DIR$/tests" />

View File

@@ -1,6 +1,7 @@
# 简介 # 简介
## DocuTranslate ## DocuTranslate
[![image](https://img.shields.io/badge/github-DocuTranslate-blue)](https://github.com/xunbu/docutranslate) [![image](https://img.shields.io/badge/github-DocuTranslate-blue)](https://github.com/xunbu/docutranslate)
文件翻译工具,借助[docling](https://github.com/docling-project/docling)与大语言模型实现多种格式文件的翻译 文件翻译工具,借助[docling](https://github.com/docling-project/docling)与大语言模型实现多种格式文件的翻译
@@ -26,6 +27,7 @@
# 前置条件 # 前置条件
## huggingface换源 ## huggingface换源
> 不能科学上网的友友注意了 > 不能科学上网的友友注意了
无法访问的huggingface的电脑在以下操作时请换源[点击测试](https://huggingface.co) 无法访问的huggingface的电脑在以下操作时请换源[点击测试](https://huggingface.co)
@@ -61,11 +63,21 @@ os.environ['HF_ENDPOINT'] = 'https://hf-mirror.com'
## 注意事项(第一次使用必看) ## 注意事项(第一次使用必看)
以下操作会自动从[huggingface](https://huggingface.co)下载模型windows需要使用**管理员模式**打开IDE运行脚本并按需换源[换源指南](#huggingface换源) 以下操作会自动从[huggingface](https://huggingface.co)下载模型windows需要使用**管理员模式**
打开IDE运行脚本并按需换源[换源指南](#huggingface换源)
- 第一次使用该库读取、翻译非markdown文本 - 第一次使用该库读取、翻译非markdown文本
- 第一次使用该库的公式识别或代码识别功能 - 第一次使用该库的公式识别或代码识别功能
## 使用ui界面
```python
from docutranslate import app
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=8010)
```
## 翻译文件 ## 翻译文件
```python ```python
@@ -81,7 +93,7 @@ translater.translate_file("<文件路径>", to_lang="中文")
translater.translate_file("<文件路径>", to_lang="中文", formula=True, code=True) translater.translate_file("<文件路径>", to_lang="中文", formula=True, code=True)
# 在先修复文本再翻译适用于翻译pdf但更耗时耗费 # 在先修复文本再翻译适用于翻译pdf但更耗时耗费
translater.translate_file("<文件路径>", to_lang="中文",refine=True) translater.translate_file("<文件路径>", to_lang="中文", refine=True)
``` ```
> 下载模型时请用管理员模式打开终端运行文件windows并按需换源 > 下载模型时请用管理员模式打开终端运行文件windows并按需换源
@@ -129,7 +141,7 @@ translater = FileTranslater(base_url="<baseurl>", # 默认的模型baseurl
chunksize=3500, # markdown分块长度单位byte分块越大效果越好不建议超过8000 chunksize=3500, # markdown分块长度单位byte分块越大效果越好不建议超过8000
max_concurrent=10, # 并发数受到ai平台并发量限制如果文章很长建议适当加大到20以上 max_concurrent=10, # 并发数受到ai平台并发量限制如果文章很长建议适当加大到20以上
docling_artifact=None, # 使用提前下载好的docling模型 docling_artifact=None, # 使用提前下载好的docling模型
timeout=2000,# 调用api的超时时间 timeout=2000, # 调用api的超时时间
tips=True # 开场提示 tips=True # 开场提示
) )

View File

@@ -1,7 +1,6 @@
import asyncio import asyncio
import io import io
import logging import logging
import os
from pathlib import Path from pathlib import Path
from typing import AsyncGenerator from typing import AsyncGenerator
@@ -10,18 +9,26 @@ from fastapi import FastAPI, File, Form, UploadFile, Request, HTTPException
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
# 假设这些导入能够正确找到你的库代码 # 导入文档翻译相关模块
from docutranslate import FileTranslater # Your existing FileTranslater from docutranslate import FileTranslater
from docutranslate.logger import translater_logger # Your existing logger from docutranslate.logger import translater_logger
os.environ["FASTAPI_RUNNING"] = "true" # 设置FastAPI运行标识
app = FastAPI() app = FastAPI()
# --- 异步队列和自定义日志处理器设置 --- # --- 全局配置 ---
log_queue = asyncio.Queue() SHUTDOWN_SENTINEL = object() # 哨兵对象,用于标识关闭
SHUTDOWN_SENTINEL = object() # 使用一个唯一的对象作为哨兵 log_queue = asyncio.Queue() # 日志队列
current_state = {
"markdown_content": None,
"html_content": None,
"original_filename_stem": None,
"is_processing": False
}
templates = Jinja2Templates(directory=".")
# --- 日志处理器 ---
class AsyncQueueHandler(logging.Handler): class AsyncQueueHandler(logging.Handler):
def __init__(self, queue: asyncio.Queue): def __init__(self, queue: asyncio.Queue):
super().__init__() super().__init__()
@@ -29,66 +36,43 @@ class AsyncQueueHandler(logging.Handler):
def emit(self, record: logging.LogRecord): def emit(self, record: logging.LogRecord):
log_entry = self.format(record) log_entry = self.format(record)
# 在 FastAPI 应用上下文中运行时,尝试使用 app.state.main_event_loop try:
main_loop = getattr(app.state, "main_event_loop", None) # 尝试使用主事件循环安全地添加日志到队列
if main_loop and main_loop.is_running(): main_loop = getattr(app.state, "main_event_loop", None)
main_loop.call_soon_threadsafe(self.queue.put_nowait, log_entry) if main_loop and main_loop.is_running():
else: main_loop.call_soon_threadsafe(self.queue.put_nowait, log_entry)
# 如果主循环不可用或未运行(例如,在测试中或非常早期的启动/非常晚的关闭阶段) else:
# 这是一个备用方案,但不如 call_soon_threadsafe 安全 # 备用方案
try:
# 如果在主事件循环上下文之外,或者事件循环已停止,
# put_nowait 可能仍然有效,因为它不依赖于正在运行的特定循环来放置项目
# 但理想情况下,日志记录应在主循环活跃时发生。
self.queue.put_nowait(log_entry) self.queue.put_nowait(log_entry)
except RuntimeError: # 例如,如果队列本身与已关闭的循环关联 except Exception as e:
print(f"Error putting log to queue (loop likely closed): {log_entry[:100]}...") # 记录部分日志以避免过长输出 print(f"Error putting log to queue: {e}")
self.handleError(record) # 调用基类的错误处理 self.handleError(record)
except Exception as e:
print(f"Error putting log to queue (no main loop/not running): {e}")
self.handleError(record)
# --- 应用生命周期事件 ---
@app.on_event("startup") @app.on_event("startup")
async def startup_event(): async def startup_event():
app.state.main_event_loop = asyncio.get_running_loop() app.state.main_event_loop = asyncio.get_running_loop()
# 配置日志处理器
queue_handler = AsyncQueueHandler(log_queue) queue_handler = AsyncQueueHandler(log_queue)
queue_handler.setLevel(logging.INFO) queue_handler.setLevel(logging.INFO)
ui_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') queue_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
queue_handler.setFormatter(ui_formatter)
# 检查 translater_logger 是否已经有这个类型的 handler避免重复添加 # 避免重复添加handler
if not any(isinstance(h, AsyncQueueHandler) for h in translater_logger.handlers): if not any(isinstance(h, AsyncQueueHandler) for h in translater_logger.handlers):
translater_logger.addHandler(queue_handler) translater_logger.addHandler(queue_handler)
translater_logger.info("Application startup complete. Log queue handler configured.") translater_logger.info("应用启动完成,日志队列处理器已配置。")
@app.on_event("shutdown") @app.on_event("shutdown")
async def shutdown_event(): async def shutdown_event():
translater_logger.info("Application shutting down. Signaling log streamer to stop.") translater_logger.info("应用正在关闭,通知日志流停止。")
# 向队列发送哨兵值以停止日志流生成器
await log_queue.put(SHUTDOWN_SENTINEL) await log_queue.put(SHUTDOWN_SENTINEL)
# (可选) 短暂等待,以允许生成器处理哨兵并退出 await asyncio.sleep(0.1) # 给处理器留出时间处理哨兵
await asyncio.sleep(0.1)
translater_logger.info("Log streamer signaled.")
# (可选) 清空队列中剩余的日志,如果不想在关闭时处理它们
# while not log_queue.empty():
# try:
# log_queue.get_nowait()
# log_queue.task_done()
# except asyncio.QueueEmpty:
# break
# translater_logger.info("Log queue cleared during shutdown.")
# --- 全局状态 --- # --- HTML模板 ---
current_translation_state = { HTML_TEMPLATE = """
"markdown_content": None, "html_content": None, "original_filename_stem": None,
"error": None, "is_processing": False
}
templates = Jinja2Templates(directory=".") # 假设模板在当前目录或使用字符串模板
# --- HTML 模板字符串 ---
HTML_TEMPLATE_STR = """
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
@@ -97,134 +81,31 @@ HTML_TEMPLATE_STR = """
<title>DocuTranslate</title> <title>DocuTranslate</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@latest/css/pico.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@latest/css/pico.min.css">
<style> <style>
:root { :root { --primary-color: #1e88e5; --border-radius: 0.25rem; }
--primary-color: #1e88e5; body { padding: 20px; font-family: system-ui, -apple-system, sans-serif; background-color: #f9f9f9; }
--border-radius: 0.25rem; .container { max-width: 800px; margin: auto; background-color: white; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); }
} h1 { font-size: 1.8rem; margin-bottom: 1.5rem; color: var(--primary-color); display: flex; align-items: center; gap: 0.5rem; }
body { h1 a { text-decoration: none; }
padding: 20px; h1 a:hover { text-decoration: underline; }
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; .log-area { background-color: #f5f5f5; border: 1px solid #e0e0e0; border-radius: var(--border-radius); padding: 10px; height: 200px; overflow-y: scroll; white-space: pre-wrap; word-break: break-all; font-family: monospace; font-size: 0.85em; line-height: 1.4; margin-top: 1rem; }
background-color: #f9f9f9;
}
.container {
max-width: 800px;
margin: auto;
background-color: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
h1 {
font-size: 1.8rem;
margin-bottom: 1.5rem;
color: var(--primary-color);
display: flex;
align-items: center;
gap: 0.5rem;
}
h1 a {
text-decoration: none;
}
h1 a:hover {
text-decoration: underline;
}
.log-area {
background-color: #f5f5f5;
border: 1px solid #e0e0e0;
border-radius: var(--border-radius);
padding: 10px;
height: 200px;
overflow-y: scroll;
white-space: pre-wrap;
word-break: break-all;
font-family: monospace;
font-size: 0.85em;
line-height: 1.4;
margin-top: 1rem;
}
.error-message { color: #d32f2f; font-weight: 500; } .error-message { color: #d32f2f; font-weight: 500; }
.success-message { color: #2e7d32; font-weight: 500; } .success-message { color: #2e7d32; font-weight: 500; }
.form-group { margin-bottom: 1rem; } .form-group { margin-bottom: 1rem; }
.form-group label { .form-group label { margin-bottom: 0.2rem; display: block; font-weight: 500; font-size: 0.9rem; }
margin-bottom: 0.2rem; .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
display: block; .button-group { margin-top: 1rem; display: flex; gap: 0.5rem; flex-wrap: wrap; }
font-weight: 500; summary { font-weight: 500; cursor: pointer; padding: 0.5rem 0; }
font-size: 0.9rem; details { border-bottom: 1px solid #eee; margin-bottom: 1rem; }
} .checkbox-label { display: flex; align-items: center; margin-right: 1rem; margin-bottom: 0.5rem; }
.form-grid { .checkbox-label input[type="checkbox"] { margin-right: 0.5rem; }
display: grid; .checkbox-group { display: flex; flex-wrap: wrap; margin-bottom: 1rem; }
grid-template-columns: 1fr 1fr; #resultArea { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid #eee; }
gap: 1rem; #downloadButtons { display: none; margin-top: 1rem; }
} .section-header { display: flex; align-items: center; margin-bottom: 0.5rem; font-size: 1.1rem; font-weight: 500; }
.button-group { select, input[type="text"], input[type="password"], input[type="file"] { padding: 0.5rem; border: 1px solid #ddd; border-radius: var(--border-radius); background-color: white; }
margin-top: 1rem; button, a[role="button"] { border-radius: var(--border-radius); padding: 0.5rem 1rem; }
display: flex; .options-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem; }
gap: 0.5rem; @media (max-width: 768px) { .form-grid, .options-grid { grid-template-columns: 1fr; } .container { padding: 1rem; } }
flex-wrap: wrap;
}
summary {
font-weight: 500;
cursor: pointer;
padding: 0.5rem 0;
}
details {
border-bottom: 1px solid #eee;
margin-bottom: 1rem;
}
.checkbox-label {
display: flex;
align-items: center;
margin-right: 1rem;
margin-bottom: 0.5rem;
}
.checkbox-label input[type="checkbox"] {
margin-right: 0.5rem;
}
.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;
}
.section-header {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
font-size: 1.1rem;
font-weight: 500;
}
select, input[type="text"], input[type="password"], input[type="file"] {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: var(--border-radius);
background-color: white;
}
button, a[role="button"] {
border-radius: var(--border-radius);
padding: 0.5rem 1rem;
}
.options-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
@media (max-width: 768px) {
.form-grid, .options-grid {
grid-template-columns: 1fr;
}
.container {
padding: 1rem;
}
}
</style> </style>
</head> </head>
<body> <body>
@@ -282,18 +163,9 @@ HTML_TEMPLATE_STR = """
<div class="form-group"> <div class="form-group">
<label>高级选项</label> <label>高级选项</label>
<div class="checkbox-group"> <div class="checkbox-group">
<label class="checkbox-label" for="formula_ocr"> <label class="checkbox-label" for="formula_ocr"><input type="checkbox" id="formula_ocr" name="formula_ocr">公式识别</label>
<input type="checkbox" id="formula_ocr" name="formula_ocr"> <label class="checkbox-label" for="code_ocr"><input type="checkbox" id="code_ocr" name="code_ocr">代码识别</label>
公式识别 <label class="checkbox-label" for="refine_markdown"><input type="checkbox" id="refine_markdown" name="refine_markdown">优化 Markdown</label>
</label>
<label class="checkbox-label" for="code_ocr">
<input type="checkbox" id="code_ocr" name="code_ocr">
代码识别
</label>
<label class="checkbox-label" for="refine_markdown">
<input type="checkbox" id="refine_markdown" name="refine_markdown">
优化 Markdown
</label>
</div> </div>
</div> </div>
</div> </div>
@@ -315,128 +187,144 @@ HTML_TEMPLATE_STR = """
</main> </main>
<script> <script>
const base_url_input = document.getElementById('base_url'); // 加载和保存本地存储的函数
const apikey_input = document.getElementById('apikey'); function loadFromStorage() {
const model_id_input = document.getElementById('model_id'); const inputs = {
const to_lang_input = document.getElementById('to_lang'); 'base_url': 'translator_base_url',
const formula_ocr_input = document.getElementById('formula_ocr'); 'apikey': 'translator_apikey',
const code_ocr_input = document.getElementById('code_ocr'); 'model_id': 'translator_model_id'
const refine_markdown_input = document.getElementById('refine_markdown'); };
// Load saved values from localStorage // 加载文本输入
if (localStorage.getItem('translator_base_url')) base_url_input.value = localStorage.getItem('translator_base_url'); Object.entries(inputs).forEach(([id, storageKey]) => {
if (localStorage.getItem('translator_apikey')) apikey_input.value = localStorage.getItem('translator_apikey'); const value = localStorage.getItem(storageKey);
if (localStorage.getItem('translator_model_id')) model_id_input.value = localStorage.getItem('translator_model_id'); if (value) document.getElementById(id).value = value;
if (localStorage.getItem('translator_to_lang')) { });
// 加载下拉菜单
const savedLang = localStorage.getItem('translator_to_lang'); const savedLang = localStorage.getItem('translator_to_lang');
// Find option with matching value if (savedLang) {
for (const option of to_lang_input.options) { const langSelect = document.getElementById('to_lang');
if (option.value === savedLang) { for (const option of langSelect.options) {
option.selected = true; if (option.value === savedLang) {
break; option.selected = true;
} break;
}
}
formula_ocr_input.checked = localStorage.getItem('translator_formula_ocr') === 'true';
code_ocr_input.checked = localStorage.getItem('translator_code_ocr') === 'true';
refine_markdown_input.checked = localStorage.getItem('translator_refine_markdown') === 'true';
// Save to localStorage
function saveToLocalStorage(key, value) {
try {
localStorage.setItem(key, value);
} catch (e) {
console.error("Error saving to localStorage:", e);
}
}
base_url_input.addEventListener('input', () => saveToLocalStorage('translator_base_url', base_url_input.value));
apikey_input.addEventListener('input', () => saveToLocalStorage('translator_apikey', apikey_input.value));
model_id_input.addEventListener('input', () => saveToLocalStorage('translator_model_id', model_id_input.value));
to_lang_input.addEventListener('change', () => saveToLocalStorage('translator_to_lang', to_lang_input.value));
formula_ocr_input.addEventListener('change', () => saveToLocalStorage('translator_formula_ocr', formula_ocr_input.checked));
code_ocr_input.addEventListener('change', () => saveToLocalStorage('translator_code_ocr', code_ocr_input.checked));
refine_markdown_input.addEventListener('change', () => saveToLocalStorage('translator_refine_markdown', refine_markdown_input.checked));
const form = document.getElementById('translateForm');
const submitButton = document.getElementById('submitButton');
const logArea = document.getElementById('logArea');
const statusMessageElement = document.getElementById('statusMessage');
const downloadButtonsDiv = document.getElementById('downloadButtons');
const downloadMarkdownLink = document.getElementById('downloadMarkdown');
const downloadHtmlLink = document.getElementById('downloadHtml');
if (form && submitButton) {
form.addEventListener('submit', async function(event) {
event.preventDefault();
submitButton.disabled = true;
submitButton.setAttribute('aria-busy', 'true');
submitButton.textContent = '翻译中...';
if(logArea) logArea.innerHTML = '';
statusMessageElement.textContent = '';
statusMessageElement.className = '';
downloadButtonsDiv.style.display = 'none';
const formData = new FormData(form);
try {
const response = await fetch('/translate', { method: 'POST', body: formData });
const resultData = await response.json();
if (resultData.error) {
statusMessageElement.textContent = resultData.message;
statusMessageElement.className = 'error-message';
} else {
statusMessageElement.textContent = resultData.message;
statusMessageElement.className = 'success-message';
if (resultData.download_ready) {
downloadMarkdownLink.href = resultData.markdown_url;
downloadMarkdownLink.setAttribute('download', resultData.original_filename_stem + '_translated.md');
downloadHtmlLink.href = resultData.html_url;
downloadHtmlLink.setAttribute('download', resultData.original_filename_stem + '_translated.html');
downloadButtonsDiv.style.display = 'block';
}
} }
} catch (error) {
console.error('Fetch error:', error);
statusMessageElement.textContent = '请求翻译失败,请检查网络或服务状态。';
statusMessageElement.className = 'error-message';
} finally {
submitButton.disabled = false;
submitButton.removeAttribute('aria-busy');
submitButton.textContent = '开始翻译';
} }
}
// 加载复选框
['formula_ocr', 'code_ocr', 'refine_markdown'].forEach(id => {
const storageKey = 'translator_' + id;
document.getElementById(id).checked = localStorage.getItem(storageKey) === 'true';
}); });
} }
function saveToStorage(key, value) {
try { localStorage.setItem(key, value); }
catch (e) { console.error("保存到本地存储失败:", e); }
}
// 加载保存的值
loadFromStorage();
// 设置事件监听器
['base_url', 'apikey', 'model_id'].forEach(id => {
document.getElementById(id).addEventListener('input', e =>
saveToStorage('translator_' + id, e.target.value));
});
document.getElementById('to_lang').addEventListener('change', e =>
saveToStorage('translator_to_lang', e.target.value));
['formula_ocr', 'code_ocr', 'refine_markdown'].forEach(id => {
document.getElementById(id).addEventListener('change', e =>
saveToStorage('translator_' + id, e.target.checked));
});
// 表单提交处理
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');
form.addEventListener('submit', async function(event) {
event.preventDefault();
// 提交前UI状态
submitButton.disabled = true;
submitButton.setAttribute('aria-busy', 'true');
submitButton.textContent = '翻译中...';
logArea.innerHTML = '';
statusMsg.textContent = '';
statusMsg.className = '';
downloadBtns.style.display = 'none';
try {
const response = await fetch('/translate', {
method: 'POST',
body: new FormData(form)
});
const result = await response.json();
// 处理结果
statusMsg.textContent = result.message;
statusMsg.className = result.error ? 'error-message' : 'success-message';
if (result.download_ready) {
markdownLink.href = result.markdown_url;
markdownLink.setAttribute('download', result.original_filename_stem + '_translated.md');
htmlLink.href = result.html_url;
htmlLink.setAttribute('download', result.original_filename_stem + '_translated.html');
downloadBtns.style.display = 'block';
}
} catch (error) {
console.error('请求失败:', error);
statusMsg.textContent = '请求翻译失败,请检查网络或服务状态。';
statusMsg.className = 'error-message';
} finally {
// 恢复按钮状态
submitButton.disabled = false;
submitButton.removeAttribute('aria-busy');
submitButton.textContent = '开始翻译';
}
});
// 日志流事件源
if (typeof(EventSource) !== "undefined") { if (typeof(EventSource) !== "undefined") {
let eventSource; let eventSource;
function connectEventSource() { function connectEventSource() {
if (eventSource) { if (eventSource) eventSource.close();
eventSource.close();
}
eventSource = new EventSource("/stream-logs"); eventSource = new EventSource("/stream-logs");
eventSource.onmessage = function(event) { eventSource.onmessage = function(event) {
if (logArea && event.data !== ":heartbeat") { // Ignore heartbeat messages for display if (event.data !== ":heartbeat") {
logArea.innerHTML += event.data; logArea.innerHTML += event.data;
logArea.scrollTop = logArea.scrollHeight; logArea.scrollTop = logArea.scrollHeight;
} }
}; };
eventSource.onerror = function(err) { eventSource.onerror = function(err) {
console.error("EventSource failed:", err); console.error("事件源连接失败:", err);
if (logArea) { const errorMsg = document.createElement('div');
const errorMsgDiv = document.createElement('div'); errorMsg.style.color = 'orange';
errorMsgDiv.style.color = 'orange'; errorMsg.textContent = '日志流连接暂时中断,尝试重连...';
errorMsgDiv.textContent = '日志流连接暂时中断,尝试重连...'; logArea.appendChild(errorMsg);
logArea.appendChild(errorMsgDiv); logArea.scrollTop = logArea.scrollHeight;
logArea.scrollTop = logArea.scrollHeight;
} eventSource.close();
eventSource.close(); // Close the failed source setTimeout(connectEventSource, 5000);
// Attempt to reconnect after a delay
setTimeout(connectEventSource, 5000); // Reconnect after 5 seconds
}; };
} }
connectEventSource(); // Initial connection
connectEventSource();
} else { } else {
if(logArea) logArea.innerHTML = "抱歉,您的浏览器不支持实时日志更新。"; logArea.innerHTML = "抱歉,您的浏览器不支持实时日志更新。";
} }
</script> </script>
</body> </body>
@@ -444,187 +332,189 @@ HTML_TEMPLATE_STR = """
""" """
# --- FastAPI Endpoints --- # --- 日志流处理 ---
async def log_stream_generator() -> AsyncGenerator[str, None]:
last_heartbeat = asyncio.get_event_loop().time()
heartbeat_interval = 15 # 15秒发送一次心跳
try:
while True:
try:
# 等待日志消息,带超时
log_message = await asyncio.wait_for(log_queue.get(), timeout=1.0)
# 检查关闭哨兵
if log_message is SHUTDOWN_SENTINEL:
translater_logger.info("日志流收到关闭信号,正在退出。")
log_queue.task_done()
break
# 正常处理日志
escaped_message = log_message.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
yield f"data: {escaped_message}<br>\n\n"
log_queue.task_done()
last_heartbeat = asyncio.get_event_loop().time()
except asyncio.TimeoutError:
# 超时,检查是否需要发送心跳
current_time = asyncio.get_event_loop().time()
if current_time - last_heartbeat >= heartbeat_interval:
yield "data: :heartbeat\n\n"
last_heartbeat = current_time
except asyncio.CancelledError:
translater_logger.info("日志流被取消。")
raise
except asyncio.CancelledError:
translater_logger.info("日志流任务被外部取消。")
raise
finally:
translater_logger.info("日志流生成器结束。")
# --- API端点 ---
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def main_page_get_endpoint(request: Request): async def main_page():
# Clear log queue only if not processing, to avoid clearing logs of an ongoing task # 如果没有处理中的任务,清空日志队列
# when page is reloaded. However, SSE should keep logs flowing. if not current_state["is_processing"]:
# This logic might be redundant if SSE handles logs independently of page reloads.
if not current_translation_state["is_processing"]:
while not log_queue.empty(): while not log_queue.empty():
try: try:
log_queue.get_nowait(); item = log_queue.get_nowait()
if item is SHUTDOWN_SENTINEL:
await log_queue.put(SHUTDOWN_SENTINEL)
log_queue.task_done() log_queue.task_done()
except asyncio.QueueEmpty: except asyncio.QueueEmpty:
break break
context = {"request": request, "config": {}, "message": None, "error": False, "download_ready": False} # 返回HTML模板
# If you are using Jinja2Templates with a file: return HTMLResponse(content=HTML_TEMPLATE)
# return templates.TemplateResponse("your_template_name.html", context)
# If using the string template:
jinja_env = templates.env # Or initialize a Jinja2 Environment if not using FastAPI's templates
template_obj = jinja_env.from_string(HTML_TEMPLATE_STR)
return HTMLResponse(content=template_obj.render(context))
async def log_stream_generator() -> AsyncGenerator[str, None]:
last_heartbeat_time = asyncio.get_event_loop().time()
heartbeat_interval = 15 # Send heartbeat every 15 seconds
is_shutting_down = False
try:
while not is_shutting_down: # Loop until sentinel or cancellation
log_message = None
try:
# Wait for a log message with a timeout, so we can send heartbeats
# and check for shutdown sentinel periodically.
log_message = await asyncio.wait_for(log_queue.get(), timeout=1.0)
except asyncio.TimeoutError:
# No log message in this interval, proceed to check heartbeat
pass
except asyncio.CancelledError: # Handle cancellation if client disconnects
translater_logger.info("Log stream generator cancelled by client disconnect.")
raise # Re-raise to ensure task cleanup
if log_message is SHUTDOWN_SENTINEL:
translater_logger.info("Log stream generator received shutdown sentinel. Exiting.")
log_queue.task_done() # Mark sentinel as processed
is_shutting_down = True
break # Exit the loop
if log_message: # Process actual log message
# Basic HTML escaping for log messages to prevent XSS if logs contain HTML/JS
escaped_message = log_message.replace('&', '&').replace('<', '<').replace('>', '>')
yield f"data: {escaped_message}<br>\n\n"
log_queue.task_done()
last_heartbeat_time = asyncio.get_event_loop().time() # Reset heartbeat timer on actual data
current_time = asyncio.get_event_loop().time()
if current_time - last_heartbeat_time >= heartbeat_interval:
yield "data: :heartbeat\n\n"
last_heartbeat_time = current_time
except asyncio.CancelledError: # Catch again if cancellation happens outside the get()
translater_logger.info("Log stream generator task was cancelled externally.")
# Ensure any pending item in queue due to this generator is marked done IF it was fetched
# However, at this point, it's safer to just re-raise.
raise
finally:
translater_logger.info("Log stream generator finished.")
# Ensure the queue is not blocked if join() is ever used elsewhere for this queue.
# If a log_message was retrieved but not task_done'd before cancellation/sentinel,
# this could be an issue. The current logic should cover it.
@app.get("/stream-logs") @app.get("/stream-logs")
async def stream_logs_endpoint(request: Request): async def stream_logs():
return StreamingResponse(log_stream_generator(), media_type="text/event-stream") return StreamingResponse(log_stream_generator(), media_type="text/event-stream")
@app.post("/translate", response_class=JSONResponse) @app.post("/translate")
async def handle_translate_endpoint( async def handle_translate(
request: Request, # Keep request if needed for other things, like client IP base_url: str = Form(...),
base_url: str = Form(...), apikey: str = Form(...), model_id: str = Form(...), apikey: str = Form(...),
to_lang: str = Form("中文"), formula_ocr: bool = Form(False), model_id: str = Form(...),
code_ocr: bool = Form(False), refine_markdown: bool = Form(False), to_lang: str = Form("中文"),
formula_ocr: bool = Form(False),
code_ocr: bool = Form(False),
refine_markdown: bool = Form(False),
file: UploadFile = File(...) file: UploadFile = File(...)
): ):
if current_translation_state["is_processing"]: # 检查是否有正在进行的任务
return JSONResponse(status_code=429, content={"error": True, "message": "另一个翻译任务正在进行中,请稍后再试。"}) if current_state["is_processing"]:
return JSONResponse(
status_code=429,
content={"error": True, "message": "另一个翻译任务正在进行中,请稍后再试。"}
)
current_translation_state["is_processing"] = True # 设置处理状态
# It's good practice to clear the log queue for a new task if appropriate, current_state["is_processing"] = True
# or ensure old logs don't interfere. The AsyncQueueHandler means logs are
# continuously added, so clearing here makes sense for a "fresh" log view per task. # 清空日志队列
# However, the main page GET also clears it, so be mindful of desired behavior.
# For now, let's assume logs for a new task should start fresh.
while not log_queue.empty(): while not log_queue.empty():
try: try:
item = log_queue.get_nowait() item = log_queue.get_nowait()
if item is SHUTDOWN_SENTINEL: # Put sentinel back if accidentally removed if item is SHUTDOWN_SENTINEL:
log_queue.put_nowait(SHUTDOWN_SENTINEL) await log_queue.put(SHUTDOWN_SENTINEL)
log_queue.task_done() log_queue.task_done()
except asyncio.QueueEmpty: except asyncio.QueueEmpty:
break break
translater_logger.info("收到翻译请求。") translater_logger.info("收到翻译请求。")
response_data = {"error": False, "message": "", "download_ready": False, "markdown_url": None, "html_url": None, response_data = {
"original_filename_stem": None} "error": False,
"message": "",
"download_ready": False,
"markdown_url": None,
"html_url": None,
"original_filename_stem": None
}
file_contents = None # Initialize to ensure it's defined for finally block
try: try:
file_contents = await file.read() # Read file contents # 读取文件内容
original_filename = file.filename if file.filename else "uploaded_file" file_contents = await file.read()
current_translation_state["original_filename_stem"] = Path(original_filename).stem original_filename = file.filename or "uploaded_file"
response_data["original_filename_stem"] = current_translation_state["original_filename_stem"] file_stem = Path(original_filename).stem
current_state["original_filename_stem"] = file_stem
response_data["original_filename_stem"] = file_stem
translater_logger.info(f"文件 '{original_filename}' 已上传, 大小: {len(file_contents)} 字节。") translater_logger.info(f"文件 '{original_filename}' 已上传, 大小: {len(file_contents)} 字节。")
# 创建翻译器并翻译
ft = FileTranslater(base_url=base_url, key=apikey, model_id=model_id, tips=False) ft = FileTranslater(base_url=base_url, key=apikey, model_id=model_id, tips=False)
# Run the blocking translation task in a separate thread # 在单独的线程中运行翻译任务
await asyncio.to_thread( await asyncio.to_thread(
ft.translate_bytes, name=original_filename, file=file_contents, to_lang=to_lang, ft.translate_bytes,
formula=formula_ocr, code=code_ocr, refine=refine_markdown, save=False name=original_filename,
# save=False if handling content in memory file=file_contents,
to_lang=to_lang,
formula=formula_ocr,
code=code_ocr,
refine=refine_markdown,
save=False
) )
# Assuming FileTranslater populates its internal state with translated content # 保存翻译结果
current_translation_state["markdown_content"] = ft.export_to_markdown() current_state["markdown_content"] = ft.export_to_markdown()
current_translation_state["html_content"] = ft.export_to_html( current_state["html_content"] = ft.export_to_html(title=file_stem)
title=current_translation_state["original_filename_stem"]) # Pass title if your method supports it
# 设置响应数据
response_data["message"] = "翻译成功!下载链接已生成。" response_data["message"] = "翻译成功!下载链接已生成。"
response_data["download_ready"] = True response_data["download_ready"] = True
response_data["markdown_url"] = f"/download/markdown/{response_data['original_filename_stem']}_translated.md" response_data["markdown_url"] = f"/download/markdown/{file_stem}_translated.md"
response_data["html_url"] = f"/download/html/{response_data['original_filename_stem']}_translated.html" response_data["html_url"] = f"/download/html/{file_stem}_translated.html"
translater_logger.info("翻译流程处理完毕。") translater_logger.info("翻译流程处理完毕。")
except Exception as e: except Exception as e:
translater_logger.error(f"翻译失败: {e}", exc_info=True) # exc_info=True for traceback translater_logger.error(f"翻译失败: {e}", exc_info=True)
response_data["error"] = True response_data["error"] = True
response_data["message"] = f"翻译过程中发生错误: {str(e)}" response_data["message"] = f"翻译过程中发生错误: {str(e)}"
finally: finally:
current_translation_state["is_processing"] = False current_state["is_processing"] = False
if file: # Ensure file object exists await file.close()
await file.close() # Close the UploadFile object
# Do not clear file_contents here as it's used by translate_bytes
# The content is in memory; if it were a temp file, you'd delete it here.
return JSONResponse(content=response_data) return JSONResponse(content=response_data)
# --- 下载接口 --- # --- 下载接口 ---
@app.get("/download/markdown/{filename_with_ext}") @app.get("/download/markdown/{filename_with_ext}")
async def download_markdown_endpoint(filename_with_ext: str): # filename_with_ext from URL async def download_markdown(filename_with_ext: str):
# Use original_filename_stem from state to construct the expected filename for security/consistency if not current_state["markdown_content"] or not current_state["original_filename_stem"]:
if current_translation_state["markdown_content"] and current_translation_state["original_filename_stem"]: raise HTTPException(status_code=404, detail="无 Markdown 翻译内容可用。")
# Compare requested filename stem with stored stem if necessary, or just use stored stem
actual_filename = f"{current_translation_state['original_filename_stem']}_translated.md"
# if Path(filename_with_ext).stem != Path(actual_filename).stem:
# raise HTTPException(status_code=404, detail="文件名不匹配或内容不可用。")
return StreamingResponse(io.StringIO(current_translation_state["markdown_content"]), media_type="text/markdown", actual_filename = f"{current_state['original_filename_stem']}_translated.md"
headers={"Content-Disposition": f"attachment; filename=\"{actual_filename}\""}) return StreamingResponse(
raise HTTPException(status_code=404, detail="无 Markdown 翻译内容可用。") io.StringIO(current_state["markdown_content"]),
media_type="text/markdown",
headers={"Content-Disposition": f"attachment; filename=\"{actual_filename}\""}
)
@app.get("/download/html/{filename_with_ext}") @app.get("/download/html/{filename_with_ext}")
async def download_html_endpoint(filename_with_ext: str): async def download_html(filename_with_ext: str):
if current_translation_state["html_content"] and current_translation_state["original_filename_stem"]: if not current_state["html_content"] or not current_state["original_filename_stem"]:
actual_filename = f"{current_translation_state['original_filename_stem']}_translated.html" raise HTTPException(status_code=404, detail="无 HTML 翻译内容可用。")
# if Path(filename_with_ext).stem != Path(actual_filename).stem:
# raise HTTPException(status_code=404, detail="文件名不匹配或内容不可用。")
return HTMLResponse(content=current_translation_state["html_content"], media_type="text/html", actual_filename = f"{current_state['original_filename_stem']}_translated.html"
headers={"Content-Disposition": f"attachment; filename=\"{actual_filename}\""}) return HTMLResponse(
raise HTTPException(status_code=404, detail="无 HTML 翻译内容可用。") content=current_state["html_content"],
media_type="text/html",
headers={"Content-Disposition": f"attachment; filename=\"{actual_filename}\""}
)
# --- Uvicorn 启动 --- # --- 启动服务 ---
if __name__ == "__main__": if __name__ == "__main__":
print("正在启动 FastAPI 文档翻译服务 (使用 asyncio.Queue 和 SSE)...") # Updated message print("正在启动 FastAPI 文档翻译服务...")
print("请访问 http://127.0.0.1:8010") print("请访问 http://127.0.0.1:8010")
# Consider adding reload_dirs if you have other modules like docutranslate in development uvicorn.run(app, host="127.0.0.1", port=8010)
uvicorn.run(app, host="127.0.0.1", port=8010) # Removed reload=True for this specific test
# Add it back if you are actively developing

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "docutranslate" name = "docutranslate"
version = "0.1.8" version = "0.1.9"
description = "文件翻译工具" description = "文件翻译工具"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"