feat(plugin): add document export, doc‑block, and TTS/ASR support

Adds a DocBlock component that renders embedded documents, new export buttons for DOCX
and PDF, and updates the file‑upload picker to accept *.txt, *.docx, *.pptx, and *.pdf.
Introduces a DOCX→PDF conversion bridge in the backend and new /tts and /asr
endpoints that expose TTS and speech‑recognition functionality.  The README is
rewritten to describe the new features and clean up legacy documentation.  All
changes are backward‑compatible and do not introduce breaking API changes.
This commit is contained in:
2026-04-04 23:56:18 +08:00
parent be4000b774
commit 9ff51ac2f3
25 changed files with 2995 additions and 1124 deletions

View File

@@ -3,13 +3,16 @@ import base64
import json
import logging
import os
import re
import shutil
import subprocess
import tempfile
import uuid
from typing import Optional
from fastapi import FastAPI, HTTPException, Request, Security
from fastapi import FastAPI, HTTPException, Request, Security, File, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.responses import JSONResponse, StreamingResponse, Response
from fastapi.security import APIKeyHeader
from pydantic import BaseModel
@@ -81,6 +84,32 @@ class ConvertRequest(BaseModel):
filename: str = "document.pdf"
ALLOWED_CONVERT_EXTENSIONS = {".txt", ".docx", ".pptx", ".pdf"}
IMAGE_MARKDOWN_RE = re.compile(r"!\[[^\]]*]\([^)]+\)")
IMAGE_HTML_RE = re.compile(r"<img\b[^>]*>", re.IGNORECASE)
def _convert_docx_to_pdf(input_path: str, output_path: str) -> None:
node_executable = shutil.which("node")
if not node_executable:
raise RuntimeError("未找到 Node.js无法转换 DOCX 为 PDF")
bridge_path = os.path.join(os.path.dirname(__file__), "docx2pdf_bridge.cjs")
if not os.path.exists(bridge_path):
raise RuntimeError("缺少 DOCX 转 PDF 桥接脚本")
result = subprocess.run(
[node_executable, bridge_path, input_path, output_path],
cwd=os.path.dirname(os.path.dirname(__file__)),
capture_output=True,
text=True,
)
if result.returncode != 0:
error_text = (result.stderr or result.stdout or "DOCX 转 PDF 失败").strip()
raise RuntimeError(error_text)
def _preview(text: str, limit: int = 80) -> str:
value = (text or "").replace("\n", "\\n")
if len(value) <= limit:
@@ -88,6 +117,14 @@ def _preview(text: str, limit: int = 80) -> str:
return value[:limit] + "..."
def _sanitize_converted_markdown(text: str) -> str:
value = (text or "").replace("\r\n", "\n").replace("\r", "\n")
value = IMAGE_MARKDOWN_RE.sub("", value)
value = IMAGE_HTML_RE.sub("", value)
value = re.sub(r"\n{3,}", "\n\n", value)
return value.strip()
def _sse_payload(payload: dict) -> str:
return f"data: {json.dumps(payload)}\n\n"
@@ -253,9 +290,9 @@ async def ocr_image(request: OCRRequest, api_key: str = Security(get_api_key)):
@app.post("/v1/convert")
async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(get_api_key)):
"""鐏忓棙鏋冩禒鎯版祮閹诡澀璐烳arkdown閺嶇厧绱?""
"""Convert file to markdown"""
request_id = str(uuid.uuid4())[:8]
try:
logger.info(
"[%s] /v1/convert filename=%s file_base64_chars=%d",
@@ -263,53 +300,106 @@ async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(g
request.filename,
len(request.file or ""),
)
# 鐟欙絿鐖淏ase64閺傚洣娆㈤崘鍛啇
# Decode base64
file_bytes = base64.b64decode(request.file)
logger.info("[%s] /v1/convert decoded file_bytes=%d", request_id, len(file_bytes))
# 閼惧嘲褰囬弬鍥︽閹碘晛鐫嶉崥?
# Get file extension
ext = os.path.splitext(request.filename)[1].lower()
# 閸掓稑缂撴稉瀛樻閺傚洣娆?
if ext not in ALLOWED_CONVERT_EXTENSIONS:
raise ValueError("仅支持 txt、docx、pptx、pdf 格式")
if ext == ".txt":
markdown_text = _sanitize_converted_markdown(file_bytes.decode("utf-8", errors="ignore"))
return {
"markdown": markdown_text,
"filename": request.filename
}
# Create temporary file
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
tmp.write(file_bytes)
tmp_path = tmp.name
try:
# 法鏁arkItDown鏉烆剚宕叉稉绡梐rkdown
# Convert using MarkItDown
md = markitdown.MarkItDown()
result = md.convert(tmp_path)
markdown_text = result.text_content
markdown_text = _sanitize_converted_markdown(result.text_content)
logger.info(
"[%s] /v1/convert success text_chars=%d text_preview='%s'",
request_id,
len(markdown_text or ""),
_preview(markdown_text, 120),
)
return {
"markdown": markdown_text,
"filename": request.filename
}
finally:
# 濞撳懐鎮婃稉瀛樻閺傚洣娆?
# Clean up temporary file
if os.path.exists(tmp_path):
os.unlink(tmp_path)
except Exception as e:
logger.exception("[%s] /v1/convert failed: %s", request_id, e)
return JSONResponse(content={"error": str(e)}, status_code=500)
@app.post("/v1/export/pdf")
async def export_pdf(file: UploadFile = File(...), api_key: str = Security(get_api_key)):
request_id = str(uuid.uuid4())[:8]
original_name = file.filename or "document.docx"
base_name = os.path.splitext(original_name)[0] or "document"
try:
file_bytes = await file.read()
logger.info(
"[%s] /v1/export/pdf filename=%s file_bytes=%d",
request_id,
original_name,
len(file_bytes),
)
with tempfile.TemporaryDirectory() as temp_dir:
input_path = os.path.join(temp_dir, f"{base_name}.docx")
output_path = os.path.join(temp_dir, f"{base_name}.pdf")
with open(input_path, "wb") as tmp_file:
tmp_file.write(file_bytes)
await asyncio.to_thread(_convert_docx_to_pdf, input_path, output_path)
if not os.path.exists(output_path):
raise RuntimeError("PDF 转换后未生成输出文件")
with open(output_path, "rb") as pdf_file:
pdf_bytes = pdf_file.read()
logger.info("[%s] /v1/export/pdf success pdf_bytes=%d", request_id, len(pdf_bytes))
headers = {
"Content-Disposition": f'attachment; filename="{base_name}.pdf"',
}
return Response(content=pdf_bytes, media_type="application/pdf", headers=headers)
except Exception as e:
logger.exception("[%s] /v1/export/pdf failed: %s", request_id, e)
return JSONResponse(content={"error": str(e)}, status_code=500)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8001)
# TTS and STT routes
# TTS and ASR routes
from tts_asr import register_tts_asr_routes
register_tts_asr_routes(app)