-
+
@@ -313,130 +185,146 @@ HTML_TEMPLATE_STR = """
-
+
@@ -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('&', '&').replace('<', '<').replace('>', '>')
+ yield f"data: {escaped_message}
\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)
-async def main_page_get_endpoint(request: Request):
- # 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.
- # This logic might be redundant if SSE handles logs independently of page reloads.
- if not current_translation_state["is_processing"]:
+async def main_page():
+ # 如果没有处理中的任务,清空日志队列
+ if not current_state["is_processing"]:
while not log_queue.empty():
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()
except asyncio.QueueEmpty:
break
- context = {"request": request, "config": {}, "message": None, "error": False, "download_ready": False}
- # If you are using Jinja2Templates with a file:
- # 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}
\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.
+ # 返回HTML模板
+ return HTMLResponse(content=HTML_TEMPLATE)
@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")
-@app.post("/translate", response_class=JSONResponse)
-async def handle_translate_endpoint(
- request: Request, # Keep request if needed for other things, like client IP
- 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),
+@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(...)
):
- 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,
- # 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.
+ # 设置处理状态
+ current_state["is_processing"] = True
+
+ # 清空日志队列
while not log_queue.empty():
try:
item = log_queue.get_nowait()
- if item is SHUTDOWN_SENTINEL: # Put sentinel back if accidentally removed
- log_queue.put_nowait(SHUTDOWN_SENTINEL)
+ if item is SHUTDOWN_SENTINEL:
+ await log_queue.put(SHUTDOWN_SENTINEL)
log_queue.task_done()
except asyncio.QueueEmpty:
break
translater_logger.info("收到翻译请求。")
- response_data = {"error": False, "message": "", "download_ready": False, "markdown_url": None, "html_url": None,
- "original_filename_stem": None}
+ response_data = {
+ "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:
- file_contents = await file.read() # Read file contents
- original_filename = file.filename if file.filename else "uploaded_file"
- current_translation_state["original_filename_stem"] = Path(original_filename).stem
- response_data["original_filename_stem"] = current_translation_state["original_filename_stem"]
+ # 读取文件内容
+ file_contents = await file.read()
+ original_filename = file.filename or "uploaded_file"
+ 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)} 字节。")
+ # 创建翻译器并翻译
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(
- ft.translate_bytes, name=original_filename, file=file_contents, to_lang=to_lang,
- formula=formula_ocr, code=code_ocr, refine=refine_markdown, save=False
- # save=False if handling content in memory
+ ft.translate_bytes,
+ name=original_filename,
+ 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_translation_state["html_content"] = ft.export_to_html(
- title=current_translation_state["original_filename_stem"]) # Pass title if your method supports it
+ # 保存翻译结果
+ current_state["markdown_content"] = ft.export_to_markdown()
+ current_state["html_content"] = ft.export_to_html(title=file_stem)
+ # 设置响应数据
response_data["message"] = "翻译成功!下载链接已生成。"
response_data["download_ready"] = True
- response_data["markdown_url"] = f"/download/markdown/{response_data['original_filename_stem']}_translated.md"
- response_data["html_url"] = f"/download/html/{response_data['original_filename_stem']}_translated.html"
+ response_data["markdown_url"] = f"/download/markdown/{file_stem}_translated.md"
+ response_data["html_url"] = f"/download/html/{file_stem}_translated.html"
+
translater_logger.info("翻译流程处理完毕。")
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["message"] = f"翻译过程中发生错误: {str(e)}"
finally:
- current_translation_state["is_processing"] = False
- if file: # Ensure file object exists
- 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.
+ current_state["is_processing"] = False
+ await file.close()
return JSONResponse(content=response_data)
# --- 下载接口 ---
@app.get("/download/markdown/{filename_with_ext}")
-async def download_markdown_endpoint(filename_with_ext: str): # filename_with_ext from URL
- # Use original_filename_stem from state to construct the expected filename for security/consistency
- if current_translation_state["markdown_content"] and current_translation_state["original_filename_stem"]:
- # 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="文件名不匹配或内容不可用。")
+async def download_markdown(filename_with_ext: str):
+ if not current_state["markdown_content"] or not current_state["original_filename_stem"]:
+ raise HTTPException(status_code=404, detail="无 Markdown 翻译内容可用。")
- return StreamingResponse(io.StringIO(current_translation_state["markdown_content"]), media_type="text/markdown",
- headers={"Content-Disposition": f"attachment; filename=\"{actual_filename}\""})
- raise HTTPException(status_code=404, detail="无 Markdown 翻译内容可用。")
+ 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=\"{actual_filename}\""}
+ )
@app.get("/download/html/{filename_with_ext}")
-async def download_html_endpoint(filename_with_ext: str):
- if current_translation_state["html_content"] and current_translation_state["original_filename_stem"]:
- actual_filename = f"{current_translation_state['original_filename_stem']}_translated.html"
- # if Path(filename_with_ext).stem != Path(actual_filename).stem:
- # raise HTTPException(status_code=404, detail="文件名不匹配或内容不可用。")
+async def download_html(filename_with_ext: str):
+ if not current_state["html_content"] or not current_state["original_filename_stem"]:
+ raise HTTPException(status_code=404, detail="无 HTML 翻译内容可用。")
- return HTMLResponse(content=current_translation_state["html_content"], media_type="text/html",
- headers={"Content-Disposition": f"attachment; filename=\"{actual_filename}\""})
- raise HTTPException(status_code=404, detail="无 HTML 翻译内容可用。")
+ 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=\"{actual_filename}\""}
+ )
-# --- Uvicorn 启动 ---
+# --- 启动服务 ---
if __name__ == "__main__":
- print("正在启动 FastAPI 文档翻译服务 (使用 asyncio.Queue 和 SSE)...") # Updated message
+ print("正在启动 FastAPI 文档翻译服务...")
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) # Removed reload=True for this specific test
- # Add it back if you are actively developing
\ No newline at end of file
+ uvicorn.run(app, host="127.0.0.1", port=8010)
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 742ec7d..1ba17ab 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "docutranslate"
-version = "0.1.8"
+version = "0.1.9"
description = "文件翻译工具"
readme = "README.md"
requires-python = ">=3.10"