first commit

This commit is contained in:
2026-03-20 19:40:17 +08:00
commit 8dcebff7a6
41 changed files with 9322 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()

34
backend/app/main.py Normal file
View 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
View 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
)

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,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
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