引入二级大纲功能,新增父子节点关系及子节点排序,同时调整API与UI以支持嵌套大纲管理

This commit is contained in:
2026-03-22 16:53:33 +08:00
parent 20c759150a
commit 098ff4e16d
33 changed files with 2516 additions and 381 deletions

View File

@@ -1,9 +1,11 @@
import os
from pathlib import Path
from sqlalchemy import text
from sqlmodel import SQLModel, Session, create_engine
# 数据库文件存放在 backend/data/ 目录下
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
# 数据目录优先使用环境变量Docker 中设为 /app/data否则用 backend/data/
DATA_DIR = Path(os.environ.get("NOVAL_DATA_DIR", Path(__file__).resolve().parent.parent / "data"))
DATA_DIR.mkdir(exist_ok=True)
DATABASE_URL = f"sqlite:///{DATA_DIR / 'noval.db'}"
@@ -13,6 +15,19 @@ engine = create_engine(DATABASE_URL, echo=False)
def init_db() -> None:
SQLModel.metadata.create_all(engine)
_migrate_outline_parent_id()
def _migrate_outline_parent_id() -> None:
"""迁移:为 outline 表添加 parent_id 列(如果尚不存在)"""
with Session(engine) as session:
# 检查 outline 表是否存在 parent_id 列
result = session.exec(text("PRAGMA table_info(outline)"))
columns = [row[1] for row in result]
if "parent_id" not in columns:
session.exec(text("ALTER TABLE outline ADD COLUMN parent_id INTEGER REFERENCES outline(id)"))
session.exec(text("CREATE INDEX IF NOT EXISTS ix_outline_parent_id ON outline(parent_id)"))
session.commit()
def get_session():

View File

@@ -1,7 +1,10 @@
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from starlette.responses import FileResponse
from .database import init_db
from .routers.novels import router as novels_router
@@ -12,6 +15,9 @@ 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
# 前端构建产物目录Docker 中为 /app/static本地开发时不存在则跳过
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
@asynccontextmanager
async def lifespan(app: FastAPI):
@@ -23,7 +29,7 @@ app = FastAPI(title="Noval", version="0.1.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -41,3 +47,17 @@ app.include_router(chat_router)
@app.get("/api/health")
def health():
return {"status": "ok"}
# 生产环境serve 前端静态文件Vue SPA
if STATIC_DIR.is_dir():
# 静态资源js/css/img
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
# 所有非 /api 路径返回 index.htmlVue Router history 模式)
@app.get("/{path:path}")
async def spa_fallback(path: str):
file_path = STATIC_DIR / path
if file_path.is_file():
return FileResponse(file_path)
return FileResponse(STATIC_DIR / "index.html")

View File

@@ -39,10 +39,11 @@ class ChatMessage(SQLModel, table=True):
created_at: datetime = Field(default_factory=_now_utc)
class Outline(SQLModel, table=True):
"""大纲表,属于小说的子资源"""
"""大纲表,属于小说的子资源,支持两级结构(父+子)"""
id: Optional[int] = Field(default=None, primary_key=True)
novel_id: int = Field(foreign_key="novel.id", index=True)
parent_id: int | None = Field(default=None, foreign_key="outline.id", index=True)
sort_order: int = Field(default=0)
summary: str = Field(max_length=200)
detail: str = Field(default="", max_length=1000)

View File

@@ -9,7 +9,8 @@ from ..database import get_session, engine
from ..models import AIConfig, ChatMessage, Novel
from ..schemas import ChatRequest, ChatMessageRead, ChatMessageList
from ..services.ai_provider import (
stream_chat, build_assistant_message, build_tool_results_messages, ToolCall,
stream_chat, simple_completion,
build_assistant_message, build_tool_results_messages, ToolCall,
)
from ..services.tools import get_openai_tools, get_anthropic_tools, execute_tool
@@ -20,12 +21,23 @@ MAX_TOOL_ROUNDS = 8
# 工具名称到中文描述
TOOL_LABELS: dict[str, str] = {
"get_novel_info": "查看小说信息",
"update_novel_info": "更新小说信息",
"list_outlines": "查询大纲",
"create_outline": "创建大纲",
"update_outline": "更新大纲",
"delete_outline": "删除大纲",
"reorder_outlines": "调整大纲排序",
"get_world_setting": "查询世界观",
"save_world_setting": "保存世界观",
"list_characters": "查询角色",
"create_character": "创建角色",
"update_character": "更新角色",
"delete_character": "删除角色",
"list_chapters": "查询章节",
"create_chapter": "创建章节",
"update_chapter": "更新章节",
"delete_chapter": "删除章节",
}
@@ -36,28 +48,50 @@ def _build_system_prompt(
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 '暂无'}",
"你是 Writing Red Dot 写作助手,专注于小说创作领域",
"你擅长:构思情节、塑造角色、打磨文笔、设计世界观、规划大纲结构。",
]
if novel_id:
novel = session.get(Novel, novel_id)
if novel:
parts.append(f"\n当前小说:《{novel.title}ID: {novel.id}")
parts.append(f"小说简介:{novel.description or '暂无'}")
if page_context:
parts.append(f"\n用户当前正在查看:{page_context}")
parts.append(f"\n用户当前正在查看:{page_context}请结合当前页面场景提供针对性建议。")
if tools_enabled:
parts.append(
"\n你可以使用工具来查看和管理大纲、世界观设定。"
"\n当用户要求查看、添加、修改或删除大纲或世界观时,请使用相应的工具。"
"\n执行完工具操作后,请向用户简要说明操作结果。"
"\n## 你的工具能力\n"
"你是一个具备工具调用能力的写作助手。你拥有以下专用工具,没有其他任何工具:\n"
"\n### 小说信息\n"
"1. get_novel_info — 查看当前小说的标题、简介等基础信息\n"
"2. update_novel_info — 修改小说标题或简介\n"
"\n### 大纲管理\n"
"3. list_outlines — 列出所有大纲节点\n"
"4. create_outline — 创建新的大纲节点需要提供摘要可选详情和父节点ID\n"
"5. update_outline — 更新指定大纲节点的摘要或详情\n"
"6. delete_outline — 删除指定大纲节点\n"
"\n### 世界观\n"
"7. get_world_setting — 获取世界观设定内容\n"
"8. save_world_setting — 保存或更新世界观设定\n"
"\n### 角色管理\n"
"9. list_characters — 列出所有角色\n"
"10. create_character — 创建新角色(需要提供名称,可选描述)\n"
"11. update_character — 更新指定角色的名称或描述\n"
"12. delete_character — 删除指定角色\n"
"\n### 章节管理\n"
"13. list_chapters — 列出所有章节\n"
"14. create_chapter — 创建新章节(需要提供标题,可选正文)\n"
"15. update_chapter — 更新指定章节的标题或正文\n"
"16. delete_chapter — 删除指定章节\n"
"\n当用户询问你有哪些工具或能力时,请据实回答上述工具。"
"\n当用户要求查看或修改小说信息、大纲、世界观、角色或章节时,请主动使用相应工具完成操作,并简要说明结果。"
)
if not novel_id:
parts.append("\n注意:工具操作需要关联到具体小说。如果用户需要使用工具,请提示他们先进入某本小说。")
return "\n".join(parts)
@@ -72,6 +106,23 @@ def _sse(data: dict) -> str:
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
def _save_assistant_message(novel_id: int | None, full_response: str, tool_call_logs: list[str]):
"""保存 AI 回复到 DB含工具调用摘要。在 finally 中调用,确保断连也能保存。"""
save_content = full_response
if tool_call_logs:
tool_summary = "\n".join(tool_call_logs)
save_content = f"[以下操作已执行]\n{tool_summary}\n\n{full_response}"
if save_content.strip():
with Session(engine) as s:
ai_msg = ChatMessage(
novel_id=novel_id,
role="assistant",
content=save_content,
)
s.add(ai_msg)
s.commit()
@router.post("/stream")
async def chat_stream(
data: ChatRequest,
@@ -101,7 +152,7 @@ async def chat_stream(
messages = [{"role": m.role, "content": m.content} for m in data.messages]
# 构建上下文
use_tools = bool(data.novel_id and data.tools_enabled)
use_tools = bool(data.tools_enabled)
system_prompt = _build_system_prompt(
data.novel_id, session, data.page_context, use_tools
)
@@ -112,6 +163,8 @@ async def chat_stream(
full_response = data.assistant_text or ""
usage_info: dict = {}
# 记录本次对话中的工具调用摘要(用于保存到 DB
tool_call_logs: list[str] = []
try:
# ── 阶段一:如果有待审批的工具调用,先执行它们 ──
@@ -133,6 +186,7 @@ async def chat_stream(
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})
tool_call_logs.append(f"[工具调用: {label}] 参数: {json.dumps(tc.arguments, ensure_ascii=False)} → 结果: {result}")
yield _sse({"tool_result": {
"name": tc.name,
"label": label,
@@ -164,7 +218,7 @@ async def chat_stream(
full_response += round_text
break
# 有工具调用 → 暂停,发给前端审批
# 有工具调用
full_response += round_text
calls_data = [
{
@@ -175,29 +229,48 @@ async def chat_stream(
}
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
if data.auto_approve_tools:
# 自动执行模式:直接执行工具,不暂停
yield _sse({"tool_calls_auto": calls_data})
assistant_msg = build_assistant_message(
config.provider, full_response, round_tool_calls
)
messages.append(assistant_msg)
tool_results = []
for tc in round_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})
tool_call_logs.append(f"[工具调用: {label}] 参数: {json.dumps(tc.arguments, ensure_ascii=False)} → 结果: {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 基于工具结果生成回复
continue
else:
# 审批模式:暂停,发给前端审批
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 _sse({"error": str(e)})
finally:
pass
# 无论连接是否断开,都保存已有的回复和工具调用到 DB
# 这确保刷新页面后 loadHistory 能恢复正确状态
_save_assistant_message(data.novel_id, full_response, tool_call_logs)
# 正常完成:保存 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(
@@ -255,3 +328,84 @@ def clear_messages(
for msg in messages:
session.delete(msg)
session.commit()
# 保留最近对话轮次数(每轮 = 1 user + 1 assistant
KEEP_RECENT_ROUNDS = 2
@router.post("/compress")
async def compress_context(
novel_id: int | None = Query(default=None),
session: Session = Depends(get_session),
):
"""压缩对话上下文:用 LLM 总结旧消息,仅保留最近几轮对话。"""
config = session.exec(select(AIConfig)).first()
if not config or not config.api_key:
return {"error": "请先配置AI服务"}
# 查询所有消息(按时间正序)
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
all_msgs = list(session.exec(query.order_by(ChatMessage.created_at)).all())
if len(all_msgs) <= KEEP_RECENT_ROUNDS * 2:
return {"compressed": False, "reason": "消息太少,无需压缩"}
# 分割:旧消息(需要压缩)+ 最近消息(保留原文)
keep_count = KEEP_RECENT_ROUNDS * 2
old_msgs = all_msgs[:-keep_count]
recent_msgs = all_msgs[-keep_count:]
# 检查第一条旧消息是否已是摘要(避免重复压缩无效内容)
# 构建需要总结的对话文本
conversation_text = ""
for msg in old_msgs:
role_label = "用户" if msg.role == "user" else "AI助手"
content = msg.content
# 如果是已压缩的摘要,标注出来
if content.startswith("[对话摘要]"):
conversation_text += f"[之前的摘要]{content[5:]}\n\n"
else:
conversation_text += f"{role_label}: {content}\n\n"
# 调用 LLM 生成摘要
summary_prompt = (
"你是一个对话摘要助手。请将以下对话历史压缩为简洁的摘要,保留关键信息:\n"
"1. 用户提出的核心需求和决策\n"
"2. AI 执行的重要操作及结果(如创建/修改了哪些大纲、角色、章节等)\n"
"3. 达成的共识和待办事项\n\n"
"只输出摘要内容,不要加前缀或解释。用简洁的条目列表形式。"
)
summary_messages = [
{"role": "user", "content": f"请压缩以下对话历史:\n\n{conversation_text}"}
]
try:
summary = await simple_completion(config, summary_messages, summary_prompt)
except Exception as e:
return {"error": f"摘要生成失败: {str(e)}"}
# 删除旧消息
for msg in old_msgs:
session.delete(msg)
# 插入摘要消息(时间设为最早保留消息之前)
summary_msg = ChatMessage(
novel_id=novel_id,
role="assistant",
content=f"[对话摘要]\n{summary}",
created_at=recent_msgs[0].created_at,
)
session.add(summary_msg)
session.commit()
return {
"compressed": True,
"removed_count": len(old_msgs),
"kept_count": len(recent_msgs),
"summary_length": len(summary),
}

View File

@@ -32,24 +32,62 @@ def _get_outline_or_404(
return outline
def _build_outline_read(outline: Outline, children: list[Outline]) -> OutlineRead:
"""将 Outline ORM 对象转为 OutlineRead附带子节点列表"""
return OutlineRead(
id=outline.id,
novel_id=outline.novel_id,
parent_id=outline.parent_id,
sort_order=outline.sort_order,
summary=outline.summary,
detail=outline.detail,
children=[
OutlineRead(
id=c.id,
novel_id=c.novel_id,
parent_id=c.parent_id,
sort_order=c.sort_order,
summary=c.summary,
detail=c.detail,
children=[],
created_at=c.created_at,
updated_at=c.updated_at,
)
for c in children
],
created_at=outline.created_at,
updated_at=outline.updated_at,
)
@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(
# 只查顶级节点parent_id IS NULL
top_items = session.exec(
select(Outline)
.where(Outline.novel_id == novel_id)
.where(Outline.novel_id == novel_id, Outline.parent_id.is_(None)) # type: ignore
.order_by(Outline.sort_order)
).all()
return OutlineList(
items=[OutlineRead.model_validate(o, from_attributes=True) for o in items],
total=total,
)
# 查询所有子节点,按 parent_id 分组
all_children = session.exec(
select(Outline)
.where(Outline.novel_id == novel_id, Outline.parent_id.isnot(None)) # type: ignore
.order_by(Outline.sort_order)
).all()
children_map: dict[int, list[Outline]] = {}
for child in all_children:
children_map.setdefault(child.parent_id, []).append(child)
items = [
_build_outline_read(o, children_map.get(o.id, []))
for o in top_items
]
return OutlineList(items=items, total=len(items))
@router.post("", response_model=OutlineRead, status_code=201)
@@ -59,17 +97,41 @@ def create_outline(
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()
# 如果指定了 parent_id校验父节点存在且是顶级节点
if data.parent_id is not None:
parent = session.get(Outline, data.parent_id)
if not parent or parent.novel_id != novel_id:
raise HTTPException(status_code=404, detail="父节点不存在")
if parent.parent_id is not None:
raise HTTPException(status_code=400, detail="仅支持两级结构,不能在子节点下创建子节点")
# 同级内自增 sort_order同 parent_id 下的 max + 1
if data.parent_id is not None:
max_order = session.exec(
select(func.max(Outline.sort_order))
.where(Outline.novel_id == novel_id, Outline.parent_id == data.parent_id)
).one()
else:
max_order = session.exec(
select(func.max(Outline.sort_order))
.where(Outline.novel_id == novel_id, Outline.parent_id.is_(None)) # type: ignore
).one()
next_order = (max_order or 0) + 1
outline = Outline(novel_id=novel_id, sort_order=next_order, **data.model_dump())
outline = Outline(
novel_id=novel_id,
parent_id=data.parent_id,
sort_order=next_order,
summary=data.summary,
detail=data.detail,
)
session.add(outline)
session.commit()
session.refresh(outline)
return outline
# 返回时附带空 children
return _build_outline_read(outline, [])
@router.put("/reorder", response_model=OutlineList)
@@ -78,25 +140,53 @@ def reorder_outlines(
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)
if outline.parent_id is not None:
raise HTTPException(status_code=400, detail=f"节点 {oid} 不是顶级节点,不能在此排序")
outline.sort_order = idx + 1
outline.updated_at = now
session.add(outline)
session.commit()
items = session.exec(
# 重新查询并返回嵌套结构
return list_outlines(novel_id, session)
@router.put("/{outline_id}/children/reorder", response_model=OutlineRead)
def reorder_children(
novel_id: int,
outline_id: int,
data: OutlineReorder,
session: Session = Depends(get_session),
):
"""子节点排序"""
_get_novel_or_404(novel_id, session)
parent = _get_outline_or_404(outline_id, novel_id, session)
if parent.parent_id is not None:
raise HTTPException(status_code=400, detail="只有顶级节点可以排序子节点")
now = datetime.now(timezone.utc)
for idx, cid in enumerate(data.ordered_ids):
child = _get_outline_or_404(cid, novel_id, session)
if child.parent_id != outline_id:
raise HTTPException(status_code=400, detail=f"节点 {cid} 不是该父节点的子节点")
child.sort_order = idx + 1
child.updated_at = now
session.add(child)
session.commit()
# 查询更新后的子节点
children = session.exec(
select(Outline)
.where(Outline.novel_id == novel_id)
.where(Outline.novel_id == novel_id, Outline.parent_id == outline_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,
)
session.refresh(parent)
return _build_outline_read(parent, list(children))
@router.get("/{outline_id}", response_model=OutlineRead)
@@ -105,7 +195,14 @@ def get_outline(
outline_id: int,
session: Session = Depends(get_session),
):
return _get_outline_or_404(outline_id, novel_id, session)
outline = _get_outline_or_404(outline_id, novel_id, session)
# 查询子节点
children = session.exec(
select(Outline)
.where(Outline.novel_id == novel_id, Outline.parent_id == outline_id)
.order_by(Outline.sort_order)
).all()
return _build_outline_read(outline, list(children))
@router.patch("/{outline_id}", response_model=OutlineRead)
@@ -123,7 +220,14 @@ def update_outline(
session.add(outline)
session.commit()
session.refresh(outline)
return outline
# 查询子节点
children = session.exec(
select(Outline)
.where(Outline.novel_id == novel_id, Outline.parent_id == outline_id)
.order_by(Outline.sort_order)
).all()
return _build_outline_read(outline, list(children))
@router.delete("/{outline_id}", status_code=204)
@@ -133,5 +237,12 @@ def delete_outline(
session: Session = Depends(get_session),
):
outline = _get_outline_or_404(outline_id, novel_id, session)
# 删除父节点时先删除所有子节点
children = session.exec(
select(Outline)
.where(Outline.novel_id == novel_id, Outline.parent_id == outline_id)
).all()
for child in children:
session.delete(child)
session.delete(outline)
session.commit()

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
@@ -32,6 +34,7 @@ class NovelList(BaseModel):
class OutlineCreate(BaseModel):
summary: str = Field(min_length=1, max_length=200)
detail: str = Field(default="", max_length=1000)
parent_id: int | None = None
class OutlineUpdate(BaseModel):
@@ -43,9 +46,11 @@ class OutlineUpdate(BaseModel):
class OutlineRead(BaseModel):
id: int
novel_id: int
parent_id: int | None = None
sort_order: int
summary: str
detail: str
children: list[OutlineRead] = []
created_at: datetime
updated_at: datetime
@@ -175,6 +180,7 @@ class ChatRequest(BaseModel):
novel_id: int | None = None
page_context: str | None = None # 用户当前所在页面,如 "大纲页" "世界观页"
tools_enabled: bool = True # 是否启用工具调用
auto_approve_tools: bool = False # 是否自动执行工具(无需人工审批)
# 工具审批续传
pending_tool_calls: list[PendingToolCall] | None = None
assistant_text: str | None = None # LLM 在工具调用前输出的文本
@@ -191,3 +197,7 @@ class ChatMessageRead(BaseModel):
class ChatMessageList(BaseModel):
items: list[ChatMessageRead]
total: int
# 解决 OutlineRead 自引用
OutlineRead.model_rebuild()

View File

@@ -103,6 +103,65 @@ def build_tool_results_messages(
]
async def simple_completion(
config: AIConfig,
messages: list[dict],
system_prompt: str = "",
) -> str:
"""非流式调用,返回完整文本。用于摘要压缩等场景。"""
if config.provider == "anthropic":
return await _simple_anthropic(config, messages, system_prompt)
return await _simple_openai(config, messages, system_prompt)
async def _simple_openai(config: AIConfig, messages: list[dict], system_prompt: str) -> str:
url = f"{config.base_url.rstrip('/')}/chat/completions"
payload_messages = []
if system_prompt:
payload_messages.append({"role": "system", "content": system_prompt})
payload_messages.extend(messages)
body = {"model": config.model, "messages": payload_messages, "stream": False}
async with httpx.AsyncClient(timeout=120) as client:
resp = await client.post(
url,
headers={"Authorization": f"Bearer {config.api_key}", "Content-Type": "application/json"},
json=body,
)
resp.raise_for_status()
data = resp.json()
return data["choices"][0]["message"]["content"] or ""
async def _simple_anthropic(config: AIConfig, messages: list[dict], system_prompt: str) -> str:
url = f"{config.base_url.rstrip('/')}/messages"
body: dict = {
"model": config.model,
"max_tokens": 4096,
"messages": messages,
"stream": False,
}
if system_prompt:
body["system"] = system_prompt
async with httpx.AsyncClient(timeout=120) as client:
resp = await client.post(
url,
headers={
"x-api-key": config.api_key,
"anthropic-version": "2023-06-01",
"Content-Type": "application/json",
},
json=body,
)
resp.raise_for_status()
data = resp.json()
for block in data.get("content", []):
if block.get("type") == "text":
return block["text"]
return ""
# ── OpenAI 兼容 ──

View File

@@ -1,4 +1,4 @@
"""工具注册表和执行器 —— 大纲 & 世界观 CRUD"""
"""工具注册表和执行器 —— 小说信息、大纲 & 世界观 CRUD"""
import json
from datetime import datetime, timezone
@@ -6,12 +6,31 @@ from datetime import datetime, timezone
from sqlmodel import Session, select, func
from ..database import engine
from ..models import Novel, Outline, WorldSetting
from ..models import Novel, Outline, WorldSetting, Character, Chapter
# ── 工具定义provider 无关格式)──
TOOL_DEFINITIONS: list[dict] = [
{
"name": "get_novel_info",
"description": "获取当前小说的基础信息,包括标题、简介、创建时间等",
"parameters": {
"type": "object",
"properties": {},
},
},
{
"name": "update_novel_info",
"description": "更新当前小说的基础信息(标题或简介)",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "新的小说标题可选1-100字"},
"description": {"type": "string", "description": "新的小说简介(可选)"},
},
},
},
{
"name": "list_outlines",
"description": "列出当前小说的所有大纲节点,返回每个节点的 id、排序、摘要和详情",
@@ -28,6 +47,7 @@ TOOL_DEFINITIONS: list[dict] = [
"properties": {
"summary": {"type": "string", "description": "大纲摘要简短标题200字以内"},
"detail": {"type": "string", "description": "大纲详情可选1000字以内"},
"parent_id": {"type": "integer", "description": "父节点 ID创建子节点时需提供"},
},
"required": ["summary"],
},
@@ -56,6 +76,25 @@ TOOL_DEFINITIONS: list[dict] = [
"required": ["outline_id"],
},
},
{
"name": "reorder_outlines",
"description": "调整大纲节点的排列顺序。传入按期望顺序排列的节点 ID 数组即可。可以排序顶级节点,也可以排序某个父节点下的子节点。",
"parameters": {
"type": "object",
"properties": {
"ordered_ids": {
"type": "array",
"items": {"type": "integer"},
"description": "按期望顺序排列的大纲节点 ID 数组",
},
"parent_id": {
"type": "integer",
"description": "父节点 ID。如果排序的是某个父节点下的子节点需提供此参数排序顶级节点时不传",
},
},
"required": ["ordered_ids"],
},
},
{
"name": "get_world_setting",
"description": "获取当前小说的世界观设定内容",
@@ -75,6 +114,96 @@ TOOL_DEFINITIONS: list[dict] = [
"required": ["content"],
},
},
# ── 角色工具 ──
{
"name": "list_characters",
"description": "列出当前小说的所有角色,返回每个角色的 id、名称和描述",
"parameters": {
"type": "object",
"properties": {},
},
},
{
"name": "create_character",
"description": "为当前小说创建一个新角色",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "角色名称100字以内"},
"description": {"type": "string", "description": "角色描述外貌、性格、背景等可选1000字以内"},
},
"required": ["name"],
},
},
{
"name": "update_character",
"description": "更新指定角色的名称或描述",
"parameters": {
"type": "object",
"properties": {
"character_id": {"type": "integer", "description": "要更新的角色 ID"},
"name": {"type": "string", "description": "新的角色名称(可选)"},
"description": {"type": "string", "description": "新的角色描述(可选)"},
},
"required": ["character_id"],
},
},
{
"name": "delete_character",
"description": "删除指定的角色",
"parameters": {
"type": "object",
"properties": {
"character_id": {"type": "integer", "description": "要删除的角色 ID"},
},
"required": ["character_id"],
},
},
# ── 章节工具 ──
{
"name": "list_chapters",
"description": "列出当前小说的所有章节,按排序顺序返回每个章节的 id、标题和内容摘要",
"parameters": {
"type": "object",
"properties": {},
},
},
{
"name": "create_chapter",
"description": "为当前小说创建一个新章节",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "章节标题200字以内"},
"content": {"type": "string", "description": "章节正文可选5000字以内"},
},
"required": ["title"],
},
},
{
"name": "update_chapter",
"description": "更新指定章节的标题或正文内容",
"parameters": {
"type": "object",
"properties": {
"chapter_id": {"type": "integer", "description": "要更新的章节 ID"},
"title": {"type": "string", "description": "新的章节标题(可选)"},
"content": {"type": "string", "description": "新的章节正文(可选)"},
},
"required": ["chapter_id"],
},
},
{
"name": "delete_chapter",
"description": "删除指定的章节",
"parameters": {
"type": "object",
"properties": {
"chapter_id": {"type": "integer", "description": "要删除的章节 ID"},
},
"required": ["chapter_id"],
},
},
]
@@ -101,12 +230,23 @@ def execute_tool(name: str, arguments: dict, novel_id: int) -> str:
"""执行工具返回结果字符串JSON"""
with Session(engine) as session:
handlers = {
"get_novel_info": _get_novel_info,
"update_novel_info": _update_novel_info,
"list_outlines": _list_outlines,
"create_outline": _create_outline,
"update_outline": _update_outline,
"delete_outline": _delete_outline,
"reorder_outlines": _reorder_outlines,
"get_world_setting": _get_world_setting,
"save_world_setting": _save_world_setting,
"list_characters": _list_characters,
"create_character": _create_character,
"update_character": _update_character,
"delete_character": _delete_character,
"list_chapters": _list_chapters,
"create_chapter": _create_chapter,
"update_chapter": _update_chapter,
"delete_chapter": _delete_chapter,
}
handler = handlers.get(name)
if not handler:
@@ -117,27 +257,100 @@ def execute_tool(name: str, arguments: dict, novel_id: int) -> str:
return json.dumps({"error": str(e)}, ensure_ascii=False)
def _get_novel_info(session: Session, novel_id: int, _args: dict) -> str:
novel = session.get(Novel, novel_id)
if not novel:
return json.dumps({"error": "小说不存在"}, ensure_ascii=False)
return json.dumps({
"id": novel.id,
"title": novel.title,
"description": novel.description or "",
"created_at": novel.created_at.isoformat() if novel.created_at else None,
"updated_at": novel.updated_at.isoformat() if novel.updated_at else None,
}, ensure_ascii=False)
def _update_novel_info(session: Session, novel_id: int, args: dict) -> str:
novel = session.get(Novel, novel_id)
if not novel:
return json.dumps({"error": "小说不存在"}, ensure_ascii=False)
if "title" in args:
title = args["title"].strip()
if not title or len(title) > 100:
return json.dumps({"error": "标题长度需在1-100字之间"}, ensure_ascii=False)
novel.title = title
if "description" in args:
novel.description = args["description"]
novel.updated_at = datetime.now(timezone.utc)
session.add(novel)
session.commit()
session.refresh(novel)
return json.dumps({
"id": novel.id,
"title": novel.title,
"description": novel.description or "",
"updated": True,
}, ensure_ascii=False)
def _list_outlines(session: Session, novel_id: int, _args: dict) -> str:
items = session.exec(
# 查询顶级节点
top_items = session.exec(
select(Outline)
.where(Outline.novel_id == novel_id)
.where(Outline.novel_id == novel_id, Outline.parent_id.is_(None)) # type: ignore
.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
]
# 查询所有子节点,按 parent_id 分组
all_children = session.exec(
select(Outline)
.where(Outline.novel_id == novel_id, Outline.parent_id.isnot(None)) # type: ignore
.order_by(Outline.sort_order)
).all()
children_map: dict[int, list[Outline]] = {}
for child in all_children:
children_map.setdefault(child.parent_id, []).append(child)
result = []
for o in top_items:
node = {"id": o.id, "sort_order": o.sort_order, "summary": o.summary, "detail": o.detail}
kids = children_map.get(o.id, [])
if kids:
node["children"] = [
{"id": c.id, "sort_order": c.sort_order, "summary": c.summary, "detail": c.detail, "parent_id": c.parent_id}
for c in kids
]
result.append(node)
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()
parent_id = args.get("parent_id")
# 如果指定了 parent_id校验父节点存在且是顶级节点
if parent_id is not None:
parent = session.get(Outline, parent_id)
if not parent or parent.novel_id != novel_id:
return json.dumps({"error": "父节点不存在"}, ensure_ascii=False)
if parent.parent_id is not None:
return json.dumps({"error": "仅支持两级结构,不能在子节点下创建子节点"}, ensure_ascii=False)
# 同级内自增 sort_order
if parent_id is not None:
max_order = session.exec(
select(func.max(Outline.sort_order))
.where(Outline.novel_id == novel_id, Outline.parent_id == parent_id)
).one()
else:
max_order = session.exec(
select(func.max(Outline.sort_order))
.where(Outline.novel_id == novel_id, Outline.parent_id.is_(None)) # type: ignore
).one()
next_order = (max_order or 0) + 1
outline = Outline(
novel_id=novel_id,
parent_id=parent_id,
sort_order=next_order,
summary=args["summary"],
detail=args.get("detail", ""),
@@ -145,10 +358,10 @@ def _create_outline(session: Session, novel_id: int, args: dict) -> str:
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,
)
result = {"id": outline.id, "sort_order": outline.sort_order, "summary": outline.summary, "detail": outline.detail}
if parent_id is not None:
result["parent_id"] = parent_id
return json.dumps(result, ensure_ascii=False)
def _update_outline(session: Session, novel_id: int, args: dict) -> str:
@@ -175,11 +388,43 @@ def _delete_outline(session: Session, novel_id: int, args: dict) -> str:
if not outline or outline.novel_id != novel_id:
return json.dumps({"error": "大纲不存在"}, ensure_ascii=False)
# 删除父节点时先删除所有子节点
children = session.exec(
select(Outline)
.where(Outline.novel_id == novel_id, Outline.parent_id == outline.id)
).all()
for child in children:
session.delete(child)
session.delete(outline)
session.commit()
return json.dumps({"deleted": True, "id": args["outline_id"]}, ensure_ascii=False)
def _reorder_outlines(session: Session, novel_id: int, args: dict) -> str:
ordered_ids = args["ordered_ids"]
parent_id = args.get("parent_id")
now = datetime.now(timezone.utc)
for idx, oid in enumerate(ordered_ids):
outline = session.get(Outline, oid)
if not outline or outline.novel_id != novel_id:
return json.dumps({"error": f"大纲节点 {oid} 不存在"}, ensure_ascii=False)
# 校验层级一致性
if parent_id is None and outline.parent_id is not None:
return json.dumps({"error": f"节点 {oid} 不是顶级节点"}, ensure_ascii=False)
if parent_id is not None and outline.parent_id != parent_id:
return json.dumps({"error": f"节点 {oid} 不属于父节点 {parent_id}"}, ensure_ascii=False)
outline.sort_order = idx + 1
outline.updated_at = now
session.add(outline)
session.commit()
return json.dumps({
"reordered": True,
"count": len(ordered_ids),
"parent_id": parent_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)
@@ -203,3 +448,148 @@ def _save_world_setting(session: Session, novel_id: int, args: dict) -> str:
session.commit()
session.refresh(ws)
return json.dumps({"saved": True, "content_length": len(ws.content)}, ensure_ascii=False)
# ── 角色处理函数 ──
def _list_characters(session: Session, novel_id: int, _args: dict) -> str:
items = session.exec(
select(Character)
.where(Character.novel_id == novel_id)
.order_by(Character.created_at)
).all()
result = [
{"id": c.id, "name": c.name, "description": c.description}
for c in items
]
return json.dumps({"characters": result, "total": len(result)}, ensure_ascii=False)
def _create_character(session: Session, novel_id: int, args: dict) -> str:
name = args["name"].strip()
if not name or len(name) > 100:
return json.dumps({"error": "角色名称长度需在1-100字之间"}, ensure_ascii=False)
char = Character(
novel_id=novel_id,
name=name,
description=args.get("description", ""),
)
session.add(char)
session.commit()
session.refresh(char)
return json.dumps(
{"id": char.id, "name": char.name, "description": char.description},
ensure_ascii=False,
)
def _update_character(session: Session, novel_id: int, args: dict) -> str:
char = session.get(Character, args["character_id"])
if not char or char.novel_id != novel_id:
return json.dumps({"error": "角色不存在"}, ensure_ascii=False)
if "name" in args:
name = args["name"].strip()
if not name or len(name) > 100:
return json.dumps({"error": "角色名称长度需在1-100字之间"}, ensure_ascii=False)
char.name = name
if "description" in args:
char.description = args["description"]
char.updated_at = datetime.now(timezone.utc)
session.add(char)
session.commit()
session.refresh(char)
return json.dumps(
{"id": char.id, "name": char.name, "description": char.description},
ensure_ascii=False,
)
def _delete_character(session: Session, novel_id: int, args: dict) -> str:
char = session.get(Character, args["character_id"])
if not char or char.novel_id != novel_id:
return json.dumps({"error": "角色不存在"}, ensure_ascii=False)
session.delete(char)
session.commit()
return json.dumps({"deleted": True, "id": args["character_id"]}, ensure_ascii=False)
# ── 章节处理函数 ──
def _list_chapters(session: Session, novel_id: int, _args: dict) -> str:
items = session.exec(
select(Chapter)
.where(Chapter.novel_id == novel_id)
.order_by(Chapter.sort_order)
).all()
result = [
{
"id": c.id,
"sort_order": c.sort_order,
"title": c.title,
"content_preview": c.content[:100] + "" if len(c.content) > 100 else c.content,
}
for c in items
]
return json.dumps({"chapters": result, "total": len(result)}, ensure_ascii=False)
def _create_chapter(session: Session, novel_id: int, args: dict) -> str:
title = args["title"].strip()
if not title or len(title) > 200:
return json.dumps({"error": "章节标题长度需在1-200字之间"}, ensure_ascii=False)
# 自增 sort_order
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,
title=title,
content=args.get("content", ""),
)
session.add(chapter)
session.commit()
session.refresh(chapter)
return json.dumps(
{"id": chapter.id, "sort_order": chapter.sort_order, "title": chapter.title},
ensure_ascii=False,
)
def _update_chapter(session: Session, novel_id: int, args: dict) -> str:
chapter = session.get(Chapter, args["chapter_id"])
if not chapter or chapter.novel_id != novel_id:
return json.dumps({"error": "章节不存在"}, ensure_ascii=False)
if "title" in args:
title = args["title"].strip()
if not title or len(title) > 200:
return json.dumps({"error": "章节标题长度需在1-200字之间"}, ensure_ascii=False)
chapter.title = title
if "content" in args:
chapter.content = args["content"]
chapter.updated_at = datetime.now(timezone.utc)
session.add(chapter)
session.commit()
session.refresh(chapter)
return json.dumps(
{"id": chapter.id, "title": chapter.title, "content_length": len(chapter.content)},
ensure_ascii=False,
)
def _delete_chapter(session: Session, novel_id: int, args: dict) -> str:
chapter = session.get(Chapter, args["chapter_id"])
if not chapter or chapter.novel_id != novel_id:
return json.dumps({"error": "章节不存在"}, ensure_ascii=False)
session.delete(chapter)
session.commit()
return json.dumps({"deleted": True, "id": args["chapter_id"]}, ensure_ascii=False)