forked from zhangyonghao/minimap
first commit
This commit is contained in:
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
__all__ = []
|
||||
22
backend/app/config.py
Normal file
22
backend/app/config.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
backend_dir = Path(__file__).resolve().parent.parent
|
||||
load_dotenv(backend_dir / ".env")
|
||||
|
||||
|
||||
class Settings:
|
||||
app_name = "Interactive Mindmap API"
|
||||
api_prefix = "/api"
|
||||
backend_dir = backend_dir
|
||||
data_dir = backend_dir / "data"
|
||||
database_path = data_dir / "mindmap.db"
|
||||
database_url = f"sqlite:///{database_path.as_posix()}"
|
||||
allowed_origins = ["*"]
|
||||
bot_app_key = os.environ.get("BOT_APP_KEY", "")
|
||||
frontend_base_url = os.environ.get("FRONTEND_BASE_URL", "http://localhost:3000")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
23
backend/app/database.py
Normal file
23
backend/app/database.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
|
||||
from app.config import settings
|
||||
|
||||
settings.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db() -> Generator:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
34
backend/app/main.py
Normal file
34
backend/app/main.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.database import Base, engine
|
||||
from app.routers import chat, mindmaps
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
app = FastAPI(title=settings.app_name)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.allowed_origins,
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(mindmaps.router, prefix=settings.api_prefix)
|
||||
app.include_router(chat.router, prefix=settings.api_prefix)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def health_check() -> dict[str, str]:
|
||||
return {"message": "Interactive Mindmap API is running"}
|
||||
24
backend/app/models.py
Normal file
24
backend/app/models.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Mindmap(Base):
|
||||
__tablename__ = "mindmaps"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
unique_id: Mapped[str] = mapped_column(
|
||||
String(32), unique=True, index=True, nullable=False
|
||||
)
|
||||
session_id: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
raw_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||
)
|
||||
1
backend/app/routers/__init__.py
Normal file
1
backend/app/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
__all__ = []
|
||||
125
backend/app/routers/chat.py
Normal file
125
backend/app/routers/chat.py
Normal file
@@ -0,0 +1,125 @@
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import AsyncGenerator
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/chat", tags=["chat"])
|
||||
|
||||
TENCENT_SSE_URL = "https://wss.lke.cloud.tencent.com/v1/qbot/chat/sse"
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
session_id: str
|
||||
content: str
|
||||
visitor_biz_id: str = "default_visitor"
|
||||
|
||||
|
||||
async def forward_events(
|
||||
response: httpx.Response, request_id: str
|
||||
) -> AsyncGenerator[bytes, None]:
|
||||
"""Read the upstream SSE stream and forward it as-is."""
|
||||
async for line in response.aiter_lines():
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
yield b"\n"
|
||||
continue
|
||||
|
||||
logger.info("[%s] Forwarding: %s", request_id, stripped[:200])
|
||||
yield (stripped + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def build_error_event(message: str, request_id: str, code: int = 500) -> bytes:
|
||||
event = {
|
||||
"type": "error",
|
||||
"request_id": request_id,
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
},
|
||||
}
|
||||
return (
|
||||
f"event: error\n"
|
||||
f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def chat(payload: ChatRequest):
|
||||
request_id = str(uuid.uuid4())[:8]
|
||||
logger.info(
|
||||
"[%s] Chat request received: session_id=%s, content=%s",
|
||||
request_id,
|
||||
payload.session_id,
|
||||
payload.content[:50],
|
||||
)
|
||||
|
||||
if not settings.bot_app_key:
|
||||
logger.error("[%s] BOT_APP_KEY is not configured", request_id)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="BOT_APP_KEY is not configured",
|
||||
)
|
||||
|
||||
request_body = {
|
||||
"request_id": request_id,
|
||||
"content": payload.content,
|
||||
"bot_app_key": settings.bot_app_key,
|
||||
"visitor_biz_id": payload.visitor_biz_id,
|
||||
"session_id": payload.session_id,
|
||||
"stream": "enable",
|
||||
}
|
||||
|
||||
logger.info("[%s] Sending to Tencent: %s", request_id, TENCENT_SSE_URL)
|
||||
|
||||
async def stream_generator() -> AsyncGenerator[bytes, None]:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
try:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
TENCENT_SSE_URL,
|
||||
json=request_body,
|
||||
headers={"Accept": "text/event-stream"},
|
||||
) as response:
|
||||
logger.info(
|
||||
"[%s] Tencent response status: %s",
|
||||
request_id,
|
||||
response.status_code,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
body = await response.aread()
|
||||
error_msg = body.decode("utf-8", errors="replace")
|
||||
logger.error("[%s] Tencent error: %s", request_id, error_msg)
|
||||
yield build_error_event(
|
||||
error_msg,
|
||||
request_id,
|
||||
response.status_code,
|
||||
)
|
||||
return
|
||||
|
||||
logger.info("[%s] Starting to forward events", request_id)
|
||||
async for chunk in forward_events(response, request_id):
|
||||
yield chunk
|
||||
|
||||
logger.info("[%s] Stream completed", request_id)
|
||||
except httpx.RequestError as exc:
|
||||
logger.exception("[%s] Request to Tencent failed: %s", request_id, exc)
|
||||
yield build_error_event(str(exc), request_id)
|
||||
|
||||
return StreamingResponse(
|
||||
stream_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
74
backend/app/routers/mindmaps.py
Normal file
74
backend/app/routers/mindmaps.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models import Mindmap
|
||||
from app.schemas import MindmapCreateRequest, MindmapNode, MindmapResponse
|
||||
|
||||
router = APIRouter(prefix="/mindmaps", tags=["mindmaps"])
|
||||
|
||||
|
||||
def generate_unique_id() -> str:
|
||||
return secrets.token_urlsafe(16)
|
||||
|
||||
|
||||
def extract_title_from_json(mindmap_json: dict) -> str:
|
||||
label = mindmap_json.get("label", "")
|
||||
return label.strip() if label else "未命名思维导图"
|
||||
|
||||
|
||||
def to_response(mindmap: Mindmap) -> MindmapResponse:
|
||||
tree_data = json.loads(mindmap.raw_json)
|
||||
tree = MindmapNode.model_validate(tree_data)
|
||||
url = f"{settings.frontend_base_url}/mindmap/{mindmap.unique_id}"
|
||||
|
||||
return MindmapResponse(
|
||||
id=mindmap.id,
|
||||
unique_id=mindmap.unique_id,
|
||||
session_id=mindmap.session_id,
|
||||
title=mindmap.title,
|
||||
raw_json=mindmap.raw_json,
|
||||
tree=tree,
|
||||
url=url,
|
||||
created_at=mindmap.created_at,
|
||||
updated_at=mindmap.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=MindmapResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_mindmap(
|
||||
payload: MindmapCreateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> MindmapResponse:
|
||||
title = extract_title_from_json(payload.mindmap_json)
|
||||
raw_json = json.dumps(payload.mindmap_json, ensure_ascii=False)
|
||||
unique_id = generate_unique_id()
|
||||
|
||||
mindmap = Mindmap(
|
||||
unique_id=unique_id,
|
||||
session_id=payload.session_id,
|
||||
title=title,
|
||||
raw_json=raw_json,
|
||||
)
|
||||
db.add(mindmap)
|
||||
db.commit()
|
||||
db.refresh(mindmap)
|
||||
|
||||
return to_response(mindmap)
|
||||
|
||||
|
||||
@router.get("/{unique_id}", response_model=MindmapResponse)
|
||||
def get_mindmap(
|
||||
unique_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
) -> MindmapResponse:
|
||||
mindmap = db.query(Mindmap).filter(Mindmap.unique_id == unique_id).first()
|
||||
|
||||
if not mindmap:
|
||||
raise HTTPException(status_code=404, detail="脑图不存在")
|
||||
|
||||
return to_response(mindmap)
|
||||
34
backend/app/schemas.py
Normal file
34
backend/app/schemas.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class MindmapNode(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
parent_id: str | None = None
|
||||
level: int
|
||||
is_leaf: bool
|
||||
children: list["MindmapNode"] = Field(default_factory=list)
|
||||
|
||||
|
||||
MindmapNode.model_rebuild()
|
||||
|
||||
|
||||
class MindmapCreateRequest(BaseModel):
|
||||
session_id: str = Field(min_length=1, max_length=255)
|
||||
mindmap_json: dict
|
||||
|
||||
|
||||
class MindmapResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
unique_id: str
|
||||
session_id: str
|
||||
title: str
|
||||
raw_json: str
|
||||
tree: MindmapNode
|
||||
url: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
Reference in New Issue
Block a user