- **文件管理**: - 支持文件上传、解析和删除。 - 限制支持格式(txt, md, docx, pdf),最大 10MB。 - 提供前端上传区、文件列表及后端解析服务。 - **技能管理**: - 新增技能的创建、更新及删除功能。 - 支持分配自定义提示词和工具权限。 - 提供技能列表及分组工具选择交互界面。 同时调整相关 API 和前端组件,优化协作体验。
This commit is contained in:
@@ -14,3 +14,4 @@ Thumbs.db
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
start.bat
|
start.bat
|
||||||
|
backend/data/
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlmodel import SQLModel, Session, create_engine
|
from sqlmodel import SQLModel, Session, create_engine, select
|
||||||
|
|
||||||
# 数据目录:优先使用环境变量(Docker 中设为 /app/data),否则用 backend/data/
|
# 数据目录:优先使用环境变量(Docker 中设为 /app/data),否则用 backend/data/
|
||||||
DATA_DIR = Path(os.environ.get("NOVAL_DATA_DIR", Path(__file__).resolve().parent.parent / "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:
|
def init_db() -> None:
|
||||||
SQLModel.metadata.create_all(engine)
|
SQLModel.metadata.create_all(engine)
|
||||||
_migrate_outline_parent_id()
|
_migrate_outline_parent_id()
|
||||||
|
_seed_builtin_skills()
|
||||||
|
|
||||||
|
|
||||||
def _migrate_outline_parent_id() -> None:
|
def _migrate_outline_parent_id() -> None:
|
||||||
@@ -30,6 +32,57 @@ def _migrate_outline_parent_id() -> None:
|
|||||||
session.commit()
|
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():
|
def get_session():
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ from .routers.chapters import router as chapters_router
|
|||||||
from .routers.world_setting import router as world_setting_router
|
from .routers.world_setting import router as world_setting_router
|
||||||
from .routers.ai_config import router as ai_config_router
|
from .routers.ai_config import router as ai_config_router
|
||||||
from .routers.chat import router as chat_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,本地开发时不存在则跳过)
|
# 前端构建产物目录(Docker 中为 /app/static,本地开发时不存在则跳过)
|
||||||
STATIC_DIR = Path(__file__).resolve().parent.parent / "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(world_setting_router)
|
||||||
app.include_router(ai_config_router)
|
app.include_router(ai_config_router)
|
||||||
app.include_router(chat_router)
|
app.include_router(chat_router)
|
||||||
|
app.include_router(files_router)
|
||||||
|
app.include_router(skills_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -74,6 +74,33 @@ class Chapter(SQLModel, table=True):
|
|||||||
updated_at: datetime = Field(default_factory=_now_utc)
|
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):
|
class WorldSetting(SQLModel, table=True):
|
||||||
"""世界观设定,每部小说一条记录"""
|
"""世界观设定,每部小说一条记录"""
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ from fastapi.responses import StreamingResponse
|
|||||||
from sqlmodel import Session, select, func
|
from sqlmodel import Session, select, func
|
||||||
|
|
||||||
from ..database import get_session, engine
|
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 ..schemas import ChatRequest, ChatMessageRead, ChatMessageList
|
||||||
from ..services.ai_provider import (
|
from ..services.ai_provider import (
|
||||||
stream_chat, simple_completion,
|
stream_chat, simple_completion,
|
||||||
build_assistant_message, build_tool_results_messages, ToolCall,
|
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"])
|
router = APIRouter(prefix="/api/chat", tags=["chat"])
|
||||||
|
|
||||||
@@ -38,6 +38,8 @@ TOOL_LABELS: dict[str, str] = {
|
|||||||
"create_chapter": "创建章节",
|
"create_chapter": "创建章节",
|
||||||
"update_chapter": "更新章节",
|
"update_chapter": "更新章节",
|
||||||
"delete_chapter": "删除章节",
|
"delete_chapter": "删除章节",
|
||||||
|
"list_files": "查询文件",
|
||||||
|
"read_file": "读取文件",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -46,13 +48,19 @@ def _build_system_prompt(
|
|||||||
session: Session,
|
session: Session,
|
||||||
page_context: str | None = None,
|
page_context: str | None = None,
|
||||||
tools_enabled: bool = False,
|
tools_enabled: bool = False,
|
||||||
|
skill: Skill | None = None,
|
||||||
|
allowed_tools: list[str] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""根据小说上下文和用户所在页面构建 system prompt"""
|
"""根据小说上下文、Skill 和工具配置构建 system prompt"""
|
||||||
parts = [
|
parts = [
|
||||||
"你是 Writing Red Dot 写作助手,专注于小说创作领域。",
|
"你是 Writing Red Dot 写作助手,专注于小说创作领域。",
|
||||||
"你擅长:构思情节、塑造角色、打磨文笔、设计世界观、规划大纲结构。",
|
"你擅长:构思情节、塑造角色、打磨文笔、设计世界观、规划大纲结构。",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# 注入 Skill 提示词
|
||||||
|
if skill and skill.system_prompt:
|
||||||
|
parts.append(f"\n## 当前技能:{skill.name}\n{skill.system_prompt}")
|
||||||
|
|
||||||
if novel_id:
|
if novel_id:
|
||||||
novel = session.get(Novel, novel_id)
|
novel = session.get(Novel, novel_id)
|
||||||
if novel:
|
if novel:
|
||||||
@@ -63,43 +71,20 @@ def _build_system_prompt(
|
|||||||
parts.append(f"\n用户当前正在查看:{page_context}。请结合当前页面场景提供针对性建议。")
|
parts.append(f"\n用户当前正在查看:{page_context}。请结合当前页面场景提供针对性建议。")
|
||||||
|
|
||||||
if tools_enabled:
|
if tools_enabled:
|
||||||
parts.append(
|
# 动态生成工具能力描述(根据 allowed_tools 过滤)
|
||||||
"\n## 你的工具能力\n"
|
tools_desc = get_tools_description(allowed_tools)
|
||||||
"你是一个具备工具调用能力的写作助手。你拥有以下专用工具,没有其他任何工具:\n"
|
if tools_desc:
|
||||||
"\n### 小说信息\n"
|
parts.append(f"\n## 你的工具能力\n{tools_desc}")
|
||||||
"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当用户要求查看或修改小说信息、大纲、世界观、角色或章节时,请主动使用相应工具完成操作,并简要说明结果。"
|
|
||||||
)
|
|
||||||
if not novel_id:
|
if not novel_id:
|
||||||
parts.append("\n注意:工具操作需要关联到具体小说。如果用户需要使用工具,请提示他们先进入某本小说。")
|
parts.append("\n注意:工具操作需要关联到具体小说。如果用户需要使用工具,请提示他们先进入某本小说。")
|
||||||
|
|
||||||
return "\n".join(parts)
|
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":
|
if provider == "anthropic":
|
||||||
return get_anthropic_tools()
|
return get_anthropic_tools(allowed)
|
||||||
return get_openai_tools()
|
return get_openai_tools(allowed)
|
||||||
|
|
||||||
|
|
||||||
def _sse(data: dict) -> str:
|
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]
|
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)
|
use_tools = bool(data.tools_enabled)
|
||||||
system_prompt = _build_system_prompt(
|
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():
|
async def generate():
|
||||||
nonlocal messages
|
nonlocal messages
|
||||||
|
|||||||
130
backend/app/routers/files.py
Normal file
130
backend/app/routers/files.py
Normal 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()
|
||||||
111
backend/app/routers/skills.py
Normal file
111
backend/app/routers/skills.py
Normal 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()
|
||||||
@@ -141,6 +141,64 @@ class WorldSettingRead(BaseModel):
|
|||||||
updated_at: datetime
|
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配置 ──
|
# ── AI配置 ──
|
||||||
|
|
||||||
|
|
||||||
@@ -181,6 +239,7 @@ class ChatRequest(BaseModel):
|
|||||||
page_context: str | None = None # 用户当前所在页面,如 "大纲页" "世界观页"
|
page_context: str | None = None # 用户当前所在页面,如 "大纲页" "世界观页"
|
||||||
tools_enabled: bool = True # 是否启用工具调用
|
tools_enabled: bool = True # 是否启用工具调用
|
||||||
auto_approve_tools: bool = False # 是否自动执行工具(无需人工审批)
|
auto_approve_tools: bool = False # 是否自动执行工具(无需人工审批)
|
||||||
|
skill_id: int | None = None # 激活的 Skill ID
|
||||||
# 工具审批续传
|
# 工具审批续传
|
||||||
pending_tool_calls: list[PendingToolCall] | None = None
|
pending_tool_calls: list[PendingToolCall] | None = None
|
||||||
assistant_text: str | None = None # LLM 在工具调用前输出的文本
|
assistant_text: str | None = None # LLM 在工具调用前输出的文本
|
||||||
|
|||||||
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 sqlmodel import Session, select, func
|
||||||
|
|
||||||
from ..database import engine
|
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 无关格式)──
|
# ── 工具定义(provider 无关格式)──
|
||||||
@@ -204,25 +205,66 @@ TOOL_DEFINITIONS: list[dict] = [
|
|||||||
"required": ["chapter_id"],
|
"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 格式"""
|
"""转换为 OpenAI function calling 格式"""
|
||||||
return [
|
return [
|
||||||
{"type": "function", "function": t}
|
{"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 格式"""
|
"""转换为 Anthropic tool_use 格式"""
|
||||||
return [
|
return [
|
||||||
{"name": t["name"], "description": t["description"], "input_schema": t["parameters"]}
|
{"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,
|
"create_chapter": _create_chapter,
|
||||||
"update_chapter": _update_chapter,
|
"update_chapter": _update_chapter,
|
||||||
"delete_chapter": _delete_chapter,
|
"delete_chapter": _delete_chapter,
|
||||||
|
"list_files": _list_files,
|
||||||
|
"read_file": _read_file,
|
||||||
}
|
}
|
||||||
handler = handlers.get(name)
|
handler = handlers.get(name)
|
||||||
if not handler:
|
if not handler:
|
||||||
@@ -593,3 +637,53 @@ def _delete_chapter(session: Session, novel_id: int, args: dict) -> str:
|
|||||||
session.delete(chapter)
|
session.delete(chapter)
|
||||||
session.commit()
|
session.commit()
|
||||||
return json.dumps({"deleted": True, "id": args["chapter_id"]}, ensure_ascii=False)
|
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)
|
||||||
|
|||||||
@@ -2,3 +2,6 @@ fastapi>=0.115.0
|
|||||||
uvicorn[standard]>=0.34.0
|
uvicorn[standard]>=0.34.0
|
||||||
sqlmodel>=0.0.22
|
sqlmodel>=0.0.22
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
|
python-multipart>=0.0.9
|
||||||
|
python-docx>=1.1.0
|
||||||
|
pymupdf>=1.24.0
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ services:
|
|||||||
image: crpi-om2xd9y8cmaizszf.cn-beijing.personal.cr.aliyuncs.com/test-namespace-gu/writing-reddot:latest
|
image: crpi-om2xd9y8cmaizszf.cn-beijing.personal.cr.aliyuncs.com/test-namespace-gu/writing-reddot:latest
|
||||||
container_name: noval
|
container_name: noval
|
||||||
ports:
|
ports:
|
||||||
- "80:8000"
|
- "8111:8000"
|
||||||
expose:
|
expose:
|
||||||
- "8000"
|
- "8000"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export interface StreamChatOptions {
|
|||||||
pageContext?: string
|
pageContext?: string
|
||||||
toolsEnabled?: boolean
|
toolsEnabled?: boolean
|
||||||
autoApproveTools?: boolean
|
autoApproveTools?: boolean
|
||||||
|
skillId?: number | null
|
||||||
// 工具审批续传
|
// 工具审批续传
|
||||||
pendingToolCalls?: PendingToolCallData[]
|
pendingToolCalls?: PendingToolCallData[]
|
||||||
assistantText?: string
|
assistantText?: string
|
||||||
@@ -91,7 +92,7 @@ export interface StreamChatOptions {
|
|||||||
|
|
||||||
export async function streamChat(options: StreamChatOptions): Promise<void> {
|
export async function streamChat(options: StreamChatOptions): Promise<void> {
|
||||||
const {
|
const {
|
||||||
messages, novelId, pageContext, toolsEnabled, autoApproveTools,
|
messages, novelId, pageContext, toolsEnabled, autoApproveTools, skillId,
|
||||||
pendingToolCalls, assistantText,
|
pendingToolCalls, assistantText,
|
||||||
onChunk, onError, onDone, onToolCallsPending, onToolCallsAuto, onToolResult,
|
onChunk, onError, onDone, onToolCallsPending, onToolCallsAuto, onToolResult,
|
||||||
signal,
|
signal,
|
||||||
@@ -103,6 +104,7 @@ export async function streamChat(options: StreamChatOptions): Promise<void> {
|
|||||||
page_context: pageContext ?? null,
|
page_context: pageContext ?? null,
|
||||||
tools_enabled: toolsEnabled ?? true,
|
tools_enabled: toolsEnabled ?? true,
|
||||||
auto_approve_tools: autoApproveTools ?? false,
|
auto_approve_tools: autoApproveTools ?? false,
|
||||||
|
skill_id: skillId ?? null,
|
||||||
pending_tool_calls: pendingToolCalls ?? null,
|
pending_tool_calls: pendingToolCalls ?? null,
|
||||||
assistant_text: assistantText ?? null,
|
assistant_text: assistantText ?? null,
|
||||||
})
|
})
|
||||||
|
|||||||
39
frontend/src/api/files.ts
Normal file
39
frontend/src/api/files.ts
Normal 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}`)
|
||||||
|
}
|
||||||
68
frontend/src/api/skills.ts
Normal file
68
frontend/src/api/skills.ts
Normal 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
|
||||||
|
}
|
||||||
321
frontend/src/components/chat/ChatFiles.vue
Normal file
321
frontend/src/components/chat/ChatFiles.vue
Normal 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">支持 txt、md、docx、pdf,最大 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>
|
||||||
@@ -3,11 +3,15 @@ import { ref, computed, nextTick, watch, onMounted } from 'vue'
|
|||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import { useChatAgent } from '@/composables/useChatAgent'
|
import { useChatAgent } from '@/composables/useChatAgent'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import { uploadFile } from '@/api/files'
|
||||||
import { isToolCallGroup } from '@/types/chat'
|
import { isToolCallGroup } from '@/types/chat'
|
||||||
import type { ToolCallGroup } from '@/types/chat'
|
import type { ToolCallGroup } from '@/types/chat'
|
||||||
import ChatMessageComp from './ChatMessage.vue'
|
import ChatMessageComp from './ChatMessage.vue'
|
||||||
import ChatToolCalls from './ChatToolCalls.vue'
|
import ChatToolCalls from './ChatToolCalls.vue'
|
||||||
import ChatConfigModal from './ChatConfigModal.vue'
|
import ChatConfigModal from './ChatConfigModal.vue'
|
||||||
|
import ChatFiles from './ChatFiles.vue'
|
||||||
|
import SkillSelector from './SkillSelector.vue'
|
||||||
|
import SkillManageModal from './SkillManageModal.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -19,7 +23,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
entries, isStreaming, isCompressing, streamingContent, lastUsage,
|
entries, isStreaming, isCompressing, streamingContent, lastUsage,
|
||||||
toolsEnabled, autoApproveTools, currentNovelId,
|
toolsEnabled, autoApproveTools, activeSkillId, currentNovelId,
|
||||||
loadHistory, sendMessage, approveToolCalls, skipToolCalls,
|
loadHistory, sendMessage, approveToolCalls, skipToolCalls,
|
||||||
stopStreaming, clearChat, compressChat,
|
stopStreaming, clearChat, compressChat,
|
||||||
} = useChatAgent()
|
} = useChatAgent()
|
||||||
@@ -28,6 +32,15 @@ const input = ref('')
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const messagesRef = ref<HTMLElement>()
|
const messagesRef = ref<HTMLElement>()
|
||||||
const showConfig = ref(false)
|
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用量
|
// Token用量
|
||||||
const DEFAULT_CONTEXT = 128000
|
const DEFAULT_CONTEXT = 128000
|
||||||
@@ -103,6 +116,46 @@ function handleSkip(group: ToolCallGroup) {
|
|||||||
skipToolCalls(group)
|
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([entries, streamingContent], scrollToBottom)
|
||||||
watch(() => props.open, (isOpen) => {
|
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" />
|
<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>
|
</svg>
|
||||||
</button>
|
</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
|
<button
|
||||||
v-if="currentNovelId"
|
v-if="currentNovelId"
|
||||||
class="icon-btn"
|
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" />
|
<path d="M6 8L7.5 9.5L10 6.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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">
|
<button class="icon-btn" title="AI配置" @click="showConfig = true">
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
<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" />
|
<circle cx="8" cy="8" r="2.5" stroke="currentColor" stroke-width="1.2" />
|
||||||
@@ -244,6 +314,26 @@ onMounted(() => {
|
|||||||
|
|
||||||
<!-- 输入区 -->
|
<!-- 输入区 -->
|
||||||
<div class="panel-input">
|
<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
|
<textarea
|
||||||
v-model="input"
|
v-model="input"
|
||||||
class="input-field"
|
class="input-field"
|
||||||
@@ -274,6 +364,13 @@ onMounted(() => {
|
|||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
<ChatConfigModal :show="showConfig" @close="showConfig = false" />
|
<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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -487,6 +584,17 @@ onMounted(() => {
|
|||||||
30% { transform: translateY(-6px); }
|
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 {
|
.panel-input {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ const TOOL_ICONS: Record<string, string> = {
|
|||||||
create_chapter: 'M3 2h7v12H3zM12 5v5M9.5 7.5h5',
|
create_chapter: 'M3 2h7v12H3zM12 5v5M9.5 7.5h5',
|
||||||
update_chapter: 'M4 2h8v12H4zM7 7l2-2 2 2-2 2z',
|
update_chapter: 'M4 2h8v12H4zM7 7l2-2 2 2-2 2z',
|
||||||
delete_chapter: 'M4 2h8v12H4zM6 6l4 4M10 6l-4 4',
|
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',
|
create_chapter: '#4a90d9',
|
||||||
update_chapter: '#d4a017',
|
update_chapter: '#d4a017',
|
||||||
delete_chapter: 'var(--danger)',
|
delete_chapter: 'var(--danger)',
|
||||||
|
// 文件
|
||||||
|
list_files: '#e8833a',
|
||||||
|
read_file: '#e8833a',
|
||||||
}
|
}
|
||||||
|
|
||||||
function getIcon(name: string): string {
|
function getIcon(name: string): string {
|
||||||
@@ -104,6 +110,9 @@ function formatArgs(call: ToolCallEntry): string {
|
|||||||
return `#${args.chapter_id}` + (args.title ? ` → ${args.title}` : '')
|
return `#${args.chapter_id}` + (args.title ? ` → ${args.title}` : '')
|
||||||
case 'delete_chapter':
|
case 'delete_chapter':
|
||||||
return `#${args.chapter_id}`
|
return `#${args.chapter_id}`
|
||||||
|
// 文件
|
||||||
|
case 'read_file':
|
||||||
|
return `#${args.file_id}` + (args.offset ? ` (偏移 ${args.offset})` : '')
|
||||||
default:
|
default:
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
@@ -164,6 +173,16 @@ function formatResult(call: ToolCallEntry): string {
|
|||||||
return `已更新: ${(data as { title: string }).title}`
|
return `已更新: ${(data as { title: string }).title}`
|
||||||
case 'delete_chapter':
|
case 'delete_chapter':
|
||||||
return '已删除'
|
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:
|
default:
|
||||||
return call.result.slice(0, 100)
|
return call.result.slice(0, 100)
|
||||||
}
|
}
|
||||||
|
|||||||
506
frontend/src/components/chat/SkillManageModal.vue
Normal file
506
frontend/src/components/chat/SkillManageModal.vue
Normal 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>
|
||||||
254
frontend/src/components/chat/SkillSelector.vue
Normal file
254
frontend/src/components/chat/SkillSelector.vue
Normal 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>
|
||||||
@@ -23,6 +23,8 @@ const LABEL_TO_TOOL_NAME: Record<string, string> = {
|
|||||||
'创建章节': 'create_chapter',
|
'创建章节': 'create_chapter',
|
||||||
'更新章节': 'update_chapter',
|
'更新章节': 'update_chapter',
|
||||||
'删除章节': 'delete_chapter',
|
'删除章节': 'delete_chapter',
|
||||||
|
'查询文件': 'list_files',
|
||||||
|
'读取文件': 'read_file',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,6 +94,7 @@ const streamingContent = ref('')
|
|||||||
const lastUsage = ref<TokenUsage | null>(null)
|
const lastUsage = ref<TokenUsage | null>(null)
|
||||||
const toolsEnabled = ref(true)
|
const toolsEnabled = ref(true)
|
||||||
const autoApproveTools = ref(false)
|
const autoApproveTools = ref(false)
|
||||||
|
const activeSkillId = ref<number | null>(null)
|
||||||
const isCompressing = ref(false)
|
const isCompressing = ref(false)
|
||||||
let abortController: AbortController | null = null
|
let abortController: AbortController | null = null
|
||||||
let idCounter = Date.now()
|
let idCounter = Date.now()
|
||||||
@@ -227,6 +230,7 @@ export function useChatAgent() {
|
|||||||
novelId,
|
novelId,
|
||||||
pageContext: pageContext.value,
|
pageContext: pageContext.value,
|
||||||
toolsEnabled: toolsEnabled.value,
|
toolsEnabled: toolsEnabled.value,
|
||||||
|
skillId: activeSkillId.value,
|
||||||
autoApproveTools: autoApproveTools.value,
|
autoApproveTools: autoApproveTools.value,
|
||||||
pendingToolCalls,
|
pendingToolCalls,
|
||||||
assistantText,
|
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')
|
(e): e is ToolCallGroup => 'type' in e && e.type === 'tool_calls' && (e.status === 'executing' || e.status === 'done')
|
||||||
)
|
)
|
||||||
if (group) {
|
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) {
|
if (call) {
|
||||||
call.result = info.result
|
call.result = info.result
|
||||||
try { call.parsedResult = JSON.parse(info.result) } catch { /* ok */ }
|
try { call.parsedResult = JSON.parse(info.result) } catch { /* ok */ }
|
||||||
@@ -397,6 +402,7 @@ export function useChatAgent() {
|
|||||||
lastUsage,
|
lastUsage,
|
||||||
toolsEnabled,
|
toolsEnabled,
|
||||||
autoApproveTools,
|
autoApproveTools,
|
||||||
|
activeSkillId,
|
||||||
currentNovelId,
|
currentNovelId,
|
||||||
pageContext,
|
pageContext,
|
||||||
loadHistory,
|
loadHistory,
|
||||||
|
|||||||
Reference in New Issue
Block a user