新增前后端AI聊天模块,包括流式对话、配置管理和UI组件优化。

This commit is contained in:
2026-03-19 01:35:13 +08:00
commit ba6ceb94c8
66 changed files with 8631 additions and 0 deletions

View File

View File

@@ -0,0 +1,99 @@
from datetime import datetime, timezone
import httpx
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from ..database import get_session
from ..models import AIConfig
from ..schemas import AIConfigSave, AIConfigRead
router = APIRouter(prefix="/api/ai", tags=["ai-config"])
def _mask_key(key: str) -> str:
if len(key) <= 8:
return "****"
return key[:4] + "****" + key[-4:]
def _get_or_create_config(session: Session) -> AIConfig:
config = session.exec(select(AIConfig)).first()
if not config:
config = AIConfig()
session.add(config)
session.commit()
session.refresh(config)
return config
def _config_to_read(config: AIConfig) -> AIConfigRead:
return AIConfigRead(
id=config.id,
provider=config.provider,
base_url=config.base_url,
api_key_masked=_mask_key(config.api_key) if config.api_key else "",
model=config.model,
updated_at=config.updated_at,
)
@router.get("/config", response_model=AIConfigRead)
def get_config(session: Session = Depends(get_session)):
config = _get_or_create_config(session)
return _config_to_read(config)
@router.put("/config", response_model=AIConfigRead)
def save_config(data: AIConfigSave, session: Session = Depends(get_session)):
config = _get_or_create_config(session)
config.provider = data.provider
config.base_url = data.base_url
config.api_key = data.api_key
config.model = data.model
config.updated_at = datetime.now(timezone.utc)
session.add(config)
session.commit()
session.refresh(config)
return _config_to_read(config)
@router.post("/config/test")
async def test_config(data: AIConfigSave):
"""测试AI连接是否有效"""
try:
async with httpx.AsyncClient(timeout=15) as client:
if data.provider == "anthropic":
resp = await client.post(
f"{data.base_url.rstrip('/')}/messages",
headers={
"x-api-key": data.api_key,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
json={
"model": data.model,
"max_tokens": 10,
"messages": [{"role": "user", "content": "Hi"}],
},
)
else:
resp = await client.post(
f"{data.base_url.rstrip('/')}/chat/completions",
headers={
"Authorization": f"Bearer {data.api_key}",
"Content-Type": "application/json",
},
json={
"model": data.model,
"max_tokens": 10,
"messages": [{"role": "user", "content": "Hi"}],
},
)
if resp.status_code < 400:
return {"ok": True, "message": "连接成功"}
return {"ok": False, "message": f"API返回错误: {resp.status_code}"}
except httpx.ConnectError:
return {"ok": False, "message": "无法连接到API地址"}
except Exception as e:
return {"ok": False, "message": str(e)}

132
backend/app/routers/chat.py Normal file
View File

@@ -0,0 +1,132 @@
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()

View File

@@ -0,0 +1,67 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select, func
from ..database import get_session
from ..models import Novel
from ..schemas import NovelCreate, NovelUpdate, NovelRead, NovelList
router = APIRouter(prefix="/api/novels", tags=["novels"])
def _get_novel_or_404(novel_id: int, session: Session) -> Novel:
novel = session.get(Novel, novel_id)
if not novel:
raise HTTPException(status_code=404, detail="小说不存在")
return novel
@router.get("", response_model=NovelList)
def list_novels(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
session: Session = Depends(get_session),
):
total = session.exec(select(func.count(Novel.id))).one()
novels = session.exec(
select(Novel).order_by(Novel.updated_at.desc()).offset(skip).limit(limit)
).all()
items = [NovelRead.model_validate(n, from_attributes=True) for n in novels]
return NovelList(items=items, total=total)
@router.post("", response_model=NovelRead, status_code=201)
def create_novel(data: NovelCreate, session: Session = Depends(get_session)):
novel = Novel(**data.model_dump())
session.add(novel)
session.commit()
session.refresh(novel)
return novel
@router.get("/{novel_id}", response_model=NovelRead)
def get_novel(novel_id: int, session: Session = Depends(get_session)):
return _get_novel_or_404(novel_id, session)
@router.patch("/{novel_id}", response_model=NovelRead)
def update_novel(
novel_id: int, data: NovelUpdate, session: Session = Depends(get_session)
):
novel = _get_novel_or_404(novel_id, session)
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(novel, key, value)
novel.updated_at = datetime.now(timezone.utc)
session.add(novel)
session.commit()
session.refresh(novel)
return novel
@router.delete("/{novel_id}", status_code=204)
def delete_novel(novel_id: int, session: Session = Depends(get_session)):
novel = _get_novel_or_404(novel_id, session)
session.delete(novel)
session.commit()

View File

@@ -0,0 +1,137 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select, func
from ..database import get_session
from ..models import Novel, Outline
from ..schemas import (
OutlineCreate,
OutlineUpdate,
OutlineRead,
OutlineList,
OutlineReorder,
)
router = APIRouter(prefix="/api/novels/{novel_id}/outlines", tags=["outlines"])
def _get_novel_or_404(novel_id: int, session: Session) -> Novel:
novel = session.get(Novel, novel_id)
if not novel:
raise HTTPException(status_code=404, detail="小说不存在")
return novel
def _get_outline_or_404(
outline_id: int, novel_id: int, session: Session
) -> Outline:
outline = session.get(Outline, outline_id)
if not outline or outline.novel_id != novel_id:
raise HTTPException(status_code=404, detail="大纲不存在")
return outline
@router.get("", response_model=OutlineList)
def list_outlines(
novel_id: int,
session: Session = Depends(get_session),
):
_get_novel_or_404(novel_id, session)
total = session.exec(
select(func.count(Outline.id)).where(Outline.novel_id == novel_id)
).one()
items = session.exec(
select(Outline)
.where(Outline.novel_id == novel_id)
.order_by(Outline.sort_order)
).all()
return OutlineList(
items=[OutlineRead.model_validate(o, from_attributes=True) for o in items],
total=total,
)
@router.post("", response_model=OutlineRead, status_code=201)
def create_outline(
novel_id: int,
data: OutlineCreate,
session: Session = Depends(get_session),
):
_get_novel_or_404(novel_id, session)
# 自动设置 sort_order 为当前最大值 + 1
max_order = session.exec(
select(func.max(Outline.sort_order)).where(Outline.novel_id == novel_id)
).one()
next_order = (max_order or 0) + 1
outline = Outline(novel_id=novel_id, sort_order=next_order, **data.model_dump())
session.add(outline)
session.commit()
session.refresh(outline)
return outline
@router.put("/reorder", response_model=OutlineList)
def reorder_outlines(
novel_id: int,
data: OutlineReorder,
session: Session = Depends(get_session),
):
_get_novel_or_404(novel_id, session)
now = datetime.now(timezone.utc)
for idx, oid in enumerate(data.ordered_ids):
outline = _get_outline_or_404(oid, novel_id, session)
outline.sort_order = idx + 1
outline.updated_at = now
session.add(outline)
session.commit()
items = session.exec(
select(Outline)
.where(Outline.novel_id == novel_id)
.order_by(Outline.sort_order)
).all()
total = len(items)
return OutlineList(
items=[OutlineRead.model_validate(o, from_attributes=True) for o in items],
total=total,
)
@router.get("/{outline_id}", response_model=OutlineRead)
def get_outline(
novel_id: int,
outline_id: int,
session: Session = Depends(get_session),
):
return _get_outline_or_404(outline_id, novel_id, session)
@router.patch("/{outline_id}", response_model=OutlineRead)
def update_outline(
novel_id: int,
outline_id: int,
data: OutlineUpdate,
session: Session = Depends(get_session),
):
outline = _get_outline_or_404(outline_id, novel_id, session)
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(outline, key, value)
outline.updated_at = datetime.now(timezone.utc)
session.add(outline)
session.commit()
session.refresh(outline)
return outline
@router.delete("/{outline_id}", status_code=204)
def delete_outline(
novel_id: int,
outline_id: int,
session: Session = Depends(get_session),
):
outline = _get_outline_or_404(outline_id, novel_id, session)
session.delete(outline)
session.commit()