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