- **文件管理**: - 支持文件上传、解析和删除。 - 限制支持格式(txt, md, docx, pdf),最大 10MB。 - 提供前端上传区、文件列表及后端解析服务。 - **技能管理**: - 新增技能的创建、更新及删除功能。 - 支持分配自定义提示词和工具权限。 - 提供技能列表及分组工具选择交互界面。 同时调整相关 API 和前端组件,优化协作体验。
This commit is contained in:
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