修复亮/暗模式切换bug
This commit is contained in:
@@ -7,21 +7,21 @@ import socket
|
|||||||
import time
|
import time
|
||||||
from contextlib import asynccontextmanager, closing
|
from contextlib import asynccontextmanager, closing
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional, Literal
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI, File, Form, UploadFile, Request, HTTPException, Query, APIRouter, Body
|
from fastapi import FastAPI, HTTPException, APIRouter, Body, Path as FastApiPath
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, FileResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from docutranslate import FileTranslater, __version__
|
from docutranslate import FileTranslater, __version__
|
||||||
|
from docutranslate.global_values import available_packages
|
||||||
from docutranslate.logger import translater_logger
|
from docutranslate.logger import translater_logger
|
||||||
from docutranslate.translater import default_params
|
from docutranslate.translater import default_params
|
||||||
from docutranslate.utils.resource_utils import resource_path
|
from docutranslate.utils.resource_utils import resource_path
|
||||||
from docutranslate.global_values import available_packages
|
|
||||||
|
|
||||||
# --- 全局配置 ---
|
# --- 全局配置 ---
|
||||||
tasks_state: Dict[str, Dict[str, Any]] = {}
|
tasks_state: Dict[str, Dict[str, Any]] = {}
|
||||||
@@ -226,9 +226,40 @@ def _cancel_translation_logic(task_id: str):
|
|||||||
|
|
||||||
|
|
||||||
# --- FastAPI 应用和路由设置 ---
|
# --- FastAPI 应用和路由设置 ---
|
||||||
app = FastAPI(lifespan=lifespan)
|
tags_metadata = [
|
||||||
backend_router = APIRouter(prefix="/backend")
|
{
|
||||||
service_router = APIRouter(prefix="/service")
|
"name": "Service API",
|
||||||
|
"description": "核心的服务API,用于提交、管理和下载翻译任务。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Application",
|
||||||
|
"description": "应用本身的相关端点,如元信息和默认参数。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Temp",
|
||||||
|
"description": "测试用接口。",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
lifespan=lifespan,
|
||||||
|
title="DocuTranslate API",
|
||||||
|
description=f"""
|
||||||
|
DocuTranslate 后端服务 API,提供文档翻译、状态查询、结果下载等功能。
|
||||||
|
|
||||||
|
### 主要工作流程:
|
||||||
|
1. **POST /service/translate**: 提交文件和翻译参数,启动一个后台任务,并获取 `task_id`。
|
||||||
|
2. **GET /service/status/{{task_id}}**: 使用 `task_id` 轮询此端点,获取任务的实时状态。
|
||||||
|
3. **GET /service/logs/{{task_id}}**: (可选) 获取实时的翻译日志。
|
||||||
|
4. **GET /service/download/{{task_id}}/{{file_type}}**: 任务完成后 (当 `download_ready` 为 `true` 时),通过此端点下载结果文件。
|
||||||
|
|
||||||
|
**版本**: {__version__}
|
||||||
|
""",
|
||||||
|
version=__version__,
|
||||||
|
openapi_tags=tags_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
service_router = APIRouter(prefix="/service", tags=["Service API"])
|
||||||
|
|
||||||
STATIC_DIR = resource_path("static")
|
STATIC_DIR = resource_path("static")
|
||||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||||
@@ -238,30 +269,66 @@ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
|||||||
# --- Pydantic Models for Service API ---
|
# --- Pydantic Models for Service API ---
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
class TranslateServiceRequest(BaseModel):
|
class TranslateServiceRequest(BaseModel):
|
||||||
task_id: str = Field("0", description="任务ID,用于跟踪,默认为'0'")
|
task_id: str = Field(
|
||||||
base_url: str
|
"0",
|
||||||
apikey: str
|
description="任务的唯一标识符。用于后续跟踪任务状态和结果。默认为 '0',表示单个任务模式。建议为每个任务提供唯一的ID,例如UUID。",
|
||||||
model_id: str
|
examples=["task-12345"]
|
||||||
to_lang: str = "中文"
|
)
|
||||||
formula_ocr: bool = False
|
base_url: str = Field(..., description="LLM API的基础URL。", examples=["https://api.openai.com/v1"])
|
||||||
code_ocr: bool = False
|
apikey: str = Field(..., description="LLM API的密钥。注意:请勿在不安全的环境中暴露此密钥。", examples=["sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxx"])
|
||||||
refine_markdown: bool = False
|
model_id: str = Field(..., description="使用的模型ID。", examples=["gpt-4-turbo"])
|
||||||
convert_engin: str
|
to_lang: str = Field("中文", description="目标翻译语言。", examples=["中文","英文","English"])
|
||||||
mineru_token: Optional[str] = None
|
formula_ocr: bool = Field(False, description="是否对公式进行OCR识别。")
|
||||||
chunk_size: int
|
code_ocr: bool = Field(False, description="是否对代码块进行OCR识别。")
|
||||||
concurrent: int
|
refine_markdown: bool = Field(False, description="是否使用ai对解析后的文档进行一遍优化(现不推荐使用)")
|
||||||
temperature: float
|
convert_engin: str = Field(..., description="文档解析和转换引擎,可选 'mineru' 或 'docling'。", examples=["mineru"])
|
||||||
custom_prompt_translate: Optional[str] = None
|
mineru_token: Optional[str] = Field(None, description="当使用 'mineru' 是必填。", examples=["token-abcdefg"])
|
||||||
file_name: str = Field(..., description="上传的原始文件名")
|
chunk_size: int = Field(..., description="文本分块的大小。", examples=[2048])
|
||||||
file_content: str = Field(..., description="Base64编码的文件内容")
|
concurrent: int = Field(..., description="并发请求的数量。", examples=[5])
|
||||||
|
temperature: float = Field(..., description="LLM的温度参数,控制生成文本的随机性。", examples=[0.7])
|
||||||
|
custom_prompt_translate: Optional[str] = Field(None, description="用户自定义的翻译Prompt。", examples=["人名保持原文不翻译。"])
|
||||||
|
file_name: str = Field(..., description="上传的原始文件名,包含扩展名。", examples=["my_document.pdf"])
|
||||||
|
file_content: str = Field(..., description="Base64编码的文件内容。", examples=["JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PAovVHlwZS..."])
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
json_schema_extra = {
|
||||||
|
"example": {
|
||||||
|
"task_id": "task-abc-123",
|
||||||
|
"base_url": "https://api.openai.com/v1",
|
||||||
|
"apikey": "sk-your-api-key",
|
||||||
|
"model_id": "gpt-4o",
|
||||||
|
"to_lang": "简体中文",
|
||||||
|
"formula_ocr": True,
|
||||||
|
"code_ocr": True,
|
||||||
|
"refine_markdown": False,
|
||||||
|
"convert_engin": "mineru",
|
||||||
|
"mineru_token": "your-mineru-token",
|
||||||
|
"chunk_size": 2048,
|
||||||
|
"concurrent": 5,
|
||||||
|
"temperature": 0.1,
|
||||||
|
"custom_prompt_translate": "Translate the following technical document into professional Chinese.",
|
||||||
|
"file_name": "example.pdf",
|
||||||
|
"file_content": "JVBERi0xLjQKJ..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
# --- Service Endpoints (/service) ---
|
# --- Service Endpoints (/service) ---
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
|
|
||||||
@service_router.post("/translate", summary="提交翻译任务 (JSON/Base64)")
|
@service_router.post(
|
||||||
async def service_translate(request: TranslateServiceRequest = Body(...)):
|
"/translate",
|
||||||
|
summary="提交翻译任务 (Base64)",
|
||||||
|
description="""
|
||||||
|
接收一个包含文件内容(Base64编码)和翻译参数的JSON请求,启动一个后台翻译任务。
|
||||||
|
|
||||||
|
- **异步处理**: 此端点会立即返回,不会等待翻译完成。
|
||||||
|
- **任务ID**: 成功启动后,会返回任务ID (`task_id`)。
|
||||||
|
- **后续步骤**: 客户端应使用返回的 `task_id` 轮询 `/service/status/{task_id}` 接口来获取任务进度和结果。
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
async def service_translate(request: TranslateServiceRequest = Body(..., description="翻译任务的详细参数和文件内容。")):
|
||||||
"""
|
"""
|
||||||
提交一个文件进行翻译,并启动一个后台任务。
|
提交一个文件进行翻译,并启动一个后台任务。
|
||||||
文件内容需以Base64编码。
|
文件内容需以Base64编码。
|
||||||
@@ -272,7 +339,7 @@ async def service_translate(request: TranslateServiceRequest = Body(...)):
|
|||||||
except (base64.binascii.Error, TypeError) as e:
|
except (base64.binascii.Error, TypeError) as e:
|
||||||
raise HTTPException(status_code=400, detail=f"无效的Base64文件内容: {e}")
|
raise HTTPException(status_code=400, detail=f"无效的Base64文件内容: {e}")
|
||||||
|
|
||||||
params = request.dict(exclude={'file_name', 'file_content', 'task_id'})
|
params = request.model_dump(exclude={'file_name', 'file_content', 'task_id'})
|
||||||
try:
|
try:
|
||||||
response_data = await _start_translation_task(
|
response_data = await _start_translation_task(
|
||||||
task_id=request.task_id,
|
task_id=request.task_id,
|
||||||
@@ -285,8 +352,12 @@ async def service_translate(request: TranslateServiceRequest = Body(...)):
|
|||||||
return JSONResponse(status_code=e.status_code, content={"task_started": False, "message": e.detail})
|
return JSONResponse(status_code=e.status_code, content={"task_started": False, "message": e.detail})
|
||||||
|
|
||||||
|
|
||||||
@service_router.post("/cancel/{task_id}", summary="取消翻译任务")
|
@service_router.post(
|
||||||
async def service_cancel_translate(task_id: str):
|
"/cancel/{task_id}",
|
||||||
|
summary="取消翻译任务",
|
||||||
|
description="根据任务ID取消一个正在进行的翻译任务。这是一个异步操作,发送取消请求后,任务不会立即停止,需要通过状态接口确认最终状态。"
|
||||||
|
)
|
||||||
|
async def service_cancel_translate(task_id: str = FastApiPath(..., description="要取消的任务的ID", example="task-12345")):
|
||||||
"""根据任务ID取消一个正在进行的翻译任务。"""
|
"""根据任务ID取消一个正在进行的翻译任务。"""
|
||||||
try:
|
try:
|
||||||
response_data = _cancel_translation_logic(task_id)
|
response_data = _cancel_translation_logic(task_id)
|
||||||
@@ -295,8 +366,17 @@ async def service_cancel_translate(task_id: str):
|
|||||||
return JSONResponse(status_code=e.status_code, content={"cancelled": False, "message": e.detail})
|
return JSONResponse(status_code=e.status_code, content={"cancelled": False, "message": e.detail})
|
||||||
|
|
||||||
|
|
||||||
@service_router.get("/status/{task_id}", summary="获取任务状态")
|
@service_router.get(
|
||||||
async def service_get_status(task_id: str):
|
"/status/{task_id}",
|
||||||
|
summary="获取任务状态",
|
||||||
|
description="""
|
||||||
|
根据任务ID获取任务的当前状态。
|
||||||
|
|
||||||
|
- **轮询**: 此端点设计用于被客户端轮询,以监控后台任务进度。
|
||||||
|
- **结果下载**: 当 `download_ready` 字段为 `true` 时,`downloads` 对象中会包含可用的下载链接。
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
async def service_get_status(task_id: str = FastApiPath(..., description="要查询状态的任务的ID", example="task-12345")):
|
||||||
"""根据任务ID获取任务的当前状态和结果下载链接。"""
|
"""根据任务ID获取任务的当前状态和结果下载链接。"""
|
||||||
task_state = tasks_state.get(task_id)
|
task_state = tasks_state.get(task_id)
|
||||||
if not task_state:
|
if not task_state:
|
||||||
@@ -322,8 +402,12 @@ async def service_get_status(task_id: str):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@service_router.get("/logs/{task_id}", summary="获取任务日志")
|
@service_router.get(
|
||||||
async def service_get_logs(task_id: str):
|
"/logs/{task_id}",
|
||||||
|
summary="获取任务增量日志",
|
||||||
|
description="获取指定任务ID自上次查询以来的新日志。这是一个非阻塞的轮询接口,用于实时显示后台任务的日志输出。"
|
||||||
|
)
|
||||||
|
async def service_get_logs(task_id: str = FastApiPath(..., description="要获取日志的任务的ID", example="task-12345")):
|
||||||
"""获取指定任务ID自上次查询以来的新日志。"""
|
"""获取指定任务ID自上次查询以来的新日志。"""
|
||||||
if task_id not in tasks_log_queues:
|
if task_id not in tasks_log_queues:
|
||||||
raise HTTPException(status_code=404, detail=f"找不到任务ID '{task_id}' 的日志队列。")
|
raise HTTPException(status_code=404, detail=f"找不到任务ID '{task_id}' 的日志队列。")
|
||||||
@@ -338,8 +422,16 @@ async def service_get_logs(task_id: str):
|
|||||||
return JSONResponse(content={"logs": new_logs})
|
return JSONResponse(content={"logs": new_logs})
|
||||||
|
|
||||||
|
|
||||||
@service_router.get("/download/{task_id}/{file_type}", summary="下载结果文件")
|
FileType = Literal["markdown", "markdown_zip", "html"]
|
||||||
async def service_download_file(task_id: str, file_type: str):
|
@service_router.get(
|
||||||
|
"/download/{task_id}/{file_type}",
|
||||||
|
summary="下载翻译结果文件",
|
||||||
|
description="根据任务ID和文件类型下载翻译结果。下载前请先通过状态接口确认 `download_ready` 为 `true`。"
|
||||||
|
)
|
||||||
|
async def service_download_file(
|
||||||
|
task_id: str = FastApiPath(..., description="已完成任务的ID", example="task-12345"),
|
||||||
|
file_type: FileType = FastApiPath(..., description="要下载的文件类型。", example="html")
|
||||||
|
):
|
||||||
"""根据任务ID和文件类型下载翻译结果。"""
|
"""根据任务ID和文件类型下载翻译结果。"""
|
||||||
task_state = tasks_state.get(task_id)
|
task_state = tasks_state.get(task_id)
|
||||||
if not task_state: raise HTTPException(status_code=404, detail=f"找不到任务ID '{task_id}'。")
|
if not task_state: raise HTTPException(status_code=404, detail=f"找不到任务ID '{task_id}'。")
|
||||||
@@ -362,139 +454,32 @@ async def service_download_file(task_id: str, file_type: str):
|
|||||||
return StreamingResponse(io.BytesIO(content), media_type=media_type, headers=headers)
|
return StreamingResponse(io.BytesIO(content), media_type=media_type, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
@service_router.get("/engin-list", summary="获取可用引擎列表")
|
@service_router.get("/engin-list", summary="获取可用解析引擎", tags=["Application"],
|
||||||
|
description="返回当前后端环境支持的文档解析引擎列表。前端可以根据此列表动态展示选项。")
|
||||||
async def service_get_engin_list():
|
async def service_get_engin_list():
|
||||||
engin_list = ["mineru"]
|
engin_list = ["mineru"]
|
||||||
if available_packages.get("docling"): engin_list.append("docling")
|
if available_packages.get("docling"): engin_list.append("docling")
|
||||||
return JSONResponse(content=engin_list)
|
return JSONResponse(content=engin_list)
|
||||||
|
|
||||||
@service_router.get("/task-list", summary="获取所有task-id")
|
|
||||||
|
@service_router.get("/task-list", summary="获取所有任务ID列表", tags=["Application"],
|
||||||
|
description="返回当前服务实例中存在的所有任务ID的列表。可用于管理或概览所有已创建的任务。")
|
||||||
async def service_get_task_list():
|
async def service_get_task_list():
|
||||||
return JSONResponse(content=list(tasks_state.keys()))
|
return JSONResponse(content=list(tasks_state.keys()))
|
||||||
|
|
||||||
|
|
||||||
@service_router.get("/default-params", summary="获取默认翻译参数")
|
@service_router.get("/default-params", summary="获取默认翻译参数", tags=["Application"],
|
||||||
|
description="返回一套默认的翻译参数,可用于填充前端表单的初始值。")
|
||||||
def service_get_default_params():
|
def service_get_default_params():
|
||||||
return JSONResponse(content=default_params)
|
return JSONResponse(content=default_params)
|
||||||
|
|
||||||
|
|
||||||
@service_router.get("/meta", summary="获取应用版本信息")
|
@service_router.get("/meta", summary="获取应用元信息", tags=["Application"],
|
||||||
|
description="返回应用程序的元数据,例如当前版本号。")
|
||||||
async def service_get_app_version():
|
async def service_get_app_version():
|
||||||
return JSONResponse(content={"version": __version__})
|
return JSONResponse(content={"version": __version__})
|
||||||
|
|
||||||
|
|
||||||
# ===================================================================
|
|
||||||
# --- Backend Endpoints for Frontend (/backend) ---
|
|
||||||
# --- (Calls /service endpoints) ---
|
|
||||||
# ===================================================================
|
|
||||||
|
|
||||||
async def _proxy_request(request: Request, method: str, service_path: str, **kwargs):
|
|
||||||
"""Helper to proxy requests to the service layer."""
|
|
||||||
service_url = f"{str(request.base_url).rstrip('/')}{service_path}"
|
|
||||||
try:
|
|
||||||
response = await httpx_client.request(method, service_url, timeout=30.0, **kwargs)
|
|
||||||
response.raise_for_status()
|
|
||||||
if "application/json" in response.headers.get("content-type", ""):
|
|
||||||
return JSONResponse(content=response.json(), status_code=response.status_code)
|
|
||||||
# For streaming downloads
|
|
||||||
return StreamingResponse(response.aiter_bytes(), status_code=response.status_code, headers=response.headers)
|
|
||||||
except httpx.HTTPStatusError as e:
|
|
||||||
content = e.response.json() if "application/json" in e.response.headers.get("content-type", "") else {
|
|
||||||
"detail": e.response.text}
|
|
||||||
return JSONResponse(content=content, status_code=e.response.status_code)
|
|
||||||
except httpx.RequestError as e:
|
|
||||||
return JSONResponse(status_code=503, content={"detail": f"服务调用失败: {e}"})
|
|
||||||
|
|
||||||
|
|
||||||
@backend_router.post("/translate")
|
|
||||||
async def handle_translate_for_frontend(
|
|
||||||
request: Request, task_id: str = Form("0"), 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), convert_engin: str = Form(...),
|
|
||||||
mineru_token: Optional[str] = Form(None), chunk_size: int = Form(...), concurrent: int = Form(...),
|
|
||||||
temperature: float = Form(...), custom_prompt_translate: Optional[str] = Form(None),
|
|
||||||
file: UploadFile = File(...)
|
|
||||||
):
|
|
||||||
file_contents = await file.read()
|
|
||||||
await file.close()
|
|
||||||
file_base64 = base64.b64encode(file_contents).decode('utf-8')
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"task_id": task_id, "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, "convert_engin": convert_engin, "mineru_token": mineru_token,
|
|
||||||
"chunk_size": chunk_size, "concurrent": concurrent, "temperature": temperature,
|
|
||||||
"custom_prompt_translate": custom_prompt_translate, "file_name": file.filename, "file_content": file_base64,
|
|
||||||
}
|
|
||||||
return await _proxy_request(request, "POST", "/service/translate", json=payload)
|
|
||||||
|
|
||||||
|
|
||||||
@backend_router.post("/cancel-translate")
|
|
||||||
async def cancel_translate_for_frontend(request: Request, task_id: str = Form("0")):
|
|
||||||
return await _proxy_request(request, "POST", f"/service/cancel/{task_id}")
|
|
||||||
|
|
||||||
|
|
||||||
@backend_router.get("/get-status")
|
|
||||||
async def get_status_for_frontend(request: Request, task_id: str = Query("0")):
|
|
||||||
service_url = f"{str(request.base_url).rstrip('/')}/service/status/{task_id}"
|
|
||||||
try:
|
|
||||||
response = await httpx_client.get(service_url)
|
|
||||||
if response.status_code == 404:
|
|
||||||
return JSONResponse(content=_create_default_task_state()) # Return default state for UI
|
|
||||||
response.raise_for_status()
|
|
||||||
service_data = response.json()
|
|
||||||
except httpx.RequestError as e:
|
|
||||||
return JSONResponse(status_code=503, content={"detail": f"服务调用失败: {e}"})
|
|
||||||
|
|
||||||
# Adapt service response for the frontend (generate frontend-specific URLs)
|
|
||||||
filename_stem = service_data.get("original_filename_stem")
|
|
||||||
|
|
||||||
def generate_frontend_url(path_prefix, ext):
|
|
||||||
if service_data["download_ready"] and filename_stem:
|
|
||||||
return f"/backend/download/{path_prefix}/{filename_stem}_translated.{ext}?task_id={task_id}"
|
|
||||||
return None
|
|
||||||
|
|
||||||
return JSONResponse(content={
|
|
||||||
"is_processing": service_data["is_processing"], "status_message": service_data["status_message"],
|
|
||||||
"error_flag": service_data["error_flag"], "download_ready": service_data["download_ready"],
|
|
||||||
"original_filename_stem": filename_stem,
|
|
||||||
"markdown_url": generate_frontend_url("markdown", "md"),
|
|
||||||
"markdown_zip_url": generate_frontend_url("markdown_zip", "zip"),
|
|
||||||
"html_url": generate_frontend_url("html", "html"),
|
|
||||||
"task_start_time": service_data.get("task_start_time", 0),
|
|
||||||
"task_end_time": service_data.get("task_end_time", 0),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@backend_router.get("/download/{file_type}/{filename_with_ext}")
|
|
||||||
async def download_file_for_frontend(request: Request, file_type: str, filename_with_ext: str,
|
|
||||||
task_id: str = Query(...)):
|
|
||||||
# filename_with_ext is for vanity, real identifiers are task_id and file_type
|
|
||||||
return await _proxy_request(request, "GET", f"/service/download/{task_id}/{file_type}")
|
|
||||||
|
|
||||||
|
|
||||||
@backend_router.get("/get-logs")
|
|
||||||
async def get_logs_from_queue_for_frontend(request: Request, task_id: str = Query("0")):
|
|
||||||
return await _proxy_request(request, "GET", f"/service/logs/{task_id}")
|
|
||||||
|
|
||||||
|
|
||||||
@backend_router.get("/get-engin-list")
|
|
||||||
async def get_engin_list_for_frontend(request: Request):
|
|
||||||
return await _proxy_request(request, "GET", "/service/engin-list")
|
|
||||||
@backend_router.get("/get-task-list")
|
|
||||||
async def get_task_list_for_frontend(request: Request):
|
|
||||||
return await _proxy_request(request, "GET", "/service/task-list")
|
|
||||||
|
|
||||||
@backend_router.get("/translate/default_param")
|
|
||||||
async def get_default_param_for_frontend(request: Request):
|
|
||||||
return await _proxy_request(request, "GET", "/service/default-params")
|
|
||||||
|
|
||||||
|
|
||||||
@backend_router.get("/meta")
|
|
||||||
async def get_app_version_for_frontend(request: Request):
|
|
||||||
return await _proxy_request(request, "GET", "/service/meta")
|
|
||||||
|
|
||||||
|
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
# --- 应用主路由和启动 ---
|
# --- 应用主路由和启动 ---
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
@@ -506,6 +491,8 @@ async def main_page():
|
|||||||
no_cache_headers = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", "Pragma": "no-cache",
|
no_cache_headers = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", "Pragma": "no-cache",
|
||||||
"Expires": "0"}
|
"Expires": "0"}
|
||||||
return FileResponse(index_path, headers=no_cache_headers)
|
return FileResponse(index_path, headers=no_cache_headers)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/admin", response_class=HTMLResponse, include_in_schema=False)
|
@app.get("/admin", response_class=HTMLResponse, include_in_schema=False)
|
||||||
async def main_page_admin():
|
async def main_page_admin():
|
||||||
index_path = Path(STATIC_DIR) / "index.html"
|
index_path = Path(STATIC_DIR) / "index.html"
|
||||||
@@ -513,22 +500,27 @@ async def main_page_admin():
|
|||||||
no_cache_headers = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", "Pragma": "no-cache",
|
no_cache_headers = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", "Pragma": "no-cache",
|
||||||
"Expires": "0"}
|
"Expires": "0"}
|
||||||
return FileResponse(index_path, headers=no_cache_headers)
|
return FileResponse(index_path, headers=no_cache_headers)
|
||||||
@app.post("/temp/translate")
|
|
||||||
async def temp_translate(base_url: str = Body(...),
|
|
||||||
api_key: str = Body(...),
|
@app.post("/temp/translate",
|
||||||
model_id: str = Body(...),
|
summary="[内部] 临时同步翻译接口",
|
||||||
mineru_token: str = Body(...),
|
description="一个简单的、同步的翻译接口,用于快速测试。不涉及后台任务、状态管理或多格式输出。**不建议在生产环境中使用。**",
|
||||||
file_name: str = Body(...),
|
tags=["Internal / UI"])
|
||||||
file_content: str = Body(...),
|
async def temp_translate(base_url: str = Body(..., description="LLM API的基础URL。", example="https://api.openai.com/v1"),
|
||||||
to_lang: str = Body("中文")
|
api_key: str = Body(..., description="LLM API的密钥。", example="sk-xxxxxxxxxx"),
|
||||||
|
model_id: str = Body(..., description="使用的模型ID。", example="gpt-4-turbo"),
|
||||||
|
mineru_token: str = Body(..., description="Mineru引擎的Token。"),
|
||||||
|
file_name: str = Body(..., description="原始文件名。", example="test.txt"),
|
||||||
|
file_content: str = Body(..., description="文件内容,可以是纯文本或Base64编码的字符串。"),
|
||||||
|
to_lang: str = Body("中文", description="目标语言。")
|
||||||
):
|
):
|
||||||
def is_base64(s):
|
def is_base64(s):
|
||||||
# 尝试解码验证
|
|
||||||
try:
|
try:
|
||||||
base64.b64decode(s)
|
base64.b64decode(s)
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
ft = FileTranslater(base_url=base_url,
|
ft = FileTranslater(base_url=base_url,
|
||||||
key=api_key,
|
key=api_key,
|
||||||
model_id=model_id,
|
model_id=model_id,
|
||||||
@@ -537,7 +529,8 @@ async def temp_translate(base_url: str = Body(...),
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if is_base64(file_content):
|
if is_base64(file_content):
|
||||||
await ft.translate_bytes_async(name=file_name,file=base64.b64decode(file_content),to_lang=to_lang,save=False)
|
await ft.translate_bytes_async(name=file_name, file=base64.b64decode(file_content), to_lang=to_lang,
|
||||||
|
save=False)
|
||||||
else:
|
else:
|
||||||
await ft.translate_bytes_async(name=file_name, file=file_content.encode(), to_lang=to_lang, save=False)
|
await ft.translate_bytes_async(name=file_name, file=file_content.encode(), to_lang=to_lang, save=False)
|
||||||
return {"success": True, "content": ft.export_to_markdown()}
|
return {"success": True, "content": ft.export_to_markdown()}
|
||||||
@@ -546,8 +539,6 @@ async def temp_translate(base_url: str = Body(...),
|
|||||||
return {"success": False, "reason": {e.__repr__()}}
|
return {"success": False, "reason": {e.__repr__()}}
|
||||||
|
|
||||||
|
|
||||||
# 包含两个路由组
|
|
||||||
app.include_router(backend_router)
|
|
||||||
app.include_router(service_router)
|
app.include_router(service_router)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -135,17 +135,17 @@
|
|||||||
<!-- Parsing Engine Settings -->
|
<!-- Parsing Engine Settings -->
|
||||||
<div class="accordion-item">
|
<div class="accordion-item">
|
||||||
<h2 class="accordion-header" id="headingOne">
|
<h2 class="accordion-header" id="headingOne">
|
||||||
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapseOne" aria-expanded="true" aria-controls="collapseOne">
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseOne" aria-expanded="false" aria-controls="collapseOne">
|
||||||
<strong><i class="bi bi-file-earmark-binary me-2"></i>解析引擎</strong>
|
<strong><i class="bi bi-file-earmark-binary me-2"></i>解析配置</strong>
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="collapseOne" class="accordion-collapse collapse show" aria-labelledby="headingOne">
|
<div id="collapseOne" class="accordion-collapse collapse" aria-labelledby="headingOne">
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="convert_engin" class="form-label">解析引擎</label>
|
<label for="convert_engin" class="form-label">解析引擎</label>
|
||||||
<select class="form-select" id="convert_engin" name="convert_engin">
|
<select class="form-select" id="convert_engin" name="convert_engin">
|
||||||
<option value="mineru">minerU (推荐)</option>
|
<option value="mineru">minerU (推荐)</option>
|
||||||
<option value="docling">Docling (本地翻译)</option>
|
<option value="docling">Docling (本地解析)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3" id="mineruTokenGroup">
|
<div class="mb-3" id="mineruTokenGroup">
|
||||||
@@ -162,11 +162,11 @@
|
|||||||
<!-- AI Settings -->
|
<!-- AI Settings -->
|
||||||
<div class="accordion-item">
|
<div class="accordion-item">
|
||||||
<h2 class="accordion-header" id="headingTwo">
|
<h2 class="accordion-header" id="headingTwo">
|
||||||
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapseTwo" aria-expanded="true" aria-controls="collapseTwo">
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseTwo" aria-expanded="false" aria-controls="collapseTwo">
|
||||||
<strong><i class="bi bi-robot me-2"></i>翻译模型</strong>
|
<strong><i class="bi bi-robot me-2"></i>翻译模型</strong>
|
||||||
</button>
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div id="collapseTwo" class="accordion-collapse collapse show" aria-labelledby="headingTwo">
|
<div id="collapseTwo" class="accordion-collapse collapse" aria-labelledby="headingTwo">
|
||||||
<div class="accordion-body">
|
<div class="accordion-body">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="platform_select" class="form-label">选择平台</label>
|
<label for="platform_select" class="form-label">选择平台</label>
|
||||||
@@ -472,6 +472,19 @@
|
|||||||
const saveToStorage = (key, value) => { try { localStorage.setItem(key, value); } catch (e) { console.warn("Save to storage failed:", e); } };
|
const saveToStorage = (key, value) => { try { localStorage.setItem(key, value); } catch (e) { console.warn("Save to storage failed:", e); } };
|
||||||
const getFromStorage = (key, defaultValue = '') => { try { return localStorage.getItem(key) || defaultValue; } catch (e) { console.warn("Read from storage failed:", e); return defaultValue; } };
|
const getFromStorage = (key, defaultValue = '') => { try { return localStorage.getItem(key) || defaultValue; } catch (e) { console.warn("Read from storage failed:", e); return defaultValue; } };
|
||||||
|
|
||||||
|
function fileToBase64(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
reader.onload = () => {
|
||||||
|
// Result is "data:...,base64,XYZ...", we only want "XYZ..."
|
||||||
|
const base64String = reader.result.split(',')[1];
|
||||||
|
resolve(base64String);
|
||||||
|
};
|
||||||
|
reader.onerror = error => reject(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- UI Update Functions ---
|
// --- UI Update Functions ---
|
||||||
function updatePlatformUI() {
|
function updatePlatformUI() {
|
||||||
const selectedPlatformValue = platformSelect.value;
|
const selectedPlatformValue = platformSelect.value;
|
||||||
@@ -690,17 +703,38 @@
|
|||||||
elements.startBtn.disabled = true;
|
elements.startBtn.disabled = true;
|
||||||
elements.startBtn.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 初始化...`;
|
elements.startBtn.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 初始化...`;
|
||||||
elements.logArea.innerHTML = '';
|
elements.logArea.innerHTML = '';
|
||||||
elements.statusMessage.textContent = '正在提交任务...';
|
elements.statusMessage.textContent = '正在编码文件并提交任务...';
|
||||||
elements.statusMessage.className = 'status-message small text-muted';
|
elements.statusMessage.className = 'status-message small text-muted';
|
||||||
elements.downloadButtons.style.display = 'none';
|
elements.downloadButtons.style.display = 'none';
|
||||||
elements.progress.style.display = 'block';
|
elements.progress.style.display = 'block';
|
||||||
|
|
||||||
const formData = new FormData(settingsForm);
|
|
||||||
formData.append('file', state.file);
|
|
||||||
formData.append('task_id', taskId);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('backend/translate', { method: 'POST', body: formData });
|
const fileContentBase64 = await fileToBase64(state.file);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
task_id: taskId,
|
||||||
|
base_url: baseUrlInput.value,
|
||||||
|
apikey: apikeyInput.value,
|
||||||
|
model_id: modelInput.value,
|
||||||
|
to_lang: toLangSelect.value,
|
||||||
|
formula_ocr: formulaCheckbox.checked,
|
||||||
|
code_ocr: codeCheckbox.checked,
|
||||||
|
refine_markdown: refineCheckbox.checked,
|
||||||
|
convert_engin: convertEnginSelect.value,
|
||||||
|
mineru_token: mineruTokenInput.value || null,
|
||||||
|
chunk_size: parseInt(chunkSizeSlider.value, 10),
|
||||||
|
concurrent: parseInt(concurrentSlider.value, 10),
|
||||||
|
temperature: parseFloat(temperatureSlider.value),
|
||||||
|
custom_prompt_translate: customPromptTranslateArea.value || null,
|
||||||
|
file_name: state.file.name,
|
||||||
|
file_content: fileContentBase64
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch('/service/translate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (response.ok && result.task_started) {
|
if (response.ok && result.task_started) {
|
||||||
@@ -731,9 +765,7 @@
|
|||||||
elements.startBtn.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 正在取消...`;
|
elements.startBtn.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 正在取消...`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const response = await fetch(`/service/cancel/${taskId}`, { method: 'POST' });
|
||||||
formData.append('task_id', taskId);
|
|
||||||
const response = await fetch('backend/cancel-translate', { method: 'POST', body: formData });
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (response.ok && result.cancelled) {
|
if (response.ok && result.cancelled) {
|
||||||
@@ -771,7 +803,7 @@
|
|||||||
async function pollLogs(taskId) {
|
async function pollLogs(taskId) {
|
||||||
const { elements } = tasks[taskId];
|
const { elements } = tasks[taskId];
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`backend/get-logs?task_id=${taskId}`);
|
const response = await fetch(`/service/logs/${taskId}`);
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.logs && data.logs.length > 0) {
|
if (data.logs && data.logs.length > 0) {
|
||||||
@@ -786,7 +818,7 @@
|
|||||||
async function pollStatus(taskId, isRestore = false) {
|
async function pollStatus(taskId, isRestore = false) {
|
||||||
const { elements, state } = tasks[taskId];
|
const { elements, state } = tasks[taskId];
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`backend/get-status?task_id=${taskId}`);
|
const response = await fetch(`/service/status/${taskId}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
// If 404 on restore, it means the task is old and gone from server. Remove it.
|
// If 404 on restore, it means the task is old and gone from server. Remove it.
|
||||||
if (response.status === 404 && isRestore) {
|
if (response.status === 404 && isRestore) {
|
||||||
@@ -810,12 +842,13 @@
|
|||||||
if (status.download_ready && !status.error_flag) {
|
if (status.download_ready && !status.error_flag) {
|
||||||
elements.statusMessage.className = 'status-message small text-success';
|
elements.statusMessage.className = 'status-message small text-success';
|
||||||
|
|
||||||
state.htmlUrl = status.html_url;
|
// Use download URLs directly from the service response
|
||||||
|
state.htmlUrl = status.downloads.html;
|
||||||
state.fileNameStem = status.original_filename_stem;
|
state.fileNameStem = status.original_filename_stem;
|
||||||
|
|
||||||
elements.htmlLink.href = status.html_url;
|
elements.htmlLink.href = status.downloads.html;
|
||||||
elements.mdLink.href = status.markdown_url;
|
elements.mdLink.href = status.downloads.markdown;
|
||||||
elements.mdZipLink.href = status.markdown_zip_url;
|
elements.mdZipLink.href = status.downloads.markdown_zip;
|
||||||
|
|
||||||
elements.previewBtn.onclick = () => setupPreview(taskId);
|
elements.previewBtn.onclick = () => setupPreview(taskId);
|
||||||
elements.pdfBtn.onclick = () => downloadPdf(taskId);
|
elements.pdfBtn.onclick = () => downloadPdf(taskId);
|
||||||
@@ -963,9 +996,9 @@
|
|||||||
// Fetch metadata
|
// Fetch metadata
|
||||||
try {
|
try {
|
||||||
const [metaRes, enginRes, paramsRes] = await Promise.all([
|
const [metaRes, enginRes, paramsRes] = await Promise.all([
|
||||||
fetch("backend/meta"),
|
fetch("/service/meta"),
|
||||||
fetch('backend/get-engin-list'),
|
fetch('/service/engin-list'),
|
||||||
fetch("backend/translate/default_param")
|
fetch("/service/default-params")
|
||||||
]);
|
]);
|
||||||
const meta = await metaRes.json();
|
const meta = await metaRes.json();
|
||||||
versionDisplay.textContent = `v${meta.version}`;
|
versionDisplay.textContent = `v${meta.version}`;
|
||||||
@@ -1005,7 +1038,7 @@
|
|||||||
if (isAdminMode) {
|
if (isAdminMode) {
|
||||||
document.title = "DocuTranslate - Admin Panel";
|
document.title = "DocuTranslate - Admin Panel";
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/backend/get-task-list');
|
const response = await fetch('/service/task-list');
|
||||||
if (!response.ok) throw new Error(`Failed to fetch task list: ${response.statusText}`);
|
if (!response.ok) throw new Error(`Failed to fetch task list: ${response.statusText}`);
|
||||||
const allTaskIds = await response.json();
|
const allTaskIds = await response.json();
|
||||||
if (allTaskIds && Array.isArray(allTaskIds) && allTaskIds.length > 0) {
|
if (allTaskIds && Array.isArray(allTaskIds) && allTaskIds.length > 0) {
|
||||||
@@ -1044,28 +1077,63 @@
|
|||||||
setTranslatedOnlyViewBtn.addEventListener('click', () => setPreviewDisplayMode('translationOnly'));
|
setTranslatedOnlyViewBtn.addEventListener('click', () => setPreviewDisplayMode('translationOnly'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Theme switcher logic
|
// --- MODIFIED: Theme switcher logic ---
|
||||||
const getPreferredTheme = () => {
|
const getPreferredTheme = () => {
|
||||||
const storedTheme = localStorage.getItem('theme');
|
const storedTheme = localStorage.getItem('theme');
|
||||||
if (storedTheme) return storedTheme;
|
if (storedTheme) {
|
||||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
return storedTheme;
|
||||||
|
}
|
||||||
|
// Default to 'auto' if no preference is stored
|
||||||
|
return 'auto';
|
||||||
};
|
};
|
||||||
|
|
||||||
const setTheme = theme => {
|
const setTheme = theme => {
|
||||||
if (theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
if (theme === 'auto') {
|
||||||
document.documentElement.setAttribute('data-bs-theme', 'dark');
|
// Set theme based on system preference
|
||||||
|
document.documentElement.setAttribute('data-bs-theme', window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||||
} else {
|
} else {
|
||||||
|
// Set theme based on user's choice (light/dark)
|
||||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
setTheme(getPreferredTheme());
|
|
||||||
|
const showActiveTheme = (theme) => {
|
||||||
|
// Remove active class from all buttons
|
||||||
|
document.querySelectorAll('[data-bs-theme-value]').forEach(element => {
|
||||||
|
element.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add active class to the button corresponding to the current theme
|
||||||
|
const activeButton = document.querySelector(`[data-bs-theme-value="${theme}"]`);
|
||||||
|
if (activeButton) {
|
||||||
|
activeButton.classList.add('active');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// On page load, set theme and update UI
|
||||||
|
const preferredTheme = getPreferredTheme();
|
||||||
|
setTheme(preferredTheme);
|
||||||
|
showActiveTheme(preferredTheme);
|
||||||
|
|
||||||
|
// When system theme changes, update if in 'auto' mode
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||||
|
const storedTheme = localStorage.getItem('theme');
|
||||||
|
if (storedTheme === 'auto' || !storedTheme) {
|
||||||
|
setTheme('auto');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add click listeners to theme switch buttons
|
||||||
document.querySelectorAll('[data-bs-theme-value]').forEach(toggle => {
|
document.querySelectorAll('[data-bs-theme-value]').forEach(toggle => {
|
||||||
toggle.addEventListener('click', () => {
|
toggle.addEventListener('click', () => {
|
||||||
const theme = toggle.getAttribute('data-bs-theme-value');
|
const theme = toggle.getAttribute('data-bs-theme-value');
|
||||||
localStorage.setItem('theme', theme);
|
localStorage.setItem('theme', theme);
|
||||||
setTheme(theme);
|
setTheme(theme);
|
||||||
|
showActiveTheme(theme);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// --- Start the application ---
|
// --- Start the application ---
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user