新增文件管理与技能管理功能:
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

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