This commit is contained in:
zhangyonghao
2026-03-20 23:09:51 +08:00
commit 8400fb6127
41 changed files with 9348 additions and 0 deletions

6
backend/.env.example Normal file
View File

@@ -0,0 +1,6 @@
# 腾讯云智能体平台 AppKey
# 获取方式:应用管理 → 找到运行中的应用 → 点击"调用" → 复制AppKey
BOT_APP_KEY=your-app-key-here
# 前端基础URL用于生成思维导图链接
FRONTEND_BASE_URL=http://localhost:3000

4
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
__pycache__
*.pyc
data/*.db
.venv

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
__all__ = []

22
backend/app/config.py Normal file
View 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
View 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()

44
backend/app/main.py Normal file
View File

@@ -0,0 +1,44 @@
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.database import Base, engine, SessionLocal
from app.models import Mindmap
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)
# 删除过期记录
db = SessionLocal()
try:
deleted_count = Mindmap.delete_expired_records(db)
if deleted_count > 0:
logger.info(f"Deleted {deleted_count} expired mindmap records")
finally:
db.close()
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"}

32
backend/app/models.py Normal file
View File

@@ -0,0 +1,32 @@
from datetime import datetime, timedelta
from sqlalchemy import DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, Session
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
)
@classmethod
def delete_expired_records(cls, db: Session, days: int = 5) -> int:
"""删除超过指定天数的记录"""
cutoff_date = datetime.utcnow() - timedelta(days=days)
deleted = db.query(cls).filter(cls.created_at < cutoff_date).delete()
db.commit()
return deleted

View File

@@ -0,0 +1 @@
__all__ = []

125
backend/app/routers/chat.py Normal file
View 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",
},
)

View File

@@ -0,0 +1,82 @@
import json
import logging
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
logger = logging.getLogger(__name__)
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:
# 先删除过期记录
deleted_count = Mindmap.delete_expired_records(db)
if deleted_count > 0:
logger.info(f"Deleted {deleted_count} expired mindmap records")
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
View 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

1
backend/data/.gitkeep Normal file
View File

@@ -0,0 +1 @@

6
backend/package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "backend",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

6
backend/requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
pydantic==2.10.4
httpx>=0.27.0
python-dotenv>=1.0.0