Files
llm-in-text/backend/main.py
T
“ydy0615” 5a26dfde2a refactor: 全栈架构升级 - 风险控制、会话管理、审计日志和验证码功能
后端变更:
- 新增 risk_config.py: 风险配置数据类,支持环境变量驱动
- 新增 risk_control.py: 风险控制控制器,管理并发和预算
- 新增 session_store.py: 匿名会话存储,基于 cookie 的 session ID
- 新增 audit_store.py: API 审计日志存储,记录请求和 LLM 调用
- 新增 captcha_api.py: 验证码 API,用于验证用户操作真实性
- 新增 llm_policy.py: LLM 策略配置,管理 completion/pro/vision 模型
- main.py: 集成 middleware、risk/audit/session 模块 (+467/-7)
- job_handlers.py: LLM 执行流程重构,新增 risk/audit 集成 (+207/-4)
- llm.py: 异步客户端封装,新增 max_output_tokens 参数 (+78/-1)
- job_system.py: stream_events 逻辑优化,支持心跳检测 (+12/-4)
- pro_completions.py: SSE heartbeat 机制,防止连接超时 (+14/-4)
- prompt.py: _normalize_preferences 支持 Mapping 类型 (+13/-0)
- tts_asr.py: asyncio loop 初始化,router export (+10/-0)

前端变更:
- src/components/CaptchaComponent.vue: 新增验证码组件 (NEW)
- src/utils/cookie_policy.js: Cookie 策略工具 (NEW)
- SettingsPanel.vue: 集成验证码组件,新增安全设置部分 (+59/-0)
- MilkdownEditor.vue: 移除硬编码 API_KEY,新增 credentials (+32/-10)
- ProBlockCrepe.vue: 样式简化,移除渐变动画 (+18/-4)
- proBlockPlugin.ts: 重构 schema/serializer 引用方式,通过 Ctx 管理 (+40/-10)
- api.js: 新增 credentials,重构 headers 条件逻辑 (+50/-14)
- config.js: API 基址改为 https://api.imageteach.tech:8002 (+8/-4)
- convert.js, docsApi.js, i18n.js: 新增 credentials 和验证码 i18n (+54/-12)
- proAccept.js: 重构正则和转义处理,修复捕获组索引 (+14/-4)

配置和基础设施:
- docker-compose.yml: 新增端口映射 8001:8001 (+2/-0)
- docker/nginx.conf: 改为 307 redirect,优化代理配置 (+8/-6)
- vite.config.js: 移除 proxy 配置,直接调用远程 API (+8/-4)
- .env.example: 新增 VITE_API_BASE_URL, VITE_API_KEY (+3/-1)
- backend/.env.example: 大量 RISK_*, SESSION_*, CORS_* 配置 (+54/-0)
- pytest.ini: 扩展 coverage 范围到整个 backend,移除 fail_under (+3/-2)
- .coveragerc: 移除 fail_under = 90 (+0/-1)
- .gitignore: 新增 docker-data/ (+3/-0)
- package.json: 新增 vue3-captcha 依赖 (+3/-1)
- AGENTS.md, README.md: 更新 Docker 部署和前端网络约定 (+20/-5)
- public/sw.js: Service Worker cache 版本从 v1 升级到 v2 (+0/-1)

测试变更:
- test_main_endpoints.py: 新增 session/risk/audit reset,新增测试用例 (+63/-4)
- test_main_cancel.py: 新增 reset 调用 (+6/-0)
- test_pro_completions.py: 新增 preferences 序列化和测试 (+23/-0)

总计: 45 个文件变更,+1009/-280 行
2026-06-08 11:51:39 +08:00

904 lines
32 KiB
Python

import asyncio
import base64
import json
import logging
import os
import uuid
from contextlib import suppress
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,
)
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 CancelCompletionRequest(BaseModel):
request_id: str
reason: str = "abort"
class OCRRequest(BaseModel):
image: str
filename: str = "image.jpg"
language: str = "auto"
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:
global _handlers_registered
if _handlers_registered:
return
manager = get_job_manager()
manager.register_handler("completion", completion_handler)
manager.register_handler("pro_completion", pro_completion_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
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) -> 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.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, "image_bytes": len(image_bytes)},
raw_size=len(image_bytes),
token_source_text=f"{req.filename}:{len(image_bytes)}:{req.language}",
extra_payload={"input_path": input_path, "filename": req.filename, "language": req.language},
)
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)