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

@@ -14,3 +14,4 @@ Thumbs.db
.env
.env.local
start.bat
backend/data/

View File

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

View File

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

View File

@@ -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):
"""世界观设定,每部小说一条记录"""

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

View File

@@ -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 在工具调用前输出的文本

View 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)

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)

View File

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

View File

@@ -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:

View File

@@ -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<void> {
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<void> {
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,
})

39
frontend/src/api/files.ts Normal file
View File

@@ -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<NovelFile> {
const form = new FormData()
form.append('file', file)
const { data } = await http.post<NovelFile>(
`/novels/${novelId}/files`,
form,
{ headers: { 'Content-Type': 'multipart/form-data' }, timeout: 60000 },
)
return data
}
/** 列出文件 */
export async function listFiles(novelId: number): Promise<FileList> {
const { data } = await http.get<FileList>(`/novels/${novelId}/files`)
return data
}
/** 删除文件 */
export async function deleteFile(novelId: number, fileId: number): Promise<void> {
await http.delete(`/novels/${novelId}/files/${fileId}`)
}

View File

@@ -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<SkillList> {
const params = novelId ? { novel_id: novelId } : {}
const { data } = await http.get<SkillList>('/skills', { params })
return data
}
/** 创建 Skill */
export async function createSkill(skill: SkillCreate): Promise<Skill> {
const { data } = await http.post<Skill>('/skills', skill)
return data
}
/** 更新 Skill */
export async function updateSkill(id: number, skill: SkillUpdate): Promise<Skill> {
const { data } = await http.patch<Skill>(`/skills/${id}`, skill)
return data
}
/** 删除 Skill */
export async function deleteSkill(id: number): Promise<void> {
await http.delete(`/skills/${id}`)
}
/** 获取所有可用工具列表 */
export async function getAvailableTools(): Promise<ToolInfo[]> {
const { data } = await http.get<ToolInfo[]>('/skills/tools')
return data
}

View File

@@ -0,0 +1,321 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { listFiles, deleteFile, uploadFile } from '@/api/files'
import type { NovelFile } from '@/api/files'
import { useToast } from '@/composables/useToast'
import BaseModal from '@/components/ui/BaseModal.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
const props = defineProps<{
show: boolean
novelId: number
}>()
const emit = defineEmits<{
close: []
}>()
const toast = useToast()
const files = ref<NovelFile[]>([])
const loading = ref(false)
const isUploading = ref(false)
const deletingId = ref<number | null>(null)
const fileInputRef = ref<HTMLInputElement>()
const ALLOWED_EXTS = ['.txt', '.md', '.docx', '.pdf']
const MAX_SIZE = 10 * 1024 * 1024
// 打开时加载文件列表
watch(() => props.show, async (val) => {
if (val) {
await loadFiles()
}
})
async function loadFiles() {
loading.value = true
try {
const result = await listFiles(props.novelId)
files.value = result.items
} catch {
toast.error('加载文件列表失败')
} finally {
loading.value = false
}
}
function triggerUpload() {
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
}
isUploading.value = true
try {
await uploadFile(props.novelId, file)
toast.success(`已上传: ${file.name}`)
await loadFiles()
} catch (err: unknown) {
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '上传失败'
toast.error(msg)
} finally {
isUploading.value = false
}
}
async function handleDelete(file: NovelFile) {
deletingId.value = file.id
try {
await deleteFile(props.novelId, file.id)
files.value = files.value.filter(f => f.id !== file.id)
toast.success(`已删除: ${file.filename}`)
} catch {
toast.error('删除失败')
} finally {
deletingId.value = null
}
}
/** 格式化文件大小 */
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
/** 格式化日期 */
function formatDate(iso: string): string {
const d = new Date(iso)
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`
}
/** 文件类型图标颜色 */
function getExtColor(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase()
switch (ext) {
case 'txt': case 'md': return 'var(--bamboo)'
case 'docx': return '#4a90d9'
case 'pdf': return '#c73e1d'
default: return 'var(--ink-400)'
}
}
</script>
<template>
<BaseModal :show="show" title="文件工作区" @close="emit('close')">
<div class="files-container">
<!-- 上传区 -->
<div class="upload-area" @click="triggerUpload">
<input
ref="fileInputRef"
type="file"
accept=".txt,.md,.docx,.pdf"
style="display: none"
@change="handleFileSelect"
/>
<div v-if="isUploading" class="upload-hint uploading">
<svg class="spin" width="20" height="20" viewBox="0 0 20 20" fill="none">
<circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="2" stroke-dasharray="40" stroke-dashoffset="10" stroke-linecap="round" />
</svg>
<span>上传中...</span>
</div>
<div v-else class="upload-hint">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M10 4v12M4 10h12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
<span>点击上传文件</span>
<span class="upload-sub">支持 txtmddocxpdf最大 10MB</span>
</div>
</div>
<!-- 文件列表 -->
<div v-if="loading" class="files-loading">加载中...</div>
<div v-else-if="files.length === 0" class="files-empty">
<p>还没有上传文件</p>
<p class="files-empty-sub">上传小说或剧本后AI 可以帮你分析内容</p>
</div>
<div v-else class="file-list">
<div v-for="file in files" :key="file.id" class="file-item">
<div class="file-icon" :style="{ color: getExtColor(file.filename) }">
<svg width="18" height="18" viewBox="0 0 16 16" fill="none">
<path d="M4 2h5l3 3v9H4V2z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9 2v3h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<div class="file-info">
<div class="file-name">{{ file.filename }}</div>
<div class="file-meta">
{{ formatSize(file.file_size) }} · {{ file.text_length.toLocaleString() }} · {{ formatDate(file.created_at) }}
</div>
</div>
<button
class="file-delete"
title="删除文件"
:disabled="deletingId === file.id"
@click.stop="handleDelete(file)"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</button>
</div>
</div>
</div>
<template #footer>
<BaseButton variant="ghost" @click="emit('close')">关闭</BaseButton>
</template>
</BaseModal>
</template>
<style scoped>
.files-container {
min-height: 200px;
}
/* 上传区 */
.upload-area {
border: 2px dashed var(--paper-300);
border-radius: var(--radius-md);
padding: var(--space-lg);
text-align: center;
cursor: pointer;
transition: all var(--duration-fast) ease;
margin-bottom: var(--space-lg);
}
.upload-area:hover {
border-color: var(--vermilion);
background: rgba(199, 62, 29, 0.03);
}
.upload-hint {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-xs);
color: var(--ink-400);
font-size: 0.88rem;
}
.upload-hint.uploading {
color: var(--vermilion);
}
.upload-sub {
font-size: 0.75rem;
color: var(--ink-300);
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 加载和空状态 */
.files-loading,
.files-empty {
text-align: center;
padding: var(--space-xl) 0;
color: var(--ink-400);
font-size: 0.88rem;
}
.files-empty-sub {
font-size: 0.78rem;
color: var(--ink-300);
margin-top: var(--space-xs);
}
/* 文件列表 */
.file-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.file-item {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
background: var(--paper-100);
border: 1px solid var(--paper-200);
transition: background var(--duration-fast) ease;
}
.file-item:hover {
background: var(--paper-50);
border-color: var(--paper-300);
}
.file-icon {
flex-shrink: 0;
display: flex;
align-items: center;
}
.file-info {
flex: 1;
min-width: 0;
}
.file-name {
font-size: 0.88rem;
font-weight: 600;
color: var(--ink-800);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-meta {
font-size: 0.72rem;
color: var(--ink-400);
margin-top: 2px;
}
.file-delete {
flex-shrink: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--ink-300);
cursor: pointer;
transition: all var(--duration-fast) ease;
}
.file-delete:hover {
color: var(--danger);
background: rgba(199, 62, 29, 0.08);
}
.file-delete:disabled {
opacity: 0.3;
cursor: default;
}
</style>

View File

@@ -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<HTMLElement>()
const showConfig = ref(false)
const showFiles = ref(false)
const showSkillManage = ref(false)
const skillSelectorRef = ref<InstanceType<typeof SkillSelector>>()
const fileInputRef = ref<HTMLInputElement>()
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(() => {
<path d="M4 2v4l4-2-4-2zM12 14v-4l-4 2 4 2zM2 8h12" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button
v-if="currentNovelId"
class="icon-btn"
title="文件工作区"
@click="showFiles = true"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M13.5 7.5l-5.8 5.8a3.2 3.2 0 01-4.5-4.5l5.8-5.8a2.1 2.1 0 013 3L6.2 11.8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button
v-if="currentNovelId"
class="icon-btn"
@@ -166,6 +229,13 @@ onMounted(() => {
<path d="M6 8L7.5 9.5L10 6.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<SkillSelector
v-if="currentNovelId"
ref="skillSelectorRef"
v-model="activeSkillId"
:novel-id="currentNovelId"
@manage="showSkillManage = true"
/>
<button class="icon-btn" title="AI配置" @click="showConfig = true">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="2.5" stroke="currentColor" stroke-width="1.2" />
@@ -244,6 +314,26 @@ onMounted(() => {
<!-- 输入区 -->
<div class="panel-input">
<!-- 附件上传按钮 -->
<button
v-if="currentNovelId"
class="icon-btn attach-btn"
:class="{ 'icon-btn--uploading': isUploading }"
:title="isUploading ? '上传中...' : '上传文件txt/md/docx/pdf'"
:disabled="isStreaming || isUploading"
@click="triggerFileInput"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M13.5 7.5l-5.8 5.8a3.2 3.2 0 01-4.5-4.5l5.8-5.8a2.1 2.1 0 013 3L6.2 11.8a1.1 1.1 0 01-1.5-1.5L10 5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<input
ref="fileInputRef"
type="file"
accept=".txt,.md,.docx,.pdf"
style="display: none"
@change="handleFileSelect"
/>
<textarea
v-model="input"
class="input-field"
@@ -274,6 +364,13 @@ onMounted(() => {
</Teleport>
<ChatConfigModal :show="showConfig" @close="showConfig = false" />
<ChatFiles v-if="currentNovelId" :show="showFiles" :novel-id="currentNovelId" @close="showFiles = false" />
<SkillManageModal
:show="showSkillManage"
:novel-id="currentNovelId"
@close="showSkillManage = false"
@updated="skillSelectorRef?.loadSkills()"
/>
</template>
<style scoped>
@@ -487,6 +584,17 @@ onMounted(() => {
30% { transform: translateY(-6px); }
}
/* 附件上传按钮 */
.attach-btn {
flex-shrink: 0;
align-self: center;
}
.icon-btn--uploading {
animation: pulse 1.2s ease-in-out infinite;
color: var(--vermilion);
}
/* 输入区 */
.panel-input {
display: flex;

View File

@@ -37,6 +37,9 @@ const TOOL_ICONS: Record<string, string> = {
create_chapter: 'M3 2h7v12H3zM12 5v5M9.5 7.5h5',
update_chapter: 'M4 2h8v12H4zM7 7l2-2 2 2-2 2z',
delete_chapter: 'M4 2h8v12H4zM6 6l4 4M10 6l-4 4',
// 文件
list_files: 'M13.5 7.5l-5.8 5.8a3.2 3.2 0 01-4.5-4.5l5.8-5.8a2.1 2.1 0 013 3L6.2 11.8',
read_file: 'M4 2h5l3 3v9H4V2zM9 2v3h3M6 8h4M6 10h4M6 12h2',
}
// 工具颜色主题
@@ -60,6 +63,9 @@ const TOOL_COLORS: Record<string, string> = {
create_chapter: '#4a90d9',
update_chapter: '#d4a017',
delete_chapter: 'var(--danger)',
// 文件
list_files: '#e8833a',
read_file: '#e8833a',
}
function getIcon(name: string): string {
@@ -104,6 +110,9 @@ function formatArgs(call: ToolCallEntry): string {
return `#${args.chapter_id}` + (args.title ? `${args.title}` : '')
case 'delete_chapter':
return `#${args.chapter_id}`
// 文件
case 'read_file':
return `#${args.file_id}` + (args.offset ? ` (偏移 ${args.offset})` : '')
default:
return ''
}
@@ -164,6 +173,16 @@ function formatResult(call: ToolCallEntry): string {
return `已更新: ${(data as { title: string }).title}`
case 'delete_chapter':
return '已删除'
// 文件
case 'list_files': {
const files = (data as { files: { id: number; filename: string; text_length: number }[] }).files
if (!files?.length) return '暂无文件'
return files.map((f: { id: number; filename: string; text_length: number }) => `${f.filename} (${f.text_length} 字)`).join('\n')
}
case 'read_file': {
const d = data as { filename: string; total_length: number; offset: number; limit: number; has_more: boolean }
return `${d.filename} [${d.offset}${d.offset + d.limit}] / ${d.total_length}${d.has_more ? '(还有更多)' : ''}`
}
default:
return call.result.slice(0, 100)
}

View File

@@ -0,0 +1,506 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { listSkills, createSkill, updateSkill, deleteSkill, getAvailableTools } from '@/api/skills'
import type { Skill, ToolInfo } from '@/api/skills'
import { useToast } from '@/composables/useToast'
import BaseModal from '@/components/ui/BaseModal.vue'
import BaseButton from '@/components/ui/BaseButton.vue'
import BaseInput from '@/components/ui/BaseInput.vue'
const props = defineProps<{
show: boolean
novelId?: number
}>()
const emit = defineEmits<{
close: []
updated: []
}>()
const toast = useToast()
const skills = ref<Skill[]>([])
const tools = ref<ToolInfo[]>([])
const selectedId = ref<number | null>(null)
const saving = ref(false)
// 编辑表单
const formName = ref('')
const formDesc = ref('')
const formPrompt = ref('')
const formTools = ref<string[]>([])
const isNew = ref(false)
const selectedSkill = computed(() =>
skills.value.find(s => s.id === selectedId.value) ?? null
)
// 工具分组
const TOOL_GROUPS: { label: string; tools: string[] }[] = [
{ label: '小说信息', tools: ['get_novel_info', 'update_novel_info'] },
{ label: '大纲管理', tools: ['list_outlines', 'create_outline', 'update_outline', 'delete_outline', 'reorder_outlines'] },
{ label: '世界观', tools: ['get_world_setting', 'save_world_setting'] },
{ label: '角色管理', tools: ['list_characters', 'create_character', 'update_character', 'delete_character'] },
{ label: '章节管理', tools: ['list_chapters', 'create_chapter', 'update_chapter', 'delete_chapter'] },
{ label: '文件管理', tools: ['list_files', 'read_file'] },
]
function getToolDesc(name: string): string {
return tools.value.find(t => t.name === name)?.description ?? name
}
// 全选/取消
const allToolsSelected = computed(() =>
formTools.value.length === 0 || tools.value.every(t => formTools.value.includes(t.name))
)
function toggleAllTools() {
if (allToolsSelected.value && formTools.value.length > 0) {
formTools.value = [] // 空数组 = 全部
} else {
formTools.value = tools.value.map(t => t.name)
}
}
watch(() => props.show, async (val) => {
if (val) {
await Promise.all([loadSkills(), loadTools()])
// 默认选中第一个
if (skills.value.length > 0 && !selectedId.value) {
selectSkill(skills.value[0])
}
}
})
async function loadSkills() {
try {
const result = await listSkills(props.novelId)
skills.value = result.items
} catch { /* 静默 */ }
}
async function loadTools() {
try {
tools.value = await getAvailableTools()
} catch { /* 静默 */ }
}
function selectSkill(skill: Skill) {
selectedId.value = skill.id
formName.value = skill.name
formDesc.value = skill.description
formPrompt.value = skill.system_prompt
formTools.value = [...skill.allowed_tools]
isNew.value = false
}
function startNew() {
selectedId.value = null
formName.value = ''
formDesc.value = ''
formPrompt.value = ''
formTools.value = []
isNew.value = true
}
async function handleSave() {
if (!formName.value.trim()) {
toast.error('请输入技能名称')
return
}
saving.value = true
try {
if (isNew.value) {
const created = await createSkill({
name: formName.value.trim(),
description: formDesc.value.trim(),
system_prompt: formPrompt.value,
allowed_tools: formTools.value,
novel_id: props.novelId ?? null,
})
toast.success(`已创建:${created.name}`)
await loadSkills()
selectSkill(created)
} else if (selectedId.value) {
const updated = await updateSkill(selectedId.value, {
name: formName.value.trim(),
description: formDesc.value.trim(),
system_prompt: formPrompt.value,
allowed_tools: formTools.value,
})
toast.success(`已更新:${updated.name}`)
await loadSkills()
selectSkill(updated)
}
emit('updated')
} catch {
toast.error('保存失败')
} finally {
saving.value = false
}
}
async function handleDelete() {
if (!selectedId.value) return
try {
await deleteSkill(selectedId.value)
toast.success('已删除')
selectedId.value = null
await loadSkills()
if (skills.value.length > 0) {
selectSkill(skills.value[0])
} else {
isNew.value = true
}
emit('updated')
} catch {
toast.error('删除失败')
}
}
</script>
<template>
<BaseModal :show="show" title="技能管理" @close="emit('close')" width="680px">
<div class="skill-manage">
<!-- 左栏列表 -->
<div class="skill-list-col">
<div class="list-header">
<span class="list-title">技能列表</span>
<button class="add-btn" @click="startNew" title="新建技能">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</button>
</div>
<div class="list-items">
<div
v-for="skill in skills"
:key="skill.id"
class="list-item"
:class="{ 'list-item--active': selectedId === skill.id }"
@click="selectSkill(skill)"
>
<span class="item-name">{{ skill.name }}</span>
<span v-if="skill.is_builtin" class="item-badge">预置</span>
</div>
<div v-if="isNew" class="list-item list-item--active">
<span class="item-name" style="color: var(--vermilion)">+ 新建技能</span>
</div>
</div>
</div>
<!-- 右栏编辑 -->
<div class="skill-edit-col">
<template v-if="selectedId || isNew">
<BaseInput
v-model="formName"
label="名称"
placeholder="技能名称"
/>
<BaseInput
v-model="formDesc"
label="描述"
placeholder="简短描述(可选)"
/>
<div class="form-group">
<label class="form-label">系统提示词</label>
<textarea
v-model="formPrompt"
class="prompt-textarea"
placeholder="定义 AI 在此技能下的行为、风格和专注领域..."
rows="5"
/>
</div>
<div class="form-group">
<div class="tools-header">
<label class="form-label">可用工具</label>
<button class="toggle-all" @click="toggleAllTools">
{{ allToolsSelected ? '取消全选' : '全选' }}
</button>
</div>
<div class="tool-groups">
<div v-for="group in TOOL_GROUPS" :key="group.label" class="tool-group">
<div class="group-label">{{ group.label }}</div>
<div class="group-tools">
<label
v-for="toolName in group.tools"
:key="toolName"
class="tool-checkbox"
:title="getToolDesc(toolName)"
>
<input
type="checkbox"
:value="toolName"
v-model="formTools"
/>
<span>{{ toolName.replace(/_/g, ' ') }}</span>
</label>
</div>
</div>
</div>
<p class="tools-hint">不选择任何工具 = 使用全部工具</p>
</div>
</template>
<div v-else class="edit-empty">
<p>选择一个技能进行编辑</p>
<p>或点击 + 创建新技能</p>
</div>
</div>
</div>
<template #footer>
<div class="modal-footer">
<BaseButton
v-if="selectedId && !isNew"
variant="ghost"
class="delete-btn"
@click="handleDelete"
>
删除
</BaseButton>
<div class="footer-right">
<BaseButton variant="ghost" @click="emit('close')">取消</BaseButton>
<BaseButton
v-if="selectedId || isNew"
:loading="saving"
@click="handleSave"
>
{{ isNew ? '创建' : '保存' }}
</BaseButton>
</div>
</div>
</template>
</BaseModal>
</template>
<style scoped>
.skill-manage {
display: flex;
gap: var(--space-lg);
min-height: 400px;
}
/* 左栏 */
.skill-list-col {
width: 180px;
flex-shrink: 0;
border-right: 1px solid var(--paper-200);
padding-right: var(--space-md);
}
.list-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-sm);
}
.list-title {
font-size: 0.78rem;
font-weight: 600;
color: var(--ink-500);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.add-btn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: 1px solid var(--paper-200);
border-radius: var(--radius-sm);
color: var(--ink-400);
cursor: pointer;
transition: all var(--duration-fast) ease;
}
.add-btn:hover {
border-color: var(--vermilion);
color: var(--vermilion);
}
.list-items {
display: flex;
flex-direction: column;
gap: 2px;
}
.list-item {
padding: 6px 10px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--duration-fast) ease;
display: flex;
align-items: center;
gap: 6px;
}
.list-item:hover {
background: var(--paper-100);
}
.list-item--active {
background: rgba(91, 140, 90, 0.1);
}
.item-name {
font-size: 0.82rem;
color: var(--ink-700);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.item-badge {
font-size: 0.6rem;
padding: 1px 4px;
border-radius: 6px;
background: rgba(91, 140, 90, 0.12);
color: var(--bamboo);
flex-shrink: 0;
}
/* 右栏 */
.skill-edit-col {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-md);
overflow-y: auto;
}
.edit-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--ink-300);
font-size: 0.88rem;
gap: var(--space-xs);
}
.form-group {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.form-label {
font-size: 0.78rem;
font-weight: 600;
color: var(--ink-600);
}
.prompt-textarea {
width: 100%;
padding: var(--space-sm) var(--space-md);
font-size: 0.85rem;
font-family: var(--font-body);
color: var(--ink-900);
background: var(--paper-100);
border: 1px solid var(--paper-200);
border-radius: var(--radius-md);
outline: none;
resize: vertical;
min-height: 80px;
max-height: 200px;
line-height: 1.5;
transition: border-color var(--duration-fast) ease;
}
.prompt-textarea:focus {
border-color: var(--vermilion);
}
/* 工具选择 */
.tools-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.toggle-all {
font-size: 0.72rem;
color: var(--ink-400);
background: none;
border: none;
cursor: pointer;
text-decoration: underline;
}
.toggle-all:hover {
color: var(--ink-700);
}
.tool-groups {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-sm);
background: var(--paper-100);
border-radius: var(--radius-md);
border: 1px solid var(--paper-200);
max-height: 180px;
overflow-y: auto;
}
.tool-group {
display: flex;
flex-direction: column;
gap: 2px;
}
.group-label {
font-size: 0.7rem;
font-weight: 600;
color: var(--ink-400);
text-transform: uppercase;
letter-spacing: 0.3px;
margin-bottom: 2px;
}
.group-tools {
display: flex;
flex-wrap: wrap;
gap: 4px 8px;
}
.tool-checkbox {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
color: var(--ink-600);
cursor: pointer;
}
.tool-checkbox input {
accent-color: var(--bamboo);
}
.tools-hint {
font-size: 0.7rem;
color: var(--ink-300);
font-style: italic;
}
/* 底部 */
.modal-footer {
display: flex;
justify-content: space-between;
width: 100%;
}
.footer-right {
display: flex;
gap: var(--space-sm);
}
.delete-btn {
color: var(--danger) !important;
}
</style>

View File

@@ -0,0 +1,254 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { listSkills } from '@/api/skills'
import type { Skill } from '@/api/skills'
const props = defineProps<{
modelValue: number | null
novelId: number
}>()
const emit = defineEmits<{
'update:modelValue': [value: number | null]
manage: []
}>()
const skills = ref<Skill[]>([])
const open = ref(false)
const dropdownRef = ref<HTMLElement>()
const activeSkill = ref<Skill | null>(null)
async function loadSkills() {
try {
const result = await listSkills(props.novelId)
skills.value = result.items
// 同步当前激活 Skill
if (props.modelValue) {
activeSkill.value = skills.value.find(s => s.id === props.modelValue) ?? null
}
} catch { /* 静默 */ }
}
function selectSkill(skill: Skill | null) {
activeSkill.value = skill
emit('update:modelValue', skill?.id ?? null)
open.value = false
}
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.value && !dropdownRef.value.contains(e.target as Node)) {
open.value = false
}
}
watch(() => props.novelId, loadSkills)
watch(() => props.modelValue, (id) => {
activeSkill.value = id ? skills.value.find(s => s.id === id) ?? null : null
})
onMounted(() => {
loadSkills()
document.addEventListener('click', handleClickOutside, true)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside, true)
})
/** 外部调用:重新加载技能列表 */
defineExpose({ loadSkills })
</script>
<template>
<div ref="dropdownRef" class="skill-selector">
<button
class="skill-trigger"
:class="{ 'skill-trigger--active': activeSkill }"
:title="activeSkill ? `当前技能:${activeSkill.name}` : '选择技能'"
@click.stop="open = !open"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<path d="M8 1l2.5 5H14l-4 3.5 1.5 5.5L8 12l-3.5 3 1.5-5.5L2 6h3.5z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span v-if="activeSkill" class="skill-name">{{ activeSkill.name }}</span>
<span v-else class="skill-name">技能</span>
<svg class="chevron" :class="{ 'chevron--open': open }" width="10" height="10" viewBox="0 0 10 10" fill="none">
<path d="M2.5 4L5 6.5L7.5 4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
</button>
<Transition name="dropdown">
<div v-if="open" class="skill-dropdown">
<!-- 无技能选项 -->
<div
class="skill-option"
:class="{ 'skill-option--active': !activeSkill }"
@click="selectSkill(null)"
>
<span class="option-name">无技能</span>
<span class="option-desc">使用默认写作助手</span>
</div>
<div class="dropdown-divider" />
<!-- 技能列表 -->
<div
v-for="skill in skills"
:key="skill.id"
class="skill-option"
:class="{ 'skill-option--active': activeSkill?.id === skill.id }"
@click="selectSkill(skill)"
>
<div class="option-header">
<span class="option-name">{{ skill.name }}</span>
<span v-if="skill.is_builtin" class="builtin-badge">预置</span>
</div>
<span class="option-desc">{{ skill.description }}</span>
</div>
<div class="dropdown-divider" />
<!-- 管理入口 -->
<div class="skill-option skill-option--manage" @click="emit('manage'); open = false">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="2.5" stroke="currentColor" stroke-width="1.2" />
<path d="M8 1v2M8 13v2M1 8h2M13 8h2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
<span>管理技能...</span>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.skill-selector {
position: relative;
}
.skill-trigger {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: none;
border: 1px solid var(--paper-200);
border-radius: var(--radius-sm);
color: var(--ink-500);
cursor: pointer;
font-size: 0.75rem;
font-family: var(--font-body);
transition: all var(--duration-fast) ease;
height: 28px;
white-space: nowrap;
}
.skill-trigger:hover {
border-color: var(--ink-300);
color: var(--ink-800);
}
.skill-trigger--active {
border-color: var(--bamboo);
color: var(--bamboo);
background: rgba(91, 140, 90, 0.06);
}
.skill-name {
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
}
.chevron {
transition: transform var(--duration-fast) ease;
}
.chevron--open {
transform: rotate(180deg);
}
/* 下拉菜单 */
.skill-dropdown {
position: absolute;
top: calc(100% + 4px);
right: 0;
width: 240px;
background: var(--paper-50);
border: 1px solid var(--paper-200);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 1100;
max-height: 320px;
overflow-y: auto;
}
.dropdown-divider {
height: 1px;
background: var(--paper-200);
margin: 2px 0;
}
.skill-option {
padding: 8px 12px;
cursor: pointer;
transition: background var(--duration-fast) ease;
}
.skill-option:hover {
background: var(--paper-100);
}
.skill-option--active {
background: rgba(91, 140, 90, 0.08);
}
.skill-option--manage {
display: flex;
align-items: center;
gap: 6px;
color: var(--ink-400);
font-size: 0.78rem;
}
.skill-option--manage:hover {
color: var(--ink-700);
}
.option-header {
display: flex;
align-items: center;
gap: 6px;
}
.option-name {
font-size: 0.82rem;
font-weight: 600;
color: var(--ink-800);
}
.option-desc {
display: block;
font-size: 0.72rem;
color: var(--ink-400);
margin-top: 2px;
line-height: 1.3;
}
.builtin-badge {
font-size: 0.65rem;
padding: 1px 5px;
border-radius: 8px;
background: rgba(91, 140, 90, 0.12);
color: var(--bamboo);
font-weight: 500;
}
/* 动画 */
.dropdown-enter-active,
.dropdown-leave-active {
transition: all var(--duration-fast) ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>

View File

@@ -23,6 +23,8 @@ const LABEL_TO_TOOL_NAME: Record<string, string> = {
'创建章节': 'create_chapter',
'更新章节': 'update_chapter',
'删除章节': 'delete_chapter',
'查询文件': 'list_files',
'读取文件': 'read_file',
}
/**
@@ -92,6 +94,7 @@ const streamingContent = ref('')
const lastUsage = ref<TokenUsage | null>(null)
const toolsEnabled = ref(true)
const autoApproveTools = ref(false)
const activeSkillId = ref<number | null>(null)
const isCompressing = ref(false)
let abortController: AbortController | null = null
let idCounter = Date.now()
@@ -227,6 +230,7 @@ export function useChatAgent() {
novelId,
pageContext: pageContext.value,
toolsEnabled: toolsEnabled.value,
skillId: activeSkillId.value,
autoApproveTools: autoApproveTools.value,
pendingToolCalls,
assistantText,
@@ -285,7 +289,8 @@ export function useChatAgent() {
(e): e is ToolCallGroup => 'type' in e && e.type === 'tool_calls' && (e.status === 'executing' || e.status === 'done')
)
if (group) {
const call = group.calls.find(c => c.name === info.name)
// 匹配第一个同名且还没有 result 的 call处理多个相同工具调用的情况
const call = group.calls.find(c => c.name === info.name && !c.result)
if (call) {
call.result = info.result
try { call.parsedResult = JSON.parse(info.result) } catch { /* ok */ }
@@ -397,6 +402,7 @@ export function useChatAgent() {
lastUsage,
toolsEnabled,
autoApproveTools,
activeSkillId,
currentNovelId,
pageContext,
loadHistory,