修复亮/暗模式切换bug

This commit is contained in:
xunbu
2025-07-14 17:41:40 +08:00
parent 47249c6dab
commit 215717b078
2 changed files with 270 additions and 211 deletions

View File

@@ -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)

View File

@@ -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>