新增文件管理与技能管理功能:
All checks were successful
Deploy Noval to K3s / deploy (push) Successful in 47s

- **文件管理**:
  - 支持文件上传、解析和删除。
  - 限制支持格式(txt, md, docx, pdf),最大 10MB。
  - 提供前端上传区、文件列表及后端解析服务。

- **技能管理**:
  - 新增技能的创建、更新及删除功能。
  - 支持分配自定义提示词和工具权限。
  - 提供技能列表及分组工具选择交互界面。

同时调整相关 API 和前端组件,优化协作体验。
This commit is contained in:
2026-03-22 23:50:14 +08:00
parent 7d5f4640b3
commit b39377e487
21 changed files with 1913 additions and 45 deletions

View File

@@ -1,8 +1,9 @@
import json
import os
from pathlib import Path
from sqlalchemy import text
from sqlmodel import SQLModel, Session, create_engine
from sqlmodel import SQLModel, Session, create_engine, select
# 数据目录优先使用环境变量Docker 中设为 /app/data否则用 backend/data/
DATA_DIR = Path(os.environ.get("NOVAL_DATA_DIR", Path(__file__).resolve().parent.parent / "data"))
@@ -16,6 +17,7 @@ engine = create_engine(DATABASE_URL, echo=False)
def init_db() -> None:
SQLModel.metadata.create_all(engine)
_migrate_outline_parent_id()
_seed_builtin_skills()
def _migrate_outline_parent_id() -> None:
@@ -30,6 +32,57 @@ def _migrate_outline_parent_id() -> None:
session.commit()
def _seed_builtin_skills() -> None:
"""初始化预置 Skill仅首次运行时插入"""
from .models import Skill # 延迟导入避免循环
with Session(engine) as session:
existing = session.exec(select(Skill).where(Skill.is_builtin == True)).first() # noqa: E712
if existing:
return # 已初始化过
builtin_skills = [
Skill(
name="通用写作助手",
description="全能写作助手,可使用所有工具",
system_prompt="你是一个专业的小说写作助手,擅长构思情节、塑造角色、打磨文笔。请根据用户需求提供创作建议,必要时主动使用工具查看和操作小说数据。",
allowed_tools="[]",
is_builtin=True,
),
Skill(
name="大纲规划师",
description="构建清晰的故事骨架,规划情节节点",
system_prompt="你是大纲规划专家。请帮助用户构建清晰的故事骨架,包括起承转合、情节节点和分章结构。分析现有大纲时请关注因果逻辑、节奏控制和悬念布置。在操作大纲时,注意保持节点间的逻辑连贯性。",
allowed_tools=json.dumps(["get_novel_info", "list_outlines", "create_outline", "update_outline", "delete_outline", "reorder_outlines"]),
is_builtin=True,
),
Skill(
name="角色塑造师",
description="设计立体、有深度的角色",
system_prompt="你是角色塑造专家。请帮助用户设计立体、有深度的角色,包括外貌、性格、动机、成长弧线和人物关系。注意角色间的化学反应和戏剧张力,确保每个角色都有独特的声音和行为逻辑。",
allowed_tools=json.dumps(["get_novel_info", "list_characters", "create_character", "update_character", "delete_character"]),
is_builtin=True,
),
Skill(
name="世界观架构师",
description="构建完整自洽的虚构世界",
system_prompt="你是世界观构建专家。请帮助用户设计完整自洽的虚构世界,包括地理、历史、政治、经济、魔法或科技体系等。确保设定内部逻辑一致,并与故事主线相辅相成。",
allowed_tools=json.dumps(["get_novel_info", "get_world_setting", "save_world_setting", "list_outlines", "list_characters"]),
is_builtin=True,
),
Skill(
name="文稿分析师",
description="分析上传的小说或剧本文件",
system_prompt="你是文稿分析专家。请仔细阅读用户上传的文件,提取关键信息,分析文风、角色、情节结构,并提供改进建议。分析时可以分段读取大文件,注意把握整体脉络。",
allowed_tools=json.dumps(["get_novel_info", "list_files", "read_file", "list_outlines", "list_characters", "list_chapters"]),
is_builtin=True,
),
]
for skill in builtin_skills:
session.add(skill)
session.commit()
def get_session():
with Session(engine) as session:
yield session

View File

@@ -14,6 +14,8 @@ 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
from .routers.files import router as files_router
from .routers.skills import router as skills_router
# 前端构建产物目录Docker 中为 /app/static本地开发时不存在则跳过
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
@@ -42,6 +44,8 @@ app.include_router(chapters_router)
app.include_router(world_setting_router)
app.include_router(ai_config_router)
app.include_router(chat_router)
app.include_router(files_router)
app.include_router(skills_router)
@app.get("/api/health")

View File

@@ -74,6 +74,33 @@ class Chapter(SQLModel, table=True):
updated_at: datetime = Field(default_factory=_now_utc)
class NovelFile(SQLModel, table=True):
"""小说关联文件"""
id: Optional[int] = Field(default=None, primary_key=True)
novel_id: int = Field(foreign_key="novel.id", index=True)
filename: str = Field(max_length=255) # 原始文件名
stored_name: str = Field(max_length=255) # 磁盘存储名uuid + 扩展名)
file_size: int = Field(default=0) # 字节数
mime_type: str = Field(default="", max_length=100)
text_length: int = Field(default=0) # 解析后纯文本字符数
created_at: datetime = Field(default_factory=_now_utc)
class Skill(SQLModel, table=True):
"""Skill 插件:自定义系统提示词 + 工具白名单"""
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(max_length=100, index=True)
description: str = Field(default="", max_length=500)
system_prompt: str = Field(default="")
allowed_tools: str = Field(default="[]") # JSON 数组字符串,"[]" 表示全部工具
is_builtin: bool = Field(default=False)
novel_id: int | None = Field(default=None, foreign_key="novel.id", index=True)
created_at: datetime = Field(default_factory=_now_utc)
updated_at: datetime = Field(default_factory=_now_utc)
class WorldSetting(SQLModel, table=True):
"""世界观设定,每部小说一条记录"""

View File

@@ -6,13 +6,13 @@ from fastapi.responses import StreamingResponse
from sqlmodel import Session, select, func
from ..database import get_session, engine
from ..models import AIConfig, ChatMessage, Novel
from ..models import AIConfig, ChatMessage, Novel, Skill
from ..schemas import ChatRequest, ChatMessageRead, ChatMessageList
from ..services.ai_provider import (
stream_chat, simple_completion,
build_assistant_message, build_tool_results_messages, ToolCall,
)
from ..services.tools import get_openai_tools, get_anthropic_tools, execute_tool
from ..services.tools import get_openai_tools, get_anthropic_tools, execute_tool, get_tools_description
router = APIRouter(prefix="/api/chat", tags=["chat"])
@@ -38,6 +38,8 @@ TOOL_LABELS: dict[str, str] = {
"create_chapter": "创建章节",
"update_chapter": "更新章节",
"delete_chapter": "删除章节",
"list_files": "查询文件",
"read_file": "读取文件",
}
@@ -46,13 +48,19 @@ def _build_system_prompt(
session: Session,
page_context: str | None = None,
tools_enabled: bool = False,
skill: Skill | None = None,
allowed_tools: list[str] | None = None,
) -> str:
"""根据小说上下文和用户所在页面构建 system prompt"""
"""根据小说上下文、Skill 和工具配置构建 system prompt"""
parts = [
"你是 Writing Red Dot 写作助手,专注于小说创作领域。",
"你擅长:构思情节、塑造角色、打磨文笔、设计世界观、规划大纲结构。",
]
# 注入 Skill 提示词
if skill and skill.system_prompt:
parts.append(f"\n## 当前技能:{skill.name}\n{skill.system_prompt}")
if novel_id:
novel = session.get(Novel, novel_id)
if novel:
@@ -63,43 +71,20 @@ def _build_system_prompt(
parts.append(f"\n用户当前正在查看:{page_context}。请结合当前页面场景提供针对性建议。")
if tools_enabled:
parts.append(
"\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当用户要求查看或修改小说信息、大纲、世界观、角色或章节时,请主动使用相应工具完成操作,并简要说明结果。"
)
# 动态生成工具能力描述(根据 allowed_tools 过滤)
tools_desc = get_tools_description(allowed_tools)
if tools_desc:
parts.append(f"\n## 你的工具能力\n{tools_desc}")
if not novel_id:
parts.append("\n注意:工具操作需要关联到具体小说。如果用户需要使用工具,请提示他们先进入某本小说。")
return "\n".join(parts)
def _get_tools(provider: str) -> list[dict]:
def _get_tools(provider: str, allowed: list[str] | None = None) -> list[dict]:
if provider == "anthropic":
return get_anthropic_tools()
return get_openai_tools()
return get_anthropic_tools(allowed)
return get_openai_tools(allowed)
def _sse(data: dict) -> str:
@@ -151,12 +136,26 @@ async def chat_stream(
# 构建消息列表
messages = [{"role": m.role, "content": m.content} for m in data.messages]
# 读取 Skill 配置
skill: Skill | None = None
allowed_tools: list[str] | None = None
if data.skill_id:
skill = session.get(Skill, data.skill_id)
if skill and skill.allowed_tools:
try:
parsed = json.loads(skill.allowed_tools)
if isinstance(parsed, list) and len(parsed) > 0:
allowed_tools = parsed
except (json.JSONDecodeError, TypeError):
pass
# 构建上下文
use_tools = bool(data.tools_enabled)
system_prompt = _build_system_prompt(
data.novel_id, session, data.page_context, use_tools
data.novel_id, session, data.page_context, use_tools,
skill=skill, allowed_tools=allowed_tools,
)
tools = _get_tools(config.provider) if use_tools else None
tools = _get_tools(config.provider, allowed_tools) if use_tools else None
async def generate():
nonlocal messages

View File

@@ -0,0 +1,130 @@
"""文件上传/管理路由"""
import uuid
from pathlib import Path
from fastapi import APIRouter, HTTPException, UploadFile, File
from sqlmodel import Session, select
from ..database import engine, DATA_DIR
from ..models import Novel, NovelFile
from ..schemas import FileRead, FileList
from ..services.file_parser import parse_file, ALLOWED_EXTENSIONS, MAX_FILE_SIZE
router = APIRouter(prefix="/api/novels/{novel_id}/files", tags=["files"])
# 上传文件存储根目录
UPLOAD_DIR = DATA_DIR / "uploads"
def _get_novel_upload_dir(novel_id: int) -> Path:
"""获取小说的上传目录,不存在则创建"""
d = UPLOAD_DIR / str(novel_id)
d.mkdir(parents=True, exist_ok=True)
return d
@router.post("", response_model=FileRead, status_code=201)
async def upload_file(novel_id: int, file: UploadFile = File(...)):
"""上传文件并解析为纯文本"""
with Session(engine) as session:
# 校验小说存在
novel = session.get(Novel, novel_id)
if not novel:
raise HTTPException(status_code=404, detail="小说不存在")
# 校验扩展名
if not file.filename:
raise HTTPException(status_code=400, detail="缺少文件名")
ext = Path(file.filename).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"不支持的文件格式 {ext},仅支持: {', '.join(sorted(ALLOWED_EXTENSIONS))}",
)
# 读取文件内容
content = await file.read()
file_size = len(content)
if file_size > MAX_FILE_SIZE:
raise HTTPException(
status_code=400,
detail=f"文件过大({file_size / 1024 / 1024:.1f}MB最大允许 10MB",
)
# 生成存储名并保存原始文件
stored_name = f"{uuid.uuid4().hex}{ext}"
upload_dir = _get_novel_upload_dir(novel_id)
file_path = upload_dir / stored_name
file_path.write_bytes(content)
# 解析纯文本
try:
text_content = parse_file(file_path, file.content_type or "")
except Exception as e:
# 解析失败,清理已保存的文件
file_path.unlink(missing_ok=True)
raise HTTPException(status_code=400, detail=f"文件解析失败: {e}")
# 保存纯文本缓存
text_path = upload_dir / f"{stored_name}.txt"
text_path.write_text(text_content, encoding="utf-8")
# 写入数据库
with Session(engine) as session:
novel_file = NovelFile(
novel_id=novel_id,
filename=file.filename,
stored_name=stored_name,
file_size=file_size,
mime_type=file.content_type or "",
text_length=len(text_content),
)
session.add(novel_file)
session.commit()
session.refresh(novel_file)
return novel_file
@router.get("", response_model=FileList)
def list_files(novel_id: int):
"""列出小说关联的所有文件"""
with Session(engine) as session:
stmt = (
select(NovelFile)
.where(NovelFile.novel_id == novel_id)
.order_by(NovelFile.created_at.desc())
)
files = session.exec(stmt).all()
items = [
FileRead(
id=f.id, # type: ignore
novel_id=f.novel_id,
filename=f.filename,
file_size=f.file_size,
mime_type=f.mime_type,
text_length=f.text_length,
created_at=f.created_at,
)
for f in files
]
return FileList(items=items, total=len(items))
@router.delete("/{file_id}", status_code=204)
def delete_file(novel_id: int, file_id: int):
"""删除文件(数据库记录 + 磁盘文件)"""
with Session(engine) as session:
novel_file = session.get(NovelFile, file_id)
if not novel_file or novel_file.novel_id != novel_id:
raise HTTPException(status_code=404, detail="文件不存在")
# 删除磁盘文件
upload_dir = _get_novel_upload_dir(novel_id)
(upload_dir / novel_file.stored_name).unlink(missing_ok=True)
(upload_dir / f"{novel_file.stored_name}.txt").unlink(missing_ok=True)
# 删除数据库记录
session.delete(novel_file)
session.commit()

View File

@@ -0,0 +1,111 @@
"""Skill 插件管理路由"""
import json
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException
from sqlmodel import Session, select, or_
from ..database import engine
from ..models import Skill
from ..schemas import SkillCreate, SkillUpdate, SkillRead, SkillList, ToolInfo
from ..services.tools import TOOL_DEFINITIONS
router = APIRouter(prefix="/api/skills", tags=["skills"])
def _skill_to_read(skill: Skill) -> SkillRead:
"""将 Skill ORM 对象转为 SkillRead反序列化 allowed_tools"""
try:
tools = json.loads(skill.allowed_tools) if skill.allowed_tools else []
except (json.JSONDecodeError, TypeError):
tools = []
return SkillRead(
id=skill.id, # type: ignore
name=skill.name,
description=skill.description,
system_prompt=skill.system_prompt,
allowed_tools=tools,
is_builtin=skill.is_builtin,
novel_id=skill.novel_id,
created_at=skill.created_at,
updated_at=skill.updated_at,
)
@router.get("", response_model=SkillList)
def list_skills(novel_id: int | None = None):
"""列出 Skill返回全局 + 指定小说的"""
with Session(engine) as session:
if novel_id is not None:
stmt = (
select(Skill)
.where(or_(Skill.novel_id.is_(None), Skill.novel_id == novel_id)) # type: ignore
.order_by(Skill.is_builtin.desc(), Skill.created_at) # type: ignore
)
else:
stmt = select(Skill).order_by(Skill.is_builtin.desc(), Skill.created_at) # type: ignore
skills = session.exec(stmt).all()
items = [_skill_to_read(s) for s in skills]
return SkillList(items=items, total=len(items))
@router.post("", response_model=SkillRead, status_code=201)
def create_skill(data: SkillCreate):
"""创建 Skill"""
with Session(engine) as session:
skill = Skill(
name=data.name,
description=data.description,
system_prompt=data.system_prompt,
allowed_tools=json.dumps(data.allowed_tools, ensure_ascii=False),
is_builtin=False,
novel_id=data.novel_id,
)
session.add(skill)
session.commit()
session.refresh(skill)
return _skill_to_read(skill)
@router.get("/tools", response_model=list[ToolInfo])
def get_available_tools():
"""返回所有可用工具名和描述(供前端工具 checkbox 使用)"""
return [
ToolInfo(name=t["name"], description=t["description"])
for t in TOOL_DEFINITIONS
]
@router.patch("/{skill_id}", response_model=SkillRead)
def update_skill(skill_id: int, data: SkillUpdate):
"""更新 Skill"""
with Session(engine) as session:
skill = session.get(Skill, skill_id)
if not skill:
raise HTTPException(status_code=404, detail="Skill 不存在")
if data.name is not None:
skill.name = data.name
if data.description is not None:
skill.description = data.description
if data.system_prompt is not None:
skill.system_prompt = data.system_prompt
if data.allowed_tools is not None:
skill.allowed_tools = json.dumps(data.allowed_tools, ensure_ascii=False)
skill.updated_at = datetime.now(timezone.utc)
session.add(skill)
session.commit()
session.refresh(skill)
return _skill_to_read(skill)
@router.delete("/{skill_id}", status_code=204)
def delete_skill(skill_id: int):
"""删除 Skill"""
with Session(engine) as session:
skill = session.get(Skill, skill_id)
if not skill:
raise HTTPException(status_code=404, detail="Skill 不存在")
session.delete(skill)
session.commit()

View File

@@ -141,6 +141,64 @@ class WorldSettingRead(BaseModel):
updated_at: datetime
# ── 文件 ──
class FileRead(BaseModel):
id: int
novel_id: int
filename: str
file_size: int
mime_type: str
text_length: int
created_at: datetime
class FileList(BaseModel):
items: list[FileRead]
total: int
# ── Skill ──
class SkillCreate(BaseModel):
name: str = Field(min_length=1, max_length=100)
description: str = Field(default="", max_length=500)
system_prompt: str = Field(default="", max_length=10000)
allowed_tools: list[str] = Field(default_factory=list)
novel_id: int | None = None
class SkillUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
description: str | None = Field(default=None, max_length=500)
system_prompt: str | None = Field(default=None, max_length=10000)
allowed_tools: list[str] | None = None
class SkillRead(BaseModel):
id: int
name: str
description: str
system_prompt: str
allowed_tools: list[str]
is_builtin: bool
novel_id: int | None
created_at: datetime
updated_at: datetime
class SkillList(BaseModel):
items: list[SkillRead]
total: int
class ToolInfo(BaseModel):
name: str
description: str
# ── AI配置 ──
@@ -181,6 +239,7 @@ class ChatRequest(BaseModel):
page_context: str | None = None # 用户当前所在页面,如 "大纲页" "世界观页"
tools_enabled: bool = True # 是否启用工具调用
auto_approve_tools: bool = False # 是否自动执行工具(无需人工审批)
skill_id: int | None = None # 激活的 Skill ID
# 工具审批续传
pending_tool_calls: list[PendingToolCall] | None = None
assistant_text: str | None = None # LLM 在工具调用前输出的文本

View File

@@ -0,0 +1,64 @@
"""文件解析服务:将上传的文件转换为纯文本"""
from pathlib import Path
# 支持的文件扩展名
ALLOWED_EXTENSIONS = {".txt", ".md", ".docx", ".pdf"}
# 最大文件大小 10MB
MAX_FILE_SIZE = 10 * 1024 * 1024
def parse_file(file_path: Path, mime_type: str) -> str:
"""解析文件为纯文本,返回文本内容"""
ext = file_path.suffix.lower()
if ext in (".txt", ".md"):
return _parse_text(file_path)
elif ext == ".docx":
return _parse_docx(file_path)
elif ext == ".pdf":
return _parse_pdf(file_path)
else:
raise ValueError(f"不支持的文件格式: {ext}")
def _parse_text(file_path: Path) -> str:
"""解析纯文本文件,自动检测编码"""
for encoding in ("utf-8", "gbk", "gb18030", "utf-16", "latin-1"):
try:
return file_path.read_text(encoding=encoding)
except (UnicodeDecodeError, UnicodeError):
continue
# 最后用 errors='replace' 兜底
return file_path.read_text(encoding="utf-8", errors="replace")
def _parse_docx(file_path: Path) -> str:
"""解析 Word 文档"""
try:
from docx import Document
except ImportError:
raise RuntimeError("需要安装 python-docx: pip install python-docx")
doc = Document(str(file_path))
paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
return "\n\n".join(paragraphs)
def _parse_pdf(file_path: Path) -> str:
"""解析 PDF 文件"""
try:
import fitz # pymupdf
except ImportError:
raise RuntimeError("需要安装 pymupdf: pip install pymupdf")
doc = fitz.open(str(file_path))
pages = []
for page in doc:
text = page.get_text().strip()
if text:
pages.append(text)
doc.close()
return "\n\n".join(pages)

View File

@@ -6,7 +6,8 @@ from datetime import datetime, timezone
from sqlmodel import Session, select, func
from ..database import engine
from ..models import Novel, Outline, WorldSetting, Character, Chapter
from ..models import Novel, Outline, WorldSetting, Character, Chapter, NovelFile
from ..database import DATA_DIR
# ── 工具定义provider 无关格式)──
@@ -204,25 +205,66 @@ TOOL_DEFINITIONS: list[dict] = [
"required": ["chapter_id"],
},
},
# ── 文件工具 ──
{
"name": "list_files",
"description": "列出当前小说关联的所有上传文件,返回文件名、大小和纯文本字符数",
"parameters": {
"type": "object",
"properties": {},
},
},
{
"name": "read_file",
"description": "读取指定上传文件的纯文本内容。支持分段读取offset/limit 按字符数),适合大文件。返回文本片段和总字符数。",
"parameters": {
"type": "object",
"properties": {
"file_id": {"type": "integer", "description": "文件 ID从 list_files 获取)"},
"offset": {"type": "integer", "description": "起始字符位置(默认 0"},
"limit": {"type": "integer", "description": "读取字符数(默认 5000最大 10000"},
},
"required": ["file_id"],
},
},
]
def get_openai_tools() -> list[dict]:
def _filter_defs(allowed: list[str] | None) -> list[dict]:
"""根据白名单过滤工具定义"""
if not allowed:
return TOOL_DEFINITIONS
return [t for t in TOOL_DEFINITIONS if t["name"] in allowed]
def get_openai_tools(allowed: list[str] | None = None) -> list[dict]:
"""转换为 OpenAI function calling 格式"""
return [
{"type": "function", "function": t}
for t in TOOL_DEFINITIONS
for t in _filter_defs(allowed)
]
def get_anthropic_tools() -> list[dict]:
def get_anthropic_tools(allowed: list[str] | None = None) -> list[dict]:
"""转换为 Anthropic tool_use 格式"""
return [
{"name": t["name"], "description": t["description"], "input_schema": t["parameters"]}
for t in TOOL_DEFINITIONS
for t in _filter_defs(allowed)
]
def get_tools_description(allowed: list[str] | None = None) -> str:
"""动态生成工具能力描述文本(用于系统提示词)"""
defs = _filter_defs(allowed)
if not defs:
return ""
lines = ["你拥有以下工具能力,可以直接操作小说数据:"]
for t in defs:
lines.append(f"- {t['name']}: {t['description']}")
lines.append("\n请根据用户需求主动使用工具,不要仅给出建议而不执行。")
return "\n".join(lines)
# ── 工具执行 ──
@@ -247,6 +289,8 @@ def execute_tool(name: str, arguments: dict, novel_id: int) -> str:
"create_chapter": _create_chapter,
"update_chapter": _update_chapter,
"delete_chapter": _delete_chapter,
"list_files": _list_files,
"read_file": _read_file,
}
handler = handlers.get(name)
if not handler:
@@ -593,3 +637,53 @@ def _delete_chapter(session: Session, novel_id: int, args: dict) -> str:
session.delete(chapter)
session.commit()
return json.dumps({"deleted": True, "id": args["chapter_id"]}, ensure_ascii=False)
# ── 文件处理函数 ──
def _list_files(session: Session, novel_id: int, _args: dict) -> str:
items = session.exec(
select(NovelFile)
.where(NovelFile.novel_id == novel_id)
.order_by(NovelFile.created_at.desc())
).all()
result = [
{
"id": f.id,
"filename": f.filename,
"file_size": f.file_size,
"text_length": f.text_length,
"created_at": f.created_at.isoformat() if f.created_at else None,
}
for f in items
]
return json.dumps({"files": result, "total": len(result)}, ensure_ascii=False)
def _read_file(session: Session, novel_id: int, args: dict) -> str:
file_id = args["file_id"]
offset = max(0, args.get("offset", 0))
limit = min(10000, max(1, args.get("limit", 5000)))
novel_file = session.get(NovelFile, file_id)
if not novel_file or novel_file.novel_id != novel_id:
return json.dumps({"error": "文件不存在"}, ensure_ascii=False)
# 读取预解析的纯文本缓存
text_path = DATA_DIR / "uploads" / str(novel_id) / f"{novel_file.stored_name}.txt"
if not text_path.exists():
return json.dumps({"error": "文件纯文本缓存不存在,可能需要重新上传"}, ensure_ascii=False)
full_text = text_path.read_text(encoding="utf-8")
total_length = len(full_text)
segment = full_text[offset:offset + limit]
return json.dumps({
"filename": novel_file.filename,
"text": segment,
"offset": offset,
"limit": limit,
"total_length": total_length,
"has_more": offset + limit < total_length,
}, ensure_ascii=False)