- **文件管理**: - 支持文件上传、解析和删除。 - 限制支持格式(txt, md, docx, pdf),最大 10MB。 - 提供前端上传区、文件列表及后端解析服务。 - **技能管理**: - 新增技能的创建、更新及删除功能。 - 支持分配自定义提示词和工具权限。 - 提供技能列表及分组工具选择交互界面。 同时调整相关 API 和前端组件,优化协作体验。
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
"""世界观设定,每部小说一条记录"""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
130
backend/app/routers/files.py
Normal file
130
backend/app/routers/files.py
Normal 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()
|
||||
111
backend/app/routers/skills.py
Normal file
111
backend/app/routers/skills.py
Normal 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()
|
||||
@@ -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 在工具调用前输出的文本
|
||||
|
||||
64
backend/app/services/file_parser.py
Normal file
64
backend/app/services/file_parser.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user