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