新增角色管理、世界观设定、章节管理和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

@@ -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)