feat: complete grounded digital avatar chat experience

This commit is contained in:
stefanfeng
2026-08-19 14:21:56 +08:00
parent 07d4a21379
commit 4a2d788e85
16 changed files with 1013 additions and 193 deletions
+39 -40
View File
@@ -1,8 +1,8 @@
from fastapi import APIRouter, Depends, Body, Header, UploadFile, File
from sqlalchemy.orm import Session
import os
import uuid
import mimetypes
from fastapi import APIRouter, Depends, Body, Header, UploadFile, File, HTTPException
from sqlalchemy.orm import Session
from database import get_db
from routers.knowledge import UPLOAD_DIR
@@ -10,6 +10,8 @@ from models import Avatar, KnowledgeDoc, KnowledgeChunk, QAPair, Authorization,
from responses import ok, fail
router = APIRouter(tags=["分身"])
ALLOWED_AVATAR_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
MAX_AVATAR_BYTES = 5 * 1024 * 1024
def _resolve_user(authorization: str | None, db: Session):
@@ -20,6 +22,40 @@ def _resolve_user(authorization: str | None, db: Session):
return db.query(User).filter(User.app_token == token).first()
def _require_owned_avatar(db: Session, avatar_id: str, authorization: str | None):
avatar = db.query(Avatar).filter(Avatar.id == avatar_id).first()
if not avatar:
raise HTTPException(status_code=404, detail="分身不存在")
user = _resolve_user(authorization, db)
if not user:
raise HTTPException(status_code=401, detail="未登录")
if avatar.owner_id and avatar.owner_id != user.huihui_user_id:
raise HTTPException(status_code=403, detail="无权访问该分身")
return avatar
@router.post("/avatar/{avatar_id}/photo")
async def upload_avatar_photo(
avatar_id: str,
file: UploadFile = File(...),
authorization: str = Header(None),
db: Session = Depends(get_db),
):
_require_owned_avatar(db, avatar_id, authorization)
extension = os.path.splitext(file.filename or "")[1].lower()
if extension not in ALLOWED_AVATAR_EXTENSIONS or not (file.content_type or "").startswith("image/"):
return fail("仅支持 JPG、PNG、WebP 或 GIF 图片", code=400)
content = await file.read()
if len(content) > MAX_AVATAR_BYTES:
return fail("头像图片不能超过 5MB", code=400)
avatar_dir = os.path.join(UPLOAD_DIR, avatar_id)
os.makedirs(avatar_dir, exist_ok=True)
stored_name = f"avatar-{uuid.uuid4().hex}{extension}"
with open(os.path.join(avatar_dir, stored_name), "wb") as stream:
stream.write(content)
return ok({"photoUrl": f"/api/files/{avatar_id}/{stored_name}"})
@router.get("/avatar")
def list_avatars(page: int = 1, limit: int = 20, authorization: str = Header(None), db: Session = Depends(get_db)):
# 仅返回当前登录用户自己的分身;未登录返回空,避免看到种子/他人数据
@@ -98,40 +134,3 @@ def delete_avatar(avatar_id: str, db: Session = Depends(get_db)):
db.commit()
return ok({"success": True})
@router.post("/avatar/{avatar_id}/photo")
async def upload_avatar_photo(
avatar_id: str,
file: UploadFile = File(...),
db: Session = Depends(get_db),
):
"""上传数字分身头像"""
a = db.query(Avatar).filter(Avatar.id == avatar_id).first()
if not a:
return fail("分身不存在", 404)
# 验证文件类型
if not file.content_type or not file.content_type.startswith("image/"):
return fail("仅支持图片文件", 400)
file_bytes = await file.read()
if len(file_bytes) > 5 * 1024 * 1024:
return fail("头像文件不能超过5MB", 400)
# 保存到 uploads 目录
ext = mimetypes.guess_extension(file.content_type) or ".jpg"
filename = f"avatar-{uuid.uuid4().hex}{ext}"
avatar_dir = os.path.join(UPLOAD_DIR, avatar_id)
os.makedirs(avatar_dir, exist_ok=True)
file_path = os.path.join(avatar_dir, filename)
with open(file_path, "wb") as f:
f.write(file_bytes)
# 更新数据库
photo_url = f"/api/files/{avatar_id}/{filename}"
a.photo_url = photo_url
db.commit()
db.refresh(a)
return ok(a.to_dict())