chore: 更新项目配置和依赖,优化前后端代码
This commit is contained in:
+19
-11
@@ -9,9 +9,14 @@ from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'gpt-oss:20b')
|
||||
OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'http://192.168.0.120:11434')
|
||||
OLLAMA_HOST = os.getenv('OLLAMA_HOST', 'http://localhost:11434')
|
||||
VLM_MODEL = os.getenv('VLM_MODEL', 'qwen3-vl:30b')
|
||||
|
||||
# Timeouts in seconds
|
||||
COMPLETION_TIMEOUT = 30
|
||||
OCR_TIMEOUT = 60
|
||||
CONVERT_TIMEOUT = 30
|
||||
|
||||
client = ollama.AsyncClient(host=OLLAMA_HOST)
|
||||
logger = logging.getLogger("llm")
|
||||
|
||||
@@ -97,7 +102,7 @@ async def call_ollama(
|
||||
if thinking:
|
||||
kwargs["think"] = thinking
|
||||
|
||||
response = await client.chat(**kwargs)
|
||||
response = await asyncio.wait_for(client.chat(**kwargs), timeout=COMPLETION_TIMEOUT)
|
||||
except asyncio.CancelledError:
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
end_dt = datetime.now()
|
||||
@@ -156,15 +161,18 @@ async def call_vlm_ocr(image_bytes: bytes, language: str = 'auto') -> str:
|
||||
)
|
||||
|
||||
try:
|
||||
response = await client.chat(
|
||||
model=VLM_MODEL,
|
||||
messages=[{
|
||||
'role': 'user',
|
||||
'content': VLM_OCR_CONTEXT_PROMPT,
|
||||
'images': [image_bytes]
|
||||
}],
|
||||
stream=False,
|
||||
options={'temperature': 0.3}
|
||||
response = await asyncio.wait_for(
|
||||
client.chat(
|
||||
model=VLM_MODEL,
|
||||
messages=[{
|
||||
'role': 'user',
|
||||
'content': VLM_OCR_CONTEXT_PROMPT,
|
||||
'images': [image_bytes]
|
||||
}],
|
||||
stream=False,
|
||||
options={'temperature': 0.3}
|
||||
),
|
||||
timeout=OCR_TIMEOUT
|
||||
)
|
||||
except Exception:
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000
|
||||
|
||||
+14
-8
@@ -1,4 +1,4 @@
|
||||
import asyncio
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
@@ -238,7 +238,7 @@ async def ocr_image(request: OCRRequest, api_key: str = Security(get_api_key)):
|
||||
)
|
||||
image_bytes = base64.b64decode(request.image)
|
||||
logger.info("[%s] /v1/ocr decoded image_bytes=%d", request_id, len(image_bytes))
|
||||
result = await call_vlm_ocr(image_bytes, request.language)
|
||||
result = await call_vlm_ocr(image_bytes, request.language)
|
||||
logger.info(
|
||||
"[%s] /v1/ocr success text_chars=%d text_preview='%s'",
|
||||
request_id,
|
||||
@@ -253,7 +253,7 @@ 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)):
|
||||
"""将文件转换为Markdown格式"""
|
||||
"""鐏忓棙鏋冩禒鎯版祮閹诡澀璐烳arkdown閺嶇厧绱?""
|
||||
request_id = str(uuid.uuid4())[:8]
|
||||
|
||||
try:
|
||||
@@ -264,20 +264,20 @@ async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(g
|
||||
len(request.file or ""),
|
||||
)
|
||||
|
||||
# 解码Base64文件内容
|
||||
# 鐟欙絿鐖淏ase64閺傚洣娆㈤崘鍛啇
|
||||
file_bytes = base64.b64decode(request.file)
|
||||
logger.info("[%s] /v1/convert decoded file_bytes=%d", request_id, len(file_bytes))
|
||||
|
||||
# 获取文件扩展名
|
||||
# 閼惧嘲褰囬弬鍥︽閹碘晛鐫嶉崥?
|
||||
ext = os.path.splitext(request.filename)[1].lower()
|
||||
|
||||
# 创建临时文件
|
||||
# 閸掓稑缂撴稉瀛樻閺傚洣娆?
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as tmp:
|
||||
tmp.write(file_bytes)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
# 使用MarkItDown转换为Markdown
|
||||
# 娴h法鏁arkItDown鏉烆剚宕叉稉绡梐rkdown
|
||||
md = markitdown.MarkItDown()
|
||||
result = md.convert(tmp_path)
|
||||
markdown_text = result.text_content
|
||||
@@ -294,7 +294,7 @@ async def convert_to_markdown(request: ConvertRequest, api_key: str = Security(g
|
||||
"filename": request.filename
|
||||
}
|
||||
finally:
|
||||
# 清理临时文件
|
||||
# 濞撳懐鎮婃稉瀛樻閺傚洣娆?
|
||||
if os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
|
||||
@@ -307,3 +307,9 @@ if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=8001)
|
||||
|
||||
|
||||
# TTS and STT routes
|
||||
from tts_asr import register_tts_asr_routes
|
||||
register_tts_asr_routes(app)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
fastapi
|
||||
fastapi
|
||||
uvicorn
|
||||
ollama
|
||||
pydantic
|
||||
@@ -10,3 +10,10 @@ python-docx
|
||||
python-pptx
|
||||
openpyxl
|
||||
pypdf
|
||||
|
||||
# TTS and ASR dependencies
|
||||
torch
|
||||
transformers
|
||||
soundfile
|
||||
numpy
|
||||
accelerate
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# TTS and Speech Recognition API for macOS Silicon
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
import base64
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException, Security
|
||||
from pydantic import BaseModel
|
||||
from fastapi.security import APIKeyHeader
|
||||
|
||||
router = APIRouter()
|
||||
api_key_header = APIKeyHeader(name="X-API-Key")
|
||||
logger = logging.getLogger("tts_stt")
|
||||
|
||||
|
||||
def _speak_text_macos(text: str, voice: str = "meijia", rate: float = 0.5) -> bytes:
|
||||
import subprocess
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
||||
output_path = tmp.name
|
||||
try:
|
||||
cmd = ["say", "-v", voice, "-r", str(rate * 10), "--output-format", "WAVE", "-o", output_path, text]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
if result.returncode != 0:
|
||||
raise Exception(f"TTS failed: {result.stderr}")
|
||||
with open(output_path, "rb") as f:
|
||||
audio_data = f.read()
|
||||
return audio_data
|
||||
finally:
|
||||
if os.path.exists(output_path):
|
||||
os.unlink(output_path)
|
||||
|
||||
|
||||
async def _speak_text_macos_async(text: str, voice: str = "meijia", rate: float = 0.5) -> bytes:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, _speak_text_macos, text, voice, rate)
|
||||
|
||||
|
||||
def _recognize_speech_macos(audio_data: bytes, language: str = "zh-CN") -> str:
|
||||
import tempfile
|
||||
try:
|
||||
import whisper
|
||||
model = whisper.load_model("tiny")
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp2:
|
||||
tmp2.write(audio_data)
|
||||
audio_for_whisper = tmp2.name
|
||||
try:
|
||||
result = model.transcribe(audio_for_whisper, language=language[:2])
|
||||
return result["text"]
|
||||
finally:
|
||||
if os.path.exists(audio_for_whisper):
|
||||
os.unlink(audio_for_whisper)
|
||||
except ImportError:
|
||||
raise Exception("Whisper is required for speech recognition on macOS")
|
||||
|
||||
|
||||
async def _recognize_speech_macos_async(audio_data: bytes, language: str = "zh-CN") -> str:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, _recognize_speech_macos, audio_data, language)
|
||||
|
||||
|
||||
class TTSRequest(BaseModel):
|
||||
text: str
|
||||
voice: str = "meijia"
|
||||
rate: float = 0.5
|
||||
format: str = "wav"
|
||||
|
||||
|
||||
class TTSResponse(BaseModel):
|
||||
audio_base64: str
|
||||
format: str
|
||||
duration_ms: int
|
||||
|
||||
|
||||
class STTRequest(BaseModel):
|
||||
audio_base64: str
|
||||
language: str = "zh-CN"
|
||||
|
||||
|
||||
class STTResponse(BaseModel):
|
||||
text: str
|
||||
language: str
|
||||
|
||||
|
||||
@router.post("/tts", response_model=TTSResponse)
|
||||
async def text_to_speech(req: TTSRequest, api_key: str = Security(get_api_key)):
|
||||
request_id = str(hash(req.text))[:8]
|
||||
try:
|
||||
logger.info("[TTS][%s] text_chars=%d voice=%s", request_id, len(req.text), req.voice)
|
||||
audio_data = await _speak_text_macos_async(req.text, req.voice, req.rate)
|
||||
if req.format.lower() == "mp3":
|
||||
import tempfile
|
||||
import subprocess
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp_in:
|
||||
tmp_in.write(audio_data)
|
||||
input_path = tmp_in.name
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp_out:
|
||||
output_path = tmp_out.name
|
||||
try:
|
||||
cmd = ["ffmpeg", "-i", input_path, "-acodec", "libmp3lame", output_path]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
if result.returncode != 0:
|
||||
raise Exception(f"MP3 conversion failed: {result.stderr}")
|
||||
with open(output_path, "rb") as f:
|
||||
audio_data = f.read()
|
||||
finally:
|
||||
for p in [input_path, output_path]:
|
||||
if os.path.exists(p):
|
||||
os.unlink(p)
|
||||
duration_ms = len(audio_data) * 1000 // 16000
|
||||
logger.info("[TTS][%s] success duration_ms=%d", request_id, duration_ms)
|
||||
return TTSResponse(audio_base64=base64.b64encode(audio_data).decode(), format=req.format, duration_ms=duration_ms)
|
||||
except Exception as e:
|
||||
logger.exception("[TTS] failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/stt", response_model=STTResponse)
|
||||
async def speech_to_text(req: STTRequest, api_key: str = Security(get_api_key)):
|
||||
request_id = str(hash(req.audio_base64))[:8]
|
||||
try:
|
||||
logger.info("[STT][%s] audio_base64_chars=%d language=%s", request_id, len(req.audio_base64), req.language)
|
||||
audio_data = base64.b64decode(req.audio_base64)
|
||||
text = await _recognize_speech_macos_async(audio_data, req.language)
|
||||
logger.info("[STT][%s] success text_chars=%d", request_id, len(text))
|
||||
return STTResponse(text=text, language=req.language)
|
||||
except Exception as e:
|
||||
logger.exception("[STT] failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
def get_api_key(api_key: str):
|
||||
from backend.main import API_KEY
|
||||
if api_key != API_KEY:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="Could not validate credentials")
|
||||
return api_key
|
||||
|
||||
|
||||
def register_tts_stt_routes(app):
|
||||
app.include_router(router, prefix="/v1/tts-stt")
|
||||
Reference in New Issue
Block a user