新增角色管理、世界观设定、章节管理和AI工具调用功能
- 角色管理:CRUD API + 卡片网格UI,每个角色约200字描述 - 世界观设定:单篇长文编辑器,建议500字以上 - 章节管理:CRUD + 排序 API,左右分栏编辑器,单章5000字上限 - AI工具调用:支持工具审批流程和结构化工具调用 - 小说详情页新增章节、角色、世界观入口按钮 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
136
backend/app/routers/chapters.py
Normal file
136
backend/app/routers/chapters.py
Normal file
@@ -0,0 +1,136 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select, func
|
||||
|
||||
from ..database import get_session
|
||||
from ..models import Novel, Chapter
|
||||
from ..schemas import (
|
||||
ChapterCreate,
|
||||
ChapterUpdate,
|
||||
ChapterRead,
|
||||
ChapterList,
|
||||
ChapterReorder,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/novels/{novel_id}/chapters", tags=["chapters"])
|
||||
|
||||
|
||||
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_chapter_or_404(
|
||||
chapter_id: int, novel_id: int, session: Session
|
||||
) -> Chapter:
|
||||
chapter = session.get(Chapter, chapter_id)
|
||||
if not chapter or chapter.novel_id != novel_id:
|
||||
raise HTTPException(status_code=404, detail="章节不存在")
|
||||
return chapter
|
||||
|
||||
|
||||
@router.get("", response_model=ChapterList)
|
||||
def list_chapters(
|
||||
novel_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_get_novel_or_404(novel_id, session)
|
||||
total = session.exec(
|
||||
select(func.count(Chapter.id)).where(Chapter.novel_id == novel_id)
|
||||
).one()
|
||||
items = session.exec(
|
||||
select(Chapter)
|
||||
.where(Chapter.novel_id == novel_id)
|
||||
.order_by(Chapter.sort_order)
|
||||
).all()
|
||||
return ChapterList(
|
||||
items=[ChapterRead.model_validate(c, from_attributes=True) for c in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=ChapterRead, status_code=201)
|
||||
def create_chapter(
|
||||
novel_id: int,
|
||||
data: ChapterCreate,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_get_novel_or_404(novel_id, session)
|
||||
max_order = session.exec(
|
||||
select(func.max(Chapter.sort_order)).where(Chapter.novel_id == novel_id)
|
||||
).one()
|
||||
next_order = (max_order or 0) + 1
|
||||
|
||||
chapter = Chapter(novel_id=novel_id, sort_order=next_order, **data.model_dump())
|
||||
session.add(chapter)
|
||||
session.commit()
|
||||
session.refresh(chapter)
|
||||
return chapter
|
||||
|
||||
|
||||
@router.put("/reorder", response_model=ChapterList)
|
||||
def reorder_chapters(
|
||||
novel_id: int,
|
||||
data: ChapterReorder,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_get_novel_or_404(novel_id, session)
|
||||
now = datetime.now(timezone.utc)
|
||||
for idx, cid in enumerate(data.ordered_ids):
|
||||
chapter = _get_chapter_or_404(cid, novel_id, session)
|
||||
chapter.sort_order = idx + 1
|
||||
chapter.updated_at = now
|
||||
session.add(chapter)
|
||||
session.commit()
|
||||
|
||||
items = session.exec(
|
||||
select(Chapter)
|
||||
.where(Chapter.novel_id == novel_id)
|
||||
.order_by(Chapter.sort_order)
|
||||
).all()
|
||||
total = len(items)
|
||||
return ChapterList(
|
||||
items=[ChapterRead.model_validate(c, from_attributes=True) for c in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{chapter_id}", response_model=ChapterRead)
|
||||
def get_chapter(
|
||||
novel_id: int,
|
||||
chapter_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
return _get_chapter_or_404(chapter_id, novel_id, session)
|
||||
|
||||
|
||||
@router.patch("/{chapter_id}", response_model=ChapterRead)
|
||||
def update_chapter(
|
||||
novel_id: int,
|
||||
chapter_id: int,
|
||||
data: ChapterUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
chapter = _get_chapter_or_404(chapter_id, novel_id, session)
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(chapter, key, value)
|
||||
chapter.updated_at = datetime.now(timezone.utc)
|
||||
session.add(chapter)
|
||||
session.commit()
|
||||
session.refresh(chapter)
|
||||
return chapter
|
||||
|
||||
|
||||
@router.delete("/{chapter_id}", status_code=204)
|
||||
def delete_chapter(
|
||||
novel_id: int,
|
||||
chapter_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
chapter = _get_chapter_or_404(chapter_id, novel_id, session)
|
||||
session.delete(chapter)
|
||||
session.commit()
|
||||
103
backend/app/routers/characters.py
Normal file
103
backend/app/routers/characters.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select, func
|
||||
|
||||
from ..database import get_session
|
||||
from ..models import Novel, Character
|
||||
from ..schemas import (
|
||||
CharacterCreate,
|
||||
CharacterUpdate,
|
||||
CharacterRead,
|
||||
CharacterList,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/novels/{novel_id}/characters", tags=["characters"])
|
||||
|
||||
|
||||
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_character_or_404(
|
||||
character_id: int, novel_id: int, session: Session
|
||||
) -> Character:
|
||||
character = session.get(Character, character_id)
|
||||
if not character or character.novel_id != novel_id:
|
||||
raise HTTPException(status_code=404, detail="角色不存在")
|
||||
return character
|
||||
|
||||
|
||||
@router.get("", response_model=CharacterList)
|
||||
def list_characters(
|
||||
novel_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_get_novel_or_404(novel_id, session)
|
||||
total = session.exec(
|
||||
select(func.count(Character.id)).where(Character.novel_id == novel_id)
|
||||
).one()
|
||||
items = session.exec(
|
||||
select(Character)
|
||||
.where(Character.novel_id == novel_id)
|
||||
.order_by(Character.created_at)
|
||||
).all()
|
||||
return CharacterList(
|
||||
items=[CharacterRead.model_validate(c, from_attributes=True) for c in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=CharacterRead, status_code=201)
|
||||
def create_character(
|
||||
novel_id: int,
|
||||
data: CharacterCreate,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_get_novel_or_404(novel_id, session)
|
||||
character = Character(novel_id=novel_id, **data.model_dump())
|
||||
session.add(character)
|
||||
session.commit()
|
||||
session.refresh(character)
|
||||
return character
|
||||
|
||||
|
||||
@router.get("/{character_id}", response_model=CharacterRead)
|
||||
def get_character(
|
||||
novel_id: int,
|
||||
character_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
return _get_character_or_404(character_id, novel_id, session)
|
||||
|
||||
|
||||
@router.patch("/{character_id}", response_model=CharacterRead)
|
||||
def update_character(
|
||||
novel_id: int,
|
||||
character_id: int,
|
||||
data: CharacterUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
character = _get_character_or_404(character_id, novel_id, session)
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(character, key, value)
|
||||
character.updated_at = datetime.now(timezone.utc)
|
||||
session.add(character)
|
||||
session.commit()
|
||||
session.refresh(character)
|
||||
return character
|
||||
|
||||
|
||||
@router.delete("/{character_id}", status_code=204)
|
||||
def delete_character(
|
||||
novel_id: int,
|
||||
character_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
character = _get_character_or_404(character_id, novel_id, session)
|
||||
session.delete(character)
|
||||
session.commit()
|
||||
@@ -6,19 +6,78 @@ from fastapi.responses import StreamingResponse
|
||||
from sqlmodel import Session, select, func
|
||||
|
||||
from ..database import get_session, engine
|
||||
from ..models import AIConfig, ChatMessage
|
||||
from ..models import AIConfig, ChatMessage, Novel
|
||||
from ..schemas import ChatRequest, ChatMessageRead, ChatMessageList
|
||||
from ..services.ai_provider import stream_chat
|
||||
from ..services.ai_provider import (
|
||||
stream_chat, build_assistant_message, build_tool_results_messages, ToolCall,
|
||||
)
|
||||
from ..services.tools import get_openai_tools, get_anthropic_tools, execute_tool
|
||||
|
||||
router = APIRouter(prefix="/api/chat", tags=["chat"])
|
||||
|
||||
# 最大工具调用轮次
|
||||
MAX_TOOL_ROUNDS = 8
|
||||
|
||||
# 工具名称到中文描述
|
||||
TOOL_LABELS: dict[str, str] = {
|
||||
"list_outlines": "查询大纲",
|
||||
"create_outline": "创建大纲",
|
||||
"update_outline": "更新大纲",
|
||||
"delete_outline": "删除大纲",
|
||||
"get_world_setting": "查询世界观",
|
||||
"save_world_setting": "保存世界观",
|
||||
}
|
||||
|
||||
|
||||
def _build_system_prompt(
|
||||
novel_id: int | None,
|
||||
session: Session,
|
||||
page_context: str | None = None,
|
||||
tools_enabled: bool = False,
|
||||
) -> str:
|
||||
"""根据小说上下文和用户所在页面构建 system prompt"""
|
||||
base = "你是一个专业的小说创作助手。"
|
||||
if not novel_id:
|
||||
return base
|
||||
|
||||
novel = session.get(Novel, novel_id)
|
||||
if not novel:
|
||||
return base
|
||||
|
||||
parts = [
|
||||
f"你是一个专业的小说创作助手,当前正在协助创作《{novel.title}》。",
|
||||
f"小说简介:{novel.description or '暂无'}",
|
||||
]
|
||||
|
||||
if page_context:
|
||||
parts.append(f"\n用户当前正在查看:{page_context}。")
|
||||
|
||||
if tools_enabled:
|
||||
parts.append(
|
||||
"\n你可以使用工具来查看和管理大纲、世界观设定。"
|
||||
"\n当用户要求查看、添加、修改或删除大纲或世界观时,请使用相应的工具。"
|
||||
"\n执行完工具操作后,请向用户简要说明操作结果。"
|
||||
)
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _get_tools(provider: str) -> list[dict]:
|
||||
if provider == "anthropic":
|
||||
return get_anthropic_tools()
|
||||
return get_openai_tools()
|
||||
|
||||
|
||||
def _sse(data: dict) -> str:
|
||||
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
@router.post("/stream")
|
||||
async def chat_stream(
|
||||
data: ChatRequest,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""SSE 流式对话端点"""
|
||||
"""SSE 流式对话端点,支持 tool calling + 用户审批"""
|
||||
config = session.exec(select(AIConfig)).first()
|
||||
if not config or not config.api_key:
|
||||
return StreamingResponse(
|
||||
@@ -26,53 +85,120 @@ async def chat_stream(
|
||||
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()
|
||||
# 保存用户消息(仅首次请求,续传审批时不保存)
|
||||
if not data.pending_tool_calls:
|
||||
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]
|
||||
# 构建上下文
|
||||
use_tools = bool(data.novel_id and data.tools_enabled)
|
||||
system_prompt = _build_system_prompt(
|
||||
data.novel_id, session, data.page_context, use_tools
|
||||
)
|
||||
tools = _get_tools(config.provider) if use_tools else None
|
||||
|
||||
async def generate():
|
||||
# 先发送上下文预览
|
||||
yield f"data: {json.dumps({'context': context_preview})}\n\n"
|
||||
nonlocal messages
|
||||
|
||||
full_response = data.assistant_text or ""
|
||||
usage_info: dict = {}
|
||||
|
||||
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
|
||||
# ── 阶段一:如果有待审批的工具调用,先执行它们 ──
|
||||
if data.pending_tool_calls:
|
||||
tool_calls = [
|
||||
ToolCall(id=tc.id, name=tc.name, arguments=tc.arguments)
|
||||
for tc in data.pending_tool_calls
|
||||
]
|
||||
|
||||
# 构建 assistant 消息(含工具调用)
|
||||
assistant_msg = build_assistant_message(
|
||||
config.provider, data.assistant_text or "", tool_calls
|
||||
)
|
||||
messages.append(assistant_msg)
|
||||
|
||||
# 执行每个工具,发送结果
|
||||
tool_results = []
|
||||
for tc in tool_calls:
|
||||
label = TOOL_LABELS.get(tc.name, tc.name)
|
||||
result = execute_tool(tc.name, tc.arguments, data.novel_id)
|
||||
tool_results.append({"id": tc.id, "result": result})
|
||||
yield _sse({"tool_result": {
|
||||
"name": tc.name,
|
||||
"label": label,
|
||||
"result": result,
|
||||
}})
|
||||
|
||||
# 追加工具结果消息
|
||||
result_msgs = build_tool_results_messages(config.provider, tool_results)
|
||||
messages.extend(result_msgs)
|
||||
|
||||
# ── 阶段二:LLM 循环 ──
|
||||
for _round in range(MAX_TOOL_ROUNDS):
|
||||
round_text = ""
|
||||
round_tool_calls = []
|
||||
|
||||
async for event in stream_chat(config, messages, system_prompt, tools):
|
||||
if event.text:
|
||||
round_text += event.text
|
||||
yield _sse({"content": event.text})
|
||||
if event.usage:
|
||||
for k, v in event.usage.items():
|
||||
if v:
|
||||
usage_info[k] = usage_info.get(k, 0) + v
|
||||
if event.tool_calls:
|
||||
round_tool_calls = event.tool_calls
|
||||
|
||||
# 没有工具调用 → 完成
|
||||
if not round_tool_calls:
|
||||
full_response += round_text
|
||||
break
|
||||
|
||||
# 有工具调用 → 暂停,发给前端审批
|
||||
full_response += round_text
|
||||
calls_data = [
|
||||
{
|
||||
"id": tc.id,
|
||||
"name": tc.name,
|
||||
"label": TOOL_LABELS.get(tc.name, tc.name),
|
||||
"arguments": tc.arguments,
|
||||
}
|
||||
for tc in round_tool_calls
|
||||
]
|
||||
yield _sse({
|
||||
"tool_calls_pending": calls_data,
|
||||
"assistant_text": full_response,
|
||||
})
|
||||
# 标记为待审批,前端需要续传
|
||||
yield _sse({"done": True, "pending": True, "usage": usage_info or None})
|
||||
return # 暂停,不保存到 DB
|
||||
|
||||
except Exception as e:
|
||||
yield f"data: {json.dumps({'error': str(e)})}\n\n"
|
||||
yield _sse({"error": str(e)})
|
||||
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"
|
||||
pass
|
||||
|
||||
# 正常完成:保存 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 _sse({"done": True, "usage": usage_info or None})
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
@@ -85,8 +211,8 @@ async def chat_stream(
|
||||
|
||||
|
||||
async def _error_stream(message: str):
|
||||
yield f"data: {json.dumps({'error': message})}\n\n"
|
||||
yield f"data: {json.dumps({'done': True})}\n\n"
|
||||
yield _sse({"error": message})
|
||||
yield _sse({"done": True})
|
||||
|
||||
|
||||
@router.get("/messages", response_model=ChatMessageList)
|
||||
@@ -108,7 +234,6 @@ def list_messages(
|
||||
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],
|
||||
|
||||
56
backend/app/routers/world_setting.py
Normal file
56
backend/app/routers/world_setting.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ..database import get_session
|
||||
from ..models import Novel, WorldSetting
|
||||
from ..schemas import WorldSettingUpdate, WorldSettingRead
|
||||
|
||||
router = APIRouter(prefix="/api/novels/{novel_id}/world-setting", tags=["world-setting"])
|
||||
|
||||
|
||||
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=WorldSettingRead)
|
||||
def get_world_setting(
|
||||
novel_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_get_novel_or_404(novel_id, session)
|
||||
ws = session.exec(
|
||||
select(WorldSetting).where(WorldSetting.novel_id == novel_id)
|
||||
).first()
|
||||
if not ws:
|
||||
# 不存在则自动创建空记录
|
||||
ws = WorldSetting(novel_id=novel_id, content="")
|
||||
session.add(ws)
|
||||
session.commit()
|
||||
session.refresh(ws)
|
||||
return ws
|
||||
|
||||
|
||||
@router.put("", response_model=WorldSettingRead)
|
||||
def save_world_setting(
|
||||
novel_id: int,
|
||||
data: WorldSettingUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_get_novel_or_404(novel_id, session)
|
||||
ws = session.exec(
|
||||
select(WorldSetting).where(WorldSetting.novel_id == novel_id)
|
||||
).first()
|
||||
if ws:
|
||||
ws.content = data.content
|
||||
ws.updated_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
ws = WorldSetting(novel_id=novel_id, content=data.content)
|
||||
session.add(ws)
|
||||
session.commit()
|
||||
session.refresh(ws)
|
||||
return ws
|
||||
Reference in New Issue
Block a user