feat: add Docker support and update backend dependencies
- Introduced `requirements.docker.txt` for Docker-specific dependencies. - Updated `requirements.txt` to include `psycopg[binary]` and `python-multipart`. - Enhanced test suite in `test_main_endpoints.py` to cover document CRUD operations. - Modified `docker-compose.yml` to include PostgreSQL and frontend services. - Added Nginx configuration for reverse proxying API requests. - Refactored file handling in Vue components to support new document storage backend. - Created new utility functions in `docsApi.js` for document management. - Updated configuration to support new API endpoints for document operations. - Adjusted Vite configuration to proxy API requests to the local backend.
This commit is contained in:
+147
-1
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
@@ -5,12 +6,13 @@ import os
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, Security
|
||||
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 docs_store import get_document_store
|
||||
from geoip import get_ip_location_text
|
||||
from job_handlers import (
|
||||
_sanitize_converted_markdown,
|
||||
@@ -110,6 +112,23 @@ class ASRJobRequest(BaseModel):
|
||||
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:
|
||||
@@ -219,6 +238,12 @@ async def _queue_load_snapshot() -> dict:
|
||||
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)
|
||||
|
||||
|
||||
@app.post("/v1/completions")
|
||||
async def create_completion(
|
||||
request: Request,
|
||||
@@ -452,6 +477,127 @@ async def get_job_load(api_key: str = Security(get_api_key)):
|
||||
return {"queues": await _queue_load_snapshot()}
|
||||
|
||||
|
||||
@app.get("/v1/docs/nodes")
|
||||
async def list_docs_nodes(api_key: str = Security(get_api_key)):
|
||||
del api_key
|
||||
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(req: CreateFolderRequest, api_key: str = Security(get_api_key)):
|
||||
del api_key
|
||||
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(req: CreateTextFileRequest, api_key: str = Security(get_api_key)):
|
||||
del api_key
|
||||
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(
|
||||
file: UploadFile = File(...),
|
||||
parent_id: Optional[str] = Form(default=None),
|
||||
api_key: str = Security(get_api_key),
|
||||
):
|
||||
del api_key
|
||||
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(node_id: str, req: UpdateNodeRequest, api_key: str = Security(get_api_key)):
|
||||
del api_key
|
||||
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(
|
||||
node_id: str,
|
||||
file: UploadFile = File(...),
|
||||
api_key: str = Security(get_api_key),
|
||||
):
|
||||
del api_key
|
||||
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(node_id: str, api_key: str = Security(get_api_key)):
|
||||
del api_key
|
||||
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(node_id: str, api_key: str = Security(get_api_key)):
|
||||
del api_key
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user