Files
NovalRedot/backend/app/routers/chat.py

133 lines
4.3 KiB
Python

import json
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from sqlmodel import Session, select, func
from ..database import get_session, engine
from ..models import AIConfig, ChatMessage
from ..schemas import ChatRequest, ChatMessageRead, ChatMessageList
from ..services.ai_provider import stream_chat
router = APIRouter(prefix="/api/chat", tags=["chat"])
@router.post("/stream")
async def chat_stream(
data: ChatRequest,
session: Session = Depends(get_session),
):
"""SSE 流式对话端点"""
config = session.exec(select(AIConfig)).first()
if not config or not config.api_key:
return StreamingResponse(
_error_stream("请先配置AI服务"),
media_type="text/event-stream",
)
# 保存用户消息
user_msg = data.messages[-1] if data.messages else None
if user_msg and user_msg.role == "user":
db_msg = ChatMessage(
novel_id=data.novel_id,
role="user",
content=user_msg.content,
)
session.add(db_msg)
session.commit()
# 构建消息列表
messages = [{"role": m.role, "content": m.content} for m in data.messages]
# 构建上下文摘要(前端展示用)
context_preview = [{"role": m["role"], "content": m["content"][:200]} for m in messages]
async def generate():
# 先发送上下文预览
yield f"data: {json.dumps({'context': context_preview})}\n\n"
full_response = ""
usage_info = {}
try:
async for event in stream_chat(config, messages):
if event.text:
full_response += event.text
yield f"data: {json.dumps({'content': event.text})}\n\n"
if event.usage:
# 累加usage
for k, v in event.usage.items():
if v:
usage_info[k] = usage_info.get(k, 0) + v
except Exception as e:
yield f"data: {json.dumps({'error': str(e)})}\n\n"
finally:
# 保存AI回复
if full_response:
with Session(engine) as s:
ai_msg = ChatMessage(
novel_id=data.novel_id,
role="assistant",
content=full_response,
)
s.add(ai_msg)
s.commit()
yield f"data: {json.dumps({'done': True, 'usage': usage_info or None})}\n\n"
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
async def _error_stream(message: str):
yield f"data: {json.dumps({'error': message})}\n\n"
yield f"data: {json.dumps({'done': True})}\n\n"
@router.get("/messages", response_model=ChatMessageList)
def list_messages(
novel_id: int | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
session: Session = Depends(get_session),
):
query = select(ChatMessage)
count_query = select(func.count(ChatMessage.id))
if novel_id is not None:
query = query.where(ChatMessage.novel_id == novel_id)
count_query = count_query.where(ChatMessage.novel_id == novel_id)
else:
query = query.where(ChatMessage.novel_id.is_(None)) # type: ignore
count_query = count_query.where(ChatMessage.novel_id.is_(None)) # type: ignore
total = session.exec(count_query).one()
items = session.exec(
query.order_by(ChatMessage.created_at.desc()).limit(limit)
).all()
# 返回时按时间正序
items = list(reversed(items))
return ChatMessageList(
items=[ChatMessageRead.model_validate(m, from_attributes=True) for m in items],
total=total,
)
@router.delete("/messages", status_code=204)
def clear_messages(
novel_id: int | None = Query(default=None),
session: Session = Depends(get_session),
):
query = select(ChatMessage)
if novel_id is not None:
query = query.where(ChatMessage.novel_id == novel_id)
else:
query = query.where(ChatMessage.novel_id.is_(None)) # type: ignore
messages = session.exec(query).all()
for msg in messages:
session.delete(msg)
session.commit()