Files
llm-in-text/backend/main.py
T
“ydy0615” 356108e792 feat: add video OCR/ASR, DOCX/PDF export, input block and risk config updates
- Video pipeline: video file OCR via VLM plus audio track ASR, integrated
  into job_handlers with progress emit per phase. New media_utils.py for
  audio extraction from video files.
- Document export: richExport.js replaces inline docx builder; DOCX and PDF
  export buttons are now enabled in MilkdownEditor. File size limit raised to
  100 MB.
- Input block: new InputBlockCrepe.vue component with inputBlockPlugin.ts
  and inputBlock.js for custom user-input nodes in the editor.
- Risk config: added Vite dev server ports (5173) to CORS allowlist and
  increased OCR max input from 10 MB to 100 MB.
- TTS/ASR refactor: simplified tts_asr.py model loading and warmup logic.
- Test coverage: updated tests for llm, main endpoints, pro completions and
  web search modules.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-18 16:32:31 +08:00

976 lines
34 KiB
Python

import asyncio
import base64
import json
import logging
import os
import uuid
from contextlib import suppress
from datetime import datetime
from typing import Optional
from fastapi import FastAPI, File, Form, HTTPException, Request, Response, Security, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.security import APIKeyHeader
from pydantic import BaseModel
from audit_store import get_audit_store
from docs_store import get_document_store
from geoip import get_ip_location_text
from job_handlers import (
_sanitize_converted_markdown,
sanitize_inline_completion_content,
ALLOWED_CONVERT_EXTENSIONS,
asr_handler,
completion_handler,
compress_handler,
convert_handler,
ocr_handler,
pro_completion_handler,
tts_handler,
web_search_handler,
)
from job_system import (
InMemoryJobManager,
JobSystemError,
JOB_TYPES,
QueueFullError,
RedisJobManager,
get_job_manager,
persist_temp_input,
)
from llm_policy import resolve_llm_policy
from models import UserPreferences
from risk_config import load_risk_config
from risk_control import RiskDecision, RiskIdentity, RiskRejected, estimate_tokens, get_risk_controller, stable_hash
from session_store import get_session_store
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s - %(message)s",
)
logger = logging.getLogger("api")
config = load_risk_config()
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=list(config.cors_allow_origins),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*", "X-API-Key", "X-Client-IP", "X-Request-Id"],
)
API_KEY = os.getenv("API_KEY", "your-secret-key-here")
DOC_COMPRESS_CONTEXT_LIMIT = int(os.getenv("DOC_COMPRESS_CONTEXT_LIMIT", "128000"))
STREAM_HEARTBEAT_SECONDS = float(os.getenv("STREAM_HEARTBEAT_SECONDS", "2"))
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
_handlers_registered = False
class CompletionRequest(BaseModel):
prefix: str
suffix: str
languageId: str = "markdown"
model_thinking: str = "low"
privacy_mode: bool = False
user_preferences: Optional[UserPreferences] = None
model: Optional[str] = None
temperature: float = 0.7
class ProCompletionRequest(BaseModel):
prefix: str
suffix: str
languageId: str = "markdown"
instruction: str = ""
pro_thinking: str = "medium"
privacy_mode: bool = False
user_preferences: Optional[UserPreferences] = None
class WebSearchRequest(BaseModel):
prefix: str
suffix: str
languageId: str = "markdown"
privacy_mode: bool = False
user_preferences: Optional[UserPreferences] = None
class CancelCompletionRequest(BaseModel):
request_id: str
reason: str = "abort"
class OCRRequest(BaseModel):
image: str
filename: str = "image.jpg"
language: str = "auto"
media_type: str = "image"
mime_type: str | None = None
class ConvertRequest(BaseModel):
file: str
filename: str = "document.pdf"
class CompressRequest(BaseModel):
content: str
docType: str = "txt"
class TTSJobRequest(BaseModel):
text: str
instruct: str = ""
speaker: str = "Vivian"
format: str = "wav"
class ASRJobRequest(BaseModel):
audio_base64: str
language: Optional[str] = "zh-CN"
class CreateFolderRequest(BaseModel):
name: str
parentId: Optional[str] = None
class CreateTextFileRequest(BaseModel):
name: str
parentId: Optional[str] = None
content: str = ""
class UpdateNodeRequest(BaseModel):
name: Optional[str] = None
parentId: Optional[str] = None
content: Optional[str] = None
def _preview(text: str, limit: int = 80) -> str:
value = (text or "").replace("\n", "\\n")
if len(value) <= limit:
return value
return value[:limit] + "..."
def get_client_ip(request: Request) -> str:
if request.client:
return request.headers.get("X-Client-IP") or request.client.host
return request.headers.get("X-Client-IP") or "unknown"
def _clamp_temperature(value: float, default: float = 0.7) -> float:
try:
numeric = float(value)
except (TypeError, ValueError):
return default
return max(0.0, min(numeric, 1.2))
async def get_api_key(api_key: str = Security(api_key_header)): # pragma: no cover
if api_key is not None and api_key != API_KEY:
raise HTTPException(status_code=403, detail="Could not validate credentials")
return api_key
@app.middleware("http")
async def attach_anonymous_session(request: Request, call_next):
client_ip_hash = stable_hash(get_client_ip(request))
user_agent_hash = stable_hash(request.headers.get("user-agent", ""))
store = get_session_store(os.getenv("DATABASE_URL", "").strip() or None)
session_id = request.cookies.get(config.session_cookie_name)
session = await asyncio.to_thread(
store.get_or_create,
session_id,
client_ip_hash=client_ip_hash,
user_agent_hash=user_agent_hash,
)
request.state.session = session
request.state.client_ip_hash = client_ip_hash
request.state.user_agent_hash = user_agent_hash
response = await call_next(request)
response.set_cookie(
key=config.session_cookie_name,
value=session.session_id,
max_age=config.session_cookie_max_age,
httponly=True,
secure=config.session_cookie_secure,
samesite=config.session_cookie_samesite,
domain=config.session_cookie_domain,
path=config.session_cookie_path,
)
return response
def _serialize_preferences(preferences: UserPreferences | None) -> dict | None:
if preferences is None:
return None
if hasattr(preferences, "model_dump"):
return preferences.model_dump()
if hasattr(preferences, "dict"):
return preferences.dict()
return dict(preferences)
def _request_id(request: Request) -> str:
return request.headers.get("X-Request-Id") or str(uuid.uuid4())
def _request_identity(request: Request) -> RiskIdentity:
session = getattr(request.state, "session")
return RiskIdentity(
request_id=_request_id(request),
session_hash=session.session_hash,
ip_hash=request.state.client_ip_hash,
route=request.url.path,
method=request.method,
)
async def _record_api_audit(
identity: RiskIdentity,
*,
decision: str,
status_code: int,
delay_ms: int = 0,
error_code: str = "",
metadata: dict | None = None,
) -> None:
store = get_audit_store(os.getenv("DATABASE_URL", "").strip() or None)
await asyncio.to_thread(
store.record_api_request,
{
"request_id": identity.request_id,
"session_hash": identity.session_hash,
"ip_hash": identity.ip_hash,
"route": identity.route,
"method": identity.method,
"status_code": status_code,
"decision": decision,
"delay_ms": delay_ms,
"error_code": error_code,
"metadata": metadata or {},
},
)
def _risk_json_response(identity: RiskIdentity, decision: RiskDecision) -> JSONResponse:
payload = {
"request_id": identity.request_id,
"error_code": decision.error_code or "request_rejected",
"message": decision.reason or "request rejected",
}
if decision.retry_after_seconds > 0:
payload["retry_after_seconds"] = decision.retry_after_seconds
return JSONResponse(payload, status_code=decision.status_code)
async def _authorize_request(
request: Request,
api_key: str | None = Security(api_key_header),
) -> dict:
del request
if api_key is not None and api_key != API_KEY:
raise HTTPException(status_code=403, detail="Could not validate credentials")
return {"api_key_authenticated": bool(api_key == API_KEY)}
def _register_handlers() -> None:
"""注册所有任务处理器。每次调用都会重新获取当前 manager 实例并强制注册,
确保 Redis 重连或实例重建后处理器不会丢失。"""
global _handlers_registered
manager = get_job_manager()
# 强制清空旧 handlers,避免重复注册累积;测试替身只暴露 register_handler。
handlers = getattr(manager, "handlers", None)
if handlers is not None:
handlers.clear()
manager.register_handler("completion", completion_handler)
manager.register_handler("pro_completion", pro_completion_handler)
manager.register_handler("web_search", web_search_handler)
manager.register_handler("compress", compress_handler)
manager.register_handler("ocr", ocr_handler)
manager.register_handler("convert", convert_handler)
manager.register_handler("tts", tts_handler)
manager.register_handler("asr", asr_handler)
_handlers_registered = True
# 打印注册信息便于调试
registered = list(getattr(manager, "handlers", {}).keys())
logger.info("handlers registered: %s", registered)
def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
async def _stream_job(job_id: str):
_register_handlers()
manager = get_job_manager()
async def event_stream():
event_iterator = manager.stream_events(job_id).__aiter__()
next_event_task = asyncio.create_task(anext(event_iterator))
try:
while True:
try:
event = await asyncio.wait_for(asyncio.shield(next_event_task), timeout=STREAM_HEARTBEAT_SECONDS)
except asyncio.TimeoutError:
yield ": keepalive\n\n"
continue
except StopAsyncIteration:
break
except Exception as exc:
logger.exception("job stream failed job_id=%s", job_id)
yield _sse("error", {"job_id": job_id, "error": str(exc)})
break
event_name = event.get("event", "message")
payload = {k: v for k, v in event.items() if k != "event"}
yield _sse(event_name, payload)
if event_name in {"done", "error", "cancelled"}:
break
next_event_task = asyncio.create_task(anext(event_iterator))
finally:
if not next_event_task.done():
next_event_task.cancel()
return StreamingResponse(
event_stream(),
media_type="text/event-stream; charset=utf-8",
headers={
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
async def _queue_job(job_type: str, payload: dict, request_id: str) -> str:
_register_handlers()
manager = get_job_manager()
return await manager.submit(job_type, payload, request_id=request_id)
async def _cancel_job(request_id: str, reason: str) -> dict:
_register_handlers()
manager = get_job_manager()
return await manager.cancel(request_id, reason)
async def _job_status(job_id: str) -> dict | None:
_register_handlers()
manager = get_job_manager()
return await manager.get_status(job_id)
async def _queue_load_snapshot() -> dict:
manager = get_job_manager()
if isinstance(manager, InMemoryJobManager):
return {job_type: manager._metrics(job_type) for job_type in manager.queues}
if isinstance(manager, RedisJobManager):
return {job_type: await manager._metrics(job_type) for job_type in JOB_TYPES}
return {}
async def _docs_store_call(method_name: str, *args, **kwargs):
store = get_document_store()
method = getattr(store, method_name)
return await asyncio.to_thread(method, *args, **kwargs)
async def _guard_api_request(request: Request, *, scope: str) -> tuple[RiskIdentity, RiskDecision]:
identity = _request_identity(request)
controller = get_risk_controller(config)
decision = await controller.check_api(identity, scope=scope)
if not decision.allowed:
await _record_api_audit(
identity,
decision="rejected",
status_code=decision.status_code,
delay_ms=decision.delay_ms,
error_code=decision.error_code,
metadata={"scope": scope},
)
return identity, decision
def _estimate_completion_chars(req: CompletionRequest | ProCompletionRequest | WebSearchRequest) -> int:
return len(req.prefix or "") + len(req.suffix or "") + len(getattr(req, "instruction", "") or "")
async def _prepare_llm_payload(
request: Request,
*,
job_type: str,
request_body: dict,
raw_size: int,
token_source_text: str | None = None,
extra_payload: dict | None = None,
) -> tuple[RiskIdentity, dict]:
identity, api_decision = await _guard_api_request(request, scope=job_type)
if not api_decision.allowed:
raise RiskRejected(api_decision)
policy = resolve_llm_policy(job_type, request_body, config)
if raw_size > policy.max_input_chars:
await _record_api_audit(
identity,
decision="rejected",
status_code=400,
error_code="input_too_large",
metadata={"job_type": job_type, "raw_size": raw_size},
)
raise HTTPException(status_code=400, detail=f"输入过长,超过限制 {policy.max_input_chars}")
estimated_input_tokens = estimate_tokens(token_source_text if token_source_text is not None else json.dumps(request_body, ensure_ascii=False))
pricing_in = {
"completion": config.completion_input_cost_per_1k,
"pro": config.pro_input_cost_per_1k,
"vision": config.vision_input_cost_per_1k,
}[policy.profile]
pricing_out = {
"completion": config.completion_output_cost_per_1k,
"pro": config.pro_output_cost_per_1k,
"vision": config.vision_output_cost_per_1k,
}[policy.profile]
estimated_cost = round(
(estimated_input_tokens / 1000.0) * pricing_in
+ (policy.max_output_tokens / 1000.0) * pricing_out,
8,
)
controller = get_risk_controller(config)
llm_decision = await controller.check_llm(identity, scope=policy.model, estimated_cost=estimated_cost)
if not llm_decision.allowed:
await _record_api_audit(
identity,
decision="rejected",
status_code=llm_decision.status_code,
delay_ms=llm_decision.delay_ms,
error_code=llm_decision.error_code,
metadata={"job_type": job_type, "estimated_cost": estimated_cost},
)
raise RiskRejected(llm_decision)
await controller.reserve_budget(identity, estimated_cost)
await _record_api_audit(
identity,
decision="accepted",
status_code=202,
delay_ms=max(api_decision.delay_ms, llm_decision.delay_ms),
metadata={"job_type": job_type, "estimated_cost": estimated_cost},
)
payload = {
"request_id": identity.request_id,
"risk": {
"request_id": identity.request_id,
"session_hash": identity.session_hash,
"ip_hash": identity.ip_hash,
"delay_ms": max(api_decision.delay_ms, llm_decision.delay_ms),
"estimated_input_tokens": estimated_input_tokens,
"estimated_cost": estimated_cost,
"policy": {
"job_type": policy.job_type,
"model": policy.model,
"profile": policy.profile,
"max_input_chars": policy.max_input_chars,
"max_output_tokens": policy.max_output_tokens,
"temperature": policy.temperature,
"thinking": policy.thinking,
},
},
"request": request_body,
}
if extra_payload:
payload.update(extra_payload)
return identity, payload
@app.post("/v1/completions")
async def create_completion(
request: Request,
req: CompletionRequest,
auth: dict = Security(_authorize_request),
):
del auth
location = ""
if not req.privacy_mode: # pragma: no cover
location = get_ip_location_text(get_client_ip(request))
body = {
"prefix": req.prefix,
"suffix": req.suffix,
"languageId": req.languageId,
"model_thinking": req.model_thinking,
"privacy_mode": req.privacy_mode,
"user_preferences": _serialize_preferences(req.user_preferences),
"temperature": _clamp_temperature(req.temperature, 0.7),
}
try:
identity, payload = await _prepare_llm_payload(
request,
job_type="completion",
request_body=body,
raw_size=_estimate_completion_chars(req),
token_source_text=f"{req.prefix}\n{req.suffix}",
extra_payload={"location": location},
)
job_id = await _queue_job("completion", payload, identity.request_id)
except RiskRejected as exc:
return _risk_json_response(_request_identity(request), exc.decision)
except QueueFullError as exc:
return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=429)
except JobSystemError as exc:
return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=503)
return await _stream_job(job_id)
@app.post("/v1/completions/cancel")
async def cancel_completion(req: CancelCompletionRequest, auth: dict = Security(_authorize_request)):
del auth
return await _cancel_job(req.request_id or "", req.reason)
@app.post("/v1/pro/completions")
async def create_pro_completion(
request: Request,
req: ProCompletionRequest,
auth: dict = Security(_authorize_request),
):
del auth
location = ""
if not req.privacy_mode: # pragma: no cover
location = get_ip_location_text(get_client_ip(request))
body = {
"prefix": req.prefix,
"suffix": req.suffix,
"languageId": req.languageId,
"instruction": req.instruction,
"pro_thinking": req.pro_thinking,
"privacy_mode": req.privacy_mode,
"user_preferences": _serialize_preferences(req.user_preferences),
}
try:
identity, payload = await _prepare_llm_payload(
request,
job_type="pro_completion",
request_body=body,
raw_size=_estimate_completion_chars(req),
token_source_text=f"{req.prefix}\n{req.suffix}\n{req.instruction}",
extra_payload={"location": location},
)
job_id = await _queue_job("pro_completion", payload, identity.request_id)
except RiskRejected as exc:
return _risk_json_response(_request_identity(request), exc.decision)
except QueueFullError as exc:
return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=429)
except JobSystemError as exc:
return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=503)
return await _stream_job(job_id)
@app.post("/v1/pro/completions/cancel")
async def cancel_pro_completion(req: CancelCompletionRequest, auth: dict = Security(_authorize_request)):
del auth
return await _cancel_job(req.request_id or "", req.reason)
@app.post("/v1/web-search")
async def create_web_search(
request: Request,
req: WebSearchRequest,
auth: dict = Security(_authorize_request),
):
del auth
body = {
"prefix": req.prefix,
"suffix": req.suffix,
"languageId": req.languageId,
"privacy_mode": req.privacy_mode,
"user_preferences": _serialize_preferences(req.user_preferences),
}
try:
identity, payload = await _prepare_llm_payload(
request,
job_type="web_search",
request_body=body,
raw_size=_estimate_completion_chars(req),
token_source_text=f"{req.prefix}\n{req.suffix}",
)
payload["created_at"] = datetime.utcnow().isoformat()
job_id = await _queue_job("web_search", payload, identity.request_id)
except RiskRejected as exc:
return _risk_json_response(_request_identity(request), exc.decision)
except QueueFullError as exc:
return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=429)
except JobSystemError as exc:
return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=503)
return await _stream_job(job_id)
@app.post("/v1/web-search/cancel")
async def cancel_web_search(req: CancelCompletionRequest, auth: dict = Security(_authorize_request)):
del auth
return await _cancel_job(req.request_id or "", req.reason)
@app.get("/v1/pro/completions/status/{request_id}")
async def get_pro_completion_status(request_id: str, auth: dict = Security(_authorize_request)):
del auth
state = await _job_status(request_id)
if state is None:
raise HTTPException(status_code=404, detail="PRO request not found")
return state
@app.post("/v1/ocr")
async def ocr_image(request: Request, req: OCRRequest, auth: dict = Security(_authorize_request)):
del auth
try:
image_bytes = base64.b64decode(req.image)
except Exception as exc:
return JSONResponse({"error": str(exc)}, status_code=500)
if len(image_bytes) > config.ocr_max_input_bytes:
return JSONResponse({"error": "文件过大,无法执行 OCR/视频解析"}, status_code=400)
input_path = persist_temp_input(image_bytes, os.path.splitext(req.filename)[1] or ".img")
try:
identity, payload = await _prepare_llm_payload(
request,
job_type="ocr",
request_body={
"filename": req.filename,
"language": req.language,
"media_type": req.media_type,
"mime_type": req.mime_type,
"image_bytes": len(image_bytes),
},
raw_size=len(image_bytes),
token_source_text=f"{req.filename}:{req.media_type}:{req.mime_type}:{len(image_bytes)}:{req.language}",
extra_payload={
"input_path": input_path,
"filename": req.filename,
"language": req.language,
"media_type": req.media_type,
"mime_type": req.mime_type,
},
)
job_id = await _queue_job("ocr", payload, identity.request_id)
except RiskRejected as exc:
return _risk_json_response(_request_identity(request), exc.decision)
except Exception:
if os.path.exists(input_path):
os.unlink(input_path)
raise
return await _stream_job(job_id)
@app.post("/v1/convert")
async def convert_to_markdown(request: Request, req: ConvertRequest, auth: dict = Security(_authorize_request)):
del auth
identity, decision = await _guard_api_request(request, scope="convert")
if not decision.allowed:
return _risk_json_response(identity, decision)
request_id = identity.request_id
ext = os.path.splitext(req.filename)[1].lower()
if ext not in ALLOWED_CONVERT_EXTENSIONS:
return JSONResponse({"error": "仅支持 txt、docx、pptx、pdf 格式"}, status_code=500)
try:
file_bytes = base64.b64decode(req.file)
except Exception as exc:
return JSONResponse({"error": str(exc)}, status_code=500)
input_path = persist_temp_input(file_bytes, ext or ".bin")
try:
job_id = await _queue_job("convert", {
"request_id": request_id,
"input_path": input_path,
"filename": req.filename,
}, request_id)
except Exception:
if os.path.exists(input_path):
os.unlink(input_path)
raise
return await _stream_job(job_id)
@app.post("/v1/compress/submit")
async def submit_compress(request: Request, req: CompressRequest, auth: dict = Security(_authorize_request)):
del auth
content = req.content or ""
if not content.strip():
raise HTTPException(status_code=400, detail="文档内容为空,无法压缩")
if len(content) > DOC_COMPRESS_CONTEXT_LIMIT:
raise HTTPException(
status_code=400,
detail=f"文档内容过长({len(content)} 字符),超过限制 {DOC_COMPRESS_CONTEXT_LIMIT},无法压缩",
)
try:
identity, payload = await _prepare_llm_payload(
request,
job_type="compress",
request_body={"content_length": len(content), "docType": req.docType or "txt"},
raw_size=len(content),
token_source_text=content,
extra_payload={"content": content, "docType": req.docType or "txt"},
)
await _queue_job("compress", payload, identity.request_id)
return {"task_id": identity.request_id, "status": "queued"}
except RiskRejected as exc:
return _risk_json_response(_request_identity(request), exc.decision)
@app.get("/v1/compress/status")
async def get_compress_status(task_id: str, auth: dict = Security(_authorize_request)):
del auth
if not task_id:
raise HTTPException(status_code=400, detail="缺少 task_id 参数")
state = await _job_status(task_id)
if state is None:
raise HTTPException(status_code=404, detail="任务不存在或已过期")
if state["status"] == "completed":
result = state.get("result") or {}
return {"task_id": task_id, "status": "completed", "content": result.get("content", "")}
if state["status"] == "failed":
return {"task_id": task_id, "status": "error", "message": state.get("error") or ""}
if state["status"] == "cancelled":
return {"task_id": task_id, "status": "cancelled"}
return {
"task_id": task_id,
"status": "processing" if state["status"] == "running" else "queued",
"busy_level": state.get("busy_level"),
"queued_count": state.get("queued_count"),
"running_count": state.get("running_count"),
}
@app.post("/v1/tts-asr/tts")
async def queue_tts(req: TTSJobRequest, request: Request, auth: dict = Security(_authorize_request)):
del auth
request_id = _request_id(request)
job_id = await _queue_job("tts", {
"request_id": request_id,
"text": req.text,
"instruct": req.instruct,
"speaker": req.speaker,
"format": req.format,
}, request_id)
return await _stream_job(job_id)
@app.post("/v1/tts-asr/asr")
async def queue_asr(req: ASRJobRequest, request: Request, auth: dict = Security(_authorize_request)):
del auth
request_id = _request_id(request)
try:
audio_bytes = base64.b64decode(req.audio_base64)
except Exception as exc:
return JSONResponse({"error": str(exc)}, status_code=500)
input_path = persist_temp_input(audio_bytes, ".wav")
try:
job_id = await _queue_job("asr", {
"request_id": request_id,
"input_path": input_path,
"language": req.language or "zh-CN",
}, request_id)
except Exception:
if os.path.exists(input_path):
os.unlink(input_path)
raise
return await _stream_job(job_id)
@app.post("/v1/jobs/{job_id}/cancel")
async def cancel_job(job_id: str, req: CancelCompletionRequest, auth: dict = Security(_authorize_request)):
del auth
return await _cancel_job(req.request_id or job_id, req.reason)
@app.get("/v1/jobs/{job_id}/status")
async def get_job_status(job_id: str, auth: dict = Security(_authorize_request)):
del auth
state = await _job_status(job_id)
if state is None:
raise HTTPException(status_code=404, detail="job not found")
return state
@app.get("/v1/jobs/load")
async def get_job_load(auth: dict = Security(_authorize_request)):
del auth
return {"queues": await _queue_load_snapshot()}
@app.get("/v1/docs/nodes")
async def list_docs_nodes(request: Request, auth: dict = Security(_authorize_request)):
del auth
identity, decision = await _guard_api_request(request, scope="docs_list")
if not decision.allowed:
return _risk_json_response(identity, decision)
try:
return {"nodes": await _docs_store_call("list_nodes")}
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
@app.post("/v1/docs/folders")
async def create_docs_folder(request: Request, req: CreateFolderRequest, auth: dict = Security(_authorize_request)):
del auth
identity, decision = await _guard_api_request(request, scope="docs_write")
if not decision.allowed:
return _risk_json_response(identity, decision)
if not (req.name or "").strip():
raise HTTPException(status_code=400, detail="文件夹名称不能为空")
try:
node = await _docs_store_call("create_folder", req.name.strip(), req.parentId)
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
return {"node": node}
@app.post("/v1/docs/files/text")
async def create_docs_text_file(request: Request, req: CreateTextFileRequest, auth: dict = Security(_authorize_request)):
del auth
identity, decision = await _guard_api_request(request, scope="docs_write")
if not decision.allowed:
return _risk_json_response(identity, decision)
if not (req.name or "").strip():
raise HTTPException(status_code=400, detail="文件名称不能为空")
try:
node = await _docs_store_call("create_text_file", req.name.strip(), req.parentId, req.content or "")
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
return {"node": node}
@app.post("/v1/docs/files/upload")
async def upload_docs_file(
request: Request,
file: UploadFile = File(...),
parent_id: Optional[str] = Form(default=None),
auth: dict = Security(_authorize_request),
):
del auth
identity, decision = await _guard_api_request(request, scope="docs_write")
if not decision.allowed:
return _risk_json_response(identity, decision)
filename = (file.filename or "").strip()
if not filename:
raise HTTPException(status_code=400, detail="文件名称不能为空")
raw_bytes = await file.read()
try:
node = await _docs_store_call("upload_file", filename, parent_id, raw_bytes, file.content_type or "")
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
return {"node": node}
@app.patch("/v1/docs/nodes/{node_id}")
async def update_docs_node(request: Request, node_id: str, req: UpdateNodeRequest, auth: dict = Security(_authorize_request)):
del auth
identity, decision = await _guard_api_request(request, scope="docs_write")
if not decision.allowed:
return _risk_json_response(identity, decision)
fields_set = req.model_fields_set if hasattr(req, "model_fields_set") else getattr(req, "__fields_set__", set())
if not fields_set:
raise HTTPException(status_code=400, detail="缺少更新内容")
update_kwargs = {}
if "name" in fields_set:
next_name = req.name.strip() if isinstance(req.name, str) else ""
if not next_name:
raise HTTPException(status_code=400, detail="名称不能为空")
update_kwargs["name"] = next_name
if "parentId" in fields_set:
update_kwargs["parent_id"] = req.parentId
if "content" in fields_set:
update_kwargs["content"] = req.content or ""
try:
node = await _docs_store_call("update_node", node_id, **update_kwargs)
except KeyError as exc:
raise HTTPException(status_code=404, detail="节点不存在") from exc
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
return {"node": node}
@app.put("/v1/docs/files/{node_id}/blob")
async def replace_docs_blob(
request: Request,
node_id: str,
file: UploadFile = File(...),
auth: dict = Security(_authorize_request),
):
del auth
identity, decision = await _guard_api_request(request, scope="docs_write")
if not decision.allowed:
return _risk_json_response(identity, decision)
filename = (file.filename or "").strip()
if not filename:
raise HTTPException(status_code=400, detail="文件名称不能为空")
raw_bytes = await file.read()
try:
node = await _docs_store_call("replace_blob", node_id, filename, raw_bytes, file.content_type or "")
except KeyError as exc:
raise HTTPException(status_code=404, detail="节点不存在") from exc
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
return {"node": node}
@app.delete("/v1/docs/nodes/{node_id}")
async def delete_docs_node(request: Request, node_id: str, auth: dict = Security(_authorize_request)):
del auth
identity, decision = await _guard_api_request(request, scope="docs_write")
if not decision.allowed:
return _risk_json_response(identity, decision)
try:
await _docs_store_call("delete_node", node_id)
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
return {"ok": True}
@app.get("/v1/docs/files/{node_id}/blob")
async def download_docs_blob(request: Request, node_id: str, auth: dict = Security(_authorize_request)):
del auth
identity, decision = await _guard_api_request(request, scope="docs_blob")
if not decision.allowed:
return _risk_json_response(identity, decision)
try:
payload = await _docs_store_call("get_blob", node_id)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail="文件不存在") from exc
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
headers = {
"Content-Disposition": f'inline; filename="{payload.filename}"',
}
return Response(content=payload.content, media_type=payload.mime_type, headers=headers)
def _register_tts_asr_routes():
try:
from tts_asr import register_tts_asr_routes
except ModuleNotFoundError as exc:
logger.warning("Skipping TTS/ASR route registration because a dependency is missing: %s", exc)
return
except Exception as exc:
logger.warning("Skipping TTS/ASR route registration because import failed: %s", exc)
return
try:
register_tts_asr_routes(app, include_generation_routes=False)
except Exception as exc:
logger.warning("Failed to register TTS/ASR routes: %s", exc)
_register_tts_asr_routes()
@app.on_event("shutdown")
async def _shutdown_job_manager(): # pragma: no cover
manager = get_job_manager()
close = getattr(manager, "close", None)
if close is not None:
await close()
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8001)