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, _infer_convert_suffix, 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 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 "") def _estimate_job_cost(policy, raw_size: int, estimated_input_tokens: int) -> float: if policy.profile == "speech_tts": return round((raw_size / 1000.0) * config.speech_tts_input_cost_per_1k_chars, 8) if policy.profile == "speech_asr": return round((raw_size / (1024.0 * 1024.0)) * config.speech_asr_input_cost_per_mb, 8) 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] return round( (estimated_input_tokens / 1000.0) * pricing_in + (policy.max_output_tokens / 1000.0) * pricing_out, 8, ) 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)) estimated_cost = _estimate_job_cost(policy, raw_size, estimated_input_tokens) 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) temp_suffix = _infer_convert_suffix(file_bytes, req.filename) if not temp_suffix: return JSONResponse({"error": "仅支持 txt、docx、pptx、pdf 格式"}, status_code=500) ext = os.path.splitext(req.filename)[1].lower() if ext != temp_suffix: return JSONResponse({"error": "仅支持 txt、docx、pptx、pdf 格式"}, status_code=500) input_path = persist_temp_input(file_bytes, temp_suffix) 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 body = { "text_chars": len((req.text or "").strip()), "speaker": req.speaker or "Vivian", "format": req.format or "wav", } try: identity, payload = await _prepare_llm_payload( request, job_type="tts", request_body=body, raw_size=len((req.text or "").strip()), token_source_text=req.text or "", extra_payload={ "text": req.text, "instruct": req.instruct, "speaker": req.speaker, "format": req.format, }, ) job_id = await _queue_job("tts", 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/tts-asr/asr") async def queue_asr(req: ASRJobRequest, request: Request, auth: dict = Security(_authorize_request)): del auth try: audio_bytes = base64.b64decode(req.audio_base64) except Exception as exc: return JSONResponse({"error": str(exc)}, status_code=400) input_path = persist_temp_input(audio_bytes, ".wav") try: identity, payload = await _prepare_llm_payload( request, job_type="asr", request_body={ "audio_bytes": len(audio_bytes), "language": req.language or "zh-CN", }, raw_size=len(audio_bytes), token_source_text=f"audio-bytes:{len(audio_bytes)} language:{req.language or 'zh-CN'}", extra_payload={ "input_path": input_path, "language": req.language or "zh-CN", "audio_bytes": len(audio_bytes), }, ) job_id = await _queue_job("asr", payload, identity.request_id) except RiskRejected as exc: if os.path.exists(input_path): os.unlink(input_path) return _risk_json_response(_request_identity(request), exc.decision) except QueueFullError as exc: if os.path.exists(input_path): os.unlink(input_path) return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=429) except JobSystemError as exc: if os.path.exists(input_path): os.unlink(input_path) return JSONResponse({"error": str(exc), "request_id": _request_id(request)}, status_code=503) 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() -> None: from tts_asr import LLM_BASE_URL, register_tts_asr_routes as _register_fn logger.info("TTS/ASR routes registered with shared LLM speech backend") _register_fn(app) _register_tts_asr_routes() @app.on_event("shutdown") async def _shutdown_job_manager(): # pragma: no cover from tts_asr import close_speech_client manager = get_job_manager() close = getattr(manager, "close", None) if close is not None: await close() await close_speech_client() if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8001)