Compare commits

..

12 Commits

Author SHA1 Message Date
dce66b9fb0 优化工具调用格式解析与后端工具调用日志保存逻辑,同时增强列表工具的返回内容展示
All checks were successful
Deploy Noval to K3s / deploy (push) Successful in 1m7s
2026-03-23 12:23:45 +08:00
2bdc18c00a 优化工具调用格式解析与后端工具调用日志保存逻辑,同时增强列表工具的返回内容展示
All checks were successful
Deploy Noval to K3s / deploy (push) Successful in 52s
2026-03-23 08:39:45 +08:00
0525c8217e 新增子 Agent 与技能系统管理:
All checks were successful
Deploy Noval to K3s / deploy (push) Successful in 54s
- **子 Agent 支持**:
  - 新增 `dispatch_subagent` 工具,支持分配特定任务给子 Agent,由其独立操作。
  - 前端新增 `ChatSubAgent` 组件,支持子 Agent 任务状态展示(运行中/已完成)、工具调用进度及输出预览等。

- **技能管理扩展**:
  - 新增 Skill 编辑器页面(`SkillEditorView.vue`),支持创建、编辑、删除技能。
  - 完善技能工具权限分组选择功能,按类型灵活分配工具。

- **工具增强**:
  - 新增工具接口:`get_outline_detail`、`get_character_detail`、`get_chapter_detail`,用于获取详情内容。
  - 优化部分工具返回数据,仅输出基础信息以减少上下文体积。

- 后端添加 `SPECIAL_TOOLS` 支持,增强 `dispatch_subagent` 的特殊处理能力。

同时优化部分前后端交互,提升代码维护性与可读性。
2026-03-23 02:23:22 +08:00
b39377e487 新增文件管理与技能管理功能:
All checks were successful
Deploy Noval to K3s / deploy (push) Successful in 47s
- **文件管理**:
  - 支持文件上传、解析和删除。
  - 限制支持格式(txt, md, docx, pdf),最大 10MB。
  - 提供前端上传区、文件列表及后端解析服务。

- **技能管理**:
  - 新增技能的创建、更新及删除功能。
  - 支持分配自定义提示词和工具权限。
  - 提供技能列表及分组工具选择交互界面。

同时调整相关 API 和前端组件,优化协作体验。
2026-03-22 23:50:14 +08:00
7d5f4640b3 fix(deploy): align deployment image/pullPolicy with cloud registry and improve rollout diagnostics
All checks were successful
Deploy Noval to K3s / deploy (push) Successful in 2m35s
2026-03-22 18:00:25 +08:00
1c9b482df3 chore(k8s): use spec.ingressClassName for traefik ingress
All checks were successful
Deploy Noval to K3s / deploy (push) Successful in 28s
2026-03-22 17:43:22 +08:00
fe07a83386 ci: enforce cloud image pull only; remove local build from compose
Some checks failed
Deploy Noval to K3s / deploy (push) Has been cancelled
2026-03-22 17:40:07 +08:00
d12902c292 更新Docker Compose配置以拉取最新云端镜像,并在后端依赖中新增httpx库
Some checks failed
Deploy Noval to K3s / deploy (push) Failing after 1m17s
2026-03-22 17:38:29 +08:00
10ef04d748 feat(ci): prefer pulling cloud image from compose, fallback to local build
Some checks failed
Deploy Noval to K3s / deploy (push) Has been cancelled
2026-03-22 17:37:59 +08:00
6fb4635c21 chore(ci): add retry pulls for base images to avoid transient docker hub timeout
Some checks failed
Deploy Noval to K3s / deploy (push) Has been cancelled
2026-03-22 17:33:50 +08:00
2ec7cd25cd fix(ci): pull current branch instead of hardcoded main
Some checks failed
Deploy Noval to K3s / deploy (push) Failing after 32s
2026-03-22 17:30:30 +08:00
a470739190 ci: add gitea deploy workflow and k3s manifests with cert-manager tls
Some checks failed
Deploy Noval to K3s / deploy (push) Failing after 1s
2026-03-22 17:29:02 +08:00
33 changed files with 4219 additions and 238 deletions

View File

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

View File

@@ -0,0 +1,93 @@
name: Deploy Noval to K3s
on:
push:
branches:
- main
- master
jobs:
deploy:
runs-on: self-hosted
steps:
- name: Deploy to K3s
run: |
set -e
APP_NAME="noval"
NAMESPACE="noval-roog-code"
IMAGE_NAME="noval-app"
echo "🚀 开始部署 ${APP_NAME}..."
cd /root/k8syaml/NovalRedot
echo "📥 拉取最新代码..."
BRANCH="${GITHUB_REF_NAME:-master}"
git pull origin "${BRANCH}"
NEW_COMMIT_SHA=$(git rev-parse --short HEAD)
NEW_COMMIT_MSG=$(git log -1 --oneline)
pull_with_retry() {
local image="$1"
local max=5
local n=1
until [ $n -gt $max ]; do
echo "尝试拉取 ${image} (${n}/${max})"
if docker pull "${image}"; then
echo "✅ 拉取成功: ${image}"
return 0
fi
sleep $((n * 5))
n=$((n + 1))
done
echo "❌ 拉取失败: ${image}"
return 1
}
# 100% 使用 docker-compose.yml 中配置的云端镜像(不再本地构建)
COMPOSE_IMAGE=$(docker compose config --images | head -n 1 || true)
if [ -z "${COMPOSE_IMAGE}" ]; then
echo "❌ docker-compose.yml 未配置 image无法走云端 pull"
exit 1
fi
echo "☁️ 使用云端镜像: ${COMPOSE_IMAGE}"
pull_with_retry "${COMPOSE_IMAGE}"
FULL_IMAGE="${COMPOSE_IMAGE}"
if command -v k3s >/dev/null 2>&1; then
echo "📤 导入镜像到 k3s containerd..."
docker save "${FULL_IMAGE}" | k3s ctr images import -
if docker image inspect "${IMAGE_NAME}:latest" >/dev/null 2>&1; then
docker save "${IMAGE_NAME}:latest" | k3s ctr images import -
fi
fi
echo "🧱 应用 K8s 基础资源..."
kubectl apply -f k8s/namespace.yaml
kubectl apply -f k8s/cluster-issuer.yaml
kubectl apply -f k8s/pvc.yaml
kubectl apply -f k8s/service.yaml
kubectl apply -f k8s/ingress.yaml
echo "🧱 应用/更新 deployment..."
kubectl apply -f k8s/deployment.yaml
kubectl set image deployment/${APP_NAME} ${APP_NAME}=${FULL_IMAGE} -n "${NAMESPACE}"
# 使用 latest 标签时,强制触发一次重启以拉取新镜像
kubectl rollout restart deployment/${APP_NAME} -n "${NAMESPACE}"
echo "⏳ 等待 rollout 完成..."
if ! kubectl rollout status deployment/${APP_NAME} -n "${NAMESPACE}" --timeout=300s; then
echo "❌ rollout 超时,输出诊断信息"
kubectl get pods -n "${NAMESPACE}" -l app=${APP_NAME} -o wide || true
kubectl describe deployment/${APP_NAME} -n "${NAMESPACE}" || true
kubectl logs -n "${NAMESPACE}" deploy/${APP_NAME} --all-containers=true --tail=200 || true
exit 1
fi
echo "✅ 部署完成"
echo "📌 版本: ${NEW_COMMIT_SHA} - ${NEW_COMMIT_MSG}"
kubectl get pods -n "${NAMESPACE}" -l app=${APP_NAME} -o wide

View File

@@ -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,140 @@ 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=(
"你是一个专业的网文小说写作助手,擅长构思情节、塑造角色、打磨文笔。"
"请根据用户需求提供创作建议,必要时主动使用工具查看和操作小说数据。\n"
"- 书写章节前,应对先查看对应的大纲与前一章的内容\n"
"- 确保章节开头可以呼应上一张的钩子\n"
"- 确保在章节尾留下钩子"
),
allowed_tools=json.dumps([
"get_novel_info", "list_outlines", "get_outline_detail",
"create_outline", "update_outline", "delete_outline", "reorder_outlines",
"get_world_setting", "list_characters",
"list_chapters", "get_chapter_detail", "create_chapter", "update_chapter", "delete_chapter",
"list_files", "read_file",
]),
is_builtin=True,
),
Skill(
name="大纲规划师",
description="构建清晰的故事骨架,规划情节节点",
system_prompt=(
"你是大纲规划专家。请帮助用户构建清晰的故事骨架,包括起承转合、情节节点和分章结构。"
"分析现有大纲时请关注因果逻辑、节奏控制和悬念布置。在操作大纲时,注意保持节点间的逻辑连贯性。"
),
allowed_tools=json.dumps([
"get_novel_info", "list_outlines", "get_outline_detail",
"create_outline", "update_outline", "delete_outline", "reorder_outlines",
"get_world_setting", "list_characters", "get_character_detail",
"list_chapters", "get_chapter_detail",
"list_files", "read_file",
]),
is_builtin=True,
),
Skill(
name="角色塑造师",
description="设计立体、有深度的角色",
system_prompt=(
"你是角色塑造专家。请帮助用户设计立体、有深度的角色,包括外貌、性格、动机、成长弧线和人物关系。"
"注意角色间的化学反应和戏剧张力,确保每个角色都有独特的声音和行为逻辑。"
),
allowed_tools=json.dumps([
"get_novel_info", "list_characters", "get_character_detail",
"create_character", "update_character", "delete_character",
"list_outlines", "get_outline_detail", "get_world_setting",
"list_chapters", "get_chapter_detail", "list_files",
]),
is_builtin=True,
),
Skill(
name="世界观架构师",
description="构建完整自洽的虚构世界",
system_prompt=(
"你是世界观构建专家。请帮助用户设计完整自洽的虚构世界,包括地理、历史、政治、经济、魔法或科技体系等。"
"确保设定内部逻辑一致,并与故事主线相辅相成。"
),
allowed_tools=json.dumps([
"get_novel_info", "get_world_setting", "save_world_setting",
"list_outlines", "get_outline_detail",
"list_characters", "get_character_detail",
"list_files", "read_file",
]),
is_builtin=True,
),
Skill(
name="大纲拆解专家",
description="分析上传的小说或剧本文件,并拆解为大纲",
system_prompt=(
"你是文稿分析专家。请仔细阅读用户上传的文件,提取关键信息,分析文风、角色、情节结构,并提供改进建议。"
"分析时调用subagent分段读取大文件并做出分析注意把握整体脉络。\n"
"单次调用subagent最多查看原文的5000字并进行工具调用保存循环处理直到整个小说都拆解完成。\n"
"拆解完成后仅输出完成即可,不需要进行任何形式的总结。\n"
"在每次阅读完成后,必须调用工具将人物写入系统。\n"
"阅读过程中逐步完成以下内容拆解:\n- 世界观\n- 大纲\n"
"在每次阅读完成后,必须调用工具将世界观与大纲写入系统。\n"
"注意:\n"
"- 调用更新和创建新大纲时,必须先调用查看章节列表,确保没有重复内容\n"
"- 每次调用工具写入大纲后,记录当前进度\n"
"- 每次读取原文前,注意延续上下文中上一次的进度\n"
"- 拆解全本小说完成前不要停止"
),
allowed_tools=json.dumps([
"get_novel_info", "list_outlines", "get_outline_detail",
"create_outline", "update_outline", "delete_outline", "reorder_outlines",
"get_world_setting", "save_world_setting",
"list_characters", "get_character_detail",
"list_chapters", "get_chapter_detail",
"list_files", "read_file",
]),
is_builtin=True,
),
Skill(
name="人物拆解专家",
description="阅读小说原文,并将人物进行拆解",
system_prompt=(
"你是文稿分析专家。请仔细阅读用户上传的文件,提取关键信息,分析文风、角色、情节结构,并提供改进建议。"
"分析时调用subagent分段读取大文件并做出分析注意把握整体脉络。\n"
"单次调用subagent最多查看原文的5000字并进行工具调用保存循环处理直到整个小说都拆解完成。\n"
"阅读过程中逐步完成人物拆解。\n"
"拆解完成后仅输出完成即可,不需要进行任何形式的总结。\n"
"在每次阅读完成后,必须调用工具将人物写入系统。\n"
"注意:\n"
"- 不要漏拆人物\n"
"- 要对人物进行深刻的理解与描写\n"
"- 调用更新和创建人物时,必须先调用查看人物列表,确保没有重复,别名重拆\n"
"- 每次调用工具写入人物后,记录当前进度\n"
"- 每次读取原文前,注意延续上下文中上一次的进度\n"
"- 拆解全本小说完成前不要停止"
),
allowed_tools=json.dumps([
"get_novel_info", "list_characters", "get_character_detail",
"create_character", "update_character", "delete_character",
"list_outlines", "get_outline_detail", "get_world_setting",
"list_files", "read_file",
]),
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

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

View File

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

View File

@@ -1,3 +1,4 @@
import asyncio
import json import json
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -6,13 +7,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, SPECIAL_TOOLS
router = APIRouter(prefix="/api/chat", tags=["chat"]) router = APIRouter(prefix="/api/chat", tags=["chat"])
@@ -24,6 +25,7 @@ TOOL_LABELS: dict[str, str] = {
"get_novel_info": "查看小说信息", "get_novel_info": "查看小说信息",
"update_novel_info": "更新小说信息", "update_novel_info": "更新小说信息",
"list_outlines": "查询大纲", "list_outlines": "查询大纲",
"get_outline_detail": "查看大纲详情",
"create_outline": "创建大纲", "create_outline": "创建大纲",
"update_outline": "更新大纲", "update_outline": "更新大纲",
"delete_outline": "删除大纲", "delete_outline": "删除大纲",
@@ -31,28 +33,42 @@ TOOL_LABELS: dict[str, str] = {
"get_world_setting": "查询世界观", "get_world_setting": "查询世界观",
"save_world_setting": "保存世界观", "save_world_setting": "保存世界观",
"list_characters": "查询角色", "list_characters": "查询角色",
"get_character_detail": "查看角色详情",
"create_character": "创建角色", "create_character": "创建角色",
"update_character": "更新角色", "update_character": "更新角色",
"delete_character": "删除角色", "delete_character": "删除角色",
"list_chapters": "查询章节", "list_chapters": "查询章节",
"get_chapter_detail": "查看章节详情",
"create_chapter": "创建章节", "create_chapter": "创建章节",
"update_chapter": "更新章节", "update_chapter": "更新章节",
"delete_chapter": "删除章节", "delete_chapter": "删除章节",
"list_files": "查询文件",
"read_file": "读取文件",
"dispatch_subagent": "派遣子Agent",
} }
# 子 Agent 最大轮次
MAX_SUBAGENT_ROUNDS = 5
def _build_system_prompt( def _build_system_prompt(
novel_id: int | None, novel_id: int | None,
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 +79,35 @@ 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注意:工具操作需要关联到具体小说。如果用户需要使用工具,请提示他们先进入某本小说。")
# 注入可用 Skill 列表(用于 dispatch_subagent
from sqlmodel import or_
skills = session.exec(
select(Skill).where(
or_(Skill.novel_id == novel_id, Skill.novel_id.is_(None)) # type: ignore
)
).all()
if skills:
skill_lines = ["\n## 可用子AgentSkill"]
skill_lines.append("你可以使用 dispatch_subagent 工具派遣以下子Agent处理子任务")
for sk in skills:
skill_lines.append(f"- skill_id={sk.id} {sk.name}: {sk.description}")
skill_lines.append("当任务复杂时考虑将子任务分派给专门的子Agent处理。")
parts.append("\n".join(skill_lines))
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:
@@ -107,20 +115,361 @@ def _sse(data: dict) -> str:
def _save_assistant_message(novel_id: int | None, full_response: str, tool_call_logs: list[str]): def _save_assistant_message(novel_id: int | None, full_response: str, tool_call_logs: list[str]):
"""保存 AI 回复到 DB(含工具调用摘要)。在 finally 中调用,确保断连也能保存""" """保存 AI 回复到 DB工具调用日志和回复文本分别存为独立记录"""
save_content = full_response with Session(engine) as s:
if tool_call_logs: # 工具调用日志作为单独一条记录
tool_summary = "\n".join(tool_call_logs) if tool_call_logs:
save_content = f"[以下操作已执行]\n{tool_summary}\n\n{full_response}" tool_summary = "\n".join(tool_call_logs)
if save_content.strip(): s.add(ChatMessage(
with Session(engine) as s:
ai_msg = ChatMessage(
novel_id=novel_id, novel_id=novel_id,
role="assistant", role="assistant",
content=save_content, content=f"[以下操作已执行]\n{tool_summary}",
))
# AI 回复文本作为单独一条记录
if full_response.strip():
s.add(ChatMessage(
novel_id=novel_id,
role="assistant",
content=full_response,
))
s.commit()
async def _run_subagent(
queue: asyncio.Queue,
config: AIConfig,
skill: Skill,
task: str,
novel_id: int,
subagent_id: str = "",
) -> str:
"""运行子 Agent独立 LLM 循环,通过 queue 发送事件。返回最终结果文本。
subagent_id 用于前端区分并行运行的多个子 Agent。"""
# 解析 Skill 的工具白名单
sub_allowed: list[str] | None = None
if skill.allowed_tools:
try:
parsed = json.loads(skill.allowed_tools)
if isinstance(parsed, list) and len(parsed) > 0:
sub_allowed = [t for t in parsed if t != "dispatch_subagent"]
except (json.JSONDecodeError, TypeError):
pass
# 构建子 Agent 系统提示词
sub_system_parts = [
f"你是子Agent「{skill.name}」。",
f"你的职责:{skill.description}" if skill.description else "",
]
if skill.system_prompt:
sub_system_parts.append(skill.system_prompt)
with Session(engine) as s:
novel = s.get(Novel, novel_id)
if novel:
sub_system_parts.append(f"\n当前小说:《{novel.title}ID: {novel.id}")
sub_tools_desc = get_tools_description(sub_allowed)
if sub_tools_desc:
sub_system_parts.append(f"\n## 你的工具能力\n{sub_tools_desc}")
sub_system = "\n".join(p for p in sub_system_parts if p)
sub_tools = _get_tools(config.provider, sub_allowed)
sub_messages: list[dict] = [{"role": "user", "content": task}]
full_response = ""
sid = subagent_id # 简写
for _round in range(MAX_SUBAGENT_ROUNDS):
round_text = ""
round_tool_calls: list[ToolCall] = []
async for event in stream_chat(config, sub_messages, sub_system, sub_tools):
if event.text:
round_text += event.text
await queue.put({"subagent_chunk": event.text, "subagent_id": sid})
if event.tool_calls:
round_tool_calls = event.tool_calls
if not round_tool_calls:
full_response += round_text
break
full_response += round_text
assistant_msg = build_assistant_message(
config.provider, round_text, round_tool_calls
)
sub_messages.append(assistant_msg)
tool_results = []
for tc in round_tool_calls:
label = TOOL_LABELS.get(tc.name, tc.name)
await queue.put({"subagent_tool_call": {
"name": tc.name, "label": label, "arguments": tc.arguments,
}, "subagent_id": sid})
result = execute_tool(tc.name, tc.arguments, novel_id)
tool_results.append({"id": tc.id, "result": result})
await queue.put({"subagent_tool_result": {
"name": tc.name, "label": label, "result": result,
}, "subagent_id": sid})
result_msgs = build_tool_results_messages(config.provider, tool_results)
sub_messages.extend(result_msgs)
await queue.put({"subagent_done": full_response, "subagent_id": sid})
return full_response
def _summarize_result(tool_name: str, result_json: str) -> str:
"""将工具结果精简为简短摘要,减少上下文 token 占用。"""
try:
data = json.loads(result_json)
except (json.JSONDecodeError, TypeError):
return result_json[:100]
if "error" in data:
return f"错误: {data['error']}"
# 列表类工具 → 显示数量 + 各条目 ID 和标题
if tool_name == "list_outlines":
items = data.get("outlines", [])
brief = ", ".join(f"#{o['id']}{o.get('summary', '')[:15]}" for o in items[:10])
return f"{data.get('total', 0)}条大纲: {brief}" if brief else f"{data.get('total', 0)}条大纲"
if tool_name == "list_characters":
items = data.get("characters", [])
brief = ", ".join(f"#{c['id']}{c.get('name', '')}" for c in items[:15])
return f"{data.get('total', 0)}个角色: {brief}" if brief else f"{data.get('total', 0)}个角色"
if tool_name == "list_chapters":
items = data.get("chapters", [])
brief = ", ".join(f"#{c['id']}{c.get('title', '')[:15]}" for c in items[:10])
return f"{data.get('total', 0)}个章节: {brief}" if brief else f"{data.get('total', 0)}个章节"
if tool_name == "list_files":
items = data.get("files", [])
brief = ", ".join(f"#{f['id']}{f.get('filename', '')}" for f in items[:5])
return f"{data.get('total', 0)}个文件: {brief}" if brief else f"{data.get('total', 0)}个文件"
# 创建类 → ID + 标题
if tool_name == "create_outline":
return f"已创建 #{data.get('id')} {data.get('summary', '')[:30]}"
if tool_name == "create_character":
return f"已创建 #{data.get('id')} {data.get('name', '')}"
if tool_name == "create_chapter":
return f"已创建 #{data.get('id')} {data.get('title', '')[:30]}"
# 更新类
if tool_name == "update_outline":
return f"已更新 #{data.get('id')}"
if tool_name == "update_character":
return f"已更新 #{data.get('id')} {data.get('name', '')}"
if tool_name == "update_chapter":
return f"已更新 #{data.get('id')}"
if tool_name == "update_novel_info":
return f"已更新小说信息"
# 删除类
if tool_name in ("delete_outline", "delete_character", "delete_chapter"):
return f"已删除 #{data.get('id')}"
# 排序
if tool_name == "reorder_outlines":
return f"已排序 {data.get('count', 0)}个节点"
# 世界观
if tool_name == "save_world_setting":
return f"已保存 ({data.get('content_length', 0)}字)"
if tool_name == "get_world_setting":
return f"{'有内容' if data.get('exists') else '暂无内容'}"
# 详情查看类
if tool_name == "get_outline_detail":
return f"大纲 #{data.get('id')} {data.get('summary', '')[:30]}"
if tool_name == "get_character_detail":
return f"角色 #{data.get('id')} {data.get('name', '')}"
if tool_name == "get_chapter_detail":
return f"章节 #{data.get('id')} {data.get('title', '')[:30]}"
if tool_name == "get_novel_info":
return f"小说: {data.get('title', '')}"
# 文件读取
if tool_name == "read_file":
return f"读取 {data.get('filename', '')} ({data.get('offset', 0)}-{data.get('offset', 0) + len(data.get('text', ''))}字/{data.get('total_length', 0)}字)"
# 其他 → 截取前 100 字符
return result_json[:100]
async def _execute_tool_with_queue(
queue: asyncio.Queue,
tc: ToolCall,
config: AIConfig,
novel_id: int,
) -> tuple[str, str]:
"""执行单个工具含子Agent通过 queue 发事件。返回 (result, log_line)。
子 Agent 使用 tc.id 作为 subagent_id用于前端区分并行的子 Agent。"""
label = TOOL_LABELS.get(tc.name, tc.name)
if tc.name == "dispatch_subagent":
skill_id = tc.arguments.get("skill_id")
task_desc = tc.arguments.get("task", "")
sub_skill = None
with Session(engine) as sub_s:
sub_skill = sub_s.get(Skill, skill_id)
if not sub_skill:
result = json.dumps({"error": f"Skill {skill_id} 不存在"}, ensure_ascii=False)
else:
await queue.put({"subagent_start": {
"skill_id": skill_id, "skill_name": sub_skill.name,
"task": task_desc, "subagent_id": tc.id,
}})
result = await _run_subagent(
queue, config, sub_skill, task_desc, novel_id, subagent_id=tc.id,
) )
s.add(ai_msg) skill_name = sub_skill.name if sub_skill else "未知"
s.commit() # 子 Agent 结果也精简(取前 200 字)
brief = result[:200] + "" if len(result) > 200 else result
log_line = f"[子Agent: {skill_name}] 任务: {task_desc}{brief}"
else:
result = execute_tool(tc.name, tc.arguments, novel_id)
await queue.put({"tool_result": {"name": tc.name, "label": label, "result": result}})
# 精简日志:仅记录摘要,避免完整 JSON 结果撑爆上下文
log_line = f"[工具调用: {label}] 参数: {json.dumps(tc.arguments, ensure_ascii=False)}{_summarize_result(tc.name, result)}"
return result, log_line
async def _execute_tools_parallel(
queue: asyncio.Queue,
tool_calls: list[ToolCall],
config: AIConfig,
novel_id: int,
) -> tuple[list[dict], list[str]]:
"""执行一组工具调用。子 Agent 并行执行,普通工具顺序执行。
返回 (tool_results, tool_call_logs)。"""
# 分离子 Agent 和普通工具
subagent_calls = [tc for tc in tool_calls if tc.name == "dispatch_subagent"]
normal_calls = [tc for tc in tool_calls if tc.name != "dispatch_subagent"]
results_map: dict[str, tuple[str, str]] = {} # tc.id → (result, log_line)
# 普通工具顺序执行
for tc in normal_calls:
result, log_line = await _execute_tool_with_queue(queue, tc, config, novel_id)
results_map[tc.id] = (result, log_line)
# 子 Agent 并行执行
if subagent_calls:
async def _run_one(tc: ToolCall) -> tuple[str, tuple[str, str]]:
r, l = await _execute_tool_with_queue(queue, tc, config, novel_id)
return tc.id, (r, l)
tasks = [_run_one(tc) for tc in subagent_calls]
for coro in asyncio.as_completed(tasks):
tc_id, rl = await coro
results_map[tc_id] = rl
# 按原始顺序组装结果
tool_results = []
tool_call_logs = []
for tc in tool_calls:
result, log_line = results_map[tc.id]
tool_results.append({"id": tc.id, "result": result})
tool_call_logs.append(log_line)
return tool_results, tool_call_logs
async def _chat_worker(
queue: asyncio.Queue,
config: AIConfig,
messages: list[dict],
system_prompt: str,
tools: list[dict] | None,
data: ChatRequest,
):
"""核心聊天逻辑 —— 运行在独立 Task 中,客户端断开也不会停止。"""
full_response = data.assistant_text or ""
usage_info: dict = {}
tool_call_logs: list[str] = []
pending_paused = False
try:
# ── 阶段一:执行待审批的工具调用 ──
if data.pending_tool_calls:
tool_calls = [
ToolCall(id=tc.id, name=tc.name, arguments=tc.arguments)
for tc in data.pending_tool_calls
]
assistant_msg = build_assistant_message(
config.provider, data.assistant_text or "", tool_calls
)
messages.append(assistant_msg)
tool_results, logs = await _execute_tools_parallel(
queue, tool_calls, config, data.novel_id,
)
tool_call_logs.extend(logs)
result_msgs = build_tool_results_messages(config.provider, tool_results)
messages.extend(result_msgs)
# ── 阶段二LLM 循环 ──
for _round in range(MAX_TOOL_ROUNDS):
round_text = ""
round_tool_calls: list[ToolCall] = []
async for event in stream_chat(config, messages, system_prompt, tools):
if event.text:
round_text += event.text
await queue.put({"content": event.text})
if event.usage:
for k, v in event.usage.items():
if v:
usage_info[k] = usage_info.get(k, 0) + v
if event.tool_calls:
round_tool_calls = event.tool_calls
if not round_tool_calls:
full_response += round_text
break
full_response += round_text
calls_data = [
{"id": tc.id, "name": tc.name,
"label": TOOL_LABELS.get(tc.name, tc.name),
"arguments": tc.arguments}
for tc in round_tool_calls
]
if data.auto_approve_tools:
await queue.put({"tool_calls_auto": calls_data})
assistant_msg = build_assistant_message(
config.provider, full_response, round_tool_calls
)
messages.append(assistant_msg)
tool_results, logs = await _execute_tools_parallel(
queue, round_tool_calls, config, data.novel_id,
)
tool_call_logs.extend(logs)
result_msgs = build_tool_results_messages(config.provider, tool_results)
messages.extend(result_msgs)
continue
else:
# 审批模式:暂停
await queue.put({"tool_calls_pending": calls_data, "assistant_text": full_response})
await queue.put({"done": True, "pending": True, "usage": usage_info or None})
pending_paused = True
return # 不保存到 DB
except Exception as e:
await queue.put({"error": str(e)})
finally:
if not pending_paused:
_save_assistant_message(data.novel_id, full_response, tool_call_logs)
await queue.put({"done": True, "usage": usage_info or None})
# 哨兵值:通知 SSE 生成器结束
await queue.put(None)
@router.post("/stream") @router.post("/stream")
@@ -128,7 +477,8 @@ async def chat_stream(
data: ChatRequest, data: ChatRequest,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""SSE 流式对话端点,支持 tool calling + 用户审批""" """SSE 流式对话端点,支持 tool calling + 用户审批
核心工作在独立 Task 中运行,客户端断开不会中断。"""
config = session.exec(select(AIConfig)).first() config = session.exec(select(AIConfig)).first()
if not config or not config.api_key: if not config or not config.api_key:
return StreamingResponse( return StreamingResponse(
@@ -151,127 +501,44 @@ 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, allowed_tools) if use_tools else None
# 创建事件队列和后台工作任务
queue: asyncio.Queue = asyncio.Queue()
asyncio.create_task(
_chat_worker(queue, config, messages, system_prompt, tools, data)
) )
tools = _get_tools(config.provider) if use_tools else None
async def generate(): async def generate():
nonlocal messages """SSE 生成器:从队列读取事件。客户端断开时后台任务继续运行。"""
full_response = data.assistant_text or ""
usage_info: dict = {}
# 记录本次对话中的工具调用摘要(用于保存到 DB
tool_call_logs: list[str] = []
try: try:
# ── 阶段一:如果有待审批的工具调用,先执行它们 ── while True:
if data.pending_tool_calls: event = await queue.get()
tool_calls = [ if event is None:
ToolCall(id=tc.id, name=tc.name, arguments=tc.arguments)
for tc in data.pending_tool_calls
]
# 构建 assistant 消息(含工具调用)
assistant_msg = build_assistant_message(
config.provider, data.assistant_text or "", tool_calls
)
messages.append(assistant_msg)
# 执行每个工具,发送结果
tool_results = []
for tc in tool_calls:
label = TOOL_LABELS.get(tc.name, tc.name)
result = execute_tool(tc.name, tc.arguments, data.novel_id)
tool_results.append({"id": tc.id, "result": result})
tool_call_logs.append(f"[工具调用: {label}] 参数: {json.dumps(tc.arguments, ensure_ascii=False)} → 结果: {result}")
yield _sse({"tool_result": {
"name": tc.name,
"label": label,
"result": result,
}})
# 追加工具结果消息
result_msgs = build_tool_results_messages(config.provider, tool_results)
messages.extend(result_msgs)
# ── 阶段二LLM 循环 ──
for _round in range(MAX_TOOL_ROUNDS):
round_text = ""
round_tool_calls = []
async for event in stream_chat(config, messages, system_prompt, tools):
if event.text:
round_text += event.text
yield _sse({"content": event.text})
if event.usage:
for k, v in event.usage.items():
if v:
usage_info[k] = usage_info.get(k, 0) + v
if event.tool_calls:
round_tool_calls = event.tool_calls
# 没有工具调用 → 完成
if not round_tool_calls:
full_response += round_text
break break
yield _sse(event)
# 有工具调用 except (asyncio.CancelledError, GeneratorExit):
full_response += round_text # 客户端断开连接 — 后台任务继续运行,最终会保存到 DB
calls_data = [ pass
{
"id": tc.id,
"name": tc.name,
"label": TOOL_LABELS.get(tc.name, tc.name),
"arguments": tc.arguments,
}
for tc in round_tool_calls
]
if data.auto_approve_tools:
# 自动执行模式:直接执行工具,不暂停
yield _sse({"tool_calls_auto": calls_data})
assistant_msg = build_assistant_message(
config.provider, full_response, round_tool_calls
)
messages.append(assistant_msg)
tool_results = []
for tc in round_tool_calls:
label = TOOL_LABELS.get(tc.name, tc.name)
result = execute_tool(tc.name, tc.arguments, data.novel_id)
tool_results.append({"id": tc.id, "result": result})
tool_call_logs.append(f"[工具调用: {label}] 参数: {json.dumps(tc.arguments, ensure_ascii=False)} → 结果: {result}")
yield _sse({"tool_result": {
"name": tc.name,
"label": label,
"result": result,
}})
result_msgs = build_tool_results_messages(config.provider, tool_results)
messages.extend(result_msgs)
# 继续循环,让 LLM 基于工具结果生成回复
continue
else:
# 审批模式:暂停,发给前端审批
yield _sse({
"tool_calls_pending": calls_data,
"assistant_text": full_response,
})
yield _sse({"done": True, "pending": True, "usage": usage_info or None})
return # 暂停,不保存到 DB
except Exception as e:
yield _sse({"error": str(e)})
finally:
# 无论连接是否断开,都保存已有的回复和工具调用到 DB
# 这确保刷新页面后 loadHistory 能恢复正确状态
_save_assistant_message(data.novel_id, full_response, tool_call_logs)
yield _sse({"done": True, "usage": usage_info or None})
return StreamingResponse( return StreamingResponse(
generate(), generate(),
@@ -355,18 +622,16 @@ async def compress_context(
if len(all_msgs) <= KEEP_RECENT_ROUNDS * 2: if len(all_msgs) <= KEEP_RECENT_ROUNDS * 2:
return {"compressed": False, "reason": "消息太少,无需压缩"} return {"compressed": False, "reason": "消息太少,无需压缩"}
# 分割:旧消息(需要压缩+ 最近消息(保留原文) # 分割:旧消息(将被删除+ 最近消息(保留原文)
keep_count = KEEP_RECENT_ROUNDS * 2 keep_count = KEEP_RECENT_ROUNDS * 2
old_msgs = all_msgs[:-keep_count] old_msgs = all_msgs[:-keep_count]
recent_msgs = all_msgs[-keep_count:] recent_msgs = all_msgs[-keep_count:]
# 检查第一条旧消息是否已是摘要(避免重复压缩无效内容) # 将 **所有消息**(包括最近保留的)都给 LLM 看,确保摘要完整准确
# 构建需要总结的对话文本
conversation_text = "" conversation_text = ""
for msg in old_msgs: for msg in all_msgs:
role_label = "用户" if msg.role == "user" else "AI助手" role_label = "用户" if msg.role == "user" else "AI助手"
content = msg.content content = msg.content
# 如果是已压缩的摘要,标注出来
if content.startswith("[对话摘要]"): if content.startswith("[对话摘要]"):
conversation_text += f"[之前的摘要]{content[5:]}\n\n" conversation_text += f"[之前的摘要]{content[5:]}\n\n"
else: else:
@@ -374,10 +639,11 @@ async def compress_context(
# 调用 LLM 生成摘要 # 调用 LLM 生成摘要
summary_prompt = ( summary_prompt = (
"你是一个对话摘要助手。请将以下对话历史压缩为简洁的摘要,保留关键信息:\n" "你是一个对话摘要助手。请将以下完整对话历史压缩为简洁的摘要,保留关键信息:\n"
"1. 用户提出的核心需求和决策\n" "1. 用户提出的核心需求和决策\n"
"2. AI 执行的重要操作及结果(如创建/修改了哪些大纲、角色、章节等)\n" "2. AI 执行的重要操作及结果(如创建/修改了哪些大纲、角色、章节等)\n"
"3. 达成的共识和待办事项\n\n" "3. 达成的共识和待办事项\n"
"4. 当前工作进展和下一步计划\n\n"
"只输出摘要内容,不要加前缀或解释。用简洁的条目列表形式。" "只输出摘要内容,不要加前缀或解释。用简洁的条目列表形式。"
) )
summary_messages = [ summary_messages = [
@@ -393,12 +659,14 @@ async def compress_context(
for msg in old_msgs: for msg in old_msgs:
session.delete(msg) session.delete(msg)
# 插入摘要消息时间设为最早保留消息之前) # 插入摘要消息时间设为保留消息之前(确保排在最前面
from datetime import timedelta
earliest_kept = recent_msgs[0].created_at
summary_msg = ChatMessage( summary_msg = ChatMessage(
novel_id=novel_id, novel_id=novel_id,
role="assistant", role="assistant",
content=f"[对话摘要]\n{summary}", content=f"[对话摘要]\n{summary}",
created_at=recent_msgs[0].created_at, created_at=earliest_kept - timedelta(seconds=1),
) )
session.add(summary_msg) session.add(summary_msg)
session.commit() session.commit()

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

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 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 无关格式)──
@@ -33,12 +34,23 @@ TOOL_DEFINITIONS: list[dict] = [
}, },
{ {
"name": "list_outlines", "name": "list_outlines",
"description": "列出当前小说的所有大纲节点,返回每个节点的 id、排序摘要和详情", "description": "列出当前小说的所有大纲节点(仅返回 id、排序摘要标题,不含详情)。如需查看某节点的详情内容,请使用 get_outline_detail 工具。",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": {}, "properties": {},
}, },
}, },
{
"name": "get_outline_detail",
"description": "查看指定大纲节点的完整详情内容",
"parameters": {
"type": "object",
"properties": {
"outline_id": {"type": "integer", "description": "大纲节点 ID"},
},
"required": ["outline_id"],
},
},
{ {
"name": "create_outline", "name": "create_outline",
"description": "为当前小说创建一个新的大纲节点", "description": "为当前小说创建一个新的大纲节点",
@@ -117,12 +129,23 @@ TOOL_DEFINITIONS: list[dict] = [
# ── 角色工具 ── # ── 角色工具 ──
{ {
"name": "list_characters", "name": "list_characters",
"description": "列出当前小说的所有角色,返回每个角色的 id、名称和描述", "description": "列出当前小说的所有角色(仅返回 id 和名称,不含描述详情)。如需查看某角色的完整描述,请使用 get_character_detail 工具。",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": {}, "properties": {},
}, },
}, },
{
"name": "get_character_detail",
"description": "查看指定角色的完整描述信息",
"parameters": {
"type": "object",
"properties": {
"character_id": {"type": "integer", "description": "角色 ID"},
},
"required": ["character_id"],
},
},
{ {
"name": "create_character", "name": "create_character",
"description": "为当前小说创建一个新角色", "description": "为当前小说创建一个新角色",
@@ -162,12 +185,23 @@ TOOL_DEFINITIONS: list[dict] = [
# ── 章节工具 ── # ── 章节工具 ──
{ {
"name": "list_chapters", "name": "list_chapters",
"description": "列出当前小说的所有章节,按排序顺序返回每个章节的 id、标题和内容摘要", "description": "列出当前小说的所有章节(仅返回 id、排序和标题不含正文。如需查看某章节的完整正文请使用 get_chapter_detail 工具。",
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": {}, "properties": {},
}, },
}, },
{
"name": "get_chapter_detail",
"description": "查看指定章节的完整正文内容",
"parameters": {
"type": "object",
"properties": {
"chapter_id": {"type": "integer", "description": "章节 ID"},
},
"required": ["chapter_id"],
},
},
{ {
"name": "create_chapter", "name": "create_chapter",
"description": "为当前小说创建一个新章节", "description": "为当前小说创建一个新章节",
@@ -204,25 +238,81 @@ 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"],
},
},
{
"name": "dispatch_subagent",
"description": "派遣一个子 Agent 执行特定任务。子 Agent 拥有独立的系统提示词和工具集(由 Skill 定义),适合将复杂任务拆分为子任务分别处理。",
"parameters": {
"type": "object",
"properties": {
"skill_id": {"type": "integer", "description": "要使用的 Skill ID从可用 Skill 列表中选择)"},
"task": {"type": "string", "description": "交给子 Agent 的具体任务描述"},
},
"required": ["skill_id", "task"],
},
},
] ]
# dispatch_subagent 不由 execute_tool 处理,它在 chat.py 中特殊处理
SPECIAL_TOOLS = {"dispatch_subagent"}
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)
# ── 工具执行 ── # ── 工具执行 ──
@@ -233,6 +323,7 @@ def execute_tool(name: str, arguments: dict, novel_id: int) -> str:
"get_novel_info": _get_novel_info, "get_novel_info": _get_novel_info,
"update_novel_info": _update_novel_info, "update_novel_info": _update_novel_info,
"list_outlines": _list_outlines, "list_outlines": _list_outlines,
"get_outline_detail": _get_outline_detail,
"create_outline": _create_outline, "create_outline": _create_outline,
"update_outline": _update_outline, "update_outline": _update_outline,
"delete_outline": _delete_outline, "delete_outline": _delete_outline,
@@ -240,13 +331,17 @@ def execute_tool(name: str, arguments: dict, novel_id: int) -> str:
"get_world_setting": _get_world_setting, "get_world_setting": _get_world_setting,
"save_world_setting": _save_world_setting, "save_world_setting": _save_world_setting,
"list_characters": _list_characters, "list_characters": _list_characters,
"get_character_detail": _get_character_detail,
"create_character": _create_character, "create_character": _create_character,
"update_character": _update_character, "update_character": _update_character,
"delete_character": _delete_character, "delete_character": _delete_character,
"list_chapters": _list_chapters, "list_chapters": _list_chapters,
"get_chapter_detail": _get_chapter_detail,
"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:
@@ -295,13 +390,12 @@ def _update_novel_info(session: Session, novel_id: int, args: dict) -> str:
def _list_outlines(session: Session, novel_id: int, _args: dict) -> str: def _list_outlines(session: Session, novel_id: int, _args: dict) -> str:
# 查询顶级节点 """列表仅返回 id + 排序 + 摘要标题(不含 detail节省上下文。"""
top_items = session.exec( top_items = session.exec(
select(Outline) select(Outline)
.where(Outline.novel_id == novel_id, Outline.parent_id.is_(None)) # type: ignore .where(Outline.novel_id == novel_id, Outline.parent_id.is_(None)) # type: ignore
.order_by(Outline.sort_order) .order_by(Outline.sort_order)
).all() ).all()
# 查询所有子节点,按 parent_id 分组
all_children = session.exec( all_children = session.exec(
select(Outline) select(Outline)
.where(Outline.novel_id == novel_id, Outline.parent_id.isnot(None)) # type: ignore .where(Outline.novel_id == novel_id, Outline.parent_id.isnot(None)) # type: ignore
@@ -313,17 +407,28 @@ def _list_outlines(session: Session, novel_id: int, _args: dict) -> str:
result = [] result = []
for o in top_items: for o in top_items:
node = {"id": o.id, "sort_order": o.sort_order, "summary": o.summary, "detail": o.detail} node: dict = {"id": o.id, "sort_order": o.sort_order, "summary": o.summary}
kids = children_map.get(o.id, []) kids = children_map.get(o.id, [])
if kids: if kids:
node["children"] = [ node["children"] = [
{"id": c.id, "sort_order": c.sort_order, "summary": c.summary, "detail": c.detail, "parent_id": c.parent_id} {"id": c.id, "sort_order": c.sort_order, "summary": c.summary, "parent_id": c.parent_id}
for c in kids for c in kids
] ]
result.append(node) result.append(node)
return json.dumps({"outlines": result, "total": len(result)}, ensure_ascii=False) return json.dumps({"outlines": result, "total": len(result)}, ensure_ascii=False)
def _get_outline_detail(session: Session, novel_id: int, args: dict) -> str:
outline = session.get(Outline, args["outline_id"])
if not outline or outline.novel_id != novel_id:
return json.dumps({"error": "大纲不存在"}, ensure_ascii=False)
return json.dumps({
"id": outline.id, "sort_order": outline.sort_order,
"summary": outline.summary, "detail": outline.detail,
"parent_id": outline.parent_id,
}, ensure_ascii=False)
def _create_outline(session: Session, novel_id: int, args: dict) -> str: def _create_outline(session: Session, novel_id: int, args: dict) -> str:
parent_id = args.get("parent_id") parent_id = args.get("parent_id")
@@ -454,18 +559,25 @@ def _save_world_setting(session: Session, novel_id: int, args: dict) -> str:
def _list_characters(session: Session, novel_id: int, _args: dict) -> str: def _list_characters(session: Session, novel_id: int, _args: dict) -> str:
"""列表仅返回 id + 名称(不含 description节省上下文。"""
items = session.exec( items = session.exec(
select(Character) select(Character)
.where(Character.novel_id == novel_id) .where(Character.novel_id == novel_id)
.order_by(Character.created_at) .order_by(Character.created_at)
).all() ).all()
result = [ result = [{"id": c.id, "name": c.name} for c in items]
{"id": c.id, "name": c.name, "description": c.description}
for c in items
]
return json.dumps({"characters": result, "total": len(result)}, ensure_ascii=False) return json.dumps({"characters": result, "total": len(result)}, ensure_ascii=False)
def _get_character_detail(session: Session, novel_id: int, args: dict) -> str:
char = session.get(Character, args["character_id"])
if not char or char.novel_id != novel_id:
return json.dumps({"error": "角色不存在"}, ensure_ascii=False)
return json.dumps({
"id": char.id, "name": char.name, "description": char.description,
}, ensure_ascii=False)
def _create_character(session: Session, novel_id: int, args: dict) -> str: def _create_character(session: Session, novel_id: int, args: dict) -> str:
name = args["name"].strip() name = args["name"].strip()
if not name or len(name) > 100: if not name or len(name) > 100:
@@ -520,23 +632,26 @@ def _delete_character(session: Session, novel_id: int, args: dict) -> str:
def _list_chapters(session: Session, novel_id: int, _args: dict) -> str: def _list_chapters(session: Session, novel_id: int, _args: dict) -> str:
"""列表仅返回 id + 排序 + 标题(不含正文),节省上下文。"""
items = session.exec( items = session.exec(
select(Chapter) select(Chapter)
.where(Chapter.novel_id == novel_id) .where(Chapter.novel_id == novel_id)
.order_by(Chapter.sort_order) .order_by(Chapter.sort_order)
).all() ).all()
result = [ result = [{"id": c.id, "sort_order": c.sort_order, "title": c.title} for c in items]
{
"id": c.id,
"sort_order": c.sort_order,
"title": c.title,
"content_preview": c.content[:100] + "" if len(c.content) > 100 else c.content,
}
for c in items
]
return json.dumps({"chapters": result, "total": len(result)}, ensure_ascii=False) return json.dumps({"chapters": result, "total": len(result)}, ensure_ascii=False)
def _get_chapter_detail(session: Session, novel_id: int, args: dict) -> str:
chapter = session.get(Chapter, args["chapter_id"])
if not chapter or chapter.novel_id != novel_id:
return json.dumps({"error": "章节不存在"}, ensure_ascii=False)
return json.dumps({
"id": chapter.id, "sort_order": chapter.sort_order,
"title": chapter.title, "content": chapter.content,
}, ensure_ascii=False)
def _create_chapter(session: Session, novel_id: int, args: dict) -> str: def _create_chapter(session: Session, novel_id: int, args: dict) -> str:
title = args["title"].strip() title = args["title"].strip()
if not title or len(title) > 200: if not title or len(title) > 200:
@@ -593,3 +708,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)

View File

@@ -1,3 +1,7 @@
fastapi>=0.115.0 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
python-multipart>=0.0.9
python-docx>=1.1.0
pymupdf>=1.24.0

View File

@@ -1,9 +1,10 @@
services: services:
noval: noval:
build: . build: .
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:

View File

@@ -70,12 +70,32 @@ export interface PendingToolCallData {
arguments: Record<string, unknown> arguments: Record<string, unknown>
} }
export interface SubAgentStartInfo {
skill_id: number
skill_name: string
task: string
subagent_id: string
}
export interface SubAgentToolCallInfo {
name: string
label: string
arguments: Record<string, unknown>
}
export interface SubAgentToolResultInfo {
name: string
label: string
result: string
}
export interface StreamChatOptions { export interface StreamChatOptions {
messages: { role: string; content: string }[] messages: { role: string; content: string }[]
novelId?: number novelId?: number
pageContext?: string pageContext?: string
toolsEnabled?: boolean toolsEnabled?: boolean
autoApproveTools?: boolean autoApproveTools?: boolean
skillId?: number | null
// 工具审批续传 // 工具审批续传
pendingToolCalls?: PendingToolCallData[] pendingToolCalls?: PendingToolCallData[]
assistantText?: string assistantText?: string
@@ -86,14 +106,21 @@ export interface StreamChatOptions {
onToolCallsPending?: (calls: PendingToolCallData[], assistantText: string) => void onToolCallsPending?: (calls: PendingToolCallData[], assistantText: string) => void
onToolCallsAuto?: (calls: PendingToolCallData[]) => void onToolCallsAuto?: (calls: PendingToolCallData[]) => void
onToolResult?: (info: ToolResultInfo) => void onToolResult?: (info: ToolResultInfo) => void
// 子 Agent 回调subagentId 用于区分并行的多个子 Agent
onSubAgentStart?: (info: SubAgentStartInfo) => void
onSubAgentChunk?: (text: string, subagentId: string) => void
onSubAgentToolCall?: (info: SubAgentToolCallInfo, subagentId: string) => void
onSubAgentToolResult?: (info: SubAgentToolResultInfo, subagentId: string) => void
onSubAgentDone?: (result: string, subagentId: string) => void
signal?: AbortSignal signal?: AbortSignal
} }
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,
onSubAgentStart, onSubAgentChunk, onSubAgentToolCall, onSubAgentToolResult, onSubAgentDone,
signal, signal,
} = options } = options
@@ -103,6 +130,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,
}) })
@@ -158,6 +186,22 @@ export async function streamChat(options: StreamChatOptions): Promise<void> {
if (event.tool_result && onToolResult) { if (event.tool_result && onToolResult) {
onToolResult(event.tool_result) onToolResult(event.tool_result)
} }
// 子 Agent 事件(所有事件携带 subagent_id 用于区分并行的子 Agent
if (event.subagent_start && onSubAgentStart) {
onSubAgentStart(event.subagent_start)
}
if (event.subagent_chunk && onSubAgentChunk) {
onSubAgentChunk(event.subagent_chunk, event.subagent_id ?? '')
}
if (event.subagent_tool_call && onSubAgentToolCall) {
onSubAgentToolCall(event.subagent_tool_call, event.subagent_id ?? '')
}
if (event.subagent_tool_result && onSubAgentToolResult) {
onSubAgentToolResult(event.subagent_tool_result, event.subagent_id ?? '')
}
if (event.subagent_done !== undefined && onSubAgentDone) {
onSubAgentDone(event.subagent_done, event.subagent_id ?? '')
}
} catch { } catch {
// 忽略解析错误 // 忽略解析错误
} }

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

@@ -1,13 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, nextTick, watch, onMounted } from 'vue' import { ref, computed, nextTick, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
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 { isToolCallGroup } from '@/types/chat' import { uploadFile } from '@/api/files'
import { listSkills } from '@/api/skills'
import type { Skill } from '@/api/skills'
import { isToolCallGroup, isSubAgentEntry } 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 ChatSubAgent from './ChatSubAgent.vue'
import ChatConfigModal from './ChatConfigModal.vue' import ChatConfigModal from './ChatConfigModal.vue'
import ChatFiles from './ChatFiles.vue'
import SkillSelector from './SkillSelector.vue'
const props = defineProps<{ const props = defineProps<{
open: boolean open: boolean
@@ -19,15 +26,131 @@ 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()
const router = useRouter()
const input = ref('') 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 skillSelectorRef = ref<InstanceType<typeof SkillSelector>>()
const fileInputRef = ref<HTMLInputElement>()
const isUploading = ref(false)
// ── 斜杠命令 ──
const showSlashMenu = ref(false)
const slashFilter = ref('')
const slashSelectedIndex = ref(0)
const cachedSkills = ref<Skill[]>([])
interface SlashCommand {
id: string
label: string
desc: string
icon: string
type: 'action' | 'skill'
skillId?: number
}
// 内置命令
const BUILTIN_COMMANDS: SlashCommand[] = [
{ id: 'clear', label: '清空对话', desc: '清除当前对话历史', icon: 'M2 4h12M5 4V2.5h6V4M3.5 4v9.5h9V4', type: 'action' },
{ id: 'compress', label: '压缩上下文', desc: '总结旧消息释放空间', icon: 'M4 2v4l4-2-4-2zM12 14v-4l-4 2 4 2zM2 8h12', type: 'action' },
{ id: 'skills', label: '管理技能', desc: '打开技能管理页面', icon: 'M8 1l2.5 5H14l-4 3.5 1.5 5.5L8 12l-3.5 3 1.5-5.5L2 6h3.5z', type: 'action' },
{ id: 'files', label: '文件工作区', desc: '管理上传的文件', icon: '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', type: 'action' },
{ id: 'config', label: 'AI 配置', desc: '修改 AI 服务设置', icon: 'M8 8m-2.5 0a2.5 2.5 0 105 0 2.5 2.5 0 10-5 0M8 1v2M8 13v2M1 8h2M13 8h2', type: 'action' },
]
const slashCommands = computed<SlashCommand[]>(() => {
const skillCmds: SlashCommand[] = cachedSkills.value.map(s => ({
id: `skill-${s.id}`,
label: s.name,
desc: s.description || '切换到此技能',
icon: 'M8 1l2.5 5H14l-4 3.5 1.5 5.5L8 12l-3.5 3 1.5-5.5L2 6h3.5z',
type: 'skill' as const,
skillId: s.id,
}))
const all = [...BUILTIN_COMMANDS, ...skillCmds]
if (!slashFilter.value) return all
const q = slashFilter.value.toLowerCase()
return all.filter(c => c.label.toLowerCase().includes(q) || c.id.toLowerCase().includes(q))
})
function onInputChange() {
const val = input.value
if (val.startsWith('/')) {
slashFilter.value = val.slice(1)
showSlashMenu.value = true
slashSelectedIndex.value = 0
} else {
showSlashMenu.value = false
}
}
function executeCommand(cmd: SlashCommand) {
showSlashMenu.value = false
input.value = ''
if (cmd.type === 'skill') {
activeSkillId.value = cmd.skillId ?? null
skillSelectorRef.value?.loadSkills()
toast.success(`已切换技能:${cmd.label}`)
return
}
switch (cmd.id) {
case 'clear':
clearChat()
toast.success('对话已清空')
break
case 'compress':
handleCompress()
break
case 'skills':
router.push('/skills')
break
case 'files':
if (currentNovelId.value) showFiles.value = true
else toast.info('请先进入一部小说')
break
case 'config':
showConfig.value = true
break
}
}
function handleSlashKeydown(e: KeyboardEvent) {
if (!showSlashMenu.value) return
const cmds = slashCommands.value
if (e.key === 'ArrowDown') {
e.preventDefault()
slashSelectedIndex.value = Math.min(slashSelectedIndex.value + 1, cmds.length - 1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
slashSelectedIndex.value = Math.max(slashSelectedIndex.value - 1, 0)
} else if (e.key === 'Enter' && cmds.length > 0) {
e.preventDefault()
executeCommand(cmds[slashSelectedIndex.value])
} else if (e.key === 'Escape') {
showSlashMenu.value = false
}
}
// 加载技能列表用于斜杠命令
async function loadCachedSkills() {
try {
const result = await listSkills(currentNovelId.value)
cachedSkills.value = result.items
} catch { /* 静默 */ }
}
// 允许的文件扩展名
const ALLOWED_EXTS = ['.txt', '.md', '.docx', '.pdf']
const MAX_SIZE = 10 * 1024 * 1024 // 10MB
// Token用量 // Token用量
const DEFAULT_CONTEXT = 128000 const DEFAULT_CONTEXT = 128000
@@ -89,6 +212,11 @@ async function handleSend() {
} }
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
// 斜杠命令键盘导航
if (showSlashMenu.value) {
handleSlashKeydown(e)
return
}
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault() e.preventDefault()
handleSend() handleSend()
@@ -103,6 +231,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) => {
@@ -111,7 +279,11 @@ watch(() => props.open, (isOpen) => {
onMounted(() => { onMounted(() => {
loadHistory() loadHistory()
loadCachedSkills()
}) })
// 小说切换时刷新技能缓存
watch(currentNovelId, () => loadCachedSkills())
</script> </script>
<template> <template>
@@ -139,6 +311,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 +348,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="router.push('/skills')"
/>
<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" />
@@ -207,6 +396,11 @@ onMounted(() => {
@approve="handleApprove(entry as ToolCallGroup)" @approve="handleApprove(entry as ToolCallGroup)"
@skip="handleSkip(entry as ToolCallGroup)" @skip="handleSkip(entry as ToolCallGroup)"
/> />
<!-- Agent -->
<ChatSubAgent
v-else-if="isSubAgentEntry(entry)"
:entry="entry"
/>
<!-- 对话摘要压缩后的历史总结 --> <!-- 对话摘要压缩后的历史总结 -->
<div <div
v-else-if="entry.role === 'assistant' && entry.content.startsWith('[对话摘要]')" v-else-if="entry.role === 'assistant' && entry.content.startsWith('[对话摘要]')"
@@ -240,18 +434,75 @@ onMounted(() => {
<div v-if="isStreaming && !streamingContent" class="typing-indicator"> <div v-if="isStreaming && !streamingContent" class="typing-indicator">
<span /><span /><span /> <span /><span /><span />
</div> </div>
<!-- 压缩中提示 -->
<div v-if="isCompressing" class="compress-indicator">
<svg class="compress-spinner" width="16" height="16" viewBox="0 0 16 16" fill="none">
<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>
<span>正在压缩上下文总结旧消息中...</span>
</div>
</div> </div>
<!-- 输入区 --> <!-- 输入区 -->
<div class="panel-input"> <div class="panel-input">
<textarea <!-- 附件上传按钮 -->
v-model="input" <button
class="input-field" v-if="currentNovelId"
placeholder="输入消息..." class="icon-btn attach-btn"
rows="1" :class="{ 'icon-btn--uploading': isUploading }"
:disabled="isStreaming || hasPendingTools" :title="isUploading ? '上传中...' : '上传文件txt/md/docx/pdf'"
@keydown="handleKeydown" :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"
/> />
<div class="input-wrapper">
<!-- 斜杠命令菜单 -->
<Transition name="slash">
<div v-if="showSlashMenu && slashCommands.length > 0" class="slash-menu">
<div class="slash-header">命令</div>
<div
v-for="(cmd, idx) in slashCommands"
:key="cmd.id"
class="slash-item"
:class="{
'slash-item--active': idx === slashSelectedIndex,
'slash-item--skill': cmd.type === 'skill',
}"
@mouseenter="slashSelectedIndex = idx"
@click="executeCommand(cmd)"
>
<svg class="slash-icon" width="14" height="14" viewBox="0 0 16 16" fill="none">
<path :d="cmd.icon" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<div class="slash-info">
<span class="slash-label">{{ cmd.type === 'skill' ? '' : '/' }}{{ cmd.label }}</span>
<span class="slash-desc">{{ cmd.desc }}</span>
</div>
<span v-if="cmd.type === 'skill'" class="slash-badge">技能</span>
</div>
</div>
</Transition>
<textarea
v-model="input"
class="input-field"
placeholder="输入消息... 输入 / 查看命令"
rows="1"
:disabled="isStreaming || hasPendingTools"
@keydown="handleKeydown"
@input="onInputChange"
/>
</div>
<button <button
v-if="!isStreaming" v-if="!isStreaming"
class="send-btn" class="send-btn"
@@ -274,6 +525,7 @@ 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" />
</template> </template>
<style scoped> <style scoped>
@@ -487,6 +739,135 @@ onMounted(() => {
30% { transform: translateY(-6px); } 30% { transform: translateY(-6px); }
} }
.compress-indicator {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: rgba(91, 140, 90, 0.08);
border: 1px solid rgba(91, 140, 90, 0.2);
border-radius: var(--radius-md);
color: var(--bamboo);
font-size: 0.78rem;
animation: fadeInUp var(--duration-fast) var(--ease-out-smooth) both;
}
.compress-spinner {
animation: compressSpin 1.5s linear infinite;
flex-shrink: 0;
}
@keyframes compressSpin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 输入区包装 */
.input-wrapper {
flex: 1;
position: relative;
min-width: 0;
}
/* 斜杠命令菜单 */
.slash-menu {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
right: 0;
background: var(--paper-50);
border: 1px solid var(--paper-200);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
max-height: 280px;
overflow-y: auto;
z-index: 1100;
}
.slash-header {
padding: 6px 12px;
font-size: 0.68rem;
font-weight: 600;
color: var(--ink-400);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--paper-200);
}
.slash-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
transition: background var(--duration-fast) ease;
}
.slash-item:hover,
.slash-item--active {
background: var(--paper-100);
}
.slash-item--skill .slash-icon {
color: var(--bamboo);
}
.slash-icon {
flex-shrink: 0;
color: var(--ink-400);
}
.slash-info {
flex: 1;
min-width: 0;
}
.slash-label {
font-size: 0.82rem;
font-weight: 600;
color: var(--ink-800);
display: block;
}
.slash-desc {
font-size: 0.7rem;
color: var(--ink-400);
display: block;
margin-top: 1px;
}
.slash-badge {
font-size: 0.6rem;
padding: 1px 5px;
border-radius: 8px;
background: rgba(91, 140, 90, 0.12);
color: var(--bamboo);
font-weight: 500;
flex-shrink: 0;
}
.slash-enter-active,
.slash-leave-active {
transition: all var(--duration-fast) ease;
}
.slash-enter-from,
.slash-leave-to {
opacity: 0;
transform: translateY(4px);
}
/* 附件上传按钮 */
.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;
@@ -498,7 +879,7 @@ onMounted(() => {
} }
.input-field { .input-field {
flex: 1; width: 100%;
padding: var(--space-sm) var(--space-md); padding: var(--space-sm) var(--space-md);
font-size: 0.9rem; font-size: 0.9rem;
font-family: var(--font-body); font-family: var(--font-body);

View File

@@ -0,0 +1,279 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { SubAgentEntry } from '@/types/chat'
const props = defineProps<{
entry: SubAgentEntry
}>()
const isRunning = computed(() => props.entry.status === 'running')
const isDone = computed(() => props.entry.status === 'done')
const expanded = ref(true)
// 截取输出文本预览
const outputPreview = computed(() => {
const text = props.entry.chunks
if (!text) return ''
if (text.length <= 200) return text
return text.slice(0, 200) + '...'
})
const hasToolCalls = computed(() => props.entry.toolCalls.length > 0)
const completedTools = computed(() =>
props.entry.toolCalls.filter(c => c.result).length
)
</script>
<template>
<div class="subagent-card" :class="{ 'subagent-card--running': isRunning }">
<!-- 头部 -->
<div class="subagent-header" @click="expanded = !expanded">
<div class="subagent-icon">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="5" r="3" stroke="currentColor" stroke-width="1.3" />
<path d="M2 14c0-3.3 2.7-6 6-6s6 2.7 6 6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<path d="M12 3l2 2-2 2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<div class="subagent-info">
<span class="subagent-name">{{ entry.skillName }}</span>
<span v-if="isRunning" class="subagent-badge badge--running">运行中</span>
<span v-else-if="isDone" class="subagent-badge badge--done">已完成</span>
</div>
<button class="subagent-toggle" :title="expanded ? '收起' : '展开'">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path
:d="expanded ? 'M3 7.5l3-3 3 3' : 'M3 4.5l3 3 3-3'"
stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"
/>
</svg>
</button>
</div>
<!-- 任务描述 -->
<div class="subagent-task">{{ entry.task }}</div>
<!-- 展开内容 -->
<div v-if="expanded" class="subagent-body">
<!-- 工具调用列表 -->
<div v-if="hasToolCalls" class="subagent-tools">
<div class="subagent-tools-header">
<span class="tools-label">工具调用</span>
<span class="tools-count">{{ completedTools }}/{{ entry.toolCalls.length }}</span>
</div>
<div
v-for="call in entry.toolCalls"
:key="call.id"
class="subagent-tool-item"
>
<span class="tool-dot" :class="{ 'tool-dot--done': call.result }" />
<span class="tool-name">{{ call.label }}</span>
<span v-if="call.result" class="tool-check"></span>
</div>
</div>
<!-- Agent 输出 -->
<div v-if="entry.chunks" class="subagent-output">
<div class="output-label">输出</div>
<div class="output-text">{{ isDone ? entry.chunks : outputPreview }}</div>
</div>
<!-- 运行中指示 -->
<div v-if="isRunning" class="subagent-running">
<span class="dot" /><span class="dot" /><span class="dot" />
</div>
</div>
</div>
</template>
<style scoped>
.subagent-card {
background: linear-gradient(135deg, var(--paper-50) 0%, rgba(91, 140, 90, 0.04) 100%);
border: 1px solid rgba(91, 140, 90, 0.2);
border-radius: var(--radius-md);
padding: var(--space-sm) var(--space-md);
margin-bottom: var(--space-md);
animation: fadeInUp var(--duration-fast) var(--ease-out-smooth) both;
}
.subagent-card--running {
border-color: rgba(91, 140, 90, 0.35);
box-shadow: 0 0 0 1px rgba(91, 140, 90, 0.08);
}
.subagent-header {
display: flex;
align-items: center;
gap: var(--space-sm);
cursor: pointer;
user-select: none;
}
.subagent-icon {
color: var(--bamboo);
flex-shrink: 0;
}
.subagent-info {
display: flex;
align-items: center;
gap: var(--space-xs);
flex: 1;
min-width: 0;
}
.subagent-name {
font-size: 0.8rem;
font-weight: 600;
color: var(--bamboo);
}
.subagent-badge {
font-size: 0.65rem;
padding: 1px 6px;
border-radius: 8px;
font-weight: 500;
}
.badge--running {
background: rgba(91, 140, 90, 0.12);
color: var(--bamboo);
animation: toolPulse 1.5s ease-in-out infinite;
}
.badge--done {
background: rgba(91, 140, 90, 0.12);
color: var(--bamboo);
}
.subagent-toggle {
background: none;
border: none;
color: var(--ink-300);
cursor: pointer;
padding: 2px;
flex-shrink: 0;
}
.subagent-task {
font-size: 0.75rem;
color: var(--ink-400);
margin: var(--space-xs) 0;
padding-left: calc(16px + var(--space-sm));
line-height: 1.5;
}
.subagent-body {
padding-left: calc(16px + var(--space-sm));
margin-top: var(--space-xs);
}
.subagent-tools {
margin-bottom: var(--space-sm);
}
.subagent-tools-header {
display: flex;
align-items: center;
gap: var(--space-xs);
margin-bottom: var(--space-xs);
}
.tools-label {
font-size: 0.68rem;
color: var(--ink-300);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.tools-count {
font-size: 0.65rem;
color: var(--ink-300);
background: var(--paper-100);
padding: 0 4px;
border-radius: 4px;
}
.subagent-tool-item {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: 2px 0;
}
.tool-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--paper-300);
flex-shrink: 0;
transition: background var(--duration-fast);
}
.tool-dot--done {
background: var(--bamboo);
}
.tool-name {
font-size: 0.72rem;
color: var(--ink-500);
}
.tool-check {
font-size: 0.68rem;
color: var(--bamboo);
}
.subagent-output {
margin-top: var(--space-xs);
}
.output-label {
font-size: 0.68rem;
color: var(--ink-300);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 2px;
}
.output-text {
font-size: 0.78rem;
color: var(--ink-600);
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow-y: auto;
background: var(--paper-100);
border-radius: var(--radius-sm);
padding: var(--space-xs) var(--space-sm);
}
/* 运行中动画 */
.subagent-running {
display: flex;
gap: 3px;
padding: var(--space-xs) 0;
}
.subagent-running .dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--bamboo);
animation: toolDotBounce 1.4s infinite ease-in-out both;
}
.subagent-running .dot:nth-child(2) { animation-delay: 0.16s; }
.subagent-running .dot:nth-child(3) { animation-delay: 0.32s; }
@keyframes toolPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes toolDotBounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
</style>

View File

@@ -20,6 +20,7 @@ const isSkipped = computed(() => props.group.status === 'skipped')
const TOOL_ICONS: Record<string, string> = { const TOOL_ICONS: Record<string, string> = {
// 大纲 // 大纲
list_outlines: 'M3 4h10M3 8h10M3 12h6', list_outlines: 'M3 4h10M3 8h10M3 12h6',
get_outline_detail: 'M4 2h8v12H4zM6 5h4M6 8h4',
create_outline: 'M8 3v10M3 8h10', create_outline: 'M8 3v10M3 8h10',
update_outline: 'M3 12l2-2 4 4-2 2H3v-4zM9 6l2-2 4 4-2 2-4-4z', update_outline: 'M3 12l2-2 4 4-2 2H3v-4zM9 6l2-2 4 4-2 2-4-4z',
delete_outline: 'M3 5h10M5 5V3h6v2M4 5v8h8V5', delete_outline: 'M3 5h10M5 5V3h6v2M4 5v8h8V5',
@@ -29,20 +30,26 @@ const TOOL_ICONS: Record<string, string> = {
save_world_setting: 'M8 1a7 7 0 100 14A7 7 0 008 1zM4 8l3 3 5-5', save_world_setting: 'M8 1a7 7 0 100 14A7 7 0 008 1zM4 8l3 3 5-5',
// 角色 // 角色
list_characters: 'M5.5 5a2.5 2.5 0 105 0 2.5 2.5 0 00-5 0zM3 13c0-2.5 2-4 5-4s5 1.5 5 4', list_characters: 'M5.5 5a2.5 2.5 0 105 0 2.5 2.5 0 00-5 0zM3 13c0-2.5 2-4 5-4s5 1.5 5 4',
get_character_detail: 'M5.5 5a2.5 2.5 0 105 0 2.5 2.5 0 00-5 0zM3 13c0-2.5 2-4 5-4s5 1.5 5 4M11 3h3v3',
create_character: 'M5 5a2.5 2.5 0 105 0 2.5 2.5 0 00-5 0zM2 13c0-2.5 2-4 5-4M12 6v5M9.5 8.5h5', create_character: 'M5 5a2.5 2.5 0 105 0 2.5 2.5 0 00-5 0zM2 13c0-2.5 2-4 5-4M12 6v5M9.5 8.5h5',
update_character: 'M5 5a2.5 2.5 0 105 0 2.5 2.5 0 00-5 0zM3 13c0-2.5 2-4 5-4M10 10l2-2 2 2-2 2z', update_character: 'M5 5a2.5 2.5 0 105 0 2.5 2.5 0 00-5 0zM3 13c0-2.5 2-4 5-4M10 10l2-2 2 2-2 2z',
delete_character: 'M5 5a2.5 2.5 0 105 0 2.5 2.5 0 00-5 0zM3 13c0-2.5 2-4 5-4M10 8l4 4M14 8l-4 4', delete_character: 'M5 5a2.5 2.5 0 105 0 2.5 2.5 0 00-5 0zM3 13c0-2.5 2-4 5-4M10 8l4 4M14 8l-4 4',
// 章节 // 章节
list_chapters: 'M4 2h8v12H4zM6 5h4M6 8h4M6 11h2', list_chapters: 'M4 2h8v12H4zM6 5h4M6 8h4M6 11h2',
get_chapter_detail: 'M4 2h8v12H4zM6 5h4M6 8h4M6 11h4',
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',
} }
// 工具颜色主题 // 工具颜色主题
const TOOL_COLORS: Record<string, string> = { const TOOL_COLORS: Record<string, string> = {
// 大纲 // 大纲
list_outlines: 'var(--bamboo)', list_outlines: 'var(--bamboo)',
get_outline_detail: 'var(--bamboo)',
create_outline: 'var(--bamboo)', create_outline: 'var(--bamboo)',
update_outline: '#d4a017', update_outline: '#d4a017',
delete_outline: 'var(--danger)', delete_outline: 'var(--danger)',
@@ -52,14 +59,19 @@ const TOOL_COLORS: Record<string, string> = {
save_world_setting: 'var(--bamboo)', save_world_setting: 'var(--bamboo)',
// 角色 // 角色
list_characters: '#7c6bc4', list_characters: '#7c6bc4',
get_character_detail: '#7c6bc4',
create_character: '#7c6bc4', create_character: '#7c6bc4',
update_character: '#d4a017', update_character: '#d4a017',
delete_character: 'var(--danger)', delete_character: 'var(--danger)',
// 章节 // 章节
list_chapters: '#4a90d9', list_chapters: '#4a90d9',
get_chapter_detail: '#4a90d9',
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 {
@@ -76,6 +88,8 @@ function formatArgs(call: ToolCallEntry): string {
if (!args || Object.keys(args).length === 0) return '' if (!args || Object.keys(args).length === 0) return ''
switch (call.name) { switch (call.name) {
case 'get_outline_detail':
return `#${args.outline_id}`
case 'create_outline': case 'create_outline':
return args.summary as string ?? '' return args.summary as string ?? ''
case 'update_outline': case 'update_outline':
@@ -91,6 +105,8 @@ function formatArgs(call: ToolCallEntry): string {
return content.length > 80 ? content.slice(0, 80) + '…' : content return content.length > 80 ? content.slice(0, 80) + '…' : content
} }
// 角色 // 角色
case 'get_character_detail':
return `#${args.character_id}`
case 'create_character': case 'create_character':
return args.name as string ?? '' return args.name as string ?? ''
case 'update_character': case 'update_character':
@@ -98,12 +114,17 @@ function formatArgs(call: ToolCallEntry): string {
case 'delete_character': case 'delete_character':
return `#${args.character_id}` return `#${args.character_id}`
// 章节 // 章节
case 'get_chapter_detail':
return `#${args.chapter_id}`
case 'create_chapter': case 'create_chapter':
return args.title as string ?? '' return args.title as string ?? ''
case 'update_chapter': case 'update_chapter':
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 +185,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)
} }

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', 'get_outline_detail', 'create_outline', 'update_outline', 'delete_outline', 'reorder_outlines'] },
{ label: '世界观', tools: ['get_world_setting', 'save_world_setting'] },
{ label: '角色管理', tools: ['list_characters', 'get_character_detail', 'create_character', 'update_character', 'delete_character'] },
{ label: '章节管理', tools: ['list_chapters', 'get_chapter_detail', '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

@@ -1,14 +1,15 @@
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { streamChat, fetchMessages, clearMessages as apiClearMessages, compressContext } from '@/api/chat' import { streamChat, fetchMessages, clearMessages as apiClearMessages, compressContext } from '@/api/chat'
import type { ChatMessage, ChatEntry, ToolCallGroup } from '@/types/chat' import type { ChatMessage, ChatEntry, ToolCallGroup, SubAgentEntry } from '@/types/chat'
import type { TokenUsage, PendingToolCallData } from '@/api/chat' import type { TokenUsage, PendingToolCallData, SubAgentStartInfo, SubAgentToolCallInfo, SubAgentToolResultInfo } from '@/api/chat'
// 中文标签 → 英文工具名(用于解析历史消息中的工具调用日志) // 中文标签 → 英文工具名(用于解析历史消息中的工具调用日志)
const LABEL_TO_TOOL_NAME: Record<string, string> = { const LABEL_TO_TOOL_NAME: Record<string, string> = {
'查看小说信息': 'get_novel_info', '查看小说信息': 'get_novel_info',
'更新小说信息': 'update_novel_info', '更新小说信息': 'update_novel_info',
'查询大纲': 'list_outlines', '查询大纲': 'list_outlines',
'查看大纲详情': 'get_outline_detail',
'创建大纲': 'create_outline', '创建大纲': 'create_outline',
'更新大纲': 'update_outline', '更新大纲': 'update_outline',
'删除大纲': 'delete_outline', '删除大纲': 'delete_outline',
@@ -16,18 +17,24 @@ const LABEL_TO_TOOL_NAME: Record<string, string> = {
'查询世界观': 'get_world_setting', '查询世界观': 'get_world_setting',
'保存世界观': 'save_world_setting', '保存世界观': 'save_world_setting',
'查询角色': 'list_characters', '查询角色': 'list_characters',
'查看角色详情': 'get_character_detail',
'创建角色': 'create_character', '创建角色': 'create_character',
'更新角色': 'update_character', '更新角色': 'update_character',
'删除角色': 'delete_character', '删除角色': 'delete_character',
'查询章节': 'list_chapters', '查询章节': 'list_chapters',
'查看章节详情': 'get_chapter_detail',
'创建章节': 'create_chapter', '创建章节': 'create_chapter',
'更新章节': 'update_chapter', '更新章节': 'update_chapter',
'删除章节': 'delete_chapter', '删除章节': 'delete_chapter',
'查询文件': 'list_files',
'读取文件': 'read_file',
'派遣子Agent': 'dispatch_subagent',
} }
/** /**
* 解析历史 assistant 消息中的工具调用日志,拆分为 ToolCallGroup + 纯文本消息 * 解析历史 assistant 消息中的工具调用日志,拆分为 ToolCallGroup / SubAgentEntry + 纯文本消息
* 格式:[以下操作已执行]\n[工具调用: 标签] 参数: {...} → 结果: {...}\n\n回复文本 * 工具格式:[工具调用: 标签] 参数: {...} → 结果: {...}
* 子Agent格式[子Agent: 技能名] 任务: ... → 结果: ...
*/ */
function parseHistoryMessage(msg: ChatMessage): ChatEntry[] { function parseHistoryMessage(msg: ChatMessage): ChatEntry[] {
const content = msg.content const content = msg.content
@@ -35,54 +42,86 @@ function parseHistoryMessage(msg: ChatMessage): ChatEntry[] {
return [msg] return [msg]
} }
const entries: ChatEntry[] = [] const resultEntries: ChatEntry[] = []
// 用双换行分隔工具调用块和后续回复 // 用双换行分隔工具调用块和后续回复
const firstBlankLine = content.indexOf('\n\n') const firstBlankLine = content.indexOf('\n\n')
const toolSection = firstBlankLine >= 0 ? content.substring(0, firstBlankLine) : content const toolSection = firstBlankLine >= 0 ? content.substring(0, firstBlankLine) : content
const replyText = firstBlankLine >= 0 ? content.substring(firstBlankLine + 2).trim() : '' const replyText = firstBlankLine >= 0 ? content.substring(firstBlankLine + 2).trim() : ''
// 解析工具调用 // 解析每一
const toolLines = toolSection.split('\n').slice(1) // 跳过 [以下操作已执行] const toolLines = toolSection.split('\n').slice(1) // 跳过 [以下操作已执行]
const calls = toolLines const toolCalls: ToolCallGroup['calls'] = []
.map(line => {
const match = line.match(/^\[工具调用: (.+?)\] 参数: (.+?) → 结果: (.+)$/) for (const line of toolLines) {
if (!match) return null // 尝试匹配子 Agent 格式
const label = match[1] const subMatch = line.match(/^\[子Agent: (.+?)\] 任务: (.+?) → 结果: (.+)$/)
if (subMatch) {
// 先把之前累积的普通工具调用推入
if (toolCalls.length > 0) {
resultEntries.push({
id: idCounter++,
type: 'tool_calls',
calls: [...toolCalls],
status: 'done',
assistantText: '',
} as ToolCallGroup)
toolCalls.length = 0
}
// 创建 SubAgentEntry
resultEntries.push({
id: idCounter++,
type: 'subagent',
subagentId: `hist-${idCounter}`,
skillId: 0,
skillName: subMatch[1],
task: subMatch[2],
status: 'done',
chunks: subMatch[3],
toolCalls: [],
} as SubAgentEntry)
continue
}
// 尝试匹配普通工具调用格式(兼容 "→ 结果: xxx" 和 "→ xxx" 两种格式)
const toolMatch = line.match(/^\[工具调用: (.+?)\] 参数: (.+?) → (?:结果: )?(.+)$/)
if (toolMatch) {
const label = toolMatch[1]
const name = LABEL_TO_TOOL_NAME[label] || label const name = LABEL_TO_TOOL_NAME[label] || label
let args: Record<string, unknown> = {} let args: Record<string, unknown> = {}
try { args = JSON.parse(match[2]) } catch { /* ok */ } try { args = JSON.parse(toolMatch[2]) } catch { /* ok */ }
const resultStr = match[3] const resultStr = toolMatch[3]
let parsedResult: unknown = undefined let parsedResult: unknown = undefined
try { parsedResult = JSON.parse(resultStr) } catch { /* ok */ } try { parsedResult = JSON.parse(resultStr) } catch { /* ok */ }
return { toolCalls.push({
id: `hist-${idCounter++}`, id: `hist-${idCounter++}`,
name, name,
label, label,
arguments: args, arguments: args,
result: resultStr, result: resultStr,
parsedResult, parsedResult,
} })
}) }
.filter(Boolean) as ToolCallGroup['calls'] }
if (calls.length > 0) { // 推入剩余的普通工具调用
entries.push({ if (toolCalls.length > 0) {
resultEntries.push({
id: idCounter++, id: idCounter++,
type: 'tool_calls', type: 'tool_calls',
calls, calls: toolCalls,
status: 'done', status: 'done',
assistantText: '', assistantText: '',
} as ToolCallGroup) } as ToolCallGroup)
} }
if (replyText) { if (replyText) {
entries.push({ resultEntries.push({
...msg, ...msg,
content: replyText, content: replyText,
}) })
} }
return entries return resultEntries
} }
// 全局状态(跨组件共享) // 全局状态(跨组件共享)
@@ -92,6 +131,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()
@@ -207,6 +247,18 @@ export function useChatAgent() {
/** 核心:发起流式请求 */ /** 核心:发起流式请求 */
async function _doStreamRequest(novelId?: number, approvedGroup?: ToolCallGroup) { async function _doStreamRequest(novelId?: number, approvedGroup?: ToolCallGroup) {
// ── 预检:发送前检查上次 token 用量,超限先压缩 ──
if (lastUsage.value && !isCompressing.value) {
const total = lastUsage.value.total_tokens
|| (lastUsage.value.prompt_tokens + lastUsage.value.completion_tokens)
if (total > DEFAULT_CONTEXT_WINDOW * AUTO_COMPRESS_THRESHOLD) {
const result = await compressChat()
if (result.success) {
// 压缩成功lastUsage 已清空,继续发送
}
}
}
isStreaming.value = true isStreaming.value = true
streamingContent.value = '' streamingContent.value = ''
abortController = new AbortController() abortController = new AbortController()
@@ -222,11 +274,19 @@ export function useChatAgent() {
: undefined : undefined
const assistantText = approvedGroup?.assistantText const assistantText = approvedGroup?.assistantText
/** 按 subagentId 查找子 Agent entry */
function _findSubAgent(subagentId: string): SubAgentEntry | undefined {
return [...entries.value].reverse().find(
(e): e is SubAgentEntry => 'type' in e && e.type === 'subagent' && e.subagentId === subagentId
)
}
await streamChat({ await streamChat({
messages: recentMessages, messages: recentMessages,
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 +345,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 */ }
@@ -301,7 +362,76 @@ export function useChatAgent() {
})) }))
}, },
// ── 子 Agent 事件(通过 subagentId 区分并行的子 Agent──
onSubAgentStart(info: SubAgentStartInfo) {
const subEntry: SubAgentEntry = {
id: idCounter++,
type: 'subagent',
subagentId: info.subagent_id,
skillId: info.skill_id,
skillName: info.skill_name,
task: info.task,
status: 'running',
chunks: '',
toolCalls: [],
}
entries.value.push(subEntry)
},
onSubAgentChunk(text: string, subagentId: string) {
const sub = _findSubAgent(subagentId)
if (sub) sub.chunks += text
},
onSubAgentToolCall(info: SubAgentToolCallInfo, subagentId: string) {
const sub = _findSubAgent(subagentId)
if (sub) {
sub.toolCalls.push({
id: `sub-${idCounter++}`,
name: info.name,
label: info.label,
arguments: info.arguments,
})
}
},
onSubAgentToolResult(info: SubAgentToolResultInfo, subagentId: string) {
const sub = _findSubAgent(subagentId)
if (sub) {
const call = sub.toolCalls.find(c => c.name === info.name && !c.result)
if (call) {
call.result = info.result
try { call.parsedResult = JSON.parse(info.result) } catch { /* ok */ }
}
}
window.dispatchEvent(new CustomEvent('tool-executed', {
detail: { name: info.name },
}))
},
onSubAgentDone(_result: string, subagentId: string) {
const sub = _findSubAgent(subagentId)
if (sub) sub.status = 'done'
},
onError(error) { onError(error) {
// 检测上下文溢出错误 — 自动压缩并续接
const isContextOverflow = /context|token|length|too long|maximum|exceeded|limit|过长|超出|上限/i.test(error)
if (isContextOverflow) {
entries.value.push({
id: idCounter++,
novel_id: novelId ?? null,
role: 'assistant',
content: '⚠️ 上下文已超出限制,正在自动压缩...',
created_at: new Date().toISOString(),
})
compressChat().then(result => {
if (result.success) {
sendMessage('上下文已压缩,请基于摘要继续之前未完成的工作。')
}
})
return
}
entries.value.push({ entries.value.push({
id: idCounter++, id: idCounter++,
novel_id: novelId ?? null, novel_id: novelId ?? null,
@@ -332,14 +462,7 @@ export function useChatAgent() {
streamingContent.value = '' streamingContent.value = ''
isStreaming.value = false isStreaming.value = false
abortController = null abortController = null
// 注:自动压缩已移至 _doStreamRequest 预检阶段(发送前检查)
// 自动压缩检测:超过 85% 上下文窗口时触发
if (usage) {
const total = usage.total_tokens || (usage.prompt_tokens + usage.completion_tokens)
if (total > DEFAULT_CONTEXT_WINDOW * AUTO_COMPRESS_THRESHOLD) {
compressChat()
}
}
}, },
}) })
} }
@@ -397,6 +520,7 @@ export function useChatAgent() {
lastUsage, lastUsage,
toolsEnabled, toolsEnabled,
autoApproveTools, autoApproveTools,
activeSkillId,
currentNovelId, currentNovelId,
pageContext, pageContext,
loadHistory, loadHistory,

View File

@@ -23,6 +23,11 @@ const router = createRouter({
name: 'novel-edit', name: 'novel-edit',
component: () => import('@/views/NovelFormView.vue'), component: () => import('@/views/NovelFormView.vue'),
}, },
{
path: '/skills',
name: 'skills',
component: () => import('@/views/SkillEditorView.vue'),
},
// 工作区:大纲/章节/角色/世界观共享父布局(导航栏持久化) // 工作区:大纲/章节/角色/世界观共享父布局(导航栏持久化)
{ {
path: '/novels/:id', path: '/novels/:id',

View File

@@ -24,13 +24,30 @@ export interface ToolCallEntry {
parsedResult?: unknown parsedResult?: unknown
} }
/** 对话条目:普通消息或工具调用组 */ /** 子 Agent 执行组(在对话中显示) */
export type ChatEntry = ChatMessage | ToolCallGroup export interface SubAgentEntry {
id: number
type: 'subagent'
subagentId: string // 后端分配的唯一标识tc.id用于区分并行子 Agent
skillId: number
skillName: string
task: string
status: 'running' | 'done'
chunks: string // 子 Agent 输出文本(实时累积)
toolCalls: ToolCallEntry[] // 子 Agent 的工具调用
}
/** 对话条目:普通消息、工具调用组或子 Agent */
export type ChatEntry = ChatMessage | ToolCallGroup | SubAgentEntry
export function isToolCallGroup(entry: ChatEntry): entry is ToolCallGroup { export function isToolCallGroup(entry: ChatEntry): entry is ToolCallGroup {
return 'type' in entry && entry.type === 'tool_calls' return 'type' in entry && entry.type === 'tool_calls'
} }
export function isSubAgentEntry(entry: ChatEntry): entry is SubAgentEntry {
return 'type' in entry && entry.type === 'subagent'
}
export interface ChatMessageList { export interface ChatMessageList {
items: ChatMessage[] items: ChatMessage[]
total: number total: number

View File

@@ -0,0 +1,662 @@
<script setup lang="ts">
import { ref, watch, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { listSkills, createSkill, updateSkill, deleteSkill, getAvailableTools } from '@/api/skills'
import type { Skill, ToolInfo } from '@/api/skills'
import { useToast } from '@/composables/useToast'
import BaseButton from '@/components/ui/BaseButton.vue'
const router = useRouter()
const toast = useToast()
const skills = ref<Skill[]>([])
const tools = ref<ToolInfo[]>([])
const selectedId = ref<number | null>(null)
const saving = ref(false)
const isNew = ref(false)
// 编辑表单
const formName = ref('')
const formDesc = ref('')
const formPrompt = ref('')
const formTools = ref<string[]>([])
const selectedSkill = computed(() =>
skills.value.find(s => s.id === selectedId.value) ?? null
)
const hasChanges = computed(() => {
if (isNew.value) return formName.value.trim().length > 0
if (!selectedSkill.value) return false
const s = selectedSkill.value
return (
formName.value !== s.name ||
formDesc.value !== s.description ||
formPrompt.value !== s.system_prompt ||
JSON.stringify(formTools.value.sort()) !== JSON.stringify([...s.allowed_tools].sort())
)
})
// 工具分组
const TOOL_GROUPS: { label: string; icon: string; tools: string[] }[] = [
{ label: '小说信息', icon: 'M4 2h8v12H4z', tools: ['get_novel_info', 'update_novel_info'] },
{ label: '大纲管理', icon: 'M3 4h10M3 8h10M3 12h6', tools: ['list_outlines', 'get_outline_detail', 'create_outline', 'update_outline', 'delete_outline', 'reorder_outlines'] },
{ label: '世界观', icon: 'M8 1a7 7 0 100 14A7 7 0 008 1z', tools: ['get_world_setting', 'save_world_setting'] },
{ label: '角色管理', icon: 'M5.5 5a2.5 2.5 0 105 0 2.5 2.5 0 00-5 0zM3 13c0-2.5 2-4 5-4s5 1.5 5 4', tools: ['list_characters', 'get_character_detail', 'create_character', 'update_character', 'delete_character'] },
{ label: '章节管理', icon: 'M4 2h8v12H4zM6 5h4M6 8h4', tools: ['list_chapters', 'get_chapter_detail', 'create_chapter', 'update_chapter', 'delete_chapter'] },
{ label: '文件管理', icon: 'M4 2h5l3 3v9H4V2z', tools: ['list_files', 'read_file'] },
]
// 工具描述映射
function getToolDesc(name: string): string {
return tools.value.find(t => t.name === name)?.description ?? name
}
// 工具显示名
function getToolLabel(name: string): string {
const labels: Record<string, string> = {
get_novel_info: '查看信息', update_novel_info: '更新信息',
list_outlines: '列表', get_outline_detail: '详情', create_outline: '创建', update_outline: '更新', delete_outline: '删除', reorder_outlines: '排序',
get_world_setting: '查看', save_world_setting: '保存',
list_characters: '列表', get_character_detail: '详情', create_character: '创建', update_character: '更新', delete_character: '删除',
list_chapters: '列表', get_chapter_detail: '详情', create_chapter: '创建', update_chapter: '更新', delete_chapter: '删除',
list_files: '列表', read_file: '读取',
}
return labels[name] ?? name
}
// 分组全选/取消
function isGroupAllSelected(groupTools: string[]): boolean {
return groupTools.every(t => formTools.value.includes(t))
}
function toggleGroup(groupTools: string[]) {
if (isGroupAllSelected(groupTools)) {
formTools.value = formTools.value.filter(t => !groupTools.includes(t))
} else {
const toAdd = groupTools.filter(t => !formTools.value.includes(t))
formTools.value.push(...toAdd)
}
}
onMounted(async () => {
await Promise.all([loadSkills(), loadTools()])
if (skills.value.length > 0) {
selectSkill(skills.value[0])
}
})
async function loadSkills() {
try {
const result = await listSkills()
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,
})
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)
}
} catch {
toast.error('保存失败')
} finally {
saving.value = false
}
}
async function handleDelete() {
if (!selectedId.value || !selectedSkill.value) return
const name = selectedSkill.value.name
try {
await deleteSkill(selectedId.value)
toast.success(`已删除:${name}`)
await loadSkills()
if (skills.value.length > 0) {
selectSkill(skills.value[0])
} else {
startNew()
}
} catch {
toast.error('删除失败')
}
}
</script>
<template>
<div class="skill-editor">
<!-- 头部 -->
<div class="editor-header">
<button class="back-btn" @click="router.back()"> 返回</button>
<h1 class="editor-title">技能管理</h1>
<p class="editor-subtitle">自定义 AI 助手的行为模式和可用工具</p>
</div>
<div class="editor-body">
<!-- 左栏技能列表 -->
<aside class="skill-sidebar">
<div class="sidebar-header">
<span class="sidebar-title">技能列表</span>
<button class="new-btn" @click="startNew" title="新建技能">
<svg width="16" height="16" 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="skill-list">
<div
v-for="skill in skills"
:key="skill.id"
class="skill-item"
:class="{ 'skill-item--active': selectedId === skill.id && !isNew }"
@click="selectSkill(skill)"
>
<div class="item-top">
<span class="item-name">{{ skill.name }}</span>
<span v-if="skill.is_builtin" class="item-badge">预置</span>
</div>
<span class="item-desc">{{ skill.description || '暂无描述' }}</span>
</div>
<!-- 新建占位 -->
<div v-if="isNew" class="skill-item skill-item--active skill-item--new">
<div class="item-top">
<span class="item-name">新建技能</span>
</div>
<span class="item-desc">正在编辑...</span>
</div>
</div>
</aside>
<!-- 右栏编辑区 -->
<main class="edit-area">
<template v-if="selectedId || isNew">
<!-- 基本信息 -->
<section class="edit-section">
<h2 class="section-title">基本信息</h2>
<div class="form-row">
<div class="form-field">
<label class="field-label">名称 <span class="required">*</span></label>
<input v-model="formName" class="field-input" placeholder="例如:大纲规划师" />
</div>
<div class="form-field">
<label class="field-label">描述</label>
<input v-model="formDesc" class="field-input" placeholder="简短描述技能的用途" />
</div>
</div>
</section>
<!-- 系统提示词 -->
<section class="edit-section edit-section--grow">
<h2 class="section-title">系统提示词</h2>
<p class="section-hint">定义 AI 在此技能下的角色定位专业领域和行为规范</p>
<textarea
v-model="formPrompt"
class="prompt-editor"
placeholder="例如:你是大纲规划专家。请帮助用户构建清晰的故事骨架,包括起承转合、情节节点和分章结构..."
/>
</section>
<!-- 可用工具 -->
<section class="edit-section">
<h2 class="section-title">可用工具</h2>
<p class="section-hint">限定此技能可以使用的工具范围不选择任何工具 = 使用全部工具</p>
<div class="tool-grid">
<div v-for="group in TOOL_GROUPS" :key="group.label" class="tool-group-card">
<div class="group-header" @click="toggleGroup(group.tools)">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<path :d="group.icon" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span class="group-name">{{ group.label }}</span>
<input
type="checkbox"
:checked="isGroupAllSelected(group.tools)"
:indeterminate="group.tools.some(t => formTools.includes(t)) && !isGroupAllSelected(group.tools)"
class="group-check"
@click.stop="toggleGroup(group.tools)"
/>
</div>
<div class="group-body">
<label
v-for="toolName in group.tools"
:key="toolName"
class="tool-label"
:title="getToolDesc(toolName)"
>
<input type="checkbox" :value="toolName" v-model="formTools" />
<span>{{ getToolLabel(toolName) }}</span>
</label>
</div>
</div>
</div>
</section>
<!-- 操作栏 -->
<div class="edit-actions">
<BaseButton
v-if="selectedId && !isNew"
variant="ghost"
class="delete-action"
@click="handleDelete"
>
删除技能
</BaseButton>
<div class="actions-right">
<BaseButton
:loading="saving"
:disabled="!hasChanges"
@click="handleSave"
>
{{ isNew ? '创建' : '保存修改' }}
</BaseButton>
</div>
</div>
</template>
<div v-else class="edit-empty">
<svg width="48" height="48" 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="var(--ink-200)" stroke-width="0.8" />
</svg>
<p>选择一个技能进行编辑</p>
<p class="edit-empty-sub">或点击 + 创建新技能</p>
</div>
</main>
</div>
</div>
</template>
<style scoped>
.skill-editor {
max-width: 1100px;
margin: 0 auto;
padding: var(--space-lg);
min-height: 100vh;
}
/* 头部 */
.editor-header {
margin-bottom: var(--space-xl);
}
.back-btn {
background: none;
border: none;
color: var(--ink-400);
cursor: pointer;
font-size: 0.85rem;
padding: 0;
margin-bottom: var(--space-sm);
transition: color var(--duration-fast) ease;
}
.back-btn:hover {
color: var(--vermilion);
}
.editor-title {
font-family: var(--font-display);
font-size: 1.8rem;
font-weight: 700;
color: var(--ink-900);
margin-bottom: var(--space-xs);
}
.editor-subtitle {
font-size: 0.88rem;
color: var(--ink-400);
}
/* 主体布局 */
.editor-body {
display: flex;
gap: var(--space-xl);
align-items: flex-start;
}
/* 左栏 */
.skill-sidebar {
width: 240px;
flex-shrink: 0;
position: sticky;
top: var(--space-lg);
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-md);
}
.sidebar-title {
font-size: 0.78rem;
font-weight: 600;
color: var(--ink-500);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.new-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: 1px solid var(--paper-300);
border-radius: var(--radius-sm);
color: var(--ink-400);
cursor: pointer;
transition: all var(--duration-fast) ease;
}
.new-btn:hover {
border-color: var(--vermilion);
color: var(--vermilion);
}
.skill-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.skill-item {
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
cursor: pointer;
border: 1px solid transparent;
transition: all var(--duration-fast) ease;
}
.skill-item:hover {
background: var(--paper-100);
}
.skill-item--active {
background: rgba(91, 140, 90, 0.08);
border-color: rgba(91, 140, 90, 0.2);
}
.skill-item--new {
border-style: dashed;
border-color: var(--vermilion);
background: rgba(199, 62, 29, 0.03);
}
.item-top {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 2px;
}
.item-name {
font-size: 0.85rem;
font-weight: 600;
color: var(--ink-800);
}
.item-badge {
font-size: 0.6rem;
padding: 1px 5px;
border-radius: 8px;
background: rgba(91, 140, 90, 0.12);
color: var(--bamboo);
font-weight: 500;
}
.item-desc {
font-size: 0.72rem;
color: var(--ink-400);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 右栏 */
.edit-area {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.edit-section {
background: white;
border: 1px solid var(--paper-200);
border-radius: var(--radius-lg);
padding: var(--space-lg);
}
.section-title {
font-family: var(--font-display);
font-size: 1rem;
font-weight: 700;
color: var(--ink-800);
margin-bottom: var(--space-xs);
}
.section-hint {
font-size: 0.78rem;
color: var(--ink-400);
margin-bottom: var(--space-md);
}
/* 表单 */
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-md);
}
.form-field {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.field-label {
font-size: 0.78rem;
font-weight: 600;
color: var(--ink-600);
}
.required {
color: var(--vermilion);
}
.field-input {
padding: var(--space-sm) var(--space-md);
font-size: 0.88rem;
font-family: var(--font-body);
color: var(--ink-900);
background: var(--paper-50);
border: 1px solid var(--paper-200);
border-radius: var(--radius-md);
outline: none;
transition: border-color var(--duration-fast) ease;
}
.field-input:focus {
border-color: var(--vermilion);
}
/* 提示词编辑器 */
.prompt-editor {
width: 100%;
min-height: 200px;
padding: var(--space-md);
font-size: 0.88rem;
font-family: var(--font-body);
color: var(--ink-900);
background: var(--paper-50);
border: 1px solid var(--paper-200);
border-radius: var(--radius-md);
outline: none;
resize: vertical;
line-height: 1.7;
transition: border-color var(--duration-fast) ease;
}
.prompt-editor:focus {
border-color: var(--vermilion);
}
/* 工具网格 */
.tool-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: var(--space-sm);
}
.tool-group-card {
background: var(--paper-50);
border: 1px solid var(--paper-200);
border-radius: var(--radius-md);
overflow: hidden;
}
.group-header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: var(--paper-100);
cursor: pointer;
font-size: 0.78rem;
font-weight: 600;
color: var(--ink-600);
transition: background var(--duration-fast) ease;
}
.group-header:hover {
background: var(--paper-200);
}
.group-check {
margin-left: auto;
accent-color: var(--bamboo);
}
.group-body {
padding: 6px 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
.tool-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.78rem;
color: var(--ink-600);
cursor: pointer;
padding: 2px 0;
}
.tool-label input {
accent-color: var(--bamboo);
}
/* 操作栏 */
.edit-actions {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: var(--space-md);
border-top: 1px solid var(--paper-200);
}
.actions-right {
margin-left: auto;
}
.delete-action {
color: var(--danger) !important;
}
/* 空状态 */
.edit-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-xxl) 0;
color: var(--ink-300);
font-size: 0.92rem;
gap: var(--space-sm);
}
.edit-empty-sub {
font-size: 0.8rem;
color: var(--ink-200);
}
@media (max-width: 768px) {
.editor-body {
flex-direction: column;
}
.skill-sidebar {
width: 100%;
position: static;
}
.form-row {
grid-template-columns: 1fr;
}
.tool-grid {
grid-template-columns: 1fr;
}
}
</style>

44
k8s/README.md Normal file
View File

@@ -0,0 +1,44 @@
# Noval K3s 部署说明
## 文件列表
- `namespace.yaml`:命名空间
- `pvc.yaml`SQLite 数据持久化存储
- `deployment.yaml`应用部署2 副本,滚动更新)
- `service.yaml`:集群内服务
- `cluster-issuer.yaml`cert-manager ClusterIssuerLet's Encrypt
- `ingress.yaml`Traefik 入口(域名 + TLS
## 一次性手动部署
```bash
cd /root/k8syaml/NovalRedot
kubectl apply -f k8s/namespace.yaml
kubectl apply -f k8s/cluster-issuer.yaml
kubectl apply -f k8s/pvc.yaml
kubectl apply -f k8s/deployment.yaml
kubectl apply -f k8s/service.yaml
kubectl apply -f k8s/ingress.yaml
```
## CI/CD 自动部署
已配置 Gitea Actions`.gitea/workflows/deploy.yml`
触发条件:`main` 分支 push。
流水线逻辑:
1. 在 Runner 所在机器拉取最新代码
2. 构建 Docker 镜像并打 tag`noval-app:<commit>` + `latest`
3. 导入镜像到 k3s containerd
4. 应用 K8s 资源并滚动更新 Deployment
5. 等待 rollout 完成
## 你需要确认/调整
1. **域名**:当前写的是 `writing-reddot.roog-code.cn``k8s/ingress.yaml`
2. **TLS Secret**:当前是 `writing-reddot-roog-code-tls`
3. **ClusterIssuer**:当前是 `letsencrypt-prod``k8s/cluster-issuer.yaml`
4. **ClusterIssuer 邮箱**:当前是 `roog-code@outlook.com`(可改成你常用邮箱)
5. **Ingress 类**:当前按 Traefik 配置k3s 默认)
6. **StorageClass**`local-path`k3s 默认)

14
k8s/cluster-issuer.yaml Normal file
View File

@@ -0,0 +1,14 @@
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: roog-code@outlook.com
privateKeySecretRef:
name: letsencrypt-prod
solvers:
- http01:
ingress:
class: traefik

65
k8s/deployment.yaml Normal file
View File

@@ -0,0 +1,65 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: noval
namespace: noval-roog-code
labels:
app: noval
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: noval
template:
metadata:
labels:
app: noval
spec:
containers:
- name: noval
image: crpi-om2xd9y8cmaizszf.cn-beijing.personal.cr.aliyuncs.com/test-namespace-gu/writing-reddot:latest
imagePullPolicy: Always
ports:
- containerPort: 8000
name: http
env:
- name: TZ
value: Asia/Shanghai
- name: NOVAL_DATA_DIR
value: /app/data
volumeMounts:
- name: noval-data
mountPath: /app/data
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "512Mi"
livenessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 20
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 8
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
volumes:
- name: noval-data
persistentVolumeClaim:
claimName: noval-data-pvc
terminationGracePeriodSeconds: 30

28
k8s/ingress.yaml Normal file
View File

@@ -0,0 +1,28 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: noval-ingress
namespace: noval-roog-code
labels:
app: noval
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
traefik.ingress.kubernetes.io/router.tls: "true"
spec:
ingressClassName: traefik
rules:
- host: writing-reddot.roog-code.cn
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: noval-service
port:
number: 80
tls:
- hosts:
- writing-reddot.roog-code.cn
secretName: writing-reddot-roog-code-tls

6
k8s/namespace.yaml Normal file
View File

@@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: noval-roog-code
labels:
app: noval

14
k8s/pvc.yaml Normal file
View File

@@ -0,0 +1,14 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: noval-data-pvc
namespace: noval-roog-code
labels:
app: noval
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 2Gi
storageClassName: local-path

16
k8s/service.yaml Normal file
View File

@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: noval-service
namespace: noval-roog-code
labels:
app: noval
spec:
type: ClusterIP
selector:
app: noval
ports:
- name: http
protocol: TCP
port: 80
targetPort: 8000