From b39377e48751b5930da332d77f0e544d97c333c5 Mon Sep 17 00:00:00 2001 From: ROOG Date: Sun, 22 Mar 2026 23:50:14 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=96=87=E4=BB=B6=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E4=B8=8E=E6=8A=80=E8=83=BD=E7=AE=A1=E7=90=86=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **文件管理**: - 支持文件上传、解析和删除。 - 限制支持格式(txt, md, docx, pdf),最大 10MB。 - 提供前端上传区、文件列表及后端解析服务。 - **技能管理**: - 新增技能的创建、更新及删除功能。 - 支持分配自定义提示词和工具权限。 - 提供技能列表及分组工具选择交互界面。 同时调整相关 API 和前端组件,优化协作体验。 --- .dockerignore | 1 + backend/app/database.py | 55 +- backend/app/main.py | 4 + backend/app/models.py | 27 + backend/app/routers/chat.py | 69 ++- backend/app/routers/files.py | 130 +++++ backend/app/routers/skills.py | 111 ++++ backend/app/schemas.py | 59 ++ backend/app/services/file_parser.py | 64 +++ backend/app/services/tools.py | 104 +++- backend/requirements.txt | 3 + docker-compose.yml | 2 +- frontend/src/api/chat.ts | 4 +- frontend/src/api/files.ts | 39 ++ frontend/src/api/skills.ts | 68 +++ frontend/src/components/chat/ChatFiles.vue | 321 +++++++++++ frontend/src/components/chat/ChatPanel.vue | 110 +++- .../src/components/chat/ChatToolCalls.vue | 19 + .../src/components/chat/SkillManageModal.vue | 506 ++++++++++++++++++ .../src/components/chat/SkillSelector.vue | 254 +++++++++ frontend/src/composables/useChatAgent.ts | 8 +- 21 files changed, 1913 insertions(+), 45 deletions(-) create mode 100644 backend/app/routers/files.py create mode 100644 backend/app/routers/skills.py create mode 100644 backend/app/services/file_parser.py create mode 100644 frontend/src/api/files.ts create mode 100644 frontend/src/api/skills.ts create mode 100644 frontend/src/components/chat/ChatFiles.vue create mode 100644 frontend/src/components/chat/SkillManageModal.vue create mode 100644 frontend/src/components/chat/SkillSelector.vue diff --git a/.dockerignore b/.dockerignore index 5f91218..6871094 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,3 +14,4 @@ Thumbs.db .env .env.local start.bat +backend/data/ \ No newline at end of file diff --git a/backend/app/database.py b/backend/app/database.py index ed06dc5..716d64a 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 4163b65..3f99928 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/models.py b/backend/app/models.py index 872d340..86f85c6 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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): """世界观设定,每部小说一条记录""" diff --git a/backend/app/routers/chat.py b/backend/app/routers/chat.py index 8337ad9..5094d93 100644 --- a/backend/app/routers/chat.py +++ b/backend/app/routers/chat.py @@ -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 diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py new file mode 100644 index 0000000..c220117 --- /dev/null +++ b/backend/app/routers/files.py @@ -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() diff --git a/backend/app/routers/skills.py b/backend/app/routers/skills.py new file mode 100644 index 0000000..3ae0f24 --- /dev/null +++ b/backend/app/routers/skills.py @@ -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() diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 276dfb2..e76e513 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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 在工具调用前输出的文本 diff --git a/backend/app/services/file_parser.py b/backend/app/services/file_parser.py new file mode 100644 index 0000000..879eed3 --- /dev/null +++ b/backend/app/services/file_parser.py @@ -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) diff --git a/backend/app/services/tools.py b/backend/app/services/tools.py index 2c5451c..aac2bbb 100644 --- a/backend/app/services/tools.py +++ b/backend/app/services/tools.py @@ -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) diff --git a/backend/requirements.txt b/backend/requirements.txt index 8076d09..ddbcc99 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,3 +2,6 @@ fastapi>=0.115.0 uvicorn[standard]>=0.34.0 sqlmodel>=0.0.22 httpx>=0.27.0 +python-multipart>=0.0.9 +python-docx>=1.1.0 +pymupdf>=1.24.0 diff --git a/docker-compose.yml b/docker-compose.yml index 5e7e199..ce6ab20 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,7 @@ services: image: crpi-om2xd9y8cmaizszf.cn-beijing.personal.cr.aliyuncs.com/test-namespace-gu/writing-reddot:latest container_name: noval ports: - - "80:8000" + - "8111:8000" expose: - "8000" volumes: diff --git a/frontend/src/api/chat.ts b/frontend/src/api/chat.ts index 6db3741..659a3cb 100644 --- a/frontend/src/api/chat.ts +++ b/frontend/src/api/chat.ts @@ -76,6 +76,7 @@ export interface StreamChatOptions { pageContext?: string toolsEnabled?: boolean autoApproveTools?: boolean + skillId?: number | null // 工具审批续传 pendingToolCalls?: PendingToolCallData[] assistantText?: string @@ -91,7 +92,7 @@ export interface StreamChatOptions { export async function streamChat(options: StreamChatOptions): Promise { const { - messages, novelId, pageContext, toolsEnabled, autoApproveTools, + messages, novelId, pageContext, toolsEnabled, autoApproveTools, skillId, pendingToolCalls, assistantText, onChunk, onError, onDone, onToolCallsPending, onToolCallsAuto, onToolResult, signal, @@ -103,6 +104,7 @@ export async function streamChat(options: StreamChatOptions): Promise { page_context: pageContext ?? null, tools_enabled: toolsEnabled ?? true, auto_approve_tools: autoApproveTools ?? false, + skill_id: skillId ?? null, pending_tool_calls: pendingToolCalls ?? null, assistant_text: assistantText ?? null, }) diff --git a/frontend/src/api/files.ts b/frontend/src/api/files.ts new file mode 100644 index 0000000..0910f97 --- /dev/null +++ b/frontend/src/api/files.ts @@ -0,0 +1,39 @@ +import { http } from './client' + +export interface NovelFile { + id: number + novel_id: number + filename: string + file_size: number + mime_type: string + text_length: number + created_at: string +} + +export interface FileList { + items: NovelFile[] + total: number +} + +/** 上传文件 */ +export async function uploadFile(novelId: number, file: File): Promise { + const form = new FormData() + form.append('file', file) + const { data } = await http.post( + `/novels/${novelId}/files`, + form, + { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 60000 }, + ) + return data +} + +/** 列出文件 */ +export async function listFiles(novelId: number): Promise { + const { data } = await http.get(`/novels/${novelId}/files`) + return data +} + +/** 删除文件 */ +export async function deleteFile(novelId: number, fileId: number): Promise { + await http.delete(`/novels/${novelId}/files/${fileId}`) +} diff --git a/frontend/src/api/skills.ts b/frontend/src/api/skills.ts new file mode 100644 index 0000000..63860a9 --- /dev/null +++ b/frontend/src/api/skills.ts @@ -0,0 +1,68 @@ +import { http } from './client' + +export interface Skill { + id: number + name: string + description: string + system_prompt: string + allowed_tools: string[] + is_builtin: boolean + novel_id: number | null + created_at: string + updated_at: string +} + +export interface SkillList { + items: Skill[] + total: number +} + +export interface ToolInfo { + name: string + description: string +} + +export interface SkillCreate { + name: string + description?: string + system_prompt?: string + allowed_tools?: string[] + novel_id?: number | null +} + +export interface SkillUpdate { + name?: string + description?: string + system_prompt?: string + allowed_tools?: string[] +} + +/** 列出 Skill(全局 + 指定小说的) */ +export async function listSkills(novelId?: number): Promise { + const params = novelId ? { novel_id: novelId } : {} + const { data } = await http.get('/skills', { params }) + return data +} + +/** 创建 Skill */ +export async function createSkill(skill: SkillCreate): Promise { + const { data } = await http.post('/skills', skill) + return data +} + +/** 更新 Skill */ +export async function updateSkill(id: number, skill: SkillUpdate): Promise { + const { data } = await http.patch(`/skills/${id}`, skill) + return data +} + +/** 删除 Skill */ +export async function deleteSkill(id: number): Promise { + await http.delete(`/skills/${id}`) +} + +/** 获取所有可用工具列表 */ +export async function getAvailableTools(): Promise { + const { data } = await http.get('/skills/tools') + return data +} diff --git a/frontend/src/components/chat/ChatFiles.vue b/frontend/src/components/chat/ChatFiles.vue new file mode 100644 index 0000000..f363d11 --- /dev/null +++ b/frontend/src/components/chat/ChatFiles.vue @@ -0,0 +1,321 @@ + + + + + diff --git a/frontend/src/components/chat/ChatPanel.vue b/frontend/src/components/chat/ChatPanel.vue index f9f05bd..2aaa41a 100644 --- a/frontend/src/components/chat/ChatPanel.vue +++ b/frontend/src/components/chat/ChatPanel.vue @@ -3,11 +3,15 @@ import { ref, computed, nextTick, watch, onMounted } from 'vue' import { marked } from 'marked' import { useChatAgent } from '@/composables/useChatAgent' import { useToast } from '@/composables/useToast' +import { uploadFile } from '@/api/files' import { isToolCallGroup } from '@/types/chat' import type { ToolCallGroup } from '@/types/chat' import ChatMessageComp from './ChatMessage.vue' import ChatToolCalls from './ChatToolCalls.vue' import ChatConfigModal from './ChatConfigModal.vue' +import ChatFiles from './ChatFiles.vue' +import SkillSelector from './SkillSelector.vue' +import SkillManageModal from './SkillManageModal.vue' const props = defineProps<{ open: boolean @@ -19,7 +23,7 @@ const emit = defineEmits<{ const { entries, isStreaming, isCompressing, streamingContent, lastUsage, - toolsEnabled, autoApproveTools, currentNovelId, + toolsEnabled, autoApproveTools, activeSkillId, currentNovelId, loadHistory, sendMessage, approveToolCalls, skipToolCalls, stopStreaming, clearChat, compressChat, } = useChatAgent() @@ -28,6 +32,15 @@ const input = ref('') const toast = useToast() const messagesRef = ref() const showConfig = ref(false) +const showFiles = ref(false) +const showSkillManage = ref(false) +const skillSelectorRef = ref>() +const fileInputRef = ref() +const isUploading = ref(false) + +// 允许的文件扩展名 +const ALLOWED_EXTS = ['.txt', '.md', '.docx', '.pdf'] +const MAX_SIZE = 10 * 1024 * 1024 // 10MB // Token用量 const DEFAULT_CONTEXT = 128000 @@ -103,6 +116,46 @@ function handleSkip(group: ToolCallGroup) { skipToolCalls(group) } +// 文件上传 +function triggerFileInput() { + fileInputRef.value?.click() +} + +async function handleFileSelect(e: Event) { + const target = e.target as HTMLInputElement + const file = target.files?.[0] + if (!file) return + target.value = '' // 重置,允许重复上传同名文件 + + // 前端校验 + const ext = '.' + file.name.split('.').pop()?.toLowerCase() + if (!ALLOWED_EXTS.includes(ext)) { + toast.error(`不支持的格式 ${ext},仅支持 ${ALLOWED_EXTS.join('、')}`) + return + } + if (file.size > MAX_SIZE) { + toast.error(`文件过大(${(file.size / 1024 / 1024).toFixed(1)}MB),最大 10MB`) + return + } + if (!currentNovelId.value) { + toast.error('请先进入一部小说') + return + } + + isUploading.value = true + try { + const result = await uploadFile(currentNovelId.value, file) + toast.success(`文件已上传:${result.filename}(${result.text_length} 字)`) + // 自动填入提示 + input.value = `我上传了文件「${result.filename}」,请帮我分析其中的内容。` + } catch (err: unknown) { + const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '上传失败' + toast.error(msg) + } finally { + isUploading.value = false + } +} + // 自动滚动:消息变化时、面板打开时 watch([entries, streamingContent], scrollToBottom) watch(() => props.open, (isOpen) => { @@ -139,6 +192,16 @@ onMounted(() => { + + +