from fastapi import APIRouter, Depends, HTTPException, Query, Request from sqlalchemy.orm import Session from database import get_db from models.chat import ChatConversation, ChatMessage from models.user import User from schemas.chat import ConversationCreate, ConversationRename, ChatSend, MessageOut, ConversationOut from services.credits import credits_payload, spend_credits from services.limiter import limiter from services.auth import get_current_user from services.audit import log_action from services.ai_service import AIService router = APIRouter(prefix="/api/chat", tags=["AI教学助手"]) ai_service = AIService() # 历史上下文窗口:最近 N 轮(user+assistant 计为 2 条),避免 token 超限 MAX_HISTORY_TURNS = 12 MAX_TITLE_LEN = 60 def _ensure_owned(db: Session, conversation_id: int, user: User) -> ChatConversation: item = db.query(ChatConversation).filter( ChatConversation.id == conversation_id, ChatConversation.user_id == user.id, ).first() if not item: raise HTTPException(status_code=404, detail="对话不存在") return item def _derive_title(text: str) -> str: cleaned = " ".join(text.split()) return (cleaned[:MAX_TITLE_LEN] + "…") if len(cleaned) > MAX_TITLE_LEN else (cleaned or "新对话") @router.get("/conversations", response_model=list[ConversationOut]) def list_conversations( keyword: str | None = None, skip: int = Query(0, ge=0), limit: int = Query(30, ge=1, le=100), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): query = db.query(ChatConversation).filter(ChatConversation.user_id == current_user.id) if keyword: query = query.filter(ChatConversation.title.contains(keyword)) return ( query.order_by(ChatConversation.pinned.desc(), ChatConversation.updated_at.desc()) .offset(skip).limit(limit).all() ) @router.post("/conversations", response_model=ConversationOut, status_code=201) def create_conversation( data: ConversationCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): item = ChatConversation( user_id=current_user.id, title=(data.title or "新对话").strip() or "新对话", subject=data.subject, grade=data.grade, ) db.add(item) db.commit() db.refresh(item) return item @router.get("/conversations/{conversation_id}", response_model=ConversationOut) def get_conversation( conversation_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): return _ensure_owned(db, conversation_id, current_user) @router.put("/conversations/{conversation_id}", response_model=ConversationOut) def rename_conversation( conversation_id: int, data: ConversationRename, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): item = _ensure_owned(db, conversation_id, current_user) item.title = data.title.strip() or item.title if data.pinned is not None: item.pinned = data.pinned db.commit() db.refresh(item) return item @router.delete("/conversations/{conversation_id}", status_code=204) def delete_conversation( conversation_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): item = _ensure_owned(db, conversation_id, current_user) db.delete(item) db.commit() return None @router.post("/conversations/{conversation_id}/messages", response_model=dict) @limiter.limit("20/minute") async def send_message( request: Request, conversation_id: int, data: ChatSend, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): conversation = _ensure_owned(db, conversation_id, current_user) account = spend_credits(db, current_user, "chat_generate", "AI教学助手对话") db.add(ChatMessage( conversation_id=conversation.id, role="user", content=data.content, )) db.flush() history = ( db.query(ChatMessage) .filter(ChatMessage.conversation_id == conversation.id) .order_by(ChatMessage.id.desc()) .limit(MAX_HISTORY_TURNS) .all() ) history = list(reversed(history)) history_payload = [{"role": m.role, "content": m.content} for m in history[:-1]] if history else [] try: reply = await ai_service.chat_teaching( history=history_payload, user_message=data.content, subject=data.subject or conversation.subject, grade=data.grade or conversation.grade, ) except RuntimeError as exc: msg = str(exc) if "CONNECT" in msg or "TIMEOUT" in msg: reply = ( "无法连接到 AI 服务,请检查网络或稍后重试。\n" "你的问题我已记录,服务恢复后重新发送即可继续对话。" ) elif "EMPTY" in msg: reply = ( "AI 模型本次未返回内容(推理模型可能因思考超时导致空回复),请稍后重试或换一种问法。" ) else: reply = ( "当前 AI 服务暂时不可用,请稍后重试。我已经记录了你的问题," "服务恢复后再次发送即可继续对话。" ) assistant_msg = ChatMessage( conversation_id=conversation.id, role="assistant", content=reply, ) db.add(assistant_msg) first_message = len(conversation.messages) <= 2 if conversation.title == "新对话" or first_message: conversation.title = _derive_title(data.content) db.commit() db.refresh(assistant_msg) log_action( db, action="chat_send", user=current_user, request=request, target_type="chat_conversation", target_id=conversation.id, detail=f"AI助手对话({len(data.content)}字)", ) return { "success": True, "data": MessageOut.model_validate(assistant_msg).model_dump(), "title": conversation.title, "credits": credits_payload(account, "chat_generate"), }