新增角色管理、世界观设定、章节管理和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:
2026-03-21 13:58:17 +08:00
parent 580dc8be52
commit 20c759150a
34 changed files with 3495 additions and 127 deletions

View File

@@ -6,6 +6,9 @@ from fastapi.middleware.cors import CORSMiddleware
from .database import init_db
from .routers.novels import router as novels_router
from .routers.outlines import router as outlines_router
from .routers.characters import router as characters_router
from .routers.chapters import router as chapters_router
from .routers.world_setting import router as world_setting_router
from .routers.ai_config import router as ai_config_router
from .routers.chat import router as chat_router
@@ -28,6 +31,9 @@ app.add_middleware(
app.include_router(novels_router)
app.include_router(outlines_router)
app.include_router(characters_router)
app.include_router(chapters_router)
app.include_router(world_setting_router)
app.include_router(ai_config_router)
app.include_router(chat_router)

View File

@@ -48,3 +48,36 @@ class Outline(SQLModel, table=True):
detail: str = Field(default="", max_length=1000)
created_at: datetime = Field(default_factory=_now_utc)
updated_at: datetime = Field(default_factory=_now_utc)
class Character(SQLModel, table=True):
"""角色表,属于小说的子资源"""
id: Optional[int] = Field(default=None, primary_key=True)
novel_id: int = Field(foreign_key="novel.id", index=True)
name: str = Field(max_length=100)
description: str = Field(default="", max_length=1000)
created_at: datetime = Field(default_factory=_now_utc)
updated_at: datetime = Field(default_factory=_now_utc)
class Chapter(SQLModel, table=True):
"""章节表,属于小说的子资源"""
id: Optional[int] = Field(default=None, primary_key=True)
novel_id: int = Field(foreign_key="novel.id", index=True)
sort_order: int = Field(default=0)
title: str = Field(max_length=200)
content: str = Field(default="", max_length=5000)
created_at: datetime = Field(default_factory=_now_utc)
updated_at: datetime = Field(default_factory=_now_utc)
class WorldSetting(SQLModel, table=True):
"""世界观设定,每部小说一条记录"""
id: Optional[int] = Field(default=None, primary_key=True)
novel_id: int = Field(foreign_key="novel.id", index=True, unique=True)
content: str = Field(default="", max_length=5000)
created_at: datetime = Field(default_factory=_now_utc)
updated_at: datetime = Field(default_factory=_now_utc)

View 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()

View 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()

View File

@@ -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],

View 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

View File

@@ -60,6 +60,82 @@ class OutlineReorder(BaseModel):
ordered_ids: list[int]
# ── 角色 ──
class CharacterCreate(BaseModel):
name: str = Field(min_length=1, max_length=100)
description: str = Field(default="", max_length=1000)
class CharacterUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
description: str | None = Field(default=None, max_length=1000)
class CharacterRead(BaseModel):
id: int
novel_id: int
name: str
description: str
created_at: datetime
updated_at: datetime
class CharacterList(BaseModel):
items: list[CharacterRead]
total: int
# ── 章节 ──
class ChapterCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
content: str = Field(default="", max_length=5000)
class ChapterUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=200)
content: str | None = Field(default=None, max_length=5000)
sort_order: int | None = None
class ChapterRead(BaseModel):
id: int
novel_id: int
sort_order: int
title: str
content: str
created_at: datetime
updated_at: datetime
class ChapterList(BaseModel):
items: list[ChapterRead]
total: int
class ChapterReorder(BaseModel):
"""排序请求按顺序传入章节id列表"""
ordered_ids: list[int]
# ── 世界观 ──
class WorldSettingUpdate(BaseModel):
content: str = Field(default="", max_length=5000)
class WorldSettingRead(BaseModel):
id: int
novel_id: int
content: str
created_at: datetime
updated_at: datetime
# ── AI配置 ──
@@ -87,9 +163,21 @@ class ChatMessageInput(BaseModel):
content: str
class PendingToolCall(BaseModel):
"""待审批的工具调用"""
id: str
name: str
arguments: dict
class ChatRequest(BaseModel):
messages: list[ChatMessageInput]
novel_id: int | None = None
page_context: str | None = None # 用户当前所在页面,如 "大纲页" "世界观页"
tools_enabled: bool = True # 是否启用工具调用
# 工具审批续传
pending_tool_calls: list[PendingToolCall] | None = None
assistant_text: str | None = None # LLM 在工具调用前输出的文本
class ChatMessageRead(BaseModel):

View File

@@ -1,6 +1,8 @@
"""AI Provider 抽象层,支持 OpenAI 兼容 API 和 Anthropic 原生 API。"""
"""AI Provider 抽象层,支持 OpenAI 兼容 API 和 Anthropic 原生 API。
支持文本流式输出和 tool calling。"""
import json
from dataclasses import dataclass, field
from typing import AsyncGenerator
import httpx
@@ -8,33 +10,109 @@ import httpx
from ..models import AIConfig
@dataclass
class ToolCall:
"""一次工具调用"""
id: str
name: str
arguments: dict
class StreamEvent:
"""流式事件文本chunkusage信息"""
def __init__(self, text: str = "", usage: dict | None = None):
"""流式事件文本chunkusage信息、或工具调用"""
def __init__(
self,
text: str = "",
usage: dict | None = None,
tool_calls: list[ToolCall] | None = None,
):
self.text = text
self.usage = usage
self.tool_calls = tool_calls
async def stream_chat(
config: AIConfig,
messages: list[dict],
system_prompt: str = "",
tools: list[dict] | None = None,
) -> AsyncGenerator[StreamEvent, None]:
"""根据 provider 类型调用对应的流式 API返回 StreamEvent。"""
if config.provider == "anthropic":
async for event in _stream_anthropic(config, messages, system_prompt):
async for event in _stream_anthropic(config, messages, system_prompt, tools):
yield event
else:
async for event in _stream_openai(config, messages, system_prompt):
async for event in _stream_openai(config, messages, system_prompt, tools):
yield event
# ── 消息格式构建(用于 tool call 循环中追加消息)──
def build_assistant_message(provider: str, text: str, tool_calls: list[ToolCall]) -> dict:
"""构建包含工具调用的 assistant 消息"""
if provider == "anthropic":
content = []
if text:
content.append({"type": "text", "text": text})
for tc in tool_calls:
content.append({
"type": "tool_use",
"id": tc.id,
"name": tc.name,
"input": tc.arguments,
})
return {"role": "assistant", "content": content}
else:
# OpenAI 格式
msg: dict = {
"role": "assistant",
"content": text or None,
"tool_calls": [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.name,
"arguments": json.dumps(tc.arguments, ensure_ascii=False),
},
}
for tc in tool_calls
],
}
return msg
def build_tool_results_messages(
provider: str, results: list[dict],
) -> list[dict]:
"""构建工具结果消息。results: [{"id": str, "result": str}]
OpenAI: 返回多条 tool message
Anthropic: 返回一条 user messagecontent 是 tool_result 数组
"""
if provider == "anthropic":
content = [
{"type": "tool_result", "tool_use_id": r["id"], "content": r["result"]}
for r in results
]
return [{"role": "user", "content": content}]
else:
return [
{"role": "tool", "tool_call_id": r["id"], "content": r["result"]}
for r in results
]
# ── OpenAI 兼容 ──
async def _stream_openai(
config: AIConfig,
messages: list[dict],
system_prompt: str,
tools: list[dict] | None = None,
) -> AsyncGenerator[StreamEvent, None]:
"""调用 OpenAI 兼容 APIstream=true"""
"""调用 OpenAI 兼容 APIstream=true,支持 tool calling"""
url = f"{config.base_url.rstrip('/')}/chat/completions"
payload_messages = []
@@ -42,12 +120,14 @@ async def _stream_openai(
payload_messages.append({"role": "system", "content": system_prompt})
payload_messages.extend(messages)
body = {
body: dict = {
"model": config.model,
"messages": payload_messages,
"stream": True,
"stream_options": {"include_usage": True},
}
if tools:
body["tools"] = tools
async with httpx.AsyncClient(timeout=120) as client:
async with client.stream(
@@ -72,6 +152,9 @@ async def _stream_openai(
if "text/html" in ct:
raise RuntimeError(f"API地址返回了HTML页面请检查API地址是否正确当前: {url}")
# 累积工具调用
pending_tool_calls: dict[int, dict] = {} # index -> {id, name, arguments}
async for line in resp.aiter_lines():
if not line.startswith("data: "):
continue
@@ -80,7 +163,8 @@ async def _stream_openai(
break
try:
chunk = json.loads(data)
# 提取usageOpenAI在最后一个chunk返回
# 提取 usage
usage = chunk.get("usage")
if usage:
yield StreamEvent(usage={
@@ -88,22 +172,63 @@ async def _stream_openai(
"completion_tokens": usage.get("completion_tokens", 0),
"total_tokens": usage.get("total_tokens", 0),
})
choices = chunk.get("choices", [])
if choices:
delta = choices[0].get("delta", {})
content = delta.get("content")
if content:
yield StreamEvent(text=content)
if not choices:
continue
choice = choices[0]
delta = choice.get("delta", {})
finish_reason = choice.get("finish_reason")
# 文本内容
content = delta.get("content")
if content:
yield StreamEvent(text=content)
# 工具调用 chunk
for tc_delta in delta.get("tool_calls", []):
idx = tc_delta["index"]
if idx not in pending_tool_calls:
pending_tool_calls[idx] = {"id": "", "name": "", "arguments": ""}
entry = pending_tool_calls[idx]
if "id" in tc_delta:
entry["id"] = tc_delta["id"]
func = tc_delta.get("function", {})
if "name" in func:
entry["name"] = func["name"]
if "arguments" in func:
entry["arguments"] += func["arguments"]
# 结束:如果有工具调用,发出事件
if finish_reason == "tool_calls" and pending_tool_calls:
tool_calls = []
for tc_data in pending_tool_calls.values():
try:
args = json.loads(tc_data["arguments"]) if tc_data["arguments"] else {}
except json.JSONDecodeError:
args = {}
tool_calls.append(ToolCall(
id=tc_data["id"],
name=tc_data["name"],
arguments=args,
))
yield StreamEvent(tool_calls=tool_calls)
except (json.JSONDecodeError, KeyError, IndexError):
continue
# ── Anthropic 原生 ──
async def _stream_anthropic(
config: AIConfig,
messages: list[dict],
system_prompt: str,
tools: list[dict] | None = None,
) -> AsyncGenerator[StreamEvent, None]:
"""调用 Anthropic Messages APIstream=true"""
"""调用 Anthropic Messages APIstream=true,支持 tool calling"""
url = f"{config.base_url.rstrip('/')}/messages"
body: dict = {
@@ -114,6 +239,8 @@ async def _stream_anthropic(
}
if system_prompt:
body["system"] = system_prompt
if tools:
body["tools"] = tools
async with httpx.AsyncClient(timeout=120) as client:
async with client.stream(
@@ -139,16 +266,39 @@ async def _stream_anthropic(
if "text/html" in ct:
raise RuntimeError(f"API地址返回了HTML页面请检查API地址是否正确当前: {url}")
# 累积工具调用
pending_tool_uses: dict[int, dict] = {} # block_index -> {id, name, input_json}
stop_reason = None
async for line in resp.aiter_lines():
if not line.startswith("data: "):
continue
try:
event = json.loads(line[6:])
if event.get("type") == "content_block_delta":
text = event.get("delta", {}).get("text", "")
if text:
yield StreamEvent(text=text)
elif event.get("type") == "message_delta":
event_type = event.get("type")
if event_type == "content_block_start":
block = event.get("content_block", {})
idx = event.get("index", 0)
if block.get("type") == "tool_use":
pending_tool_uses[idx] = {
"id": block["id"],
"name": block["name"],
"input_json": "",
}
elif event_type == "content_block_delta":
idx = event.get("index", 0)
delta = event.get("delta", {})
if delta.get("type") == "text_delta":
text = delta.get("text", "")
if text:
yield StreamEvent(text=text)
elif delta.get("type") == "input_json_delta":
if idx in pending_tool_uses:
pending_tool_uses[idx]["input_json"] += delta.get("partial_json", "")
elif event_type == "message_delta":
usage = event.get("usage", {})
if usage:
yield StreamEvent(usage={
@@ -156,7 +306,9 @@ async def _stream_anthropic(
"completion_tokens": usage.get("output_tokens", 0),
"total_tokens": 0,
})
elif event.get("type") == "message_start":
stop_reason = event.get("delta", {}).get("stop_reason")
elif event_type == "message_start":
msg = event.get("message", {})
usage = msg.get("usage", {})
if usage:
@@ -165,7 +317,24 @@ async def _stream_anthropic(
"completion_tokens": 0,
"total_tokens": 0,
})
elif event.get("type") == "message_stop":
elif event_type == "message_stop":
break
except json.JSONDecodeError:
continue
# 如果有工具调用,发出事件
if stop_reason == "tool_use" and pending_tool_uses:
tool_calls = []
for tu_data in pending_tool_uses.values():
try:
args = json.loads(tu_data["input_json"]) if tu_data["input_json"] else {}
except json.JSONDecodeError:
args = {}
tool_calls.append(ToolCall(
id=tu_data["id"],
name=tu_data["name"],
arguments=args,
))
yield StreamEvent(tool_calls=tool_calls)

View File

@@ -0,0 +1,205 @@
"""工具注册表和执行器 —— 大纲 & 世界观 CRUD"""
import json
from datetime import datetime, timezone
from sqlmodel import Session, select, func
from ..database import engine
from ..models import Novel, Outline, WorldSetting
# ── 工具定义provider 无关格式)──
TOOL_DEFINITIONS: list[dict] = [
{
"name": "list_outlines",
"description": "列出当前小说的所有大纲节点,返回每个节点的 id、排序、摘要和详情",
"parameters": {
"type": "object",
"properties": {},
},
},
{
"name": "create_outline",
"description": "为当前小说创建一个新的大纲节点",
"parameters": {
"type": "object",
"properties": {
"summary": {"type": "string", "description": "大纲摘要简短标题200字以内"},
"detail": {"type": "string", "description": "大纲详情可选1000字以内"},
},
"required": ["summary"],
},
},
{
"name": "update_outline",
"description": "更新指定大纲节点的摘要或详情",
"parameters": {
"type": "object",
"properties": {
"outline_id": {"type": "integer", "description": "要更新的大纲节点 ID"},
"summary": {"type": "string", "description": "新的摘要(可选)"},
"detail": {"type": "string", "description": "新的详情(可选)"},
},
"required": ["outline_id"],
},
},
{
"name": "delete_outline",
"description": "删除指定的大纲节点",
"parameters": {
"type": "object",
"properties": {
"outline_id": {"type": "integer", "description": "要删除的大纲节点 ID"},
},
"required": ["outline_id"],
},
},
{
"name": "get_world_setting",
"description": "获取当前小说的世界观设定内容",
"parameters": {
"type": "object",
"properties": {},
},
},
{
"name": "save_world_setting",
"description": "保存/更新当前小说的世界观设定",
"parameters": {
"type": "object",
"properties": {
"content": {"type": "string", "description": "世界观设定内容5000字以内"},
},
"required": ["content"],
},
},
]
def get_openai_tools() -> list[dict]:
"""转换为 OpenAI function calling 格式"""
return [
{"type": "function", "function": t}
for t in TOOL_DEFINITIONS
]
def get_anthropic_tools() -> list[dict]:
"""转换为 Anthropic tool_use 格式"""
return [
{"name": t["name"], "description": t["description"], "input_schema": t["parameters"]}
for t in TOOL_DEFINITIONS
]
# ── 工具执行 ──
def execute_tool(name: str, arguments: dict, novel_id: int) -> str:
"""执行工具返回结果字符串JSON"""
with Session(engine) as session:
handlers = {
"list_outlines": _list_outlines,
"create_outline": _create_outline,
"update_outline": _update_outline,
"delete_outline": _delete_outline,
"get_world_setting": _get_world_setting,
"save_world_setting": _save_world_setting,
}
handler = handlers.get(name)
if not handler:
return json.dumps({"error": f"未知工具: {name}"}, ensure_ascii=False)
try:
return handler(session, novel_id, arguments)
except Exception as e:
return json.dumps({"error": str(e)}, ensure_ascii=False)
def _list_outlines(session: Session, novel_id: int, _args: dict) -> str:
items = session.exec(
select(Outline)
.where(Outline.novel_id == novel_id)
.order_by(Outline.sort_order)
).all()
result = [
{"id": o.id, "sort_order": o.sort_order, "summary": o.summary, "detail": o.detail}
for o in items
]
return json.dumps({"outlines": result, "total": len(result)}, ensure_ascii=False)
def _create_outline(session: Session, novel_id: int, args: dict) -> str:
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,
summary=args["summary"],
detail=args.get("detail", ""),
)
session.add(outline)
session.commit()
session.refresh(outline)
return json.dumps(
{"id": outline.id, "sort_order": outline.sort_order, "summary": outline.summary, "detail": outline.detail},
ensure_ascii=False,
)
def _update_outline(session: Session, novel_id: int, args: dict) -> str:
outline = session.get(Outline, args["outline_id"])
if not outline or outline.novel_id != novel_id:
return json.dumps({"error": "大纲不存在"}, ensure_ascii=False)
if "summary" in args:
outline.summary = args["summary"]
if "detail" in args:
outline.detail = args["detail"]
outline.updated_at = datetime.now(timezone.utc)
session.add(outline)
session.commit()
session.refresh(outline)
return json.dumps(
{"id": outline.id, "summary": outline.summary, "detail": outline.detail},
ensure_ascii=False,
)
def _delete_outline(session: Session, novel_id: int, args: dict) -> str:
outline = session.get(Outline, args["outline_id"])
if not outline or outline.novel_id != novel_id:
return json.dumps({"error": "大纲不存在"}, ensure_ascii=False)
session.delete(outline)
session.commit()
return json.dumps({"deleted": True, "id": args["outline_id"]}, ensure_ascii=False)
def _get_world_setting(session: Session, novel_id: int, _args: dict) -> str:
ws = session.exec(
select(WorldSetting).where(WorldSetting.novel_id == novel_id)
).first()
if not ws:
return json.dumps({"content": "", "exists": False}, ensure_ascii=False)
return json.dumps({"content": ws.content, "exists": True}, ensure_ascii=False)
def _save_world_setting(session: Session, novel_id: int, args: dict) -> str:
ws = session.exec(
select(WorldSetting).where(WorldSetting.novel_id == novel_id)
).first()
now = datetime.now(timezone.utc)
if ws:
ws.content = args["content"]
ws.updated_at = now
else:
ws = WorldSetting(novel_id=novel_id, content=args["content"])
session.add(ws)
session.commit()
session.refresh(ws)
return json.dumps({"saved": True, "content_length": len(ws.content)}, ensure_ascii=False)